import AboutInfoDialog from '@/components/AboutInfoDialog' import QrScannerModal from '@/components/QrScannerModal' import Donation from '@/components/Donation' import Emoji from '@/components/Emoji' import EmojiPackList from '@/components/EmojiPackList' import EmojiPickerDialog from '@/components/EmojiPickerDialog' import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting' import MailboxSetting from '@/components/MailboxSetting' import NRCSettings from '@/components/NRCSettings' import NoteList from '@/components/NoteList' import Tabs from '@/components/Tabs' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { Tabs as RadixTabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { BIG_RELAY_URLS, DEFAULT_FAVICON_URL_TEMPLATE, MEDIA_AUTO_LOAD_POLICY, NSFW_DISPLAY_POLICY, PRIMARY_COLORS, TPrimaryColor } from '@/constants' import { LocalizedLanguageNames, TLanguage } from '@/i18n' import { cn, isSupportCheckConnectionType } from '@/lib/utils' import MediaUploadServiceSetting from '@/pages/secondary/PostSettingsPage/MediaUploadServiceSetting' import DefaultZapAmountInput from '@/pages/secondary/WalletPage/DefaultZapAmountInput' import DefaultZapCommentInput from '@/pages/secondary/WalletPage/DefaultZapCommentInput' import LightningAddressInput from '@/pages/secondary/WalletPage/LightningAddressInput' import QuickZapSwitch from '@/pages/secondary/WalletPage/QuickZapSwitch' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useTheme } from '@/providers/ThemeProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import { useZap } from '@/providers/ZapProvider' import storage, { dispatchSettingsChanged } from '@/services/local-storage.service' import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types' import { connectNWC, disconnect, launchModal } from '@getalby/bitcoin-connect-react' import { Check, Cog, Columns2, Copy, Info, KeyRound, LayoutList, List, MessageSquare, Monitor, Moon, Palette, PanelLeft, PencilLine, RotateCcw, ScanLine, RefreshCw, Server, Settings2, Smile, Sun, Wallet } from 'lucide-react' import { kinds } from 'nostr-tools' import { forwardRef, HTMLProps, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useKeyboardNavigation, useNavigationRegion, NavigationIntent } from '@/providers/KeyboardNavigationProvider' import { usePrimaryPage } from '@/PageManager' type TEmojiTab = 'my-packs' | 'explore' const THEMES = [ { key: 'system', label: 'System', icon: }, { key: 'light', label: 'Light', icon: }, { key: 'dark', label: 'Dark', icon: }, { key: 'pure-black', label: 'Pure Black', icon: } ] as const const LAYOUTS = [ { key: false, label: 'Two-column', icon: }, { key: true, label: 'Single-column', icon: } ] as const const NOTIFICATION_STYLES = [ { key: 'detailed', label: 'Detailed', icon: }, { key: 'compact', label: 'Compact', icon: } ] as const // Accordion item values for keyboard navigation const ACCORDION_ITEMS = ['general', 'appearance', 'relays', 'sync', 'wallet', 'posts', 'emoji-packs', 'messaging', 'system'] export default function Settings() { const { t, i18n } = useTranslation() const { pubkey, nsec, ncryptsec } = useNostr() const { isSmallScreen } = useScreenSize() const [copiedNsec, setCopiedNsec] = useState(false) const [copiedNcryptsec, setCopiedNcryptsec] = useState(false) const [openSection, setOpenSection] = useState('') const [selectedAccordionIndex, setSelectedAccordionIndex] = useState(-1) const accordionRefs = useRef<(HTMLDivElement | null)[]>([]) const { activeColumn, scrollToCenter } = useKeyboardNavigation() const { current: currentPage } = usePrimaryPage() // Get the visible accordion items based on pubkey availability const visibleAccordionItems = pubkey ? ACCORDION_ITEMS : ACCORDION_ITEMS.filter((item) => !['sync', 'wallet', 'posts', 'emoji-packs', 'messaging'].includes(item)) // Register as a navigation region - Settings decides what "up/down" means const handleSettingsIntent = useCallback( (intent: NavigationIntent): boolean => { switch (intent) { case 'up': setSelectedAccordionIndex((prev) => { const newIndex = prev <= 0 ? 0 : prev - 1 setTimeout(() => { const el = accordionRefs.current[newIndex] if (el) scrollToCenter(el) }, 0) return newIndex }) return true case 'down': setSelectedAccordionIndex((prev) => { const newIndex = prev < 0 ? 0 : Math.min(prev + 1, visibleAccordionItems.length - 1) setTimeout(() => { const el = accordionRefs.current[newIndex] if (el) scrollToCenter(el) }, 0) return newIndex }) return true case 'activate': if (selectedAccordionIndex >= 0 && selectedAccordionIndex < visibleAccordionItems.length) { const value = visibleAccordionItems[selectedAccordionIndex] setOpenSection((prev) => (prev === value ? '' : value)) return true } return false case 'cancel': if (openSection) { setOpenSection('') return true } return false default: return false } }, [selectedAccordionIndex, openSection, visibleAccordionItems, scrollToCenter] ) // Register this component as a navigation region when it's active useNavigationRegion( 'settings-accordion', 100, // High priority - handle intents before default handlers () => activeColumn === 1 && currentPage === 'settings', // Only active when settings is displayed handleSettingsIntent, [handleSettingsIntent, activeColumn, currentPage] ) // Reset selection when column changes useEffect(() => { if (activeColumn !== 1) { setSelectedAccordionIndex(-1) } }, [activeColumn]) // Helper to get accordion index and check selection const getAccordionIndex = useCallback( (value: string) => visibleAccordionItems.indexOf(value), [visibleAccordionItems] ) const isAccordionSelected = useCallback( (value: string) => selectedAccordionIndex === getAccordionIndex(value), [selectedAccordionIndex, getAccordionIndex] ) const setAccordionRef = useCallback((value: string) => (el: HTMLDivElement | null) => { const idx = visibleAccordionItems.indexOf(value) if (idx !== -1) { accordionRefs.current[idx] = el } }, [visibleAccordionItems]) // General settings const [language, setLanguage] = useState(i18n.language as TLanguage) const { autoplay, setAutoplay, nsfwDisplayPolicy, setNsfwDisplayPolicy, hideContentMentioningMutedUsers, setHideContentMentioningMutedUsers, mediaAutoLoadPolicy, setMediaAutoLoadPolicy, faviconUrlTemplate, setFaviconUrlTemplate } = useContentPolicy() const { hideUntrustedNotes, updateHideUntrustedNotes, hideUntrustedInteractions, updateHideUntrustedInteractions, hideUntrustedNotifications, updateHideUntrustedNotifications } = useUserTrust() const { quickReaction, updateQuickReaction, quickReactionEmoji, updateQuickReactionEmoji, enableSingleColumnLayout, updateEnableSingleColumnLayout, notificationListStyle, updateNotificationListStyle } = useUserPreferences() // Appearance settings const { themeSetting, setThemeSetting, primaryColor, setPrimaryColor } = useTheme() // Wallet settings const { isWalletConnected, walletInfo } = useZap() // Relay settings const [relayTabValue, setRelayTabValue] = useState('favorite-relays') // Emoji settings const [emojiTab, setEmojiTab] = useState('my-packs') // System settings const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays()) const [graphQueriesEnabled, setGraphQueriesEnabled] = useState(storage.getGraphQueriesEnabled()) // Messaging settings const [preferNip44, setPreferNip44] = useState(storage.getPreferNip44()) // Wallet QR scanner const [showWalletScanner, setShowWalletScanner] = useState(false) const handleWalletScan = useCallback((result: string) => { // Check if it's a valid NWC URI if (result.startsWith('nostr+walletconnect://')) { connectNWC(result) } }, []) const handleLanguageChange = (value: TLanguage) => { i18n.changeLanguage(value) setLanguage(value) } const handleAccordionChange = useCallback((value: string) => { // Prevent auto-scroll when opening accordion sections const scrollY = window.scrollY setOpenSection(value) requestAnimationFrame(() => { window.scrollTo(0, scrollY) }) }, []) return ( {/* General */} {t('General')} {t('Languages')} {Object.entries(LocalizedLanguageNames).map(([key, value]) => ( {value} ))} {t('Auto-load media')} setMediaAutoLoadPolicy(value)} > {t('Always')} {isSupportCheckConnectionType() && ( {t('Wi-Fi only')} )} {t('Never')} {t('Autoplay')} {t('Enable video autoplay on this device')} {t('Hide untrusted notes')} {t('Hide untrusted interactions')} {t('Hide untrusted notifications')} {t('Hide content mentioning muted users')} {t('NSFW content display')} setNsfwDisplayPolicy(value)} > {t('Hide completely')} {t('Show but hide content')} {t('Show directly')} {t('Quick reaction')} {t('If enabled, you can react with a single click. Click and hold for more options')} {quickReaction && ( {t('Quick reaction emoji')} updateQuickReactionEmoji('+')} className="text-muted-foreground hover:text-foreground" > { if (!emoji) return updateQuickReactionEmoji(emoji) }} > )} {/* Appearance */} {t('Appearance')} {t('Theme')} {THEMES.map(({ key, label, icon }) => ( setThemeSetting(key)} /> ))} {!isSmallScreen && ( {t('Layout')} {LAYOUTS.map(({ key, label, icon }) => ( updateEnableSingleColumnLayout(key)} /> ))} )} {t('Notification list style')} {NOTIFICATION_STYLES.map(({ key, label, icon }) => ( updateNotificationListStyle(key)} /> ))} {t('Primary color')} {Object.entries(PRIMARY_COLORS).map(([key, config]) => ( } label={t(config.name)} onClick={() => setPrimaryColor(key as TPrimaryColor)} /> ))} {/* Relays */} {t('Relays')} {t('Favorite Relays')} {t('Read & Write Relays')} {/* Sync (NRC) */} {!!pubkey && ( {t('Device Sync')} )} {/* Wallet */} {!!pubkey && ( {t('Wallet')} {isWalletConnected ? ( <> {walletInfo?.node.alias && ( {t('Connected to')} {walletInfo.node.alias} )} {t('Disconnect Wallet')} {t('Are you absolutely sure?')} {t('You will not be able to send zaps to others.')} {t('Cancel')} disconnect()}> {t('Disconnect')} > ) : ( <> {showWalletScanner && ( setShowWalletScanner(false)} /> )} launchModal()}> {t('Connect Wallet')} setShowWalletScanner(true)} title={t('Scan NWC QR code')} > > )} )} {/* Post Settings */} {!!pubkey && ( {t('Post settings')} )} {/* Emoji Packs */} {!!pubkey && ( {t('Emoji Packs')} setEmojiTab(tab as TEmojiTab)} /> {emojiTab === 'my-packs' ? ( ) : ( )} )} {/* Messaging */} {!!pubkey && ( {t('Messaging')} {t('Prefer NIP-44 encryption')} {t('Use modern encryption for new conversations')} { storage.setPreferNip44(checked) setPreferNip44(checked) dispatchSettingsChanged() }} /> )} {/* System */} {t('System')} {t('Favicon URL')} setFaviconUrlTemplate(e.target.value)} placeholder={DEFAULT_FAVICON_URL_TEMPLATE} /> {t('Filter out onion relays')} { storage.setFilterOutOnionRelays(checked) setFilterOutOnionRelays(checked) dispatchSettingsChanged() }} /> {t('Graph query optimization')} {t('Use graph queries for faster follow/thread loading on supported relays')} { storage.setGraphQueriesEnabled(checked) setGraphQueriesEnabled(checked) dispatchSettingsChanged() }} /> {/* Non-accordion items */} {!!nsec && ( { navigator.clipboard.writeText(nsec) setCopiedNsec(true) setTimeout(() => setCopiedNsec(false), 2000) }} > {t('Copy private key')} (nsec) {copiedNsec ? : } )} {!!ncryptsec && ( { navigator.clipboard.writeText(ncryptsec) setCopiedNcryptsec(true) setTimeout(() => setCopiedNcryptsec(false), 2000) }} > {t('Copy private key')} (ncryptsec) {copiedNcryptsec ? : } )} {t('About')} v{import.meta.env.APP_VERSION} ({import.meta.env.GIT_COMMIT}) ) } const SettingItem = forwardRef>( ({ children, className, ...props }, ref) => { return ( {children} ) } ) SettingItem.displayName = 'SettingItem' const OptionButton = ({ isSelected, onClick, icon, label }: { isSelected: boolean onClick: () => void icon: React.ReactNode label: string }) => { return ( {icon} {label} ) } // Wrapper for keyboard-navigable accordion items const NavigableAccordionItem = forwardRef< HTMLDivElement, { isSelected: boolean children: React.ReactNode } >(({ isSelected, children }, ref) => { return ( {children} ) }) NavigableAccordionItem.displayName = 'NavigableAccordionItem'
{t('Use graph queries for faster follow/thread loading on supported relays')}