Compare commits

...

3 Commits

Author SHA1 Message Date
ed91efacf5 open chat everywhere 2025-10-21 20:26:20 +03:00
3658d5a963 add global notification 2025-10-21 19:57:43 +03:00
a6f399bb08 notification from socket 2025-10-21 19:44:59 +03:00
9 changed files with 399 additions and 72 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

@ -9,6 +9,7 @@ struct TopBarView: View {
var accounts: [String] var accounts: [String]
// var viewModel: LoginViewModel // var viewModel: LoginViewModel
@ObservedObject var viewModel: LoginViewModel @ObservedObject var viewModel: LoginViewModel
@Binding var isSettingsPresented: Bool
// Привязка для управления боковым меню // Привязка для управления боковым меню
@Binding var isSideMenuPresented: Bool @Binding var isSideMenuPresented: Bool
@ -101,7 +102,9 @@ struct TopBarView: View {
} }
} }
} else if isProfileTab { } else if isProfileTab {
NavigationLink(destination: SettingsView(viewModel: viewModel)) { NavigationLink(isActive: $isSettingsPresented) {
SettingsView(viewModel: viewModel)
} label: {
Image(systemName: "wrench") Image(systemName: "wrench")
.imageScale(.large) .imageScale(.large)
.foregroundColor(.primary) .foregroundColor(.primary)
@ -213,6 +216,7 @@ struct TopBarView_Previews: PreviewProvider {
@State private var revealProgress: CGFloat = 1 @State private var revealProgress: CGFloat = 1
@StateObject private var viewModel = LoginViewModel() @StateObject private var viewModel = LoginViewModel()
@State private var searchText: String = "" @State private var searchText: String = ""
@State private var isSettingsPresented = false
var body: some View { var body: some View {
TopBarView( TopBarView(
@ -220,6 +224,7 @@ struct TopBarView_Previews: PreviewProvider {
selectedAccount: $selectedAccount, selectedAccount: $selectedAccount,
accounts: [selectedAccount], accounts: [selectedAccount],
viewModel: viewModel, viewModel: viewModel,
isSettingsPresented: $isSettingsPresented,
isSideMenuPresented: $isSideMenuPresented, isSideMenuPresented: $isSideMenuPresented,
chatSearchRevealProgress: $revealProgress, chatSearchRevealProgress: $revealProgress,
chatSearchText: $searchText chatSearchText: $searchText

View File

@ -195,6 +195,9 @@
} }
} }
}, },
"Аудио" : {
"comment" : "Audio message placeholder"
},
"Без звука (скоро)" : { "Без звука (скоро)" : {
}, },
@ -232,6 +235,9 @@
} }
} }
}, },
"Видео" : {
"comment" : "Video message placeholder"
},
"Видимость и контент" : { "Видимость и контент" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -524,6 +530,9 @@
} }
} }
}, },
"Изображение" : {
"comment" : "Image message placeholder"
},
"Инвайт-код (необязательно)" : { "Инвайт-код (необязательно)" : {
"comment" : "Инвайт-код", "comment" : "Инвайт-код",
"localizations" : { "localizations" : {
@ -1141,6 +1150,9 @@
"Ничего не найдено" : { "Ничего не найдено" : {
"comment" : "Global search empty state" "comment" : "Global search empty state"
}, },
"Новое сообщение" : {
"comment" : "Default banner subtitle"
},
"Новый пароль" : { "Новый пароль" : {
"comment" : "Новый пароль", "comment" : "Новый пароль",
"localizations" : { "localizations" : {

View File

@ -0,0 +1,105 @@
import Foundation
import Combine
final class IncomingMessageCenter: ObservableObject {
@Published private(set) var banner: IncomingMessageBanner?
@Published var presentedChat: PrivateChatListItem?
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 }
presentedChat = makeChatItem(from: 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 makeChatItem(from message: MessageItem) -> PrivateChatListItem {
let profile = message.senderData
return PrivateChatListItem(
chatId: message.chatId,
chatType: .privateChat,
chatData: profile,
lastMessage: message,
createdAt: message.createdAt,
unreadCount: 0
)
}
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

@ -25,6 +25,12 @@ final class SocketService {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
var newPrivateMessagePublisher: AnyPublisher<MessageItem, Never> {
privateMessageSubject
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
var currentConnectionState: ConnectionState { var currentConnectionState: ConnectionState {
connectionStateSubject.value connectionStateSubject.value
} }
@ -42,6 +48,7 @@ final class SocketService {
private var lastHeartbeatSentAt: Date? private var lastHeartbeatSentAt: Date?
private var consecutiveHeartbeatMisses = 0 private var consecutiveHeartbeatMisses = 0
private var reconnectWorkItem: DispatchWorkItem? private var reconnectWorkItem: DispatchWorkItem?
private let privateMessageSubject = PassthroughSubject<MessageItem, Never>()
#endif #endif
private init() {} private init() {}
@ -204,13 +211,17 @@ final class SocketService {
socket.on(clientEvent: .disconnect) { data, _ in socket.on(clientEvent: .disconnect) { data, _ in
if AppConfig.DEBUG { print("[SocketService] Disconnected: \(data)") } if AppConfig.DEBUG { print("[SocketService] Disconnected: \(data)") }
self.updateConnectionState(.disconnected) self.updateConnectionState(.disconnected)
self.scheduleReconnect() if self.currentToken != nil {
self.scheduleReconnect()
}
} }
socket.on(clientEvent: .error) { data, _ in socket.on(clientEvent: .error) { data, _ in
if AppConfig.DEBUG { print("[SocketService] Error: \(data)") } if AppConfig.DEBUG { print("[SocketService] Error: \(data)") }
self.updateConnectionState(.disconnected) self.updateConnectionState(.disconnected)
self.scheduleReconnect() if self.currentToken != nil {
self.scheduleReconnect()
}
} }
socket.on("pong") { [weak self] _, _ in socket.on("pong") { [weak self] _, _ in
@ -221,6 +232,10 @@ final class SocketService {
self?.handleMessageEvent(data) self?.handleMessageEvent(data)
} }
socket.on("chat_private:new_message") { [weak self] data, _ in
self?.handleNewPrivateMessage(data)
}
self.manager = manager self.manager = manager
self.socket = socket 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() { private func handleHeartbeatSuccess() {
consecutiveHeartbeatMisses = 0 consecutiveHeartbeatMisses = 0
heartbeatAckInFlight = false heartbeatAckInFlight = false
@ -367,7 +418,38 @@ final class SocketService {
reconnectWorkItem?.cancel() reconnectWorkItem?.cancel()
reconnectWorkItem = nil 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 #else
private func disconnectInternal() { } private func disconnectInternal() { }
#endif #endif
} }
extension Notification.Name {
static let socketDidReceivePrivateMessage = Notification.Name("socketDidReceivePrivateMessage")
}

View File

@ -15,12 +15,32 @@ final class PrivateChatViewModel: ObservableObject {
private let maxMessageLength: Int = 4096 private let maxMessageLength: Int = 4096
private var hasMore: Bool = true private var hasMore: Bool = true
private var didLoadInitially: Bool = false private var didLoadInitially: Bool = false
private var messageObserver: NSObjectProtocol?
init(chatId: String, currentUserId: String?, chatService: ChatService = ChatService(), pageSize: Int = 30) { init(chatId: String, currentUserId: String?, chatService: ChatService = ChatService(), pageSize: Int = 30) {
self.chatId = chatId self.chatId = chatId
self.currentUserId = currentUserId self.currentUserId = currentUserId
self.chatService = chatService self.chatService = chatService
self.pageSize = pageSize 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) { func loadInitialHistory(force: Bool = false) {

View File

@ -38,7 +38,11 @@ struct ChatsTab: View {
return userId.isEmpty ? nil : userId return userId.isEmpty ? nil : userId
} }
init(loginViewModel: LoginViewModel, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) { init(
loginViewModel: LoginViewModel,
searchRevealProgress: Binding<CGFloat>,
searchText: Binding<String>
) {
self._loginViewModel = ObservedObject(wrappedValue: loginViewModel) self._loginViewModel = ObservedObject(wrappedValue: loginViewModel)
self._searchRevealProgress = searchRevealProgress self._searchRevealProgress = searchRevealProgress
self._searchText = searchText self._searchText = searchText
@ -52,9 +56,15 @@ struct ChatsTab: View {
handleSearchQueryChange(searchText) handleSearchQueryChange(searchText)
} }
.onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in .onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in
if loginViewModel.chatLoadingState != .loading {
loginViewModel.chatLoadingState = .loading
}
viewModel.refresh() viewModel.refresh()
} }
.onReceive(NotificationCenter.default.publisher(for: .chatsShouldRefresh)) { _ in .onReceive(NotificationCenter.default.publisher(for: .chatsShouldRefresh)) { _ in
if loginViewModel.chatLoadingState != .loading {
loginViewModel.chatLoadingState = .loading
}
viewModel.refresh() viewModel.refresh()
} }
.onChange(of: searchText) { newValue in .onChange(of: searchText) { newValue in
@ -116,11 +126,11 @@ struct ChatsTab: View {
Text(message) Text(message)
.font(.subheadline) .font(.subheadline)
.foregroundColor(.orange) .foregroundColor(.orange)
// Spacer(minLength: 0) Spacer(minLength: 0)
// Button(action: triggerChatsReload) { Button(action: triggerChatsReload) {
// Text(NSLocalizedString("Обновить", comment: "")) Text(NSLocalizedString("Обновить", comment: ""))
// .font(.subheadline) .font(.subheadline)
// } }
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }

View File

@ -15,7 +15,8 @@ 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 isSettingsPresented = false
private var tabTitle: String { private var tabTitle: String {
switch selectedTab { switch selectedTab {
case 0: return "Home" case 0: return "Home"
@ -32,66 +33,69 @@ struct MainView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
ZStack(alignment: .leading) { // Выравниваем ZStack по левому краю ZStack(alignment: .top) {
// Основной контент ZStack(alignment: .leading) { // Выравниваем ZStack по левому краю
VStack(spacing: 0) { // Основной контент
TopBarView( VStack(spacing: 0) {
title: tabTitle, TopBarView(
selectedAccount: $selectedAccount, title: tabTitle,
accounts: accounts, selectedAccount: $selectedAccount,
viewModel: viewModel, accounts: accounts,
isSideMenuPresented: $isSideMenuPresented, viewModel: viewModel,
chatSearchRevealProgress: $chatSearchRevealProgress, isSettingsPresented: $isSettingsPresented,
chatSearchText: $chatSearchText 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
) )
.opacity(selectedTab == 2 ? 1 : 0)
.allowsHitTesting(selectedTab == 2)
ProfileTab() ZStack {
.opacity(selectedTab == 3 ? 1 : 0) NewHomeTab()
} .opacity(selectedTab == 0 ? 1 : 0)
.frame(maxWidth: .infinity, maxHeight: .infinity)
ConceptTab()
CustomTabBar(selectedTab: $selectedTab) { .opacity(selectedTab == 1 ? 1 : 0)
print("Create button tapped")
} ChatsTab(
} loginViewModel: viewModel,
.frame(maxWidth: .infinity, maxHeight: .infinity) // Убедимся, что основной контент занимает все пространство searchRevealProgress: $chatSearchRevealProgress,
.ignoresSafeArea(edges: .bottom) searchText: $chatSearchText
.navigationBarHidden(true) )
// .sheet(item: $sheetType) { type in .opacity(selectedTab == 2 ? 1 : 0)
// // ... sheet presentation logic .allowsHitTesting(selectedTab == 2)
// }
ProfileTab()
// Затемнение и закрытие по тапу .opacity(selectedTab == 3 ? 1 : 0)
Color.black }
.opacity(Double(menuOffset / menuWidth) * 0.4) .frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
.onTapGesture { CustomTabBar(selectedTab: $selectedTab) {
withAnimation(.easeInOut) { print("Create button tapped")
isSideMenuPresented = false
} }
} }
.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) SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
.frame(width: menuWidth) .frame(width: menuWidth)
.offset(x: -menuWidth + menuOffset) // Новая логика смещения .offset(x: -menuWidth + menuOffset) // Новая логика смещения
.ignoresSafeArea(edges: .vertical) .ignoresSafeArea(edges: .vertical)
}
} }
.gesture( .gesture(
DragGesture() DragGesture()

View File

@ -12,22 +12,60 @@ 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 {
Group { ZStack(alignment: .top) {
if viewModel.isLoading { Group {
SplashScreenView() if viewModel.isLoading {
} else if viewModel.isLoggedIn { SplashScreenView()
MainView(viewModel: viewModel) } else if viewModel.isLoggedIn {
} else { MainView(viewModel: viewModel)
LoginView(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)
.sheet(item: $messageCenter.presentedChat) { chatItem in
NavigationView {
PrivateChatView(
chat: chatItem,
currentUserId: messageCenter.currentUserId
)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(NSLocalizedString("Закрыть", comment: "")) {
messageCenter.presentedChat = nil
}
}
}
}
}
.environmentObject(messageCenter)
.environmentObject(themeManager) .environmentObject(themeManager)
.preferredColorScheme(themeManager.theme.colorScheme) .preferredColorScheme(themeManager.theme.colorScheme)
.environment(\.managedObjectContext, persistenceController.viewContext) .environment(\.managedObjectContext, persistenceController.viewContext)
.onAppear {
messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId
}
.onChange(of: viewModel.userId) { newValue in
messageCenter.currentUserId = newValue.isEmpty ? nil : newValue
}
} }
} }
} }