From 44dbcfdc942aebe827eb7fb3fadf0149547c6554 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 30 Oct 2025 01:28:36 +0800 Subject: [PATCH] :sparkles: Chat room listing --- ios/WatchRunner Watch App/ContentView.swift | 5 + ios/WatchRunner Watch App/Models/Models.swift | 86 +++- .../Services/NetworkService.swift | 113 +++++ .../State/AppState.swift | 3 +- .../Views/ChatView.swift | 472 ++++++++++++++++++ .../Views/ExploreView.swift | 5 +- 6 files changed, 680 insertions(+), 4 deletions(-) create mode 100644 ios/WatchRunner Watch App/Views/ChatView.swift diff --git a/ios/WatchRunner Watch App/ContentView.swift b/ios/WatchRunner Watch App/ContentView.swift index 057a9e2c..472da0a2 100644 --- a/ios/WatchRunner Watch App/ContentView.swift +++ b/ios/WatchRunner Watch App/ContentView.swift @@ -14,6 +14,7 @@ struct ContentView: View { enum Panel: Hashable { case explore + case chat case notifications case account } @@ -22,6 +23,7 @@ struct ContentView: View { NavigationSplitView { List(selection: $selection) { Label("Explore", systemImage: "globe").tag(Panel.explore) + Label("Chat", systemImage: "message").tag(Panel.chat) Label("Notifications", systemImage: "bell").tag(Panel.notifications) Label("Account", systemImage: "person.circle").tag(Panel.account) } @@ -31,6 +33,9 @@ struct ContentView: View { case .explore: ExploreView() .environmentObject(appState) + case .chat: + ChatView() + .environmentObject(appState) case .notifications: NotificationView() .environmentObject(appState) diff --git a/ios/WatchRunner Watch App/Models/Models.swift b/ios/WatchRunner Watch App/Models/Models.swift index f30d00bc..665e325e 100644 --- a/ios/WatchRunner Watch App/Models/Models.swift +++ b/ios/WatchRunner Watch App/Models/Models.swift @@ -1,4 +1,3 @@ -// // Models.swift // WatchRunner Watch App // @@ -88,7 +87,7 @@ enum DiscoveryItemData: Codable { } self = .unknown } - + func encode(to encoder: Encoder) throws { // Not needed for decoding } @@ -246,3 +245,86 @@ struct SnAccountStatus: Codable { let updatedAt: Date let deletedAt: Date? } + +// MARK: - Chat Models + +struct SnChatRoom: Codable, Identifiable { + let id: String + let name: String? + let description: String? + let type: Int + let isPublic: Bool + let isCommunity: Bool + let picture: SnCloudFile? + let background: SnCloudFile? + let realmId: String? + let realm: SnRealm? + let createdAt: Date + let updatedAt: Date + let deletedAt: Date? + let members: [SnChatMember]? +} + +struct SnChatMessage: Codable, Identifiable { + let id: String + let type: String + let content: String? + let nonce: String? + let meta: [String: AnyCodable] + let membersMentioned: [String] + let editedAt: Date? + let attachments: [SnCloudFile] + let reactions: [SnChatReaction] + let repliedMessageId: String? + let forwardedMessageId: String? + let senderId: String + let sender: SnChatMember + let chatRoomId: String + let createdAt: Date + let updatedAt: Date + let deletedAt: Date? +} + +struct SnChatReaction: Codable, Identifiable { + let id: String + let messageId: String + let senderId: String + let sender: SnChatMember + let symbol: String + let attitude: Int + let createdAt: Date + let updatedAt: Date + let deletedAt: Date? +} + +struct SnChatMember: Codable, Identifiable { + let id: String + let chatRoomId: String + let chatRoom: SnChatRoom? + let accountId: String + let account: SnAccount + let nick: String? + let role: Int + let notify: Int + let joinedAt: Date? + let breakUntil: Date? + let timeoutUntil: Date? + let isBot: Bool + let status: SnAccountStatus? + let createdAt: Date + let updatedAt: Date + let deletedAt: Date? +} + +struct SnChatSummary: Codable { + let unreadCount: Int + let lastMessage: SnChatMessage? +} + +struct ChatRoomsResponse { + let rooms: [SnChatRoom] +} + +struct ChatInvitesResponse { + let invites: [SnChatMember] +} diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift index 4de64ba0..80d551dd 100644 --- a/ios/WatchRunner Watch App/Services/NetworkService.swift +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -211,4 +211,117 @@ class NetworkService { 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)) + } + } } diff --git a/ios/WatchRunner Watch App/State/AppState.swift b/ios/WatchRunner Watch App/State/AppState.swift index 3e75a0a6..e8c69ae2 100644 --- a/ios/WatchRunner Watch App/State/AppState.swift +++ b/ios/WatchRunner Watch App/State/AppState.swift @@ -15,7 +15,8 @@ class AppState: ObservableObject { @Published var token: String? = nil @Published var serverUrl: String? = nil @Published var isReady = false - + + let networkService = NetworkService() private var wcService = WatchConnectivityService() private var cancellables = Set() diff --git a/ios/WatchRunner Watch App/Views/ChatView.swift b/ios/WatchRunner Watch App/Views/ChatView.swift new file mode 100644 index 00000000..e30952a7 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/ChatView.swift @@ -0,0 +1,472 @@ +// +// ChatView.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/30. +// + +import SwiftUI + +struct ChatView: View { + @EnvironmentObject var appState: AppState + @State private var selectedTab = 0 + @State private var chatRooms: [SnChatRoom] = [] + @State private var chatInvites: [SnChatMember] = [] + @State private var isLoading = false + @State private var error: Error? + @State private var showingInvites = false + + private let tabs = ["All", "Direct", "Group"] + + var body: some View { + TabView(selection: $selectedTab) { + ForEach(0.. [SnChatRoom] { + switch tabIndex { + case 0: // All + return chatRooms + case 1: // Direct + return chatRooms.filter { $0.type == 1 } + case 2: // Group + return chatRooms.filter { $0.type != 1 } + default: + return chatRooms + } + } + + private func loadChatRooms() async { + guard let token = appState.token, let serverUrl = appState.serverUrl else { return } + + isLoading = true + error = nil + + do { + let response = try await appState.networkService.fetchChatRooms(token: token, serverUrl: serverUrl) + chatRooms = response.rooms + } catch { + self.error = error + } + + isLoading = false + } + + private func loadChatInvites() async { + guard let token = appState.token, let serverUrl = appState.serverUrl else { return } + + do { + let response = try await appState.networkService.fetchChatInvites(token: token, serverUrl: serverUrl) + chatInvites = response.invites + } catch { + // Handle error silently for invites + } + } +} + +struct ChatRoomListView: View { + let chatRooms: [SnChatRoom] + let selectedTab: Int + + var body: some View { + if chatRooms.isEmpty { + VStack { + Image(systemName: "message") + .font(.largeTitle) + .foregroundColor(.secondary) + Text("No chats yet") + .font(.caption) + .foregroundColor(.secondary) + } + } else { + List(chatRooms) { room in + ChatRoomListItem(room: room) + } + .listStyle(.plain) + } + } +} + +struct ChatRoomListItem: View { + let room: SnChatRoom + @EnvironmentObject var appState: AppState + @StateObject private var avatarLoader = ImageLoader() + + private var displayName: String { + if room.type == 1, let members = room.members, !members.isEmpty { + // For direct messages, show the other member's name + return members[0].account.nick + } else { + // For group chats, show room name or fallback + return room.name ?? "Group Chat" + } + } + + private var subtitle: String { + if room.type == 1, let members = room.members, members.count > 1 { + // For direct messages, show member usernames + return members.map { "@\($0.account.name)" }.joined(separator: ", ") + } else if let description = room.description { + // For group chats with description + return description + } else { + // Fallback + return "" + } + } + + private var avatarPictureId: String? { + if room.type == 1, let members = room.members, !members.isEmpty { + // For direct messages, use the other member's avatar + return members[0].account.profile.picture?.id + } else { + // For group chats, use room picture + return room.picture?.id + } + } + + var body: some View { + NavigationLink(destination: ChatRoomView(room: room)) { + HStack { + // Avatar using ImageLoader pattern + Group { + if avatarLoader.isLoading { + ProgressView() + .frame(width: 32, height: 32) + } else if let image = avatarLoader.image { + image + .resizable() + .frame(width: 32, height: 32) + .clipShape(Circle()) + } else if let errorMessage = avatarLoader.errorMessage { + // Error state - show fallback + Circle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 32, height: 32) + .overlay( + Text(displayName.prefix(1).uppercased()) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.primary) + ) + } else { + // No image available - show initial + Circle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 32, height: 32) + .overlay( + Text(displayName.prefix(1).uppercased()) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.primary) + ) + } + } + .task(id: avatarPictureId) { + if let serverUrl = appState.serverUrl, + let pictureId = avatarPictureId, + let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), + let token = appState.token { + await avatarLoader.loadImage(from: imageUrl, token: token) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text(displayName) + .font(.system(size: 14, weight: .medium)) + .lineLimit(1) + + if !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + + Spacer() + + // Unread count badge placeholder + // In a full implementation, this would show unread count + } + .padding(.vertical, 4) + } + } +} + +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? + + var body: some View { + VStack { + if isLoading { + ProgressView() + } else if error != nil { + VStack { + Text("Error loading messages") + .font(.caption) + Button("Retry") { + Task { + await loadMessages() + } + } + .font(.caption2) + } + } else if messages.isEmpty { + VStack { + Image(systemName: "bubble.left") + .font(.largeTitle) + .foregroundColor(.secondary) + Text("No messages yet") + .font(.caption) + .foregroundColor(.secondary) + } + } else { + List(messages) { message in + ChatMessageItem(message: message) + } + .listStyle(.plain) + } + } + .navigationTitle(room.name ?? "Chat") + .task { + await loadMessages() + } + } + + 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 + isLoading = false + } +} + +struct ChatMessageItem: View { + let message: SnChatMessage + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(message.sender.account.nick) + .font(.system(size: 12, weight: .medium)) + Spacer() + Text(message.createdAt, style: .time) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + + if let content = message.content { + Text(content) + .font(.system(size: 14)) + } + } + .padding(.vertical, 4) + } +} + +struct ChatInvitesView: View { + @Binding var invites: [SnChatMember] + let appState: AppState + @Environment(\.dismiss) private var dismiss + @State private var isLoading = false + + var body: some View { + NavigationView { + VStack { + if invites.isEmpty { + VStack { + Image(systemName: "envelope.open") + .font(.largeTitle) + .foregroundColor(.secondary) + Text("No invites") + .font(.caption) + .foregroundColor(.secondary) + } + } else { + List(invites) { invite in + ChatInviteItem(invite: invite, appState: appState, invites: $invites) + } + .listStyle(.plain) + } + } + .navigationTitle("Invites") + .navigationBarTitleDisplayMode(.inline) + } + } +} + +struct ChatInviteItem: View { + let invite: SnChatMember + let appState: AppState + @Binding var invites: [SnChatMember] + @State private var isAccepting = false + @State private var isDeclining = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Circle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 24, height: 24) + .overlay( + Text((invite.chatRoom?.name ?? "C").prefix(1).uppercased()) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.primary) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(invite.chatRoom?.name ?? "Unknown Chat") + .font(.system(size: 14, weight: .medium)) + .lineLimit(1) + + HStack(spacing: 4) { + Text(invite.role == 100 ? "Owner" : invite.role >= 50 ? "Moderator" : "Member") + .font(.system(size: 12)) + .foregroundColor(.secondary) + + if invite.chatRoom?.type == 1 { + Text("Direct") + .font(.system(size: 12)) + .foregroundColor(.blue) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color.blue.opacity(0.1)) + .cornerRadius(4) + } + } + } + + Spacer() + } + + HStack(spacing: 8) { + Button { + Task { + await acceptInvite() + } + } label: { + if isAccepting { + ProgressView() + .frame(width: 20, height: 20) + } else { + Image(systemName: "checkmark") + .frame(width: 20, height: 20) + } + } + .disabled(isAccepting || isDeclining) + + Button { + Task { + await declineInvite() + } + } label: { + if isDeclining { + ProgressView() + .frame(width: 20, height: 20) + } else { + Image(systemName: "xmark") + .frame(width: 20, height: 20) + } + } + .disabled(isAccepting || isDeclining) + } + } + .padding(.vertical, 8) + } + + private func acceptInvite() async { + guard let token = appState.token, + let serverUrl = appState.serverUrl, + let chatRoomId = invite.chatRoom?.id else { return } + + isAccepting = true + + do { + try await appState.networkService.acceptChatInvite(chatRoomId: chatRoomId, token: token, serverUrl: serverUrl) + // Remove from invites list + invites.removeAll { $0.id == invite.id } + } catch { + // Handle error - could show alert + print("Failed to accept invite: \(error)") + } + + isAccepting = false + } + + private func declineInvite() async { + guard let token = appState.token, + let serverUrl = appState.serverUrl, + let chatRoomId = invite.chatRoom?.id else { return } + + isDeclining = true + + do { + try await appState.networkService.declineChatInvite(chatRoomId: chatRoomId, token: token, serverUrl: serverUrl) + // Remove from invites list + invites.removeAll { $0.id == invite.id } + } catch { + // Handle error - could show alert + print("Failed to decline invite: \(error)") + } + + isDeclining = false + } +} diff --git a/ios/WatchRunner Watch App/Views/ExploreView.swift b/ios/WatchRunner Watch App/Views/ExploreView.swift index 1aca9bab..c8c48829 100644 --- a/ios/WatchRunner Watch App/Views/ExploreView.swift +++ b/ios/WatchRunner Watch App/Views/ExploreView.swift @@ -22,18 +22,21 @@ struct ExploreView: View { .tabItem { Label("Explore", systemImage: "safari") } + .labelStyle(.titleOnly) ActivityListView(filter: "Subscriptions") .tag("Subscriptions") .tabItem { Label("Subscriptions", systemImage: "star") } + .labelStyle(.titleOnly) ActivityListView(filter: "Friends") .tag("Friends") .tabItem { Label("Friends", systemImage: "person.2") } + .labelStyle(.titleOnly) } .navigationTitle(selectedTab) .toolbar { @@ -56,4 +59,4 @@ struct ExploreView: View { .environmentObject(appState) } } -} \ No newline at end of file +}