field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | LICENSE

commit 1d70711d6ca55c959efa53da08454a2eca3bf1bc
parent d95083797276a0b3061f3ff7ea941f7a48cf10a9
Author: triesap <triesap@radroots.dev>
Date:   Mon,  6 Oct 2025 18:18:01 +0100

Refactor post feed view model with main-actor–bound async tasks, detached concurrency for replies and live updates, and structured result handling. Update settings view with a copyable npub field and removal of redundant state.

Diffstat:
MRadroots/Views/PostFeedViewModel.swift | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
MRadroots/Views/SettingsView.swift | 13++-----------
2 files changed, 62 insertions(+), 37 deletions(-)

diff --git a/Radroots/Views/PostFeedViewModel.swift b/Radroots/Views/PostFeedViewModel.swift @@ -13,7 +13,9 @@ final class PostFeedViewModel: ObservableObject { private var liveTask: Task<Void, Never>? func onAppear(app: AppState) { - if posts.isEmpty { Task { await load(app: app) } } + if posts.isEmpty { + Task { await load(app: app) } + } startLiveLoop(app: app) } @@ -54,7 +56,7 @@ final class PostFeedViewModel: ObservableObject { func sendReply( app: AppState, to post: NostrPostEventMetadata, - setResult: @escaping (_ title: String, _ message: String) -> Void + setResult: @escaping @MainActor @Sendable (_ title: String, _ message: String) -> Void ) { guard let rt = app.radroots.runtime else { return } let raw = draftReplies[post.id, default: ""] @@ -62,31 +64,47 @@ final class PostFeedViewModel: ObservableObject { guard !reply.isEmpty else { return } sendingReplyFor.insert(post.id) - Task { - do { - let id = try rt.nostrPostReply( - parentEventIdHex: post.id, - parentAuthorHex: post.author, - content: reply, - rootEventIdHex: nil as String? - ) - draftReplies[post.id] = "" + Task { @MainActor in + let runtime = rt + let parentId = post.id + let parentAuthor = post.author + let text = reply + + let result: Result<String, Error> = await Task.detached { @Sendable in + do { + let id = try runtime.nostrPostReply( + parentEventIdHex: parentId, + parentAuthorHex: parentAuthor, + content: text, + rootEventIdHex: nil as String? + ) + return .success(id) + } catch { + return .failure(error) + } + }.value + + switch result { + case .success(let id): + draftReplies[parentId] = "" expandedReplyFor = nil - sendingReplyFor.remove(post.id) + sendingReplyFor.remove(parentId) setResult("Reply Posted", "Event \(id)") - } catch { - sendingReplyFor.remove(post.id) - setResult("Failed to Post Reply", String(describing: error)) + case .failure(let e): + sendingReplyFor.remove(parentId) + setResult("Failed to Post Reply", String(describing: e)) } } } + private func startLiveLoop(app: AppState) { guard liveTask == nil else { return } - liveTask = Task { [weak self] in + liveTask = Task { @MainActor [weak self] in guard let self else { return } - var known = Set(posts.map { $0.id }) - var since: UInt64? = posts.first?.publishedAt + var knownIds = Set(posts.map(\.id)) + var since = posts.map(\.publishedAt).max() + while !Task.isCancelled { if app.relayConnectedCount == 0 { try? await Task.sleep(for: .seconds(2)) @@ -96,18 +114,34 @@ final class PostFeedViewModel: ObservableObject { try? await Task.sleep(for: .seconds(2)) continue } - do { - let fetched = try rt.nostrFetchTextNotes(limit: 50, sinceUnix: since) - let newOnes = fetched.filter { !known.contains($0.id) } + + let currentSince = since + let fetchResult: Result<[NostrPostEventMetadata], Error> = await Task.detached { @Sendable in + do { + let items = try rt.nostrFetchTextNotes(limit: 50, sinceUnix: currentSince) + return .success(items) + } catch { + return .failure(error) + } + }.value + + if Task.isCancelled { break } + + switch fetchResult { + case .failure: + break + case .success(let fetched): + let newOnes = fetched.filter { !knownIds.contains($0.id) } if !newOnes.isEmpty { - known.formUnion(newOnes.map { $0.id }) - let combined = (newOnes + posts).sorted { $0.publishedAt > $1.publishedAt } - posts = combined - if let m = newOnes.map(\.publishedAt).max() { + knownIds.formUnion(newOnes.map(\.id)) + let maxTs = newOnes.map(\.publishedAt).max() + posts = (newOnes + posts).sorted { $0.publishedAt > $1.publishedAt } + if let m = maxTs { since = max(since ?? 0, m) } } - } catch { } + } + try? await Task.sleep(for: .seconds(3)) } } diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift @@ -4,22 +4,13 @@ import RadrootsKit struct SettingsView: View { @EnvironmentObject private var app: AppState @EnvironmentObject private var radroots: Radroots - @EnvironmentObject private var keys: RadrootsKeys @State private var exportError: String? var body: some View { List { Section("Account") { if let npub = app.npub { - HStack { - Text("npub") - Spacer() - Text(npub) - .font(.callout.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } + CopyRow(title: "npub", value: npub) } else { Text("No key configured") .foregroundStyle(.secondary) @@ -28,7 +19,7 @@ struct SettingsView: View { if app.hasKey { Section { - Button(role: .none) { + Button { exportSecretHex() } label: { Label("Export Secret Hex (Danger)", systemImage: "square.and.arrow.up")