The favorites timeline was empty because: 1. The @StateObject filter in InnerTimelineView was captured once at init 2. Favorite events were mixed with follows events and got drowned out Fixed by: - Adding viewId parameter to TimelineView to force view recreation on switch - Creating separate favoriteEvents EventHolder for favorites - Adding dedicated subscribe_to_favorites() subscription that inserts directly into favoriteEvents when contact cards are loaded
1157 lines
50 KiB
Swift
1157 lines
50 KiB
Swift
//
|
|
// ContentView.swift
|
|
// damus
|
|
//
|
|
// Created by William Casarin on 2022-04-01.
|
|
//
|
|
|
|
import SwiftUI
|
|
import AVKit
|
|
import MediaPlayer
|
|
import EmojiPicker
|
|
import TipKit
|
|
|
|
struct ZapSheet {
|
|
let target: ZapTarget
|
|
let lnurl: String
|
|
}
|
|
|
|
struct SelectWallet {
|
|
let invoice: String
|
|
}
|
|
|
|
enum Sheets: Identifiable {
|
|
case post(PostAction)
|
|
case report(ReportTarget)
|
|
case event(NostrEvent)
|
|
case profile_action(Pubkey)
|
|
case zap(ZapSheet)
|
|
case select_wallet(SelectWallet)
|
|
case filter
|
|
case user_status
|
|
case onboardingSuggestions
|
|
case purple(DamusPurpleURL)
|
|
case purple_onboarding
|
|
case error(ErrorView.UserPresentableError)
|
|
|
|
static func zap(target: ZapTarget, lnurl: String) -> Sheets {
|
|
return .zap(ZapSheet(target: target, lnurl: lnurl))
|
|
}
|
|
|
|
static func select_wallet(invoice: String) -> Sheets {
|
|
return .select_wallet(SelectWallet(invoice: invoice))
|
|
}
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .report: return "report"
|
|
case .user_status: return "user_status"
|
|
case .post(let action): return "post-" + (action.ev?.id.hex() ?? "")
|
|
case .event(let ev): return "event-" + ev.id.hex()
|
|
case .profile_action(let pubkey): return "profile-action-" + pubkey.npub
|
|
case .zap(let sheet): return "zap-" + hex_encode(sheet.target.id)
|
|
case .select_wallet: return "select-wallet"
|
|
case .filter: return "filter"
|
|
case .onboardingSuggestions: return "onboarding-suggestions"
|
|
case .purple(let purple_url): return "purple" + purple_url.url_string()
|
|
case .purple_onboarding: return "purple_onboarding"
|
|
case .error(_): return "error"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An item to be presented full screen in a mechanism that is more robust for timeline views.
|
|
///
|
|
/// ## Implementation notes
|
|
///
|
|
/// 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
|
|
/// 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,
|
|
/// causing the user to lose the full screen view randomly.
|
|
///
|
|
/// The `ContentView` is responsible for handling these objects
|
|
///
|
|
/// New items can be added as needed.
|
|
///
|
|
enum FullScreenItem: Identifiable, Equatable {
|
|
/// A full screen media carousel for images and videos.
|
|
case full_screen_carousel(urls: [MediaUrl], selectedIndex: Binding<Int>)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .full_screen_carousel(let urls, _): return "full_screen_carousel:\(urls.map(\.url))"
|
|
}
|
|
}
|
|
|
|
static func == (lhs: FullScreenItem, rhs: FullScreenItem) -> Bool {
|
|
return lhs.id == rhs.id
|
|
}
|
|
|
|
/// The view to display the item
|
|
func view(damus_state: DamusState) -> some View {
|
|
switch self {
|
|
case .full_screen_carousel(let urls, let selectedIndex):
|
|
return FullScreenCarouselView<AnyView>(video_coordinator: damus_state.video, urls: urls, settings: damus_state.settings, selectedIndex: selectedIndex)
|
|
}
|
|
}
|
|
}
|
|
|
|
func present_sheet(_ sheet: Sheets) {
|
|
notify(.present_sheet(sheet))
|
|
}
|
|
|
|
var tabHeight: CGFloat = 0.0
|
|
|
|
struct ContentView: View {
|
|
let keypair: Keypair
|
|
let appDelegate: AppDelegate?
|
|
|
|
var pubkey: Pubkey {
|
|
return keypair.pubkey
|
|
}
|
|
|
|
var privkey: Privkey? {
|
|
return keypair.privkey
|
|
}
|
|
|
|
@Environment(\.scenePhase) var scenePhase
|
|
|
|
@State var active_sheet: Sheets? = nil
|
|
@State var active_full_screen_item: FullScreenItem? = nil
|
|
@State var damus_state: DamusState!
|
|
@State var menu_subtitle: String? = nil
|
|
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home {
|
|
willSet {
|
|
self.menu_subtitle = nil
|
|
}
|
|
}
|
|
@State var muting: MuteItem? = nil
|
|
@State var confirm_mute: Bool = false
|
|
@State var hide_bar: Bool = false
|
|
@State var user_muted_confirm: Bool = false
|
|
@State var confirm_overwrite_mutelist: Bool = false
|
|
@State private var isSideBarOpened = false
|
|
@State var headerOffset: CGFloat = 0.0
|
|
var home: HomeModel = HomeModel()
|
|
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
|
|
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
|
|
let sub_id = UUID().description
|
|
@State var damusClosingTask: Task<Void, Never>? = nil
|
|
|
|
// connect retry timer
|
|
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
|
|
|
func navIsAtRoot() -> Bool {
|
|
return navigationCoordinator.isAtRoot()
|
|
}
|
|
|
|
func popToRoot() {
|
|
navigationCoordinator.popToRoot()
|
|
isSideBarOpened = false
|
|
}
|
|
|
|
var timelineNavItem: some View {
|
|
VStack {
|
|
Text(timeline_name(selected_timeline))
|
|
.bold()
|
|
if let menu_subtitle {
|
|
Text(menu_subtitle)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
func MainContent(damus: DamusState) -> some View {
|
|
VStack {
|
|
switch selected_timeline {
|
|
case .search:
|
|
if #available(iOS 16.0, *) {
|
|
SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
|
|
.scrollDismissesKeyboard(.immediately)
|
|
} else {
|
|
// Fallback on earlier versions
|
|
SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!))
|
|
}
|
|
|
|
case .home:
|
|
PostingTimelineView(damus_state: damus_state!, home: home, homeEvents: home.events, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset)
|
|
|
|
case .notifications:
|
|
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
|
|
|
case .dms:
|
|
DirectMessagesView(damus_state: damus_state!, home: home, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle)
|
|
}
|
|
}
|
|
.background(DamusColors.adaptableWhite)
|
|
.edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
|
|
.navigationBarTitle(timeline_name(selected_timeline), displayMode: .inline)
|
|
.toolbar(selected_timeline != .home ? .visible : .hidden)
|
|
.toolbar {
|
|
ToolbarItem(placement: .principal) {
|
|
VStack {
|
|
timelineNavItem
|
|
.opacity(isSideBarOpened ? 0 : 1)
|
|
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
notify(.display_tabbar(true))
|
|
}
|
|
}
|
|
|
|
func MaybeReportView(target: ReportTarget) -> some View {
|
|
Group {
|
|
if let keypair = damus_state.keypair.to_full() {
|
|
ReportView(postbox: damus_state.nostrNetwork.postbox, target: target, keypair: keypair)
|
|
} else {
|
|
EmptyView()
|
|
}
|
|
}
|
|
}
|
|
|
|
func open_event(ev: NostrEvent) {
|
|
let thread = ThreadModel(event: ev, damus_state: damus_state!)
|
|
navigationCoordinator.push(route: Route.Thread(thread: thread))
|
|
}
|
|
|
|
func open_wallet(nwc: WalletConnectURL) {
|
|
self.damus_state!.wallet.new(nwc)
|
|
navigationCoordinator.push(route: Route.Wallet(wallet: damus_state!.wallet))
|
|
}
|
|
|
|
func open_script(_ script: [UInt8]) {
|
|
print("pushing script nav")
|
|
let model = ScriptModel(data: script, state: .not_loaded)
|
|
navigationCoordinator.push(route: Route.Script(script: model))
|
|
}
|
|
|
|
func open_search(filt: NostrFilter) {
|
|
let search = SearchModel(state: damus_state!, search: filt)
|
|
navigationCoordinator.push(route: Route.Search(search: search))
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
if let damus = self.damus_state {
|
|
NavigationStack(path: $navigationCoordinator.path) {
|
|
TabView { // Prevents navbar appearance change on scroll
|
|
MainContent(damus: damus)
|
|
.toolbar() {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
TopbarSideMenuButton(damus_state: damus, isSideBarOpened: $isSideBarOpened)
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
HStack(alignment: .center) {
|
|
SignalView(state: damus_state!, signal: home.signal)
|
|
|
|
// maybe expand this to other timelines in the future
|
|
if selected_timeline == .search {
|
|
|
|
Button(action: {
|
|
present_sheet(.filter)
|
|
}, label: {
|
|
Image("filter")
|
|
.foregroundColor(.gray)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.background(DamusColors.adaptableWhite)
|
|
.edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
|
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
|
.overlay(
|
|
SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation(), selected: $selected_timeline)
|
|
)
|
|
.navigationDestination(for: Route.self) { route in
|
|
route.view(navigationCoordinator: navigationCoordinator, damusState: damus_state!)
|
|
}
|
|
.onReceive(handle_notify(.switched_timeline)) { _ in
|
|
navigationCoordinator.popToRoot()
|
|
}
|
|
}
|
|
.navigationViewStyle(.stack)
|
|
.damus_full_screen_cover($active_full_screen_item, damus_state: damus, content: { item in
|
|
return item.view(damus_state: damus)
|
|
})
|
|
.overlay(alignment: .bottom) {
|
|
if !hide_bar {
|
|
if !isSideBarOpened {
|
|
TabBar(nstatus: home.notification_status, navIsAtRoot: navIsAtRoot(), selected: $selected_timeline, headerOffset: $headerOffset, settings: damus.settings, action: switch_timeline)
|
|
.padding([.bottom], 8)
|
|
.background(selected_timeline != .home || (selected_timeline == .home && !self.navIsAtRoot()) ? DamusColors.adaptableWhite : DamusColors.adaptableWhite.opacity(abs(1.25 - (abs(headerOffset/100.0)))))
|
|
.anchorPreference(key: HeaderBoundsKey.self, value: .bounds){$0}
|
|
.overlayPreferenceValue(HeaderBoundsKey.self) { value in
|
|
GeometryReader{ proxy in
|
|
if let anchor = value{
|
|
Color.clear
|
|
.onAppear {
|
|
tabHeight = proxy[anchor].height
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.ignoresSafeArea(.keyboard)
|
|
.edgesIgnoringSafeArea(hide_bar ? [.bottom] : [])
|
|
.onAppear() {
|
|
Task {
|
|
await self.connect()
|
|
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
|
|
setup_notifications()
|
|
if !hasSeenOnboardingSuggestions || damus_state!.settings.always_show_onboarding_suggestions {
|
|
if damus_state.is_privkey_user {
|
|
active_sheet = .onboardingSuggestions
|
|
hasSeenOnboardingSuggestions = true
|
|
}
|
|
}
|
|
self.appDelegate?.state = damus_state
|
|
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.
|
|
await self.listenAndHandleLocalNotifications()
|
|
}
|
|
}
|
|
}
|
|
.sheet(item: $active_sheet) { item in
|
|
switch item {
|
|
case .report(let target):
|
|
MaybeReportView(target: target)
|
|
case .post(let action):
|
|
PostView(action: action, damus_state: damus_state!)
|
|
case .user_status:
|
|
UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.nostrNetwork.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
|
|
.presentationDragIndicator(.visible)
|
|
case .event:
|
|
EventDetailView()
|
|
case .profile_action(let pubkey):
|
|
ProfileActionSheetView(damus_state: damus_state!, pubkey: pubkey)
|
|
case .zap(let zapsheet):
|
|
CustomizeZapView(state: damus_state!, target: zapsheet.target, lnurl: zapsheet.lnurl)
|
|
case .select_wallet(let select):
|
|
SelectWalletView(default_wallet: damus_state!.settings.default_wallet, active_sheet: $active_sheet, our_pubkey: damus_state!.pubkey, invoice: select.invoice)
|
|
case .filter:
|
|
let timeline = selected_timeline
|
|
RelayFilterView(state: damus_state!, timeline: timeline)
|
|
.presentationDetents([.height(550)])
|
|
.presentationDragIndicator(.visible)
|
|
case .onboardingSuggestions:
|
|
if let model = try? SuggestedUsersViewModel(damus_state: damus_state!) {
|
|
OnboardingSuggestionsView(model: model)
|
|
.interactiveDismissDisabled(true)
|
|
}
|
|
else {
|
|
ErrorView(
|
|
damus_state: damus_state,
|
|
error: .init(
|
|
user_visible_description: NSLocalizedString("Unexpected error loading user suggestions", comment: "Human readable error label"),
|
|
tip: NSLocalizedString("Please contact support", comment: "Human readable error tip"),
|
|
technical_info: "Error inializing SuggestedUsersViewModel"
|
|
)
|
|
)
|
|
}
|
|
case .purple(let purple_url):
|
|
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
|
|
case .purple_onboarding:
|
|
DamusPurpleNewUserOnboardingView(damus_state: damus_state)
|
|
case .error(let error):
|
|
ErrorView(damus_state: damus_state!, error: error)
|
|
}
|
|
}
|
|
.onOpenURL { url in
|
|
Task {
|
|
let open_action = await DamusURLHandler.handle_opening_url_and_compute_view_action(damus_state: self.damus_state, url: url)
|
|
self.execute_open_action(open_action)
|
|
}
|
|
}
|
|
.onReceive(handle_notify(.compose)) { action in
|
|
self.active_sheet = .post(action)
|
|
}
|
|
.onReceive(handle_notify(.display_tabbar)) { display in
|
|
let show = display
|
|
self.hide_bar = !show
|
|
}
|
|
.onReceive(timer) { n in
|
|
Task{ await self.damus_state?.nostrNetwork.postbox.try_flushing_events() }
|
|
self.damus_state!.profiles.profile_data(self.damus_state!.pubkey).status.try_expire()
|
|
}
|
|
.onReceive(handle_notify(.report)) { target in
|
|
self.active_sheet = .report(target)
|
|
}
|
|
.onReceive(handle_notify(.mute)) { mute_item in
|
|
self.muting = mute_item
|
|
self.confirm_mute = true
|
|
}
|
|
.onReceive(handle_notify(.attached_wallet)) { nwc in
|
|
Task {
|
|
try? await damus_state.nostrNetwork.userRelayList.load() // Reload relay list to apply changes
|
|
|
|
// update the lightning address on our profile when we attach a
|
|
// wallet with an associated
|
|
guard let ds = self.damus_state,
|
|
let lud16 = nwc.lud16,
|
|
let keypair = ds.keypair.to_full(),
|
|
let profile = try? ds.profiles.lookup(id: ds.pubkey),
|
|
lud16 != profile.lud16 else {
|
|
return
|
|
}
|
|
|
|
// clear zapper cache for old lud16
|
|
if profile.lud16 != nil {
|
|
// TODO: should this be somewhere else, where we process profile events!?
|
|
invalidate_zapper_cache(pubkey: keypair.pubkey, profiles: ds.profiles, lnurl: ds.lnurls)
|
|
}
|
|
|
|
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)
|
|
|
|
guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
|
await ds.nostrNetwork.postbox.send(ev)
|
|
}
|
|
}
|
|
.onReceive(handle_notify(.broadcast)) { ev in
|
|
guard let ds = self.damus_state else { return }
|
|
|
|
Task { await ds.nostrNetwork.postbox.send(ev) }
|
|
}
|
|
.onReceive(handle_notify(.unfollow)) { target in
|
|
guard let state = self.damus_state else { return }
|
|
Task { _ = await handle_unfollow(state: state, unfollow: target.follow_ref) }
|
|
}
|
|
.onReceive(handle_notify(.unfollowed)) { unfollow in
|
|
home.resubscribe(.unfollowing(unfollow))
|
|
}
|
|
.onReceive(handle_notify(.follow)) { target in
|
|
guard let state = self.damus_state else { return }
|
|
Task { await handle_follow_notif(state: state, target: target) }
|
|
}
|
|
.onReceive(handle_notify(.followed)) { _ in
|
|
home.resubscribe(.following)
|
|
}
|
|
.onReceive(handle_notify(.post)) { post in
|
|
guard let state = self.damus_state,
|
|
let keypair = state.keypair.to_full() else {
|
|
return
|
|
}
|
|
|
|
Task {
|
|
if await !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post) {
|
|
self.active_sheet = nil
|
|
}
|
|
}
|
|
}
|
|
.onReceive(handle_notify(.new_mutes)) { _ in
|
|
home.filter_events()
|
|
}
|
|
.onReceive(handle_notify(.mute_thread)) { _ in
|
|
home.filter_events()
|
|
}
|
|
.onReceive(handle_notify(.unmute_thread)) { _ in
|
|
home.filter_events()
|
|
}
|
|
.onReceive(handle_notify(.present_sheet)) { sheet in
|
|
self.active_sheet = sheet
|
|
}
|
|
.onReceive(handle_notify(.present_full_screen_item)) { item in
|
|
self.active_full_screen_item = item
|
|
}
|
|
.onReceive(handle_notify(.favoriteUpdated)) { _ in
|
|
home.subscribe_to_favorites()
|
|
}
|
|
.onReceive(handle_notify(.zapping)) { zap_ev in
|
|
guard !zap_ev.is_custom else {
|
|
return
|
|
}
|
|
|
|
switch zap_ev.type {
|
|
case .failed:
|
|
break
|
|
case .got_zap_invoice(let inv):
|
|
if damus_state!.settings.show_wallet_selector {
|
|
present_sheet(.select_wallet(invoice: inv))
|
|
} else {
|
|
let wallet = damus_state!.settings.default_wallet.model
|
|
do {
|
|
try open_with_wallet(wallet: wallet, invoice: inv)
|
|
}
|
|
catch {
|
|
present_sheet(.select_wallet(invoice: inv))
|
|
}
|
|
}
|
|
case .sent_from_nwc:
|
|
break
|
|
}
|
|
}
|
|
.onReceive(handle_notify(.disconnect_relays)) { () in
|
|
Task { await damus_state.nostrNetwork.disconnectRelays() }
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
|
|
print("txn: 📙 DAMUS ACTIVE NOTIFY")
|
|
Task {
|
|
if damus_state.ndb.reopen() {
|
|
print("txn: NOSTRDB REOPENED")
|
|
} else {
|
|
print("txn: NOSTRDB FAILED TO REOPEN closed:\(damus_state.ndb.is_closed)")
|
|
}
|
|
if damus_state.purple.checkout_ids_in_progress.count > 0 {
|
|
// For extra assurance, run this after one second, to avoid race conditions if the app is also handling a damus purple welcome url.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
|
Task {
|
|
let freshly_completed_checkout_ids = try? await damus_state.purple.check_status_of_checkouts_in_progress()
|
|
let there_is_a_completed_checkout: Bool = (freshly_completed_checkout_ids?.count ?? 0) > 0
|
|
let account_info = try await damus_state.purple.fetch_account(pubkey: self.keypair.pubkey)
|
|
if there_is_a_completed_checkout == true && account_info?.active == true {
|
|
if damus_state.purple.onboarding_status.user_has_never_seen_the_onboarding_before() {
|
|
// Show welcome sheet
|
|
self.active_sheet = .purple_onboarding
|
|
}
|
|
else {
|
|
self.active_sheet = .purple(DamusPurpleURL.init(is_staging: damus_state.purple.environment == .staging, variant: .landing))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
|
|
}
|
|
}
|
|
.onChange(of: scenePhase) { (phase: ScenePhase) in
|
|
guard let damus_state else { return }
|
|
switch phase {
|
|
case .background:
|
|
print("txn: 📙 DAMUS BACKGROUNDED")
|
|
let bgTask = this_app.beginBackgroundTask(withName: "Closing things down gracefully", expirationHandler: { [weak damus_state] in
|
|
})
|
|
|
|
damusClosingTask = Task { @MainActor in
|
|
Log.debug("App background signal handling: App being backgrounded", for: .app_lifecycle)
|
|
let startTime = CFAbsoluteTimeGetCurrent()
|
|
|
|
// Stop periodic snapshots
|
|
await damus_state.snapshotManager.stopPeriodicSnapshots()
|
|
|
|
await damus_state.nostrNetwork.handleAppBackgroundRequest() // Close ndb streaming tasks before closing ndb to avoid memory errors
|
|
|
|
Log.debug("App background signal handling: Nostr network manager closed after %.2f seconds", for: .app_lifecycle, CFAbsoluteTimeGetCurrent() - startTime)
|
|
|
|
this_app.endBackgroundTask(bgTask)
|
|
}
|
|
break
|
|
case .inactive:
|
|
print("txn: 📙 DAMUS INACTIVE")
|
|
break
|
|
case .active:
|
|
print("txn: 📙 DAMUS ACTIVE")
|
|
Task {
|
|
await damusClosingTask?.value // Wait for the closing task to finish before reopening things, to avoid race conditions
|
|
damusClosingTask = nil
|
|
await damus_state.nostrNetwork.handleAppForegroundRequest()
|
|
|
|
// Restart periodic snapshots when returning to foreground
|
|
await damus_state.snapshotManager.startPeriodicSnapshots()
|
|
}
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
.onReceive(handle_notify(.onlyzaps_mode)) { hide in
|
|
Task {
|
|
home.filter_events()
|
|
|
|
guard let ds = damus_state,
|
|
let profile = try? ds.profiles.lookup(id: ds.pubkey),
|
|
let keypair = ds.keypair.to_full()
|
|
else {
|
|
return
|
|
}
|
|
|
|
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)
|
|
|
|
guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
|
await ds.nostrNetwork.postbox.send(profile_ev)
|
|
}
|
|
}
|
|
.alert(NSLocalizedString("User muted", comment: "Alert message to indicate the user has been muted"), isPresented: $user_muted_confirm, actions: {
|
|
Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to muted a user was successful.")) {
|
|
user_muted_confirm = false
|
|
}
|
|
}, message: {
|
|
if case let .user(pubkey, _) = self.muting {
|
|
let profile = try? damus_state!.profiles.lookup(id: pubkey)
|
|
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
|
|
Text("\(name) has been muted", comment: "Alert message that informs a user was muted.")
|
|
} else {
|
|
Text("User has been muted", comment: "Alert message that informs a user was muted.")
|
|
}
|
|
})
|
|
.alert(NSLocalizedString("Create new mutelist", comment: "Title of alert prompting the user to create a new mutelist."), isPresented: $confirm_overwrite_mutelist, actions: {
|
|
Button(NSLocalizedString("Cancel", comment: "Button to cancel out of alert that creates a new mutelist.")) {
|
|
confirm_overwrite_mutelist = false
|
|
confirm_mute = false
|
|
}
|
|
|
|
Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) {
|
|
Task {
|
|
guard let ds = damus_state,
|
|
let keypair = ds.keypair.to_full(),
|
|
let muting,
|
|
let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: muting)
|
|
else {
|
|
return
|
|
}
|
|
|
|
ds.mutelist_manager.set_mutelist(mutelist)
|
|
await ds.nostrNetwork.postbox.send(mutelist)
|
|
|
|
confirm_overwrite_mutelist = false
|
|
confirm_mute = false
|
|
user_muted_confirm = true
|
|
}
|
|
}
|
|
}, message: {
|
|
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.")
|
|
})
|
|
.alert(NSLocalizedString("Mute/Block User", comment: "Title of alert for muting/blocking a user."), isPresented: $confirm_mute, actions: {
|
|
Button(NSLocalizedString("Cancel", comment: "Alert button to cancel out of alert for muting a user."), role: .cancel) {
|
|
confirm_mute = false
|
|
}
|
|
Button(NSLocalizedString("Mute", comment: "Alert button to mute a user."), role: .destructive) {
|
|
guard let ds = damus_state else {
|
|
return
|
|
}
|
|
|
|
if ds.mutelist_manager.event == nil {
|
|
home.load_latest_mutelist_event_from_damus_state()
|
|
}
|
|
|
|
if ds.mutelist_manager.event == nil {
|
|
confirm_overwrite_mutelist = true
|
|
} else {
|
|
guard let keypair = ds.keypair.to_full(),
|
|
let muting
|
|
else {
|
|
return
|
|
}
|
|
|
|
guard let ev = create_or_update_mutelist(keypair: keypair, mprev: ds.mutelist_manager.event, to_add: muting) else {
|
|
return
|
|
}
|
|
|
|
ds.mutelist_manager.set_mutelist(ev)
|
|
Task { await ds.nostrNetwork.postbox.send(ev) }
|
|
}
|
|
}
|
|
}, message: {
|
|
if case let .user(pubkey, _) = muting {
|
|
let profile = try? damus_state?.profiles.lookup(id: pubkey)
|
|
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
|
|
Text("Mute \(name)?", comment: "Alert message prompt to ask if a user should be muted.")
|
|
} else {
|
|
Text("Could not find user to mute...", comment: "Alert message to indicate that the muted user could not be found.")
|
|
}
|
|
})
|
|
}
|
|
|
|
func switch_timeline(_ timeline: Timeline) {
|
|
self.isSideBarOpened = false
|
|
let navWasAtRoot = self.navIsAtRoot()
|
|
self.popToRoot()
|
|
|
|
notify(.switched_timeline(timeline))
|
|
|
|
if timeline == self.selected_timeline && navWasAtRoot {
|
|
notify(.scroll_to_top)
|
|
return
|
|
}
|
|
|
|
self.selected_timeline = timeline
|
|
}
|
|
|
|
/// Listens to requests to open a push/local user notification
|
|
///
|
|
/// This function never returns, it just keeps streaming
|
|
func listenAndHandleLocalNotifications() async {
|
|
for await notification in await QueueableNotify<LossyLocalNotification>.shared.stream {
|
|
self.handleNotification(notification: notification)
|
|
}
|
|
}
|
|
|
|
func handleNotification(notification: LossyLocalNotification) {
|
|
Log.info("ContentView is handling a notification", for: .push_notifications)
|
|
guard damus_state != nil else {
|
|
// This should never happen because `listenAndHandleLocalNotifications` is called after damus state is initialized in `onAppear`
|
|
assertionFailure("DamusState not loaded when ContentView (new handler) was handling a notification")
|
|
Log.error("DamusState not loaded when ContentView (new handler) was handling a notification", for: .push_notifications)
|
|
return
|
|
}
|
|
let local = notification
|
|
let openAction = local.toViewOpenAction()
|
|
self.execute_open_action(openAction)
|
|
}
|
|
|
|
func connect() async {
|
|
// nostrdb
|
|
var mndb = Ndb()
|
|
if mndb == nil {
|
|
// try recovery
|
|
print("DB ISSUE! RECOVERING")
|
|
mndb = Ndb.safemode()
|
|
|
|
// out of space or something?? maybe we need a in-memory fallback
|
|
if mndb == nil {
|
|
logout(nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
guard let ndb = mndb else { return }
|
|
|
|
let model_cache = RelayModelCache()
|
|
let relay_filters = RelayFilters(our_pubkey: pubkey)
|
|
|
|
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
|
|
|
|
let new_relay_filters = await load_relay_filters(pubkey) == nil
|
|
|
|
self.damus_state = DamusState(keypair: keypair,
|
|
likes: EventCounter(our_pubkey: pubkey),
|
|
boosts: EventCounter(our_pubkey: pubkey),
|
|
contacts: Contacts(our_pubkey: pubkey),
|
|
contactCards: ContactCardManager(),
|
|
mutelist_manager: MutelistManager(user_keypair: keypair),
|
|
profiles: Profiles(ndb: ndb),
|
|
dms: home.dms,
|
|
previews: PreviewCache(),
|
|
zaps: Zaps(our_pubkey: pubkey),
|
|
lnurls: LNUrls(),
|
|
settings: settings,
|
|
relay_filters: relay_filters,
|
|
relay_model_cache: model_cache,
|
|
drafts: Drafts(),
|
|
events: EventCache(ndb: ndb),
|
|
bookmarks: BookmarksManager(pubkey: pubkey),
|
|
replies: ReplyCounter(our_pubkey: pubkey),
|
|
wallet: WalletModel(settings: settings),
|
|
nav: self.navigationCoordinator,
|
|
music: MusicController(onChange: music_changed),
|
|
video: DamusVideoCoordinator(),
|
|
ndb: ndb,
|
|
quote_reposts: .init(our_pubkey: pubkey),
|
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
|
favicon_cache: FaviconCache()
|
|
)
|
|
|
|
home.damus_state = self.damus_state!
|
|
|
|
await damus_state.snapshotManager.startPeriodicSnapshots()
|
|
|
|
if let damus_state, damus_state.purple.enable_purple {
|
|
// Assign delegate so that we can send receipts to the Purple API server as soon as we get updates from user's purchases
|
|
StoreObserver.standard.delegate = damus_state.purple
|
|
Task {
|
|
await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
|
|
}
|
|
}
|
|
else {
|
|
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
|
|
}
|
|
|
|
|
|
|
|
if #available(iOS 17, *) {
|
|
if damus_state.settings.developer_mode && damus_state.settings.reset_tips_on_launch {
|
|
do {
|
|
try Tips.resetDatastore()
|
|
} catch {
|
|
Log.error("Failed to reset tips datastore: %s", for: .tips, error.localizedDescription)
|
|
}
|
|
}
|
|
do {
|
|
try Tips.configure()
|
|
} catch {
|
|
Log.error("Failed to configure tips: %s", for: .tips, error.localizedDescription)
|
|
}
|
|
}
|
|
await damus_state.nostrNetwork.connect()
|
|
// TODO: Move this to a better spot. Not sure what is the best signal to listen to for sending initial filters
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: {
|
|
self.home.send_initial_filters()
|
|
})
|
|
}
|
|
|
|
func music_changed(_ state: MusicState) {
|
|
Task {
|
|
guard let damus_state else { return }
|
|
switch state {
|
|
case .playback_state:
|
|
break
|
|
case .song(let song):
|
|
guard let song, let kp = damus_state.keypair.to_full() else { return }
|
|
|
|
let pdata = damus_state.profiles.profile_data(damus_state.pubkey)
|
|
|
|
let desc = "\(song.title ?? "Unknown") - \(song.artist ?? "Unknown")"
|
|
let encodedDesc = desc.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
|
let url = encodedDesc.flatMap { enc in
|
|
URL(string: "spotify:search:\(enc)")
|
|
}
|
|
let music = UserStatus(type: .music, expires_at: Date.now.addingTimeInterval(song.playbackDuration), content: desc, created_at: UInt32(Date.now.timeIntervalSince1970), url: url)
|
|
|
|
pdata.status.music = music
|
|
|
|
guard let ev = music.to_note(keypair: kp) else { return }
|
|
await damus_state.nostrNetwork.postbox.send(ev)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An open action within the app
|
|
/// This is used to model, store, and communicate a desired view action to be taken as a result of opening an object,
|
|
/// for example a URL
|
|
///
|
|
/// ## Implementation notes
|
|
///
|
|
/// - 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)
|
|
enum ViewOpenAction {
|
|
/// Open a page route
|
|
case route(Route)
|
|
/// Open a sheet
|
|
case sheet(Sheets)
|
|
/// Open an external URL
|
|
case external_url(URL)
|
|
/// Do nothing.
|
|
///
|
|
/// ## Implementation notes
|
|
/// - 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.
|
|
case no_action
|
|
}
|
|
|
|
/// Executes an action to open something in the app view
|
|
///
|
|
/// - Parameter open_action: The action to perform
|
|
func execute_open_action(_ open_action: ViewOpenAction) {
|
|
switch open_action {
|
|
case .route(let route):
|
|
navigationCoordinator.push(route: route)
|
|
case .sheet(let sheet):
|
|
self.active_sheet = sheet
|
|
case .external_url(let url):
|
|
this_app.open(url)
|
|
case .no_action:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TopbarSideMenuButton: View {
|
|
let damus_state: DamusState
|
|
@Binding var isSideBarOpened: Bool
|
|
|
|
var body: some View {
|
|
Button {
|
|
isSideBarOpened.toggle()
|
|
} label: {
|
|
ProfilePicView(pubkey: damus_state.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation, damusState: damus_state)
|
|
.opacity(isSideBarOpened ? 0 : 1)
|
|
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
|
.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
|
|
}
|
|
.accessibilityIdentifier(AppAccessibilityIdentifiers.main_side_menu_button.rawValue)
|
|
.accessibilityLabel(NSLocalizedString("Side menu", comment: "Accessibility label for the side menu button at the topbar"))
|
|
.disabled(isSideBarOpened)
|
|
}
|
|
}
|
|
|
|
struct ContentView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
ContentView(keypair: Keypair(pubkey: test_pubkey, privkey: nil), appDelegate: nil)
|
|
}
|
|
}
|
|
|
|
func get_since_time(last_event: NostrEvent?) -> UInt32? {
|
|
if let last_event = last_event {
|
|
return last_event.created_at - 60 * 10
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
extension UINavigationController: UIGestureRecognizerDelegate {
|
|
override open func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
interactivePopGestureRecognizer?.delegate = self
|
|
}
|
|
|
|
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
return viewControllers.count > 1
|
|
}
|
|
}
|
|
|
|
struct LastNotification {
|
|
let id: NoteId
|
|
let created_at: Int64
|
|
}
|
|
|
|
func get_last_event(_ timeline: Timeline) -> LastNotification? {
|
|
let str = timeline.rawValue
|
|
let last = UserDefaults.standard.string(forKey: "last_\(str)")
|
|
let last_created = UserDefaults.standard.string(forKey: "last_\(str)_time")
|
|
.flatMap { Int64($0) }
|
|
|
|
guard let last,
|
|
let note_id = NoteId(hex: last),
|
|
let last_created
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return LastNotification(id: note_id, created_at: last_created)
|
|
}
|
|
|
|
func save_last_event(_ ev: NostrEvent, timeline: Timeline) {
|
|
let str = timeline.rawValue
|
|
UserDefaults.standard.set(ev.id.hex(), forKey: "last_\(str)")
|
|
UserDefaults.standard.set(String(ev.created_at), forKey: "last_\(str)_time")
|
|
}
|
|
|
|
func save_last_event(_ ev_id: NoteId, created_at: UInt32, timeline: Timeline) {
|
|
let str = timeline.rawValue
|
|
UserDefaults.standard.set(ev_id.hex(), forKey: "last_\(str)")
|
|
UserDefaults.standard.set(String(created_at), forKey: "last_\(str)_time")
|
|
}
|
|
|
|
func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [NostrFilter]) -> [NostrFilter] {
|
|
|
|
return filters.map { filter in
|
|
let kinds = filter.kinds ?? []
|
|
let initial: UInt32? = nil
|
|
let earliest = kinds.reduce(initial) { earliest, kind in
|
|
let last = last_of_kind[kind.rawValue]
|
|
let since: UInt32? = get_since_time(last_event: last)
|
|
|
|
if earliest == nil {
|
|
if since == nil {
|
|
return nil
|
|
}
|
|
return since
|
|
}
|
|
|
|
if since == nil {
|
|
return earliest
|
|
}
|
|
|
|
return since! < earliest! ? since! : earliest!
|
|
}
|
|
|
|
if let earliest = earliest {
|
|
var with_since = NostrFilter.copy(from: filter)
|
|
with_since.since = earliest
|
|
return with_since
|
|
}
|
|
|
|
return filter
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func setup_notifications() {
|
|
this_app.registerForRemoteNotifications()
|
|
let center = UNUserNotificationCenter.current()
|
|
|
|
center.getNotificationSettings { settings in
|
|
guard settings.authorizationStatus == .authorized else {
|
|
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
|
|
|
}
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
struct FindEvent {
|
|
let type: FindEventType
|
|
let find_from: [RelayURL]?
|
|
|
|
static func profile(pubkey: Pubkey, find_from: [RelayURL]? = nil) -> FindEvent {
|
|
return FindEvent(type: .profile(pubkey), find_from: find_from)
|
|
}
|
|
|
|
static func event(evid: NoteId, find_from: [RelayURL]? = nil) -> FindEvent {
|
|
return FindEvent(type: .event(evid), find_from: find_from)
|
|
}
|
|
}
|
|
|
|
enum FindEventType {
|
|
case profile(Pubkey)
|
|
case event(NoteId)
|
|
}
|
|
|
|
enum FoundEvent {
|
|
// TODO: Why not return the profile record itself? Right now the code probably just wants to trigger ndb to ingest the profile record and be available at ndb in parallel, but it would be cleaner if the function that uses this simply does that ndb query on their behalf.
|
|
case profile(Pubkey)
|
|
case event(NostrEvent)
|
|
}
|
|
|
|
func timeline_name(_ timeline: Timeline?) -> String {
|
|
guard let timeline else {
|
|
return ""
|
|
}
|
|
switch timeline {
|
|
case .home:
|
|
return NSLocalizedString("Home", comment: "Navigation bar title for Home view where notes and replies appear from those who the user is following.")
|
|
case .notifications:
|
|
return NSLocalizedString("Notifications", comment: "Toolbar label for Notifications view.")
|
|
case .search:
|
|
return NSLocalizedString("Universe 🛸", comment: "Toolbar label for the universal view where notes from all connected relay servers appear.")
|
|
case .dms:
|
|
return NSLocalizedString("DMs", comment: "Toolbar label for DMs view, where DM is the English abbreviation for Direct Message.")
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
@MainActor
|
|
func handle_unfollow(state: DamusState, unfollow: FollowRef) async -> Bool {
|
|
guard let keypair = state.keypair.to_full() else {
|
|
return false
|
|
}
|
|
|
|
let old_contacts = state.contacts.event
|
|
|
|
guard let ev = await unfollow_reference(postbox: state.nostrNetwork.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
|
|
else {
|
|
return false
|
|
}
|
|
|
|
notify(.unfollowed(unfollow))
|
|
|
|
state.contacts.event = ev
|
|
|
|
switch unfollow {
|
|
case .pubkey(let pk):
|
|
state.contacts.remove_friend(pk)
|
|
case .hashtag:
|
|
// nothing to handle here really
|
|
break
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
@MainActor
|
|
func handle_follow(state: DamusState, follow: FollowRef) async -> Bool {
|
|
guard let keypair = state.keypair.to_full() else {
|
|
return false
|
|
}
|
|
|
|
guard let ev = await follow_reference(box: state.nostrNetwork.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
|
|
else {
|
|
return false
|
|
}
|
|
|
|
notify(.followed(follow))
|
|
|
|
state.contacts.event = ev
|
|
switch follow {
|
|
case .pubkey(let pubkey):
|
|
state.contacts.add_friend_pubkey(pubkey)
|
|
case .hashtag:
|
|
// nothing to do
|
|
break
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func handle_follow_notif(state: DamusState, target: FollowTarget) async -> Bool {
|
|
switch target {
|
|
case .pubkey(let pk):
|
|
await state.contacts.add_friend_pubkey(pk)
|
|
case .contact(let ev):
|
|
await state.contacts.add_friend_contact(ev)
|
|
}
|
|
|
|
return await handle_follow(state: state, follow: target.follow_ref)
|
|
}
|
|
|
|
func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult) async -> Bool {
|
|
switch post {
|
|
case .post(let post):
|
|
//let post = tup.0
|
|
//let to_relays = tup.1
|
|
print("post \(post.content)")
|
|
guard let new_ev = post.to_event(keypair: keypair) else {
|
|
return false
|
|
}
|
|
await postbox.send(new_ev)
|
|
for eref in new_ev.referenced_ids.prefix(3) {
|
|
// also broadcast at most 3 referenced events
|
|
if let ev = events.lookup(eref) {
|
|
await postbox.send(ev)
|
|
}
|
|
}
|
|
for qref in new_ev.referenced_quote_ids.prefix(3) {
|
|
// also broadcast at most 3 referenced quoted events
|
|
if let ev = events.lookup(qref.note_id) {
|
|
await postbox.send(ev)
|
|
}
|
|
}
|
|
return true
|
|
case .cancel:
|
|
print("post cancelled")
|
|
return false
|
|
}
|
|
}
|
|
|
|
extension LossyLocalNotification {
|
|
/// Computes a view open action from a mention reference.
|
|
/// Converts this mention's NIP-19 reference into a UI action for the app.
|
|
///
|
|
/// Maps NPUB and NPROFILE references to profile routes, NOTE/NEVENT/NADDR references to loadable note routes, NSCRIPT to a script view, and returns an error sheet for deprecated or unsafe references (`nrelay`, `nsec`).
|
|
/// - Returns: A `ContentView.ViewOpenAction` that represents the route or sheet to present for this mention.
|
|
func toViewOpenAction() -> ContentView.ViewOpenAction {
|
|
switch self.mention.nip19 {
|
|
case .npub(let pubkey):
|
|
return .route(.ProfileByKey(pubkey: pubkey))
|
|
case .note(let noteId):
|
|
return .route(.LoadableNostrEvent(note_reference: .note_id(noteId, relays: [])))
|
|
case .nevent(let nEvent):
|
|
return .route(.LoadableNostrEvent(note_reference: .note_id(nEvent.noteid, relays: nEvent.relays)))
|
|
case .nprofile(let nProfile):
|
|
// TODO: Improve this by implementing a profile route that handles nprofiles with their relay hints.
|
|
return .route(.ProfileByKey(pubkey: nProfile.author))
|
|
case .nrelay:
|
|
// We do not need to implement `nrelay` support, it has been deprecated.
|
|
// See https://github.com/nostr-protocol/nips/blob/6e7a618e7f873bb91e743caacc3b09edab7796a0/BREAKING.md?plain=1#L21
|
|
return .sheet(.error(ErrorView.UserPresentableError(
|
|
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."),
|
|
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."),
|
|
technical_info: "`MentionRef.toViewOpenAction` detected deprecated `nrelay` contents"
|
|
)))
|
|
case .naddr(let nAddr):
|
|
return .route(.LoadableNostrEvent(note_reference: .naddr(nAddr)))
|
|
case .nsec(_):
|
|
// `nsec` urls are a terrible idea security-wise, so we should intentionally not support those — in order to discourage their use.
|
|
return .sheet(.error(ErrorView.UserPresentableError(
|
|
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."),
|
|
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."),
|
|
technical_info: "`MentionRef.toViewOpenAction` detected unsupported `nsec` contents"
|
|
)))
|
|
case .nscript(let script):
|
|
return .route(.Script(script: ScriptModel(data: script, state: .not_loaded)))
|
|
}
|
|
}
|
|
}
|
|
|
|
func logout(_ state: DamusState?)
|
|
{
|
|
state?.close()
|
|
notify(.logout)
|
|
} |