diff --git a/yobble.xcodeproj/project.pbxproj b/yobble.xcodeproj/project.pbxproj index b24a89f..856e416 100644 --- a/yobble.xcodeproj/project.pbxproj +++ b/yobble.xcodeproj/project.pbxproj @@ -411,10 +411,10 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 11.5; + MACOSX_DEPLOYMENT_TARGET = 11; MARKETING_VERSION = 0.1; PRODUCT_BUNDLE_IDENTIFIER = org.yobble.yobble; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -424,7 +424,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 1.3; + XROS_DEPLOYMENT_TARGET = 1; }; name = Debug; }; @@ -451,10 +451,10 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 11.5; + MACOSX_DEPLOYMENT_TARGET = 11; MARKETING_VERSION = 0.1; PRODUCT_BUNDLE_IDENTIFIER = org.yobble.yobble; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -464,7 +464,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 1.3; + XROS_DEPLOYMENT_TARGET = 1; }; name = Release; }; diff --git a/yobble/Network/ChatModels.swift b/yobble/Network/ChatModels.swift index 4e2d397..5a9868e 100644 --- a/yobble/Network/ChatModels.swift +++ b/yobble/Network/ChatModels.swift @@ -10,6 +10,25 @@ struct PrivateChatHistoryData: Decodable { let hasMore: Bool } +struct PrivateMessageSendData: Decodable { + let messageId: String + let chatId: String + let createdAt: Date? + + private enum CodingKeys: String, CodingKey { + case messageId + case chatId + case createdAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.messageId = try container.decodeFlexibleString(forKey: .messageId) + self.chatId = try container.decodeFlexibleString(forKey: .chatId) + self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) + } +} + struct PrivateChatListItem: Decodable, Identifiable { enum ChatType: String, Decodable { case `self` @@ -76,6 +95,32 @@ struct MessageItem: Decodable, Identifiable { self.updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) self.forwardMetadata = try container.decodeIfPresent(ForwardMetadata.self, forKey: .forwardMetadata) } + + init( + messageId: String, + messageType: String, + chatId: String, + senderId: String, + senderData: ChatProfile?, + content: String?, + mediaLink: String?, + isViewed: Bool?, + createdAt: Date?, + updatedAt: Date?, + forwardMetadata: ForwardMetadata? + ) { + self.messageId = messageId + self.messageType = messageType + self.chatId = chatId + self.senderId = senderId + self.senderData = senderData + self.content = content + self.mediaLink = mediaLink + self.isViewed = isViewed + self.createdAt = createdAt + self.updatedAt = updatedAt + self.forwardMetadata = forwardMetadata + } } struct ForwardMetadata: Decodable { diff --git a/yobble/Network/ChatService.swift b/yobble/Network/ChatService.swift index 8a83c3a..580dcde 100644 --- a/yobble/Network/ChatService.swift +++ b/yobble/Network/ChatService.swift @@ -111,6 +111,52 @@ final class ChatService { } } + func sendPrivateMessage( + chatId: String, + content: String, + completion: @escaping (Result) -> Void + ) { + let payload: [String: Any] = [ + "chat_id": chatId, + "content": content, + "message_type": ["text"] + ] + + guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else { + completion(.failure(ChatServiceError.decoding(debugDescription: "Невозможно сериализовать тело запроса."))) + return + } + + client.request( + path: "/v1/chat/private/send", + method: .post, + headers: ["Content-Type": "application/json"], + body: body, + requiresAuth: true + ) { [decoder] result in + switch result { + case .success(let response): + do { + let apiResponse = try decoder.decode(APIResponse.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] send private message decode failed: \(debugMessage)") + } + completion(.failure(ChatServiceError.decoding(debugDescription: debugMessage))) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + private static func decodeDate(from decoder: Decoder) throws -> Date { let container = try decoder.singleValueContainer() let string = try container.decode(String.self) diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index f03a539..c468373 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -938,6 +938,9 @@ } } } + }, + "Не удалось отправить сообщение." : { + }, "Не удалось подготовить данные запроса." : { "comment" : "Profile update encoding error" @@ -1983,6 +1986,9 @@ }, "Сообщение" : { + }, + "Сообщение слишком длинное." : { + }, "Сообщите о материалах" : { "comment" : "feedback category subtitle: content", diff --git a/yobble/ViewModels/PrivateChatViewModel.swift b/yobble/ViewModels/PrivateChatViewModel.swift index bcccc65..80c2fc1 100644 --- a/yobble/ViewModels/PrivateChatViewModel.swift +++ b/yobble/ViewModels/PrivateChatViewModel.swift @@ -6,15 +6,19 @@ final class PrivateChatViewModel: ObservableObject { @Published private(set) var isInitialLoading: Bool = false @Published private(set) var isLoadingMore: Bool = false @Published var errorMessage: String? + @Published private(set) var isSending: Bool = false private let chatService: ChatService private let chatId: String + private let currentUserId: String? private let pageSize: Int + private let maxMessageLength: Int = 4096 private var hasMore: Bool = true private var didLoadInitially: Bool = false - init(chatId: String, chatService: ChatService = ChatService(), pageSize: Int = 30) { + init(chatId: String, currentUserId: String?, chatService: ChatService = ChatService(), pageSize: Int = 30) { self.chatId = chatId + self.currentUserId = currentUserId self.chatService = chatService self.pageSize = pageSize } @@ -43,6 +47,59 @@ final class PrivateChatViewModel: ObservableObject { } } + func sendMessage(text: String, completion: @escaping (Bool) -> Void) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + completion(false) + return + } + guard trimmed.count <= maxMessageLength else { + errorMessage = NSLocalizedString("Сообщение слишком длинное.", comment: "") + completion(false) + return + } + guard !isSending else { + completion(false) + return + } + guard let currentUserId else { + completion(false) + return + } + + isSending = true + + chatService.sendPrivateMessage(chatId: chatId, content: trimmed) { [weak self] result in + guard let self else { return } + + switch result { + case .success(let data): + let newMessage = MessageItem( + messageId: data.messageId, + messageType: "text", + chatId: data.chatId, + senderId: currentUserId, + senderData: nil, + content: trimmed, + mediaLink: nil, + isViewed: true, + createdAt: data.createdAt, + updatedAt: data.createdAt, + forwardMetadata: nil + ) + + self.messages = Self.merge(existing: self.messages, newMessages: [newMessage]) + self.errorMessage = nil + completion(true) + case .failure(let error): + self.errorMessage = self.message(for: error) + completion(false) + } + + self.isSending = false + } + } + func refresh() { didLoadInitially = false loadInitialHistory(force: true) diff --git a/yobble/Views/Chat/PrivateChatView.swift b/yobble/Views/Chat/PrivateChatView.swift index 49ceb49..a78a25b 100644 --- a/yobble/Views/Chat/PrivateChatView.swift +++ b/yobble/Views/Chat/PrivateChatView.swift @@ -6,11 +6,13 @@ struct PrivateChatView: View { @StateObject private var viewModel: PrivateChatViewModel @State private var hasPositionedToBottom: Bool = false + @State private var draftText: String = "" + @FocusState private var isComposerFocused: Bool init(chat: PrivateChatListItem, currentUserId: String?) { self.chat = chat self.currentUserId = currentUserId - _viewModel = StateObject(wrappedValue: PrivateChatViewModel(chatId: chat.chatId)) + _viewModel = StateObject(wrappedValue: PrivateChatViewModel(chatId: chat.chatId, currentUserId: currentUserId)) } var body: some View { @@ -37,6 +39,9 @@ struct PrivateChatView: View { hasPositionedToBottom = false } } + .safeAreaInset(edge: .bottom) { + composer + } } @ViewBuilder @@ -185,6 +190,48 @@ struct PrivateChatView: View { .padding(.top, 8) } + private var composer: some View { + VStack(spacing: 0) { + Divider() + + HStack(alignment: .center, spacing: 12) { + TextField(NSLocalizedString("Сообщение", comment: ""), text: $draftText, axis: .vertical) + .lineLimit(1...4) + .focused($isComposerFocused) + .submitLabel(.send) + .disabled(viewModel.isSending || currentUserId == nil) + .onSubmit { sendCurrentMessage() } + + Button(action: sendCurrentMessage) { + Image(systemName: viewModel.isSending ? "hourglass" : "paperplane.fill") + .font(.system(size: 18, weight: .semibold)) + } + .disabled(isSendDisabled) + .buttonStyle(.plain) + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + .background(.clear) + } + .background(.ultraThinMaterial) + } + + private var isSendDisabled: Bool { + draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending || currentUserId == nil + } + + private func sendCurrentMessage() { + let text = draftText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + + viewModel.sendMessage(text: text) { success in + if success { + draftText = "" + hasPositionedToBottom = true + } + } + } + private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .none