damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

LoadableNostrEventView.swift (11434B)


      1 //
      2 //  LoadableNostrEventView.swift
      3 //  damus
      4 //
      5 //  Created by Daniel D'Aquino on 2025-01-08.
      6 //
      7 
      8 import SwiftUI
      9 
     10 
     11 /// A view model for `LoadableNostrEventView`
     12 ///
     13 /// This takes a nostr event reference, automatically tries to load it, and updates itself to reflect its current state
     14 ///
     15 /// ## Implementation notes
     16 ///
     17 /// - This is on the main actor because `ObservableObjects` with `Published` properties should be on the main actor for thread-safety.
     18 ///
     19 @MainActor
     20 class LoadableNostrEventViewModel: ObservableObject {
     21     let damus_state: DamusState
     22     let note_reference: NoteReference
     23     @Published var state: ThreadModelLoadingState = .loading
     24     /// The time period after which it will give up loading the view.
     25     /// Written in nanoseconds
     26     let TIMEOUT: UInt64 = 10 * 1_000_000_000    // 10 seconds
     27     
     28     init(damus_state: DamusState, note_reference: NoteReference) {
     29         self.damus_state = damus_state
     30         self.note_reference = note_reference
     31         Task { await self.load() }
     32     }
     33     
     34     func load() async {
     35         // Start the loading process in a separate task to manage the timeout independently.
     36         let loadTask = Task { @MainActor in
     37             self.state = await executeLoadingLogic(note_reference: self.note_reference)
     38         }
     39 
     40         // Setup a timer to cancel the load after the timeout period
     41         let timeoutTask = Task { @MainActor in
     42             try await Task.sleep(nanoseconds: TIMEOUT)
     43             loadTask.cancel() // This sends a cancellation signal to the load task.
     44             self.state = .not_found
     45         }
     46         
     47         await loadTask.value
     48         timeoutTask.cancel() // Cancel the timeout task if loading finishes earlier.
     49     }
     50     
     51     /// Asynchronously find an event from NostrDB or from the network (if not available on NostrDB)
     52     private func loadEvent(noteId: NoteId) async -> NostrEvent? {
     53         let res = await find_event(state: damus_state, query: .event(evid: noteId))
     54         guard let res, case .event(let ev) = res else { return nil }
     55         return ev
     56     }
     57     
     58     /// Gets the note reference and tries to load it, outputting a new state for this view model.
     59     private func executeLoadingLogic(note_reference: NoteReference) async -> ThreadModelLoadingState {
     60         switch note_reference {
     61         case .note_id(let note_id):
     62             guard let ev = await self.loadEvent(noteId: note_id) else { return .not_found }
     63             guard let known_kind = ev.known_kind else { return .unknown_or_unsupported_kind }
     64             switch known_kind {
     65             case .text, .highlight:
     66                 return .loaded(route: Route.Thread(thread: ThreadModel(event: ev, damus_state: damus_state)))
     67             case .dm:
     68                 let dm_model = damus_state.dms.lookup_or_create(ev.pubkey)
     69                 return .loaded(route: Route.DMChat(dms: dm_model))
     70             case .like:
     71                 // Load the event that this reaction refers to.
     72                 guard let first_referenced_note_id = ev.referenced_ids.first else { return .not_found }
     73                 return await self.executeLoadingLogic(note_reference: .note_id(first_referenced_note_id))
     74             case .zap, .zap_request:
     75                 guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
     76                 return .loaded(route: Route.Zaps(target: zap.target))
     77             case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .zap, .zap_request, .nwc_request, .nwc_response, .http_auth, .status:
     78                 return .unknown_or_unsupported_kind
     79             }
     80         case .naddr(let naddr):
     81             guard let event = await naddrLookup(damus_state: damus_state, naddr: naddr) else { return .not_found }
     82             return .loaded(route: Route.Thread(thread: ThreadModel(event: event, damus_state: damus_state)))
     83         }
     84     }
     85     
     86     enum ThreadModelLoadingState {
     87         case loading
     88         case loaded(route: Route)
     89         case not_found
     90         case unknown_or_unsupported_kind
     91     }
     92     
     93     enum NoteReference: Hashable {
     94         case note_id(NoteId)
     95         case naddr(NAddr)
     96     }
     97 }
     98 
     99 /// A view for a Nostr event that has not been loaded yet.
    100 /// This takes a Nostr event reference and loads it, while providing nice loading UX and graceful error handling.
    101 struct LoadableNostrEventView: View {
    102     let state: DamusState
    103     @StateObject var loadableModel: LoadableNostrEventViewModel
    104     var loading: Bool {
    105         switch loadableModel.state {
    106         case .loading:
    107             return true
    108         case .loaded, .not_found, .unknown_or_unsupported_kind:
    109             return false
    110         }
    111     }
    112     
    113     init(state: DamusState, note_reference: LoadableNostrEventViewModel.NoteReference) {
    114         self.state = state
    115         self._loadableModel = StateObject.init(wrappedValue: LoadableNostrEventViewModel(damus_state: state, note_reference: note_reference))
    116     }
    117     
    118     var body: some View {
    119         switch self.loadableModel.state {
    120         case .loading:
    121             ScrollView(.vertical) {
    122                 self.skeleton
    123                     .redacted(reason: loading ? .placeholder : [])
    124                     .shimmer(loading)
    125                     .accessibilityElement(children: .ignore)
    126                     .accessibilityLabel(NSLocalizedString("Loading thread", comment: "Accessibility label for the thread view when it is loading"))
    127             }
    128         case .loaded(route: let route):
    129             route.view(navigationCoordinator: state.nav, damusState: state)
    130         case .not_found:
    131             self.not_found
    132         case .unknown_or_unsupported_kind:
    133             self.unknown_or_unsupported_kind
    134         }
    135     }
    136     
    137     var not_found: some View {
    138         SomethingWrong(
    139             imageSystemName: "questionmark.app",
    140             heading: NSLocalizedString("Note not found", comment: "Heading for the thread view in a not found error state."),
    141             description: NSLocalizedString("We were unable to find the note you were looking for.", comment: "Text for the thread view when it is unable to find the note the user is looking for"),
    142             advice: NSLocalizedString("Try checking the link again, your internet connection, or contact the person who provided you the link for help.", comment: "Tips on what to do if a note cannot be found.")
    143         )
    144     }
    145     
    146     var unknown_or_unsupported_kind: some View {
    147         SomethingWrong(
    148             imageSystemName: "questionmark.app",
    149             heading: NSLocalizedString("Can’t display note", comment: "User-visible heading for an error message indicating a note has an unknown kind or is unsupported for viewing."),
    150             description: NSLocalizedString("We do not yet support viewing this type of content.", comment: "User-visible description of an error indicating a note has an unknown kind or is unsupported for viewing."),
    151             advice: NSLocalizedString("Please try opening this content on another Nostr app that supports this type of content.", comment: "User-visible advice on what to do if they see the error indicating a note has an unknown kind or is unsupported for viewing.")
    152         )
    153     }
    154     
    155     // MARK: Skeleton views
    156     // Implementation notes
    157     // - No localization is needed because the text will be redacted
    158     // - No accessibility label is needed because these will be summarized into a single accessibility label at the top-level view. See `body` in this struct
    159     
    160     var skeleton: some View {
    161         VStack(alignment: .leading, spacing: 40) {
    162             Self.skeleton_selected_event
    163             Self.skeleton_chat_event(message: "Nice! Have you tried Damus?", right: false)
    164             Self.skeleton_chat_event(message: "Yes, it's awesome.", right: true)
    165             Spacer()
    166         }
    167         .padding()
    168     }
    169     
    170     static func skeleton_chat_event(message: String, right: Bool) -> some View {
    171         HStack(alignment: .center) {
    172             if !right {
    173                 self.skeleton_chat_user_avatar
    174             }
    175             else {
    176                 Spacer()
    177             }
    178             ChatBubble(
    179                 direction: right ? .right : .left,
    180                 stroke_content: Color.accentColor.opacity(0),
    181                 stroke_style: .init(lineWidth: 4),
    182                 background_style: Color.secondary.opacity(0.5),
    183                 content: {
    184                     Text(verbatim: message)
    185                         .padding()
    186                 }
    187             )
    188             if right {
    189                 self.skeleton_chat_user_avatar
    190             }
    191             else {
    192                 Spacer()
    193             }
    194         }
    195     }
    196     
    197     static var skeleton_selected_event: some View {
    198         VStack(alignment: .leading, spacing: 10) {
    199             HStack {
    200                 Circle()
    201                     .frame(width: 50, height: 50)
    202                     .foregroundStyle(.secondary.opacity(0.5))
    203                 Text(verbatim: "Satoshi Nakamoto")
    204                     .bold()
    205             }
    206             Text(verbatim: "Nostr is the super app. Because it’s actually an ecosystem of apps, all of which make each other better. People haven’t grasped that yet. They will when it’s more accessible and onboarding is more straightforward and intuitive.")
    207             HStack {
    208                 self.skeleton_action_item
    209                 Spacer()
    210                 self.skeleton_action_item
    211                 Spacer()
    212                 self.skeleton_action_item
    213                 Spacer()
    214                 self.skeleton_action_item
    215             }
    216         }
    217     }
    218     
    219     static var skeleton_chat_user_avatar: some View {
    220         Circle()
    221             .fill(.secondary.opacity(0.5))
    222             .frame(width: 35, height: 35)
    223             .padding(.bottom, -21)
    224     }
    225     
    226     static var skeleton_action_item: some View {
    227         Circle()
    228             .fill(Color.secondary.opacity(0.5))
    229             .frame(width: 25, height: 25)
    230     }
    231 }
    232 
    233 extension LoadableNostrEventView {
    234     struct SomethingWrong: View {
    235         let imageSystemName: String
    236         let heading: String
    237         let description: String
    238         let advice: String
    239         
    240         var body: some View {
    241             VStack(spacing: 6) {
    242                 Image(systemName: imageSystemName)
    243                     .resizable()
    244                     .frame(width: 30, height: 30)
    245                     .accessibilityHidden(true)
    246                 Text(heading)
    247                     .font(.title)
    248                     .bold()
    249                     .padding(.bottom, 10)
    250                 Text(description)
    251                     .multilineTextAlignment(.center)
    252                     .foregroundStyle(.secondary)
    253                 
    254                 VStack(alignment: .leading, spacing: 6) {
    255                     HStack(spacing: 5) {
    256                         Image(systemName: "sparkles")
    257                             .accessibilityHidden(true)
    258                         Text("Advice", comment: "Heading for some advice text to help the user with an error")
    259                             .font(.headline)
    260                     }
    261                     Text(advice)
    262                 }
    263                 .padding()
    264                 .background(Color.secondary.opacity(0.2))
    265                 .cornerRadius(10)
    266                 .padding(.vertical, 30)
    267             }
    268             .padding()
    269         }
    270     }
    271 }
    272 
    273 #Preview("Loadable") {
    274     LoadableNostrEventView(state: test_damus_state, note_reference: .note_id(test_thread_note_1.id))
    275 }