diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 54f4b5d91..d52ddb1b6 100644 --- 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 = ""; }; D703D72A2C66F29500A400EA /* getSelection.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = getSelection.js; sourceTree = ""; }; D706C5AE2D5D31B20027C627 /* AutoSaveIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoSaveIndicatorView.swift; sourceTree = ""; }; + D706C5B62D602A050027C627 /* QueueableNotify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueableNotify.swift; sourceTree = ""; }; D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFormatter.swift; sourceTree = ""; }; D7100C552B76F8E600C59298 /* PurpleViewPrimitives.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleViewPrimitives.swift; sourceTree = ""; }; D7100C572B76FC8400C59298 /* MarketingContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketingContentView.swift; sourceTree = ""; }; @@ -2453,7 +2457,7 @@ D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Zaps+.swift"; sourceTree = ""; }; D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WalletConnect+.swift"; sourceTree = ""; }; D74EA08D2D2E271E002290DD /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; - D74EA0922D2E77B9002290DD /* LoadableThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableThreadView.swift; sourceTree = ""; }; + D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableNostrEventView.swift; sourceTree = ""; }; D74F43092B23F0BE00425B75 /* DamusPurple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurple.swift; sourceTree = ""; }; D74F430B2B23FB9B00425B75 /* StoreObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreObserver.swift; sourceTree = ""; }; D753CEA92BE9DE04001C3A5D /* MutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutingTests.swift; sourceTree = ""; }; @@ -3177,7 +3181,7 @@ D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */, D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */, D71AD8FC2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift */, - D74EA0922D2E77B9002290DD /* LoadableThreadView.swift */, + D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */, ); path = Views; sourceTree = ""; @@ -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 index 7a09796e2..136867526 100644 --- 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 { @@ -513,27 +510,6 @@ struct ContentView: View { @unknown default: 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.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 index 4d3ede160..03198d8f1 100644 --- 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 index bb142d27d..0e5ec2f6a 100644 --- 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 { - .init() - } -} - -extension Notifications { - static func local_notification(_ payload: LossyLocalNotification) -> Notifications { - .init(.init(payload: payload)) - } +extension QueueableNotify { + /// 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 new file mode 100644 index 000000000..d183cf765 --- /dev/null +++ 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 { + /// 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.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 { + 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 index f812142cd..1f9a66521 100644 --- 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 new file mode 100644 index 000000000..0b6f740ca --- /dev/null +++ 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 deleted file mode 100644 index 98c565161..000000000 --- a/damus/Views/LoadableThreadView.swift +++ /dev/null @@ -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 index a95a5d592..5f3dcf251 100644 --- 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.shared.add(item: notification) } completionHandler() } }