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