✨ Chat room listing
This commit is contained in:
		| @@ -14,6 +14,7 @@ struct ContentView: View { | |||||||
|  |  | ||||||
|     enum Panel: Hashable { |     enum Panel: Hashable { | ||||||
|         case explore |         case explore | ||||||
|  |         case chat | ||||||
|         case notifications |         case notifications | ||||||
|         case account |         case account | ||||||
|     } |     } | ||||||
| @@ -22,6 +23,7 @@ struct ContentView: View { | |||||||
|         NavigationSplitView { |         NavigationSplitView { | ||||||
|             List(selection: $selection) { |             List(selection: $selection) { | ||||||
|                 Label("Explore", systemImage: "globe").tag(Panel.explore) |                 Label("Explore", systemImage: "globe").tag(Panel.explore) | ||||||
|  |                 Label("Chat", systemImage: "message").tag(Panel.chat) | ||||||
|                 Label("Notifications", systemImage: "bell").tag(Panel.notifications) |                 Label("Notifications", systemImage: "bell").tag(Panel.notifications) | ||||||
|                 Label("Account", systemImage: "person.circle").tag(Panel.account) |                 Label("Account", systemImage: "person.circle").tag(Panel.account) | ||||||
|             } |             } | ||||||
| @@ -31,6 +33,9 @@ struct ContentView: View { | |||||||
|             case .explore: |             case .explore: | ||||||
|                 ExploreView() |                 ExploreView() | ||||||
|                     .environmentObject(appState) |                     .environmentObject(appState) | ||||||
|  |             case .chat: | ||||||
|  |                 ChatView() | ||||||
|  |                     .environmentObject(appState) | ||||||
|             case .notifications: |             case .notifications: | ||||||
|                 NotificationView() |                 NotificationView() | ||||||
|                     .environmentObject(appState) |                     .environmentObject(appState) | ||||||
|   | |||||||
| @@ -1,4 +1,3 @@ | |||||||
| // |  | ||||||
| //  Models.swift | //  Models.swift | ||||||
| //  WatchRunner Watch App | //  WatchRunner Watch App | ||||||
| // | // | ||||||
| @@ -246,3 +245,86 @@ struct SnAccountStatus: Codable { | |||||||
|     let updatedAt: Date |     let updatedAt: Date | ||||||
|     let deletedAt: 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] | ||||||
|  | } | ||||||
|   | |||||||
| @@ -211,4 +211,117 @@ class NetworkService { | |||||||
|             throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) |             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)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -16,6 +16,7 @@ class AppState: ObservableObject { | |||||||
|     @Published var serverUrl: String? = nil |     @Published var serverUrl: String? = nil | ||||||
|     @Published var isReady = false |     @Published var isReady = false | ||||||
|  |  | ||||||
|  |     let networkService = NetworkService() | ||||||
|     private var wcService = WatchConnectivityService() |     private var wcService = WatchConnectivityService() | ||||||
|     private var cancellables = Set<AnyCancellable>() |     private var cancellables = Set<AnyCancellable>() | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										472
									
								
								ios/WatchRunner Watch App/Views/ChatView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										472
									
								
								ios/WatchRunner Watch App/Views/ChatView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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..<tabs.count, id: \.self) { index in | ||||||
|  |                 VStack { | ||||||
|  |                     if isLoading { | ||||||
|  |                         ProgressView() | ||||||
|  |                     } else if error != nil { | ||||||
|  |                         VStack { | ||||||
|  |                             Text("Error loading chats") | ||||||
|  |                                 .font(.caption) | ||||||
|  |                             Button("Retry") { | ||||||
|  |                                 Task { | ||||||
|  |                                     await loadChatRooms() | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                             .font(.caption2) | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         ChatRoomListView( | ||||||
|  |                             chatRooms: filteredChatRooms(for: index), | ||||||
|  |                             selectedTab: index | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 .tabItem { | ||||||
|  |                     Text(tabs[index]) | ||||||
|  |                 } | ||||||
|  |                 .tag(index) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .tabViewStyle(.page) | ||||||
|  |         .navigationTitle("Chat") | ||||||
|  |         .toolbar { | ||||||
|  |             ToolbarItem(placement: .topBarTrailing) { | ||||||
|  |                 Button { | ||||||
|  |                     showingInvites = true | ||||||
|  |                 } label: { | ||||||
|  |                     ZStack { | ||||||
|  |                         Image(systemName: "envelope") | ||||||
|  |                         if !chatInvites.isEmpty { | ||||||
|  |                             Circle() | ||||||
|  |                                 .fill(Color.red) | ||||||
|  |                                 .frame(width: 8, height: 8) | ||||||
|  |                                 .offset(x: 8, y: -8) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .sheet(isPresented: $showingInvites) { | ||||||
|  |             ChatInvitesView(invites: $chatInvites, appState: appState) | ||||||
|  |         } | ||||||
|  |         .onAppear { | ||||||
|  |             Task.detached { | ||||||
|  |                 await loadChatRooms() | ||||||
|  |                 await loadChatInvites() | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private func filteredChatRooms(for tabIndex: Int) -> [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 | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -22,18 +22,21 @@ struct ExploreView: View { | |||||||
|                         .tabItem { |                         .tabItem { | ||||||
|                             Label("Explore", systemImage: "safari") |                             Label("Explore", systemImage: "safari") | ||||||
|                         } |                         } | ||||||
|  |                         .labelStyle(.titleOnly) | ||||||
|  |  | ||||||
|                     ActivityListView(filter: "Subscriptions") |                     ActivityListView(filter: "Subscriptions") | ||||||
|                         .tag("Subscriptions") |                         .tag("Subscriptions") | ||||||
|                         .tabItem { |                         .tabItem { | ||||||
|                             Label("Subscriptions", systemImage: "star") |                             Label("Subscriptions", systemImage: "star") | ||||||
|                         } |                         } | ||||||
|  |                         .labelStyle(.titleOnly) | ||||||
|  |  | ||||||
|                     ActivityListView(filter: "Friends") |                     ActivityListView(filter: "Friends") | ||||||
|                         .tag("Friends") |                         .tag("Friends") | ||||||
|                         .tabItem { |                         .tabItem { | ||||||
|                             Label("Friends", systemImage: "person.2") |                             Label("Friends", systemImage: "person.2") | ||||||
|                         } |                         } | ||||||
|  |                         .labelStyle(.titleOnly) | ||||||
|                 } |                 } | ||||||
|                 .navigationTitle(selectedTab) |                 .navigationTitle(selectedTab) | ||||||
|                 .toolbar { |                 .toolbar { | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user