index.tsx raw
1 import AboutInfoDialog from '@/components/AboutInfoDialog'
2 import QrScannerModal from '@/components/QrScannerModal'
3 import Donation from '@/components/Donation'
4 import RelayDiscovery from '@/components/RelayDiscovery'
5 import Emoji from '@/components/Emoji'
6 import EmojiPackList from '@/components/EmojiPackList'
7 import EmojiPickerDialog from '@/components/EmojiPickerDialog'
8 import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
9 import CacheRelaysSetting from '@/components/CacheRelaysSetting'
10 import MailboxSetting from '@/components/MailboxSetting'
11 import ManagedOutboxSetting from '@/components/ManagedOutboxSetting'
12 import NRCSettings from '@/components/NRCSettings'
13 import NoteList from '@/components/NoteList'
14 import Tabs from '@/components/Tabs'
15 import {
16 Accordion,
17 AccordionContent,
18 AccordionItem,
19 AccordionTrigger
20 } from '@/components/ui/accordion'
21 import {
22 AlertDialog,
23 AlertDialogAction,
24 AlertDialogCancel,
25 AlertDialogContent,
26 AlertDialogDescription,
27 AlertDialogFooter,
28 AlertDialogHeader,
29 AlertDialogTitle,
30 AlertDialogTrigger
31 } from '@/components/ui/alert-dialog'
32 import { Button } from '@/components/ui/button'
33 import { Input } from '@/components/ui/input'
34 import { Label } from '@/components/ui/label'
35 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
36 import { Switch } from '@/components/ui/switch'
37 import { Tabs as RadixTabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
38 import {
39 DEFAULT_FAVICON_URL_TEMPLATE,
40 MEDIA_AUTO_LOAD_POLICY,
41 NSFW_DISPLAY_POLICY,
42 PRIMARY_COLORS,
43 TPrimaryColor
44 } from '@/constants'
45 import client from '@/services/client.service'
46 import { LocalizedLanguageNames, TLanguage } from '@/i18n'
47 import { cn, isSupportCheckConnectionType } from '@/lib/utils'
48 import LlmSetting from '@/pages/secondary/PostSettingsPage/LlmSetting'
49 import MediaUploadServiceSetting from '@/pages/secondary/PostSettingsPage/MediaUploadServiceSetting'
50 import DefaultZapAmountInput from '@/pages/secondary/WalletPage/DefaultZapAmountInput'
51 import DefaultZapCommentInput from '@/pages/secondary/WalletPage/DefaultZapCommentInput'
52 import LightningAddressInput from '@/pages/secondary/WalletPage/LightningAddressInput'
53 import QuickZapSwitch from '@/pages/secondary/WalletPage/QuickZapSwitch'
54 import { useContentPolicy } from '@/providers/ContentPolicyProvider'
55 import { useNostr } from '@/providers/NostrProvider'
56 import { useScreenSize } from '@/providers/ScreenSizeProvider'
57 import { useTheme } from '@/providers/ThemeProvider'
58 import { useUserPreferences } from '@/providers/UserPreferencesProvider'
59 import { useUserTrust } from '@/providers/UserTrustProvider'
60 import { useZap } from '@/providers/ZapProvider'
61 import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
62 import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types'
63 import { connectNWC, disconnect, launchModal } from '@getalby/bitcoin-connect-react'
64 import {
65 Check,
66 Cog,
67 Columns2,
68 Copy,
69 Info,
70 KeyRound,
71 LayoutList,
72 List,
73 MessageSquare,
74 Monitor,
75 Moon,
76 Palette,
77 PanelLeft,
78 PencilLine,
79 RotateCcw,
80 ScanLine,
81 RefreshCw,
82 Server,
83 Settings2,
84 Smile,
85 Sun,
86 Wallet,
87 Wrench
88 } from 'lucide-react'
89 import { kinds } from 'nostr-tools'
90 import { forwardRef, HTMLProps, useCallback, useEffect, useRef, useState } from 'react'
91 import { useTranslation } from 'react-i18next'
92 import { useKeyboardNavigation, useNavigationRegion, NavigationIntent } from '@/providers/KeyboardNavigationProvider'
93 import { usePrimaryPage } from '@/PageManager'
94
95 type TEmojiTab = 'my-packs' | 'explore'
96
97 const THEMES = [
98 { key: 'system', label: 'System', icon: <Monitor className="size-5" /> },
99 { key: 'light', label: 'Light', icon: <Sun className="size-5" /> },
100 { key: 'dark', label: 'Dark', icon: <Moon className="size-5" /> },
101 ] as const
102
103 const LAYOUTS = [
104 { key: false, label: 'Two-column', icon: <Columns2 className="size-5" /> },
105 { key: true, label: 'Single-column', icon: <PanelLeft className="size-5" /> }
106 ] as const
107
108 const NOTIFICATION_STYLES = [
109 { key: 'detailed', label: 'Detailed', icon: <LayoutList className="size-5" /> },
110 { key: 'compact', label: 'Compact', icon: <List className="size-5" /> }
111 ] as const
112
113 // Accordion item values for keyboard navigation
114 const ACCORDION_ITEMS = ['general', 'appearance', 'relays', 'sync', 'wallet', 'posts', 'emoji-packs', 'messaging', 'system', 'tools']
115
116 export default function Settings() {
117 const { t, i18n } = useTranslation()
118 const { pubkey, nsec, ncryptsec } = useNostr()
119 const { isSmallScreen } = useScreenSize()
120 const [copiedNsec, setCopiedNsec] = useState(false)
121 const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
122 const [openSection, setOpenSection] = useState<string>('')
123 const [selectedAccordionIndex, setSelectedAccordionIndex] = useState(-1)
124 const accordionRefs = useRef<(HTMLDivElement | null)[]>([])
125
126 const { activeColumn, scrollToCenter } = useKeyboardNavigation()
127 const { current: currentPage } = usePrimaryPage()
128
129 // Get the visible accordion items based on pubkey availability
130 const visibleAccordionItems = pubkey
131 ? ACCORDION_ITEMS
132 : ACCORDION_ITEMS.filter((item) => !['sync', 'wallet', 'posts', 'emoji-packs', 'messaging'].includes(item))
133
134 // Register as a navigation region - Settings decides what "up/down" means
135 const handleSettingsIntent = useCallback(
136 (intent: NavigationIntent): boolean => {
137 switch (intent) {
138 case 'up':
139 setSelectedAccordionIndex((prev) => {
140 const newIndex = prev <= 0 ? 0 : prev - 1
141 setTimeout(() => {
142 const el = accordionRefs.current[newIndex]
143 if (el) scrollToCenter(el)
144 }, 0)
145 return newIndex
146 })
147 return true
148
149 case 'down':
150 setSelectedAccordionIndex((prev) => {
151 const newIndex = prev < 0 ? 0 : Math.min(prev + 1, visibleAccordionItems.length - 1)
152 setTimeout(() => {
153 const el = accordionRefs.current[newIndex]
154 if (el) scrollToCenter(el)
155 }, 0)
156 return newIndex
157 })
158 return true
159
160 case 'activate':
161 if (selectedAccordionIndex >= 0 && selectedAccordionIndex < visibleAccordionItems.length) {
162 const value = visibleAccordionItems[selectedAccordionIndex]
163 setOpenSection((prev) => (prev === value ? '' : value))
164 return true
165 }
166 return false
167
168 case 'cancel':
169 if (openSection) {
170 setOpenSection('')
171 return true
172 }
173 return false
174
175 default:
176 return false
177 }
178 },
179 [selectedAccordionIndex, openSection, visibleAccordionItems, scrollToCenter]
180 )
181
182 // Register this component as a navigation region when it's active
183 useNavigationRegion(
184 'settings-accordion',
185 100, // High priority - handle intents before default handlers
186 () => activeColumn === 1 && currentPage === 'settings', // Only active when settings is displayed
187 handleSettingsIntent,
188 [handleSettingsIntent, activeColumn, currentPage]
189 )
190
191 // Reset selection when column changes
192 useEffect(() => {
193 if (activeColumn !== 1) {
194 setSelectedAccordionIndex(-1)
195 }
196 }, [activeColumn])
197
198 // Helper to get accordion index and check selection
199 const getAccordionIndex = useCallback(
200 (value: string) => visibleAccordionItems.indexOf(value),
201 [visibleAccordionItems]
202 )
203
204 const isAccordionSelected = useCallback(
205 (value: string) => selectedAccordionIndex === getAccordionIndex(value),
206 [selectedAccordionIndex, getAccordionIndex]
207 )
208
209 const setAccordionRef = useCallback((value: string) => (el: HTMLDivElement | null) => {
210 const idx = visibleAccordionItems.indexOf(value)
211 if (idx !== -1) {
212 accordionRefs.current[idx] = el
213 }
214 }, [visibleAccordionItems])
215
216 // General settings
217 const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
218 const {
219 autoplay,
220 setAutoplay,
221 nsfwDisplayPolicy,
222 setNsfwDisplayPolicy,
223 hideContentMentioningMutedUsers,
224 setHideContentMentioningMutedUsers,
225 mediaAutoLoadPolicy,
226 setMediaAutoLoadPolicy,
227 faviconUrlTemplate,
228 setFaviconUrlTemplate
229 } = useContentPolicy()
230 const {
231 hideUntrustedNotes,
232 updateHideUntrustedNotes,
233 hideUntrustedInteractions,
234 updateHideUntrustedInteractions,
235 hideUntrustedNotifications,
236 updateHideUntrustedNotifications
237 } = useUserTrust()
238 const {
239 quickReaction,
240 updateQuickReaction,
241 quickReactionEmoji,
242 updateQuickReactionEmoji,
243 enableSingleColumnLayout,
244 updateEnableSingleColumnLayout,
245 autoInsertNewNotes,
246 updateAutoInsertNewNotes,
247 notificationListStyle,
248 updateNotificationListStyle
249 } = useUserPreferences()
250
251 // Appearance settings
252 const { themeSetting, setThemeSetting, primaryColor, setPrimaryColor } = useTheme()
253
254 // Wallet settings
255 const { isWalletConnected, walletInfo } = useZap()
256
257 // Relay settings
258 const [relayTabValue, setRelayTabValue] = useState('favorite-relays')
259
260 // Emoji settings
261 const [emojiTab, setEmojiTab] = useState<TEmojiTab>('my-packs')
262
263 // System settings
264 const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays())
265 const [graphQueriesEnabled, setGraphQueriesEnabled] = useState(storage.getGraphQueriesEnabled())
266
267 // Messaging settings
268 const [preferNip44, setPreferNip44] = useState(storage.getPreferNip44())
269
270 // Post settings
271 const [addClientTag, setAddClientTag] = useState(storage.getAddClientTag())
272
273 // Wallet QR scanner
274 const [showWalletScanner, setShowWalletScanner] = useState(false)
275
276 const handleWalletScan = useCallback((result: string) => {
277 // Check if it's a valid NWC URI
278 if (result.startsWith('nostr+walletconnect://')) {
279 connectNWC(result)
280 }
281 }, [])
282
283 const handleLanguageChange = (value: TLanguage) => {
284 i18n.changeLanguage(value)
285 setLanguage(value)
286 }
287
288 const handleAccordionChange = useCallback((value: string) => {
289 // Prevent auto-scroll when opening accordion sections
290 const scrollY = window.scrollY
291 setOpenSection(value)
292 requestAnimationFrame(() => {
293 window.scrollTo(0, scrollY)
294 })
295 }, [])
296
297 return (
298 <div>
299 <Accordion
300 type="single"
301 collapsible
302 value={openSection}
303 onValueChange={handleAccordionChange}
304 className="w-full"
305 >
306 {/* General */}
307 <NavigableAccordionItem ref={setAccordionRef('general')} isSelected={isAccordionSelected('general')}>
308 <AccordionItem value="general">
309 <AccordionTrigger className="px-4 hover:no-underline">
310 <div className="flex items-center gap-4">
311 <Settings2 className="size-4" />
312 <span>{t('General')}</span>
313 </div>
314 </AccordionTrigger>
315 <AccordionContent className="px-4 space-y-4">
316 <SettingItem>
317 <Label htmlFor="languages" className="text-base font-normal">
318 {t('Languages')}
319 </Label>
320 <Select defaultValue="en" value={language} onValueChange={handleLanguageChange}>
321 <SelectTrigger id="languages" className="w-48">
322 <SelectValue />
323 </SelectTrigger>
324 <SelectContent>
325 {Object.entries(LocalizedLanguageNames).map(([key, value]) => (
326 <SelectItem key={key} value={key}>
327 {value}
328 </SelectItem>
329 ))}
330 </SelectContent>
331 </Select>
332 </SettingItem>
333 <SettingItem>
334 <Label htmlFor="media-auto-load-policy" className="text-base font-normal">
335 {t('Auto-load media')}
336 </Label>
337 <Select
338 defaultValue="wifi-only"
339 value={mediaAutoLoadPolicy}
340 onValueChange={(value: TMediaAutoLoadPolicy) => setMediaAutoLoadPolicy(value)}
341 >
342 <SelectTrigger id="media-auto-load-policy" className="w-48">
343 <SelectValue />
344 </SelectTrigger>
345 <SelectContent>
346 <SelectItem value={MEDIA_AUTO_LOAD_POLICY.ALWAYS}>{t('Always')}</SelectItem>
347 {isSupportCheckConnectionType() && (
348 <SelectItem value={MEDIA_AUTO_LOAD_POLICY.WIFI_ONLY}>{t('Wi-Fi only')}</SelectItem>
349 )}
350 <SelectItem value={MEDIA_AUTO_LOAD_POLICY.NEVER}>{t('Never')}</SelectItem>
351 </SelectContent>
352 </Select>
353 </SettingItem>
354 <SettingItem>
355 <Label htmlFor="autoplay" className="text-base font-normal">
356 <div>{t('Autoplay')}</div>
357 <div className="text-muted-foreground">{t('Enable video autoplay on this device')}</div>
358 </Label>
359 <Switch id="autoplay" checked={autoplay} onCheckedChange={setAutoplay} />
360 </SettingItem>
361 <SettingItem>
362 <Label htmlFor="auto-insert-new-notes" className="text-base font-normal">
363 <div>{t('Live feed')}</div>
364 <div className="text-muted-foreground">{t('Automatically insert new notes into the feed')}</div>
365 </Label>
366 <Switch id="auto-insert-new-notes" checked={autoInsertNewNotes} onCheckedChange={updateAutoInsertNewNotes} />
367 </SettingItem>
368 <SettingItem>
369 <Label htmlFor="hide-untrusted-notes" className="text-base font-normal">
370 {t('Hide untrusted notes')}
371 </Label>
372 <Switch
373 id="hide-untrusted-notes"
374 checked={hideUntrustedNotes}
375 onCheckedChange={updateHideUntrustedNotes}
376 />
377 </SettingItem>
378 <SettingItem>
379 <Label htmlFor="hide-untrusted-interactions" className="text-base font-normal">
380 {t('Hide untrusted interactions')}
381 </Label>
382 <Switch
383 id="hide-untrusted-interactions"
384 checked={hideUntrustedInteractions}
385 onCheckedChange={updateHideUntrustedInteractions}
386 />
387 </SettingItem>
388 <SettingItem>
389 <Label htmlFor="hide-untrusted-notifications" className="text-base font-normal">
390 {t('Hide untrusted notifications')}
391 </Label>
392 <Switch
393 id="hide-untrusted-notifications"
394 checked={hideUntrustedNotifications}
395 onCheckedChange={updateHideUntrustedNotifications}
396 />
397 </SettingItem>
398 <SettingItem>
399 <Label htmlFor="hide-content-mentioning-muted-users" className="text-base font-normal">
400 {t('Hide content mentioning muted users')}
401 </Label>
402 <Switch
403 id="hide-content-mentioning-muted-users"
404 checked={hideContentMentioningMutedUsers}
405 onCheckedChange={setHideContentMentioningMutedUsers}
406 />
407 </SettingItem>
408 <SettingItem>
409 <Label htmlFor="nsfw-display-policy" className="text-base font-normal">
410 {t('NSFW content display')}
411 </Label>
412 <Select
413 value={nsfwDisplayPolicy}
414 onValueChange={(value: TNsfwDisplayPolicy) => setNsfwDisplayPolicy(value)}
415 >
416 <SelectTrigger id="nsfw-display-policy" className="w-48">
417 <SelectValue />
418 </SelectTrigger>
419 <SelectContent>
420 <SelectItem value={NSFW_DISPLAY_POLICY.HIDE}>{t('Hide completely')}</SelectItem>
421 <SelectItem value={NSFW_DISPLAY_POLICY.HIDE_CONTENT}>{t('Show but hide content')}</SelectItem>
422 <SelectItem value={NSFW_DISPLAY_POLICY.SHOW}>{t('Show directly')}</SelectItem>
423 </SelectContent>
424 </Select>
425 </SettingItem>
426 <SettingItem>
427 <Label htmlFor="quick-reaction" className="text-base font-normal">
428 <div>{t('Quick reaction')}</div>
429 <div className="text-muted-foreground">
430 {t('If enabled, you can react with a single click. Click and hold for more options')}
431 </div>
432 </Label>
433 <Switch id="quick-reaction" checked={quickReaction} onCheckedChange={updateQuickReaction} />
434 </SettingItem>
435 {quickReaction && (
436 <SettingItem>
437 <Label htmlFor="quick-reaction-emoji" className="text-base font-normal">
438 {t('Quick reaction emoji')}
439 </Label>
440 <div className="flex items-center gap-2">
441 <Button
442 variant="ghost"
443 size="icon"
444 onClick={() => updateQuickReactionEmoji('+')}
445 className="text-muted-foreground hover:text-foreground"
446 >
447 <RotateCcw />
448 </Button>
449 <EmojiPickerDialog
450 onEmojiClick={(emoji) => {
451 if (!emoji) return
452 updateQuickReactionEmoji(emoji)
453 }}
454 >
455 <Button variant="ghost" size="icon" className="border">
456 <Emoji emoji={quickReactionEmoji} />
457 </Button>
458 </EmojiPickerDialog>
459 </div>
460 </SettingItem>
461 )}
462 </AccordionContent>
463 </AccordionItem>
464 </NavigableAccordionItem>
465
466 {/* Appearance */}
467 <NavigableAccordionItem ref={setAccordionRef('appearance')} isSelected={isAccordionSelected('appearance')}>
468 <AccordionItem value="appearance">
469 <AccordionTrigger className="px-4 hover:no-underline">
470 <div className="flex items-center gap-4">
471 <Palette className="size-4" />
472 <span>{t('Appearance')}</span>
473 </div>
474 </AccordionTrigger>
475 <AccordionContent className="px-4 space-y-4">
476 <div className="flex flex-col gap-2">
477 <Label className="text-base">{t('Theme')}</Label>
478 <div className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full">
479 {THEMES.map(({ key, label, icon }) => (
480 <OptionButton
481 key={key}
482 isSelected={themeSetting === key}
483 icon={icon}
484 label={t(label)}
485 onClick={() => setThemeSetting(key)}
486 />
487 ))}
488 </div>
489 </div>
490 {!isSmallScreen && (
491 <div className="flex flex-col gap-2">
492 <Label className="text-base">{t('Layout')}</Label>
493 <div className="grid grid-cols-2 gap-4 w-full">
494 {LAYOUTS.map(({ key, label, icon }) => (
495 <OptionButton
496 key={key.toString()}
497 isSelected={enableSingleColumnLayout === key}
498 icon={icon}
499 label={t(label)}
500 onClick={() => updateEnableSingleColumnLayout(key)}
501 />
502 ))}
503 </div>
504 </div>
505 )}
506 <div className="flex flex-col gap-2">
507 <Label className="text-base">{t('Notification list style')}</Label>
508 <div className="grid grid-cols-2 gap-4 w-full">
509 {NOTIFICATION_STYLES.map(({ key, label, icon }) => (
510 <OptionButton
511 key={key}
512 isSelected={notificationListStyle === key}
513 icon={icon}
514 label={t(label)}
515 onClick={() => updateNotificationListStyle(key)}
516 />
517 ))}
518 </div>
519 </div>
520 <div className="flex flex-col gap-2">
521 <Label className="text-base">{t('Primary color')}</Label>
522 <div className="grid grid-cols-4 gap-4 w-full">
523 {Object.entries(PRIMARY_COLORS).map(([key, config]) => (
524 <OptionButton
525 key={key}
526 isSelected={primaryColor === key}
527 icon={
528 <div
529 className="size-8 rounded-full shadow-md"
530 style={{ backgroundColor: `hsl(${config.light.primary})` }}
531 />
532 }
533 label={t(config.name)}
534 onClick={() => setPrimaryColor(key as TPrimaryColor)}
535 />
536 ))}
537 </div>
538 </div>
539 </AccordionContent>
540 </AccordionItem>
541 </NavigableAccordionItem>
542
543 {/* Relays */}
544 <NavigableAccordionItem ref={setAccordionRef('relays')} isSelected={isAccordionSelected('relays')}>
545 <AccordionItem value="relays">
546 <AccordionTrigger className="px-4 hover:no-underline">
547 <div className="flex items-center gap-4">
548 <Server className="size-4" />
549 <span>{t('Relays')}</span>
550 </div>
551 </AccordionTrigger>
552 <AccordionContent className="px-4">
553 <RadixTabs value={relayTabValue} onValueChange={setRelayTabValue} className="space-y-4">
554 <TabsList>
555 <TabsTrigger value="favorite-relays">{t('Favorite Relays')}</TabsTrigger>
556 <TabsTrigger value="mailbox">{t('Read & Write Relays')}</TabsTrigger>
557 <TabsTrigger value="cache-relays">{t('Cache Relays')}</TabsTrigger>
558 <TabsTrigger value="outbox">{t('Outbox')}</TabsTrigger>
559 </TabsList>
560 <TabsContent value="favorite-relays">
561 <FavoriteRelaysSetting />
562 </TabsContent>
563 <TabsContent value="mailbox">
564 <MailboxSetting />
565 </TabsContent>
566 <TabsContent value="cache-relays">
567 <CacheRelaysSetting />
568 </TabsContent>
569 <TabsContent value="outbox">
570 <ManagedOutboxSetting />
571 </TabsContent>
572 </RadixTabs>
573 </AccordionContent>
574 </AccordionItem>
575 </NavigableAccordionItem>
576
577 {/* Sync (NRC) */}
578 {!!pubkey && (
579 <NavigableAccordionItem ref={setAccordionRef('sync')} isSelected={isAccordionSelected('sync')}>
580 <AccordionItem value="sync">
581 <AccordionTrigger className="px-4 hover:no-underline">
582 <div className="flex items-center gap-4">
583 <RefreshCw className="size-4" />
584 <span>{t('Device Sync')}</span>
585 </div>
586 </AccordionTrigger>
587 <AccordionContent className="px-4">
588 <NRCSettings />
589 </AccordionContent>
590 </AccordionItem>
591 </NavigableAccordionItem>
592 )}
593
594 {/* Wallet */}
595 {!!pubkey && (
596 <NavigableAccordionItem ref={setAccordionRef('wallet')} isSelected={isAccordionSelected('wallet')}>
597 <AccordionItem value="wallet">
598 <AccordionTrigger className="px-4 hover:no-underline">
599 <div className="flex items-center gap-4">
600 <Wallet className="size-4" />
601 <span>{t('Wallet')}</span>
602 </div>
603 </AccordionTrigger>
604 <AccordionContent className="px-4 space-y-4">
605 {isWalletConnected ? (
606 <>
607 <div>
608 {walletInfo?.node.alias && (
609 <div className="mb-2">
610 {t('Connected to')} <strong>{walletInfo.node.alias}</strong>
611 </div>
612 )}
613 <AlertDialog>
614 <AlertDialogTrigger asChild>
615 <Button variant="destructive">{t('Disconnect Wallet')}</Button>
616 </AlertDialogTrigger>
617 <AlertDialogContent>
618 <AlertDialogHeader>
619 <AlertDialogTitle>{t('Are you absolutely sure?')}</AlertDialogTitle>
620 <AlertDialogDescription>
621 {t('You will not be able to send zaps to others.')}
622 </AlertDialogDescription>
623 </AlertDialogHeader>
624 <AlertDialogFooter>
625 <AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
626 <AlertDialogAction variant="destructive" onClick={() => disconnect()}>
627 {t('Disconnect')}
628 </AlertDialogAction>
629 </AlertDialogFooter>
630 </AlertDialogContent>
631 </AlertDialog>
632 </div>
633 <DefaultZapAmountInput />
634 <DefaultZapCommentInput />
635 <QuickZapSwitch />
636 <LightningAddressInput />
637 </>
638 ) : (
639 <>
640 {showWalletScanner && (
641 <QrScannerModal
642 onScan={handleWalletScan}
643 onClose={() => setShowWalletScanner(false)}
644 />
645 )}
646 <div className="flex items-center gap-2">
647 <Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
648 {t('Connect Wallet')}
649 </Button>
650 <Button
651 variant="outline"
652 size="icon"
653 onClick={() => setShowWalletScanner(true)}
654 title={t('Scan NWC QR code')}
655 >
656 <ScanLine className="h-4 w-4" />
657 </Button>
658 </div>
659 </>
660 )}
661 </AccordionContent>
662 </AccordionItem>
663 </NavigableAccordionItem>
664 )}
665
666 {/* Post Settings */}
667 {!!pubkey && (
668 <NavigableAccordionItem ref={setAccordionRef('posts')} isSelected={isAccordionSelected('posts')}>
669 <AccordionItem value="posts">
670 <AccordionTrigger className="px-4 hover:no-underline">
671 <div className="flex items-center gap-4">
672 <PencilLine className="size-4" />
673 <span>{t('Post settings')}</span>
674 </div>
675 </AccordionTrigger>
676 <AccordionContent className="px-4 space-y-4">
677 <MediaUploadServiceSetting />
678 <LlmSetting />
679 <SettingItem>
680 <div>
681 <Label htmlFor="add-client-tag" className="text-base font-normal">
682 {t('Include client tag')}
683 </Label>
684 <p className="text-sm text-muted-foreground">
685 {t('Add a tag to identify posts as coming from smesh')}
686 </p>
687 </div>
688 <Switch
689 id="add-client-tag"
690 checked={addClientTag}
691 onCheckedChange={(checked) => {
692 storage.setAddClientTag(checked)
693 setAddClientTag(checked)
694 dispatchSettingsChanged()
695 }}
696 />
697 </SettingItem>
698 </AccordionContent>
699 </AccordionItem>
700 </NavigableAccordionItem>
701 )}
702
703 {/* Emoji Packs */}
704 {!!pubkey && (
705 <NavigableAccordionItem ref={setAccordionRef('emoji-packs')} isSelected={isAccordionSelected('emoji-packs')}>
706 <AccordionItem value="emoji-packs">
707 <AccordionTrigger className="px-4 hover:no-underline">
708 <div className="flex items-center gap-4">
709 <Smile className="size-4" />
710 <span>{t('Emoji Packs')}</span>
711 </div>
712 </AccordionTrigger>
713 <AccordionContent className="px-4">
714 <Tabs
715 value={emojiTab}
716 tabs={[
717 { value: 'my-packs', label: 'My Packs' },
718 { value: 'explore', label: 'Explore' }
719 ]}
720 onTabChange={(tab) => setEmojiTab(tab as TEmojiTab)}
721 />
722 {emojiTab === 'my-packs' ? (
723 <EmojiPackList />
724 ) : (
725 <NoteList
726 showKinds={[kinds.Emojisets]}
727 subRequests={[{ urls: client.currentRelays, filter: {} }]}
728 hideUntrustedNotes={hideUntrustedNotes}
729 />
730 )}
731 </AccordionContent>
732 </AccordionItem>
733 </NavigableAccordionItem>
734 )}
735
736 {/* Messaging */}
737 {!!pubkey && (
738 <NavigableAccordionItem ref={setAccordionRef('messaging')} isSelected={isAccordionSelected('messaging')}>
739 <AccordionItem value="messaging">
740 <AccordionTrigger className="px-4 hover:no-underline">
741 <div className="flex items-center gap-4">
742 <MessageSquare className="size-4" />
743 <span>{t('Messaging')}</span>
744 </div>
745 </AccordionTrigger>
746 <AccordionContent className="px-4 space-y-4">
747 <SettingItem>
748 <Label htmlFor="prefer-nip44" className="text-base font-normal">
749 <div>{t('Prefer NIP-44 encryption')}</div>
750 <div className="text-muted-foreground text-sm">
751 {t('Use modern encryption for new conversations')}
752 </div>
753 </Label>
754 <Switch
755 id="prefer-nip44"
756 checked={preferNip44}
757 onCheckedChange={(checked) => {
758 storage.setPreferNip44(checked)
759 setPreferNip44(checked)
760 dispatchSettingsChanged()
761 }}
762 />
763 </SettingItem>
764 </AccordionContent>
765 </AccordionItem>
766 </NavigableAccordionItem>
767 )}
768
769 {/* System */}
770 <NavigableAccordionItem ref={setAccordionRef('system')} isSelected={isAccordionSelected('system')}>
771 <AccordionItem value="system">
772 <AccordionTrigger className="px-4 hover:no-underline">
773 <div className="flex items-center gap-4">
774 <Cog className="size-4" />
775 <span>{t('System')}</span>
776 </div>
777 </AccordionTrigger>
778 <AccordionContent className="px-4 space-y-4">
779 <div className="space-y-2">
780 <Label htmlFor="favicon-url" className="text-base font-normal">
781 {t('Favicon URL')}
782 </Label>
783 <Input
784 id="favicon-url"
785 type="text"
786 value={faviconUrlTemplate}
787 onChange={(e) => setFaviconUrlTemplate(e.target.value)}
788 placeholder={DEFAULT_FAVICON_URL_TEMPLATE}
789 />
790 </div>
791 <SettingItem>
792 <Label htmlFor="filter-out-onion-relays" className="text-base font-normal">
793 {t('Filter out onion relays')}
794 </Label>
795 <Switch
796 id="filter-out-onion-relays"
797 checked={filterOutOnionRelays}
798 onCheckedChange={(checked) => {
799 storage.setFilterOutOnionRelays(checked)
800 setFilterOutOnionRelays(checked)
801 dispatchSettingsChanged()
802 }}
803 />
804 </SettingItem>
805 <SettingItem>
806 <div>
807 <Label htmlFor="graph-queries-enabled" className="text-base font-normal">
808 {t('Graph query optimization')}
809 </Label>
810 <p className="text-sm text-muted-foreground">
811 {t('Use graph queries for faster follow/thread loading on supported relays')}
812 </p>
813 </div>
814 <Switch
815 id="graph-queries-enabled"
816 checked={graphQueriesEnabled}
817 onCheckedChange={(checked) => {
818 storage.setGraphQueriesEnabled(checked)
819 setGraphQueriesEnabled(checked)
820 dispatchSettingsChanged()
821 }}
822 />
823 </SettingItem>
824 </AccordionContent>
825 </AccordionItem>
826 </NavigableAccordionItem>
827
828 {/* Tools */}
829 <NavigableAccordionItem ref={setAccordionRef('tools')} isSelected={isAccordionSelected('tools')}>
830 <AccordionItem value="tools">
831 <AccordionTrigger className="px-4 hover:no-underline">
832 <div className="flex items-center gap-4">
833 <Wrench className="size-4" />
834 <span>{t('Tools')}</span>
835 </div>
836 </AccordionTrigger>
837 <AccordionContent className="px-4 space-y-4">
838 <div className="space-y-2">
839 <h4 className="font-medium">{t('Relay Discovery')}</h4>
840 <RelayDiscovery />
841 </div>
842 </AccordionContent>
843 </AccordionItem>
844 </NavigableAccordionItem>
845 </Accordion>
846
847 {/* Non-accordion items */}
848 {!!nsec && (
849 <SettingItem
850 className="clickable"
851 onClick={() => {
852 navigator.clipboard.writeText(nsec)
853 setCopiedNsec(true)
854 setTimeout(() => setCopiedNsec(false), 2000)
855 }}
856 >
857 <div className="flex items-center gap-4">
858 <KeyRound />
859 <div>{t('Copy private key')} (nsec)</div>
860 </div>
861 {copiedNsec ? <Check /> : <Copy />}
862 </SettingItem>
863 )}
864 {!!ncryptsec && (
865 <SettingItem
866 className="clickable"
867 onClick={() => {
868 navigator.clipboard.writeText(ncryptsec)
869 setCopiedNcryptsec(true)
870 setTimeout(() => setCopiedNcryptsec(false), 2000)
871 }}
872 >
873 <div className="flex items-center gap-4">
874 <KeyRound />
875 <div>{t('Copy private key')} (ncryptsec)</div>
876 </div>
877 {copiedNcryptsec ? <Check /> : <Copy />}
878 </SettingItem>
879 )}
880 <AboutInfoDialog>
881 <SettingItem className="clickable">
882 <div className="flex items-center gap-4">
883 <Info />
884 <div>{t('About')}</div>
885 </div>
886 <div className="flex gap-2 items-center">
887 <div className="text-muted-foreground">
888 v{import.meta.env.APP_VERSION} ({import.meta.env.GIT_COMMIT})
889 </div>
890 </div>
891 </SettingItem>
892 </AboutInfoDialog>
893 <div className="p-4">
894 <Donation />
895 </div>
896 </div>
897 )
898 }
899
900 const SettingItem = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
901 ({ children, className, ...props }, ref) => {
902 return (
903 <div
904 className={cn(
905 'flex justify-between select-none items-center px-4 min-h-9 [&_svg]:size-4 [&_svg]:shrink-0',
906 className
907 )}
908 {...props}
909 ref={ref}
910 >
911 {children}
912 </div>
913 )
914 }
915 )
916 SettingItem.displayName = 'SettingItem'
917
918 const OptionButton = ({
919 isSelected,
920 onClick,
921 icon,
922 label
923 }: {
924 isSelected: boolean
925 onClick: () => void
926 icon: React.ReactNode
927 label: string
928 }) => {
929 return (
930 <button
931 onClick={onClick}
932 className={cn(
933 'flex flex-col items-center gap-2 py-4 rounded-lg border-2 transition-all',
934 isSelected ? 'border-primary' : 'border-border hover:border-muted-foreground/40'
935 )}
936 >
937 <div className="flex items-center justify-center w-8 h-8">{icon}</div>
938 <span className="text-xs font-medium">{label}</span>
939 </button>
940 )
941 }
942
943 // Wrapper for keyboard-navigable accordion items
944 const NavigableAccordionItem = forwardRef<
945 HTMLDivElement,
946 {
947 isSelected: boolean
948 children: React.ReactNode
949 }
950 >(({ isSelected, children }, ref) => {
951 return (
952 <div
953 ref={ref}
954 className={cn(
955 'rounded-lg transition-all',
956 isSelected && 'ring-2 ring-primary ring-offset-2 ring-offset-background'
957 )}
958 >
959 {children}
960 </div>
961 )
962 })
963 NavigableAccordionItem.displayName = 'NavigableAccordionItem'
964