notification from socket

This commit is contained in:
cheykrym 2025-10-21 19:44:59 +03:00
parent cc32d7acad
commit a6f399bb08
5 changed files with 399 additions and 63 deletions

View File

@ -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" : {

View File

@ -25,6 +25,12 @@ final class SocketService {
.eraseToAnyPublisher()
}
var newPrivateMessagePublisher: AnyPublisher<MessageItem, Never> {
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<MessageItem, Never>()
#endif
private init() {}
@ -204,14 +211,18 @@ final class SocketService {
socket.on(clientEvent: .disconnect) { data, _ in
if AppConfig.DEBUG { print("[SocketService] Disconnected: \(data)") }
self.updateConnectionState(.disconnected)
if self.currentToken != nil {
self.scheduleReconnect()
}
}
socket.on(clientEvent: .error) { data, _ in
if AppConfig.DEBUG { print("[SocketService] Error: \(data)") }
self.updateConnectionState(.disconnected)
if self.currentToken != nil {
self.scheduleReconnect()
}
}
socket.on("pong") { [weak self] _, _ in
self?.handleHeartbeatSuccess()
@ -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")
}

View File

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

View File

@ -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<CGFloat>, searchText: Binding<String>) {
init(
loginViewModel: LoginViewModel,
pendingDeepLink: Binding<ChatDeepLink?>,
searchRevealProgress: Binding<CGFloat>,
searchText: Binding<String>
) {
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
)

View File

@ -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,6 +35,7 @@ struct MainView: View {
var body: some View {
NavigationView {
ZStack(alignment: .top) {
ZStack(alignment: .leading) { // Выравниваем ZStack по левому краю
// Основной контент
VStack(spacing: 0) {
@ -54,6 +58,7 @@ struct MainView: View {
ChatsTab(
loginViewModel: viewModel,
pendingDeepLink: $pendingChatDeepLink,
searchRevealProgress: $chatSearchRevealProgress,
searchText: $chatSearchText
)
@ -93,6 +98,20 @@ 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()
.onChanged { gesture in
@ -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)
}
}