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 nsfwDisplayPolicy,
220 setNsfwDisplayPolicy,
221 hideContentMentioningMutedUsers,
222 setHideContentMentioningMutedUsers,
223 mediaAutoLoadPolicy,
224 setMediaAutoLoadPolicy,
225 faviconUrlTemplate,
226 setFaviconUrlTemplate
227 } = useContentPolicy()
228 const {
229 hideUntrustedNotes,
230 updateHideUntrustedNotes,
231 hideUntrustedInteractions,
232 updateHideUntrustedInteractions,
233 hideUntrustedNotifications,
234 updateHideUntrustedNotifications
235 } = useUserTrust()
236 const {
237 quickReaction,
238 updateQuickReaction,
239 quickReactionEmoji,
240 updateQuickReactionEmoji,
241 enableSingleColumnLayout,
242 updateEnableSingleColumnLayout,
243 autoInsertNewNotes,
244 updateAutoInsertNewNotes,
245 notificationListStyle,
246 updateNotificationListStyle
247 } = useUserPreferences()
248
249 // Appearance settings
250 const { themeSetting, setThemeSetting, primaryColor, setPrimaryColor } = useTheme()
251
252 // Wallet settings
253 const { isWalletConnected, walletInfo } = useZap()
254
255 // Relay settings
256 const [relayTabValue, setRelayTabValue] = useState('favorite-relays')
257
258 // Emoji settings
259 const [emojiTab, setEmojiTab] = useState<TEmojiTab>('my-packs')
260
261 // System settings
262 const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays())
263 const [graphQueriesEnabled, setGraphQueriesEnabled] = useState(storage.getGraphQueriesEnabled())
264
265 // Messaging settings
266 const [preferNip44, setPreferNip44] = useState(storage.getPreferNip44())
267
268 // Post settings
269 const [addClientTag, setAddClientTag] = useState(storage.getAddClientTag())
270
271 // Wallet QR scanner
272 const [showWalletScanner, setShowWalletScanner] = useState(false)
273
274 const handleWalletScan = useCallback((result: string) => {
275 // Check if it's a valid NWC URI
276 if (result.startsWith('nostr+walletconnect://')) {
277 connectNWC(result)
278 }
279 }, [])
280
281 const handleLanguageChange = (value: TLanguage) => {
282 i18n.changeLanguage(value)
283 setLanguage(value)
284 }
285
286 const handleAccordionChange = useCallback((value: string) => {
287 // Prevent auto-scroll when opening accordion sections
288 const scrollY = window.scrollY
289 setOpenSection(value)
290 requestAnimationFrame(() => {
291 window.scrollTo(0, scrollY)
292 })
293 }, [])
294
295 return (
296 <div>
297 <Accordion
298 type="single"
299 collapsible
300 value={openSection}
301 onValueChange={handleAccordionChange}
302 className="w-full"
303 >
304 {/* General */}
305 <NavigableAccordionItem ref={setAccordionRef('general')} isSelected={isAccordionSelected('general')}>
306 <AccordionItem value="general">
307 <AccordionTrigger className="px-4 hover:no-underline">
308 <div className="flex items-center gap-4">
309 <Settings2 className="size-4" />
310 <span>{t('General')}</span>
311 </div>
312 </AccordionTrigger>
313 <AccordionContent className="px-4 space-y-4">
314 <SettingItem>
315 <Label htmlFor="languages" className="text-base font-normal">
316 {t('Languages')}
317 </Label>
318 <Select defaultValue="en" value={language} onValueChange={handleLanguageChange}>
319 <SelectTrigger id="languages" className="w-48">
320 <SelectValue />
321 </SelectTrigger>
322 <SelectContent>
323 {Object.entries(LocalizedLanguageNames).map(([key, value]) => (
324 <SelectItem key={key} value={key}>
325 {value}
326 </SelectItem>
327 ))}
328 </SelectContent>
329 </Select>
330 </SettingItem>
331 <SettingItem>
332 <Label htmlFor="media-auto-load-policy" className="text-base font-normal">
333 {t('Auto-load media')}
334 </Label>
335 <Select
336 defaultValue="wifi-only"
337 value={mediaAutoLoadPolicy}
338 onValueChange={(value: TMediaAutoLoadPolicy) => setMediaAutoLoadPolicy(value)}
339 >
340 <SelectTrigger id="media-auto-load-policy" className="w-48">
341 <SelectValue />
342 </SelectTrigger>
343 <SelectContent>
344 <SelectItem value={MEDIA_AUTO_LOAD_POLICY.ALWAYS}>{t('Always')}</SelectItem>
345 {isSupportCheckConnectionType() && (
346 <SelectItem value={MEDIA_AUTO_LOAD_POLICY.WIFI_ONLY}>{t('Wi-Fi only')}</SelectItem>
347 )}
348 <SelectItem value={MEDIA_AUTO_LOAD_POLICY.NEVER}>{t('Never')}</SelectItem>
349 </SelectContent>
350 </Select>
351 </SettingItem>
352 <SettingItem>
353 <Label htmlFor="auto-insert-new-notes" className="text-base font-normal">
354 <div>{t('Live feed')}</div>
355 <div className="text-muted-foreground">{t('Automatically insert new notes into the feed')}</div>
356 </Label>
357 <Switch id="auto-insert-new-notes" checked={autoInsertNewNotes} onCheckedChange={updateAutoInsertNewNotes} />
358 </SettingItem>
359 <SettingItem>
360 <Label htmlFor="hide-untrusted-notes" className="text-base font-normal">
361 {t('Hide untrusted notes')}
362 </Label>
363 <Switch
364 id="hide-untrusted-notes"
365 checked={hideUntrustedNotes}
366 onCheckedChange={updateHideUntrustedNotes}
367 />
368 </SettingItem>
369 <SettingItem>
370 <Label htmlFor="hide-untrusted-interactions" className="text-base font-normal">
371 {t('Hide untrusted interactions')}
372 </Label>
373 <Switch
374 id="hide-untrusted-interactions"
375 checked={hideUntrustedInteractions}
376 onCheckedChange={updateHideUntrustedInteractions}
377 />
378 </SettingItem>
379 <SettingItem>
380 <Label htmlFor="hide-untrusted-notifications" className="text-base font-normal">
381 {t('Hide untrusted notifications')}
382 </Label>
383 <Switch
384 id="hide-untrusted-notifications"
385 checked={hideUntrustedNotifications}
386 onCheckedChange={updateHideUntrustedNotifications}
387 />
388 </SettingItem>
389 <SettingItem>
390 <Label htmlFor="hide-content-mentioning-muted-users" className="text-base font-normal">
391 {t('Hide content mentioning muted users')}
392 </Label>
393 <Switch
394 id="hide-content-mentioning-muted-users"
395 checked={hideContentMentioningMutedUsers}
396 onCheckedChange={setHideContentMentioningMutedUsers}
397 />
398 </SettingItem>
399 <SettingItem>
400 <Label htmlFor="nsfw-display-policy" className="text-base font-normal">
401 {t('NSFW content display')}
402 </Label>
403 <Select
404 value={nsfwDisplayPolicy}
405 onValueChange={(value: TNsfwDisplayPolicy) => setNsfwDisplayPolicy(value)}
406 >
407 <SelectTrigger id="nsfw-display-policy" className="w-48">
408 <SelectValue />
409 </SelectTrigger>
410 <SelectContent>
411 <SelectItem value={NSFW_DISPLAY_POLICY.HIDE}>{t('Hide completely')}</SelectItem>
412 <SelectItem value={NSFW_DISPLAY_POLICY.HIDE_CONTENT}>{t('Show but hide content')}</SelectItem>
413 <SelectItem value={NSFW_DISPLAY_POLICY.SHOW}>{t('Show directly')}</SelectItem>
414 </SelectContent>
415 </Select>
416 </SettingItem>
417 <SettingItem>
418 <Label htmlFor="quick-reaction" className="text-base font-normal">
419 <div>{t('Quick reaction')}</div>
420 <div className="text-muted-foreground">
421 {t('If enabled, you can react with a single click. Click and hold for more options')}
422 </div>
423 </Label>
424 <Switch id="quick-reaction" checked={quickReaction} onCheckedChange={updateQuickReaction} />
425 </SettingItem>
426 {quickReaction && (
427 <SettingItem>
428 <Label htmlFor="quick-reaction-emoji" className="text-base font-normal">
429 {t('Quick reaction emoji')}
430 </Label>
431 <div className="flex items-center gap-2">
432 <Button
433 variant="ghost"
434 size="icon"
435 onClick={() => updateQuickReactionEmoji('+')}
436 className="text-muted-foreground hover:text-foreground"
437 >
438 <RotateCcw />
439 </Button>
440 <EmojiPickerDialog
441 onEmojiClick={(emoji) => {
442 if (!emoji) return
443 updateQuickReactionEmoji(emoji)
444 }}
445 >
446 <Button variant="ghost" size="icon" className="border">
447 <Emoji emoji={quickReactionEmoji} />
448 </Button>
449 </EmojiPickerDialog>
450 </div>
451 </SettingItem>
452 )}
453 </AccordionContent>
454 </AccordionItem>
455 </NavigableAccordionItem>
456
457 {/* Appearance */}
458 <NavigableAccordionItem ref={setAccordionRef('appearance')} isSelected={isAccordionSelected('appearance')}>
459 <AccordionItem value="appearance">
460 <AccordionTrigger className="px-4 hover:no-underline">
461 <div className="flex items-center gap-4">
462 <Palette className="size-4" />
463 <span>{t('Appearance')}</span>
464 </div>
465 </AccordionTrigger>
466 <AccordionContent className="px-4 space-y-4">
467 <div className="flex flex-col gap-2">
468 <Label className="text-base">{t('Theme')}</Label>
469 <div className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full">
470 {THEMES.map(({ key, label, icon }) => (
471 <OptionButton
472 key={key}
473 isSelected={themeSetting === key}
474 icon={icon}
475 label={t(label)}
476 onClick={() => setThemeSetting(key)}
477 />
478 ))}
479 </div>
480 </div>
481 {!isSmallScreen && (
482 <div className="flex flex-col gap-2">
483 <Label className="text-base">{t('Layout')}</Label>
484 <div className="grid grid-cols-2 gap-4 w-full">
485 {LAYOUTS.map(({ key, label, icon }) => (
486 <OptionButton
487 key={key.toString()}
488 isSelected={enableSingleColumnLayout === key}
489 icon={icon}
490 label={t(label)}
491 onClick={() => updateEnableSingleColumnLayout(key)}
492 />
493 ))}
494 </div>
495 </div>
496 )}
497 <div className="flex flex-col gap-2">
498 <Label className="text-base">{t('Notification list style')}</Label>
499 <div className="grid grid-cols-2 gap-4 w-full">
500 {NOTIFICATION_STYLES.map(({ key, label, icon }) => (
501 <OptionButton
502 key={key}
503 isSelected={notificationListStyle === key}
504 icon={icon}
505 label={t(label)}
506 onClick={() => updateNotificationListStyle(key)}
507 />
508 ))}
509 </div>
510 </div>
511 <div className="flex flex-col gap-2">
512 <Label className="text-base">{t('Primary color')}</Label>
513 <div className="grid grid-cols-4 gap-4 w-full">
514 {Object.entries(PRIMARY_COLORS).map(([key, config]) => (
515 <OptionButton
516 key={key}
517 isSelected={primaryColor === key}
518 icon={
519 <div
520 className="size-8 rounded-full shadow-md"
521 style={{ backgroundColor: `hsl(${config.light.primary})` }}
522 />
523 }
524 label={t(config.name)}
525 onClick={() => setPrimaryColor(key as TPrimaryColor)}
526 />
527 ))}
528 </div>
529 </div>
530 </AccordionContent>
531 </AccordionItem>
532 </NavigableAccordionItem>
533
534 {/* Relays */}
535 <NavigableAccordionItem ref={setAccordionRef('relays')} isSelected={isAccordionSelected('relays')}>
536 <AccordionItem value="relays">
537 <AccordionTrigger className="px-4 hover:no-underline">
538 <div className="flex items-center gap-4">
539 <Server className="size-4" />
540 <span>{t('Relays')}</span>
541 </div>
542 </AccordionTrigger>
543 <AccordionContent className="px-4">
544 <RadixTabs value={relayTabValue} onValueChange={setRelayTabValue} className="space-y-4">
545 <TabsList>
546 <TabsTrigger value="favorite-relays">{t('Favorite Relays')}</TabsTrigger>
547 <TabsTrigger value="mailbox">{t('Read & Write Relays')}</TabsTrigger>
548 <TabsTrigger value="cache-relays">{t('Cache Relays')}</TabsTrigger>
549 <TabsTrigger value="outbox">{t('Outbox')}</TabsTrigger>
550 </TabsList>
551 <TabsContent value="favorite-relays">
552 <FavoriteRelaysSetting />
553 </TabsContent>
554 <TabsContent value="mailbox">
555 <MailboxSetting />
556 </TabsContent>
557 <TabsContent value="cache-relays">
558 <CacheRelaysSetting />
559 </TabsContent>
560 <TabsContent value="outbox">
561 <ManagedOutboxSetting />
562 </TabsContent>
563 </RadixTabs>
564 </AccordionContent>
565 </AccordionItem>
566 </NavigableAccordionItem>
567
568 {/* Sync (NRC) */}
569 {!!pubkey && (
570 <NavigableAccordionItem ref={setAccordionRef('sync')} isSelected={isAccordionSelected('sync')}>
571 <AccordionItem value="sync">
572 <AccordionTrigger className="px-4 hover:no-underline">
573 <div className="flex items-center gap-4">
574 <RefreshCw className="size-4" />
575 <span>{t('Device Sync')}</span>
576 </div>
577 </AccordionTrigger>
578 <AccordionContent className="px-4">
579 <NRCSettings />
580 </AccordionContent>
581 </AccordionItem>
582 </NavigableAccordionItem>
583 )}
584
585 {/* Wallet */}
586 {!!pubkey && (
587 <NavigableAccordionItem ref={setAccordionRef('wallet')} isSelected={isAccordionSelected('wallet')}>
588 <AccordionItem value="wallet">
589 <AccordionTrigger className="px-4 hover:no-underline">
590 <div className="flex items-center gap-4">
591 <Wallet className="size-4" />
592 <span>{t('Wallet')}</span>
593 </div>
594 </AccordionTrigger>
595 <AccordionContent className="px-4 space-y-4">
596 {isWalletConnected ? (
597 <>
598 <div>
599 {walletInfo?.node.alias && (
600 <div className="mb-2">
601 {t('Connected to')} <strong>{walletInfo.node.alias}</strong>
602 </div>
603 )}
604 <AlertDialog>
605 <AlertDialogTrigger asChild>
606 <Button variant="destructive">{t('Disconnect Wallet')}</Button>
607 </AlertDialogTrigger>
608 <AlertDialogContent>
609 <AlertDialogHeader>
610 <AlertDialogTitle>{t('Are you absolutely sure?')}</AlertDialogTitle>
611 <AlertDialogDescription>
612 {t('You will not be able to send zaps to others.')}
613 </AlertDialogDescription>
614 </AlertDialogHeader>
615 <AlertDialogFooter>
616 <AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
617 <AlertDialogAction variant="destructive" onClick={() => disconnect()}>
618 {t('Disconnect')}
619 </AlertDialogAction>
620 </AlertDialogFooter>
621 </AlertDialogContent>
622 </AlertDialog>
623 </div>
624 <DefaultZapAmountInput />
625 <DefaultZapCommentInput />
626 <QuickZapSwitch />
627 <LightningAddressInput />
628 </>
629 ) : (
630 <>
631 {showWalletScanner && (
632 <QrScannerModal
633 onScan={handleWalletScan}
634 onClose={() => setShowWalletScanner(false)}
635 />
636 )}
637 <div className="flex items-center gap-2">
638 <Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
639 {t('Connect Wallet')}
640 </Button>
641 <Button
642 variant="outline"
643 size="icon"
644 onClick={() => setShowWalletScanner(true)}
645 title={t('Scan NWC QR code')}
646 >
647 <ScanLine className="h-4 w-4" />
648 </Button>
649 </div>
650 </>
651 )}
652 </AccordionContent>
653 </AccordionItem>
654 </NavigableAccordionItem>
655 )}
656
657 {/* Post Settings */}
658 {!!pubkey && (
659 <NavigableAccordionItem ref={setAccordionRef('posts')} isSelected={isAccordionSelected('posts')}>
660 <AccordionItem value="posts">
661 <AccordionTrigger className="px-4 hover:no-underline">
662 <div className="flex items-center gap-4">
663 <PencilLine className="size-4" />
664 <span>{t('Post settings')}</span>
665 </div>
666 </AccordionTrigger>
667 <AccordionContent className="px-4 space-y-4">
668 <MediaUploadServiceSetting />
669 <LlmSetting />
670 <SettingItem>
671 <div>
672 <Label htmlFor="add-client-tag" className="text-base font-normal">
673 {t('Include client tag')}
674 </Label>
675 <p className="text-sm text-muted-foreground">
676 {t('Add a tag to identify posts as coming from smesh')}
677 </p>
678 </div>
679 <Switch
680 id="add-client-tag"
681 checked={addClientTag}
682 onCheckedChange={(checked) => {
683 storage.setAddClientTag(checked)
684 setAddClientTag(checked)
685 dispatchSettingsChanged()
686 }}
687 />
688 </SettingItem>
689 </AccordionContent>
690 </AccordionItem>
691 </NavigableAccordionItem>
692 )}
693
694 {/* Emoji Packs */}
695 {!!pubkey && (
696 <NavigableAccordionItem ref={setAccordionRef('emoji-packs')} isSelected={isAccordionSelected('emoji-packs')}>
697 <AccordionItem value="emoji-packs">
698 <AccordionTrigger className="px-4 hover:no-underline">
699 <div className="flex items-center gap-4">
700 <Smile className="size-4" />
701 <span>{t('Emoji Packs')}</span>
702 </div>
703 </AccordionTrigger>
704 <AccordionContent className="px-4">
705 <Tabs
706 value={emojiTab}
707 tabs={[
708 { value: 'my-packs', label: 'My Packs' },
709 { value: 'explore', label: 'Explore' }
710 ]}
711 onTabChange={(tab) => setEmojiTab(tab as TEmojiTab)}
712 />
713 {emojiTab === 'my-packs' ? (
714 <EmojiPackList />
715 ) : (
716 <NoteList
717 showKinds={[kinds.Emojisets]}
718 subRequests={[{ urls: client.currentRelays, filter: {} }]}
719 hideUntrustedNotes={hideUntrustedNotes}
720 />
721 )}
722 </AccordionContent>
723 </AccordionItem>
724 </NavigableAccordionItem>
725 )}
726
727 {/* Messaging */}
728 {!!pubkey && (
729 <NavigableAccordionItem ref={setAccordionRef('messaging')} isSelected={isAccordionSelected('messaging')}>
730 <AccordionItem value="messaging">
731 <AccordionTrigger className="px-4 hover:no-underline">
732 <div className="flex items-center gap-4">
733 <MessageSquare className="size-4" />
734 <span>{t('Messaging')}</span>
735 </div>
736 </AccordionTrigger>
737 <AccordionContent className="px-4 space-y-4">
738 <SettingItem>
739 <Label htmlFor="prefer-nip44" className="text-base font-normal">
740 <div>{t('Prefer NIP-44 encryption')}</div>
741 <div className="text-muted-foreground text-sm">
742 {t('Use modern encryption for new conversations')}
743 </div>
744 </Label>
745 <Switch
746 id="prefer-nip44"
747 checked={preferNip44}
748 onCheckedChange={(checked) => {
749 storage.setPreferNip44(checked)
750 setPreferNip44(checked)
751 dispatchSettingsChanged()
752 }}
753 />
754 </SettingItem>
755 </AccordionContent>
756 </AccordionItem>
757 </NavigableAccordionItem>
758 )}
759
760 {/* System */}
761 <NavigableAccordionItem ref={setAccordionRef('system')} isSelected={isAccordionSelected('system')}>
762 <AccordionItem value="system">
763 <AccordionTrigger className="px-4 hover:no-underline">
764 <div className="flex items-center gap-4">
765 <Cog className="size-4" />
766 <span>{t('System')}</span>
767 </div>
768 </AccordionTrigger>
769 <AccordionContent className="px-4 space-y-4">
770 <div className="space-y-2">
771 <Label htmlFor="favicon-url" className="text-base font-normal">
772 {t('Favicon URL')}
773 </Label>
774 <Input
775 id="favicon-url"
776 type="text"
777 value={faviconUrlTemplate}
778 onChange={(e) => setFaviconUrlTemplate(e.target.value)}
779 placeholder={DEFAULT_FAVICON_URL_TEMPLATE}
780 />
781 </div>
782 <SettingItem>
783 <Label htmlFor="filter-out-onion-relays" className="text-base font-normal">
784 {t('Filter out onion relays')}
785 </Label>
786 <Switch
787 id="filter-out-onion-relays"
788 checked={filterOutOnionRelays}
789 onCheckedChange={(checked) => {
790 storage.setFilterOutOnionRelays(checked)
791 setFilterOutOnionRelays(checked)
792 dispatchSettingsChanged()
793 }}
794 />
795 </SettingItem>
796 <SettingItem>
797 <div>
798 <Label htmlFor="graph-queries-enabled" className="text-base font-normal">
799 {t('Graph query optimization')}
800 </Label>
801 <p className="text-sm text-muted-foreground">
802 {t('Use graph queries for faster follow/thread loading on supported relays')}
803 </p>
804 </div>
805 <Switch
806 id="graph-queries-enabled"
807 checked={graphQueriesEnabled}
808 onCheckedChange={(checked) => {
809 storage.setGraphQueriesEnabled(checked)
810 setGraphQueriesEnabled(checked)
811 dispatchSettingsChanged()
812 }}
813 />
814 </SettingItem>
815 </AccordionContent>
816 </AccordionItem>
817 </NavigableAccordionItem>
818
819 {/* Tools */}
820 <NavigableAccordionItem ref={setAccordionRef('tools')} isSelected={isAccordionSelected('tools')}>
821 <AccordionItem value="tools">
822 <AccordionTrigger className="px-4 hover:no-underline">
823 <div className="flex items-center gap-4">
824 <Wrench className="size-4" />
825 <span>{t('Tools')}</span>
826 </div>
827 </AccordionTrigger>
828 <AccordionContent className="px-4 space-y-4">
829 <div className="space-y-2">
830 <h4 className="font-medium">{t('Relay Discovery')}</h4>
831 <RelayDiscovery />
832 </div>
833 </AccordionContent>
834 </AccordionItem>
835 </NavigableAccordionItem>
836 </Accordion>
837
838 {/* Non-accordion items */}
839 {!!nsec && (
840 <SettingItem
841 className="clickable"
842 onClick={() => {
843 navigator.clipboard.writeText(nsec)
844 setCopiedNsec(true)
845 setTimeout(() => setCopiedNsec(false), 2000)
846 }}
847 >
848 <div className="flex items-center gap-4">
849 <KeyRound />
850 <div>{t('Copy private key')} (nsec)</div>
851 </div>
852 {copiedNsec ? <Check /> : <Copy />}
853 </SettingItem>
854 )}
855 {!!ncryptsec && (
856 <SettingItem
857 className="clickable"
858 onClick={() => {
859 navigator.clipboard.writeText(ncryptsec)
860 setCopiedNcryptsec(true)
861 setTimeout(() => setCopiedNcryptsec(false), 2000)
862 }}
863 >
864 <div className="flex items-center gap-4">
865 <KeyRound />
866 <div>{t('Copy private key')} (ncryptsec)</div>
867 </div>
868 {copiedNcryptsec ? <Check /> : <Copy />}
869 </SettingItem>
870 )}
871 <AboutInfoDialog>
872 <SettingItem className="clickable">
873 <div className="flex items-center gap-4">
874 <Info />
875 <div>{t('About')}</div>
876 </div>
877 <div className="flex gap-2 items-center">
878 <div className="text-muted-foreground">
879 v{import.meta.env.APP_VERSION} ({import.meta.env.GIT_COMMIT})
880 </div>
881 </div>
882 </SettingItem>
883 </AboutInfoDialog>
884 <div className="p-4">
885 <Donation />
886 </div>
887 </div>
888 )
889 }
890
891 const SettingItem = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
892 ({ children, className, ...props }, ref) => {
893 return (
894 <div
895 className={cn(
896 'flex justify-between select-none items-center px-4 min-h-9 [&_svg]:size-4 [&_svg]:shrink-0',
897 className
898 )}
899 {...props}
900 ref={ref}
901 >
902 {children}
903 </div>
904 )
905 }
906 )
907 SettingItem.displayName = 'SettingItem'
908
909 const OptionButton = ({
910 isSelected,
911 onClick,
912 icon,
913 label
914 }: {
915 isSelected: boolean
916 onClick: () => void
917 icon: React.ReactNode
918 label: string
919 }) => {
920 return (
921 <button
922 onClick={onClick}
923 className={cn(
924 'flex flex-col items-center gap-2 py-4 rounded-lg border-2 transition-all',
925 isSelected ? 'border-primary' : 'border-border hover:border-muted-foreground/40'
926 )}
927 >
928 <div className="flex items-center justify-center w-8 h-8">{icon}</div>
929 <span className="text-xs font-medium">{label}</span>
930 </button>
931 )
932 }
933
934 // Wrapper for keyboard-navigable accordion items
935 const NavigableAccordionItem = forwardRef<
936 HTMLDivElement,
937 {
938 isSelected: boolean
939 children: React.ReactNode
940 }
941 >(({ isSelected, children }, ref) => {
942 return (
943 <div
944 ref={ref}
945 className={cn(
946 'rounded-lg transition-all',
947 isSelected && 'ring-2 ring-primary ring-offset-2 ring-offset-background'
948 )}
949 >
950 {children}
951 </div>
952 )
953 })
954 NavigableAccordionItem.displayName = 'NavigableAccordionItem'
955