PostFeedViewModel.swift (4514B)
1 import SwiftUI 2 3 @MainActor 4 final class PostFeedViewModel: ObservableObject { 5 @Published var posts: [NostrPostEventMetadata] = [] 6 @Published var isLoading = false 7 @Published var errorMessage: String? 8 @Published var expandedReplyFor: String? 9 @Published var draftReplies: [String: String] = [:] 10 @Published var sendingReplyFor: Set<String> = [] 11 12 private var liveTask: Task<Void, Never>? 13 14 func onAppear(app: AppState) { 15 if posts.isEmpty { 16 Task { await load(app: app) } 17 } 18 startStream(app: app) 19 } 20 21 func onDisappear(app: AppState) { 22 liveTask?.cancel() 23 liveTask = nil 24 Task { try? await app.runtimeService?.nostrStopPostStream() } 25 } 26 27 func load(app: AppState) async { 28 guard let service = app.runtimeService else { return } 29 isLoading = true 30 errorMessage = nil 31 do { 32 let fetched = try await service.nostrFetchTextNotes(limit: 50, sinceUnix: nil) 33 posts = fetched.sorted { $0.publishedAt > $1.publishedAt } 34 isLoading = false 35 } catch { 36 errorMessage = error.fieldRuntimeMessage 37 isLoading = false 38 } 39 } 40 41 func refresh(app: AppState) async { 42 await load(app: app) 43 } 44 45 func toggleReply(for id: String) { 46 expandedReplyFor = expandedReplyFor == id ? nil : id 47 } 48 49 func bindingForReply(_ id: String) -> Binding<String> { 50 Binding( 51 get: { self.draftReplies[id, default: ""] }, 52 set: { self.draftReplies[id] = $0 } 53 ) 54 } 55 56 func sendReply( 57 app: AppState, 58 to post: NostrPostEventMetadata, 59 setResult: @escaping @MainActor @Sendable (_ title: String, _ message: String) -> Void 60 ) { 61 guard let service = app.runtimeService else { return } 62 let raw = draftReplies[post.id, default: ""] 63 let reply = raw.trimmingCharacters(in: .whitespacesAndNewlines) 64 guard !reply.isEmpty else { return } 65 sendingReplyFor.insert(post.id) 66 67 Task { @MainActor in 68 let parentId = post.id 69 let parentAuthor = post.author 70 let text = reply 71 72 do { 73 let id = try await service.nostrPostReply( 74 parentEventIdHex: parentId, 75 parentAuthorHex: parentAuthor, 76 content: text, 77 rootEventIdHex: nil as String? 78 ) 79 draftReplies[parentId] = "" 80 expandedReplyFor = nil 81 sendingReplyFor.remove(parentId) 82 setResult("Reply Posted", "Event \(id.rawValue)") 83 } catch { 84 sendingReplyFor.remove(parentId) 85 setResult("Failed to Post Reply", error.fieldRuntimeMessage) 86 } 87 } 88 } 89 90 91 private func startStream(app: AppState) { 92 guard liveTask == nil else { return } 93 liveTask = Task { @MainActor [weak self] in 94 guard let self else { return } 95 guard let service = app.runtimeService else { return } 96 var knownIds = Set(posts.map(\.id)) 97 let since = posts.map(\.publishedAt).max() 98 do { 99 try await service.nostrStartPostStream(sinceUnix: since) 100 } catch { 101 errorMessage = error.fieldRuntimeMessage 102 } 103 104 while !Task.isCancelled { 105 if app.relayConnectedCount == 0 { 106 try? await Task.sleep(for: .seconds(1)) 107 continue 108 } 109 110 if knownIds.count != posts.count { 111 knownIds = Set(posts.map(\.id)) 112 } 113 114 do { 115 if let event = try await service.nostrNextPostStreamEvent() { 116 if knownIds.insert(event.id).inserted { 117 posts.insert(event, at: 0) 118 posts.sort { $0.publishedAt > $1.publishedAt } 119 if posts.count > 200 { 120 posts = Array(posts.prefix(200)) 121 } 122 } 123 } else { 124 try? await Task.sleep(for: .milliseconds(300)) 125 } 126 } catch { 127 errorMessage = error.fieldRuntimeMessage 128 try? await Task.sleep(for: .milliseconds(300)) 129 } 130 } 131 } 132 } 133 }