damus

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

NotificationsManager.swift (10307B)


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