diff --git a/ios/WatchRunner Watch App/Models/Models.swift b/ios/WatchRunner Watch App/Models/Models.swift index 665e325e..09356735 100644 --- a/ios/WatchRunner Watch App/Models/Models.swift +++ b/ios/WatchRunner Watch App/Models/Models.swift @@ -271,7 +271,7 @@ struct SnChatMessage: Codable, Identifiable { let content: String? let nonce: String? let meta: [String: AnyCodable] - let membersMentioned: [String] + let membersMentioned: [String]? let editedAt: Date? let attachments: [SnCloudFile] let reactions: [SnChatReaction] @@ -283,6 +283,31 @@ struct SnChatMessage: Codable, Identifiable { let createdAt: Date let updatedAt: Date let deletedAt: Date? + + enum CodingKeys: String, CodingKey { + case id, type, content, nonce, meta, membersMentioned, editedAt, attachments, reactions, repliedMessageId, forwardedMessageId, senderId, sender, chatRoomId, createdAt, updatedAt, deletedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + type = try container.decode(String.self, forKey: .type) + content = try container.decodeIfPresent(String.self, forKey: .content) + nonce = try container.decodeIfPresent(String.self, forKey: .nonce) + meta = try container.decode([String: AnyCodable].self, forKey: .meta) + membersMentioned = try container.decodeIfPresent([String].self, forKey: .membersMentioned) ?? [] + editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt) + attachments = try container.decode([SnCloudFile].self, forKey: .attachments) + reactions = try container.decode([SnChatReaction].self, forKey: .reactions) + repliedMessageId = try container.decodeIfPresent(String.self, forKey: .repliedMessageId) + forwardedMessageId = try container.decodeIfPresent(String.self, forKey: .forwardedMessageId) + senderId = try container.decode(String.self, forKey: .senderId) + sender = try container.decode(SnChatMember.self, forKey: .sender) + chatRoomId = try container.decode(String.self, forKey: .chatRoomId) + createdAt = try container.decode(Date.self, forKey: .createdAt) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + deletedAt = try container.decodeIfPresent(Date.self, forKey: .deletedAt) + } } struct SnChatReaction: Codable, Identifiable { @@ -328,3 +353,13 @@ struct ChatRoomsResponse { struct ChatInvitesResponse { let invites: [SnChatMember] } + +struct MessageSyncResponse: Codable { + let messages: [SnChatMessage] + let currentTimestamp: Date + + enum CodingKeys: String, CodingKey { + case messages + case currentTimestamp = "current_timestamp" + } +} diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift index 80d551dd..188deb6a 100644 --- a/ios/WatchRunner Watch App/Services/NetworkService.swift +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -11,7 +11,7 @@ import Foundation class NetworkService { private let session = URLSession.shared - + func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> ActivityResponse { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) @@ -25,53 +25,53 @@ class NetworkService { queryItems.append(URLQueryItem(name: "cursor", value: cursor)) } components.queryItems = queryItems - + var request = URLRequest(url: components.url!) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") - + request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - + let (data, _) = try await session.data(for: request) - + let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase - + let activities = try decoder.decode([SnActivity].self, from: data) - + let hasMore = (activities.first?.type ?? "empty") != "empty" let nextCursor = activities.isEmpty ? nil : activities.map { $0.createdAt }.min()?.ISO8601Format() - + return ActivityResponse(activities: activities, hasMore: hasMore, nextCursor: nextCursor) } - + func createPost(title: String, content: String, token: String, serverUrl: String) async throws { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/sphere/posts") - + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - + let body: [String: Any] = ["title": title, "content": content] request.httpBody = try JSONSerialization.data(withJSONObject: body) - + let (data, response) = try await session.data(for: request) - + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 { let responseBody = String(data: data, encoding: .utf8) ?? "" print("[watchOS] createPost failed with status code: \(httpResponse.statusCode), body: \(responseBody)") throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) } } - + func fetchNotifications(offset: Int = 0, take: Int = 20, token: String, serverUrl: String) async throws -> NotificationResponse { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) @@ -79,249 +79,321 @@ class NetworkService { var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)! var queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))] components.queryItems = queryItems - + var request = URLRequest(url: components.url!) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - + let (data, response) = try await session.data(for: request) - + let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase - + let notifications = try decoder.decode([SnNotification].self, from: data) - + let httpResponse = response as? HTTPURLResponse let total = Int(httpResponse?.value(forHTTPHeaderField: "X-Total") ?? "0") ?? 0 let hasMore = offset + notifications.count < total - + return NotificationResponse(notifications: notifications, total: total, hasMore: hasMore) } - + func fetchUserProfile(token: String, serverUrl: String) async throws -> SnAccount { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/pass/accounts/me") - + var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - + let (data, _) = try await session.data(for: request) - + let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase - + return try decoder.decode(SnAccount.self, from: data) } - + func fetchAccountStatus(token: String, serverUrl: String) async throws -> SnAccountStatus? { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses") - + var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - + let (data, response) = try await session.data(for: request) - + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 { return nil } - + let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase - + return try decoder.decode(SnAccountStatus.self, from: data) } - + 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 let existingStatus = try? await fetchAccountStatus(token: token, serverUrl: serverUrl) let method = (existingStatus?.isCustomized == true) ? "PATCH" : "POST" - + guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses") - + var request = URLRequest(url: url) request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - + var body: [String: Any] = [ "attitude": attitude, "is_invisible": isInvisible, "is_not_disturb": isNotDisturb ] - + if let label = label, !label.isEmpty { body["label"] = label } - + request.httpBody = try JSONSerialization.data(withJSONObject: body) - + let (data, response) = try await session.data(for: request) - + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 && httpResponse.statusCode != 200 { let responseBody = String(data: data, encoding: .utf8) ?? "" print("[watchOS] createOrUpdateStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)") throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) } - + let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase - + return try decoder.decode(SnAccountStatus.self, from: data) } - + func clearStatus(token: String, serverUrl: String) async throws { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses") - + var request = URLRequest(url: url) request.httpMethod = "DELETE" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - + let (data, response) = try await session.data(for: request) - + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 204 { let responseBody = String(data: data, encoding: .utf8) ?? "" print("[watchOS] clearStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)") throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) } } - + // MARK: - Chat API Methods - + func fetchChatRooms(token: String, serverUrl: String) async throws -> ChatRoomsResponse { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/sphere/chat") - + var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - + let (data, _) = try await session.data(for: request) - + let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase - + let rooms = try decoder.decode([SnChatRoom].self, from: data) return ChatRoomsResponse(rooms: rooms) } - + func fetchChatRoom(identifier: String, token: String, serverUrl: String) async throws -> SnChatRoom { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/sphere/chat/\(identifier)") - + var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - + let (data, response) = try await session.data(for: request) - + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 { throw URLError(.resourceUnavailable) } - + let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase - + return try decoder.decode(SnChatRoom.self, from: data) } - + func fetchChatInvites(token: String, serverUrl: String) async throws -> ChatInvitesResponse { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/sphere/chat/invites") - + var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - + let (data, _) = try await session.data(for: request) - + let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase - + let invites = try decoder.decode([SnChatMember].self, from: data) return ChatInvitesResponse(invites: invites) } - + func acceptChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/accept") - + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - + let (data, response) = try await session.data(for: request) - + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { let responseBody = String(data: data, encoding: .utf8) ?? "" print("[watchOS] acceptChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)") throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) } } - + func declineChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/decline") - + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - + let (data, response) = try await session.data(for: request) - + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { let responseBody = String(data: data, encoding: .utf8) ?? "" print("[watchOS] declineChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)") throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) } } + + // MARK: - Message API Methods + + func fetchChatMessages(chatRoomId: String, token: String, serverUrl: String, before: Date? = nil, take: Int = 50) async throws -> [SnChatMessage] { + guard let baseURL = URL(string: serverUrl) else { + throw URLError(.badURL) + } + + // Try a different pattern: /sphere/chat/messages with roomId as query param + var components = URLComponents( + url: baseURL.appendingPathComponent("/sphere/chat/\(chatRoomId)/messages"), + resolvingAgainstBaseURL: false + )! + var queryItems = [ + URLQueryItem(name: "take", value: String(take)) + ] + if let before = before { + queryItems.append(URLQueryItem(name: "before", value: ISO8601DateFormatter().string(from: before))) + } + components.queryItems = queryItems + + var request = URLRequest(url: components.url!) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") + request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") + + let (data, response) = try await session.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + _ = String(data: data, encoding: .utf8) ?? "Unable to decode response body" + + if httpResponse.statusCode != 200 { + print("[watchOS] fetchChatMessages failed with status \(httpResponse.statusCode)") + throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) + } + } + + // Check if data is empty + if data.isEmpty { + print("[watchOS] fetchChatMessages received empty response data") + return [] + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.keyDecodingStrategy = .convertFromSnakeCase + + do { + 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("Key '\(key)' not found:", context.debugDescription) + print("codingPath:", context.codingPath) + return [] + } catch DecodingError.valueNotFound(let value, let context) { + print("Value '\(value)' not found:", context.debugDescription) + print("codingPath:", context.codingPath) + return [] + } catch DecodingError.typeMismatch(let type, let context) { + print("Type '\(type)' mismatch:", context.debugDescription) + print("codingPath:", context.codingPath) + return [] + } catch { + print("error: ", error) + throw error + } + } } diff --git a/ios/WatchRunner Watch App/Views/ChatView.swift b/ios/WatchRunner Watch App/Views/ChatView.swift index e30952a7..89cd7468 100644 --- a/ios/WatchRunner Watch App/Views/ChatView.swift +++ b/ios/WatchRunner Watch App/Views/ChatView.swift @@ -181,7 +181,10 @@ struct ChatRoomListItem: View { } var body: some View { - NavigationLink(destination: ChatRoomView(room: room)) { + NavigationLink( + destination: ChatRoomView(room: room) + .environmentObject(appState) + ) { HStack { // Avatar using ImageLoader pattern Group { @@ -292,9 +295,23 @@ struct ChatRoomView: View { } private func loadMessages() async { - // Placeholder for message loading - // In a full implementation, this would fetch messages from the API - // For now, just show empty state + guard let token = appState.token, let serverUrl = appState.serverUrl else { + isLoading = false + return + } + + do { + let messages = try await appState.networkService.fetchChatMessages( + chatRoomId: room.id, + token: token, + serverUrl: serverUrl + ) + self.messages = messages.sorted { $0.createdAt < $1.createdAt } + } catch { + print("[watchOS] Error loading messages: \(error.localizedDescription)") + self.error = error + } + isLoading = false } }