damus

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

commit 1ca0519e257f29cef7fb20db30d50d2d2555bc41
parent c87f19b4796a0640043b4ebf7a82e59e098016ce
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 30 Apr 2023 19:31:14 -0700

Event Preloading

Changelog-Added: Added event preloading when scrolling
Changelog-Added: Preload images so they don't pop in
Changelog-Fixed: Fixed preview elements popping in
Changelog-Changed: Cached various UI elements so its not as laggy
Changelog-Fixed: Fixed glitchy preview

Diffstat:
Mdamus/Components/TranslateView.swift | 143++++++++++++++++++++++++++++++++-----------------------------------------------
Mdamus/Models/HomeModel.swift | 43+++++++++++++++++++++++++++++++------------
Mdamus/Models/NotificationsModel.swift | 10+++++++++-
Mdamus/Models/UserSettingsStore.swift | 2+-
Mdamus/Nostr/Nostr.swift | 12++++++++++++
Mdamus/Nostr/RelayConnection.swift | 15++++-----------
Mdamus/Util/EventCache.swift | 261++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mdamus/Util/PreviewCache.swift | 50+++++++++++++++++++++++++++++++++++++++++---------
Mdamus/Views/Events/TextEvent.swift | 40++++++++++++++++++++++++++++++++--------
Mdamus/Views/NoteContentView.swift | 115++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mdamus/Views/Timeline/InnerTimelineView.swift | 33+++++++++++++++++++++++++--------
11 files changed, 532 insertions(+), 192 deletions(-)

