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.
361 lines
9.9 KiB
Swift
361 lines
9.9 KiB
Swift
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 } }
|
||
}
|
||
}
|
||
}
|