add creation chat

This commit is contained in:
cheykrym 2025-10-08 07:35:22 +03:00
parent 21d120cb3d
commit a937db4385
5 changed files with 310 additions and 76 deletions

View File

@ -395,7 +395,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements; CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = V22H44W47J; DEVELOPMENT_TEAM = V22H44W47J;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -435,7 +435,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements; CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = V22H44W47J; DEVELOPMENT_TEAM = V22H44W47J;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;

View File

@ -10,6 +10,28 @@ struct PrivateChatHistoryData: Decodable {
let hasMore: Bool let hasMore: Bool
} }
struct PrivateChatCreateData: Decodable {
let chatId: String
let chatType: PrivateChatListItem.ChatType
let status: String
let message: String
private enum CodingKeys: String, CodingKey {
case chatId
case chatType
case status
case message
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.chatId = try container.decodeFlexibleString(forKey: .chatId)
self.chatType = try container.decode(PrivateChatListItem.ChatType.self, forKey: .chatType)
self.status = try container.decode(String.self, forKey: .status)
self.message = try container.decode(String.self, forKey: .message)
}
}
struct PrivateMessageSendData: Decodable { struct PrivateMessageSendData: Decodable {
let messageId: String let messageId: String
let chatId: String let chatId: String
@ -180,6 +202,34 @@ struct ChatProfile: Decodable {
} }
} }
extension ChatProfile {
init(
userId: String,
login: String?,
fullName: String?,
customName: String?,
bio: String? = nil,
lastSeen: Int? = nil,
createdAt: Date? = nil,
stories: [JSONValue] = [],
permissions: ChatPermissions? = nil,
profilePermissions: ChatProfilePermissions? = nil,
relationship: RelationshipStatus? = nil
) {
self.userId = userId
self.login = login
self.fullName = fullName
self.customName = customName
self.bio = bio
self.lastSeen = lastSeen
self.createdAt = createdAt
self.stories = stories
self.permissions = permissions
self.profilePermissions = profilePermissions
self.relationship = relationship
}
}
struct ChatPermissions: Decodable { struct ChatPermissions: Decodable {
let youCanSendMessage: Bool let youCanSendMessage: Bool
let youCanPublicInvitePermission: Bool let youCanPublicInvitePermission: Bool

View File

@ -111,6 +111,43 @@ final class ChatService {
} }
} }
func createOrFindPrivateChat(
targetUserId: String,
completion: @escaping (Result<PrivateChatCreateData, Error>) -> Void
) {
let query: [String: String?] = [
"target_user_id": targetUserId
]
client.request(
path: "/v1/chat/private/create",
method: .post,
query: query,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<PrivateChatCreateData>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось создать чат.", comment: "")
completion(.failure(ChatServiceError.unexpectedStatus(message)))
return
}
completion(.success(apiResponse.data))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[ChatService] create private chat decode failed: \(debugMessage)")
}
completion(.failure(ChatServiceError.decoding(debugDescription: debugMessage)))
}
case .failure(let error):
completion(.failure(error))
}
}
}
func sendPrivateMessage( func sendPrivateMessage(
chatId: String, chatId: String,
content: String, content: String,

View File

@ -939,6 +939,12 @@
} }
} }
}, },
"Не удалось открыть чат" : {
"comment" : "Chat creation error title"
},
"Не удалось открыть чат." : {
"comment" : "Chat creation fallback"
},
"Не удалось отправить сообщение." : { "Не удалось отправить сообщение." : {
}, },
@ -954,6 +960,9 @@
} }
} }
} }
},
"Не удалось создать чат." : {
}, },
"Не удалось сохранить изменения профиля." : { "Не удалось сохранить изменения профиля." : {
"comment" : "Profile update unexpected status" "comment" : "Profile update unexpected status"
@ -1303,6 +1312,7 @@
} }
}, },
"Ошибка сервера (%@)." : { "Ошибка сервера (%@)." : {
"comment" : "Chat creation server status",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1323,6 +1333,7 @@
} }
}, },
"Ошибка сети: %@" : { "Ошибка сети: %@" : {
"comment" : "Chat creation network error",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1333,7 +1344,7 @@
} }
}, },
"Ошибка соединения с сервером." : { "Ошибка соединения с сервером." : {
"comment" : "Search error connection", "comment" : "Chat creation connection\nSearch error connection",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1565,6 +1576,9 @@
} }
} }
}, },
"Понятно" : {
"comment" : "Chat creation error acknowledgment"
},
"Попробуйте изменить запрос поиска." : { "Попробуйте изменить запрос поиска." : {
}, },
@ -1715,6 +1729,9 @@
"Произошла неизвестная ошибка." : { "Произошла неизвестная ошибка." : {
"comment" : "Search unknown error" "comment" : "Search unknown error"
}, },
"Произошла неизвестная ошибка. Попробуйте позже." : {
"comment" : "Chat creation unknown error"
},
"Произошла ошибка." : { "Произошла ошибка." : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1911,7 +1928,7 @@
} }
}, },
"Сессия истекла. Войдите снова." : { "Сессия истекла. Войдите снова." : {
"comment" : "Search error unauthorized", "comment" : "Chat creation unauthorized\nSearch error unauthorized",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {

View File

@ -15,6 +15,7 @@ struct ChatsTab: View {
@Binding var searchRevealProgress: CGFloat @Binding var searchRevealProgress: CGFloat
@Binding var searchText: String @Binding var searchText: String
private let searchService = SearchService() private let searchService = SearchService()
private let chatService = ChatService()
@AppStorage("chatRowMessageLineLimit") private var messageLineLimitSetting: Int = 2 @AppStorage("chatRowMessageLineLimit") private var messageLineLimitSetting: Int = 2
@StateObject private var viewModel = PrivateChatsViewModel() @StateObject private var viewModel = PrivateChatsViewModel()
@State private var selectedChatId: String? @State private var selectedChatId: String?
@ -24,7 +25,11 @@ struct ChatsTab: View {
@State private var isGlobalSearchLoading: Bool = false @State private var isGlobalSearchLoading: Bool = false
@State private var globalSearchError: String? @State private var globalSearchError: String?
@State private var globalSearchTask: Task<Void, Never>? = nil @State private var globalSearchTask: Task<Void, Never>? = nil
@State private var selectedSearchUserId: UUID? @State private var creatingChatForUserId: UUID?
@State private var chatCreationError: String?
@State private var isChatCreationAlertPresented: Bool = false
@State private var pendingChatItem: PrivateChatListItem?
@State private var isPendingChatActive: Bool = false
private let searchRevealDistance: CGFloat = 90 private let searchRevealDistance: CGFloat = 90
@ -53,6 +58,23 @@ struct ChatsTab: View {
} }
} }
} }
.alert(
NSLocalizedString("Не удалось открыть чат", comment: "Chat creation error title"),
isPresented: Binding(
get: { isChatCreationAlertPresented && chatCreationError != nil },
set: { newValue in
isChatCreationAlertPresented = newValue
if !newValue {
chatCreationError = nil
}
}
),
presenting: chatCreationError
) { _ in
Button(NSLocalizedString("Понятно", comment: "Chat creation error acknowledgment"), role: .cancel) { }
} message: { error in
Text(error)
}
.onDisappear { .onDisappear {
globalSearchTask?.cancel() globalSearchTask?.cancel()
globalSearchTask = nil globalSearchTask = nil
@ -69,80 +91,111 @@ struct ChatsTab: View {
} }
private var chatList: some View { private var chatList: some View {
List { ZStack {
List {
// VStack(spacing: 0) { // VStack(spacing: 0) {
// searchBar // searchBar
// .padding(.horizontal, 16) // .padding(.horizontal, 16)
// .padding(.vertical, 8) // .padding(.vertical, 8)
// } // }
// .background(Color(UIColor.systemBackground)) // .background(Color(UIColor.systemBackground))
if let message = viewModel.errorMessage { if let message = viewModel.errorMessage {
Section { Section {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange) .foregroundColor(.orange)
Text(message) Text(message)
.font(.subheadline)
.foregroundColor(.orange)
Spacer(minLength: 0)
Button(action: { viewModel.refresh() }) {
Text(NSLocalizedString("Обновить", comment: ""))
.font(.subheadline) .font(.subheadline)
.foregroundColor(.orange)
Spacer(minLength: 0)
Button(action: { viewModel.refresh() }) {
Text(NSLocalizedString("Обновить", comment: ""))
.font(.subheadline)
}
}
.padding(.vertical, 4)
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
if isSearching {
Section(header: localSearchHeader) {
if localSearchResults.isEmpty {
emptySearchResultView
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
.listRowSeparator(.hidden)
} else {
ForEach(localSearchResults) { chat in
chatRowItem(for: chat)
}
} }
} }
.padding(.vertical, 4)
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
if isSearching { Section(header: globalSearchHeader) {
Section(header: localSearchHeader) { globalSearchContent
if localSearchResults.isEmpty { }
emptySearchResultView } else {
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
.listRowSeparator(.hidden) errorState(message: message)
} else if viewModel.chats.isEmpty {
emptyState
} else { } else {
ForEach(localSearchResults) { chat in
ForEach(viewModel.chats) { chat in
chatRowItem(for: chat) chatRowItem(for: chat)
} }
}
}
Section(header: globalSearchHeader) { if viewModel.isLoadingMore {
globalSearchContent loadingMoreRow
} }
} else {
if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
errorState(message: message)
} else if viewModel.chats.isEmpty {
emptyState
} else {
ForEach(viewModel.chats) { chat in
chatRowItem(for: chat)
}
if viewModel.isLoadingMore {
loadingMoreRow
} }
} }
} }
} .listStyle(.plain)
.listStyle(.plain) .modifier(ScrollDismissesKeyboardModifier())
.modifier(ScrollDismissesKeyboardModifier()) .simultaneousGesture(searchBarGesture)
.simultaneousGesture(searchBarGesture) .simultaneousGesture(tapToDismissKeyboardGesture)
.simultaneousGesture(tapToDismissKeyboardGesture) // .safeAreaInset(edge: .top) {
// .safeAreaInset(edge: .top) { // VStack(spacing: 0) {
// VStack(spacing: 0) { // searchBar
// searchBar // .padding(.horizontal, 16)
// .padding(.horizontal, 16) // .padding(.top, 8)
// .padding(.top, 8) // .padding(.bottom, 8)
// .padding(.bottom, 8) // Divider()
// Divider() // }
// .background(Color(UIColor.systemBackground))
// } // }
// .background(Color(UIColor.systemBackground))
// } pendingChatNavigationLink
}
}
private var pendingChatNavigationLink: some View {
NavigationLink(
destination: pendingChatDestination,
isActive: Binding(
get: { isPendingChatActive && pendingChatItem != nil },
set: { newValue in
if !newValue {
isPendingChatActive = false
pendingChatItem = nil
}
}
)
) {
EmptyView()
}
.hidden()
}
@ViewBuilder
private var pendingChatDestination: some View {
if let pendingChatItem {
PrivateChatView(chat: pendingChatItem, currentUserId: currentUserId)
} else {
EmptyView()
}
} }
private var searchBarGesture: some Gesture { private var searchBarGesture: some Gesture {
@ -404,7 +457,7 @@ struct ChatsTab: View {
private func globalSearchRow(for user: UserSearchResult) -> some View { private func globalSearchRow(for user: UserSearchResult) -> some View {
Button { Button {
selectedSearchUserId = user.userId openPrivateChat(for: user)
} label: { } label: {
HStack(spacing: 12) { HStack(spacing: 12) {
Circle() Circle()
@ -440,21 +493,17 @@ struct ChatsTab: View {
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
if creatingChatForUserId == user.userId {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
} }
.padding(.vertical, 8) .padding(.vertical, 8)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(creatingChatForUserId != nil)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.background(
NavigationLink(
destination: SearchResultPlaceholderView(userId: user.userId),
tag: user.userId,
selection: $selectedSearchUserId
) {
EmptyView()
}
.hidden()
)
} }
} }
@ -480,7 +529,6 @@ private extension ChatsTab {
globalSearchResults = [] globalSearchResults = []
globalSearchError = nil globalSearchError = nil
isGlobalSearchLoading = false isGlobalSearchLoading = false
selectedSearchUserId = nil
return return
} }
@ -499,7 +547,6 @@ private extension ChatsTab {
isGlobalSearchLoading = false isGlobalSearchLoading = false
globalSearchError = nil globalSearchError = nil
globalSearchTask = nil globalSearchTask = nil
selectedSearchUserId = nil
} }
} catch is CancellationError { } catch is CancellationError {
// Ignore cancellation // Ignore cancellation
@ -510,7 +557,6 @@ private extension ChatsTab {
isGlobalSearchLoading = false isGlobalSearchLoading = false
globalSearchError = friendlyErrorMessage(for: error) globalSearchError = friendlyErrorMessage(for: error)
globalSearchTask = nil globalSearchTask = nil
selectedSearchUserId = nil
} }
} }
} }
@ -522,7 +568,6 @@ private extension ChatsTab {
globalSearchResults = [] globalSearchResults = []
globalSearchError = nil globalSearchError = nil
isGlobalSearchLoading = false isGlobalSearchLoading = false
selectedSearchUserId = nil
} }
func secondaryLine(for user: UserSearchResult) -> String? { func secondaryLine(for user: UserSearchResult) -> String? {
@ -552,6 +597,91 @@ private extension ChatsTab {
return nil return nil
} }
func openPrivateChat(for user: UserSearchResult) {
guard creatingChatForUserId == nil else { return }
dismissKeyboard()
creatingChatForUserId = user.userId
chatCreationError = nil
chatService.createOrFindPrivateChat(targetUserId: user.userId.uuidString) { result in
creatingChatForUserId = nil
switch result {
case .success(let data):
let chatItem = PrivateChatListItem(
chatId: data.chatId,
chatType: data.chatType,
chatData: chatProfile(from: user),
lastMessage: nil,
createdAt: nil,
unreadCount: 0
)
pendingChatItem = chatItem
isPendingChatActive = true
viewModel.refresh()
case .failure(let error):
chatCreationError = friendlyChatCreationMessage(for: error)
isChatCreationAlertPresented = true
}
}
}
func chatProfile(from user: UserSearchResult) -> ChatProfile {
let profile = user.profile
let login = profile?.login ?? user.login
let fullName = user.officialFullName ?? user.fullName ?? profile?.fullName
let customName = user.preferredCustomName ?? user.customName ?? profile?.customName
return ChatProfile(
userId: user.userId.uuidString,
login: login,
fullName: fullName,
customName: customName,
bio: profile?.bio,
lastSeen: profile?.lastSeen,
createdAt: profile?.createdAt
)
}
func friendlyChatCreationMessage(for error: Error) -> String {
if let chatError = error as? ChatServiceError {
return chatError.errorDescription ?? NSLocalizedString("Не удалось открыть чат.", comment: "Chat creation fallback")
}
if let networkError = error as? NetworkError {
switch networkError {
case .unauthorized:
return NSLocalizedString("Сессия истекла. Войдите снова.", comment: "Chat creation unauthorized")
case .invalidURL, .noResponse:
return NSLocalizedString("Ошибка соединения с сервером.", comment: "Chat creation connection")
case .network(let underlying):
return String(format: NSLocalizedString("Ошибка сети: %@", comment: "Chat creation network error"), underlying.localizedDescription)
case .server(let statusCode, let data):
if let data {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
if let payload = try? decoder.decode(ErrorResponse.self, from: data) {
if let detail = payload.detail?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty {
return detail
}
if let message = payload.data?.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty {
return message
}
}
if let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty {
return raw
}
}
return String(format: NSLocalizedString("Ошибка сервера (%@).", comment: "Chat creation server status"), "\(statusCode)")
}
}
return NSLocalizedString("Произошла неизвестная ошибка. Попробуйте позже.", comment: "Chat creation unknown error")
}
func friendlyErrorMessage(for error: Error) -> String { func friendlyErrorMessage(for error: Error) -> String {
if let searchError = error as? SearchServiceError { if let searchError = error as? SearchServiceError {