Files
damus/damus/Notify/QueueableNotify.swift
Daniel D’Aquino 4324b185fe 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>
2025-02-21 11:28:26 -08:00

91 lines
3.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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)
}
}