damus

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

EventCache.swift (14085B)


      1 //
      2 //  EventCache.swift
      3 //  damus
      4 //
      5 //  Created by William Casarin on 2023-02-21.
      6 //
      7 
      8 import Combine
      9 import Foundation
     10 import UIKit
     11 import LinkPresentation
     12 import Kingfisher
     13 
     14 class ImageMetadataState {
     15     var state: ImageMetaProcessState
     16     var meta: ImageMetadata
     17     
     18     init(state: ImageMetaProcessState, meta: ImageMetadata) {
     19         self.state = state
     20         self.meta = meta
     21     }
     22 }
     23 
     24 enum ImageMetaProcessState {
     25     case processing
     26     case failed
     27     case processed(UIImage)
     28     case not_needed
     29 }
     30 
     31 class TranslationModel: ObservableObject {
     32     @Published var note_language: String?
     33     @Published var state: TranslateStatus
     34     
     35     init(state: TranslateStatus) {
     36         self.state = state
     37         self.note_language = nil
     38     }
     39 }
     40 
     41 class NoteArtifactsModel: ObservableObject {
     42     @Published var state: NoteArtifactState
     43     
     44     init(state: NoteArtifactState) {
     45         self.state = state
     46     }
     47 }
     48 
     49 class PreviewModel: ObservableObject {
     50     @Published var state: PreviewState
     51     
     52     init(state: PreviewState) {
     53         self.state = state
     54     }
     55 }
     56 
     57 class RelativeTimeModel: ObservableObject {
     58     @Published var value: String = ""
     59 }
     60 
     61 class MediaMetaModel: ObservableObject {
     62     @Published var fill: ImageFill? = nil
     63 }
     64 
     65 class EventData {
     66     var translations_model: TranslationModel
     67     var artifacts_model: NoteArtifactsModel
     68     var preview_model: PreviewModel
     69     var zaps_model : ZapsDataModel
     70     var relative_time: RelativeTimeModel = RelativeTimeModel()
     71     var validated: ValidationResult
     72     var media_metadata_model: MediaMetaModel
     73     
     74     var translations: TranslateStatus {
     75         return translations_model.state
     76     }
     77     
     78     var artifacts: NoteArtifactState {
     79         return artifacts_model.state
     80     }
     81     
     82     var preview: PreviewState {
     83         return preview_model.state
     84     }
     85     
     86     init(zaps: [Zapping] = []) {
     87         self.translations_model = .init(state: .havent_tried)
     88         self.artifacts_model = .init(state: .not_loaded)
     89         self.zaps_model = .init(zaps)
     90         self.validated = .unknown
     91         self.media_metadata_model = MediaMetaModel()
     92         self.preview_model = .init(state: .not_loaded)
     93     }
     94 }
     95 
     96 class EventCache {
     97     // TODO: remove me and change code to use ndb directly
     98     private let ndb: Ndb
     99     private var events: [NoteId: NostrEvent] = [:]
    100     private var cancellable: AnyCancellable?
    101     private var image_metadata: [String: ImageMetadataState] = [:] // lowercased URL key
    102     private var event_data: [NoteId: EventData] = [:]
    103     var replies = ReplyMap()
    104 
    105     //private var thread_latest: [String: Int64]
    106 
    107     init(ndb: Ndb) {
    108         self.ndb = ndb
    109         cancellable = NotificationCenter.default.publisher(
    110             for: UIApplication.didReceiveMemoryWarningNotification
    111         ).sink { [weak self] _ in
    112             self?.prune()
    113         }
    114     }
    115     
    116     func get_cache_data(_ evid: NoteId) -> EventData {
    117         guard let data = event_data[evid] else {
    118             let data = EventData()
    119             event_data[evid] = data
    120             return data
    121         }
    122         
    123         return data
    124     }
    125     
    126     func is_event_valid(_ evid: NoteId) -> ValidationResult {
    127         return get_cache_data(evid).validated
    128     }
    129     
    130     func store_event_validation(evid: NoteId, validated: ValidationResult) {
    131         get_cache_data(evid).validated = validated
    132     }
    133     
    134     @discardableResult
    135     func store_zap(zap: Zapping) -> Bool {
    136         let data = get_cache_data(NoteId(zap.target.id)).zaps_model
    137         if let ev = zap.event {
    138             insert(ev)
    139         }
    140         return insert_uniq_sorted_zap_by_amount(zaps: &data.zaps, new_zap: zap)
    141     }
    142     
    143     func remove_zap(zap: Zapping) {
    144         switch zap.target {
    145         case .note(let note_target):
    146             let zaps = get_cache_data(note_target.note_id).zaps_model
    147             zaps.remove(reqid: zap.request.id)
    148         case .profile:
    149             // these aren't stored anywhere yet
    150             break
    151         }
    152     }
    153     
    154     func lookup_zaps(target: ZapTarget) -> [Zapping] {
    155         return get_cache_data(NoteId(target.id)).zaps_model.zaps
    156     }
    157     
    158     func store_img_metadata(url: URL, meta: ImageMetadataState) {
    159         self.image_metadata[url.absoluteString.lowercased()] = meta
    160     }
    161     
    162     func lookup_img_metadata(url: URL) -> ImageMetadataState? {
    163         return image_metadata[url.absoluteString.lowercased()]
    164     }
    165     
    166     func parent_events(event: NostrEvent, keypair: Keypair) -> [NostrEvent] {
    167         var parents: [NostrEvent] = []
    168         
    169         var ev = event
    170         
    171         while true {
    172             guard let direct_reply = ev.direct_replies(),
    173                   let next_ev = lookup(direct_reply), next_ev != ev
    174             else {
    175                 break
    176             }
    177             
    178             parents.append(next_ev)
    179             ev = next_ev
    180         }
    181         
    182         return parents.reversed()
    183     }
    184     
    185     func add_replies(ev: NostrEvent, keypair: Keypair) {
    186         if let reply = ev.direct_replies() {
    187             replies.add(id: reply, reply_id: ev.id)
    188         }
    189     }
    190 
    191     func child_events(event: NostrEvent) -> [NostrEvent] {
    192         guard let xs = replies.lookup(event.id) else {
    193             return []
    194         }
    195         let evs: [NostrEvent] = xs.reduce(into: [], { evs, evid in
    196             guard let ev = self.lookup(evid) else {
    197                 return
    198             }
    199             
    200             evs.append(ev)
    201         }).sorted(by: { $0.created_at < $1.created_at })
    202         return evs
    203     }
    204     
    205     func upsert(_ ev: NostrEvent) -> NostrEvent {
    206         if let found = lookup(ev.id) {
    207             return found
    208         }
    209         
    210         insert(ev)
    211         return ev
    212     }
    213 
    214     /*
    215     func lookup_by_key(_ key: UInt64) -> NostrEvent? {
    216         ndb.lookup_note_by_key(key)
    217     }
    218      */
    219 
    220     func lookup(_ evid: NoteId) -> NostrEvent? {
    221         if let ev = events[evid] {
    222             return ev
    223         }
    224 
    225         if let ev = self.ndb.lookup_note(evid)?.unsafeUnownedValue?.to_owned() {
    226             events[ev.id] = ev
    227             return ev
    228         }
    229 
    230         return nil
    231     }
    232     
    233     func insert(_ ev: NostrEvent) {
    234         guard events[ev.id] == nil else {
    235             return
    236         }
    237         events[ev.id] = ev
    238     }
    239     
    240     private func prune() {
    241         events = [:]
    242         event_data = [:]
    243         replies.replies = [:]
    244     }
    245 }
    246 
    247 func should_translate(event: NostrEvent, our_keypair: Keypair, note_lang: String?) -> Bool {
    248     // don't translate reposts, longform, etc
    249     if event.kind != 1 {
    250         return false;
    251     }
    252 
    253     // Do not translate self-authored notes if logged in with a private key
    254     // as we can assume the user can understand their own notes.
    255     // The detected language prediction could be incorrect and not in the list of preferred languages.
    256     // Offering a translation in this case is definitely incorrect so let's avoid it altogether.
    257     if our_keypair.privkey != nil && our_keypair.pubkey == event.pubkey {
    258         return false
    259     }
    260 
    261     if let note_lang {
    262         let currentLanguage = localeToLanguage(Locale.current.identifier)
    263 
    264         // Don't translate if the note is in our current language
    265         guard currentLanguage != note_lang else {
    266             return false
    267         }
    268     }
    269 
    270     // we should start translating if we have auto_translate on
    271     return true
    272 }
    273 
    274 func can_and_should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore, note_lang: String?) -> Bool {
    275     guard settings.can_translate else {
    276         return false
    277     }
    278 
    279     return should_translate(event: event, our_keypair: our_keypair, note_lang: note_lang)
    280 }
    281 
    282 func should_preload_translation(event: NostrEvent, our_keypair: Keypair, current_status: TranslateStatus, settings: UserSettingsStore, note_lang: String?) -> Bool {
    283     switch current_status {
    284     case .havent_tried:
    285         return can_and_should_translate(event: event, our_keypair: our_keypair, settings: settings, note_lang: note_lang) && settings.auto_translate
    286     case .translating: return false
    287     case .translated: return false
    288     case .not_needed: return false
    289     }
    290 }
    291 
    292 struct PreloadPlan {
    293     let data: EventData
    294     let img_metadata: [ImageMetadata]
    295     let event: NostrEvent
    296     var load_artifacts: Bool
    297     let load_translations: Bool
    298     let load_preview: Bool
    299 }
    300 
    301 func load_preview(artifacts: NoteArtifactsSeparated) async -> Preview? {
    302     guard let link = artifacts.links.first else {
    303         return nil
    304     }
    305     let meta = await Preview.fetch_metadata(for: link)
    306     return Preview(meta: meta)
    307 }
    308 
    309 func get_preload_plan(evcache: EventCache, ev: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore) -> PreloadPlan? {
    310     let cache = evcache.get_cache_data(ev.id)
    311     let load_artifacts = cache.artifacts.should_preload
    312     if load_artifacts {
    313         cache.artifacts_model.state = .loading
    314     }
    315 
    316     // Cached event might not have the note language determined yet, so determine the language here before figuring out if translations should be preloaded.
    317     let note_lang = cache.translations_model.note_language ?? /*ev.note_language(our_keypair.privkey)*/ current_language()
    318 
    319     let load_translations = should_preload_translation(event: ev, our_keypair: our_keypair, current_status: cache.translations, settings: settings, note_lang: note_lang)
    320     if load_translations {
    321         cache.translations_model.state = .translating
    322     }
    323     
    324     let load_urls = event_image_metadata(ev: ev)
    325         .reduce(into: [ImageMetadata]()) { to_load, meta in
    326             let cached = evcache.lookup_img_metadata(url: meta.url)
    327             guard cached == nil else {
    328                 return
    329             }
    330             
    331             let m = ImageMetadataState(state: .processing, meta: meta)
    332             evcache.store_img_metadata(url: meta.url, meta: m)
    333             to_load.append(meta)
    334     }
    335     
    336     let load_preview = cache.preview.should_preload
    337     if load_preview {
    338         cache.preview_model.state = .loading
    339     }
    340     
    341     if !load_artifacts && !load_translations && !load_preview && load_urls.count == 0 {
    342         return nil
    343     }
    344     
    345     return PreloadPlan(data: cache, img_metadata: load_urls, event: ev, load_artifacts: load_artifacts, load_translations: load_translations, load_preview: load_preview)
    346 }
    347 
    348 func preload_image(url: URL) {
    349     if ImageCache.default.isCached(forKey: url.absoluteString) {
    350         //print("Preloaded image \(url.absoluteString) found in cache")
    351         // looks like we already have it cached. no download needed
    352         return
    353     }
    354     
    355     //print("Preloading image \(url.absoluteString)")
    356 
    357     KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: url)) { val in
    358         //print("Preloaded image \(url.absoluteString)")
    359     }
    360 }
    361 
    362 func is_animated_image(url: URL) -> Bool {
    363     guard let ext = url.pathComponents.last?.split(separator: ".").last?.lowercased() else {
    364         return false
    365     }
    366     
    367     return ext == "gif"
    368 }
    369 
    370 func preload_event(plan: PreloadPlan, state: DamusState) async {
    371     var artifacts: NoteArtifacts? = plan.data.artifacts.artifacts
    372     let settings = state.settings
    373     let profiles = state.profiles
    374     let our_keypair = state.keypair
    375     
    376     //print("Preloading event \(plan.event.content)")
    377 
    378     if artifacts == nil && plan.load_artifacts {
    379         let arts = render_note_content(ev: plan.event, profiles: profiles, keypair: our_keypair)
    380         artifacts = arts
    381         
    382         // we need these asap
    383         DispatchQueue.main.async {
    384             plan.data.artifacts_model.state = .loaded(arts)
    385         }
    386         
    387         for url in arts.images {
    388             guard !is_animated_image(url: url) else {
    389                 // jb55: I have a theory that animated images are not working with the preloader due
    390                 // to some disk-cache write race condition. normal images need not apply
    391                 
    392                 continue
    393             }
    394             
    395             preload_image(url: url)
    396         }
    397     }
    398     
    399     if plan.load_preview, note_artifact_is_separated(kind: plan.event.known_kind) {
    400         let arts = artifacts ?? render_note_content(ev: plan.event, profiles: profiles, keypair: our_keypair)
    401 
    402         // only separated artifacts have previews
    403         if case .separated(let sep) = arts {
    404             let preview = await load_preview(artifacts: sep)
    405             DispatchQueue.main.async {
    406                 if let preview {
    407                     plan.data.preview_model.state = .loaded(preview)
    408                 } else {
    409                     plan.data.preview_model.state = .loaded(.failed)
    410                 }
    411             }
    412         }
    413     }
    414     
    415     let note_language = plan.data.translations_model.note_language ?? plan.event.note_language(our_keypair) ?? current_language()
    416 
    417     var translations: TranslateStatus? = nil
    418     // We have to recheck should_translate here now that we have note_language
    419     if plan.load_translations && can_and_should_translate(event: plan.event, our_keypair: our_keypair, settings: settings, note_lang: note_language) && settings.auto_translate
    420     {
    421         translations = await translate_note(profiles: profiles, keypair: our_keypair, event: plan.event, settings: settings, note_lang: note_language, purple: state.purple)
    422     }
    423     
    424     let ts = translations
    425     if plan.data.translations_model.note_language == nil || ts != nil {
    426         DispatchQueue.main.async {
    427             if let ts {
    428                 plan.data.translations_model.state = ts
    429             }
    430             if plan.data.translations_model.note_language != note_language {
    431                 plan.data.translations_model.note_language = note_language
    432             }
    433         }
    434     }
    435     
    436 }
    437 
    438 func preload_events(state: DamusState, events: [NostrEvent]) {
    439     let event_cache = state.events
    440     let our_keypair = state.keypair
    441     let settings = state.settings
    442     
    443     let plans = events.compactMap { ev in
    444         get_preload_plan(evcache: event_cache, ev: ev, our_keypair: our_keypair, settings: settings)
    445     }
    446     
    447     if plans.count == 0 {
    448         return
    449     }
    450     
    451     Task {
    452         for plan in plans {
    453             await preload_event(plan: plan, state: state)
    454         }
    455     }
    456 }
    457