field_ios

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

commit d95083797276a0b3061f3ff7ea941f7a48cf10a9
parent ccdbdcf88355d226de8e32fbad70da1e504cb2f9
Author: triesap <triesap@radroots.dev>
Date:   Mon,  6 Oct 2025 15:18:10 +0100

Refactor post feed to use a view model for state, loading, and live updates. Add model handling async fetch, replies, and refresh loop.

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 4++++
MRadroots/Views/PostFeedView.swift | 111+++++++++++++++++--------------------------------------------------------------
MRadroots/Views/PostFeedViewModel.swift | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 142 insertions(+), 88 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */; }; 360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D448C9655B708CA3FA8712B9 /* AppEntry.swift */; }; 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */; }; + 5AECD474FB2F91855BDD79C0 /* PostFeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */; }; 7FD8FB018DA09568303194B2 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE61264E2C98E73828E8680 /* Strings.swift */; }; 8B923F78BF5B680C7F6A7CE3 /* PostFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE0EB327C10171444553378 /* PostFeedView.swift */; }; 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C7DE4207398DE242519F9C /* CopyButton.swift */; }; @@ -60,6 +61,7 @@ D448C9655B708CA3FA8712B9 /* AppEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEntry.swift; sourceTree = "<group>"; }; E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugDump.swift; sourceTree = "<group>"; }; + F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedViewModel.swift; sourceTree = "<group>"; }; F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostDetailView.swift; sourceTree = "<group>"; }; F4C7DE4207398DE242519F9C /* CopyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyButton.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -163,6 +165,7 @@ CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */, F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */, 9AE0EB327C10171444553378 /* PostFeedView.swift */, + F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */, C71A93F98C7B93188748B99B /* ProfileView.swift */, C1D9496F9F05A4E79E73A247 /* RelaysView.swift */, E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */, @@ -306,6 +309,7 @@ F32EFF00A8A852F76657FEE1 /* PostCreateView.swift in Sources */, EB7C19F62D7DAB9C044D53AA /* PostDetailView.swift in Sources */, 8B923F78BF5B680C7F6A7CE3 /* PostFeedView.swift in Sources */, + 5AECD474FB2F91855BDD79C0 /* PostFeedViewModel.swift in Sources */, 275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */, 022DA21729F49893319717AA /* RelaysView.swift in Sources */, A1B921027DA7ACD7343BE250 /* SectionWideButton.swift in Sources */, diff --git a/Radroots/Views/PostFeedView.swift b/Radroots/Views/PostFeedView.swift @@ -3,19 +3,14 @@ import RadrootsKit struct PostFeedView: View { @EnvironmentObject private var app: AppState - @State private var posts: [NostrPostEventMetadata] = [] - @State private var isLoading = false - @State private var errorMessage: String? - @State private var expandedReplyFor: String? - @State private var draftReplies: [String: String] = [:] - @State private var sendingReplyFor: Set<String> = [] + @StateObject private var vm = PostFeedViewModel() @State private var resultTitle: String = "" @State private var resultMessage: String = "" @State private var showResult: Bool = false var body: some View { List { - if let e = errorMessage { + if let e = vm.errorMessage { Section { Text(e) .foregroundStyle(.red) @@ -24,20 +19,26 @@ struct PostFeedView: View { } Section { - ForEach(posts, id: \.id) { item in + ForEach(vm.posts, id: \.id) { item in FeedPostRow( post: item, - isExpanded: expandedReplyFor == item.id, - onToggleReply: { toggleReply(for: item.id) } + isExpanded: vm.expandedReplyFor == item.id, + onToggleReply: { vm.toggleReply(for: item.id) } ) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - if expandedReplyFor == item.id { + if vm.expandedReplyFor == item.id { InlineReplyComposer( - text: bindingForReply(item.id), - isSending: sendingReplyFor.contains(item.id), - onCancel: { expandedReplyFor = nil }, - onSend: { handleReply(item) } + text: vm.bindingForReply(item.id), + isSending: vm.sendingReplyFor.contains(item.id), + onCancel: { vm.expandedReplyFor = nil }, + onSend: { + vm.sendReply(app: app, to: item) { title, message in + resultTitle = title + resultMessage = message + showResult = true + } + } ) .listRowInsets(EdgeInsets(top: 6, leading: 64, bottom: 12, trailing: 16)) .listRowSeparator(.hidden) @@ -53,91 +54,24 @@ struct PostFeedView: View { .inlineNavigationTitle("Feed") .toolbar { ToolbarItem(placement: .topBarTrailing) { - if isLoading { ProgressView() } + if vm.isLoading { ProgressView() } } ToolbarItem(placement: .topBarTrailing) { - Button { Task { await load() } } label: { + Button { Task { await vm.refresh(app: app) } } label: { Image(systemName: "arrow.clockwise") } - .disabled(isLoading) + .disabled(vm.isLoading) } } - .task { if posts.isEmpty { await load() } } - .refreshable { await load() } + .task { vm.onAppear(app: app) } + .onDisappear { vm.onDisappear() } + .refreshable { await vm.refresh(app: app) } .alert(resultTitle, isPresented: $showResult) { Button("OK", role: .cancel) { } } message: { Text(resultMessage) } } - - private func load() async { - guard let rt = app.radroots.runtime else { return } - await MainActor.run { - isLoading = true - errorMessage = nil - } - do { - let fetched = try rt.nostrFetchTextNotes(limit: 50, sinceUnix: nil) - let sorted = fetched.sorted { $0.publishedAt > $1.publishedAt } - DebugDump.posts(sorted, label: "PostFeed.kind1.displayed") - await MainActor.run { - posts = sorted - isLoading = false - } - } catch { - await MainActor.run { - errorMessage = String(describing: error) - isLoading = false - } - } - } - - private func toggleReply(for id: String) { - expandedReplyFor = expandedReplyFor == id ? nil : id - } - - private func bindingForReply(_ id: String) -> Binding<String> { - Binding<String>( - get: { draftReplies[id, default: ""] }, - set: { draftReplies[id] = $0 } - ) - } - - private func handleReply(_ post: NostrPostEventMetadata) { - guard let rt = app.radroots.runtime else { return } - let raw = draftReplies[post.id, default: ""] - let reply = raw.trimmingCharacters(in: .whitespacesAndNewlines) - 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? - ) - await MainActor.run { - draftReplies[post.id] = "" - expandedReplyFor = nil - sendingReplyFor.remove(post.id) - resultTitle = "Reply Posted" - resultMessage = "Event \(id)" - showResult = true - app.refresh() - } - } catch { - await MainActor.run { - sendingReplyFor.remove(post.id) - resultTitle = "Failed to Post Reply" - resultMessage = String(describing: error) - showResult = true - } - } - } - } } fileprivate struct FeedPostRow: View { @@ -197,6 +131,7 @@ fileprivate struct FeedPostRow: View { } } + fileprivate struct InlineReplyComposer: View { @Binding var text: String let isSending: Bool diff --git a/Radroots/Views/PostFeedViewModel.swift b/Radroots/Views/PostFeedViewModel.swift @@ -0,0 +1,115 @@ +import SwiftUI +import RadrootsKit + +@MainActor +final class PostFeedViewModel: ObservableObject { + @Published var posts: [NostrPostEventMetadata] = [] + @Published var isLoading = false + @Published var errorMessage: String? + @Published var expandedReplyFor: String? + @Published var draftReplies: [String: String] = [:] + @Published var sendingReplyFor: Set<String> = [] + + private var liveTask: Task<Void, Never>? + + func onAppear(app: AppState) { + if posts.isEmpty { Task { await load(app: app) } } + startLiveLoop(app: app) + } + + func onDisappear() { + liveTask?.cancel() + liveTask = nil + } + + func load(app: AppState) async { + guard let rt = app.radroots.runtime else { return } + isLoading = true + errorMessage = nil + do { + let fetched = try rt.nostrFetchTextNotes(limit: 50, sinceUnix: nil) + posts = fetched.sorted { $0.publishedAt > $1.publishedAt } + isLoading = false + } catch { + errorMessage = String(describing: error) + isLoading = false + } + } + + func refresh(app: AppState) async { + await load(app: app) + } + + func toggleReply(for id: String) { + expandedReplyFor = expandedReplyFor == id ? nil : id + } + + func bindingForReply(_ id: String) -> Binding<String> { + Binding( + get: { self.draftReplies[id, default: ""] }, + set: { self.draftReplies[id] = $0 } + ) + } + + func sendReply( + app: AppState, + to post: NostrPostEventMetadata, + setResult: @escaping (_ title: String, _ message: String) -> Void + ) { + guard let rt = app.radroots.runtime else { return } + let raw = draftReplies[post.id, default: ""] + let reply = raw.trimmingCharacters(in: .whitespacesAndNewlines) + 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] = "" + expandedReplyFor = nil + sendingReplyFor.remove(post.id) + setResult("Reply Posted", "Event \(id)") + } catch { + sendingReplyFor.remove(post.id) + setResult("Failed to Post Reply", String(describing: error)) + } + } + } + + private func startLiveLoop(app: AppState) { + guard liveTask == nil else { return } + liveTask = Task { [weak self] in + guard let self else { return } + var known = Set(posts.map { $0.id }) + var since: UInt64? = posts.first?.publishedAt + while !Task.isCancelled { + if app.relayConnectedCount == 0 { + try? await Task.sleep(for: .seconds(2)) + continue + } + guard let rt = app.radroots.runtime else { + 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) } + 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() { + since = max(since ?? 0, m) + } + } + } catch { } + try? await Task.sleep(for: .seconds(3)) + } + } + } +}