add global notification
This commit is contained in:
parent
a6f399bb08
commit
3658d5a963
51
yobble/Components/NewMessageBannerView.swift
Normal file
51
yobble/Components/NewMessageBannerView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
yobble/Models/ChatDeepLink.swift
Normal file
8
yobble/Models/ChatDeepLink.swift
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ChatDeepLink: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let chatId: String
|
||||||
|
let chatProfile: ChatProfile?
|
||||||
|
let message: MessageItem?
|
||||||
|
}
|
||||||
97
yobble/Services/IncomingMessageCenter.swift
Normal file
97
yobble/Services/IncomingMessageCenter.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,13 +10,6 @@ import SwiftUI
|
|||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
struct ChatDeepLink: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let chatId: String
|
|
||||||
let chatProfile: ChatProfile?
|
|
||||||
let message: MessageItem?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ChatsTab: View {
|
struct ChatsTab: View {
|
||||||
@ObservedObject private var loginViewModel: LoginViewModel
|
@ObservedObject private var loginViewModel: LoginViewModel
|
||||||
@Binding private var pendingDeepLink: ChatDeepLink?
|
@Binding private var pendingDeepLink: ChatDeepLink?
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct MainView: View {
|
struct MainView: View {
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
@ObservedObject var viewModel: LoginViewModel
|
||||||
|
@EnvironmentObject private var messageCenter: IncomingMessageCenter
|
||||||
@State private var selectedTab: Int = 0
|
@State private var selectedTab: Int = 0
|
||||||
// @StateObject private var newHomeTabViewModel = NewHomeTabViewModel()
|
// @StateObject private var newHomeTabViewModel = NewHomeTabViewModel()
|
||||||
|
|
||||||
@ -15,9 +16,6 @@ struct MainView: View {
|
|||||||
@State private var menuOffset: CGFloat = 0
|
@State private var menuOffset: CGFloat = 0
|
||||||
@State private var chatSearchRevealProgress: CGFloat = 0
|
@State private var chatSearchRevealProgress: CGFloat = 0
|
||||||
@State private var chatSearchText: String = ""
|
@State private var chatSearchText: String = ""
|
||||||
@State private var pendingChatDeepLink: ChatDeepLink?
|
|
||||||
@State private var incomingMessageBanner: IncomingMessageBanner?
|
|
||||||
@State private var bannerDismissWorkItem: DispatchWorkItem?
|
|
||||||
|
|
||||||
private var tabTitle: String {
|
private var tabTitle: String {
|
||||||
switch selectedTab {
|
switch selectedTab {
|
||||||
@ -58,7 +56,10 @@ struct MainView: View {
|
|||||||
|
|
||||||
ChatsTab(
|
ChatsTab(
|
||||||
loginViewModel: viewModel,
|
loginViewModel: viewModel,
|
||||||
pendingDeepLink: $pendingChatDeepLink,
|
pendingDeepLink: Binding(
|
||||||
|
get: { messageCenter.pendingDeepLink },
|
||||||
|
set: { messageCenter.pendingDeepLink = $0 }
|
||||||
|
),
|
||||||
searchRevealProgress: $chatSearchRevealProgress,
|
searchRevealProgress: $chatSearchRevealProgress,
|
||||||
searchText: $chatSearchText
|
searchText: $chatSearchText
|
||||||
)
|
)
|
||||||
@ -98,19 +99,6 @@ struct MainView: View {
|
|||||||
.offset(x: -menuWidth + menuOffset) // Новая логика смещения
|
.offset(x: -menuWidth + menuOffset) // Новая логика смещения
|
||||||
.ignoresSafeArea(edges: .vertical)
|
.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(
|
.gesture(
|
||||||
DragGesture()
|
DragGesture()
|
||||||
@ -152,9 +140,22 @@ struct MainView: View {
|
|||||||
menuOffset = presented ? menuWidth : 0
|
menuOffset = presented ? menuWidth : 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .socketDidReceivePrivateMessage)) { notification in
|
.onAppear {
|
||||||
guard let message = notification.object as? MessageItem else { return }
|
messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId
|
||||||
handleIncomingMessage(message)
|
}
|
||||||
|
.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 {
|
static var previews: some View {
|
||||||
let mockViewModel = LoginViewModel()
|
let mockViewModel = LoginViewModel()
|
||||||
MainView(viewModel: mockViewModel)
|
MainView(viewModel: mockViewModel)
|
||||||
|
.environmentObject(IncomingMessageCenter())
|
||||||
.environmentObject(ThemeManager())
|
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -12,10 +12,12 @@ import CoreData
|
|||||||
struct yobbleApp: App {
|
struct yobbleApp: App {
|
||||||
@StateObject private var themeManager = ThemeManager()
|
@StateObject private var themeManager = ThemeManager()
|
||||||
@StateObject private var viewModel = LoginViewModel()
|
@StateObject private var viewModel = LoginViewModel()
|
||||||
|
@StateObject private var messageCenter = IncomingMessageCenter()
|
||||||
private let persistenceController = PersistenceController.shared
|
private let persistenceController = PersistenceController.shared
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
|
ZStack(alignment: .top) {
|
||||||
Group {
|
Group {
|
||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
SplashScreenView()
|
SplashScreenView()
|
||||||
@ -25,6 +27,21 @@ struct yobbleApp: App {
|
|||||||
LoginView(viewModel: viewModel)
|
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)
|
.environmentObject(themeManager)
|
||||||
.preferredColorScheme(themeManager.theme.colorScheme)
|
.preferredColorScheme(themeManager.theme.colorScheme)
|
||||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
.environment(\.managedObjectContext, persistenceController.viewContext)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user