diff --git a/yobble/Components/NewMessageBannerView.swift b/yobble/Components/NewMessageBannerView.swift new file mode 100644 index 0000000..7aeb6b4 --- /dev/null +++ b/yobble/Components/NewMessageBannerView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct IncomingMessageBanner: Identifiable { + let id = UUID() + let message: MessageItem + let senderName: String + let messagePreview: String +} + +struct NewMessageBannerView: View { + let banner: IncomingMessageBanner + 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(banner.senderName) + .font(.headline) + .foregroundColor(.primary) + .lineLimit(1) + + Text(banner.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) + } +} diff --git a/yobble/Models/ChatDeepLink.swift b/yobble/Models/ChatDeepLink.swift new file mode 100644 index 0000000..07a0d46 --- /dev/null +++ b/yobble/Models/ChatDeepLink.swift @@ -0,0 +1,8 @@ +import Foundation + +struct ChatDeepLink: Identifiable { + let id = UUID() + let chatId: String + let chatProfile: ChatProfile? + let message: MessageItem? +} diff --git a/yobble/Services/IncomingMessageCenter.swift b/yobble/Services/IncomingMessageCenter.swift new file mode 100644 index 0000000..bbff503 --- /dev/null +++ b/yobble/Services/IncomingMessageCenter.swift @@ -0,0 +1,97 @@ +import Foundation +import Combine + +final class IncomingMessageCenter: ObservableObject { + @Published private(set) var banner: IncomingMessageBanner? + @Published var pendingDeepLink: ChatDeepLink? + var currentUserId: String? + + private var dismissWorkItem: DispatchWorkItem? + private var cancellables = Set() + private let notificationCenter: NotificationCenter + + init(notificationCenter: NotificationCenter = .default) { + self.notificationCenter = notificationCenter + notificationCenter.publisher(for: .socketDidReceivePrivateMessage) + .compactMap { $0.object as? MessageItem } + .receive(on: RunLoop.main) + .sink { [weak self] message in + self?.handleIncoming(message) + } + .store(in: &cancellables) + } + + func dismissBanner() { + dismissWorkItem?.cancel() + dismissWorkItem = nil + banner = nil + } + + func openCurrentChat() { + guard let banner else { return } + pendingDeepLink = ChatDeepLink( + chatId: banner.message.chatId, + chatProfile: banner.message.senderData, + message: banner.message + ) + dismissBanner() + } + + private func handleIncoming(_ message: MessageItem) { + if let currentUserId, message.senderId == currentUserId { + return + } + + banner = buildBanner(from: message) + scheduleDismiss() + } + + private func buildBanner(from message: MessageItem) -> IncomingMessageBanner { + let sender = senderName(for: message) + let preview = messagePreview(for: message) + return IncomingMessageBanner(message: message, senderName: sender, messagePreview: preview) + } + + private func senderName(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 + } + + private 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") + } + } + + private func scheduleDismiss(after delay: TimeInterval = 5) { + dismissWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.banner = nil + self?.dismissWorkItem = nil + } + dismissWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } +} diff --git a/yobble/Views/Tab/ChatsTab.swift b/yobble/Views/Tab/ChatsTab.swift index be7e546..76a8439 100644 --- a/yobble/Views/Tab/ChatsTab.swift +++ b/yobble/Views/Tab/ChatsTab.swift @@ -10,13 +10,6 @@ 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? diff --git a/yobble/Views/Tab/MainView.swift b/yobble/Views/Tab/MainView.swift index b370253..fda2be4 100644 --- a/yobble/Views/Tab/MainView.swift +++ b/yobble/Views/Tab/MainView.swift @@ -2,6 +2,7 @@ import SwiftUI struct MainView: View { @ObservedObject var viewModel: LoginViewModel + @EnvironmentObject private var messageCenter: IncomingMessageCenter @State private var selectedTab: Int = 0 // @StateObject private var newHomeTabViewModel = NewHomeTabViewModel() @@ -15,10 +16,7 @@ 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 { case 0: return "Home" @@ -58,7 +56,10 @@ struct MainView: View { ChatsTab( loginViewModel: viewModel, - pendingDeepLink: $pendingChatDeepLink, + pendingDeepLink: Binding( + get: { messageCenter.pendingDeepLink }, + set: { messageCenter.pendingDeepLink = $0 } + ), searchRevealProgress: $chatSearchRevealProgress, searchText: $chatSearchText ) @@ -98,19 +99,6 @@ struct MainView: View { .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() @@ -152,9 +140,22 @@ 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) + .onAppear { + messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId + } + .onChange(of: viewModel.userId) { newValue in + messageCenter.currentUserId = newValue.isEmpty ? nil : newValue + } + .onChange(of: messageCenter.pendingDeepLink?.id) { _ in + guard messageCenter.pendingDeepLink != nil else { return } + withAnimation(.easeInOut) { + selectedTab = 2 + isSideMenuPresented = false + menuOffset = 0 + } + } + .onDisappear { + messageCenter.currentUserId = nil } } } @@ -163,138 +164,7 @@ struct MainView_Previews: PreviewProvider { static var previews: some View { let mockViewModel = LoginViewModel() MainView(viewModel: mockViewModel) + .environmentObject(IncomingMessageCenter()) .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) - } -} diff --git a/yobble/yobbleApp.swift b/yobble/yobbleApp.swift index bd6f3a9..cb6d308 100644 --- a/yobble/yobbleApp.swift +++ b/yobble/yobbleApp.swift @@ -12,19 +12,36 @@ import CoreData struct yobbleApp: App { @StateObject private var themeManager = ThemeManager() @StateObject private var viewModel = LoginViewModel() + @StateObject private var messageCenter = IncomingMessageCenter() private let persistenceController = PersistenceController.shared var body: some Scene { WindowGroup { - Group { - if viewModel.isLoading { - SplashScreenView() - } else if viewModel.isLoggedIn { - MainView(viewModel: viewModel) - } else { - LoginView(viewModel: viewModel) + ZStack(alignment: .top) { + Group { + if viewModel.isLoading { + SplashScreenView() + } else if viewModel.isLoggedIn { + MainView(viewModel: viewModel) + } else { + LoginView(viewModel: viewModel) + } + } + + if let banner = messageCenter.banner { + NewMessageBannerView( + banner: banner, + onOpen: { messageCenter.openCurrentChat() }, + onDismiss: { messageCenter.dismissBanner() } + ) + .padding(.horizontal, 16) + .padding(.top, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(1) } } + .animation(.spring(response: 0.35, dampingFraction: 0.8), value: messageCenter.banner != nil) + .environmentObject(messageCenter) .environmentObject(themeManager) .preferredColorScheme(themeManager.theme.colorScheme) .environment(\.managedObjectContext, persistenceController.viewContext)