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