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