Features: - Full DM inbox UI with conversation list and message view - Support for both NIP-04 (kind 4) and NIP-17 (kind 14/1059) encryption - Progressive message decryption with background loading - Soft delete using kind 30078 Application Specific Data events - Message selection UI with delete selected/delete all - Undelete all functionality per conversation - Jump to newest button with new message counter - Conversation filtering (all / follows only) - Per-conversation relay and encryption settings - New messages indicator on sidebar (clears when inbox viewed) - Follow indicator on conversation items 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
700 lines
26 KiB
TypeScript
700 lines
26 KiB
TypeScript
import AboutInfoDialog from '@/components/AboutInfoDialog'
|
|
import Donation from '@/components/Donation'
|
|
import Emoji from '@/components/Emoji'
|
|
import EmojiPackList from '@/components/EmojiPackList'
|
|
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
|
|
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
|
|
import MailboxSetting from '@/components/MailboxSetting'
|
|
import NoteList from '@/components/NoteList'
|
|
import Tabs from '@/components/Tabs'
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger
|
|
} from '@/components/ui/accordion'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger
|
|
} from '@/components/ui/alert-dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Tabs as RadixTabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import {
|
|
BIG_RELAY_URLS,
|
|
DEFAULT_FAVICON_URL_TEMPLATE,
|
|
MEDIA_AUTO_LOAD_POLICY,
|
|
NSFW_DISPLAY_POLICY,
|
|
PRIMARY_COLORS,
|
|
TPrimaryColor
|
|
} from '@/constants'
|
|
import { LocalizedLanguageNames, TLanguage } from '@/i18n'
|
|
import { cn, isSupportCheckConnectionType } from '@/lib/utils'
|
|
import MediaUploadServiceSetting from '@/pages/secondary/PostSettingsPage/MediaUploadServiceSetting'
|
|
import DefaultZapAmountInput from '@/pages/secondary/WalletPage/DefaultZapAmountInput'
|
|
import DefaultZapCommentInput from '@/pages/secondary/WalletPage/DefaultZapCommentInput'
|
|
import LightningAddressInput from '@/pages/secondary/WalletPage/LightningAddressInput'
|
|
import QuickZapSwitch from '@/pages/secondary/WalletPage/QuickZapSwitch'
|
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
|
import { useNostr } from '@/providers/NostrProvider'
|
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
|
import { useTheme } from '@/providers/ThemeProvider'
|
|
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
|
import { useZap } from '@/providers/ZapProvider'
|
|
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
|
|
import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types'
|
|
import { disconnect, launchModal } from '@getalby/bitcoin-connect-react'
|
|
import {
|
|
Check,
|
|
Cog,
|
|
Columns2,
|
|
Copy,
|
|
Info,
|
|
KeyRound,
|
|
LayoutList,
|
|
List,
|
|
MessageSquare,
|
|
Monitor,
|
|
Moon,
|
|
Palette,
|
|
PanelLeft,
|
|
PencilLine,
|
|
RotateCcw,
|
|
Server,
|
|
Settings2,
|
|
Smile,
|
|
Sun,
|
|
Wallet
|
|
} from 'lucide-react'
|
|
import { kinds } from 'nostr-tools'
|
|
import { forwardRef, HTMLProps, useCallback, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
type TEmojiTab = 'my-packs' | 'explore'
|
|
|
|
const THEMES = [
|
|
{ key: 'system', label: 'System', icon: <Monitor className="size-5" /> },
|
|
{ key: 'light', label: 'Light', icon: <Sun className="size-5" /> },
|
|
{ key: 'dark', label: 'Dark', icon: <Moon className="size-5" /> },
|
|
{ key: 'pure-black', label: 'Pure Black', icon: <Moon className="size-5 fill-current" /> }
|
|
] as const
|
|
|
|
const LAYOUTS = [
|
|
{ key: false, label: 'Two-column', icon: <Columns2 className="size-5" /> },
|
|
{ key: true, label: 'Single-column', icon: <PanelLeft className="size-5" /> }
|
|
] as const
|
|
|
|
const NOTIFICATION_STYLES = [
|
|
{ key: 'detailed', label: 'Detailed', icon: <LayoutList className="size-5" /> },
|
|
{ key: 'compact', label: 'Compact', icon: <List className="size-5" /> }
|
|
] as const
|
|
|
|
export default function Settings() {
|
|
const { t, i18n } = useTranslation()
|
|
const { pubkey, nsec, ncryptsec } = useNostr()
|
|
const { isSmallScreen } = useScreenSize()
|
|
const [copiedNsec, setCopiedNsec] = useState(false)
|
|
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
|
|
const [openSection, setOpenSection] = useState<string>('')
|
|
|
|
// General settings
|
|
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
|
|
const {
|
|
autoplay,
|
|
setAutoplay,
|
|
nsfwDisplayPolicy,
|
|
setNsfwDisplayPolicy,
|
|
hideContentMentioningMutedUsers,
|
|
setHideContentMentioningMutedUsers,
|
|
mediaAutoLoadPolicy,
|
|
setMediaAutoLoadPolicy,
|
|
faviconUrlTemplate,
|
|
setFaviconUrlTemplate
|
|
} = useContentPolicy()
|
|
const {
|
|
hideUntrustedNotes,
|
|
updateHideUntrustedNotes,
|
|
hideUntrustedInteractions,
|
|
updateHideUntrustedInteractions,
|
|
hideUntrustedNotifications,
|
|
updateHideUntrustedNotifications
|
|
} = useUserTrust()
|
|
const {
|
|
quickReaction,
|
|
updateQuickReaction,
|
|
quickReactionEmoji,
|
|
updateQuickReactionEmoji,
|
|
enableSingleColumnLayout,
|
|
updateEnableSingleColumnLayout,
|
|
notificationListStyle,
|
|
updateNotificationListStyle
|
|
} = useUserPreferences()
|
|
|
|
// Appearance settings
|
|
const { themeSetting, setThemeSetting, primaryColor, setPrimaryColor } = useTheme()
|
|
|
|
// Wallet settings
|
|
const { isWalletConnected, walletInfo } = useZap()
|
|
|
|
// Relay settings
|
|
const [relayTabValue, setRelayTabValue] = useState('favorite-relays')
|
|
|
|
// Emoji settings
|
|
const [emojiTab, setEmojiTab] = useState<TEmojiTab>('my-packs')
|
|
|
|
// System settings
|
|
const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays())
|
|
|
|
// Messaging settings
|
|
const [preferNip44, setPreferNip44] = useState(storage.getPreferNip44())
|
|
|
|
const handleLanguageChange = (value: TLanguage) => {
|
|
i18n.changeLanguage(value)
|
|
setLanguage(value)
|
|
}
|
|
|
|
const handleAccordionChange = useCallback((value: string) => {
|
|
// Prevent auto-scroll when opening accordion sections
|
|
const scrollY = window.scrollY
|
|
setOpenSection(value)
|
|
requestAnimationFrame(() => {
|
|
window.scrollTo(0, scrollY)
|
|
})
|
|
}, [])
|
|
|
|
return (
|
|
<div>
|
|
<Accordion
|
|
type="single"
|
|
collapsible
|
|
value={openSection}
|
|
onValueChange={handleAccordionChange}
|
|
className="w-full"
|
|
>
|
|
{/* General */}
|
|
<AccordionItem value="general">
|
|
<AccordionTrigger className="px-4 hover:no-underline">
|
|
<div className="flex items-center gap-4">
|
|
<Settings2 className="size-4" />
|
|
<span>{t('General')}</span>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-4 space-y-4">
|
|
<SettingItem>
|
|
<Label htmlFor="languages" className="text-base font-normal">
|
|
{t('Languages')}
|
|
</Label>
|
|
<Select defaultValue="en" value={language} onValueChange={handleLanguageChange}>
|
|
<SelectTrigger id="languages" className="w-48">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(LocalizedLanguageNames).map(([key, value]) => (
|
|
<SelectItem key={key} value={key}>
|
|
{value}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</SettingItem>
|
|
<SettingItem>
|
|
<Label htmlFor="media-auto-load-policy" className="text-base font-normal">
|
|
{t('Auto-load media')}
|
|
</Label>
|
|
<Select
|
|
defaultValue="wifi-only"
|
|
value={mediaAutoLoadPolicy}
|
|
onValueChange={(value: TMediaAutoLoadPolicy) => setMediaAutoLoadPolicy(value)}
|
|
>
|
|
<SelectTrigger id="media-auto-load-policy" className="w-48">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.ALWAYS}>{t('Always')}</SelectItem>
|
|
{isSupportCheckConnectionType() && (
|
|
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.WIFI_ONLY}>{t('Wi-Fi only')}</SelectItem>
|
|
)}
|
|
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.NEVER}>{t('Never')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</SettingItem>
|
|
<SettingItem>
|
|
<Label htmlFor="autoplay" className="text-base font-normal">
|
|
<div>{t('Autoplay')}</div>
|
|
<div className="text-muted-foreground">{t('Enable video autoplay on this device')}</div>
|
|
</Label>
|
|
<Switch id="autoplay" checked={autoplay} onCheckedChange={setAutoplay} />
|
|
</SettingItem>
|
|
<SettingItem>
|
|
<Label htmlFor="hide-untrusted-notes" className="text-base font-normal">
|
|
{t('Hide untrusted notes')}
|
|
</Label>
|
|
<Switch
|
|
id="hide-untrusted-notes"
|
|
checked={hideUntrustedNotes}
|
|
onCheckedChange={updateHideUntrustedNotes}
|
|
/>
|
|
</SettingItem>
|
|
<SettingItem>
|
|
<Label htmlFor="hide-untrusted-interactions" className="text-base font-normal">
|
|
{t('Hide untrusted interactions')}
|
|
</Label>
|
|
<Switch
|
|
id="hide-untrusted-interactions"
|
|
checked={hideUntrustedInteractions}
|
|
onCheckedChange={updateHideUntrustedInteractions}
|
|
/>
|
|
</SettingItem>
|
|
<SettingItem>
|
|
<Label htmlFor="hide-untrusted-notifications" className="text-base font-normal">
|
|
{t('Hide untrusted notifications')}
|
|
</Label>
|
|
<Switch
|
|
id="hide-untrusted-notifications"
|
|
checked={hideUntrustedNotifications}
|
|
onCheckedChange={updateHideUntrustedNotifications}
|
|
/>
|
|
</SettingItem>
|
|
<SettingItem>
|
|
<Label htmlFor="hide-content-mentioning-muted-users" className="text-base font-normal">
|
|
{t('Hide content mentioning muted users')}
|
|
</Label>
|
|
<Switch
|
|
id="hide-content-mentioning-muted-users"
|
|
checked={hideContentMentioningMutedUsers}
|
|
onCheckedChange={setHideContentMentioningMutedUsers}
|
|
/>
|
|
</SettingItem>
|
|
<SettingItem>
|
|
<Label htmlFor="nsfw-display-policy" className="text-base font-normal">
|
|
{t('NSFW content display')}
|
|
</Label>
|
|
<Select
|
|
value={nsfwDisplayPolicy}
|
|
onValueChange={(value: TNsfwDisplayPolicy) => setNsfwDisplayPolicy(value)}
|
|
>
|
|
<SelectTrigger id="nsfw-display-policy" className="w-48">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NSFW_DISPLAY_POLICY.HIDE}>{t('Hide completely')}</SelectItem>
|
|
<SelectItem value={NSFW_DISPLAY_POLICY.HIDE_CONTENT}>{t('Show but hide content')}</SelectItem>
|
|
<SelectItem value={NSFW_DISPLAY_POLICY.SHOW}>{t('Show directly')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</SettingItem>
|
|
<SettingItem>
|
|
<Label htmlFor="quick-reaction" className="text-base font-normal">
|
|
<div>{t('Quick reaction')}</div>
|
|
<div className="text-muted-foreground">
|
|
{t('If enabled, you can react with a single click. Click and hold for more options')}
|
|
</div>
|
|
</Label>
|
|
<Switch id="quick-reaction" checked={quickReaction} onCheckedChange={updateQuickReaction} />
|
|
</SettingItem>
|
|
{quickReaction && (
|
|
<SettingItem>
|
|
<Label htmlFor="quick-reaction-emoji" className="text-base font-normal">
|
|
{t('Quick reaction emoji')}
|
|
</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => updateQuickReactionEmoji('+')}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
<RotateCcw />
|
|
</Button>
|
|
<EmojiPickerDialog
|
|
onEmojiClick={(emoji) => {
|
|
if (!emoji) return
|
|
updateQuickReactionEmoji(emoji)
|
|
}}
|
|
>
|
|
<Button variant="ghost" size="icon" className="border">
|
|
<Emoji emoji={quickReactionEmoji} />
|
|
</Button>
|
|
</EmojiPickerDialog>
|
|
</div>
|
|
</SettingItem>
|
|
)}
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
|
|
{/* Appearance */}
|
|
<AccordionItem value="appearance">
|
|
<AccordionTrigger className="px-4 hover:no-underline">
|
|
<div className="flex items-center gap-4">
|
|
<Palette className="size-4" />
|
|
<span>{t('Appearance')}</span>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-4 space-y-4">
|
|
<div className="flex flex-col gap-2">
|
|
<Label className="text-base">{t('Theme')}</Label>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full">
|
|
{THEMES.map(({ key, label, icon }) => (
|
|
<OptionButton
|
|
key={key}
|
|
isSelected={themeSetting === key}
|
|
icon={icon}
|
|
label={t(label)}
|
|
onClick={() => setThemeSetting(key)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{!isSmallScreen && (
|
|
<div className="flex flex-col gap-2">
|
|
<Label className="text-base">{t('Layout')}</Label>
|
|
<div className="grid grid-cols-2 gap-4 w-full">
|
|
{LAYOUTS.map(({ key, label, icon }) => (
|
|
<OptionButton
|
|
key={key.toString()}
|
|
isSelected={enableSingleColumnLayout === key}
|
|
icon={icon}
|
|
label={t(label)}
|
|
onClick={() => updateEnableSingleColumnLayout(key)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col gap-2">
|
|
<Label className="text-base">{t('Notification list style')}</Label>
|
|
<div className="grid grid-cols-2 gap-4 w-full">
|
|
{NOTIFICATION_STYLES.map(({ key, label, icon }) => (
|
|
<OptionButton
|
|
key={key}
|
|
isSelected={notificationListStyle === key}
|
|
icon={icon}
|
|
label={t(label)}
|
|
onClick={() => updateNotificationListStyle(key)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<Label className="text-base">{t('Primary color')}</Label>
|
|
<div className="grid grid-cols-4 gap-4 w-full">
|
|
{Object.entries(PRIMARY_COLORS).map(([key, config]) => (
|
|
<OptionButton
|
|
key={key}
|
|
isSelected={primaryColor === key}
|
|
icon={
|
|
<div
|
|
className="size-8 rounded-full shadow-md"
|
|
style={{ backgroundColor: `hsl(${config.light.primary})` }}
|
|
/>
|
|
}
|
|
label={t(config.name)}
|
|
onClick={() => setPrimaryColor(key as TPrimaryColor)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
|
|
{/* Relays */}
|
|
<AccordionItem value="relays">
|
|
<AccordionTrigger className="px-4 hover:no-underline">
|
|
<div className="flex items-center gap-4">
|
|
<Server className="size-4" />
|
|
<span>{t('Relays')}</span>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-4">
|
|
<RadixTabs value={relayTabValue} onValueChange={setRelayTabValue} className="space-y-4">
|
|
<TabsList>
|
|
<TabsTrigger value="favorite-relays">{t('Favorite Relays')}</TabsTrigger>
|
|
<TabsTrigger value="mailbox">{t('Read & Write Relays')}</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="favorite-relays">
|
|
<FavoriteRelaysSetting />
|
|
</TabsContent>
|
|
<TabsContent value="mailbox">
|
|
<MailboxSetting />
|
|
</TabsContent>
|
|
</RadixTabs>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
|
|
{/* Wallet */}
|
|
{!!pubkey && (
|
|
<AccordionItem value="wallet">
|
|
<AccordionTrigger className="px-4 hover:no-underline">
|
|
<div className="flex items-center gap-4">
|
|
<Wallet className="size-4" />
|
|
<span>{t('Wallet')}</span>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-4 space-y-4">
|
|
{isWalletConnected ? (
|
|
<>
|
|
<div>
|
|
{walletInfo?.node.alias && (
|
|
<div className="mb-2">
|
|
{t('Connected to')} <strong>{walletInfo.node.alias}</strong>
|
|
</div>
|
|
)}
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="destructive">{t('Disconnect Wallet')}</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t('Are you absolutely sure?')}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t('You will not be able to send zaps to others.')}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
|
<AlertDialogAction variant="destructive" onClick={() => disconnect()}>
|
|
{t('Disconnect')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
<DefaultZapAmountInput />
|
|
<DefaultZapCommentInput />
|
|
<QuickZapSwitch />
|
|
<LightningAddressInput />
|
|
</>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
|
|
{t('Connect Wallet')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
)}
|
|
|
|
{/* Post Settings */}
|
|
{!!pubkey && (
|
|
<AccordionItem value="posts">
|
|
<AccordionTrigger className="px-4 hover:no-underline">
|
|
<div className="flex items-center gap-4">
|
|
<PencilLine className="size-4" />
|
|
<span>{t('Post settings')}</span>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-4">
|
|
<MediaUploadServiceSetting />
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
)}
|
|
|
|
{/* Emoji Packs */}
|
|
{!!pubkey && (
|
|
<AccordionItem value="emoji-packs">
|
|
<AccordionTrigger className="px-4 hover:no-underline">
|
|
<div className="flex items-center gap-4">
|
|
<Smile className="size-4" />
|
|
<span>{t('Emoji Packs')}</span>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-4">
|
|
<Tabs
|
|
value={emojiTab}
|
|
tabs={[
|
|
{ value: 'my-packs', label: 'My Packs' },
|
|
{ value: 'explore', label: 'Explore' }
|
|
]}
|
|
onTabChange={(tab) => setEmojiTab(tab as TEmojiTab)}
|
|
/>
|
|
{emojiTab === 'my-packs' ? (
|
|
<EmojiPackList />
|
|
) : (
|
|
<NoteList
|
|
showKinds={[kinds.Emojisets]}
|
|
subRequests={[{ urls: BIG_RELAY_URLS, filter: {} }]}
|
|
hideUntrustedNotes={hideUntrustedNotes}
|
|
/>
|
|
)}
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
)}
|
|
|
|
{/* Messaging */}
|
|
{!!pubkey && (
|
|
<AccordionItem value="messaging">
|
|
<AccordionTrigger className="px-4 hover:no-underline">
|
|
<div className="flex items-center gap-4">
|
|
<MessageSquare className="size-4" />
|
|
<span>{t('Messaging')}</span>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-4 space-y-4">
|
|
<SettingItem>
|
|
<Label htmlFor="prefer-nip44" className="text-base font-normal">
|
|
<div>{t('Prefer NIP-44 encryption')}</div>
|
|
<div className="text-muted-foreground text-sm">
|
|
{t('Use modern encryption for new conversations')}
|
|
</div>
|
|
</Label>
|
|
<Switch
|
|
id="prefer-nip44"
|
|
checked={preferNip44}
|
|
onCheckedChange={(checked) => {
|
|
storage.setPreferNip44(checked)
|
|
setPreferNip44(checked)
|
|
dispatchSettingsChanged()
|
|
}}
|
|
/>
|
|
</SettingItem>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
)}
|
|
|
|
{/* System */}
|
|
<AccordionItem value="system">
|
|
<AccordionTrigger className="px-4 hover:no-underline">
|
|
<div className="flex items-center gap-4">
|
|
<Cog className="size-4" />
|
|
<span>{t('System')}</span>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-4 space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="favicon-url" className="text-base font-normal">
|
|
{t('Favicon URL')}
|
|
</Label>
|
|
<Input
|
|
id="favicon-url"
|
|
type="text"
|
|
value={faviconUrlTemplate}
|
|
onChange={(e) => setFaviconUrlTemplate(e.target.value)}
|
|
placeholder={DEFAULT_FAVICON_URL_TEMPLATE}
|
|
/>
|
|
</div>
|
|
<SettingItem>
|
|
<Label htmlFor="filter-out-onion-relays" className="text-base font-normal">
|
|
{t('Filter out onion relays')}
|
|
</Label>
|
|
<Switch
|
|
id="filter-out-onion-relays"
|
|
checked={filterOutOnionRelays}
|
|
onCheckedChange={(checked) => {
|
|
storage.setFilterOutOnionRelays(checked)
|
|
setFilterOutOnionRelays(checked)
|
|
dispatchSettingsChanged()
|
|
}}
|
|
/>
|
|
</SettingItem>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
|
|
{/* Non-accordion items */}
|
|
{!!nsec && (
|
|
<SettingItem
|
|
className="clickable"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(nsec)
|
|
setCopiedNsec(true)
|
|
setTimeout(() => setCopiedNsec(false), 2000)
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<KeyRound />
|
|
<div>{t('Copy private key')} (nsec)</div>
|
|
</div>
|
|
{copiedNsec ? <Check /> : <Copy />}
|
|
</SettingItem>
|
|
)}
|
|
{!!ncryptsec && (
|
|
<SettingItem
|
|
className="clickable"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(ncryptsec)
|
|
setCopiedNcryptsec(true)
|
|
setTimeout(() => setCopiedNcryptsec(false), 2000)
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<KeyRound />
|
|
<div>{t('Copy private key')} (ncryptsec)</div>
|
|
</div>
|
|
{copiedNcryptsec ? <Check /> : <Copy />}
|
|
</SettingItem>
|
|
)}
|
|
<AboutInfoDialog>
|
|
<SettingItem className="clickable">
|
|
<div className="flex items-center gap-4">
|
|
<Info />
|
|
<div>{t('About')}</div>
|
|
</div>
|
|
<div className="flex gap-2 items-center">
|
|
<div className="text-muted-foreground">
|
|
v{import.meta.env.APP_VERSION} ({import.meta.env.GIT_COMMIT})
|
|
</div>
|
|
</div>
|
|
</SettingItem>
|
|
</AboutInfoDialog>
|
|
<div className="p-4">
|
|
<Donation />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const SettingItem = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
|
|
({ children, className, ...props }, ref) => {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex justify-between select-none items-center px-4 min-h-9 [&_svg]:size-4 [&_svg]:shrink-0',
|
|
className
|
|
)}
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
SettingItem.displayName = 'SettingItem'
|
|
|
|
const OptionButton = ({
|
|
isSelected,
|
|
onClick,
|
|
icon,
|
|
label
|
|
}: {
|
|
isSelected: boolean
|
|
onClick: () => void
|
|
icon: React.ReactNode
|
|
label: string
|
|
}) => {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={cn(
|
|
'flex flex-col items-center gap-2 py-4 rounded-lg border-2 transition-all',
|
|
isSelected ? 'border-primary' : 'border-border hover:border-muted-foreground/40'
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-center w-8 h-8">{icon}</div>
|
|
<span className="text-xs font-medium">{label}</span>
|
|
</button>
|
|
)
|
|
}
|