damus

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

ContentView.swift (46572B)


      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 
     12 struct ZapSheet {
     13     let target: ZapTarget
     14     let lnurl: String
     15 }
     16 
     17 struct SelectWallet {
     18     let invoice: String
     19 }
     20 
     21 enum Sheets: Identifiable {
     22     case post(PostAction)
     23     case report(ReportTarget)
     24     case event(NostrEvent)
     25     case profile_action(Pubkey)
     26     case zap(ZapSheet)
     27     case select_wallet(SelectWallet)
     28     case filter
     29     case user_status
     30     case onboardingSuggestions
     31     case purple(DamusPurpleURL)
     32     case purple_onboarding
     33     
     34     static func zap(target: ZapTarget, lnurl: String) -> Sheets {
     35         return .zap(ZapSheet(target: target, lnurl: lnurl))
     36     }
     37     
     38     static func select_wallet(invoice: String) -> Sheets {
     39         return .select_wallet(SelectWallet(invoice: invoice))
     40     }
     41     
     42     var id: String {
     43         switch self {
     44         case .report: return "report"
     45         case .user_status: return "user_status"
     46         case .post(let action): return "post-" + (action.ev?.id.hex() ?? "")
     47         case .event(let ev): return "event-" + ev.id.hex()
     48         case .profile_action(let pubkey): return "profile-action-" + pubkey.npub
     49         case .zap(let sheet): return "zap-" + hex_encode(sheet.target.id)
     50         case .select_wallet: return "select-wallet"
     51         case .filter: return "filter"
     52         case .onboardingSuggestions: return "onboarding-suggestions"
     53         case .purple(let purple_url): return "purple" + purple_url.url_string()
     54         case .purple_onboarding: return "purple_onboarding"
     55         }
     56     }
     57 }
     58 
     59 struct ContentView: View {
     60     let keypair: Keypair
     61     let appDelegate: AppDelegate?
     62     
     63     var pubkey: Pubkey {
     64         return keypair.pubkey
     65     }
     66     
     67     var privkey: Privkey? {
     68         return keypair.privkey
     69     }
     70     
     71     @Environment(\.scenePhase) var scenePhase
     72     
     73     @State var active_sheet: Sheets? = nil
     74     @State var damus_state: DamusState!
     75     @SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home
     76     @State var muting: MuteItem? = nil
     77     @State var confirm_mute: Bool = false
     78     @State var hide_bar: Bool = false
     79     @State var user_muted_confirm: Bool = false
     80     @State var confirm_overwrite_mutelist: Bool = false
     81     @SceneStorage("ContentView.filter_state") var filter_state : FilterState = .posts_and_replies
     82     @State private var isSideBarOpened = false
     83     var home: HomeModel = HomeModel()
     84     @StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
     85     @AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
     86     let sub_id = UUID().description
     87 
     88     @Environment(\.colorScheme) var colorScheme
     89     
     90     // connect retry timer
     91     let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
     92     
     93     var mystery: some View {
     94         Text("Are you lost?", comment: "Text asking the user if they are lost in the app.")
     95         .id("what")
     96     }
     97 
     98     func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
     99         var filters = ContentFilters.defaults(damus_state: damus_state!)
    100         filters.append(fstate.filter)
    101         return ContentFilters(filters: filters).filter
    102     }
    103 
    104     var PostingTimelineView: some View {
    105         VStack {
    106             ZStack {
    107                 TabView(selection: $filter_state) {
    108                     // This is needed or else there is a bug when switching from the 3rd or 2nd tab to first. no idea why.
    109                     mystery
    110                     
    111                     contentTimelineView(filter: content_filter(.posts))
    112                         .tag(FilterState.posts)
    113                         .id(FilterState.posts)
    114                     contentTimelineView(filter: content_filter(.posts_and_replies))
    115                         .tag(FilterState.posts_and_replies)
    116                         .id(FilterState.posts_and_replies)
    117                 }
    118                 .tabViewStyle(.page(indexDisplayMode: .never))
    119                 
    120                 if privkey != nil {
    121                     PostButtonContainer(is_left_handed: damus_state?.settings.left_handed ?? false) {
    122                         self.active_sheet = .post(.posting(.none))
    123                     }
    124                 }
    125             }
    126         }
    127         .safeAreaInset(edge: .top, spacing: 0) {
    128             VStack(spacing: 0) {
    129                 CustomPicker(selection: $filter_state, content: {
    130                     Text("Notes", comment: "Label for filter for seeing only notes (instead of notes and replies).").tag(FilterState.posts)
    131                     Text("Notes & Replies", comment: "Label for filter for seeing notes and replies (instead of only notes).").tag(FilterState.posts_and_replies)
    132                 })
    133                 Divider()
    134                     .frame(height: 1)
    135             }
    136             .background(colorScheme == .dark ? Color.black : Color.white)
    137         }
    138     }
    139     
    140     func contentTimelineView(filter: (@escaping (NostrEvent) -> Bool)) -> some View {
    141         TimelineView(events: home.events, loading: .constant(false), damus: damus_state, show_friend_icon: false, filter: filter) {
    142             PullDownSearchView(state: damus_state, on_cancel: {})
    143         }
    144     }
    145     
    146     func navIsAtRoot() -> Bool {
    147         return navigationCoordinator.isAtRoot()
    148     }
    149     
    150     func popToRoot() {
    151         navigationCoordinator.popToRoot()
    152         isSideBarOpened = false
    153     }
    154     
    155     var timelineNavItem: Text {
    156         return Text(timeline_name(selected_timeline))
    157             .bold()
    158     }
    159     
    160     func MainContent(damus: DamusState) -> some View {
    161         VStack {
    162             switch selected_timeline {
    163             case .search:
    164                 if #available(iOS 16.0, *) {
    165                     SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
    166                         .scrollDismissesKeyboard(.immediately)
    167                 } else {
    168                     // Fallback on earlier versions
    169                     SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
    170                 }
    171                 
    172             case .home:
    173                 PostingTimelineView
    174                 
    175             case .notifications:
    176                 NotificationsView(state: damus, notifications: home.notifications)
    177                 
    178             case .dms:
    179                 DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
    180             }
    181         }
    182         .navigationBarTitle(timeline_name(selected_timeline), displayMode: .inline)
    183         .toolbar {
    184             ToolbarItem(placement: .principal) {
    185                 VStack {
    186                     if selected_timeline == .home {
    187                         Image("damus-home")
    188                             .resizable()
    189                             .frame(width:30,height:30)
    190                             .shadow(color: DamusColors.purple, radius: 2)
    191                             .opacity(isSideBarOpened ? 0 : 1)
    192                             .animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
    193                             .onTapGesture {
    194                                 isSideBarOpened.toggle()
    195                             }
    196                     } else {
    197                         timelineNavItem
    198                             .opacity(isSideBarOpened ? 0 : 1)
    199                             .animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
    200                     }
    201                 }
    202             }
    203         }
    204     }
    205     
    206     func MaybeReportView(target: ReportTarget) -> some View {
    207         Group {
    208             if let keypair = damus_state.keypair.to_full() {
    209                 ReportView(postbox: damus_state.postbox, target: target, keypair: keypair)
    210             } else {
    211                 EmptyView()
    212             }
    213         }
    214     }
    215     
    216     func open_event(ev: NostrEvent) {
    217         let thread = ThreadModel(event: ev, damus_state: damus_state!)
    218         navigationCoordinator.push(route: Route.Thread(thread: thread))
    219     }
    220     
    221     func open_wallet(nwc: WalletConnectURL) {
    222         self.damus_state!.wallet.new(nwc)
    223         navigationCoordinator.push(route: Route.Wallet(wallet: damus_state!.wallet))
    224     }
    225     
    226     func open_script(_ script: [UInt8]) {
    227         print("pushing script nav")
    228         let model = ScriptModel(data: script, state: .not_loaded)
    229         navigationCoordinator.push(route: Route.Script(script: model))
    230     }
    231     
    232     func open_profile(pubkey: Pubkey) {
    233         let profile_model = ProfileModel(pubkey: pubkey, damus: damus_state!)
    234         let followers = FollowersModel(damus_state: damus_state!, target: pubkey)
    235         navigationCoordinator.push(route: Route.Profile(profile: profile_model, followers: followers))
    236     }
    237     
    238     func open_search(filt: NostrFilter) {
    239         let search = SearchModel(state: damus_state!, search: filt)
    240         navigationCoordinator.push(route: Route.Search(search: search))
    241     }
    242     
    243     var body: some View {
    244         VStack(alignment: .leading, spacing: 0) {
    245             if let damus = self.damus_state {
    246                 NavigationStack(path: $navigationCoordinator.path) {
    247                     TabView { // Prevents navbar appearance change on scroll
    248                         MainContent(damus: damus)
    249                             .toolbar() {
    250                                 ToolbarItem(placement: .navigationBarLeading) {
    251                                     Button {
    252                                         isSideBarOpened.toggle()
    253                                     } label: {
    254                                         ProfilePicView(pubkey: damus_state!.pubkey, size: 32, highlight: .none, profiles: damus_state!.profiles, disable_animation: damus_state!.settings.disable_animation)
    255                                             .opacity(isSideBarOpened ? 0 : 1)
    256                                             .animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
    257                                     }
    258                                     .disabled(isSideBarOpened)
    259                                 }
    260                                 
    261                                 ToolbarItem(placement: .navigationBarTrailing) {
    262                                     HStack(alignment: .center) {
    263                                         SignalView(state: damus_state!, signal: home.signal)
    264                                         
    265                                         // maybe expand this to other timelines in the future
    266                                         if selected_timeline == .search {
    267                                             
    268                                             Button(action: {
    269                                                 present_sheet(.filter)
    270                                             }, label: {
    271                                                 Image("filter")
    272                                                     .foregroundColor(.gray)
    273                                             })
    274                                         }
    275                                     }
    276                                 }
    277                             }
    278                     }
    279                     .tabViewStyle(.page(indexDisplayMode: .never))
    280                     .overlay(
    281                         SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation())
    282                     )
    283                     .navigationDestination(for: Route.self) { route in
    284                         route.view(navigationCoordinator: navigationCoordinator, damusState: damus_state!)
    285                     }
    286                     .onReceive(handle_notify(.switched_timeline)) { _ in
    287                         navigationCoordinator.popToRoot()
    288                     }
    289                 }
    290                 .navigationViewStyle(.stack)
    291             
    292                 if !hide_bar {
    293                     TabBar(nstatus: home.notification_status, selected: $selected_timeline, settings: damus.settings, action: switch_timeline)
    294                         .padding([.bottom], 8)
    295                         .background(Color(uiColor: .systemBackground).ignoresSafeArea())
    296                 } else {
    297                     Text("")
    298                 }
    299             }
    300         }
    301         .ignoresSafeArea(.keyboard)
    302         .edgesIgnoringSafeArea(hide_bar ? [.bottom] : [])
    303         .onAppear() {
    304             self.connect()
    305             try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
    306             setup_notifications()
    307             if !hasSeenOnboardingSuggestions || damus_state!.settings.always_show_onboarding_suggestions {
    308                 active_sheet = .onboardingSuggestions
    309                 hasSeenOnboardingSuggestions = true
    310             }
    311             self.appDelegate?.settings = damus_state?.settings
    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             }
    342         }
    343         .onOpenURL { url in
    344             on_open_url(state: damus_state!, url: url) { res in
    345                 guard let res else {
    346                     return
    347                 }
    348                 
    349                 switch res {
    350                     case .filter(let filt): self.open_search(filt: filt)
    351                     case .profile(let pk):  self.open_profile(pubkey: pk)
    352                     case .event(let ev):    self.open_event(ev: ev)
    353                     case .wallet_connect(let nwc): self.open_wallet(nwc: nwc)
    354                     case .script(let data): self.open_script(data)
    355                     case .purple(let purple_url):
    356                         if case let .welcome(checkout_id) = purple_url.variant {
    357                             // If this is a welcome link, do the following before showing the onboarding screen:
    358                             // 1. Check if this is legitimate and good to go.
    359                             // 2. Mark as complete if this is good to go.
    360                             Task {
    361                                 let is_good_to_go = try? await damus_state.purple.check_and_mark_ln_checkout_is_good_to_go(checkout_id: checkout_id)
    362                                 if is_good_to_go == true {
    363                                     self.active_sheet = .purple(purple_url)
    364                                 }
    365                             }
    366                         }
    367                         else {
    368                             self.active_sheet = .purple(purple_url)
    369                         }
    370                 }
    371             }
    372         }
    373         .onReceive(handle_notify(.compose)) { action in
    374             self.active_sheet = .post(action)
    375         }
    376         .onReceive(handle_notify(.display_tabbar)) { display in
    377             let show = display
    378             self.hide_bar = !show
    379         }
    380         .onReceive(timer) { n in
    381             self.damus_state?.postbox.try_flushing_events()
    382             self.damus_state!.profiles.profile_data(self.damus_state!.pubkey).status.try_expire()
    383         }
    384         .onReceive(handle_notify(.report)) { target in
    385             self.active_sheet = .report(target)
    386         }
    387         .onReceive(handle_notify(.mute)) { mute_item in
    388             self.muting = mute_item
    389             self.confirm_mute = true
    390         }
    391         .onReceive(handle_notify(.attached_wallet)) { nwc in
    392             // update the lightning address on our profile when we attach a
    393             // wallet with an associated
    394             guard let ds = self.damus_state,
    395                   let lud16 = nwc.lud16,
    396                   let keypair = ds.keypair.to_full(),
    397                   let profile_txn = ds.profiles.lookup(id: ds.pubkey),
    398                   let profile = profile_txn.unsafeUnownedValue,
    399                   lud16 != profile.lud16 else {
    400                 return
    401             }
    402 
    403             // clear zapper cache for old lud16
    404             if profile.lud16 != nil {
    405                 // TODO: should this be somewhere else, where we process profile events!?
    406                 invalidate_zapper_cache(pubkey: keypair.pubkey, profiles: ds.profiles, lnurl: ds.lnurls)
    407             }
    408             
    409             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)
    410 
    411             guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
    412             ds.postbox.send(ev)
    413         }
    414         .onReceive(handle_notify(.broadcast)) { ev in
    415             guard let ds = self.damus_state else { return }
    416 
    417             ds.postbox.send(ev)
    418         }
    419         .onReceive(handle_notify(.unfollow)) { target in
    420             guard let state = self.damus_state else { return }
    421             _ = handle_unfollow(state: state, unfollow: target.follow_ref)
    422         }
    423         .onReceive(handle_notify(.unfollowed)) { unfollow in
    424             home.resubscribe(.unfollowing(unfollow))
    425         }
    426         .onReceive(handle_notify(.follow)) { target in
    427             guard let state = self.damus_state else { return }
    428             handle_follow_notif(state: state, target: target)
    429         }
    430         .onReceive(handle_notify(.followed)) { _ in
    431             home.resubscribe(.following)
    432         }
    433         .onReceive(handle_notify(.post)) { post in
    434             guard let state = self.damus_state,
    435                   let keypair = state.keypair.to_full() else {
    436                       return
    437             }
    438 
    439             if !handle_post_notification(keypair: keypair, postbox: state.postbox, events: state.events, post: post) {
    440                 self.active_sheet = nil
    441             }
    442         }
    443         .onReceive(handle_notify(.new_mutes)) { _ in
    444             home.filter_events()
    445         }
    446         .onReceive(handle_notify(.mute_thread)) { _ in
    447             home.filter_events()
    448         }
    449         .onReceive(handle_notify(.unmute_thread)) { _ in
    450             home.filter_events()
    451         }
    452         .onReceive(handle_notify(.present_sheet)) { sheet in
    453             self.active_sheet = sheet
    454         }
    455         .onReceive(handle_notify(.zapping)) { zap_ev in
    456             guard !zap_ev.is_custom else {
    457                 return
    458             }
    459             
    460             switch zap_ev.type {
    461             case .failed:
    462                 break
    463             case .got_zap_invoice(let inv):
    464                 if damus_state!.settings.show_wallet_selector {
    465                     present_sheet(.select_wallet(invoice: inv))
    466                 } else {
    467                     let wallet = damus_state!.settings.default_wallet.model
    468                     do {
    469                         try open_with_wallet(wallet: wallet, invoice: inv)
    470                     }
    471                     catch {
    472                         present_sheet(.select_wallet(invoice: inv))
    473                     }
    474                 }
    475             case .sent_from_nwc:
    476                 break
    477             }
    478         }
    479         .onReceive(handle_notify(.disconnect_relays)) { () in
    480             damus_state.pool.disconnect()
    481         }
    482         .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
    483             print("txn: 📙 DAMUS ACTIVE NOTIFY")
    484             if damus_state.ndb.reopen() {
    485                 print("txn: NOSTRDB REOPENED")
    486             } else {
    487                 print("txn: NOSTRDB FAILED TO REOPEN closed:\(damus_state.ndb.is_closed)")
    488             }
    489             if damus_state.purple.checkout_ids_in_progress.count > 0 {
    490                 // For extra assurance, run this after one second, to avoid race conditions if the app is also handling a damus purple welcome url.
    491                 DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    492                     Task {
    493                         let freshly_completed_checkout_ids = try? await damus_state.purple.check_status_of_checkouts_in_progress()
    494                         let there_is_a_completed_checkout: Bool = (freshly_completed_checkout_ids?.count ?? 0) > 0
    495                         let account_info = try await damus_state.purple.fetch_account(pubkey: self.keypair.pubkey)
    496                         if there_is_a_completed_checkout == true && account_info?.active == true {
    497                             if damus_state.purple.onboarding_status.user_has_never_seen_the_onboarding_before() {
    498                                 // Show welcome sheet
    499                                 self.active_sheet = .purple_onboarding
    500                             }
    501                             else {
    502                                 self.active_sheet = .purple(DamusPurpleURL.init(is_staging: damus_state.purple.environment == .staging, variant: .landing))
    503                             }
    504                         }
    505                     }
    506                 }
    507             }
    508             Task {
    509                 await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
    510             }
    511         }
    512         .onChange(of: scenePhase) { (phase: ScenePhase) in
    513             guard let damus_state else { return }
    514             switch phase {
    515             case .background:
    516                 print("txn: 📙 DAMUS BACKGROUNDED")
    517                 Task { @MainActor in
    518                     damus_state.ndb.close()
    519                     VideoCache.standard?.periodic_purge()
    520                 }
    521                 break
    522             case .inactive:
    523                 print("txn: 📙 DAMUS INACTIVE")
    524                 break
    525             case .active:
    526                 print("txn: 📙 DAMUS ACTIVE")
    527                 damus_state.pool.ping()
    528             @unknown default:
    529                 break
    530             }
    531         }
    532         .onReceive(handle_notify(.local_notification)) { local in
    533             guard let damus_state else { return }
    534 
    535             switch local.mention {
    536             case .pubkey(let pubkey):
    537                 open_profile(pubkey: pubkey)
    538 
    539             case .note(let noteId):
    540                 openEvent(noteId: noteId, notificationType: local.type)
    541             case .nevent(let nevent):
    542                 openEvent(noteId: nevent.noteid, notificationType: local.type)
    543             case .nprofile(let nprofile):
    544                 open_profile(pubkey: nprofile.author)
    545             case .nrelay(_):
    546                 break
    547             case .naddr(let naddr):
    548                 break
    549             }
    550 
    551 
    552         }
    553         .onReceive(handle_notify(.onlyzaps_mode)) { hide in
    554             home.filter_events()
    555 
    556             guard let ds = damus_state,
    557                   let profile_txn = ds.profiles.lookup(id: ds.pubkey),
    558                   let profile = profile_txn.unsafeUnownedValue,
    559                   let keypair = ds.keypair.to_full()
    560             else {
    561                 return
    562             }
    563 
    564             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)
    565 
    566             guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
    567             ds.postbox.send(profile_ev)
    568         }
    569         .alert(NSLocalizedString("User muted", comment: "Alert message to indicate the user has been muted"), isPresented: $user_muted_confirm, actions: {
    570             Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to muted a user was successful.")) {
    571                 user_muted_confirm = false
    572             }
    573         }, message: {
    574             if case let .user(pubkey, _) = self.muting {
    575                 let profile_txn = damus_state!.profiles.lookup(id: pubkey)
    576                 let profile = profile_txn?.unsafeUnownedValue
    577                 let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
    578                 Text("\(name) has been muted", comment: "Alert message that informs a user was muted.")
    579             } else {
    580                 Text("User has been muted", comment: "Alert message that informs a user was muted.")
    581             }
    582         })
    583         .alert(NSLocalizedString("Create new mutelist", comment: "Title of alert prompting the user to create a new mutelist."), isPresented: $confirm_overwrite_mutelist, actions: {
    584             Button(NSLocalizedString("Cancel", comment: "Button to cancel out of alert that creates a new mutelist.")) {
    585                 confirm_overwrite_mutelist = false
    586                 confirm_mute = false
    587             }
    588 
    589             Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) {
    590                 guard let ds = damus_state,
    591                       let keypair = ds.keypair.to_full(),
    592                       let muting,
    593                       let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: muting)
    594                 else {
    595                     return
    596                 }
    597                 
    598                 ds.mutelist_manager.set_mutelist(mutelist)
    599                 ds.postbox.send(mutelist)
    600 
    601                 confirm_overwrite_mutelist = false
    602                 confirm_mute = false
    603                 user_muted_confirm = true
    604             }
    605         }, message: {
    606             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.")
    607         })
    608         .alert(NSLocalizedString("Mute User", comment: "Title of alert for muting a user."), isPresented: $confirm_mute, actions: {
    609             Button(NSLocalizedString("Cancel", comment: "Alert button to cancel out of alert for muting a user."), role: .cancel) {
    610                 confirm_mute = false
    611             }
    612             Button(NSLocalizedString("Mute", comment: "Alert button to mute a user."), role: .destructive) {
    613                 guard let ds = damus_state else {
    614                     return
    615                 }
    616 
    617                 if ds.mutelist_manager.event == nil {
    618                     confirm_overwrite_mutelist = true
    619                 } else {
    620                     guard let keypair = ds.keypair.to_full(),
    621                           let muting
    622                     else {
    623                         return
    624                     }
    625 
    626                     guard let ev = create_or_update_mutelist(keypair: keypair, mprev: ds.mutelist_manager.event, to_add: muting) else {
    627                         return
    628                     }
    629 
    630                     ds.mutelist_manager.set_mutelist(ev)
    631                     ds.postbox.send(ev)
    632                 }
    633             }
    634         }, message: {
    635             if case let .user(pubkey, _) = muting {
    636                 let profile_txn = damus_state?.profiles.lookup(id: pubkey)
    637                 let profile = profile_txn?.unsafeUnownedValue
    638                 let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
    639                 Text("Mute \(name)?", comment: "Alert message prompt to ask if a user should be muted.")
    640             } else {
    641                 Text("Could not find user to mute...", comment: "Alert message to indicate that the muted user could not be found.")
    642             }
    643         })
    644     }
    645     
    646     func switch_timeline(_ timeline: Timeline) {
    647         self.isSideBarOpened = false
    648         let navWasAtRoot = self.navIsAtRoot()
    649         self.popToRoot()
    650 
    651         notify(.switched_timeline(timeline))
    652 
    653         if timeline == self.selected_timeline && navWasAtRoot {
    654             notify(.scroll_to_top)
    655             return
    656         }
    657         
    658         self.selected_timeline = timeline
    659     }
    660 
    661     func connect() {
    662         // nostrdb
    663         var mndb = Ndb()
    664         if mndb == nil {
    665             // try recovery
    666             print("DB ISSUE! RECOVERING")
    667             mndb = Ndb.safemode()
    668 
    669             // out of space or something?? maybe we need a in-memory fallback
    670             if mndb == nil {
    671                 logout(nil)
    672                 return
    673             }
    674         }
    675 
    676         guard let ndb = mndb else { return  }
    677 
    678         let pool = RelayPool(ndb: ndb, keypair: keypair)
    679         let model_cache = RelayModelCache()
    680         let relay_filters = RelayFilters(our_pubkey: pubkey)
    681         let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
    682         
    683         let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
    684 
    685         let new_relay_filters = load_relay_filters(pubkey) == nil
    686         for relay in bootstrap_relays {
    687             let descriptor = RelayDescriptor(url: relay, info: .rw)
    688             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)
    689         }
    690 
    691         pool.register_handler(sub_id: sub_id, handler: home.handle_event)
    692         
    693         if let nwc_str = settings.nostr_wallet_connect,
    694            let nwc = WalletConnectURL(str: nwc_str) {
    695             try? pool.add_relay(.nwc(url: nwc.relay))
    696         }
    697 
    698         self.damus_state = DamusState(pool: pool,
    699                                       keypair: keypair,
    700                                       likes: EventCounter(our_pubkey: pubkey),
    701                                       boosts: EventCounter(our_pubkey: pubkey),
    702                                       contacts: Contacts(our_pubkey: pubkey),
    703                                       mutelist_manager: MutelistManager(),
    704                                       profiles: Profiles(ndb: ndb),
    705                                       dms: home.dms,
    706                                       previews: PreviewCache(),
    707                                       zaps: Zaps(our_pubkey: pubkey),
    708                                       lnurls: LNUrls(),
    709                                       settings: settings,
    710                                       relay_filters: relay_filters,
    711                                       relay_model_cache: model_cache,
    712                                       drafts: Drafts(),
    713                                       events: EventCache(ndb: ndb),
    714                                       bookmarks: BookmarksManager(pubkey: pubkey),
    715                                       postbox: PostBox(pool: pool),
    716                                       bootstrap_relays: bootstrap_relays,
    717                                       replies: ReplyCounter(our_pubkey: pubkey),
    718                                       wallet: WalletModel(settings: settings),
    719                                       nav: self.navigationCoordinator,
    720                                       music: MusicController(onChange: music_changed),
    721                                       video: VideoController(),
    722                                       ndb: ndb,
    723                                       quote_reposts: .init(our_pubkey: pubkey)
    724         )
    725         
    726         home.damus_state = self.damus_state!
    727         
    728         if let damus_state, damus_state.purple.enable_purple {
    729             // Assign delegate so that we can send receipts to the Purple API server as soon as we get updates from user's purchases
    730             StoreObserver.standard.delegate = damus_state.purple
    731             Task {
    732                 await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
    733             }
    734         }
    735         else {
    736             // Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
    737         }
    738         
    739         pool.connect()
    740     }
    741 
    742     func music_changed(_ state: MusicState) {
    743         guard let damus_state else { return }
    744         switch state {
    745         case .playback_state:
    746             break
    747         case .song(let song):
    748             guard let song, let kp = damus_state.keypair.to_full() else { return }
    749 
    750             let pdata = damus_state.profiles.profile_data(damus_state.pubkey)
    751 
    752             let desc = "\(song.title ?? "Unknown") - \(song.artist ?? "Unknown")"
    753             let encodedDesc = desc.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
    754             let url = encodedDesc.flatMap { enc in
    755                 URL(string: "spotify:search:\(enc)")
    756             }
    757             let music = UserStatus(type: .music, expires_at: Date.now.addingTimeInterval(song.playbackDuration), content: desc, created_at: UInt32(Date.now.timeIntervalSince1970), url: url)
    758 
    759             pdata.status.music = music
    760 
    761             guard let ev = music.to_note(keypair: kp) else { return }
    762             damus_state.postbox.send(ev)
    763         }
    764     }
    765 
    766     private func openEvent(noteId: NoteId, notificationType: LocalNotificationType) {
    767         guard let target = damus_state.events.lookup(noteId) else {
    768             return
    769         }
    770 
    771         switch notificationType {
    772         case .dm:
    773             selected_timeline = .dms
    774             damus_state.dms.set_active_dm(target.pubkey)
    775             navigationCoordinator.push(route: Route.DMChat(dms: damus_state.dms.active_model))
    776         case .like, .zap, .mention, .repost:
    777             open_event(ev: target)
    778         case .profile_zap:
    779             break
    780         }
    781     }
    782 }
    783 
    784 struct ContentView_Previews: PreviewProvider {
    785     static var previews: some View {
    786         ContentView(keypair: Keypair(pubkey: test_pubkey, privkey: nil), appDelegate: nil)
    787     }
    788 }
    789 
    790 func get_since_time(last_event: NostrEvent?) -> UInt32? {
    791     if let last_event = last_event {
    792         return last_event.created_at - 60 * 10
    793     }
    794     
    795     return nil
    796 }
    797 
    798 extension UINavigationController: UIGestureRecognizerDelegate {
    799     override open func viewDidLoad() {
    800         super.viewDidLoad()
    801         interactivePopGestureRecognizer?.delegate = self
    802     }
    803 
    804     public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    805         return viewControllers.count > 1
    806     }
    807 }
    808 
    809 struct LastNotification {
    810     let id: NoteId
    811     let created_at: Int64
    812 }
    813 
    814 func get_last_event(_ timeline: Timeline) -> LastNotification? {
    815     let str = timeline.rawValue
    816     let last = UserDefaults.standard.string(forKey: "last_\(str)")
    817     let last_created = UserDefaults.standard.string(forKey: "last_\(str)_time")
    818         .flatMap { Int64($0) }
    819 
    820     guard let last,
    821           let note_id = NoteId(hex: last),
    822           let last_created
    823     else {
    824         return nil
    825     }
    826 
    827     return LastNotification(id: note_id, created_at: last_created)
    828 }
    829 
    830 func save_last_event(_ ev: NostrEvent, timeline: Timeline) {
    831     let str = timeline.rawValue
    832     UserDefaults.standard.set(ev.id.hex(), forKey: "last_\(str)")
    833     UserDefaults.standard.set(String(ev.created_at), forKey: "last_\(str)_time")
    834 }
    835 
    836 func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [NostrFilter]) -> [NostrFilter] {
    837 
    838     return filters.map { filter in
    839         let kinds = filter.kinds ?? []
    840         let initial: UInt32? = nil
    841         let earliest = kinds.reduce(initial) { earliest, kind in
    842             let last = last_of_kind[kind.rawValue]
    843             let since: UInt32? = get_since_time(last_event: last)
    844 
    845             if earliest == nil {
    846                 if since == nil {
    847                     return nil
    848                 }
    849                 return since
    850             }
    851             
    852             if since == nil {
    853                 return earliest
    854             }
    855             
    856             return since! < earliest! ? since! : earliest!
    857         }
    858         
    859         if let earliest = earliest {
    860             var with_since = NostrFilter.copy(from: filter)
    861             with_since.since = earliest
    862             return with_since
    863         }
    864         
    865         return filter
    866     }
    867 }
    868 
    869 
    870 func setup_notifications() {
    871     UIApplication.shared.registerForRemoteNotifications()
    872     let center = UNUserNotificationCenter.current()
    873     
    874     center.getNotificationSettings { settings in
    875         guard settings.authorizationStatus == .authorized else {
    876             center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
    877                 
    878             }
    879             
    880             return
    881         }
    882     }
    883 }
    884 
    885 struct FindEvent {
    886     let type: FindEventType
    887     let find_from: [RelayURL]?
    888 
    889     static func profile(pubkey: Pubkey, find_from: [RelayURL]? = nil) -> FindEvent {
    890         return FindEvent(type: .profile(pubkey), find_from: find_from)
    891     }
    892 
    893     static func event(evid: NoteId, find_from: [RelayURL]? = nil) -> FindEvent {
    894         return FindEvent(type: .event(evid), find_from: find_from)
    895     }
    896 }
    897 
    898 enum FindEventType {
    899     case profile(Pubkey)
    900     case event(NoteId)
    901 }
    902 
    903 enum FoundEvent {
    904     case profile(Pubkey)
    905     case invalid_profile(NostrEvent)
    906     case event(NostrEvent)
    907 }
    908 
    909 func find_event(state: DamusState, query query_: FindEvent, callback: @escaping (FoundEvent?) -> ()) {
    910     return find_event_with_subid(state: state, query: query_, subid: UUID().description, callback: callback)
    911 }
    912 
    913 func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: String, callback: @escaping (FoundEvent?) -> ()) {
    914 
    915     var filter: NostrFilter? = nil
    916     let find_from = query_.find_from
    917     let query = query_.type
    918     
    919     switch query {
    920     case .profile(let pubkey):
    921         if let profile_txn = state.ndb.lookup_profile(pubkey),
    922            let record = profile_txn.unsafeUnownedValue,
    923            record.profile != nil
    924         {
    925             callback(.profile(pubkey))
    926             return
    927         }
    928         filter = NostrFilter(kinds: [.metadata], limit: 1, authors: [pubkey])
    929         
    930     case .event(let evid):
    931         if let ev = state.events.lookup(evid) {
    932             callback(.event(ev))
    933             return
    934         }
    935     
    936         filter = NostrFilter(ids: [evid], limit: 1)
    937     }
    938     
    939     var attempts: Int = 0
    940     var has_event = false
    941     guard let filter else { return }
    942     
    943     state.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res  in
    944         guard case .nostr_event(let ev) = res else {
    945             return
    946         }
    947         
    948         guard ev.subid == subid else {
    949             return
    950         }
    951         
    952         switch ev {
    953         case .ok:
    954             break
    955         case .event(_, let ev):
    956             has_event = true
    957             state.pool.unsubscribe(sub_id: subid)
    958             
    959             switch query {
    960             case .profile:
    961                 if ev.known_kind == .metadata {
    962                     guard state.ndb.lookup_profile_key(ev.pubkey) != nil else {
    963                         callback(.invalid_profile(ev))
    964                         return
    965                     }
    966                     callback(.profile(ev.pubkey))
    967                 }
    968             case .event:
    969                 callback(.event(ev))
    970             }
    971         case .eose:
    972             if !has_event {
    973                 attempts += 1
    974                 if attempts == state.pool.our_descriptors.count / 2 {
    975                     callback(nil)
    976                 }
    977                 state.pool.unsubscribe(sub_id: subid, to: [relay_id])
    978             }
    979         case .notice:
    980             break
    981         case .auth:
    982             break
    983         }
    984 
    985     }
    986 }
    987 
    988 func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
    989     var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
    990 
    991     let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
    992     
    993     let subid = UUID().description
    994     
    995     damus_state.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res  in
    996         guard case .nostr_event(let ev) = res else {
    997             damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
    998             return
    999         }
   1000         
   1001         if case .event(_, let ev) = ev {
   1002             for tag in ev.tags {
   1003                 if(tag.count >= 2 && tag[0].string() == "d"){
   1004                     if (tag[1].string() == naddr.identifier){
   1005                         damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
   1006                         callback(ev)
   1007                         return
   1008                     }
   1009                 }
   1010             }
   1011         }
   1012         damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
   1013     }
   1014 }
   1015 
   1016 func timeline_name(_ timeline: Timeline?) -> String {
   1017     guard let timeline else {
   1018         return ""
   1019     }
   1020     switch timeline {
   1021     case .home:
   1022         return NSLocalizedString("Home", comment: "Navigation bar title for Home view where notes and replies appear from those who the user is following.")
   1023     case .notifications:
   1024         return NSLocalizedString("Notifications", comment: "Toolbar label for Notifications view.")
   1025     case .search:
   1026         return NSLocalizedString("Universe 🛸", comment: "Toolbar label for the universal view where notes from all connected relay servers appear.")
   1027     case .dms:
   1028         return NSLocalizedString("DMs", comment: "Toolbar label for DMs view, where DM is the English abbreviation for Direct Message.")
   1029     }
   1030 }
   1031 
   1032 @discardableResult
   1033 func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool {
   1034     guard let keypair = state.keypair.to_full() else {
   1035         return false
   1036     }
   1037 
   1038     let old_contacts = state.contacts.event
   1039 
   1040     guard let ev = unfollow_reference(postbox: state.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
   1041     else {
   1042         return false
   1043     }
   1044 
   1045     notify(.unfollowed(unfollow))
   1046 
   1047     state.contacts.event = ev
   1048 
   1049     switch unfollow {
   1050     case .pubkey(let pk):
   1051         state.contacts.remove_friend(pk)
   1052     case .hashtag:
   1053         // nothing to handle here really
   1054         break
   1055     }
   1056 
   1057     return true
   1058 }
   1059 
   1060 @discardableResult
   1061 func handle_follow(state: DamusState, follow: FollowRef) -> Bool {
   1062     guard let keypair = state.keypair.to_full() else {
   1063         return false
   1064     }
   1065 
   1066     guard let ev = follow_reference(box: state.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
   1067     else {
   1068         return false
   1069     }
   1070 
   1071     notify(.followed(follow))
   1072 
   1073     state.contacts.event = ev
   1074     switch follow {
   1075     case .pubkey(let pubkey):
   1076         state.contacts.add_friend_pubkey(pubkey)
   1077     case .hashtag:
   1078         // nothing to do
   1079         break
   1080     }
   1081 
   1082     return true
   1083 }
   1084 
   1085 @discardableResult
   1086 func handle_follow_notif(state: DamusState, target: FollowTarget) -> Bool {
   1087     switch target {
   1088     case .pubkey(let pk):
   1089         state.contacts.add_friend_pubkey(pk)
   1090     case .contact(let ev):
   1091         state.contacts.add_friend_contact(ev)
   1092     }
   1093 
   1094     return handle_follow(state: state, follow: target.follow_ref)
   1095 }
   1096 
   1097 func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult) -> Bool {
   1098     switch post {
   1099     case .post(let post):
   1100         //let post = tup.0
   1101         //let to_relays = tup.1
   1102         print("post \(post.content)")
   1103         guard let new_ev = post_to_event(post: post, keypair: keypair) else {
   1104             return false
   1105         }
   1106         postbox.send(new_ev)
   1107         for eref in new_ev.referenced_ids.prefix(3) {
   1108             // also broadcast at most 3 referenced events
   1109             if let ev = events.lookup(eref) {
   1110                 postbox.send(ev)
   1111             }
   1112         }
   1113         for qref in new_ev.referenced_quote_ids.prefix(3) {
   1114             // also broadcast at most 3 referenced quoted events
   1115             if let ev = events.lookup(qref.note_id) {
   1116                 postbox.send(ev)
   1117             }
   1118         }
   1119         return true
   1120     case .cancel:
   1121         print("post cancelled")
   1122         return false
   1123     }
   1124 }
   1125 
   1126 
   1127 enum OpenResult {
   1128     case profile(Pubkey)
   1129     case filter(NostrFilter)
   1130     case event(NostrEvent)
   1131     case wallet_connect(WalletConnectURL)
   1132     case script([UInt8])
   1133     case purple(DamusPurpleURL)
   1134 }
   1135 
   1136 func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) -> Void) {
   1137     if let purple_url = DamusPurpleURL(url: url) {
   1138         result(.purple(purple_url))
   1139         return
   1140     }
   1141     
   1142     if let nwc = WalletConnectURL(str: url.absoluteString) {
   1143         result(.wallet_connect(nwc))
   1144         return
   1145     }
   1146     
   1147     guard let link = decode_nostr_uri(url.absoluteString) else {
   1148         result(nil)
   1149         return
   1150     }
   1151     
   1152     switch link {
   1153     case .ref(let ref):
   1154         switch ref {
   1155         case .pubkey(let pk):
   1156             result(.profile(pk))
   1157         case .event(let noteid):
   1158             find_event(state: state, query: .event(evid: noteid)) { res in
   1159                 guard let res, case .event(let ev) = res else { return }
   1160                 result(.event(ev))
   1161             }
   1162         case .hashtag(let ht):
   1163             result(.filter(.filter_hashtag([ht.hashtag])))
   1164         case .param, .quote:
   1165             // doesn't really make sense here
   1166             break
   1167         case .naddr(let naddr):
   1168             naddrLookup(damus_state: state, naddr: naddr) { res in
   1169                 guard let res = res else { return }
   1170                 result(.event(res))
   1171             }
   1172         }
   1173     case .filter(let filt):
   1174         result(.filter(filt))
   1175         break
   1176         // TODO: handle filter searches?
   1177     case .script(let script):
   1178         result(.script(script))
   1179         break
   1180     }
   1181 }
   1182 
   1183 
   1184 func logout(_ state: DamusState?)
   1185 {
   1186     state?.close()
   1187     notify(.logout)
   1188 }
   1189