✨ 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
|
||||||
//
|
//
|
||||||
@@ -88,7 +87,7 @@ enum DiscoveryItemData: Codable {
|
|||||||
}
|
}
|
||||||
self = .unknown
|
self = .unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
// Not needed for decoding
|
// Not needed for decoding
|
||||||
}
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class AppState: ObservableObject {
|
|||||||
@Published var token: String? = nil
|
@Published var token: String? = nil
|
||||||
@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 {
|
||||||
@@ -56,4 +59,4 @@ struct ExploreView: View {
|
|||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user