Compare commits

...

3 Commits

Author SHA1 Message Date
41adedee40 add ping pong 2025-10-21 04:43:14 +03:00
f886386374 patch 2025-10-21 04:03:26 +03:00
d8793b0947 socket patch 2025-10-21 03:55:48 +03:00
7 changed files with 169 additions and 27 deletions

View File

@ -27,8 +27,14 @@ struct TopBarView: View {
return title == "Profile" return title == "Profile"
} }
private var shouldShowConnectionStatus: Bool { private var statusMessage: String? {
viewModel.socketState != .connected if viewModel.socketState != .connected {
return NSLocalizedString("Подключение", comment: "")
}
if viewModel.chatLoadingState == .loading {
return NSLocalizedString("Загрузка чатов", comment: "")
}
return nil
} }
var body: some View { var body: some View {
@ -47,8 +53,8 @@ struct TopBarView: View {
// Spacer() // Spacer()
if shouldShowConnectionStatus { if let statusMessage {
connectionStatusView connectionStatusView(message: statusMessage)
Spacer() Spacer()
} else if isHomeTab{ } else if isHomeTab{
Text("Yobble") Text("Yobble")
@ -136,11 +142,11 @@ struct TopBarView: View {
} }
private extension TopBarView { private extension TopBarView {
var connectionStatusView: some View { func connectionStatusView(message: String) -> some View {
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
Text(NSLocalizedString("Подключение", comment: "")) Text(message)
.font(.headline) .font(.headline)
.foregroundColor(.primary) .foregroundColor(.primary)
} }

View File

@ -430,6 +430,16 @@
}, },
"Загрузка сообщений…" : { "Загрузка сообщений…" : {
},
"Загрузка чатов" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Loading chats"
}
}
}
}, },
"Загрузка..." : { "Загрузка..." : {
"localizations" : { "localizations" : {

View File

@ -30,11 +30,17 @@ final class SocketService {
} }
private let heartbeatInterval: TimeInterval = 10 private let heartbeatInterval: TimeInterval = 10
private let heartbeatTimeout: TimeInterval = 5
private let maxHeartbeatMissCount = 2
private let heartbeatEventName = AppConfig.SOCKET_HEARTBEAT_EVENT
#if canImport(SocketIO) #if canImport(SocketIO)
private var manager: SocketManager? private var manager: SocketManager?
private var socket: SocketIOClient? private var socket: SocketIOClient?
private var heartbeatTimer: DispatchSourceTimer? private var heartbeatTimer: DispatchSourceTimer?
private var heartbeatAckInFlight = false
private var lastHeartbeatSentAt: Date?
private var consecutiveHeartbeatMisses = 0
#endif #endif
private init() {} private init() {}
@ -150,13 +156,13 @@ final class SocketService {
socket.on(clientEvent: .connect) { _, _ in socket.on(clientEvent: .connect) { _, _ in
if AppConfig.DEBUG { print("[SocketService] Connected") } if AppConfig.DEBUG { print("[SocketService] Connected") }
self.updateConnectionState(.connected) self.handleHeartbeatSuccess()
} }
socket.on(clientEvent: .statusChange) { data, _ in socket.on(clientEvent: .statusChange) { data, _ in
guard let rawStatus = data.first as? SocketIOStatus else { return } guard let rawStatus = data.first as? SocketIOStatus else { return }
if rawStatus == .connected { if rawStatus == .connected {
self.updateConnectionState(.connected) self.handleHeartbeatSuccess()
} else if rawStatus == .connecting { } else if rawStatus == .connecting {
self.updateConnectionState(.connecting) self.updateConnectionState(.connecting)
} else { } else {
@ -184,6 +190,14 @@ final class SocketService {
self.updateConnectionState(.disconnected) self.updateConnectionState(.disconnected)
} }
socket.on("pong") { [weak self] _, _ in
self?.handleHeartbeatSuccess()
}
socket.on("message") { [weak self] data, _ in
self?.handleMessageEvent(data)
}
self.manager = manager self.manager = manager
self.socket = socket self.socket = socket
} }
@ -204,23 +218,7 @@ final class SocketService {
timer.schedule(deadline: .now() + heartbeatInterval, repeating: heartbeatInterval) timer.schedule(deadline: .now() + heartbeatInterval, repeating: heartbeatInterval)
timer.setEventHandler { [weak self] in timer.setEventHandler { [weak self] in
guard let self else { return } guard let self else { return }
self.performHeartbeatCheck()
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)
}
} }
heartbeatTimer = timer heartbeatTimer = timer
timer.resume() timer.resume()
@ -229,6 +227,94 @@ final class SocketService {
private func stopHeartbeat() { private func stopHeartbeat() {
heartbeatTimer?.cancel() heartbeatTimer?.cancel()
heartbeatTimer = nil heartbeatTimer = nil
heartbeatAckInFlight = false
consecutiveHeartbeatMisses = 0
lastHeartbeatSentAt = nil
}
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) {
if heartbeatAckInFlight {
if let lastSentAt = lastHeartbeatSentAt,
Date().timeIntervalSince(lastSentAt) >= heartbeatTimeout {
heartbeatAckInFlight = false
handleMissedHeartbeat(for: socket)
} else {
return
}
}
heartbeatAckInFlight = true
lastHeartbeatSentAt = Date()
socket.emit(heartbeatEventName, ["data": "ping"])
}
private func handleMessageEvent(_ data: [Any]) {
guard let payload = data.first else { return }
let messageText: String?
if let dictionary = payload as? [String: Any] {
if let nestedData = dictionary["data"] as? [String: Any],
let nestedMessage = nestedData["message"] as? String {
messageText = nestedMessage
} else {
messageText = dictionary["message"] as? String
}
} else if let stringValue = payload as? String {
messageText = stringValue
} else {
messageText = nil
}
guard let message = messageText?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else {
return
}
if message == "pong" {
handleHeartbeatSuccess()
}
}
private func handleHeartbeatSuccess() {
consecutiveHeartbeatMisses = 0
heartbeatAckInFlight = false
lastHeartbeatSentAt = nil
updateConnectionState(.connected)
}
private func handleMissedHeartbeat(for socket: SocketIOClient) {
consecutiveHeartbeatMisses += 1
heartbeatAckInFlight = false
lastHeartbeatSentAt = nil
updateConnectionState(.connecting)
guard consecutiveHeartbeatMisses > maxHeartbeatMissCount else {
return
}
consecutiveHeartbeatMisses = 0
updateConnectionState(.connecting)
socket.disconnect()
socket.connect(withPayload: currentAuthPayload)
} }
#else #else
private func disconnectInternal() { } private func disconnectInternal() { }

View File

@ -17,11 +17,17 @@ class LoginViewModel: ObservableObject {
@Published var errorMessage: String = "" @Published var errorMessage: String = ""
@Published var isLoggedIn: Bool = false @Published var isLoggedIn: Bool = false
@Published var socketState: SocketService.ConnectionState @Published var socketState: SocketService.ConnectionState
@Published var chatLoadingState: ChatLoadingState = .idle
private let authService = AuthService() private let authService = AuthService()
private let socketService = SocketService.shared private let socketService = SocketService.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
enum ChatLoadingState: Equatable {
case idle
case loading
}
private enum DefaultsKeys { private enum DefaultsKeys {
static let currentUser = "currentUser" static let currentUser = "currentUser"
static let userId = "userId" static let userId = "userId"
@ -30,6 +36,7 @@ class LoginViewModel: ObservableObject {
init() { init() {
socketState = socketService.currentConnectionState socketState = socketService.currentConnectionState
observeSocketState() observeSocketState()
observeChatsReload()
// loadStoredUser() // loadStoredUser()
// Запускаем автологин // Запускаем автологин
@ -40,11 +47,35 @@ class LoginViewModel: ObservableObject {
socketService.connectionStatePublisher socketService.connectionStatePublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] state in .sink { [weak self] state in
self?.socketState = state self?.handleSocketStateChange(state)
} }
.store(in: &cancellables) .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() { func autoLogin() {
authService.autoLogin { [weak self] success, error in authService.autoLogin { [weak self] success, error in
DispatchQueue.main.async { DispatchQueue.main.async {

View File

@ -30,6 +30,9 @@ final class PrivateChatsViewModel: ObservableObject {
chatService.fetchPrivateChats(offset: 0, limit: pageSize) { [weak self] result in chatService.fetchPrivateChats(offset: 0, limit: pageSize) { [weak self] result in
guard let self else { return } guard let self else { return }
self.isInitialLoading = false self.isInitialLoading = false
defer {
NotificationCenter.default.post(name: .chatsReloadCompleted, object: nil)
}
switch result { switch result {
case .success(let data): case .success(let data):

View File

@ -49,6 +49,9 @@ struct ChatsTab: View {
.onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in .onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in
viewModel.refresh() viewModel.refresh()
} }
.onReceive(NotificationCenter.default.publisher(for: .chatsShouldRefresh)) { _ in
viewModel.refresh()
}
.onChange(of: searchText) { newValue in .onChange(of: searchText) { newValue in
handleSearchQueryChange(newValue) handleSearchQueryChange(newValue)
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@ -1146,4 +1149,6 @@ struct ChatsTab_Previews: PreviewProvider {
extension Notification.Name { extension Notification.Name {
static let debugRefreshChats = Notification.Name("debugRefreshChats") static let debugRefreshChats = Notification.Name("debugRefreshChats")
static let chatsShouldRefresh = Notification.Name("chatsShouldRefresh")
static let chatsReloadCompleted = Notification.Name("chatsReloadCompleted")
} }

View File

@ -6,6 +6,7 @@ struct AppConfig {
static let PROTOCOL = "https" static let PROTOCOL = "https"
static let API_SERVER = "\(PROTOCOL)://api.yobble.org" static let API_SERVER = "\(PROTOCOL)://api.yobble.org"
static let SOCKET_PATH = "/socket.io/" static let SOCKET_PATH = "/socket.io/"
static let SOCKET_HEARTBEAT_EVENT = "client_message"
static let USER_AGENT = "yobble ios" static let USER_AGENT = "yobble ios"
static let APP_BUILD = "appstore" // appstore / freestore static let APP_BUILD = "appstore" // appstore / freestore