NotificationService.swift (13769B)
1 // 2 // NotificationService.swift 3 // DamusNotificationService 4 // 5 // Created by Daniel D’Aquino on 2023-11-10. 6 // 7 8 import Kingfisher 9 import ImageIO 10 import UserNotifications 11 import Foundation 12 import UniformTypeIdentifiers 13 import Intents 14 15 class NotificationService: UNNotificationServiceExtension { 16 17 var contentHandler: ((UNNotificationContent) -> Void)? 18 var bestAttemptContent: UNMutableNotificationContent? 19 20 private func configureKingfisherCache() { 21 guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER) else { 22 return 23 } 24 25 let cachePath = groupURL.appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME) 26 if let cache = try? ImageCache(name: "sharedCache", cacheDirectoryURL: cachePath) { 27 KingfisherManager.shared.cache = cache 28 } 29 } 30 31 override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { 32 configureKingfisherCache() 33 34 self.contentHandler = contentHandler 35 36 guard let nostr_event_json = request.content.userInfo["nostr_event"] as? String, 37 let nostr_event = NdbNote.owned_from_json(json: nostr_event_json) 38 else { 39 // No nostr event detected. Just display the original notification 40 contentHandler(request.content) 41 return; 42 } 43 44 // Log that we got a push notification 45 Log.debug("Got nostr event push notification from pubkey %s", for: .push_notifications, nostr_event.pubkey.hex()) 46 47 guard let state = NotificationExtensionState() else { 48 Log.debug("Failed to open nostrdb", for: .push_notifications) 49 50 // Something failed to initialize so let's go for the next best thing 51 guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else { 52 // We cannot format this nostr event. Suppress notification. 53 contentHandler(UNNotificationContent()) 54 return 55 } 56 contentHandler(improved_content) 57 return 58 } 59 60 let sender_profile = { 61 let txn = state.ndb.lookup_profile(nostr_event.pubkey) 62 let profile = txn?.unsafeUnownedValue?.profile 63 let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))! 64 return ProfileBuf(picture: picture, 65 name: profile?.name, 66 display_name: profile?.display_name, 67 nip05: profile?.nip05) 68 }() 69 let sender_pubkey = nostr_event.pubkey 70 71 // Don't show notification details that match mute list. 72 // TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block 73 if state.mutelist_manager.is_event_muted(nostr_event) { 74 // We cannot really suppress muted notifications until we have the notification supression entitlement. 75 // The best we can do if we ever get those muted notifications (which we generally won't due to server-side processing) is to obscure the details 76 let content = UNMutableNotificationContent() 77 content.title = NSLocalizedString("Muted event", comment: "Title for a push notification which has been muted") 78 content.body = NSLocalizedString("This is an event that has been muted according to your mute list rules. We cannot suppress this notification, but we obscured the details to respect your preferences", comment: "Description for a push notification which has been muted, and explanation that we cannot suppress it") 79 content.sound = UNNotificationSound.default 80 contentHandler(content) 81 return 82 } 83 84 guard should_display_notification(state: state, event: nostr_event, mode: .push) else { 85 Log.debug("should_display_notification failed", for: .push_notifications) 86 // We should not display notification for this event. Suppress notification. 87 // contentHandler(UNNotificationContent()) 88 // TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification 89 contentHandler(request.content) 90 return 91 } 92 93 guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else { 94 Log.debug("generate_local_notification_object failed", for: .push_notifications) 95 // We could not process this notification. Probably an unsupported nostr event kind. Suppress. 96 // contentHandler(UNNotificationContent()) 97 // TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification 98 contentHandler(request.content) 99 return 100 } 101 102 103 Task { 104 let sender_dn = DisplayName(name: sender_profile.name, display_name: sender_profile.display_name, pubkey: sender_pubkey) 105 guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: sender_dn.displayName, notify: notification_object, state: state) else { 106 107 Log.debug("NotificationFormatter.format_message failed", for: .push_notifications) 108 return 109 } 110 111 do { 112 var options: [AnyHashable: Any] = [:] 113 if let imageSource = CGImageSourceCreateWithURL(sender_profile.picture as CFURL, nil), 114 let uti = CGImageSourceGetType(imageSource) { 115 options[UNNotificationAttachmentOptionsTypeHintKey] = uti 116 } 117 118 let attachment = try UNNotificationAttachment(identifier: sender_profile.picture.absoluteString, url: sender_profile.picture, options: options) 119 improvedContent.attachments = [attachment] 120 } catch { 121 Log.error("failed to get notification attachment: %s", for: .push_notifications, error.localizedDescription) 122 } 123 124 let kind = nostr_event.known_kind 125 126 // these aren't supported yet 127 if !(kind == .text || kind == .dm) { 128 contentHandler(improvedContent) 129 return 130 } 131 132 // rich communication notifications for kind1, dms, etc 133 134 let message_intent = await message_intent_from_note(ndb: state.ndb, 135 sender_profile: sender_profile, 136 content: improvedContent.body, 137 note: nostr_event, 138 our_pubkey: state.keypair.pubkey) 139 140 improvedContent.threadIdentifier = nostr_event.thread_id().hex() 141 improvedContent.categoryIdentifier = "COMMUNICATION" 142 143 let interaction = INInteraction(intent: message_intent, response: nil) 144 interaction.direction = .incoming 145 do { 146 try await interaction.donate() 147 let updated = try improvedContent.updating(from: message_intent) 148 contentHandler(updated) 149 } catch { 150 Log.error("failed to donate interaction: %s", for: .push_notifications, error.localizedDescription) 151 contentHandler(improvedContent) 152 } 153 } 154 } 155 156 override func serviceExtensionTimeWillExpire() { 157 // Called just before the extension will be terminated by the system. 158 // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. 159 if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { 160 contentHandler(bestAttemptContent) 161 } 162 } 163 164 } 165 166 struct ProfileBuf { 167 let picture: URL 168 let name: String? 169 let display_name: String? 170 let nip05: String? 171 } 172 173 func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: String, note: NdbNote, our_pubkey: Pubkey) async -> INSendMessageIntent { 174 let sender_pk = note.pubkey 175 let sender = await profile_to_inperson(name: sender_profile.name, 176 display_name: sender_profile.display_name, 177 picture: sender_profile.picture.absoluteString, 178 nip05: sender_profile.nip05, 179 pubkey: sender_pk, 180 our_pubkey: our_pubkey) 181 182 let conversationIdentifier = note.thread_id().hex() 183 var recipients: [INPerson] = [] 184 var pks: [Pubkey] = [] 185 let meta = INSendMessageIntentDonationMetadata() 186 187 // gather recipients 188 if let recipient_note_id = note.direct_replies() { 189 let replying_to = ndb.lookup_note(recipient_note_id) 190 if let replying_to_pk = replying_to?.unsafeUnownedValue?.pubkey { 191 meta.isReplyToCurrentUser = replying_to_pk == our_pubkey 192 193 if replying_to_pk != sender_pk { 194 // we push the actual person being replied to first 195 pks.append(replying_to_pk) 196 } 197 } 198 } 199 200 let pubkeys = Array(note.referenced_pubkeys) 201 meta.recipientCount = pubkeys.count 202 if pubkeys.contains(sender_pk) { 203 meta.recipientCount -= 1 204 } 205 206 for pk in pubkeys.prefix(3) { 207 if pk == sender_pk || pks.contains(pk) { 208 continue 209 } 210 211 if !meta.isReplyToCurrentUser && pk == our_pubkey { 212 meta.mentionsCurrentUser = true 213 } 214 215 pks.append(pk) 216 } 217 218 for pk in pks { 219 let recipient = await pubkey_to_inperson(ndb: ndb, pubkey: pk, our_pubkey: our_pubkey) 220 recipients.append(recipient) 221 } 222 223 // we enable default formatting this way 224 var groupName = INSpeakableString(spokenPhrase: "") 225 226 // otherwise we just say its a DM 227 if note.known_kind == .dm { 228 groupName = INSpeakableString(spokenPhrase: "DM") 229 } 230 231 let intent = INSendMessageIntent(recipients: recipients, 232 outgoingMessageType: .outgoingMessageText, 233 content: content, 234 speakableGroupName: groupName, 235 conversationIdentifier: conversationIdentifier, 236 serviceName: "kind\(note.kind)", 237 sender: sender, 238 attachments: nil) 239 intent.donationMetadata = meta 240 241 // this is needed for recipients > 0 242 if let img = sender.image { 243 intent.setImage(img, forParameterNamed: \.speakableGroupName) 244 } 245 246 return intent 247 } 248 249 func pubkey_to_inperson(ndb: Ndb, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson { 250 let profile_txn = ndb.lookup_profile(pubkey) 251 let profile = profile_txn?.unsafeUnownedValue?.profile 252 let name = profile?.name 253 let display_name = profile?.display_name 254 let nip05 = profile?.nip05 255 let picture = profile?.picture 256 257 return await profile_to_inperson(name: name, 258 display_name: display_name, 259 picture: picture, 260 nip05: nip05, 261 pubkey: pubkey, 262 our_pubkey: our_pubkey) 263 } 264 265 func fetch_pfp(picture: URL) async throws -> RetrieveImageResult { 266 try await withCheckedThrowingContinuation { continuation in 267 KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: picture)) { result in 268 switch result { 269 case .success(let img): 270 continuation.resume(returning: img) 271 case .failure(let error): 272 continuation.resume(throwing: error) 273 } 274 } 275 } 276 } 277 278 func profile_to_inperson(name: String?, display_name: String?, picture: String?, nip05: String?, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson { 279 let npub = pubkey.npub 280 let handle = INPersonHandle(value: npub, type: .unknown) 281 var aliases: [INPersonHandle] = [] 282 283 if let nip05 { 284 aliases.append(INPersonHandle(value: nip05, type: .emailAddress)) 285 } 286 287 let nostrName = DisplayName(name: name, display_name: display_name, pubkey: pubkey) 288 let nameComponents = nostrName.nameComponents() 289 let displayName = nostrName.displayName 290 let contactIdentifier = npub 291 let customIdentifier = npub 292 let suggestionType = INPersonSuggestionType.socialProfile 293 294 var image: INImage? = nil 295 296 if let picture, 297 let url = URL(string: picture), 298 let img = try? await fetch_pfp(picture: url), 299 let imgdata = img.data() 300 { 301 image = INImage(imageData: imgdata) 302 } else { 303 Log.error("Failed to fetch pfp (%s) for %s", for: .push_notifications, picture ?? "nil", displayName) 304 } 305 306 let person = INPerson(personHandle: handle, 307 nameComponents: nameComponents, 308 displayName: displayName, 309 image: image, 310 contactIdentifier: contactIdentifier, 311 customIdentifier: customIdentifier, 312 isMe: pubkey == our_pubkey, 313 suggestionType: suggestionType 314 ) 315 316 return person 317 } 318 319 func robohash(_ pk: Pubkey) -> String { 320 return "https://robohash.org/" + pk.hex() 321 } 322 323