damus

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

EventCache.swift (13705B)


      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 replies = ReplyMap()
    101     private var cancellable: AnyCancellable?
    102     private var image_metadata: [String: ImageMetadataState] = [:] // lowercased URL key
    103     private var event_data: [NoteId: EventData] = [:]
    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(keypair).last,
    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         for reply in ev.direct_replies(keypair) {
    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         return events[evid]
    222     }
    223     
    224     func insert(_ ev: NostrEvent) {
    225         guard events[ev.id] == nil else {
    226             return
    227         }
    228         events[ev.id] = ev
    229     }
    230     
    231     private func prune() {
    232         events = [:]
    233         event_data = [:]
    234         replies.replies = [:]
    235     }
    236 }
    237 
    238 func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore, note_lang: String?) -> Bool {
    239     guard settings.can_translate else {
    240         return false
    241     }
    242     
    243     // Do not translate self-authored notes if logged in with a private key
    244     // as we can assume the user can understand their own notes.
    245     // The detected language prediction could be incorrect and not in the list of preferred languages.
    246     // Offering a translation in this case is definitely incorrect so let's avoid it altogether.
    247     if our_keypair.privkey != nil && our_keypair.pubkey == event.pubkey {
    248         return false
    249     }
    250     
    251     if let note_lang {
    252         let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
    253         
    254         // Don't translate if its in our preferred languages
    255         guard !preferredLanguages.contains(note_lang) else {
    256             // if its the same, give up and don't retry
    257             return false
    258         }
    259     }
    260     
    261     // we should start translating if we have auto_translate on
    262     return true
    263 }
    264 
    265 func should_preload_translation(event: NostrEvent, our_keypair: Keypair, current_status: TranslateStatus, settings: UserSettingsStore, note_lang: String?) -> Bool {
    266     switch current_status {
    267     case .havent_tried:
    268         return should_translate(event: event, our_keypair: our_keypair, settings: settings, note_lang: note_lang) && settings.auto_translate
    269     case .translating: return false
    270     case .translated: return false
    271     case .not_needed: return false
    272     }
    273 }
    274 
    275 struct PreloadPlan {
    276     let data: EventData
    277     let img_metadata: [ImageMetadata]
    278     let event: NostrEvent
    279     var load_artifacts: Bool
    280     let load_translations: Bool
    281     let load_preview: Bool
    282 }
    283 
    284 func load_preview(artifacts: NoteArtifactsSeparated) async -> Preview? {
    285     guard let link = artifacts.links.first else {
    286         return nil
    287     }
    288     let meta = await Preview.fetch_metadata(for: link)
    289     return Preview(meta: meta)
    290 }
    291 
    292 func get_preload_plan(evcache: EventCache, ev: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore) -> PreloadPlan? {
    293     let cache = evcache.get_cache_data(ev.id)
    294     let load_artifacts = cache.artifacts.should_preload
    295     if load_artifacts {
    296         cache.artifacts_model.state = .loading
    297     }
    298 
    299     // Cached event might not have the note language determined yet, so determine the language here before figuring out if translations should be preloaded.
    300     let note_lang = cache.translations_model.note_language ?? /*ev.note_language(our_keypair.privkey)*/ current_language()
    301 
    302     let load_translations = should_preload_translation(event: ev, our_keypair: our_keypair, current_status: cache.translations, settings: settings, note_lang: note_lang)
    303     if load_translations {
    304         cache.translations_model.state = .translating
    305     }
    306     
    307     let load_urls = event_image_metadata(ev: ev)
    308         .reduce(into: [ImageMetadata]()) { to_load, meta in
    309             let cached = evcache.lookup_img_metadata(url: meta.url)
    310             guard cached == nil else {
    311                 return
    312             }
    313             
    314             let m = ImageMetadataState(state: .processing, meta: meta)
    315             evcache.store_img_metadata(url: meta.url, meta: m)
    316             to_load.append(meta)
    317     }
    318     
    319     let load_preview = cache.preview.should_preload
    320     if load_preview {
    321         cache.preview_model.state = .loading
    322     }
    323     
    324     if !load_artifacts && !load_translations && !load_preview && load_urls.count == 0 {
    325         return nil
    326     }
    327     
    328     return PreloadPlan(data: cache, img_metadata: load_urls, event: ev, load_artifacts: load_artifacts, load_translations: load_translations, load_preview: load_preview)
    329 }
    330 
    331 func preload_image(url: URL) {
    332     if ImageCache.default.isCached(forKey: url.absoluteString) {
    333         //print("Preloaded image \(url.absoluteString) found in cache")
    334         // looks like we already have it cached. no download needed
    335         return
    336     }
    337     
    338     //print("Preloading image \(url.absoluteString)")
    339 
    340     KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: url)) { val in
    341         //print("Preloaded image \(url.absoluteString)")
    342     }
    343 }
    344 
    345 func is_animated_image(url: URL) -> Bool {
    346     guard let ext = url.pathComponents.last?.split(separator: ".").last?.lowercased() else {
    347         return false
    348     }
    349     
    350     return ext == "gif"
    351 }
    352 
    353 func preload_event(plan: PreloadPlan, state: DamusState) async {
    354     var artifacts: NoteArtifacts? = plan.data.artifacts.artifacts
    355     let settings = state.settings
    356     let profiles = state.profiles
    357     let our_keypair = state.keypair
    358     
    359     //print("Preloading event \(plan.event.content)")
    360 
    361     if artifacts == nil && plan.load_artifacts {
    362         let arts = render_note_content(ev: plan.event, profiles: profiles, keypair: our_keypair)
    363         artifacts = arts
    364         
    365         // we need these asap
    366         DispatchQueue.main.async {
    367             plan.data.artifacts_model.state = .loaded(arts)
    368         }
    369         
    370         for url in arts.images {
    371             guard !is_animated_image(url: url) else {
    372                 // jb55: I have a theory that animated images are not working with the preloader due
    373                 // to some disk-cache write race condition. normal images need not apply
    374                 
    375                 continue
    376             }
    377             
    378             preload_image(url: url)
    379         }
    380     }
    381     
    382     if plan.load_preview, note_artifact_is_separated(kind: plan.event.known_kind) {
    383         let arts = artifacts ?? render_note_content(ev: plan.event, profiles: profiles, keypair: our_keypair)
    384 
    385         // only separated artifacts have previews
    386         if case .separated(let sep) = arts {
    387             let preview = await load_preview(artifacts: sep)
    388             DispatchQueue.main.async {
    389                 if let preview {
    390                     plan.data.preview_model.state = .loaded(preview)
    391                 } else {
    392                     plan.data.preview_model.state = .loaded(.failed)
    393                 }
    394             }
    395         }
    396     }
    397     
    398     let note_language = plan.data.translations_model.note_language ?? plan.event.note_language(our_keypair) ?? current_language()
    399 
    400     var translations: TranslateStatus? = nil
    401     // We have to recheck should_translate here now that we have note_language
    402     if plan.load_translations && should_translate(event: plan.event, our_keypair: our_keypair, settings: settings, note_lang: note_language) && settings.auto_translate
    403     {
    404         translations = await translate_note(profiles: profiles, keypair: our_keypair, event: plan.event, settings: settings, note_lang: note_language, purple: state.purple)
    405     }
    406     
    407     let ts = translations
    408     if plan.data.translations_model.note_language == nil || ts != nil {
    409         DispatchQueue.main.async {
    410             if let ts {
    411                 plan.data.translations_model.state = ts
    412             }
    413             if plan.data.translations_model.note_language != note_language {
    414                 plan.data.translations_model.note_language = note_language
    415             }
    416         }
    417     }
    418     
    419 }
    420 
    421 func preload_events(state: DamusState, events: [NostrEvent]) {
    422     let event_cache = state.events
    423     let our_keypair = state.keypair
    424     let settings = state.settings
    425     
    426     let plans = events.compactMap { ev in
    427         get_preload_plan(evcache: event_cache, ev: ev, our_keypair: our_keypair, settings: settings)
    428     }
    429     
    430     if plans.count == 0 {
    431         return
    432     }
    433     
    434     Task {
    435         for plan in plans {
    436             await preload_event(plan: plan, state: state)
    437         }
    438     }
    439 }
    440