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

149 lines
4.1 KiB
Swift

import SwiftUI
struct NodeListView: View {
@Environment(APIClient.self) private var api
@Environment(MeshDataStore.self) private var store
@State private var alertText: String?
@AppStorage("nodes.onlyOnline") private var onlyOnline: Bool = true
private static let onlineThreshold: TimeInterval = 900
private var visibleNodes: [Node] {
guard onlyOnline else { return store.nodes }
return store.nodes.filter { Self.isOnline($0, threshold: Self.onlineThreshold) }
}
var body: some View {
Group {
if store.loading && store.nodes.isEmpty {
ProgressView("Loading nodes…")
} else if store.nodes.isEmpty {
ContentUnavailableView("No nodes", systemImage: "antenna.radiowaves.left.and.right.slash")
} else if visibleNodes.isEmpty {
ContentUnavailableView {
Label("No online nodes", systemImage: "antenna.radiowaves.left.and.right.slash")
} description: {
Text("Turn off the Only online filter to see every node.")
} actions: {
Button("Show all nodes") { onlyOnline = false }
}
} else {
List(visibleNodes) { node in
if node.hasPKC == true, let id = node.nodeId {
NavigationLink {
ChatView(target: .dm(nodeId: id, name: node.displayName))
} label: {
NodeRow(node: node, onlineThreshold: Self.onlineThreshold)
}
} else {
Button {
alertText = "Node \(node.displayName) has not exchanged encryption keys, DM is not possible."
} label: {
NodeRow(node: node, onlineThreshold: Self.onlineThreshold)
}
.buttonStyle(.plain)
}
}
}
}
.navigationTitle("Nodes")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Toggle(isOn: $onlyOnline) {
Label("Only online", systemImage: "wifi")
}
Button {
Task { await store.refresh(api: api) }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.disabled(store.loading)
} label: {
Image(systemName: onlyOnline
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
}
}
.refreshable { await store.refresh(api: api) }
.task {
if store.nodes.isEmpty { await store.refresh(api: api) }
}
.alert("Cannot open DM",
isPresented: .init(
get: { alertText != nil },
set: { if !$0 { alertText = nil } }
)) {
Button("OK") { alertText = nil }
} message: {
Text(alertText ?? "")
}
}
static func isOnline(_ node: Node, threshold: TimeInterval) -> Bool {
guard let raw = node.lastHeard, raw > 0 else { return false }
let secs = raw > 9_999_999_999 ? raw / 1000.0 : raw
return Date().timeIntervalSince1970 - secs < threshold
}
}
private struct NodeRow: View {
let node: Node
let onlineThreshold: TimeInterval
var body: some View {
HStack(spacing: 10) {
VStack {
Text("\(node.hopsAway ?? 99)")
.font(.headline.monospacedDigit())
Text("hops").font(.caption2).foregroundStyle(.secondary)
}
.frame(width: 44)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Circle()
.fill(isOnline ? Color.green : Color.gray)
.frame(width: 9, height: 9)
Text(node.displayName).font(.body)
if node.hasPKC == true {
Image(systemName: "key.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
}
HStack(spacing: 6) {
if let id = node.nodeId {
Text(id).font(.caption.monospaced()).foregroundStyle(.secondary)
}
Spacer()
Text(lastHeardText)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 2)
}
private var lastHeardSeconds: TimeInterval? {
guard let raw = node.lastHeard, raw > 0 else { return nil }
let secs = raw > 9_999_999_999 ? raw / 1000.0 : raw
return Date().timeIntervalSince1970 - secs
}
private var isOnline: Bool {
guard let s = lastHeardSeconds else { return false }
return s < onlineThreshold
}
private var lastHeardText: String {
guard let s = lastHeardSeconds else { return "?" }
if s < 60 { return "now" }
if s < 3600 { return "\(Int(s / 60))m" }
if s < 86400 { return "\(Int(s / 3600))h" }
return "\(Int(s / 86400))d"
}
}