This commit is contained in:
Nicolas Cantu 2026-01-13 14:49:19 +01:00
parent f454553a66
commit 9ad602d100
181 changed files with 11140 additions and 10498 deletions

View File

@ -1,593 +1 @@
import { useState, useCallback, useEffect, type FormEvent } from 'react' export { AuthorPresentationEditor } from './authorPresentationEditor/AuthorPresentationEditor'
import { useRouter } from 'next/router'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { ArticleField } from './ArticleField'
import { CreateAccountModal } from './CreateAccountModal'
import { RecoveryStep } from './CreateAccountModalSteps'
import { UnlockAccountModal } from './UnlockAccountModal'
import { ImageUploadField } from './ImageUploadField'
import { PresentationFormHeader } from './PresentationFormHeader'
import { extractPresentationData } from '@/lib/presentationParsing'
import type { Article } from '@/types/nostr'
import { t } from '@/lib/i18n'
import { userConfirm } from '@/lib/userConfirm'
interface AuthorPresentationDraft {
authorName: string
presentation: string
contentDescription: string
mainnetAddress: string
pictureUrl?: string
}
const ADDRESS_PATTERN = /^(1|3|bc1)[a-zA-Z0-9]{25,62}$/
function SuccessNotice({ pubkey }: { pubkey: string | null }): React.ReactElement {
return (
<div className="border border-neon-green/50 rounded-lg p-6 bg-neon-green/10">
<h3 className="text-lg font-semibold text-neon-green mb-2">{t('presentation.success')}</h3>
<p className="text-cyber-accent mb-4">
{t('presentation.successMessage')}
</p>
{pubkey && (
<div className="mt-4">
<a
href={`/author/${pubkey}`}
className="inline-block px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('presentation.manageSeries')}
</a>
</div>
)}
</div>
)
}
function ValidationError({ message }: { message: string | null }): React.ReactElement | null {
if (!message) {
return null
}
return (
<div className="bg-red-500/10 border border-red-500/50 rounded-lg p-3">
<p className="text-sm text-red-400">{message}</p>
</div>
)
}
function PresentationField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement {
return (
<ArticleField
id="presentation"
label={t('presentation.field.presentation')}
value={draft.presentation}
onChange={(value) => onChange({ ...draft, presentation: value as string })}
required
type="textarea"
rows={6}
placeholder={t('presentation.field.presentation.placeholder')}
helpText={t('presentation.field.presentation.help')}
/>
)
}
function ContentDescriptionField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement {
return (
<ArticleField
id="contentDescription"
label={t('presentation.field.contentDescription')}
value={draft.contentDescription}
onChange={(value) => onChange({ ...draft, contentDescription: value as string })}
required
type="textarea"
rows={6}
placeholder={t('presentation.field.contentDescription.placeholder')}
helpText={t('presentation.field.contentDescription.help')}
/>
)
}
function MainnetAddressField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement {
return (
<ArticleField
id="mainnetAddress"
label={t('presentation.field.mainnetAddress')}
value={draft.mainnetAddress}
onChange={(value) => onChange({ ...draft, mainnetAddress: value as string })}
required
type="text"
placeholder={t('presentation.field.mainnetAddress.placeholder')}
helpText={t('presentation.field.mainnetAddress.help')}
/>
)
}
function AuthorNameField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement {
return (
<ArticleField
id="authorName"
label={t('presentation.field.authorName')}
value={draft.authorName}
onChange={(value) => onChange({ ...draft, authorName: value as string })}
required
type="text"
placeholder={t('presentation.field.authorName.placeholder')}
helpText={t('presentation.field.authorName.help')}
/>
)
}
function PictureField({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement {
return (
<ImageUploadField
id="picture"
value={draft.pictureUrl}
onChange={(url) => onChange({ ...draft, pictureUrl: url })}
/>
)
}
const PresentationFields = ({
draft,
onChange,
}: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement => (
<div className="space-y-4">
<AuthorNameField draft={draft} onChange={onChange} />
<PictureField draft={draft} onChange={onChange} />
<PresentationField draft={draft} onChange={onChange} />
<ContentDescriptionField draft={draft} onChange={onChange} />
<MainnetAddressField draft={draft} onChange={onChange} />
</div>
)
function DeleteButton({ onDelete, deleting }: { onDelete: () => void; deleting: boolean }): React.ReactElement {
return (
<button
type="button"
onClick={onDelete}
disabled={deleting}
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-all border border-red-500/50 hover:shadow-glow-red disabled:opacity-50"
>
{deleting ? t('presentation.delete.deleting') : t('presentation.delete.button')}
</button>
)
}
type PresentationFormProps = {
draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void
validationError: string | null
error: string | null
loading: boolean
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
deleting: boolean
handleDelete: () => void
hasExistingPresentation: boolean
}
function PresentationForm(props: PresentationFormProps): React.ReactElement {
return (
<form
onSubmit={(e: FormEvent<HTMLFormElement>) => {
void props.handleSubmit(e)
}}
className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark space-y-4"
>
<PresentationFormHeader />
<PresentationFields draft={props.draft} onChange={props.setDraft} />
<ValidationError message={props.validationError ?? props.error} />
<div className="flex items-center gap-4">
<div className="flex-1">
<button
type="submit"
disabled={props.loading ?? props.deleting}
className="w-full px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50 disabled:cursor-not-allowed"
>
{getSubmitLabel(props)}
</button>
</div>
{props.hasExistingPresentation && (
<DeleteButton onDelete={() => { void props.handleDelete() }} deleting={props.deleting} />
)}
</div>
</form>
)
}
function getSubmitLabel(params: { loading: boolean; deleting: boolean; hasExistingPresentation: boolean }): string {
if (params.loading || params.deleting) {
return t('publish.publishing')
}
return params.hasExistingPresentation ? t('presentation.update.button') : t('publish.button')
}
function useAuthorPresentationState(pubkey: string | null, existingAuthorName?: string, existingPresentation?: Article | null): {
draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void
validationError: string | null
error: string | null
loading: boolean
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
deleting: boolean
handleDelete: () => Promise<void>
success: boolean
} {
const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey)
const router = useRouter()
const [draft, setDraft] = useState<AuthorPresentationDraft>(() => buildInitialDraft(existingPresentation, existingAuthorName))
const [validationError, setValidationError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
// Update authorName when profile changes
useEffect(() => {
syncAuthorNameIntoDraft({ existingAuthorName, draftAuthorName: draft.authorName, hasExistingPresentation: Boolean(existingPresentation), setDraft })
}, [existingAuthorName, existingPresentation, draft.authorName])
const handleSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
await submitPresentationDraft({ draft, setValidationError, publishPresentation })
},
[draft, publishPresentation]
)
const handleDelete = useCallback(async () => {
await deletePresentationFlow({
existingPresentationId: existingPresentation?.id,
deletePresentation,
router,
setDeleting,
setValidationError,
})
}, [existingPresentation, deletePresentation, router])
return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete }
}
function buildInitialDraft(existingPresentation: Article | null | undefined, existingAuthorName: string | undefined): AuthorPresentationDraft {
if (existingPresentation) {
const { presentation, contentDescription } = extractPresentationData(existingPresentation)
const authorName = existingPresentation.title.replace(/^Présentation de /, '') ?? existingAuthorName ?? ''
return {
authorName,
presentation,
contentDescription,
mainnetAddress: existingPresentation.mainnetAddress ?? '',
...(existingPresentation.bannerUrl ? { pictureUrl: existingPresentation.bannerUrl } : {}),
}
}
return {
authorName: existingAuthorName ?? '',
presentation: '',
contentDescription: '',
mainnetAddress: '',
}
}
function syncAuthorNameIntoDraft(params: {
existingAuthorName: string | undefined
draftAuthorName: string
hasExistingPresentation: boolean
setDraft: (updater: (prev: AuthorPresentationDraft) => AuthorPresentationDraft) => void
}): void {
if (!params.existingAuthorName || params.hasExistingPresentation || params.existingAuthorName === params.draftAuthorName) {
return
}
params.setDraft((prev) => ({ ...prev, authorName: params.existingAuthorName as string }))
}
async function submitPresentationDraft(params: {
draft: AuthorPresentationDraft
setValidationError: (value: string | null) => void
publishPresentation: (draft: AuthorPresentationDraft) => Promise<void>
}): Promise<void> {
const error = validatePresentationDraft(params.draft)
if (error) {
params.setValidationError(error)
return
}
params.setValidationError(null)
await params.publishPresentation(params.draft)
}
function validatePresentationDraft(draft: AuthorPresentationDraft): string | null {
const address = draft.mainnetAddress.trim()
if (!ADDRESS_PATTERN.test(address)) {
return t('presentation.validation.invalidAddress')
}
if (!draft.authorName.trim()) {
return t('presentation.validation.authorNameRequired')
}
return null
}
async function deletePresentationFlow(params: {
existingPresentationId: string | undefined
deletePresentation: (articleId: string) => Promise<void>
router: ReturnType<typeof useRouter>
setDeleting: (value: boolean) => void
setValidationError: (value: string | null) => void
}): Promise<void> {
if (!params.existingPresentationId) {
return
}
const confirmed = await userConfirm(t('presentation.delete.confirm'))
if (!confirmed) {
return
}
params.setDeleting(true)
params.setValidationError(null)
try {
await params.deletePresentation(params.existingPresentationId)
await params.router.push('/')
} catch (e) {
params.setValidationError(e instanceof Error ? e.message : t('presentation.delete.error'))
} finally {
params.setDeleting(false)
}
}
function NoAccountActionButtons({
onGenerate,
onImport,
}: {
onGenerate: () => void
onImport: () => void
}): React.ReactElement {
return (
<div className="flex flex-col gap-3 w-full max-w-xs">
<button
onClick={onGenerate}
className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('account.create.generateButton')}
</button>
<button
onClick={onImport}
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors"
>
{t('account.create.importButton')}
</button>
</div>
)
}
function NoAccountView(): React.ReactElement {
const [showImportModal, setShowImportModal] = useState(false)
const [showRecoveryStep, setShowRecoveryStep] = useState(false)
const [showUnlockModal, setShowUnlockModal] = useState(false)
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
const [npub, setNpub] = useState('')
const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleGenerate = (): Promise<void> => generateNoAccount({ setGenerating, setError, setRecoveryPhrase, setNpub, setShowRecoveryStep })
const handleRecoveryContinue = (): void => transitionToUnlock({ setShowRecoveryStep, setShowUnlockModal })
const handleUnlockSuccess = (): void => resetNoAccountAfterUnlock({ setShowUnlockModal, setRecoveryPhrase, setNpub })
const handleImportSuccess = (): void => {
setShowImportModal(false)
setShowUnlockModal(true)
}
return (
<NoAccountCard
error={error}
generating={generating}
onGenerate={() => { void handleGenerate() }}
onImport={() => setShowImportModal(true)}
modals={
<NoAccountModals
showImportModal={showImportModal}
onCloseImport={() => setShowImportModal(false)}
onImportSuccess={handleImportSuccess}
showRecoveryStep={showRecoveryStep}
recoveryPhrase={recoveryPhrase}
npub={npub}
onRecoveryContinue={handleRecoveryContinue}
showUnlockModal={showUnlockModal}
onUnlockSuccess={handleUnlockSuccess}
onCloseUnlock={() => setShowUnlockModal(false)}
/>
}
/>
)
}
async function generateNoAccount(params: {
setGenerating: (value: boolean) => void
setError: (value: string | null) => void
setRecoveryPhrase: (value: string[]) => void
setNpub: (value: string) => void
setShowRecoveryStep: (value: boolean) => void
}): Promise<void> {
params.setGenerating(true)
params.setError(null)
try {
const { nostrAuthService } = await import('@/lib/nostrAuth')
const result = await nostrAuthService.createAccount()
params.setRecoveryPhrase(result.recoveryPhrase)
params.setNpub(result.npub)
params.setShowRecoveryStep(true)
} catch (e) {
params.setError(e instanceof Error ? e.message : t('account.create.error.failed'))
} finally {
params.setGenerating(false)
}
}
function transitionToUnlock(params: { setShowRecoveryStep: (value: boolean) => void; setShowUnlockModal: (value: boolean) => void }): void {
params.setShowRecoveryStep(false)
params.setShowUnlockModal(true)
}
function resetNoAccountAfterUnlock(params: {
setShowUnlockModal: (value: boolean) => void
setRecoveryPhrase: (value: string[]) => void
setNpub: (value: string) => void
}): void {
params.setShowUnlockModal(false)
params.setRecoveryPhrase([])
params.setNpub('')
}
function NoAccountCard(params: {
error: string | null
generating: boolean
onGenerate: () => void
onImport: () => void
modals: React.ReactElement
}): React.ReactElement {
return (
<div className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-cyber-accent mb-2">Créez un compte ou importez votre clé secrète pour commencer</p>
{params.error && <p className="text-sm text-red-400">{params.error}</p>}
<NoAccountActionButtons onGenerate={params.onGenerate} onImport={params.onImport} />
{params.generating && <p className="text-cyber-accent text-sm">Génération du compte...</p>}
{params.modals}
</div>
</div>
)
}
function NoAccountModals(params: {
showImportModal: boolean
onImportSuccess: () => void
onCloseImport: () => void
showRecoveryStep: boolean
recoveryPhrase: string[]
npub: string
onRecoveryContinue: () => void
showUnlockModal: boolean
onUnlockSuccess: () => void
onCloseUnlock: () => void
}): React.ReactElement {
return (
<>
{params.showImportModal && <CreateAccountModal onSuccess={params.onImportSuccess} onClose={params.onCloseImport} initialStep="import" />}
{params.showRecoveryStep && <RecoveryStep recoveryPhrase={params.recoveryPhrase} npub={params.npub} onContinue={params.onRecoveryContinue} />}
{params.showUnlockModal && <UnlockAccountModal onSuccess={params.onUnlockSuccess} onClose={params.onCloseUnlock} />}
</>
)
}
function AuthorPresentationFormView(props: {
pubkey: string | null
profile: { name?: string; pubkey: string } | null
}): React.ReactElement {
const { checkPresentationExists } = useAuthorPresentation(props.pubkey)
const presentation = useExistingPresentation({ pubkey: props.pubkey, checkPresentationExists })
const state = useAuthorPresentationState(props.pubkey, props.profile?.name, presentation.existingPresentation)
if (!props.pubkey) {
return <NoAccountView />
}
if (presentation.loadingPresentation) {
return <LoadingNotice />
}
if (state.success) {
return <SuccessNotice pubkey={props.pubkey} />
}
return (
<PresentationForm
draft={state.draft}
setDraft={state.setDraft}
validationError={state.validationError}
error={state.error}
loading={state.loading}
handleSubmit={state.handleSubmit}
deleting={state.deleting}
handleDelete={() => { void state.handleDelete() }}
hasExistingPresentation={presentation.existingPresentation !== null}
/>
)
}
function LoadingNotice(): React.ReactElement {
return (
<div className="text-center py-12">
<p className="text-cyber-accent/70">{t('common.loading')}</p>
</div>
)
}
function useExistingPresentation(params: {
pubkey: string | null
checkPresentationExists: () => Promise<Article | null>
}): { existingPresentation: Article | null; loadingPresentation: boolean } {
const [existingPresentation, setExistingPresentation] = useState<Article | null>(null)
const [loadingPresentation, setLoadingPresentation] = useState(true)
const { pubkey, checkPresentationExists } = params
useEffect(() => {
void loadExistingPresentation({ pubkey, checkPresentationExists, setExistingPresentation, setLoadingPresentation })
}, [pubkey, checkPresentationExists])
return { existingPresentation, loadingPresentation }
}
async function loadExistingPresentation(params: {
pubkey: string | null
checkPresentationExists: () => Promise<Article | null>
setExistingPresentation: (value: Article | null) => void
setLoadingPresentation: (value: boolean) => void
}): Promise<void> {
if (!params.pubkey) {
params.setLoadingPresentation(false)
return
}
try {
params.setExistingPresentation(await params.checkPresentationExists())
} catch (e) {
console.error('Error loading presentation:', e)
} finally {
params.setLoadingPresentation(false)
}
}
function useAutoLoadPubkey(accountExists: boolean | null, pubkey: string | null, connect: () => Promise<void>): void {
useEffect(() => {
if (accountExists === true && !pubkey) {
void connect()
}
}, [accountExists, pubkey, connect])
}
export function AuthorPresentationEditor(): React.ReactElement {
const { pubkey, profile, accountExists, connect } = useNostrAuth()
useAutoLoadPubkey(accountExists, pubkey ?? null, connect)
return <AuthorPresentationFormView pubkey={pubkey ?? null} profile={profile} />
}

View File

@ -1,559 +1 @@
import { useState, useEffect } from 'react' export { KeyManagementManager } from './keyManagement/KeyManagementManager'
import { nostrAuthService } from '@/lib/nostrAuth'
import { keyManagementService } from '@/lib/keyManagement'
import { nip19 } from 'nostr-tools'
import { t } from '@/lib/i18n'
import { SyncProgressBar } from './SyncProgressBar'
interface PublicKeys {
publicKey: string
npub: string
}
export function KeyManagementManager(): React.ReactElement {
const [publicKeys, setPublicKeys] = useState<PublicKeys | null>(null)
const [accountExists, setAccountExists] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [importKey, setImportKey] = useState('')
const [importing, setImporting] = useState(false)
const [showImportForm, setShowImportForm] = useState(false)
const [showReplaceWarning, setShowReplaceWarning] = useState(false)
const [recoveryPhrase, setRecoveryPhrase] = useState<string[] | null>(null)
const [newNpub, setNewNpub] = useState<string | null>(null)
const [copiedNpub, setCopiedNpub] = useState(false)
const [copiedPublicKey, setCopiedPublicKey] = useState(false)
const [copiedRecoveryPhrase, setCopiedRecoveryPhrase] = useState(false)
useEffect(() => {
void loadKeys()
}, [])
async function loadKeys(): Promise<void> {
try {
setLoading(true)
setError(null)
const exists = await nostrAuthService.accountExists()
setAccountExists(exists)
if (exists) {
const keys = await keyManagementService.getPublicKeys()
if (keys) {
setPublicKeys(keys)
}
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.loading')
setError(errorMessage)
console.error('Error loading keys:', e)
} finally {
setLoading(false)
}
}
function extractKeyFromUrl(url: string): string | null {
try {
// Try to parse as URL
const urlObj = new URL(url)
// Check if it's a nostr:// URL with nsec
if (urlObj.protocol === 'nostr:' || urlObj.protocol === 'nostr://') {
const path = urlObj.pathname ?? urlObj.href.replace(/^nostr:?\/\//, '')
if (path.startsWith('nsec')) {
return path
}
}
// Check if URL contains nsec
const nsecMatch = url.match(/nsec1[a-z0-9]+/i)
if (nsecMatch) {
return nsecMatch[0]
}
return null
} catch {
return extractKeyFromText(url)
}
}
function extractKeyFromText(text: string): string {
const nsec = extractNsec(text)
if (nsec) {
return nsec
}
return text.trim()
}
function extractNsec(text: string): string | null {
const nsecMatch = text.match(/nsec1[a-z0-9]+/i)
return nsecMatch?.[0] ?? null
}
function isValidPrivateKeyFormat(key: string): boolean {
try {
const decoded = nip19.decode(key)
if (decoded.type !== 'nsec') {
return false
}
return typeof decoded.data === 'string' || decoded.data instanceof Uint8Array
} catch {
return /^[0-9a-f]{64}$/i.test(key)
}
}
async function handleImport(): Promise<void> {
if (!importKey.trim()) {
setError(t('settings.keyManagement.import.error.required'))
return
}
// Extract key from URL or text
const extractedKey = extractKeyFromUrl(importKey.trim())
if (!extractedKey) {
setError(t('settings.keyManagement.import.error.invalid'))
return
}
// Validate key format
if (!isValidPrivateKeyFormat(extractedKey)) {
setError(t('settings.keyManagement.import.error.invalid'))
return
}
// If account exists, show warning
if (accountExists) {
setShowReplaceWarning(true)
return
}
await performImport(extractedKey)
}
async function performImport(key: string): Promise<void> {
try {
setImporting(true)
setError(null)
setShowReplaceWarning(false)
// If account exists, delete it first
if (accountExists) {
await nostrAuthService.deleteAccount()
}
// Create new account with imported key
const result = await nostrAuthService.createAccount(key)
setRecoveryPhrase(result.recoveryPhrase)
setNewNpub(result.npub)
setImportKey('')
setShowImportForm(false)
await loadKeys()
// Sync user content via Service Worker
if (result.publicKey) {
const { swClient } = await import('@/lib/swClient')
const isReady = await swClient.isReady()
if (isReady) {
void swClient.startUserSync(result.publicKey)
}
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.import.error.failed')
setError(errorMessage)
console.error('Error importing key:', e)
} finally {
setImporting(false)
}
}
async function handleCopyRecoveryPhrase(): Promise<void> {
if (!recoveryPhrase) {
return
}
try {
await navigator.clipboard.writeText(recoveryPhrase.join(' '))
setCopiedRecoveryPhrase(true)
setTimeout(() => {
setCopiedRecoveryPhrase(false)
}, 2000)
} catch (e) {
console.error('Error copying recovery phrase:', e)
}
}
async function handleCopyNpub(): Promise<void> {
if (!publicKeys?.npub) {
return
}
try {
await navigator.clipboard.writeText(publicKeys.npub)
setCopiedNpub(true)
setTimeout(() => {
setCopiedNpub(false)
}, 2000)
} catch (e) {
console.error('Error copying npub:', e)
}
}
async function handleCopyPublicKey(): Promise<void> {
if (!publicKeys?.publicKey) {
return
}
try {
await navigator.clipboard.writeText(publicKeys.publicKey)
setCopiedPublicKey(true)
setTimeout(() => {
setCopiedPublicKey(false)
}, 2000)
} catch (e) {
console.error('Error copying public key:', e)
}
}
if (loading) {
return (
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<p className="text-cyber-accent">{t('settings.keyManagement.loading')}</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<h2 className="text-2xl font-bold text-neon-cyan mb-4">{t('settings.keyManagement.title')}</h2>
<KeyManagementErrorBanner error={error} />
<KeyManagementPublicKeysPanel
publicKeys={publicKeys}
copiedNpub={copiedNpub}
copiedPublicKey={copiedPublicKey}
onCopyNpub={handleCopyNpub}
onCopyPublicKey={handleCopyPublicKey}
/>
{/* Sync Progress Bar - Always show if connected, even if publicKeys not loaded yet */}
<SyncProgressBar />
<KeyManagementNoAccountBanner publicKeys={publicKeys} accountExists={accountExists} />
<KeyManagementImportButton
accountExists={accountExists}
showImportForm={showImportForm}
onClick={() => {
setShowImportForm(true)
setError(null)
}}
/>
<KeyManagementImportForm
accountExists={accountExists}
showImportForm={showImportForm}
showReplaceWarning={showReplaceWarning}
importing={importing}
importKey={importKey}
onChangeImportKey={(value) => {
setImportKey(value)
setError(null)
}}
onCancel={() => {
setShowImportForm(false)
setImportKey('')
setError(null)
}}
onImport={() => {
void handleImport()
}}
onDismissReplaceWarning={() => {
setShowReplaceWarning(false)
}}
onConfirmReplace={() => {
void performImport(extractKeyFromUrl(importKey.trim()) ?? importKey.trim())
}}
/>
{/* Recovery Phrase Display (after import) */}
<KeyManagementRecoveryPanel
recoveryPhrase={recoveryPhrase}
newNpub={newNpub}
copiedRecoveryPhrase={copiedRecoveryPhrase}
onCopyRecoveryPhrase={handleCopyRecoveryPhrase}
onDone={() => {
setRecoveryPhrase(null)
setNewNpub(null)
void loadKeys()
}}
/>
</div>
</div>
)
}
function KeyManagementErrorBanner(params: { error: string | null }): React.ReactElement | null {
if (!params.error) {
return null
}
return (
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4 mb-4">
<p className="text-red-400">{params.error}</p>
</div>
)
}
function KeyManagementPublicKeysPanel(params: {
publicKeys: PublicKeys | null
copiedNpub: boolean
copiedPublicKey: boolean
onCopyNpub: () => Promise<void>
onCopyPublicKey: () => Promise<void>
}): React.ReactElement | null {
if (!params.publicKeys) {
return null
}
return (
<div className="space-y-4 mb-6">
<KeyManagementKeyCard
label={t('settings.keyManagement.publicKey.npub')}
value={params.publicKeys.npub}
copied={params.copiedNpub}
onCopy={params.onCopyNpub}
/>
<KeyManagementKeyCard
label={t('settings.keyManagement.publicKey.hex')}
value={params.publicKeys.publicKey}
copied={params.copiedPublicKey}
onCopy={params.onCopyPublicKey}
/>
</div>
)
}
function KeyManagementKeyCard(params: {
label: string
value: string
copied: boolean
onCopy: () => Promise<void>
}): React.ReactElement {
return (
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<p className="text-neon-blue font-semibold">{params.label}</p>
<button
onClick={() => {
void params.onCopy()
}}
className="px-3 py-1 text-xs bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded transition-colors"
>
{params.copied ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
</button>
</div>
<p className="text-neon-cyan text-sm font-mono break-all">{params.value}</p>
</div>
)
}
function KeyManagementNoAccountBanner(params: {
publicKeys: PublicKeys | null
accountExists: boolean
}): React.ReactElement | null {
if (params.publicKeys || params.accountExists) {
return null
}
return (
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4 mb-6">
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.noAccount.title')}</p>
<p className="text-yellow-300/90 text-sm">{t('settings.keyManagement.noAccount.description')}</p>
</div>
)
}
function KeyManagementImportButton(params: {
accountExists: boolean
showImportForm: boolean
onClick: () => void
}): React.ReactElement | null {
if (params.showImportForm) {
return null
}
return (
<button
onClick={params.onClick}
className="w-full py-3 px-6 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{params.accountExists ? t('settings.keyManagement.import.button.replace') : t('settings.keyManagement.import.button.new')}
</button>
)
}
function KeyManagementImportForm(params: {
accountExists: boolean
showImportForm: boolean
showReplaceWarning: boolean
importing: boolean
importKey: string
onChangeImportKey: (value: string) => void
onCancel: () => void
onImport: () => void
onDismissReplaceWarning: () => void
onConfirmReplace: () => void
}): React.ReactElement | null {
if (!params.showImportForm) {
return null
}
return (
<div className="space-y-4">
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.import.warning.title')}</p>
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.description') }} />
{params.accountExists && (
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.replace') }} />
)}
</div>
<div>
<label htmlFor="importKey" className="block text-sm font-medium text-cyber-accent mb-2">
{t('settings.keyManagement.import.label')}
</label>
<textarea
id="importKey"
value={params.importKey}
onChange={(e) => {
params.onChangeImportKey(e.target.value)
}}
placeholder={t('settings.keyManagement.import.placeholder')}
className="w-full px-3 py-2 bg-cyber-dark border border-neon-cyan/30 rounded-lg font-mono text-sm text-neon-cyan focus:border-neon-cyan focus:outline-none"
rows={4}
/>
<p className="text-sm text-cyber-accent/70 mt-2">{t('settings.keyManagement.import.help')}</p>
</div>
<KeyManagementReplaceWarning
show={params.showReplaceWarning}
importing={params.importing}
onCancel={params.onDismissReplaceWarning}
onConfirm={params.onConfirmReplace}
/>
<KeyManagementImportFormActions
show={!params.showReplaceWarning}
importing={params.importing}
onCancel={params.onCancel}
onImport={params.onImport}
/>
</div>
)
}
function KeyManagementReplaceWarning(params: {
show: boolean
importing: boolean
onCancel: () => void
onConfirm: () => void
}): React.ReactElement | null {
if (!params.show) {
return null
}
return (
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4">
<p className="text-red-400 font-semibold mb-2">{t('settings.keyManagement.replace.warning.title')}</p>
<p className="text-red-300/90 text-sm mb-4">{t('settings.keyManagement.replace.warning.description')}</p>
<div className="flex gap-4">
<button
onClick={params.onCancel}
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
>
{t('settings.keyManagement.replace.cancel')}
</button>
<button
onClick={params.onConfirm}
disabled={params.importing}
className="flex-1 py-2 px-4 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg font-medium transition-all border border-red-400/50 hover:shadow-glow-red disabled:opacity-50"
>
{params.importing ? t('settings.keyManagement.replace.replacing') : t('settings.keyManagement.replace.confirm')}
</button>
</div>
</div>
)
}
function KeyManagementImportFormActions(params: {
show: boolean
importing: boolean
onCancel: () => void
onImport: () => void
}): React.ReactElement | null {
if (!params.show) {
return null
}
return (
<div className="flex gap-4">
<button
onClick={params.onCancel}
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
>
{t('settings.keyManagement.import.cancel')}
</button>
<button
onClick={params.onImport}
disabled={params.importing}
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
>
{params.importing ? t('settings.keyManagement.import.importing') : t('settings.keyManagement.import.import')}
</button>
</div>
)
}
function KeyManagementRecoveryPanel(params: {
recoveryPhrase: string[] | null
newNpub: string | null
copiedRecoveryPhrase: boolean
onCopyRecoveryPhrase: () => Promise<void>
onDone: () => void
}): React.ReactElement | null {
if (!params.recoveryPhrase || !params.newNpub) {
return null
}
return (
<div className="mt-6 space-y-4">
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.recovery.warning.title')}</p>
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part1') }} />
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part2') }} />
<p className="text-yellow-300/90 text-sm mt-2">{t('settings.keyManagement.recovery.warning.part3')}</p>
</div>
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<div className="grid grid-cols-2 gap-4 mb-4">
{params.recoveryPhrase.map((word, index) => (
<div
key={`recovery-word-${index}-${word}`}
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
>
<span className="text-cyber-accent/70 text-sm mr-2">{index + 1}.</span>
<span className="font-semibold text-neon-cyan">{word}</span>
</div>
))}
</div>
<button
onClick={() => {
void params.onCopyRecoveryPhrase()
}}
className="w-full py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg text-sm font-medium transition-colors"
>
{params.copiedRecoveryPhrase ? t('settings.keyManagement.recovery.copied') : t('settings.keyManagement.recovery.copy')}
</button>
</div>
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
<p className="text-neon-blue font-semibold mb-2">{t('settings.keyManagement.recovery.newNpub')}</p>
<p className="text-neon-cyan text-sm font-mono break-all">{params.newNpub}</p>
</div>
<button
onClick={params.onDone}
className="w-full py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('settings.keyManagement.recovery.done')}
</button>
</div>
)
}

View File

@ -1,379 +1 @@
import { useState } from 'react' export { MarkdownEditorTwoColumns } from './markdownEditorTwoColumns/MarkdownEditorTwoColumns'
import type { MediaRef, Page } from '@/types/nostr'
import { uploadNip95Media } from '@/lib/nip95'
import { t } from '@/lib/i18n'
interface MarkdownEditorTwoColumnsProps {
value: string
onChange: (value: string) => void
pages?: Page[]
onPagesChange?: (pages: Page[]) => void
onMediaAdd?: (media: MediaRef) => void
onBannerChange?: (url: string) => void
}
export function MarkdownEditorTwoColumns(props: MarkdownEditorTwoColumnsProps): React.ReactElement {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const pages = props.pages ?? []
const pagesHandlers = createPagesHandlers({ pages, onPagesChange: props.onPagesChange })
const handleImageUpload = createImageUploadHandler({
setError,
setUploading,
onMediaAdd: props.onMediaAdd,
onBannerChange: props.onBannerChange,
onSetPageImageUrl: pagesHandlers.setPageContent,
})
return (
<div className="space-y-4">
<MarkdownToolbar
onFileSelected={(file) => {
void handleImageUpload({ file })
}}
uploading={uploading}
error={error}
{...(props.onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})}
/>
<div className="grid grid-cols-2 gap-4">
<EditorColumn value={props.value} onChange={props.onChange} />
<PreviewColumn value={props.value} />
</div>
{props.onPagesChange && (
<PagesManager
pages={pages}
onPageContentChange={pagesHandlers.setPageContent}
onPageTypeChange={pagesHandlers.setPageType}
onRemovePage={pagesHandlers.removePage}
onImageUpload={async (file, pageNumber) => {
await handleImageUpload({ file, pageNumber })
}}
/>
)}
</div>
)
}
function MarkdownToolbar({
onFileSelected,
uploading,
error,
onAddPage,
}: {
onFileSelected: (file: File) => void
uploading: boolean
error: string | null
onAddPage?: (type: 'markdown' | 'image') => void
}): React.ReactElement {
return (
<div className="flex items-center gap-2 flex-wrap">
<ToolbarUploadButton onFileSelected={onFileSelected} />
<ToolbarAddPageButtons onAddPage={onAddPage} />
<ToolbarStatus uploading={uploading} error={error} />
</div>
)
}
function MarkdownPreview({ value }: { value: string }): React.ReactElement {
return (
<div className="prose max-w-none border rounded p-3 bg-white h-96 overflow-y-auto whitespace-pre-wrap">
{value || <span className="text-gray-400">{t('markdown.preview.empty')}</span>}
</div>
)
}
function PagesManager({
pages,
onPageContentChange,
onPageTypeChange,
onRemovePage,
onImageUpload,
}: {
pages: Page[]
onPageContentChange: (pageNumber: number, content: string) => void
onPageTypeChange: (pageNumber: number, type: 'markdown' | 'image') => void
onRemovePage: (pageNumber: number) => void
onImageUpload: (file: File, pageNumber: number) => Promise<void>
}): React.ReactElement {
if (pages.length === 0) {
return <div className="text-sm text-gray-500">{t('page.empty')}</div>
}
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('page.title')}</h3>
{pages.map((page) => (
<PageEditor
key={page.number}
page={page}
onContentChange={(content) => onPageContentChange(page.number, content)}
onTypeChange={(type) => onPageTypeChange(page.number, type)}
onRemove={() => onRemovePage(page.number)}
onImageUpload={async (file) => {
await onImageUpload(file, page.number)
}}
/>
))}
</div>
)
}
function PageEditor({
page,
onContentChange,
onTypeChange,
onRemove,
onImageUpload,
}: {
page: Page
onContentChange: (content: string) => void
onTypeChange: (type: 'markdown' | 'image') => void
onRemove: () => void
onImageUpload: (file: File) => Promise<void>
}): React.ReactElement {
return (
<div className="border rounded-lg p-4 space-y-3">
<PageEditorHeader page={page} onTypeChange={onTypeChange} onRemove={onRemove} />
<PageEditorBody page={page} onContentChange={onContentChange} onImageUpload={onImageUpload} />
</div>
)
}
function EditorColumn(params: { value: string; onChange: (value: string) => void }): React.ReactElement {
return (
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-800">{t('markdown.editor')}</label>
<textarea
className="w-full border rounded p-3 h-96 font-mono text-sm"
value={params.value}
onChange={(e) => params.onChange(e.target.value)}
placeholder={t('markdown.placeholder')}
/>
</div>
)
}
function PreviewColumn(params: { value: string }): React.ReactElement {
return (
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-800">{t('markdown.preview')}</label>
<MarkdownPreview value={params.value} />
</div>
)
}
function ToolbarUploadButton(params: { onFileSelected: (file: File) => void }): React.ReactElement {
return (
<label className="px-3 py-1 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700">
{t('markdown.upload.media')}
<input
type="file"
accept=".png,.jpg,.jpeg,.webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
params.onFileSelected(file)
}
}}
/>
</label>
)
}
function ToolbarAddPageButtons(params: { onAddPage: ((type: 'markdown' | 'image') => void) | undefined }): React.ReactElement | null {
if (!params.onAddPage) {
return null
}
return (
<>
<button
type="button"
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
onClick={() => params.onAddPage?.('markdown')}
>
{t('page.add.markdown')}
</button>
<button
type="button"
className="px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700"
onClick={() => params.onAddPage?.('image')}
>
{t('page.add.image')}
</button>
</>
)
}
function ToolbarStatus(params: { uploading: boolean; error: string | null }): React.ReactElement | null {
if (!params.uploading && !params.error) {
return null
}
return (
<>
{params.uploading ? <span className="text-sm text-gray-500">{t('markdown.upload.uploading')}</span> : null}
{params.error ? <span className="text-sm text-red-600">{params.error}</span> : null}
</>
)
}
function PageEditorHeader(params: {
page: Page
onTypeChange: (type: 'markdown' | 'image') => void
onRemove: () => void
}): React.ReactElement {
return (
<div className="flex items-center justify-between">
<h4 className="font-semibold">
{t('page.number', { number: params.page.number })} - {t(`page.type.${params.page.type}`)}
</h4>
<div className="flex items-center gap-2">
<select
value={params.page.type}
onChange={(e) => params.onTypeChange(e.target.value as 'markdown' | 'image')}
className="text-sm border rounded px-2 py-1"
>
<option value="markdown">{t('page.type.markdown')}</option>
<option value="image">{t('page.type.image')}</option>
</select>
<button
type="button"
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
onClick={params.onRemove}
>
{t('page.remove')}
</button>
</div>
</div>
)
}
function PageEditorBody(params: {
page: Page
onContentChange: (content: string) => void
onImageUpload: (file: File) => Promise<void>
}): React.ReactElement {
if (params.page.type === 'markdown') {
return (
<textarea
className="w-full border rounded p-2 h-48 font-mono text-sm"
value={params.page.content}
onChange={(e) => params.onContentChange(e.target.value)}
placeholder={t('page.markdown.placeholder')}
/>
)
}
return <PageEditorImageBody page={params.page} onContentChange={params.onContentChange} onImageUpload={params.onImageUpload} />
}
function PageEditorImageBody(params: {
page: Page
onContentChange: (content: string) => void
onImageUpload: (file: File) => Promise<void>
}): React.ReactElement {
return (
<div className="space-y-2">
{params.page.content ? (
<div className="relative">
<img src={params.page.content} alt={t('page.image.alt', { number: params.page.number })} className="max-w-full h-auto rounded" />
<button
type="button"
className="absolute top-2 right-2 px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
onClick={() => params.onContentChange('')}
>
{t('page.image.remove')}
</button>
</div>
) : (
<PageImageUploadButton onFileSelected={params.onImageUpload} />
)}
</div>
)
}
function PageImageUploadButton(params: { onFileSelected: (file: File) => Promise<void> }): React.ReactElement {
return (
<label className="block px-3 py-2 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700 text-center">
{t('page.image.upload')}
<input
type="file"
accept=".png,.jpg,.jpeg,.webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
void params.onFileSelected(file)
}
}}
/>
</label>
)
}
function createPagesHandlers(params: {
pages: Page[]
onPagesChange: ((pages: Page[]) => void) | undefined
}): { addPage: (type: 'markdown' | 'image') => void; setPageContent: (pageNumber: number, content: string) => void; setPageType: (pageNumber: number, type: 'markdown' | 'image') => void; removePage: (pageNumber: number) => void } {
const update = (next: Page[]): void => {
params.onPagesChange?.(next)
}
return {
addPage: (type) => {
if (!params.onPagesChange) {
return
}
const newPage: Page = { number: params.pages.length + 1, type, content: '' }
update([...params.pages, newPage])
},
setPageContent: (pageNumber, content) => {
if (!params.onPagesChange) {
return
}
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, content } : p)))
},
setPageType: (pageNumber, type) => {
if (!params.onPagesChange) {
return
}
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, type, content: '' } : p)))
},
removePage: (pageNumber) => {
if (!params.onPagesChange) {
return
}
update(params.pages.filter((p) => p.number !== pageNumber).map((p, idx) => ({ ...p, number: idx + 1 })))
},
}
}
function createImageUploadHandler(params: {
setError: (value: string | null) => void
setUploading: (value: boolean) => void
onMediaAdd: ((media: MediaRef) => void) | undefined
onBannerChange: ((url: string) => void) | undefined
onSetPageImageUrl: (pageNumber: number, url: string) => void
}): (args: { file: File; pageNumber?: number }) => Promise<void> {
return async (args): Promise<void> => {
params.setError(null)
params.setUploading(true)
try {
const media = await uploadNip95Media(args.file)
if (media.type !== 'image') {
return
}
if (args.pageNumber !== undefined) {
params.onSetPageImageUrl(args.pageNumber, media.url)
} else {
params.onBannerChange?.(media.url)
}
params.onMediaAdd?.(media)
} catch (e) {
params.setError(e instanceof Error ? e.message : t('upload.error.failed'))
} finally {
params.setUploading(false)
}
}
}

View File

@ -1,367 +1 @@
import { useState, useEffect } from 'react' export { Nip95ConfigManager } from './nip95Config/Nip95ConfigManager'
import { configStorage } from '@/lib/configStorage'
import type { Nip95Config } from '@/lib/configStorageTypes'
import { t } from '@/lib/i18n'
import { userConfirm } from '@/lib/userConfirm'
import { DragHandle } from './DragHandle'
interface Nip95ConfigManagerProps {
onConfigChange?: () => void
}
export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps): React.ReactElement {
const [apis, setApis] = useState<Nip95Config[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [newUrl, setNewUrl] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
const [draggedId, setDraggedId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
useEffect(() => {
void loadApis()
}, [])
async function loadApis(): Promise<void> {
try {
setLoading(true)
setError(null)
const config = await configStorage.getConfig()
setApis(config.nip95Apis.sort((a, b) => a.priority - b.priority))
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.loadFailed')
setError(errorMessage)
console.error('Error loading NIP-95 APIs:', e)
} finally {
setLoading(false)
}
}
async function handleToggleEnabled(id: string, enabled: boolean): Promise<void> {
try {
await configStorage.updateNip95Api(id, { enabled })
await loadApis()
onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.updateFailed')
setError(errorMessage)
console.error('Error updating NIP-95 API:', e)
}
}
async function handleUpdatePriorities(newOrder: Nip95Config[]): Promise<void> {
try {
// Update priorities based on new order (priority = index + 1, lower number = higher priority)
const updatePromises = newOrder.map((api, index) => {
const newPriority = index + 1
if (api.priority !== newPriority) {
return configStorage.updateNip95Api(api.id, { priority: newPriority })
}
return Promise.resolve()
})
await Promise.all(updatePromises)
await loadApis()
onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.priorityFailed')
setError(errorMessage)
console.error('Error updating priorities:', e)
}
}
function handleDragStart(e: React.DragEvent<HTMLDivElement>, id: string): void {
setDraggedId(id)
const { dataTransfer } = e
dataTransfer.effectAllowed = 'move'
dataTransfer.setData('text/plain', id)
}
function handleDragOver(e: React.DragEvent<HTMLDivElement>, id: string): void {
e.preventDefault()
const { dataTransfer } = e
dataTransfer.dropEffect = 'move'
setDragOverId(id)
}
function handleDragLeave(): void {
setDragOverId(null)
}
function handleDrop(e: React.DragEvent<HTMLDivElement>, targetId: string): void {
e.preventDefault()
setDragOverId(null)
if (!draggedId || draggedId === targetId) {
setDraggedId(null)
return
}
const draggedIndex = apis.findIndex((api) => api.id === draggedId)
const targetIndex = apis.findIndex((api) => api.id === targetId)
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedId(null)
return
}
// Reorder the array
const newApis = [...apis]
const removed = newApis[draggedIndex]
if (!removed) {
setDraggedId(null)
return
}
newApis.splice(draggedIndex, 1)
newApis.splice(targetIndex, 0, removed)
setApis(newApis)
setDraggedId(null)
// Update priorities based on new order
void handleUpdatePriorities(newApis)
}
async function handleUpdateUrl(id: string, url: string): Promise<void> {
try {
await configStorage.updateNip95Api(id, { url })
await loadApis()
setEditingId(null)
onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.urlFailed')
setError(errorMessage)
console.error('Error updating URL:', e)
}
}
async function handleAddApi(): Promise<void> {
if (!newUrl.trim()) {
setError(t('settings.nip95.error.urlRequired'))
return
}
try {
// Validate URL format - throws if invalid
void new URL(newUrl)
await configStorage.addNip95Api(newUrl.trim(), false)
setNewUrl('')
setShowAddForm(false)
await loadApis()
onConfigChange?.()
} catch (e) {
if (e instanceof TypeError && e.message.includes('Invalid URL')) {
setError(t('settings.nip95.error.invalidUrl'))
} else {
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.addFailed')
setError(errorMessage)
}
console.error('Error adding NIP-95 API:', e)
}
}
async function handleRemoveApi(id: string): Promise<void> {
const confirmed = await userConfirm(t('settings.nip95.remove.confirm'))
if (!confirmed) {
return
}
try {
await configStorage.removeNip95Api(id)
await loadApis()
onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.removeFailed')
setError(errorMessage)
console.error('Error removing NIP-95 API:', e)
}
}
if (loading) {
return (
<div className="text-center py-8 text-neon-cyan">
<div>{t('settings.nip95.loading')}</div>
</div>
)
}
return (
<div className="space-y-6">
{error && (
<div className="bg-red-900/30 border border-red-500/50 rounded p-4 text-red-300">
{error}
<button
onClick={() => setError(null)}
className="ml-4 text-red-400 hover:text-red-200"
>
×
</button>
</div>
)}
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.nip95.title')}</h2>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
>
{showAddForm ? t('settings.nip95.add.cancel') : t('settings.nip95.addButton')}
</button>
</div>
{showAddForm && (
<div className="bg-cyber-dark border border-neon-cyan/30 rounded p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-cyber-accent mb-2">
{t('settings.nip95.add.url')}
</label>
<input
type="url"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder={t('settings.nip95.add.placeholder')}
className="w-full px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => void handleAddApi()}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
>
{t('settings.nip95.add.add')}
</button>
<button
onClick={() => {
setShowAddForm(false)
setNewUrl('')
setError(null)
}}
className="px-4 py-2 bg-cyber-accent/20 hover:bg-cyber-accent/30 text-cyber-accent border border-cyber-accent/50 rounded transition-colors"
>
{t('settings.nip95.add.cancel')}
</button>
</div>
</div>
)}
<div className="space-y-4">
{apis.length === 0 ? (
<div className="text-center py-8 text-cyber-accent">
{t('settings.nip95.empty')}
</div>
) : (
apis.map((api, index) => (
<div
key={api.id}
onDragOver={(e) => {
handleDragOver(e, api.id)
}}
onDragLeave={handleDragLeave}
onDrop={(e) => {
handleDrop(e, api.id)
}}
className={`bg-cyber-dark border rounded p-4 space-y-3 transition-all ${getApiCardClassName(api.id, draggedId, dragOverId)}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<div
className="drag-handle cursor-grab active:cursor-grabbing"
draggable
onDragStart={(e) => {
handleDragStart(e, api.id)
e.stopPropagation()
}}
onMouseDown={(e) => {
e.stopPropagation()
}}
>
<DragHandle />
</div>
<div className="flex-1">
{editingId === api.id ? (
<div className="space-y-2">
<input
type="url"
defaultValue={api.url}
onBlur={(e) => {
if (e.target.value !== api.url) {
void handleUpdateUrl(api.id, e.target.value)
} else {
setEditingId(null)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setEditingId(null)
}
}}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/50 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
autoFocus
/>
</div>
) : (
<div
className="text-neon-cyan cursor-pointer hover:text-neon-green transition-colors"
onClick={() => setEditingId(api.id)}
title={t('settings.nip95.list.editUrl')}
>
{api.url}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={api.enabled}
onChange={(e) => void handleToggleEnabled(api.id, e.target.checked)}
className="w-4 h-4 text-neon-cyan bg-cyber-darker border-cyber-accent rounded focus:ring-neon-cyan"
/>
<span className="text-sm text-cyber-accent">
{api.enabled ? t('settings.nip95.list.enabled') : t('settings.nip95.list.disabled')}
</span>
</label>
<button
onClick={() => void handleRemoveApi(api.id)}
className="px-3 py-1 text-sm bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-500/50 rounded transition-colors"
title={t('settings.nip95.list.remove')}
>
{t('settings.nip95.list.remove')}
</button>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-cyber-accent/70">
<span>
{t('settings.nip95.list.priorityLabel', { priority: index + 1, id: api.id })}
</span>
</div>
</div>
))
)}
</div>
<div className="text-sm text-cyber-accent space-y-2">
<p>
<strong>{t('settings.nip95.note.title')}</strong> {t('settings.nip95.note.priority')}
</p>
<p>
{t('settings.nip95.note.fallback')}
</p>
</div>
</div>
)
}
function getApiCardClassName(apiId: string, draggedId: string | null, dragOverId: string | null): string {
if (draggedId === apiId) {
return 'opacity-50 border-neon-cyan'
}
if (dragOverId === apiId) {
return 'border-neon-green shadow-lg'
}
return 'border-neon-cyan/30'
}

View File

@ -1,412 +1 @@
import { useState, useEffect } from 'react' export { RelayManager } from './relayManager/RelayManager'
import { configStorage } from '@/lib/configStorage'
import type { RelayConfig } from '@/lib/configStorageTypes'
import { t } from '@/lib/i18n'
import { userConfirm } from '@/lib/userConfirm'
import { relaySessionManager } from '@/lib/relaySessionManager'
import { DragHandle } from './DragHandle'
interface RelayManagerProps {
onConfigChange?: () => void
}
export function RelayManager({ onConfigChange }: RelayManagerProps): React.ReactElement {
const [relays, setRelays] = useState<RelayConfig[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [newUrl, setNewUrl] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
const [draggedId, setDraggedId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
useEffect(() => {
void loadRelays()
}, [])
async function loadRelays(): Promise<void> {
try {
setLoading(true)
setError(null)
const config = await configStorage.getConfig()
// Get failed relays from session manager and disable them in config
const failedRelays = relaySessionManager.getFailedRelays()
if (failedRelays.length > 0) {
let hasChanges = false
for (const relayUrl of failedRelays) {
// Find the relay config by URL
const relayConfig = config.relays.find((r) => r.url === relayUrl)
if (relayConfig?.enabled) {
// Disable the failed relay
await configStorage.updateRelay(relayConfig.id, { enabled: false })
hasChanges = true
}
}
// Reload config if we made changes
if (hasChanges) {
const updatedConfig = await configStorage.getConfig()
setRelays(updatedConfig.relays.sort((a, b) => a.priority - b.priority))
} else {
setRelays(config.relays.sort((a, b) => a.priority - b.priority))
}
} else {
setRelays(config.relays.sort((a, b) => a.priority - b.priority))
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.loadFailed')
setError(errorMessage)
console.error('Error loading relays:', e)
} finally {
setLoading(false)
}
}
async function handleToggleEnabled(id: string, enabled: boolean): Promise<void> {
try {
await configStorage.updateRelay(id, { enabled })
await loadRelays()
onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.updateFailed')
setError(errorMessage)
console.error('Error updating relay:', e)
}
}
async function handleUpdatePriorities(newOrder: RelayConfig[]): Promise<void> {
try {
const updatePromises = newOrder.map((relay, index) => {
const newPriority = index + 1
if (relay.priority !== newPriority) {
return configStorage.updateRelay(relay.id, { priority: newPriority })
}
return Promise.resolve()
})
await Promise.all(updatePromises)
await loadRelays()
onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.priorityFailed')
setError(errorMessage)
console.error('Error updating priorities:', e)
}
}
function handleDragStart(e: React.DragEvent<HTMLDivElement>, id: string): void {
setDraggedId(id)
const { dataTransfer } = e
dataTransfer.effectAllowed = 'move'
dataTransfer.setData('text/plain', id)
}
function handleDragOver(e: React.DragEvent<HTMLDivElement>, id: string): void {
e.preventDefault()
const { dataTransfer } = e
dataTransfer.dropEffect = 'move'
setDragOverId(id)
}
function handleDragLeave(): void {
setDragOverId(null)
}
function handleDrop(e: React.DragEvent<HTMLDivElement>, targetId: string): void {
e.preventDefault()
setDragOverId(null)
if (!draggedId || draggedId === targetId) {
setDraggedId(null)
return
}
const draggedIndex = relays.findIndex((relay) => relay.id === draggedId)
const targetIndex = relays.findIndex((relay) => relay.id === targetId)
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedId(null)
return
}
const newRelays = [...relays]
const removed = newRelays[draggedIndex]
if (!removed) {
setDraggedId(null)
return
}
newRelays.splice(draggedIndex, 1)
newRelays.splice(targetIndex, 0, removed)
setRelays(newRelays)
setDraggedId(null)
void handleUpdatePriorities(newRelays)
}
function handleDragEnd(): void {
setDraggedId(null)
}
async function handleUpdateUrl(id: string, url: string): Promise<void> {
try {
await configStorage.updateRelay(id, { url })
await loadRelays()
setEditingId(null)
onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.urlFailed')
setError(errorMessage)
console.error('Error updating URL:', e)
}
}
async function handleAddRelay(): Promise<void> {
if (!newUrl.trim()) {
setError(t('settings.relay.error.urlRequired'))
return
}
try {
// Normalize URL (add wss:// if missing)
let normalizedUrl = newUrl.trim()
if (!normalizedUrl.startsWith('ws://') && !normalizedUrl.startsWith('wss://')) {
normalizedUrl = `wss://${normalizedUrl}`
}
// Validate URL format - throws if invalid
void new URL(normalizedUrl)
await configStorage.addRelay(normalizedUrl, true)
setNewUrl('')
setShowAddForm(false)
await loadRelays()
onConfigChange?.()
} catch (e) {
if (e instanceof TypeError && e.message.includes('Invalid URL')) {
setError(t('settings.relay.error.invalidUrl'))
} else {
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.addFailed')
setError(errorMessage)
}
console.error('Error adding relay:', e)
}
}
async function handleRemoveRelay(id: string): Promise<void> {
const confirmed = await userConfirm(t('settings.relay.remove.confirm'))
if (!confirmed) {
return
}
try {
await configStorage.removeRelay(id)
await loadRelays()
onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.removeFailed')
setError(errorMessage)
console.error('Error removing relay:', e)
}
}
if (loading) {
return (
<div className="text-center py-8 text-neon-cyan">
<div>{t('settings.relay.loading')}</div>
</div>
)
}
return (
<div className="space-y-6">
{error && (
<div className="bg-red-900/30 border border-red-500/50 rounded p-4 text-red-300">
{error}
<button
onClick={() => {
setError(null)
}}
className="ml-4 text-red-400 hover:text-red-200"
>
×
</button>
</div>
)}
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.relay.title')}</h2>
<button
onClick={() => {
setShowAddForm(!showAddForm)
}}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
>
{showAddForm ? t('settings.relay.add.cancel') : t('settings.relay.addButton')}
</button>
</div>
{showAddForm && (
<div className="bg-cyber-dark border border-neon-cyan/30 rounded p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-cyber-accent mb-2">
{t('settings.relay.add.url')}
</label>
<input
type="text"
value={newUrl}
onChange={(e) => {
setNewUrl(e.target.value)
}}
placeholder={t('settings.relay.add.placeholder')}
className="w-full px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => {
void handleAddRelay()
}}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
>
{t('settings.relay.add.add')}
</button>
<button
onClick={() => {
setShowAddForm(false)
setNewUrl('')
setError(null)
}}
className="px-4 py-2 bg-cyber-accent/20 hover:bg-cyber-accent/30 text-cyber-accent border border-cyber-accent/50 rounded transition-colors"
>
{t('settings.relay.add.cancel')}
</button>
</div>
</div>
)}
<div className="space-y-4">
{relays.length === 0 ? (
<div className="text-center py-8 text-cyber-accent">
{t('settings.relay.empty')}
</div>
) : (
relays.map((relay) => (
<div
key={relay.id}
onDragOver={(e) => {
handleDragOver(e, relay.id)
}}
onDragLeave={handleDragLeave}
onDrop={(e) => {
handleDrop(e, relay.id)
}}
className={`bg-cyber-dark border rounded p-4 space-y-3 transition-all ${
(() => {
if (draggedId === relay.id) {
return 'opacity-50 border-neon-cyan'
}
if (dragOverId === relay.id) {
return 'border-neon-green shadow-lg'
}
return 'border-neon-cyan/30'
})()
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<div
className="drag-handle cursor-grab active:cursor-grabbing"
draggable
onDragStart={(e) => {
handleDragStart(e, relay.id)
}}
onDragEnd={handleDragEnd}
onMouseDown={(e) => {
e.stopPropagation()
}}
>
<DragHandle />
</div>
<div className="flex-1">
{editingId === relay.id ? (
<div className="space-y-2">
<input
type="text"
defaultValue={relay.url}
onBlur={(e) => {
if (e.target.value !== relay.url) {
void handleUpdateUrl(relay.id, e.target.value)
} else {
setEditingId(null)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setEditingId(null)
}
}}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/50 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
autoFocus
/>
</div>
) : (
<div
className="text-neon-cyan cursor-pointer hover:text-neon-green transition-colors"
onClick={() => {
setEditingId(relay.id)
}}
title={t('settings.relay.list.editUrl')}
>
{relay.url}
</div>
)}
</div>
{relay.lastSyncDate && (
<div className="text-xs text-cyber-accent/70 mt-1">
{t('settings.relay.list.lastSync')}: {new Date(relay.lastSyncDate).toLocaleString()}
</div>
)}
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={relay.enabled}
onChange={(e) => {
void handleToggleEnabled(relay.id, e.target.checked)
}}
className="w-4 h-4 text-neon-cyan bg-cyber-darker border-cyber-accent rounded focus:ring-neon-cyan"
/>
<span className="text-sm text-cyber-accent">
{relay.enabled ? t('settings.relay.list.enabled') : t('settings.relay.list.disabled')}
</span>
</label>
<button
onClick={() => {
void handleRemoveRelay(relay.id)
}}
className="px-3 py-1 text-sm bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-500/50 rounded transition-colors"
title={t('settings.relay.list.remove')}
>
{t('settings.relay.list.remove')}
</button>
</div>
</div>
</div>
))
)}
</div>
<div className="text-sm text-cyber-accent space-y-2">
<p>
<strong>{t('settings.relay.note.title')}</strong> {t('settings.relay.note.priority')}
</p>
<p>
{t('settings.relay.note.rotation')}
</p>
</div>
</div>
)
}

View File

@ -13,190 +13,189 @@ interface SponsoringFormProps {
export function SponsoringForm({ author, onSuccess, onCancel }: SponsoringFormProps): React.ReactElement { export function SponsoringForm({ author, onSuccess, onCancel }: SponsoringFormProps): React.ReactElement {
const { pubkey, connect } = useNostrAuth() const { pubkey, connect } = useNostrAuth()
const [text, setText] = useState('') const state = useSponsoringFormState()
const [loading, setLoading] = useState(false) const onSubmit = (e: React.FormEvent): void => {
const [error, setError] = useState<string | null>(null) e.preventDefault()
const [instructions, setInstructions] = useState<{ void handleSubmit({ pubkey, author, setInstructions: state.setInstructions, setError: state.setError, setLoading: state.setLoading })
}
if (!pubkey) {
return <SponsoringConnectRequired onConnect={() => { void connect() }} />
}
if (!author.mainnetAddress) {
return <SponsoringNoAddress />
}
if (state.instructions) {
return (
<SponsoringInstructions
instructions={state.instructions}
onClose={() => {
state.setInstructions(null)
onSuccess?.()
}}
onCancel={() => {
state.setInstructions(null)
onCancel?.()
}}
/>
)
}
return (
<SponsoringFormView
text={state.text}
setText={state.setText}
loading={state.loading}
error={state.error}
onSubmit={onSubmit}
{...(onCancel ? { onCancel } : {})}
/>
)
}
async function handleSubmit(params: {
pubkey: string | null
author: AuthorPresentationArticle
setInstructions: (value: SponsoringInstructionsState) => void
setError: (value: string | null) => void
setLoading: (value: boolean) => void
}): Promise<void> {
if (!params.pubkey || !params.author.mainnetAddress) {
return
}
await submitSponsoring({
pubkey: params.pubkey,
author: params.author,
setInstructions: params.setInstructions,
setError: params.setError,
setLoading: params.setLoading,
})
}
type SponsoringInstructionsState = {
authorAddress: string authorAddress: string
platformAddress: string platformAddress: string
authorBtc: string authorBtc: string
platformBtc: string platformBtc: string
} | null>(null) }
const handleSubmit = async (e: React.FormEvent): Promise<void> => { function useSponsoringFormState(): {
e.preventDefault() text: string
if (!pubkey) { setText: (value: string) => void
await connect() loading: boolean
return setLoading: (value: boolean) => void
} error: string | null
setError: (value: string | null) => void
if (!author.mainnetAddress) { instructions: SponsoringInstructionsState | null
setError(t('sponsoring.form.error.noAddress')) setInstructions: (value: SponsoringInstructionsState | null) => void
return } {
} const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
setLoading(true) const [error, setError] = useState<string | null>(null)
setError(null) const [instructions, setInstructions] = useState<SponsoringInstructionsState | null>(null)
return { text, setText, loading, setLoading, error, setError, instructions, setInstructions }
}
async function submitSponsoring(params: {
pubkey: string
author: AuthorPresentationArticle
setInstructions: (value: SponsoringInstructionsState) => void
setError: (value: string | null) => void
setLoading: (value: boolean) => void
}): Promise<void> {
params.setLoading(true)
params.setError(null)
try { try {
const privateKey = nostrService.getPrivateKey() const privateKey = nostrService.getPrivateKey()
if (!privateKey) { if (!privateKey) {
setError(t('sponsoring.form.error.noPrivateKey')) params.setError(t('sponsoring.form.error.noPrivateKey'))
return return
} }
const result = await sponsoringPaymentService.createSponsoringPayment({ authorPubkey: params.author.pubkey, authorMainnetAddress: params.author.mainnetAddress ?? '', amount: 0.046 })
// Create sponsoring payment request
const result = await sponsoringPaymentService.createSponsoringPayment({
authorPubkey: author.pubkey,
authorMainnetAddress: author.mainnetAddress,
amount: 0.046, // Fixed amount for sponsoring
})
if (!result.success) { if (!result.success) {
setError(result.error ?? t('sponsoring.form.error.paymentFailed')) params.setError(result.error ?? t('sponsoring.form.error.paymentFailed'))
return return
} }
console.warn('Sponsoring payment info:', { authorAddress: result.authorAddress, platformAddress: result.platformAddress, authorAmount: result.split.authorSats, platformAmount: result.split.platformSats, totalAmount: result.split.totalSats })
// Note: Sponsoring is done via Bitcoin mainnet, not Lightning zap params.setInstructions({ authorAddress: result.authorAddress, platformAddress: result.platformAddress, authorBtc: (result.split.authorSats / 100_000_000).toFixed(8), platformBtc: (result.split.platformSats / 100_000_000).toFixed(8) })
// The user needs to create a Bitcoin transaction with two outputs:
// 1. Author address: result.split.authorSats
// 2. Platform address: result.split.platformSats
// After the transaction is confirmed, we can create a zap receipt for tracking
// Store payment info for later verification
// The user will need to provide the transaction ID after payment
console.warn('Sponsoring payment info:', {
authorAddress: result.authorAddress,
platformAddress: result.platformAddress,
authorAmount: result.split.authorSats,
platformAmount: result.split.platformSats,
totalAmount: result.split.totalSats,
})
// Show instructions inline (no-alert)
setInstructions({
authorAddress: result.authorAddress,
platformAddress: result.platformAddress,
authorBtc: (result.split.authorSats / 100_000_000).toFixed(8),
platformBtc: (result.split.platformSats / 100_000_000).toFixed(8),
})
setText('')
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : t('sponsoring.form.error.paymentFailed')) params.setError(submitError instanceof Error ? submitError.message : t('sponsoring.form.error.paymentFailed'))
} finally { } finally {
setLoading(false) params.setLoading(false)
}
} }
}
if (!pubkey) { function SponsoringConnectRequired(params: { onConnect: () => void }): React.ReactElement {
return ( return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark"> <div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
<p className="text-cyber-accent mb-4">{t('sponsoring.form.connectRequired')}</p> <p className="text-cyber-accent mb-4">{t('sponsoring.form.connectRequired')}</p>
<button <button onClick={params.onConnect} className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50">
onClick={() => {
void connect()
}}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"
>
{t('connect.connect')} {t('connect.connect')}
</button> </button>
</div> </div>
) )
} }
if (!author.mainnetAddress) { function SponsoringNoAddress(): React.ReactElement {
return ( return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark"> <div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
<p className="text-cyber-accent">{t('sponsoring.form.error.noAddress')}</p> <p className="text-cyber-accent">{t('sponsoring.form.error.noAddress')}</p>
</div> </div>
) )
} }
if (instructions) { function SponsoringInstructions(params: { instructions: SponsoringInstructionsState; onClose: () => void; onCancel: () => void }): React.ReactElement {
return ( return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4" role="dialog" aria-modal="true"> <div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4" role="dialog" aria-modal="true">
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3> <h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
<p className="text-sm text-cyber-accent/70"> <p className="text-sm text-cyber-accent/70">{t('sponsoring.form.instructions', { authorAddress: params.instructions.authorAddress, platformAddress: params.instructions.platformAddress, authorAmount: params.instructions.authorBtc, platformAmount: params.instructions.platformBtc })}</p>
{t('sponsoring.form.instructions', {
authorAddress: instructions.authorAddress,
platformAddress: instructions.platformAddress,
authorAmount: instructions.authorBtc,
platformAmount: instructions.platformBtc,
})}
</p>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button type="button" onClick={params.onClose} className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50">
type="button"
onClick={() => {
setInstructions(null)
onSuccess?.()
}}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"
>
{t('common.close')} {t('common.close')}
</button> </button>
<button <button type="button" onClick={params.onCancel} className="px-4 py-2 bg-neon-cyan/10 hover:bg-neon-cyan/20 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/30">
type="button"
onClick={() => {
setInstructions(null)
onCancel?.()
}}
className="px-4 py-2 bg-neon-cyan/10 hover:bg-neon-cyan/20 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/30"
>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
</div> </div>
</div> </div>
) )
} }
function SponsoringFormView(params: {
text: string
setText: (value: string) => void
loading: boolean
error: string | null
onSubmit: (e: React.FormEvent) => void
onCancel?: () => void
}): React.ReactElement {
return ( return (
<form onSubmit={(e) => { <form onSubmit={params.onSubmit} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
void handleSubmit(e)
}} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3> <h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
<p className="text-sm text-cyber-accent/70"> <p className="text-sm text-cyber-accent/70">{t('sponsoring.form.description', { amount: '0.046' })}</p>
{t('sponsoring.form.description', { amount: '0.046' })}
</p>
<div> <div>
<label htmlFor="sponsoring-text" className="block text-sm font-medium text-cyber-accent mb-1"> <label htmlFor="sponsoring-text" className="block text-sm font-medium text-cyber-accent mb-1">
{t('sponsoring.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span> {t('sponsoring.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
</label> </label>
<textarea <textarea
id="sponsoring-text" id="sponsoring-text"
value={text} value={params.text}
onChange={(e) => { onChange={(e) => params.setText(e.target.value)}
setText(e.target.value)
}}
placeholder={t('sponsoring.form.text.placeholder')} placeholder={t('sponsoring.form.text.placeholder')}
rows={3} rows={3}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none" className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
/> />
<p className="text-xs text-cyber-accent/70 mt-1">{t('sponsoring.form.text.help')}</p> <p className="text-xs text-cyber-accent/70 mt-1">{t('sponsoring.form.text.help')}</p>
</div> </div>
{params.error && <div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">{params.error}</div>}
{error && (
<div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">
{error}
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button type="submit" disabled={params.loading} className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50">
type="submit" {params.loading ? t('common.loading') : t('sponsoring.form.submit')}
disabled={loading}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
>
{loading ? t('common.loading') : t('sponsoring.form.submit')}
</button> </button>
{onCancel && ( {params.onCancel && (
<button <button type="button" onClick={params.onCancel} className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30">
type="button"
onClick={onCancel}
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
)} )}

View File

@ -1,346 +1 @@
import { useState, useEffect, useCallback } from 'react' export { SyncProgressBar } from './syncProgressBar/SyncProgressBar'
import { nostrAuthService } from '@/lib/nostrAuth'
import { getLastSyncDate, setLastSyncDate as setLastSyncDateStorage, getCurrentTimestamp, calculateDaysBetween } from '@/lib/syncStorage'
import { MIN_EVENT_DATE } from '@/lib/platformConfig'
import { objectCache } from '@/lib/objectCache'
import { t } from '@/lib/i18n'
import { useSyncProgress } from '@/lib/hooks/useSyncProgress'
export function SyncProgressBar(): React.ReactElement | null {
const [lastSyncDate, setLastSyncDate] = useState<number | null>(null)
const [totalDays, setTotalDays] = useState<number>(0)
const [isInitialized, setIsInitialized] = useState(false)
const [connectionState, setConnectionState] = useState<{ connected: boolean; pubkey: string | null }>({ connected: false, pubkey: null })
const [error, setError] = useState<string | null>(null)
const loadSyncStatus = useCallback(async (): Promise<void> => {
try {
const state = nostrAuthService.getState()
if (!state.connected || !state.pubkey) {
return
}
const storedLastSyncDate = await getLastSyncDate()
const currentTimestamp = getCurrentTimestamp()
const days = calculateDaysBetween(storedLastSyncDate, currentTimestamp)
setLastSyncDate(storedLastSyncDate)
setTotalDays(days)
} catch (loadError) {
console.error('Error loading sync status:', loadError)
}
}, [])
const { syncProgress, isSyncing, startMonitoring, stopMonitoring } = useSyncProgress({
onComplete: loadSyncStatus,
})
useEffect(() => {
// Check connection state
const checkConnection = (): void => {
const state = nostrAuthService.getState()
console.warn('[SyncProgressBar] Initial connection check:', { connected: state.connected, pubkey: state.pubkey })
setConnectionState({ connected: state.connected ?? false, pubkey: state.pubkey ?? null })
setIsInitialized(true)
}
// Initial check
checkConnection()
// Listen to connection changes
const unsubscribe = nostrAuthService.subscribe((state) => {
console.warn('[SyncProgressBar] Connection state changed:', { connected: state.connected, pubkey: state.pubkey })
setConnectionState({ connected: state.connected ?? false, pubkey: state.pubkey ?? null })
})
return () => {
unsubscribe()
}
}, [])
useEffect(() => {
console.warn('[SyncProgressBar] Effect triggered:', { isInitialized, connected: connectionState.connected, pubkey: connectionState.pubkey, isSyncing })
if (!isInitialized) {
console.warn('[SyncProgressBar] Not initialized yet')
return
}
if (!connectionState.connected) {
console.warn('[SyncProgressBar] Not connected')
return
}
if (!connectionState.pubkey) {
console.warn('[SyncProgressBar] No pubkey')
return
}
void runAutoSyncCheck({
connection: { connected: connectionState.connected, pubkey: connectionState.pubkey },
isSyncing,
loadSyncStatus,
startMonitoring,
stopMonitoring,
setError,
})
}, [isInitialized, connectionState.connected, connectionState.pubkey, isSyncing, loadSyncStatus, startMonitoring, stopMonitoring])
async function resynchronize(): Promise<void> {
try {
const state = nostrAuthService.getState()
if (!state.connected || !state.pubkey) {
return
}
// Clear cache for user content (but keep other data)
await Promise.all([
objectCache.clear('author'),
objectCache.clear('series'),
objectCache.clear('publication'),
objectCache.clear('review'),
objectCache.clear('purchase'),
objectCache.clear('sponsoring'),
objectCache.clear('review_tip'),
])
// Reset last sync date to force full resync
await setLastSyncDateStorage(MIN_EVENT_DATE)
// Reload sync status
await loadSyncStatus()
// Start full resynchronization via Service Worker
if (state.pubkey !== null) {
const { swClient } = await import('@/lib/swClient')
const isReady = await swClient.isReady()
if (isReady) {
await swClient.startUserSync(state.pubkey)
startMonitoring()
} else {
stopMonitoring()
}
}
} catch (resyncError) {
console.error('Error resynchronizing:', resyncError)
stopMonitoring()
}
}
// Don't show if not initialized or not connected
if (!isInitialized || !connectionState.connected || !connectionState.pubkey) {
return null
}
// Check if sync is recently completed (within last hour)
const isRecentlySynced = lastSyncDate !== null && lastSyncDate >= getCurrentTimestamp() - 3600
const progressPercentage = computeProgressPercentage(syncProgress)
const formatDate = (timestamp: number): string => {
const date = new Date(timestamp * 1000)
const locale = typeof window !== 'undefined' ? navigator.language : 'fr-FR'
return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
}
const getStartDate = (): number => {
if (lastSyncDate !== null) {
return lastSyncDate
}
return MIN_EVENT_DATE
}
const startDate = getStartDate()
const endDate = getCurrentTimestamp()
return (
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-4 mt-6">
<SyncErrorBanner
error={error}
onDismiss={() => {
setError(null)
}}
/>
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold text-neon-cyan">
{t('settings.sync.title')}
</h3>
<SyncResyncButton
isSyncing={isSyncing}
onClick={() => {
void resynchronize()
}}
/>
</div>
<SyncDateRange
totalDays={totalDays}
startDate={formatDate(startDate)}
endDate={formatDate(endDate)}
/>
<SyncProgressSection
isSyncing={isSyncing}
syncProgress={syncProgress}
progressPercentage={progressPercentage}
/>
<SyncStatusMessage
isSyncing={isSyncing}
totalDays={totalDays}
isRecentlySynced={isRecentlySynced}
/>
</div>
)
}
function computeProgressPercentage(syncProgress: ReturnType<typeof useSyncProgress>['syncProgress']): number {
if (!syncProgress || syncProgress.totalSteps <= 0) {
return 0
}
return Math.min(100, (syncProgress.currentStep / syncProgress.totalSteps) * 100)
}
function SyncErrorBanner(params: { error: string | null; onDismiss: () => void }): React.ReactElement | null {
if (!params.error) {
return null
}
return (
<div className="mb-4 bg-red-900/30 border border-red-500/50 rounded p-3 text-red-300 text-sm">
{params.error}
<button onClick={params.onDismiss} className="ml-2 text-red-400 hover:text-red-200">
×
</button>
</div>
)
}
function SyncResyncButton(params: { isSyncing: boolean; onClick: () => void }): React.ReactElement | null {
if (params.isSyncing) {
return null
}
return (
<button
onClick={params.onClick}
className="px-3 py-1 text-xs bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded border border-neon-cyan/50 hover:border-neon-cyan transition-colors"
>
{t('settings.sync.resync')}
</button>
)
}
function SyncDateRange(params: { totalDays: number; startDate: string; endDate: string }): React.ReactElement | null {
if (params.totalDays <= 0) {
return null
}
return (
<div className="mb-2">
<p className="text-sm text-cyber-accent">
{t('settings.sync.daysRange', {
startDate: params.startDate,
endDate: params.endDate,
days: params.totalDays,
})}
</p>
</div>
)
}
function SyncProgressSection(params: {
isSyncing: boolean
syncProgress: ReturnType<typeof useSyncProgress>['syncProgress']
progressPercentage: number
}): React.ReactElement | null {
if (!params.isSyncing || !params.syncProgress) {
return null
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-cyber-accent">
{t('settings.sync.progress', {
current: params.syncProgress.currentStep,
total: params.syncProgress.totalSteps,
})}
</span>
<span className="text-neon-cyan font-semibold">{Math.round(params.progressPercentage)}%</span>
</div>
<div className="w-full bg-cyber-dark rounded-full h-2 overflow-hidden">
<div className="bg-neon-cyan h-full transition-all duration-300" style={{ width: `${params.progressPercentage}%` }} />
</div>
</div>
)
}
function SyncStatusMessage(params: { isSyncing: boolean; totalDays: number; isRecentlySynced: boolean }): React.ReactElement | null {
if (params.isSyncing || params.totalDays !== 0) {
return null
}
if (params.isRecentlySynced) {
return <p className="text-sm text-green-400">{t('settings.sync.completed')}</p>
}
return <p className="text-sm text-cyber-accent">{t('settings.sync.ready')}</p>
}
async function runAutoSyncCheck(params: {
connection: { connected: boolean; pubkey: string | null }
isSyncing: boolean
loadSyncStatus: () => Promise<void>
startMonitoring: () => void
stopMonitoring: () => void
setError: (value: string | null) => void
}): Promise<void> {
console.warn('[SyncProgressBar] Starting sync check...')
await params.loadSyncStatus()
const shouldStart = await shouldAutoStartSync({
isSyncing: params.isSyncing,
pubkey: params.connection.pubkey,
})
if (!shouldStart || !params.connection.pubkey) {
console.warn('[SyncProgressBar] Skipping auto-sync:', { shouldStart, isSyncing: params.isSyncing, hasPubkey: Boolean(params.connection.pubkey) })
return
}
console.warn('[SyncProgressBar] Starting auto-sync...')
await startAutoSync({
pubkey: params.connection.pubkey,
startMonitoring: params.startMonitoring,
stopMonitoring: params.stopMonitoring,
setError: params.setError,
})
}
async function shouldAutoStartSync(params: { isSyncing: boolean; pubkey: string | null }): Promise<boolean> {
if (params.isSyncing || !params.pubkey) {
return false
}
const storedLastSyncDate = await getLastSyncDate()
const currentTimestamp = getCurrentTimestamp()
const isRecentlySynced = storedLastSyncDate >= currentTimestamp - 3600
console.warn('[SyncProgressBar] Sync status:', { storedLastSyncDate, currentTimestamp, isRecentlySynced })
return !isRecentlySynced
}
async function startAutoSync(params: {
pubkey: string
startMonitoring: () => void
stopMonitoring: () => void
setError: (value: string | null) => void
}): Promise<void> {
try {
const { swClient } = await import('@/lib/swClient')
const isReady = await swClient.isReady()
if (!isReady) {
params.stopMonitoring()
return
}
await swClient.startUserSync(params.pubkey)
params.startMonitoring()
} catch (autoSyncError) {
console.error('[SyncProgressBar] Error during auto-sync:', autoSyncError)
params.stopMonitoring()
params.setError(autoSyncError instanceof Error ? autoSyncError.message : 'Erreur de synchronisation')
}
}

View File

@ -1,274 +1 @@
import { useState, useRef, useMemo } from 'react' export { UnlockAccountModal } from './unlockAccount/UnlockAccountModal'
import { nostrAuthService } from '@/lib/nostrAuth'
import { getWordSuggestions } from '@/lib/keyManagementBIP39'
interface UnlockAccountModalProps {
onSuccess: () => void
onClose: () => void
}
function WordInputWithAutocomplete({
index,
value,
onChange,
onFocus,
onBlur,
}: {
index: number
value: string
onChange: (value: string) => void
onFocus: () => void
onBlur: () => void
}): React.ReactElement {
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const suggestionsRef = useRef<HTMLDivElement>(null)
const suggestions = useMemo((): string[] => {
if (value.length === 0) {
return []
}
return getWordSuggestions(value, 5)
}, [value])
const handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
const newValue = event.target.value.trim().toLowerCase()
setSelectedIndex(-1)
if (newValue.length === 0) {
setShowSuggestions(false)
}
onChange(newValue)
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : prev))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
} else if (event.key === 'Enter' && selectedIndex >= 0 && suggestions[selectedIndex]) {
event.preventDefault()
onChange(suggestions[selectedIndex] ?? '')
setShowSuggestions(false)
inputRef.current?.blur()
} else if (event.key === 'Escape') {
setShowSuggestions(false)
inputRef.current?.blur()
}
}
const handleSuggestionClick = (suggestion: string): void => {
onChange(suggestion)
setShowSuggestions(false)
inputRef.current?.blur()
}
return (
<div className="relative">
<label htmlFor={`word-${index}`} className="block text-sm font-medium text-gray-700 mb-2">
Mot {index + 1}
</label>
<input
ref={inputRef}
id={`word-${index}`}
type="text"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={onFocus}
onBlur={() => {
// Delay to allow click on suggestion
setTimeout(() => {
setShowSuggestions(false)
onBlur()
}, 200)
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-lg text-center"
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
/>
{showSuggestions && suggestions.length > 0 && (
<div
ref={suggestionsRef}
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-40 overflow-y-auto"
>
{suggestions.map((suggestion, idx) => (
<button
key={suggestion}
type="button"
onClick={() => handleSuggestionClick(suggestion)}
className={`w-full text-left px-3 py-2 hover:bg-gray-100 ${
idx === selectedIndex ? 'bg-gray-100' : ''
}`}
>
{suggestion}
</button>
))}
</div>
)}
</div>
)
}
function WordInputs({
words,
onWordChange,
}: {
words: string[]
onWordChange: (index: number, value: string) => void
}): React.ReactElement {
const [, setFocusedIndex] = useState<number | null>(null)
return (
<div className="grid grid-cols-2 gap-4">
{words.map((word, index) => (
<WordInputWithAutocomplete
key={`word-input-${index}-${word}`}
index={index}
value={word}
onChange={(value) => onWordChange(index, value)}
onFocus={() => setFocusedIndex(index)}
onBlur={() => setFocusedIndex(null)}
/>
))}
</div>
)
}
function useUnlockAccount(words: string[], setWords: (words: string[]) => void, setError: (error: string | null) => void): {
handleWordChange: (index: number, value: string) => void
handlePaste: () => void
} {
const handleWordChange = (index: number, value: string): void => {
const newWords = [...words]
newWords[index] = value.trim().toLowerCase()
setWords(newWords)
setError(null)
}
const handlePasteAsync = async (): Promise<void> => {
try {
const text = await navigator.clipboard.readText()
const pastedWords = text.trim().split(/\s+/).slice(0, 4)
if (pastedWords.length === 4) {
setWords(pastedWords.map((w) => w.toLowerCase()))
setError(null)
}
} catch {
// Ignore clipboard errors
}
}
const handlePaste = (): void => {
void handlePasteAsync()
}
return { handleWordChange, handlePaste }
}
function UnlockAccountButtons({
loading,
words,
onUnlock,
onClose,
}: {
loading: boolean
words: string[]
onUnlock: () => void
onClose: () => void
}): React.ReactElement {
return (
<div className="flex gap-4">
<button
onClick={onClose}
className="flex-1 py-2 px-4 bg-gray-200 hover:bg-gray-300 rounded-lg font-medium transition-colors"
>
Annuler
</button>
<button
onClick={() => {
void onUnlock()
}}
disabled={loading || words.some((word) => !word)}
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
>
{loading ? 'Déverrouillage...' : 'Déverrouiller'}
</button>
</div>
)
}
function UnlockAccountForm({
words,
handleWordChange,
handlePaste,
}: {
words: string[]
handleWordChange: (index: number, value: string) => void
handlePaste: () => void
}): React.ReactElement {
return (
<div className="mb-4">
<WordInputs words={words} onWordChange={handleWordChange} />
<button
type="button"
onClick={() => {
void handlePaste()
}}
className="mt-2 text-sm text-gray-600 hover:text-gray-800 underline"
>
Coller depuis le presse-papiers
</button>
</div>
)
}
export function UnlockAccountModal({ onSuccess, onClose }: UnlockAccountModalProps): React.ReactElement {
const [words, setWords] = useState(['', '', '', ''])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { handleWordChange, handlePaste } = useUnlockAccount(words, setWords, setError)
const handleUnlock = async (): Promise<void> => {
if (words.some((word) => !word)) {
setError('Veuillez remplir tous les mots-clés')
return
}
setLoading(true)
setError(null)
try {
await nostrAuthService.unlockAccount(words)
onSuccess()
onClose()
} catch (unlockError) {
setError(unlockError instanceof Error ? unlockError.message : 'Échec du déverrouillage. Vérifiez vos mots-clés.')
} finally {
setLoading(false)
}
}
const onUnlock = (): void => {
void handleUnlock()
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4">Déverrouiller votre compte</h2>
<p className="text-gray-600 mb-6">
Entrez vos 4 mots-clés de récupération (dictionnaire BIP39) pour déverrouiller votre compte.
Ces mots déchiffrent la clé de chiffrement (KEK) stockée dans l&apos;API Credentials, qui déchiffre ensuite votre clé privée.
</p>
<UnlockAccountForm words={words} handleWordChange={handleWordChange} handlePaste={handlePaste} />
{error && <p className="text-sm text-red-600 mb-4">{error}</p>}
<UnlockAccountButtons loading={loading} words={words} onUnlock={onUnlock} onClose={onClose} />
</div>
</div>
)
}

View File

@ -1,325 +1 @@
import { useMemo, useState, type Dispatch, type SetStateAction } from 'react' export { UserArticles } from './userArticles/UserArticles'
import type { Article } from '@/types/nostr'
import type { ArticleDraft } from '@/lib/articlePublisherTypes'
import { useArticleEditing } from '@/hooks/useArticleEditing'
import { UserArticlesView } from './UserArticlesList'
import { EditPanel } from './UserArticlesEditPanel'
interface UserArticlesProps {
articles: Article[]
loading: boolean
error: string | null
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
showEmptyMessage?: boolean
currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
type UserArticlesController = {
localArticles: Article[]
unlockedArticles: Set<string>
pendingDeleteId: string | null
requestDelete: (id: string) => void
handleUnlock: (article: Article) => Promise<void>
handleDelete: (article: Article) => Promise<void>
handleEditSubmit: () => Promise<void>
editingDraft: ArticleDraft | null
editingArticleId: string | null
loading: boolean
error: string | null
updateDraft: (draft: ArticleDraft) => void
startEditing: (article: Article) => Promise<void>
cancelEditing: () => void
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>
deleteArticle: (id: string) => Promise<boolean>
}
export function UserArticles({
articles,
loading,
error,
onLoadContent,
showEmptyMessage = true,
currentPubkey,
onSelectSeries,
}: UserArticlesProps): React.ReactElement {
const controller = useUserArticlesController({ articles, onLoadContent, currentPubkey })
return (
<UserArticlesLayout
controller={controller}
loading={loading}
error={error}
showEmptyMessage={showEmptyMessage ?? true}
currentPubkey={currentPubkey}
onSelectSeries={onSelectSeries}
/>
)
}
function useUserArticlesController({
articles,
onLoadContent,
currentPubkey,
}: {
articles: Article[]
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
currentPubkey: string | null
}): UserArticlesController {
const [deletedArticleIds, setDeletedArticleIds] = useState<Set<string>>(new Set())
const [articleOverridesById, setArticleOverridesById] = useState<Map<string, Article>>(new Map())
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const editingCtx = useArticleEditing(currentPubkey)
const localArticles = useMemo((): Article[] => {
return articles
.filter((a) => !deletedArticleIds.has(a.id))
.map((a) => articleOverridesById.get(a.id) ?? a)
}, [articles, articleOverridesById, deletedArticleIds])
return {
localArticles,
unlockedArticles,
pendingDeleteId,
requestDelete: (id: string) => setPendingDeleteId(id),
handleUnlock: createHandleUnlock(onLoadContent, setUnlockedArticles),
handleDelete: createHandleDelete(editingCtx.deleteArticle, setDeletedArticleIds, setPendingDeleteId),
handleEditSubmit: createHandleEditSubmit(
editingCtx.submitEdit,
editingCtx.editingDraft,
currentPubkey,
setArticleOverridesById
),
...editingCtx,
}
}
function createHandleUnlock(
onLoadContent: (id: string, pubkey: string) => Promise<Article | null>,
setUnlocked: Dispatch<SetStateAction<Set<string>>>
): (article: Article) => Promise<void> {
return async (article: Article): Promise<void> => {
const full = await onLoadContent(article.id, article.pubkey)
if (full?.paid) {
setUnlocked((prev) => new Set([...prev, article.id]))
}
}
}
function createHandleDelete(
deleteArticle: (id: string) => Promise<boolean>,
setDeletedArticleIds: Dispatch<SetStateAction<Set<string>>>,
setPendingDeleteId: Dispatch<SetStateAction<string | null>>
): (article: Article) => Promise<void> {
return async (article: Article): Promise<void> => {
const ok = await deleteArticle(article.id)
if (ok) {
setDeletedArticleIds((prev) => new Set([...prev, article.id]))
}
setPendingDeleteId(null)
}
}
function createHandleEditSubmit(
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>,
draft: ReturnType<typeof useArticleEditing>['editingDraft'],
currentPubkey: string | null,
setArticleOverridesById: Dispatch<SetStateAction<Map<string, Article>>>
): () => Promise<void> {
return async (): Promise<void> => {
const result = await submitEdit()
if (result && draft) {
const updated = buildUpdatedArticle(draft, currentPubkey ?? '', result.articleId)
setArticleOverridesById((prev) => {
const next = new Map(prev)
next.set(result.originalArticleId, { ...updated, id: result.originalArticleId })
return next
})
}
}
}
function buildUpdatedArticle(
draft: NonNullable<ReturnType<typeof useArticleEditing>['editingDraft']>,
pubkey: string,
newId: string
): Article {
const hash = newId.split('_')[0] ?? ''
const index = Number.parseInt(newId.split('_')[1] ?? '0', 10)
const version = Number.parseInt(newId.split('_')[2] ?? '0', 10)
return {
id: newId,
hash,
version,
index,
pubkey,
title: draft.title,
preview: draft.preview,
content: '',
description: draft.preview,
contentDescription: draft.preview,
createdAt: Math.floor(Date.now() / 1000),
zapAmount: draft.zapAmount,
paid: false,
thumbnailUrl: draft.bannerUrl ?? '',
...(draft.category ? { category: draft.category } : {}),
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
...(draft.media ? { media: draft.media } : {}),
...(draft.pages ? { pages: draft.pages } : {}),
}
}
function UserArticlesLayout({
controller,
loading,
error,
showEmptyMessage,
currentPubkey,
onSelectSeries,
}: {
controller: ReturnType<typeof useUserArticlesController>
loading: boolean
error: string | null
showEmptyMessage: boolean
currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}): React.ReactElement {
const { editPanelProps, listProps } = createLayoutProps(controller, {
loading,
error,
showEmptyMessage,
currentPubkey,
onSelectSeries,
})
return (
<div className="space-y-4">
<EditPanel {...editPanelProps} />
<UserArticlesView {...listProps} />
</div>
)
}
function createLayoutProps(
controller: UserArticlesController,
view: {
loading: boolean
error: string | null
showEmptyMessage: boolean
currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
): {
editPanelProps: {
draft: ArticleDraft | null
editingArticleId: string | null
loading: boolean
error: string | null
onCancel: () => void
onDraftChange: (draft: ArticleDraft) => void
onSubmit: () => Promise<void>
}
listProps: {
articles: Article[]
loading: boolean
error: string | null
showEmptyMessage: boolean
unlockedArticles: Set<string>
onUnlock: (article: Article) => void
onEdit: (article: Article) => void
onDelete: (article: Article) => void
editingArticleId: string | null
currentPubkey: string | null
pendingDeleteId: string | null
requestDelete: (articleId: string) => void
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
} {
return {
editPanelProps: buildEditPanelProps(controller),
listProps: buildListProps(controller, view),
}
}
function buildEditPanelProps(controller: UserArticlesController): {
draft: ArticleDraft | null
editingArticleId: string | null
loading: boolean
error: string | null
onCancel: () => void
onDraftChange: (draft: ArticleDraft) => void
onSubmit: () => Promise<void>
} {
return {
draft: controller.editingDraft,
editingArticleId: controller.editingArticleId,
loading: controller.loading,
error: controller.error,
onCancel: controller.cancelEditing,
onDraftChange: controller.updateDraft,
onSubmit: controller.handleEditSubmit,
}
}
type UserArticlesListProps = {
articles: Article[]
loading: boolean
error: string | null
showEmptyMessage: boolean
unlockedArticles: Set<string>
onUnlock: (article: Article) => void
onEdit: (article: Article) => void
onDelete: (article: Article) => void
editingArticleId: string | null
currentPubkey: string | null
pendingDeleteId: string | null
requestDelete: (articleId: string) => void
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
function buildListProps(
controller: UserArticlesController,
view: {
loading: boolean
error: string | null
showEmptyMessage: boolean
currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
): UserArticlesListProps {
const handlers = buildUserArticlesHandlers(controller)
return {
articles: controller.localArticles,
loading: view.loading,
error: view.error,
showEmptyMessage: view.showEmptyMessage,
unlockedArticles: controller.unlockedArticles,
onUnlock: handlers.onUnlock,
onEdit: handlers.onEdit,
onDelete: handlers.onDelete,
editingArticleId: controller.editingArticleId,
currentPubkey: view.currentPubkey,
pendingDeleteId: controller.pendingDeleteId,
requestDelete: controller.requestDelete,
...(view.onSelectSeries ? { onSelectSeries: view.onSelectSeries } : {}),
}
}
function buildUserArticlesHandlers(controller: UserArticlesController): {
onUnlock: (article: Article) => void
onEdit: (article: Article) => void
onDelete: (article: Article) => void
} {
return {
onUnlock: (a: Article): void => {
void controller.handleUnlock(a)
},
onEdit: (a: Article): void => {
void controller.startEditing(a)
},
onDelete: (a: Article): void => {
void controller.handleDelete(a)
},
}
}

View File

@ -0,0 +1,80 @@
import { useEffect } from 'react'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { t } from '@/lib/i18n'
import { NoAccountView } from './NoAccountView'
import { PresentationForm } from './PresentationForm'
import { useExistingPresentation } from './useExistingPresentation'
import { useAuthorPresentationState } from './useAuthorPresentationState'
function SuccessNotice(params: { pubkey: string | null }): React.ReactElement {
return (
<div className="border border-neon-green/50 rounded-lg p-6 bg-neon-green/10">
<h3 className="text-lg font-semibold text-neon-green mb-2">{t('presentation.success')}</h3>
<p className="text-cyber-accent mb-4">{t('presentation.successMessage')}</p>
{params.pubkey ? (
<div className="mt-4">
<a
href={`/author/${params.pubkey}`}
className="inline-block px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('presentation.manageSeries')}
</a>
</div>
) : null}
</div>
)
}
function LoadingNotice(): React.ReactElement {
return (
<div className="text-center py-12">
<p className="text-cyber-accent/70">{t('common.loading')}</p>
</div>
)
}
function AuthorPresentationFormView(props: { pubkey: string | null; profile: { name?: string; pubkey: string } | null }): React.ReactElement {
const { checkPresentationExists } = useAuthorPresentation(props.pubkey)
const presentation = useExistingPresentation({ pubkey: props.pubkey, checkPresentationExists })
const state = useAuthorPresentationState(props.pubkey, props.profile?.name, presentation.existingPresentation)
if (!props.pubkey) {
return <NoAccountView />
}
if (presentation.loadingPresentation) {
return <LoadingNotice />
}
if (state.success) {
return <SuccessNotice pubkey={props.pubkey} />
}
return (
<PresentationForm
draft={state.draft}
setDraft={state.setDraft}
validationError={state.validationError}
error={state.error}
loading={state.loading}
handleSubmit={state.handleSubmit}
deleting={state.deleting}
handleDelete={() => {
void state.handleDelete()
}}
hasExistingPresentation={presentation.existingPresentation !== null}
/>
)
}
function useAutoLoadPubkey(accountExists: boolean | null, pubkey: string | null, connect: () => Promise<void>): void {
useEffect(() => {
if (accountExists === true && !pubkey) {
void connect()
}
}, [accountExists, pubkey, connect])
}
export function AuthorPresentationEditor(): React.ReactElement {
const { pubkey, profile, accountExists, connect } = useNostrAuth()
useAutoLoadPubkey(accountExists, pubkey ?? null, connect)
return <AuthorPresentationFormView pubkey={pubkey ?? null} profile={profile} />
}

View File

@ -0,0 +1,147 @@
import { useState } from 'react'
import { CreateAccountModal } from '../CreateAccountModal'
import { RecoveryStep } from '../CreateAccountModalSteps'
import { UnlockAccountModal } from '../UnlockAccountModal'
import { t } from '@/lib/i18n'
export function NoAccountView(): React.ReactElement {
const [showImportModal, setShowImportModal] = useState(false)
const [showRecoveryStep, setShowRecoveryStep] = useState(false)
const [showUnlockModal, setShowUnlockModal] = useState(false)
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
const [npub, setNpub] = useState('')
const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleGenerate = (): void => {
void generateNoAccount({ setGenerating, setError, setRecoveryPhrase, setNpub, setShowRecoveryStep })
}
const handleRecoveryContinue = (): void => transitionToUnlock({ setShowRecoveryStep, setShowUnlockModal })
const handleUnlockSuccess = (): void => resetNoAccountAfterUnlock({ setShowUnlockModal, setRecoveryPhrase, setNpub })
const handleImportSuccess = (): void => {
setShowImportModal(false)
setShowUnlockModal(true)
}
return (
<NoAccountCard
error={error}
generating={generating}
onGenerate={handleGenerate}
onImport={() => setShowImportModal(true)}
modals={
<NoAccountModals
showImportModal={showImportModal}
onCloseImport={() => setShowImportModal(false)}
onImportSuccess={handleImportSuccess}
showRecoveryStep={showRecoveryStep}
recoveryPhrase={recoveryPhrase}
npub={npub}
onRecoveryContinue={handleRecoveryContinue}
showUnlockModal={showUnlockModal}
onUnlockSuccess={handleUnlockSuccess}
onCloseUnlock={() => setShowUnlockModal(false)}
/>
}
/>
)
}
function NoAccountActionButtons(params: { onGenerate: () => void; onImport: () => void }): React.ReactElement {
return (
<div className="flex flex-col gap-3 w-full max-w-xs">
<button
type="button"
onClick={params.onGenerate}
className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('account.create.generateButton')}
</button>
<button
type="button"
onClick={params.onImport}
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors"
>
{t('account.create.importButton')}
</button>
</div>
)
}
function NoAccountCard(params: {
error: string | null
generating: boolean
onGenerate: () => void
onImport: () => void
modals: React.ReactElement
}): React.ReactElement {
return (
<div className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-cyber-accent mb-2">Créez un compte ou importez votre clé secrète pour commencer</p>
{params.error ? <p className="text-sm text-red-400">{params.error}</p> : null}
<NoAccountActionButtons onGenerate={params.onGenerate} onImport={params.onImport} />
{params.generating ? <p className="text-cyber-accent text-sm">Génération du compte...</p> : null}
{params.modals}
</div>
</div>
)
}
function NoAccountModals(params: {
showImportModal: boolean
onImportSuccess: () => void
onCloseImport: () => void
showRecoveryStep: boolean
recoveryPhrase: string[]
npub: string
onRecoveryContinue: () => void
showUnlockModal: boolean
onUnlockSuccess: () => void
onCloseUnlock: () => void
}): React.ReactElement {
return (
<>
{params.showImportModal ? <CreateAccountModal onSuccess={params.onImportSuccess} onClose={params.onCloseImport} initialStep="import" /> : null}
{params.showRecoveryStep ? <RecoveryStep recoveryPhrase={params.recoveryPhrase} npub={params.npub} onContinue={params.onRecoveryContinue} /> : null}
{params.showUnlockModal ? <UnlockAccountModal onSuccess={params.onUnlockSuccess} onClose={params.onCloseUnlock} /> : null}
</>
)
}
async function generateNoAccount(params: {
setGenerating: (value: boolean) => void
setError: (value: string | null) => void
setRecoveryPhrase: (value: string[]) => void
setNpub: (value: string) => void
setShowRecoveryStep: (value: boolean) => void
}): Promise<void> {
params.setGenerating(true)
params.setError(null)
try {
const { nostrAuthService } = await import('@/lib/nostrAuth')
const result = await nostrAuthService.createAccount()
params.setRecoveryPhrase(result.recoveryPhrase)
params.setNpub(result.npub)
params.setShowRecoveryStep(true)
} catch (e) {
params.setError(e instanceof Error ? e.message : t('account.create.error.failed'))
} finally {
params.setGenerating(false)
}
}
function transitionToUnlock(params: { setShowRecoveryStep: (value: boolean) => void; setShowUnlockModal: (value: boolean) => void }): void {
params.setShowRecoveryStep(false)
params.setShowUnlockModal(true)
}
function resetNoAccountAfterUnlock(params: {
setShowUnlockModal: (value: boolean) => void
setRecoveryPhrase: (value: string[]) => void
setNpub: (value: string) => void
}): void {
params.setShowUnlockModal(false)
params.setRecoveryPhrase([])
params.setNpub('')
}

View File

@ -0,0 +1,75 @@
import type { FormEvent } from 'react'
import { PresentationFormHeader } from '../PresentationFormHeader'
import { t } from '@/lib/i18n'
import { PresentationFields } from './fields'
import type { AuthorPresentationDraft } from './types'
export interface PresentationFormProps {
draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void
validationError: string | null
error: string | null
loading: boolean
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
deleting: boolean
handleDelete: () => void
hasExistingPresentation: boolean
}
export function PresentationForm(props: PresentationFormProps): React.ReactElement {
return (
<form
onSubmit={(e: FormEvent<HTMLFormElement>) => {
void props.handleSubmit(e)
}}
className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark space-y-4"
>
<PresentationFormHeader />
<PresentationFields draft={props.draft} onChange={props.setDraft} />
<ValidationError message={props.validationError ?? props.error} />
<div className="flex items-center gap-4">
<div className="flex-1">
<button
type="submit"
disabled={props.loading || props.deleting}
className="w-full px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50 disabled:cursor-not-allowed"
>
{getSubmitLabel({ loading: props.loading, deleting: props.deleting, hasExistingPresentation: props.hasExistingPresentation })}
</button>
</div>
{props.hasExistingPresentation ? <DeleteButton onDelete={props.handleDelete} deleting={props.deleting} /> : null}
</div>
</form>
)
}
function ValidationError(params: { message: string | null }): React.ReactElement | null {
if (!params.message) {
return null
}
return (
<div className="bg-red-500/10 border border-red-500/50 rounded-lg p-3">
<p className="text-sm text-red-400">{params.message}</p>
</div>
)
}
function DeleteButton(params: { onDelete: () => void; deleting: boolean }): React.ReactElement {
return (
<button
type="button"
onClick={params.onDelete}
disabled={params.deleting}
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-all border border-red-500/50 hover:shadow-glow-red disabled:opacity-50"
>
{params.deleting ? t('presentation.delete.deleting') : t('presentation.delete.button')}
</button>
)
}
function getSubmitLabel(params: { loading: boolean; deleting: boolean; hasExistingPresentation: boolean }): string {
if (params.loading || params.deleting) {
return t('publish.publishing')
}
return params.hasExistingPresentation ? t('presentation.update.button') : t('publish.button')
}

View File

@ -0,0 +1,88 @@
import { ArticleField } from '../ArticleField'
import { ImageUploadField } from '../ImageUploadField'
import { t } from '@/lib/i18n'
import type { AuthorPresentationDraft } from './types'
export function PresentationFields(params: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement {
return (
<div className="space-y-4">
<AuthorNameField draft={params.draft} onChange={params.onChange} />
<PictureField draft={params.draft} onChange={params.onChange} />
<PresentationField draft={params.draft} onChange={params.onChange} />
<ContentDescriptionField draft={params.draft} onChange={params.onChange} />
<MainnetAddressField draft={params.draft} onChange={params.onChange} />
</div>
)
}
function PresentationField(params: { draft: AuthorPresentationDraft; onChange: (next: AuthorPresentationDraft) => void }): React.ReactElement {
return (
<ArticleField
id="presentation"
label={t('presentation.field.presentation')}
value={params.draft.presentation}
onChange={(value) => params.onChange({ ...params.draft, presentation: value as string })}
required
type="textarea"
rows={6}
placeholder={t('presentation.field.presentation.placeholder')}
helpText={t('presentation.field.presentation.help')}
/>
)
}
function ContentDescriptionField(params: {
draft: AuthorPresentationDraft
onChange: (next: AuthorPresentationDraft) => void
}): React.ReactElement {
return (
<ArticleField
id="contentDescription"
label={t('presentation.field.contentDescription')}
value={params.draft.contentDescription}
onChange={(value) => params.onChange({ ...params.draft, contentDescription: value as string })}
required
type="textarea"
rows={6}
placeholder={t('presentation.field.contentDescription.placeholder')}
helpText={t('presentation.field.contentDescription.help')}
/>
)
}
function MainnetAddressField(params: { draft: AuthorPresentationDraft; onChange: (next: AuthorPresentationDraft) => void }): React.ReactElement {
return (
<ArticleField
id="mainnetAddress"
label={t('presentation.field.mainnetAddress')}
value={params.draft.mainnetAddress}
onChange={(value) => params.onChange({ ...params.draft, mainnetAddress: value as string })}
required
type="text"
placeholder={t('presentation.field.mainnetAddress.placeholder')}
helpText={t('presentation.field.mainnetAddress.help')}
/>
)
}
function AuthorNameField(params: { draft: AuthorPresentationDraft; onChange: (next: AuthorPresentationDraft) => void }): React.ReactElement {
return (
<ArticleField
id="authorName"
label={t('presentation.field.authorName')}
value={params.draft.authorName}
onChange={(value) => params.onChange({ ...params.draft, authorName: value as string })}
required
type="text"
placeholder={t('presentation.field.authorName.placeholder')}
helpText={t('presentation.field.authorName.help')}
/>
)
}
function PictureField(params: { draft: AuthorPresentationDraft; onChange: (next: AuthorPresentationDraft) => void }): React.ReactElement {
return <ImageUploadField id="picture" value={params.draft.pictureUrl} onChange={(url) => params.onChange({ ...params.draft, pictureUrl: url })} />
}

View File

@ -0,0 +1,7 @@
export interface AuthorPresentationDraft {
authorName: string
presentation: string
contentDescription: string
mainnetAddress: string
pictureUrl?: string
}

View File

@ -0,0 +1,124 @@
import { useCallback, useEffect, useState, type Dispatch, type FormEvent, type SetStateAction } from 'react'
import { useRouter } from 'next/router'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { extractPresentationData } from '@/lib/presentationParsing'
import { userConfirm } from '@/lib/userConfirm'
import { t } from '@/lib/i18n'
import type { Article } from '@/types/nostr'
import type { AuthorPresentationDraft } from './types'
import { validatePresentationDraft } from './validation'
export interface AuthorPresentationState {
draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void
validationError: string | null
error: string | null
loading: boolean
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
deleting: boolean
handleDelete: () => Promise<void>
success: boolean
}
export function useAuthorPresentationState(
pubkey: string | null,
existingAuthorName: string | undefined,
existingPresentation: Article | null | undefined,
): AuthorPresentationState {
const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey)
const router = useRouter()
const [draft, setDraft] = useState<AuthorPresentationDraft>(() => buildInitialDraft(existingPresentation, existingAuthorName))
const [validationError, setValidationError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
useEffect(() => {
syncAuthorNameIntoDraft({ existingAuthorName, draftAuthorName: draft.authorName, hasExistingPresentation: Boolean(existingPresentation), setDraft })
}, [existingAuthorName, existingPresentation, draft.authorName])
const handleSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
await submitPresentationDraft({ draft, setValidationError, publishPresentation })
},
[draft, publishPresentation],
)
const handleDelete = useCallback(async () => {
await deletePresentationFlow({
existingPresentationId: existingPresentation?.id,
deletePresentation,
router,
setDeleting,
setValidationError,
})
}, [existingPresentation, deletePresentation, router])
return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete }
}
function buildInitialDraft(existingPresentation: Article | null | undefined, existingAuthorName: string | undefined): AuthorPresentationDraft {
if (existingPresentation) {
const { presentation, contentDescription } = extractPresentationData(existingPresentation)
const authorName = existingPresentation.title.replace(/^Présentation de /, '') ?? existingAuthorName ?? ''
return {
authorName,
presentation,
contentDescription,
mainnetAddress: existingPresentation.mainnetAddress ?? '',
...(existingPresentation.bannerUrl ? { pictureUrl: existingPresentation.bannerUrl } : {}),
}
}
return { authorName: existingAuthorName ?? '', presentation: '', contentDescription: '', mainnetAddress: '' }
}
function syncAuthorNameIntoDraft(params: {
existingAuthorName: string | undefined
draftAuthorName: string
hasExistingPresentation: boolean
setDraft: Dispatch<SetStateAction<AuthorPresentationDraft>>
}): void {
if (!params.existingAuthorName || params.hasExistingPresentation || params.existingAuthorName === params.draftAuthorName) {
return
}
params.setDraft((prev) => ({ ...prev, authorName: params.existingAuthorName as string }))
}
async function submitPresentationDraft(params: {
draft: AuthorPresentationDraft
setValidationError: (value: string | null) => void
publishPresentation: (draft: AuthorPresentationDraft) => Promise<void>
}): Promise<void> {
const validationError = validatePresentationDraft(params.draft)
if (validationError) {
params.setValidationError(validationError)
return
}
params.setValidationError(null)
await params.publishPresentation(params.draft)
}
async function deletePresentationFlow(params: {
existingPresentationId: string | undefined
deletePresentation: (articleId: string) => Promise<void>
router: ReturnType<typeof useRouter>
setDeleting: (value: boolean) => void
setValidationError: (value: string | null) => void
}): Promise<void> {
if (!params.existingPresentationId) {
return
}
const confirmed = await userConfirm(t('presentation.delete.confirm'))
if (!confirmed) {
return
}
params.setDeleting(true)
params.setValidationError(null)
try {
await params.deletePresentation(params.existingPresentationId)
await params.router.push('/')
} catch (e) {
params.setValidationError(e instanceof Error ? e.message : t('presentation.delete.error'))
} finally {
params.setDeleting(false)
}
}

View File

@ -0,0 +1,40 @@
import { useEffect, useState } from 'react'
import type { Article } from '@/types/nostr'
export function useExistingPresentation(params: {
pubkey: string | null
checkPresentationExists: () => Promise<Article | null>
}): { existingPresentation: Article | null; loadingPresentation: boolean } {
const [existingPresentation, setExistingPresentation] = useState<Article | null>(null)
const [loadingPresentation, setLoadingPresentation] = useState(true)
useEffect(() => {
void loadExistingPresentation({
pubkey: params.pubkey,
checkPresentationExists: params.checkPresentationExists,
setExistingPresentation,
setLoadingPresentation,
})
}, [params.pubkey, params.checkPresentationExists])
return { existingPresentation, loadingPresentation }
}
async function loadExistingPresentation(params: {
pubkey: string | null
checkPresentationExists: () => Promise<Article | null>
setExistingPresentation: (value: Article | null) => void
setLoadingPresentation: (value: boolean) => void
}): Promise<void> {
if (!params.pubkey) {
params.setLoadingPresentation(false)
return
}
try {
params.setExistingPresentation(await params.checkPresentationExists())
} catch (e) {
console.error('[AuthorPresentationEditor] Error loading presentation:', e)
} finally {
params.setLoadingPresentation(false)
}
}

View File

@ -0,0 +1,15 @@
import { t } from '@/lib/i18n'
import type { AuthorPresentationDraft } from './types'
const ADDRESS_PATTERN = /^(1|3|bc1)[a-zA-Z0-9]{25,62}$/
export function validatePresentationDraft(draft: AuthorPresentationDraft): string | null {
const address = draft.mainnetAddress.trim()
if (!ADDRESS_PATTERN.test(address)) {
return t('presentation.validation.invalidAddress')
}
if (!draft.authorName.trim()) {
return t('presentation.validation.authorNameRequired')
}
return null
}

View File

@ -0,0 +1,130 @@
import { t } from '@/lib/i18n'
import type { KeyManagementManagerActions } from './useKeyManagementManager'
import type { KeyManagementManagerState } from './keyManagementController'
export function KeyManagementImportForm(params: {
state: KeyManagementManagerState
actions: KeyManagementManagerActions
}): React.ReactElement | null {
if (!params.state.showImportForm) {
return null
}
return (
<div className="space-y-4">
<KeyManagementImportWarning accountExists={params.state.accountExists} />
<KeyManagementImportTextarea importKey={params.state.importKey} onChangeImportKey={params.actions.onChangeImportKey} />
<KeyManagementReplaceWarning
show={params.state.showReplaceWarning}
importing={params.state.importing}
onCancel={params.actions.onDismissReplaceWarning}
onConfirm={params.actions.onConfirmReplace}
/>
<KeyManagementImportFormActions
show={!params.state.showReplaceWarning}
importing={params.state.importing}
onCancel={params.actions.onCancelImport}
onImport={params.actions.onImport}
/>
</div>
)
}
function KeyManagementImportWarning(params: { accountExists: boolean }): React.ReactElement {
return (
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.import.warning.title')}</p>
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.description') }} />
{params.accountExists ? (
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.replace') }} />
) : null}
</div>
)
}
function KeyManagementImportTextarea(params: {
importKey: string
onChangeImportKey: (value: string) => void
}): React.ReactElement {
return (
<div>
<label htmlFor="importKey" className="block text-sm font-medium text-cyber-accent mb-2">
{t('settings.keyManagement.import.label')}
</label>
<textarea
id="importKey"
value={params.importKey}
onChange={(e) => {
params.onChangeImportKey(e.target.value)
}}
placeholder={t('settings.keyManagement.import.placeholder')}
className="w-full px-3 py-2 bg-cyber-dark border border-neon-cyan/30 rounded-lg font-mono text-sm text-neon-cyan focus:border-neon-cyan focus:outline-none"
rows={4}
/>
<p className="text-sm text-cyber-accent/70 mt-2">{t('settings.keyManagement.import.help')}</p>
</div>
)
}
function KeyManagementReplaceWarning(params: {
show: boolean
importing: boolean
onCancel: () => void
onConfirm: () => void
}): React.ReactElement | null {
if (!params.show) {
return null
}
return (
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4">
<p className="text-red-400 font-semibold mb-2">{t('settings.keyManagement.replace.warning.title')}</p>
<p className="text-red-300/90 text-sm mb-4">{t('settings.keyManagement.replace.warning.description')}</p>
<div className="flex gap-4">
<button
type="button"
onClick={params.onCancel}
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
>
{t('settings.keyManagement.replace.cancel')}
</button>
<button
type="button"
onClick={params.onConfirm}
disabled={params.importing}
className="flex-1 py-2 px-4 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg font-medium transition-all border border-red-400/50 hover:shadow-glow-red disabled:opacity-50"
>
{params.importing ? t('settings.keyManagement.replace.replacing') : t('settings.keyManagement.replace.confirm')}
</button>
</div>
</div>
)
}
function KeyManagementImportFormActions(params: {
show: boolean
importing: boolean
onCancel: () => void
onImport: () => void
}): React.ReactElement | null {
if (!params.show) {
return null
}
return (
<div className="flex gap-4">
<button
type="button"
onClick={params.onCancel}
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
>
{t('settings.keyManagement.import.cancel')}
</button>
<button
type="button"
onClick={params.onImport}
disabled={params.importing}
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
>
{params.importing ? t('settings.keyManagement.import.importing') : t('settings.keyManagement.import.import')}
</button>
</div>
)
}

View File

@ -0,0 +1,125 @@
import { t } from '@/lib/i18n'
import type { KeyManagementManagerActions } from './useKeyManagementManager'
import type { KeyManagementManagerState } from './keyManagementController'
import { KeyManagementImportForm } from './KeyManagementImportForm'
export function KeyManagementImportSection(params: {
state: KeyManagementManagerState
actions: KeyManagementManagerActions
}): React.ReactElement {
return (
<>
<KeyManagementErrorBanner error={params.state.error} />
<KeyManagementPublicKeysPanel
publicKeys={params.state.publicKeys}
copiedNpub={params.state.copiedNpub}
copiedPublicKey={params.state.copiedPublicKey}
onCopyNpub={params.actions.onCopyNpub}
onCopyPublicKey={params.actions.onCopyPublicKey}
/>
<KeyManagementNoAccountBanner publicKeys={params.state.publicKeys} accountExists={params.state.accountExists} />
<KeyManagementImportButton
accountExists={params.state.accountExists}
showImportForm={params.state.showImportForm}
onClick={params.actions.onShowImportForm}
/>
<KeyManagementImportForm state={params.state} actions={params.actions} />
</>
)
}
function KeyManagementErrorBanner(params: { error: string | null }): React.ReactElement | null {
if (!params.error) {
return null
}
return (
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4 mb-4">
<p className="text-red-400">{params.error}</p>
</div>
)
}
function KeyManagementPublicKeysPanel(params: {
publicKeys: KeyManagementManagerState['publicKeys']
copiedNpub: boolean
copiedPublicKey: boolean
onCopyNpub: () => void
onCopyPublicKey: () => void
}): React.ReactElement | null {
if (!params.publicKeys) {
return null
}
return (
<div className="space-y-4 mb-6">
<KeyManagementKeyCard
label={t('settings.keyManagement.publicKey.npub')}
value={params.publicKeys.npub}
copied={params.copiedNpub}
onCopy={params.onCopyNpub}
/>
<KeyManagementKeyCard
label={t('settings.keyManagement.publicKey.hex')}
value={params.publicKeys.publicKey}
copied={params.copiedPublicKey}
onCopy={params.onCopyPublicKey}
/>
</div>
)
}
function KeyManagementKeyCard(params: {
label: string
value: string
copied: boolean
onCopy: () => void
}): React.ReactElement {
return (
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<p className="text-neon-blue font-semibold">{params.label}</p>
<button
type="button"
onClick={params.onCopy}
className="px-3 py-1 text-xs bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded transition-colors"
>
{params.copied ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
</button>
</div>
<p className="text-neon-cyan text-sm font-mono break-all">{params.value}</p>
</div>
)
}
function KeyManagementNoAccountBanner(params: {
publicKeys: KeyManagementManagerState['publicKeys']
accountExists: boolean
}): React.ReactElement | null {
if (params.publicKeys || params.accountExists) {
return null
}
return (
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4 mb-6">
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.noAccount.title')}</p>
<p className="text-yellow-300/90 text-sm">{t('settings.keyManagement.noAccount.description')}</p>
</div>
)
}
function KeyManagementImportButton(params: {
accountExists: boolean
showImportForm: boolean
onClick: () => void
}): React.ReactElement | null {
if (params.showImportForm) {
return null
}
return (
<button
type="button"
onClick={params.onClick}
className="w-full py-3 px-6 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{params.accountExists ? t('settings.keyManagement.import.button.replace') : t('settings.keyManagement.import.button.new')}
</button>
)
}

View File

@ -0,0 +1,28 @@
import { t } from '@/lib/i18n'
import { SyncProgressBar } from '../SyncProgressBar'
import { KeyManagementImportSection } from './KeyManagementImportSection'
import { KeyManagementRecoverySection } from './KeyManagementRecoverySection'
import { useKeyManagementManager } from './useKeyManagementManager'
export function KeyManagementManager(): React.ReactElement {
const { state, actions } = useKeyManagementManager()
if (state.loading) {
return (
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<p className="text-cyber-accent">{t('settings.keyManagement.loading')}</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<h2 className="text-2xl font-bold text-neon-cyan mb-4">{t('settings.keyManagement.title')}</h2>
<KeyManagementImportSection state={state} actions={actions} />
<SyncProgressBar />
<KeyManagementRecoverySection state={state} actions={actions} />
</div>
</div>
)
}

View File

@ -0,0 +1,98 @@
import { t } from '@/lib/i18n'
import type { KeyManagementManagerActions } from './useKeyManagementManager'
import type { KeyManagementManagerState } from './keyManagementController'
export function KeyManagementRecoverySection(params: {
state: KeyManagementManagerState
actions: KeyManagementManagerActions
}): React.ReactElement | null {
if (!params.state.recoveryPhrase || !params.state.newNpub) {
return null
}
return (
<div className="mt-6 space-y-4">
<KeyManagementRecoveryWarning />
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<RecoveryWordsGrid recoveryPhrase={params.state.recoveryPhrase} />
<button
type="button"
onClick={params.actions.onCopyRecoveryPhrase}
className="w-full py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg text-sm font-medium transition-colors"
>
{params.state.copiedRecoveryPhrase ? t('settings.keyManagement.recovery.copied') : t('settings.keyManagement.recovery.copy')}
</button>
</div>
<KeyManagementNewNpubCard newNpub={params.state.newNpub} />
<KeyManagementDoneButton onDone={params.actions.onDoneRecovery} />
</div>
)
}
function KeyManagementRecoveryWarning(): React.ReactElement {
return (
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.recovery.warning.title')}</p>
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part1') }} />
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part2') }} />
<p className="text-yellow-300/90 text-sm mt-2">{t('settings.keyManagement.recovery.warning.part3')}</p>
</div>
)
}
function KeyManagementNewNpubCard(params: { newNpub: string }): React.ReactElement {
return (
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
<p className="text-neon-blue font-semibold mb-2">{t('settings.keyManagement.recovery.newNpub')}</p>
<p className="text-neon-cyan text-sm font-mono break-all">{params.newNpub}</p>
</div>
)
}
function KeyManagementDoneButton(params: { onDone: () => void }): React.ReactElement {
return (
<button
type="button"
onClick={params.onDone}
className="w-full py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('settings.keyManagement.recovery.done')}
</button>
)
}
function RecoveryWordsGrid(params: { recoveryPhrase: string[] }): React.ReactElement {
const items = buildRecoveryWordItems(params.recoveryPhrase)
return (
<div className="grid grid-cols-2 gap-4 mb-4">
{items.map((item) => (
<div
key={item.id}
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
>
<span className="text-cyber-accent/70 text-sm mr-2">{item.position}.</span>
<span className="font-semibold text-neon-cyan">{item.word}</span>
</div>
))}
</div>
)
}
interface RecoveryWordItem {
id: string
position: number
word: string
}
function buildRecoveryWordItems(recoveryPhrase: readonly string[]): RecoveryWordItem[] {
const occurrences = new Map<string, number>()
const items: RecoveryWordItem[] = []
let position = 1
for (const word of recoveryPhrase) {
const current = occurrences.get(word) ?? 0
const next = current + 1
occurrences.set(word, next)
items.push({ id: `${word}-${next}`, position, word })
position += 1
}
return items
}

View File

@ -0,0 +1,53 @@
import { nip19 } from 'nostr-tools'
export function extractKeyFromInput(rawInput: string): string | null {
const trimmed = rawInput.trim()
if (trimmed.length === 0) {
return null
}
const fromUrl = extractKeyFromUrl(trimmed)
return fromUrl ?? extractKeyFromText(trimmed)
}
function extractKeyFromUrl(url: string): string | null {
try {
const urlObj = new URL(url)
const {protocol} = urlObj
if (protocol === 'nostr:' || protocol === 'nostr://') {
const path = urlObj.pathname ?? urlObj.href.replace(/^nostr:?\/\//, '')
if (path.startsWith('nsec')) {
return path
}
}
const nsecMatch = url.match(/nsec1[a-z0-9]+/i)
return nsecMatch?.[0] ?? null
} catch {
return null
}
}
function extractKeyFromText(text: string): string | null {
const nsec = extractNsec(text)
if (nsec) {
return nsec
}
const trimmed = text.trim()
return trimmed.length > 0 ? trimmed : null
}
function extractNsec(text: string): string | null {
const nsecMatch = text.match(/nsec1[a-z0-9]+/i)
return nsecMatch?.[0] ?? null
}
export function isValidPrivateKeyFormat(key: string): boolean {
try {
const decoded = nip19.decode(key)
if (decoded.type !== 'nsec') {
return false
}
return typeof decoded.data === 'string' || decoded.data instanceof Uint8Array
} catch {
return /^[0-9a-f]{64}$/i.test(key)
}
}

View File

@ -0,0 +1,154 @@
import { nostrAuthService } from '@/lib/nostrAuth'
import { keyManagementService } from '@/lib/keyManagement'
import { t } from '@/lib/i18n'
import type { PublicKeys } from './types'
import { extractKeyFromInput, isValidPrivateKeyFormat } from './keyImportParsing'
export interface KeyManagementManagerState {
publicKeys: PublicKeys | null
accountExists: boolean
loading: boolean
error: string | null
importKey: string
importing: boolean
showImportForm: boolean
showReplaceWarning: boolean
recoveryPhrase: string[] | null
newNpub: string | null
copiedNpub: boolean
copiedPublicKey: boolean
copiedRecoveryPhrase: boolean
}
export type PatchState = (patch: Partial<KeyManagementManagerState>) => void
export const INITIAL_KEY_MANAGEMENT_STATE: KeyManagementManagerState = {
publicKeys: null,
accountExists: false,
loading: true,
error: null,
importKey: '',
importing: false,
showImportForm: false,
showReplaceWarning: false,
recoveryPhrase: null,
newNpub: null,
copiedNpub: false,
copiedPublicKey: false,
copiedRecoveryPhrase: false,
}
export async function loadKeys(params: { patchState: PatchState }): Promise<void> {
try {
params.patchState({ loading: true, error: null })
const exists = await nostrAuthService.accountExists()
params.patchState({ accountExists: exists })
if (!exists) {
params.patchState({ publicKeys: null })
return
}
const keys = await keyManagementService.getPublicKeys()
params.patchState({ publicKeys: keys ?? null })
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.loading')
params.patchState({ error: errorMessage })
console.error('[KeyManagement] Error loading keys:', e)
} finally {
params.patchState({ loading: false })
}
}
export async function handleImport(params: {
state: KeyManagementManagerState
patchState: PatchState
}): Promise<void> {
const extractedKey = validateAndExtractImportKey({ importKey: params.state.importKey, patchState: params.patchState })
if (!extractedKey) {
return
}
if (params.state.accountExists) {
params.patchState({ showReplaceWarning: true })
return
}
await performImport({ key: extractedKey, accountExists: false, patchState: params.patchState })
}
export async function confirmReplace(params: {
state: KeyManagementManagerState
patchState: PatchState
}): Promise<void> {
const extractedKey = validateAndExtractImportKey({ importKey: params.state.importKey, patchState: params.patchState })
if (!extractedKey) {
return
}
await performImport({ key: extractedKey, accountExists: true, patchState: params.patchState })
}
function validateAndExtractImportKey(params: { importKey: string; patchState: PatchState }): string | null {
if (!params.importKey.trim()) {
params.patchState({ error: t('settings.keyManagement.import.error.required') })
return null
}
const extractedKey = extractKeyFromInput(params.importKey)
if (!extractedKey) {
params.patchState({ error: t('settings.keyManagement.import.error.invalid') })
return null
}
if (!isValidPrivateKeyFormat(extractedKey)) {
params.patchState({ error: t('settings.keyManagement.import.error.invalid') })
return null
}
return extractedKey
}
async function performImport(params: {
key: string
accountExists: boolean
patchState: PatchState
}): Promise<void> {
try {
params.patchState({ importing: true, error: null, showReplaceWarning: false })
if (params.accountExists) {
await nostrAuthService.deleteAccount()
}
const result = await nostrAuthService.createAccount(params.key)
params.patchState({
recoveryPhrase: result.recoveryPhrase,
newNpub: result.npub,
importKey: '',
showImportForm: false,
})
await loadKeys({ patchState: params.patchState })
await maybeStartUserSync({ publicKey: result.publicKey })
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.import.error.failed')
params.patchState({ error: errorMessage })
console.error('[KeyManagement] Error importing key:', e)
} finally {
params.patchState({ importing: false })
}
}
async function maybeStartUserSync(params: { publicKey: string | undefined }): Promise<void> {
if (!params.publicKey) {
return
}
const { swClient } = await import('@/lib/swClient')
const isReady = await swClient.isReady()
if (isReady) {
void swClient.startUserSync(params.publicKey)
}
}
export async function copyToClipboard(params: {
text: string
onCopied: () => void
onCopyFailed: (error: unknown) => void
}): Promise<void> {
try {
await navigator.clipboard.writeText(params.text)
params.onCopied()
} catch (e) {
params.onCopyFailed(e)
}
}

View File

@ -0,0 +1,4 @@
export interface PublicKeys {
publicKey: string
npub: string
}

View File

@ -0,0 +1,140 @@
import { useEffect, useMemo, useState } from 'react'
import {
INITIAL_KEY_MANAGEMENT_STATE,
confirmReplace,
copyToClipboard,
handleImport,
loadKeys,
type KeyManagementManagerState,
type PatchState,
} from './keyManagementController'
export interface KeyManagementManagerActions {
onChangeImportKey: (value: string) => void
onShowImportForm: () => void
onCancelImport: () => void
onImport: () => void
onDismissReplaceWarning: () => void
onConfirmReplace: () => void
onCopyNpub: () => void
onCopyPublicKey: () => void
onCopyRecoveryPhrase: () => void
onDoneRecovery: () => void
}
export interface UseKeyManagementManagerResult {
state: KeyManagementManagerState
actions: KeyManagementManagerActions
}
export function useKeyManagementManager(): UseKeyManagementManagerResult {
const [state, setState] = useState<KeyManagementManagerState>(INITIAL_KEY_MANAGEMENT_STATE)
const patchState = useMemo((): PatchState => createPatchState(setState), [])
useEffect(() => {
void loadKeys({ patchState })
}, [patchState])
const actions = useMemo((): KeyManagementManagerActions => createActions({ state, patchState }), [state, patchState])
return { state, actions }
}
function createPatchState(setState: React.Dispatch<React.SetStateAction<KeyManagementManagerState>>): PatchState {
return (patch: Partial<KeyManagementManagerState>): void => {
setState((previous) => ({ ...previous, ...patch }))
}
}
function createActions(params: { state: KeyManagementManagerState; patchState: PatchState }): KeyManagementManagerActions {
return {
onChangeImportKey: (value: string): void => {
params.patchState({ importKey: value, error: null })
},
onShowImportForm: (): void => {
params.patchState({ showImportForm: true, error: null })
},
onCancelImport: (): void => {
params.patchState({ showImportForm: false, importKey: '', error: null, showReplaceWarning: false })
},
onImport: (): void => {
void handleImport({ state: params.state, patchState: params.patchState })
},
onDismissReplaceWarning: (): void => {
params.patchState({ showReplaceWarning: false })
},
onConfirmReplace: (): void => {
void confirmReplace({ state: params.state, patchState: params.patchState })
},
onCopyNpub: (): void => {
void copyNpub({ state: params.state, patchState: params.patchState })
},
onCopyPublicKey: (): void => {
void copyPublicKey({ state: params.state, patchState: params.patchState })
},
onCopyRecoveryPhrase: (): void => {
void copyRecoveryPhrase({ state: params.state, patchState: params.patchState })
},
onDoneRecovery: (): void => {
params.patchState({ recoveryPhrase: null, newNpub: null })
void loadKeys({ patchState: params.patchState })
},
}
}
async function copyNpub(params: { state: KeyManagementManagerState; patchState: PatchState }): Promise<void> {
const npub = params.state.publicKeys?.npub
if (!npub) {
return
}
await copyToClipboard({
text: npub,
onCopied: () => {
params.patchState({ copiedNpub: true })
scheduleResetCopiedFlag(() => params.patchState({ copiedNpub: false }))
},
onCopyFailed: (e) => {
console.error('[KeyManagement] Error copying npub:', e)
},
})
}
async function copyPublicKey(params: { state: KeyManagementManagerState; patchState: PatchState }): Promise<void> {
const publicKey = params.state.publicKeys?.publicKey
if (!publicKey) {
return
}
await copyToClipboard({
text: publicKey,
onCopied: () => {
params.patchState({ copiedPublicKey: true })
scheduleResetCopiedFlag(() => params.patchState({ copiedPublicKey: false }))
},
onCopyFailed: (e) => {
console.error('[KeyManagement] Error copying public key:', e)
},
})
}
async function copyRecoveryPhrase(params: { state: KeyManagementManagerState; patchState: PatchState }): Promise<void> {
const {recoveryPhrase} = params.state
if (!recoveryPhrase) {
return
}
await copyToClipboard({
text: recoveryPhrase.join(' '),
onCopied: () => {
params.patchState({ copiedRecoveryPhrase: true })
scheduleResetCopiedFlag(() => params.patchState({ copiedRecoveryPhrase: false }))
},
onCopyFailed: (e) => {
console.error('[KeyManagement] Error copying recovery phrase:', e)
},
})
}
function scheduleResetCopiedFlag(reset: () => void): void {
setTimeout(() => {
reset()
}, 2000)
}

View File

@ -0,0 +1,158 @@
import { useState } from 'react'
import type { MediaRef, Page } from '@/types/nostr'
import { t } from '@/lib/i18n'
import { createPagesHandlers, PagesManager } from './PagesManager'
import { createImageUploadHandler } from './imageUpload'
export interface MarkdownEditorTwoColumnsProps {
value: string
onChange: (value: string) => void
pages?: Page[]
onPagesChange?: (pages: Page[]) => void
onMediaAdd?: (media: MediaRef) => void
onBannerChange?: (url: string) => void
}
export function MarkdownEditorTwoColumns(props: MarkdownEditorTwoColumnsProps): React.ReactElement {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const pages = props.pages ?? []
const pagesHandlers = createPagesHandlers({ pages, onPagesChange: props.onPagesChange })
const handleImageUpload = createImageUploadHandler({
setError,
setUploading,
onMediaAdd: props.onMediaAdd,
onBannerChange: props.onBannerChange,
onSetPageImageUrl: pagesHandlers.setPageContent,
})
return (
<div className="space-y-4">
<MarkdownToolbar
onFileSelected={(file) => {
void handleImageUpload({ file })
}}
uploading={uploading}
error={error}
{...(props.onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})}
/>
<div className="grid grid-cols-2 gap-4">
<EditorColumn value={props.value} onChange={props.onChange} />
<PreviewColumn value={props.value} />
</div>
{props.onPagesChange ? (
<PagesManager
pages={pages}
onPageContentChange={pagesHandlers.setPageContent}
onPageTypeChange={pagesHandlers.setPageType}
onRemovePage={pagesHandlers.removePage}
onImageUpload={async (file, pageNumber) => {
await handleImageUpload({ file, pageNumber })
}}
/>
) : null}
</div>
)
}
function MarkdownToolbar(params: {
onFileSelected: (file: File) => void
uploading: boolean
error: string | null
onAddPage?: (type: 'markdown' | 'image') => void
}): React.ReactElement {
return (
<div className="flex items-center gap-2 flex-wrap">
<ToolbarUploadButton onFileSelected={params.onFileSelected} />
<ToolbarAddPageButtons onAddPage={params.onAddPage} />
<ToolbarStatus uploading={params.uploading} error={params.error} />
</div>
)
}
function EditorColumn(params: { value: string; onChange: (value: string) => void }): React.ReactElement {
return (
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-800">{t('markdown.editor')}</label>
<textarea
className="w-full border rounded p-3 h-96 font-mono text-sm"
value={params.value}
onChange={(e) => params.onChange(e.target.value)}
placeholder={t('markdown.placeholder')}
/>
</div>
)
}
function PreviewColumn(params: { value: string }): React.ReactElement {
return (
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-800">{t('markdown.preview')}</label>
<MarkdownPreview value={params.value} />
</div>
)
}
function MarkdownPreview(params: { value: string }): React.ReactElement {
return (
<div className="prose max-w-none border rounded p-3 bg-white h-96 overflow-y-auto whitespace-pre-wrap">
{params.value || <span className="text-gray-400">{t('markdown.preview.empty')}</span>}
</div>
)
}
function ToolbarUploadButton(params: { onFileSelected: (file: File) => void }): React.ReactElement {
return (
<label className="px-3 py-1 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700">
{t('markdown.upload.media')}
<input
type="file"
accept=".png,.jpg,.jpeg,.webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
params.onFileSelected(file)
}
}}
/>
</label>
)
}
function ToolbarAddPageButtons(params: { onAddPage: ((type: 'markdown' | 'image') => void) | undefined }): React.ReactElement | null {
if (!params.onAddPage) {
return null
}
return (
<>
<button
type="button"
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
onClick={() => params.onAddPage?.('markdown')}
>
{t('page.add.markdown')}
</button>
<button
type="button"
className="px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700"
onClick={() => params.onAddPage?.('image')}
>
{t('page.add.image')}
</button>
</>
)
}
function ToolbarStatus(params: { uploading: boolean; error: string | null }): React.ReactElement | null {
if (!params.uploading && !params.error) {
return null
}
return (
<>
{params.uploading ? <span className="text-sm text-gray-500">{t('markdown.upload.uploading')}</span> : null}
{params.error ? <span className="text-sm text-red-600">{params.error}</span> : null}
</>
)
}

View File

@ -0,0 +1,165 @@
import { t } from '@/lib/i18n'
import type { Page } from '@/types/nostr'
export function PagesManager(params: {
pages: Page[]
onPageContentChange: (pageNumber: number, content: string) => void
onPageTypeChange: (pageNumber: number, type: 'markdown' | 'image') => void
onRemovePage: (pageNumber: number) => void
onImageUpload: (file: File, pageNumber: number) => Promise<void>
}): React.ReactElement {
if (params.pages.length === 0) {
return <div className="text-sm text-gray-500">{t('page.empty')}</div>
}
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('page.title')}</h3>
{params.pages.map((page) => (
<PageEditor
key={page.number}
page={page}
onContentChange={(content) => params.onPageContentChange(page.number, content)}
onTypeChange={(type) => params.onPageTypeChange(page.number, type)}
onRemove={() => params.onRemovePage(page.number)}
onImageUpload={async (file) => {
await params.onImageUpload(file, page.number)
}}
/>
))}
</div>
)
}
function PageEditor(params: {
page: Page
onContentChange: (content: string) => void
onTypeChange: (type: 'markdown' | 'image') => void
onRemove: () => void
onImageUpload: (file: File) => Promise<void>
}): React.ReactElement {
return (
<div className="border rounded-lg p-4 space-y-3">
<PageEditorHeader page={params.page} onTypeChange={params.onTypeChange} onRemove={params.onRemove} />
<PageEditorBody page={params.page} onContentChange={params.onContentChange} onImageUpload={params.onImageUpload} />
</div>
)
}
function PageEditorHeader(params: { page: Page; onTypeChange: (type: 'markdown' | 'image') => void; onRemove: () => void }): React.ReactElement {
return (
<div className="flex items-center justify-between">
<h4 className="font-semibold">
{t('page.number', { number: params.page.number })} - {t(`page.type.${params.page.type}`)}
</h4>
<div className="flex items-center gap-2">
<select
value={params.page.type}
onChange={(e) => params.onTypeChange(e.target.value as 'markdown' | 'image')}
className="text-sm border rounded px-2 py-1"
>
<option value="markdown">{t('page.type.markdown')}</option>
<option value="image">{t('page.type.image')}</option>
</select>
<button
type="button"
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
onClick={params.onRemove}
>
{t('page.remove')}
</button>
</div>
</div>
)
}
function PageEditorBody(params: { page: Page; onContentChange: (content: string) => void; onImageUpload: (file: File) => Promise<void> }): React.ReactElement {
if (params.page.type === 'markdown') {
return (
<textarea
className="w-full border rounded p-2 h-48 font-mono text-sm"
value={params.page.content}
onChange={(e) => params.onContentChange(e.target.value)}
placeholder={t('page.markdown.placeholder')}
/>
)
}
return <PageEditorImageBody page={params.page} onContentChange={params.onContentChange} onImageUpload={params.onImageUpload} />
}
function PageEditorImageBody(params: { page: Page; onContentChange: (content: string) => void; onImageUpload: (file: File) => Promise<void> }): React.ReactElement {
if (params.page.content) {
return (
<div className="relative">
<img src={params.page.content} alt={t('page.image.alt', { number: params.page.number })} className="max-w-full h-auto rounded" />
<button
type="button"
className="absolute top-2 right-2 px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
onClick={() => params.onContentChange('')}
>
{t('page.image.remove')}
</button>
</div>
)
}
return <PageImageUploadButton onFileSelected={params.onImageUpload} />
}
function PageImageUploadButton(params: { onFileSelected: (file: File) => Promise<void> }): React.ReactElement {
return (
<label className="block px-3 py-2 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700 text-center">
{t('page.image.upload')}
<input
type="file"
accept=".png,.jpg,.jpeg,.webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
void params.onFileSelected(file)
}
}}
/>
</label>
)
}
export function createPagesHandlers(params: {
pages: Page[]
onPagesChange: ((pages: Page[]) => void) | undefined
}): {
addPage: (type: 'markdown' | 'image') => void
setPageContent: (pageNumber: number, content: string) => void
setPageType: (pageNumber: number, type: 'markdown' | 'image') => void
removePage: (pageNumber: number) => void
} {
const update = (next: Page[]): void => {
params.onPagesChange?.(next)
}
return {
addPage: (type: 'markdown' | 'image'): void => {
if (!params.onPagesChange) {
return
}
const newPage: Page = { number: params.pages.length + 1, type, content: '' }
update([...params.pages, newPage])
},
setPageContent: (pageNumber: number, content: string): void => {
if (!params.onPagesChange) {
return
}
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, content } : p)))
},
setPageType: (pageNumber: number, type: 'markdown' | 'image'): void => {
if (!params.onPagesChange) {
return
}
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, type, content: '' } : p)))
},
removePage: (pageNumber: number): void => {
if (!params.onPagesChange) {
return
}
update(params.pages.filter((p) => p.number !== pageNumber).map((p, idx) => ({ ...p, number: idx + 1 })))
},
}
}

View File

@ -0,0 +1,32 @@
import type { MediaRef } from '@/types/nostr'
import { uploadNip95Media } from '@/lib/nip95'
import { t } from '@/lib/i18n'
export function createImageUploadHandler(params: {
setError: (value: string | null) => void
setUploading: (value: boolean) => void
onMediaAdd: ((media: MediaRef) => void) | undefined
onBannerChange: ((url: string) => void) | undefined
onSetPageImageUrl: (pageNumber: number, url: string) => void
}): (args: { file: File; pageNumber?: number }) => Promise<void> {
return async (args): Promise<void> => {
params.setError(null)
params.setUploading(true)
try {
const media = await uploadNip95Media(args.file)
if (media.type !== 'image') {
return
}
if (args.pageNumber !== undefined) {
params.onSetPageImageUrl(args.pageNumber, media.url)
} else {
params.onBannerChange?.(media.url)
}
params.onMediaAdd?.(media)
} catch (e) {
params.setError(e instanceof Error ? e.message : t('upload.error.failed'))
} finally {
params.setUploading(false)
}
}
}

View File

@ -0,0 +1,155 @@
import type { Nip95Config } from '@/lib/configStorageTypes'
import { t } from '@/lib/i18n'
import { DragHandle } from '../DragHandle'
import { getApiCardClassName } from './getApiCardClassName'
export function Nip95ApiCard(params: {
api: Nip95Config
priority: number
isEditing: boolean
draggedId: string | null
dragOverId: string | null
onStartEditing: (id: string) => void
onStopEditing: () => void
onUpdateUrl: (id: string, url: string) => void
onToggleEnabled: (id: string, enabled: boolean) => void
onRemoveApi: (id: string) => void
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
}): React.ReactElement {
return (
<div
onDragOver={(e) => params.onDragOver(e, params.api.id)}
onDragLeave={params.onDragLeave}
onDrop={(e) => params.onDrop(e, params.api.id)}
className={`bg-cyber-dark border rounded p-4 space-y-3 transition-all ${getApiCardClassName(params.api.id, params.draggedId, params.dragOverId)}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<DragGrip apiId={params.api.id} onDragStart={params.onDragStart} />
<UrlCell
api={params.api}
isEditing={params.isEditing}
onStartEditing={params.onStartEditing}
onStopEditing={params.onStopEditing}
onUpdateUrl={params.onUpdateUrl}
/>
</div>
<ActionsCell api={params.api} onToggleEnabled={params.onToggleEnabled} onRemoveApi={params.onRemoveApi} />
</div>
<PriorityRow priority={params.priority} apiId={params.api.id} />
</div>
)
}
function DragGrip(params: { apiId: string; onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void }): React.ReactElement {
return (
<div
className="drag-handle cursor-grab active:cursor-grabbing"
draggable
onDragStart={(e) => {
params.onDragStart(e, params.apiId)
e.stopPropagation()
}}
onMouseDown={(e) => e.stopPropagation()}
>
<DragHandle />
</div>
)
}
function UrlCell(params: {
api: Nip95Config
isEditing: boolean
onStartEditing: (id: string) => void
onStopEditing: () => void
onUpdateUrl: (id: string, url: string) => void
}): React.ReactElement {
return <div className="flex-1">{params.isEditing ? <UrlEditor api={params.api} onStop={params.onStopEditing} onUpdate={params.onUpdateUrl} /> : <UrlText api={params.api} onStartEditing={params.onStartEditing} />}</div>
}
function UrlText(params: { api: Nip95Config; onStartEditing: (id: string) => void }): React.ReactElement {
return (
<div
className="text-neon-cyan cursor-pointer hover:text-neon-green transition-colors"
onClick={() => params.onStartEditing(params.api.id)}
title={t('settings.nip95.list.editUrl')}
>
{params.api.url}
</div>
)
}
function UrlEditor(params: { api: Nip95Config; onStop: () => void; onUpdate: (id: string, url: string) => void }): React.ReactElement {
return (
<input
type="url"
defaultValue={params.api.url}
onBlur={(e) => {
const next = e.target.value
if (next !== params.api.url) {
params.onUpdate(params.api.id, next)
} else {
params.onStop()
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
params.onStop()
}
}}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/50 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
autoFocus
/>
)
}
function ActionsCell(params: { api: Nip95Config; onToggleEnabled: (id: string, enabled: boolean) => void; onRemoveApi: (id: string) => void }): React.ReactElement {
return (
<div className="flex items-center gap-2">
<EnabledToggle api={params.api} onToggleEnabled={params.onToggleEnabled} />
<RemoveButton apiId={params.api.id} onRemove={params.onRemoveApi} />
</div>
)
}
function EnabledToggle(params: { api: Nip95Config; onToggleEnabled: (id: string, enabled: boolean) => void }): React.ReactElement {
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={params.api.enabled}
onChange={(e) => params.onToggleEnabled(params.api.id, e.target.checked)}
className="w-4 h-4 text-neon-cyan bg-cyber-darker border-cyber-accent rounded focus:ring-neon-cyan"
/>
<span className="text-sm text-cyber-accent">
{params.api.enabled ? t('settings.nip95.list.enabled') : t('settings.nip95.list.disabled')}
</span>
</label>
)
}
function RemoveButton(params: { apiId: string; onRemove: (id: string) => void }): React.ReactElement {
return (
<button
type="button"
onClick={() => params.onRemove(params.apiId)}
className="px-3 py-1 text-sm bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-500/50 rounded transition-colors"
title={t('settings.nip95.list.remove')}
>
{t('settings.nip95.list.remove')}
</button>
)
}
function PriorityRow(params: { priority: number; apiId: string }): React.ReactElement {
return (
<div className="flex items-center gap-2 text-xs text-cyber-accent/70">
<span>{t('settings.nip95.list.priorityLabel', { priority: params.priority, id: params.apiId })}</span>
</div>
)
}

View File

@ -0,0 +1,30 @@
import type { Nip95Config } from '@/lib/configStorageTypes'
import { t } from '@/lib/i18n'
import { Nip95ApiCard } from './Nip95ApiCard'
export function Nip95ApiList(params: {
apis: Nip95Config[]
editingId: string | null
draggedId: string | null
dragOverId: string | null
onStartEditing: (id: string) => void
onStopEditing: () => void
onUpdateUrl: (id: string, url: string) => void
onToggleEnabled: (id: string, enabled: boolean) => void
onRemoveApi: (id: string) => void
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
}): React.ReactElement {
if (params.apis.length === 0) {
return <div className="text-center py-8 text-cyber-accent">{t('settings.nip95.empty')}</div>
}
return (
<div className="space-y-4">
{params.apis.map((api, index) => (
<Nip95ApiCard key={api.id} api={api} priority={index + 1} isEditing={params.editingId === api.id} draggedId={params.draggedId} dragOverId={params.dragOverId} onStartEditing={params.onStartEditing} onStopEditing={params.onStopEditing} onUpdateUrl={params.onUpdateUrl} onToggleEnabled={params.onToggleEnabled} onRemoveApi={params.onRemoveApi} onDragStart={params.onDragStart} onDragOver={params.onDragOver} onDragLeave={params.onDragLeave} onDrop={params.onDrop} />
))}
</div>
)
}

View File

@ -0,0 +1,107 @@
import type { Nip95Config } from '@/lib/configStorageTypes'
import { t } from '@/lib/i18n'
import { Nip95ApiList } from './Nip95ApiList'
export function Nip95ConfigContent(params: {
apis: Nip95Config[]
error: string | null
editingId: string | null
newUrl: string
showAddForm: boolean
draggedId: string | null
dragOverId: string | null
onClearError: () => void
onToggleAddForm: () => void
onNewUrlChange: (value: string) => void
onCancelAdd: () => void
onAddApi: () => void
onStartEditing: (id: string) => void
onStopEditing: () => void
onUpdateUrl: (id: string, url: string) => void
onToggleEnabled: (id: string, enabled: boolean) => void
onRemoveApi: (id: string) => void
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
}): React.ReactElement {
return (
<div className="space-y-6">
{params.error ? <ErrorBanner error={params.error} onClear={params.onClearError} /> : null}
<Header showAddForm={params.showAddForm} onToggleAddForm={params.onToggleAddForm} />
{params.showAddForm ? <AddForm newUrl={params.newUrl} onNewUrlChange={params.onNewUrlChange} onAdd={params.onAddApi} onCancel={params.onCancelAdd} /> : null}
<Nip95ApiList apis={params.apis} editingId={params.editingId} draggedId={params.draggedId} dragOverId={params.dragOverId} onStartEditing={params.onStartEditing} onStopEditing={params.onStopEditing} onUpdateUrl={params.onUpdateUrl} onToggleEnabled={params.onToggleEnabled} onRemoveApi={params.onRemoveApi} onDragStart={params.onDragStart} onDragOver={params.onDragOver} onDragLeave={params.onDragLeave} onDrop={params.onDrop} />
<Notes />
</div>
)
}
function ErrorBanner(params: { error: string; onClear: () => void }): React.ReactElement {
return (
<div className="bg-red-900/30 border border-red-500/50 rounded p-4 text-red-300">
{params.error}
<button type="button" onClick={params.onClear} className="ml-4 text-red-400 hover:text-red-200">
×
</button>
</div>
)
}
function Header(params: { showAddForm: boolean; onToggleAddForm: () => void }): React.ReactElement {
return (
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.nip95.title')}</h2>
<button
type="button"
onClick={params.onToggleAddForm}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
>
{params.showAddForm ? t('settings.nip95.add.cancel') : t('settings.nip95.addButton')}
</button>
</div>
)
}
function AddForm(params: { newUrl: string; onNewUrlChange: (value: string) => void; onAdd: () => void; onCancel: () => void }): React.ReactElement {
return (
<div className="bg-cyber-dark border border-neon-cyan/30 rounded p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-cyber-accent mb-2">{t('settings.nip95.add.url')}</label>
<input
type="url"
value={params.newUrl}
onChange={(e) => params.onNewUrlChange(e.target.value)}
placeholder={t('settings.nip95.add.placeholder')}
className="w-full px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={params.onAdd}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
>
{t('settings.nip95.add.add')}
</button>
<button
type="button"
onClick={params.onCancel}
className="px-4 py-2 bg-cyber-accent/20 hover:bg-cyber-accent/30 text-cyber-accent border border-cyber-accent/50 rounded transition-colors"
>
{t('settings.nip95.add.cancel')}
</button>
</div>
</div>
)
}
function Notes(): React.ReactElement {
return (
<div className="text-sm text-cyber-accent space-y-2">
<p>
<strong>{t('settings.nip95.note.title')}</strong> {t('settings.nip95.note.priority')}
</p>
<p>{t('settings.nip95.note.fallback')}</p>
</div>
)
}

View File

@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
import type { Nip95Config } from '@/lib/configStorageTypes'
import { Nip95ConfigView } from './view'
import { loadApis } from './controller'
import { createNip95ConfigViewProps } from './viewModel'
export interface Nip95ConfigManagerProps {
onConfigChange?: () => void
}
export function Nip95ConfigManager(props: Nip95ConfigManagerProps): React.ReactElement {
const [apis, setApis] = useState<Nip95Config[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [newUrl, setNewUrl] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
const [draggedId, setDraggedId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
useEffect(() => {
void loadApis({ setApis, setLoading, setError })
}, [])
const viewProps = createNip95ConfigViewProps({
apis,
loading,
error,
editingId,
newUrl,
showAddForm,
draggedId,
dragOverId,
setApis,
setError,
setEditingId,
setNewUrl,
setShowAddForm,
setDraggedId,
setDragOverId,
onConfigChange: props.onConfigChange,
})
return <Nip95ConfigView {...viewProps} />
}

View File

@ -0,0 +1,37 @@
import type { Nip95Config } from '@/lib/configStorageTypes'
import { t } from '@/lib/i18n'
import { Nip95ConfigContent } from './Nip95ConfigContent'
export function Nip95ConfigView(params: {
apis: Nip95Config[]
loading: boolean
error: string | null
editingId: string | null
newUrl: string
showAddForm: boolean
draggedId: string | null
dragOverId: string | null
onClearError: () => void
onToggleAddForm: () => void
onNewUrlChange: (value: string) => void
onCancelAdd: () => void
onAddApi: () => void
onStartEditing: (id: string) => void
onStopEditing: () => void
onUpdateUrl: (id: string, url: string) => void
onToggleEnabled: (id: string, enabled: boolean) => void
onRemoveApi: (id: string) => void
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
}): React.ReactElement {
if (params.loading) {
return (
<div className="text-center py-8 text-neon-cyan">
<div>{t('settings.nip95.loading')}</div>
</div>
)
}
return <Nip95ConfigContent {...params} />
}

View File

@ -0,0 +1,212 @@
import { configStorage } from '@/lib/configStorage'
import type { Nip95Config } from '@/lib/configStorageTypes'
import { t } from '@/lib/i18n'
import { userConfirm } from '@/lib/userConfirm'
export async function loadApis(params: {
setApis: (value: Nip95Config[]) => void
setLoading: (value: boolean) => void
setError: (value: string | null) => void
}): Promise<void> {
try {
params.setLoading(true)
params.setError(null)
const config = await configStorage.getConfig()
params.setApis(config.nip95Apis.sort((a, b) => a.priority - b.priority))
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.loadFailed')
params.setError(errorMessage)
console.error('[Nip95Config] Error loading APIs:', e)
} finally {
params.setLoading(false)
}
}
export async function toggleEnabled(params: {
id: string
enabled: boolean
setApis: (value: Nip95Config[]) => void
setError: (value: string | null) => void
onConfigChange: (() => void) | undefined
}): Promise<void> {
try {
await configStorage.updateNip95Api(params.id, { enabled: params.enabled })
await reloadApis({ setApis: params.setApis, setError: params.setError })
params.onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.updateFailed')
params.setError(errorMessage)
console.error('[Nip95Config] Error updating API:', e)
}
}
export async function updateUrl(params: {
id: string
url: string
setApis: (value: Nip95Config[]) => void
setError: (value: string | null) => void
setEditingId: (value: string | null) => void
onConfigChange: (() => void) | undefined
}): Promise<void> {
try {
await configStorage.updateNip95Api(params.id, { url: params.url })
await reloadApis({ setApis: params.setApis, setError: params.setError })
params.setEditingId(null)
params.onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.urlFailed')
params.setError(errorMessage)
console.error('[Nip95Config] Error updating URL:', e)
}
}
export async function addApi(params: {
newUrl: string
setError: (value: string | null) => void
setNewUrl: (value: string) => void
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
setApis: (value: Nip95Config[]) => void
onConfigChange: (() => void) | undefined
}): Promise<void> {
if (!params.newUrl.trim()) {
params.setError(t('settings.nip95.error.urlRequired'))
return
}
try {
void new URL(params.newUrl)
await configStorage.addNip95Api(params.newUrl.trim(), false)
params.setNewUrl('')
params.setShowAddForm(false)
await reloadApis({ setApis: params.setApis, setError: params.setError })
params.onConfigChange?.()
} catch (e) {
params.setError(getAddApiErrorMessage(e))
console.error('[Nip95Config] Error adding API:', e)
}
}
export async function removeApi(params: {
id: string
setApis: (value: Nip95Config[]) => void
setError: (value: string | null) => void
onConfigChange: (() => void) | undefined
}): Promise<void> {
const confirmed = await userConfirm(t('settings.nip95.remove.confirm'))
if (!confirmed) {
return
}
try {
await configStorage.removeNip95Api(params.id)
await reloadApis({ setApis: params.setApis, setError: params.setError })
params.onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.removeFailed')
params.setError(errorMessage)
console.error('[Nip95Config] Error removing API:', e)
}
}
export function onDragStart(params: {
e: React.DragEvent<HTMLDivElement>
id: string
setDraggedId: (value: string | null) => void
}): void {
params.setDraggedId(params.id)
const { dataTransfer } = params.e
dataTransfer.effectAllowed = 'move'
dataTransfer.setData('text/plain', params.id)
}
export function onDragOver(params: {
e: React.DragEvent<HTMLDivElement>
id: string
setDragOverId: (value: string | null) => void
}): void {
params.e.preventDefault()
const { dataTransfer } = params.e
dataTransfer.dropEffect = 'move'
params.setDragOverId(params.id)
}
export async function onDrop(params: {
e: React.DragEvent<HTMLDivElement>
targetId: string
apis: Nip95Config[]
draggedId: string | null
setApis: (value: Nip95Config[]) => void
setDraggedId: (value: string | null) => void
setDragOverId: (value: string | null) => void
setError: (value: string | null) => void
onConfigChange: (() => void) | undefined
}): Promise<void> {
params.e.preventDefault()
params.setDragOverId(null)
if (!params.draggedId || params.draggedId === params.targetId) {
params.setDraggedId(null)
return
}
const reordered = reorderApis({ apis: params.apis, draggedId: params.draggedId, targetId: params.targetId })
if (!reordered) {
params.setDraggedId(null)
return
}
params.setApis(reordered)
params.setDraggedId(null)
await updatePriorities({ apis: reordered, setError: params.setError, onConfigChange: params.onConfigChange })
}
async function reloadApis(params: { setApis: (value: Nip95Config[]) => void; setError: (value: string | null) => void }): Promise<void> {
try {
const config = await configStorage.getConfig()
params.setApis(config.nip95Apis.sort((a, b) => a.priority - b.priority))
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.loadFailed')
params.setError(errorMessage)
console.error('[Nip95Config] Error reloading APIs:', e)
}
}
function getAddApiErrorMessage(error: unknown): string {
if (error instanceof TypeError && error.message.includes('Invalid URL')) {
return t('settings.nip95.error.invalidUrl')
}
if (error instanceof Error) {
return error.message
}
return t('settings.nip95.error.addFailed')
}
function reorderApis(params: { apis: Nip95Config[]; draggedId: string; targetId: string }): Nip95Config[] | null {
const draggedIndex = params.apis.findIndex((api) => api.id === params.draggedId)
const targetIndex = params.apis.findIndex((api) => api.id === params.targetId)
if (draggedIndex === -1 || targetIndex === -1) {
return null
}
const next = [...params.apis]
const removed = next[draggedIndex]
if (!removed) {
return null
}
next.splice(draggedIndex, 1)
next.splice(targetIndex, 0, removed)
return next
}
async function updatePriorities(params: {
apis: Nip95Config[]
setError: (value: string | null) => void
onConfigChange: (() => void) | undefined
}): Promise<void> {
try {
const updates = params.apis.map((api, index) => {
const priority = index + 1
return api.priority !== priority ? configStorage.updateNip95Api(api.id, { priority }) : Promise.resolve()
})
await Promise.all(updates)
params.onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.priorityFailed')
params.setError(errorMessage)
console.error('[Nip95Config] Error updating priorities:', e)
}
}

View File

@ -0,0 +1,9 @@
export function getApiCardClassName(apiId: string, draggedId: string | null, dragOverId: string | null): string {
if (draggedId === apiId) {
return 'opacity-50 border-neon-cyan'
}
if (dragOverId === apiId) {
return 'border-neon-green shadow-lg'
}
return 'border-neon-cyan/30'
}

View File

@ -0,0 +1 @@
export { Nip95ConfigView } from './Nip95ConfigView'

View File

@ -0,0 +1,121 @@
import type { Nip95Config } from '@/lib/configStorageTypes'
import { Nip95ConfigView } from './view'
import { addApi, onDragOver, onDragStart, onDrop, removeApi, toggleEnabled, updateUrl } from './controller'
type ViewProps = Parameters<typeof Nip95ConfigView>[0]
export function createNip95ConfigViewProps(params: {
apis: Nip95Config[]
loading: boolean
error: string | null
editingId: string | null
newUrl: string
showAddForm: boolean
draggedId: string | null
dragOverId: string | null
setApis: (value: Nip95Config[]) => void
setError: (value: string | null) => void
setEditingId: (value: string | null) => void
setNewUrl: (value: string) => void
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
setDraggedId: (value: string | null) => void
setDragOverId: (value: string | null) => void
onConfigChange: (() => void) | undefined
}): ViewProps {
return { ...baseStateProps(params), ...formHandlers(params), ...apiHandlers(params), ...dragHandlers(params) }
}
function baseStateProps(params: {
apis: Nip95Config[]
loading: boolean
error: string | null
editingId: string | null
newUrl: string
showAddForm: boolean
draggedId: string | null
dragOverId: string | null
}): Pick<ViewProps, 'apis' | 'loading' | 'error' | 'editingId' | 'newUrl' | 'showAddForm' | 'draggedId' | 'dragOverId'> {
return {
apis: params.apis,
loading: params.loading,
error: params.error,
editingId: params.editingId,
newUrl: params.newUrl,
showAddForm: params.showAddForm,
draggedId: params.draggedId,
dragOverId: params.dragOverId,
}
}
function formHandlers(params: {
newUrl: string
setApis: (value: Nip95Config[]) => void
setError: (value: string | null) => void
setNewUrl: (value: string) => void
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
onConfigChange: (() => void) | undefined
}): Pick<ViewProps, 'onClearError' | 'onToggleAddForm' | 'onNewUrlChange' | 'onCancelAdd' | 'onAddApi'> {
return {
onClearError: () => params.setError(null),
onToggleAddForm: () => params.setShowAddForm((prev) => !prev),
onNewUrlChange: (value) => params.setNewUrl(value),
onCancelAdd: () => {
params.setShowAddForm(false)
params.setNewUrl('')
params.setError(null)
},
onAddApi: () =>
void addApi({
newUrl: params.newUrl,
setError: params.setError,
setNewUrl: params.setNewUrl,
setShowAddForm: params.setShowAddForm,
setApis: params.setApis,
onConfigChange: params.onConfigChange,
}),
}
}
function apiHandlers(params: {
setApis: (value: Nip95Config[]) => void
setError: (value: string | null) => void
setEditingId: (value: string | null) => void
onConfigChange: (() => void) | undefined
}): Pick<ViewProps, 'onStartEditing' | 'onStopEditing' | 'onUpdateUrl' | 'onToggleEnabled' | 'onRemoveApi'> {
return {
onStartEditing: (id) => params.setEditingId(id),
onStopEditing: () => params.setEditingId(null),
onUpdateUrl: (id, url) =>
void updateUrl({ id, url, setError: params.setError, setEditingId: params.setEditingId, setApis: params.setApis, onConfigChange: params.onConfigChange }),
onToggleEnabled: (id, enabled) => void toggleEnabled({ id, enabled, setError: params.setError, setApis: params.setApis, onConfigChange: params.onConfigChange }),
onRemoveApi: (id) => void removeApi({ id, setError: params.setError, setApis: params.setApis, onConfigChange: params.onConfigChange }),
}
}
function dragHandlers(params: {
apis: Nip95Config[]
draggedId: string | null
setApis: (value: Nip95Config[]) => void
setDraggedId: (value: string | null) => void
setDragOverId: (value: string | null) => void
setError: (value: string | null) => void
onConfigChange: (() => void) | undefined
}): Pick<ViewProps, 'onDragStart' | 'onDragOver' | 'onDragLeave' | 'onDrop'> {
return {
onDragStart: (e, id) => onDragStart({ e, id, setDraggedId: params.setDraggedId }),
onDragOver: (e, id) => onDragOver({ e, id, setDragOverId: params.setDragOverId }),
onDragLeave: () => params.setDragOverId(null),
onDrop: (e, targetId) =>
void onDrop({
e,
targetId,
apis: params.apis,
draggedId: params.draggedId,
setApis: params.setApis,
setDraggedId: params.setDraggedId,
setDragOverId: params.setDragOverId,
setError: params.setError,
onConfigChange: params.onConfigChange,
}),
}
}

View File

@ -0,0 +1,132 @@
import type { RelayConfig } from '@/lib/configStorageTypes'
import { t } from '@/lib/i18n'
import { DragHandle } from '../DragHandle'
import { getRelayCardClassName } from './controller'
export function RelayCard(params: {
relay: RelayConfig
isEditing: boolean
draggedId: string | null
dragOverId: string | null
onStartEditing: (id: string) => void
onStopEditing: () => void
onUpdateUrl: (id: string, url: string) => void
onToggleEnabled: (id: string, enabled: boolean) => void
onRemoveRelay: (id: string) => void
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
onDragEnd: () => void
}): React.ReactElement {
return (
<div
onDragOver={(e) => params.onDragOver(e, params.relay.id)}
onDragLeave={params.onDragLeave}
onDrop={(e) => params.onDrop(e, params.relay.id)}
className={`bg-cyber-dark border rounded p-4 space-y-3 transition-all ${getRelayCardClassName(params.relay.id, params.draggedId, params.dragOverId)}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<DragGrip relayId={params.relay.id} onDragStart={params.onDragStart} onDragEnd={params.onDragEnd} />
<UrlCell relay={params.relay} isEditing={params.isEditing} onStartEditing={params.onStartEditing} onStopEditing={params.onStopEditing} onUpdateUrl={params.onUpdateUrl} />
{params.relay.lastSyncDate ? <LastSync lastSyncDate={params.relay.lastSyncDate} /> : null}
</div>
<ActionsCell relay={params.relay} onToggleEnabled={params.onToggleEnabled} onRemoveRelay={params.onRemoveRelay} />
</div>
</div>
)
}
function DragGrip(params: {
relayId: string
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragEnd: () => void
}): React.ReactElement {
return (
<div
className="drag-handle cursor-grab active:cursor-grabbing"
draggable
onDragStart={(e) => params.onDragStart(e, params.relayId)}
onDragEnd={params.onDragEnd}
onMouseDown={(e) => e.stopPropagation()}
>
<DragHandle />
</div>
)
}
function UrlCell(params: {
relay: RelayConfig
isEditing: boolean
onStartEditing: (id: string) => void
onStopEditing: () => void
onUpdateUrl: (id: string, url: string) => void
}): React.ReactElement {
if (params.isEditing) {
return <UrlEditor relay={params.relay} onStop={params.onStopEditing} onUpdate={params.onUpdateUrl} />
}
return (
<div className="text-neon-cyan cursor-pointer hover:text-neon-green transition-colors" onClick={() => params.onStartEditing(params.relay.id)} title={t('settings.relay.list.editUrl')}>
{params.relay.url}
</div>
)
}
function UrlEditor(params: { relay: RelayConfig; onStop: () => void; onUpdate: (id: string, url: string) => void }): React.ReactElement {
return (
<input
type="text"
defaultValue={params.relay.url}
onBlur={(e) => {
const next = e.target.value
if (next !== params.relay.url) {
params.onUpdate(params.relay.id, next)
} else {
params.onStop()
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
params.onStop()
}
}}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/50 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
autoFocus
/>
)
}
function LastSync(params: { lastSyncDate: string }): React.ReactElement {
return (
<div className="text-xs text-cyber-accent/70 mt-1">
{t('settings.relay.list.lastSync')}: {new Date(params.lastSyncDate).toLocaleString()}
</div>
)
}
function ActionsCell(params: { relay: RelayConfig; onToggleEnabled: (id: string, enabled: boolean) => void; onRemoveRelay: (id: string) => void }): React.ReactElement {
return (
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={params.relay.enabled}
onChange={(e) => params.onToggleEnabled(params.relay.id, e.target.checked)}
className="w-4 h-4 text-neon-cyan bg-cyber-darker border-cyber-accent rounded focus:ring-neon-cyan"
/>
<span className="text-sm text-cyber-accent">{params.relay.enabled ? t('settings.relay.list.enabled') : t('settings.relay.list.disabled')}</span>
</label>
<button
type="button"
onClick={() => params.onRemoveRelay(params.relay.id)}
className="px-3 py-1 text-sm bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-500/50 rounded transition-colors"
title={t('settings.relay.list.remove')}
>
{t('settings.relay.list.remove')}
</button>
</div>
)
}

View File

@ -0,0 +1,49 @@
import type { RelayConfig } from '@/lib/configStorageTypes'
import { t } from '@/lib/i18n'
import { RelayCard } from './RelayCard'
interface RelayListProps {
relays: RelayConfig[]
editingId: string | null
draggedId: string | null
dragOverId: string | null
onStartEditing: (id: string) => void
onStopEditing: () => void
onUpdateUrl: (id: string, url: string) => void
onToggleEnabled: (id: string, enabled: boolean) => void
onRemoveRelay: (id: string) => void
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
onDragEnd: () => void
}
export function RelayList(params: RelayListProps): React.ReactElement {
if (params.relays.length === 0) {
return <div className="text-center py-8 text-cyber-accent">{t('settings.relay.empty')}</div>
}
return (
<div className="space-y-4">
{params.relays.map((relay) => (
<RelayCard
key={relay.id}
relay={relay}
isEditing={params.editingId === relay.id}
draggedId={params.draggedId}
dragOverId={params.dragOverId}
onStartEditing={params.onStartEditing}
onStopEditing={params.onStopEditing}
onUpdateUrl={params.onUpdateUrl}
onToggleEnabled={params.onToggleEnabled}
onRemoveRelay={params.onRemoveRelay}
onDragStart={params.onDragStart}
onDragOver={params.onDragOver}
onDragLeave={params.onDragLeave}
onDrop={params.onDrop}
onDragEnd={params.onDragEnd}
/>
))}
</div>
)
}

View File

@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
import type { RelayConfig } from '@/lib/configStorageTypes'
import { RelayManagerView } from './view'
import { initialLoadRelays } from './viewModel'
import { buildRelayManagerViewProps } from './viewProps'
export interface RelayManagerProps {
onConfigChange?: () => void
}
export function RelayManager(props: RelayManagerProps): React.ReactElement {
const [relays, setRelays] = useState<RelayConfig[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [newUrl, setNewUrl] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
const [draggedId, setDraggedId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
useEffect(() => {
initialLoadRelays({ setRelays, setLoading, setError })
}, [])
const viewProps = buildRelayManagerViewProps({
relays,
loading,
error,
editingId,
newUrl,
showAddForm,
draggedId,
dragOverId,
setRelays,
setLoading,
setError,
setEditingId,
setNewUrl,
setShowAddForm,
setDraggedId,
setDragOverId,
onConfigChange: props.onConfigChange,
})
return <RelayManagerView {...viewProps} />
}

View File

@ -0,0 +1,100 @@
import { t } from '@/lib/i18n'
import { RelayList } from './RelayList'
import type { RelayManagerContentProps } from './types'
export function RelayManagerContent(params: RelayManagerContentProps): React.ReactElement {
return (
<div className="space-y-6">
{params.error ? <ErrorBanner error={params.error} onClear={params.onClearError} /> : null}
<Header showAddForm={params.showAddForm} onToggleAddForm={params.onToggleAddForm} />
{params.showAddForm ? <AddForm newUrl={params.newUrl} onNewUrlChange={params.onNewUrlChange} onAdd={params.onAddRelay} onCancel={params.onCancelAdd} /> : null}
<RelayList
relays={params.relays}
editingId={params.editingId}
draggedId={params.draggedId}
dragOverId={params.dragOverId}
onStartEditing={params.onStartEditing}
onStopEditing={params.onStopEditing}
onUpdateUrl={params.onUpdateUrl}
onToggleEnabled={params.onToggleEnabled}
onRemoveRelay={params.onRemoveRelay}
onDragStart={params.onDragStart}
onDragOver={params.onDragOver}
onDragLeave={params.onDragLeave}
onDrop={params.onDrop}
onDragEnd={params.onDragEnd}
/>
<Notes />
</div>
)
}
function ErrorBanner(params: { error: string; onClear: () => void }): React.ReactElement {
return (
<div className="bg-red-900/30 border border-red-500/50 rounded p-4 text-red-300">
{params.error}
<button type="button" onClick={params.onClear} className="ml-4 text-red-400 hover:text-red-200">
×
</button>
</div>
)
}
function Header(params: { showAddForm: boolean; onToggleAddForm: () => void }): React.ReactElement {
return (
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.relay.title')}</h2>
<button
type="button"
onClick={params.onToggleAddForm}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
>
{params.showAddForm ? t('settings.relay.add.cancel') : t('settings.relay.addButton')}
</button>
</div>
)
}
function AddForm(params: { newUrl: string; onNewUrlChange: (value: string) => void; onAdd: () => void; onCancel: () => void }): React.ReactElement {
return (
<div className="bg-cyber-dark border border-neon-cyan/30 rounded p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-cyber-accent mb-2">{t('settings.relay.add.url')}</label>
<input
type="text"
value={params.newUrl}
onChange={(e) => params.onNewUrlChange(e.target.value)}
placeholder={t('settings.relay.add.placeholder')}
className="w-full px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={params.onAdd}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
>
{t('settings.relay.add.add')}
</button>
<button
type="button"
onClick={params.onCancel}
className="px-4 py-2 bg-cyber-accent/20 hover:bg-cyber-accent/30 text-cyber-accent border border-cyber-accent/50 rounded transition-colors"
>
{t('settings.relay.add.cancel')}
</button>
</div>
</div>
)
}
function Notes(): React.ReactElement {
return (
<div className="text-sm text-cyber-accent space-y-2">
<p>
<strong>{t('settings.relay.note.title')}</strong> {t('settings.relay.note.priority')}
</p>
<p>{t('settings.relay.note.rotation')}</p>
</div>
)
}

View File

@ -0,0 +1,256 @@
import { configStorage } from '@/lib/configStorage'
import type { RelayConfig } from '@/lib/configStorageTypes'
import { t } from '@/lib/i18n'
import { relaySessionManager } from '@/lib/relaySessionManager'
import { userConfirm } from '@/lib/userConfirm'
export async function loadRelays(params: {
setRelays: (value: RelayConfig[]) => void
setLoading: (value: boolean) => void
setError: (value: string | null) => void
}): Promise<void> {
try {
params.setLoading(true)
params.setError(null)
const config = await configStorage.getConfig()
const updated = await maybeDisableFailedRelays(config.relays)
params.setRelays(sortByPriority(updated))
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.loadFailed')
params.setError(errorMessage)
console.error('[RelayManager] Error loading relays:', e)
} finally {
params.setLoading(false)
}
}
export async function toggleEnabled(params: {
id: string
enabled: boolean
setRelays: (value: RelayConfig[]) => void
setError: (value: string | null) => void
onConfigChange: (() => void) | undefined
}): Promise<void> {
try {
await configStorage.updateRelay(params.id, { enabled: params.enabled })
await reloadRelays({ setRelays: params.setRelays, setError: params.setError })
params.onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.updateFailed')
params.setError(errorMessage)
console.error('[RelayManager] Error updating relay:', e)
}
}
export async function updateUrl(params: {
id: string
url: string
setRelays: (value: RelayConfig[]) => void
setError: (value: string | null) => void
setEditingId: (value: string | null) => void
onConfigChange: (() => void) | undefined
}): Promise<void> {
try {
await configStorage.updateRelay(params.id, { url: params.url })
await reloadRelays({ setRelays: params.setRelays, setError: params.setError })
params.setEditingId(null)
params.onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.urlFailed')
params.setError(errorMessage)
console.error('[RelayManager] Error updating URL:', e)
}
}
export async function addRelay(params: {
newUrl: string
setError: (value: string | null) => void
setNewUrl: (value: string) => void
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
setRelays: (value: RelayConfig[]) => void
onConfigChange: (() => void) | undefined
}): Promise<void> {
const normalizedUrl = normalizeRelayUrl(params.newUrl)
if (!normalizedUrl) {
params.setError(t('settings.relay.error.urlRequired'))
return
}
try {
void new URL(normalizedUrl)
await configStorage.addRelay(normalizedUrl, true)
params.setNewUrl('')
params.setShowAddForm(false)
await reloadRelays({ setRelays: params.setRelays, setError: params.setError })
params.onConfigChange?.()
} catch (e) {
params.setError(getAddRelayErrorMessage(e))
console.error('[RelayManager] Error adding relay:', e)
}
}
export async function removeRelay(params: {
id: string
setRelays: (value: RelayConfig[]) => void
setError: (value: string | null) => void
onConfigChange: (() => void) | undefined
}): Promise<void> {
const confirmed = await userConfirm(t('settings.relay.remove.confirm'))
if (!confirmed) {
return
}
try {
await configStorage.removeRelay(params.id)
await reloadRelays({ setRelays: params.setRelays, setError: params.setError })
params.onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.removeFailed')
params.setError(errorMessage)
console.error('[RelayManager] Error removing relay:', e)
}
}
export async function updatePriorities(params: {
relays: RelayConfig[]
setError: (value: string | null) => void
onConfigChange: (() => void) | undefined
}): Promise<void> {
try {
const updates = params.relays.map((relay, index) => {
const priority = index + 1
return relay.priority !== priority ? configStorage.updateRelay(relay.id, { priority }) : Promise.resolve()
})
await Promise.all(updates)
params.onConfigChange?.()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.priorityFailed')
params.setError(errorMessage)
console.error('[RelayManager] Error updating priorities:', e)
}
}
export function onDragStart(params: {
e: React.DragEvent<HTMLDivElement>
id: string
setDraggedId: (value: string | null) => void
}): void {
params.setDraggedId(params.id)
const { dataTransfer } = params.e
dataTransfer.effectAllowed = 'move'
dataTransfer.setData('text/plain', params.id)
}
export function onDragOver(params: {
e: React.DragEvent<HTMLDivElement>
id: string
setDragOverId: (value: string | null) => void
}): void {
params.e.preventDefault()
const { dataTransfer } = params.e
dataTransfer.dropEffect = 'move'
params.setDragOverId(params.id)
}
export async function onDrop(params: {
e: React.DragEvent<HTMLDivElement>
targetId: string
relays: RelayConfig[]
draggedId: string | null
setRelays: (value: RelayConfig[]) => void
setDraggedId: (value: string | null) => void
setDragOverId: (value: string | null) => void
setError: (value: string | null) => void
onConfigChange: (() => void) | undefined
}): Promise<void> {
params.e.preventDefault()
params.setDragOverId(null)
if (!params.draggedId || params.draggedId === params.targetId) {
params.setDraggedId(null)
return
}
const reordered = reorderRelays({ relays: params.relays, draggedId: params.draggedId, targetId: params.targetId })
if (!reordered) {
params.setDraggedId(null)
return
}
params.setRelays(reordered)
params.setDraggedId(null)
await updatePriorities({ relays: reordered, setError: params.setError, onConfigChange: params.onConfigChange })
}
export function getRelayCardClassName(relayId: string, draggedId: string | null, dragOverId: string | null): string {
if (draggedId === relayId) {
return 'opacity-50 border-neon-cyan'
}
if (dragOverId === relayId) {
return 'border-neon-green shadow-lg'
}
return 'border-neon-cyan/30'
}
function sortByPriority(relays: RelayConfig[]): RelayConfig[] {
return relays.sort((a, b) => a.priority - b.priority)
}
async function reloadRelays(params: { setRelays: (value: RelayConfig[]) => void; setError: (value: string | null) => void }): Promise<void> {
try {
const config = await configStorage.getConfig()
params.setRelays(sortByPriority(config.relays))
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.loadFailed')
params.setError(errorMessage)
console.error('[RelayManager] Error reloading relays:', e)
}
}
async function maybeDisableFailedRelays(relays: RelayConfig[]): Promise<RelayConfig[]> {
const failedRelays = relaySessionManager.getFailedRelays()
if (failedRelays.length === 0) {
return relays
}
const toDisable = relays.filter((relay) => relay.enabled && failedRelays.includes(relay.url))
if (toDisable.length === 0) {
return relays
}
for (const relay of toDisable) {
await configStorage.updateRelay(relay.id, { enabled: false })
}
const updated = await configStorage.getConfig()
return updated.relays
}
function normalizeRelayUrl(rawUrl: string): string | null {
const trimmed = rawUrl.trim()
if (trimmed.length === 0) {
return null
}
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
return trimmed
}
return `wss://${trimmed}`
}
function getAddRelayErrorMessage(error: unknown): string {
if (error instanceof TypeError && error.message.includes('Invalid URL')) {
return t('settings.relay.error.invalidUrl')
}
if (error instanceof Error) {
return error.message
}
return t('settings.relay.error.addFailed')
}
function reorderRelays(params: { relays: RelayConfig[]; draggedId: string; targetId: string }): RelayConfig[] | null {
const draggedIndex = params.relays.findIndex((relay) => relay.id === params.draggedId)
const targetIndex = params.relays.findIndex((relay) => relay.id === params.targetId)
if (draggedIndex === -1 || targetIndex === -1) {
return null
}
const next = [...params.relays]
const removed = next[draggedIndex]
if (!removed) {
return null
}
next.splice(draggedIndex, 1)
next.splice(targetIndex, 0, removed)
return next
}

View File

@ -0,0 +1,29 @@
import type { RelayConfig } from '@/lib/configStorageTypes'
export interface RelayManagerViewProps {
relays: RelayConfig[]
loading: boolean
error: string | null
editingId: string | null
newUrl: string
showAddForm: boolean
draggedId: string | null
dragOverId: string | null
onClearError: () => void
onToggleAddForm: () => void
onNewUrlChange: (value: string) => void
onCancelAdd: () => void
onAddRelay: () => void
onStartEditing: (id: string) => void
onStopEditing: () => void
onUpdateUrl: (id: string, url: string) => void
onToggleEnabled: (id: string, enabled: boolean) => void
onRemoveRelay: (id: string) => void
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
onDragEnd: () => void
}
export type RelayManagerContentProps = Omit<RelayManagerViewProps, 'loading'>

View File

@ -0,0 +1,14 @@
import { t } from '@/lib/i18n'
import { RelayManagerContent } from './RelayManagerContent'
import type { RelayManagerViewProps } from './types'
export function RelayManagerView(params: RelayManagerViewProps): React.ReactElement {
if (params.loading) {
return (
<div className="text-center py-8 text-neon-cyan">
<div>{t('settings.relay.loading')}</div>
</div>
)
}
return <RelayManagerContent {...params} />
}

View File

@ -0,0 +1,6 @@
import type { RelayConfig } from '@/lib/configStorageTypes'
import { loadRelays } from './controller'
export function initialLoadRelays(params: { setRelays: (value: RelayConfig[]) => void; setLoading: (value: boolean) => void; setError: (value: string | null) => void }): void {
void loadRelays({ setRelays: params.setRelays, setLoading: params.setLoading, setError: params.setError })
}

View File

@ -0,0 +1,130 @@
import type { RelayConfig } from '@/lib/configStorageTypes'
import type { RelayManagerProps } from './RelayManager'
import { addRelay, onDragOver, onDragStart, onDrop, removeRelay, toggleEnabled, updateUrl } from './controller'
import type { RelayManagerViewProps } from './types'
export function buildRelayManagerViewProps(params: {
relays: RelayConfig[]
loading: boolean
error: string | null
editingId: string | null
newUrl: string
showAddForm: boolean
draggedId: string | null
dragOverId: string | null
setRelays: (value: RelayConfig[]) => void
setLoading: (value: boolean) => void
setError: (value: string | null) => void
setEditingId: (value: string | null) => void
setNewUrl: (value: string) => void
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
setDraggedId: (value: string | null) => void
setDragOverId: (value: string | null) => void
onConfigChange: RelayManagerProps['onConfigChange']
}): RelayManagerViewProps {
return { ...stateProps(params), ...formProps(params), ...relayActionProps(params), ...dragProps(params) }
}
function stateProps(params: {
relays: RelayConfig[]
loading: boolean
error: string | null
editingId: string | null
newUrl: string
showAddForm: boolean
draggedId: string | null
dragOverId: string | null
}): Pick<RelayManagerViewProps, 'relays' | 'loading' | 'error' | 'editingId' | 'newUrl' | 'showAddForm' | 'draggedId' | 'dragOverId'> {
return {
relays: params.relays,
loading: params.loading,
error: params.error,
editingId: params.editingId,
newUrl: params.newUrl,
showAddForm: params.showAddForm,
draggedId: params.draggedId,
dragOverId: params.dragOverId,
}
}
function formProps(params: {
newUrl: string
setError: (value: string | null) => void
setNewUrl: (value: string) => void
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
setRelays: (value: RelayConfig[]) => void
onConfigChange: RelayManagerProps['onConfigChange']
}): Pick<RelayManagerViewProps, 'onClearError' | 'onToggleAddForm' | 'onNewUrlChange' | 'onCancelAdd' | 'onAddRelay'> {
return {
onClearError: () => params.setError(null),
onToggleAddForm: () => params.setShowAddForm((prev) => !prev),
onNewUrlChange: (value) => params.setNewUrl(value),
onCancelAdd: () => {
params.setShowAddForm(false)
params.setNewUrl('')
params.setError(null)
},
onAddRelay: () =>
void addRelay({
newUrl: params.newUrl,
setError: params.setError,
setNewUrl: params.setNewUrl,
setShowAddForm: params.setShowAddForm,
setRelays: params.setRelays,
onConfigChange: params.onConfigChange,
}),
}
}
function relayActionProps(params: {
setRelays: (value: RelayConfig[]) => void
setError: (value: string | null) => void
setEditingId: (value: string | null) => void
onConfigChange: RelayManagerProps['onConfigChange']
}): Pick<RelayManagerViewProps, 'onStartEditing' | 'onStopEditing' | 'onUpdateUrl' | 'onToggleEnabled' | 'onRemoveRelay'> {
return {
onStartEditing: (id) => params.setEditingId(id),
onStopEditing: () => params.setEditingId(null),
onUpdateUrl: (id, url) =>
void updateUrl({
id,
url,
setRelays: params.setRelays,
setError: params.setError,
setEditingId: params.setEditingId,
onConfigChange: params.onConfigChange,
}),
onToggleEnabled: (id, enabled) =>
void toggleEnabled({ id, enabled, setRelays: params.setRelays, setError: params.setError, onConfigChange: params.onConfigChange }),
onRemoveRelay: (id) => void removeRelay({ id, setRelays: params.setRelays, setError: params.setError, onConfigChange: params.onConfigChange }),
}
}
function dragProps(params: {
relays: RelayConfig[]
draggedId: string | null
setRelays: (value: RelayConfig[]) => void
setDraggedId: (value: string | null) => void
setDragOverId: (value: string | null) => void
setError: (value: string | null) => void
onConfigChange: RelayManagerProps['onConfigChange']
}): Pick<RelayManagerViewProps, 'onDragStart' | 'onDragOver' | 'onDragLeave' | 'onDrop' | 'onDragEnd'> {
return {
onDragStart: (e, id) => onDragStart({ e, id, setDraggedId: params.setDraggedId }),
onDragOver: (e, id) => onDragOver({ e, id, setDragOverId: params.setDragOverId }),
onDragLeave: () => params.setDragOverId(null),
onDrop: (e, targetId) =>
void onDrop({
e,
targetId,
relays: params.relays,
draggedId: params.draggedId,
setRelays: params.setRelays,
setDraggedId: params.setDraggedId,
setDragOverId: params.setDragOverId,
setError: params.setError,
onConfigChange: params.onConfigChange,
}),
onDragEnd: () => params.setDraggedId(null),
}
}

View File

@ -0,0 +1,22 @@
import { t } from '@/lib/i18n'
import { useSyncProgressBarController } from './controller'
import { SyncDateRange, SyncErrorBanner, SyncProgressSection, SyncResyncButton, SyncStatusMessage } from './view'
export function SyncProgressBar(): React.ReactElement | null {
const controller = useSyncProgressBarController()
if (!controller) {
return null
}
return (
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-4 mt-6">
<SyncErrorBanner error={controller.error} onDismiss={controller.dismissError} />
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold text-neon-cyan">{t('settings.sync.title')}</h3>
<SyncResyncButton isSyncing={controller.isSyncing} onClick={controller.onResyncClick} />
</div>
<SyncDateRange totalDays={controller.totalDays} startDate={controller.startDateLabel} endDate={controller.endDateLabel} />
<SyncProgressSection isSyncing={controller.isSyncing} syncProgress={controller.syncProgress} progressPercentage={controller.progressPercentage} />
<SyncStatusMessage isSyncing={controller.isSyncing} totalDays={controller.totalDays} isRecentlySynced={controller.isRecentlySynced} />
</div>
)
}

View File

@ -0,0 +1,216 @@
import { useCallback, useEffect, useState } from 'react'
import { nostrAuthService } from '@/lib/nostrAuth'
import { MIN_EVENT_DATE } from '@/lib/platformConfig'
import { objectCache } from '@/lib/objectCache'
import { calculateDaysBetween, getCurrentTimestamp, getLastSyncDate, setLastSyncDate as setLastSyncDateStorage } from '@/lib/syncStorage'
import { useSyncProgress } from '@/lib/hooks/useSyncProgress'
import type { SyncProgress, SyncProgressBarController } from './types'
type ConnectionState = { isInitialized: boolean; connected: boolean; pubkey: string | null }
export function useSyncProgressBarController(): SyncProgressBarController | null {
const connection = useNostrConnectionState()
const [error, setError] = useState<string | null>(null)
const syncStatus = useSyncStatus()
const { syncProgress, isSyncing, startMonitoring, stopMonitoring } = useSyncProgress({ onComplete: syncStatus.loadSyncStatus })
useAutoSyncEffect({ connection, isSyncing, loadSyncStatus: syncStatus.loadSyncStatus, startMonitoring, stopMonitoring, setError })
if (!connection.isInitialized || !connection.connected || !connection.pubkey) {
return null
}
const progressPercentage = computeProgressPercentage(syncProgress)
const isRecentlySynced = isRecentlySyncedFromLastSyncDate(syncStatus.lastSyncDate)
const startDate = getSyncStartDate(syncStatus.lastSyncDate)
const endDate = getCurrentTimestamp()
return {
error,
dismissError: () => setError(null),
isSyncing,
syncProgress,
progressPercentage,
totalDays: syncStatus.totalDays,
startDateLabel: formatSyncDate(startDate),
endDateLabel: formatSyncDate(endDate),
isRecentlySynced,
onResyncClick: () => {
void resynchronizeUserContent({ startMonitoring, stopMonitoring, loadSyncStatus: syncStatus.loadSyncStatus, setError })
},
}
}
function useNostrConnectionState(): ConnectionState {
const initial = nostrAuthService.getState()
const [state, setState] = useState<{ connected: boolean; pubkey: string | null }>(() => ({
connected: initial.connected ?? false,
pubkey: initial.pubkey ?? null,
}))
const [isInitialized] = useState(true)
useEffect(() => {
const unsubscribe = nostrAuthService.subscribe((next) => setState({ connected: next.connected ?? false, pubkey: next.pubkey ?? null }))
return () => unsubscribe()
}, [])
return { isInitialized, connected: state.connected, pubkey: state.pubkey }
}
function useSyncStatus(): { lastSyncDate: number | null; totalDays: number; loadSyncStatus: () => Promise<void> } {
const [lastSyncDate, setLastSyncDate] = useState<number | null>(null)
const [totalDays, setTotalDays] = useState<number>(0)
const loadSyncStatus = useCallback(async (): Promise<void> => {
try {
const state = nostrAuthService.getState()
if (!state.connected || !state.pubkey) {
return
}
const storedLastSyncDate = await getLastSyncDate()
const days = calculateDaysBetween(storedLastSyncDate, getCurrentTimestamp())
setLastSyncDate(storedLastSyncDate)
setTotalDays(days)
} catch (loadError) {
console.error('[SyncProgressBar] Error loading sync status:', loadError)
}
}, [])
return { lastSyncDate, totalDays, loadSyncStatus }
}
function useAutoSyncEffect(params: {
connection: ConnectionState
isSyncing: boolean
loadSyncStatus: () => Promise<void>
startMonitoring: () => void
stopMonitoring: () => void
setError: (value: string | null) => void
}): void {
useEffect(() => {
if (!params.connection.isInitialized || !params.connection.connected || !params.connection.pubkey) {
return
}
void runAutoSyncCheck({
connection: { connected: true, pubkey: params.connection.pubkey },
isSyncing: params.isSyncing,
loadSyncStatus: params.loadSyncStatus,
startMonitoring: params.startMonitoring,
stopMonitoring: params.stopMonitoring,
setError: params.setError,
})
}, [params.connection.connected, params.connection.isInitialized, params.connection.pubkey, params.isSyncing, params.loadSyncStatus, params.setError, params.startMonitoring, params.stopMonitoring])
}
function formatSyncDate(timestamp: number): string {
const date = new Date(timestamp * 1000)
const locale = typeof window !== 'undefined' ? navigator.language : 'fr-FR'
return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function getSyncStartDate(lastSyncDate: number | null): number {
return lastSyncDate ?? MIN_EVENT_DATE
}
function isRecentlySyncedFromLastSyncDate(lastSyncDate: number | null): boolean {
return lastSyncDate !== null && lastSyncDate >= getCurrentTimestamp() - 3600
}
async function resynchronizeUserContent(params: {
startMonitoring: () => void
stopMonitoring: () => void
loadSyncStatus: () => Promise<void>
setError: (value: string | null) => void
}): Promise<void> {
try {
const state = nostrAuthService.getState()
if (!state.connected || !state.pubkey) {
return
}
await clearUserContentCache()
await setLastSyncDateStorage(MIN_EVENT_DATE)
await params.loadSyncStatus()
await startUserSyncOrStop({ pubkey: state.pubkey, startMonitoring: params.startMonitoring, stopMonitoring: params.stopMonitoring })
} catch (resyncError) {
console.error('[SyncProgressBar] Error resynchronizing:', resyncError)
params.stopMonitoring()
params.setError(resyncError instanceof Error ? resyncError.message : 'Erreur de synchronisation')
}
}
async function clearUserContentCache(): Promise<void> {
await Promise.all([
objectCache.clear('author'),
objectCache.clear('series'),
objectCache.clear('publication'),
objectCache.clear('review'),
objectCache.clear('purchase'),
objectCache.clear('sponsoring'),
objectCache.clear('review_tip'),
])
}
async function startUserSyncOrStop(params: { pubkey: string; startMonitoring: () => void; stopMonitoring: () => void }): Promise<void> {
const { swClient } = await import('@/lib/swClient')
const isReady = await swClient.isReady()
if (!isReady) {
params.stopMonitoring()
return
}
await swClient.startUserSync(params.pubkey)
params.startMonitoring()
}
function computeProgressPercentage(syncProgress: SyncProgress): number {
if (!syncProgress || syncProgress.totalSteps <= 0) {
return 0
}
return Math.min(100, (syncProgress.currentStep / syncProgress.totalSteps) * 100)
}
async function runAutoSyncCheck(params: {
connection: { connected: boolean; pubkey: string | null }
isSyncing: boolean
loadSyncStatus: () => Promise<void>
startMonitoring: () => void
stopMonitoring: () => void
setError: (value: string | null) => void
}): Promise<void> {
console.warn('[SyncProgressBar] Starting sync check...')
await params.loadSyncStatus()
const shouldStart = await shouldAutoStartSync({ isSyncing: params.isSyncing, pubkey: params.connection.pubkey })
if (!shouldStart || !params.connection.pubkey) {
console.warn('[SyncProgressBar] Skipping auto-sync:', { shouldStart, isSyncing: params.isSyncing, hasPubkey: Boolean(params.connection.pubkey) })
return
}
console.warn('[SyncProgressBar] Starting auto-sync...')
await startAutoSync({
pubkey: params.connection.pubkey,
startMonitoring: params.startMonitoring,
stopMonitoring: params.stopMonitoring,
setError: params.setError,
})
}
async function shouldAutoStartSync(params: { isSyncing: boolean; pubkey: string | null }): Promise<boolean> {
if (params.isSyncing || !params.pubkey) {
return false
}
const storedLastSyncDate = await getLastSyncDate()
const currentTimestamp = getCurrentTimestamp()
const isRecentlySynced = storedLastSyncDate >= currentTimestamp - 3600
console.warn('[SyncProgressBar] Sync status:', { storedLastSyncDate, currentTimestamp, isRecentlySynced })
return !isRecentlySynced
}
async function startAutoSync(params: { pubkey: string; startMonitoring: () => void; stopMonitoring: () => void; setError: (value: string | null) => void }): Promise<void> {
try {
const { swClient } = await import('@/lib/swClient')
const isReady = await swClient.isReady()
if (!isReady) {
params.stopMonitoring()
return
}
await swClient.startUserSync(params.pubkey)
params.startMonitoring()
} catch (autoSyncError) {
console.error('[SyncProgressBar] Error during auto-sync:', autoSyncError)
params.stopMonitoring()
params.setError(autoSyncError instanceof Error ? autoSyncError.message : 'Erreur de synchronisation')
}
}

View File

@ -0,0 +1,16 @@
import type { useSyncProgress } from '@/lib/hooks/useSyncProgress'
export type SyncProgress = ReturnType<typeof useSyncProgress>['syncProgress']
export type SyncProgressBarController = {
error: string | null
dismissError: () => void
isSyncing: boolean
syncProgress: SyncProgress
progressPercentage: number
totalDays: number
startDateLabel: string
endDateLabel: string
isRecentlySynced: boolean
onResyncClick: () => void
}

View File

@ -0,0 +1,73 @@
import { t } from '@/lib/i18n'
import type { SyncProgress } from './types'
export function SyncErrorBanner(params: { error: string | null; onDismiss: () => void }): React.ReactElement | null {
if (!params.error) {
return null
}
return (
<div className="mb-4 bg-red-900/30 border border-red-500/50 rounded p-3 text-red-300 text-sm">
{params.error}
<button type="button" onClick={params.onDismiss} className="ml-2 text-red-400 hover:text-red-200">
×
</button>
</div>
)
}
export function SyncResyncButton(params: { isSyncing: boolean; onClick: () => void }): React.ReactElement | null {
if (params.isSyncing) {
return null
}
return (
<button
type="button"
onClick={params.onClick}
className="px-3 py-1 text-xs bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded border border-neon-cyan/50 hover:border-neon-cyan transition-colors"
>
{t('settings.sync.resync')}
</button>
)
}
export function SyncDateRange(params: { totalDays: number; startDate: string; endDate: string }): React.ReactElement | null {
if (params.totalDays <= 0) {
return null
}
return (
<div className="mb-2">
<p className="text-sm text-cyber-accent">
{t('settings.sync.daysRange', { startDate: params.startDate, endDate: params.endDate, days: params.totalDays })}
</p>
</div>
)
}
export function SyncProgressSection(params: { isSyncing: boolean; syncProgress: SyncProgress; progressPercentage: number }): React.ReactElement | null {
if (!params.isSyncing || !params.syncProgress) {
return null
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-cyber-accent">
{t('settings.sync.progress', { current: params.syncProgress.currentStep, total: params.syncProgress.totalSteps })}
</span>
<span className="text-neon-cyan font-semibold">{Math.round(params.progressPercentage)}%</span>
</div>
<div className="w-full bg-cyber-dark rounded-full h-2 overflow-hidden">
<div className="bg-neon-cyan h-full transition-all duration-300" style={{ width: `${params.progressPercentage}%` }} />
</div>
</div>
)
}
export function SyncStatusMessage(params: { isSyncing: boolean; totalDays: number; isRecentlySynced: boolean }): React.ReactElement | null {
if (params.isSyncing || params.totalDays !== 0) {
return null
}
if (params.isRecentlySynced) {
return <p className="text-sm text-green-400">{t('settings.sync.completed')}</p>
}
return <p className="text-sm text-cyber-accent">{t('settings.sync.ready')}</p>
}

View File

@ -0,0 +1,26 @@
export function UnlockAccountButtons(params: {
loading: boolean
words: string[]
onUnlock: () => void
onClose: () => void
}): React.ReactElement {
return (
<div className="flex gap-4">
<button
type="button"
onClick={params.onClose}
className="flex-1 py-2 px-4 bg-gray-200 hover:bg-gray-300 rounded-lg font-medium transition-colors"
>
Annuler
</button>
<button
type="button"
onClick={params.onUnlock}
disabled={params.loading || params.words.some((word) => !word)}
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
>
{params.loading ? 'Déverrouillage...' : 'Déverrouiller'}
</button>
</div>
)
}

View File

@ -0,0 +1,45 @@
import { WordInputWithAutocomplete } from './WordInputWithAutocomplete'
export function UnlockAccountForm(params: {
words: string[]
onWordChange: (index: number, value: string) => void
onPaste: () => void
}): React.ReactElement {
return (
<div className="mb-4">
<WordInputsGrid words={params.words} onWordChange={params.onWordChange} />
<button
type="button"
onClick={params.onPaste}
className="mt-2 text-sm text-gray-600 hover:text-gray-800 underline"
>
Coller depuis le presse-papiers
</button>
</div>
)
}
function WordInputsGrid(params: { words: string[]; onWordChange: (index: number, value: string) => void }): React.ReactElement {
return (
<div className="grid grid-cols-2 gap-4">
<WordInputSlot index={0} value={params.words[0] ?? ''} onWordChange={params.onWordChange} />
<WordInputSlot index={1} value={params.words[1] ?? ''} onWordChange={params.onWordChange} />
<WordInputSlot index={2} value={params.words[2] ?? ''} onWordChange={params.onWordChange} />
<WordInputSlot index={3} value={params.words[3] ?? ''} onWordChange={params.onWordChange} />
</div>
)
}
function WordInputSlot(params: { index: number; value: string; onWordChange: (index: number, value: string) => void }): React.ReactElement {
const onFocus = (): void => {}
const onBlur = (): void => {}
return (
<WordInputWithAutocomplete
index={params.index}
value={params.value}
onChange={(value) => params.onWordChange(params.index, value)}
onFocus={onFocus}
onBlur={onBlur}
/>
)
}

View File

@ -0,0 +1,35 @@
import { useState } from 'react'
import type { UnlockAccountModalProps } from './types'
import { UnlockAccountButtons } from './UnlockAccountButtons'
import { UnlockAccountForm } from './UnlockAccountForm'
import { useUnlockAccountController } from './useUnlockAccountController'
export function UnlockAccountModal(params: UnlockAccountModalProps): React.ReactElement {
const [words, setWords] = useState<string[]>(['', '', '', ''])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const controller = useUnlockAccountController({
words,
setWords,
setLoading,
setError,
onSuccess: params.onSuccess,
onClose: params.onClose,
})
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4">Déverrouiller votre compte</h2>
<p className="text-gray-600 mb-6">
Entrez vos 4 mots-clés de récupération (dictionnaire BIP39) pour déverrouiller votre compte. Ces mots déchiffrent la clé de chiffrement
(KEK) stockée dans l&apos;API Credentials, qui déchiffre ensuite votre clé privée.
</p>
<UnlockAccountForm words={words} onWordChange={controller.handleWordChange} onPaste={controller.handlePaste} />
{error ? <p className="text-sm text-red-600 mb-4">{error}</p> : null}
<UnlockAccountButtons loading={loading} words={words} onUnlock={controller.handleUnlock} onClose={params.onClose} />
</div>
</div>
)
}

View File

@ -0,0 +1,52 @@
import { useRef } from 'react'
import { useWordAutocomplete } from './useWordAutocomplete'
import { WordSuggestions } from './WordSuggestions'
export interface WordInputWithAutocompleteProps {
index: number
value: string
onChange: (value: string) => void
onFocus: () => void
onBlur: () => void
}
export function WordInputWithAutocomplete(params: WordInputWithAutocompleteProps): React.ReactElement {
const inputRef = useRef<HTMLInputElement>(null)
const autocomplete = useWordAutocomplete({ value: params.value, onChange: params.onChange, inputRef })
return (
<div className="relative">
<label htmlFor={`word-${params.index}`} className="block text-sm font-medium text-gray-700 mb-2">
Mot {params.index + 1}
</label>
<input
ref={inputRef}
id={`word-${params.index}`}
type="text"
value={params.value}
onChange={autocomplete.handleChange}
onKeyDown={autocomplete.handleKeyDown}
onFocus={params.onFocus}
onBlur={() => hideSuggestionsOnBlur({ onBlur: params.onBlur, setShowSuggestions: autocomplete.setShowSuggestions })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-lg text-center"
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
/>
<WordSuggestions
showSuggestions={autocomplete.showSuggestions}
suggestions={autocomplete.suggestions}
selectedIndex={autocomplete.selectedIndex}
onSuggestionClick={autocomplete.applySuggestion}
/>
</div>
)
}
function hideSuggestionsOnBlur(params: { onBlur: () => void; setShowSuggestions: (value: boolean) => void }): void {
setTimeout(() => {
params.setShowSuggestions(false)
params.onBlur()
}, 200)
}

View File

@ -0,0 +1,24 @@
export function WordSuggestions(params: {
showSuggestions: boolean
suggestions: string[]
selectedIndex: number
onSuggestionClick: (suggestion: string) => void
}): React.ReactElement | null {
if (!params.showSuggestions || params.suggestions.length === 0) {
return null
}
return (
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-40 overflow-y-auto">
{params.suggestions.map((suggestion, idx) => (
<button
key={suggestion}
type="button"
onClick={() => params.onSuggestionClick(suggestion)}
className={`w-full text-left px-3 py-2 hover:bg-gray-100 ${idx === params.selectedIndex ? 'bg-gray-100' : ''}`}
>
{suggestion}
</button>
))}
</div>
)
}

View File

@ -0,0 +1,27 @@
export type AutocompleteKeyDecision =
| { action: 'none' }
| { action: 'escape' }
| { action: 'move'; nextIndex: number }
| { action: 'select'; index: number }
export function decideAutocompleteKeyAction(params: {
key: string
selectedIndex: number
suggestionsCount: number
}): AutocompleteKeyDecision {
if (params.key === 'Escape') {
return { action: 'escape' }
}
if (params.key === 'ArrowDown') {
const nextIndex = params.selectedIndex < params.suggestionsCount - 1 ? params.selectedIndex + 1 : params.selectedIndex
return { action: 'move', nextIndex }
}
if (params.key === 'ArrowUp') {
const nextIndex = params.selectedIndex > 0 ? params.selectedIndex - 1 : -1
return { action: 'move', nextIndex }
}
if (params.key === 'Enter' && params.selectedIndex >= 0) {
return { action: 'select', index: params.selectedIndex }
}
return { action: 'none' }
}

View File

@ -0,0 +1,4 @@
export interface UnlockAccountModalProps {
onSuccess: () => void
onClose: () => void
}

View File

@ -0,0 +1,92 @@
import { nostrAuthService } from '@/lib/nostrAuth'
export interface UseUnlockAccountControllerResult {
handleWordChange: (index: number, value: string) => void
handlePaste: () => void
handleUnlock: () => void
}
export function useUnlockAccountController(params: {
words: string[]
setWords: (words: string[]) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
onSuccess: () => void
onClose: () => void
}): UseUnlockAccountControllerResult {
const handleWordChange = (index: number, value: string): void => {
const nextWords = buildNextWords({ current: params.words, index, value })
params.setWords(nextWords)
params.setError(null)
}
const handlePaste = (): void => {
void pasteWordsFromClipboard({ setWords: params.setWords, setError: params.setError })
}
const handleUnlock = (): void => {
void unlockAccount({
words: params.words,
setLoading: params.setLoading,
setError: params.setError,
onSuccess: params.onSuccess,
onClose: params.onClose,
})
}
return { handleWordChange, handlePaste, handleUnlock }
}
function buildNextWords(params: { current: string[]; index: number; value: string }): string[] {
const next = [...params.current]
next[params.index] = normalizeWord(params.value)
return next
}
function normalizeWord(value: string): string {
return value.trim().toLowerCase()
}
async function pasteWordsFromClipboard(params: {
setWords: (words: string[]) => void
setError: (error: string | null) => void
}): Promise<void> {
try {
const text = await navigator.clipboard.readText()
const words = text.trim().split(/\s+/).slice(0, 4).map(normalizeWord)
if (words.length !== 4) {
params.setError('Le presse-papiers ne contient pas 4 mots.')
return
}
params.setWords(words)
params.setError(null)
} catch (e) {
console.error('[UnlockAccountModal] Error reading clipboard:', e)
params.setError('Impossible de lire le presse-papiers.')
}
}
async function unlockAccount(params: {
words: string[]
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
onSuccess: () => void
onClose: () => void
}): Promise<void> {
if (params.words.some((word) => !word)) {
params.setError('Veuillez remplir tous les mots-clés')
return
}
params.setLoading(true)
params.setError(null)
try {
await nostrAuthService.unlockAccount(params.words)
params.onSuccess()
params.onClose()
} catch (e) {
console.error('[UnlockAccountModal] Error unlocking account:', e)
params.setError(e instanceof Error ? e.message : 'Échec du déverrouillage. Vérifiez vos mots-clés.')
} finally {
params.setLoading(false)
}
}

View File

@ -0,0 +1,108 @@
import { useMemo, useState } from 'react'
import { getWordSuggestions } from '@/lib/keyManagementBIP39'
import { decideAutocompleteKeyAction } from './autocompleteKeyDecision'
export interface WordAutocompleteState {
suggestions: string[]
showSuggestions: boolean
setShowSuggestions: (value: boolean) => void
selectedIndex: number
handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
applySuggestion: (suggestion: string) => void
}
export function useWordAutocomplete(params: {
value: string
onChange: (value: string) => void
inputRef: React.RefObject<HTMLInputElement | null>
}): WordAutocompleteState {
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const suggestions = useWordSuggestions(params.value)
const applySuggestion = (suggestion: string): void => {
applySuggestionImpl({ suggestion, onChange: params.onChange, setShowSuggestions, inputRef: params.inputRef })
}
const handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
handleChangeImpl({ event, onChange: params.onChange, setShowSuggestions, setSelectedIndex })
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
handleKeyDownImpl({
event,
suggestions,
selectedIndex,
setSelectedIndex,
setShowSuggestions,
inputRef: params.inputRef,
applySuggestion,
})
}
return { suggestions, showSuggestions, setShowSuggestions, selectedIndex, handleChange, handleKeyDown, applySuggestion }
}
function useWordSuggestions(value: string): string[] {
return useMemo((): string[] => {
if (value.length === 0) {
return []
}
return getWordSuggestions(value, 5)
}, [value])
}
function applySuggestionImpl(params: {
suggestion: string
onChange: (value: string) => void
setShowSuggestions: (value: boolean) => void
inputRef: React.RefObject<HTMLInputElement | null>
}): void {
params.onChange(params.suggestion)
params.setShowSuggestions(false)
params.inputRef.current?.blur()
}
function handleChangeImpl(params: {
event: React.ChangeEvent<HTMLInputElement>
onChange: (value: string) => void
setShowSuggestions: (value: boolean) => void
setSelectedIndex: (value: number) => void
}): void {
const newValue = params.event.target.value.trim().toLowerCase()
params.setSelectedIndex(-1)
params.setShowSuggestions(newValue.length > 0)
params.onChange(newValue)
}
function handleKeyDownImpl(params: {
event: React.KeyboardEvent<HTMLInputElement>
suggestions: string[]
selectedIndex: number
setSelectedIndex: (value: number) => void
setShowSuggestions: (value: boolean) => void
inputRef: React.RefObject<HTMLInputElement | null>
applySuggestion: (suggestion: string) => void
}): void {
const decision = decideAutocompleteKeyAction({
key: params.event.key,
selectedIndex: params.selectedIndex,
suggestionsCount: params.suggestions.length,
})
if (decision.action === 'none') {
return
}
params.event.preventDefault()
if (decision.action === 'move') {
params.setSelectedIndex(decision.nextIndex)
return
}
if (decision.action === 'escape') {
params.setShowSuggestions(false)
params.inputRef.current?.blur()
return
}
const suggestion = params.suggestions[decision.index]
if (suggestion) {
params.applySuggestion(suggestion)
}
}

View File

@ -0,0 +1,17 @@
import { useUserArticlesController } from './controller'
import { UserArticlesLayout } from './layout'
import type { UserArticlesProps } from './types'
export function UserArticles(props: UserArticlesProps): React.ReactElement {
const controller = useUserArticlesController({ articles: props.articles, onLoadContent: props.onLoadContent, currentPubkey: props.currentPubkey })
return (
<UserArticlesLayout
controller={controller}
loading={props.loading}
error={props.error}
showEmptyMessage={props.showEmptyMessage ?? true}
currentPubkey={props.currentPubkey}
onSelectSeries={props.onSelectSeries}
/>
)
}

View File

@ -0,0 +1,101 @@
import { useMemo, useState } from 'react'
import type { Article } from '@/types/nostr'
import { useArticleEditing } from '@/hooks/useArticleEditing'
import type { ArticleDraft } from '@/lib/articlePublisherTypes'
import type { UserArticlesController, UserArticlesControllerParams } from './types'
export function useUserArticlesController(params: UserArticlesControllerParams): UserArticlesController {
const [deletedArticleIds, setDeletedArticleIds] = useState<Set<string>>(new Set())
const [articleOverridesById, setArticleOverridesById] = useState<Map<string, Article>>(new Map())
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const editingCtx = useArticleEditing(params.currentPubkey)
const localArticles = useMemo((): Article[] => {
return params.articles.filter((a) => !deletedArticleIds.has(a.id)).map((a) => articleOverridesById.get(a.id) ?? a)
}, [articleOverridesById, deletedArticleIds, params.articles])
return {
localArticles,
unlockedArticles,
pendingDeleteId,
requestDelete: (id: string) => setPendingDeleteId(id),
handleUnlock: createHandleUnlock(params.onLoadContent, setUnlockedArticles),
handleDelete: createHandleDelete(editingCtx.deleteArticle, setDeletedArticleIds, setPendingDeleteId),
handleEditSubmit: createHandleEditSubmit(editingCtx.submitEdit, editingCtx.editingDraft, params.currentPubkey, setArticleOverridesById),
...editingCtx,
}
}
function createHandleUnlock(
onLoadContent: (id: string, pubkey: string) => Promise<Article | null>,
setUnlocked: React.Dispatch<React.SetStateAction<Set<string>>>
): (article: Article) => Promise<void> {
return async (article: Article): Promise<void> => {
const full = await onLoadContent(article.id, article.pubkey)
if (full?.paid) {
setUnlocked((prev) => new Set([...prev, article.id]))
}
}
}
function createHandleDelete(
deleteArticle: (id: string) => Promise<boolean>,
setDeletedArticleIds: React.Dispatch<React.SetStateAction<Set<string>>>,
setPendingDeleteId: React.Dispatch<React.SetStateAction<string | null>>
): (article: Article) => Promise<void> {
return async (article: Article): Promise<void> => {
const ok = await deleteArticle(article.id)
if (ok) {
setDeletedArticleIds((prev) => new Set([...prev, article.id]))
}
setPendingDeleteId(null)
}
}
function createHandleEditSubmit(
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>,
draft: ReturnType<typeof useArticleEditing>['editingDraft'],
currentPubkey: string | null,
setArticleOverridesById: React.Dispatch<React.SetStateAction<Map<string, Article>>>
): () => Promise<void> {
return async (): Promise<void> => {
const result = await submitEdit()
if (result && draft) {
const updated = buildUpdatedArticle(draft, currentPubkey ?? '', result.articleId)
setArticleOverridesById((prev) => {
const next = new Map(prev)
next.set(result.originalArticleId, { ...updated, id: result.originalArticleId })
return next
})
}
}
}
function buildUpdatedArticle(draft: ArticleDraft, pubkey: string, newId: string): Article {
const parts = newId.split('_')
const hash = parts[0] ?? ''
const index = Number.parseInt(parts[1] ?? '0', 10)
const version = Number.parseInt(parts[2] ?? '0', 10)
return {
id: newId,
hash,
version,
index,
pubkey,
title: draft.title,
preview: draft.preview,
content: '',
description: draft.preview,
contentDescription: draft.preview,
createdAt: Math.floor(Date.now() / 1000),
zapAmount: draft.zapAmount,
paid: false,
thumbnailUrl: draft.bannerUrl ?? '',
...(draft.category ? { category: draft.category } : {}),
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
...(draft.media ? { media: draft.media } : {}),
...(draft.pages ? { pages: draft.pages } : {}),
}
}

View File

@ -0,0 +1,93 @@
import type { Article } from '@/types/nostr'
import type { ArticleDraft } from '@/lib/articlePublisherTypes'
import { EditPanel } from '../UserArticlesEditPanel'
import { UserArticlesView } from '../UserArticlesList'
import type { UserArticlesController } from './types'
type UserArticlesLayoutProps = {
controller: UserArticlesController
loading: boolean
error: string | null
showEmptyMessage: boolean
currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
export function UserArticlesLayout(props: UserArticlesLayoutProps): React.ReactElement {
const { editPanelProps, listProps } = createLayoutProps(props.controller, props)
return (
<div className="space-y-4">
<EditPanel {...editPanelProps} />
<UserArticlesView {...listProps} />
</div>
)
}
function createLayoutProps(controller: UserArticlesController, view: UserArticlesLayoutProps): { editPanelProps: EditPanelProps; listProps: UserArticlesListProps } {
return { editPanelProps: buildEditPanelProps(controller), listProps: buildListProps(controller, view) }
}
type EditPanelProps = {
draft: ArticleDraft | null
editingArticleId: string | null
loading: boolean
error: string | null
onCancel: () => void
onDraftChange: (draft: ArticleDraft) => void
onSubmit: () => Promise<void>
}
function buildEditPanelProps(controller: UserArticlesController): EditPanelProps {
return {
draft: controller.editingDraft,
editingArticleId: controller.editingArticleId,
loading: controller.loading,
error: controller.error,
onCancel: controller.cancelEditing,
onDraftChange: controller.updateDraft,
onSubmit: controller.handleEditSubmit,
}
}
type UserArticlesListProps = {
articles: Article[]
loading: boolean
error: string | null
showEmptyMessage: boolean
unlockedArticles: Set<string>
onUnlock: (article: Article) => void
onEdit: (article: Article) => void
onDelete: (article: Article) => void
editingArticleId: string | null
currentPubkey: string | null
pendingDeleteId: string | null
requestDelete: (articleId: string) => void
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
function buildListProps(controller: UserArticlesController, view: UserArticlesLayoutProps): UserArticlesListProps {
const handlers = buildUserArticlesHandlers(controller)
return {
articles: controller.localArticles,
loading: view.loading,
error: view.error,
showEmptyMessage: view.showEmptyMessage,
unlockedArticles: controller.unlockedArticles,
onUnlock: handlers.onUnlock,
onEdit: handlers.onEdit,
onDelete: handlers.onDelete,
editingArticleId: controller.editingArticleId,
currentPubkey: view.currentPubkey,
pendingDeleteId: controller.pendingDeleteId,
requestDelete: controller.requestDelete,
...(view.onSelectSeries ? { onSelectSeries: view.onSelectSeries } : {}),
}
}
function buildUserArticlesHandlers(controller: UserArticlesController): { onUnlock: (article: Article) => void; onEdit: (article: Article) => void; onDelete: (article: Article) => void } {
return {
onUnlock: (a: Article): void => void controller.handleUnlock(a),
onEdit: (a: Article): void => void controller.startEditing(a),
onDelete: (a: Article): void => void controller.handleDelete(a),
}
}

View File

@ -0,0 +1,45 @@
import type { Dispatch, SetStateAction } from 'react'
import type { Article } from '@/types/nostr'
import type { ArticleDraft } from '@/lib/articlePublisherTypes'
import type { useArticleEditing } from '@/hooks/useArticleEditing'
export interface UserArticlesProps {
articles: Article[]
loading: boolean
error: string | null
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
showEmptyMessage?: boolean
currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
export type UserArticlesController = {
localArticles: Article[]
unlockedArticles: Set<string>
pendingDeleteId: string | null
requestDelete: (id: string) => void
handleUnlock: (article: Article) => Promise<void>
handleDelete: (article: Article) => Promise<void>
handleEditSubmit: () => Promise<void>
editingDraft: ArticleDraft | null
editingArticleId: string | null
loading: boolean
error: string | null
updateDraft: (draft: ArticleDraft) => void
startEditing: (article: Article) => Promise<void>
cancelEditing: () => void
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>
deleteArticle: (id: string) => Promise<boolean>
}
export type UserArticlesControllerParams = {
articles: Article[]
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
currentPubkey: string | null
}
export type SetStringSet = Dispatch<SetStateAction<Set<string>>>
export type SetStringOrNull = Dispatch<SetStateAction<string | null>>
export type SetArticleMap = Dispatch<SetStateAction<Map<string, Article>>>
export type EditingContext = ReturnType<typeof useArticleEditing>

View File

@ -43,7 +43,8 @@ async function unlockArticlePayment(params: {
setPaymentInvoice: (value: AlbyInvoice | null) => void setPaymentInvoice: (value: AlbyInvoice | null) => void
setPaymentHash: (value: string | null) => void setPaymentHash: (value: string | null) => void
}): Promise<void> { }): Promise<void> {
if (!params.pubkey) { const {pubkey} = params
if (!pubkey) {
await ensureConnectedOrError({ await ensureConnectedOrError({
connect: params.connect, connect: params.connect,
setLoading: params.setLoading, setLoading: params.setLoading,
@ -52,13 +53,40 @@ async function unlockArticlePayment(params: {
return return
} }
await startArticlePaymentFlow({
article: params.article,
pubkey,
onUnlockSuccess: params.onUnlockSuccess,
setLoading: params.setLoading,
setError: params.setError,
setPaymentInvoice: params.setPaymentInvoice,
setPaymentHash: params.setPaymentHash,
})
}
function readPaymentResult(value: Awaited<ReturnType<typeof paymentService.createArticlePayment>>): { invoice: AlbyInvoice; paymentHash: string } | null {
if (!value.success || !value.invoice || !value.paymentHash) {
return null
}
return { invoice: value.invoice, paymentHash: value.paymentHash }
}
async function startArticlePaymentFlow(params: {
article: Article
pubkey: string
onUnlockSuccess: (() => void) | undefined
setLoading: (value: boolean) => void
setError: (value: string | null) => void
setPaymentInvoice: (value: AlbyInvoice | null) => void
setPaymentHash: (value: string | null) => void
}): Promise<void> {
params.setLoading(true) params.setLoading(true)
params.setError(null) params.setError(null)
try { try {
const paymentResult = await paymentService.createArticlePayment({ article: params.article, userPubkey: params.pubkey }) const result = await paymentService.createArticlePayment({ article: params.article, userPubkey: params.pubkey })
const ok = readPaymentResult(paymentResult) const ok = readPaymentResult(result)
if (!ok) { if (!ok) {
params.setError(paymentResult.error ?? 'Failed to create payment invoice') params.setError(result.error ?? 'Failed to create payment invoice')
return return
} }
params.setPaymentInvoice(ok.invoice) params.setPaymentInvoice(ok.invoice)
@ -73,13 +101,6 @@ async function unlockArticlePayment(params: {
} }
} }
function readPaymentResult(value: Awaited<ReturnType<typeof paymentService.createArticlePayment>>): { invoice: AlbyInvoice; paymentHash: string } | null {
if (!value.success || !value.invoice || !value.paymentHash) {
return null
}
return { invoice: value.invoice, paymentHash: value.paymentHash }
}
async function ensureConnectedOrError(params: { async function ensureConnectedOrError(params: {
connect: (() => Promise<void>) | undefined connect: (() => Promise<void>) | undefined
setLoading: (value: boolean) => void setLoading: (value: boolean) => void

View File

@ -1,675 +1,5 @@
import { nostrService } from './nostr' export * from './articleMutations/index'
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
import { storePrivateContent, getStoredPrivateContent } from './articleStorage'
import { buildTags } from './nostrTagSystem'
import { PLATFORM_SERVICE } from './platformConfig'
import { generateSeriesHashId, generatePublicationHashId } from './hashIdGenerator'
import { buildObjectId } from './urlGenerator'
import type { ArticleDraft, PublishedArticle } from './articlePublisher'
import type { AlbyInvoice } from '@/types/alby'
import type { Article, Review, Series } from '@/types/nostr'
import { writeOrchestrator } from './writeOrchestrator'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils'
import { buildParsedArticleFromDraft as buildParsedArticleFromDraftCore } from './articleDraftToParsedArticle'
import { getPublishRelays } from './relaySelection'
export interface ArticleUpdateResult extends PublishedArticle {
originalArticleId: string
}
function ensureKeys(authorPubkey: string, authorPrivateKey?: string): void {
nostrService.setPublicKey(authorPubkey)
if (authorPrivateKey) {
nostrService.setPrivateKey(authorPrivateKey)
} else if (!nostrService.getPrivateKey()) {
throw new Error('Private key required for signing. Connect a wallet that can sign.')
}
}
function requireCategory(category?: ArticleDraft['category']): asserts category is NonNullable<ArticleDraft['category']> {
if (category !== 'science-fiction' && category !== 'scientific-research') {
throw new Error('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
}
}
async function ensurePresentation(authorPubkey: string): Promise<string> {
const { articlePublisher } = await import('./articlePublisher')
const presentation = await articlePublisher.getAuthorPresentation(authorPubkey)
if (!presentation) {
throw new Error('Vous devez créer un article de présentation avant de publier des articles.')
}
return presentation.id
}
async function buildParsedArticleFromDraft(
draft: ArticleDraft,
invoice: AlbyInvoice,
authorPubkey: string
): Promise<{ article: Article; hash: string; version: number; index: number }> {
return buildParsedArticleFromDraftCore({ draft, invoice, authorPubkey })
}
interface PublishPreviewWithInvoiceParams {
draft: ArticleDraft
invoice: AlbyInvoice
authorPubkey: string
presentationId: string
extraTags?: string[][]
customArticle?: Article
}
async function publishPreviewWithInvoice(
params: PublishPreviewWithInvoiceParams
): Promise<import('nostr-tools').Event | null> {
const payload = await resolvePublicationPayload(params)
// Build event template
const previewEventTemplate = await createPreviewEvent({
draft: params.draft,
invoice: params.invoice,
authorPubkey: params.authorPubkey,
authorPresentationId: params.presentationId,
...(params.extraTags ? { extraTags: params.extraTags } : {}),
})
// Set private key in orchestrator
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
throw new Error('Private key required for signing')
}
writeOrchestrator.setPrivateKey(privateKey)
// Finalize event
const secretKey = hexToBytes(privateKey)
const event = finalizeEvent(previewEventTemplate, secretKey)
return publishPublicationToRelays({ event, payload })
}
async function resolvePublicationPayload(params: PublishPreviewWithInvoiceParams): Promise<{
article: Article
hash: string
version: number
index: number
}> {
if (params.customArticle) {
return {
article: params.customArticle,
hash: params.customArticle.hash,
version: params.customArticle.version,
index: params.customArticle.index ?? 0,
}
}
return buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey)
}
async function publishPublicationToRelays(params: {
event: import('nostr-tools').Event
payload: { article: Article; hash: string; version: number; index: number }
}): Promise<import('nostr-tools').Event | null> {
const relays = await getPublishRelays()
const result = await writeOrchestrator.writeAndPublish(
{
objectType: 'publication',
hash: params.payload.hash,
event: params.event,
parsed: params.payload.article,
version: params.payload.version,
hidden: false,
index: params.payload.index,
},
relays
)
return result.success ? params.event : null
}
export async function publishSeries(params: {
title: string
description: string
preview?: string
coverUrl?: string
category: ArticleDraft['category']
authorPubkey: string
authorPrivateKey?: string
}): Promise<Series> {
ensureKeys(params.authorPubkey, params.authorPrivateKey)
const category = requireSeriesCategory(params.category)
const newCategory = mapSeriesCategoryToTag(category)
const preview = buildSeriesPreview(params.preview, params.description)
const hashId = await generateSeriesHashId({
pubkey: params.authorPubkey,
title: params.title,
description: params.description,
category: newCategory,
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
})
const parsedSeries = buildParsedSeries({
authorPubkey: params.authorPubkey,
title: params.title,
description: params.description,
preview,
coverUrl: params.coverUrl,
category,
hashId,
})
const eventTemplate = buildSeriesEventTemplate({
authorPubkey: params.authorPubkey,
title: params.title,
description: params.description,
preview,
coverUrl: params.coverUrl,
category: newCategory,
hashId,
})
const event = finalizeEvent(eventTemplate, hexToBytes(getPrivateKeyForSigning(params.authorPrivateKey)))
const relays = await getPublishRelays()
const result = await writeOrchestrator.writeAndPublish(
{
objectType: 'series',
hash: parsedSeries.hash,
event,
parsed: parsedSeries,
version: parsedSeries.version,
hidden: false,
index: parsedSeries.index,
},
relays
)
if (!result.success) {
throw new Error('Failed to publish series')
}
return parsedSeries
}
function requireSeriesCategory(category: ArticleDraft['category']): NonNullable<ArticleDraft['category']> {
requireCategory(category)
return category
}
function mapSeriesCategoryToTag(category: NonNullable<ArticleDraft['category']>): 'sciencefiction' | 'research' {
return category === 'science-fiction' ? 'sciencefiction' : 'research'
}
function buildSeriesPreview(preview: string | undefined, description: string): string {
return preview ?? description.substring(0, 200)
}
function buildParsedSeries(params: {
authorPubkey: string
title: string
description: string
preview: string
coverUrl: string | undefined
category: NonNullable<ArticleDraft['category']>
hashId: string
}): Series {
const hash = params.hashId
const version = 0
const index = 0
const id = buildObjectId(hash, index, version)
return {
id,
hash,
version,
index,
pubkey: params.authorPubkey,
title: params.title,
description: params.description,
preview: params.preview,
thumbnailUrl: params.coverUrl ?? '',
category: params.category,
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
kindType: 'series',
}
}
function buildSeriesEventTemplate(params: {
authorPubkey: string
title: string
description: string
preview: string
coverUrl: string | undefined
category: 'sciencefiction' | 'research'
hashId: string
}): EventTemplate {
const tags = buildTags({
type: 'series',
category: params.category,
id: params.hashId,
service: PLATFORM_SERVICE,
version: 0,
hidden: false,
paywall: false,
title: params.title,
description: params.description,
preview: params.preview,
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
})
tags.push(['json', buildSeriesJson(params)])
return { kind: 1, created_at: Math.floor(Date.now() / 1000), content: params.preview, tags }
}
function buildSeriesJson(params: {
authorPubkey: string
title: string
description: string
preview: string
coverUrl: string | undefined
category: 'sciencefiction' | 'research'
hashId: string
}): string {
return JSON.stringify({
type: 'series',
pubkey: params.authorPubkey,
title: params.title,
description: params.description,
preview: params.preview,
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
category: params.category,
id: params.hashId,
version: 0,
index: 0,
})
}
function getPrivateKeyForSigning(authorPrivateKey: string | undefined): string {
const privateKey = authorPrivateKey ?? nostrService.getPrivateKey()
if (!privateKey) {
throw new Error('Private key required for signing')
}
writeOrchestrator.setPrivateKey(privateKey)
return privateKey
}
export async function publishReview(params: {
articleId: string
seriesId: string
category: ArticleDraft['category']
authorPubkey: string
reviewerPubkey: string
content: string
title?: string
text?: string
authorPrivateKey?: string
}): Promise<Review> {
ensureKeys(params.reviewerPubkey, params.authorPrivateKey)
const {category} = params
requireCategory(category)
// Generate hash ID from review data
const { generateReviewHashId } = await import('./hashIdGenerator')
const hashId = await generateReviewHashId({
pubkey: params.reviewerPubkey,
articleId: params.articleId,
reviewerPubkey: params.reviewerPubkey,
content: params.content,
...(params.title ? { title: params.title } : {}),
})
const hash = hashId
const version = 0
const index = 0
const id = buildObjectId(hash, index, version)
// Build parsed Review object
const parsedReview: Review = {
id,
hash,
version,
index,
articleId: params.articleId,
authorPubkey: params.authorPubkey,
reviewerPubkey: params.reviewerPubkey,
content: params.content,
description: params.content.substring(0, 200),
createdAt: Math.floor(Date.now() / 1000),
...(params.title ? { title: params.title } : {}),
...(params.text ? { text: params.text } : {}),
kindType: 'review',
}
// Build event template
const eventTemplate = await buildReviewEvent(params, category)
// Set private key in orchestrator
const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey()
if (!privateKey) {
throw new Error('Private key required for signing')
}
writeOrchestrator.setPrivateKey(privateKey)
// Finalize event
const secretKey = hexToBytes(privateKey)
const event = finalizeEvent(eventTemplate, secretKey)
// Get active relays
const { relaySessionManager } = await import('./relaySessionManager')
const activeRelays = await relaySessionManager.getActiveRelays()
const { getPrimaryRelay } = await import('./config')
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
// Publish via writeOrchestrator (parallel network + local write)
const result = await writeOrchestrator.writeAndPublish(
{
objectType: 'review',
hash,
event,
parsed: parsedReview,
version,
hidden: false,
index,
},
relays
)
if (!result.success) {
throw new Error('Failed to publish review')
}
return parsedReview
}
async function buildReviewEvent(
params: {
articleId: string
seriesId: string
authorPubkey: string
reviewerPubkey: string
content: string
title?: string
text?: string
},
category: NonNullable<ArticleDraft['category']>
): Promise<{
kind: number
created_at: number
content: string
tags: string[][]
}> {
// Map category to new system
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
// Generate hash ID from review data
const { generateReviewHashId } = await import('./hashIdGenerator')
const hashId = await generateReviewHashId({
pubkey: params.reviewerPubkey,
articleId: params.articleId,
reviewerPubkey: params.reviewerPubkey,
content: params.content,
...(params.title ? { title: params.title } : {}),
})
// Build JSON metadata
const reviewJson = JSON.stringify({
type: 'review',
pubkey: params.reviewerPubkey,
articleId: params.articleId,
reviewerPubkey: params.reviewerPubkey,
content: params.content,
title: params.title,
category: newCategory,
id: hashId,
version: 0,
index: 0,
})
const tags = buildTags({
type: 'quote',
category: newCategory,
id: hashId,
service: PLATFORM_SERVICE,
version: 0, // New object
hidden: false,
paywall: false,
articleId: params.articleId,
reviewerPubkey: params.reviewerPubkey,
...(params.title ? { title: params.title } : {}),
})
// Add text tag if provided
if (params.text) {
tags.push(['text', params.text])
}
// Add JSON metadata as a tag
tags.push(['json', reviewJson])
return {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
content: params.content,
tags,
}
}
async function buildUpdateTags(params: {
draft: ArticleDraft
originalArticleId: string
newCategory: 'sciencefiction' | 'research'
authorPubkey: string
currentVersion?: number
}): Promise<string[][]> {
// Generate hash ID from publication data
const hashId = await generatePublicationHashId({
pubkey: params.authorPubkey,
title: params.draft.title,
preview: params.draft.preview,
category: params.newCategory,
seriesId: params.draft.seriesId ?? undefined,
bannerUrl: params.draft.bannerUrl ?? undefined,
zapAmount: params.draft.zapAmount,
})
// Increment version for update
const currentVersion = params.currentVersion ?? 0
const nextVersion = currentVersion + 1
const updateTags = buildTags({
type: 'publication',
category: params.newCategory,
id: hashId,
service: PLATFORM_SERVICE,
version: nextVersion,
hidden: false,
paywall: true,
title: params.draft.title,
preview: params.draft.preview,
zapAmount: params.draft.zapAmount,
...(params.draft.seriesId ? { seriesId: params.draft.seriesId } : {}),
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
})
updateTags.push(['e', params.originalArticleId], ['replace', 'article-update'])
return updateTags
}
async function publishUpdate(
draft: ArticleDraft,
authorPubkey: string,
originalArticleId: string
): Promise<ArticleUpdateResult> {
const category = requireUpdateCategory(draft)
const originalArticle = await loadOriginalArticleForUpdate(originalArticleId)
if (!originalArticle) {
return updateFailure(originalArticleId, 'Original article not found in cache')
}
if (originalArticle.pubkey !== authorPubkey) {
return updateFailure(originalArticleId, 'Only the author can update this article')
}
const presentationId = await ensurePresentation(authorPubkey)
const invoice = await createArticleInvoice(draft)
const newCategory = mapPublicationCategoryToTag(category)
const currentVersion = originalArticle.version ?? 0
const updateTags = await buildUpdateTags({ draft, originalArticleId, newCategory, authorPubkey, currentVersion })
const updatedArticle = await buildUpdatedArticleForUpdate({ draft, invoice, authorPubkey, currentVersion })
const publishedEvent = await publishPreviewWithInvoice({ draft, invoice, authorPubkey, presentationId, extraTags: updateTags, customArticle: updatedArticle })
if (!publishedEvent) {
return updateFailure(originalArticleId, 'Failed to publish article update')
}
await storePrivateContent({ articleId: publishedEvent.id, content: draft.content, authorPubkey, invoice })
return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true, originalArticleId }
}
function requireUpdateCategory(draft: ArticleDraft): NonNullable<ArticleDraft['category']> {
requireCategory(draft.category)
return draft.category
}
async function loadOriginalArticleForUpdate(originalArticleId: string): Promise<Article | null> {
const { objectCache } = await import('./objectCache')
return (await objectCache.getById('publication', originalArticleId)) as Article | null
}
function mapPublicationCategoryToTag(category: NonNullable<ArticleDraft['category']>): 'sciencefiction' | 'research' {
return category === 'science-fiction' ? 'sciencefiction' : 'research'
}
async function buildUpdatedArticleForUpdate(params: {
draft: ArticleDraft
invoice: AlbyInvoice
authorPubkey: string
currentVersion: number
}): Promise<Article> {
const { article } = await buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey)
return { ...article, version: params.currentVersion + 1 }
}
export async function publishArticleUpdate(
originalArticleId: string,
draft: ArticleDraft,
authorPubkey: string,
authorPrivateKey?: string
): Promise<ArticleUpdateResult> {
try {
ensureKeys(authorPubkey, authorPrivateKey)
return publishUpdate(draft, authorPubkey, originalArticleId)
} catch (error) {
return updateFailure(originalArticleId, error instanceof Error ? error.message : 'Unknown error')
}
}
function updateFailure(originalArticleId: string, error?: string): ArticleUpdateResult {
return {
articleId: '',
previewEventId: '',
success: false,
originalArticleId,
...(error ? { error } : {}),
}
}
export async function deleteArticleEvent(articleId: string, authorPubkey: string, authorPrivateKey?: string): Promise<void> {
ensureKeys(authorPubkey, authorPrivateKey)
const originalEvent = await getOriginalPublicationEventOrThrow(articleId)
assertAuthorOwnsEvent({ eventPubkey: originalEvent.pubkey, authorPubkey })
const deleteEventTemplate = await buildDeleteEventTemplateOrThrow({ originalEvent, authorPubkey })
const originalParsed = await parseOriginalArticleOrThrow(originalEvent)
const deletePayload = await buildDeletedArticlePayload({ originalParsed, deleteEventTemplate })
const event = await finalizeEventTemplate({ template: deleteEventTemplate, authorPrivateKey })
const relays = await getPublishRelays()
await publishDeletion({ event, relays, payload: deletePayload })
}
async function getOriginalPublicationEventOrThrow(articleId: string): Promise<import('nostr-tools').Event> {
const { objectCache } = await import('./objectCache')
const originalEvent = await objectCache.getEventById('publication', articleId)
if (!originalEvent) {
throw new Error('Article not found in cache')
}
return originalEvent
}
function assertAuthorOwnsEvent(params: { eventPubkey: string; authorPubkey: string }): void {
if (params.eventPubkey !== params.authorPubkey) {
throw new Error('Only the author can delete this article')
}
}
async function buildDeleteEventTemplateOrThrow(params: {
originalEvent: import('nostr-tools').Event
authorPubkey: string
}): Promise<import('nostr-tools').EventTemplate> {
const { buildDeleteEvent } = await import('./objectModification')
const template = await buildDeleteEvent(params.originalEvent, params.authorPubkey)
if (!template) {
throw new Error('Failed to build delete event')
}
return template
}
async function parseOriginalArticleOrThrow(originalEvent: import('nostr-tools').Event): Promise<Article> {
const { parseArticleFromEvent } = await import('./nostrEventParsing')
const parsed = await parseArticleFromEvent(originalEvent)
if (!parsed) {
throw new Error('Failed to parse original article')
}
return parsed
}
async function buildDeletedArticlePayload(params: {
originalParsed: Article
deleteEventTemplate: import('nostr-tools').EventTemplate
}): Promise<{ hash: string; index: number; version: number; parsed: Article }> {
const { extractTagsFromEvent } = await import('./nostrTagSystem')
const tags = extractTagsFromEvent(params.deleteEventTemplate)
const version = tags.version ?? params.originalParsed.version + 1
const index = params.originalParsed.index ?? 0
const parsed: Article = { ...params.originalParsed, version }
return { hash: params.originalParsed.hash, index, version, parsed }
}
async function finalizeEventTemplate(params: {
template: import('nostr-tools').EventTemplate
authorPrivateKey: string | undefined
}): Promise<import('nostr-tools').Event> {
const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey()
if (!privateKey) {
throw new Error('Private key required for signing')
}
const { writeOrchestrator: writeOrchestratorInstance } = await import('./writeOrchestrator')
writeOrchestratorInstance.setPrivateKey(privateKey)
const { finalizeEvent: finalizeNostrEvent } = await import('nostr-tools')
const { hexToBytes: hexToBytesUtil } = await import('nostr-tools/utils')
const secretKey = hexToBytesUtil(privateKey)
return finalizeNostrEvent(params.template, secretKey)
}
async function publishDeletion(params: {
event: import('nostr-tools').Event
relays: string[]
payload: { hash: string; index: number; version: number; parsed: Article }
}): Promise<void> {
const { writeOrchestrator: writeOrchestratorInstance } = await import('./writeOrchestrator')
const result = await writeOrchestratorInstance.writeAndPublish(
{
objectType: 'publication',
hash: params.payload.hash,
event: params.event,
parsed: params.payload.parsed,
version: params.payload.version,
hidden: true,
index: params.payload.index,
},
params.relays
)
if (!result.success) {
throw new Error('Failed to publish delete event')
}
}
// Re-export for convenience to avoid circular imports in hooks // Re-export for convenience to avoid circular imports in hooks
export { articlePublisher } from './articlePublisher' export { articlePublisher } from './articlePublisher'
export const getStoredContent = getStoredPrivateContent export { getStoredPrivateContent as getStoredContent } from './articleStorage'

View File

@ -0,0 +1,85 @@
import type { Article } from '@/types/nostr'
import { nostrService } from '../nostr'
import { getPublishRelays } from '../relaySelection'
import { ensureKeys } from './shared'
export async function deleteArticleEvent(articleId: string, authorPubkey: string, authorPrivateKey?: string): Promise<void> {
ensureKeys(authorPubkey, authorPrivateKey)
const originalEvent = await getOriginalPublicationEventOrThrow(articleId)
assertAuthorOwnsEvent({ eventPubkey: originalEvent.pubkey, authorPubkey })
const deleteEventTemplate = await buildDeleteEventTemplateOrThrow({ originalEvent, authorPubkey })
const originalParsed = await parseOriginalArticleOrThrow(originalEvent)
const deletePayload = await buildDeletedArticlePayload({ originalParsed, deleteEventTemplate })
const event = await finalizeEventTemplate({ template: deleteEventTemplate, authorPrivateKey })
const relays = await getPublishRelays()
await publishDeletion({ event, relays, payload: deletePayload })
}
async function getOriginalPublicationEventOrThrow(articleId: string): Promise<import('nostr-tools').Event> {
const { objectCache } = await import('../objectCache')
const originalEvent = await objectCache.getEventById('publication', articleId)
if (!originalEvent) {
throw new Error('Article not found in cache')
}
return originalEvent
}
function assertAuthorOwnsEvent(params: { eventPubkey: string; authorPubkey: string }): void {
if (params.eventPubkey !== params.authorPubkey) {
throw new Error('Only the author can delete this article')
}
}
async function buildDeleteEventTemplateOrThrow(params: { originalEvent: import('nostr-tools').Event; authorPubkey: string }): Promise<import('nostr-tools').EventTemplate> {
const { buildDeleteEvent } = await import('../objectModification')
const template = await buildDeleteEvent(params.originalEvent, params.authorPubkey)
if (!template) {
throw new Error('Failed to build delete event')
}
return template
}
async function parseOriginalArticleOrThrow(originalEvent: import('nostr-tools').Event): Promise<Article> {
const { parseArticleFromEvent } = await import('../nostrEventParsing')
const parsed = await parseArticleFromEvent(originalEvent)
if (!parsed) {
throw new Error('Failed to parse original article')
}
return parsed
}
async function buildDeletedArticlePayload(params: { originalParsed: Article; deleteEventTemplate: import('nostr-tools').EventTemplate }): Promise<{ hash: string; index: number; version: number; parsed: Article }> {
const { extractTagsFromEvent } = await import('../nostrTagSystem')
const tags = extractTagsFromEvent(params.deleteEventTemplate)
const version = tags.version ?? params.originalParsed.version + 1
const index = params.originalParsed.index ?? 0
const parsed: Article = { ...params.originalParsed, version }
return { hash: params.originalParsed.hash, index, version, parsed }
}
async function finalizeEventTemplate(params: { template: import('nostr-tools').EventTemplate; authorPrivateKey: string | undefined }): Promise<import('nostr-tools').Event> {
const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey()
if (!privateKey) {
throw new Error('Private key required for signing')
}
const { writeOrchestrator } = await import('../writeOrchestrator')
writeOrchestrator.setPrivateKey(privateKey)
const { finalizeEvent } = await import('nostr-tools')
const { hexToBytes } = await import('nostr-tools/utils')
return finalizeEvent(params.template, hexToBytes(privateKey))
}
async function publishDeletion(params: {
event: import('nostr-tools').Event
relays: string[]
payload: { hash: string; index: number; version: number; parsed: Article }
}): Promise<void> {
const { writeOrchestrator } = await import('../writeOrchestrator')
const result = await writeOrchestrator.writeAndPublish(
{ objectType: 'publication', hash: params.payload.hash, event: params.event, parsed: params.payload.parsed, version: params.payload.version, hidden: true, index: params.payload.index },
params.relays
)
if (!result.success) {
throw new Error('Failed to publish delete event')
}
}

View File

@ -0,0 +1,5 @@
export type { ArticleUpdateResult } from './types'
export { publishSeries } from './series'
export { publishReview } from './review'
export { publishArticleUpdate } from './update'
export { deleteArticleEvent } from './delete'

View File

@ -0,0 +1,57 @@
import { createPreviewEvent } from '../articleInvoice'
import type { ArticleDraft } from '../articlePublisher'
import type { AlbyInvoice } from '@/types/alby'
import type { Article } from '@/types/nostr'
import { finalizeEvent } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils'
import { buildParsedArticleFromDraft as buildParsedArticleFromDraftCore } from '../articleDraftToParsedArticle'
import { getPublishRelays } from '../relaySelection'
import { writeOrchestrator } from '../writeOrchestrator'
import { getPrivateKeyForSigning } from './shared'
export async function buildParsedArticleFromDraft(
draft: ArticleDraft,
invoice: AlbyInvoice,
authorPubkey: string
): Promise<{ article: Article; hash: string; version: number; index: number }> {
return buildParsedArticleFromDraftCore({ draft, invoice, authorPubkey })
}
export interface PublishPreviewWithInvoiceParams {
draft: ArticleDraft
invoice: AlbyInvoice
authorPubkey: string
presentationId: string
extraTags?: string[][]
customArticle?: Article
}
export async function publishPreviewWithInvoice(params: PublishPreviewWithInvoiceParams): Promise<import('nostr-tools').Event | null> {
const payload = await resolvePublicationPayload(params)
const previewEventTemplate = await createPreviewEvent({
draft: params.draft,
invoice: params.invoice,
authorPubkey: params.authorPubkey,
authorPresentationId: params.presentationId,
...(params.extraTags ? { extraTags: params.extraTags } : {}),
})
const privateKey = getPrivateKeyForSigning(undefined)
const event = finalizeEvent(previewEventTemplate, hexToBytes(privateKey))
return publishPublicationToRelays({ event, payload })
}
async function resolvePublicationPayload(params: PublishPreviewWithInvoiceParams): Promise<{ article: Article; hash: string; version: number; index: number }> {
if (params.customArticle) {
return { article: params.customArticle, hash: params.customArticle.hash, version: params.customArticle.version, index: params.customArticle.index ?? 0 }
}
return buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey)
}
async function publishPublicationToRelays(params: { event: import('nostr-tools').Event; payload: { article: Article; hash: string; version: number; index: number } }): Promise<import('nostr-tools').Event | null> {
const relays = await getPublishRelays()
const result = await writeOrchestrator.writeAndPublish(
{ objectType: 'publication', hash: params.payload.hash, event: params.event, parsed: params.payload.article, version: params.payload.version, hidden: false, index: params.payload.index },
relays
)
return result.success ? params.event : null
}

View File

@ -0,0 +1,133 @@
import type { ArticleDraft } from '../articlePublisher'
import type { Review } from '@/types/nostr'
import { buildTags } from '../nostrTagSystem'
import { PLATFORM_SERVICE } from '../platformConfig'
import { buildObjectId } from '../urlGenerator'
import { getPublishRelays } from '../relaySelection'
import { writeOrchestrator } from '../writeOrchestrator'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils'
import { ensureKeys, getPrivateKeyForSigning, mapDraftCategoryToTag, requireCategory } from './shared'
export async function publishReview(params: {
articleId: string
seriesId: string
category: ArticleDraft['category']
authorPubkey: string
reviewerPubkey: string
content: string
title?: string
text?: string
authorPrivateKey?: string
}): Promise<Review> {
ensureKeys(params.reviewerPubkey, params.authorPrivateKey)
requireCategory(params.category)
const hash = await buildReviewHashId({ reviewerPubkey: params.reviewerPubkey, articleId: params.articleId, content: params.content, ...(params.title ? { title: params.title } : {}) })
const parsedReview = buildParsedReview({ params, hash, version: 0, index: 0 })
const eventTemplate = await buildReviewEvent(params, params.category)
const privateKey = getPrivateKeyForSigning(params.authorPrivateKey)
const event = finalizeEvent(eventTemplate, hexToBytes(privateKey))
await publishReviewToRelays({ parsedReview, event })
return parsedReview
}
function buildParsedReview(params: {
params: { articleId: string; authorPubkey: string; reviewerPubkey: string; content: string; title?: string; text?: string }
hash: string
version: number
index: number
}): Review {
return {
id: buildObjectId(params.hash, params.index, params.version),
hash: params.hash,
version: params.version,
index: params.index,
articleId: params.params.articleId,
authorPubkey: params.params.authorPubkey,
reviewerPubkey: params.params.reviewerPubkey,
content: params.params.content,
description: params.params.content.substring(0, 200),
createdAt: Math.floor(Date.now() / 1000),
...(params.params.title ? { title: params.params.title } : {}),
...(params.params.text ? { text: params.params.text } : {}),
kindType: 'review',
}
}
async function publishReviewToRelays(params: { parsedReview: Review; event: import('nostr-tools').Event }): Promise<void> {
const relays = await getPublishRelays()
const result = await writeOrchestrator.writeAndPublish(
{ objectType: 'review', hash: params.parsedReview.hash, event: params.event, parsed: params.parsedReview, version: params.parsedReview.version, hidden: false, index: params.parsedReview.index },
relays
)
if (!result.success) {
throw new Error('Failed to publish review')
}
}
async function buildReviewEvent(
params: { articleId: string; seriesId: string; authorPubkey: string; reviewerPubkey: string; content: string; title?: string; text?: string },
category: NonNullable<ArticleDraft['category']>
): Promise<Pick<EventTemplate, 'kind' | 'created_at' | 'content' | 'tags'>> {
const ctx = await buildReviewEventContext(params, category)
return { kind: 1, created_at: Math.floor(Date.now() / 1000), content: params.content, tags: ctx.tags }
}
async function buildReviewEventContext(
params: { articleId: string; reviewerPubkey: string; content: string; title?: string; text?: string },
category: NonNullable<ArticleDraft['category']>
): Promise<{ tags: string[][] }> {
const newCategory = mapDraftCategoryToTag(category)
const hashId = await buildReviewHashId({ reviewerPubkey: params.reviewerPubkey, articleId: params.articleId, content: params.content, ...(params.title ? { title: params.title } : {}) })
const reviewJson = buildReviewJson({ reviewerPubkey: params.reviewerPubkey, articleId: params.articleId, content: params.content, ...(params.title ? { title: params.title } : {}), newCategory, hashId })
const tags = buildReviewTags({ newCategory, hashId, articleId: params.articleId, reviewerPubkey: params.reviewerPubkey, ...(params.title ? { title: params.title } : {}), ...(params.text ? { text: params.text } : {}), reviewJson })
return { tags }
}
async function buildReviewHashId(params: { reviewerPubkey: string; articleId: string; content: string; title?: string }): Promise<string> {
const { generateReviewHashId } = await import('../hashIdGenerator')
return generateReviewHashId({ pubkey: params.reviewerPubkey, articleId: params.articleId, reviewerPubkey: params.reviewerPubkey, content: params.content, ...(params.title ? { title: params.title } : {}) })
}
function buildReviewJson(params: { reviewerPubkey: string; articleId: string; content: string; title?: string; newCategory: 'sciencefiction' | 'research'; hashId: string }): string {
return JSON.stringify({
type: 'review',
pubkey: params.reviewerPubkey,
articleId: params.articleId,
reviewerPubkey: params.reviewerPubkey,
content: params.content,
title: params.title,
category: params.newCategory,
id: params.hashId,
version: 0,
index: 0,
})
}
function buildReviewTags(params: {
newCategory: 'sciencefiction' | 'research'
hashId: string
articleId: string
reviewerPubkey: string
title?: string
text?: string
reviewJson: string
}): string[][] {
const tags = buildTags({
type: 'quote',
category: params.newCategory,
id: params.hashId,
service: PLATFORM_SERVICE,
version: 0,
hidden: false,
paywall: false,
articleId: params.articleId,
reviewerPubkey: params.reviewerPubkey,
...(params.title ? { title: params.title } : {}),
})
if (params.text) {
tags.push(['text', params.text])
}
tags.push(['json', params.reviewJson])
return tags
}

View File

@ -0,0 +1,143 @@
import { buildTags } from '../nostrTagSystem'
import { PLATFORM_SERVICE } from '../platformConfig'
import { generateSeriesHashId } from '../hashIdGenerator'
import { buildObjectId } from '../urlGenerator'
import type { ArticleDraft } from '../articlePublisher'
import type { Series } from '@/types/nostr'
import { getPublishRelays } from '../relaySelection'
import { writeOrchestrator } from '../writeOrchestrator'
import { finalizeEvent } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils'
import type { EventTemplate } from 'nostr-tools'
import { ensureKeys, getPrivateKeyForSigning, mapDraftCategoryToTag, requireCategory } from './shared'
export async function publishSeries(params: {
title: string
description: string
preview?: string
coverUrl?: string
category: ArticleDraft['category']
authorPubkey: string
authorPrivateKey?: string
}): Promise<Series> {
ensureKeys(params.authorPubkey, params.authorPrivateKey)
const ctx = await buildSeriesPublishContext(params)
const event = finalizeEvent(ctx.eventTemplate, hexToBytes(getPrivateKeyForSigning(params.authorPrivateKey)))
await publishSeriesToRelays({ parsedSeries: ctx.parsedSeries, event })
return ctx.parsedSeries
}
function buildSeriesPreview(preview: string | undefined, description: string): string {
return preview ?? description.substring(0, 200)
}
async function buildSeriesPublishContext(params: {
title: string
description: string
preview?: string
coverUrl?: string
category: ArticleDraft['category']
authorPubkey: string
}): Promise<{ parsedSeries: Series; eventTemplate: EventTemplate }> {
requireCategory(params.category)
const newCategory = mapDraftCategoryToTag(params.category)
const preview = buildSeriesPreview(params.preview, params.description)
const hashId = await generateSeriesHashId({
pubkey: params.authorPubkey,
title: params.title,
description: params.description,
category: newCategory,
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
})
const parsedSeries = buildParsedSeries({ authorPubkey: params.authorPubkey, title: params.title, description: params.description, preview, coverUrl: params.coverUrl, category: params.category, hashId })
const eventTemplate = buildSeriesEventTemplate({ authorPubkey: params.authorPubkey, title: params.title, description: params.description, preview, coverUrl: params.coverUrl, category: newCategory, hashId })
return { parsedSeries, eventTemplate }
}
async function publishSeriesToRelays(params: { parsedSeries: Series; event: import('nostr-tools').Event }): Promise<void> {
const relays = await getPublishRelays()
const result = await writeOrchestrator.writeAndPublish(
{ objectType: 'series', hash: params.parsedSeries.hash, event: params.event, parsed: params.parsedSeries, version: params.parsedSeries.version, hidden: false, index: params.parsedSeries.index },
relays
)
if (!result.success) {
throw new Error('Failed to publish series')
}
}
function buildParsedSeries(params: {
authorPubkey: string
title: string
description: string
preview: string
coverUrl: string | undefined
category: NonNullable<ArticleDraft['category']>
hashId: string
}): Series {
const hash = params.hashId
const version = 0
const index = 0
return {
id: buildObjectId(hash, index, version),
hash,
version,
index,
pubkey: params.authorPubkey,
title: params.title,
description: params.description,
preview: params.preview,
thumbnailUrl: params.coverUrl ?? '',
category: params.category,
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
kindType: 'series',
}
}
function buildSeriesEventTemplate(params: {
authorPubkey: string
title: string
description: string
preview: string
coverUrl: string | undefined
category: 'sciencefiction' | 'research'
hashId: string
}): EventTemplate {
const tags = buildTags({
type: 'series',
category: params.category,
id: params.hashId,
service: PLATFORM_SERVICE,
version: 0,
hidden: false,
paywall: false,
title: params.title,
description: params.description,
preview: params.preview,
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
})
tags.push(['json', buildSeriesJson(params)])
return { kind: 1, created_at: Math.floor(Date.now() / 1000), content: params.preview, tags }
}
function buildSeriesJson(params: {
authorPubkey: string
title: string
description: string
preview: string
coverUrl: string | undefined
category: 'sciencefiction' | 'research'
hashId: string
}): string {
return JSON.stringify({
type: 'series',
pubkey: params.authorPubkey,
title: params.title,
description: params.description,
preview: params.preview,
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
category: params.category,
id: params.hashId,
version: 0,
index: 0,
})
}

View File

@ -0,0 +1,40 @@
import { nostrService } from '../nostr'
import type { ArticleDraft } from '../articlePublisher'
import { writeOrchestrator } from '../writeOrchestrator'
export function ensureKeys(authorPubkey: string, authorPrivateKey?: string): void {
nostrService.setPublicKey(authorPubkey)
if (authorPrivateKey) {
nostrService.setPrivateKey(authorPrivateKey)
} else if (!nostrService.getPrivateKey()) {
throw new Error('Private key required for signing. Connect a wallet that can sign.')
}
}
export function requireCategory(category?: ArticleDraft['category']): asserts category is NonNullable<ArticleDraft['category']> {
if (category !== 'science-fiction' && category !== 'scientific-research') {
throw new Error('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
}
}
export async function ensurePresentation(authorPubkey: string): Promise<string> {
const { articlePublisher } = await import('../articlePublisher')
const presentation = await articlePublisher.getAuthorPresentation(authorPubkey)
if (!presentation) {
throw new Error('Vous devez créer un article de présentation avant de publier des articles.')
}
return presentation.id
}
export function mapDraftCategoryToTag(category: NonNullable<ArticleDraft['category']>): 'sciencefiction' | 'research' {
return category === 'science-fiction' ? 'sciencefiction' : 'research'
}
export function getPrivateKeyForSigning(authorPrivateKey: string | undefined): string {
const privateKey = authorPrivateKey ?? nostrService.getPrivateKey()
if (!privateKey) {
throw new Error('Private key required for signing')
}
writeOrchestrator.setPrivateKey(privateKey)
return privateKey
}

View File

@ -0,0 +1,5 @@
import type { PublishedArticle } from '../articlePublisher'
export interface ArticleUpdateResult extends PublishedArticle {
originalArticleId: string
}

View File

@ -0,0 +1,100 @@
import type { ArticleDraft } from '../articlePublisher'
import type { AlbyInvoice } from '@/types/alby'
import type { Article } from '@/types/nostr'
import { createArticleInvoice } from '../articleInvoice'
import { storePrivateContent } from '../articleStorage'
import { buildTags } from '../nostrTagSystem'
import { PLATFORM_SERVICE } from '../platformConfig'
import { generatePublicationHashId } from '../hashIdGenerator'
import { ensureKeys, ensurePresentation, mapDraftCategoryToTag, requireCategory } from './shared'
import { buildParsedArticleFromDraft, publishPreviewWithInvoice } from './publicationPreview'
import type { ArticleUpdateResult } from './types'
export async function publishArticleUpdate(
originalArticleId: string,
draft: ArticleDraft,
authorPubkey: string,
authorPrivateKey?: string
): Promise<ArticleUpdateResult> {
try {
ensureKeys(authorPubkey, authorPrivateKey)
return publishUpdate(draft, authorPubkey, originalArticleId)
} catch (error) {
return updateFailure(originalArticleId, error instanceof Error ? error.message : 'Unknown error')
}
}
async function publishUpdate(draft: ArticleDraft, authorPubkey: string, originalArticleId: string): Promise<ArticleUpdateResult> {
requireCategory(draft.category)
const originalArticle = await loadOriginalArticleForUpdate(originalArticleId)
if (!originalArticle) {
return updateFailure(originalArticleId, 'Original article not found in cache')
}
if (originalArticle.pubkey !== authorPubkey) {
return updateFailure(originalArticleId, 'Only the author can update this article')
}
const presentationId = await ensurePresentation(authorPubkey)
const invoice = await createArticleInvoice(draft)
const newCategory = mapDraftCategoryToTag(draft.category)
const currentVersion = originalArticle.version ?? 0
const updateTags = await buildUpdateTags({ draft, originalArticleId, newCategory, authorPubkey, currentVersion })
const updatedArticle = await buildUpdatedArticleForUpdate({ draft, invoice, authorPubkey, currentVersion })
const publishedEvent = await publishPreviewWithInvoice({ draft, invoice, authorPubkey, presentationId, extraTags: updateTags, customArticle: updatedArticle })
if (!publishedEvent) {
return updateFailure(originalArticleId, 'Failed to publish article update')
}
await storePrivateContent({ articleId: publishedEvent.id, content: draft.content, authorPubkey, invoice })
return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true, originalArticleId }
}
async function buildUpdateTags(params: {
draft: ArticleDraft
originalArticleId: string
newCategory: 'sciencefiction' | 'research'
authorPubkey: string
currentVersion?: number
}): Promise<string[][]> {
const hashId = await generatePublicationHashId({
pubkey: params.authorPubkey,
title: params.draft.title,
preview: params.draft.preview,
category: params.newCategory,
seriesId: params.draft.seriesId ?? undefined,
bannerUrl: params.draft.bannerUrl ?? undefined,
zapAmount: params.draft.zapAmount,
})
const currentVersion = params.currentVersion ?? 0
const nextVersion = currentVersion + 1
const updateTags = buildTags({
type: 'publication',
category: params.newCategory,
id: hashId,
service: PLATFORM_SERVICE,
version: nextVersion,
hidden: false,
paywall: true,
title: params.draft.title,
preview: params.draft.preview,
zapAmount: params.draft.zapAmount,
...(params.draft.seriesId ? { seriesId: params.draft.seriesId } : {}),
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
})
updateTags.push(['e', params.originalArticleId], ['replace', 'article-update'])
return updateTags
}
async function loadOriginalArticleForUpdate(originalArticleId: string): Promise<Article | null> {
const { objectCache } = await import('../objectCache')
return (await objectCache.getById('publication', originalArticleId)) as Article | null
}
async function buildUpdatedArticleForUpdate(params: { draft: ArticleDraft; invoice: AlbyInvoice; authorPubkey: string; currentVersion: number }): Promise<Article> {
const { article } = await buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey)
return { ...article, version: params.currentVersion + 1 }
}
function updateFailure(originalArticleId: string, error?: string): ArticleUpdateResult {
return { articleId: '', previewEventId: '', success: false, originalArticleId, ...(error ? { error } : {}) }
}

View File

@ -1,16 +1,14 @@
import { nostrService } from './nostr' import { nostrService } from './nostr'
import type { AlbyInvoice } from '@/types/alby' import type { AlbyInvoice } from '@/types/alby'
import { getStoredPrivateContent, getStoredInvoice, removeStoredPrivateContent } from './articleStorage' import { getStoredPrivateContent, getStoredInvoice, removeStoredPrivateContent } from './articleStorage'
import { buildPresentationEvent, sendEncryptedContent } from './articlePublisherHelpers' import { sendEncryptedContent } from './articlePublisherHelpers'
import type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes' import type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
import { prepareAuthorKeys, isValidCategory, type PublishValidationResult } from './articlePublisherValidation' import { prepareAuthorKeys, isValidCategory, type PublishValidationResult } from './articlePublisherValidation'
import { buildFailure, encryptAndPublish } from './articlePublisherPublish' import { buildFailure, encryptAndPublish } from './articlePublisherPublish'
import { writeOrchestrator } from './writeOrchestrator' import { writeOrchestrator } from './writeOrchestrator'
import { finalizeEvent } from 'nostr-tools' import { finalizeEvent, type Event, type EventTemplate } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils' import { hexToBytes } from 'nostr-tools/utils'
import { generateAuthorHashId } from './hashIdGenerator' import { buildPresentationPublishContext, getActiveRelaysOrPrimary } from './articlePublisherPresentationHelpers'
import { buildObjectId } from './urlGenerator'
import { extractAuthorNameFromTitle, parseAuthorPresentationDraft } from './authorPresentationParsing'
export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes' export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
@ -215,114 +213,27 @@ async function publishPresentationArticleCore(params: {
authorPubkey: string authorPubkey: string
authorPrivateKey: string authorPrivateKey: string
}): Promise<PublishedArticle> { }): Promise<PublishedArticle> {
nostrService.setPublicKey(params.authorPubkey) initializeAuthorKeys({ authorPubkey: params.authorPubkey, authorPrivateKey: params.authorPrivateKey })
nostrService.setPrivateKey(params.authorPrivateKey) const ctx = await buildPresentationPublishContext({ draft: params.draft, authorPubkey: params.authorPubkey })
const event = finalizeEventFromTemplate({ eventTemplate: ctx.eventTemplate, authorPrivateKey: params.authorPrivateKey })
const authorName = extractAuthorNameFromTitle(params.draft.title)
const { presentation, contentDescription } = parseAuthorPresentationDraft(params.draft)
const category = 'sciencefiction'
const version = 0
const index = 0
const hashId = await generateAuthorHashId({
pubkey: params.authorPubkey,
authorName,
presentation,
contentDescription,
mainnetAddress: params.draft.mainnetAddress ?? undefined,
pictureUrl: params.draft.pictureUrl ?? undefined,
category,
})
const hash = hashId
const id = buildObjectId(hash, index, version)
const parsedAuthor = buildParsedAuthorPresentation({
draft: params.draft,
authorPubkey: params.authorPubkey,
id,
hash,
version,
index,
presentation,
contentDescription,
})
const eventTemplate = await buildPresentationEvent({
draft: params.draft,
authorPubkey: params.authorPubkey,
authorName,
category,
version,
index,
})
writeOrchestrator.setPrivateKey(params.authorPrivateKey) writeOrchestrator.setPrivateKey(params.authorPrivateKey)
const secretKey = hexToBytes(params.authorPrivateKey)
const event = finalizeEvent(eventTemplate, secretKey)
const relays = await getActiveRelaysOrPrimary() const relays = await getActiveRelaysOrPrimary()
const result = await writeOrchestrator.writeAndPublish( const result = await writeOrchestrator.writeAndPublish(
{ { objectType: 'author', hash: ctx.hash, event, parsed: ctx.parsedAuthor, version: ctx.version, hidden: false, index: ctx.index },
objectType: 'author',
hash,
event,
parsed: parsedAuthor,
version,
hidden: false,
index,
},
relays relays
) )
return result.success ? { articleId: event.id, previewEventId: event.id, success: true } : buildFailure('Failed to publish presentation article')
if (!result.success) {
return buildFailure('Failed to publish presentation article')
}
return { articleId: event.id, previewEventId: event.id, success: true }
} }
function buildParsedAuthorPresentation(params: { function initializeAuthorKeys(params: { authorPubkey: string; authorPrivateKey: string }): void {
draft: AuthorPresentationDraft nostrService.setPublicKey(params.authorPubkey)
authorPubkey: string nostrService.setPrivateKey(params.authorPrivateKey)
id: string
hash: string
version: number
index: number
presentation: string
contentDescription: string
}): import('@/types/nostr').AuthorPresentationArticle {
return {
id: params.id,
hash: params.hash,
version: params.version,
index: params.index,
pubkey: params.authorPubkey,
title: params.draft.title,
preview: params.draft.preview,
content: params.draft.content,
description: params.presentation,
contentDescription: params.contentDescription,
thumbnailUrl: params.draft.pictureUrl ?? '',
createdAt: Math.floor(Date.now() / 1000),
zapAmount: 0,
paid: true,
category: 'author-presentation',
isPresentation: true,
mainnetAddress: params.draft.mainnetAddress ?? '',
totalSponsoring: 0,
originalCategory: 'science-fiction',
...(params.draft.pictureUrl ? { bannerUrl: params.draft.pictureUrl } : {}),
}
} }
async function getActiveRelaysOrPrimary(): Promise<string[]> { function finalizeEventFromTemplate(params: { eventTemplate: EventTemplate; authorPrivateKey: string }): Event {
const { relaySessionManager } = await import('./relaySessionManager') const secretKey = hexToBytes(params.authorPrivateKey)
const activeRelays = await relaySessionManager.getActiveRelays() return finalizeEvent(params.eventTemplate, secretKey)
if (activeRelays.length > 0) {
return activeRelays
}
const { getPrimaryRelay } = await import('./config')
return [await getPrimaryRelay()]
} }
// moved to `articlePublisherPresentationHelpers.ts`

View File

@ -1,518 +1,3 @@
import { type Event } from 'nostr-tools' export { buildPresentationEvent } from './articlePublisherHelpersPresentation/buildPresentationEvent'
import { nip19 } from 'nostr-tools' export { parsePresentationEvent } from './articlePublisherHelpersPresentation/parsePresentationEvent'
import type { AuthorPresentationDraft } from './articlePublisher' export { fetchAuthorPresentationFromPool } from './articlePublisherHelpersPresentation/fetchAuthorPresentationFromPool'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import { createSubscription } from '@/types/nostr-tools-extended'
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
import { getPrimaryRelaySync } from './config'
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
import { generateAuthorHashId } from './hashIdGenerator'
import { generateObjectUrl, buildObjectId, parseObjectId } from './urlGenerator'
import { getLatestVersion } from './versionManager'
import { objectCache } from './objectCache'
import { parseAuthorPresentationDraft } from './authorPresentationParsing'
interface BuildPresentationEventParams {
draft: AuthorPresentationDraft
authorPubkey: string
authorName: string
category?: 'sciencefiction' | 'research'
version?: number
index?: number
}
export async function buildPresentationEvent(
params: BuildPresentationEventParams
): Promise<{
kind: 1
created_at: number
tags: string[][]
content: string
}> {
const category = params.category ?? 'sciencefiction'
const version = params.version ?? 0
const index = params.index ?? 0
const { presentation, contentDescription } = parseAuthorPresentationDraft(params.draft)
// Generate hash ID from author data first (needed for URL)
const hashId = await generateAuthorHashId({
pubkey: params.authorPubkey,
authorName: params.authorName,
presentation,
contentDescription,
mainnetAddress: params.draft.mainnetAddress ?? undefined,
pictureUrl: params.draft.pictureUrl ?? undefined,
category,
})
// Build URL: https://zapwall.fr/author/<hash>_<index>_<version> (using hash ID)
const profileUrl = generateObjectUrl('author', hashId, index, version)
// Encode pubkey to npub (for metadata JSON)
const npub = nip19.npubEncode(params.authorPubkey)
const linkWithPreview = buildProfileLink({
profileUrl,
authorName: params.authorName,
pictureUrl: params.draft.pictureUrl,
})
const visibleContent = [
'Nouveau profil auteur publié sur zapwall.fr (plateforme de publications scientifiques)',
linkWithPreview,
`Présentation personnelle : ${presentation}`,
...(contentDescription ? [`Description de votre contenu : ${contentDescription}`] : []),
].join('\n')
// Build profile JSON for metadata (stored in tag, not in content)
const profileJson = JSON.stringify({
authorName: params.authorName,
npub,
pubkey: params.authorPubkey,
presentation,
contentDescription,
mainnetAddress: params.draft.mainnetAddress,
pictureUrl: params.draft.pictureUrl,
category,
url: profileUrl,
version,
index,
})
// Build tags (profile JSON is in tag, not in content)
const tags = buildTags({
type: 'author',
category,
id: hashId,
service: PLATFORM_SERVICE,
version,
hidden: false,
paywall: false,
title: params.draft.title,
preview: params.draft.preview,
mainnetAddress: params.draft.mainnetAddress,
...(params.draft.pictureUrl ? { pictureUrl: params.draft.pictureUrl } : {}),
})
// Add JSON metadata as a tag (not in visible content)
tags.push(['json', profileJson])
return {
kind: 1 as const,
created_at: Math.floor(Date.now() / 1000),
tags,
content: visibleContent,
}
}
function buildProfileLink(params: { profileUrl: string; authorName: string; pictureUrl: string | undefined }): string {
if (params.pictureUrl) {
return `[![${params.authorName}](${params.pictureUrl})](${params.profileUrl})`
}
return params.profileUrl
}
export async function parsePresentationEvent(event: Event): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
const tags = extractTagsFromEvent(event)
// Check if it's an author type (tag is 'author' in English)
if (tags.type !== 'author') {
return null
}
const profileData = readPresentationProfileData(tags.json, event.content)
const originalCategory = mapTagCategoryToOriginalCategory(tags.category)
const { hash, version, index } = await resolvePresentationIdParts({ tags, event, profileData })
const id = buildObjectId(hash, index, version)
return buildPresentationArticle({
id,
hash,
version,
index,
event,
tags,
profileData,
originalCategory,
})
}
type PresentationProfileData = {
authorName?: string
presentation?: string
contentDescription?: string
mainnetAddress?: string
pictureUrl?: string
category?: string
}
function readPresentationProfileData(jsonTag: string | undefined, content: string): PresentationProfileData | null {
if (jsonTag) {
return parsePresentationProfileJson(jsonTag)
}
// Backward compatibility: invisible format (with zero-width characters)
const invisibleJsonMatch = content.match(/[\u200B\u200C]\[Metadata JSON\][\u200B\u200C]\n[\u200B\u200C](.+)[\u200B\u200C]$/s)
if (invisibleJsonMatch?.[1]) {
try {
const cleanedJson = invisibleJsonMatch[1].replace(/[\u200B\u200C\u200D\u200E\u200F]/g, '').trim()
return parsePresentationProfileJson(cleanedJson)
} catch (invisibleJsonError) {
console.error('Error parsing profile JSON from invisible content:', invisibleJsonError)
}
}
// Backward compatibility: visible format
const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s)
if (jsonMatch?.[1]) {
return parsePresentationProfileJson(jsonMatch[1].trim())
}
return null
}
function mapTagCategoryToOriginalCategory(category: unknown): 'science-fiction' | 'scientific-research' | undefined {
if (category === 'sciencefiction') {
return 'science-fiction'
}
if (category === 'research') {
return 'scientific-research'
}
return undefined
}
async function resolvePresentationIdParts(params: {
tags: ReturnType<typeof extractTagsFromEvent>
event: Event
profileData: PresentationProfileData | null
}): Promise<{ hash: string; version: number; index: number }> {
const version = typeof params.tags.version === 'number' ? params.tags.version : 0
const index = 0
const fromIdTag = resolvePresentationIdPartsFromIdTag(params.tags.id, version, index)
if (fromIdTag) {
return fromIdTag
}
const mainnetAddress = resolvePresentationMainnetAddressCandidate(params.profileData, params.tags)
const pictureUrl = resolvePresentationPictureUrlCandidate(params.profileData, params.tags)
const hash = await generateAuthorHashId({
pubkey: params.event.pubkey,
authorName: resolveOptionalString(params.profileData?.authorName),
presentation: resolveOptionalString(params.profileData?.presentation),
contentDescription: resolveOptionalString(params.profileData?.contentDescription),
mainnetAddress,
pictureUrl,
category: resolvePresentationHashCategory(params.profileData, params.tags),
})
return { hash, version, index }
}
function resolvePresentationIdPartsFromIdTag(
idTag: string | undefined,
defaultVersion: number,
defaultIndex: number
): { hash: string; version: number; index: number } | undefined {
if (!idTag) {
return undefined
}
const parsed = parseObjectId(idTag)
if (parsed.hash) {
return {
hash: parsed.hash,
version: parsed.version ?? defaultVersion,
index: parsed.index ?? defaultIndex,
}
}
return { hash: idTag, version: defaultVersion, index: defaultIndex }
}
function buildPresentationArticle(params: {
id: string
hash: string
version: number
index: number
event: Event
tags: ReturnType<typeof extractTagsFromEvent>
profileData: PresentationProfileData | null
originalCategory: 'science-fiction' | 'scientific-research' | undefined
}): import('@/types/nostr').AuthorPresentationArticle {
const description = resolvePresentationDescription(params.profileData, params.tags)
const contentDescription = sanitizePresentationContentDescription(resolvePresentationContentDescriptionRaw(params.profileData, params.tags))
const thumbnailUrl = resolvePresentationThumbnailUrl(params.profileData, params.tags)
const mainnetAddress = resolvePresentationMainnetAddress(params.profileData, params.tags)
const bannerUrl = resolvePresentationBannerUrl(params.profileData, params.tags)
const title = resolvePresentationTitle(params.tags)
const preview = resolvePresentationPreview(params.tags, params.event.content)
return {
id: params.id,
hash: params.hash,
version: params.version,
index: params.index,
pubkey: params.event.pubkey,
title,
preview,
content: params.event.content,
description,
contentDescription,
thumbnailUrl,
createdAt: params.event.created_at,
zapAmount: 0,
paid: true,
category: 'author-presentation',
isPresentation: true,
mainnetAddress,
totalSponsoring: 0,
originalCategory: params.originalCategory ?? 'science-fiction',
...(bannerUrl ? { bannerUrl } : {}),
}
}
function resolvePresentationTitle(tags: ReturnType<typeof extractTagsFromEvent>): string {
return typeof tags.title === 'string' && tags.title.length > 0 ? tags.title : 'Présentation'
}
function resolvePresentationPreview(tags: ReturnType<typeof extractTagsFromEvent>, content: string): string {
if (typeof tags.preview === 'string' && tags.preview.length > 0) {
return tags.preview
}
return content.substring(0, 200)
}
function resolvePresentationDescription(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
if (typeof profileData?.presentation === 'string') {
return profileData.presentation
}
return typeof tags.description === 'string' ? tags.description : ''
}
function resolvePresentationContentDescriptionRaw(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
if (typeof profileData?.contentDescription === 'string') {
return profileData.contentDescription
}
return typeof tags.description === 'string' ? tags.description : ''
}
function resolveOptionalString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function resolvePresentationMainnetAddressCandidate(
profileData: PresentationProfileData | null,
tags: ReturnType<typeof extractTagsFromEvent>
): string | undefined {
if (typeof profileData?.mainnetAddress === 'string') {
return profileData.mainnetAddress
}
return typeof tags.mainnetAddress === 'string' ? tags.mainnetAddress : undefined
}
function resolvePresentationPictureUrlCandidate(
profileData: PresentationProfileData | null,
tags: ReturnType<typeof extractTagsFromEvent>
): string | undefined {
if (typeof profileData?.pictureUrl === 'string') {
return profileData.pictureUrl
}
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : undefined
}
function resolvePresentationHashCategory(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
if (typeof profileData?.category === 'string') {
return profileData.category
}
return typeof tags.category === 'string' ? tags.category : 'sciencefiction'
}
function sanitizePresentationContentDescription(raw: string): string {
return raw
.split('\n')
.filter((line) => !line.includes('Adresse Bitcoin mainnet (pour le sponsoring)'))
.join('\n')
.trim()
}
function resolvePresentationThumbnailUrl(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
if (typeof profileData?.pictureUrl === 'string') {
return profileData.pictureUrl
}
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : ''
}
function resolvePresentationBannerUrl(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string | undefined {
if (typeof profileData?.pictureUrl === 'string' && profileData.pictureUrl.length > 0) {
return profileData.pictureUrl
}
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : undefined
}
function resolvePresentationMainnetAddress(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
const fromProfile = profileData?.mainnetAddress
if (typeof fromProfile === 'string') {
return fromProfile
}
return typeof tags.mainnetAddress === 'string' ? tags.mainnetAddress : ''
}
function parsePresentationProfileJson(json: string): {
authorName?: string
presentation?: string
contentDescription?: string
mainnetAddress?: string
pictureUrl?: string
category?: string
} | null {
try {
const parsed: unknown = JSON.parse(json)
if (typeof parsed !== 'object' || parsed === null) {
return null
}
const obj = parsed as Record<string, unknown>
return {
...readOptionalStringFields(obj, [
'authorName',
'presentation',
'contentDescription',
'mainnetAddress',
'pictureUrl',
'category',
]),
}
} catch (error) {
console.error('Error parsing presentation profile JSON:', error)
return null
}
}
function readOptionalStringFields<TKeys extends readonly string[]>(
obj: Record<string, unknown>,
keys: TKeys
): Partial<Record<TKeys[number], string>> {
const result: Partial<Record<TKeys[number], string>> = {}
for (const key of keys as ReadonlyArray<TKeys[number]>) {
const value = obj[key]
if (typeof value === 'string') {
result[key] = value
}
}
return result
}
export async function fetchAuthorPresentationFromPool(
pool: SimplePoolWithSub,
pubkey: string
): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
// Check cache first - this is the primary source
const cached = await objectCache.getAuthorByPubkey(pubkey)
if (cached) {
// Calculate totalSponsoring from cache
const { getAuthorSponsoring } = await import('./sponsoring')
cached.totalSponsoring = await getAuthorSponsoring(pubkey)
return cached
}
const filters = [
{
...buildTagFilter({
type: 'author',
authorPubkey: pubkey,
service: PLATFORM_SERVICE,
}),
since: MIN_EVENT_DATE,
limit: 100, // Get all versions to find the latest
},
]
return new Promise<import('@/types/nostr').AuthorPresentationArticle | null>((resolve) => {
let resolved = false
const relayUrl = getPrimaryRelaySync()
const sub = createSubscription(pool, [relayUrl], filters)
const events: Event[] = []
const finalize = async (value: import('@/types/nostr').AuthorPresentationArticle | null): Promise<void> => {
if (resolved) {
return
}
resolved = true
sub.unsub()
// Cache the result if found
if (value && events.length > 0) {
const event = events.find((e) => e.id === value.id) ?? events[0]
if (event) {
const tags = extractTagsFromEvent(event)
if (value.hash) {
// Calculate totalSponsoring from cache before storing
const { getAuthorSponsoring } = await import('./sponsoring')
const totalSponsoring = await getAuthorSponsoring(value.pubkey)
const cachedValue: import('@/types/nostr').AuthorPresentationArticle = {
...value,
totalSponsoring,
}
const { writeObjectToCache } = await import('./helpers/writeObjectHelper')
await writeObjectToCache({
objectType: 'author',
hash: value.hash,
event,
parsed: cachedValue,
version: tags.version,
hidden: tags.hidden,
index: value.index,
})
resolve(cachedValue)
return
}
}
}
resolve(value)
}
sub.on('event', (event: Event): void => {
// Collect all events first
const tags = extractTagsFromEvent(event)
if (tags.type === 'author' && !tags.hidden) {
events.push(event)
}
})
sub.on('eose', (): void => {
void (async (): Promise<void> => {
// Get the latest version from all collected events
const latestEvent = getLatestVersion(events)
if (latestEvent) {
const parsed = await parsePresentationEvent(latestEvent)
if (parsed) {
await finalize(parsed)
return
}
}
await finalize(null)
})()
})
// Reduced timeout for faster feedback when cache is empty
setTimeout((): void => {
void (async (): Promise<void> => {
// Get the latest version from all collected events
const latestEvent = getLatestVersion(events)
if (latestEvent) {
const parsed = await parsePresentationEvent(latestEvent)
if (parsed) {
await finalize(parsed)
return
}
}
await finalize(null)
})()
}, 2000).unref?.()
})
}

View File

@ -0,0 +1,102 @@
import type { Event } from 'nostr-tools'
import { extractTagsFromEvent } from '../nostrTagSystem'
import type { PresentationProfileData } from './profileJson'
type ExtractedTags = ReturnType<typeof extractTagsFromEvent>
export function buildPresentationArticle(params: {
id: string
hash: string
version: number
index: number
event: Event
tags: ExtractedTags
profileData: PresentationProfileData | null
originalCategory: 'science-fiction' | 'scientific-research' | undefined
}): import('@/types/nostr').AuthorPresentationArticle {
const description = resolvePresentationDescription(params.profileData, params.tags)
const contentDescription = sanitizePresentationContentDescription(resolvePresentationContentDescriptionRaw(params.profileData, params.tags))
const thumbnailUrl = resolvePresentationThumbnailUrl(params.profileData, params.tags)
const mainnetAddress = resolvePresentationMainnetAddress(params.profileData, params.tags)
const bannerUrl = resolvePresentationBannerUrl(params.profileData, params.tags)
const title = resolvePresentationTitle(params.tags)
const preview = resolvePresentationPreview(params.tags, params.event.content)
return {
id: params.id,
hash: params.hash,
version: params.version,
index: params.index,
pubkey: params.event.pubkey,
title,
preview,
content: params.event.content,
description,
contentDescription,
thumbnailUrl,
createdAt: params.event.created_at,
zapAmount: 0,
paid: true,
category: 'author-presentation',
isPresentation: true,
mainnetAddress,
totalSponsoring: 0,
originalCategory: params.originalCategory ?? 'science-fiction',
...(bannerUrl ? { bannerUrl } : {}),
}
}
function resolvePresentationTitle(tags: ExtractedTags): string {
return typeof tags.title === 'string' && tags.title.length > 0 ? tags.title : 'Présentation'
}
function resolvePresentationPreview(tags: ExtractedTags, content: string): string {
if (typeof tags.preview === 'string' && tags.preview.length > 0) {
return tags.preview
}
return content.substring(0, 200)
}
function resolvePresentationDescription(profileData: PresentationProfileData | null, tags: ExtractedTags): string {
if (typeof profileData?.presentation === 'string') {
return profileData.presentation
}
return typeof tags.description === 'string' ? tags.description : ''
}
function resolvePresentationContentDescriptionRaw(profileData: PresentationProfileData | null, tags: ExtractedTags): string {
if (typeof profileData?.contentDescription === 'string') {
return profileData.contentDescription
}
return typeof tags.description === 'string' ? tags.description : ''
}
function sanitizePresentationContentDescription(raw: string): string {
return raw
.split('\n')
.filter((line) => !line.includes('Adresse Bitcoin mainnet (pour le sponsoring)'))
.join('\n')
.trim()
}
function resolvePresentationThumbnailUrl(profileData: PresentationProfileData | null, tags: ExtractedTags): string {
if (typeof profileData?.pictureUrl === 'string') {
return profileData.pictureUrl
}
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : ''
}
function resolvePresentationBannerUrl(profileData: PresentationProfileData | null, tags: ExtractedTags): string | undefined {
if (typeof profileData?.pictureUrl === 'string' && profileData.pictureUrl.length > 0) {
return profileData.pictureUrl
}
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : undefined
}
function resolvePresentationMainnetAddress(profileData: PresentationProfileData | null, tags: ExtractedTags): string {
const fromProfile = profileData?.mainnetAddress
if (typeof fromProfile === 'string') {
return fromProfile
}
return typeof tags.mainnetAddress === 'string' ? tags.mainnetAddress : ''
}

View File

@ -0,0 +1,148 @@
import { nip19 } from 'nostr-tools'
import type { AuthorPresentationDraft } from '../articlePublisher'
import { buildTags } from '../nostrTagSystem'
import { PLATFORM_SERVICE } from '../platformConfig'
import { generateAuthorHashId } from '../hashIdGenerator'
import { generateObjectUrl } from '../urlGenerator'
import { parseAuthorPresentationDraft } from '../authorPresentationParsing'
interface BuildPresentationEventParams {
draft: AuthorPresentationDraft
authorPubkey: string
authorName: string
category?: 'sciencefiction' | 'research'
version?: number
index?: number
}
export async function buildPresentationEvent(
params: BuildPresentationEventParams
): Promise<{ kind: 1; created_at: number; tags: string[][]; content: string }> {
const { category, version, index } = normalizeBuildPresentationEventParams(params)
const parsedDraft = parseAuthorPresentationDraft(params.draft)
const hashId = await generateAuthorHashIdForPresentation({
authorPubkey: params.authorPubkey,
authorName: params.authorName,
parsedDraft,
mainnetAddress: params.draft.mainnetAddress,
pictureUrl: params.draft.pictureUrl,
category,
})
const profileUrl = generateObjectUrl('author', hashId, index, version)
const visibleContent = buildPresentationVisibleContent({
profileUrl,
authorName: params.authorName,
pictureUrl: params.draft.pictureUrl,
presentation: parsedDraft.presentation,
contentDescription: parsedDraft.contentDescription,
})
const profileJson = buildPresentationProfileJson({
authorPubkey: params.authorPubkey,
authorName: params.authorName,
profileUrl,
presentation: parsedDraft.presentation,
contentDescription: parsedDraft.contentDescription,
mainnetAddress: params.draft.mainnetAddress,
pictureUrl: params.draft.pictureUrl,
category,
version,
index,
})
const tags = buildPresentationTags({ draft: params.draft, category, hashId, version, profileJson })
return { kind: 1 as const, created_at: Math.floor(Date.now() / 1000), tags, content: visibleContent }
}
function normalizeBuildPresentationEventParams(params: BuildPresentationEventParams): { category: 'sciencefiction' | 'research'; version: number; index: number } {
return { category: params.category ?? 'sciencefiction', version: params.version ?? 0, index: params.index ?? 0 }
}
async function generateAuthorHashIdForPresentation(params: {
authorPubkey: string
authorName: string
parsedDraft: { presentation: string; contentDescription: string }
mainnetAddress: string | undefined
pictureUrl: string | undefined
category: 'sciencefiction' | 'research'
}): Promise<string> {
return generateAuthorHashId({
pubkey: params.authorPubkey,
authorName: params.authorName,
presentation: params.parsedDraft.presentation,
contentDescription: params.parsedDraft.contentDescription,
mainnetAddress: params.mainnetAddress ?? undefined,
pictureUrl: params.pictureUrl ?? undefined,
category: params.category,
})
}
function buildPresentationVisibleContent(params: {
profileUrl: string
authorName: string
pictureUrl: string | undefined
presentation: string
contentDescription: string
}): string {
const linkWithPreview = buildProfileLink({ profileUrl: params.profileUrl, authorName: params.authorName, pictureUrl: params.pictureUrl })
const lines = [
'Nouveau profil auteur publié sur zapwall.fr (plateforme de publications scientifiques)',
linkWithPreview,
`Présentation personnelle : ${params.presentation}`,
]
if (params.contentDescription) {
lines.push(`Description de votre contenu : ${params.contentDescription}`)
}
return lines.join('\n')
}
function buildPresentationProfileJson(params: {
authorPubkey: string
authorName: string
profileUrl: string
presentation: string
contentDescription: string
mainnetAddress: string | undefined
pictureUrl: string | undefined
category: 'sciencefiction' | 'research'
version: number
index: number
}): string {
const npub = nip19.npubEncode(params.authorPubkey)
return JSON.stringify({
authorName: params.authorName,
npub,
pubkey: params.authorPubkey,
presentation: params.presentation,
contentDescription: params.contentDescription,
mainnetAddress: params.mainnetAddress,
pictureUrl: params.pictureUrl,
category: params.category,
url: params.profileUrl,
version: params.version,
index: params.index,
})
}
function buildPresentationTags(params: { draft: AuthorPresentationDraft; category: 'sciencefiction' | 'research'; hashId: string; version: number; profileJson: string }): string[][] {
const tags = buildTags({
type: 'author',
category: params.category,
id: params.hashId,
service: PLATFORM_SERVICE,
version: params.version,
hidden: false,
paywall: false,
title: params.draft.title,
preview: params.draft.preview,
mainnetAddress: params.draft.mainnetAddress,
...(params.draft.pictureUrl ? { pictureUrl: params.draft.pictureUrl } : {}),
})
tags.push(['json', params.profileJson])
return tags
}
function buildProfileLink(params: { profileUrl: string; authorName: string; pictureUrl: string | undefined }): string {
if (params.pictureUrl) {
return `[![${params.authorName}](${params.pictureUrl})](${params.profileUrl})`
}
return params.profileUrl
}

View File

@ -0,0 +1,122 @@
import type { Event, Filter } from 'nostr-tools'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import { createSubscription } from '@/types/nostr-tools-extended'
import { buildTagFilter, extractTagsFromEvent } from '../nostrTagSystem'
import { getPrimaryRelaySync } from '../config'
import { MIN_EVENT_DATE, PLATFORM_SERVICE } from '../platformConfig'
import { getLatestVersion } from '../versionManager'
import { objectCache } from '../objectCache'
import { parsePresentationEvent } from './parsePresentationEvent'
export async function fetchAuthorPresentationFromPool(
pool: SimplePoolWithSub,
pubkey: string
): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
const cached = await objectCache.getAuthorByPubkey(pubkey)
if (cached) {
return enrichAuthorPresentationWithSponsoring({ author: cached, pubkey })
}
const filters: Filter[] = [
{
...buildTagFilter({ type: 'author', authorPubkey: pubkey, service: PLATFORM_SERVICE }),
since: MIN_EVENT_DATE,
limit: 100,
},
]
return fetchAuthorPresentationFromRelay({ pool, pubkey, filters })
}
async function enrichAuthorPresentationWithSponsoring(params: {
author: import('@/types/nostr').AuthorPresentationArticle
pubkey: string
}): Promise<import('@/types/nostr').AuthorPresentationArticle> {
const { getAuthorSponsoring } = await import('../sponsoring')
const totalSponsoring = await getAuthorSponsoring(params.pubkey)
return { ...params.author, totalSponsoring }
}
async function fetchAuthorPresentationFromRelay(params: {
pool: SimplePoolWithSub
pubkey: string
filters: Filter[]
}): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
const relayUrl = getPrimaryRelaySync()
const events: Event[] = []
const sub = createSubscription(params.pool, [relayUrl], params.filters)
const finalize = createRelayFetchFinalizer({ pubkey: params.pubkey, sub, events })
return new Promise<import('@/types/nostr').AuthorPresentationArticle | null>((resolve) => {
const finalizeAndResolve = async (value: import('@/types/nostr').AuthorPresentationArticle | null): Promise<void> => {
const finalValue = await finalize(value)
resolve(finalValue)
}
sub.on('event', (event: Event): void => {
const tags = extractTagsFromEvent(event)
if (tags.type === 'author' && !tags.hidden) {
events.push(event)
}
})
sub.on('eose', (): void => {
void tryFinalizeFromLatestEvent({ events, finalize: finalizeAndResolve })
})
setTimeout((): void => {
void tryFinalizeFromLatestEvent({ events, finalize: finalizeAndResolve })
}, 2000).unref?.()
})
}
async function tryFinalizeFromLatestEvent(params: {
events: Event[]
finalize: (value: import('@/types/nostr').AuthorPresentationArticle | null) => Promise<void>
}): Promise<void> {
const latestEvent = getLatestVersion(params.events)
if (!latestEvent) {
await params.finalize(null)
return
}
const parsed = await parsePresentationEvent(latestEvent)
await params.finalize(parsed)
}
function createRelayFetchFinalizer(params: {
pubkey: string
sub: import('@/types/nostr-tools-extended').Subscription
events: Event[]
}): (value: import('@/types/nostr').AuthorPresentationArticle | null) => Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
let resolved = false
return async (value: import('@/types/nostr').AuthorPresentationArticle | null): Promise<import('@/types/nostr').AuthorPresentationArticle | null> => {
if (resolved) {
return value
}
resolved = true
params.sub.unsub()
return cacheAuthorPresentationFromEvents({ value, events: params.events, pubkey: params.pubkey })
}
}
async function cacheAuthorPresentationFromEvents(params: {
value: import('@/types/nostr').AuthorPresentationArticle | null
events: Event[]
pubkey: string
}): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
if (!params.value || params.events.length === 0 || !params.value.hash) {
return params.value
}
const event = params.events.find((e) => e.id === params.value?.id) ?? params.events[0]
if (!event) {
return params.value
}
const tags = extractTagsFromEvent(event)
const cachedValue = await enrichAuthorPresentationWithSponsoring({ author: params.value, pubkey: params.pubkey })
const { writeObjectToCache } = await import('../helpers/writeObjectHelper')
await writeObjectToCache({
objectType: 'author',
hash: params.value.hash,
event,
parsed: cachedValue,
version: tags.version,
hidden: tags.hidden,
index: params.value.index,
})
return cachedValue
}

View File

@ -0,0 +1,97 @@
import type { Event } from 'nostr-tools'
import { generateAuthorHashId } from '../hashIdGenerator'
import { parseObjectId } from '../urlGenerator'
import { extractTagsFromEvent } from '../nostrTagSystem'
import type { PresentationProfileData } from './profileJson'
type ExtractedTags = ReturnType<typeof extractTagsFromEvent>
export function mapTagCategoryToOriginalCategory(category: unknown): 'science-fiction' | 'scientific-research' | undefined {
if (category === 'sciencefiction') {
return 'science-fiction'
}
if (category === 'research') {
return 'scientific-research'
}
return undefined
}
export async function resolvePresentationIdParts(params: {
tags: ExtractedTags
event: Event
profileData: PresentationProfileData | null
}): Promise<{ hash: string; version: number; index: number }> {
const version = typeof params.tags.version === 'number' ? params.tags.version : 0
const index = 0
const fromIdTag = resolvePresentationIdPartsFromIdTag(params.tags.id, version, index)
if (fromIdTag) {
return fromIdTag
}
const mainnetAddress = resolvePresentationMainnetAddressCandidate(params.profileData, params.tags)
const pictureUrl = resolvePresentationPictureUrlCandidate(params.profileData, params.tags)
const hash = await generateAuthorHashId({
pubkey: params.event.pubkey,
authorName: resolveOptionalString(params.profileData?.authorName),
presentation: resolveOptionalString(params.profileData?.presentation),
contentDescription: resolveOptionalString(params.profileData?.contentDescription),
mainnetAddress,
pictureUrl,
category: resolvePresentationHashCategory(params.profileData, params.tags),
})
return { hash, version, index }
}
function resolvePresentationIdPartsFromIdTag(
idTag: string | undefined,
defaultVersion: number,
defaultIndex: number
): { hash: string; version: number; index: number } | undefined {
if (!idTag) {
return undefined
}
const parsed = parseObjectId(idTag)
if (parsed.hash) {
return { hash: parsed.hash, version: parsed.version ?? defaultVersion, index: parsed.index ?? defaultIndex }
}
return { hash: idTag, version: defaultVersion, index: defaultIndex }
}
function resolveOptionalString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function resolvePresentationMainnetAddressCandidate(
profileData: PresentationProfileData | null,
tags: ExtractedTags
): string | undefined {
if (typeof profileData?.mainnetAddress === 'string') {
return profileData.mainnetAddress
}
return typeof tags.mainnetAddress === 'string' ? tags.mainnetAddress : undefined
}
function resolvePresentationPictureUrlCandidate(
profileData: PresentationProfileData | null,
tags: ExtractedTags
): string | undefined {
if (typeof profileData?.pictureUrl === 'string') {
return profileData.pictureUrl
}
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : undefined
}
function resolvePresentationHashCategory(
profileData: PresentationProfileData | null,
tags: ExtractedTags
): string {
if (typeof profileData?.category === 'string') {
return profileData.category
}
return typeof tags.category === 'string' ? tags.category : 'sciencefiction'
}

View File

@ -0,0 +1,18 @@
import type { Event } from 'nostr-tools'
import { extractTagsFromEvent } from '../nostrTagSystem'
import { buildObjectId } from '../urlGenerator'
import { readPresentationProfileData } from './profileData'
import { mapTagCategoryToOriginalCategory, resolvePresentationIdParts } from './idResolution'
import { buildPresentationArticle } from './articleBuilder'
export async function parsePresentationEvent(event: Event): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
const tags = extractTagsFromEvent(event)
if (tags.type !== 'author') {
return null
}
const profileData = readPresentationProfileData(tags.json, event.content)
const originalCategory = mapTagCategoryToOriginalCategory(tags.category)
const { hash, version, index } = await resolvePresentationIdParts({ tags, event, profileData })
const id = buildObjectId(hash, index, version)
return buildPresentationArticle({ id, hash, version, index, event, tags, profileData, originalCategory })
}

View File

@ -0,0 +1,26 @@
import { parsePresentationProfileJson, type PresentationProfileData } from './profileJson'
export function readPresentationProfileData(jsonTag: string | undefined, content: string): PresentationProfileData | null {
if (jsonTag) {
return parsePresentationProfileJson(jsonTag)
}
// Backward compatibility: invisible format (with zero-width characters)
const invisibleJsonMatch = content.match(/[\u200B\u200C]\[Metadata JSON\][\u200B\u200C]\n[\u200B\u200C](.+)[\u200B\u200C]$/s)
if (invisibleJsonMatch?.[1]) {
try {
const cleanedJson = invisibleJsonMatch[1].replace(/[\u200B\u200C\u200D\u200E\u200F]/g, '').trim()
return parsePresentationProfileJson(cleanedJson)
} catch (invisibleJsonError) {
console.error('Error parsing profile JSON from invisible content:', invisibleJsonError)
}
}
// Backward compatibility: visible format
const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s)
if (jsonMatch?.[1]) {
return parsePresentationProfileJson(jsonMatch[1].trim())
}
return null
}

View File

@ -0,0 +1,38 @@
export type PresentationProfileData = {
authorName?: string
presentation?: string
contentDescription?: string
mainnetAddress?: string
pictureUrl?: string
category?: string
}
export function parsePresentationProfileJson(json: string): PresentationProfileData | null {
try {
const parsed: unknown = JSON.parse(json)
if (typeof parsed !== 'object' || parsed === null) {
return null
}
const obj = parsed as Record<string, unknown>
return {
...readOptionalStringFields(obj, ['authorName', 'presentation', 'contentDescription', 'mainnetAddress', 'pictureUrl', 'category']),
}
} catch (error) {
console.error('Error parsing presentation profile JSON:', error)
return null
}
}
function readOptionalStringFields<TKeys extends readonly string[]>(
obj: Record<string, unknown>,
keys: TKeys
): Partial<Record<TKeys[number], string>> {
const result: Partial<Record<TKeys[number], string>> = {}
for (const key of keys as ReadonlyArray<TKeys[number]>) {
const value = obj[key]
if (typeof value === 'string') {
result[key] = value
}
}
return result
}

View File

@ -0,0 +1,91 @@
import type { AuthorPresentationDraft } from './articlePublisherTypes'
import { buildPresentationEvent } from './articlePublisherHelpers'
import { generateAuthorHashId } from './hashIdGenerator'
import { buildObjectId } from './urlGenerator'
import { extractAuthorNameFromTitle, parseAuthorPresentationDraft } from './authorPresentationParsing'
import type { EventTemplate } from 'nostr-tools'
export async function buildPresentationPublishContext(params: {
draft: AuthorPresentationDraft
authorPubkey: string
}): Promise<{
hash: string
version: number
index: number
parsedAuthor: import('@/types/nostr').AuthorPresentationArticle
eventTemplate: EventTemplate
}> {
const authorName = extractAuthorNameFromTitle(params.draft.title)
const { presentation, contentDescription } = parseAuthorPresentationDraft(params.draft)
const category = 'sciencefiction'
const version = 0
const index = 0
const hash = await generateAuthorHashId({
pubkey: params.authorPubkey,
authorName,
presentation,
contentDescription,
mainnetAddress: params.draft.mainnetAddress ?? undefined,
pictureUrl: params.draft.pictureUrl ?? undefined,
category,
})
const id = buildObjectId(hash, index, version)
const parsedAuthor = buildParsedAuthorPresentation({
draft: params.draft,
authorPubkey: params.authorPubkey,
id,
hash,
version,
index,
presentation,
contentDescription,
})
const eventTemplate = await buildPresentationEvent({ draft: params.draft, authorPubkey: params.authorPubkey, authorName, category, version, index })
return { hash, version, index, parsedAuthor, eventTemplate }
}
function buildParsedAuthorPresentation(params: {
draft: AuthorPresentationDraft
authorPubkey: string
id: string
hash: string
version: number
index: number
presentation: string
contentDescription: string
}): import('@/types/nostr').AuthorPresentationArticle {
return {
id: params.id,
hash: params.hash,
version: params.version,
index: params.index,
pubkey: params.authorPubkey,
title: params.draft.title,
preview: params.draft.preview,
content: params.draft.content,
description: params.presentation,
contentDescription: params.contentDescription,
thumbnailUrl: params.draft.pictureUrl ?? '',
createdAt: Math.floor(Date.now() / 1000),
zapAmount: 0,
paid: true,
category: 'author-presentation',
isPresentation: true,
mainnetAddress: params.draft.mainnetAddress ?? '',
totalSponsoring: 0,
originalCategory: 'science-fiction',
...(params.draft.pictureUrl ? { bannerUrl: params.draft.pictureUrl } : {}),
}
}
export async function getActiveRelaysOrPrimary(): Promise<string[]> {
const { relaySessionManager } = await import('./relaySessionManager')
const activeRelays = await relaySessionManager.getActiveRelays()
if (activeRelays.length > 0) {
return activeRelays
}
const { getPrimaryRelay } = await import('./config')
return [await getPrimaryRelay()]
}

View File

@ -1,616 +1 @@
/** export * from './indexedDBHelper/index'
* Centralized IndexedDB helper for initialization and transaction management
* Provides unified API for all IndexedDB operations across the application
*/
export interface IndexedDBIndex {
name: string
keyPath: string | string[]
unique?: boolean
}
export interface IndexedDBConfig {
dbName: string
version: number
storeName: string
keyPath: string
indexes?: IndexedDBIndex[]
onUpgrade?: (db: IDBDatabase, event: IDBVersionChangeEvent) => void
}
export class IndexedDBError extends Error {
public readonly operation: string
public readonly storeName: string | undefined
public override readonly cause: unknown
public override readonly name = 'IndexedDBError'
constructor(message: string, operation: string, storeName?: string, cause?: unknown) {
super(message)
this.operation = operation
this.storeName = storeName
this.cause = cause
console.error(`[IndexedDBError] ${operation}${storeName ? ` on ${storeName}` : ''}: ${message}`, cause)
}
}
class IndexedDBHelper {
private db: IDBDatabase | null = null
private initPromise: Promise<void> | null = null
private readonly config: IndexedDBConfig
constructor(config: IndexedDBConfig) {
this.config = config
}
/**
* Initialize the IndexedDB database
*/
async init(): Promise<IDBDatabase> {
if (this.db) {
return this.db
}
if (this.initPromise) {
await this.initPromise
if (this.db) {
return this.db
}
throw new IndexedDBError('Database initialization failed', 'init', this.config.storeName)
}
this.initPromise = this.openDatabase()
try {
await this.initPromise
if (!this.db) {
throw new IndexedDBError('Database not initialized after open', 'init', this.config.storeName)
}
return this.db
} catch (error) {
this.initPromise = null
throw new IndexedDBError(
error instanceof Error ? error.message : 'Unknown error',
'init',
this.config.storeName,
error
)
}
}
private openDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof window === 'undefined' || !window.indexedDB) {
reject(new IndexedDBError('IndexedDB is not available', 'openDatabase', this.config.storeName))
return
}
const request = window.indexedDB.open(this.config.dbName, this.config.version)
request.onerror = (): void => {
reject(
new IndexedDBError(
`Failed to open IndexedDB: ${request.error}`,
'openDatabase',
this.config.storeName,
request.error
)
)
}
request.onsuccess = (): void => {
this.db = request.result
resolve()
}
request.onupgradeneeded = (event: IDBVersionChangeEvent): void => {
this.handleUpgrade(event)
}
})
}
private handleUpgrade(event: IDBVersionChangeEvent): void {
const db = (event.target as IDBOpenDBRequest).result
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains(this.config.storeName)) {
this.createObjectStore(db)
} else {
// Store exists, check for missing indexes
this.createMissingIndexes(db, event)
}
// Call custom upgrade handler if provided
if (this.config.onUpgrade) {
this.config.onUpgrade(db, event)
}
}
private createObjectStore(db: IDBDatabase): void {
const store = db.createObjectStore(this.config.storeName, { keyPath: this.config.keyPath })
// Create indexes
if (this.config.indexes) {
for (const index of this.config.indexes) {
if (!store.indexNames.contains(index.name)) {
store.createIndex(index.name, index.keyPath, { unique: index.unique ?? false })
}
}
}
}
private createMissingIndexes(_db: IDBDatabase, event: IDBVersionChangeEvent): void {
const target = event.target as IDBOpenDBRequest
const { transaction } = target
if (!transaction) {
return
}
const store = transaction.objectStore(this.config.storeName)
if (this.config.indexes) {
for (const index of this.config.indexes) {
if (!store.indexNames.contains(index.name)) {
store.createIndex(index.name, index.keyPath, { unique: index.unique ?? false })
}
}
}
}
/**
* Get object store for read operations
*/
async getStore(mode: 'readonly'): Promise<IDBObjectStore> {
const db = await this.init()
const transaction = db.transaction([this.config.storeName], mode)
return transaction.objectStore(this.config.storeName)
}
/**
* Get object store for write operations
*/
async getStoreWrite(mode: 'readwrite'): Promise<IDBObjectStore> {
const db = await this.init()
const transaction = db.transaction([this.config.storeName], mode)
return transaction.objectStore(this.config.storeName)
}
/**
* Get a value from the store by key
*/
async get<T = unknown>(key: string | number): Promise<T | null> {
try {
const store = await this.getStore('readonly')
return new Promise<T | null>((resolve, reject) => {
const request = store.get(key)
request.onsuccess = (): void => {
resolve((request.result as T) ?? null)
}
request.onerror = (): void => {
reject(
new IndexedDBError(
`Failed to get value: ${request.error}`,
'get',
this.config.storeName,
request.error
)
)
}
})
} catch (error) {
if (error instanceof IndexedDBError) {
throw error
}
throw new IndexedDBError(
error instanceof Error ? error.message : 'Unknown error',
'get',
this.config.storeName,
error
)
}
}
/**
* Get a value from an index
*/
async getByIndex<T = unknown>(indexName: string, key: string | number): Promise<T | null> {
try {
const store = await this.getStore('readonly')
const index = store.index(indexName)
return new Promise<T | null>((resolve, reject) => {
const request = index.get(key)
request.onsuccess = (): void => {
resolve((request.result as T) ?? null)
}
request.onerror = (): void => {
reject(
new IndexedDBError(
`Failed to get value by index: ${request.error}`,
'getByIndex',
this.config.storeName,
request.error
)
)
}
})
} catch (error) {
if (error instanceof IndexedDBError) {
throw error
}
throw new IndexedDBError(
error instanceof Error ? error.message : 'Unknown error',
'getByIndex',
this.config.storeName,
error
)
}
}
/**
* Get all values from an index
*/
async getAllByIndex<T = unknown>(indexName: string, key?: IDBValidKey | IDBKeyRange): Promise<T[]> {
try {
const store = await this.getStore('readonly')
const index = store.index(indexName)
return new Promise<T[]>((resolve, reject) => {
const request = key !== undefined ? index.getAll(key) : index.getAll()
request.onsuccess = (): void => {
resolve((request.result as T[]) ?? [])
}
request.onerror = (): void => {
reject(
new IndexedDBError(
`Failed to get all values by index: ${request.error}`,
'getAllByIndex',
this.config.storeName,
request.error
)
)
}
})
} catch (error) {
if (error instanceof IndexedDBError) {
throw error
}
throw new IndexedDBError(
error instanceof Error ? error.message : 'Unknown error',
'getAllByIndex',
this.config.storeName,
error
)
}
}
/**
* Put a value in the store
*/
async put<T = unknown>(value: T): Promise<void> {
try {
const store = await this.getStoreWrite('readwrite')
return new Promise<void>((resolve, reject) => {
const request = store.put(value)
request.onsuccess = (): void => {
resolve()
}
request.onerror = (): void => {
reject(
new IndexedDBError(
`Failed to put value: ${request.error}`,
'put',
this.config.storeName,
request.error
)
)
}
})
} catch (error) {
if (error instanceof IndexedDBError) {
throw error
}
throw new IndexedDBError(
error instanceof Error ? error.message : 'Unknown error',
'put',
this.config.storeName,
error
)
}
}
/**
* Add a value to the store (fails if key exists)
*/
async add<T = unknown>(value: T): Promise<void> {
try {
const store = await this.getStoreWrite('readwrite')
return new Promise<void>((resolve, reject) => {
const request = store.add(value)
request.onsuccess = (): void => {
resolve()
}
request.onerror = (): void => {
reject(
new IndexedDBError(
`Failed to add value: ${request.error}`,
'add',
this.config.storeName,
request.error
)
)
}
})
} catch (error) {
if (error instanceof IndexedDBError) {
throw error
}
throw new IndexedDBError(
error instanceof Error ? error.message : 'Unknown error',
'add',
this.config.storeName,
error
)
}
}
/**
* Delete a value from the store by key
*/
async delete(key: string | number): Promise<void> {
try {
const store = await this.getStoreWrite('readwrite')
return new Promise<void>((resolve, reject) => {
const request = store.delete(key)
request.onsuccess = (): void => {
resolve()
}
request.onerror = (): void => {
reject(
new IndexedDBError(
`Failed to delete value: ${request.error}`,
'delete',
this.config.storeName,
request.error
)
)
}
})
} catch (error) {
if (error instanceof IndexedDBError) {
throw error
}
throw new IndexedDBError(
error instanceof Error ? error.message : 'Unknown error',
'delete',
this.config.storeName,
error
)
}
}
/**
* Clear all values from the store
*/
async clear(): Promise<void> {
try {
const store = await this.getStoreWrite('readwrite')
return new Promise<void>((resolve, reject) => {
const request = store.clear()
request.onsuccess = (): void => {
resolve()
}
request.onerror = (): void => {
reject(
new IndexedDBError(
`Failed to clear store: ${request.error}`,
'clear',
this.config.storeName,
request.error
)
)
}
})
} catch (error) {
if (error instanceof IndexedDBError) {
throw error
}
throw new IndexedDBError(
error instanceof Error ? error.message : 'Unknown error',
'clear',
this.config.storeName,
error
)
}
}
/**
* Open a cursor on the store
*/
async openCursor(
direction?: IDBCursorDirection,
range?: IDBKeyRange
): Promise<IDBCursorWithValue | null> {
try {
const store = await this.getStore('readonly')
return new Promise<IDBCursorWithValue | null>((resolve, reject) => {
const request = range ? store.openCursor(range, direction) : store.openCursor(direction)
request.onsuccess = (): void => {
resolve(request.result)
}
request.onerror = (): void => {
reject(
new IndexedDBError(
`Failed to open cursor: ${request.error}`,
'openCursor',
this.config.storeName,
request.error
)
)
}
})
} catch (error) {
if (error instanceof IndexedDBError) {
throw error
}
throw new IndexedDBError(
error instanceof Error ? error.message : 'Unknown error',
'openCursor',
this.config.storeName,
error
)
}
}
/**
* Open a cursor on an index
*/
async openCursorOnIndex(
indexName: string,
direction?: IDBCursorDirection,
range?: IDBKeyRange
): Promise<IDBCursorWithValue | null> {
try {
const store = await this.getStore('readonly')
const index = store.index(indexName)
return new Promise<IDBCursorWithValue | null>((resolve, reject) => {
const request = range ? index.openCursor(range, direction) : index.openCursor(direction)
request.onsuccess = (): void => {
resolve(request.result)
}
request.onerror = (): void => {
reject(
new IndexedDBError(
`Failed to open cursor on index: ${request.error}`,
'openCursorOnIndex',
this.config.storeName,
request.error
)
)
}
})
} catch (error) {
if (error instanceof IndexedDBError) {
throw error
}
throw new IndexedDBError(
error instanceof Error ? error.message : 'Unknown error',
'openCursorOnIndex',
this.config.storeName,
error
)
}
}
/**
* Count records in the store
*/
async count(range?: IDBKeyRange): Promise<number> {
try {
const store = await this.getStore('readonly')
return new Promise<number>((resolve, reject) => {
const request = range ? store.count(range) : store.count()
request.onsuccess = (): void => {
resolve(request.result)
}
request.onerror = (): void => {
reject(
new IndexedDBError(
`Failed to count records: ${request.error}`,
'count',
this.config.storeName,
request.error
)
)
}
})
} catch (error) {
if (error instanceof IndexedDBError) {
throw error
}
throw new IndexedDBError(
error instanceof Error ? error.message : 'Unknown error',
'count',
this.config.storeName,
error
)
}
}
/**
* Count records in an index
*/
async countByIndex(indexName: string, range?: IDBKeyRange): Promise<number> {
try {
const store = await this.getStore('readonly')
const index = store.index(indexName)
return new Promise<number>((resolve, reject) => {
const request = range ? index.count(range) : index.count()
request.onsuccess = (): void => {
resolve(request.result)
}
request.onerror = (): void => {
reject(
new IndexedDBError(
`Failed to count records by index: ${request.error}`,
'countByIndex',
this.config.storeName,
request.error
)
)
}
})
} catch (error) {
if (error instanceof IndexedDBError) {
throw error
}
throw new IndexedDBError(
error instanceof Error ? error.message : 'Unknown error',
'countByIndex',
this.config.storeName,
error
)
}
}
/**
* Get all values from the store
*/
async getAll<T = unknown>(range?: IDBKeyRange, count?: number): Promise<T[]> {
try {
const store = await this.getStore('readonly')
return new Promise<T[]>((resolve, reject) => {
const request = range ? store.getAll(range, count) : store.getAll(undefined, count)
request.onsuccess = (): void => {
resolve((request.result as T[]) ?? [])
}
request.onerror = (): void => {
reject(
new IndexedDBError(
`Failed to get all values: ${request.error}`,
'getAll',
this.config.storeName,
request.error
)
)
}
})
} catch (error) {
if (error instanceof IndexedDBError) {
throw error
}
throw new IndexedDBError(
error instanceof Error ? error.message : 'Unknown error',
'getAll',
this.config.storeName,
error
)
}
}
}
/**
* Create a new IndexedDB helper instance
*/
export function createIndexedDBHelper(config: IndexedDBConfig): IndexedDBHelper {
return new IndexedDBHelper(config)
}
export { IndexedDBHelper }

View File

@ -0,0 +1,15 @@
export class IndexedDBError extends Error {
public readonly operation: string
public readonly storeName: string | undefined
public override readonly cause: unknown
public override readonly name = 'IndexedDBError'
constructor(message: string, operation: string, storeName?: string, cause?: unknown) {
super(message)
this.operation = operation
this.storeName = storeName
this.cause = cause
console.error(`[IndexedDBError] ${operation}${storeName ? ` on ${storeName}` : ''}: ${message}`, cause)
}
}

View File

@ -0,0 +1,97 @@
import type { IndexedDBConfig } from './types'
import { IndexedDBError } from './IndexedDBError'
import { initDatabase, type CoreAccessors } from './core'
import { getObjectStore } from './store'
import { getAll, getAllByIndex, getByIndex, getValue } from './read'
import { addValue, clearStore, deleteValue, putValue } from './write'
import { count, countByIndex, openCursor, openCursorOnIndex } from './cursor'
export class IndexedDBHelper {
private db: IDBDatabase | null = null
private initPromise: Promise<void> | null = null
private readonly config: IndexedDBConfig
constructor(config: IndexedDBConfig) {
this.config = config
}
private accessors(): CoreAccessors {
return {
getDb: () => this.db,
setDb: (db) => {
this.db = db
},
getInitPromise: () => this.initPromise,
setInitPromise: (promise) => {
this.initPromise = promise
},
getConfig: () => this.config,
}
}
async init(): Promise<IDBDatabase> {
return initDatabase(this.accessors())
}
async getStore(mode: 'readonly'): Promise<IDBObjectStore> {
return getObjectStore(this.accessors(), mode)
}
async getStoreWrite(mode: 'readwrite'): Promise<IDBObjectStore> {
return getObjectStore(this.accessors(), mode)
}
async get<T = unknown>(key: string | number): Promise<T | null> {
return getValue<T>(this.accessors(), key)
}
async getByIndex<T = unknown>(indexName: string, key: string | number): Promise<T | null> {
return getByIndex<T>(this.accessors(), indexName, key)
}
async getAllByIndex<T = unknown>(indexName: string, key?: IDBValidKey | IDBKeyRange): Promise<T[]> {
return getAllByIndex<T>(this.accessors(), indexName, key)
}
async put<T = unknown>(value: T): Promise<void> {
return putValue<T>(this.accessors(), value)
}
async add<T = unknown>(value: T): Promise<void> {
return addValue<T>(this.accessors(), value)
}
async delete(key: string | number): Promise<void> {
return deleteValue(this.accessors(), key)
}
async clear(): Promise<void> {
return clearStore(this.accessors())
}
async openCursor(direction?: IDBCursorDirection, range?: IDBKeyRange): Promise<IDBCursorWithValue | null> {
return openCursor(this.accessors(), direction, range)
}
async openCursorOnIndex(indexName: string, direction?: IDBCursorDirection, range?: IDBKeyRange): Promise<IDBCursorWithValue | null> {
return openCursorOnIndex(this.accessors(), indexName, direction, range)
}
async count(range?: IDBKeyRange): Promise<number> {
return count(this.accessors(), range)
}
async countByIndex(indexName: string, range?: IDBKeyRange): Promise<number> {
return countByIndex(this.accessors(), indexName, range)
}
async getAll<T = unknown>(range?: IDBKeyRange, countParam?: number): Promise<T[]> {
return getAll<T>(this.accessors(), range, countParam)
}
}
export function assertIsIndexedDBError(error: unknown): asserts error is IndexedDBError {
if (!(error instanceof IndexedDBError)) {
throw error
}
}

View File

@ -0,0 +1,97 @@
import type { IndexedDBConfig } from './types'
import { IndexedDBError } from './IndexedDBError'
export type CoreAccessors = {
getDb: () => IDBDatabase | null
setDb: (db: IDBDatabase | null) => void
getInitPromise: () => Promise<void> | null
setInitPromise: (promise: Promise<void> | null) => void
getConfig: () => IndexedDBConfig
}
export async function initDatabase(accessors: CoreAccessors): Promise<IDBDatabase> {
const existing = accessors.getDb()
if (existing) {
return existing
}
const pending = accessors.getInitPromise()
if (pending) {
await pending
const db = accessors.getDb()
if (db) {
return db
}
throw new IndexedDBError('Database initialization failed', 'init', accessors.getConfig().storeName)
}
const initPromise = openDatabase(accessors)
accessors.setInitPromise(initPromise)
try {
await initPromise
const db = accessors.getDb()
if (!db) {
throw new IndexedDBError('Database not initialized after open', 'init', accessors.getConfig().storeName)
}
return db
} catch (error) {
accessors.setInitPromise(null)
throw new IndexedDBError(error instanceof Error ? error.message : 'Unknown error', 'init', accessors.getConfig().storeName, error)
}
}
export function openDatabase(accessors: CoreAccessors): Promise<void> {
return new Promise((resolve, reject) => {
const config = accessors.getConfig()
if (typeof window === 'undefined' || !window.indexedDB) {
reject(new IndexedDBError('IndexedDB is not available', 'openDatabase', config.storeName))
return
}
const request = window.indexedDB.open(config.dbName, config.version)
request.onerror = (): void => {
reject(new IndexedDBError(`Failed to open IndexedDB: ${request.error}`, 'openDatabase', config.storeName, request.error))
}
request.onsuccess = (): void => {
accessors.setDb(request.result)
resolve()
}
request.onupgradeneeded = (event: IDBVersionChangeEvent): void => {
handleUpgrade({ config, event })
}
})
}
function handleUpgrade(params: { config: IndexedDBConfig; event: IDBVersionChangeEvent }): void {
const db = (params.event.target as IDBOpenDBRequest).result
if (!db.objectStoreNames.contains(params.config.storeName)) {
createObjectStore(db, params.config)
} else {
createMissingIndexes(db, params.event, params.config)
}
params.config.onUpgrade?.(db, params.event)
}
function createObjectStore(db: IDBDatabase, config: IndexedDBConfig): void {
const store = db.createObjectStore(config.storeName, { keyPath: config.keyPath })
if (!config.indexes) {
return
}
for (const index of config.indexes) {
if (!store.indexNames.contains(index.name)) {
store.createIndex(index.name, index.keyPath, { unique: index.unique ?? false })
}
}
}
function createMissingIndexes(_db: IDBDatabase, event: IDBVersionChangeEvent, config: IndexedDBConfig): void {
const target = event.target as IDBOpenDBRequest
const { transaction } = target
if (!transaction || !config.indexes) {
return
}
const store = transaction.objectStore(config.storeName)
for (const index of config.indexes) {
if (!store.indexNames.contains(index.name)) {
store.createIndex(index.name, index.keyPath, { unique: index.unique ?? false })
}
}
}

View File

@ -0,0 +1,77 @@
import { IndexedDBError } from './IndexedDBError'
import type { CoreAccessors } from './core'
import { getObjectStore } from './store'
export async function openCursor(accessors: CoreAccessors, direction?: IDBCursorDirection, range?: IDBKeyRange): Promise<IDBCursorWithValue | null> {
const {storeName} = accessors.getConfig()
try {
const store = await getObjectStore(accessors, 'readonly')
const request = range ? store.openCursor(range, direction) : store.openCursor(direction)
return await wrapCursorRequest(request, 'openCursor', storeName)
} catch (error) {
throw ensureIndexedDBError(error, 'openCursor', storeName)
}
}
export async function openCursorOnIndex(
accessors: CoreAccessors,
indexName: string,
direction?: IDBCursorDirection,
range?: IDBKeyRange
): Promise<IDBCursorWithValue | null> {
const {storeName} = accessors.getConfig()
try {
const store = await getObjectStore(accessors, 'readonly')
const index = store.index(indexName)
const request = range ? index.openCursor(range, direction) : index.openCursor(direction)
return await wrapCursorRequest(request, 'openCursorOnIndex', storeName)
} catch (error) {
throw ensureIndexedDBError(error, 'openCursorOnIndex', storeName)
}
}
export async function count(accessors: CoreAccessors, range?: IDBKeyRange): Promise<number> {
const {storeName} = accessors.getConfig()
try {
const store = await getObjectStore(accessors, 'readonly')
const request = range ? store.count(range) : store.count()
return await wrapNumberRequest(request, 'count', storeName)
} catch (error) {
throw ensureIndexedDBError(error, 'count', storeName)
}
}
export async function countByIndex(accessors: CoreAccessors, indexName: string, range?: IDBKeyRange): Promise<number> {
const {storeName} = accessors.getConfig()
try {
const store = await getObjectStore(accessors, 'readonly')
const index = store.index(indexName)
const request = range ? index.count(range) : index.count()
return await wrapNumberRequest(request, 'countByIndex', storeName)
} catch (error) {
throw ensureIndexedDBError(error, 'countByIndex', storeName)
}
}
function wrapCursorRequest(requestParam: IDBRequest, operation: string, storeName: string): Promise<IDBCursorWithValue | null> {
return new Promise((resolve, reject) => {
const request = requestParam
request.onsuccess = (): void => resolve(request.result as IDBCursorWithValue | null)
request.onerror = (): void => reject(new IndexedDBError(`Failed to ${operation}: ${request.error}`, operation, storeName, request.error))
})
}
function wrapNumberRequest(requestParam: IDBRequest, operation: string, storeName: string): Promise<number> {
return new Promise((resolve, reject) => {
const request = requestParam
request.onsuccess = (): void => resolve(request.result as number)
request.onerror = (): void => reject(new IndexedDBError(`Failed to ${operation}: ${request.error}`, operation, storeName, request.error))
})
}
function ensureIndexedDBError(error: unknown, operation: string, storeName: string): IndexedDBError {
if (error instanceof IndexedDBError) {
return error
}
return new IndexedDBError(error instanceof Error ? error.message : 'Unknown error', operation, storeName, error)
}

View File

@ -0,0 +1,10 @@
export type { IndexedDBConfig, IndexedDBIndex } from './types'
export { IndexedDBError } from './IndexedDBError'
export { IndexedDBHelper } from './IndexedDBHelper'
import type { IndexedDBConfig } from './types'
import { IndexedDBHelper } from './IndexedDBHelper'
export function createIndexedDBHelper(config: IndexedDBConfig): IndexedDBHelper {
return new IndexedDBHelper(config)
}

View File

@ -0,0 +1,67 @@
import { IndexedDBError } from './IndexedDBError'
import type { CoreAccessors } from './core'
import { getObjectStore } from './store'
export async function getValue<T>(accessors: CoreAccessors, key: string | number): Promise<T | null> {
const {storeName} = accessors.getConfig()
try {
const store = await getObjectStore(accessors, 'readonly')
return await wrapRequest<T | null>(store.get(key), 'get', storeName, (r) => (r.result as T) ?? null)
} catch (error) {
throw ensureIndexedDBError(error, 'get', storeName)
}
}
export async function getByIndex<T>(accessors: CoreAccessors, indexName: string, key: string | number): Promise<T | null> {
const {storeName} = accessors.getConfig()
try {
const store = await getObjectStore(accessors, 'readonly')
const index = store.index(indexName)
return await wrapRequest<T | null>(index.get(key), 'getByIndex', storeName, (r) => (r.result as T) ?? null)
} catch (error) {
throw ensureIndexedDBError(error, 'getByIndex', storeName)
}
}
export async function getAllByIndex<T>(accessors: CoreAccessors, indexName: string, key?: IDBValidKey | IDBKeyRange): Promise<T[]> {
const {storeName} = accessors.getConfig()
try {
const store = await getObjectStore(accessors, 'readonly')
const index = store.index(indexName)
const request = key !== undefined ? index.getAll(key) : index.getAll()
return await wrapRequest<T[]>(request, 'getAllByIndex', storeName, (r) => (r.result as T[]) ?? [])
} catch (error) {
throw ensureIndexedDBError(error, 'getAllByIndex', storeName)
}
}
export async function getAll<T>(accessors: CoreAccessors, range?: IDBKeyRange, count?: number): Promise<T[]> {
const {storeName} = accessors.getConfig()
try {
const store = await getObjectStore(accessors, 'readonly')
const request = range ? store.getAll(range, count) : store.getAll(undefined, count)
return await wrapRequest<T[]>(request, 'getAll', storeName, (r) => (r.result as T[]) ?? [])
} catch (error) {
throw ensureIndexedDBError(error, 'getAll', storeName)
}
}
function wrapRequest<TResult>(
requestParam: IDBRequest,
operation: string,
storeName: string,
mapResult: (request: IDBRequest) => TResult
): Promise<TResult> {
return new Promise<TResult>((resolve, reject) => {
const request = requestParam
request.onsuccess = (): void => resolve(mapResult(request))
request.onerror = (): void => reject(new IndexedDBError(`Failed to ${operation}: ${request.error}`, operation, storeName, request.error))
})
}
function ensureIndexedDBError(error: unknown, operation: string, storeName: string): IndexedDBError {
if (error instanceof IndexedDBError) {
return error
}
return new IndexedDBError(error instanceof Error ? error.message : 'Unknown error', operation, storeName, error)
}

View File

@ -0,0 +1,14 @@
import type { IndexedDBConfig } from './types'
import { IndexedDBError } from './IndexedDBError'
import { initDatabase, type CoreAccessors } from './core'
export async function getObjectStore(accessors: CoreAccessors, mode: IDBTransactionMode): Promise<IDBObjectStore> {
const db = await initDatabase(accessors)
const config: IndexedDBConfig = accessors.getConfig()
try {
const transaction = db.transaction([config.storeName], mode)
return transaction.objectStore(config.storeName)
} catch (error) {
throw new IndexedDBError(error instanceof Error ? error.message : 'Unknown error', 'getStore', config.storeName, error)
}
}

View File

@ -0,0 +1,14 @@
export interface IndexedDBIndex {
name: string
keyPath: string | string[]
unique?: boolean
}
export interface IndexedDBConfig {
dbName: string
version: number
storeName: string
keyPath: string
indexes?: IndexedDBIndex[]
onUpgrade?: (db: IDBDatabase, event: IDBVersionChangeEvent) => void
}

View File

@ -0,0 +1,58 @@
import { IndexedDBError } from './IndexedDBError'
import type { CoreAccessors } from './core'
import { getObjectStore } from './store'
export async function putValue<T>(accessors: CoreAccessors, value: T): Promise<void> {
const {storeName} = accessors.getConfig()
try {
const store = await getObjectStore(accessors, 'readwrite')
await wrapVoidRequest(store.put(value), 'put', storeName)
} catch (error) {
throw ensureIndexedDBError(error, 'put', storeName)
}
}
export async function addValue<T>(accessors: CoreAccessors, value: T): Promise<void> {
const {storeName} = accessors.getConfig()
try {
const store = await getObjectStore(accessors, 'readwrite')
await wrapVoidRequest(store.add(value), 'add', storeName)
} catch (error) {
throw ensureIndexedDBError(error, 'add', storeName)
}
}
export async function deleteValue(accessors: CoreAccessors, key: string | number): Promise<void> {
const {storeName} = accessors.getConfig()
try {
const store = await getObjectStore(accessors, 'readwrite')
await wrapVoidRequest(store.delete(key), 'delete', storeName)
} catch (error) {
throw ensureIndexedDBError(error, 'delete', storeName)
}
}
export async function clearStore(accessors: CoreAccessors): Promise<void> {
const {storeName} = accessors.getConfig()
try {
const store = await getObjectStore(accessors, 'readwrite')
await wrapVoidRequest(store.clear(), 'clear', storeName)
} catch (error) {
throw ensureIndexedDBError(error, 'clear', storeName)
}
}
function wrapVoidRequest(requestParam: IDBRequest, operation: string, storeName: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const request = requestParam
request.onsuccess = (): void => resolve()
request.onerror = (): void => reject(new IndexedDBError(`Failed to ${operation}: ${request.error}`, operation, storeName, request.error))
})
}
function ensureIndexedDBError(error: unknown, operation: string, storeName: string): IndexedDBError {
if (error instanceof IndexedDBError) {
return error
}
return new IndexedDBError(error instanceof Error ? error.message : 'Unknown error', operation, storeName, error)
}

View File

@ -3,7 +3,7 @@
* Centralizes the pattern of polling syncProgressManager and updating state * Centralizes the pattern of polling syncProgressManager and updating state
*/ */
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback, type MutableRefObject } from 'react'
import type { SyncProgress } from '../helpers/syncProgressHelper' import type { SyncProgress } from '../helpers/syncProgressHelper'
export interface UseSyncProgressOptions { export interface UseSyncProgressOptions {
@ -23,85 +23,125 @@ export interface UseSyncProgressResult {
* Hook to monitor sync progress from syncProgressManager * Hook to monitor sync progress from syncProgressManager
*/ */
export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncProgressResult { export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncProgressResult {
const { onComplete, pollInterval = 500, maxDuration = 60000 } = options const config = normalizeSyncProgressOptions(options)
const state = useSyncProgressState({ onComplete: config.onComplete })
const checkProgress = useCheckProgress({ onCompleteRef: state.onCompleteRef, setSyncProgress: state.setSyncProgress, setIsSyncing: state.setIsSyncing, stopMonitoring: state.stopMonitoring })
const startMonitoring = useStartMonitoring({ pollInterval: config.pollInterval, maxDuration: config.maxDuration, checkProgress, stopMonitoring: state.stopMonitoring, setIsSyncing: state.setIsSyncing, setSyncProgress: state.setSyncProgress, isMonitoringRef: state.isMonitoringRef, intervalRef: state.intervalRef, timeoutRef: state.timeoutRef })
useUnmountCleanup({ stopMonitoring: state.stopMonitoring })
return { syncProgress: state.syncProgress, isSyncing: state.isSyncing, startMonitoring, stopMonitoring: state.stopMonitoring }
}
function normalizeSyncProgressOptions(options: UseSyncProgressOptions): { onComplete: UseSyncProgressOptions['onComplete']; pollInterval: number; maxDuration: number } {
return { onComplete: options.onComplete, pollInterval: options.pollInterval ?? 500, maxDuration: options.maxDuration ?? 60000 }
}
function useSyncProgressState(params: { onComplete: UseSyncProgressOptions['onComplete'] }): {
syncProgress: SyncProgress | null
setSyncProgress: (value: SyncProgress | null) => void
isSyncing: boolean
setIsSyncing: (value: boolean) => void
intervalRef: MutableRefObject<ReturnType<typeof setInterval> | null>
timeoutRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
isMonitoringRef: MutableRefObject<boolean>
onCompleteRef: MutableRefObject<UseSyncProgressOptions['onComplete']>
stopMonitoring: () => void
} {
const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null) const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null)
const [isSyncing, setIsSyncing] = useState(false) const [isSyncing, setIsSyncing] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null) const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const onCompleteRef = useRef(onComplete)
const isMonitoringRef = useRef(false) const isMonitoringRef = useRef(false)
const onCompleteRef = useRef(params.onComplete)
useEffect(() => {
onCompleteRef.current = params.onComplete
}, [params.onComplete])
const stopMonitoring = useCallback((): void => { const stopMonitoring = useCallback((): void => {
if (!isMonitoringRef.current) { if (!isMonitoringRef.current) {
return return
} }
isMonitoringRef.current = false isMonitoringRef.current = false
setIsSyncing(false) setIsSyncing(false)
intervalRef.current = clearIntervalAndReturnNull(intervalRef.current)
if (intervalRef.current) { timeoutRef.current = clearTimeoutAndReturnNull(timeoutRef.current)
clearInterval(intervalRef.current)
intervalRef.current = null
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, []) }, [])
// Update onComplete ref when it changes return { syncProgress, setSyncProgress, isSyncing, setIsSyncing, intervalRef, timeoutRef, isMonitoringRef, onCompleteRef, stopMonitoring }
useEffect(() => { }
onCompleteRef.current = onComplete
}, [onComplete])
const checkProgress = useCallback(async (): Promise<void> => { function clearIntervalAndReturnNull(current: ReturnType<typeof setInterval> | null): null {
if (current) {
clearInterval(current)
}
return null
}
function clearTimeoutAndReturnNull(current: ReturnType<typeof setTimeout> | null): null {
if (current) {
clearTimeout(current)
}
return null
}
function useCheckProgress(params: {
onCompleteRef: MutableRefObject<UseSyncProgressOptions['onComplete']>
setSyncProgress: (value: SyncProgress | null) => void
setIsSyncing: (value: boolean) => void
stopMonitoring: () => void
}): () => Promise<void> {
const { onCompleteRef, setSyncProgress, setIsSyncing, stopMonitoring } = params
return useCallback(async (): Promise<void> => {
const { syncProgressManager } = await import('../syncProgressManager') const { syncProgressManager } = await import('../syncProgressManager')
const currentProgress = syncProgressManager.getProgress() const current = syncProgressManager.getProgress()
if (currentProgress) { if (!current) {
setSyncProgress(currentProgress) return
if (currentProgress.completed) { }
setSyncProgress(current)
if (!current.completed) {
return
}
setIsSyncing(false) setIsSyncing(false)
if (onCompleteRef.current) { await onCompleteRef.current?.()
await onCompleteRef.current()
}
stopMonitoring() stopMonitoring()
} }, [onCompleteRef, setIsSyncing, setSyncProgress, stopMonitoring])
} }
}, [stopMonitoring])
const startMonitoring = useCallback((): void => { function useStartMonitoring(params: {
pollInterval: number
maxDuration: number
checkProgress: () => Promise<void>
stopMonitoring: () => void
setIsSyncing: (value: boolean) => void
setSyncProgress: (value: SyncProgress | null) => void
isMonitoringRef: MutableRefObject<boolean>
intervalRef: MutableRefObject<ReturnType<typeof setInterval> | null>
timeoutRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
}): () => void {
const {
pollInterval,
maxDuration,
checkProgress,
stopMonitoring,
setIsSyncing,
setSyncProgress,
isMonitoringRef,
intervalRef,
timeoutRef,
} = params
return useCallback((): void => {
if (isMonitoringRef.current) { if (isMonitoringRef.current) {
return return
} }
isMonitoringRef.current = true isMonitoringRef.current = true
setIsSyncing(true) setIsSyncing(true)
setSyncProgress({ currentStep: 0, totalSteps: 7, completed: false }) setSyncProgress({ currentStep: 0, totalSteps: 7, completed: false })
intervalRef.current = setInterval(() => void checkProgress(), pollInterval)
// Poll progress periodically timeoutRef.current = setTimeout(() => stopMonitoring(), maxDuration)
intervalRef.current = setInterval(() => { }, [checkProgress, intervalRef, isMonitoringRef, maxDuration, pollInterval, setIsSyncing, setSyncProgress, stopMonitoring, timeoutRef])
void checkProgress() }
}, pollInterval)
function useUnmountCleanup(params: { stopMonitoring: () => void }): void {
// Cleanup after max duration const { stopMonitoring } = params
timeoutRef.current = setTimeout(() => { useEffect(() => () => stopMonitoring(), [stopMonitoring])
stopMonitoring()
}, maxDuration)
}, [checkProgress, maxDuration, pollInterval, stopMonitoring])
// Cleanup on unmount
useEffect(() => {
return () => {
stopMonitoring()
}
}, [stopMonitoring])
return {
syncProgress,
isSyncing,
startMonitoring,
stopMonitoring,
}
} }

View File

@ -1,439 +1 @@
/** export * from './keyManagementTwoLevel/api'
* Two-level encryption system for key management
*
* Level 1: Private key encrypted with KEK (Key Encryption Key)
* Level 2: KEK encrypted with 4-word recovery phrase
*
* Flow:
* - KEK is generated randomly and stored encrypted in Credentials API
* - Private key is encrypted with KEK and stored in IndexedDB
* - Recovery phrase (4 words) is used to encrypt/decrypt KEK
*/
import type { EncryptedPayload } from './keyManagementEncryption'
import { generateRecoveryPhrase } from './keyManagementBIP39'
const PBKDF2_ITERATIONS = 100000
const PBKDF2_HASH = 'SHA-256'
/**
* Generate a random KEK (Key Encryption Key)
*/
async function generateKEK(): Promise<CryptoKey> {
return globalThis.crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // extractable
['encrypt', 'decrypt']
)
}
/**
* Derive encryption key from recovery phrase using PBKDF2
*/
async function deriveKeyFromPhrase(phrase: string[]): Promise<CryptoKey> {
const phraseString = phrase.join(' ')
const encoder = new TextEncoder()
const password = encoder.encode(phraseString)
// Generate deterministic salt from phrase
const saltBuffer = await globalThis.crypto.subtle.digest('SHA-256', password)
const saltArray = new Uint8Array(saltBuffer)
const salt = saltArray.slice(0, 32)
// Import password as key material
const keyMaterial = await globalThis.crypto.subtle.importKey(
'raw',
password,
'PBKDF2',
false,
['deriveBits', 'deriveKey']
)
// Derive key using PBKDF2
const derivedKey = await globalThis.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: PBKDF2_ITERATIONS,
hash: PBKDF2_HASH,
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
return derivedKey
}
/**
* Export KEK to raw bytes (for storage)
*/
async function exportKEK(kek: CryptoKey): Promise<Uint8Array> {
const exported = await globalThis.crypto.subtle.exportKey('raw', kek)
return new Uint8Array(exported)
}
/**
* Import KEK from raw bytes
*/
async function importKEK(keyBytes: Uint8Array): Promise<CryptoKey> {
// Create a new ArrayBuffer from the Uint8Array
const buffer = new ArrayBuffer(keyBytes.length)
const view = new Uint8Array(buffer)
view.set(keyBytes)
return globalThis.crypto.subtle.importKey(
'raw',
buffer,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt']
)
}
/**
* Encrypt KEK with recovery phrase
*/
async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise<EncryptedPayload> {
const phraseKey = await deriveKeyFromPhrase(recoveryPhrase)
const kekBytes = await exportKEK(kek)
const encoder = new TextEncoder()
const data = encoder.encode(Array.from(kekBytes).map(b => b.toString(16).padStart(2, '0')).join(''))
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
const encrypted = await globalThis.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
phraseKey,
data
)
const encryptedArray = new Uint8Array(encrypted)
function toBase64(bytes: Uint8Array): string {
let binary = ''
bytes.forEach((b) => {
binary += String.fromCharCode(b)
})
return globalThis.btoa(binary)
}
return {
iv: toBase64(iv),
ciphertext: toBase64(encryptedArray),
}
}
/**
* Decrypt KEK with recovery phrase
*/
async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string[]): Promise<CryptoKey> {
const phraseKey = await deriveKeyFromPhrase(recoveryPhrase)
function fromBase64(value: string): Uint8Array {
const binary = globalThis.atob(value)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
const iv = fromBase64(encryptedKEK.iv)
const ciphertext = fromBase64(encryptedKEK.ciphertext)
// Create ArrayBuffer views for decrypt
const ivBuffer = new ArrayBuffer(iv.length)
const ivView = new Uint8Array(ivBuffer)
ivView.set(iv)
const cipherBuffer = new ArrayBuffer(ciphertext.length)
const cipherView = new Uint8Array(cipherBuffer)
cipherView.set(ciphertext)
const decrypted = await globalThis.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivView },
phraseKey,
cipherBuffer
)
const decoder = new TextDecoder()
const hexString = decoder.decode(decrypted)
// Convert hex string back to bytes
const kekBytes = new Uint8Array(hexString.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) ?? [])
return importKEK(kekBytes)
}
/**
* Encrypt private key with KEK
*/
async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Promise<EncryptedPayload> {
const encoder = new TextEncoder()
const data = encoder.encode(privateKey)
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
const encrypted = await globalThis.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
kek,
data
)
const encryptedArray = new Uint8Array(encrypted)
function toBase64(bytes: Uint8Array): string {
let binary = ''
bytes.forEach((b) => {
binary += String.fromCharCode(b)
})
return globalThis.btoa(binary)
}
return {
iv: toBase64(iv),
ciphertext: toBase64(encryptedArray),
}
}
/**
* Decrypt private key with KEK
*/
async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, kek: CryptoKey): Promise<string> {
function fromBase64(value: string): Uint8Array {
const binary = globalThis.atob(value)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
const iv = fromBase64(encryptedPrivateKey.iv)
const ciphertext = fromBase64(encryptedPrivateKey.ciphertext)
// Create ArrayBuffer views for decrypt
const ivBuffer = new ArrayBuffer(iv.length)
const ivView = new Uint8Array(ivBuffer)
ivView.set(iv)
const cipherBuffer = new ArrayBuffer(ciphertext.length)
const cipherView = new Uint8Array(cipherBuffer)
cipherView.set(ciphertext)
const decrypted = await globalThis.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivView },
kek,
cipherBuffer
)
const decoder = new TextDecoder()
return decoder.decode(decrypted)
}
/**
* Store encrypted KEK in Credentials API
*/
async function storeEncryptedKEK(encryptedKEK: EncryptedPayload): Promise<void> {
if (typeof window === 'undefined') {
throw new Error('Window not available')
}
// Type definition for PasswordCredential
interface PasswordCredentialData {
id: string
name: string
password: string
iconURL?: string
}
type PasswordCredentialConstructorType = new (data: PasswordCredentialData) => Credential & { id: string; password: string }
const PasswordCredentialConstructor = (window as unknown as { PasswordCredential?: PasswordCredentialConstructorType }).PasswordCredential
if (!PasswordCredentialConstructor || !navigator.credentials?.store) {
throw new Error('PasswordCredential API not available')
}
// Store encrypted KEK as password in credential
// Type assertion for PasswordCredential
type PasswordCredentialType = new (data: PasswordCredentialData) => Credential & { id: string; password: string }
const PasswordCredentialClass = PasswordCredentialConstructor as PasswordCredentialType
const credential = new PasswordCredentialClass({
id: 'nostr_kek',
name: 'Nostr KEK',
password: JSON.stringify(encryptedKEK),
iconURL: `${window.location.origin }/favicon.ico`,
})
await navigator.credentials.store(credential)
}
/**
* Retrieve encrypted KEK from Credentials API
*/
async function getEncryptedKEK(): Promise<EncryptedPayload | null> {
if (typeof window === 'undefined' || !navigator.credentials?.get) {
return null
}
try {
const credential = await navigator.credentials.get({
password: true,
} as CredentialRequestOptions)
if (credential && 'password' in credential && typeof credential.password === 'string' && credential.id === 'nostr_kek') {
return JSON.parse(credential.password) as EncryptedPayload
}
return null
} catch (e) {
console.error('Error retrieving encrypted KEK:', e)
return null
}
}
export interface CreateAccountResult {
recoveryPhrase: string[]
npub: string
publicKey: string
}
export interface UnlockAccountResult {
privateKey: string
publicKey: string
npub: string
}
/**
* Create account with two-level encryption
*/
export async function createAccountTwoLevel(
privateKeyHex: string,
getPublicKey: (secretKey: Uint8Array) => string,
encodeNpub: (publicKey: string) => string
): Promise<CreateAccountResult> {
// Step 1: Generate recovery phrase (4 words from BIP39)
const recoveryPhrase = generateRecoveryPhrase()
// Step 2: Generate KEK (in memory)
const kek = await generateKEK()
// Step 3: Encrypt private key with KEK
const encryptedPrivateKey = await encryptPrivateKeyWithKEK(privateKeyHex, kek)
// Step 4: Encrypt KEK with recovery phrase
const encryptedKEK = await encryptKEK(kek, recoveryPhrase)
// Step 5: Store encrypted KEK in Credentials API
await storeEncryptedKEK(encryptedKEK)
// Step 6: Store encrypted private key in IndexedDB (via storage service)
const { storageService } = await import('./storage/indexedDB')
await storageService.set('nostr_encrypted_key', encryptedPrivateKey, 'nostr_key_storage')
// Step 7: Compute public key and npub
const { hexToBytes } = await import('nostr-tools/utils')
const secretKey = hexToBytes(privateKeyHex)
const publicKey = getPublicKey(secretKey)
const npub = encodeNpub(publicKey)
// Step 8: Store public keys in IndexedDB
await storageService.set('nostr_public_key', { publicKey, npub }, 'nostr_key_storage')
// Step 9: Store account flag in IndexedDB (not localStorage)
await storageService.set('nostr_account_exists', true, 'nostr_key_storage')
// Step 10: Clear KEK from memory (it's now encrypted and stored)
// Note: In JavaScript, we can't force garbage collection, but we can null the reference
// The KEK will be garbage collected automatically
return {
recoveryPhrase,
npub,
publicKey,
}
}
/**
* Unlock account with two-level decryption
*/
export async function unlockAccountTwoLevel(
recoveryPhrase: string[],
getPublicKey: (secretKey: Uint8Array) => string,
encodeNpub: (publicKey: string) => string
): Promise<UnlockAccountResult> {
// Step 1: Get encrypted KEK from Credentials API
const encryptedKEK = await getEncryptedKEK()
if (!encryptedKEK) {
throw new Error('No encrypted KEK found in Credentials API')
}
// Step 2: Decrypt KEK with recovery phrase (in memory)
const kek = await decryptKEK(encryptedKEK, recoveryPhrase)
// Step 3: Clear recovery phrase from memory (set to empty)
// Note: In JavaScript, we can't force memory clearing, but we can overwrite
recoveryPhrase.fill('')
// Step 4: Get encrypted private key from IndexedDB
const { storageService: indexedDBStorage } = await import('./storage/indexedDB')
const encryptedPrivateKey = await indexedDBStorage.get<EncryptedPayload>('nostr_encrypted_key', 'nostr_key_storage')
if (!encryptedPrivateKey) {
throw new Error('No encrypted private key found in IndexedDB')
}
// Step 5: Decrypt private key with KEK (in memory)
const privateKeyHex = await decryptPrivateKeyWithKEK(encryptedPrivateKey, kek)
// Step 6: Clear KEK from memory
// Note: In JavaScript, we can't force memory clearing, but we can null the reference
// Step 7: Verify by computing public key
const { hexToBytes } = await import('nostr-tools/utils')
const secretKey = hexToBytes(privateKeyHex)
const publicKey = getPublicKey(secretKey)
const npub = encodeNpub(publicKey)
return {
privateKey: privateKeyHex,
publicKey,
npub,
}
}
/**
* Check if account exists
*/
export async function accountExistsTwoLevel(): Promise<boolean> {
try {
const { storageService: indexedDBStorage } = await import('./storage/indexedDB')
const exists = await indexedDBStorage.get<boolean>('nostr_account_exists', 'nostr_key_storage')
return exists === true
} catch {
return false
}
}
/**
* Get public keys if account exists
*/
export async function getPublicKeysTwoLevel(): Promise<{ publicKey: string; npub: string } | null> {
try {
const { storageService: indexedDBStorage } = await import('./storage/indexedDB')
return indexedDBStorage.get<{ publicKey: string; npub: string }>('nostr_public_key', 'nostr_key_storage')
} catch {
return null
}
}
/**
* Delete account (remove all stored data)
*/
export async function deleteAccountTwoLevel(): Promise<void> {
const { storageService: indexedDBStorage } = await import('./storage/indexedDB')
await indexedDBStorage.delete('nostr_encrypted_key')
await indexedDBStorage.delete('nostr_public_key')
await indexedDBStorage.delete('nostr_account_exists')
// Try to remove credential (may not be possible via API)
if (navigator.credentials?.preventSilentAccess) {
void navigator.credentials.preventSilentAccess()
}
}

View File

@ -0,0 +1,94 @@
import { generateRecoveryPhrase } from '../keyManagementBIP39'
import type { EncryptedPayload } from '../keyManagementEncryption'
import { generateKEK } from './crypto'
import { decryptKEK, encryptKEK } from './kekEncryption'
import { storeEncryptedKEK, getEncryptedKEK } from './credentials'
import { decryptPrivateKeyWithKEK, encryptPrivateKeyWithKEK } from './privateKeyEncryption'
export interface CreateAccountResult {
recoveryPhrase: string[]
npub: string
publicKey: string
}
export interface UnlockAccountResult {
privateKey: string
publicKey: string
npub: string
}
export async function createAccountTwoLevel(
privateKeyHex: string,
getPublicKey: (secretKey: Uint8Array) => string,
encodeNpub: (publicKey: string) => string
): Promise<CreateAccountResult> {
const recoveryPhrase = generateRecoveryPhrase()
const kek = await generateKEK()
const encryptedPrivateKey = await encryptPrivateKeyWithKEK(privateKeyHex, kek)
const encryptedKEK = await encryptKEK(kek, recoveryPhrase)
await storeEncryptedKEK(encryptedKEK)
const { storageService } = await import('../storage/indexedDB')
await storageService.set('nostr_encrypted_key', encryptedPrivateKey, 'nostr_key_storage')
const { hexToBytes } = await import('nostr-tools/utils')
const secretKey = hexToBytes(privateKeyHex)
const publicKey = getPublicKey(secretKey)
const npub = encodeNpub(publicKey)
await storageService.set('nostr_public_key', { publicKey, npub }, 'nostr_key_storage')
await storageService.set('nostr_account_exists', true, 'nostr_key_storage')
return { recoveryPhrase, npub, publicKey }
}
export async function unlockAccountTwoLevel(
recoveryPhrase: string[],
getPublicKey: (secretKey: Uint8Array) => string,
encodeNpub: (publicKey: string) => string
): Promise<UnlockAccountResult> {
const encryptedKEK = await getEncryptedKEK()
if (!encryptedKEK) {
throw new Error('No encrypted KEK found in Credentials API')
}
const kek = await decryptKEK(encryptedKEK, recoveryPhrase)
recoveryPhrase.fill('')
const { storageService: indexedDBStorage } = await import('../storage/indexedDB')
const encryptedPrivateKey = await indexedDBStorage.get<EncryptedPayload>('nostr_encrypted_key', 'nostr_key_storage')
if (!encryptedPrivateKey) {
throw new Error('No encrypted private key found in IndexedDB')
}
const privateKeyHex = await decryptPrivateKeyWithKEK(encryptedPrivateKey, kek)
const { hexToBytes } = await import('nostr-tools/utils')
const secretKey = hexToBytes(privateKeyHex)
const publicKey = getPublicKey(secretKey)
const npub = encodeNpub(publicKey)
return { privateKey: privateKeyHex, publicKey, npub }
}
export async function accountExistsTwoLevel(): Promise<boolean> {
try {
const { storageService: indexedDBStorage } = await import('../storage/indexedDB')
const exists = await indexedDBStorage.get<boolean>('nostr_account_exists', 'nostr_key_storage')
return exists === true
} catch (e) {
console.error('[keyManagementTwoLevel] Error checking account existence:', e)
return false
}
}
export async function getPublicKeysTwoLevel(): Promise<{ publicKey: string; npub: string } | null> {
try {
const { storageService: indexedDBStorage } = await import('../storage/indexedDB')
return indexedDBStorage.get<{ publicKey: string; npub: string }>('nostr_public_key', 'nostr_key_storage')
} catch (e) {
console.error('[keyManagementTwoLevel] Error reading public keys:', e)
return null
}
}
export async function deleteAccountTwoLevel(): Promise<void> {
const { storageService: indexedDBStorage } = await import('../storage/indexedDB')
await indexedDBStorage.delete('nostr_encrypted_key')
await indexedDBStorage.delete('nostr_public_key')
await indexedDBStorage.delete('nostr_account_exists')
if (navigator.credentials?.preventSilentAccess) {
void navigator.credentials.preventSilentAccess()
}
}

View File

@ -0,0 +1,51 @@
import type { EncryptedPayload } from '../keyManagementEncryption'
type PasswordCredentialData = {
id: string
name: string
password: string
iconURL?: string
}
type PasswordCredentialType = new (data: PasswordCredentialData) => Credential & { id: string; password: string }
function getPasswordCredentialConstructor(): PasswordCredentialType | undefined {
if (typeof window === 'undefined') {
return undefined
}
const ctor = (window as unknown as { PasswordCredential?: PasswordCredentialType }).PasswordCredential
return ctor
}
export async function storeEncryptedKEK(encryptedKEK: EncryptedPayload): Promise<void> {
if (typeof window === 'undefined') {
throw new Error('Window not available')
}
const PasswordCredentialConstructor = getPasswordCredentialConstructor()
if (!PasswordCredentialConstructor || !navigator.credentials?.store) {
throw new Error('PasswordCredential API not available')
}
const credential = new PasswordCredentialConstructor({
id: 'nostr_kek',
name: 'Nostr KEK',
password: JSON.stringify(encryptedKEK),
iconURL: `${window.location.origin}/favicon.ico`,
})
await navigator.credentials.store(credential)
}
export async function getEncryptedKEK(): Promise<EncryptedPayload | null> {
if (typeof window === 'undefined' || !navigator.credentials?.get) {
return null
}
try {
const credential = await navigator.credentials.get({ password: true } as CredentialRequestOptions)
if (credential && 'password' in credential && typeof credential.password === 'string' && credential.id === 'nostr_kek') {
return JSON.parse(credential.password) as EncryptedPayload
}
return null
} catch (e) {
console.error('[keyManagementTwoLevel] Error retrieving encrypted KEK:', e)
return null
}
}

View File

@ -0,0 +1,60 @@
import { bytesToHex, hexToBytes } from './encoding'
const PBKDF2_ITERATIONS = 100000
const PBKDF2_HASH = 'SHA-256'
export async function generateKEK(): Promise<CryptoKey> {
return globalThis.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
}
export async function deriveKeyFromPhrase(phrase: string[]): Promise<CryptoKey> {
const phraseString = phrase.join(' ')
const encoder = new TextEncoder()
const password = encoder.encode(phraseString)
const saltBuffer = await globalThis.crypto.subtle.digest('SHA-256', password)
const salt = new Uint8Array(saltBuffer).slice(0, 32)
const keyMaterial = await globalThis.crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey'])
return globalThis.crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
}
export async function exportKEK(kek: CryptoKey): Promise<Uint8Array> {
const exported = await globalThis.crypto.subtle.exportKey('raw', kek)
return new Uint8Array(exported)
}
export async function importKEK(keyBytes: Uint8Array): Promise<CryptoKey> {
const buffer = new ArrayBuffer(keyBytes.length)
new Uint8Array(buffer).set(keyBytes)
return globalThis.crypto.subtle.importKey('raw', buffer, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
}
export async function encryptWithAesGcm(params: { key: CryptoKey; plaintext: Uint8Array }): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, params.key, params.plaintext)
return { iv, ciphertext: new Uint8Array(encrypted) }
}
export async function decryptWithAesGcm(params: { key: CryptoKey; iv: Uint8Array; ciphertext: Uint8Array }): Promise<Uint8Array> {
const ivBuffer = new ArrayBuffer(params.iv.length)
new Uint8Array(ivBuffer).set(params.iv)
const cipherBuffer = new ArrayBuffer(params.ciphertext.length)
new Uint8Array(cipherBuffer).set(params.ciphertext)
const decrypted = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(ivBuffer) }, params.key, cipherBuffer)
return new Uint8Array(decrypted)
}
export function encodeKekBytesForStorage(bytes: Uint8Array): Uint8Array {
const encoder = new TextEncoder()
return encoder.encode(bytesToHex(bytes))
}
export function decodeKekBytesFromStorage(payload: Uint8Array): Uint8Array {
const decoder = new TextDecoder()
return hexToBytes(decoder.decode(payload))
}

View File

@ -0,0 +1,30 @@
export function bytesToBase64(bytes: Uint8Array): string {
let binary = ''
bytes.forEach((b) => {
binary += String.fromCharCode(b)
})
return globalThis.btoa(binary)
}
export function base64ToBytes(value: string): Uint8Array {
const binary = globalThis.atob(value)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
export function hexToBytes(hexString: string): Uint8Array {
const parts = hexString.match(/.{1,2}/g)
if (!parts) {
return new Uint8Array([])
}
return new Uint8Array(parts.map((part) => Number.parseInt(part, 16)))
}

View File

@ -0,0 +1,20 @@
import type { EncryptedPayload } from '../keyManagementEncryption'
import { base64ToBytes, bytesToBase64 } from './encoding'
import { decodeKekBytesFromStorage, decryptWithAesGcm, deriveKeyFromPhrase, encodeKekBytesForStorage, encryptWithAesGcm, exportKEK, importKEK } from './crypto'
export async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise<EncryptedPayload> {
const phraseKey = await deriveKeyFromPhrase(recoveryPhrase)
const kekBytes = await exportKEK(kek)
const plaintext = encodeKekBytesForStorage(kekBytes)
const { iv, ciphertext } = await encryptWithAesGcm({ key: phraseKey, plaintext })
return { iv: bytesToBase64(iv), ciphertext: bytesToBase64(ciphertext) }
}
export async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string[]): Promise<CryptoKey> {
const phraseKey = await deriveKeyFromPhrase(recoveryPhrase)
const iv = base64ToBytes(encryptedKEK.iv)
const ciphertext = base64ToBytes(encryptedKEK.ciphertext)
const decrypted = await decryptWithAesGcm({ key: phraseKey, iv, ciphertext })
const kekBytes = decodeKekBytesFromStorage(decrypted)
return importKEK(kekBytes)
}

Some files were not shown because too many files have changed in this diff Show More