damus

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

ContentView.swift (51005B)


      1 //
      2 //  ContentView.swift
      3 //  damus
      4 //
      5 //  Created by William Casarin on 2022-04-01.
      6 //
      7 
      8 import SwiftUI
      9 import AVKit
     10 import MediaPlayer
     11 import EmojiPicker
     12 
     13 struct ZapSheet {
     14     let target: ZapTarget
     15     let lnurl: String
     16 }
     17 
     18 struct SelectWallet {
     19     let invoice: String
     20 }
     21 
     22 enum Sheets: Identifiable {
     23     case post(PostAction)
     24     case report(ReportTarget)
     25     case event(NostrEvent)
     26     case profile_action(Pubkey)
     27     case zap(ZapSheet)
     28     case select_wallet(SelectWallet)
     29     case filter
     30     case user_status
     31     case onboardingSuggestions
     32     case purple(DamusPurpleURL)
     33     case purple_onboarding
     34     case error(ErrorView.UserPresentableError)
     35 
     36     static func zap(target: ZapTarget, lnurl: String) -> Sheets {
     37         return .zap(ZapSheet(target: target, lnurl: lnurl))
     38     }
     39     
     40     static func select_wallet(invoice: String) -> Sheets {
     41         return .select_wallet(SelectWallet(invoice: invoice))
     42     }
     43     
     44     var id: String {
     45         switch self {
     46         case .report: return "report"
     47         case .user_status: return "user_status"
     48         case .post(let action): return "post-" + (action.ev?.id.hex() ?? "")
     49         case .event(let ev): return "event-" + ev.id.hex()
     50         case .profile_action(let pubkey): return "profile-action-" + pubkey.npub
     51         case .zap(let sheet): return "zap-" + hex_encode(sheet.target.id)
     52         case .select_wallet: return "select-wallet"
     53         case .filter: return "filter"
     54         case .onboardingSuggestions: return "onboarding-suggestions"
     55         case .purple(let purple_url): return "purple" + purple_url.url_string()
     56         case .purple_onboarding: return "purple_onboarding"
     57         case .error(_): return "error"
     58         }
     59     }
     60 }
     61 
     62 /// An item to be presented full screen in a mechanism that is more robust for timeline views.
     63 ///
     64 /// ## Implementation notes
     65 ///
     66 /// This is part of the `present(full_screen_item: FullScreenItem)` interface that allows views in a timeline to show something full-screen without the lazy stack issues
     67 /// Full screen cover modifiers are not suitable in those cases because device orientation changes or programmatic scroll commands will cause the view to be unloaded along with the cover,
     68 /// causing the user to lose the full screen view randomly.
     69 ///
     70 /// The `ContentView` is responsible for handling these objects
     71 ///
     72 /// New items can be added as needed.
     73 ///
     74 enum FullScreenItem: Identifiable, Equatable {
     75     /// A full screen media carousel for images and videos.
     76     case full_screen_carousel(urls: [MediaUrl], selectedIndex: Binding<Int>)
     77     
     78     var id: String {
     79         switch self {
     80             case .full_screen_carousel(let urls, _): return "full_screen_carousel:\(urls.map(\.url))"
     81         }
     82     }
     83     
     84     static func == (lhs: FullScreenItem, rhs: FullScreenItem) -> Bool {
     85         return lhs.id == rhs.id
     86     }
     87     
     88     /// The view to display the item
     89     func view(damus_state: DamusState) -> some View {
     90         switch self {
     91             case .full_screen_carousel(let urls, let selectedIndex):
     92                 return FullScreenCarouselView<AnyView>(video_coordinator: damus_state.video, urls: urls, settings: damus_state.settings, selectedIndex: selectedIndex)
     93         }
     94     }
     95 }
     96 
     97 func present_sheet(_ sheet: Sheets) {
     98     notify(.present_sheet(sheet))
     99 }
    100 
    101 var tabHeight: CGFloat = 0.0
    102 
    103 struct ContentView: View {
    104     let keypair: Keypair
    105     let appDelegate: AppDelegate?
    106     
    107     var pubkey: Pubkey {
    108         return keypair.pubkey
    109     }
    110     
    111     var privkey: Privkey? {
    112         return keypair.privkey
    113     }
    114     
    115     @Environment(\.scenePhase) var scenePhase
    116     
    117     @State var active_sheet: Sheets? = nil
    118     @State var active_full_screen_item: FullScreenItem? = nil
    119     @State var damus_state: DamusState!
    120     @State var menu_subtitle: String? = nil
    121     @SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home {
    122         willSet {
    123             self.menu_subtitle = nil
    124         }
    125     }
    126     @State var muting: MuteItem? = nil
    127     @State var confirm_mute: Bool = false
    128     @State var hide_bar: Bool = false
    129     @State var user_muted_confirm: Bool = false
    130     @State var confirm_overwrite_mutelist: Bool = false
    131     @State private var isSideBarOpened = false
    132     @State var headerOffset: CGFloat = 0.0
    133     var home: HomeModel = HomeModel()
    134     @StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
    135     @AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
    136     let sub_id = UUID().description
    137     
    138     // connect retry timer
    139     let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    140     
    141     func navIsAtRoot() -> Bool {
    142         return navigationCoordinator.isAtRoot()
    143     }
    144     
    145     func popToRoot() {
    146         navigationCoordinator.popToRoot()
    147         isSideBarOpened = false
    148     }
    149     
    150     var timelineNavItem: some View {
    151         VStack {
    152             Text(timeline_name(selected_timeline))
    153                 .bold()
    154             if let menu_subtitle {
    155                 Text(menu_subtitle)
    156                     .font(.caption)
    157                     .foregroundStyle(.secondary)
    158             }
    159         }
    160     }
    161     
    162     func MainContent(damus: DamusState) -> some View {
    163         VStack {
    164             switch selected_timeline {
    165             case .search:
    166                 if #available(iOS 16.0, *) {
    167                     SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
    168                         .scrollDismissesKeyboard(.immediately)
    169                 } else {
    170                     // Fallback on earlier versions
    171                     SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
    172                 }
    173                 
    174             case .home:
    175                 PostingTimelineView(damus_state: damus_state!, home: home, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset)
    176                 
    177             case .notifications:
    178                 NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
    179                 
    180             case .dms:
    181                 DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
    182             }
    183         }
    184         .background(DamusColors.adaptableWhite)
    185         .edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
    186         .navigationBarTitle(timeline_name(selected_timeline), displayMode: .inline)
    187         .toolbar(selected_timeline != .home ? .visible : .hidden)
    188         .toolbar {
    189             ToolbarItem(placement: .principal) {
    190                 VStack {
    191                     timelineNavItem
    192                         .opacity(isSideBarOpened ? 0 : 1)
    193                         .animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
    194                 }
    195             }
    196         }
    197     }
    198     
    199     func MaybeReportView(target: ReportTarget) -> some View {
    200         Group {
    201             if let keypair = damus_state.keypair.to_full() {
    202                 ReportView(postbox: damus_state.postbox, target: target, keypair: keypair)
    203             } else {
    204                 EmptyView()
    205             }
    206         }
    207     }
    208     
    209     func open_event(ev: NostrEvent) {
    210         let thread = ThreadModel(event: ev, damus_state: damus_state!)
    211         navigationCoordinator.push(route: Route.Thread(thread: thread))
    212     }
    213     
    214     func open_wallet(nwc: WalletConnectURL) {
    215         self.damus_state!.wallet.new(nwc)
    216         navigationCoordinator.push(route: Route.Wallet(wallet: damus_state!.wallet))
    217     }
    218     
    219     func open_script(_ script: [UInt8]) {
    220         print("pushing script nav")
    221         let model = ScriptModel(data: script, state: .not_loaded)
    222         navigationCoordinator.push(route: Route.Script(script: model))
    223     }
    224     
    225     func open_search(filt: NostrFilter) {
    226         let search = SearchModel(state: damus_state!, search: filt)
    227         navigationCoordinator.push(route: Route.Search(search: search))
    228     }
    229     
    230     var body: some View {
    231         VStack(alignment: .leading, spacing: 0) {
    232             if let damus = self.damus_state {
    233                 NavigationStack(path: $navigationCoordinator.path) {
    234                     TabView { // Prevents navbar appearance change on scroll
    235                         MainContent(damus: damus)
    236                             .toolbar() {
    237                                 ToolbarItem(placement: .navigationBarLeading) {
    238                                     TopbarSideMenuButton(damus_state: damus, isSideBarOpened: $isSideBarOpened)
    239                                 }
    240                                 
    241                                 ToolbarItem(placement: .navigationBarTrailing) {
    242                                     HStack(alignment: .center) {
    243                                         SignalView(state: damus_state!, signal: home.signal)
    244                                         
    245                                         // maybe expand this to other timelines in the future
    246                                         if selected_timeline == .search {
    247                                             
    248                                             Button(action: {
    249                                                 present_sheet(.filter)
    250                                             }, label: {
    251                                                 Image("filter")
    252                                                     .foregroundColor(.gray)
    253                                             })
    254                                         }
    255                                     }
    256                                 }
    257                             }
    258                     }
    259                     .background(DamusColors.adaptableWhite)
    260                     .edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
    261                     .tabViewStyle(.page(indexDisplayMode: .never))
    262                     .overlay(
    263                         SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation(), selected: $selected_timeline)
    264                     )
    265                     .navigationDestination(for: Route.self) { route in
    266                         route.view(navigationCoordinator: navigationCoordinator, damusState: damus_state!)
    267                     }
    268                     .onReceive(handle_notify(.switched_timeline)) { _ in
    269                         navigationCoordinator.popToRoot()
    270                     }
    271                 }
    272                 .navigationViewStyle(.stack)
    273                 .damus_full_screen_cover($active_full_screen_item, damus_state: damus, content: { item in
    274                     return item.view(damus_state: damus)
    275                 })
    276                 .overlay(alignment: .bottom) {
    277                     if !hide_bar {
    278                         if !isSideBarOpened {
    279                             TabBar(nstatus: home.notification_status, navIsAtRoot: navIsAtRoot(), selected: $selected_timeline, headerOffset: $headerOffset, settings: damus.settings, action: switch_timeline)
    280                                 .padding([.bottom], 8)
    281                                 .background(selected_timeline != .home || (selected_timeline == .home && !self.navIsAtRoot()) ? DamusColors.adaptableWhite : DamusColors.adaptableWhite.opacity(abs(1.25 - (abs(headerOffset/100.0)))))
    282                                 .anchorPreference(key: HeaderBoundsKey.self, value: .bounds){$0}
    283                                 .overlayPreferenceValue(HeaderBoundsKey.self) { value in
    284                                     GeometryReader{ proxy in
    285                                         if let anchor = value{
    286                                             Color.clear
    287                                                 .onAppear {
    288                                                     tabHeight = proxy[anchor].height
    289                                                 }
    290                                         }
    291                                     }
    292                                 }
    293                         }
    294                     }
    295                 }
    296             }
    297         }
    298         .ignoresSafeArea(.keyboard)
    299         .edgesIgnoringSafeArea(hide_bar ? [.bottom] : [])
    300         .onAppear() {
    301             self.connect()
    302             try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
    303             setup_notifications()
    304             if !hasSeenOnboardingSuggestions || damus_state!.settings.always_show_onboarding_suggestions {
    305                 active_sheet = .onboardingSuggestions
    306                 hasSeenOnboardingSuggestions = true
    307             }
    308             self.appDelegate?.state = damus_state
    309             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.
    310                 await self.listenAndHandleLocalNotifications()
    311             }
    312         }
    313         .sheet(item: $active_sheet) { item in
    314             switch item {
    315             case .report(let target):
    316                 MaybeReportView(target: target)
    317             case .post(let action):
    318                 PostView(action: action, damus_state: damus_state!)
    319             case .user_status:
    320                 UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
    321                     .presentationDragIndicator(.visible)
    322             case .event:
    323                 EventDetailView()
    324             case .profile_action(let pubkey):
    325                 ProfileActionSheetView(damus_state: damus_state!, pubkey: pubkey)
    326             case .zap(let zapsheet):
    327                 CustomizeZapView(state: damus_state!, target: zapsheet.target, lnurl: zapsheet.lnurl)
    328             case .select_wallet(let select):
    329                 SelectWalletView(default_wallet: damus_state!.settings.default_wallet, active_sheet: $active_sheet, our_pubkey: damus_state!.pubkey, invoice: select.invoice)
    330             case .filter:
    331                 let timeline = selected_timeline
    332                 RelayFilterView(state: damus_state!, timeline: timeline)
    333                     .presentationDetents([.height(550)])
    334                     .presentationDragIndicator(.visible)
    335             case .onboardingSuggestions:
    336                 OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!))
    337             case .purple(let purple_url):
    338                 DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
    339             case .purple_onboarding:
    340                 DamusPurpleNewUserOnboardingView(damus_state: damus_state)
    341             case .error(let error):
    342                 ErrorView(damus_state: damus_state!, error: error)
    343             }
    344         }
    345         .onOpenURL { url in
    346             Task {
    347                 let open_action = await DamusURLHandler.handle_opening_url_and_compute_view_action(damus_state: self.damus_state, url: url)
    348                 self.execute_open_action(open_action)
    349             }
    350         }
    351         .onReceive(handle_notify(.compose)) { action in
    352             self.active_sheet = .post(action)
    353         }
    354         .onReceive(handle_notify(.display_tabbar)) { display in
    355             let show = display
    356             self.hide_bar = !show
    357         }
    358         .onReceive(timer) { n in
    359             self.damus_state?.postbox.try_flushing_events()
    360             self.damus_state!.profiles.profile_data(self.damus_state!.pubkey).status.try_expire()
    361         }
    362         .onReceive(handle_notify(.report)) { target in
    363             self.active_sheet = .report(target)
    364         }
    365         .onReceive(handle_notify(.mute)) { mute_item in
    366             self.muting = mute_item
    367             self.confirm_mute = true
    368         }
    369         .onReceive(handle_notify(.attached_wallet)) { nwc in
    370             try? damus_state.pool.add_relay(.nwc(url: nwc.relay))
    371 
    372             // update the lightning address on our profile when we attach a
    373             // wallet with an associated
    374             guard let ds = self.damus_state,
    375                   let lud16 = nwc.lud16,
    376                   let keypair = ds.keypair.to_full(),
    377                   let profile_txn = ds.profiles.lookup(id: ds.pubkey),
    378                   let profile = profile_txn.unsafeUnownedValue,
    379                   lud16 != profile.lud16 else {
    380                 return
    381             }
    382 
    383             // clear zapper cache for old lud16
    384             if profile.lud16 != nil {
    385                 // TODO: should this be somewhere else, where we process profile events!?
    386                 invalidate_zapper_cache(pubkey: keypair.pubkey, profiles: ds.profiles, lnurl: ds.lnurls)
    387             }
    388             
    389             let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: profile.reactions)
    390 
    391             guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
    392             ds.postbox.send(ev)
    393         }
    394         .onReceive(handle_notify(.broadcast)) { ev in
    395             guard let ds = self.damus_state else { return }
    396 
    397             ds.postbox.send(ev)
    398         }
    399         .onReceive(handle_notify(.unfollow)) { target in
    400             guard let state = self.damus_state else { return }
    401             _ = handle_unfollow(state: state, unfollow: target.follow_ref)
    402         }
    403         .onReceive(handle_notify(.unfollowed)) { unfollow in
    404             home.resubscribe(.unfollowing(unfollow))
    405         }
    406         .onReceive(handle_notify(.follow)) { target in
    407             guard let state = self.damus_state else { return }
    408             handle_follow_notif(state: state, target: target)
    409         }
    410         .onReceive(handle_notify(.followed)) { _ in
    411             home.resubscribe(.following)
    412         }
    413         .onReceive(handle_notify(.post)) { post in
    414             guard let state = self.damus_state,
    415                   let keypair = state.keypair.to_full() else {
    416                       return
    417             }
    418 
    419             if !handle_post_notification(keypair: keypair, postbox: state.postbox, events: state.events, post: post) {
    420                 self.active_sheet = nil
    421             }
    422         }
    423         .onReceive(handle_notify(.new_mutes)) { _ in
    424             home.filter_events()
    425         }
    426         .onReceive(handle_notify(.mute_thread)) { _ in
    427             home.filter_events()
    428         }
    429         .onReceive(handle_notify(.unmute_thread)) { _ in
    430             home.filter_events()
    431         }
    432         .onReceive(handle_notify(.present_sheet)) { sheet in
    433             self.active_sheet = sheet
    434         }
    435         .onReceive(handle_notify(.present_full_screen_item)) { item in
    436             self.active_full_screen_item = item
    437         }
    438         .onReceive(handle_notify(.zapping)) { zap_ev in
    439             guard !zap_ev.is_custom else {
    440                 return
    441             }
    442             
    443             switch zap_ev.type {
    444             case .failed:
    445                 break
    446             case .got_zap_invoice(let inv):
    447                 if damus_state!.settings.show_wallet_selector {
    448                     present_sheet(.select_wallet(invoice: inv))
    449                 } else {
    450                     let wallet = damus_state!.settings.default_wallet.model
    451                     do {
    452                         try open_with_wallet(wallet: wallet, invoice: inv)
    453                     }
    454                     catch {
    455                         present_sheet(.select_wallet(invoice: inv))
    456                     }
    457                 }
    458             case .sent_from_nwc:
    459                 break
    460             }
    461         }
    462         .onReceive(handle_notify(.disconnect_relays)) { () in
    463             damus_state.pool.disconnect()
    464         }
    465         .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
    466             print("txn: 📙 DAMUS ACTIVE NOTIFY")
    467             if damus_state.ndb.reopen() {
    468                 print("txn: NOSTRDB REOPENED")
    469             } else {
    470                 print("txn: NOSTRDB FAILED TO REOPEN closed:\(damus_state.ndb.is_closed)")
    471             }
    472             if damus_state.purple.checkout_ids_in_progress.count > 0 {
    473                 // For extra assurance, run this after one second, to avoid race conditions if the app is also handling a damus purple welcome url.
    474                 DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    475                     Task {
    476                         let freshly_completed_checkout_ids = try? await damus_state.purple.check_status_of_checkouts_in_progress()
    477                         let there_is_a_completed_checkout: Bool = (freshly_completed_checkout_ids?.count ?? 0) > 0
    478                         let account_info = try await damus_state.purple.fetch_account(pubkey: self.keypair.pubkey)
    479                         if there_is_a_completed_checkout == true && account_info?.active == true {
    480                             if damus_state.purple.onboarding_status.user_has_never_seen_the_onboarding_before() {
    481                                 // Show welcome sheet
    482                                 self.active_sheet = .purple_onboarding
    483                             }
    484                             else {
    485                                 self.active_sheet = .purple(DamusPurpleURL.init(is_staging: damus_state.purple.environment == .staging, variant: .landing))
    486                             }
    487                         }
    488                     }
    489                 }
    490             }
    491             Task {
    492                 await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
    493             }
    494         }
    495         .onChange(of: scenePhase) { (phase: ScenePhase) in
    496             guard let damus_state else { return }
    497             switch phase {
    498             case .background:
    499                 print("txn: 📙 DAMUS BACKGROUNDED")
    500                 Task { @MainActor in
    501                     damus_state.ndb.close()
    502                 }
    503                 break
    504             case .inactive:
    505                 print("txn: 📙 DAMUS INACTIVE")
    506                 break
    507             case .active:
    508                 print("txn: 📙 DAMUS ACTIVE")
    509                 damus_state.pool.ping()
    510             @unknown default:
    511                 break
    512             }
    513         }
    514         .onReceive(handle_notify(.onlyzaps_mode)) { hide in
    515             home.filter_events()
    516 
    517             guard let ds = damus_state,
    518                   let profile_txn = ds.profiles.lookup(id: ds.pubkey),
    519                   let profile = profile_txn.unsafeUnownedValue,
    520                   let keypair = ds.keypair.to_full()
    521             else {
    522                 return
    523             }
    524 
    525             let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: !hide)
    526 
    527             guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
    528             ds.postbox.send(profile_ev)
    529         }
    530         .alert(NSLocalizedString("User muted", comment: "Alert message to indicate the user has been muted"), isPresented: $user_muted_confirm, actions: {
    531             Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to muted a user was successful.")) {
    532                 user_muted_confirm = false
    533             }
    534         }, message: {
    535             if case let .user(pubkey, _) = self.muting {
    536                 let profile_txn = damus_state!.profiles.lookup(id: pubkey)
    537                 let profile = profile_txn?.unsafeUnownedValue
    538                 let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
    539                 Text("\(name) has been muted", comment: "Alert message that informs a user was muted.")
    540             } else {
    541                 Text("User has been muted", comment: "Alert message that informs a user was muted.")
    542             }
    543         })
    544         .alert(NSLocalizedString("Create new mutelist", comment: "Title of alert prompting the user to create a new mutelist."), isPresented: $confirm_overwrite_mutelist, actions: {
    545             Button(NSLocalizedString("Cancel", comment: "Button to cancel out of alert that creates a new mutelist.")) {
    546                 confirm_overwrite_mutelist = false
    547                 confirm_mute = false
    548             }
    549 
    550             Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) {
    551                 guard let ds = damus_state,
    552                       let keypair = ds.keypair.to_full(),
    553                       let muting,
    554                       let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: muting)
    555                 else {
    556                     return
    557                 }
    558                 
    559                 ds.mutelist_manager.set_mutelist(mutelist)
    560                 ds.postbox.send(mutelist)
    561 
    562                 confirm_overwrite_mutelist = false
    563                 confirm_mute = false
    564                 user_muted_confirm = true
    565             }
    566         }, message: {
    567             Text("No mute list found, create a new one? This will overwrite any previous mute lists.", comment: "Alert message prompt that asks if the user wants to create a new mute list, overwriting previous mute lists.")
    568         })
    569         .alert(NSLocalizedString("Mute/Block User", comment: "Title of alert for muting/blocking a user."), isPresented: $confirm_mute, actions: {
    570             Button(NSLocalizedString("Cancel", comment: "Alert button to cancel out of alert for muting a user."), role: .cancel) {
    571                 confirm_mute = false
    572             }
    573             Button(NSLocalizedString("Mute", comment: "Alert button to mute a user."), role: .destructive) {
    574                 guard let ds = damus_state else {
    575                     return
    576                 }
    577 
    578                 if ds.mutelist_manager.event == nil {
    579                     confirm_overwrite_mutelist = true
    580                 } else {
    581                     guard let keypair = ds.keypair.to_full(),
    582                           let muting
    583                     else {
    584                         return
    585                     }
    586 
    587                     guard let ev = create_or_update_mutelist(keypair: keypair, mprev: ds.mutelist_manager.event, to_add: muting) else {
    588                         return
    589                     }
    590 
    591                     ds.mutelist_manager.set_mutelist(ev)
    592                     ds.postbox.send(ev)
    593                 }
    594             }
    595         }, message: {
    596             if case let .user(pubkey, _) = muting {
    597                 let profile_txn = damus_state?.profiles.lookup(id: pubkey)
    598                 let profile = profile_txn?.unsafeUnownedValue
    599                 let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
    600                 Text("Mute \(name)?", comment: "Alert message prompt to ask if a user should be muted.")
    601             } else {
    602                 Text("Could not find user to mute...", comment: "Alert message to indicate that the muted user could not be found.")
    603             }
    604         })
    605     }
    606     
    607     func switch_timeline(_ timeline: Timeline) {
    608         self.isSideBarOpened = false
    609         let navWasAtRoot = self.navIsAtRoot()
    610         self.popToRoot()
    611 
    612         notify(.switched_timeline(timeline))
    613 
    614         if timeline == self.selected_timeline && navWasAtRoot {
    615             notify(.scroll_to_top)
    616             return
    617         }
    618         
    619         self.selected_timeline = timeline
    620     }
    621 
    622     /// Listens to requests to open a push/local user notification
    623     ///
    624     /// This function never returns, it just keeps streaming
    625     func listenAndHandleLocalNotifications() async {
    626         for await notification in await QueueableNotify<LossyLocalNotification>.shared.stream {
    627             self.handleNotification(notification: notification)
    628         }
    629     }
    630 
    631     func handleNotification(notification: LossyLocalNotification) {
    632         Log.info("ContentView is handling a notification", for: .push_notifications)
    633         guard let damus_state else {
    634             // This should never happen because `listenAndHandleLocalNotifications` is called after damus state is initialized in `onAppear`
    635             assertionFailure("DamusState not loaded when ContentView (new handler) was handling a notification")
    636             Log.error("DamusState not loaded when ContentView (new handler) was handling a notification", for: .push_notifications)
    637             return
    638         }
    639         let local = notification
    640         let openAction = local.toViewOpenAction()
    641         self.execute_open_action(openAction)
    642     }
    643 
    644     func connect() {
    645         // nostrdb
    646         var mndb = Ndb()
    647         if mndb == nil {
    648             // try recovery
    649             print("DB ISSUE! RECOVERING")
    650             mndb = Ndb.safemode()
    651 
    652             // out of space or something?? maybe we need a in-memory fallback
    653             if mndb == nil {
    654                 logout(nil)
    655                 return
    656             }
    657         }
    658 
    659         guard let ndb = mndb else { return  }
    660 
    661         let pool = RelayPool(ndb: ndb, keypair: keypair)
    662         let model_cache = RelayModelCache()
    663         let relay_filters = RelayFilters(our_pubkey: pubkey)
    664         let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
    665         
    666         let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
    667 
    668         let new_relay_filters = load_relay_filters(pubkey) == nil
    669         for relay in bootstrap_relays {
    670             let descriptor = RelayDescriptor(url: relay, info: .rw)
    671             add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
    672         }
    673 
    674         pool.register_handler(sub_id: sub_id, handler: home.handle_event)
    675         
    676         if let nwc_str = settings.nostr_wallet_connect,
    677            let nwc = WalletConnectURL(str: nwc_str) {
    678             try? pool.add_relay(.nwc(url: nwc.relay))
    679         }
    680 
    681         self.damus_state = DamusState(pool: pool,
    682                                       keypair: keypair,
    683                                       likes: EventCounter(our_pubkey: pubkey),
    684                                       boosts: EventCounter(our_pubkey: pubkey),
    685                                       contacts: Contacts(our_pubkey: pubkey),
    686                                       mutelist_manager: MutelistManager(user_keypair: keypair),
    687                                       profiles: Profiles(ndb: ndb),
    688                                       dms: home.dms,
    689                                       previews: PreviewCache(),
    690                                       zaps: Zaps(our_pubkey: pubkey),
    691                                       lnurls: LNUrls(),
    692                                       settings: settings,
    693                                       relay_filters: relay_filters,
    694                                       relay_model_cache: model_cache,
    695                                       drafts: Drafts(),
    696                                       events: EventCache(ndb: ndb),
    697                                       bookmarks: BookmarksManager(pubkey: pubkey),
    698                                       postbox: PostBox(pool: pool),
    699                                       bootstrap_relays: bootstrap_relays,
    700                                       replies: ReplyCounter(our_pubkey: pubkey),
    701                                       wallet: WalletModel(settings: settings),
    702                                       nav: self.navigationCoordinator,
    703                                       music: MusicController(onChange: music_changed),
    704                                       video: DamusVideoCoordinator(),
    705                                       ndb: ndb,
    706                                       quote_reposts: .init(our_pubkey: pubkey),
    707                                       emoji_provider: DefaultEmojiProvider(showAllVariations: true)
    708         )
    709         
    710         home.damus_state = self.damus_state!
    711         
    712         if let damus_state, damus_state.purple.enable_purple {
    713             // Assign delegate so that we can send receipts to the Purple API server as soon as we get updates from user's purchases
    714             StoreObserver.standard.delegate = damus_state.purple
    715             Task {
    716                 await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
    717             }
    718         }
    719         else {
    720             // Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
    721         }
    722         
    723         pool.connect()
    724     }
    725 
    726     func music_changed(_ state: MusicState) {
    727         guard let damus_state else { return }
    728         switch state {
    729         case .playback_state:
    730             break
    731         case .song(let song):
    732             guard let song, let kp = damus_state.keypair.to_full() else { return }
    733 
    734             let pdata = damus_state.profiles.profile_data(damus_state.pubkey)
    735 
    736             let desc = "\(song.title ?? "Unknown") - \(song.artist ?? "Unknown")"
    737             let encodedDesc = desc.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
    738             let url = encodedDesc.flatMap { enc in
    739                 URL(string: "spotify:search:\(enc)")
    740             }
    741             let music = UserStatus(type: .music, expires_at: Date.now.addingTimeInterval(song.playbackDuration), content: desc, created_at: UInt32(Date.now.timeIntervalSince1970), url: url)
    742 
    743             pdata.status.music = music
    744 
    745             guard let ev = music.to_note(keypair: kp) else { return }
    746             damus_state.postbox.send(ev)
    747         }
    748     }
    749     
    750     /// An open action within the app
    751     /// This is used to model, store, and communicate a desired view action to be taken as a result of opening an object,
    752     /// for example a URL
    753     ///
    754     /// ## Implementation notes
    755     ///
    756     /// - The reason this was created was to separate URL parsing logic, the underlying actions that mutate the state of the app, and the action to be taken on the view layer as a result. This makes it easier to test, to read the URL handling code, and to add new functionality in between the two (e.g. a confirmation screen before proceeding with a given open action)
    757     enum ViewOpenAction {
    758         /// Open a page route
    759         case route(Route)
    760         /// Open a sheet
    761         case sheet(Sheets)
    762         /// Do nothing.
    763         ///
    764         /// ## Implementation notes
    765         /// - This is used here instead of Optional values to make semantics explicit and force better programming intent, instead of accidentally doing nothing because of Swift's syntax sugar.
    766         case no_action
    767     }
    768     
    769     /// Executes an action to open something in the app view
    770     ///
    771     /// - Parameter open_action: The action to perform
    772     func execute_open_action(_ open_action: ViewOpenAction) {
    773         switch open_action {
    774         case .route(let route):
    775             navigationCoordinator.push(route: route)
    776         case .sheet(let sheet):
    777             self.active_sheet = sheet
    778         case .no_action:
    779             return
    780         }
    781     }
    782 }
    783 
    784 struct TopbarSideMenuButton: View {
    785     let damus_state: DamusState
    786     @Binding var isSideBarOpened: Bool
    787     
    788     var body: some View {
    789         Button {
    790             isSideBarOpened.toggle()
    791         } label: {
    792             ProfilePicView(pubkey: damus_state.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
    793                 .opacity(isSideBarOpened ? 0 : 1)
    794                 .animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
    795                 .accessibilityHidden(true)  // Knowing there is a profile picture here leads to no actionable outcome to VoiceOver users, so it is best not to show it
    796         }
    797         .accessibilityIdentifier(AppAccessibilityIdentifiers.main_side_menu_button.rawValue)
    798         .accessibilityLabel(NSLocalizedString("Side menu", comment: "Accessibility label for the side menu button at the topbar"))
    799         .disabled(isSideBarOpened)
    800     }
    801 }
    802 
    803 struct ContentView_Previews: PreviewProvider {
    804     static var previews: some View {
    805         ContentView(keypair: Keypair(pubkey: test_pubkey, privkey: nil), appDelegate: nil)
    806     }
    807 }
    808 
    809 func get_since_time(last_event: NostrEvent?) -> UInt32? {
    810     if let last_event = last_event {
    811         return last_event.created_at - 60 * 10
    812     }
    813     
    814     return nil
    815 }
    816 
    817 extension UINavigationController: UIGestureRecognizerDelegate {
    818     override open func viewDidLoad() {
    819         super.viewDidLoad()
    820         interactivePopGestureRecognizer?.delegate = self
    821     }
    822 
    823     public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    824         return viewControllers.count > 1
    825     }
    826 }
    827 
    828 struct LastNotification {
    829     let id: NoteId
    830     let created_at: Int64
    831 }
    832 
    833 func get_last_event(_ timeline: Timeline) -> LastNotification? {
    834     let str = timeline.rawValue
    835     let last = UserDefaults.standard.string(forKey: "last_\(str)")
    836     let last_created = UserDefaults.standard.string(forKey: "last_\(str)_time")
    837         .flatMap { Int64($0) }
    838 
    839     guard let last,
    840           let note_id = NoteId(hex: last),
    841           let last_created
    842     else {
    843         return nil
    844     }
    845 
    846     return LastNotification(id: note_id, created_at: last_created)
    847 }
    848 
    849 func save_last_event(_ ev: NostrEvent, timeline: Timeline) {
    850     let str = timeline.rawValue
    851     UserDefaults.standard.set(ev.id.hex(), forKey: "last_\(str)")
    852     UserDefaults.standard.set(String(ev.created_at), forKey: "last_\(str)_time")
    853 }
    854 
    855 func save_last_event(_ ev_id: NoteId, created_at: UInt32, timeline: Timeline) {
    856     let str = timeline.rawValue
    857     UserDefaults.standard.set(ev_id.hex(), forKey: "last_\(str)")
    858     UserDefaults.standard.set(String(created_at), forKey: "last_\(str)_time")
    859 }
    860 
    861 func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [NostrFilter]) -> [NostrFilter] {
    862 
    863     return filters.map { filter in
    864         let kinds = filter.kinds ?? []
    865         let initial: UInt32? = nil
    866         let earliest = kinds.reduce(initial) { earliest, kind in
    867             let last = last_of_kind[kind.rawValue]
    868             let since: UInt32? = get_since_time(last_event: last)
    869 
    870             if earliest == nil {
    871                 if since == nil {
    872                     return nil
    873                 }
    874                 return since
    875             }
    876             
    877             if since == nil {
    878                 return earliest
    879             }
    880             
    881             return since! < earliest! ? since! : earliest!
    882         }
    883         
    884         if let earliest = earliest {
    885             var with_since = NostrFilter.copy(from: filter)
    886             with_since.since = earliest
    887             return with_since
    888         }
    889         
    890         return filter
    891     }
    892 }
    893 
    894 
    895 func setup_notifications() {
    896     this_app.registerForRemoteNotifications()
    897     let center = UNUserNotificationCenter.current()
    898     
    899     center.getNotificationSettings { settings in
    900         guard settings.authorizationStatus == .authorized else {
    901             center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
    902                 
    903             }
    904             
    905             return
    906         }
    907     }
    908 }
    909 
    910 struct FindEvent {
    911     let type: FindEventType
    912     let find_from: [RelayURL]?
    913 
    914     static func profile(pubkey: Pubkey, find_from: [RelayURL]? = nil) -> FindEvent {
    915         return FindEvent(type: .profile(pubkey), find_from: find_from)
    916     }
    917 
    918     static func event(evid: NoteId, find_from: [RelayURL]? = nil) -> FindEvent {
    919         return FindEvent(type: .event(evid), find_from: find_from)
    920     }
    921 }
    922 
    923 enum FindEventType {
    924     case profile(Pubkey)
    925     case event(NoteId)
    926 }
    927 
    928 enum FoundEvent {
    929     case profile(Pubkey)
    930     case event(NostrEvent)
    931 }
    932 
    933 /// Finds an event from NostrDB if it exists, or from the network
    934 ///
    935 /// This is the callback version. There is also an asyc/await version of this function.
    936 ///
    937 /// - Parameters:
    938 ///   - state: Damus state
    939 ///   - query_: The query, including the event being looked for, and the relays to use when looking
    940 ///   - callback: The function to call with results
    941 func find_event(state: DamusState, query query_: FindEvent, callback: @escaping (FoundEvent?) -> ()) {
    942     return find_event_with_subid(state: state, query: query_, subid: UUID().description, callback: callback)
    943 }
    944 
    945 /// Finds an event from NostrDB if it exists, or from the network
    946 ///
    947 /// This is a the async/await version of `find_event`. Use this when using callbacks is impossible or cumbersome.
    948 ///
    949 /// - Parameters:
    950 ///   - state: Damus state
    951 ///   - query_: The query, including the event being looked for, and the relays to use when looking
    952 ///   - callback: The function to call with results
    953 func find_event(state: DamusState, query query_: FindEvent) async -> FoundEvent? {
    954     await withCheckedContinuation { continuation in
    955         find_event(state: state, query: query_) { event in
    956             var already_resumed = false
    957             if !already_resumed {   // Ensure we do not resume twice, as it causes a crash
    958                 continuation.resume(returning: event)
    959                 already_resumed = true
    960             }
    961         }
    962     }
    963 }
    964 
    965 func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: String, callback: @escaping (FoundEvent?) -> ()) {
    966 
    967     var filter: NostrFilter? = nil
    968     let find_from = query_.find_from
    969     let query = query_.type
    970     
    971     switch query {
    972     case .profile(let pubkey):
    973         if let profile_txn = state.ndb.lookup_profile(pubkey),
    974            let record = profile_txn.unsafeUnownedValue,
    975            record.profile != nil
    976         {
    977             callback(.profile(pubkey))
    978             return
    979         }
    980         filter = NostrFilter(kinds: [.metadata], limit: 1, authors: [pubkey])
    981         
    982     case .event(let evid):
    983         if let ev = state.events.lookup(evid) {
    984             callback(.event(ev))
    985             return
    986         }
    987     
    988         filter = NostrFilter(ids: [evid], limit: 1)
    989     }
    990     
    991     var attempts: Int = 0
    992     var has_event = false
    993     guard let filter else { return }
    994     
    995     state.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res  in
    996         guard case .nostr_event(let ev) = res else {
    997             return
    998         }
    999         
   1000         guard ev.subid == subid else {
   1001             return
   1002         }
   1003         
   1004         switch ev {
   1005         case .ok:
   1006             break
   1007         case .event(_, let ev):
   1008             has_event = true
   1009             state.pool.unsubscribe(sub_id: subid)
   1010             
   1011             switch query {
   1012             case .profile:
   1013                 if ev.known_kind == .metadata {
   1014                     callback(.profile(ev.pubkey))
   1015                 }
   1016             case .event:
   1017                 callback(.event(ev))
   1018             }
   1019         case .eose:
   1020             if !has_event {
   1021                 attempts += 1
   1022                 if attempts >= state.pool.our_descriptors.count {
   1023                     callback(nil)   // If we could not find any events in any of the relays we are connected to, send back nil
   1024                 }
   1025             }
   1026             state.pool.unsubscribe(sub_id: subid, to: [relay_id])   // We are only finding an event once, so close subscription on eose
   1027         case .notice:
   1028             break
   1029         case .auth:
   1030             break
   1031         }
   1032     }
   1033 }
   1034 
   1035 
   1036 /// Finds a replaceable event based on an `naddr` address.
   1037 ///
   1038 /// This is the callback version of the function. There is another function that makes use of async/await
   1039 ///
   1040 /// - Parameters:
   1041 ///   - damus_state: The Damus state
   1042 ///   - naddr: the `naddr` address
   1043 ///   - callback: A function to handle the found event
   1044 func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
   1045     var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
   1046 
   1047     let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
   1048     
   1049     let subid = UUID().description
   1050     
   1051     damus_state.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res  in
   1052         guard case .nostr_event(let ev) = res else {
   1053             damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
   1054             return
   1055         }
   1056         
   1057         if case .event(_, let ev) = ev {
   1058             for tag in ev.tags {
   1059                 if(tag.count >= 2 && tag[0].string() == "d"){
   1060                     if (tag[1].string() == naddr.identifier){
   1061                         damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
   1062                         callback(ev)
   1063                         return
   1064                     }
   1065                 }
   1066             }
   1067         }
   1068         damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
   1069     }
   1070 }
   1071 
   1072 /// Finds a replaceable event based on an `naddr` address.
   1073 ///
   1074 /// This is the async/await version of the function. Another version of this function which makes use of callback functions also exists .
   1075 ///
   1076 /// - Parameters:
   1077 ///   - damus_state: The Damus state
   1078 ///   - naddr: the `naddr` address
   1079 ///   - callback: A function to handle the found event
   1080 func naddrLookup(damus_state: DamusState, naddr: NAddr) async -> NostrEvent? {
   1081     await withCheckedContinuation { continuation in
   1082         var already_resumed = false
   1083         naddrLookup(damus_state: damus_state, naddr: naddr) { event in
   1084             if !already_resumed {   // Ensure we do not resume twice, as it causes a crash
   1085                 continuation.resume(returning: event)
   1086                 already_resumed = true
   1087             }
   1088         }
   1089     }
   1090 }
   1091 
   1092 func timeline_name(_ timeline: Timeline?) -> String {
   1093     guard let timeline else {
   1094         return ""
   1095     }
   1096     switch timeline {
   1097     case .home:
   1098         return NSLocalizedString("Home", comment: "Navigation bar title for Home view where notes and replies appear from those who the user is following.")
   1099     case .notifications:
   1100         return NSLocalizedString("Notifications", comment: "Toolbar label for Notifications view.")
   1101     case .search:
   1102         return NSLocalizedString("Universe 🛸", comment: "Toolbar label for the universal view where notes from all connected relay servers appear.")
   1103     case .dms:
   1104         return NSLocalizedString("DMs", comment: "Toolbar label for DMs view, where DM is the English abbreviation for Direct Message.")
   1105     }
   1106 }
   1107 
   1108 @discardableResult
   1109 func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool {
   1110     guard let keypair = state.keypair.to_full() else {
   1111         return false
   1112     }
   1113 
   1114     let old_contacts = state.contacts.event
   1115 
   1116     guard let ev = unfollow_reference(postbox: state.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
   1117     else {
   1118         return false
   1119     }
   1120 
   1121     notify(.unfollowed(unfollow))
   1122 
   1123     state.contacts.event = ev
   1124 
   1125     switch unfollow {
   1126     case .pubkey(let pk):
   1127         state.contacts.remove_friend(pk)
   1128     case .hashtag:
   1129         // nothing to handle here really
   1130         break
   1131     }
   1132 
   1133     return true
   1134 }
   1135 
   1136 @discardableResult
   1137 func handle_follow(state: DamusState, follow: FollowRef) -> Bool {
   1138     guard let keypair = state.keypair.to_full() else {
   1139         return false
   1140     }
   1141 
   1142     guard let ev = follow_reference(box: state.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
   1143     else {
   1144         return false
   1145     }
   1146 
   1147     notify(.followed(follow))
   1148 
   1149     state.contacts.event = ev
   1150     switch follow {
   1151     case .pubkey(let pubkey):
   1152         state.contacts.add_friend_pubkey(pubkey)
   1153     case .hashtag:
   1154         // nothing to do
   1155         break
   1156     }
   1157 
   1158     return true
   1159 }
   1160 
   1161 @discardableResult
   1162 func handle_follow_notif(state: DamusState, target: FollowTarget) -> Bool {
   1163     switch target {
   1164     case .pubkey(let pk):
   1165         state.contacts.add_friend_pubkey(pk)
   1166     case .contact(let ev):
   1167         state.contacts.add_friend_contact(ev)
   1168     }
   1169 
   1170     return handle_follow(state: state, follow: target.follow_ref)
   1171 }
   1172 
   1173 func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult) -> Bool {
   1174     switch post {
   1175     case .post(let post):
   1176         //let post = tup.0
   1177         //let to_relays = tup.1
   1178         print("post \(post.content)")
   1179         guard let new_ev = post.to_event(keypair: keypair) else {
   1180             return false
   1181         }
   1182         postbox.send(new_ev)
   1183         for eref in new_ev.referenced_ids.prefix(3) {
   1184             // also broadcast at most 3 referenced events
   1185             if let ev = events.lookup(eref) {
   1186                 postbox.send(ev)
   1187             }
   1188         }
   1189         for qref in new_ev.referenced_quote_ids.prefix(3) {
   1190             // also broadcast at most 3 referenced quoted events
   1191             if let ev = events.lookup(qref.note_id) {
   1192                 postbox.send(ev)
   1193             }
   1194         }
   1195         return true
   1196     case .cancel:
   1197         print("post cancelled")
   1198         return false
   1199     }
   1200 }
   1201 
   1202 extension LossyLocalNotification {
   1203     /// Computes a view open action from a mention reference.
   1204     /// Use this when opening a user-presentable interface to a specific mention reference.
   1205     func toViewOpenAction() -> ContentView.ViewOpenAction {
   1206         switch self.mention {
   1207         case .pubkey(let pubkey):
   1208             return .route(.ProfileByKey(pubkey: pubkey))
   1209         case .note(let noteId):
   1210             return .route(.LoadableNostrEvent(note_reference: .note_id(noteId)))
   1211         case .nevent(let nEvent):
   1212             // TODO: Improve this by implementing a route that handles nevents with their relay hints.
   1213             return .route(.LoadableNostrEvent(note_reference: .note_id(nEvent.noteid)))
   1214         case .nprofile(let nProfile):
   1215             // TODO: Improve this by implementing a profile route that handles nprofiles with their relay hints.
   1216             return .route(.ProfileByKey(pubkey: nProfile.author))
   1217         case .nrelay(let string):
   1218             // We do not need to implement `nrelay` support, it has been deprecated.
   1219             // See https://github.com/nostr-protocol/nips/blob/6e7a618e7f873bb91e743caacc3b09edab7796a0/BREAKING.md?plain=1#L21
   1220             return .sheet(.error(ErrorView.UserPresentableError(
   1221                 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."),
   1222                 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."),
   1223                 technical_info: "`MentionRef.toViewOpenAction` detected deprecated `nrelay` contents"
   1224             )))
   1225         case .naddr(let nAddr):
   1226             return .route(.LoadableNostrEvent(note_reference: .naddr(nAddr)))
   1227         }
   1228     }
   1229 }
   1230 
   1231 
   1232 func logout(_ state: DamusState?)
   1233 {
   1234     state?.close()
   1235     notify(.logout)
   1236 }
   1237