import SwiftUI struct ChatView: View { @State private var conversation: Conversation var appState: AppState var conversationListVM: ConversationListVM? init(conversation: Conversation, appState: AppState, conversationListVM: ConversationListVM? = nil) { self._conversation = State(initialValue: conversation) self.appState = appState self.conversationListVM = conversationListVM } @State private var viewModel = ChatViewModel() @State private var inputText = "" @State private var replyTo: Message? @State private var showGroupInfo = false @State private var showDMInfo = false @State private var showSearch = false @State private var showDeleteConfirm = false @State private var showError = false @State private var memberListenerTask: Task? @State private var forwardingMessage: Message? @State private var showForwardPicker = false @State private var showPinnedMessages = false @State private var scrollTarget: String? @State private var showVerification = false @State private var verificationStatus: String = "unverified" private var currentUserId: String { appState.currentUser?.id ?? "" } private var isPartnerOnline: Bool { guard !conversation.isGroup, let partnerId = conversation.dmPartnerId(currentUserId: currentUserId), let listVM = conversationListVM else { return false } return listVM.onlineUsers.contains(partnerId) } var body: some View { VStack(spacing: 0) { searchBar messagesScrollView replyPreview inputView } .navigationBarTitleDisplayMode(.inline) .toolbar { toolbarContent } .alert("Delete Conversation?", isPresented: $showDeleteConfirm) { Button("Cancel", role: .cancel) {} Button("Delete", role: .destructive) { Task { await appState.chatClient.deleteConversation(convId: conversation.id) } } } message: { Text(conversation.isGroup ? "This will remove all members and delete the conversation." : "This will remove you from the conversation.") } .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) {} } message: { Text(viewModel.errorMessage ?? "Unknown error") } .sheet(isPresented: $showGroupInfo) { GroupInfoView(conversation: $conversation, appState: appState, conversationListVM: conversationListVM) } .sheet(isPresented: $showDMInfo) { if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) { ProfileView(appState: appState, isOwnProfile: false, userId: partnerId) } } .sheet(isPresented: $showForwardPicker) { if let msg = forwardingMessage { ForwardPickerView(message: msg, appState: appState) } } .sheet(isPresented: $showVerification) { if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) { NavigationStack { SafetyNumberView( peerUserId: partnerId, peerUsername: conversation.displayName(currentUserId: currentUserId), chatClient: appState.chatClient ) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Done") { showVerification = false } } } } } } .sheet(isPresented: $showPinnedMessages) { PinnedMessagesView( messages: viewModel.messages.filter { $0.pinnedAt != nil }, onScrollTo: { scrollTarget = $0 } ) } .task { // Use already-loaded data from conversation list (avoid redundant list_conversations call) if let updated = conversationListVM?.conversations.first(where: { $0.id == conversation.id }) { conversation = updated } conversationListVM?.markConversationRead(convId: conversation.id) await viewModel.loadMessages(convId: conversation.id, chatClient: appState.chatClient) viewModel.startNotificationListener(convId: conversation.id, chatClient: appState.chatClient) // Load verification status for DM partner if !conversation.isGroup, let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) { verificationStatus = await appState.chatClient.getVerificationStatus(userId: partnerId) } memberListenerTask = Task { for await notification in await appState.chatClient.makeNotificationStream() { switch notification { case .memberAdded, .memberRemoved, .conversationRenamed: let refreshed = await appState.chatClient.listConversations() if let updated = refreshed.first(where: { $0.id == conversation.id }) { await MainActor.run { conversation = updated } } default: break } } } } .onDisappear { viewModel.stop() memberListenerTask?.cancel() memberListenerTask = nil } } // MARK: - Search Bar @ViewBuilder private var searchBar: some View { if showSearch { SearchOverlayView( query: $viewModel.searchQuery, matchCount: viewModel.searchResults.count, currentIndex: viewModel.currentSearchIndex, onSearch: { viewModel.search(query: $0) }, onNext: { viewModel.nextSearchResult() }, onPrev: { viewModel.prevSearchResult() }, onClose: { showSearch = false; viewModel.search(query: "") } ) } } // MARK: - Messages private var messagesScrollView: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 8) { if viewModel.messages.count >= 50 { Button("Load older messages") { Task { await viewModel.loadOlderMessages(convId: conversation.id, chatClient: appState.chatClient) } } .font(.caption) .padding() } ForEach(viewModel.messages) { message in messageBubble(for: message) .id(message.id) } } .padding(.horizontal) .padding(.vertical, 8) } .onChange(of: viewModel.messages.count) { if let lastId = viewModel.messages.last?.id { withAnimation { proxy.scrollTo(lastId, anchor: .bottom) } } } .onChange(of: scrollTarget) { if let target = scrollTarget { withAnimation { proxy.scrollTo(target, anchor: .center) } scrollTarget = nil } } } } private func messageBubble(for message: Message) -> some View { let isCurrentSearch = viewModel.searchResults.indices.contains(viewModel.currentSearchIndex) && viewModel.searchResults[viewModel.currentSearchIndex] == message.id return MessageBubbleView( message: message, isMine: message.isMine(currentUserId: currentUserId), isGroup: conversation.isGroup, isHighlighted: viewModel.searchResults.contains(message.id), isCurrentSearchResult: isCurrentSearch, chatClient: appState.chatClient, currentUserId: currentUserId, onReply: { replyTo = message }, onReact: { reaction in Task { await viewModel.reactToMessage( messageId: message.id, convId: conversation.id, reaction: reaction, currentUserId: currentUserId, chatClient: appState.chatClient ) } }, onForward: { forwardingMessage = message showForwardPicker = true }, onPin: { pin in Task { await viewModel.pinMessage( messageId: message.id, convId: conversation.id, pin: pin, chatClient: appState.chatClient ) } }, onDelete: { Task { await viewModel.deleteMessage( messageId: message.id, convId: conversation.id, chatClient: appState.chatClient ) } } ) } // MARK: - Reply Preview @ViewBuilder private var replyPreview: some View { if let reply = replyTo { HStack { Rectangle() .fill(.blue) .frame(width: 3) VStack(alignment: .leading) { Text(reply.senderUsername) .font(.caption.bold()) Text(reply.text ?? "") .font(.caption) .lineLimit(1) } Spacer() Button(action: { replyTo = nil }) { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } } .padding(.horizontal) .padding(.vertical, 6) .background(.ultraThinMaterial) } } // MARK: - Input private var inputView: some View { MessageInputView( text: $inputText, isSending: viewModel.isSending, onSend: { Task { let text = inputText inputText = "" let reply = replyTo?.id replyTo = nil await viewModel.sendMessage( convId: conversation.id, text: text, members: conversation.members, chatClient: appState.chatClient, replyTo: reply ) } }, onImageSelected: { imageData in Task { viewModel.isSending = true let (success, msg, sentMessage) = await appState.chatClient.sendImage( convId: conversation.id, imageData: imageData, members: conversation.members ) viewModel.isSending = false if success, let sentMessage { if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) { viewModel.messages.append(sentMessage) } await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient) } else if !success { viewModel.errorMessage = msg showError = true } } }, onFileSelected: { fileData, filename, mimeType in Task { viewModel.isSending = true let (success, msg, sentMessage) = await appState.chatClient.sendFile( convId: conversation.id, fileData: fileData, filename: filename, mimeType: mimeType, members: conversation.members ) viewModel.isSending = false if success, let sentMessage { if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) { viewModel.messages.append(sentMessage) } await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient) } else if !success { viewModel.errorMessage = msg showError = true } } }, members: conversation.members ) } // MARK: - Toolbar @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .principal) { HStack(spacing: 8) { CircularAvatarView( name: conversation.displayName(currentUserId: currentUserId), imageData: conversationListVM?.avatarCache[conversation.id], size: 28, isGroup: conversation.isGroup ) Text(conversation.displayName(currentUserId: currentUserId)) .font(.headline) if !conversation.isGroup && verificationStatus == "verified" { Image(systemName: "checkmark.shield.fill") .font(.caption) .foregroundStyle(.green) } if isPartnerOnline { Circle().fill(.green).frame(width: 8, height: 8) } } } ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 16) { if !conversation.isGroup { Button(action: { showVerification = true }) { Image(systemName: verificationStatus == "verified" ? "checkmark.shield.fill" : "shield") .foregroundStyle(verificationStatus == "verified" ? .green : .secondary) } } Button(action: { showPinnedMessages = true }) { Image(systemName: "pin") } Button(action: { showSearch.toggle() }) { Image(systemName: "magnifyingglass") } if conversation.isGroup { Button(action: { showGroupInfo = true }) { Image(systemName: "info.circle") } } else { Button(action: { showDMInfo = true }) { Image(systemName: "info.circle") } } if !conversation.isGroup || conversation.createdBy == currentUserId { Button(action: { showDeleteConfirm = true }) { Image(systemName: "trash").foregroundStyle(.red) } } } } } }