diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index f22bdb8..ecc121e 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -195,6 +195,9 @@ } } }, + "Аудио" : { + "comment" : "Audio message placeholder" + }, "Без звука (скоро)" : { }, @@ -232,6 +235,9 @@ } } }, + "Видео" : { + "comment" : "Video message placeholder" + }, "Видимость и контент" : { "localizations" : { "en" : { @@ -524,6 +530,9 @@ } } }, + "Изображение" : { + "comment" : "Image message placeholder" + }, "Инвайт-код (необязательно)" : { "comment" : "Инвайт-код", "localizations" : { @@ -1141,6 +1150,9 @@ "Ничего не найдено" : { "comment" : "Global search empty state" }, + "Новое сообщение" : { + "comment" : "Default banner subtitle" + }, "Новый пароль" : { "comment" : "Новый пароль", "localizations" : { diff --git a/yobble/Services/SocketService.swift b/yobble/Services/SocketService.swift index cd2eaf1..a66a68e 100644 --- a/yobble/Services/SocketService.swift +++ b/yobble/Services/SocketService.swift @@ -25,6 +25,12 @@ final class SocketService { .eraseToAnyPublisher() } + var newPrivateMessagePublisher: AnyPublisher { + privateMessageSubject + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + var currentConnectionState: ConnectionState { connectionStateSubject.value } @@ -42,6 +48,7 @@ final class SocketService { private var lastHeartbeatSentAt: Date? private var consecutiveHeartbeatMisses = 0 private var reconnectWorkItem: DispatchWorkItem? + private let privateMessageSubject = PassthroughSubject() #endif private init() {} @@ -204,13 +211,17 @@ final class SocketService { socket.on(clientEvent: .disconnect) { data, _ in if AppConfig.DEBUG { print("[SocketService] Disconnected: \(data)") } self.updateConnectionState(.disconnected) - self.scheduleReconnect() + if self.currentToken != nil { + self.scheduleReconnect() + } } socket.on(clientEvent: .error) { data, _ in if AppConfig.DEBUG { print("[SocketService] Error: \(data)") } self.updateConnectionState(.disconnected) - self.scheduleReconnect() + if self.currentToken != nil { + self.scheduleReconnect() + } } socket.on("pong") { [weak self] _, _ in @@ -221,6 +232,10 @@ final class SocketService { self?.handleMessageEvent(data) } + socket.on("chat_private:new_message") { [weak self] data, _ in + self?.handleNewPrivateMessage(data) + } + self.manager = manager self.socket = socket } @@ -322,6 +337,42 @@ final class SocketService { } } + private func handleNewPrivateMessage(_ data: [Any]) { + guard let payload = data.first else { return } + + let messageData: Data + if let dictionary = payload as? [String: Any], + JSONSerialization.isValidJSONObject(dictionary), + let json = try? JSONSerialization.data(withJSONObject: dictionary, options: []) { + messageData = json + } else if let string = payload as? String, + let data = string.data(using: .utf8) { + messageData = data + } else { + return + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .custom(Self.decodeServerDate) + + do { + let message = try decoder.decode(MessageItem.self, from: messageData) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .socketDidReceivePrivateMessage, object: message) + self.privateMessageSubject.send(message) + NotificationCenter.default.post(name: .chatsShouldRefresh, object: nil) + } + } catch { + if AppConfig.DEBUG { + print("[SocketService] Failed to decode new message: \(error)") + if let payloadString = String(data: messageData, encoding: .utf8) { + print("[SocketService] payload=\(payloadString)") + } + } + } + } + private func handleHeartbeatSuccess() { consecutiveHeartbeatMisses = 0 heartbeatAckInFlight = false @@ -367,7 +418,38 @@ final class SocketService { reconnectWorkItem?.cancel() reconnectWorkItem = nil } + + private static func decodeServerDate(from decoder: Decoder) throws -> Date { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + if let date = iso8601WithFractionalSeconds.date(from: string) { + return date + } + if let date = iso8601Simple.date(from: string) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unable to decode date: \(string)" + ) + } + + private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let iso8601Simple: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() #else private func disconnectInternal() { } #endif } + +extension Notification.Name { + static let socketDidReceivePrivateMessage = Notification.Name("socketDidReceivePrivateMessage") +} diff --git a/yobble/ViewModels/PrivateChatViewModel.swift b/yobble/ViewModels/PrivateChatViewModel.swift index 80c2fc1..b1ebc77 100644 --- a/yobble/ViewModels/PrivateChatViewModel.swift +++ b/yobble/ViewModels/PrivateChatViewModel.swift @@ -15,12 +15,32 @@ final class PrivateChatViewModel: ObservableObject { private let maxMessageLength: Int = 4096 private var hasMore: Bool = true private var didLoadInitially: Bool = false + private var messageObserver: NSObjectProtocol? init(chatId: String, currentUserId: String?, chatService: ChatService = ChatService(), pageSize: Int = 30) { self.chatId = chatId self.currentUserId = currentUserId self.chatService = chatService self.pageSize = pageSize + + messageObserver = NotificationCenter.default.addObserver( + forName: .socketDidReceivePrivateMessage, + object: nil, + queue: .main + ) { [weak self] notification in + guard + let self, + let message = notification.object as? MessageItem, + message.chatId == self.chatId + else { return } + self.messages = Self.merge(existing: self.messages, newMessages: [message]) + } + } + + deinit { + if let observer = messageObserver { + NotificationCenter.default.removeObserver(observer) + } } func loadInitialHistory(force: Bool = false) { diff --git a/yobble/Views/Tab/ChatsTab.swift b/yobble/Views/Tab/ChatsTab.swift index 637ea7c..be7e546 100644 --- a/yobble/Views/Tab/ChatsTab.swift +++ b/yobble/Views/Tab/ChatsTab.swift @@ -10,8 +10,16 @@ import SwiftUI import UIKit #endif +struct ChatDeepLink: Identifiable { + let id = UUID() + let chatId: String + let chatProfile: ChatProfile? + let message: MessageItem? +} + struct ChatsTab: View { @ObservedObject private var loginViewModel: LoginViewModel + @Binding private var pendingDeepLink: ChatDeepLink? @Binding var searchRevealProgress: CGFloat @Binding var searchText: String private let searchService = SearchService() @@ -38,8 +46,14 @@ struct ChatsTab: View { return userId.isEmpty ? nil : userId } - init(loginViewModel: LoginViewModel, searchRevealProgress: Binding, searchText: Binding) { + init( + loginViewModel: LoginViewModel, + pendingDeepLink: Binding, + searchRevealProgress: Binding, + searchText: Binding + ) { self._loginViewModel = ObservedObject(wrappedValue: loginViewModel) + self._pendingDeepLink = pendingDeepLink self._searchRevealProgress = searchRevealProgress self._searchText = searchText } @@ -52,9 +66,15 @@ struct ChatsTab: View { handleSearchQueryChange(searchText) } .onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in + if loginViewModel.chatLoadingState != .loading { + loginViewModel.chatLoadingState = .loading + } viewModel.refresh() } .onReceive(NotificationCenter.default.publisher(for: .chatsShouldRefresh)) { _ in + if loginViewModel.chatLoadingState != .loading { + loginViewModel.chatLoadingState = .loading + } viewModel.refresh() } .onChange(of: searchText) { newValue in @@ -87,6 +107,13 @@ struct ChatsTab: View { globalSearchTask?.cancel() globalSearchTask = nil } + .onChange(of: pendingDeepLink?.id) { _ in + guard let link = pendingDeepLink else { return } + handle(chatDeepLink: link) + DispatchQueue.main.async { + pendingDeepLink = nil + } + } } @ViewBuilder @@ -116,11 +143,11 @@ struct ChatsTab: View { Text(message) .font(.subheadline) .foregroundColor(.orange) -// Spacer(minLength: 0) -// Button(action: triggerChatsReload) { -// Text(NSLocalizedString("Обновить", comment: "")) -// .font(.subheadline) -// } + Spacer(minLength: 0) + Button(action: triggerChatsReload) { + Text(NSLocalizedString("Обновить", comment: "")) + .font(.subheadline) + } } .padding(.vertical, 4) } @@ -532,6 +559,44 @@ private extension ChatsTab { #endif } + func handle(chatDeepLink: ChatDeepLink) { + dismissKeyboard() + if !searchText.isEmpty { + searchText = "" + } + if searchRevealProgress > 0 { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + searchRevealProgress = 0 + } + } + + let existingChat = viewModel.chats.first(where: { $0.chatId == chatDeepLink.chatId }) + pendingChatItem = existingChat ?? makeChatItem(from: chatDeepLink) + selectedChatId = chatDeepLink.chatId + isPendingChatActive = true + + if existingChat == nil { + if loginViewModel.chatLoadingState != .loading { + loginViewModel.chatLoadingState = .loading + } + viewModel.refresh() + } + } + + func makeChatItem(from deepLink: ChatDeepLink) -> PrivateChatListItem { + let profile = deepLink.chatProfile ?? deepLink.message?.senderData + let lastMessage = deepLink.message + let createdAt = deepLink.message?.createdAt + return PrivateChatListItem( + chatId: deepLink.chatId, + chatType: .privateChat, + chatData: profile, + lastMessage: lastMessage, + createdAt: createdAt, + unreadCount: 0 + ) + } + func handleSearchQueryChange(_ query: String) { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1151,10 +1216,12 @@ struct ChatsTab_Previews: PreviewProvider { @State private var progress: CGFloat = 1 @State private var searchText: String = "" @StateObject private var loginViewModel = LoginViewModel() + @State private var deepLink: ChatDeepLink? var body: some View { ChatsTab( loginViewModel: loginViewModel, + pendingDeepLink: $deepLink, searchRevealProgress: $progress, searchText: $searchText ) diff --git a/yobble/Views/Tab/MainView.swift b/yobble/Views/Tab/MainView.swift index dd5349b..b370253 100644 --- a/yobble/Views/Tab/MainView.swift +++ b/yobble/Views/Tab/MainView.swift @@ -15,6 +15,9 @@ struct MainView: View { @State private var menuOffset: CGFloat = 0 @State private var chatSearchRevealProgress: CGFloat = 0 @State private var chatSearchText: String = "" + @State private var pendingChatDeepLink: ChatDeepLink? + @State private var incomingMessageBanner: IncomingMessageBanner? + @State private var bannerDismissWorkItem: DispatchWorkItem? private var tabTitle: String { switch selectedTab { @@ -32,66 +35,82 @@ struct MainView: View { var body: some View { NavigationView { - ZStack(alignment: .leading) { // Выравниваем ZStack по левому краю - // Основной контент - VStack(spacing: 0) { - TopBarView( - title: tabTitle, - selectedAccount: $selectedAccount, - accounts: accounts, - viewModel: viewModel, - isSideMenuPresented: $isSideMenuPresented, - chatSearchRevealProgress: $chatSearchRevealProgress, - chatSearchText: $chatSearchText - ) - - ZStack { - NewHomeTab() - .opacity(selectedTab == 0 ? 1 : 0) - - ConceptTab() - .opacity(selectedTab == 1 ? 1 : 0) - - ChatsTab( - loginViewModel: viewModel, - searchRevealProgress: $chatSearchRevealProgress, - searchText: $chatSearchText + ZStack(alignment: .top) { + ZStack(alignment: .leading) { // Выравниваем ZStack по левому краю + // Основной контент + VStack(spacing: 0) { + TopBarView( + title: tabTitle, + selectedAccount: $selectedAccount, + accounts: accounts, + viewModel: viewModel, + isSideMenuPresented: $isSideMenuPresented, + chatSearchRevealProgress: $chatSearchRevealProgress, + chatSearchText: $chatSearchText ) - .opacity(selectedTab == 2 ? 1 : 0) - .allowsHitTesting(selectedTab == 2) - ProfileTab() - .opacity(selectedTab == 3 ? 1 : 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - CustomTabBar(selectedTab: $selectedTab) { - print("Create button tapped") - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) // Убедимся, что основной контент занимает все пространство - .ignoresSafeArea(edges: .bottom) - .navigationBarHidden(true) -// .sheet(item: $sheetType) { type in -// // ... sheet presentation logic -// } - - // Затемнение и закрытие по тапу - Color.black - .opacity(Double(menuOffset / menuWidth) * 0.4) - .ignoresSafeArea() - .onTapGesture { - withAnimation(.easeInOut) { - isSideMenuPresented = false + ZStack { + NewHomeTab() + .opacity(selectedTab == 0 ? 1 : 0) + + ConceptTab() + .opacity(selectedTab == 1 ? 1 : 0) + + ChatsTab( + loginViewModel: viewModel, + pendingDeepLink: $pendingChatDeepLink, + searchRevealProgress: $chatSearchRevealProgress, + searchText: $chatSearchText + ) + .opacity(selectedTab == 2 ? 1 : 0) + .allowsHitTesting(selectedTab == 2) + + ProfileTab() + .opacity(selectedTab == 3 ? 1 : 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + CustomTabBar(selectedTab: $selectedTab) { + print("Create button tapped") } } - .allowsHitTesting(menuOffset > 0) + .frame(maxWidth: .infinity, maxHeight: .infinity) // Убедимся, что основной контент занимает все пространство + .ignoresSafeArea(edges: .bottom) + .navigationBarHidden(true) +// .sheet(item: $sheetType) { type in +// // ... sheet presentation logic +// } + + // Затемнение и закрытие по тапу + Color.black + .opacity(Double(menuOffset / menuWidth) * 0.4) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.easeInOut) { + isSideMenuPresented = false + } + } + .allowsHitTesting(menuOffset > 0) - // Боковое меню - SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented) - .frame(width: menuWidth) - .offset(x: -menuWidth + menuOffset) // Новая логика смещения - .ignoresSafeArea(edges: .vertical) + // Боковое меню + SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented) + .frame(width: menuWidth) + .offset(x: -menuWidth + menuOffset) // Новая логика смещения + .ignoresSafeArea(edges: .vertical) + } + + if let banner = incomingMessageBanner { + NewMessageBannerView( + senderName: banner.senderName, + messagePreview: banner.messagePreview, + onOpen: { openChat(from: banner) }, + onDismiss: { dismissIncomingBanner() } + ) + .padding(.horizontal, 16) + .padding(.top, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(1) + } } .gesture( DragGesture() @@ -133,6 +152,10 @@ struct MainView: View { menuOffset = presented ? menuWidth : 0 } } + .onReceive(NotificationCenter.default.publisher(for: .socketDidReceivePrivateMessage)) { notification in + guard let message = notification.object as? MessageItem else { return } + handleIncomingMessage(message) + } } } @@ -143,3 +166,135 @@ struct MainView_Previews: PreviewProvider { .environmentObject(ThemeManager()) } } + +private extension MainView { + func handleIncomingMessage(_ message: MessageItem) { + guard message.senderId != viewModel.userId else { return } + + let banner = IncomingMessageBanner( + message: message, + senderName: senderDisplayName(for: message), + messagePreview: messagePreview(for: message) + ) + + bannerDismissWorkItem?.cancel() + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + incomingMessageBanner = banner + } + + let workItem = DispatchWorkItem { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + incomingMessageBanner = nil + } + bannerDismissWorkItem = nil + } + bannerDismissWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: workItem) + } + + func dismissIncomingBanner() { + bannerDismissWorkItem?.cancel() + bannerDismissWorkItem = nil + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + incomingMessageBanner = nil + } + } + + func openChat(from banner: IncomingMessageBanner) { + dismissIncomingBanner() + pendingChatDeepLink = ChatDeepLink( + chatId: banner.message.chatId, + chatProfile: banner.message.senderData, + message: banner.message + ) + withAnimation(.easeInOut) { + selectedTab = 2 + isSideMenuPresented = false + menuOffset = 0 + } + } + + func senderDisplayName(for message: MessageItem) -> String { + let candidates: [String?] = [ + message.senderData?.customName, + message.senderData?.fullName, + message.senderData?.login.map { "@\($0)" } + ] + + for candidate in candidates { + if let value = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty { + return value + } + } + + return message.senderId + } + + func messagePreview(for message: MessageItem) -> String { + if let content = message.content?.trimmingCharacters(in: .whitespacesAndNewlines), !content.isEmpty { + return content + } + + switch message.messageType.lowercased() { + case "image": + return NSLocalizedString("Изображение", comment: "Image message placeholder") + case "audio": + return NSLocalizedString("Аудио", comment: "Audio message placeholder") + case "video": + return NSLocalizedString("Видео", comment: "Video message placeholder") + default: + return NSLocalizedString("Новое сообщение", comment: "Default banner subtitle") + } + } +} + +struct IncomingMessageBanner: Identifiable { + let id = UUID() + let message: MessageItem + let senderName: String + let messagePreview: String +} + +struct NewMessageBannerView: View { + let senderName: String + let messagePreview: String + let onOpen: () -> Void + let onDismiss: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "bubble.left.and.bubble.right.fill") + .foregroundColor(.accentColor) + .imageScale(.medium) + + VStack(alignment: .leading, spacing: 4) { + Text(senderName) + .font(.headline) + .foregroundColor(.primary) + .lineLimit(1) + + Text(messagePreview) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(2) + } + + Spacer(minLength: 8) + + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.footnote.weight(.semibold)) + .foregroundColor(.secondary) + .padding(6) + .background(Color.secondary.opacity(0.15), in: Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .shadow(color: Color.black.opacity(0.12), radius: 12, y: 6) + .contentShape(Rectangle()) + .onTapGesture(perform: onOpen) + } +}