Files
smesh/src/components/Settings/index.tsx
woikos fecd4fdd45 Add DM inbox with NIP-04/NIP-17 support and soft delete
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>
2025-12-31 11:06:51 +01:00

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>
)
}