Files
andros d8a6299600 Identify own messages reliably and tidy the Setup screen
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.
2026-05-07 10:01:15 +02:00

133 lines
3.3 KiB
Swift

import SwiftUI
struct UnreadListView: View {
@Environment(APIClient.self) private var api
@Environment(MeshDataStore.self) private var store
@Environment(ReadTracker.self) private var readTracker
@State private var entries: [UnreadEntry] = []
@State private var loading = false
@State private var error: String?
var body: some View {
Group {
if loading && entries.isEmpty {
ProgressView("Checking unread…")
} else if let error, entries.isEmpty {
ContentUnavailableView {
Label("Error", systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
Button("Retry") { Task { await load() } }
}
} else if entries.isEmpty {
ContentUnavailableView("All caught up", systemImage: "checkmark.circle")
} else {
List(entries) { entry in
NavigationLink {
ChatView(target: .dm(nodeId: entry.nodeId, name: entry.displayName))
} label: {
UnreadRow(entry: entry)
}
}
}
}
.navigationTitle("Unread")
.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)
entries = Self.compute(
from: msgs,
localNodeId: store.localNodeId,
nodeNames: store.nodeNames,
readTracker: readTracker
)
} catch {
self.error = error.localizedDescription
}
}
static func compute(from messages: [Message],
localNodeId: String?,
nodeNames: [String: String],
readTracker: ReadTracker) -> [UnreadEntry] {
var byNode: [String: (count: Int, last: Message)] = [:]
for msg in messages where msg.channel == -1 && msg.isText && !msg.isReaction {
guard let from = msg.fromNodeId else { continue }
if from == localNodeId { continue }
let lastRead = readTracker.lastRead(for: from)
guard msg.timestamp.timeIntervalSince1970 > lastRead else { continue }
if var existing = byNode[from] {
existing.count += 1
if msg.timestamp > existing.last.timestamp { existing.last = msg }
byNode[from] = existing
} else {
byNode[from] = (1, msg)
}
}
return byNode.map { (id, v) in
UnreadEntry(
nodeId: id,
displayName: nodeNames[id] ?? id,
count: v.count,
lastMessage: v.last.text ?? "",
lastTimestamp: v.last.timestamp
)
}.sorted { $0.count > $1.count }
}
}
struct UnreadEntry: Identifiable, Hashable {
let nodeId: String
let displayName: String
let count: Int
let lastMessage: String
let lastTimestamp: Date
var id: String { nodeId }
}
private struct UnreadRow: View {
let entry: UnreadEntry
var body: some View {
HStack(spacing: 10) {
ZStack {
Circle().fill(Color.accentColor).frame(width: 32, height: 32)
Text("\(entry.count)")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text(entry.displayName).font(.body)
Text(entry.lastMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.vertical, 2)
}
}