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>
276 lines
11 KiB
Swift
276 lines
11 KiB
Swift
//
|
||
// 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))
|
||
}
|