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