Files
damus/damus/Util/EventCache.swift
William Casarin 69fc6694f1 nwc: turn pending zap orange when we have a NWC success
Orange means payment successful now, not just presence of zap

This introduces a paid pending state, which shows up as an orange timer
thing in the zaps view. This can be useful if the zap is never sent. We
don't want the user to think the payment didn't go through.
2023-05-13 23:30:03 -07:00

450 lines
13 KiB
Swift

//
// EventCache.swift
// damus
//
// Created by William Casarin on 2023-02-21.
//
import Combine
import Foundation
import UIKit
import LinkPresentation
import Kingfisher
class ImageMetadataState {
var state: ImageMetaProcessState
var meta: ImageMetadata
init(state: ImageMetaProcessState, meta: ImageMetadata) {
self.state = state
self.meta = meta
}
}
enum ImageMetaProcessState {
case processing
case failed
case processed(UIImage)
case not_needed
}
class TranslationModel: ObservableObject {
@Published var note_language: String?
@Published var state: TranslateStatus
init(state: TranslateStatus) {
self.state = state
self.note_language = nil
}
}
class NoteArtifactsModel: ObservableObject {
@Published var state: NoteArtifactState
init(state: NoteArtifactState) {
self.state = state
}
}
class PreviewModel: ObservableObject {
@Published var state: PreviewState
init(state: PreviewState) {
self.state = state
}
}
class ZapsDataModel: ObservableObject {
@Published var zaps: [Zapping]
init(_ zaps: [Zapping]) {
self.zaps = zaps
}
func confirm_nwc(reqid: String) {
guard let zap = zaps.first(where: { z in z.request.id == reqid }),
case .pending(let pzap) = zap
else {
return
}
switch pzap.state {
case .external:
break
case .nwc(let nwc_state):
if nwc_state.update_state(state: .confirmed) {
self.objectWillChange.send()
}
}
}
var zap_total: Int64 {
zaps.reduce(0) { total, zap in total + zap.amount }
}
func from(_ pubkey: String) -> [Zapping] {
return self.zaps.filter { z in z.request.pubkey == pubkey }
}
@discardableResult
func remove(reqid: String) -> Bool {
guard zaps.first(where: { z in z.request.id == reqid }) != nil else {
return false
}
self.zaps = zaps.filter { z in z.request.id != reqid }
return true
}
}
class RelativeTimeModel: ObservableObject {
@Published var value: String = ""
}
class EventData {
var translations_model: TranslationModel
var artifacts_model: NoteArtifactsModel
var preview_model: PreviewModel
var zaps_model : ZapsDataModel
var relative_time: RelativeTimeModel = 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
}
init(zaps: [Zapping] = []) {
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)
}
}
class EventCache {
private var events: [String: NostrEvent] = [:]
private var replies = ReplyMap()
private var cancellable: AnyCancellable?
private var image_metadata: [String: ImageMetadataState] = [:]
private var event_data: [String: EventData] = [:]
//private var thread_latest: [String: Int64]
init() {
cancellable = NotificationCenter.default.publisher(
for: UIApplication.didReceiveMemoryWarningNotification
).sink { [weak self] _ in
self?.prune()
}
}
func get_cache_data(_ evid: String) -> EventData {
guard let data = event_data[evid] else {
let data = EventData()
event_data[evid] = data
return data
}
return data
}
func is_event_valid(_ evid: String) -> ValidationResult {
return get_cache_data(evid).validated
}
func store_event_validation(evid: String, validated: ValidationResult) {
get_cache_data(evid).validated = validated
}
@discardableResult
func store_zap(zap: Zapping) -> Bool {
let data = get_cache_data(zap.target.id).zaps_model
return insert_uniq_sorted_zap_by_amount(zaps: &data.zaps, new_zap: zap)
}
func remove_zap(zap: Zapping) {
switch zap.target {
case .note(let note_target):
let zaps = get_cache_data(note_target.note_id).zaps_model
zaps.remove(reqid: zap.request.id)
case .profile:
// these aren't stored anywhere yet
break
}
}
func lookup_zaps(target: ZapTarget) -> [Zapping] {
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_img_metadata(url: URL) -> ImageMetadataState? {
return image_metadata[url.absoluteString.lowercased()]
}
func parent_events(event: NostrEvent) -> [NostrEvent] {
var parents: [NostrEvent] = []
var ev = event
while true {
guard let direct_reply = ev.direct_replies(nil).last else {
break
}
guard let next_ev = lookup(direct_reply.ref_id), next_ev != ev else {
break
}
parents.append(next_ev)
ev = next_ev
}
return parents.reversed()
}
func add_replies(ev: NostrEvent) {
for reply in ev.direct_replies(nil) {
replies.add(id: reply.ref_id, reply_id: ev.id)
}
}
func child_events(event: NostrEvent) -> [NostrEvent] {
guard let xs = replies.lookup(event.id) else {
return []
}
let evs: [NostrEvent] = xs.reduce(into: [], { evs, evid in
guard let ev = self.lookup(evid) else {
return
}
evs.append(ev)
}).sorted(by: { $0.created_at < $1.created_at })
return evs
}
func upsert(_ ev: NostrEvent) -> NostrEvent {
if let found = lookup(ev.id) {
return found
}
insert(ev)
return ev
}
func lookup(_ evid: String) -> NostrEvent? {
return events[evid]
}
func insert(_ ev: NostrEvent) {
guard events[ev.id] == nil else {
return
}
events[ev.id] = ev
}
private func prune() {
events = [:]
event_data = [:]
replies.replies = [:]
}
}
func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore, note_lang: String?) -> 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
}
if let note_lang {
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 false
}
}
// we should start translating if we have auto_translate on
return true
}
func should_preload_translation(event: NostrEvent, our_keypair: Keypair, current_status: TranslateStatus, settings: UserSettingsStore, note_lang: String?) -> Bool {
switch current_status {
case .havent_tried:
return should_translate(event: event, our_keypair: our_keypair, settings: settings, note_lang: note_lang) && settings.auto_translate
case .translating: return false
case .translated: return false
case .not_needed: return false
}
}
struct PreloadPlan {
let data: EventData
let img_metadata: [ImageMetadata]
let event: NostrEvent
var 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(evcache: EventCache, ev: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore) -> PreloadPlan? {
let cache = evcache.get_cache_data(ev.id)
let load_artifacts = cache.artifacts.should_preload
if load_artifacts {
cache.artifacts_model.state = .loading
}
// Cached event might not have the note language determined yet, so determine the language here before figuring out if translations should be preloaded.
let note_lang = cache.translations_model.note_language ?? ev.note_language(our_keypair.privkey) ?? current_language()
let load_translations = should_preload_translation(event: ev, our_keypair: our_keypair, current_status: cache.translations, settings: settings, note_lang: note_lang)
if load_translations {
cache.translations_model.state = .translating
}
let load_urls = event_image_metadata(ev: ev)
.reduce(into: [ImageMetadata]()) { to_load, meta in
let cached = evcache.lookup_img_metadata(url: meta.url)
guard cached == nil else {
return
}
let m = ImageMetadataState(state: .processing, meta: meta)
evcache.store_img_metadata(url: meta.url, meta: m)
to_load.append(meta)
}
let load_preview = cache.preview.should_preload
if load_preview {
cache.preview_model.state = .loading
}
if !load_artifacts && !load_translations && !load_preview && load_urls.count == 0 {
return nil
}
return PreloadPlan(data: cache, img_metadata: load_urls, event: ev, load_artifacts: load_artifacts, load_translations: load_translations, load_preview: load_preview)
}
func preload_image(url: URL) {
if ImageCache.default.isCached(forKey: url.absoluteString) {
print("Preloaded image \(url.absoluteString) found in cache")
// looks like we already have it cached. no download needed
return
}
print("Preloading image \(url.absoluteString)")
KingfisherManager.shared.retrieveImage(with: ImageResource(downloadURL: url)) { val in
print("Preloaded image \(url.absoluteString)")
}
}
func preload_event(plan: PreloadPlan, state: DamusState) async {
var artifacts: NoteArtifacts? = plan.data.artifacts.artifacts
let settings = state.settings
let profiles = state.profiles
let our_keypair = state.keypair
print("Preloading event \(plan.event.content)")
if artifacts == nil && plan.load_artifacts {
let arts = render_note_content(ev: plan.event, profiles: profiles, privkey: our_keypair.privkey)
artifacts = arts
// we need these asap
DispatchQueue.main.async {
plan.data.artifacts_model.state = .loaded(arts)
}
for url in arts.images {
preload_image(url: url)
}
}
if plan.load_preview {
let arts = artifacts ?? render_note_content(ev: plan.event, profiles: profiles, privkey: our_keypair.privkey)
let preview = await load_preview(artifacts: arts)
DispatchQueue.main.async {
if let preview {
plan.data.preview_model.state = .loaded(preview)
} else {
plan.data.preview_model.state = .loaded(.failed)
}
}
}
let note_language = plan.data.translations_model.note_language ?? plan.event.note_language(our_keypair.privkey) ?? current_language()
var translations: TranslateStatus? = nil
// We have to recheck should_translate here now that we have note_language
if plan.load_translations && should_translate(event: plan.event, our_keypair: our_keypair, settings: settings, note_lang: note_language) && settings.auto_translate
{
translations = await translate_note(profiles: profiles, privkey: our_keypair.privkey, event: plan.event, settings: settings, note_lang: note_language)
}
let ts = translations
if plan.data.translations_model.note_language == nil || ts != nil {
DispatchQueue.main.async {
if let ts {
plan.data.translations_model.state = ts
}
if plan.data.translations_model.note_language != note_language {
plan.data.translations_model.note_language = note_language
}
}
}
}
func preload_events(state: DamusState, events: [NostrEvent]) {
let event_cache = state.events
let our_keypair = state.keypair
let settings = state.settings
let plans = events.compactMap { ev in
get_preload_plan(evcache: event_cache, ev: ev, our_keypair: our_keypair, settings: settings)
}
if plans.count == 0 {
return
}
Task.init {
for plan in plans {
await preload_event(plan: plan, state: state)
}
}
}