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