786 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			786 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
//
 | 
						|
//  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)
 | 
						|
                .environmentObject(appState)
 | 
						|
        ) {
 | 
						|
            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 avatarLoader.errorMessage != nil {
 | 
						|
                        // 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)
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
import Combine
 | 
						|
import SwiftUI
 | 
						|
 | 
						|
struct ChatRoomView: View {
 | 
						|
    let room: SnChatRoom
 | 
						|
    @EnvironmentObject var appState: AppState
 | 
						|
    @State private var messages: [SnChatMessage] = []
 | 
						|
    @State private var isLoading = false
 | 
						|
    @State private var error: Error?
 | 
						|
    @State private var wsState: WebSocketState = .disconnected // New state for WebSocket status
 | 
						|
    @State private var hasLoadedMessages = false // Track if messages have been loaded
 | 
						|
    @State private var messageText = "" // Text input for sending messages
 | 
						|
    @State private var isSending = false // Track sending state
 | 
						|
    @State private var isInputHidden = false // Track if input should be hidden during scrolling
 | 
						|
    @State private var scrollTimer: Timer? // Timer to show input after scrolling stops
 | 
						|
 | 
						|
    @State private var cancellables = Set<AnyCancellable>() // For managing subscriptions
 | 
						|
 | 
						|
    var body: some View {
 | 
						|
        VStack {
 | 
						|
            // Display WebSocket connection status
 | 
						|
            if (wsState != .connected)
 | 
						|
            {
 | 
						|
                Text(webSocketStatusMessage)
 | 
						|
                    .font(.caption2)
 | 
						|
                    .foregroundColor(.secondary)
 | 
						|
                    .padding(.vertical, 2)
 | 
						|
                    .animation(.easeInOut, value: wsState) // Animate status changes
 | 
						|
                    .transition(.opacity)
 | 
						|
            }
 | 
						|
 | 
						|
            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 {
 | 
						|
                ScrollViewReader { scrollView in
 | 
						|
                    ScrollView {
 | 
						|
                        LazyVStack(alignment: .leading, spacing: 8) {
 | 
						|
                            ForEach(messages) { message in
 | 
						|
                                ChatMessageItem(message: message)
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
                        .padding(.horizontal)
 | 
						|
                        .padding(.vertical, 8)
 | 
						|
                        .padding(.bottom, 8)
 | 
						|
                    }
 | 
						|
                    .onAppear {
 | 
						|
                        // Scroll to bottom when messages load
 | 
						|
                        if let lastMessage = messages.last {
 | 
						|
                            scrollView.scrollTo(lastMessage.id, anchor: .bottom)
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                    .onChange(of: messages.count) { _, _ in
 | 
						|
                        // Scroll to bottom when new messages arrive
 | 
						|
                        if let lastMessage = messages.last {
 | 
						|
                            withAnimation {
 | 
						|
                                scrollView.scrollTo(lastMessage.id, anchor: .bottom)
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                    .onScrollPhaseChange { _, phase  in
 | 
						|
                        switch phase {
 | 
						|
                        case .interacting:
 | 
						|
                            if !isInputHidden {
 | 
						|
                                withAnimation(.easeOut(duration: 0.2)) {
 | 
						|
                                    isInputHidden = true
 | 
						|
                                }
 | 
						|
                            }
 | 
						|
                        case .idle:
 | 
						|
                            withAnimation(.easeIn(duration: 0.3)) {
 | 
						|
                                isInputHidden = false
 | 
						|
                            }
 | 
						|
                        default: break
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            // Message input area
 | 
						|
            if !isInputHidden {
 | 
						|
                HStack(spacing: 8) {
 | 
						|
                    TextField("Send message...", text: $messageText)
 | 
						|
                        .font(.system(size: 14))
 | 
						|
                        .disabled(isSending)
 | 
						|
                        .frame(height: 40)
 | 
						|
 | 
						|
                    Button {
 | 
						|
                        Task {
 | 
						|
                            await sendMessage()
 | 
						|
                        }
 | 
						|
                    } label: {
 | 
						|
                        if isSending {
 | 
						|
                            ProgressView()
 | 
						|
                                .frame(width: 20, height: 20)
 | 
						|
                        } else {
 | 
						|
                            Image(systemName: "arrow.up.circle.fill")
 | 
						|
                                .resizable()
 | 
						|
                                .frame(width: 20, height: 20)
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                    .labelStyle(.iconOnly)
 | 
						|
                    .buttonStyle(.automatic)
 | 
						|
                    .disabled(messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
 | 
						|
                    .frame(width: 40, height: 40)
 | 
						|
                }
 | 
						|
                .padding(.horizontal)
 | 
						|
                .padding(.top, 8)
 | 
						|
                .transition(.move(edge: .bottom).combined(with: .opacity))
 | 
						|
            }
 | 
						|
        }
 | 
						|
        .navigationTitle(room.name ?? "Chat")
 | 
						|
        .task {
 | 
						|
            await loadMessages()
 | 
						|
        }
 | 
						|
        .onAppear {
 | 
						|
            setupWebSocketListeners()
 | 
						|
        }
 | 
						|
        .onDisappear {
 | 
						|
            cancellables.forEach { $0.cancel() }
 | 
						|
            cancellables.removeAll()
 | 
						|
            scrollTimer?.invalidate()
 | 
						|
            scrollTimer = nil
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private var webSocketStatusMessage: String {
 | 
						|
        switch wsState {
 | 
						|
        case .connected: return "Connected"
 | 
						|
        case .connecting: return "Connecting..."
 | 
						|
        case .disconnected: return "Disconnected"
 | 
						|
        case .serverDown: return "Server Down"
 | 
						|
        case .duplicateDevice: return "Duplicate Device"
 | 
						|
        case .error(let msg): return "Error: \(msg)"
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private func loadMessages() async {
 | 
						|
        // Prevent reloading if already loaded
 | 
						|
        guard !hasLoadedMessages else { return }
 | 
						|
 | 
						|
        guard let token = appState.token, let serverUrl = appState.serverUrl else {
 | 
						|
            isLoading = false
 | 
						|
            return
 | 
						|
        }
 | 
						|
 | 
						|
        isLoading = true
 | 
						|
        error = nil
 | 
						|
 | 
						|
        do {
 | 
						|
            let messages = try await appState.networkService.fetchChatMessages(
 | 
						|
                chatRoomId: room.id,
 | 
						|
                token: token,
 | 
						|
                serverUrl: serverUrl
 | 
						|
            )
 | 
						|
            // Sort with newest messages first (for flipped list, newest will appear at bottom)
 | 
						|
            self.messages = messages.sorted { $0.createdAt < $1.createdAt }
 | 
						|
            hasLoadedMessages = true
 | 
						|
        } catch {
 | 
						|
            print("[watchOS] Error loading messages: \(error.localizedDescription)")
 | 
						|
            self.error = error
 | 
						|
        }
 | 
						|
 | 
						|
        isLoading = false
 | 
						|
    }
 | 
						|
 | 
						|
    private func sendMessage() async {
 | 
						|
        let content = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
 | 
						|
        guard !content.isEmpty,
 | 
						|
              let token = appState.token,
 | 
						|
              let serverUrl = appState.serverUrl else { return }
 | 
						|
 | 
						|
        isSending = true
 | 
						|
 | 
						|
        do {
 | 
						|
            // Generate a nonce for the message
 | 
						|
            let nonce = UUID().uuidString
 | 
						|
 | 
						|
            // Prepare the request data
 | 
						|
            let messageData: [String: Any] = [
 | 
						|
                "content": content,
 | 
						|
                "attachments_id": [], // Empty for now, can be extended for attachments
 | 
						|
                "meta": [:],
 | 
						|
                "nonce": nonce
 | 
						|
            ]
 | 
						|
 | 
						|
            // Create the URL
 | 
						|
            guard let url = URL(string: "\(serverUrl)/sphere/chat/\(room.id)/messages") else {
 | 
						|
                throw URLError(.badURL)
 | 
						|
            }
 | 
						|
 | 
						|
            // Create the request
 | 
						|
            var request = URLRequest(url: url)
 | 
						|
            request.httpMethod = "POST"
 | 
						|
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
 | 
						|
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 | 
						|
            request.httpBody = try JSONSerialization.data(withJSONObject: messageData, options: [])
 | 
						|
 | 
						|
            // Send the request
 | 
						|
            let (data, response) = try await URLSession.shared.data(for: request)
 | 
						|
 | 
						|
            guard let httpResponse = response as? HTTPURLResponse,
 | 
						|
                  (200...299).contains(httpResponse.statusCode) else {
 | 
						|
                throw URLError(.badServerResponse)
 | 
						|
            }
 | 
						|
 | 
						|
            // Parse the response to get the sent message
 | 
						|
            let decoder = JSONDecoder()
 | 
						|
            decoder.dateDecodingStrategy = .iso8601
 | 
						|
            decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
						|
            let sentMessage = try decoder.decode(SnChatMessage.self, from: data)
 | 
						|
 | 
						|
            // Add the message to the local list
 | 
						|
            messages.append(sentMessage)
 | 
						|
 | 
						|
            // Clear the input
 | 
						|
            messageText = ""
 | 
						|
 | 
						|
        } catch {
 | 
						|
            print("[watchOS] Error sending message: \(error.localizedDescription)")
 | 
						|
            // Could show an error alert here
 | 
						|
        }
 | 
						|
 | 
						|
        isSending = false
 | 
						|
    }
 | 
						|
 | 
						|
    private func sendReadReceipt() {
 | 
						|
        let data: [String: Any] = ["chat_room_id": room.id]
 | 
						|
        let packet: [String: Any] = ["type": "messages.read", "data": data, "endpoint": "sphere"]
 | 
						|
        if let jsonData = try? JSONSerialization.data(withJSONObject: packet, options: []),
 | 
						|
           let jsonString = String(data: jsonData, encoding: .utf8) {
 | 
						|
            appState.networkService.sendWebSocketMessage(message: jsonString)
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private func setupWebSocketListeners() {
 | 
						|
        // Listen for WebSocket packets (new messages)
 | 
						|
        appState.networkService.packetStream
 | 
						|
            .receive(on: DispatchQueue.main) // Ensure UI updates on main thread
 | 
						|
            .sink(receiveCompletion: { completion in
 | 
						|
                if case .failure(let err) = completion {
 | 
						|
                    print("[ChatRoomView] WebSocket packet stream error: \(err.localizedDescription)")
 | 
						|
                }
 | 
						|
            }, receiveValue: { packet in
 | 
						|
                if ["messages.new", "messages.update", "messages.delete"].contains(packet.type),
 | 
						|
                   let messageData = packet.data {
 | 
						|
                    do {
 | 
						|
                        let jsonData = try JSONSerialization.data(withJSONObject: messageData, options: [])
 | 
						|
                        let decoder = JSONDecoder()
 | 
						|
                        decoder.dateDecodingStrategy = .iso8601
 | 
						|
                        decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
						|
                        let message = try decoder.decode(SnChatMessage.self, from: jsonData)
 | 
						|
 | 
						|
                        if message.chatRoomId == room.id {
 | 
						|
                            switch packet.type {
 | 
						|
                            case "messages.new":
 | 
						|
                                if message.type.hasPrefix("call") {
 | 
						|
                                    // TODO: Handle ongoing call
 | 
						|
                                }
 | 
						|
                                if !messages.contains(where: { $0.id == message.id }) {
 | 
						|
                                    messages.append(message)
 | 
						|
                                }
 | 
						|
                                sendReadReceipt()
 | 
						|
                            case "messages.update":
 | 
						|
                                if let index = messages.firstIndex(where: { $0.id == message.id }) {
 | 
						|
                                    messages[index] = message
 | 
						|
                                }
 | 
						|
                            case "messages.delete":
 | 
						|
                                messages.removeAll(where: { $0.id == message.id })
 | 
						|
                            default:
 | 
						|
                                break
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
                    } catch {
 | 
						|
                        print("[ChatRoomView] Error decoding message from websocket: \(error.localizedDescription)")
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            })
 | 
						|
            .store(in: &cancellables)
 | 
						|
 | 
						|
        // Listen for WebSocket connection state changes
 | 
						|
        appState.networkService.stateStream
 | 
						|
            .receive(on: DispatchQueue.main) // Ensure UI updates on main thread
 | 
						|
            .sink { state in
 | 
						|
                wsState = state
 | 
						|
            }
 | 
						|
            .store(in: &cancellables)
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
struct ChatMessageItem: View {
 | 
						|
    let message: SnChatMessage
 | 
						|
    @EnvironmentObject var appState: AppState
 | 
						|
    @StateObject private var avatarLoader = ImageLoader()
 | 
						|
 | 
						|
    private var avatarPictureId: String? {
 | 
						|
        message.sender.account.profile.picture?.id
 | 
						|
    }
 | 
						|
 | 
						|
    var body: some View {
 | 
						|
        HStack(alignment: .top, spacing: 8) {
 | 
						|
            // Avatar
 | 
						|
            Group {
 | 
						|
                if avatarLoader.isLoading {
 | 
						|
                    ProgressView()
 | 
						|
                        .frame(width: 24, height: 24)
 | 
						|
                } else if let image = avatarLoader.image {
 | 
						|
                    image
 | 
						|
                        .resizable()
 | 
						|
                        .frame(width: 24, height: 24)
 | 
						|
                        .clipShape(Circle())
 | 
						|
                } else {
 | 
						|
                    Circle()
 | 
						|
                        .fill(Color.gray.opacity(0.3))
 | 
						|
                        .frame(width: 24, height: 24)
 | 
						|
                        .overlay(
 | 
						|
                            Text(message.sender.account.nick.prefix(1).uppercased())
 | 
						|
                                .font(.system(size: 10, 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: 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, !content.isEmpty {
 | 
						|
                    Text(content)
 | 
						|
                        .font(.system(size: 14))
 | 
						|
                        .lineLimit(nil)
 | 
						|
                        .fixedSize(horizontal: false, vertical: true)
 | 
						|
                }
 | 
						|
 | 
						|
                if !message.attachments.isEmpty {
 | 
						|
                    AttachmentView(attachment: message.attachments[0])
 | 
						|
                    if message.attachments.count > 1 {
 | 
						|
                        HStack(spacing: 8) {
 | 
						|
                            Image(systemName: "paperclip.circle.fill")
 | 
						|
                                .frame(width: 12, height: 12)
 | 
						|
                                .foregroundStyle(.gray)
 | 
						|
                            Text("\(message.attachments.count - 1)+ attachments")
 | 
						|
                                .font(.footnote)
 | 
						|
                                .foregroundStyle(.gray)
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
        .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
 | 
						|
    }
 | 
						|
}
 |