d8a6299600
Recognise own messages by id (prefix and case insensitive) or by node number, so chat bubbles align right with the accent colour even when the message only carries fromNodeNum. Fetch /api/v1/status and /api/status in parallel and merge them, since MeshMonitor 4 dropped localNode from the legacy endpoint. Add a "How to create a token" link, plus Source code and Report a bug links to the Settings screen. Default the server port to 8080 (MeshMonitor's Docker default). Add PRIVACY.md, refresh the README and ignore CLAUDE.md.
143 lines
3.5 KiB
Swift
143 lines
3.5 KiB
Swift
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)
|
|
}
|
|
}
|