diff --git a/DamusNotificationService/NotificationExtensionState.swift b/DamusNotificationService/NotificationExtensionState.swift new file mode 100644 index 00000000..7bb1c810 --- /dev/null +++ b/DamusNotificationService/NotificationExtensionState.swift @@ -0,0 +1,29 @@ +// +// NotificationExtensionState.swift +// DamusNotificationService +// +// Created by Daniel D’Aquino on 2023-11-27. +// + +import Foundation + +struct NotificationExtensionState: HeadlessDamusState { + let ndb: Ndb + let settings: UserSettingsStore + let contacts: Contacts + let muted_threads: MutedThreadsManager + let keypair: Keypair + let profiles: Profiles + + init?() { + guard let ndb = try? Ndb(owns_db_file: false) else { return nil } + self.ndb = ndb + self.settings = UserSettingsStore() + + guard let keypair = get_saved_keypair() else { return nil } + self.contacts = Contacts(our_pubkey: keypair.pubkey) + self.muted_threads = MutedThreadsManager(keypair: keypair) + self.keypair = keypair + self.profiles = Profiles(ndb: ndb) + } +} diff --git a/DamusNotificationService/NotificationFormatter.swift b/DamusNotificationService/NotificationFormatter.swift index 56d2ad62..06698259 100644 --- a/DamusNotificationService/NotificationFormatter.swift +++ b/DamusNotificationService/NotificationFormatter.swift @@ -13,46 +13,6 @@ struct NotificationFormatter { // MARK: - Formatting with NdbNote - // TODO: Prepare a `LocalNotification` object from `NdbNote` to reuse Notification formatting code from Local notifications - func format_message(event: NdbNote, ndb: Ndb?) -> UNMutableNotificationContent? { - guard let txn = ndb?.lookup_profile(event.pubkey), - let display_name = txn.unsafeUnownedValue?.profile?.display_name - else { - return self.format_message(event: event) - } - - return self.format_message(event: event, display_name: display_name) - } - - func format_message(event: NdbNote, display_name: String) -> UNMutableNotificationContent? { - guard let best_attempt_content: UNMutableNotificationContent = self.format_message(event: event) else { return nil } - - switch event.known_kind { - case .text: - best_attempt_content.title = String(format: NSLocalizedString("%@ posted a note", comment: "Title label for push notification where a user posted a note"), display_name) - break - case .dm: - best_attempt_content.title = String(format: NSLocalizedString("New message from %@", comment: "Title label for push notifications where a direct message was sent to the user"), display_name) - break - case .like: - guard let reaction_emoji = to_reaction_emoji(ev: event) else { - best_attempt_content.title = String(format: NSLocalizedString("%@ reacted to your note", comment: "Reaction heading in local/push notification"), display_name) - best_attempt_content.body = "" - break - } - best_attempt_content.title = String(format: NSLocalizedString("%@ reacted with %@", comment: "Reacted by heading in local notification"), display_name, reaction_emoji) - best_attempt_content.body = "" - break - case .zap: - best_attempt_content.title = String(format: NSLocalizedString("%@ zapped you ⚡️", comment: "Title label for a push notification where someone zapped the user"), display_name) - break - default: - return nil - } - - return best_attempt_content - } - func format_message(event: NdbNote) -> UNMutableNotificationContent? { let content = UNMutableNotificationContent() if let event_json_data = try? JSONEncoder().encode(event), // Must be encoded, as the notification completion handler requires this object to conform to `NSSecureCoding` diff --git a/DamusNotificationService/NotificationService.swift b/DamusNotificationService/NotificationService.swift index 2da9509e..dfdc354e 100644 --- a/DamusNotificationService/NotificationService.swift +++ b/DamusNotificationService/NotificationService.swift @@ -16,24 +16,44 @@ class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler - let ndb: Ndb? = try? Ndb(owns_db_file: false) - - // Modify the notification content here... - guard let nostrEventJSON = request.content.userInfo["nostr_event"] as? String, - let nostrEvent = NdbNote.owned_from_json(json: nostrEventJSON) + guard let nostr_event_json = request.content.userInfo["nostr_event"] as? String, + let nostr_event = NdbNote.owned_from_json(json: nostr_event_json) else { + // No nostr event detected. Just display the original notification contentHandler(request.content) return; } // Log that we got a push notification - if let txn = ndb?.lookup_profile(nostrEvent.pubkey) { - Log.debug("Got push notification from %s (%s)", for: .push_notifications, (txn.unsafeUnownedValue?.profile?.display_name ?? "Unknown"), nostrEvent.pubkey.hex()) + Log.debug("Got nostr event push notification from pubkey %s", for: .push_notifications, nostr_event.pubkey.hex()) + + guard let state = NotificationExtensionState(), + let display_name = state.ndb.lookup_profile(nostr_event.pubkey).unsafeUnownedValue?.profile?.display_name // We are not holding the txn here. + else { + // Something failed to initialize so let's go for the next best thing + guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else { + // We cannot format this nostr event. Suppress notification. + contentHandler(UNNotificationContent()) + return + } + contentHandler(improved_content) + return } - if let improvedContent = NotificationFormatter.shared.format_message(event: nostrEvent, ndb: ndb) { - contentHandler(improvedContent) + guard should_display_notification(state: state, event: nostr_event) else { + // We should not display notification for this event. Suppress notification. + contentHandler(UNNotificationContent()) + return } + + guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else { + // We could not process this notification. Probably an unsupported nostr event kind. Suppress. + contentHandler(UNNotificationContent()) + return + } + + let (improvedContent, _) = NotificationFormatter.shared.format_message(displayName: display_name, notify: notification_object) + contentHandler(improvedContent) } override func serviceExtensionTimeWillExpire() { diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 08d94170..80876cbd 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -448,6 +448,9 @@ D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F43092B23F0BE00425B75 /* DamusPurple.swift */; }; D74F430C2B23FB9B00425B75 /* StoreObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F430B2B23FB9B00425B75 /* StoreObserver.swift */; }; D76556D62B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */; }; + D74AAFC22B153395006CF0F4 /* HeadlessDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */; }; + D74AAFC32B153395006CF0F4 /* HeadlessDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */; }; + D74AAFC52B1538DF006CF0F4 /* NotificationExtensionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFC42B1538DE006CF0F4 /* NotificationExtensionState.swift */; }; D76874F32AE3632B00FB0F68 /* ProfileZapLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */; }; D77BFA0B2AE3051200621634 /* ProfileActionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */; }; D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */; }; @@ -1319,6 +1322,8 @@ D74F43092B23F0BE00425B75 /* DamusPurple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurple.swift; sourceTree = ""; }; D74F430B2B23FB9B00425B75 /* StoreObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreObserver.swift; sourceTree = ""; }; D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleWelcomeView.swift; sourceTree = ""; }; + D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlessDamusState.swift; sourceTree = ""; }; + D74AAFC42B1538DE006CF0F4 /* NotificationExtensionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationExtensionState.swift; sourceTree = ""; }; D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileZapLinkView.swift; sourceTree = ""; }; D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = ""; }; D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = ""; }; @@ -1554,6 +1559,7 @@ D7EDED1B2B1178FE0018B19C /* NoteContent.swift */, D7EDED1D2B11797D0018B19C /* LongformEvent.swift */, D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */, + D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */, ); path = Models; sourceTree = ""; @@ -2603,6 +2609,7 @@ D79C4C162AFEB061003A41B4 /* NotificationService.swift */, D79C4C182AFEB061003A41B4 /* Info.plist */, D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */, + D74AAFC42B1538DE006CF0F4 /* NotificationExtensionState.swift */, ); path = DamusNotificationService; sourceTree = ""; @@ -3001,6 +3008,7 @@ BA4AB0B02A63B94D0070A32A /* EmojiListItemView.swift in Sources */, 4C75EFB328049D640006080F /* NostrEvent.swift in Sources */, 4C32B9582A9AD44700DC3548 /* VeriferOptions.swift in Sources */, + D74AAFC22B153395006CF0F4 /* HeadlessDamusState.swift in Sources */, 4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */, 4C30AC7629A5770900E2BD5A /* NotificationItemView.swift in Sources */, 4C86F7C42A76C44C00EC0817 /* ZappingNotify.swift in Sources */, @@ -3408,6 +3416,7 @@ D7CE1B292B0BE239002EDAD4 /* node_id.c in Sources */, D7EDED2C2B128CFA0018B19C /* DamusColors.swift in Sources */, D7CE1B2E2B0BE25C002EDAD4 /* talstr.c in Sources */, + D74AAFC52B1538DF006CF0F4 /* NotificationExtensionState.swift in Sources */, D798D2292B08686C00234419 /* ContentParsing.swift in Sources */, D798D2242B0859C900234419 /* LocalizationUtil.swift in Sources */, D7CE1B322B0BE6C3002EDAD4 /* NdbTxn.swift in Sources */, @@ -3439,6 +3448,7 @@ D7CB5D542B1174F700AD4105 /* NIP05.swift in Sources */, D798D2232B0859B700234419 /* KeychainStorage.swift in Sources */, D7CB5D432B116F9B00AD4105 /* MutedThreadsManager.swift in Sources */, + D74AAFC32B153395006CF0F4 /* HeadlessDamusState.swift in Sources */, D7CE1B272B0BE224002EDAD4 /* bech32_util.c in Sources */, D7CCFC102B05880F00323D86 /* Id.swift in Sources */, D7CB5D532B1174E900AD4105 /* DeepLPlan.swift in Sources */, diff --git a/damus/Models/DamusState.swift b/damus/Models/DamusState.swift index 61fe394f..14d3fe91 100644 --- a/damus/Models/DamusState.swift +++ b/damus/Models/DamusState.swift @@ -8,7 +8,7 @@ import Foundation import LinkPresentation -struct DamusState { +struct DamusState: HeadlessDamusState { let pool: RelayPool let keypair: Keypair let likes: EventCounter diff --git a/damus/Models/HeadlessDamusState.swift b/damus/Models/HeadlessDamusState.swift new file mode 100644 index 00000000..8ab0ce57 --- /dev/null +++ b/damus/Models/HeadlessDamusState.swift @@ -0,0 +1,21 @@ +// +// HeadlessDamusState.swift +// damus +// +// Created by Daniel D’Aquino on 2023-11-27. +// + +import Foundation + +/// HeadlessDamusState +/// +/// A protocl for a lighter headless alternative to DamusState that does not have dependencies on View objects or UI logic. +/// This is useful in limited environments (e.g. Notification Service Extension) where we do not want View/UI dependencies +protocol HeadlessDamusState { + var ndb: Ndb { get } + var settings: UserSettingsStore { get } + var contacts: Contacts { get } + var muted_threads: MutedThreadsManager { get } + var keypair: Keypair { get } + var profiles: Profiles { get } +} diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index ac2dc07b..f7af6f0e 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -602,7 +602,7 @@ class HomeModel { } if handle_last_event(ev: ev, timeline: .notifications) { - process_local_notification(damus_state: damus_state, event: ev) + process_local_notification(state: damus_state, event: ev) } } @@ -644,11 +644,13 @@ class HomeModel { func got_new_dm(notifs: NewEventsBits, ev: NostrEvent) { notification_status.new_events = notifs - if damus_state.settings.dm_notification && ev.age < HomeModel.event_max_age_for_notification { - let convo = ev.decrypted(keypair: self.damus_state.keypair) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message") - let notify = LocalNotification(type: .dm, event: ev, target: ev, content: convo) - create_local_notification(profiles: damus_state.profiles, notify: notify) + guard should_display_notification(state: damus_state, event: ev), + let notification_object = generate_local_notification_object(from: ev, state: damus_state) + else { + return } + + create_local_notification(profiles: damus_state.profiles, notify: notification_object) } func handle_dm(_ ev: NostrEvent) { @@ -1161,19 +1163,6 @@ func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale: } } -func process_local_notification(damus_state: DamusState, event ev: NostrEvent) { - process_local_notification( - ndb: damus_state.ndb, - settings: damus_state.settings, - contacts: damus_state.contacts, - muted_threads: damus_state.muted_threads, - user_keypair: damus_state.keypair, - profiles: damus_state.profiles, - event: ev - ) -} - - enum ProcessZapResult { case already_processed(Zap) case done(Zap) diff --git a/damus/Models/NotificationsManager.swift b/damus/Models/NotificationsManager.swift index 7415b2e1..ea1cd16a 100644 --- a/damus/Models/NotificationsManager.swift +++ b/damus/Models/NotificationsManager.swift @@ -12,68 +12,75 @@ import UIKit let EVENT_MAX_AGE_FOR_NOTIFICATION: TimeInterval = 12 * 60 * 60 -func process_local_notification(ndb: Ndb, settings: UserSettingsStore, contacts: Contacts, muted_threads: MutedThreadsManager, user_keypair: Keypair, profiles: Profiles, event ev: NostrEvent) { - if ev.known_kind == nil { +func process_local_notification(state: HeadlessDamusState, event ev: NostrEvent) { + guard should_display_notification(state: state, event: ev) else { + // We should not display notification. Exit. return } - if settings.notification_only_from_following, - contacts.follow_state(ev.pubkey) != .follows - { + guard let local_notification = generate_local_notification_object(from: ev, state: state) else { return } + create_local_notification(profiles: state.profiles, notify: local_notification) +} + +func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent) -> Bool { + if ev.known_kind == nil { + return false + } + + if state.settings.notification_only_from_following, + state.contacts.follow_state(ev.pubkey) != .follows + { + return false + } // Don't show notifications from muted threads. - if muted_threads.isMutedThread(ev, keypair: user_keypair) { - return + if state.muted_threads.isMutedThread(ev, keypair: state.keypair) { + return false } // Don't show notifications for old events guard ev.age < EVENT_MAX_AGE_FOR_NOTIFICATION else { - return + return false } - - guard let local_notification = generate_local_notification_object( - ndb: ndb, - from: ev, - settings: settings, - user_keypair: user_keypair, - profiles: profiles - ) else { - return - } - create_local_notification(profiles: profiles, notify: local_notification) + + return true } - -func generate_local_notification_object(ndb: Ndb, from ev: NostrEvent, settings: UserSettingsStore, user_keypair: Keypair, profiles: Profiles) -> LocalNotification? { +func generate_local_notification_object(from ev: NostrEvent, state: HeadlessDamusState) -> LocalNotification? { guard let type = ev.known_kind else { return nil } - if type == .text, settings.mention_notification { - let blocks = ev.blocks(user_keypair).blocks + if type == .text, state.settings.mention_notification { + let blocks = ev.blocks(state.keypair).blocks for case .mention(let mention) in blocks { - guard case .pubkey(let pk) = mention.ref, pk == user_keypair.pubkey else { + guard case .pubkey(let pk) = mention.ref, pk == state.keypair.pubkey else { continue } - let content_preview = render_notification_content_preview(ev: ev, profiles: profiles, keypair: user_keypair) + let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair) return LocalNotification(type: .mention, event: ev, target: ev, content: content_preview) } } else if type == .boost, - settings.repost_notification, + state.settings.repost_notification, let inner_ev = ev.get_inner_event() { - let content_preview = render_notification_content_preview(ev: inner_ev, profiles: profiles, keypair: user_keypair) + let content_preview = render_notification_content_preview(ev: inner_ev, profiles: state.profiles, keypair: state.keypair) return LocalNotification(type: .repost, event: ev, target: inner_ev, content: content_preview) } else if type == .like, - settings.like_notification, + state.settings.like_notification, let evid = ev.referenced_ids.last, - let liked_event = ndb.lookup_note(evid).unsafeUnownedValue // We are only accessing it temporarily to generate notification content + let liked_event = state.ndb.lookup_note(evid).unsafeUnownedValue // We are only accessing it temporarily to generate notification content { - let content_preview = render_notification_content_preview(ev: liked_event, profiles: profiles, keypair: user_keypair) + let content_preview = render_notification_content_preview(ev: liked_event, profiles: state.profiles, keypair: state.keypair) return LocalNotification(type: .like, event: ev, target: liked_event, content: content_preview) } + else if type == .dm, + state.settings.dm_notification { + let convo = ev.decrypted(keypair: state.keypair) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message") + return LocalNotification(type: .dm, event: ev, target: ev, content: convo) + } return nil }