damus

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

NotificationsManager.swift (8967B)


      1 //
      2 //  NotificationsManager.swift
      3 //  damus
      4 //
      5 //  Handles several aspects of notification logic (Both local and push notifications)
      6 //
      7 //  Created by Daniel D’Aquino on 2023-11-24.
      8 //
      9 
     10 import Foundation
     11 import UIKit
     12 
     13 let EVENT_MAX_AGE_FOR_NOTIFICATION: TimeInterval = 12 * 60 * 60
     14 
     15 func process_local_notification(state: HeadlessDamusState, event ev: NostrEvent) {
     16     guard should_display_notification(state: state, event: ev) else {
     17         // We should not display notification. Exit.
     18         return
     19     }
     20 
     21     guard let local_notification = generate_local_notification_object(from: ev, state: state) else {
     22         return
     23     }
     24 
     25     create_local_notification(profiles: state.profiles, notify: local_notification)
     26 }
     27 
     28 func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent) -> Bool {
     29     if ev.known_kind == nil {
     30         return false
     31     }
     32 
     33     if state.settings.notification_only_from_following,
     34        state.contacts.follow_state(ev.pubkey) != .follows
     35         {
     36         return false
     37     }
     38 
     39     // Don't show notifications that match mute list.
     40     if state.mutelist_manager.is_event_muted(ev) {
     41         return false
     42     }
     43 
     44     // Don't show notifications for old events
     45     guard ev.age < EVENT_MAX_AGE_FOR_NOTIFICATION else {
     46         return false
     47     }
     48     
     49     return true
     50 }
     51 
     52 func generate_local_notification_object(from ev: NostrEvent, state: HeadlessDamusState) -> LocalNotification? {
     53     guard let type = ev.known_kind else {
     54         return nil
     55     }
     56     
     57     if type == .text, state.settings.mention_notification {
     58         let blocks = ev.blocks(state.keypair).blocks
     59         for case .mention(let mention) in blocks {
     60             guard case .pubkey(let pk) = mention.ref, pk == state.keypair.pubkey else {
     61                 continue
     62             }
     63             let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
     64             return LocalNotification(type: .mention, event: ev, target: ev, content: content_preview)
     65         }
     66     } else if type == .boost,
     67               state.settings.repost_notification,
     68               let inner_ev = ev.get_inner_event()
     69     {
     70         let content_preview = render_notification_content_preview(ev: inner_ev, profiles: state.profiles, keypair: state.keypair)
     71         return LocalNotification(type: .repost, event: ev, target: inner_ev, content: content_preview)
     72     } else if type == .like,
     73               state.settings.like_notification,
     74               let evid = ev.referenced_ids.last,
     75               let txn = state.ndb.lookup_note(evid, txn_name: "local_notification_like"),
     76               let liked_event = txn.unsafeUnownedValue?.to_owned()
     77     {
     78         let content_preview = render_notification_content_preview(ev: liked_event, profiles: state.profiles, keypair: state.keypair)
     79         return LocalNotification(type: .like, event: ev, target: liked_event, content: content_preview)
     80     }
     81     else if type == .dm,
     82             state.settings.dm_notification {
     83         let convo = ev.decrypted(keypair: state.keypair) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message")
     84         return LocalNotification(type: .dm, event: ev, target: ev, content: convo)
     85     }
     86     else if type == .zap,
     87             state.settings.zap_notification {
     88         return LocalNotification(type: .zap, event: ev, target: ev, content: ev.content)
     89     }
     90     
     91     return nil
     92 }
     93 
     94 func create_local_notification(profiles: Profiles, notify: LocalNotification) {
     95     let displayName = event_author_name(profiles: profiles, pubkey: notify.event.pubkey)
     96     
     97     guard let (content, identifier) = NotificationFormatter.shared.format_message(displayName: displayName, notify: notify) else { return }
     98 
     99     let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
    100 
    101     let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
    102 
    103     UNUserNotificationCenter.current().add(request) { error in
    104         if let error = error {
    105             print("Error: \(error)")
    106         } else {
    107             print("Local notification scheduled")
    108         }
    109     }
    110 }
    111 
    112 func render_notification_content_preview(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> String {
    113 
    114     let prefix_len = 300
    115     let artifacts = render_note_content(ev: ev, profiles: profiles, keypair: keypair)
    116 
    117     // special case for longform events
    118     if ev.known_kind == .longform {
    119         let longform = LongformEvent(event: ev)
    120         return longform.title ?? longform.summary ?? "Longform Event"
    121     }
    122     
    123     switch artifacts {
    124     case .longform:
    125         // we should never hit this until we have more note types built out of parts
    126         // since we handle this case above in known_kind == .longform
    127         return String(ev.content.prefix(prefix_len))
    128         
    129     case .separated(let artifacts):
    130         return String(NSAttributedString(artifacts.content.attributed).string.prefix(prefix_len))
    131     }
    132 }
    133 
    134 func event_author_name(profiles: Profiles, pubkey: Pubkey) -> String {
    135     let profile_txn = profiles.lookup(id: pubkey)
    136     let profile = profile_txn?.unsafeUnownedValue
    137     return Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
    138 }
    139 
    140 @MainActor
    141 func get_zap(from ev: NostrEvent, state: HeadlessDamusState) async -> Zap? {
    142     return await withCheckedContinuation { continuation in
    143         process_zap_event(state: state, ev: ev) { zapres in
    144             continuation.resume(returning: zapres.get_zap())
    145         }
    146     }
    147 }
    148 
    149 @MainActor
    150 func process_zap_event(state: HeadlessDamusState, ev: NostrEvent, completion: @escaping (ProcessZapResult) -> Void) {
    151     // These are zap notifications
    152     guard let ptag = get_zap_target_pubkey(ev: ev, ndb: state.ndb) else {
    153         completion(.failed)
    154         return
    155     }
    156 
    157     // just return the zap if we already have it
    158     if let zap = state.zaps.zaps[ev.id], case .zap(let z) = zap {
    159         completion(.already_processed(z))
    160         return
    161     }
    162     
    163     if let local_zapper = state.profiles.lookup_zapper(pubkey: ptag) {
    164         guard let zap = process_zap_event_with_zapper(state: state, ev: ev, zapper: local_zapper) else {
    165             completion(.failed)
    166             return
    167         }
    168         state.add_zap(zap: .zap(zap))
    169         completion(.done(zap))
    170         return
    171     }
    172     
    173     guard let txn = state.profiles.lookup_with_timestamp(ptag),
    174           let lnurl = txn.map({ pr in pr?.lnurl }).value else {
    175         completion(.failed)
    176         return
    177     }
    178 
    179     Task { [lnurl] in
    180         guard let zapper = await fetch_zapper_from_lnurl(lnurls: state.lnurls, pubkey: ptag, lnurl: lnurl) else {
    181             completion(.failed)
    182             return
    183         }
    184         
    185         DispatchQueue.main.async {
    186             state.profiles.profile_data(ptag).zapper = zapper
    187             guard let zap = process_zap_event_with_zapper(state: state, ev: ev, zapper: zapper) else {
    188                 completion(.failed)
    189                 return
    190             }
    191             state.add_zap(zap: .zap(zap))
    192             completion(.done(zap))
    193         }
    194     }
    195 }
    196 
    197 // securely get the zap target's pubkey. this can be faked so we need to be
    198 // careful
    199 func get_zap_target_pubkey(ev: NostrEvent, ndb: Ndb) -> Pubkey? {
    200     let etags = Array(ev.referenced_ids)
    201 
    202     guard let etag = etags.first else {
    203         // no etags, ptag-only case
    204 
    205         guard let a = ev.referenced_pubkeys.just_one() else {
    206             return nil
    207         }
    208 
    209         // TODO: just return data here
    210         return a
    211     }
    212 
    213     // we have an e-tag
    214 
    215     // ensure that there is only 1 etag to stop fake note zap attacks
    216     guard etags.count == 1 else {
    217         return nil
    218     }
    219 
    220     // we can't trust the p tag on note zaps because they can be faked
    221     guard let txn = ndb.lookup_note(etag),
    222           let pk = txn.unsafeUnownedValue?.pubkey else {
    223         // We don't have the event in cache so we can't check the pubkey.
    224 
    225         // We could return this as an invalid zap but that wouldn't be correct
    226         // all of the time, and may reject valid zaps. What we need is a new
    227         // unvalidated zap state, but for now we simply leak a bit of correctness...
    228 
    229         return ev.referenced_pubkeys.just_one()
    230     }
    231 
    232     return pk
    233 }
    234 
    235 fileprivate func process_zap_event_with_zapper(state: HeadlessDamusState, ev: NostrEvent, zapper: Pubkey) -> Zap? {
    236     let our_keypair = state.keypair
    237     
    238     guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: our_keypair.privkey) else {
    239         return nil
    240     }
    241     
    242     state.add_zap(zap: .zap(zap))
    243     
    244     return zap
    245 }
    246 
    247 enum ProcessZapResult {
    248     case already_processed(Zap)
    249     case done(Zap)
    250     case failed
    251     
    252     func get_zap() -> Zap? {
    253         switch self {
    254             case .already_processed(let zap):
    255                 return zap
    256             case .done(let zap):
    257                 return zap
    258             default:
    259                 return nil
    260         }
    261     }
    262 }