✨ Make a broke websocket on watchOS (w.i.p)
This commit is contained in:
		| @@ -148,6 +148,13 @@ | ||||
| /* End PBXFileReference section */ | ||||
|  | ||||
| /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ | ||||
| 		7355265E2EB3A8870013AFE4 /* Exceptions for "WatchRunner Watch App" folder in "WatchRunner Watch App" target */ = { | ||||
| 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | ||||
| 			membershipExceptions = ( | ||||
| 				"WatchRunner-Watch-App-Info.plist", | ||||
| 			); | ||||
| 			target = 7310A7D32EB10962002C0FD3 /* WatchRunner Watch App */; | ||||
| 		}; | ||||
| 		73ACDFCA2E3D0E6100B63535 /* Exceptions for "SolianBroadcastExtension" folder in "SolianBroadcastExtension" target */ = { | ||||
| 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | ||||
| 			membershipExceptions = ( | ||||
| @@ -182,6 +189,9 @@ | ||||
| /* Begin PBXFileSystemSynchronizedRootGroup section */ | ||||
| 		7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */ = { | ||||
| 			isa = PBXFileSystemSynchronizedRootGroup; | ||||
| 			exceptions = ( | ||||
| 				7355265E2EB3A8870013AFE4 /* Exceptions for "WatchRunner Watch App" folder in "WatchRunner Watch App" target */, | ||||
| 			); | ||||
| 			path = "WatchRunner Watch App"; | ||||
| 			sourceTree = "<group>"; | ||||
| 		}; | ||||
| @@ -1092,6 +1102,7 @@ | ||||
| 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | ||||
| 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||
| 				GENERATE_INFOPLIST_FILE = YES; | ||||
| 				INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist"; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; | ||||
| 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; | ||||
| 				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; | ||||
| @@ -1140,6 +1151,7 @@ | ||||
| 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | ||||
| 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||
| 				GENERATE_INFOPLIST_FILE = YES; | ||||
| 				INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist"; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; | ||||
| 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; | ||||
| 				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; | ||||
| @@ -1185,6 +1197,7 @@ | ||||
| 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | ||||
| 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||
| 				GENERATE_INFOPLIST_FILE = YES; | ||||
| 				INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist"; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; | ||||
| 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; | ||||
| 				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; | ||||
|   | ||||
| @@ -22,7 +22,9 @@ struct ContentView: View { | ||||
|     var body: some View { | ||||
|         NavigationSplitView { | ||||
|             List(selection: $selection) { | ||||
|                 AppInfoHeaderView().listRowBackground(Color.clear) | ||||
|                 AppInfoHeaderView() | ||||
|                     .listRowBackground(Color.clear) | ||||
|                     .environmentObject(appState) | ||||
|                  | ||||
|                 Label("Explore", systemImage: "globe.fill").tag(Panel.explore) | ||||
|                 Label("Chat", systemImage: "message.fill").tag(Panel.chat) | ||||
|   | ||||
| @@ -2,16 +2,53 @@ | ||||
| //  NetworkService.swift | ||||
| //  WatchRunner Watch App | ||||
| // | ||||
| //  Created by LittleSheep on 2025/10/29. | ||||
| // | ||||
| //  Created by LittleSheep on 2025/10/29. // | ||||
|  | ||||
| import Combine | ||||
| import Foundation | ||||
|  | ||||
| // MARK: - WebSocket Data Structures | ||||
|  | ||||
| enum WebSocketState: Equatable { | ||||
|     case connected | ||||
|     case connecting | ||||
|     case disconnected | ||||
|     case serverDown | ||||
|     case duplicateDevice | ||||
|     case error(String) | ||||
|      | ||||
|     // Equatable conformance | ||||
|     static func == (lhs: WebSocketState, rhs: WebSocketState) -> Bool { | ||||
|         switch (lhs, rhs) { | ||||
|         case (.connected, .connected), | ||||
|             (.connecting, .connecting), | ||||
|             (.disconnected, .disconnected), | ||||
|             (.serverDown, .serverDown), | ||||
|             (.duplicateDevice, .duplicateDevice): | ||||
|             return true | ||||
|         case let (.error(a), .error(b)): | ||||
|             return a == b | ||||
|         default: | ||||
|             return false | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct WebSocketPacket { | ||||
|     let type: String | ||||
|     let data: [String: Any]? | ||||
|     let endpoint: String? | ||||
|     let errorMessage: String? | ||||
| } | ||||
|  | ||||
| // MARK: - Network Service | ||||
|  | ||||
| class NetworkService { | ||||
|     private let session = URLSession.shared | ||||
|      | ||||
|     // Add a serial queue for WebSocket operations | ||||
|     private let webSocketQueue = DispatchQueue(label: "com.solian.websocketQueue") | ||||
|      | ||||
|     func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> ActivityResponse { | ||||
|         guard let baseURL = URL(string: serverUrl) else { | ||||
|             throw URLError(.badURL) | ||||
| @@ -77,7 +114,7 @@ class NetworkService { | ||||
|             throw URLError(.badURL) | ||||
|         } | ||||
|         var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)! | ||||
|         var queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))] | ||||
|         let queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))] | ||||
|         components.queryItems = queryItems | ||||
|          | ||||
|         var request = URLRequest(url: components.url!) | ||||
| @@ -148,7 +185,7 @@ class NetworkService { | ||||
|     } | ||||
|      | ||||
|     func createOrUpdateStatus(attitude: Int, isInvisible: Bool, isNotDisturb: Bool, label: String?, token: String, serverUrl: String) async throws -> SnAccountStatus { | ||||
|         // Check if there's already a customized status | ||||
|         // Check if there\'s already a customized status | ||||
|         let existingStatus = try? await fetchAccountStatus(token: token, serverUrl: serverUrl) | ||||
|         let method = (existingStatus?.isCustomized == true) ? "PATCH" : "POST" | ||||
|          | ||||
| @@ -167,7 +204,7 @@ class NetworkService { | ||||
|         var body: [String: Any] = [ | ||||
|             "attitude": attitude, | ||||
|             "is_invisible": isInvisible, | ||||
|             "is_not_disturb": isNotDisturb | ||||
|             "is_not_disturb": isNotDisturb, | ||||
|         ] | ||||
|          | ||||
|         if let label = label, !label.isEmpty { | ||||
| @@ -338,7 +375,7 @@ class NetworkService { | ||||
|             resolvingAgainstBaseURL: false | ||||
|         )! | ||||
|         var queryItems = [ | ||||
|             URLQueryItem(name: "take", value: String(take)) | ||||
|             URLQueryItem(name: "take", value: String(take)), | ||||
|         ] | ||||
|         if let before = before { | ||||
|             queryItems.append(URLQueryItem(name: "before", value: ISO8601DateFormatter().string(from: before))) | ||||
| @@ -376,24 +413,224 @@ class NetworkService { | ||||
|             let messages = try decoder.decode([SnChatMessage].self, from: data) | ||||
|             print("[watchOS] fetchChatMessages successfully decoded \(messages.count) messages") | ||||
|             return messages | ||||
|         } catch DecodingError.dataCorrupted(let context) { | ||||
|             print(context) | ||||
|             return [] | ||||
|         } catch DecodingError.keyNotFound(let key, let context) { | ||||
|             print("[watchOS] Message decode failed: Key '\(key)' not found:", context.debugDescription) | ||||
|             print("[watchOS] Message decode failed: codingPath:", context.codingPath) | ||||
|             return [] | ||||
|         } catch DecodingError.valueNotFound(let value, let context) { | ||||
|             print("[watchOS] Message decode failed: Value '\(value)' not found:", context.debugDescription) | ||||
|             print("[watchOS] Message decode failed: codingPath:", context.codingPath) | ||||
|             return [] | ||||
|         } catch DecodingError.typeMismatch(let type, let context) { | ||||
|             print("[watchOS] Message decode failed: Type '\(type)' mismatch:", context.debugDescription) | ||||
|             print("[watchOS] Message decode failed: codingPath:", context.codingPath) | ||||
|             return [] | ||||
|         } catch { | ||||
|             print("error: ", error) | ||||
|             throw error | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // MARK: - WebSocket | ||||
|  | ||||
|     private var webSocketTask: URLSessionWebSocketTask? | ||||
|     private var heartbeatTimer: Timer? | ||||
|     private var reconnectTimer: Timer? | ||||
|     private var isDisconnectingManually = false | ||||
|  | ||||
|     private var lastToken: String? | ||||
|     private var lastServerUrl: String? | ||||
|  | ||||
|     private var heartbeatAt: Date? | ||||
|     var heartbeatDelay: TimeInterval? | ||||
|  | ||||
|     private let connectLock = NSLock() | ||||
|      | ||||
|     private let packetSubject = PassthroughSubject<WebSocketPacket, Error>() | ||||
|     private let stateSubject = CurrentValueSubject<WebSocketState, Never>(.disconnected) // Changed to CurrentValueSubject | ||||
|      | ||||
|     private var currentConnectionState: WebSocketState = .disconnected { // New property | ||||
|         didSet { | ||||
|             // Only send updates if the state has actually changed | ||||
|             if oldValue != currentConnectionState { | ||||
|                 stateSubject.send(currentConnectionState) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     var packetStream: AnyPublisher<WebSocketPacket, Error> { | ||||
|         packetSubject.eraseToAnyPublisher() | ||||
|     } | ||||
|      | ||||
|     var stateStream: AnyPublisher<WebSocketState, Never> { | ||||
|         stateSubject.eraseToAnyPublisher() | ||||
|     } | ||||
|      | ||||
|     func connectWebSocket(token: String, serverUrl: String) { | ||||
|         connectLock.lock() | ||||
|         defer { connectLock.unlock() } | ||||
|  | ||||
|         webSocketQueue.async { [weak self] in | ||||
|             guard let self = self else { return } | ||||
|  | ||||
|             // Prevent redundant connection attempts | ||||
|             if self.currentConnectionState == .connecting || self.currentConnectionState == .connected { | ||||
|                 print("[WebSocket] Already connecting or connected, ignoring new connect request.") | ||||
|                 return | ||||
|             } | ||||
|  | ||||
|             // Ensure any existing task is cancelled before starting a new one | ||||
|             self.webSocketTask?.cancel(with: .goingAway, reason: nil) | ||||
|             self.webSocketTask = nil | ||||
|  | ||||
|             self.isDisconnectingManually = false // Reset this flag for a new connection attempt | ||||
|  | ||||
|             self.lastToken = token | ||||
|             self.lastServerUrl = serverUrl | ||||
|  | ||||
|             guard var urlComponents = URLComponents(string: serverUrl) else { | ||||
|                 self.currentConnectionState = .error("Invalid server URL") | ||||
|                 return | ||||
|             } | ||||
|  | ||||
|             urlComponents.scheme = urlComponents.scheme?.replacingOccurrences(of: "http", with: "ws") | ||||
|             urlComponents.path = "/ws" | ||||
|             urlComponents.queryItems = [URLQueryItem(name: "deviceAlt", value: "watch")] | ||||
|  | ||||
|             guard let url = urlComponents.url else { | ||||
|                 self.currentConnectionState = .error("Invalid WebSocket URL") | ||||
|                 return | ||||
|             } | ||||
|  | ||||
|             print("[WebSocket] Trying connecting to \(url)") | ||||
|             self.currentConnectionState = .connecting | ||||
|  | ||||
|             var request = URLRequest(url: url) | ||||
|             request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") | ||||
|             request.addValue("application/json", forHTTPHeaderField: "Content-Type") | ||||
|  | ||||
|             self.webSocketTask = self.session.webSocketTask(with: request) | ||||
|             self.webSocketTask?.resume() | ||||
|  | ||||
|             self.listenForWebSocketMessages() | ||||
|             self.scheduleHeartbeat() | ||||
|             self.currentConnectionState = .connected | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private func listenForWebSocketMessages() { | ||||
|         // Ensure webSocketTask is still valid before attempting to receive | ||||
|         guard let task = webSocketTask else { | ||||
|             print("[WebSocket] listenForWebSocketMessages: webSocketTask is nil, stopping listen.") | ||||
|             return | ||||
|         } | ||||
|          | ||||
|         task.receive { [weak self] result in | ||||
|             guard let self = self else { return } | ||||
|              | ||||
|             switch result { | ||||
|             case .failure(let error): | ||||
|                 print("[WebSocket] Error in receiving message: \(error)") | ||||
|                 // Only attempt to reconnect if not manually disconnecting | ||||
|                 if !self.isDisconnectingManually { | ||||
|                     self.currentConnectionState = .error(error.localizedDescription) | ||||
|                     self.scheduleReconnect() | ||||
|                 } else { | ||||
|                     // If manually disconnecting, just ensure state is disconnected | ||||
|                     self.currentConnectionState = .disconnected | ||||
|                 } | ||||
|             case .success(let message): | ||||
|                 switch message { | ||||
|                 case .string(let text): | ||||
|                     self.handleWebSocketMessage(text: text) | ||||
|                 case .data(let data): | ||||
|                     if let text = String(data: data, encoding: .utf8) { | ||||
|                         self.handleWebSocketMessage(text: text) | ||||
|                     } | ||||
|                 @unknown default: | ||||
|                     break | ||||
|                 } | ||||
|                 // Continue listening for next message only if task is still valid | ||||
|                 if self.webSocketTask === task { // Check if it's the same task | ||||
|                     self.listenForWebSocketMessages() | ||||
|                 } else { | ||||
|                     print("[WebSocket] listenForWebSocketMessages: Task changed, stopping listen for old task.") | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func handleWebSocketMessage(text: String) { | ||||
|         guard let data = text.data(using: .utf8) else { | ||||
|             print("[WebSocket] Could not convert message to data") | ||||
|             return | ||||
|         } | ||||
|          | ||||
|         do { | ||||
|             if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], | ||||
|                let type = json["type"] as? String | ||||
|             { | ||||
|                 let packet = WebSocketPacket( | ||||
|                     type: type, | ||||
|                     data: json["data"] as? [String: Any], | ||||
|                     endpoint: json["endpoint"] as? String, | ||||
|                     errorMessage: json["errorMessage"] as? String | ||||
|                 ) | ||||
|                  | ||||
|                 print("[WebSocket] Received packet: \(packet.type) \(packet.errorMessage ?? "")") | ||||
|                  | ||||
|                 if packet.type == "error.dupe" { | ||||
|                     self.currentConnectionState = .duplicateDevice | ||||
|                     self.disconnectWebSocket() | ||||
|                     return | ||||
|                 } | ||||
|                  | ||||
|                 if packet.type == "pong" { | ||||
|                     if let beatAt = self.heartbeatAt { | ||||
|                         let now = Date() | ||||
|                         self.heartbeatDelay = now.timeIntervalSince(beatAt) | ||||
|                         print("[WebSocket] Server respond last heartbeat for \((self.heartbeatDelay ?? 0) * 1000) ms") | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 self.packetSubject.send(packet) | ||||
|             } | ||||
|         } catch { | ||||
|             print("[WebSocket] Could not parse message json: \(error.localizedDescription)") | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func scheduleReconnect() { | ||||
|         reconnectTimer?.invalidate() | ||||
|         reconnectTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in | ||||
|             guard let self = self, let token = self.lastToken, let serverUrl = self.lastServerUrl else { return } | ||||
|             print("[WebSocket] Attempting to reconnect...") | ||||
|              | ||||
|             // No need to call disconnectWebSocket here, connectWebSocket will handle cancelling old task | ||||
|             self.isDisconnectingManually = false // Reset for the new connection attempt | ||||
|              | ||||
|             self.connectWebSocket(token: token, serverUrl: serverUrl) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func scheduleHeartbeat() { | ||||
|         heartbeatTimer?.invalidate() | ||||
|         heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in | ||||
|             self?.beatTheHeart() | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     private func beatTheHeart() { | ||||
|         heartbeatAt = Date() | ||||
|         print("[WebSocket] We\'re beating the heart! \(String(describing: self.heartbeatAt))") | ||||
|         sendWebSocketMessage(message: "{\"type\":\"ping\"}") | ||||
|     } | ||||
|      | ||||
|     func sendWebSocketMessage(message: String) { | ||||
|         webSocketTask?.send(.string(message)) { error in | ||||
|             if let error = error { | ||||
|                 print("[WebSocket] Error sending message: \(error.localizedDescription)") | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func disconnectWebSocket() { | ||||
|         isDisconnectingManually = true | ||||
|         reconnectTimer?.invalidate() | ||||
|         heartbeatTimer?.invalidate() | ||||
|          | ||||
|         // Cancel the task and then nil it out | ||||
|         webSocketTask?.cancel(with: .goingAway, reason: nil) | ||||
|         webSocketTask = nil // Set to nil immediately after cancelling | ||||
|          | ||||
|         self.currentConnectionState = .disconnected | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -26,10 +26,15 @@ class AppState: ObservableObject { | ||||
|             .sink { [weak self] token, serverUrl in | ||||
|                 self?.token = token | ||||
|                 self?.serverUrl = serverUrl | ||||
|                 if token != nil && serverUrl != nil { | ||||
|                     self?.isReady = true | ||||
|                 } | ||||
|             } | ||||
|             if let token = token, let serverUrl = serverUrl { | ||||
|                 self?.isReady = true | ||||
|                 // Auto-connect WebSocket here | ||||
|                 self?.networkService.connectWebSocket(token: token, serverUrl: serverUrl) | ||||
|             } else { | ||||
|                 self?.isReady = false | ||||
|                 // Disconnect WebSocket if token or serverUrl become nil | ||||
|                 self?.networkService.disconnectWebSocket() | ||||
|             }            } | ||||
|             .store(in: &cancellables) | ||||
|     } | ||||
|      | ||||
|   | ||||
| @@ -5,9 +5,14 @@ | ||||
| //  Created by LittleSheep on 2025/10/30. | ||||
| // | ||||
|  | ||||
| import Combine | ||||
| import SwiftUI | ||||
|  | ||||
| struct AppInfoHeaderView : View { | ||||
|     @EnvironmentObject var appState: AppState // Access AppState | ||||
|     @State private var webSocketConnectionState: WebSocketState = .disconnected // New state for WebSocket status | ||||
|     @State private var cancellables = Set<AnyCancellable>() // For managing subscriptions | ||||
|  | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading) { | ||||
|             HStack(spacing: 12) { | ||||
| @@ -18,8 +23,40 @@ struct AppInfoHeaderView : View { | ||||
|                 VStack(alignment: .leading) { | ||||
|                     Text("Solian").font(.headline) | ||||
|                     Text("for Apple Watch").font(.system(size: 11)) | ||||
|                      | ||||
|                     // Display WebSocket connection status | ||||
|                     Text(webSocketStatusMessage) | ||||
|                         .font(.caption2) | ||||
|                         .foregroundColor(.secondary) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         .onAppear { | ||||
|             setupWebSocketListeners() | ||||
|         } | ||||
|         .onDisappear { | ||||
|             cancellables.forEach { $0.cancel() } | ||||
|             cancellables.removeAll() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private var webSocketStatusMessage: String { | ||||
|         switch webSocketConnectionState { | ||||
|         case .connected: return "Connected" | ||||
|         case .connecting: return "Connecting..." | ||||
|         case .disconnected: return "Disconnected" | ||||
|         case .serverDown: return "Server Down" | ||||
|         case .duplicateDevice: return "Duplicate Device" | ||||
|         case .error(let msg): return "Error: \(msg)" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private func setupWebSocketListeners() { | ||||
|         appState.networkService.stateStream | ||||
|             .receive(on: DispatchQueue.main) | ||||
|             .sink { state in | ||||
|                 webSocketConnectionState = state | ||||
|             } | ||||
|             .store(in: &cancellables) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -196,7 +196,7 @@ struct ChatRoomListItem: View { | ||||
|                             .resizable() | ||||
|                             .frame(width: 32, height: 32) | ||||
|                             .clipShape(Circle()) | ||||
|                     } else if let errorMessage = avatarLoader.errorMessage { | ||||
|                     } else if avatarLoader.errorMessage != nil { | ||||
|                         // Error state - show fallback | ||||
|                         Circle() | ||||
|                             .fill(Color.gray.opacity(0.3)) | ||||
| @@ -250,15 +250,28 @@ struct ChatRoomListItem: View { | ||||
|     } | ||||
| } | ||||
|  | ||||
| import Combine | ||||
| import SwiftUI | ||||
|  | ||||
| struct ChatRoomView: View { | ||||
|     let room: SnChatRoom | ||||
|     @EnvironmentObject var appState: AppState | ||||
|     @State private var messages: [SnChatMessage] = [] | ||||
|     @State private var isLoading = false | ||||
|     @State private var error: Error? | ||||
|     @State private var webSocketConnectionState: WebSocketState = .disconnected // New state for WebSocket status | ||||
|  | ||||
|     @State private var cancellables = Set<AnyCancellable>() // For managing subscriptions | ||||
|  | ||||
|     var body: some View { | ||||
|         VStack { | ||||
|             // Display WebSocket connection status | ||||
|             Text(webSocketStatusMessage) | ||||
|                 .font(.caption2) | ||||
|                 .foregroundColor(.secondary) | ||||
|                 .padding(.vertical, 2) | ||||
|                 .animation(.easeInOut, value: webSocketConnectionState) // Animate status changes | ||||
|  | ||||
|             if isLoading { | ||||
|                 ProgressView() | ||||
|             } else if error != nil { | ||||
| @@ -313,6 +326,24 @@ struct ChatRoomView: View { | ||||
|         .task { | ||||
|             await loadMessages() | ||||
|         } | ||||
|         .onAppear { | ||||
|             setupWebSocketListeners() | ||||
|         } | ||||
|         .onDisappear { | ||||
|             cancellables.forEach { $0.cancel() } | ||||
|             cancellables.removeAll() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private var webSocketStatusMessage: String { | ||||
|         switch webSocketConnectionState { | ||||
|         case .connected: return "Connected" | ||||
|         case .connecting: return "Connecting..." | ||||
|         case .disconnected: return "Disconnected" | ||||
|         case .serverDown: return "Server Down" | ||||
|         case .duplicateDevice: return "Duplicate Device" | ||||
|         case .error(let msg): return "Error: \(msg)" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private func loadMessages() async { | ||||
| @@ -336,6 +367,47 @@ struct ChatRoomView: View { | ||||
|  | ||||
|         isLoading = false | ||||
|     } | ||||
|  | ||||
|     private func setupWebSocketListeners() { | ||||
|         // Listen for WebSocket packets (new messages) | ||||
|         appState.networkService.packetStream | ||||
|             .receive(on: DispatchQueue.main) // Ensure UI updates on main thread | ||||
|             .sink(receiveCompletion: { completion in | ||||
|                 if case .failure(let err) = completion { | ||||
|                     print("[ChatRoomView] WebSocket packet stream error: \(err.localizedDescription)") | ||||
|                 } | ||||
|             }, receiveValue: { packet in | ||||
|                 // Assuming 'message.created' is the type for new messages | ||||
|                 if packet.type == "message.created", | ||||
|                    let messageData = packet.data { | ||||
|                     do { | ||||
|                         let jsonData = try JSONSerialization.data(withJSONObject: messageData, options: []) | ||||
|                         let decoder = JSONDecoder() | ||||
|                         decoder.dateDecodingStrategy = .iso8601 | ||||
|                         decoder.keyDecodingStrategy = .convertFromSnakeCase | ||||
|                         let newMessage = try decoder.decode(SnChatMessage.self, from: jsonData) | ||||
|  | ||||
|                         if newMessage.chatRoomId == room.id { | ||||
|                             // Avoid adding duplicates | ||||
|                             if !messages.contains(where: { $0.id == newMessage.id }) { | ||||
|                                 messages.append(newMessage) | ||||
|                             } | ||||
|                         } | ||||
|                     } catch { | ||||
|                         print("[ChatRoomView] Error decoding message from websocket: \(error.localizedDescription)") | ||||
|                     } | ||||
|                 } | ||||
|             }) | ||||
|             .store(in: &cancellables) | ||||
|  | ||||
|         // Listen for WebSocket connection state changes | ||||
|         appState.networkService.stateStream | ||||
|             .receive(on: DispatchQueue.main) // Ensure UI updates on main thread | ||||
|             .sink { state in | ||||
|                 webSocketConnectionState = state | ||||
|             } | ||||
|             .store(in: &cancellables) | ||||
|     } | ||||
| } | ||||
|  | ||||
| struct ChatMessageItem: View { | ||||
|   | ||||
							
								
								
									
										10
									
								
								ios/WatchRunner-Watch-App-Info.plist
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								ios/WatchRunner-Watch-App-Info.plist
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||
| <plist version="1.0"> | ||||
| <dict> | ||||
| 	<key>UIBackgroundModes</key> | ||||
| 	<array> | ||||
| 		<string>remote-notification</string> | ||||
| 	</array> | ||||
| </dict> | ||||
| </plist> | ||||
		Reference in New Issue
	
	Block a user