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