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:
woikos
2026-01-04 07:29:07 +01:00
parent 158f3d77d3
commit 4c3e8d5cc7
167 changed files with 13451 additions and 1903 deletions

View File

@@ -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'