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() @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? @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 } } } } }