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.
450 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
|