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