field_ios

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

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 }