ThreadModel.swift (16270B)
1 // 2 // ThreadModel.swift 3 // damus 4 // 5 // Created by William Casarin on 2022-04-19. 6 // 7 8 import Foundation 9 10 /// manages the lifetime of a thread in a thread view such as `ChatroomThreadView` 11 /// Makes a subscription to the relay pool to get events related to the thread 12 /// It also keeps track of a selected event in the thread, and can pinpoint all of its parents and reply chain 13 @MainActor 14 class ThreadModel: ObservableObject { 15 /// The original event where this thread was loaded from 16 /// We use this to know the starting point from which we try to load the rest of the thread 17 /// This is immutable because this is our starting point of the thread, and we don't expect this to ever change during the lifetime of a thread view 18 let original_event: NostrEvent 19 /// A map of events, the reply chain, etc 20 /// This can be read by the view, but it can only be updated internally, because it is this classes' responsibility to ensure we load the proper events 21 @Published private(set) var event_map: ThreadEventMap 22 /// The currently selected event 23 /// Can only be directly changed internally. Views should set this via the `select` methods 24 @Published private(set) var selected_event: NostrEvent 25 26 /// All of the parent events of `selected_event` in the thread, sorted from the highest level in the thread (The root of the thread), down to the direct parent 27 /// 28 /// ## Implementation notes 29 /// 30 /// This is a computed property because we then don't need to worry about keeping things in sync 31 var parent_events: [NostrEvent] { 32 // This block of code helps ensure `ThreadEventMap` stays in sync with `EventCache` 33 let parent_events_from_cache = damus_state.events.parent_events(event: selected_event, keypair: damus_state.keypair) 34 for parent_event in parent_events_from_cache { 35 add_event( 36 parent_event, 37 keypair: damus_state.keypair, 38 look_for_parent_events: false, // We have all parents we need for now 39 publish_changes: false // Publishing changes during a view render is problematic 40 ) 41 } 42 43 return parent_events_from_cache 44 } 45 /// All of the direct and indirect replies of `selected_event` in the thread. sorted chronologically 46 /// 47 /// ## Implementation notes 48 /// 49 /// This is a computed property because we then don't need to worry about keeping things in sync 50 var sorted_child_events: [NostrEvent] { 51 event_map.sorted_recursive_child_events(of: selected_event).filter({ 52 should_show_event(event: $0, damus_state: damus_state) // Hide muted events from chatroom conversation 53 }) 54 } 55 56 /// The damus state, needed to access the relay pool and load the thread events 57 let damus_state: DamusState 58 59 private let profiles_subid = UUID().description 60 private let base_subid = UUID().description 61 private let meta_subid = UUID().description 62 private var subids: [String] { 63 return [profiles_subid, base_subid, meta_subid] 64 } 65 66 67 // MARK: Initialization 68 69 /// Initialize this model 70 /// 71 /// You should also call `subscribe()` to start loading thread events from the relay pool. 72 /// This is done manually to ensure we only load stuff when needed (e.g. when a view appears) 73 init(event: NostrEvent, damus_state: DamusState) { 74 self.damus_state = damus_state 75 self.event_map = ThreadEventMap() 76 self.original_event = event 77 self.selected_event = event 78 add_event(event, keypair: damus_state.keypair) 79 } 80 81 /// All events in the thread, sorted in chronological order 82 var events: [NostrEvent] { 83 return event_map.sorted_events 84 } 85 86 87 // MARK: Relay pool subscription management 88 89 /// Unsubscribe from events in the relay pool. Call this when unloading the view 90 func unsubscribe() { 91 self.damus_state.nostrNetwork.pool.remove_handler(sub_id: base_subid) 92 self.damus_state.nostrNetwork.pool.remove_handler(sub_id: meta_subid) 93 self.damus_state.nostrNetwork.pool.remove_handler(sub_id: profiles_subid) 94 self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid) 95 self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: meta_subid) 96 self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid) 97 Log.info("unsubscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid) 98 } 99 100 /// Subscribe to events in this thread. Call this when loading the view. 101 func subscribe() { 102 var meta_events = NostrFilter() 103 var quote_events = NostrFilter() 104 var event_filter = NostrFilter() 105 var ref_events = NostrFilter() 106 107 let thread_id = original_event.thread_id() 108 109 ref_events.referenced_ids = [thread_id, original_event.id] 110 ref_events.kinds = [.text] 111 ref_events.limit = 1000 112 113 event_filter.ids = [thread_id, original_event.id] 114 115 meta_events.referenced_ids = [original_event.id] 116 117 var kinds: [NostrKind] = [.zap, .text, .boost] 118 if !damus_state.settings.onlyzaps_mode { 119 kinds.append(.like) 120 } 121 meta_events.kinds = kinds 122 meta_events.limit = 1000 123 124 quote_events.kinds = [.text] 125 quote_events.quotes = [original_event.id] 126 quote_events.limit = 1000 127 128 let base_filters = [event_filter, ref_events] 129 let meta_filters = [meta_events, quote_events] 130 131 Log.info("subscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid) 132 damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event) 133 damus_state.nostrNetwork.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event) 134 } 135 136 /// Adds an event to this thread. 137 /// Normally this does not need to be called externally because it is the responsibility of this class to load the events, not the view's. 138 /// However, this can be called externally for testing purposes (e.g. injecting events for testing) 139 /// 140 /// - Parameters: 141 /// - ev: The event to add into the thread event map 142 /// - keypair: The user's keypair 143 /// - look_for_parent_events: Whether to search for parent events of the input event in NostrDB 144 /// - publish_changes: Whether to publish changes at the end 145 func add_event(_ ev: NostrEvent, keypair: Keypair, look_for_parent_events: Bool = true, publish_changes: Bool = true) { 146 if event_map.contains(id: ev.id) { 147 return 148 } 149 150 _ = damus_state.events.upsert(ev) 151 damus_state.replies.count_replies(ev, keypair: keypair) 152 damus_state.events.add_replies(ev: ev, keypair: keypair) 153 154 event_map.add(event: ev) 155 156 if look_for_parent_events { 157 // Add all parent events that we have on EventCache (and subsequently on NostrDB) 158 // This helps ensure we include as many locally-stored notes as possible — even on poor networking conditions 159 damus_state.events.parent_events(event: ev, keypair: damus_state.keypair).forEach { 160 add_event( 161 $0, // The `lookup` function in `parent_events` turns the event into an "owned" object, so we do not need to clone here 162 keypair: damus_state.keypair, 163 look_for_parent_events: false, // We do not need deep recursion 164 publish_changes: false // Do not publish changes multiple times 165 ) 166 } 167 } 168 169 if publish_changes { 170 objectWillChange.send() 171 } 172 } 173 174 /// Handles an incoming event from a relay pool 175 /// 176 /// Marked as private because it is this class' responsibility to load events, not the view's. Simplify the interface 177 @MainActor 178 private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) { 179 let (sub_id, done) = handle_subid_event(pool: damus_state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sid, ev in 180 guard subids.contains(sid) else { 181 return 182 } 183 184 if ev.known_kind == .zap { 185 process_zap_event(state: damus_state, ev: ev) { zap in 186 187 } 188 } else if ev.is_textlike { 189 // handle thread quote reposts, we just count them instead of 190 // adding them to the thread 191 if let target = ev.is_quote_repost, target == self.selected_event.id { 192 //let _ = self.damus_state.quote_reposts.add_event(ev, target: target) 193 } else { 194 self.add_event(ev, keypair: damus_state.keypair) 195 } 196 } 197 } 198 199 guard done, let sub_id, subids.contains(sub_id) else { 200 return 201 } 202 203 if sub_id == self.base_subid { 204 guard let txn = NdbTxn(ndb: damus_state.ndb) else { return } 205 load_profiles(context: "thread", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(Array(event_map.events)), damus_state: damus_state, txn: txn) 206 } 207 } 208 209 // MARK: External control interface 210 // Control methods created for the thread view 211 212 /// Change the currently selected event 213 /// 214 /// - Parameter event: Event to select 215 func select(event: NostrEvent) { 216 self.selected_event = event 217 add_event(event, keypair: damus_state.keypair) 218 } 219 } 220 221 /// A thread event map, a model that holds events, indexes them, and can efficiently answer questions about a thread. 222 /// 223 /// Add events that are part of a thread to this model, and use one of its many convenience functions to get answers about the hierarchy of the thread. 224 /// 225 /// This does NOT perform any event loading, networking, or storage operations. This is simply a convenient/efficient way to keep and query about a thread 226 struct ThreadEventMap { 227 /// A map for keeping nostr events, and efficiently querying them by note id 228 /// 229 /// Marked as `private` because: 230 /// - We want to hide this complexity from the user of this struct 231 /// - It is this struct's responsibility to keep this in sync with `event_reply_index` 232 private var event_map: [NoteId: NostrEvent] = [:] 233 /// An index of the reply hierarchy, which allows replies to be found in O(1) efficiency 234 /// 235 /// ## Implementation notes 236 /// 237 /// Marked as `private` because: 238 /// - We want to hide this complexity from the user of this struct 239 /// - It is this struct's responsibility to keep this in sync with `event_map` 240 /// 241 /// We only store note ids to save space, as we can easily get them from `event_map` 242 private var event_reply_index: [NoteId: Set<NoteId>] = [:] 243 244 245 // MARK: External interface 246 247 /// Events in the thread, in no particular order 248 /// Use this when the order does not matter 249 var events: Set<NostrEvent> { 250 return Set(event_map.values) 251 } 252 253 /// Events in the thread, sorted chronologically. Use this when the order matters. 254 /// Use `.events` when the order doesn't matter, as it is more computationally efficient. 255 var sorted_events: [NostrEvent] { 256 return events.sorted(by: { a, b in 257 return a.created_at < b.created_at 258 }) 259 } 260 261 /// Add an event to this map 262 /// 263 /// Efficiency: O(1) 264 /// 265 /// - Parameter event: The event to be added 266 mutating func add(event: NostrEvent) { 267 self.event_map[event.id] = event 268 269 // Update our efficient reply index 270 if let note_id_replied_to = event.direct_replies() { 271 if event_reply_index[note_id_replied_to] == nil { 272 event_reply_index[note_id_replied_to] = [event.id] 273 } 274 else { 275 event_reply_index[note_id_replied_to]?.insert(event.id) 276 } 277 } 278 } 279 280 /// Whether the thread map contains a given note, referenced by ID 281 /// 282 /// Efficiency: O(1) 283 /// 284 /// - Parameter id: The ID to look for 285 /// - Returns: True if it does, false otherwise 286 func contains(id: NoteId) -> Bool { 287 return self.event_map[id] != nil 288 } 289 290 /// Gets a note from the thread by its id 291 /// 292 /// Efficiency: O(1) 293 /// 294 /// - Parameter id: The note id 295 /// - Returns: The note, if it exists in the thread map. 296 func get(id: NoteId) -> NostrEvent? { 297 return self.event_map[id] 298 } 299 300 301 /// Returns all the parent events in a thread, relative to a given event 302 /// 303 /// Efficiency: O(N) in the worst case 304 /// 305 /// - Parameter query_event: The event for which to find the parents for 306 /// - Returns: An array of parent events, sorted from the highest level in the thread (The root of the thread), down to the direct parent of the query event. If query event is not found, this will return an empty array 307 func parent_events(of query_event: NostrEvent) -> [NostrEvent] { 308 var parents: [NostrEvent] = [] 309 var event = query_event 310 while true { 311 guard let direct_reply = event.direct_replies(), 312 let parent_event = self.get(id: direct_reply), parent_event != event 313 else { 314 break 315 } 316 317 parents.append(parent_event) 318 event = parent_event 319 } 320 321 return parents.reversed() 322 } 323 324 325 /// All of the replies in a thread for a given event, including indirect replies (reply of a reply), sorted in chronological order 326 /// 327 /// Efficiency: O(Nlog(N)) in the worst case scenario, coming from Swift's built-in sorting algorithm "Timsort" 328 /// 329 /// - Parameter query_event: The event for which to find the children for 330 /// - Returns: All of the direct and indirect replies for an event, sorted in chronological order. If query event is not present, this will be an empty array. 331 func sorted_recursive_child_events(of query_event: NostrEvent) -> [NostrEvent] { 332 let all_recursive_child_events = self.recursive_child_events(of: query_event) 333 return all_recursive_child_events.sorted(by: { a, b in 334 return a.created_at < b.created_at 335 }) 336 } 337 338 /// All of the replies in a thread for a given event, including indirect replies (reply of a reply), in any order 339 /// 340 /// Use this when the order does not matter, as it is more efficient 341 /// 342 /// Efficiency: O(N) in the worst case scenario. 343 /// 344 /// - Parameter query_event: The event for which to find the children for 345 /// - Returns: All of the direct and indirect replies for an event, sorted in chronological order. If query event is not present, this will be an empty array. 346 func recursive_child_events(of query_event: NostrEvent) -> Set<NostrEvent> { 347 let immediate_children_ids = self.event_reply_index[query_event.id] ?? [] 348 var immediate_children: Set<NostrEvent> = [] 349 for immediate_child_id in immediate_children_ids { 350 guard let immediate_child = self.event_map[immediate_child_id] else { 351 // This is an internal inconsistency. 352 // Crash the app in debug mode to increase awareness, but let it go in production mode (not mission critical) 353 assertionFailure("Desync between `event_map` and `event_reply_index` should never happen in `ThreadEventMap`!") 354 continue 355 } 356 immediate_children.insert(immediate_child) 357 } 358 359 var indirect_children: Set<NdbNote> = [] 360 for immediate_child in immediate_children { 361 let recursive_children = self.recursive_child_events(of: immediate_child) 362 indirect_children = indirect_children.union(recursive_children) 363 } 364 return immediate_children.union(indirect_children) 365 } 366 } 367 368 369 func get_top_zap(events: EventCache, evid: NoteId) -> Zapping? { 370 return events.get_cache_data(evid).zaps_model.zaps.first(where: { zap in 371 !zap.request.marked_hidden 372 }) 373 }