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

361 lines
9.9 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct ChatView: View {
let target: ChatTarget
@Environment(APIClient.self) private var api
@Environment(MeshDataStore.self) private var store
@Environment(ReadTracker.self) private var readTracker
private var nodeNames: [String: String] { store.nodeNames }
private var localNodeId: String? { store.localNodeId }
private var localNodeNum: Int? { store.localNodeNum }
@State private var messages: [Message] = []
@State private var seenIds = Set<String>()
@State private var input: String = ""
@State private var sending = false
@State private var loading = false
@State private var error: String?
@State private var toast: String?
@State private var lastTimestamp: Date?
@State private var pollTask: Task<Void, Never>?
@State private var replyTo: (requestId: Int, senderName: String)?
@State private var dmTarget: ChatTarget?
@State private var ready = false
@FocusState private var inputFocused: Bool
private let pollInterval: TimeInterval = 10
var body: some View {
VStack(spacing: 0) {
messageList
Divider()
if let r = replyTo { replyBar(name: r.senderName) }
inputBar
}
.navigationTitle(target.title)
.navigationBarTitleDisplayMode(.inline)
.task { await initialLoad() }
.onDisappear { pollTask?.cancel() }
.navigationDestination(item: $dmTarget) { t in
ChatView(target: t)
}
.alert("Error", isPresented: .init(
get: { error != nil },
set: { if !$0 { error = nil } }
)) {
Button("OK") { error = nil }
} message: {
Text(error ?? "")
}
.overlay(alignment: .top) { toastView }
}
@ViewBuilder
private var messageList: some View {
ZStack {
if !ready {
ProgressView("Loading…")
}
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(messages) { msg in
MessageRow(
message: msg,
senderName: senderName(for: msg),
isSelf: isSelf(msg),
replyToName: replyName(for: msg),
canDMSender: !isSelf(msg) && canDM(msg),
onReply: { startReply(to: msg) },
onReact: { emoji in Task { await react(to: msg, emoji: emoji) } },
onDMSender: { openDM(with: msg) }
)
.id(msg.id)
}
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 28)
}
.defaultScrollAnchor(.bottom)
.onChange(of: messages.count) { scrollToBottom(proxy: proxy, animated: ready) }
.onChange(of: inputFocused) { _, focused in
if focused {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
scrollToBottom(proxy: proxy)
}
}
}
}
.opacity(ready ? 1 : 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.animation(.easeIn(duration: 0.15), value: ready)
}
private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool = true) {
guard let last = messages.last else { return }
if animated {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
} else {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
private func replyBar(name: String) -> some View {
HStack(spacing: 8) {
Image(systemName: "arrowshape.turn.up.left.fill")
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 1) {
Text("Replying to \(name)")
.font(.caption.weight(.semibold))
Text("Tap × to cancel")
.font(.caption2).foregroundStyle(.secondary)
}
Spacer()
Button {
replyTo = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color(.secondarySystemBackground))
}
private var inputBar: some View {
HStack(alignment: .bottom, spacing: 8) {
TextField("Message", text: $input)
.textFieldStyle(.roundedBorder)
.focused($inputFocused)
.disabled(sending)
.submitLabel(.send)
.onSubmit { Task { await sendCurrent() } }
Button {
Task { await sendCurrent() }
} label: {
Image(systemName: sending ? "hourglass" : "paperplane.fill")
.font(.title3)
}
.disabled(sending || input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(8)
.background(.thinMaterial)
}
@ViewBuilder
private var toastView: some View {
if let t = toast {
Text(t)
.font(.footnote)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.thinMaterial, in: Capsule())
.padding(.top, 6)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
private func senderName(for msg: Message) -> String {
if let id = msg.fromNodeId, let n = nodeNames[id] { return n }
if let num = msg.fromNodeNum, let n = nodeNames[String(num)] { return n }
return msg.fromNodeId ?? "?"
}
private func isSelf(_ msg: Message) -> Bool {
if let id = msg.fromNodeId, let local = localNodeId,
Self.normalizeNodeId(id) == Self.normalizeNodeId(local) {
return true
}
if let num = msg.fromNodeNum, let localNum = localNodeNum, num == localNum {
return true
}
return false
}
private static func normalizeNodeId(_ id: String) -> String {
var s = id
if s.hasPrefix("!") { s.removeFirst() }
return s.lowercased()
}
private func canDM(_ msg: Message) -> Bool {
guard case .channel = target, let from = msg.fromNodeId else { return false }
return from != localNodeId
}
private func replyName(for msg: Message) -> String? {
guard let rid = msg.replyId else { return nil }
let target = messages.first { $0.requestIdValue == rid }
guard let t = target else { return nil }
return senderName(for: t)
}
@MainActor
private func initialLoad() async {
loading = true
defer { loading = false }
do {
let msgs = try await fetch(limit: 50, since: nil)
ingest(msgs, replace: true)
markRead()
// Let the ScrollView lay out at its defaultScrollAnchor(.bottom)
// before fading the content in, so no scroll jump is visible.
try? await Task.sleep(for: .milliseconds(80))
ready = true
startPolling()
} catch {
ready = true
self.error = error.localizedDescription
}
}
private func startPolling() {
pollTask?.cancel()
pollTask = Task { @MainActor in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(pollInterval))
if Task.isCancelled { break }
do {
// Poll without `since` so delivery-state updates on
// existing messages (ack / ) are picked up, not just
// brand new ones. Bandwidth is trivial for <=20 msgs.
let msgs = try await fetch(limit: 20, since: nil)
ingest(msgs, replace: false)
if !msgs.isEmpty { markRead() }
} catch {
// Silent fail; keep polling.
}
}
}
}
private func fetch(limit: Int, since: Date?) async throws -> [Message] {
switch target {
case .channel(let id, _):
return try await api.fetchMessages(channel: id, limit: limit, since: since)
case .dm(let nodeId, _):
return try await api.fetchDMMessages(nodeId: nodeId, limit: limit, since: since)
}
}
@MainActor
private func ingest(_ incoming: [Message], replace: Bool) {
let textOnly = incoming.filter { $0.isText && !$0.isReaction }
if replace {
messages.removeAll()
seenIds.removeAll()
}
var added: [Message] = []
for m in textOnly {
if let idx = messages.firstIndex(where: { $0.id == m.id }) {
// Same id already on screen: replace so SwiftUI picks up any
// delivery-state change (pending confirmed / failed).
if hasChanged(messages[idx], m) {
messages[idx] = m
}
} else {
seenIds.insert(m.id)
added.append(m)
}
}
if !added.isEmpty {
messages.append(contentsOf: added)
messages.sort { $0.timestamp < $1.timestamp }
}
if let newest = messages.last?.timestamp {
if lastTimestamp == nil || newest > lastTimestamp! {
lastTimestamp = newest
}
}
}
private func hasChanged(_ a: Message, _ b: Message) -> Bool {
a.deliveryState != b.deliveryState
|| a.ackFailed != b.ackFailed
|| a.text != b.text
}
private func markRead() {
guard case .dm(let nodeId, _) = target,
let last = messages.last?.timestamp else { return }
readTracker.markRead(nodeId, at: last)
}
@MainActor
private func sendCurrent() async {
let text = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
sending = true
defer { sending = false }
let replyId = replyTo?.requestId
do {
try await send(text: text, replyId: replyId)
input = ""
replyTo = nil
inputFocused = true
let msgs = try await fetch(limit: 20, since: nil)
ingest(msgs, replace: false)
} catch APIError.http(let code, _) where code == 413 {
error = "Message too long (max ~600 bytes, 3 parts)."
} catch APIError.http(let code, _) where code == 503 {
error = "Meshtastic node not connected."
} catch {
self.error = error.localizedDescription
}
}
private func send(text: String, replyId: Int?) async throws {
switch target {
case .channel(let id, _):
_ = try await api.sendChannelMessage(text: text, channel: id, replyId: replyId)
case .dm(let nodeId, _):
_ = try await api.sendDM(text: text, toNodeId: nodeId, replyId: replyId)
}
}
// MARK: - Message actions
private func startReply(to msg: Message) {
guard let rid = msg.requestIdValue else {
showToast("No request id on this message.")
return
}
replyTo = (rid, senderName(for: msg))
inputFocused = true
}
@MainActor
private func react(to msg: Message, emoji: String) async {
guard let rid = msg.requestIdValue else {
showToast("No request id on this message.")
return
}
do {
try await send(text: emoji, replyId: rid)
showToast("Reaction sent: \(emoji)")
} catch {
self.error = error.localizedDescription
}
}
private func openDM(with msg: Message) {
guard let from = msg.fromNodeId, from != localNodeId else { return }
let name = senderName(for: msg)
dmTarget = .dm(nodeId: from, name: name)
}
@MainActor
private func showToast(_ text: String) {
withAnimation { toast = text }
Task { @MainActor in
try? await Task.sleep(for: .seconds(2))
withAnimation { if toast == text { toast = nil } }
}
}
}