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 }