Release v0.3.1
- Feed bounded context with DDD implementation (Phases 1-5) - Domain event handlers for cross-context coordination - Fix Blossom media upload setting persistence - Fix wallet connection persistence on page reload - New branding assets and icons - Vitest testing infrastructure with 151 domain model tests - Help page scaffolding - Keyboard navigation provider 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -78,8 +78,9 @@ import {
|
||||
Wallet
|
||||
} from 'lucide-react'
|
||||
import { kinds } from 'nostr-tools'
|
||||
import { forwardRef, HTMLProps, useCallback, useState } from 'react'
|
||||
import { forwardRef, HTMLProps, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||
|
||||
type TEmojiTab = 'my-packs' | 'explore'
|
||||
|
||||
@@ -100,6 +101,9 @@ const NOTIFICATION_STYLES = [
|
||||
{ key: 'compact', label: 'Compact', icon: <List className="size-5" /> }
|
||||
] as const
|
||||
|
||||
// Accordion item values for keyboard navigation
|
||||
const ACCORDION_ITEMS = ['general', 'appearance', 'relays', 'wallet', 'posts', 'emoji-packs', 'messaging', 'system']
|
||||
|
||||
export default function Settings() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { pubkey, nsec, ncryptsec } = useNostr()
|
||||
@@ -107,6 +111,78 @@ export default function Settings() {
|
||||
const [copiedNsec, setCopiedNsec] = useState(false)
|
||||
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
|
||||
const [openSection, setOpenSection] = useState<string>('')
|
||||
const [selectedAccordionIndex, setSelectedAccordionIndex] = useState(-1)
|
||||
const accordionRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
const { activeColumn, registerSettingsHandlers, unregisterSettingsHandlers } = useKeyboardNavigation()
|
||||
|
||||
// Get the visible accordion items based on pubkey availability
|
||||
const visibleAccordionItems = pubkey
|
||||
? ACCORDION_ITEMS
|
||||
: ACCORDION_ITEMS.filter((item) => !['wallet', 'posts', 'emoji-packs', 'messaging'].includes(item))
|
||||
|
||||
// Register keyboard handlers for settings page navigation
|
||||
useEffect(() => {
|
||||
if (activeColumn !== 1) {
|
||||
setSelectedAccordionIndex(-1)
|
||||
return
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
onUp: () => {
|
||||
setSelectedAccordionIndex((prev) => {
|
||||
const newIndex = prev <= 0 ? 0 : prev - 1
|
||||
setTimeout(() => {
|
||||
accordionRefs.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, 0)
|
||||
return newIndex
|
||||
})
|
||||
},
|
||||
onDown: () => {
|
||||
setSelectedAccordionIndex((prev) => {
|
||||
const newIndex = prev < 0 ? 0 : Math.min(prev + 1, visibleAccordionItems.length - 1)
|
||||
setTimeout(() => {
|
||||
accordionRefs.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, 0)
|
||||
return newIndex
|
||||
})
|
||||
},
|
||||
onEnter: () => {
|
||||
if (selectedAccordionIndex >= 0 && selectedAccordionIndex < visibleAccordionItems.length) {
|
||||
const value = visibleAccordionItems[selectedAccordionIndex]
|
||||
setOpenSection((prev) => (prev === value ? '' : value))
|
||||
}
|
||||
},
|
||||
onEscape: () => {
|
||||
if (openSection) {
|
||||
setOpenSection('')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
registerSettingsHandlers(handlers)
|
||||
return () => unregisterSettingsHandlers()
|
||||
}, [activeColumn, selectedAccordionIndex, openSection, visibleAccordionItems])
|
||||
|
||||
// Helper to get accordion index and check selection
|
||||
const getAccordionIndex = useCallback(
|
||||
(value: string) => visibleAccordionItems.indexOf(value),
|
||||
[visibleAccordionItems]
|
||||
)
|
||||
|
||||
const isAccordionSelected = useCallback(
|
||||
(value: string) => selectedAccordionIndex === getAccordionIndex(value),
|
||||
[selectedAccordionIndex, getAccordionIndex]
|
||||
)
|
||||
|
||||
const setAccordionRef = useCallback((value: string) => (el: HTMLDivElement | null) => {
|
||||
const idx = visibleAccordionItems.indexOf(value)
|
||||
if (idx !== -1) {
|
||||
accordionRefs.current[idx] = el
|
||||
}
|
||||
}, [visibleAccordionItems])
|
||||
|
||||
// General settings
|
||||
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
|
||||
@@ -183,13 +259,14 @@ export default function Settings() {
|
||||
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>
|
||||
<NavigableAccordionItem ref={setAccordionRef('general')} isSelected={isAccordionSelected('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">
|
||||
@@ -331,10 +408,12 @@ export default function Settings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionItem>
|
||||
</NavigableAccordionItem>
|
||||
|
||||
{/* Appearance */}
|
||||
<AccordionItem value="appearance">
|
||||
<NavigableAccordionItem ref={setAccordionRef('appearance')} isSelected={isAccordionSelected('appearance')}>
|
||||
<AccordionItem value="appearance">
|
||||
<AccordionTrigger className="px-4 hover:no-underline">
|
||||
<div className="flex items-center gap-4">
|
||||
<Palette className="size-4" />
|
||||
@@ -406,10 +485,12 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionItem>
|
||||
</NavigableAccordionItem>
|
||||
|
||||
{/* Relays */}
|
||||
<AccordionItem value="relays">
|
||||
<NavigableAccordionItem ref={setAccordionRef('relays')} isSelected={isAccordionSelected('relays')}>
|
||||
<AccordionItem value="relays">
|
||||
<AccordionTrigger className="px-4 hover:no-underline">
|
||||
<div className="flex items-center gap-4">
|
||||
<Server className="size-4" />
|
||||
@@ -430,11 +511,13 @@ export default function Settings() {
|
||||
</TabsContent>
|
||||
</RadixTabs>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionItem>
|
||||
</NavigableAccordionItem>
|
||||
|
||||
{/* Wallet */}
|
||||
{!!pubkey && (
|
||||
<AccordionItem value="wallet">
|
||||
<NavigableAccordionItem ref={setAccordionRef('wallet')} isSelected={isAccordionSelected('wallet')}>
|
||||
<AccordionItem value="wallet">
|
||||
<AccordionTrigger className="px-4 hover:no-underline">
|
||||
<div className="flex items-center gap-4">
|
||||
<Wallet className="size-4" />
|
||||
@@ -483,27 +566,31 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionItem>
|
||||
</NavigableAccordionItem>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<NavigableAccordionItem ref={setAccordionRef('posts')} isSelected={isAccordionSelected('posts')}>
|
||||
<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>
|
||||
</NavigableAccordionItem>
|
||||
)}
|
||||
|
||||
{/* Emoji Packs */}
|
||||
{!!pubkey && (
|
||||
<AccordionItem value="emoji-packs">
|
||||
<NavigableAccordionItem ref={setAccordionRef('emoji-packs')} isSelected={isAccordionSelected('emoji-packs')}>
|
||||
<AccordionItem value="emoji-packs">
|
||||
<AccordionTrigger className="px-4 hover:no-underline">
|
||||
<div className="flex items-center gap-4">
|
||||
<Smile className="size-4" />
|
||||
@@ -529,45 +616,49 @@ export default function Settings() {
|
||||
/>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionItem>
|
||||
</NavigableAccordionItem>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<NavigableAccordionItem ref={setAccordionRef('messaging')} isSelected={isAccordionSelected('messaging')}>
|
||||
<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>
|
||||
</NavigableAccordionItem>
|
||||
)}
|
||||
|
||||
{/* System */}
|
||||
<AccordionItem value="system">
|
||||
<AccordionTrigger className="px-4 hover:no-underline">
|
||||
<div className="flex items-center gap-4">
|
||||
<Cog className="size-4" />
|
||||
<NavigableAccordionItem ref={setAccordionRef('system')} isSelected={isAccordionSelected('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>
|
||||
@@ -599,7 +690,8 @@ export default function Settings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionItem>
|
||||
</NavigableAccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* Non-accordion items */}
|
||||
@@ -697,3 +789,25 @@ const OptionButton = ({
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Wrapper for keyboard-navigable accordion items
|
||||
const NavigableAccordionItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
isSelected: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
>(({ isSelected, children }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg transition-all',
|
||||
isSelected && 'ring-2 ring-primary ring-offset-2 ring-offset-background'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
NavigableAccordionItem.displayName = 'NavigableAccordionItem'
|
||||
|
||||
Reference in New Issue
Block a user