add global notification

This commit is contained in:
cheykrym 2025-10-21 19:57:43 +03:00
parent a6f399bb08
commit 3658d5a963
6 changed files with 203 additions and 167 deletions

View File

@ -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)
}
}

View File

@ -0,0 +1,8 @@
import Foundation
struct ChatDeepLink: Identifiable {
let id = UUID()
let chatId: String
let chatProfile: ChatProfile?
let message: MessageItem?
}

View File

@ -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<AnyCancellable>()
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)
}
}

View File

@ -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?

View File

@ -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,9 +16,6 @@ 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 {
@ -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)
}
}

View File

@ -12,10 +12,12 @@ 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 {
ZStack(alignment: .top) {
Group {
if viewModel.isLoading {
SplashScreenView()
@ -25,6 +27,21 @@ struct yobbleApp: App {
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)