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 }