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:
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))
+ }
+ }
+ }
+}