damus

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

commit 4324b185fe1ec5638bbf284a797ca988cf906ac0
parent 1ab9b30b852bae8d9ef935e31b6f1099cc529752
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Wed, 12 Feb 2025 18:25:39 -0800

Improve open action handling for notifications

Push notifications were not opened reliably. To improve robustness, the
following changes were introduced:
1. The notification opening logic was updated to become more similar to
   URL handling, in a way that uses better defined interfaces and
   functions that provide better result guarantees, by separating
   complex handling logic, and the side-effects/mutations that
   are made after computing the open action — instead of relying on a
   complex logic function that produces side-effects as a result, which
   obfuscates the actual behavior of the function.
2. The LoadableThreadView was expanded and renamed to
   LoadableNostrEventView, to reflect that it can also handle non-thread
   nostr events, such as DMs, which is a necessity for handling push
   notifications.
3. A new type of Notify object, the `QueueableNotify` was introduced, to
   address issues where the listener/handler is not instantiated at the
   time the app notifies that there is a push notification to be opened.
   This was implemented using async streams, which simplifies the usage
   of this down to a simple "for-in" loop.

Closes: https://github.com/damus-io/damus/issues/2825
Changelog-Fixed: Fixed issue where some push notifications would not open in the app and leave users confused
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 24++++++++++++++++--------
Mdamus/ContentView.swift | 98+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mdamus/Models/URLHandler.swift | 4++--
Mdamus/Notify/LocalNotificationNotify.swift | 22+++++++---------------
Adamus/Notify/QueueableNotify.swift | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Util/Router.swift | 10+++++-----
Adamus/Views/LoadableNostrEventView.swift | 275+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ddamus/Views/LoadableThreadView.swift | 216-------------------------------------------------------------------------------
Mdamus/damusApp.swift | 5++++-
9 files changed, 453 insertions(+), 291 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -1048,6 +1048,9 @@ D706C5AF2D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706C5AE2D5D31B20027C627 /* AutoSaveIndicatorView.swift */; }; D706C5B02D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706C5AE2D5D31B20027C627 /* AutoSaveIndicatorView.swift */; }; D706C5B12D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706C5AE2D5D31B20027C627 /* AutoSaveIndicatorView.swift */; }; + D706C5B72D602A110027C627 /* QueueableNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706C5B62D602A050027C627 /* QueueableNotify.swift */; }; + D706C5B82D602A110027C627 /* QueueableNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706C5B62D602A050027C627 /* QueueableNotify.swift */; }; + D706C5B92D602A110027C627 /* QueueableNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706C5B62D602A050027C627 /* QueueableNotify.swift */; }; D70A3B172B02DCE5008BD568 /* NotificationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */; }; D70D90982CDED61800CD0534 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = D70D90972CDED61800CD0534 /* CodeScanner */; }; D70D909C2CDED7B200CD0534 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = D70D909B2CDED7B200CD0534 /* CodeScanner */; }; @@ -1451,9 +1454,9 @@ D74EA08F2D2E271E002290DD /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA08D2D2E271E002290DD /* ErrorView.swift */; }; D74EA0902D2E271E002290DD /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA08D2D2E271E002290DD /* ErrorView.swift */; }; D74EA0912D2E3464002290DD /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D767066E2C8BB3CE00F09726 /* URLHandler.swift */; }; - D74EA0932D2E77B9002290DD /* LoadableThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableThreadView.swift */; }; - D74EA0942D2E77B9002290DD /* LoadableThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableThreadView.swift */; }; - D74EA0952D2E77B9002290DD /* LoadableThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableThreadView.swift */; }; + D74EA0932D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */; }; + D74EA0942D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */; }; + D74EA0952D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */; }; D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F43092B23F0BE00425B75 /* DamusPurple.swift */; }; D74F430C2B23FB9B00425B75 /* StoreObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F430B2B23FB9B00425B75 /* StoreObserver.swift */; }; D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D753CEA92BE9DE04001C3A5D /* MutingTests.swift */; }; @@ -2420,6 +2423,7 @@ D703D7262C66E47100A400EA /* highlighter action extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "highlighter action extension.entitlements"; sourceTree = "<group>"; }; D703D72A2C66F29500A400EA /* getSelection.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = getSelection.js; sourceTree = "<group>"; }; D706C5AE2D5D31B20027C627 /* AutoSaveIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoSaveIndicatorView.swift; sourceTree = "<group>"; }; + D706C5B62D602A050027C627 /* QueueableNotify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueableNotify.swift; sourceTree = "<group>"; }; D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFormatter.swift; sourceTree = "<group>"; }; D7100C552B76F8E600C59298 /* PurpleViewPrimitives.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleViewPrimitives.swift; sourceTree = "<group>"; }; D7100C572B76FC8400C59298 /* MarketingContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketingContentView.swift; sourceTree = "<group>"; }; @@ -2453,7 +2457,7 @@ D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Zaps+.swift"; sourceTree = "<group>"; }; D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WalletConnect+.swift"; sourceTree = "<group>"; }; D74EA08D2D2E271E002290DD /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; }; - D74EA0922D2E77B9002290DD /* LoadableThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableThreadView.swift; sourceTree = "<group>"; }; + D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableNostrEventView.swift; sourceTree = "<group>"; }; 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>"; }; D753CEA92BE9DE04001C3A5D /* MutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutingTests.swift; sourceTree = "<group>"; }; @@ -3177,7 +3181,7 @@ D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */, D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */, D71AD8FC2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift */, - D74EA0922D2E77B9002290DD /* LoadableThreadView.swift */, + D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */, ); path = Views; sourceTree = "<group>"; @@ -3355,6 +3359,7 @@ 4CA3529C2A76AE47003BB08B /* Notify */ = { isa = PBXGroup; children = ( + D706C5B62D602A050027C627 /* QueueableNotify.swift */, D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */, 4C86F7C52A76C51100EC0817 /* AttachedWalletNotify.swift */, 4C9D6D152B1AA9C6004E5CD9 /* DisplayTabBarNotify.swift */, @@ -4624,6 +4629,7 @@ 9CA876E229A00CEA0003B9A3 /* AttachMediaUtility.swift in Sources */, D734B1452CCC19B1000B5C97 /* DamusFullScreenCover.swift in Sources */, 4C4E137D2A76D63600BDD832 /* UnmuteThreadNotify.swift in Sources */, + D706C5B72D602A110027C627 /* QueueableNotify.swift in Sources */, 4CE4F0F829DB7399005914DB /* ThiccDivider.swift in Sources */, 4CFF8F5929C9FD1E008DB934 /* DamusPurpleView.swift in Sources */, 4CE0E2B629A3ED5500DB4CA2 /* InnerTimelineView.swift in Sources */, @@ -4718,7 +4724,7 @@ 4CA927612A290E340098A105 /* EventShell.swift in Sources */, 4C363AA428296DEE006E126D /* SearchModel.swift in Sources */, 4C8D00CC29DF92DF0036AF10 /* Hashtags.swift in Sources */, - D74EA0942D2E77B9002290DD /* LoadableThreadView.swift in Sources */, + D74EA0942D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */, 4CEE2AF3280B25C500AB5EEF /* ProfilePicView.swift in Sources */, 4CC7AAF6297F1A6A00430951 /* EventBody.swift in Sources */, D76556D62B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift in Sources */, @@ -4989,7 +4995,7 @@ 82D6FAEC2CD99F7900C925F4 /* OnlyZapsNotify.swift in Sources */, 82D6FAED2CD99F7900C925F4 /* PostNotify.swift in Sources */, 82D6FAEE2CD99F7900C925F4 /* PresentSheetNotify.swift in Sources */, - D74EA0932D2E77B9002290DD /* LoadableThreadView.swift in Sources */, + D74EA0932D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */, 82D6FAEF2CD99F7900C925F4 /* ProfileUpdatedNotify.swift in Sources */, 82D6FAF02CD99F7900C925F4 /* ReportNotify.swift in Sources */, 82D6FAF12CD99F7900C925F4 /* ScrollToTopNotify.swift in Sources */, @@ -5350,6 +5356,7 @@ 82D6FC522CD99F7900C925F4 /* DMView.swift in Sources */, 82D6FC532CD99F7900C925F4 /* EmptyTimelineView.swift in Sources */, 82D6FC542CD99F7900C925F4 /* EmptyUserSearchView.swift in Sources */, + D706C5B82D602A110027C627 /* QueueableNotify.swift in Sources */, 82D6FC552CD99F7900C925F4 /* EventView.swift in Sources */, 82D6FC562CD99F7900C925F4 /* EventDetailView.swift in Sources */, 82D6FC572CD99F7900C925F4 /* FollowButtonView.swift in Sources */, @@ -5526,7 +5533,7 @@ D73E5E9C2C6A97F4007EB227 /* Reply.swift in Sources */, D73E5E9D2C6A97F4007EB227 /* SearchModel.swift in Sources */, D73E5E9E2C6A97F4007EB227 /* NostrFilter+Hashable.swift in Sources */, - D74EA0952D2E77B9002290DD /* LoadableThreadView.swift in Sources */, + D74EA0952D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */, D73E5F912C6AA71B007EB227 /* InputDismissKeyboard.swift in Sources */, D73E5E9F2C6A97F4007EB227 /* CreateAccountModel.swift in Sources */, D73E5EA12C6A97F4007EB227 /* SignalModel.swift in Sources */, @@ -5553,6 +5560,7 @@ D73E5EB42C6A97F4007EB227 /* NoteContent.swift in Sources */, D73E5EB52C6A97F4007EB227 /* LongformEvent.swift in Sources */, D73E5EB62C6A97F4007EB227 /* PushNotificationClient.swift in Sources */, + D706C5B92D602A110027C627 /* QueueableNotify.swift in Sources */, D71AD8FD2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift in Sources */, D73E5EB72C6A97F4007EB227 /* HighlightEvent.swift in Sources */, D73E5EB82C6A97F4007EB227 /* RelayConnection.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -222,12 +222,6 @@ struct ContentView: View { navigationCoordinator.push(route: Route.Script(script: model)) } - func open_profile(pubkey: Pubkey) { - let profile_model = ProfileModel(pubkey: pubkey, damus: damus_state!) - let followers = FollowersModel(damus_state: damus_state!, target: pubkey) - navigationCoordinator.push(route: Route.Profile(profile: profile_model, followers: followers)) - } - func open_search(filt: NostrFilter) { let search = SearchModel(state: damus_state!, search: filt) navigationCoordinator.push(route: Route.Search(search: search)) @@ -312,6 +306,9 @@ struct ContentView: View { hasSeenOnboardingSuggestions = true } self.appDelegate?.state = damus_state + Task { // We probably don't need this to be a detached task. According to https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Defining-and-Calling-Asynchronous-Functions, awaits are only suspension points that do not block the thread. + await self.listenAndHandleLocalNotifications() + } } .sheet(item: $active_sheet) { item in switch item { @@ -514,27 +511,6 @@ struct ContentView: View { break } } - .onReceive(handle_notify(.local_notification)) { local in - guard let damus_state else { return } - - switch local.mention { - case .pubkey(let pubkey): - open_profile(pubkey: pubkey) - - case .note(let noteId): - openEvent(noteId: noteId, notificationType: local.type) - case .nevent(let nevent): - openEvent(noteId: nevent.noteid, notificationType: local.type) - case .nprofile(let nprofile): - open_profile(pubkey: nprofile.author) - case .nrelay(_): - break - case .naddr(let naddr): - break - } - - - } .onReceive(handle_notify(.onlyzaps_mode)) { hide in home.filter_events() @@ -643,6 +619,28 @@ struct ContentView: View { self.selected_timeline = timeline } + /// Listens to requests to open a push/local user notification + /// + /// This function never returns, it just keeps streaming + func listenAndHandleLocalNotifications() async { + for await notification in await QueueableNotify<LossyLocalNotification>.shared.stream { + self.handleNotification(notification: notification) + } + } + + func handleNotification(notification: LossyLocalNotification) { + Log.info("ContentView is handling a notification", for: .push_notifications) + guard let damus_state else { + // This should never happen because `listenAndHandleLocalNotifications` is called after damus state is initialized in `onAppear` + assertionFailure("DamusState not loaded when ContentView (new handler) was handling a notification") + Log.error("DamusState not loaded when ContentView (new handler) was handling a notification", for: .push_notifications) + return + } + let local = notification + let openAction = local.toViewOpenAction() + self.execute_open_action(openAction) + } + func connect() { // nostrdb var mndb = Ndb() @@ -748,23 +746,6 @@ struct ContentView: View { damus_state.postbox.send(ev) } } - - private func openEvent(noteId: NoteId, notificationType: LocalNotificationType) { - guard let target = damus_state.events.lookup(noteId) else { - return - } - - switch notificationType { - case .dm: - selected_timeline = .dms - damus_state.dms.set_active_dm(target.pubkey) - navigationCoordinator.push(route: Route.DMChat(dms: damus_state.dms.active_model)) - case .like, .zap, .mention, .repost, .reply, .tagged: - open_event(ev: target) - case .profile_zap: - break - } - } /// An open action within the app /// This is used to model, store, and communicate a desired view action to be taken as a result of opening an object, @@ -1218,6 +1199,35 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev } } +extension LossyLocalNotification { + /// Computes a view open action from a mention reference. + /// Use this when opening a user-presentable interface to a specific mention reference. + func toViewOpenAction() -> ContentView.ViewOpenAction { + switch self.mention { + case .pubkey(let pubkey): + return .route(.ProfileByKey(pubkey: pubkey)) + case .note(let noteId): + return .route(.LoadableNostrEvent(note_reference: .note_id(noteId))) + case .nevent(let nEvent): + // TODO: Improve this by implementing a route that handles nevents with their relay hints. + return .route(.LoadableNostrEvent(note_reference: .note_id(nEvent.noteid))) + case .nprofile(let nProfile): + // TODO: Improve this by implementing a profile route that handles nprofiles with their relay hints. + return .route(.ProfileByKey(pubkey: nProfile.author)) + case .nrelay(let string): + // We do not need to implement `nrelay` support, it has been deprecated. + // See https://github.com/nostr-protocol/nips/blob/6e7a618e7f873bb91e743caacc3b09edab7796a0/BREAKING.md?plain=1#L21 + return .sheet(.error(ErrorView.UserPresentableError( + user_visible_description: NSLocalizedString("You opened an invalid link. The link you tried to open refers to \"nrelay\", which has been deprecated and is not supported.", comment: "User-visible error description for a user who tries to open a deprecated \"nrelay\" link."), + tip: NSLocalizedString("Please contact the person who provided the link, and ask for another link.", comment: "User-visible tip on what to do if a link contains a deprecated \"nrelay\" reference."), + technical_info: "`MentionRef.toViewOpenAction` detected deprecated `nrelay` contents" + ))) + case .naddr(let nAddr): + return .route(.LoadableNostrEvent(note_reference: .naddr(nAddr))) + } + } +} + func logout(_ state: DamusState?) { diff --git a/damus/Models/URLHandler.swift b/damus/Models/URLHandler.swift @@ -34,7 +34,7 @@ struct DamusURLHandler { let thread = await ThreadModel(event: nostrEvent, damus_state: damus_state) return .route(.Thread(thread: thread)) case .event_reference(let event_reference): - return .route(.ThreadFromReference(note_reference: event_reference)) + return .route(.LoadableNostrEvent(note_reference: event_reference)) case .wallet_connect(let walletConnectURL): damus_state.wallet.new(walletConnectURL) return .route(.Wallet(wallet: damus_state.wallet)) @@ -99,7 +99,7 @@ struct DamusURLHandler { case profile(Pubkey) case filter(NostrFilter) case event(NostrEvent) - case event_reference(LoadableThreadModel.NoteReference) + case event_reference(LoadableNostrEventViewModel.NoteReference) case wallet_connect(WalletConnectURL) case script([UInt8]) case purple(DamusPurpleURL) diff --git a/damus/Notify/LocalNotificationNotify.swift b/damus/Notify/LocalNotificationNotify.swift @@ -7,19 +7,11 @@ import Foundation -struct LocalNotificationNotify: Notify { - typealias Payload = LossyLocalNotification - var payload: Payload -} - -extension NotifyHandler { - static var local_notification: NotifyHandler<LocalNotificationNotify> { - .init() - } -} - -extension Notifications { - static func local_notification(_ payload: LossyLocalNotification) -> Notifications<LocalNotificationNotify> { - .init(.init(payload: payload)) - } +extension QueueableNotify<LossyLocalNotification> { + /// A shared singleton for opening local and push user notifications + /// + /// ## Implementation notes + /// + /// - The queue can only hold one element. This is done because if the user hypothetically opened 10 push notifications and there was a lag, we wouldn't want the app to suddenly open 10 different things. + static let shared = QueueableNotify(maxQueueItems: 1) } diff --git a/damus/Notify/QueueableNotify.swift b/damus/Notify/QueueableNotify.swift @@ -0,0 +1,90 @@ +// +// QueueableNotify.swift +// damus +// +// Created by Daniel D’Aquino on 2025-02-14. +// + +/// This notifies another object about some payload, +/// with automatic "queueing" of messages if there are no listeners. +/// +/// When used as a singleton, this can be used to easily send notifications to be handled at the app-level. +/// +/// This serves the same purpose as `Notify`, except this implements the queueing of messages, +/// which means that messages can be handled even if the listener is not instantiated yet. +/// +/// **Example:** The app delegate can send some events that need handling from `ContentView` — but some can occur before `ContentView` is even instantiated. +/// +/// +/// ## Usage notes +/// +/// - This code was mainly written to have one listener at a time. Have more than one listener may be possible, but this class has not been tested/optimized for that purpose. +/// +/// +/// ## Implementation notes +/// +/// - This makes heavy use of `AsyncStream` and continuations, because that allows complexities here to be handled elegantly with a simple "for-in" loop +/// - Without this, it would take a couple of callbacks and manual handling of queued items to achieve the same effect +/// - Modeled as an `actor` for extra thread-safety +actor QueueableNotify<T: Sendable> { + /// The continuation, which allows us to publish new items to the listener + /// If `nil`, that means there is no listeners to the stream, which is used for determining whether to queue new incoming items. + private var continuation: AsyncStream<T>.Continuation? + /// Holds queue items + private var queue: [T] = [] + /// The maximum amount of items allowed in the queue. Older items will be discarded from the queue after it is full + var maxQueueItems: Int + + /// Initializes the object + /// - Parameter maxQueueItems: The maximum amount of items allowed in the queue. Older items will be discarded from the queue after it is full + init(maxQueueItems: Int) { + self.maxQueueItems = maxQueueItems + } + + /// The async stream, used for listening for notifications + /// + /// This will first stream the queued "inbox" items that the listener may have missed, and then it will do a real-time stream of new items as they come in. + /// + /// Example: + /// + /// ```swift + /// for await notification in queueableNotify.stream { + /// // Do something with the notification + /// } + /// ``` + var stream: AsyncStream<T> { + return AsyncStream { continuation in + // Stream queued "inbox" items that the listener may have missed + for item in queue { + continuation.yield(item) + } + + // Clean up if the stream closes + continuation.onTermination = { continuation in + Task { await self.cleanup() } + } + + // Point to this stream, so that it can receive new updates + self.continuation = continuation + } + } + + /// Cleans up after a stream is closed by the listener + private func cleanup() { + self.continuation = nil // This will cause new items to be queued for when another listener is attached + } + + /// Adds a new notification item to be handled by a listener. + /// + /// This will automatically stream the new item to the listener, or queue the item if no one is listening + func add(item: T) { + while queue.count >= maxQueueItems { queue.removeFirst() } // Ensures queue stays within the desired size + guard let continuation else { + // No one is listening, queue it (send it to an inbox for later handling) + queue.append(item) + return + } + // Send directly to the active listener stream + continuation.yield(item) + } +} diff --git a/damus/Util/Router.swift b/damus/Util/Router.swift @@ -32,7 +32,7 @@ enum Route: Hashable { case DeveloperSettings(settings: UserSettingsStore) case FirstAidSettings(settings: UserSettingsStore) case Thread(thread: ThreadModel) - case ThreadFromReference(note_reference: LoadableThreadModel.NoteReference) + case LoadableNostrEvent(note_reference: LoadableNostrEventViewModel.NoteReference) case Reposts(reposts: EventsModel) case QuoteReposts(quotes: EventsModel) case Reactions(reactions: EventsModel) @@ -97,8 +97,8 @@ enum Route: Hashable { case .Thread(let thread): ChatroomThreadView(damus: damusState, thread: thread) //ThreadView(state: damusState, thread: thread) - case .ThreadFromReference(let note_reference): - LoadableThreadView(state: damusState, note_reference: note_reference) + case .LoadableNostrEvent(let note_reference): + LoadableNostrEventView(state: damusState, note_reference: note_reference) case .Reposts(let reposts): RepostsView(damus_state: damusState, model: reposts) case .QuoteReposts(let quote_reposts): @@ -190,8 +190,8 @@ enum Route: Hashable { case .Thread(let threadModel): hasher.combine("thread") hasher.combine(threadModel.original_event.id) - case .ThreadFromReference(note_reference: let note_reference): - hasher.combine("thread_from_reference") + case .LoadableNostrEvent(note_reference: let note_reference): + hasher.combine("loadable_nostr_event") hasher.combine(note_reference) case .Reposts(let reposts): hasher.combine("reposts") diff --git a/damus/Views/LoadableNostrEventView.swift b/damus/Views/LoadableNostrEventView.swift @@ -0,0 +1,275 @@ +// +// LoadableNostrEventView.swift +// damus +// +// Created by Daniel D'Aquino on 2025-01-08. +// + +import SwiftUI + + +/// A view model for `LoadableNostrEventView` +/// +/// This takes a nostr event reference, automatically tries to load it, and updates itself to reflect its current state +/// +/// ## Implementation notes +/// +/// - This is on the main actor because `ObservableObjects` with `Published` properties should be on the main actor for thread-safety. +/// +@MainActor +class LoadableNostrEventViewModel: ObservableObject { + let damus_state: DamusState + let note_reference: NoteReference + @Published var state: ThreadModelLoadingState = .loading + /// The time period after which it will give up loading the view. + /// Written in nanoseconds + let TIMEOUT: UInt64 = 10 * 1_000_000_000 // 10 seconds + + init(damus_state: DamusState, note_reference: NoteReference) { + self.damus_state = damus_state + self.note_reference = note_reference + Task { await self.load() } + } + + func load() async { + // Start the loading process in a separate task to manage the timeout independently. + let loadTask = Task { @MainActor in + self.state = await executeLoadingLogic(note_reference: self.note_reference) + } + + // Setup a timer to cancel the load after the timeout period + let timeoutTask = Task { @MainActor in + try await Task.sleep(nanoseconds: TIMEOUT) + loadTask.cancel() // This sends a cancellation signal to the load task. + self.state = .not_found + } + + await loadTask.value + timeoutTask.cancel() // Cancel the timeout task if loading finishes earlier. + } + + /// Asynchronously find an event from NostrDB or from the network (if not available on NostrDB) + private func loadEvent(noteId: NoteId) async -> NostrEvent? { + let res = await find_event(state: damus_state, query: .event(evid: noteId)) + guard let res, case .event(let ev) = res else { return nil } + return ev + } + + /// Gets the note reference and tries to load it, outputting a new state for this view model. + private func executeLoadingLogic(note_reference: NoteReference) async -> ThreadModelLoadingState { + switch note_reference { + case .note_id(let note_id): + guard let ev = await self.loadEvent(noteId: note_id) else { return .not_found } + guard let known_kind = ev.known_kind else { return .unknown_or_unsupported_kind } + switch known_kind { + case .text, .highlight: + return .loaded(route: Route.Thread(thread: ThreadModel(event: ev, damus_state: damus_state))) + case .dm: + let dm_model = damus_state.dms.lookup_or_create(ev.pubkey) + return .loaded(route: Route.DMChat(dms: dm_model)) + case .like: + // Load the event that this reaction refers to. + guard let first_referenced_note_id = ev.referenced_ids.first else { return .not_found } + return await self.executeLoadingLogic(note_reference: .note_id(first_referenced_note_id)) + case .zap, .zap_request: + guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found } + return .loaded(route: Route.Zaps(target: zap.target)) + case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .zap, .zap_request, .nwc_request, .nwc_response, .http_auth, .status: + return .unknown_or_unsupported_kind + } + case .naddr(let naddr): + guard let event = await naddrLookup(damus_state: damus_state, naddr: naddr) else { return .not_found } + return .loaded(route: Route.Thread(thread: ThreadModel(event: event, damus_state: damus_state))) + } + } + + enum ThreadModelLoadingState { + case loading + case loaded(route: Route) + case not_found + case unknown_or_unsupported_kind + } + + enum NoteReference: Hashable { + case note_id(NoteId) + case naddr(NAddr) + } +} + +/// A view for a Nostr event that has not been loaded yet. +/// This takes a Nostr event reference and loads it, while providing nice loading UX and graceful error handling. +struct LoadableNostrEventView: View { + let state: DamusState + @StateObject var loadableModel: LoadableNostrEventViewModel + var loading: Bool { + switch loadableModel.state { + case .loading: + return true + case .loaded, .not_found, .unknown_or_unsupported_kind: + return false + } + } + + init(state: DamusState, note_reference: LoadableNostrEventViewModel.NoteReference) { + self.state = state + self._loadableModel = StateObject.init(wrappedValue: LoadableNostrEventViewModel(damus_state: state, note_reference: note_reference)) + } + + var body: some View { + switch self.loadableModel.state { + case .loading: + ScrollView(.vertical) { + self.skeleton + .redacted(reason: loading ? .placeholder : []) + .shimmer(loading) + .accessibilityElement(children: .ignore) + .accessibilityLabel(NSLocalizedString("Loading thread", comment: "Accessibility label for the thread view when it is loading")) + } + case .loaded(route: let route): + route.view(navigationCoordinator: state.nav, damusState: state) + case .not_found: + self.not_found + case .unknown_or_unsupported_kind: + self.unknown_or_unsupported_kind + } + } + + var not_found: some View { + SomethingWrong( + imageSystemName: "questionmark.app", + heading: NSLocalizedString("Note not found", comment: "Heading for the thread view in a not found error state."), + description: NSLocalizedString("We were unable to find the note you were looking for.", comment: "Text for the thread view when it is unable to find the note the user is looking for"), + advice: NSLocalizedString("Try checking the link again, your internet connection, or contact the person who provided you the link for help.", comment: "Tips on what to do if a note cannot be found.") + ) + } + + var unknown_or_unsupported_kind: some View { + SomethingWrong( + imageSystemName: "questionmark.app", + heading: NSLocalizedString("Can’t display note", comment: "User-visible heading for an error message indicating a note has an unknown kind or is unsupported for viewing."), + description: NSLocalizedString("We do not yet support viewing this type of content.", comment: "User-visible description of an error indicating a note has an unknown kind or is unsupported for viewing."), + advice: NSLocalizedString("Please try opening this content on another Nostr app that supports this type of content.", comment: "User-visible advice on what to do if they see the error indicating a note has an unknown kind or is unsupported for viewing.") + ) + } + + // MARK: Skeleton views + // Implementation notes + // - No localization is needed because the text will be redacted + // - No accessibility label is needed because these will be summarized into a single accessibility label at the top-level view. See `body` in this struct + + var skeleton: some View { + VStack(alignment: .leading, spacing: 40) { + Self.skeleton_selected_event + Self.skeleton_chat_event(message: "Nice! Have you tried Damus?", right: false) + Self.skeleton_chat_event(message: "Yes, it's awesome.", right: true) + Spacer() + } + .padding() + } + + static func skeleton_chat_event(message: String, right: Bool) -> some View { + HStack(alignment: .center) { + if !right { + self.skeleton_chat_user_avatar + } + else { + Spacer() + } + ChatBubble( + direction: right ? .right : .left, + stroke_content: Color.accentColor.opacity(0), + stroke_style: .init(lineWidth: 4), + background_style: Color.secondary.opacity(0.5), + content: { + Text(verbatim: message) + .padding() + } + ) + if right { + self.skeleton_chat_user_avatar + } + else { + Spacer() + } + } + } + + static var skeleton_selected_event: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Circle() + .frame(width: 50, height: 50) + .foregroundStyle(.secondary.opacity(0.5)) + Text(verbatim: "Satoshi Nakamoto") + .bold() + } + Text(verbatim: "Nostr is the super app. Because it’s actually an ecosystem of apps, all of which make each other better. People haven’t grasped that yet. They will when it’s more accessible and onboarding is more straightforward and intuitive.") + HStack { + self.skeleton_action_item + Spacer() + self.skeleton_action_item + Spacer() + self.skeleton_action_item + Spacer() + self.skeleton_action_item + } + } + } + + static var skeleton_chat_user_avatar: some View { + Circle() + .fill(.secondary.opacity(0.5)) + .frame(width: 35, height: 35) + .padding(.bottom, -21) + } + + static var skeleton_action_item: some View { + Circle() + .fill(Color.secondary.opacity(0.5)) + .frame(width: 25, height: 25) + } +} + +extension LoadableNostrEventView { + struct SomethingWrong: View { + let imageSystemName: String + let heading: String + let description: String + let advice: String + + var body: some View { + VStack(spacing: 6) { + Image(systemName: imageSystemName) + .resizable() + .frame(width: 30, height: 30) + .accessibilityHidden(true) + Text(heading) + .font(.title) + .bold() + .padding(.bottom, 10) + Text(description) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 5) { + Image(systemName: "sparkles") + .accessibilityHidden(true) + Text("Advice", comment: "Heading for some advice text to help the user with an error") + .font(.headline) + } + Text(advice) + } + .padding() + .background(Color.secondary.opacity(0.2)) + .cornerRadius(10) + .padding(.vertical, 30) + } + .padding() + } + } +} + +#Preview("Loadable") { + LoadableNostrEventView(state: test_damus_state, note_reference: .note_id(test_thread_note_1.id)) +} diff --git a/damus/Views/LoadableThreadView.swift b/damus/Views/LoadableThreadView.swift @@ -1,216 +0,0 @@ -// -// LoadableThreadView.swift -// damus -// -// Created by Daniel D'Aquino on 2025-01-08. -// - -import SwiftUI - - -/// A view model for `LoadableThreadView` -/// -/// This takes a note reference, automatically tries to load it, and updates itself to reflect its current state -/// -/// -class LoadableThreadModel: ObservableObject { - let damus_state: DamusState - let note_reference: NoteReference - @Published var state: ThreadModelLoadingState = .loading - /// The time period after which it will give up loading the view. - /// Written in nanoseconds - let TIMEOUT: UInt64 = 10 * 1_000_000_000 // 10 seconds - - init(damus_state: DamusState, note_reference: NoteReference) { - self.damus_state = damus_state - self.note_reference = note_reference - Task { await self.load() } - } - - func load() async { - // Start the loading process in a separate task to manage the timeout independently. - let loadTask = Task { @MainActor in - self.state = await executeLoadingLogic() - } - - // Setup a timer to cancel the load after the timeout period - let timeoutTask = Task { @MainActor in - try await Task.sleep(nanoseconds: TIMEOUT) - loadTask.cancel() // This sends a cancellation signal to the load task. - self.state = .not_found - } - - await loadTask.value - timeoutTask.cancel() // Cancel the timeout task if loading finishes earlier. - } - - private func executeLoadingLogic() async -> ThreadModelLoadingState { - switch note_reference { - case .note_id(let note_id): - let res = await find_event(state: damus_state, query: .event(evid: note_id)) - guard let res, case .event(let ev) = res else { return .not_found } - return .loaded(model: await ThreadModel(event: ev, damus_state: damus_state)) - case .naddr(let naddr): - guard let event = await naddrLookup(damus_state: damus_state, naddr: naddr) else { return .not_found } - return .loaded(model: await ThreadModel(event: event, damus_state: damus_state)) - } - } - - enum ThreadModelLoadingState { - case loading - case loaded(model: ThreadModel) - case not_found - } - - enum NoteReference: Hashable { - case note_id(NoteId) - case naddr(NAddr) - } -} - -struct LoadableThreadView: View { - let state: DamusState - @StateObject var loadable_thread: LoadableThreadModel - var loading: Bool { - switch loadable_thread.state { - case .loading: - return true - case .loaded, .not_found: - return false - } - } - - init(state: DamusState, note_reference: LoadableThreadModel.NoteReference) { - self.state = state - self._loadable_thread = StateObject.init(wrappedValue: LoadableThreadModel(damus_state: state, note_reference: note_reference)) - } - - var body: some View { - switch self.loadable_thread.state { - case .loading: - ScrollView(.vertical) { - self.skeleton - .redacted(reason: loading ? .placeholder : []) - .shimmer(loading) - .accessibilityElement(children: .ignore) - .accessibilityLabel(NSLocalizedString("Loading thread", comment: "Accessibility label for the thread view when it is loading")) - } - case .loaded(model: let thread_model): - ChatroomThreadView(damus: state, thread: thread_model) - case .not_found: - self.not_found - } - } - - var not_found: some View { - VStack(spacing: 6) { - Image(systemName: "questionmark.app") - .resizable() - .frame(width: 30, height: 30) - .accessibilityHidden(true) - Text("Note not found", comment: "Heading for the thread view in a not found error state") - .font(.title) - .bold() - .padding(.bottom, 10) - Text("We were unable to find the note you were looking for.", comment: "Text for the thread view when it is unable to find the note the user is looking for") - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 5) { - Image(systemName: "sparkles") - .accessibilityHidden(true) - Text("Advice", comment: "Heading for some advice text to help the user with an error") - .font(.headline) - } - Text("Try checking the link again, your internet connection, whether you need to connect to a specific relay to access this content.", comment: "Tips on what to do if a note cannot be found.") - } - .padding() - .background(Color.secondary.opacity(0.2)) - .cornerRadius(10) - .padding(.vertical, 30) - } - .padding() - } - - // MARK: Skeleton views - // Implementation notes - // - No localization is needed because the text will be redacted - // - No accessibility label is needed because these will be summarized into a single accessibility label at the top-level view. See `body` in this struct - - var skeleton: some View { - VStack(alignment: .leading, spacing: 40) { - self.skeleton_selected_event - self.skeleton_chat_event(message: "Nice! Have you tried Damus?", right: false) - self.skeleton_chat_event(message: "Yes, it's awesome.", right: true) - Spacer() - } - .padding() - } - - func skeleton_chat_event(message: String, right: Bool) -> some View { - HStack(alignment: .center) { - if !right { - self.skeleton_chat_user_avatar - } - else { - Spacer() - } - ChatBubble( - direction: right ? .right : .left, - stroke_content: Color.accentColor.opacity(0), - stroke_style: .init(lineWidth: 4), - background_style: Color.secondary.opacity(0.5), - content: { - Text(message) - .padding() - } - ) - if right { - self.skeleton_chat_user_avatar - } - else { - Spacer() - } - } - } - - var skeleton_selected_event: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - Circle() - .frame(width: 50, height: 50) - .foregroundStyle(.secondary.opacity(0.5)) - Text("Satoshi Nakamoto") - .bold() - } - Text("Nostr is the super app. Because it’s actually an ecosystem of apps, all of which make each other better. People haven’t grasped that yet. They will when it’s more accessible and onboarding is more straightforward and intuitive.") - HStack { - self.skeleton_action_item - Spacer() - self.skeleton_action_item - Spacer() - self.skeleton_action_item - Spacer() - self.skeleton_action_item - } - } - } - - var skeleton_chat_user_avatar: some View { - Circle() - .fill(.secondary.opacity(0.5)) - .frame(width: 35, height: 35) - .padding(.bottom, -21) - } - - var skeleton_action_item: some View { - Circle() - .fill(Color.secondary.opacity(0.5)) - .frame(width: 25, height: 25) - } -} - -#Preview("Loadable") { - LoadableThreadView(state: test_damus_state, note_reference: .note_id(test_thread_note_1.id)) -} diff --git a/damus/damusApp.swift b/damus/damusApp.swift @@ -86,11 +86,14 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + Log.info("App delegate is handling a push notification", for: .push_notifications) let userInfo = response.notification.request.content.userInfo guard let notification = LossyLocalNotification.from_user_info(user_info: userInfo) else { + Log.error("App delegate could not decode notification information", for: .push_notifications) return } - notify(.local_notification(notification)) + Log.info("App delegate notifying the app about the received push notification", for: .push_notifications) + Task { await QueueableNotify<LossyLocalNotification>.shared.add(item: notification) } completionHandler() } }