damus

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

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 }