damus

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

ContentView.swift (52984B)


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