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.
149 lines
4.1 KiB
Swift
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"
|
|
}
|
|
}
|