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>
This commit is contained in:
Daniel D’Aquino
2025-02-12 18:25:39 -08:00
parent 1ab9b30b85
commit 4324b185fe
9 changed files with 453 additions and 291 deletions

View File

@@ -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)
}

View File

@@ -0,0 +1,90 @@
//
// QueueableNotify.swift
// damus
//
// Created by Daniel DAquino 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)
}
}