damus

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

commit c4f0e833ff1fb34fdebcba38f670c6f274defa80
parent 5db22ae24417b8e196b0f3fcd9091ec6c07ab791
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Fri,  1 Dec 2023 21:26:21 +0000

Reuse local notification logic with push notifications

Testing
-------

Conditional pass

Device: iPhone 15 Pro simulator
iOS: 17.0.1
Damus: This commit
Coverage:

1. Mention notification works (local and push). PASS

2. Thread replies do not appear (but upon code inspection it seems like
   it was not supported before). PASS?

3. DM notification works with decryption (local and push). PASS

4. Zaps not yet implemented. Coming later.

Closes: https://github.com/damus-io/damus/issues/1702
Closes: https://github.com/damus-io/damus/issues/1703
Changelog-Changed: Improve push notification support to match local notification support
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
ADamusNotificationService/NotificationExtensionState.swift | 29+++++++++++++++++++++++++++++
MDamusNotificationService/NotificationFormatter.swift | 40----------------------------------------
MDamusNotificationService/NotificationService.swift | 38+++++++++++++++++++++++++++++---------
Mdamus.xcodeproj/project.pbxproj | 10++++++++++
Mdamus/Models/DamusState.swift | 2+-
Adamus/Models/HeadlessDamusState.swift | 21+++++++++++++++++++++
Mdamus/Models/HomeModel.swift | 25+++++++------------------
Mdamus/Models/NotificationsManager.swift | 67+++++++++++++++++++++++++++++++++++++------------------------------
8 files changed, 134 insertions(+), 98 deletions(-)

diff --git a/DamusNotificationService/NotificationExtensionState.swift 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 @@ -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 @@ -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 @@ -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 = "<group>"; }; D74F430B2B23FB9B00425B75 /* StoreObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreObserver.swift; sourceTree = "<group>"; }; D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleWelcomeView.swift; sourceTree = "<group>"; }; + D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlessDamusState.swift; sourceTree = "<group>"; }; + D74AAFC42B1538DE006CF0F4 /* NotificationExtensionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationExtensionState.swift; sourceTree = "<group>"; }; D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileZapLinkView.swift; sourceTree = "<group>"; }; D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = "<group>"; }; D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = "<group>"; }; @@ -1554,6 +1559,7 @@ D7EDED1B2B1178FE0018B19C /* NoteContent.swift */, D7EDED1D2B11797D0018B19C /* LongformEvent.swift */, D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */, + D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */, ); path = Models; sourceTree = "<group>"; @@ -2603,6 +2609,7 @@ D79C4C162AFEB061003A41B4 /* NotificationService.swift */, D79C4C182AFEB061003A41B4 /* Info.plist */, D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */, + D74AAFC42B1538DE006CF0F4 /* NotificationExtensionState.swift */, ); path = DamusNotificationService; sourceTree = "<group>"; @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 - } - - guard let local_notification = generate_local_notification_object( - ndb: ndb, - from: ev, - settings: settings, - user_keypair: user_keypair, - profiles: profiles - ) else { - return + return false } - 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 }