damus

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

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