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 }