diff --git a/yobble/Components/TopBarView.swift b/yobble/Components/TopBarView.swift index e3a1c25..44537ea 100644 --- a/yobble/Components/TopBarView.swift +++ b/yobble/Components/TopBarView.swift @@ -27,8 +27,14 @@ struct TopBarView: View { return title == "Profile" } - private var shouldShowConnectionStatus: Bool { - viewModel.socketState != .connected + private var statusMessage: String? { + if viewModel.socketState != .connected { + return NSLocalizedString("Подключение", comment: "") + } + if viewModel.chatLoadingState == .loading { + return NSLocalizedString("Загрузка чатов", comment: "") + } + return nil } var body: some View { @@ -47,8 +53,8 @@ struct TopBarView: View { // Spacer() - if shouldShowConnectionStatus { - connectionStatusView + if let statusMessage { + connectionStatusView(message: statusMessage) Spacer() } else if isHomeTab{ Text("Yobble") @@ -136,11 +142,11 @@ struct TopBarView: View { } private extension TopBarView { - var connectionStatusView: some View { + func connectionStatusView(message: String) -> some View { HStack(spacing: 8) { ProgressView() .progressViewStyle(.circular) - Text(NSLocalizedString("Подключение", comment: "")) + Text(message) .font(.headline) .foregroundColor(.primary) } diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 45709b7..c81d6e6 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -430,6 +430,16 @@ }, "Загрузка сообщений…" : { + }, + "Загрузка чатов" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loading chats" + } + } + } }, "Загрузка..." : { "localizations" : { @@ -2277,4 +2287,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/yobble/Services/SocketService.swift b/yobble/Services/SocketService.swift index 053de11..ed52497 100644 --- a/yobble/Services/SocketService.swift +++ b/yobble/Services/SocketService.swift @@ -30,11 +30,16 @@ final class SocketService { } private let heartbeatInterval: TimeInterval = 10 + private let heartbeatTimeout: TimeInterval = 5 + private let maxHeartbeatMissCount = 2 + private let heartbeatEventName = AppConfig.SOCKET_HEARTBEAT_EVENT #if canImport(SocketIO) private var manager: SocketManager? private var socket: SocketIOClient? private var heartbeatTimer: DispatchSourceTimer? + private var heartbeatAckInFlight = false + private var consecutiveHeartbeatMisses = 0 #endif private init() {} @@ -150,13 +155,13 @@ final class SocketService { socket.on(clientEvent: .connect) { _, _ in if AppConfig.DEBUG { print("[SocketService] Connected") } - self.updateConnectionState(.connected) + self.handleHeartbeatSuccess() } socket.on(clientEvent: .statusChange) { data, _ in guard let rawStatus = data.first as? SocketIOStatus else { return } if rawStatus == .connected { - self.updateConnectionState(.connected) + self.handleHeartbeatSuccess() } else if rawStatus == .connecting { self.updateConnectionState(.connecting) } else { @@ -184,6 +189,10 @@ final class SocketService { self.updateConnectionState(.disconnected) } + socket.on("pong") { [weak self] _, _ in + self?.handleHeartbeatSuccess() + } + self.manager = manager self.socket = socket } @@ -204,23 +213,7 @@ final class SocketService { timer.schedule(deadline: .now() + heartbeatInterval, repeating: heartbeatInterval) timer.setEventHandler { [weak self] in guard let self else { return } - - guard let socket = self.socket else { - self.updateConnectionState(.disconnected) - return - } - - switch socket.status { - case .connected: - self.updateConnectionState(.connected) - case .connecting: - self.updateConnectionState(.connecting) - case .disconnected, .notConnected: - self.updateConnectionState(.connecting) - socket.connect(withPayload: self.currentAuthPayload) - @unknown default: - self.updateConnectionState(.connecting) - } + self.performHeartbeatCheck() } heartbeatTimer = timer timer.resume() @@ -229,6 +222,76 @@ final class SocketService { private func stopHeartbeat() { heartbeatTimer?.cancel() heartbeatTimer = nil + heartbeatAckInFlight = false + consecutiveHeartbeatMisses = 0 + } + + private func performHeartbeatCheck() { + guard let socket = socket else { + updateConnectionState(.disconnected) + return + } + + switch socket.status { + case .connected: + sendHeartbeat(on: socket) + case .connecting: + updateConnectionState(.connecting) + case .disconnected, .notConnected: + updateConnectionState(.connecting) + socket.connect(withPayload: currentAuthPayload) + @unknown default: + updateConnectionState(.connecting) + } + } + + private func sendHeartbeat(on socket: SocketIOClient) { + guard !heartbeatAckInFlight else { return } + heartbeatAckInFlight = true + + socket.emitWithAck(heartbeatEventName, ["timestamp": Date().timeIntervalSince1970]) + .timingOut(after: heartbeatTimeout) { [weak self] data in + guard let self else { return } + self.heartbeatAckInFlight = false + + if self.isSuccessfulHeartbeatResponse(data) { + self.handleHeartbeatSuccess() + } else { + self.handleMissedHeartbeat(for: socket) + } + } + } + + private func isSuccessfulHeartbeatResponse(_ data: [Any]) -> Bool { + guard !data.isEmpty else { return true } + + if let stringValue = data.first as? String, stringValue.uppercased() == "NO ACK" { + return false + } + + if let ackStatus = data.first as? SocketAckStatus, ackStatus == .noAck { + return false + } + + return true + } + + private func handleHeartbeatSuccess() { + consecutiveHeartbeatMisses = 0 + updateConnectionState(.connected) + } + + private func handleMissedHeartbeat(for socket: SocketIOClient) { + consecutiveHeartbeatMisses += 1 + if consecutiveHeartbeatMisses <= maxHeartbeatMissCount { + updateConnectionState(.connecting) + return + } + + updateConnectionState(.connecting) + consecutiveHeartbeatMisses = 0 + socket.disconnect() + socket.connect(withPayload: currentAuthPayload) } #else private func disconnectInternal() { } diff --git a/yobble/ViewModels/LoginViewModel.swift b/yobble/ViewModels/LoginViewModel.swift index ac6b2e1..6f01504 100644 --- a/yobble/ViewModels/LoginViewModel.swift +++ b/yobble/ViewModels/LoginViewModel.swift @@ -17,11 +17,17 @@ class LoginViewModel: ObservableObject { @Published var errorMessage: String = "" @Published var isLoggedIn: Bool = false @Published var socketState: SocketService.ConnectionState + @Published var chatLoadingState: ChatLoadingState = .idle private let authService = AuthService() private let socketService = SocketService.shared private var cancellables = Set() + enum ChatLoadingState: Equatable { + case idle + case loading + } + private enum DefaultsKeys { static let currentUser = "currentUser" static let userId = "userId" @@ -30,6 +36,7 @@ class LoginViewModel: ObservableObject { init() { socketState = socketService.currentConnectionState observeSocketState() + observeChatsReload() // loadStoredUser() // Запускаем автологин @@ -40,11 +47,35 @@ class LoginViewModel: ObservableObject { socketService.connectionStatePublisher .receive(on: DispatchQueue.main) .sink { [weak self] state in - self?.socketState = state + self?.handleSocketStateChange(state) } .store(in: &cancellables) } + private func observeChatsReload() { + NotificationCenter.default.publisher(for: .chatsReloadCompleted) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.chatLoadingState = .idle + } + .store(in: &cancellables) + } + + private func handleSocketStateChange(_ state: SocketService.ConnectionState) { + socketState = state + if state == .connected { + triggerChatsReloadIfNeeded() + } else { + chatLoadingState = .idle + } + } + + private func triggerChatsReloadIfNeeded() { + guard chatLoadingState != .loading else { return } + chatLoadingState = .loading + NotificationCenter.default.post(name: .chatsShouldRefresh, object: nil) + } + func autoLogin() { authService.autoLogin { [weak self] success, error in DispatchQueue.main.async { diff --git a/yobble/ViewModels/PrivateChatsViewModel.swift b/yobble/ViewModels/PrivateChatsViewModel.swift index b8a8616..912c98a 100644 --- a/yobble/ViewModels/PrivateChatsViewModel.swift +++ b/yobble/ViewModels/PrivateChatsViewModel.swift @@ -30,6 +30,9 @@ final class PrivateChatsViewModel: ObservableObject { chatService.fetchPrivateChats(offset: 0, limit: pageSize) { [weak self] result in guard let self else { return } self.isInitialLoading = false + defer { + NotificationCenter.default.post(name: .chatsReloadCompleted, object: nil) + } switch result { case .success(let data): diff --git a/yobble/Views/Tab/ChatsTab.swift b/yobble/Views/Tab/ChatsTab.swift index 05e8904..f212733 100644 --- a/yobble/Views/Tab/ChatsTab.swift +++ b/yobble/Views/Tab/ChatsTab.swift @@ -49,6 +49,9 @@ struct ChatsTab: View { .onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in viewModel.refresh() } + .onReceive(NotificationCenter.default.publisher(for: .chatsShouldRefresh)) { _ in + viewModel.refresh() + } .onChange(of: searchText) { newValue in handleSearchQueryChange(newValue) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1146,4 +1149,6 @@ struct ChatsTab_Previews: PreviewProvider { extension Notification.Name { static let debugRefreshChats = Notification.Name("debugRefreshChats") + static let chatsShouldRefresh = Notification.Name("chatsShouldRefresh") + static let chatsReloadCompleted = Notification.Name("chatsReloadCompleted") } diff --git a/yobble/config.swift b/yobble/config.swift index 7d55cac..572c155 100644 --- a/yobble/config.swift +++ b/yobble/config.swift @@ -6,6 +6,7 @@ struct AppConfig { static let PROTOCOL = "https" static let API_SERVER = "\(PROTOCOL)://api.yobble.org" static let SOCKET_PATH = "/socket.io/" + static let SOCKET_HEARTBEAT_EVENT = "ping" static let USER_AGENT = "yobble ios" static let APP_BUILD = "appstore" // appstore / freestore