damus

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

LoadableThreadView.swift (7877B)


      1 //
      2 //  LoadableThreadView.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 `LoadableThreadView`
     12 ///
     13 /// This takes a note reference, automatically tries to load it, and updates itself to reflect its current state
     14 ///
     15 ///
     16 class LoadableThreadModel: ObservableObject {
     17     let damus_state: DamusState
     18     let note_reference: NoteReference
     19     @Published var state: ThreadModelLoadingState = .loading
     20     /// The time period after which it will give up loading the view.
     21     /// Written in nanoseconds
     22     let TIMEOUT: UInt64 = 10 * 1_000_000_000    // 10 seconds
     23     
     24     init(damus_state: DamusState, note_reference: NoteReference) {
     25         self.damus_state = damus_state
     26         self.note_reference = note_reference
     27         Task { await self.load() }
     28     }
     29     
     30     func load() async {
     31         // Start the loading process in a separate task to manage the timeout independently.
     32         let loadTask = Task { @MainActor in
     33             self.state = await executeLoadingLogic()
     34         }
     35 
     36         // Setup a timer to cancel the load after the timeout period
     37         let timeoutTask = Task { @MainActor in
     38             try await Task.sleep(nanoseconds: TIMEOUT)
     39             loadTask.cancel() // This sends a cancellation signal to the load task.
     40             self.state = .not_found
     41         }
     42         
     43         await loadTask.value
     44         timeoutTask.cancel() // Cancel the timeout task if loading finishes earlier.
     45     }
     46     
     47     private func executeLoadingLogic() async -> ThreadModelLoadingState {
     48         switch note_reference {
     49         case .note_id(let note_id):
     50             let res = await find_event(state: damus_state, query: .event(evid: note_id))
     51             guard let res, case .event(let ev) = res else { return .not_found }
     52             return .loaded(model: await ThreadModel(event: ev, damus_state: damus_state))
     53         case .naddr(let naddr):
     54             guard let event = await naddrLookup(damus_state: damus_state, naddr: naddr) else { return .not_found }
     55             return .loaded(model: await ThreadModel(event: event, damus_state: damus_state))
     56         }
     57     }
     58     
     59     enum ThreadModelLoadingState {
     60         case loading
     61         case loaded(model: ThreadModel)
     62         case not_found
     63     }
     64     
     65     enum NoteReference: Hashable {
     66         case note_id(NoteId)
     67         case naddr(NAddr)
     68     }
     69 }
     70 
     71 struct LoadableThreadView: View {
     72     let state: DamusState
     73     @StateObject var loadable_thread: LoadableThreadModel
     74     var loading: Bool {
     75         switch loadable_thread.state {
     76         case .loading:
     77             return true
     78         case .loaded, .not_found:
     79             return false
     80         }
     81     }
     82     
     83     init(state: DamusState, note_reference: LoadableThreadModel.NoteReference) {
     84         self.state = state
     85         self._loadable_thread = StateObject.init(wrappedValue: LoadableThreadModel(damus_state: state, note_reference: note_reference))
     86     }
     87     
     88     var body: some View {
     89         switch self.loadable_thread.state {
     90         case .loading:
     91             ScrollView(.vertical) {
     92                 self.skeleton
     93                     .redacted(reason: loading ? .placeholder : [])
     94                     .shimmer(loading)
     95                     .accessibilityElement(children: .ignore)
     96                     .accessibilityLabel(NSLocalizedString("Loading thread", comment: "Accessibility label for the thread view when it is loading"))
     97             }
     98         case .loaded(model: let thread_model):
     99             ChatroomThreadView(damus: state, thread: thread_model)
    100         case .not_found:
    101             self.not_found
    102         }
    103     }
    104     
    105     var not_found: some View {
    106         VStack(spacing: 6) {
    107             Image(systemName: "questionmark.app")
    108                 .resizable()
    109                 .frame(width: 30, height: 30)
    110                 .accessibilityHidden(true)
    111             Text("Note not found", comment: "Heading for the thread view in a not found error state")
    112                 .font(.title)
    113                 .bold()
    114                 .padding(.bottom, 10)
    115             Text("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")
    116                 .multilineTextAlignment(.center)
    117                 .foregroundStyle(.secondary)
    118             
    119             VStack(alignment: .leading, spacing: 6) {
    120                 HStack(spacing: 5) {
    121                     Image(systemName: "sparkles")
    122                         .accessibilityHidden(true)
    123                     Text("Advice", comment: "Heading for some advice text to help the user with an error")
    124                         .font(.headline)
    125                 }
    126                 Text("Try checking the link again, your internet connection, whether you need to connect to a specific relay to access this content.", comment: "Tips on what to do if a note cannot be found.")
    127             }
    128             .padding()
    129             .background(Color.secondary.opacity(0.2))
    130             .cornerRadius(10)
    131             .padding(.vertical, 30)
    132         }
    133         .padding()
    134     }
    135     
    136     // MARK: Skeleton views
    137     // Implementation notes
    138     // - No localization is needed because the text will be redacted
    139     // - 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
    140     
    141     var skeleton: some View {
    142         VStack(alignment: .leading, spacing: 40) {
    143             self.skeleton_selected_event
    144             self.skeleton_chat_event(message: "Nice! Have you tried Damus?", right: false)
    145             self.skeleton_chat_event(message: "Yes, it's awesome.", right: true)
    146             Spacer()
    147         }
    148         .padding()
    149     }
    150     
    151     func skeleton_chat_event(message: String, right: Bool) -> some View {
    152         HStack(alignment: .center) {
    153             if !right {
    154                 self.skeleton_chat_user_avatar
    155             }
    156             else {
    157                 Spacer()
    158             }
    159             ChatBubble(
    160                 direction: right ? .right : .left,
    161                 stroke_content: Color.accentColor.opacity(0),
    162                 stroke_style: .init(lineWidth: 4),
    163                 background_style: Color.secondary.opacity(0.5),
    164                 content: {
    165                     Text(message)
    166                         .padding()
    167                 }
    168             )
    169             if right {
    170                 self.skeleton_chat_user_avatar
    171             }
    172             else {
    173                 Spacer()
    174             }
    175         }
    176     }
    177     
    178     var skeleton_selected_event: some View {
    179         VStack(alignment: .leading, spacing: 10) {
    180             HStack {
    181                 Circle()
    182                     .frame(width: 50, height: 50)
    183                     .foregroundStyle(.secondary.opacity(0.5))
    184                 Text("Satoshi Nakamoto")
    185                     .bold()
    186             }
    187             Text("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.")
    188             HStack {
    189                 self.skeleton_action_item
    190                 Spacer()
    191                 self.skeleton_action_item
    192                 Spacer()
    193                 self.skeleton_action_item
    194                 Spacer()
    195                 self.skeleton_action_item
    196             }
    197         }
    198     }
    199     
    200     var skeleton_chat_user_avatar: some View {
    201         Circle()
    202             .fill(.secondary.opacity(0.5))
    203             .frame(width: 35, height: 35)
    204             .padding(.bottom, -21)
    205     }
    206     
    207     var skeleton_action_item: some View {
    208         Circle()
    209             .fill(Color.secondary.opacity(0.5))
    210             .frame(width: 25, height: 25)
    211     }
    212 }
    213 
    214 #Preview("Loadable") {
    215     LoadableThreadView(state: test_damus_state, note_reference: .note_id(test_thread_note_1.id))
    216 }