diff --git a/damus/Components/TranslateView.swift b/damus/Components/TranslateView.swift @@ -16,7 +16,6 @@ struct Translated: Equatable { enum TranslateStatus: Equatable { case havent_tried - case trying case translating case translated(Translated) case not_needed @@ -26,40 +25,19 @@ struct TranslateView: View { let damus_state: DamusState let event: NostrEvent let size: EventViewKind - let currentLanguage: String - @State var translated: TranslateStatus + @ObservedObject var translations_model: TranslationModel init(damus_state: DamusState, event: NostrEvent, size: EventViewKind) { self.damus_state = damus_state self.event = event self.size = size - - if #available(iOS 16, *) { - self.currentLanguage = Locale.current.language.languageCode?.identifier ?? "en" - } else { - self.currentLanguage = Locale.current.languageCode ?? "en" - } - - if damus_state.pubkey == event.pubkey && damus_state.is_privkey_user { - // Do not translate self-authored notes if logged in with a private key - // as we can assume the user can understand their own notes. - // The detected language prediction could be incorrect and not in the list of preferred languages. - // Offering a translation in this case is definitely incorrect so let's avoid it altogether. - self._translated = State(initialValue: .not_needed) - } else if let cached = damus_state.events.lookup_translated_artifacts(evid: event.id) { - self._translated = State(initialValue: cached) - } else { - let initval: TranslateStatus = self.damus_state.settings.auto_translate ? .trying : .havent_tried - self._translated = State(initialValue: initval) - } + self._translations_model = ObservedObject(wrappedValue: damus_state.events.get_cache_data(event.id).translations_model) } - let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) }) - var TranslateButton: some View { Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) { - self.translated = .trying + translate() } .translate_button_style() } @@ -80,73 +58,32 @@ struct TranslateView: View { } } - func failed_attempt() { - DispatchQueue.main.async { - self.translated = .not_needed - damus_state.events.store_translation_artifacts(evid: event.id, translated: .not_needed) + func translate() { + Task { + let res = await translate_note(profiles: damus_state.profiles, privkey: damus_state.keypair.privkey, event: event, settings: damus_state.settings) + DispatchQueue.main.async { + self.translations_model.state = res + } } } - func attempt_translation() async { - guard case .trying = translated else { - return - } - - guard damus_state.settings.can_translate(damus_state.pubkey) else { - return - } - - let note_lang = event.note_language(damus_state.keypair.privkey) ?? currentLanguage - - // Don't translate if its in our preferred languages - guard !preferredLanguages.contains(note_lang) else { - failed_attempt() - return - } - - DispatchQueue.main.async { - self.translated = .translating - } - - // If the note language is different from our preferred languages, send a translation request. - let translator = Translator(damus_state.settings) - let originalContent = event.get_content(damus_state.keypair.privkey) - let translated_note = try? await translator.translate(originalContent, from: note_lang, to: currentLanguage) - - guard let translated_note else { - // if its the same, give up and don't retry - failed_attempt() - return - } - - guard originalContent != translated_note else { - // if its the same, give up and don't retry - failed_attempt() + func attempt_translation() { + guard should_translate(event: event, our_keypair: damus_state.keypair, settings: damus_state.settings) else { return } - - // Render translated note - let translated_blocks = event.get_blocks(content: translated_note) - let artifacts = render_blocks(blocks: translated_blocks, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey) - // and cache it - DispatchQueue.main.async { - self.translated = .translated(Translated(artifacts: artifacts, language: note_lang)) - damus_state.events.store_translation_artifacts(evid: event.id, translated: self.translated) - } + translate() } var body: some View { Group { - switch translated { + switch self.translations_model.state { case .havent_tried: if damus_state.settings.auto_translate { Text("") } else { TranslateButton } - case .trying: - Text("") case .translating: Text("Translating...", comment: "Text to display when waiting for the translation of a note to finish processing before showing it.") .foregroundColor(.gray) @@ -159,17 +96,8 @@ struct TranslateView: View { Text("") } } - .onChange(of: translated) { val in - guard case .trying = translated else { - return - } - - Task { - await attempt_translation() - } - } .task { - await attempt_translation() + attempt_translation() } } } @@ -189,3 +117,46 @@ struct TranslateView_Previews: PreviewProvider { TranslateView(damus_state: ds, event: test_event, size: .normal) } } + +func translate_note(profiles: Profiles, privkey: String?, event: NostrEvent, settings: UserSettingsStore) async -> TranslateStatus { + let note_lang = await event.note_language(privkey) ?? current_language() + + let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) }) + + // Don't translate if its in our preferred languages + guard !preferredLanguages.contains(note_lang) else { + // if its the same, give up and don't retry + return .not_needed + } + + // If the note language is different from our preferred languages, send a translation request. + let translator = Translator(settings) + let originalContent = event.get_content(privkey) + let translated_note = try? await translator.translate(originalContent, from: note_lang, to: current_language()) + + guard let translated_note else { + // if its the same, give up and don't retry + return .not_needed + } + + guard originalContent != translated_note else { + // if its the same, give up and don't retry + return .not_needed + } + + // Render translated note + let translated_blocks = event.get_blocks(content: translated_note) + let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles, privkey: privkey) + + // and cache it + return .translated(Translated(artifacts: artifacts, language: note_lang)) +} + +func current_language() -> String { + if #available(iOS 16, *) { + return Locale.current.language.languageCode?.identifier ?? "en" + } else { + return Locale.current.languageCode ?? "en" + } +} + diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -232,13 +232,19 @@ class HomeModel: ObservableObject { if let inner_ev = ev.get_inner_event(cache: damus_state.events) { boost_ev_id = inner_ev.id - guard validate_event(ev: inner_ev) == .ok else { - return + + Task.init { + guard validate_event(ev: inner_ev) == .ok else { + return + } + + if inner_ev.is_textlike { + DispatchQueue.main.async { + self.handle_text_event(sub_id: sub_id, ev) + } + } } - if inner_ev.is_textlike { - handle_text_event(sub_id: sub_id, ev) - } } guard let e = boost_ev_id else { @@ -271,8 +277,8 @@ class HomeModel: ObservableObject { case .success(let n): handle_notification(ev: ev) let liked = Counted(event: ev, id: e.ref_id, total: n) - notify(.liked, liked) - notify(.update_stats, e.ref_id) + //notify(.liked, liked) + //notify(.update_stats, e.ref_id) } } @@ -689,6 +695,7 @@ func process_metadata_profile(our_pubkey: String, profiles: Profiles, profile: P profiles.add(id: ev.pubkey, profile: tprof) if let nip05 = profile.nip05, old_nip05 != profile.nip05 { + Task.detached(priority: .background) { let validated = await validate_nip05(pubkey: ev.pubkey, nip05_str: nip05) if validated != nil { @@ -704,17 +711,22 @@ func process_metadata_profile(our_pubkey: String, profiles: Profiles, profile: P } // load pfps asap + + var changed = false + let picture = tprof.profile.picture ?? robohash(ev.pubkey) if URL(string: picture) != nil { - notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) + changed = true } let banner = tprof.profile.banner ?? "" if URL(string: banner) != nil { - notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) + changed = true } - notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) + if changed { + notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) + } } func guard_valid_event(events: EventCache, ev: NostrEvent, callback: @escaping () -> Void) { @@ -750,6 +762,8 @@ func process_metadata_event(events: EventCache, our_pubkey: String, profiles: Pr return } + profile.cache_lnurl() + DispatchQueue.main.async { process_metadata_profile(our_pubkey: our_pubkey, profiles: profiles, profile: profile, ev: ev) } @@ -936,8 +950,13 @@ func handle_incoming_dms(prev_events: NewEventsBits, dms: DirectMessagesModel, o } if inserted { - dms.dms = dms.dms.filter({ $0.events.count > 0 }).sorted { a, b in - return a.events.last!.created_at > b.events.last!.created_at + Task.init { + let new_dms = dms.dms.filter({ $0.events.count > 0 }).sorted { a, b in + return a.events.last!.created_at > b.events.last!.created_at + } + DispatchQueue.main.async { + dms.dms = new_dms + } } } diff --git a/damus/Models/NotificationsModel.swift b/damus/Models/NotificationsModel.swift @@ -110,6 +110,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { var reposts: [String: EventGroup] var replies: [NostrEvent] var has_reply: Set<String> + var has_ev: Set<String> @Published var notifications: [NotificationItem] @@ -124,6 +125,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { self.incoming_events = [] self.profile_zaps = ZapGroup() self.notifications = [] + self.has_ev = Set() } func set_should_queue(_ val: Bool) { @@ -265,8 +267,14 @@ class NotificationsModel: ObservableObject, ScrollQueue { } func insert_event(_ ev: NostrEvent, damus_state: DamusState) -> Bool { + if has_ev.contains(ev.id) { + return false + } + if should_queue { - return insert_uniq_sorted_event_created(events: &incoming_events, new_ev: ev) + incoming_events.append(ev) + has_ev.insert(ev.id) + return true } if insert_event_immediate(ev, cache: damus_state.events) { diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift @@ -250,7 +250,7 @@ class UserSettingsStore: ObservableObject { try Vault.deletePrivateKey(keychainConfiguration: DamusDeepLKeychainConfiguration()) } - func can_translate(_ pubkey: String) -> Bool { + var can_translate: Bool { switch translation_service { case .none: return false diff --git a/damus/Nostr/Nostr.swift b/damus/Nostr/Nostr.swift @@ -115,6 +115,18 @@ class Profile: Codable { } } + func cache_lnurl() { + guard self._lnurl == nil else { + return + } + + guard let addr = lud16 ?? lud06 else { + return + } + + self._lnurl = lnaddress_to_lnurl(addr) + } + private var _lnurl: String? = nil var lnurl: String? { if let _lnurl { diff --git a/damus/Nostr/RelayConnection.swift b/damus/Nostr/RelayConnection.swift @@ -127,18 +127,11 @@ final class RelayConnection { private func receive(message: URLSessionWebSocketTask.Message) { switch message { case .string(let messageString): - if messageString.utf8.count > 2000 { - DispatchQueue.global(qos: .default).async { - if let ev = decode_nostr_event(txt: messageString) { - DispatchQueue.main.async { - self.handleEvent(.nostr_event(ev)) - } - return - } - } - } else { + DispatchQueue.global(qos: .default).async { if let ev = decode_nostr_event(txt: messageString) { - handleEvent(.nostr_event(ev)) + DispatchQueue.main.async { + self.handleEvent(.nostr_event(ev)) + } return } } diff --git a/damus/Util/EventCache.swift b/damus/Util/EventCache.swift @@ -8,6 +8,8 @@ import Combine import Foundation import UIKit +import LinkPresentation +import Kingfisher class ImageMetadataState { var state: ImageMetaProcessState @@ -34,17 +36,88 @@ enum ImageMetaProcessState { } } -class EventData: ObservableObject { - @Published var translations: TranslateStatus? - @Published var artifacts: NoteArtifacts? +class TranslationModel: ObservableObject { + @Published var state: TranslateStatus + + init(state: TranslateStatus) { + self.state = state + } +} + +class NoteArtifactsModel: ObservableObject { + @Published var state: NoteArtifactState + + init(state: NoteArtifactState) { + self.state = state + } +} + +class PreviewModel: ObservableObject { + @Published var state: PreviewState + + func store(preview: LPLinkMetadata?) { + state = .loaded(Preview(meta: preview)) + } + + init(state: PreviewState) { + self.state = state + } +} + +class ZapsDataModel: ObservableObject { @Published var zaps: [Zap] + + init(_ zaps: [Zap]) { + self.zaps = zaps + } +} + +class RelativeTimeModel: ObservableObject { + private(set) var last_update: Int64 + @Published var value: String { + didSet { + self.last_update = Int64(Date().timeIntervalSince1970) + } + } + + init(value: String) { + self.last_update = 0 + self.value = "" + } +} + +class EventData { + var translations_model: TranslationModel + var artifacts_model: NoteArtifactsModel + var preview_model: PreviewModel + var zaps_model : ZapsDataModel + var relative_time: RelativeTimeModel + var validated: ValidationResult + var translations: TranslateStatus { + return translations_model.state + } + + var artifacts: NoteArtifactState { + return artifacts_model.state + } + + var preview: PreviewState { + return preview_model.state + } + + var zaps: [Zap] { + return zaps_model.zaps + } + init(zaps: [Zap] = []) { - self.translations = nil - self.artifacts = nil - self.zaps = zaps + self.translations_model = .init(state: .havent_tried) + self.artifacts_model = .init(state: .not_loaded) + self.zaps_model = .init(zaps) self.validated = .unknown + self.preview_model = .init(state: .not_loaded) + self.relative_time = .init(value: "") } } @@ -65,7 +138,7 @@ class EventCache { } } - private func get_cache_data(_ evid: String) -> EventData { + func get_cache_data(_ evid: String) -> EventData { guard let data = event_data[evid] else { let data = EventData() event_data[evid] = data @@ -84,29 +157,29 @@ class EventCache { } func store_translation_artifacts(evid: String, translated: TranslateStatus) { - get_cache_data(evid).translations = translated + get_cache_data(evid).translations_model.state = translated } func store_artifacts(evid: String, artifacts: NoteArtifacts) { - get_cache_data(evid).artifacts = artifacts + get_cache_data(evid).artifacts_model.state = .loaded(artifacts) } @discardableResult func store_zap(zap: Zap) -> Bool { - var data = get_cache_data(zap.target.id) + let data = get_cache_data(zap.target.id).zaps_model return insert_uniq_sorted_zap_by_amount(zaps: &data.zaps, new_zap: zap) } func lookup_zaps(target: ZapTarget) -> [Zap] { - return get_cache_data(target.id).zaps + return get_cache_data(target.id).zaps_model.zaps } func store_img_metadata(url: URL, meta: ImageMetadataState) { self.image_metadata[url.absoluteString.lowercased()] = meta } - func lookup_artifacts(evid: String) -> NoteArtifacts? { - return get_cache_data(evid).artifacts + func lookup_artifacts(evid: String) -> NoteArtifactState { + return get_cache_data(evid).artifacts_model.state } func lookup_img_metadata(url: URL) -> ImageMetadataState? { @@ -114,7 +187,7 @@ class EventCache { } func lookup_translated_artifacts(evid: String) -> TranslateStatus? { - return get_cache_data(evid).translations + return get_cache_data(evid).translations_model.state } func parent_events(event: NostrEvent) -> [NostrEvent] { @@ -184,3 +257,163 @@ class EventCache { replies.replies = [:] } } + +func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore) -> Bool { + guard settings.can_translate else { + return false + } + + // Do not translate self-authored notes if logged in with a private key + // as we can assume the user can understand their own notes. + // The detected language prediction could be incorrect and not in the list of preferred languages. + // Offering a translation in this case is definitely incorrect so let's avoid it altogether. + if our_keypair.privkey != nil && our_keypair.pubkey == event.pubkey { + return false + } + + // we should start translating if we have auto_translate on + return settings.auto_translate +} + +func should_preload_translation(event: NostrEvent, our_keypair: Keypair, current_status: TranslateStatus, settings: UserSettingsStore) -> Bool { + + switch current_status { + case .havent_tried: + return should_translate(event: event, our_keypair: our_keypair, settings: settings) + case .translating: return false + case .translated: return false + case .not_needed: return false + } +} + + + +struct PreloadResult { + let event: NostrEvent + let artifacts: NoteArtifacts? + let translations: TranslateStatus? + let preview: Preview? + let timeago: String +} + + +struct PreloadPlan { + let data: EventData + let event: NostrEvent + let load_artifacts: Bool + let load_translations: Bool + let load_preview: Bool +} + +func load_preview(artifacts: NoteArtifacts) async -> Preview? { + guard let link = artifacts.links.first else { + return nil + } + let meta = await Preview.fetch_metadata(for: link) + return Preview(meta: meta) +} + +func get_preload_plan(cache: EventData, ev: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore) -> PreloadPlan? { + let load_artifacts = cache.artifacts.should_preload + if load_artifacts { + cache.artifacts_model.state = .loading + } + + let load_translations = should_preload_translation(event: ev, our_keypair: our_keypair, current_status: cache.translations, settings: settings) + if load_translations { + cache.translations_model.state = .translating + } + + let load_preview = cache.preview.should_preload + if load_preview { + cache.preview_model.state = .loading + } + + if !load_artifacts && !load_translations && !load_preview { + return nil + } + + return PreloadPlan(data: cache, event: ev, load_artifacts: load_artifacts, load_translations: load_translations, load_preview: load_preview) +} + +func preload_event(plan: PreloadPlan, profiles: Profiles, our_keypair: Keypair, settings: UserSettingsStore) async -> PreloadResult { + var artifacts: NoteArtifacts? = nil + var translations: TranslateStatus? = nil + var preview: Preview? = nil + + print("Preloading event \(plan.event.content)") + + if plan.load_artifacts { + artifacts = render_note_content(ev: plan.event, profiles: profiles, privkey: our_keypair.privkey) + let arts = artifacts! + + for url in arts.images { + print("Preloading image \(url.absoluteString)") + KingfisherManager.shared.retrieveImage(with: ImageResource(downloadURL: url)) { val in + print("Finished preloading image \(url.absoluteString)") + } + } + } + + if plan.load_preview { + if let arts = artifacts ?? plan.data.artifacts.artifacts { + preview = await load_preview(artifacts: arts) + } else { + print("couldnt preload preview") + } + } + + if plan.load_translations { + translations = await translate_note(profiles: profiles, privkey: our_keypair.privkey, event: plan.event, settings: settings) + } + + return PreloadResult(event: plan.event, artifacts: artifacts, translations: translations, preview: preview, timeago: format_relative_time(plan.event.created_at)) +} + +func set_preload_results(plan: PreloadPlan, res: PreloadResult, privkey: String?) { + if plan.load_translations { + if let translations = res.translations { + plan.data.translations_model.state = translations + } else { + // failed + plan.data.translations_model.state = .not_needed + } + } + + if plan.load_artifacts, case .loading = plan.data.artifacts { + if let artifacts = res.artifacts { + plan.data.artifacts_model.state = .loaded(artifacts) + } else { + plan.data.artifacts_model.state = .loaded(.just_content(plan.event.get_content(privkey))) + } + } + + if plan.load_preview, case .loading = plan.data.preview { + if let preview = res.preview { + plan.data.preview_model.state = .loaded(preview) + } else { + plan.data.preview_model.state = .loaded(.failed) + } + } + + plan.data.relative_time.value = res.timeago +} + +func preload_events(event_cache: EventCache, events: [NostrEvent], profiles: Profiles, our_keypair: Keypair, settings: UserSettingsStore) { + + let plans = events.compactMap { ev in + get_preload_plan(cache: event_cache.get_cache_data(ev.id), ev: ev, our_keypair: our_keypair, settings: settings) + } + + Task.init { + for plan in plans { + let res = await preload_event(plan: plan, profiles: profiles, our_keypair: our_keypair, settings: settings) + // dispatch results right away + DispatchQueue.main.async { [plan] in + set_preload_results(plan: plan, res: res, privkey: our_keypair.privkey) + } + } + } + +} + diff --git a/damus/Util/PreviewCache.swift b/damus/Util/PreviewCache.swift @@ -21,6 +21,47 @@ class CachedMetadata { enum Preview { case value(CachedMetadata) case failed + + init(meta: LPLinkMetadata?) { + if let meta { + self = .value(CachedMetadata(meta: meta)) + } else { + self = .failed + } + } + + static func fetch_metadata(for url: URL) async -> LPLinkMetadata? { + // iOS 15 is crashing for some reason + guard #available(iOS 16, *) else { + return nil + } + + let provider = LPMetadataProvider() + + do { + return try await provider.startFetchingMetadata(for: url) + } catch { + return nil + } + } + +} + +enum PreviewState { + case not_loaded + case loading + case loaded(Preview) + + var should_preload: Bool { + switch self { + case .loaded: + return false + case .loading: + return false + case .not_loaded: + return true + } + } } class PreviewCache { @@ -39,15 +80,6 @@ class PreviewCache { self.image_meta[evid] = image_fill } - func store(evid: String, preview: LPLinkMetadata?) { - switch preview { - case .none: - previews[evid] = .failed - case .some(let meta): - previews[evid] = .value(CachedMetadata(meta: meta)) - } - } - init() { self.previews = [:] self.image_meta = [:] diff --git a/damus/Views/Events/TextEvent.swift b/damus/Views/Events/TextEvent.swift @@ -23,11 +23,30 @@ struct EventViewOptions: OptionSet { static let embedded: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested] } +struct RelativeTime: View { + @ObservedObject var time: RelativeTimeModel + + var body: some View { + Text(verbatim: "\(time.value)") + .font(.system(size: 16)) + .foregroundColor(.gray) + } +} + struct TextEvent: View { let damus: DamusState let event: NostrEvent let pubkey: String let options: EventViewOptions + let evdata: EventData + + init(damus: DamusState, event: NostrEvent, pubkey: String, options: EventViewOptions) { + self.damus = damus + self.event = event + self.pubkey = pubkey + self.options = options + self.evdata = damus.events.get_cache_data(event.id) + } var has_action_bar: Bool { !options.contains(.no_action_bar) @@ -55,7 +74,7 @@ struct TextEvent: View { HStack(alignment: .center, spacing: 0) { ProfileName(is_anon: is_anon) TimeDot - Time + RelativeTime(time: self.evdata.relative_time) Spacer() ContextButton } @@ -106,12 +125,6 @@ struct TextEvent: View { .foregroundColor(.gray) } - var Time: some View { - Text(verbatim: "\(format_relative_time(event.created_at))") - .font(.system(size: 16)) - .foregroundColor(.gray) - } - var ContextButton: some View { EventMenuContext(event: event, keypair: damus.keypair, target_pubkey: event.pubkey, bookmarks: damus.bookmarks, muted_threads: damus.muted_threads) .padding([.bottom], 4) @@ -124,7 +137,17 @@ struct TextEvent: View { } func EvBody(options: EventViewOptions) -> some View { - return EventBody(damus_state: damus, event: event, size: .normal, options: options) + let show_imgs = should_show_images(settings: damus.settings, contacts: damus.contacts, ev: event, our_pubkey: damus.pubkey) + let artifacts = damus.events.get_cache_data(event.id).artifacts.artifacts ?? .just_content(event.get_content(damus.keypair.privkey)) + return NoteContentView( + damus_state: damus, + event: event, + show_images: show_imgs, + size: .normal, + artifacts: artifacts, + options: options + ) + .fixedSize(horizontal: false, vertical: true) } func Mention(_ mention: Mention) -> some View { @@ -162,6 +185,7 @@ struct TextEvent: View { TopPart(is_anon: is_anon) ReplyPart + EvBody(options: self.options) if let mention = get_mention() { diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift @@ -30,8 +30,12 @@ struct NoteContentView: View { let preview_height: CGFloat? let options: EventViewOptions - @State var artifacts: NoteArtifacts - @State var preview: LinkViewRepresentable? + @ObservedObject var artifacts_model: NoteArtifactsModel + @ObservedObject var preview_model: PreviewModel + + var artifacts: NoteArtifacts { + return self.artifacts_model.state.artifacts ?? .just_content(event.get_content(damus_state.keypair.privkey)) + } init(damus_state: DamusState, event: NostrEvent, show_images: Bool, size: EventViewKind, artifacts: NoteArtifacts, options: EventViewOptions) { self.damus_state = damus_state @@ -39,16 +43,10 @@ struct NoteContentView: View { self.show_images = show_images self.size = size self.options = options - self._artifacts = State(initialValue: artifacts) self.preview_height = lookup_cached_preview_size(previews: damus_state.previews, evid: event.id) - self._preview = State(initialValue: load_cached_preview(previews: damus_state.previews, evid: event.id)) - if let cache = damus_state.events.lookup_artifacts(evid: event.id) { - self._artifacts = State(initialValue: cache) - } else { - let artifacts = render_note_content(ev: event, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey) - damus_state.events.store_artifacts(evid: event.id, artifacts: artifacts) - self._artifacts = State(initialValue: artifacts) - } + let cached = damus_state.events.get_cache_data(event.id) + self._preview_model = ObservedObject(wrappedValue: cached.preview_model) + self._artifacts_model = ObservedObject(wrappedValue: cached.artifacts_model) } var truncate: Bool { @@ -59,6 +57,16 @@ struct NoteContentView: View { return options.contains(.pad_content) } + var preview: LinkViewRepresentable? { + guard show_images, + case .loaded(let preview) = preview_model.state, + case .value(let cached) = preview else { + return nil + } + + return LinkViewRepresentable(meta: .linkmeta(cached)) + } + var truncatedText: some View { Group { if truncate { @@ -151,6 +159,18 @@ struct NoteContentView: View { } } + func load() async { + guard let plan = get_preload_plan(cache: damus_state.events.get_cache_data(event.id), ev: event, our_keypair: damus_state.keypair, settings: damus_state.settings) else { + return + } + + let result = await preload_event(plan: plan, profiles: damus_state.profiles, our_keypair: damus_state.keypair, settings: damus_state.settings) + + DispatchQueue.main.async { + set_preload_results(plan: plan, res: result, privkey: damus_state.keypair.privkey) + } + } + var body: some View { MainContent .onReceive(handle_notify(.profile_updated)) { notif in @@ -160,7 +180,11 @@ struct NoteContentView: View { switch block { case .mention(let m): if m.type == .pubkey && m.ref.ref_id == profile.pubkey { - self.artifacts = render_note_content(ev: event, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey) + self.artifacts_model.state = .loading + Task.init { + await load() + } + return } case .relay: return case .text: return @@ -171,39 +195,10 @@ struct NoteContentView: View { } } .task { - guard self.preview == nil else { - return - } - - if show_images, artifacts.links.count == 1 { - let meta = await getMetaData(for: artifacts.links.first!) - - damus_state.previews.store(evid: self.event.id, preview: meta) - guard case .value(let cached) = damus_state.previews.lookup(self.event.id) else { - return - } - let view = LinkViewRepresentable(meta: .linkmeta(cached)) - - self.preview = view - } - + await load() } } - func getMetaData(for url: URL) async -> LPLinkMetadata? { - // iOS 15 is crashing for some reason - guard #available(iOS 16, *) else { - return nil - } - - let provider = LPMetadataProvider() - - do { - return try await provider.startFetchingMetadata(for: url) - } catch { - return nil - } - } } enum ImageName { @@ -274,6 +269,42 @@ struct NoteArtifacts: Equatable { } } +enum NoteArtifactState { + case not_loaded + case loading + case loaded(NoteArtifacts) + + var artifacts: NoteArtifacts? { + if case .loaded(let artifacts) = self { + return artifacts + } + + return nil + } + + var is_loaded: Bool { + switch self { + case .not_loaded: + return false + case .loading: + return false + case .loaded: + return true + } + } + + var should_preload: Bool { + switch self { + case .loaded: + return false + case .loading: + return false + case .not_loaded: + return true + } + } +} + func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: String?) -> NoteArtifacts { let blocks = ev.blocks(privkey) diff --git a/damus/Views/Timeline/InnerTimelineView.swift b/damus/Views/Timeline/InnerTimelineView.swift @@ -10,7 +10,7 @@ import SwiftUI struct InnerTimelineView: View { @ObservedObject var events: EventHolder - let damus: DamusState + let state: DamusState let show_friend_icon: Bool let filter: (NostrEvent) -> Bool @State var nav_target: NostrEvent @@ -18,7 +18,7 @@ struct InnerTimelineView: View { init(events: EventHolder, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool) { self.events = events - self.damus = damus + self.state = damus self.show_friend_icon = show_friend_icon self.filter = filter // dummy event to avoid MaybeThreadView @@ -26,7 +26,7 @@ struct InnerTimelineView: View { } var event_options: EventViewOptions { - if self.damus.settings.truncate_timeline_text { + if self.state.settings.truncate_timeline_text { return [.wide, .truncate_content] } @@ -34,8 +34,8 @@ struct InnerTimelineView: View { } var body: some View { - let thread = ThreadModel(event: nav_target, damus_state: damus) - let dest = ThreadView(state: damus, thread: thread) + let thread = ThreadModel(event: nav_target, damus_state: state) + let dest = ThreadView(state: state, thread: thread) NavigationLink(destination: dest, isActive: $navigating) { EmptyView() } @@ -44,13 +44,28 @@ struct InnerTimelineView: View { if events.isEmpty { EmptyTimelineView() } else { - ForEach(events.filter(filter), id: \.id) { (ev: NostrEvent) in - EventView(damus: damus, event: ev, options: event_options) + let evs = events.filter(filter) + let indexed = Array(zip(evs, 0...)) + ForEach(indexed, id: \.0.id) { tup in + let ev = tup.0 + let ind = tup.1 + EventView(damus: state, event: ev, options: event_options) .onTapGesture { - nav_target = ev.get_inner_event(cache: self.damus.events) ?? ev + nav_target = ev.get_inner_event(cache: state.events) ?? ev navigating = true } .padding(.top, 7) + .onAppear { + let to_preload = + Array([indexed[safe: ind+1]?.0, + indexed[safe: ind+2]?.0, + indexed[safe: ind+3]?.0, + indexed[safe: ind+4]?.0, + indexed[safe: ind+5]?.0 + ].compactMap({ $0 })) + + preload_events(event_cache: state.events, events: to_preload, profiles: state.profiles, our_keypair: state.keypair, settings: state.settings) + } ThiccDivider() .padding([.top], 7) @@ -58,6 +73,7 @@ struct InnerTimelineView: View { } } //.padding(.horizontal) + } } @@ -69,3 +85,4 @@ struct InnerTimelineView_Previews: PreviewProvider { .border(Color.red) } } +