import SwiftUI struct DMListView: View { @Environment(APIClient.self) private var api @Environment(MeshDataStore.self) private var store @State private var conversations: [DMConversation] = [] @State private var loading = false @State private var error: String? var body: some View { Group { if loading && conversations.isEmpty { ProgressView("Loading conversations…") } else if let error, conversations.isEmpty { ContentUnavailableView { Label("Error", systemImage: "exclamationmark.triangle") } description: { Text(error) } actions: { Button("Retry") { Task { await load() } } } } else if conversations.isEmpty { ContentUnavailableView("No direct messages", systemImage: "bubble.left.and.bubble.right") } else { List(conversations) { conv in NavigationLink { ChatView(target: .dm(nodeId: conv.nodeId, name: conv.displayName)) } label: { DMRow(conversation: conv) } } } } .navigationTitle("Direct Messages") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { Task { await load() } } label: { Image(systemName: "arrow.clockwise") } .disabled(loading) } } .refreshable { await load() } .task { await load() } } @MainActor private func load() async { loading = true defer { loading = false } error = nil if store.nodes.isEmpty || store.localNodeId == nil { await store.refresh(api: api) } do { let msgs = try await api.fetchAllMessages(limit: 200) conversations = Self.extractConversations( from: msgs, localNodeId: store.localNodeId, nodeNames: store.nodeNames ) } catch { self.error = error.localizedDescription } } static func extractConversations(from messages: [Message], localNodeId: String?, nodeNames: [String: String]) -> [DMConversation] { var byNode: [String: Message] = [:] for msg in messages where msg.channel == -1 && msg.isText && !msg.isReaction { let from = msg.fromNodeId let to = msg.toNodeId let partner: String? if let local = localNodeId { if from == local { partner = to } else if to == local { partner = from } else { partner = from } } else { partner = from } guard let p = partner, p != "broadcast", p != localNodeId else { continue } if let existing = byNode[p] { if msg.timestamp > existing.timestamp { byNode[p] = msg } } else { byNode[p] = msg } } return byNode.map { (id, msg) in DMConversation( nodeId: id, displayName: nodeNames[id] ?? id, lastMessage: msg.text ?? "", lastTimestamp: msg.timestamp ) }.sorted { $0.lastTimestamp > $1.lastTimestamp } } } struct DMConversation: Identifiable, Hashable { let nodeId: String let displayName: String let lastMessage: String let lastTimestamp: Date var id: String { nodeId } } private struct DMRow: View { let conversation: DMConversation private static let timeFmt: DateFormatter = { let f = DateFormatter() f.dateStyle = .none f.timeStyle = .short return f }() var body: some View { HStack(spacing: 10) { Image(systemName: "person.circle.fill") .font(.title2) .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 2) { HStack { Text(conversation.displayName).font(.body) Spacer() Text(Self.timeFmt.string(from: conversation.lastTimestamp)) .font(.caption2).foregroundStyle(.secondary) } Text(conversation.lastMessage) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } .padding(.vertical, 2) } }