This commit is contained in:
Nicolas Cantu 2026-01-10 09:41:57 +01:00
parent 9e76a9e18a
commit 620f5955ca
55 changed files with 5459 additions and 3497 deletions

View File

@ -79,15 +79,6 @@ function HomeContent({
// At startup, we don't know yet if we're loading articles or authors // At startup, we don't know yet if we're loading articles or authors
// Use a generic loading message until we have content // Use a generic loading message until we have content
const isInitialLoad = loading && allArticles.length === 0 && allAuthors.length === 0 const isInitialLoad = loading && allArticles.length === 0 && allAuthors.length === 0
const articlesListProps = {
articles,
allArticles,
loading: loading && !isInitialLoad, // Don't show loading if it's the initial generic state
error,
onUnlock,
unlockedArticles
}
const authorsListProps = { authors, allAuthors, loading: loading && !isInitialLoad, error }
return ( return (
<div className="w-full px-4 py-8"> <div className="w-full px-4 py-8">
@ -102,21 +93,75 @@ function HomeContent({
<ArticleFiltersComponent filters={filters} onFiltersChange={setFilters} articles={allArticles} /> <ArticleFiltersComponent filters={filters} onFiltersChange={setFilters} articles={allArticles} />
)} )}
{(() => { <HomeMainList
if (isInitialLoad) { isInitialLoad={isInitialLoad}
shouldShowAuthors={shouldShowAuthors}
articlesListProps={buildArticlesListProps({
articles,
allArticles,
loading,
isInitialLoad,
error,
onUnlock,
unlockedArticles,
})}
authorsListProps={buildAuthorsListProps({ authors, allAuthors, loading, isInitialLoad, error })}
/>
</div>
)
}
function HomeMainList(params: {
isInitialLoad: boolean
shouldShowAuthors: boolean
articlesListProps: Parameters<typeof ArticlesList>[0]
authorsListProps: Parameters<typeof AuthorsList>[0]
}): React.ReactElement {
if (params.isInitialLoad) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-cyber-accent/70">{t('common.loading')}</p> <p className="text-cyber-accent/70">{t('common.loading')}</p>
</div> </div>
) )
} }
if (shouldShowAuthors) { if (params.shouldShowAuthors) {
return <AuthorsList {...authorsListProps} /> return <AuthorsList {...params.authorsListProps} />
}
return <ArticlesList {...params.articlesListProps} />
}
function buildArticlesListProps(params: {
articles: Article[]
allArticles: Article[]
loading: boolean
isInitialLoad: boolean
error: string | null
onUnlock: (article: Article) => void
unlockedArticles: Set<string>
}): Parameters<typeof ArticlesList>[0] {
return {
articles: params.articles,
allArticles: params.allArticles,
loading: params.loading && !params.isInitialLoad,
error: params.error,
onUnlock: params.onUnlock,
unlockedArticles: params.unlockedArticles,
}
}
function buildAuthorsListProps(params: {
authors: Article[]
allAuthors: Article[]
loading: boolean
isInitialLoad: boolean
error: string | null
}): Parameters<typeof AuthorsList>[0] {
return {
authors: params.authors,
allAuthors: params.allAuthors,
loading: params.loading && !params.isInitialLoad,
error: params.error,
} }
return <ArticlesList {...articlesListProps} />
})()}
</div>
)
} }
export function HomeView(props: HomeViewProps): React.ReactElement { export function HomeView(props: HomeViewProps): React.ReactElement {

View File

@ -106,7 +106,7 @@ function useImageUpload(onChange: (url: string) => void): {
const [pendingFile, setPendingFile] = useState<File | null>(null) const [pendingFile, setPendingFile] = useState<File | null>(null)
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => { const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = event.target.files?.[0] const file = readFirstFile(event)
if (!file) { if (!file) {
return return
} }
@ -117,9 +117,8 @@ function useImageUpload(onChange: (url: string) => void): {
try { try {
await processFileUpload(file, onChange, setError) await processFileUpload(file, onChange, setError)
} catch (uploadError) { } catch (uploadError) {
const uploadErr = uploadError instanceof Error ? uploadError : new Error(String(uploadError)) const uploadErr = normalizeError(uploadError)
// Check if unlock is required if (isUnlockRequiredError(uploadErr)) {
if (uploadErr.message === 'UNLOCK_REQUIRED' || ('unlockRequired' in uploadErr && (uploadErr as { unlockRequired?: boolean }).unlockRequired)) {
setPendingFile(file) setPendingFile(file)
setShowUnlockModal(true) setShowUnlockModal(true)
setError(null) // Don't show error, show unlock modal instead setError(null) // Don't show error, show unlock modal instead
@ -132,25 +131,62 @@ function useImageUpload(onChange: (url: string) => void): {
} }
const handleUnlockSuccess = async (): Promise<void> => { const handleUnlockSuccess = async (): Promise<void> => {
setShowUnlockModal(false) await retryPendingUpload({
if (pendingFile) { pendingFile,
// Retry upload after unlock onChange,
setUploading(true) setError,
setError(null) setPendingFile,
try { setShowUnlockModal,
await processFileUpload(pendingFile, onChange, setError) setUploading,
setPendingFile(null) })
} catch (retryError) {
setError(retryError instanceof Error ? retryError.message : t('presentation.field.picture.error.uploadFailed'))
} finally {
setUploading(false)
}
}
} }
return { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess } return { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess }
} }
function readFirstFile(event: React.ChangeEvent<HTMLInputElement>): File | null {
return event.target.files?.[0] ?? null
}
function normalizeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
function isUnlockRequiredError(error: Error): boolean {
if (error.message === 'UNLOCK_REQUIRED') {
return true
}
if (typeof error === 'object' && error !== null && 'unlockRequired' in error) {
return (error as { unlockRequired?: boolean }).unlockRequired === true
}
return false
}
async function retryPendingUpload(params: {
pendingFile: File | null
onChange: (url: string) => void
setError: (error: string | null) => void
setPendingFile: (file: File | null) => void
setShowUnlockModal: (show: boolean) => void
setUploading: (uploading: boolean) => void
}): Promise<void> {
params.setShowUnlockModal(false)
if (!params.pendingFile) {
return
}
params.setUploading(true)
params.setError(null)
try {
await processFileUpload(params.pendingFile, params.onChange, params.setError)
params.setPendingFile(null)
} catch (retryError) {
params.setError(retryError instanceof Error ? retryError.message : t('presentation.field.picture.error.uploadFailed'))
} finally {
params.setUploading(false)
}
}
export function ImageUploadField({ id, label, value, onChange, helpText }: ImageUploadFieldProps): React.ReactElement { export function ImageUploadField({ id, label, value, onChange, helpText }: ImageUploadFieldProps): React.ReactElement {
const { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess } = useImageUpload(onChange) const { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess } = useImageUpload(onChange)
const displayLabel = label ?? t('presentation.field.picture') const displayLabel = label ?? t('presentation.field.picture')

View File

@ -11,8 +11,6 @@ interface PublicKeys {
} }
export function KeyManagementManager(): React.ReactElement { export function KeyManagementManager(): React.ReactElement {
console.warn('[KeyManagementManager] Component rendered')
const [publicKeys, setPublicKeys] = useState<PublicKeys | null>(null) const [publicKeys, setPublicKeys] = useState<PublicKeys | null>(null)
const [accountExists, setAccountExists] = useState(false) const [accountExists, setAccountExists] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -71,13 +69,32 @@ export function KeyManagementManager(): React.ReactElement {
} }
return null return null
} catch { } catch {
// Not a valid URL, try to extract nsec from text return extractKeyFromText(url)
const nsecMatch = url.match(/nsec1[a-z0-9]+/i)
if (nsecMatch) {
return nsecMatch[0]
} }
// Assume it's already a key (hex or nsec) }
return url.trim()
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)
} }
} }
@ -95,23 +112,10 @@ export function KeyManagementManager(): React.ReactElement {
} }
// Validate key format // Validate key format
try { if (!isValidPrivateKeyFormat(extractedKey)) {
// Try to decode as nsec
const decoded = nip19.decode(extractedKey)
if (decoded.type !== 'nsec') {
throw new Error('Invalid nsec format')
}
// decoded.data can be string (hex) or Uint8Array, both are valid
if (typeof decoded.data !== 'string' && !(decoded.data instanceof Uint8Array)) {
throw new Error('Invalid nsec format')
}
} catch {
// If decoding failed, assume it's hex, validate length (64 hex chars = 32 bytes)
if (!/^[0-9a-f]{64}$/i.test(extractedKey)) {
setError(t('settings.keyManagement.import.error.invalid')) setError(t('settings.keyManagement.import.error.invalid'))
return return
} }
}
// If account exists, show warning // If account exists, show warning
if (accountExists) { if (accountExists) {
@ -216,81 +220,190 @@ export function KeyManagementManager(): React.ReactElement {
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-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> <h2 className="text-2xl font-bold text-neon-cyan mb-4">{t('settings.keyManagement.title')}</h2>
{error && ( <KeyManagementErrorBanner error={error} />
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4 mb-4">
<p className="text-red-400">{error}</p>
</div>
)}
{/* Public Keys Display */} <KeyManagementPublicKeysPanel
{publicKeys && ( publicKeys={publicKeys}
<div className="space-y-4 mb-6"> copiedNpub={copiedNpub}
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4"> copiedPublicKey={copiedPublicKey}
<div className="flex justify-between items-start mb-2"> onCopyNpub={handleCopyNpub}
<p className="text-neon-blue font-semibold">{t('settings.keyManagement.publicKey.npub')}</p> onCopyPublicKey={handleCopyPublicKey}
<button />
onClick={() => {
void handleCopyNpub()
}}
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"
>
{copiedNpub ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
</button>
</div>
<p className="text-neon-cyan text-sm font-mono break-all">{publicKeys.npub}</p>
</div>
<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">{t('settings.keyManagement.publicKey.hex')}</p>
<button
onClick={() => {
void handleCopyPublicKey()
}}
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"
>
{copiedPublicKey ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
</button>
</div>
<p className="text-neon-cyan text-sm font-mono break-all">{publicKeys.publicKey}</p>
</div>
</div>
)}
{/* Sync Progress Bar - Always show if connected, even if publicKeys not loaded yet */} {/* Sync Progress Bar - Always show if connected, even if publicKeys not loaded yet */}
{(() => { <SyncProgressBar />
console.warn('[KeyManagementManager] Rendering SyncProgressBar')
return <SyncProgressBar />
})()}
{!publicKeys && !accountExists && ( <KeyManagementNoAccountBanner publicKeys={publicKeys} accountExists={accountExists} />
<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>
)}
{/* Import Form */} <KeyManagementImportButton
{!showImportForm && ( accountExists={accountExists}
<button showImportForm={showImportForm}
onClick={() => { onClick={() => {
setShowImportForm(true) setShowImportForm(true)
setError(null) 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" 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"
> >
{accountExists ? t('settings.keyManagement.import.button.replace') : t('settings.keyManagement.import.button.new')} {params.accountExists ? t('settings.keyManagement.import.button.replace') : t('settings.keyManagement.import.button.new')}
</button> </button>
)} )
}
{showImportForm && ( 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="space-y-4">
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-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-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') }} /> <p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.description') }} />
{accountExists && ( {params.accountExists && (
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.replace') }} /> <p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.replace') }} />
)} )}
</div> </div>
@ -301,89 +414,116 @@ export function KeyManagementManager(): React.ReactElement {
</label> </label>
<textarea <textarea
id="importKey" id="importKey"
value={importKey} value={params.importKey}
onChange={(e) => { onChange={(e) => {
setImportKey(e.target.value) params.onChangeImportKey(e.target.value)
setError(null)
}} }}
placeholder={t('settings.keyManagement.import.placeholder')} 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" 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} rows={4}
/> />
<p className="text-sm text-cyber-accent/70 mt-2"> <p className="text-sm text-cyber-accent/70 mt-2">{t('settings.keyManagement.import.help')}</p>
{t('settings.keyManagement.import.help')}
</p>
</div> </div>
{showReplaceWarning && ( <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"> <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-400 font-semibold mb-2">{t('settings.keyManagement.replace.warning.title')}</p>
<p className="text-red-300/90 text-sm mb-4"> <p className="text-red-300/90 text-sm mb-4">{t('settings.keyManagement.replace.warning.description')}</p>
{t('settings.keyManagement.replace.warning.description')}
</p>
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
onClick={() => { onClick={params.onCancel}
setShowReplaceWarning(false)
}}
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" 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')} {t('settings.keyManagement.replace.cancel')}
</button> </button>
<button <button
onClick={() => { onClick={params.onConfirm}
void performImport(extractKeyFromUrl(importKey.trim()) ?? importKey.trim()) disabled={params.importing}
}}
disabled={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" 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"
> >
{importing ? t('settings.keyManagement.replace.replacing') : t('settings.keyManagement.replace.confirm')} {params.importing ? t('settings.keyManagement.replace.replacing') : t('settings.keyManagement.replace.confirm')}
</button> </button>
</div> </div>
</div> </div>
)} )
}
{!showReplaceWarning && ( 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"> <div className="flex gap-4">
<button <button
onClick={() => { onClick={params.onCancel}
setShowImportForm(false)
setImportKey('')
setError(null)
}}
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" 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')} {t('settings.keyManagement.import.cancel')}
</button> </button>
<button <button
onClick={() => { onClick={params.onImport}
void handleImport() disabled={params.importing}
}}
disabled={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" 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"
> >
{importing ? t('settings.keyManagement.import.importing') : t('settings.keyManagement.import.import')} {params.importing ? t('settings.keyManagement.import.importing') : t('settings.keyManagement.import.import')}
</button> </button>
</div> </div>
)} )
</div> }
)}
{/* Recovery Phrase Display (after import) */} function KeyManagementRecoveryPanel(params: {
{recoveryPhrase && newNpub && ( 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="mt-6 space-y-4">
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-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-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" 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" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part2') }} />
<p className="text-yellow-300/90 text-sm mt-2"> <p className="text-yellow-300/90 text-sm mt-2">{t('settings.keyManagement.recovery.warning.part3')}</p>
{t('settings.keyManagement.recovery.warning.part3')}
</p>
</div> </div>
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6"> <div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<div className="grid grid-cols-2 gap-4 mb-4"> <div className="grid grid-cols-2 gap-4 mb-4">
{recoveryPhrase.map((word, index) => ( {params.recoveryPhrase.map((word, index) => (
<div <div
key={`recovery-word-${index}-${word}`} key={`recovery-word-${index}-${word}`}
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg" className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
@ -395,32 +535,25 @@ export function KeyManagementManager(): React.ReactElement {
</div> </div>
<button <button
onClick={() => { onClick={() => {
void handleCopyRecoveryPhrase() 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" 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"
> >
{copiedRecoveryPhrase ? t('settings.keyManagement.recovery.copied') : t('settings.keyManagement.recovery.copy')} {params.copiedRecoveryPhrase ? t('settings.keyManagement.recovery.copied') : t('settings.keyManagement.recovery.copy')}
</button> </button>
</div> </div>
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4"> <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-blue font-semibold mb-2">{t('settings.keyManagement.recovery.newNpub')}</p>
<p className="text-neon-cyan text-sm font-mono break-all">{newNpub}</p> <p className="text-neon-cyan text-sm font-mono break-all">{params.newNpub}</p>
</div> </div>
<button <button
onClick={() => { onClick={params.onDone}
setRecoveryPhrase(null)
setNewNpub(null)
void loadKeys()
}}
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" 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')} {t('settings.keyManagement.recovery.done')}
</button> </button>
</div> </div>
)}
</div>
</div>
) )
} }

View File

@ -31,36 +31,11 @@ export function LanguageSettingsManager(): React.ReactElement {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
const loadLocale = async (): Promise<void> => { void loadLocaleIntoState({ setCurrentLocale, setLoading })
try {
// Migrate from localStorage if needed
await localeStorage.migrateFromLocalStorage()
// Load from IndexedDB
const savedLocale = await localeStorage.getLocale()
if (savedLocale) {
setLocale(savedLocale)
setCurrentLocale(savedLocale)
}
} catch (e) {
console.error('Error loading locale:', e)
} finally {
setLoading(false)
}
}
void loadLocale()
}, []) }, [])
const handleLocaleChange = async (locale: Locale): Promise<void> => { const handleLocaleChange = async (locale: Locale): Promise<void> => {
setLocale(locale) await applyLocaleChange({ locale, setCurrentLocale })
setCurrentLocale(locale)
try {
await localeStorage.saveLocale(locale)
} catch (e) {
console.error('Error saving locale:', e)
}
// Force page reload to update all translations
window.location.reload()
} }
const onLocaleClick = (locale: Locale): void => { const onLocaleClick = (locale: Locale): void => {
@ -86,3 +61,32 @@ export function LanguageSettingsManager(): React.ReactElement {
</div> </div>
) )
} }
async function loadLocaleIntoState(params: {
setCurrentLocale: (locale: Locale) => void
setLoading: (loading: boolean) => void
}): Promise<void> {
try {
await localeStorage.migrateFromLocalStorage()
const savedLocale = await localeStorage.getLocale()
if (savedLocale) {
setLocale(savedLocale)
params.setCurrentLocale(savedLocale)
}
} catch (e) {
console.error('Error loading locale:', e)
} finally {
params.setLoading(false)
}
}
async function applyLocaleChange(params: { locale: Locale; setCurrentLocale: (locale: Locale) => void }): Promise<void> {
setLocale(params.locale)
params.setCurrentLocale(params.locale)
try {
await localeStorage.saveLocale(params.locale)
} catch (e) {
console.error('Error saving locale:', e)
}
window.location.reload()
}

View File

@ -23,96 +23,38 @@ export function MarkdownEditorTwoColumns({
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const handleAddPage = (type: 'markdown' | 'image'): void => { const pagesHandlers = createPagesHandlers({ pages, onPagesChange })
if (!onPagesChange) { const handleImageUpload = createImageUploadHandler({
return setError,
} setUploading,
const newPage: Page = { onMediaAdd,
number: pages.length + 1, onBannerChange,
type, onSetPageImageUrl: pagesHandlers.setPageContent,
content: type === 'markdown' ? '' : '', })
}
onPagesChange([...pages, newPage])
}
const handlePageContentChange = (pageNumber: number, content: string): void => {
if (!onPagesChange) {
return
}
const updatedPages = pages.map((p) => (p.number === pageNumber ? { ...p, content } : p))
onPagesChange(updatedPages)
}
const handlePageTypeChange = (pageNumber: number, type: 'markdown' | 'image'): void => {
if (!onPagesChange) {
return
}
const updatedPages = pages.map((p) => (p.number === pageNumber ? { ...p, type, content: '' } : p))
onPagesChange(updatedPages)
}
const handleRemovePage = (pageNumber: number): void => {
if (!onPagesChange) {
return
}
const updatedPages = pages
.filter((p) => p.number !== pageNumber)
.map((p, index) => ({ ...p, number: index + 1 }))
onPagesChange(updatedPages)
}
const handleImageUpload = async (file: File, pageNumber?: number): Promise<void> => {
setError(null)
setUploading(true)
try {
const media = await uploadNip95Media(file)
if (media.type === 'image') {
if (pageNumber !== undefined && onPagesChange) {
handlePageContentChange(pageNumber, media.url)
} else {
onBannerChange?.(media.url)
}
onMediaAdd?.(media)
}
} catch (e) {
setError(e instanceof Error ? e.message : t('upload.error.failed'))
} finally {
setUploading(false)
}
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<MarkdownToolbar <MarkdownToolbar
onFileSelected={(file) => { onFileSelected={(file) => {
void handleImageUpload(file) void handleImageUpload({ file })
}} }}
uploading={uploading} uploading={uploading}
error={error} error={error}
{...(onPagesChange ? { onAddPage: handleAddPage } : {})} {...(onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})}
/> />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <EditorColumn value={value} onChange={onChange} />
<label className="block text-sm font-semibold text-gray-800">{t('markdown.editor')}</label> <PreviewColumn value={value} />
<textarea
className="w-full border rounded p-3 h-96 font-mono text-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t('markdown.placeholder')}
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-800">{t('markdown.preview')}</label>
<MarkdownPreview value={value} />
</div>
</div> </div>
{onPagesChange && ( {onPagesChange && (
<PagesManager <PagesManager
pages={pages} pages={pages}
onPageContentChange={handlePageContentChange} onPageContentChange={pagesHandlers.setPageContent}
onPageTypeChange={handlePageTypeChange} onPageTypeChange={pagesHandlers.setPageType}
onRemovePage={handleRemovePage} onRemovePage={pagesHandlers.removePage}
onImageUpload={handleImageUpload} onImageUpload={async (file, pageNumber) => {
await handleImageUpload({ file, pageNumber })
}}
/> />
)} )}
</div> </div>
@ -132,40 +74,9 @@ function MarkdownToolbar({
}): React.ReactElement { }): React.ReactElement {
return ( return (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<label className="px-3 py-1 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700"> <ToolbarUploadButton onFileSelected={onFileSelected} />
{t('markdown.upload.media')} <ToolbarAddPageButtons onAddPage={onAddPage} />
<input <ToolbarStatus uploading={uploading} error={error} />
type="file"
accept=".png,.jpg,.jpeg,.webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
onFileSelected(file)
}
}}
/>
</label>
{onAddPage && (
<>
<button
type="button"
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
onClick={() => 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={() => onAddPage('image')}
>
{t('page.add.image')}
</button>
</>
)}
{uploading && <span className="text-sm text-gray-500">{t('markdown.upload.uploading')}</span>}
{error && <span className="text-sm text-red-600">{error}</span>}
</div> </div>
) )
} }
@ -229,14 +140,104 @@ function PageEditor({
}): React.ReactElement { }): React.ReactElement {
return ( return (
<div className="border rounded-lg p-4 space-y-3"> <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"> <div className="flex items-center justify-between">
<h4 className="font-semibold"> <h4 className="font-semibold">
{t('page.number', { number: page.number })} - {t(`page.type.${page.type}`)} {t('page.number', { number: params.page.number })} - {t(`page.type.${params.page.type}`)}
</h4> </h4>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select <select
value={page.type} value={params.page.type}
onChange={(e) => onTypeChange(e.target.value as 'markdown' | 'image')} onChange={(e) => params.onTypeChange(e.target.value as 'markdown' | 'image')}
className="text-sm border rounded px-2 py-1" className="text-sm border rounded px-2 py-1"
> >
<option value="markdown">{t('page.type.markdown')}</option> <option value="markdown">{t('page.type.markdown')}</option>
@ -245,33 +246,61 @@ function PageEditor({
<button <button
type="button" type="button"
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700" className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
onClick={onRemove} onClick={params.onRemove}
> >
{t('page.remove')} {t('page.remove')}
</button> </button>
</div> </div>
</div> </div>
{page.type === 'markdown' ? ( )
}
function PageEditorBody(params: {
page: Page
onContentChange: (content: string) => void
onImageUpload: (file: File) => Promise<void>
}): React.ReactElement {
if (params.page.type === 'markdown') {
return (
<textarea <textarea
className="w-full border rounded p-2 h-48 font-mono text-sm" className="w-full border rounded p-2 h-48 font-mono text-sm"
value={page.content} value={params.page.content}
onChange={(e) => onContentChange(e.target.value)} onChange={(e) => params.onContentChange(e.target.value)}
placeholder={t('page.markdown.placeholder')} 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"> <div className="space-y-2">
{page.content ? ( {params.page.content ? (
<div className="relative"> <div className="relative">
<img src={page.content} alt={t('page.image.alt', { number: page.number })} className="max-w-full h-auto rounded" /> <img src={params.page.content} alt={t('page.image.alt', { number: params.page.number })} className="max-w-full h-auto rounded" />
<button <button
type="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" className="absolute top-2 right-2 px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
onClick={() => onContentChange('')} onClick={() => params.onContentChange('')}
> >
{t('page.image.remove')} {t('page.image.remove')}
</button> </button>
</div> </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"> <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')} {t('page.image.upload')}
<input <input
@ -281,14 +310,76 @@ function PageEditor({
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (file) { if (file) {
void onImageUpload(file) void params.onFileSelected(file)
} }
}} }}
/> />
</label> </label>
)}
</div>
)}
</div>
) )
} }
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

@ -60,10 +60,28 @@ export function PageHeader(): React.ReactElement {
return ( return (
<header className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan"> <header className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan">
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center"> <div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<HeaderLeft />
<HeaderRight />
</div>
</header>
)
}
function HeaderLeft(): React.ReactElement {
return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/" className="text-2xl font-bold text-neon-cyan text-glow-cyan font-mono hover:text-neon-green transition-colors"> <Link href="/" className="text-2xl font-bold text-neon-cyan text-glow-cyan font-mono hover:text-neon-green transition-colors">
{t('home.title')} {t('home.title')}
</Link> </Link>
<HeaderLinks />
<KeyIndicator />
</div>
)
}
function HeaderLinks(): React.ReactElement {
return (
<>
<Link <Link
href="/docs" href="/docs"
className="text-cyber-accent hover:text-neon-cyan transition-colors" className="text-cyber-accent hover:text-neon-cyan transition-colors"
@ -88,13 +106,15 @@ export function PageHeader(): React.ReactElement {
> >
<GitIcon /> <GitIcon />
</a> </a>
<KeyIndicator /> </>
</div> )
}
function HeaderRight(): React.ReactElement {
return (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<LanguageSelector /> <LanguageSelector />
<ConditionalPublishButton /> <ConditionalPublishButton />
</div> </div>
</div>
</header>
) )
} }

View File

@ -130,50 +130,90 @@ function ExpiredNotice({ show }: { show: boolean }): React.ReactElement | null {
) )
} }
function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => void): { type PaymentModalState = {
copied: boolean copied: boolean
errorMessage: string | null errorMessage: string | null
paymentUrl: string paymentUrl: string
timeRemaining: number | null timeRemaining: number | null
handleCopy: () => Promise<void> handleCopy: () => Promise<void>
handleOpenWallet: () => Promise<void> handleOpenWallet: () => Promise<void>
} { }
function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => void): PaymentModalState {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const paymentUrl = `lightning:${invoice.invoice}` const paymentUrl = `lightning:${invoice.invoice}`
const timeRemaining = useInvoiceTimer(invoice.expiresAt) const timeRemaining = useInvoiceTimer(invoice.expiresAt)
const handleCopy = useCallback(async (): Promise<void> => { const handleCopy = useCallback(
createHandleCopy({ invoice: invoice.invoice, setCopied, setErrorMessage }),
[invoice.invoice]
)
const handleOpenWallet = useCallback(
createHandleOpenWallet({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage }),
[invoice.invoice, onPaymentComplete]
)
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
}
function createHandleCopy(params: {
invoice: string
setCopied: (value: boolean) => void
setErrorMessage: (value: string | null) => void
}): () => Promise<void> {
return async (): Promise<void> => {
try { try {
await navigator.clipboard.writeText(invoice.invoice) await navigator.clipboard.writeText(params.invoice)
setCopied(true) params.setCopied(true)
setTimeout(() => setCopied(false), 2000) scheduleCopiedReset(params.setCopied)
} catch (e) { } catch (e) {
console.error('Failed to copy:', e) console.error('Failed to copy:', e)
setErrorMessage(t('payment.modal.copyFailed')) params.setErrorMessage(t('payment.modal.copyFailed'))
} }
}, [invoice.invoice]) }
}
const handleOpenWallet = useCallback(async (): Promise<void> => { function createHandleOpenWallet(params: {
invoice: string
onPaymentComplete: () => void
setErrorMessage: (value: string | null) => void
}): () => Promise<void> {
return async (): Promise<void> => {
try { try {
await payWithWebLN(params.invoice)
params.onPaymentComplete()
} catch (e) {
const error = normalizePaymentError(e)
if (isUserCancellationError(error)) {
return
}
console.error('Payment failed:', error)
params.setErrorMessage(error.message)
}
}
}
function normalizePaymentError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
function scheduleCopiedReset(setCopied: (value: boolean) => void): void {
setTimeout(() => setCopied(false), 2000)
}
function isUserCancellationError(error: Error): boolean {
return error.message.includes('user rejected') || error.message.includes('cancelled')
}
async function payWithWebLN(invoice: string): Promise<void> {
const alby = getAlbyService() const alby = getAlbyService()
if (!isWebLNAvailable()) { if (!isWebLNAvailable()) {
throw new Error(t('payment.modal.weblnNotAvailable')) throw new Error(t('payment.modal.weblnNotAvailable'))
} }
await alby.enable() await alby.enable()
await alby.sendPayment(invoice.invoice) await alby.sendPayment(invoice)
onPaymentComplete()
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
if (error.message.includes('user rejected') || error.message.includes('cancelled')) {
return
}
console.error('Payment failed:', error)
setErrorMessage(error.message)
}
}, [invoice.invoice, onPaymentComplete])
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
} }
export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps): React.ReactElement { export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps): React.ReactElement {

View File

@ -7,8 +7,6 @@ import { t } from '@/lib/i18n'
import { useSyncProgress } from '@/lib/hooks/useSyncProgress' import { useSyncProgress } from '@/lib/hooks/useSyncProgress'
export function SyncProgressBar(): React.ReactElement | null { export function SyncProgressBar(): React.ReactElement | null {
console.warn('[SyncProgressBar] Component function called')
const [lastSyncDate, setLastSyncDate] = useState<number | null>(null) const [lastSyncDate, setLastSyncDate] = useState<number | null>(null)
const [totalDays, setTotalDays] = useState<number>(0) const [totalDays, setTotalDays] = useState<number>(0)
const [isInitialized, setIsInitialized] = useState(false) const [isInitialized, setIsInitialized] = useState(false)
@ -78,39 +76,14 @@ export function SyncProgressBar(): React.ReactElement | null {
return return
} }
void (async () => { void runAutoSyncCheck({
console.warn('[SyncProgressBar] Starting sync check...') connection: { connected: connectionState.connected, pubkey: connectionState.pubkey },
await loadSyncStatus() isSyncing,
loadSyncStatus,
// Auto-start sync if not recently synced startMonitoring,
const storedLastSyncDate = await getLastSyncDate() stopMonitoring,
const currentTimestamp = getCurrentTimestamp() setError,
const isRecentlySynced = storedLastSyncDate >= currentTimestamp - 3600 })
console.warn('[SyncProgressBar] Sync status:', { storedLastSyncDate, currentTimestamp, isRecentlySynced, isSyncing })
// Only auto-start if not recently synced
if (!isRecentlySynced && !isSyncing && connectionState.pubkey) {
console.warn('[SyncProgressBar] Starting auto-sync...')
try {
const { swClient } = await import('@/lib/swClient')
const isReady = await swClient.isReady()
if (isReady) {
await swClient.startUserSync(connectionState.pubkey)
startMonitoring()
} else {
stopMonitoring()
}
} catch (autoSyncError) {
console.error('[SyncProgressBar] Error during auto-sync:', autoSyncError)
stopMonitoring()
setError(autoSyncError instanceof Error ? autoSyncError.message : 'Erreur de synchronisation')
}
} else {
console.warn('[SyncProgressBar] Skipping auto-sync:', { isRecentlySynced, isSyncing, hasPubkey: Boolean(connectionState.pubkey) })
}
})()
}, [isInitialized, connectionState.connected, connectionState.pubkey, isSyncing, loadSyncStatus, startMonitoring, stopMonitoring]) }, [isInitialized, connectionState.connected, connectionState.pubkey, isSyncing, loadSyncStatus, startMonitoring, stopMonitoring])
async function resynchronize(): Promise<void> { async function resynchronize(): Promise<void> {
@ -156,18 +129,13 @@ export function SyncProgressBar(): React.ReactElement | null {
// Don't show if not initialized or not connected // Don't show if not initialized or not connected
if (!isInitialized || !connectionState.connected || !connectionState.pubkey) { if (!isInitialized || !connectionState.connected || !connectionState.pubkey) {
console.warn('[SyncProgressBar] Not rendering:', { isInitialized, connected: connectionState.connected, pubkey: connectionState.pubkey })
return null return null
} }
console.warn('[SyncProgressBar] Rendering component')
// Check if sync is recently completed (within last hour) // Check if sync is recently completed (within last hour)
const isRecentlySynced = lastSyncDate !== null && lastSyncDate >= getCurrentTimestamp() - 3600 const isRecentlySynced = lastSyncDate !== null && lastSyncDate >= getCurrentTimestamp() - 3600
const progressPercentage = syncProgress && syncProgress.totalSteps > 0 const progressPercentage = computeProgressPercentage(syncProgress)
? Math.min(100, (syncProgress.currentStep / syncProgress.totalSteps) * 100)
: 0
const formatDate = (timestamp: number): string => { const formatDate = (timestamp: number): string => {
const date = new Date(timestamp * 1000) const date = new Date(timestamp * 1000)
@ -187,80 +155,192 @@ export function SyncProgressBar(): React.ReactElement | null {
return ( return (
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-4 mt-6"> <div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-4 mt-6">
{error && ( <SyncErrorBanner
<div className="mb-4 bg-red-900/30 border border-red-500/50 rounded p-3 text-red-300 text-sm"> error={error}
{error} onDismiss={() => {
<button
onClick={() => {
setError(null) setError(null)
}} }}
className="ml-2 text-red-400 hover:text-red-200" />
>
×
</button>
</div>
)}
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold text-neon-cyan"> <h3 className="text-lg font-semibold text-neon-cyan">
{t('settings.sync.title')} {t('settings.sync.title')}
</h3> </h3>
{!isSyncing && ( <SyncResyncButton
<button isSyncing={isSyncing}
onClick={() => { onClick={() => {
void resynchronize() 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" 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')} {t('settings.sync.resync')}
</button> </button>
)} )
</div> }
{totalDays > 0 && ( function SyncDateRange(params: { totalDays: number; startDate: string; endDate: string }): React.ReactElement | null {
if (params.totalDays <= 0) {
return null
}
return (
<div className="mb-2"> <div className="mb-2">
<p className="text-sm text-cyber-accent"> <p className="text-sm text-cyber-accent">
{t('settings.sync.daysRange', { {t('settings.sync.daysRange', {
startDate: formatDate(startDate), startDate: params.startDate,
endDate: formatDate(endDate), endDate: params.endDate,
days: totalDays, days: params.totalDays,
})} })}
</p> </p>
</div> </div>
)} )
}
{isSyncing && syncProgress && ( 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="space-y-2">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-cyber-accent"> <span className="text-cyber-accent">
{t('settings.sync.progress', { {t('settings.sync.progress', {
current: syncProgress.currentStep, current: params.syncProgress.currentStep,
total: syncProgress.totalSteps, total: params.syncProgress.totalSteps,
})} })}
</span> </span>
<span className="text-neon-cyan font-semibold"> <span className="text-neon-cyan font-semibold">{Math.round(params.progressPercentage)}%</span>
{Math.round(progressPercentage)}%
</span>
</div> </div>
<div className="w-full bg-cyber-dark rounded-full h-2 overflow-hidden"> <div className="w-full bg-cyber-dark rounded-full h-2 overflow-hidden">
<div <div className="bg-neon-cyan h-full transition-all duration-300" style={{ width: `${params.progressPercentage}%` }} />
className="bg-neon-cyan h-full transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
/>
</div> </div>
</div> </div>
)}
{!isSyncing && totalDays === 0 && isRecentlySynced && (
<p className="text-sm text-green-400">
{t('settings.sync.completed')}
</p>
)}
{!isSyncing && totalDays === 0 && !isRecentlySynced && (
<p className="text-sm text-cyber-accent">
{t('settings.sync.ready')}
</p>
)}
</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

@ -15,6 +15,25 @@ interface UserArticlesProps {
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined 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({ export function UserArticles({
articles, articles,
loading, loading,
@ -45,24 +64,7 @@ function useUserArticlesController({
articles: Article[] articles: Article[]
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null> onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
currentPubkey: string | null currentPubkey: string | null
}): { }): 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>
} {
const [deletedArticleIds, setDeletedArticleIds] = useState<Set<string>>(new Set()) const [deletedArticleIds, setDeletedArticleIds] = useState<Set<string>>(new Set())
const [articleOverridesById, setArticleOverridesById] = useState<Map<string, Article>>(new Map()) const [articleOverridesById, setArticleOverridesById] = useState<Map<string, Article>>(new Map())
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set()) const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
@ -200,7 +202,7 @@ function UserArticlesLayout({
} }
function createLayoutProps( function createLayoutProps(
controller: ReturnType<typeof useUserArticlesController>, controller: UserArticlesController,
view: { view: {
loading: boolean loading: boolean
error: string | null error: string | null
@ -240,7 +242,7 @@ function createLayoutProps(
} }
} }
function buildEditPanelProps(controller: ReturnType<typeof useUserArticlesController>): { function buildEditPanelProps(controller: UserArticlesController): {
draft: ArticleDraft | null draft: ArticleDraft | null
editingArticleId: string | null editingArticleId: string | null
loading: boolean loading: boolean
@ -260,16 +262,7 @@ function buildEditPanelProps(controller: ReturnType<typeof useUserArticlesContro
} }
} }
function buildListProps( type UserArticlesListProps = {
controller: ReturnType<typeof useUserArticlesController>,
view: {
loading: boolean
error: string | null
showEmptyMessage: boolean
currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
): {
articles: Article[] articles: Article[]
loading: boolean loading: boolean
error: string | null error: string | null
@ -283,22 +276,28 @@ function buildListProps(
pendingDeleteId: string | null pendingDeleteId: string | null
requestDelete: (articleId: string) => void requestDelete: (articleId: string) => void
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined 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 { return {
articles: controller.localArticles, articles: controller.localArticles,
loading: view.loading, loading: view.loading,
error: view.error, error: view.error,
showEmptyMessage: view.showEmptyMessage, showEmptyMessage: view.showEmptyMessage,
unlockedArticles: controller.unlockedArticles, unlockedArticles: controller.unlockedArticles,
onUnlock: (a: Article) => { onUnlock: handlers.onUnlock,
void controller.handleUnlock(a) onEdit: handlers.onEdit,
}, onDelete: handlers.onDelete,
onEdit: (a: Article) => {
void controller.startEditing(a)
},
onDelete: (a: Article) => {
void controller.handleDelete(a)
},
editingArticleId: controller.editingArticleId, editingArticleId: controller.editingArticleId,
currentPubkey: view.currentPubkey, currentPubkey: view.currentPubkey,
pendingDeleteId: controller.pendingDeleteId, pendingDeleteId: controller.pendingDeleteId,
@ -306,3 +305,21 @@ function buildListProps(
...(view.onSelectSeries ? { onSelectSeries: view.onSelectSeries } : {}), ...(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

@ -15,7 +15,7 @@ export function useAutoConnect(params: {
}, [accountExists, pubkey, showRecoveryStep, showUnlockModal, connect]) }, [accountExists, pubkey, showRecoveryStep, showUnlockModal, connect])
} }
export function useConnectButtonUiState(): { type ConnectButtonUiState = {
showRecoveryStep: boolean showRecoveryStep: boolean
showUnlockModal: boolean showUnlockModal: boolean
setShowUnlockModal: (show: boolean) => void setShowUnlockModal: (show: boolean) => void
@ -28,7 +28,9 @@ export function useConnectButtonUiState(): {
onUnlockSuccess: () => void onUnlockSuccess: () => void
openUnlockModal: () => void openUnlockModal: () => void
closeUnlockModal: () => void closeUnlockModal: () => void
} { }
export function useConnectButtonUiState(): ConnectButtonUiState {
const unlockModal = useUnlockModalVisibility() const unlockModal = useUnlockModalVisibility()
const recovery = useRecoveryStepState() const recovery = useRecoveryStepState()
const [creatingAccount, setCreatingAccount] = useState(false) const [creatingAccount, setCreatingAccount] = useState(false)

View File

@ -8,8 +8,21 @@ export function ReviewFormView(params: { ctrl: ReviewFormController; onCancel?:
onSubmit={(e) => void params.ctrl.handleSubmit(e)} onSubmit={(e) => void params.ctrl.handleSubmit(e)}
className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4" 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('review.form.title')}</h3> <ReviewFormHeader />
<ReviewFormFields ctrl={params.ctrl} />
{params.ctrl.error ? <ErrorBox message={params.ctrl.error} /> : null}
<ReviewFormActions loading={params.ctrl.loading} onCancel={params.onCancel} />
</form>
)
}
function ReviewFormHeader(): React.ReactElement {
return <h3 className="text-lg font-semibold text-neon-cyan">{t('review.form.title')}</h3>
}
function ReviewFormFields(params: { ctrl: ReviewFormController }): React.ReactElement {
return (
<>
<TextInput <TextInput
id="review-title" id="review-title"
label={t('review.form.title.label')} label={t('review.form.title.label')}
@ -40,16 +53,19 @@ export function ReviewFormView(params: { ctrl: ReviewFormController; onCancel?:
helpText={t('review.form.text.help')} helpText={t('review.form.text.help')}
optionalLabel={`(${t('common.optional')})`} optionalLabel={`(${t('common.optional')})`}
/> />
</>
)
}
{params.ctrl.error ? <ErrorBox message={params.ctrl.error} /> : null} function ReviewFormActions(params: { loading: boolean; onCancel?: (() => void) | undefined }): React.ReactElement {
return (
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="submit" type="submit"
disabled={params.ctrl.loading} 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" 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"
> >
{params.ctrl.loading ? t('common.loading') : t('review.form.submit')} {params.loading ? t('common.loading') : t('review.form.submit')}
</button> </button>
{params.onCancel ? ( {params.onCancel ? (
<button <button
@ -61,7 +77,6 @@ export function ReviewFormView(params: { ctrl: ReviewFormController; onCancel?:
</button> </button>
) : null} ) : null}
</div> </div>
</form>
) )
} }

View File

@ -8,39 +8,61 @@ export function ReviewTipFormView(params: { ctrl: ReviewTipFormController; onCan
onSubmit={(e) => void params.ctrl.handleSubmit(e)} onSubmit={(e) => void params.ctrl.handleSubmit(e)}
className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4" className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"
> >
<ReviewTipFormHeader split={params.ctrl.split} />
<ReviewTipTextField value={params.ctrl.text} onChange={params.ctrl.setText} />
{params.ctrl.error ? <ErrorBox message={params.ctrl.error} /> : null}
<ReviewTipFormActions amount={params.ctrl.split.total} loading={params.ctrl.loading} onCancel={params.onCancel} />
</form>
)
}
function ReviewTipFormHeader(params: { split: { total: number; reviewer: number; platform: number } }): React.ReactElement {
return (
<>
<h3 className="text-lg font-semibold text-neon-cyan">{t('reviewTip.form.title')}</h3> <h3 className="text-lg font-semibold text-neon-cyan">{t('reviewTip.form.title')}</h3>
<p className="text-sm text-cyber-accent/70"> <p className="text-sm text-cyber-accent/70">
{t('reviewTip.form.description', { {t('reviewTip.form.description', {
amount: params.ctrl.split.total, amount: params.split.total,
reviewer: params.ctrl.split.reviewer, reviewer: params.split.reviewer,
platform: params.ctrl.split.platform, platform: params.split.platform,
})} })}
</p> </p>
</>
)
}
function ReviewTipTextField(params: { value: string; onChange: (value: string) => void }): React.ReactElement {
return (
<div> <div>
<label htmlFor="review-tip-text" className="block text-sm font-medium text-cyber-accent mb-1"> <label htmlFor="review-tip-text" className="block text-sm font-medium text-cyber-accent mb-1">
{t('reviewTip.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span> {t('reviewTip.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
</label> </label>
<textarea <textarea
id="review-tip-text" id="review-tip-text"
value={params.ctrl.text} value={params.value}
onChange={(e) => params.ctrl.setText(e.target.value)} onChange={(e) => params.onChange(e.target.value)}
placeholder={t('reviewTip.form.text.placeholder')} placeholder={t('reviewTip.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('reviewTip.form.text.help')}</p> <p className="text-xs text-cyber-accent/70 mt-1">{t('reviewTip.form.text.help')}</p>
</div> </div>
)
}
{params.ctrl.error ? <ErrorBox message={params.ctrl.error} /> : null} function ReviewTipFormActions(params: {
amount: number
loading: boolean
onCancel?: (() => void) | undefined
}): React.ReactElement {
return (
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="submit" type="submit"
disabled={params.ctrl.loading} 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" 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"
> >
{params.ctrl.loading ? t('common.loading') : t('reviewTip.form.submit', { amount: params.ctrl.split.total })} {params.loading ? t('common.loading') : t('reviewTip.form.submit', { amount: params.amount })}
</button> </button>
{params.onCancel ? ( {params.onCancel ? (
<button <button
@ -52,7 +74,6 @@ export function ReviewTipFormView(params: { ctrl: ReviewTipFormController; onCan
</button> </button>
) : null} ) : null}
</div> </div>
</form>
) )
} }

View File

@ -10,7 +10,7 @@ interface EditState {
articleId: string | null articleId: string | null
} }
export function useArticleEditing(authorPubkey: string | null): { type UseArticleEditingResult = {
editingDraft: ArticleDraft | null editingDraft: ArticleDraft | null
editingArticleId: string | null editingArticleId: string | null
loading: boolean loading: boolean
@ -20,95 +20,25 @@ export function useArticleEditing(authorPubkey: string | null): {
submitEdit: () => Promise<ArticleUpdateResult | null> submitEdit: () => Promise<ArticleUpdateResult | null>
deleteArticle: (articleId: string) => Promise<boolean> deleteArticle: (articleId: string) => Promise<boolean>
updateDraft: (draft: ArticleDraft | null) => void updateDraft: (draft: ArticleDraft | null) => void
} { }
export function useArticleEditing(authorPubkey: string | null): UseArticleEditingResult {
const [state, setState] = useState<EditState>({ draft: null, articleId: null }) const [state, setState] = useState<EditState>({ draft: null, articleId: null })
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const updateDraft = (draft: ArticleDraft | null): void => { const updateDraft = (draft: ArticleDraft | null): void => setState((prev) => ({ ...prev, draft }))
setState((prev) => ({ ...prev, draft }))
}
const startEditing = async (article: Article): Promise<void> => { const startEditing = (article: Article): Promise<void> =>
if (!authorPubkey) { startEditingArticle({ authorPubkey, article, setState, setLoading, setError })
setError('Connect your Nostr wallet to edit')
return
}
setLoading(true)
setError(null)
try {
const stored = await getStoredContent(article.id)
if (!stored) {
setError('Private content not available locally. Please republish from original device.')
setLoading(false)
return
}
setState({
articleId: article.id,
draft: {
title: article.title,
preview: article.preview,
content: stored.content,
zapAmount: article.zapAmount,
...(article.category === 'science-fiction' || article.category === 'scientific-research'
? { category: article.category }
: {}),
},
})
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load draft')
} finally {
setLoading(false)
}
}
const cancelEditing = (): void => { const cancelEditing = (): void => resetEditingState({ setState, setError })
setState({ draft: null, articleId: null })
setError(null)
}
const submitEdit = async (): Promise<ArticleUpdateResult | null> => { const submitEdit = (): Promise<ArticleUpdateResult | null> =>
if (!authorPubkey || !state.articleId || !state.draft) { submitArticleEdit({ authorPubkey, state, setState, setLoading, setError })
setError('Missing data for update')
return null
}
setLoading(true)
setError(null)
try {
const privateKey = nostrService.getPrivateKey() ?? undefined
const result = await publishArticleUpdate(state.articleId, state.draft, authorPubkey, privateKey)
if (!result.success) {
setError(result.error ?? 'Update failed')
return null
}
return result
} catch (e) {
setError(e instanceof Error ? e.message : 'Update failed')
return null
} finally {
setLoading(false)
setState({ draft: null, articleId: null })
}
}
const deleteArticle = async (articleId: string): Promise<boolean> => { const deleteArticle = (articleId: string): Promise<boolean> =>
if (!authorPubkey) { deleteArticleById({ authorPubkey, articleId, setLoading, setError })
setError('Connect your Nostr wallet to delete')
return false
}
setLoading(true)
setError(null)
try {
const privateKey = nostrService.getPrivateKey() ?? undefined
await deleteArticleEvent(articleId, authorPubkey, privateKey)
return true
} catch (e) {
setError(e instanceof Error ? e.message : 'Delete failed')
return false
} finally {
setLoading(false)
}
}
return { return {
editingDraft: state.draft, editingDraft: state.draft,
@ -122,3 +52,102 @@ export function useArticleEditing(authorPubkey: string | null): {
updateDraft, updateDraft,
} }
} }
function resetEditingState(params: {
setState: (value: EditState) => void
setError: (value: string | null) => void
}): void {
params.setState({ draft: null, articleId: null })
params.setError(null)
}
async function startEditingArticle(params: {
authorPubkey: string | null
article: Article
setState: (value: EditState) => void
setLoading: (value: boolean) => void
setError: (value: string | null) => void
}): Promise<void> {
if (!params.authorPubkey) {
params.setError('Connect your Nostr wallet to edit')
return
}
params.setLoading(true)
params.setError(null)
try {
const stored = await getStoredContent(params.article.id)
if (!stored) {
params.setError('Private content not available locally. Please republish from original device.')
return
}
params.setState({ articleId: params.article.id, draft: buildDraftForEdit(params.article, stored.content) })
} catch (e) {
params.setError(e instanceof Error ? e.message : 'Failed to load draft')
} finally {
params.setLoading(false)
}
}
function buildDraftForEdit(article: Article, content: string): ArticleDraft {
return {
title: article.title,
preview: article.preview,
content,
zapAmount: article.zapAmount,
...(article.category === 'science-fiction' || article.category === 'scientific-research' ? { category: article.category } : {}),
}
}
async function submitArticleEdit(params: {
authorPubkey: string | null
state: EditState
setState: (value: EditState) => void
setLoading: (value: boolean) => void
setError: (value: string | null) => void
}): Promise<ArticleUpdateResult | null> {
if (!params.authorPubkey || !params.state.articleId || !params.state.draft) {
params.setError('Missing data for update')
return null
}
params.setLoading(true)
params.setError(null)
try {
const privateKey = nostrService.getPrivateKey() ?? undefined
const result = await publishArticleUpdate(params.state.articleId, params.state.draft, params.authorPubkey, privateKey)
if (!result.success) {
params.setError(result.error ?? 'Update failed')
return null
}
return result
} catch (e) {
params.setError(e instanceof Error ? e.message : 'Update failed')
return null
} finally {
params.setLoading(false)
params.setState({ draft: null, articleId: null })
}
}
async function deleteArticleById(params: {
authorPubkey: string | null
articleId: string
setLoading: (value: boolean) => void
setError: (value: string | null) => void
}): Promise<boolean> {
if (!params.authorPubkey) {
params.setError('Connect your Nostr wallet to delete')
return false
}
params.setLoading(true)
params.setError(null)
try {
const privateKey = nostrService.getPrivateKey() ?? undefined
await deleteArticleEvent(params.articleId, params.authorPubkey, privateKey)
return true
} catch (e) {
params.setError(e instanceof Error ? e.message : 'Delete failed')
return false
} finally {
params.setLoading(false)
}
}

View File

@ -4,99 +4,50 @@ import type { AlbyInvoice } from '@/types/alby'
import { paymentService } from '@/lib/payment' import { paymentService } from '@/lib/payment'
import { nostrService } from '@/lib/nostr' import { nostrService } from '@/lib/nostr'
export function useArticlePayment( type UseArticlePaymentResult = {
article: Article,
pubkey: string | null,
onUnlockSuccess?: () => void,
connect?: () => Promise<void>
): {
loading: boolean loading: boolean
error: string | null error: string | null
paymentInvoice: AlbyInvoice | null paymentInvoice: AlbyInvoice | null
handleUnlock: () => Promise<void> handleUnlock: () => Promise<void>
handlePaymentComplete: () => Promise<void> handlePaymentComplete: () => Promise<void>
handleCloseModal: () => void handleCloseModal: () => void
} { }
export function useArticlePayment(
article: Article,
pubkey: string | null,
onUnlockSuccess?: () => void,
connect?: () => Promise<void>
): UseArticlePaymentResult {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [paymentInvoice, setPaymentInvoice] = useState<AlbyInvoice | null>(null) const [paymentInvoice, setPaymentInvoice] = useState<AlbyInvoice | null>(null)
const [paymentHash, setPaymentHash] = useState<string | null>(null) const [paymentHash, setPaymentHash] = useState<string | null>(null)
const checkPaymentStatus = async (hash: string, userPubkey: string): Promise<void> => { const handleUnlock = (): Promise<void> =>
try { unlockArticlePayment({
const hasPaid = await paymentService.waitForArticlePayment({
paymentHash: hash,
articleId: article.id,
articlePubkey: article.pubkey,
amount: article.zapAmount,
recipientPubkey: userPubkey,
timeout: 300000,
})
if (hasPaid) {
const content = await nostrService.getPrivateContent(article.id, article.pubkey)
if (content) {
setPaymentInvoice(null)
setPaymentHash(null)
onUnlockSuccess?.()
} else {
setError('Content not available. Please contact the author.')
}
}
} catch (e) {
console.error('Payment check error:', e)
}
}
const handleUnlock = async (): Promise<void> => {
if (!pubkey) {
if (connect) {
setLoading(true)
await connect()
setLoading(false)
} else {
setError('Please connect with Nostr first')
}
return
}
setLoading(true)
setError(null)
try {
const paymentResult = await paymentService.createArticlePayment({
article, article,
userPubkey: pubkey, pubkey,
connect,
onUnlockSuccess,
setLoading,
setError,
setPaymentInvoice,
setPaymentHash,
}) })
if (!paymentResult.success || !paymentResult.invoice || !paymentResult.paymentHash) { const handlePaymentComplete = (): Promise<void> =>
setError(paymentResult.error ?? 'Failed to create payment invoice') checkPaymentAndUnlock({
setLoading(false) article,
return pubkey,
} paymentHash,
onUnlockSuccess,
setError,
setPaymentInvoice,
setPaymentHash,
})
setPaymentInvoice(paymentResult.invoice) const handleCloseModal = (): void => resetPaymentModalState({ setPaymentInvoice, setPaymentHash })
setPaymentHash(paymentResult.paymentHash)
setLoading(false)
void checkPaymentStatus(paymentResult.paymentHash, pubkey)
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to process payment'
console.error('Payment processing error:', e)
setError(errorMessage)
setLoading(false)
}
}
const handlePaymentComplete = async (): Promise<void> => {
if (paymentHash && pubkey) {
await checkPaymentStatus(paymentHash, pubkey)
}
}
const handleCloseModal = (): void => {
setPaymentInvoice(null)
setPaymentHash(null)
}
return { return {
loading, loading,
@ -107,3 +58,114 @@ export function useArticlePayment(
handleCloseModal, handleCloseModal,
} }
} }
async function unlockArticlePayment(params: {
article: Article
pubkey: string | null
connect: (() => Promise<void>) | undefined
onUnlockSuccess: (() => void) | undefined
setLoading: (value: boolean) => void
setError: (value: string | null) => void
setPaymentInvoice: (value: AlbyInvoice | null) => void
setPaymentHash: (value: string | null) => void
}): Promise<void> {
if (!params.pubkey) {
await ensureConnectedOrError({
connect: params.connect,
setLoading: params.setLoading,
setError: params.setError,
})
return
}
params.setLoading(true)
params.setError(null)
try {
const paymentResult = await paymentService.createArticlePayment({
article: params.article,
userPubkey: params.pubkey,
})
if (!paymentResult.success || !paymentResult.invoice || !paymentResult.paymentHash) {
params.setError(paymentResult.error ?? 'Failed to create payment invoice')
return
}
params.setPaymentInvoice(paymentResult.invoice)
params.setPaymentHash(paymentResult.paymentHash)
void checkPaymentAndUnlock({
article: params.article,
pubkey: params.pubkey,
paymentHash: paymentResult.paymentHash,
onUnlockSuccess: params.onUnlockSuccess,
setError: params.setError,
setPaymentInvoice: params.setPaymentInvoice,
setPaymentHash: params.setPaymentHash,
})
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to process payment'
console.error('Payment processing error:', e)
params.setError(errorMessage)
} finally {
params.setLoading(false)
}
}
async function ensureConnectedOrError(params: {
connect: (() => Promise<void>) | undefined
setLoading: (value: boolean) => void
setError: (value: string | null) => void
}): Promise<void> {
if (!params.connect) {
params.setError('Please connect with Nostr first')
return
}
params.setLoading(true)
try {
await params.connect()
} finally {
params.setLoading(false)
}
}
async function checkPaymentAndUnlock(params: {
article: Article
pubkey: string | null
paymentHash: string | null
onUnlockSuccess: (() => void) | undefined
setError: (value: string | null) => void
setPaymentInvoice: (value: AlbyInvoice | null) => void
setPaymentHash: (value: string | null) => void
}): Promise<void> {
if (!params.paymentHash || !params.pubkey) {
return
}
try {
const hasPaid = await paymentService.waitForArticlePayment({
paymentHash: params.paymentHash,
articleId: params.article.id,
articlePubkey: params.article.pubkey,
amount: params.article.zapAmount,
recipientPubkey: params.pubkey,
timeout: 300000,
})
if (!hasPaid) {
return
}
const content = await nostrService.getPrivateContent(params.article.id, params.article.pubkey)
if (!content) {
params.setError('Content not available. Please contact the author.')
return
}
resetPaymentModalState({ setPaymentInvoice: params.setPaymentInvoice, setPaymentHash: params.setPaymentHash })
params.onUnlockSuccess?.()
} catch (e) {
console.error('Payment check error:', e)
}
}
function resetPaymentModalState(params: {
setPaymentInvoice: (value: AlbyInvoice | null) => void
setPaymentHash: (value: string | null) => void
}): void {
params.setPaymentInvoice(null)
params.setPaymentHash(null)
}

View File

@ -12,118 +12,27 @@ interface AuthorPresentationDraft {
pictureUrl?: string pictureUrl?: string
} }
export function useAuthorPresentation(pubkey: string | null): { type UseAuthorPresentationResult = {
loading: boolean loading: boolean
error: string | null error: string | null
success: boolean success: boolean
publishPresentation: (draft: AuthorPresentationDraft) => Promise<void> publishPresentation: (draft: AuthorPresentationDraft) => Promise<void>
checkPresentationExists: () => Promise<Article | null> checkPresentationExists: () => Promise<Article | null>
deletePresentation: (articleId: string) => Promise<void> deletePresentation: (articleId: string) => Promise<void>
} { }
export function useAuthorPresentation(pubkey: string | null): UseAuthorPresentationResult {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
const publishPresentation = async (draft: AuthorPresentationDraft): Promise<void> => { const publishPresentation = (draft: AuthorPresentationDraft): Promise<void> =>
if (!pubkey) { publishAuthorPresentation({ pubkey, draft, setLoading, setError, setSuccess })
setError('Clé publique non disponible')
return
}
setLoading(true) const checkPresentationExists = (): Promise<Article | null> => checkPresentation({ pubkey })
setError(null)
try { const deletePresentation = (articleId: string): Promise<void> =>
const privateKey = nostrService.getPrivateKey() deleteAuthorPresentation({ pubkey, articleId, setLoading, setError })
if (!privateKey) {
setError('Clé privée requise pour publier. Veuillez vous connecter avec un portefeuille Nostr qui fournit des capacités de signature.')
setLoading(false)
return
}
// Update Nostr profile (kind 0) with author name and picture
const profileUpdates: Partial<NostrProfile> = {
name: draft.authorName.trim(),
...(draft.pictureUrl ? { picture: draft.pictureUrl } : {}),
}
try {
await nostrService.updateProfile(profileUpdates)
} catch (e) {
console.error('Error updating profile:', e)
// Continue with article publication even if profile update fails
}
// Create presentation article
const title = `Présentation de ${draft.authorName.trim()}`
const preview = draft.presentation.substring(0, 200)
const fullContent = `${draft.presentation}\n\n---\n\nDescription du contenu :\n${draft.contentDescription}`
const result = await articlePublisher.publishPresentationArticle(
{
title,
preview,
content: fullContent,
presentation: draft.presentation,
contentDescription: draft.contentDescription,
mainnetAddress: draft.mainnetAddress,
...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
},
pubkey,
privateKey
)
if (result.success) {
setSuccess(true)
} else {
setError(result.error ?? 'Erreur lors de la publication')
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Erreur inconnue'
console.error('Error publishing presentation:', e)
setError(errorMessage)
} finally {
setLoading(false)
}
}
const checkPresentationExists = async (): Promise<Article | null> => {
if (!pubkey) {
return null
}
try {
return articlePublisher.getAuthorPresentation(pubkey)
} catch (e) {
console.error('Error checking presentation:', e)
return null
}
}
const deletePresentation = async (articleId: string): Promise<void> => {
if (!pubkey) {
throw new Error('Clé publique non disponible')
}
setLoading(true)
setError(null)
try {
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
throw new Error('Clé privée requise pour supprimer. Veuillez vous connecter avec un portefeuille Nostr qui fournit des capacités de signature.')
}
const { deleteArticleEvent } = await import('@/lib/articleMutations')
await deleteArticleEvent(articleId, pubkey, privateKey)
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Erreur inconnue'
console.error('Error deleting presentation:', e)
setError(errorMessage)
throw e
} finally {
setLoading(false)
}
}
return { return {
loading, loading,
@ -134,3 +43,114 @@ export function useAuthorPresentation(pubkey: string | null): {
deletePresentation, deletePresentation,
} }
} }
async function publishAuthorPresentation(params: {
pubkey: string | null
draft: AuthorPresentationDraft
setLoading: (value: boolean) => void
setError: (value: string | null) => void
setSuccess: (value: boolean) => void
}): Promise<void> {
if (!params.pubkey) {
params.setError('Clé publique non disponible')
return
}
params.setLoading(true)
params.setError(null)
try {
const privateKey = getPrivateKeyOrThrow('Clé privée requise pour publier. Veuillez vous connecter avec un portefeuille Nostr qui fournit des capacités de signature.')
await updateProfileBestEffort(params.draft)
const { title, preview, fullContent } = buildPresentationContent(params.draft)
const result = await articlePublisher.publishPresentationArticle(
{
title,
preview,
content: fullContent,
presentation: params.draft.presentation,
contentDescription: params.draft.contentDescription,
mainnetAddress: params.draft.mainnetAddress,
...(params.draft.pictureUrl ? { pictureUrl: params.draft.pictureUrl } : {}),
},
params.pubkey,
privateKey
)
if (result.success) {
params.setSuccess(true)
} else {
params.setError(result.error ?? 'Erreur lors de la publication')
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Erreur inconnue'
console.error('Error publishing presentation:', e)
params.setError(errorMessage)
} finally {
params.setLoading(false)
}
}
async function updateProfileBestEffort(draft: AuthorPresentationDraft): Promise<void> {
const profileUpdates: Partial<NostrProfile> = {
name: draft.authorName.trim(),
...(draft.pictureUrl ? { picture: draft.pictureUrl } : {}),
}
try {
await nostrService.updateProfile(profileUpdates)
} catch (e) {
console.error('Error updating profile:', e)
}
}
function buildPresentationContent(draft: AuthorPresentationDraft): { title: string; preview: string; fullContent: string } {
const title = `Présentation de ${draft.authorName.trim()}`
const preview = draft.presentation.substring(0, 200)
const fullContent = `${draft.presentation}\n\n---\n\nDescription du contenu :\n${draft.contentDescription}`
return { title, preview, fullContent }
}
async function checkPresentation(params: { pubkey: string | null }): Promise<Article | null> {
if (!params.pubkey) {
return null
}
try {
return articlePublisher.getAuthorPresentation(params.pubkey)
} catch (e) {
console.error('Error checking presentation:', e)
return null
}
}
async function deleteAuthorPresentation(params: {
pubkey: string | null
articleId: string
setLoading: (value: boolean) => void
setError: (value: string | null) => void
}): Promise<void> {
if (!params.pubkey) {
throw new Error('Clé publique non disponible')
}
params.setLoading(true)
params.setError(null)
try {
const privateKey = getPrivateKeyOrThrow(
'Clé privée requise pour supprimer. Veuillez vous connecter avec un portefeuille Nostr qui fournit des capacités de signature.'
)
const { deleteArticleEvent } = await import('@/lib/articleMutations')
await deleteArticleEvent(params.articleId, params.pubkey, privateKey)
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Erreur inconnue'
console.error('Error deleting presentation:', e)
params.setError(errorMessage)
throw e
} finally {
params.setLoading(false)
}
}
function getPrivateKeyOrThrow(message: string): string {
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
throw new Error(message)
}
return privateKey
}

View File

@ -16,43 +16,51 @@ export function useAuthorsProfiles(authorPubkeys: string[]): {
const pubkeysKey = useMemo(() => [...authorPubkeys].sort().join(','), [authorPubkeys]) const pubkeysKey = useMemo(() => [...authorPubkeys].sort().join(','), [authorPubkeys])
useEffect(() => { useEffect(() => {
const loadProfiles = async (): Promise<void> => { void loadAndSetProfiles({ authorPubkeys, setProfiles, setLoading })
if (authorPubkeys.length === 0) {
setProfiles(new Map())
setLoading(false)
return
}
setLoading(true)
const profilesMap = new Map<string, AuthorProfile>()
const profilePromises = authorPubkeys.map(async (pubkey) => {
try {
const profile = await nostrService.getProfile(pubkey)
return {
pubkey,
profile: profile ?? { pubkey },
}
} catch (loadError) {
console.error(`Error loading profile for ${pubkey}:`, loadError)
return {
pubkey,
profile: { pubkey },
}
}
})
const results = await Promise.all(profilePromises)
results.forEach(({ pubkey, profile }) => {
profilesMap.set(pubkey, profile)
})
setProfiles(profilesMap)
setLoading(false)
}
void loadProfiles()
}, [pubkeysKey, authorPubkeys]) }, [pubkeysKey, authorPubkeys])
return { profiles, loading } return { profiles, loading }
} }
async function loadAndSetProfiles(params: {
authorPubkeys: string[]
setProfiles: (value: Map<string, AuthorProfile>) => void
setLoading: (value: boolean) => void
}): Promise<void> {
if (params.authorPubkeys.length === 0) {
params.setProfiles(new Map())
params.setLoading(false)
return
}
params.setLoading(true)
const profilesMap = await loadProfilesMap(params.authorPubkeys)
params.setProfiles(profilesMap)
params.setLoading(false)
}
async function loadProfilesMap(authorPubkeys: string[]): Promise<Map<string, AuthorProfile>> {
const results = await Promise.all(authorPubkeys.map(loadSingleProfile))
const map = new Map<string, AuthorProfile>()
results.forEach(({ pubkey, profile }) => {
map.set(pubkey, profile)
})
return map
}
async function loadSingleProfile(pubkey: string): Promise<{ pubkey: string; profile: AuthorProfile }> {
try {
const profile = await nostrService.getProfile(pubkey)
return { pubkey, profile: ensureAuthorProfile(pubkey, profile) }
} catch (loadError) {
console.error(`Error loading profile for ${pubkey}:`, loadError)
return { pubkey, profile: { pubkey } }
}
}
function ensureAuthorProfile(pubkey: string, profile: NostrProfile | null): AuthorProfile {
if (!profile) {
return { pubkey }
}
return { ...profile, pubkey }
}

View File

@ -19,35 +19,15 @@ export function useDocs(docs: DocLink[]): {
const [docContent, setDocContent] = useState<string>('') const [docContent, setDocContent] = useState<string>('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const loadDoc = useCallback(async (docId: DocSection): Promise<void> => { const loadDoc = useCallback(
const doc = docs.find((d) => d.id === docId) createLoadDoc({
if (!doc) { docs,
return setLoading,
} setSelectedDoc,
setDocContent,
setLoading(true) }),
setSelectedDoc(docId) [docs]
)
try {
// Get current locale and pass it to the API
const locale = getLocale()
const response = await globalThis.fetch(`/api/docs/${doc.file}?locale=${locale}`)
if (response.ok) {
const text = await response.text()
setDocContent(text)
} else {
// Import t dynamically to avoid circular dependency
const { t } = await import('@/lib/i18n')
setDocContent(`# ${t('docs.error')}\n\n${t('docs.error.loadFailed')}`)
}
} catch {
// Import t dynamically to avoid circular dependency
const { t } = await import('@/lib/i18n')
setDocContent(`# ${t('docs.error')}\n\n${t('docs.error.loadFailed')}`)
} finally {
setLoading(false)
}
}, [docs])
useEffect(() => { useEffect(() => {
void loadDoc('user-guide') void loadDoc('user-guide')
@ -60,3 +40,45 @@ export function useDocs(docs: DocLink[]): {
loadDoc, loadDoc,
} }
} }
function createLoadDoc(params: {
docs: DocLink[]
setSelectedDoc: (doc: DocSection) => void
setDocContent: (value: string) => void
setLoading: (value: boolean) => void
}): (docId: DocSection) => Promise<void> {
return async (docId: DocSection): Promise<void> => {
const doc = params.docs.find((d) => d.id === docId)
if (!doc) {
return
}
params.setLoading(true)
params.setSelectedDoc(docId)
try {
const text = await fetchDocContent(doc.file)
params.setDocContent(text)
} catch (error) {
console.error('[useDocs] Error loading doc:', error)
params.setDocContent(await buildDocLoadErrorMarkdown())
} finally {
params.setLoading(false)
}
}
}
async function fetchDocContent(docFile: string): Promise<string> {
const locale = getLocale()
const response = await globalThis.fetch(`/api/docs/${docFile}?locale=${locale}`)
if (!response.ok) {
throw new Error('DOC_FETCH_FAILED')
}
return response.text()
}
async function buildDocLoadErrorMarkdown(): Promise<string> {
// Import t dynamically to avoid circular dependency
const { t } = await import('@/lib/i18n')
return `# ${t('docs.error')}\n\n${t('docs.error.loadFailed')}`
}

View File

@ -10,45 +10,66 @@ export function useI18n(locale: Locale = 'fr'): {
const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale()) const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale())
useEffect(() => { useEffect(() => {
const load = async (): Promise<void> => { void initializeI18n({
try { locale,
// Get saved locale from IndexedDB or use provided locale setLoaded,
let savedLocale: Locale | null = null setCurrentLocale,
try { })
// Migrate from localStorage if needed
const { localeStorage } = await import('@/lib/localeStorage')
await localeStorage.migrateFromLocalStorage()
// Load from IndexedDB
savedLocale = await localeStorage.getLocale()
} catch {
// Fallback to provided locale
}
const initialLocale = savedLocale && (savedLocale === 'fr' || savedLocale === 'en') ? savedLocale : locale
// Load translations from files in public directory
const frResponse = await globalThis.fetch('/locales/fr.txt')
const enResponse = await globalThis.fetch('/locales/en.txt')
if (frResponse.ok) {
const frText = await frResponse.text()
loadTranslations('fr', frText)
}
if (enResponse.ok) {
const enText = await enResponse.text()
loadTranslations('en', enText)
}
setLocale(initialLocale)
setCurrentLocale(initialLocale)
setLoaded(true)
} catch (e) {
console.error('Error loading translations:', e)
setLoaded(true) // Continue even if translations fail to load
}
}
void load()
}, [locale]) }, [locale])
return { loaded, locale: currentLocale, t } return { loaded, locale: currentLocale, t }
} }
async function initializeI18n(params: {
locale: Locale
setLoaded: (value: boolean) => void
setCurrentLocale: (value: Locale) => void
}): Promise<void> {
try {
const savedLocale = await readSavedLocale()
const initialLocale = isSupportedLocale(savedLocale) ? savedLocale : params.locale
await loadAllTranslations()
setLocale(initialLocale)
params.setCurrentLocale(initialLocale)
} catch (e) {
console.error('Error loading translations:', e)
} finally {
params.setLoaded(true)
}
}
function isSupportedLocale(value: unknown): value is Locale {
return value === 'fr' || value === 'en'
}
async function readSavedLocale(): Promise<Locale | null> {
try {
const { localeStorage } = await import('@/lib/localeStorage')
await localeStorage.migrateFromLocalStorage()
return localeStorage.getLocale()
} catch {
return null
}
}
async function loadAllTranslations(): Promise<void> {
const [frText, enText] = await Promise.all([
fetchTranslationText('/locales/fr.txt'),
fetchTranslationText('/locales/en.txt'),
])
if (frText) {
void loadTranslations('fr', frText)
}
if (enText) {
void loadTranslations('en', enText)
}
}
async function fetchTranslationText(url: string): Promise<string | undefined> {
const response = await globalThis.fetch(url)
if (!response.ok) {
return undefined
}
return response.text()
}

View File

@ -2,52 +2,84 @@ import { useState, useEffect } from 'react'
import { nostrAuthService } from '@/lib/nostrAuth' import { nostrAuthService } from '@/lib/nostrAuth'
import type { NostrConnectState } from '@/types/nostr' import type { NostrConnectState } from '@/types/nostr'
export function useNostrAuth(): NostrConnectState & { type UseNostrAuthResult = NostrConnectState & {
loading: boolean loading: boolean
error: string | null error: string | null
connect: () => Promise<void> connect: () => Promise<void>
disconnect: () => Promise<void> disconnect: () => Promise<void>
accountExists: boolean | null accountExists: boolean | null
isUnlocked: boolean isUnlocked: boolean
} { }
function useAuthState(): NostrConnectState {
const [state, setState] = useState<NostrConnectState>(nostrAuthService.getState()) const [state, setState] = useState<NostrConnectState>(nostrAuthService.getState())
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [accountExists, setAccountExists] = useState<boolean | null>(null)
useEffect(() => { useEffect(() => {
const unsubscribe = nostrAuthService.subscribe((newState) => { const unsubscribe = nostrAuthService.subscribe(setState)
setState(newState)
})
// Check if account exists on mount
nostrAuthService.accountExists().then(setAccountExists).catch(() => setAccountExists(false))
return unsubscribe return unsubscribe
}, []) }, [])
return state
}
const connect = async (): Promise<void> => { function useAccountExistsStatus(): boolean | null {
setLoading(true) const [accountExists, setAccountExists] = useState<boolean | null>(null)
setError(null) useEffect(() => {
const load = async (): Promise<void> => {
try {
setAccountExists(await nostrAuthService.accountExists())
} catch {
setAccountExists(false)
}
}
void load()
}, [])
return accountExists
}
function createAuthActions(params: {
setLoading: (next: boolean) => void
setError: (next: string | null) => void
}): { connect: () => Promise<void>; disconnect: () => Promise<void> } {
return {
connect: () => connectAuth(params),
disconnect: () => disconnectAuth(params),
}
}
async function connectAuth(params: {
setLoading: (next: boolean) => void
setError: (next: string | null) => void
}): Promise<void> {
params.setLoading(true)
params.setError(null)
try { try {
await nostrAuthService.connect() await nostrAuthService.connect()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Connection failed') params.setError(e instanceof Error ? e.message : 'Connection failed')
} finally { } finally {
setLoading(false) params.setLoading(false)
}
} }
}
const disconnect = async (): Promise<void> => { async function disconnectAuth(params: {
setLoading(true) setLoading: (next: boolean) => void
setError: (next: string | null) => void
}): Promise<void> {
params.setLoading(true)
try { try {
nostrAuthService.disconnect() nostrAuthService.disconnect()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Disconnection failed') params.setError(e instanceof Error ? e.message : 'Disconnection failed')
} finally { } finally {
setLoading(false) params.setLoading(false)
}
} }
}
export function useNostrAuth(): UseNostrAuthResult {
const state = useAuthState()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const accountExists = useAccountExistsStatus()
const { connect, disconnect } = createAuthActions({ setLoading, setError })
return { return {
...state, ...state,

View File

@ -2,6 +2,9 @@ import { useState, useEffect, useCallback } from 'react'
import { notificationService } from '@/lib/notificationService' import { notificationService } from '@/lib/notificationService'
import type { Notification } from '@/lib/notificationService' import type { Notification } from '@/lib/notificationService'
const MAX_NOTIFICATIONS = 100
const POLL_INTERVAL_MS = 30_000
export function useNotifications(userPubkey: string | null): { export function useNotifications(userPubkey: string | null): {
notifications: Notification[] notifications: Notification[]
unreadCount: number unreadCount: number
@ -13,55 +16,28 @@ export function useNotifications(userPubkey: string | null): {
const [notifications, setNotifications] = useState<Notification[]>([]) const [notifications, setNotifications] = useState<Notification[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Load stored notifications on mount and refresh periodically
useEffect(() => { useEffect(() => {
if (!userPubkey) { if (!userPubkey) {
setNotifications([])
setLoading(false)
return return
} }
const loadNotifications = async (): Promise<void> => { void loadAndSetNotifications({ setNotifications, setLoading })
try {
setLoading(true)
const storedNotifications = await notificationService.getAllNotifications(100)
setNotifications(storedNotifications)
} catch (error) {
console.error('[useNotifications] Error loading notifications:', error)
} finally {
setLoading(false)
}
}
void loadNotifications()
// Refresh notifications every 30 seconds
const interval = setInterval(() => { const interval = setInterval(() => {
void loadNotifications() void loadAndSetNotifications({ setNotifications, setLoading })
}, 30000) }, POLL_INTERVAL_MS)
return () => clearInterval(interval)
return () => {
clearInterval(interval)
}
}, [userPubkey]) }, [userPubkey])
const unreadCount = notifications.filter((n) => !n.read).length const effectiveNotifications = userPubkey ? notifications : []
const effectiveLoading = userPubkey ? loading : false
const unreadCount = effectiveNotifications.filter((n) => !n.read).length
const markAsRead = useCallback( const markAsRead = useCallback(
(notificationId: string): void => { (notificationId: string): void => {
if (!userPubkey) { if (!userPubkey) {
return return
} }
void markAsReadAndRefresh({ notificationId, setNotifications })
void (async (): Promise<void> => {
try {
await notificationService.markAsRead(notificationId)
const storedNotifications = await notificationService.getAllNotifications(100)
setNotifications(storedNotifications)
} catch (error) {
console.error('[useNotifications] Error marking notification as read:', error)
}
})()
}, },
[userPubkey] [userPubkey]
) )
@ -70,16 +46,7 @@ export function useNotifications(userPubkey: string | null): {
if (!userPubkey) { if (!userPubkey) {
return return
} }
void markAllAsReadAndRefresh({ setNotifications })
void (async (): Promise<void> => {
try {
await notificationService.markAllAsRead()
const storedNotifications = await notificationService.getAllNotifications(100)
setNotifications(storedNotifications)
} catch (error) {
console.error('[useNotifications] Error marking all as read:', error)
}
})()
}, [userPubkey]) }, [userPubkey])
const deleteNotificationHandler = useCallback( const deleteNotificationHandler = useCallback(
@ -87,26 +54,70 @@ export function useNotifications(userPubkey: string | null): {
if (!userPubkey) { if (!userPubkey) {
return return
} }
void deleteNotificationAndRefresh({ notificationId, setNotifications })
void (async (): Promise<void> => {
try {
await notificationService.deleteNotification(notificationId)
const storedNotifications = await notificationService.getAllNotifications(100)
setNotifications(storedNotifications)
} catch (error) {
console.error('[useNotifications] Error deleting notification:', error)
}
})()
}, },
[userPubkey] [userPubkey]
) )
return { return {
notifications, notifications: effectiveNotifications,
unreadCount, unreadCount,
loading, loading: effectiveLoading,
markAsRead, markAsRead,
markAllAsRead: markAllAsReadHandler, markAllAsRead: markAllAsReadHandler,
deleteNotification: deleteNotificationHandler, deleteNotification: deleteNotificationHandler,
} }
} }
async function loadAndSetNotifications(params: {
setNotifications: (value: Notification[]) => void
setLoading: (value: boolean) => void
}): Promise<void> {
try {
params.setLoading(true)
params.setNotifications(await notificationService.getAllNotifications(MAX_NOTIFICATIONS))
} catch (error) {
console.error('[useNotifications] Error loading notifications:', error)
} finally {
params.setLoading(false)
}
}
async function refreshNotifications(params: { setNotifications: (value: Notification[]) => void }): Promise<void> {
params.setNotifications(await notificationService.getAllNotifications(MAX_NOTIFICATIONS))
}
async function markAsReadAndRefresh(params: {
notificationId: string
setNotifications: (value: Notification[]) => void
}): Promise<void> {
try {
await notificationService.markAsRead(params.notificationId)
await refreshNotifications({ setNotifications: params.setNotifications })
} catch (error) {
console.error('[useNotifications] Error marking notification as read:', error)
}
}
async function markAllAsReadAndRefresh(params: {
setNotifications: (value: Notification[]) => void
}): Promise<void> {
try {
await notificationService.markAllAsRead()
await refreshNotifications({ setNotifications: params.setNotifications })
} catch (error) {
console.error('[useNotifications] Error marking all as read:', error)
}
}
async function deleteNotificationAndRefresh(params: {
notificationId: string
setNotifications: (value: Notification[]) => void
}): Promise<void> {
try {
await notificationService.deleteNotification(params.notificationId)
await refreshNotifications({ setNotifications: params.setNotifications })
} catch (error) {
console.error('[useNotifications] Error deleting notification:', error)
}
}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import { nostrService } from '@/lib/nostr' import { nostrService } from '@/lib/nostr'
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { applyFiltersAndSort } from '@/lib/articleFiltering' import { applyFiltersAndSort } from '@/lib/articleFiltering'
@ -24,86 +24,30 @@ export function useUserArticles(
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const hasArticlesRef = useRef(false) const hasArticlesRef = useRef(false)
useEffect(() => { useLoadUserArticlesFromCache({
if (!userPubkey) { userPubkey,
setLoading(false) setArticles,
return setLoading,
} setError,
hasArticlesRef,
setLoading(true) })
setError(null)
// Read only from IndexedDB cache - no network subscription
void (async (): Promise<void> => {
try {
const allPublications = await objectCache.getAll('publication')
const allAuthors = await objectCache.getAll('author')
const allArticles = [...allPublications, ...allAuthors] as Article[]
// Filter by user pubkey
const userArticles = allArticles.filter((article) => article.pubkey === userPubkey)
// Sort by creation date descending
const sortedArticles = userArticles.sort((a, b) => b.createdAt - a.createdAt)
setArticles(sortedArticles)
hasArticlesRef.current = sortedArticles.length > 0
if (sortedArticles.length === 0) {
setError('Aucun contenu trouvé')
}
} catch (loadError) {
console.error('Error loading user articles from cache:', loadError)
setError('Erreur lors du chargement des articles')
} finally {
setLoading(false)
}
})()
return () => {
// No cleanup needed - no network subscription
}
}, [userPubkey])
// Apply filters and sorting // Apply filters and sorting
const filteredArticles = useMemo(() => { const filteredArticles = useMemo(() => {
const effectiveFilters = const effectiveFilters = buildDefaultFilters(filters)
filters ??
({
authorPubkey: null,
sortBy: 'newest',
category: 'all',
} as const)
if (!filters && !searchQuery.trim()) { if (!filters && !searchQuery.trim()) {
return articles return articles
} }
return applyFiltersAndSort(articles, searchQuery, effectiveFilters) return applyFiltersAndSort(articles, searchQuery, effectiveFilters)
}, [articles, searchQuery, filters]) }, [articles, searchQuery, filters])
const loadArticleContent = async (articleId: string, authorPubkey: string): Promise<Article | null> => { const loadArticleContent = (articleId: string, authorPubkey: string): Promise<Article | null> =>
try { loadAndDecryptUserArticle({
const article = await nostrService.getArticleById(articleId) articleId,
if (article) { authorPubkey,
// Try to decrypt article content using decryption key from private messages setArticles,
const decryptedContent = await nostrService.getDecryptedArticleContent(articleId, authorPubkey) setError,
if (decryptedContent) { })
setArticles((prev) =>
prev.map((a) =>
(a.id === articleId
? { ...a, content: decryptedContent, paid: true }
: a)
)
)
}
return article
}
} catch (e) {
console.error('Error loading article content:', e)
setError(e instanceof Error ? e.message : 'Failed to load article')
}
return null
}
return { return {
articles: filteredArticles, articles: filteredArticles,
@ -113,3 +57,86 @@ export function useUserArticles(
loadArticleContent, loadArticleContent,
} }
} }
function useLoadUserArticlesFromCache(params: {
userPubkey: string
setArticles: (value: Article[]) => void
setLoading: (value: boolean) => void
setError: (value: string | null) => void
hasArticlesRef: MutableRefObject<boolean>
}): void {
const { userPubkey, setArticles, setLoading, setError, hasArticlesRef } = params
useEffect(() => {
if (!userPubkey) {
setLoading(false)
return
}
void loadUserArticlesFromCache({ userPubkey, setArticles, setLoading, setError, hasArticlesRef })
}, [userPubkey, setArticles, setLoading, setError, hasArticlesRef])
}
async function loadUserArticlesFromCache(params: {
userPubkey: string
setArticles: (value: Article[]) => void
setLoading: (value: boolean) => void
setError: (value: string | null) => void
hasArticlesRef: MutableRefObject<boolean>
}): Promise<void> {
const {hasArticlesRef} = params
try {
params.setLoading(true)
params.setError(null)
const all = (await readArticlesFromCache()).filter((a) => a.pubkey === params.userPubkey)
const sorted = all.sort((a, b) => b.createdAt - a.createdAt)
params.setArticles(sorted)
hasArticlesRef.current = sorted.length > 0
if (sorted.length === 0) {
params.setError('Aucun contenu trouvé')
}
} catch (e) {
console.error('Error loading user articles from cache:', e)
params.setError('Erreur lors du chargement des articles')
} finally {
params.setLoading(false)
}
}
async function readArticlesFromCache(): Promise<Article[]> {
const [publications, authors] = await Promise.all([
objectCache.getAll('publication'),
objectCache.getAll('author'),
])
return [...publications, ...authors] as Article[]
}
function buildDefaultFilters(filters: ArticleFilters | null): ArticleFilters {
if (filters) {
return filters
}
return { authorPubkey: null, sortBy: 'newest', category: 'all' }
}
async function loadAndDecryptUserArticle(params: {
articleId: string
authorPubkey: string
setArticles: Dispatch<SetStateAction<Article[]>>
setError: (value: string | null) => void
}): Promise<Article | null> {
try {
const article = await nostrService.getArticleById(params.articleId)
if (!article) {
return null
}
const decrypted = await nostrService.getDecryptedArticleContent(params.articleId, params.authorPubkey)
if (decrypted) {
params.setArticles((prev) =>
prev.map((a) => (a.id === params.articleId ? { ...a, content: decrypted, paid: true } : a))
)
}
return article
} catch (e) {
console.error('Error loading article content:', e)
params.setError(e instanceof Error ? e.message : 'Failed to load article')
return null
}
}

View File

@ -0,0 +1,70 @@
import type { ArticleDraft } from './articlePublisherTypes'
import type { AlbyInvoice } from '@/types/alby'
import type { Article } from '@/types/nostr'
import { generatePublicationHashId } from './hashIdGenerator'
import { buildObjectId } from './urlGenerator'
export async function buildParsedArticleFromDraft(params: {
draft: ArticleDraft
invoice: AlbyInvoice
authorPubkey: string
}): Promise<{ article: Article; hash: string; version: number; index: number }> {
const categoryTag = mapDraftCategoryToTag(params.draft.category)
const hashId = await generatePublicationHashId(buildPublicationHashInput({ draft: params.draft, authorPubkey: params.authorPubkey, categoryTag }))
const hash = hashId
const version = 0
const index = 0
const id = buildObjectId(hash, index, version)
const article: Article = {
id,
hash,
version,
index,
pubkey: params.authorPubkey,
title: params.draft.title,
preview: params.draft.preview,
content: '',
description: params.draft.preview,
contentDescription: params.draft.preview,
createdAt: Math.floor(Date.now() / 1000),
zapAmount: params.draft.zapAmount,
paid: false,
thumbnailUrl: params.draft.bannerUrl ?? '',
invoice: params.invoice.invoice,
...(params.invoice.paymentHash ? { paymentHash: params.invoice.paymentHash } : {}),
...(params.draft.category ? { category: params.draft.category } : {}),
...(params.draft.seriesId ? { seriesId: params.draft.seriesId } : {}),
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
...(params.draft.pages && params.draft.pages.length > 0 ? { pages: params.draft.pages } : {}),
kindType: 'article',
}
return { article, hash, version, index }
}
export function mapDraftCategoryToTag(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' {
if (category === 'scientific-research') {
return 'research'
}
return 'sciencefiction'
}
function buildPublicationHashInput(params: {
draft: ArticleDraft
authorPubkey: string
categoryTag: 'sciencefiction' | 'research'
}): Parameters<typeof generatePublicationHashId>[0] {
return {
pubkey: params.authorPubkey,
title: params.draft.title,
preview: params.draft.preview,
category: params.categoryTag,
...(params.draft.seriesId ? { seriesId: params.draft.seriesId } : {}),
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
zapAmount: params.draft.zapAmount,
}
}

View File

@ -88,27 +88,57 @@ async function buildPreviewTags(
} }
): Promise<string[][]> { ): Promise<string[][]> {
const category = normalizePublicationCategory(params.draft.category) const category = normalizePublicationCategory(params.draft.category)
const hashId = await generatePublicationHashId(
buildPublicationHashParams({ draft: params.draft, authorPubkey: params.authorPubkey, category })
)
// Generate hash ID from publication data const tags = buildPublicationTags({
const hashId = await generatePublicationHashId({ draft: params.draft,
invoice: params.invoice,
authorPubkey: params.authorPubkey,
category,
hashId,
encryptedKey: params.encryptedKey,
})
tags.push(['json', buildPublicationJson({ draft: params.draft, invoice: params.invoice, authorPubkey: params.authorPubkey, category, hashId })])
if (params.extraTags && params.extraTags.length > 0) {
tags.push(...params.extraTags)
}
return tags
}
function buildPublicationHashParams(params: {
draft: ArticleDraft
authorPubkey: string
category: 'sciencefiction' | 'research'
}): Parameters<typeof generatePublicationHashId>[0] {
return {
pubkey: params.authorPubkey, pubkey: params.authorPubkey,
title: params.draft.title, title: params.draft.title,
preview: params.draft.preview, preview: params.draft.preview,
category, category: params.category,
seriesId: params.draft.seriesId ?? undefined,
bannerUrl: params.draft.bannerUrl ?? undefined,
zapAmount: params.draft.zapAmount, zapAmount: params.draft.zapAmount,
}) ...(params.draft.seriesId ? { seriesId: params.draft.seriesId } : {}),
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
}
}
// Build tags using new system function buildPublicationTags(params: {
const newTags = buildTags({ draft: ArticleDraft
invoice: AlbyInvoice
authorPubkey: string
category: 'sciencefiction' | 'research'
hashId: string
encryptedKey: string | undefined
}): string[][] {
return buildTags({
type: 'publication', type: 'publication',
category, category: params.category,
id: hashId, id: params.hashId,
service: PLATFORM_SERVICE, service: PLATFORM_SERVICE,
version: 0, // New object version: 0,
hidden: false, hidden: false,
paywall: true, // Publications are paid paywall: true,
title: params.draft.title, title: params.draft.title,
preview: params.draft.preview, preview: params.draft.preview,
zapAmount: params.draft.zapAmount, zapAmount: params.draft.zapAmount,
@ -118,34 +148,31 @@ async function buildPreviewTags(
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}), ...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
...(params.encryptedKey ? { encryptedKey: params.encryptedKey } : {}), ...(params.encryptedKey ? { encryptedKey: params.encryptedKey } : {}),
}) })
}
// Build JSON metadata function buildPublicationJson(params: {
const publicationJson = JSON.stringify({ draft: ArticleDraft
invoice: AlbyInvoice
authorPubkey: string
category: 'sciencefiction' | 'research'
hashId: string
}): string {
return JSON.stringify({
type: 'publication', type: 'publication',
pubkey: params.authorPubkey, pubkey: params.authorPubkey,
title: params.draft.title, title: params.draft.title,
preview: params.draft.preview, preview: params.draft.preview,
category, category: params.category,
seriesId: params.draft.seriesId, seriesId: params.draft.seriesId,
bannerUrl: params.draft.bannerUrl, bannerUrl: params.draft.bannerUrl,
zapAmount: params.draft.zapAmount, zapAmount: params.draft.zapAmount,
invoice: params.invoice.invoice, invoice: params.invoice.invoice,
paymentHash: params.invoice.paymentHash, paymentHash: params.invoice.paymentHash,
id: hashId, id: params.hashId,
version: 0, version: 0,
index: 0, index: 0,
...(params.draft.pages && params.draft.pages.length > 0 ? { pages: params.draft.pages } : {}), ...(params.draft.pages && params.draft.pages.length > 0 ? { pages: params.draft.pages } : {}),
}) })
// Add JSON metadata as a tag
newTags.push(['json', publicationJson])
// Add any extra tags (for backward compatibility)
if (params.extraTags && params.extraTags.length > 0) {
newTags.push(...params.extraTags)
}
return newTags
} }
function normalizePublicationCategory(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' { function normalizePublicationCategory(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' {

View File

@ -45,14 +45,7 @@ async function buildParsedArticleFromDraft(
invoice: AlbyInvoice, invoice: AlbyInvoice,
authorPubkey: string authorPubkey: string
): Promise<{ article: Article; hash: string; version: number; index: number }> { ): Promise<{ article: Article; hash: string; version: number; index: number }> {
let category: string const category = mapDraftCategoryToTag(draft.category)
if (draft.category === 'science-fiction') {
category = 'sciencefiction'
} else if (draft.category === 'scientific-research') {
category = 'research'
} else {
category = 'sciencefiction'
}
const hashId = await generatePublicationHashId({ const hashId = await generatePublicationHashId({
pubkey: authorPubkey, pubkey: authorPubkey,
@ -96,6 +89,13 @@ async function buildParsedArticleFromDraft(
return { article, hash, version, index } return { article, hash, version, index }
} }
function mapDraftCategoryToTag(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' {
if (category === 'scientific-research') {
return 'research'
}
return 'sciencefiction'
}
interface PublishPreviewWithInvoiceParams { interface PublishPreviewWithInvoiceParams {
draft: ArticleDraft draft: ArticleDraft
invoice: AlbyInvoice invoice: AlbyInvoice
@ -614,81 +614,111 @@ function updateFailure(originalArticleId: string, error?: string): ArticleUpdate
export async function deleteArticleEvent(articleId: string, authorPubkey: string, authorPrivateKey?: string): Promise<void> { export async function deleteArticleEvent(articleId: string, authorPubkey: string, authorPrivateKey?: string): Promise<void> {
ensureKeys(authorPubkey, authorPrivateKey) ensureKeys(authorPubkey, authorPrivateKey)
// Get original event from IndexedDB cache 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 getActiveRelaysOrPrimary()
await publishDeletion({ event, relays, payload: deletePayload })
}
async function getOriginalPublicationEventOrThrow(articleId: string): Promise<import('nostr-tools').Event> {
const { objectCache } = await import('./objectCache') const { objectCache } = await import('./objectCache')
const originalEvent = await objectCache.getEventById('publication', articleId) const originalEvent = await objectCache.getEventById('publication', articleId)
if (!originalEvent) { if (!originalEvent) {
throw new Error('Article not found in cache') throw new Error('Article not found in cache')
} }
return originalEvent
}
// Verify user is the author function assertAuthorOwnsEvent(params: { eventPubkey: string; authorPubkey: string }): void {
if (originalEvent.pubkey !== authorPubkey) { if (params.eventPubkey !== params.authorPubkey) {
throw new Error('Only the author can delete this article') throw new Error('Only the author can delete this article')
} }
}
// Build delete event (new version with hidden=true) async function buildDeleteEventTemplateOrThrow(params: {
originalEvent: import('nostr-tools').Event
authorPubkey: string
}): Promise<import('nostr-tools').EventTemplate> {
const { buildDeleteEvent } = await import('./objectModification') const { buildDeleteEvent } = await import('./objectModification')
const deleteEventTemplate = await buildDeleteEvent(originalEvent, authorPubkey) const template = await buildDeleteEvent(params.originalEvent, params.authorPubkey)
if (!template) {
if (!deleteEventTemplate) {
throw new Error('Failed to build delete event') throw new Error('Failed to build delete event')
} }
return template
}
// Parse the original article to get hash/version/index async function parseOriginalArticleOrThrow(originalEvent: import('nostr-tools').Event): Promise<Article> {
const { parseArticleFromEvent } = await import('./nostrEventParsing') const { parseArticleFromEvent } = await import('./nostrEventParsing')
const originalParsed = await parseArticleFromEvent(originalEvent) const parsed = await parseArticleFromEvent(originalEvent)
if (!originalParsed) { if (!parsed) {
throw new Error('Failed to parse original article') throw new Error('Failed to parse original article')
} }
return parsed
}
// Increment version for deletion 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 { extractTagsFromEvent } = await import('./nostrTagSystem')
const tags = extractTagsFromEvent(deleteEventTemplate) const tags = extractTagsFromEvent(params.deleteEventTemplate)
const newVersion = tags.version ?? originalParsed.version + 1 const version = tags.version ?? params.originalParsed.version + 1
const {hash} = originalParsed const index = params.originalParsed.index ?? 0
const index = originalParsed.index ?? 0 const parsed: Article = { ...params.originalParsed, version }
return { hash: params.originalParsed.hash, index, version, parsed }
}
// Build updated parsed Article object with hidden flag async function finalizeEventTemplate(params: {
const deletedArticle: Article = { template: import('nostr-tools').EventTemplate
...originalParsed, authorPrivateKey: string | undefined
version: newVersion, }): Promise<import('nostr-tools').Event> {
} const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey()
// Set private key in orchestrator
const privateKey = authorPrivateKey ?? nostrService.getPrivateKey()
if (!privateKey) { if (!privateKey) {
throw new Error('Private key required for signing') throw new Error('Private key required for signing')
} }
const { writeOrchestrator: writeOrchestratorInstance } = await import('./writeOrchestrator') const { writeOrchestrator: writeOrchestratorInstance } = await import('./writeOrchestrator')
writeOrchestratorInstance.setPrivateKey(privateKey) writeOrchestratorInstance.setPrivateKey(privateKey)
// Finalize event
const { finalizeEvent: finalizeNostrEvent } = await import('nostr-tools') const { finalizeEvent: finalizeNostrEvent } = await import('nostr-tools')
const { hexToBytes: hexToBytesUtil } = await import('nostr-tools/utils') const { hexToBytes: hexToBytesUtil } = await import('nostr-tools/utils')
const secretKey = hexToBytesUtil(privateKey) const secretKey = hexToBytesUtil(privateKey)
const event = finalizeNostrEvent(deleteEventTemplate, secretKey) return finalizeNostrEvent(params.template, secretKey)
}
// Get active relays async function getActiveRelaysOrPrimary(): Promise<string[]> {
const { relaySessionManager } = await import('./relaySessionManager') const { relaySessionManager } = await import('./relaySessionManager')
const activeRelays = await relaySessionManager.getActiveRelays() const activeRelays = await relaySessionManager.getActiveRelays()
if (activeRelays.length > 0) {
return activeRelays
}
const { getPrimaryRelay } = await import('./config') const { getPrimaryRelay } = await import('./config')
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()] return [await getPrimaryRelay()]
}
// Publish via writeOrchestrator (parallel network + local write) 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( const result = await writeOrchestratorInstance.writeAndPublish(
{ {
objectType: 'publication', objectType: 'publication',
hash, hash: params.payload.hash,
event, event: params.event,
parsed: deletedArticle, parsed: params.payload.parsed,
version: newVersion, version: params.payload.version,
hidden: true, // Mark as hidden (deleted) hidden: true,
index, index: params.payload.index,
}, },
relays params.relays
) )
if (!result.success) { if (!result.success) {
throw new Error('Failed to publish delete event') throw new Error('Failed to publish delete event')
} }

View File

@ -10,6 +10,7 @@ import { finalizeEvent } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils' import { hexToBytes } from 'nostr-tools/utils'
import { generateAuthorHashId } from './hashIdGenerator' import { generateAuthorHashId } from './hashIdGenerator'
import { buildObjectId } from './urlGenerator' import { buildObjectId } from './urlGenerator'
import { extractAuthorNameFromTitle, parseAuthorPresentationDraft } from './authorPresentationParsing'
export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes' export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
@ -179,108 +180,7 @@ export class ArticlePublisher {
authorPrivateKey: string authorPrivateKey: string
): Promise<PublishedArticle> { ): Promise<PublishedArticle> {
try { try {
nostrService.setPublicKey(authorPubkey) return publishPresentationArticleCore({ draft, authorPubkey, authorPrivateKey })
nostrService.setPrivateKey(authorPrivateKey)
// Extract author name from title (format: "Présentation de <name>")
const authorName = draft.title.replace(/^Présentation de /, '').trim() ?? 'Auteur'
// Extract presentation and contentDescription from draft.content
const separator = '\n\n---\n\nDescription du contenu :\n'
const separatorIndex = draft.content.indexOf(separator)
const presentation = separatorIndex !== -1 ? draft.content.substring(0, separatorIndex) : draft.presentation
let contentDescription = separatorIndex !== -1 ? draft.content.substring(separatorIndex + separator.length) : draft.contentDescription
// Remove Bitcoin address from contentDescription if present
if (contentDescription) {
contentDescription = contentDescription
.split('\n')
.filter((line) => !line.includes('Adresse Bitcoin mainnet (pour le sponsoring)'))
.join('\n')
.trim()
}
const category = 'sciencefiction'
const version = 0
const index = 0
// Generate hash ID
const hashId = await generateAuthorHashId({
pubkey: authorPubkey,
authorName,
presentation,
contentDescription,
mainnetAddress: draft.mainnetAddress ?? undefined,
pictureUrl: draft.pictureUrl ?? undefined,
category,
})
const hash = hashId
const id = buildObjectId(hash, index, version)
// Build parsed AuthorPresentationArticle object
const parsedAuthor: import('@/types/nostr').AuthorPresentationArticle = {
id,
hash,
version,
index,
pubkey: authorPubkey,
title: draft.title,
preview: draft.preview,
content: draft.content,
description: presentation,
contentDescription,
thumbnailUrl: draft.pictureUrl ?? '',
createdAt: Math.floor(Date.now() / 1000),
zapAmount: 0,
paid: true,
category: 'author-presentation',
isPresentation: true,
mainnetAddress: draft.mainnetAddress ?? '',
totalSponsoring: 0,
originalCategory: 'science-fiction',
...(draft.pictureUrl ? { bannerUrl: draft.pictureUrl } : {}),
}
// Build event template
const eventTemplate = await buildPresentationEvent({ draft, authorPubkey, authorName, category, version, index })
// Set private key in orchestrator
writeOrchestrator.setPrivateKey(authorPrivateKey)
// Finalize event
const secretKey = hexToBytes(authorPrivateKey)
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: 'author',
hash,
event,
parsed: parsedAuthor,
version,
hidden: false,
index,
},
relays
)
if (!result.success) {
return buildFailure('Failed to publish presentation article')
}
return {
articleId: event.id,
previewEventId: event.id,
success: true,
}
} catch (error) { } catch (error) {
console.error('Error publishing presentation article:', error) console.error('Error publishing presentation article:', error)
return buildFailure(error instanceof Error ? error.message : 'Unknown error') return buildFailure(error instanceof Error ? error.message : 'Unknown error')
@ -309,3 +209,120 @@ export class ArticlePublisher {
} }
export const articlePublisher = new ArticlePublisher() export const articlePublisher = new ArticlePublisher()
async function publishPresentationArticleCore(params: {
draft: AuthorPresentationDraft
authorPubkey: string
authorPrivateKey: string
}): Promise<PublishedArticle> {
nostrService.setPublicKey(params.authorPubkey)
nostrService.setPrivateKey(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)
const secretKey = hexToBytes(params.authorPrivateKey)
const event = finalizeEvent(eventTemplate, secretKey)
const relays = await getActiveRelaysOrPrimary()
const result = await writeOrchestrator.writeAndPublish(
{
objectType: 'author',
hash,
event,
parsed: parsedAuthor,
version,
hidden: false,
index,
},
relays
)
if (!result.success) {
return buildFailure('Failed to publish presentation article')
}
return { articleId: event.id, previewEventId: event.id, success: true }
}
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 } : {}),
}
}
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

@ -10,6 +10,7 @@ import { generateAuthorHashId } from './hashIdGenerator'
import { generateObjectUrl, buildObjectId, parseObjectId } from './urlGenerator' import { generateObjectUrl, buildObjectId, parseObjectId } from './urlGenerator'
import { getLatestVersion } from './versionManager' import { getLatestVersion } from './versionManager'
import { objectCache } from './objectCache' import { objectCache } from './objectCache'
import { parseAuthorPresentationDraft } from './authorPresentationParsing'
interface BuildPresentationEventParams { interface BuildPresentationEventParams {
draft: AuthorPresentationDraft draft: AuthorPresentationDraft
@ -31,22 +32,8 @@ export async function buildPresentationEvent(
const category = params.category ?? 'sciencefiction' const category = params.category ?? 'sciencefiction'
const version = params.version ?? 0 const version = params.version ?? 0
const index = params.index ?? 0 const index = params.index ?? 0
// Extract presentation and contentDescription from draft.content
// Format: "${presentation}\n\n---\n\nDescription du contenu :\n${contentDescription}"
const separator = '\n\n---\n\nDescription du contenu :\n'
const separatorIndex = params.draft.content.indexOf(separator)
const presentation = separatorIndex !== -1 ? params.draft.content.substring(0, separatorIndex) : params.draft.presentation
let contentDescription = separatorIndex !== -1 ? params.draft.content.substring(separatorIndex + separator.length) : params.draft.contentDescription
// Remove Bitcoin address from contentDescription if present (should not be visible in note content) const { presentation, contentDescription } = parseAuthorPresentationDraft(params.draft)
// Remove lines matching "Adresse Bitcoin mainnet (pour le sponsoring) : ..."
if (contentDescription) {
contentDescription = contentDescription
.split('\n')
.filter((line) => !line.includes('Adresse Bitcoin mainnet (pour le sponsoring)'))
.join('\n')
.trim()
}
// Generate hash ID from author data first (needed for URL) // Generate hash ID from author data first (needed for URL)
const hashId = await generateAuthorHashId({ const hashId = await generateAuthorHashId({
@ -65,13 +52,11 @@ export async function buildPresentationEvent(
// Encode pubkey to npub (for metadata JSON) // Encode pubkey to npub (for metadata JSON)
const npub = nip19.npubEncode(params.authorPubkey) const npub = nip19.npubEncode(params.authorPubkey)
// Build visible content message const linkWithPreview = buildProfileLink({
// If picture exists, use it as preview image for the link (markdown format) profileUrl,
// Note: The image will display at full size in most Nostr clients, not as a thumbnail authorName: params.authorName,
const {draft} = params pictureUrl: params.draft.pictureUrl,
const linkWithPreview = draft.pictureUrl })
? `[![${params.authorName}](${draft.pictureUrl})](${profileUrl})`
: profileUrl
const visibleContent = [ const visibleContent = [
'Nouveau profil auteur publié sur zapwall.fr (plateforme de publications scientifiques)', 'Nouveau profil auteur publié sur zapwall.fr (plateforme de publications scientifiques)',
@ -87,8 +72,8 @@ export async function buildPresentationEvent(
pubkey: params.authorPubkey, pubkey: params.authorPubkey,
presentation, presentation,
contentDescription, contentDescription,
mainnetAddress: draft.mainnetAddress, mainnetAddress: params.draft.mainnetAddress,
pictureUrl: draft.pictureUrl, pictureUrl: params.draft.pictureUrl,
category, category,
url: profileUrl, url: profileUrl,
version, version,
@ -104,10 +89,10 @@ export async function buildPresentationEvent(
version, version,
hidden: false, hidden: false,
paywall: false, paywall: false,
title: draft.title, title: params.draft.title,
preview: draft.preview, preview: params.draft.preview,
mainnetAddress: draft.mainnetAddress, mainnetAddress: params.draft.mainnetAddress,
...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}), ...(params.draft.pictureUrl ? { pictureUrl: params.draft.pictureUrl } : {}),
}) })
// Add JSON metadata as a tag (not in visible content) // Add JSON metadata as a tag (not in visible content)
@ -121,6 +106,13 @@ export async function buildPresentationEvent(
} }
} }
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> { export async function parsePresentationEvent(event: Event): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
const tags = extractTagsFromEvent(event) const tags = extractTagsFromEvent(event)
@ -383,41 +375,36 @@ function parsePresentationProfileJson(json: string): {
} }
const obj = parsed as Record<string, unknown> const obj = parsed as Record<string, unknown>
const result: { return {
authorName?: string ...readOptionalStringFields(obj, [
presentation?: string 'authorName',
contentDescription?: string 'presentation',
mainnetAddress?: string 'contentDescription',
pictureUrl?: string 'mainnetAddress',
category?: string 'pictureUrl',
} = {} 'category',
]),
if (typeof obj.authorName === 'string') {
result.authorName = obj.authorName
} }
if (typeof obj.presentation === 'string') {
result.presentation = obj.presentation
}
if (typeof obj.contentDescription === 'string') {
result.contentDescription = obj.contentDescription
}
if (typeof obj.mainnetAddress === 'string') {
result.mainnetAddress = obj.mainnetAddress
}
if (typeof obj.pictureUrl === 'string') {
result.pictureUrl = obj.pictureUrl
}
if (typeof obj.category === 'string') {
result.category = obj.category
}
return result
} catch (error) { } catch (error) {
console.error('Error parsing presentation profile JSON:', error) console.error('Error parsing presentation profile JSON:', error)
return null 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) {
const value = obj[key]
if (typeof value === 'string') {
result[key] = value
}
}
return result
}
export async function fetchAuthorPresentationFromPool( export async function fetchAuthorPresentationFromPool(
pool: SimplePoolWithSub, pool: SimplePoolWithSub,
pubkey: string pubkey: string

View File

@ -26,14 +26,7 @@ async function buildParsedArticleFromDraft(
invoice: AlbyInvoice, invoice: AlbyInvoice,
authorPubkey: string authorPubkey: string
): Promise<{ article: Article; hash: string; version: number; index: number }> { ): Promise<{ article: Article; hash: string; version: number; index: number }> {
let category: string const category = mapDraftCategoryToTag(draft.category)
if (draft.category === 'science-fiction') {
category = 'sciencefiction'
} else if (draft.category === 'scientific-research') {
category = 'research'
} else {
category = 'sciencefiction'
}
const hashId = await generatePublicationHashId({ const hashId = await generatePublicationHashId({
pubkey: authorPubkey, pubkey: authorPubkey,
@ -77,6 +70,13 @@ async function buildParsedArticleFromDraft(
return { article, hash, version, index } return { article, hash, version, index }
} }
function mapDraftCategoryToTag(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' {
if (category === 'scientific-research') {
return 'research'
}
return 'sciencefiction'
}
interface PublishPreviewParams { interface PublishPreviewParams {
draft: ArticleDraft draft: ArticleDraft
invoice: AlbyInvoice invoice: AlbyInvoice

View File

@ -0,0 +1,32 @@
import type { AuthorPresentationDraft } from './articlePublisher'
const CONTENT_SEPARATOR = '\n\n---\n\nDescription du contenu :\n'
const MAINNET_ADDRESS_LABEL = 'Adresse Bitcoin mainnet (pour le sponsoring)'
export function extractAuthorNameFromTitle(title: string): string {
const result = title.replace(/^Présentation de /, '').trim()
return result.length > 0 ? result : 'Auteur'
}
export function parseAuthorPresentationDraft(draft: AuthorPresentationDraft): {
presentation: string
contentDescription: string
} {
const separatorIndex = draft.content.indexOf(CONTENT_SEPARATOR)
const presentation = separatorIndex !== -1 ? draft.content.substring(0, separatorIndex) : draft.presentation
const rawContentDescription =
separatorIndex !== -1 ? draft.content.substring(separatorIndex + CONTENT_SEPARATOR.length) : draft.contentDescription
return {
presentation,
contentDescription: stripMainnetAddressLine(rawContentDescription),
}
}
function stripMainnetAddressLine(value: string): string {
return value
.split('\n')
.filter((line) => !line.includes(MAINNET_ADDRESS_LABEL))
.join('\n')
.trim()
}

View File

@ -57,48 +57,17 @@ export class AutomaticTransferService {
articlePubkey: string, articlePubkey: string,
paymentAmount: number paymentAmount: number
): Promise<TransferResult> { ): Promise<TransferResult> {
try { return this.transferPortion({
const split = calculateArticleSplit(paymentAmount)
if (!authorLightningAddress) {
return {
success: false,
error: 'Author Lightning address not available',
amount: split.author,
recipient: authorLightningAddress,
}
}
this.logTransferRequired({
type: 'article', type: 'article',
id: articleId, id: articleId,
pubkey: articlePubkey, pubkey: articlePubkey,
amount: split.author,
recipient: authorLightningAddress, recipient: authorLightningAddress,
platformCommission: split.platform, paymentAmount,
computeSplit: calculateArticleSplit,
getRecipientAmount: (split) => split.author,
missingRecipientError: 'Author Lightning address not available',
errorLogMessage: 'Error transferring author portion',
}) })
this.trackTransferRequirement({
type: 'article',
id: articleId,
recipientPubkey: articlePubkey,
amount: split.author,
recipientAddress: authorLightningAddress,
})
return {
success: true,
amount: split.author,
recipient: authorLightningAddress,
}
} catch (error) {
console.error('Error transferring author portion', {
articleId,
articlePubkey,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
})
return this.buildTransferError(error, authorLightningAddress)
}
} }
/** /**
@ -110,48 +79,94 @@ export class AutomaticTransferService {
reviewerPubkey: string, reviewerPubkey: string,
paymentAmount: number paymentAmount: number
): Promise<TransferResult> { ): Promise<TransferResult> {
try { return this.transferPortion({
const split = calculateReviewSplit(paymentAmount)
if (!reviewerLightningAddress) {
return {
success: false,
error: 'Reviewer Lightning address not available',
amount: split.reviewer,
recipient: reviewerLightningAddress,
}
}
this.logTransferRequired({
type: 'review', type: 'review',
id: reviewId, id: reviewId,
pubkey: reviewerPubkey, pubkey: reviewerPubkey,
amount: split.reviewer,
recipient: reviewerLightningAddress, recipient: reviewerLightningAddress,
paymentAmount,
computeSplit: calculateReviewSplit,
getRecipientAmount: (split) => split.reviewer,
missingRecipientError: 'Reviewer Lightning address not available',
errorLogMessage: 'Error transferring reviewer portion',
})
}
private async transferPortion(params: {
type: 'article' | 'review'
id: string
pubkey: string
recipient: string
paymentAmount: number
computeSplit: (amount: number) => { platform: number } & Record<string, number>
getRecipientAmount: (split: { platform: number } & Record<string, number>) => number
missingRecipientError: string
errorLogMessage: string
}): Promise<TransferResult> {
try {
const split = params.computeSplit(params.paymentAmount)
const recipientAmount = params.getRecipientAmount(split)
if (!params.recipient) {
return this.buildMissingRecipientResult({
error: params.missingRecipientError,
recipient: params.recipient,
amount: recipientAmount,
})
}
this.logAndTrackTransferRequirement({
type: params.type,
id: params.id,
pubkey: params.pubkey,
recipient: params.recipient,
amount: recipientAmount,
platformCommission: split.platform, platformCommission: split.platform,
}) })
this.trackTransferRequirement({
type: 'review',
id: reviewId,
recipientPubkey: reviewerPubkey,
amount: split.reviewer,
recipientAddress: reviewerLightningAddress,
})
return { return { success: true, amount: recipientAmount, recipient: params.recipient }
success: true,
amount: split.reviewer,
recipient: reviewerLightningAddress,
}
} catch (error) { } catch (error) {
console.error('Error transferring reviewer portion', { this.logTransferError({ message: params.errorLogMessage, id: params.id, pubkey: params.pubkey, error })
reviewId, return this.buildTransferError(error, params.recipient)
reviewerPubkey, }
error: error instanceof Error ? error.message : 'Unknown error', }
private buildMissingRecipientResult(params: { error: string; recipient: string; amount: number }): TransferResult {
return { success: false, error: params.error, amount: params.amount, recipient: params.recipient }
}
private logAndTrackTransferRequirement(params: {
type: 'article' | 'review'
id: string
pubkey: string
recipient: string
amount: number
platformCommission: number
}): void {
this.logTransferRequired({
type: params.type,
id: params.id,
pubkey: params.pubkey,
amount: params.amount,
recipient: params.recipient,
platformCommission: params.platformCommission,
})
this.trackTransferRequirement({
type: params.type,
id: params.id,
recipientPubkey: params.pubkey,
amount: params.amount,
recipientAddress: params.recipient,
})
}
private logTransferError(params: { message: string; id: string; pubkey: string; error: unknown }): void {
console.error(params.message, {
id: params.id,
pubkey: params.pubkey,
error: params.error instanceof Error ? params.error.message : 'Unknown error',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}) })
return this.buildTransferError(error, reviewerLightningAddress)
}
} }
/** /**

View File

@ -85,52 +85,57 @@ export class ConfigStorage {
* Get all configuration from IndexedDB or return defaults * Get all configuration from IndexedDB or return defaults
*/ */
async getConfig(): Promise<ConfigData> { async getConfig(): Promise<ConfigData> {
try {
await this.init() await this.init()
const {db} = this
if (!this.db) { if (!db) {
return this.getDefaultConfig() return this.getDefaultConfig()
} }
const {db} = this try {
const stored = await this.readStoredConfig(db)
if (!stored) {
const defaults = this.getDefaultConfig()
await this.saveConfig(defaults)
return defaults
}
return await this.migrateConfigIfNeeded(stored)
} catch (error) {
console.error('Error getting config from IndexedDB:', error)
return this.getDefaultConfig()
}
}
private readStoredConfig(db: IDBDatabase): Promise<ConfigData | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
const transaction = db.transaction([STORE_NAME], 'readonly') const transaction = db.transaction([STORE_NAME], 'readonly')
const store = transaction.objectStore(STORE_NAME) const store = transaction.objectStore(STORE_NAME)
const request = store.get('config') const request = store.get('config')
request.onsuccess = async () => { request.onsuccess = () => {
const result = request.result as { key: string; value: ConfigData } | undefined const result = request.result as { key: string; value: ConfigData } | undefined
resolve(result?.value ?? null)
if (!result?.value) {
// First time: initialize with defaults
const defaultConfig = this.getDefaultConfig()
await this.saveConfig(defaultConfig)
resolve(defaultConfig)
return
}
// Migrate: if relays array is empty or only has old default, add all defaults
const existingConfig = result.value
if (existingConfig.relays.length === 0 || (existingConfig.relays.length === 1 && existingConfig.relays[0]?.id === 'default')) {
const defaultConfig = this.getDefaultConfig()
existingConfig.relays = defaultConfig.relays
await this.saveConfig(existingConfig)
resolve(existingConfig)
return
}
resolve(existingConfig)
} }
request.onerror = () => { request.onerror = () => {
console.warn('Failed to read config from IndexedDB, using defaults') console.warn('Failed to read config from IndexedDB, using defaults')
resolve(this.getDefaultConfig()) resolve(null)
} }
}) })
} catch (error) {
console.error('Error getting config from IndexedDB:', error)
return this.getDefaultConfig()
} }
private async migrateConfigIfNeeded(config: ConfigData): Promise<ConfigData> {
const shouldReplaceRelays =
config.relays.length === 0 ||
(config.relays.length === 1 && config.relays[0]?.id === 'default')
if (!shouldReplaceRelays) {
return config
}
const defaults = this.getDefaultConfig()
const migrated = { ...config, relays: defaults.relays }
await this.saveConfig(migrated)
return migrated
} }
/** /**

View File

@ -33,95 +33,121 @@ export interface SyncSubscriptionResult {
export async function createSyncSubscription( export async function createSyncSubscription(
config: SyncSubscriptionConfig config: SyncSubscriptionConfig
): Promise<SyncSubscriptionResult> { ): Promise<SyncSubscriptionResult> {
const { pool, filters, onEvent, onComplete, timeout = 10000, updateProgress, eventFilter } = config const timeout = config.timeout ?? 10000
const { subscription, relayUrl } = await createSubscriptionWithRelayRotation({
pool: config.pool,
filters: config.filters,
updateProgress: config.updateProgress,
})
return collectSubscriptionEvents({
subscription,
relayUrl,
timeout,
onEvent: config.onEvent,
onComplete: config.onComplete,
eventFilter: config.eventFilter,
})
}
const events: Event[] = [] async function createSubscriptionWithRelayRotation(params: {
let sub: ReturnType<typeof createSubscription> | null = null pool: SimplePoolWithSub
let usedRelayUrl = '' filters: Filter[]
updateProgress: ((relayUrl: string) => void) | undefined
// Try relays with rotation }): Promise<{ subscription: ReturnType<typeof createSubscription>; relayUrl: string }> {
try { try {
const result = await tryWithRelayRotation( let usedRelayUrl = ''
pool as unknown as SimplePool, const subscription = await tryWithRelayRotation(
params.pool as unknown as SimplePool,
async (relayUrl, poolWithSub) => { async (relayUrl, poolWithSub) => {
usedRelayUrl = relayUrl usedRelayUrl = relayUrl
await updateSyncProgress(relayUrl, params.updateProgress)
return createSubscription(poolWithSub, [relayUrl], params.filters)
},
5000
)
if (!usedRelayUrl) {
throw new Error('Relay rotation did not return a relay URL')
}
return { subscription, relayUrl: usedRelayUrl }
} catch (error) {
console.warn('[createSyncSubscription] Relay rotation failed, falling back to primary relay:', error)
const relayUrl = getPrimaryRelaySync()
return { subscription: createSubscription(params.pool, [relayUrl], params.filters), relayUrl }
}
}
// Update progress if callback provided async function updateSyncProgress(relayUrl: string, updateProgress: ((relayUrl: string) => void) | undefined): Promise<void> {
if (updateProgress) { if (updateProgress) {
updateProgress(relayUrl) updateProgress(relayUrl)
} else { return
// Default: notify progress manager }
const { syncProgressManager } = await import('../syncProgressManager') const { syncProgressManager } = await import('../syncProgressManager')
const currentProgress = syncProgressManager.getProgress() const currentProgress = syncProgressManager.getProgress()
if (currentProgress) { if (!currentProgress) {
return
}
syncProgressManager.setProgress({ syncProgressManager.setProgress({
...currentProgress, ...currentProgress,
currentStep: 0, currentStep: 0,
currentRelay: relayUrl, currentRelay: relayUrl,
}) })
} }
}
return createSubscription(poolWithSub, [relayUrl], filters)
},
5000 // 5 second timeout per relay
)
sub = result
} catch {
// Fallback to primary relay if rotation fails
usedRelayUrl = getPrimaryRelaySync()
sub = createSubscription(pool, [usedRelayUrl], filters)
}
if (!sub) {
throw new Error('Failed to create subscription')
}
async function collectSubscriptionEvents(params: {
subscription: ReturnType<typeof createSubscription>
relayUrl: string
timeout: number
onEvent: ((event: Event) => void | Promise<void>) | undefined
onComplete: ((events: Event[]) => void | Promise<void>) | undefined
eventFilter: ((event: Event) => boolean) | undefined
}): Promise<SyncSubscriptionResult> {
const events: Event[] = []
return new Promise<SyncSubscriptionResult>((resolve) => { return new Promise<SyncSubscriptionResult>((resolve) => {
let finished = false const finalize = createFinalizeHandler({ subscription: params.subscription, relayUrl: params.relayUrl, events, resolve, onComplete: params.onComplete })
registerSubscriptionEventHandlers({ subscription: params.subscription, events, onEvent: params.onEvent, eventFilter: params.eventFilter, finalize })
const timeoutId = setTimeout((): void => void finalize(), params.timeout)
timeoutId.unref?.()
})
}
const done = async (): Promise<void> => { function createFinalizeHandler(params: {
subscription: ReturnType<typeof createSubscription>
relayUrl: string
events: Event[]
resolve: (value: SyncSubscriptionResult) => void
onComplete: ((events: Event[]) => void | Promise<void>) | undefined
}): () => Promise<void> {
let finished = false
return async (): Promise<void> => {
if (finished) { if (finished) {
return return
} }
finished = true finished = true
sub?.unsub() params.subscription.unsub()
if (params.onComplete) {
// Call onComplete callback if provided await params.onComplete(params.events)
if (onComplete) {
await onComplete(events)
} }
params.resolve({ subscription: params.subscription, relayUrl: params.relayUrl, events: params.events })
resolve({
subscription: sub,
relayUrl: usedRelayUrl,
events,
})
} }
}
// Handle events function registerSubscriptionEventHandlers(params: {
sub.on('event', (event: Event): void => { subscription: ReturnType<typeof createSubscription>
// Apply event filter if provided events: Event[]
if (eventFilter && !eventFilter(event)) { onEvent: ((event: Event) => void | Promise<void>) | undefined
eventFilter: ((event: Event) => boolean) | undefined
finalize: () => Promise<void>
}): void {
params.subscription.on('event', (event: Event): void => {
if (params.eventFilter && !params.eventFilter(event)) {
return return
} }
params.events.push(event)
events.push(event) if (params.onEvent) {
void params.onEvent(event)
// Call onEvent callback if provided
if (onEvent) {
void onEvent(event)
} }
}) })
params.subscription.on('eose', (): void => {
// Handle end of stream void params.finalize()
sub.on('eose', (): void => {
void done()
})
// Timeout fallback
setTimeout((): void => {
void done()
}, timeout).unref?.()
}) })
} }

View File

@ -160,38 +160,11 @@ export async function extractAuthorFromEvent(event: Event): Promise<ExtractedAut
return null return null
} }
// Try to extract from tag first (new format) const metadata = getMetadataFromEvent(event)
let metadata = extractMetadataJsonFromTag(event)
// Fallback to content format (for backward compatibility)
metadata ??= extractMetadataJson(event.content)
if (metadata?.type === 'author') { if (metadata?.type === 'author') {
const authorData = { const authorData = buildAuthorDataFromMetadata({ event, tags, metadata })
pubkey: (metadata.pubkey as string) ?? event.pubkey,
authorName: (metadata.authorName as string) ?? '',
presentation: (metadata.presentation as string) ?? '',
contentDescription: (metadata.contentDescription as string) ?? '',
mainnetAddress: metadata.mainnetAddress as string | undefined,
pictureUrl: metadata.pictureUrl as string | undefined,
category: (metadata.category as string) ?? tags.category ?? 'sciencefiction',
}
const id = await generateAuthorHashId(authorData) const id = await generateAuthorHashId(authorData)
return buildExtractedAuthor({ eventId: event.id, id, data: authorData, metadata })
return {
type: 'author',
id,
pubkey: authorData.pubkey,
authorName: authorData.authorName,
presentation: authorData.presentation,
contentDescription: authorData.contentDescription,
category: authorData.category,
eventId: event.id,
...(authorData.mainnetAddress ? { mainnetAddress: authorData.mainnetAddress } : {}),
...(authorData.pictureUrl ? { pictureUrl: authorData.pictureUrl } : {}),
...(metadata.url ? { url: metadata.url as string } : {}),
}
} }
// Fallback: extract from tags and visible content // Fallback: extract from tags and visible content
@ -199,6 +172,72 @@ export async function extractAuthorFromEvent(event: Event): Promise<ExtractedAut
return null return null
} }
function buildAuthorDataFromMetadata(params: {
event: Event
tags: ReturnType<typeof extractTagsFromEvent>
metadata: Record<string, unknown>
}): {
pubkey: string
authorName: string
presentation: string
contentDescription: string
mainnetAddress?: string
pictureUrl?: string
category: string
} {
const pubkey = firstString(params.metadata.pubkey, params.event.pubkey) ?? params.event.pubkey
const mainnetAddress = firstString(params.metadata.mainnetAddress)
const pictureUrl = firstString(params.metadata.pictureUrl)
return {
pubkey,
authorName: firstString(params.metadata.authorName) ?? '',
presentation: firstString(params.metadata.presentation) ?? '',
contentDescription: firstString(params.metadata.contentDescription) ?? '',
...(mainnetAddress ? { mainnetAddress } : {}),
...(pictureUrl ? { pictureUrl } : {}),
category: firstString(params.metadata.category, params.tags.category, 'sciencefiction') ?? 'sciencefiction',
}
}
function buildExtractedAuthor(params: {
eventId: string
id: string
data: {
pubkey: string
authorName: string
presentation: string
contentDescription: string
mainnetAddress?: string
pictureUrl?: string
category: string
}
metadata: Record<string, unknown>
}): ExtractedAuthor {
const url = firstString(params.metadata.url)
return {
type: 'author',
id: params.id,
pubkey: params.data.pubkey,
authorName: params.data.authorName,
presentation: params.data.presentation,
contentDescription: params.data.contentDescription,
category: params.data.category,
eventId: params.eventId,
...(params.data.mainnetAddress ? { mainnetAddress: params.data.mainnetAddress } : {}),
...(params.data.pictureUrl ? { pictureUrl: params.data.pictureUrl } : {}),
...(url ? { url } : {}),
}
}
function firstString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === 'string') {
return value
}
}
return undefined
}
/** /**
* Extract series from event * Extract series from event
*/ */
@ -208,35 +247,11 @@ export async function extractSeriesFromEvent(event: Event): Promise<ExtractedSer
return null return null
} }
// Try to extract from tag first (new format) const metadata = getMetadataFromEvent(event)
let metadata = extractMetadataJsonFromTag(event)
// Fallback to content format (for backward compatibility)
metadata ??= extractMetadataJson(event.content)
if (metadata?.type === 'series') { if (metadata?.type === 'series') {
const seriesData = { const seriesData = buildSeriesDataFromMetadata({ event, tags, metadata })
pubkey: (metadata.pubkey as string) ?? event.pubkey,
title: (metadata.title as string) ?? (tags.title as string) ?? '',
description: (metadata.description as string) ?? '',
preview: (metadata.preview as string) ?? (tags.preview as string) ?? event.content.substring(0, 200),
coverUrl: (metadata.coverUrl as string) ?? (tags.coverUrl as string) ?? undefined,
category: (metadata.category as string) ?? tags.category ?? 'sciencefiction',
}
const id = await generateSeriesHashId(seriesData) const id = await generateSeriesHashId(seriesData)
return buildExtractedSeries({ eventId: event.id, id, data: seriesData })
return {
type: 'series',
id,
pubkey: seriesData.pubkey,
title: seriesData.title,
description: seriesData.description,
category: seriesData.category,
eventId: event.id,
...(seriesData.coverUrl ? { coverUrl: seriesData.coverUrl } : {}),
...(seriesData.preview ? { preview: seriesData.preview } : {}),
}
} }
// Fallback: extract from tags // Fallback: extract from tags
@ -246,23 +261,15 @@ export async function extractSeriesFromEvent(event: Event): Promise<ExtractedSer
title: tags.title, title: tags.title,
description: tags.description, description: tags.description,
preview: (tags.preview as string) ?? event.content.substring(0, 200), preview: (tags.preview as string) ?? event.content.substring(0, 200),
coverUrl: tags.coverUrl,
category: tags.category ?? 'sciencefiction', category: tags.category ?? 'sciencefiction',
} }
const seriesDataWithOptionals = {
const id = await generateSeriesHashId(seriesData) ...seriesData,
...(tags.coverUrl ? { coverUrl: tags.coverUrl } : {}),
return {
type: 'series',
id,
pubkey: seriesData.pubkey,
title: seriesData.title,
description: seriesData.description,
category: seriesData.category,
eventId: event.id,
...(seriesData.coverUrl ? { coverUrl: seriesData.coverUrl } : {}),
...(seriesData.preview ? { preview: seriesData.preview } : {}),
} }
const id = await generateSeriesHashId(seriesDataWithOptionals)
return buildExtractedSeries({ eventId: event.id, id, data: seriesDataWithOptionals })
} }
return null return null
@ -277,41 +284,12 @@ export async function extractPublicationFromEvent(event: Event): Promise<Extract
return null return null
} }
// Try to extract from tag first (new format) const metadata = getMetadataFromEvent(event)
let metadata = extractMetadataJsonFromTag(event)
// Fallback to content format (for backward compatibility)
metadata ??= extractMetadataJson(event.content)
if (metadata?.type === 'publication') { if (metadata?.type === 'publication') {
const publicationData = { const publicationData = buildPublicationDataFromMetadata({ event, tags, metadata })
pubkey: (metadata.pubkey as string) ?? event.pubkey,
title: (metadata.title as string) ?? (tags.title as string) ?? '',
preview: (metadata.preview as string) ?? (tags.preview as string) ?? event.content.substring(0, 200),
category: (metadata.category as string) ?? tags.category ?? 'sciencefiction',
seriesId: (metadata.seriesId as string) ?? tags.seriesId ?? undefined,
bannerUrl: (metadata.bannerUrl as string) ?? tags.bannerUrl ?? undefined,
zapAmount: (metadata.zapAmount as number) ?? tags.zapAmount ?? 800,
}
const id = await generatePublicationHashId(publicationData) const id = await generatePublicationHashId(publicationData)
const pages = readPublicationPages(metadata)
// Extract pages from metadata if present return buildExtractedPublication({ eventId: event.id, id, data: publicationData, pages })
const pages = metadata.pages as Array<{ number: number; type: 'markdown' | 'image'; content: string }> | undefined
return {
type: 'publication',
id,
pubkey: publicationData.pubkey,
title: publicationData.title,
preview: publicationData.preview,
category: publicationData.category,
zapAmount: publicationData.zapAmount,
eventId: event.id,
...(publicationData.seriesId ? { seriesId: publicationData.seriesId } : {}),
...(publicationData.bannerUrl ? { bannerUrl: publicationData.bannerUrl } : {}),
...(pages && pages.length > 0 ? { pages } : {}),
}
} }
// Fallback: extract from tags // Fallback: extract from tags
@ -321,25 +299,16 @@ export async function extractPublicationFromEvent(event: Event): Promise<Extract
title: tags.title, title: tags.title,
preview: (tags.preview as string) ?? event.content.substring(0, 200), preview: (tags.preview as string) ?? event.content.substring(0, 200),
category: tags.category ?? 'sciencefiction', category: tags.category ?? 'sciencefiction',
seriesId: tags.seriesId,
bannerUrl: tags.bannerUrl,
zapAmount: tags.zapAmount ?? 800, zapAmount: tags.zapAmount ?? 800,
} }
const publicationDataWithOptionals = {
const id = await generatePublicationHashId(publicationData) ...publicationData,
...(tags.seriesId ? { seriesId: tags.seriesId } : {}),
return { ...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl } : {}),
type: 'publication',
id,
pubkey: publicationData.pubkey,
title: publicationData.title,
preview: publicationData.preview,
category: publicationData.category,
zapAmount: publicationData.zapAmount,
eventId: event.id,
...(publicationData.seriesId ? { seriesId: publicationData.seriesId } : {}),
...(publicationData.bannerUrl ? { bannerUrl: publicationData.bannerUrl } : {}),
} }
const id = await generatePublicationHashId(publicationDataWithOptionals)
return buildExtractedPublication({ eventId: event.id, id, data: publicationDataWithOptionals, pages: undefined })
} }
return null return null
@ -354,217 +323,402 @@ export async function extractReviewFromEvent(event: Event): Promise<ExtractedRev
return null return null
} }
// Try to extract from tag first (new format) const metadata = getMetadataFromEvent(event)
let metadata = extractMetadataJsonFromTag(event) const fromMetadata = await extractReviewFromMetadata({ event, tags, metadata })
if (fromMetadata) {
// Fallback to content format (for backward compatibility) return fromMetadata
metadata ??= extractMetadataJson(event.content)
if (metadata?.type === 'review') {
const reviewData = {
pubkey: (metadata.pubkey as string) ?? event.pubkey,
articleId: (metadata.articleId as string) ?? (tags.articleId as string) ?? '',
reviewerPubkey: (metadata.reviewerPubkey as string) ?? (tags.reviewerPubkey as string) ?? event.pubkey,
content: (metadata.content as string) ?? event.content,
title: (metadata.title as string) ?? (tags.title as string) ?? undefined,
} }
if (!reviewData.articleId || !reviewData.reviewerPubkey) { return extractReviewFromTags({ event, tags })
}
async function extractReviewFromMetadata(params: {
event: Event
tags: ReturnType<typeof extractTagsFromEvent>
metadata: Record<string, unknown> | null
}): Promise<ExtractedReview | null> {
if (params.metadata?.type !== 'review') {
return null
}
const reviewData = buildReviewDataFromMetadata({ event: params.event, tags: params.tags, metadata: params.metadata })
if (!reviewData) {
return null return null
} }
const id = await generateReviewHashId(reviewData) const id = await generateReviewHashId(reviewData)
return { type: 'review', id, ...reviewData, eventId: params.event.id }
}
return { async function extractReviewFromTags(params: {
type: 'review', event: Event
id, tags: ReturnType<typeof extractTagsFromEvent>
...reviewData, }): Promise<ExtractedReview | null> {
eventId: event.id, if (!params.tags.articleId || !params.tags.reviewerPubkey) {
}
}
// Fallback: extract from tags
if (tags.articleId && tags.reviewerPubkey) {
const reviewData = {
pubkey: event.pubkey,
articleId: tags.articleId,
reviewerPubkey: tags.reviewerPubkey,
content: event.content,
title: tags.title,
}
const id = await generateReviewHashId({
pubkey: reviewData.pubkey,
articleId: reviewData.articleId,
reviewerPubkey: reviewData.reviewerPubkey,
content: reviewData.content,
...(reviewData.title ? { title: reviewData.title } : {}),
})
return {
type: 'review',
id,
pubkey: reviewData.pubkey,
articleId: reviewData.articleId,
reviewerPubkey: reviewData.reviewerPubkey,
content: reviewData.content,
eventId: event.id,
...(reviewData.title ? { title: reviewData.title } : {}),
}
}
return null return null
}
const base = {
pubkey: params.event.pubkey,
articleId: params.tags.articleId,
reviewerPubkey: params.tags.reviewerPubkey,
content: params.event.content,
...(params.tags.title ? { title: params.tags.title } : {}),
}
return buildExtractedReviewFromTags({ ...base, eventId: params.event.id })
}
async function buildExtractedReviewFromTags(params: {
pubkey: string
articleId: string
reviewerPubkey: string
content: string
title?: string
eventId: string
}): Promise<ExtractedReview> {
const id = await generateReviewHashId({
pubkey: params.pubkey,
articleId: params.articleId,
reviewerPubkey: params.reviewerPubkey,
content: params.content,
...(params.title ? { title: params.title } : {}),
})
return {
type: 'review',
id,
pubkey: params.pubkey,
articleId: params.articleId,
reviewerPubkey: params.reviewerPubkey,
content: params.content,
eventId: params.eventId,
...(params.title ? { title: params.title } : {}),
}
}
function getMetadataFromEvent(event: Event): Record<string, unknown> | null {
return extractMetadataJsonFromTag(event) ?? extractMetadataJson(event.content)
}
function buildSeriesDataFromMetadata(params: {
event: Event
tags: ReturnType<typeof extractTagsFromEvent>
metadata: Record<string, unknown>
}): { pubkey: string; title: string; description: string; preview: string; coverUrl?: string; category: string } {
const title = firstString(params.metadata.title, params.tags.title) ?? ''
const preview = firstString(params.metadata.preview, params.tags.preview) ?? params.event.content.substring(0, 200)
const pubkey = firstString(params.metadata.pubkey, params.event.pubkey) ?? params.event.pubkey
const coverUrl = firstString(params.metadata.coverUrl, params.tags.coverUrl)
const result: { pubkey: string; title: string; description: string; preview: string; coverUrl?: string; category: string } = {
pubkey,
title,
description: firstString(params.metadata.description) ?? '',
preview,
category: firstString(params.metadata.category, params.tags.category, 'sciencefiction') ?? 'sciencefiction',
}
if (coverUrl) {
result.coverUrl = coverUrl
}
return result
}
function buildExtractedSeries(params: {
eventId: string
id: string
data: { pubkey: string; title: string; description: string; preview: string; coverUrl?: string; category: string }
}): ExtractedSeries {
return {
type: 'series',
id: params.id,
pubkey: params.data.pubkey,
title: params.data.title,
description: params.data.description,
category: params.data.category,
eventId: params.eventId,
...(params.data.coverUrl ? { coverUrl: params.data.coverUrl } : {}),
...(params.data.preview ? { preview: params.data.preview } : {}),
}
}
function buildPublicationDataFromMetadata(params: {
event: Event
tags: ReturnType<typeof extractTagsFromEvent>
metadata: Record<string, unknown>
}): {
pubkey: string
title: string
preview: string
category: string
seriesId?: string
bannerUrl?: string
zapAmount: number
} {
const result: {
pubkey: string
title: string
preview: string
category: string
seriesId?: string
bannerUrl?: string
zapAmount: number
} = {
pubkey: firstString(params.metadata.pubkey, params.event.pubkey) ?? params.event.pubkey,
title: firstString(params.metadata.title, params.tags.title) ?? '',
preview: firstString(params.metadata.preview, params.tags.preview) ?? params.event.content.substring(0, 200),
category: firstString(params.metadata.category, params.tags.category, 'sciencefiction') ?? 'sciencefiction',
zapAmount: firstNumber(params.metadata.zapAmount, params.tags.zapAmount) ?? 800,
}
const seriesId = firstString(params.metadata.seriesId, params.tags.seriesId)
if (seriesId) {
result.seriesId = seriesId
}
const bannerUrl = firstString(params.metadata.bannerUrl, params.tags.bannerUrl)
if (bannerUrl) {
result.bannerUrl = bannerUrl
}
return result
}
function readPublicationPages(metadata: Record<string, unknown>): Array<{ number: number; type: 'markdown' | 'image'; content: string }> | undefined {
const pages = metadata.pages as Array<{ number: number; type: 'markdown' | 'image'; content: string }> | undefined
return pages && Array.isArray(pages) && pages.length > 0 ? pages : undefined
}
function buildExtractedPublication(params: {
eventId: string
id: string
data: {
pubkey: string
title: string
preview: string
category: string
seriesId?: string
bannerUrl?: string
zapAmount: number
}
pages: Array<{ number: number; type: 'markdown' | 'image'; content: string }> | undefined
}): ExtractedPublication {
return {
type: 'publication',
id: params.id,
pubkey: params.data.pubkey,
title: params.data.title,
preview: params.data.preview,
category: params.data.category,
zapAmount: params.data.zapAmount,
eventId: params.eventId,
...(params.data.seriesId ? { seriesId: params.data.seriesId } : {}),
...(params.data.bannerUrl ? { bannerUrl: params.data.bannerUrl } : {}),
...(params.pages ? { pages: params.pages } : {}),
}
}
function buildReviewDataFromMetadata(params: {
event: Event
tags: ReturnType<typeof extractTagsFromEvent>
metadata: Record<string, unknown>
}): { pubkey: string; articleId: string; reviewerPubkey: string; content: string; title?: string } | null {
const articleId = firstString(params.metadata.articleId, params.tags.articleId) ?? ''
const reviewerPubkey = firstString(params.metadata.reviewerPubkey, params.tags.reviewerPubkey, params.event.pubkey) ?? params.event.pubkey
if (!articleId || !reviewerPubkey) {
return null
}
const title = firstString(params.metadata.title, params.tags.title)
const base: { pubkey: string; articleId: string; reviewerPubkey: string; content: string; title?: string } = {
pubkey: firstString(params.metadata.pubkey, params.event.pubkey) ?? params.event.pubkey,
articleId,
reviewerPubkey,
content: firstString(params.metadata.content, params.event.content) ?? params.event.content,
}
if (title) {
base.title = title
}
return base
}
function firstNumber(...values: unknown[]): number | undefined {
for (const value of values) {
if (typeof value === 'number') {
return value
}
}
return undefined
} }
/** /**
* Extract purchase from zap receipt (kind 9735) * Extract purchase from zap receipt (kind 9735)
*/ */
export async function extractPurchaseFromEvent(event: Event): Promise<ExtractedPurchase | null> { export async function extractPurchaseFromEvent(event: Event): Promise<ExtractedPurchase | null> {
if (event.kind !== 9735) { const kind = readZapReceiptKind(event)
if (kind !== 'purchase') {
return null return null
} }
// Check for purchase kind_type tag const authorPubkey = readTagValue(event, 'p')
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'purchase') const articleId = readTagValue(event, 'e')
if (!kindTypeTag) { const amountSats = readAmountSats(event)
if (!authorPubkey || !articleId || amountSats === undefined) {
return null return null
} }
const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1]
const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1]
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1]
if (!pTag || !eTag || !amountTag) {
return null
}
const amount = parseInt(amountTag, 10) / 1000 // Convert millisats to sats
const paymentHash = paymentHashTag ?? event.id // Use event.id as fallback
const purchaseData = { const purchaseData = {
payerPubkey: event.pubkey, payerPubkey: event.pubkey,
articleId: eTag, articleId,
authorPubkey: pTag, authorPubkey,
amount, amount: amountSats,
paymentHash, paymentHash: readPaymentHash(event),
} }
const id = await generatePurchaseHashId(purchaseData) const id = await generatePurchaseHashId(purchaseData)
return { type: 'purchase', id, ...purchaseData, eventId: event.id }
return {
type: 'purchase',
id,
...purchaseData,
eventId: event.id,
}
} }
/** /**
* Extract review tip from zap receipt (kind 9735) * Extract review tip from zap receipt (kind 9735)
*/ */
export async function extractReviewTipFromEvent(event: Event): Promise<ExtractedReviewTip | null> { export async function extractReviewTipFromEvent(event: Event): Promise<ExtractedReviewTip | null> {
if (event.kind !== 9735) { const kind = readZapReceiptKind(event)
if (kind !== 'review_tip') {
return null return null
} }
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'review_tip') const authorPubkey = readTagValue(event, 'p')
if (!kindTypeTag) { const articleId = readTagValue(event, 'e')
const reviewId = readTagValue(event, 'review_id')
const reviewerPubkey = readTagValue(event, 'reviewer')
const amountSats = readAmountSats(event)
if (!authorPubkey || !articleId || !reviewId || !reviewerPubkey || amountSats === undefined) {
return null return null
} }
const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1]
const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1]
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
const reviewerTag = event.tags.find((tag) => tag[0] === 'reviewer')?.[1]
const reviewIdTag = event.tags.find((tag) => tag[0] === 'review_id')?.[1]
const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1]
if (!pTag || !eTag || !amountTag || !reviewerTag || !reviewIdTag) {
return null
}
const amount = parseInt(amountTag, 10) / 1000
const paymentHash = paymentHashTag ?? event.id
const tipData = { const tipData = {
payerPubkey: event.pubkey, payerPubkey: event.pubkey,
articleId: eTag, articleId,
reviewId: reviewIdTag, reviewId,
reviewerPubkey: reviewerTag, reviewerPubkey,
authorPubkey: pTag, authorPubkey,
amount, amount: amountSats,
paymentHash, paymentHash: readPaymentHash(event),
} }
const id = await generateReviewTipHashId(tipData) const id = await generateReviewTipHashId(tipData)
return { type: 'review_tip', id, ...tipData, eventId: event.id }
return {
type: 'review_tip',
id,
...tipData,
eventId: event.id,
}
} }
/** /**
* Extract sponsoring from zap receipt (kind 9735) * Extract sponsoring from zap receipt (kind 9735)
*/ */
export async function extractSponsoringFromEvent(event: Event): Promise<ExtractedSponsoring | null> { export async function extractSponsoringFromEvent(event: Event): Promise<ExtractedSponsoring | null> {
if (event.kind !== 9735) { const kind = readZapReceiptKind(event)
if (kind !== 'sponsoring') {
return null return null
} }
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'sponsoring') const authorPubkey = readTagValue(event, 'p')
if (!kindTypeTag) { const amountSats = readAmountSats(event)
if (!authorPubkey || amountSats === undefined) {
return null return null
} }
const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1] const sponsoringData = buildSponsoringData({ event, authorPubkey, amountSats })
const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1] const id = await generateSponsoringHashId(buildSponsoringHashInput(sponsoringData))
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1] return buildExtractedSponsoring({ id, eventId: event.id, sponsoringData })
const seriesTag = event.tags.find((tag) => tag[0] === 'series')?.[1] }
const articleTag = event.tags.find((tag) => tag[0] === 'article')?.[1]
const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1]
if (!pTag || !amountTag) { function buildSponsoringData(params: { event: Event; authorPubkey: string; amountSats: number }): {
return null payerPubkey: string
authorPubkey: string
seriesId: string | undefined
articleId: string | undefined
amount: number
paymentHash: string
} {
return {
payerPubkey: params.event.pubkey,
authorPubkey: params.authorPubkey,
seriesId: readTagValue(params.event, 'series'),
articleId: resolveSponsoringArticleId({
articleTag: readTagValue(params.event, 'article'),
eTag: readTagValue(params.event, 'e'),
}),
amount: params.amountSats,
paymentHash: readPaymentHash(params.event),
} }
}
const amount = parseInt(amountTag, 10) / 1000 function buildSponsoringHashInput(params: {
const paymentHash = paymentHashTag ?? event.id payerPubkey: string
authorPubkey: string
const sponsoringData = { seriesId: string | undefined
payerPubkey: event.pubkey, articleId: string | undefined
authorPubkey: pTag, amount: number
seriesId: seriesTag, paymentHash: string
articleId: articleTag ?? eTag, // Use eTag as fallback for articleId }): Parameters<typeof generateSponsoringHashId>[0] {
amount, return {
paymentHash, payerPubkey: params.payerPubkey,
authorPubkey: params.authorPubkey,
amount: params.amount,
paymentHash: params.paymentHash,
...(params.seriesId ? { seriesId: params.seriesId } : {}),
...(params.articleId ? { articleId: params.articleId } : {}),
} }
}
const id = await generateSponsoringHashId({ function buildExtractedSponsoring(params: {
payerPubkey: sponsoringData.payerPubkey, id: string
authorPubkey: sponsoringData.authorPubkey, eventId: string
amount: sponsoringData.amount, sponsoringData: {
paymentHash: sponsoringData.paymentHash, payerPubkey: string
...(sponsoringData.seriesId ? { seriesId: sponsoringData.seriesId } : {}), authorPubkey: string
...(sponsoringData.articleId ? { articleId: sponsoringData.articleId } : {}), seriesId: string | undefined
}) articleId: string | undefined
amount: number
paymentHash: string
}
}): ExtractedSponsoring {
return { return {
type: 'sponsoring', type: 'sponsoring',
id, id: params.id,
payerPubkey: sponsoringData.payerPubkey, payerPubkey: params.sponsoringData.payerPubkey,
authorPubkey: sponsoringData.authorPubkey, authorPubkey: params.sponsoringData.authorPubkey,
amount: sponsoringData.amount, amount: params.sponsoringData.amount,
paymentHash: sponsoringData.paymentHash, paymentHash: params.sponsoringData.paymentHash,
eventId: event.id, eventId: params.eventId,
...(sponsoringData.seriesId ? { seriesId: sponsoringData.seriesId } : {}), ...(params.sponsoringData.seriesId ? { seriesId: params.sponsoringData.seriesId } : {}),
...(sponsoringData.articleId ? { articleId: sponsoringData.articleId } : {}), ...(params.sponsoringData.articleId ? { articleId: params.sponsoringData.articleId } : {}),
} }
} }
function readTagValue(event: Event, key: string): string | undefined {
return event.tags.find((tag) => tag[0] === key)?.[1]
}
function readZapReceiptKind(event: Event): string | undefined {
if (event.kind !== 9735) {
return undefined
}
return readTagValue(event, 'kind_type')
}
function readAmountSats(event: Event): number | undefined {
const amountTag = readTagValue(event, 'amount')
if (!amountTag) {
return undefined
}
const millisats = parseInt(amountTag, 10)
if (Number.isNaN(millisats)) {
return undefined
}
return millisats / 1000
}
function readPaymentHash(event: Event): string {
return readTagValue(event, 'payment_hash') ?? event.id
}
function resolveSponsoringArticleId(params: { articleTag: string | undefined; eTag: string | undefined }): string | undefined {
return params.articleTag ?? params.eTag
}
/** /**
* Extract all objects from an event * Extract all objects from an event
*/ */

View File

@ -18,54 +18,62 @@ import { nostrAuthService } from './nostrAuth'
* @returns Base64-encoded signed event token * @returns Base64-encoded signed event token
*/ */
export async function generateNip98Token(method: string, url: string, payloadHash?: string): Promise<string> { export async function generateNip98Token(method: string, url: string, payloadHash?: string): Promise<string> {
const pubkey = getPubkeyOrThrow()
const privateKey = getPrivateKeyOrThrow()
const eventTemplate = buildNip98EventTemplate({ method, url, payloadHash, pubkey })
const signedEvent = finalizeEvent(eventTemplate, hexToBytes(privateKey))
return encodeEventAsBase64Json(signedEvent)
}
function getPubkeyOrThrow(): string {
const pubkey = nostrService.getPublicKey() const pubkey = nostrService.getPublicKey()
if (!pubkey) { if (!pubkey) {
throw new Error('Public key required for NIP-98 authentication. Please unlock your account.') throw new Error('Public key required for NIP-98 authentication. Please unlock your account.')
} }
return pubkey
}
// Check if private key is available (unlocked) function getPrivateKeyOrThrow(): string {
if (!nostrAuthService.isUnlocked()) { if (!nostrAuthService.isUnlocked()) {
throw new Error('Private key required for NIP-98 authentication. Please unlock your account with your recovery phrase.') throw new Error(
'Private key required for NIP-98 authentication. Please unlock your account with your recovery phrase.'
)
} }
const privateKey = nostrAuthService.getPrivateKey() const privateKey = nostrAuthService.getPrivateKey()
if (!privateKey) { if (!privateKey) {
throw new Error('Private key not available. Please unlock your account.') throw new Error('Private key not available. Please unlock your account.')
} }
return privateKey
}
// Parse URL to get components function buildNip98EventTemplate(params: {
const urlObj = new URL(url) method: string
url: string
payloadHash: string | undefined
pubkey: string
}): EventTemplate & { pubkey: string } {
const urlObj = new URL(params.url)
const path = urlObj.pathname + urlObj.search const path = urlObj.pathname + urlObj.search
// Build event template for NIP-98
const tags: string[][] = [ const tags: string[][] = [
['u', urlObj.origin + path], ['u', urlObj.origin + path],
['method', method], ['method', params.method],
] ]
if (params.payloadHash) {
// Add payload hash if provided (for POST/PUT requests) tags.push(['payload', params.payloadHash])
if (payloadHash) {
tags.push(['payload', payloadHash])
} }
return {
const eventTemplate: EventTemplate & { pubkey: string } = { kind: 27235,
kind: 27235, // NIP-98 kind for HTTP auth
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags, tags,
content: '', content: '',
pubkey, pubkey: params.pubkey,
} }
}
// Sign the event directly with the private key (no plugin needed) function encodeEventAsBase64Json(event: unknown): string {
const secretKey = hexToBytes(privateKey) const eventJson = JSON.stringify(event)
const signedEvent = finalizeEvent(eventTemplate, secretKey)
// Encode event as base64 JSON
const eventJson = JSON.stringify(signedEvent)
const eventBytes = new TextEncoder().encode(eventJson) const eventBytes = new TextEncoder().encode(eventJson)
const base64Token = globalThis.btoa(String.fromCharCode(...eventBytes)) return globalThis.btoa(String.fromCharCode(...eventBytes))
return base64Token
} }
/** /**

View File

@ -27,46 +27,34 @@ export async function parseArticleFromEvent(event: Event): Promise<Article | nul
export async function parseSeriesFromEvent(event: Event): Promise<Series | null> { export async function parseSeriesFromEvent(event: Event): Promise<Series | null> {
try { try {
const tags = extractTagsFromEvent(event) const tags = extractTagsFromEvent(event)
// Check if it's a series type (tag is 'series' in English) const input = readSeriesInput({ tags, eventContent: event.content })
if (tags.type !== 'series') { if (!input) {
return null
}
if (!tags.title || !tags.description) {
return null return null
} }
const category = mapNostrCategoryToLegacy(tags.category) ?? 'science-fiction' const category = mapNostrCategoryToLegacy(tags.category) ?? 'science-fiction'
const { hash, version, index } = await resolveObjectIdParts({ const { hash, version, index } = await resolveObjectIdParts({
...(tags.id ? { idTag: tags.id } : {}), ...(input.idTag ? { idTag: input.idTag } : {}),
defaultVersion: tags.version ?? 0, defaultVersion: input.defaultVersion,
defaultIndex: 0, defaultIndex: 0,
generateHash: async (): Promise<string> => generateHashId({ generateHash: async (): Promise<string> => generateHashId({
type: 'series', type: 'series',
pubkey: event.pubkey, pubkey: event.pubkey,
title: tags.title, title: input.title,
description: tags.description, description: input.description,
category: tags.category ?? 'sciencefiction', category: input.categoryTag,
coverUrl: tags.coverUrl ?? '', coverUrl: input.coverUrl,
}), }),
}) })
const id = buildObjectId(hash, index, version) return buildSeriesFromParsed({
event,
const series: Series = { input,
id,
hash, hash,
version, version,
index, index,
pubkey: event.pubkey,
title: tags.title,
description: tags.description,
preview: (tags.preview) ?? event.content.substring(0, 200),
thumbnailUrl: tags.coverUrl ?? '', // Use coverUrl as thumbnail if available
category, category,
...(tags.coverUrl ? { coverUrl: tags.coverUrl } : {}), })
}
series.kindType = 'series'
return series
} catch (e) { } catch (e) {
console.error('Error parsing series:', e) console.error('Error parsing series:', e)
return null return null
@ -76,75 +64,147 @@ export async function parseSeriesFromEvent(event: Event): Promise<Series | null>
export async function parseReviewFromEvent(event: Event): Promise<Review | null> { export async function parseReviewFromEvent(event: Event): Promise<Review | null> {
try { try {
const tags = extractTagsFromEvent(event) const tags = extractTagsFromEvent(event)
// Check if it's a quote type (reviews are quotes, tag is 'quote' in English) const input = readReviewInput(tags)
if (tags.type !== 'quote') { if (!input) {
return null return null
} }
const {articleId} = tags
const reviewer = tags.reviewerPubkey
if (!articleId || !reviewer) {
return null
}
const rewardedTag = event.tags.find((tag) => tag[0] === 'rewarded' && tag[1] === 'true')
const rewardAmountTag = event.tags.find((tag) => tag[0] === 'reward_amount')
// Extract hash, version, index from id tag or parse it const { hash, version, index } = await resolveObjectIdParts({
let hash: string ...(input.idTag ? { idTag: input.idTag } : {}),
let version = tags.version ?? 0 defaultVersion: input.defaultVersion,
let index = 0 defaultIndex: 0,
generateHash: async (): Promise<string> => generateHashId({
if (tags.id) {
const parsed = parseObjectId(tags.id)
const { hash: parsedHash, version: parsedVersion, index: parsedIndex } = parsed
if (parsedHash) {
hash = parsedHash
version = parsedVersion ?? version
index = parsedIndex ?? index
} else {
// If id is just a hash, use it directly
hash = tags.id
}
} else {
// Generate hash from review data
hash = await generateHashId({
type: 'quote', type: 'quote',
pubkey: event.pubkey, pubkey: event.pubkey,
articleId, articleId: input.articleId,
reviewerPubkey: reviewer, reviewerPubkey: input.reviewerPubkey,
content: event.content, content: event.content,
title: tags.title ?? '', title: input.title ?? '',
}),
}) })
}
const id = buildObjectId(hash, index, version) const rewardInfo = readRewardInfo(event.tags)
const text = readTextTag(event.tags)
// Extract text from tags if present return buildReviewFromParsed({ event, input, hash, version, index, rewardInfo, text })
const textTag = event.tags.find((tag) => tag[0] === 'text')?.[1]
const review: Review = {
id,
hash,
version,
index,
articleId,
authorPubkey: event.pubkey,
reviewerPubkey: reviewer,
content: event.content,
description: tags.description ?? '', // Required field with default
createdAt: event.created_at,
...(tags.title ? { title: tags.title } : {}),
...(textTag ? { text: textTag } : {}),
...(rewardedTag ? { rewarded: true } : {}),
...(rewardAmountTag ? { rewardAmount: parseInt(rewardAmountTag[1] ?? '0', 10) } : {}),
}
review.kindType = 'review'
return review
} catch (e) { } catch (e) {
console.error('Error parsing review:', e) console.error('Error parsing review:', e)
return null return null
} }
} }
function buildSeriesFromParsed(params: {
event: Event
input: { title: string; description: string; preview: string; coverUrl: string }
hash: string
version: number
index: number
category: Series['category']
}): Series {
const id = buildObjectId(params.hash, params.index, params.version)
const series: Series = {
id,
hash: params.hash,
version: params.version,
index: params.index,
pubkey: params.event.pubkey,
title: params.input.title,
description: params.input.description,
preview: params.input.preview,
thumbnailUrl: params.input.coverUrl,
category: params.category,
...(params.input.coverUrl ? { coverUrl: params.input.coverUrl } : {}),
}
series.kindType = 'series'
return series
}
function buildReviewFromParsed(params: {
event: Event
input: { articleId: string; reviewerPubkey: string; title: string | undefined; description: string }
hash: string
version: number
index: number
rewardInfo: { rewarded: boolean; rewardAmount: number | undefined }
text: string | undefined
}): Review {
const id = buildObjectId(params.hash, params.index, params.version)
const review: Review = {
id,
hash: params.hash,
version: params.version,
index: params.index,
articleId: params.input.articleId,
authorPubkey: params.event.pubkey,
reviewerPubkey: params.input.reviewerPubkey,
content: params.event.content,
description: params.input.description,
createdAt: params.event.created_at,
...(params.input.title ? { title: params.input.title } : {}),
...(params.text ? { text: params.text } : {}),
...(params.rewardInfo.rewarded ? { rewarded: true } : {}),
...(params.rewardInfo.rewardAmount !== undefined ? { rewardAmount: params.rewardInfo.rewardAmount } : {}),
}
review.kindType = 'review'
return review
}
function readSeriesInput(params: { tags: ReturnType<typeof extractTagsFromEvent>; eventContent: string }): {
idTag: string | undefined
defaultVersion: number
title: string
description: string
preview: string
coverUrl: string
categoryTag: string
} | null {
if (params.tags.type !== 'series' || !params.tags.title || !params.tags.description) {
return null
}
return {
idTag: params.tags.id,
defaultVersion: params.tags.version ?? 0,
title: params.tags.title,
description: params.tags.description,
preview: params.tags.preview ?? params.eventContent.substring(0, 200),
coverUrl: params.tags.coverUrl ?? '',
categoryTag: params.tags.category ?? 'sciencefiction',
}
}
function readReviewInput(tags: ReturnType<typeof extractTagsFromEvent>): {
idTag: string | undefined
defaultVersion: number
articleId: string
reviewerPubkey: string
title: string | undefined
description: string
} | null {
if (tags.type !== 'quote' || !tags.articleId || !tags.reviewerPubkey) {
return null
}
return {
idTag: tags.id,
defaultVersion: tags.version ?? 0,
articleId: tags.articleId,
reviewerPubkey: tags.reviewerPubkey,
title: tags.title,
description: tags.description ?? '',
}
}
function readRewardInfo(eventTags: string[][]): { rewarded: boolean; rewardAmount: number | undefined } {
const rewarded = eventTags.some((tag) => tag[0] === 'rewarded' && tag[1] === 'true')
const rewardAmountTag = eventTags.find((tag) => tag[0] === 'reward_amount')?.[1]
const rewardAmountRaw = rewardAmountTag ? parseInt(rewardAmountTag, 10) : NaN
const rewardAmount = Number.isNaN(rewardAmountRaw) ? undefined : rewardAmountRaw
return { rewarded, rewardAmount }
}
function readTextTag(eventTags: string[][]): string | undefined {
return eventTags.find((tag) => tag[0] === 'text')?.[1]
}
function getPreviewContent(content: string, previewTag?: string): { previewContent: string } { function getPreviewContent(content: string, previewTag?: string): { previewContent: string } {
const lines = content.split('\n') const lines = content.split('\n')

View File

@ -42,47 +42,108 @@ export function extractCommonTags(findTag: (key: string) => string | undefined,
reviewerPubkey?: string reviewerPubkey?: string
json?: string json?: string
} { } {
const id = findTag('id') const base = readCommonTagBase(findTag)
const service = findTag('service')
const title = findTag('title')
const preview = findTag('preview')
const description = findTag('description')
const mainnetAddress = findTag('mainnet_address')
const totalSponsoring = parseNumericTag(findTag, 'total_sponsoring')
const pictureUrl = findTag('picture')
const seriesId = findTag('series')
const coverUrl = findTag('cover')
const bannerUrl = findTag('banner')
const zapAmount = parseNumericTag(findTag, 'zap')
const invoice = findTag('invoice')
const paymentHash = findTag('payment_hash')
const encryptedKey = findTag('encrypted_key')
const articleId = findTag('article')
const reviewerPubkey = findTag('reviewer')
const json = findTag('json')
return { return {
...(id ? { id } : {}), ...buildCoreCommonTagFields(findTag, hasTag),
...(service ? { service } : {}), ...buildOptionalCommonTagFields(base),
version: parseNumericTag(findTag, 'version') ?? 0, // Default to 0 if not present ...buildOptionalNumericFields(base),
hidden: findTag('hidden') === 'true', // true only if tag exists and value is 'true' }
}
function addOptionalString(target: Record<string, string>, key: string, value: string | undefined): Record<string, string> {
if (typeof value === 'string' && value.length > 0) {
return { ...target, [key]: value }
}
return target
}
function readCommonTagBase(findTag: (key: string) => string | undefined): {
id: string | undefined
service: string | undefined
title: string | undefined
preview: string | undefined
description: string | undefined
mainnetAddress: string | undefined
totalSponsoring: number | undefined
pictureUrl: string | undefined
seriesId: string | undefined
coverUrl: string | undefined
bannerUrl: string | undefined
zapAmount: number | undefined
invoice: string | undefined
paymentHash: string | undefined
encryptedKey: string | undefined
articleId: string | undefined
reviewerPubkey: string | undefined
json: string | undefined
} {
return {
id: findTag('id'), service: findTag('service'), title: findTag('title'), preview: findTag('preview'),
description: findTag('description'), mainnetAddress: findTag('mainnet_address'), pictureUrl: findTag('picture'),
seriesId: findTag('series'), coverUrl: findTag('cover'), bannerUrl: findTag('banner'), invoice: findTag('invoice'),
paymentHash: findTag('payment_hash'), encryptedKey: findTag('encrypted_key'), articleId: findTag('article'),
reviewerPubkey: findTag('reviewer'), json: findTag('json'),
totalSponsoring: parseNumericTag(findTag, 'total_sponsoring'), zapAmount: parseNumericTag(findTag, 'zap'),
}
}
function buildCoreCommonTagFields(
findTag: (key: string) => string | undefined,
hasTag: (key: string) => boolean
): { version: number; hidden: boolean; paywall: boolean; payment: boolean } {
return {
version: parseNumericTag(findTag, 'version') ?? 0,
hidden: findTag('hidden') === 'true',
paywall: hasTag('paywall'), paywall: hasTag('paywall'),
payment: hasTag('payment'), payment: hasTag('payment'),
...(title ? { title } : {}), }
...(preview ? { preview } : {}), }
...(description ? { description } : {}),
...(mainnetAddress ? { mainnetAddress } : {}), function buildOptionalCommonTagFields(base: {
...(totalSponsoring !== undefined ? { totalSponsoring } : {}), id: string | undefined
...(pictureUrl ? { pictureUrl } : {}), service: string | undefined
...(seriesId ? { seriesId } : {}), title: string | undefined
...(coverUrl ? { coverUrl } : {}), preview: string | undefined
...(bannerUrl ? { bannerUrl } : {}), description: string | undefined
...(zapAmount !== undefined ? { zapAmount } : {}), mainnetAddress: string | undefined
...(invoice ? { invoice } : {}), pictureUrl: string | undefined
...(paymentHash ? { paymentHash } : {}), seriesId: string | undefined
...(encryptedKey ? { encryptedKey } : {}), coverUrl: string | undefined
...(articleId ? { articleId } : {}), bannerUrl: string | undefined
...(reviewerPubkey ? { reviewerPubkey } : {}), invoice: string | undefined
...(json ? { json } : {}), paymentHash: string | undefined
encryptedKey: string | undefined
articleId: string | undefined
reviewerPubkey: string | undefined
json: string | undefined
}): Record<string, string> {
let optional: Record<string, string> = {}
optional = addOptionalString(optional, 'id', base.id)
optional = addOptionalString(optional, 'service', base.service)
optional = addOptionalString(optional, 'title', base.title)
optional = addOptionalString(optional, 'preview', base.preview)
optional = addOptionalString(optional, 'description', base.description)
optional = addOptionalString(optional, 'mainnetAddress', base.mainnetAddress)
optional = addOptionalString(optional, 'pictureUrl', base.pictureUrl)
optional = addOptionalString(optional, 'seriesId', base.seriesId)
optional = addOptionalString(optional, 'coverUrl', base.coverUrl)
optional = addOptionalString(optional, 'bannerUrl', base.bannerUrl)
optional = addOptionalString(optional, 'invoice', base.invoice)
optional = addOptionalString(optional, 'paymentHash', base.paymentHash)
optional = addOptionalString(optional, 'encryptedKey', base.encryptedKey)
optional = addOptionalString(optional, 'articleId', base.articleId)
optional = addOptionalString(optional, 'reviewerPubkey', base.reviewerPubkey)
optional = addOptionalString(optional, 'json', base.json)
return optional
}
function buildOptionalNumericFields(base: { totalSponsoring: number | undefined; zapAmount: number | undefined }): {
totalSponsoring?: number
zapAmount?: number
} {
return {
...(base.totalSponsoring !== undefined ? { totalSponsoring: base.totalSponsoring } : {}),
...(base.zapAmount !== undefined ? { zapAmount: base.zapAmount } : {}),
} }
} }

View File

@ -79,41 +79,60 @@ export function checkZapReceipt(
userPubkey: string userPubkey: string
} }
): Promise<boolean> { ): Promise<boolean> {
if (!params.pool) { return setupZapReceiptCheck(params)
return Promise.resolve(false) }
}
function setupZapReceiptCheck(params: {
pool: SimplePool
targetPubkey: string
targetEventId: string
amount: number
userPubkey: string
}): Promise<boolean> {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
let resolved = false
const relayUrl = getPrimaryRelaySync() const relayUrl = getPrimaryRelaySync()
const sub = createSubscription(params.pool, [relayUrl], createZapFilters(params.targetPubkey, params.targetEventId, params.userPubkey)) const sub = createSubscription(params.pool, [relayUrl], createZapFilters(params.targetPubkey, params.targetEventId, params.userPubkey))
const state = createZapReceiptState({ sub, resolve })
registerZapReceiptHandlers({ sub, params, state })
})
}
function createZapReceiptState(params: {
sub: import('@/types/nostr-tools-extended').Subscription
resolve: (value: boolean) => void
}): { resolvedRef: { current: boolean }; finalize: (value: boolean) => void } {
const resolvedRef = { current: false }
const finalize = (value: boolean): void => { const finalize = (value: boolean): void => {
if (resolved) { if (resolvedRef.current) {
return return
} }
resolved = true resolvedRef.current = true
sub.unsub() params.sub.unsub()
resolve(value) params.resolve(value)
} }
return { resolvedRef, finalize }
}
const resolvedRef = { current: resolved } function registerZapReceiptHandlers(params: {
sub.on('event', (event: Event): void => { sub: import('@/types/nostr-tools-extended').Subscription
params: { targetPubkey: string; targetEventId: string; amount: number; userPubkey: string }
state: { resolvedRef: { current: boolean }; finalize: (value: boolean) => void }
}): void {
params.sub.on('event', (event: Event): void => {
handleZapReceiptEvent({ handleZapReceiptEvent({
event, event,
targetEventId: params.targetEventId, targetEventId: params.params.targetEventId,
targetPubkey: params.targetPubkey, targetPubkey: params.params.targetPubkey,
userPubkey: params.userPubkey, userPubkey: params.params.userPubkey,
amount: params.amount, amount: params.params.amount,
finalize, finalize: params.state.finalize,
resolved: resolvedRef, resolved: params.state.resolvedRef,
}) })
}) })
const end = (): void => { const end = (): void => {
finalize(false) params.state.finalize(false)
} }
sub.on('eose', end) params.sub.on('eose', end)
setTimeout(end, 3000) setTimeout(end, 3000)
})
} }

View File

@ -7,6 +7,14 @@ import { objectCache } from './objectCache'
import { notificationService, type NotificationType } from './notificationService' import { notificationService, type NotificationType } from './notificationService'
import type { CachedObject } from './objectCache' import type { CachedObject } from './objectCache'
const USER_OBJECT_NOTIFICATION_TYPES: Array<{ type: string; notificationType: NotificationType }> = [
{ type: 'purchase', notificationType: 'purchase' },
{ type: 'review', notificationType: 'review' },
{ type: 'sponsoring', notificationType: 'sponsoring' },
{ type: 'review_tip', notificationType: 'review_tip' },
{ type: 'payment_note', notificationType: 'payment_note' },
]
interface ObjectChange { interface ObjectChange {
objectType: string objectType: string
objectId: string objectId: string
@ -85,58 +93,42 @@ class NotificationDetector {
return return
} }
const objectTypes: Array<{ type: string; notificationType: NotificationType }> = [ for (const cfg of USER_OBJECT_NOTIFICATION_TYPES) {
{ type: 'purchase', notificationType: 'purchase' }, await this.scanUserObjectsOfType(cfg)
{ type: 'review', notificationType: 'review' }, }
{ type: 'sponsoring', notificationType: 'sponsoring' }, }
{ type: 'review_tip', notificationType: 'review_tip' },
{ type: 'payment_note', notificationType: 'payment_note' },
]
for (const { type, notificationType } of objectTypes) { private async scanUserObjectsOfType(params: { type: string; notificationType: NotificationType }): Promise<void> {
try { try {
const allObjects = await objectCache.getAll(type as Parameters<typeof objectCache.getAll>[0]) const {userPubkey} = this
const userObjects = (allObjects as CachedObject[]).filter((obj: CachedObject) => { if (!userPubkey) {
// Check if object is related to the user return
// For purchases: targetPubkey === userPubkey }
// For reviews: targetEventId points to user's article const allObjects = await objectCache.getAll(params.type as Parameters<typeof objectCache.getAll>[0])
// For sponsoring: targetPubkey === userPubkey const userObjects = filterUserRelatedObjects({ type: params.type, allObjects: allObjects as CachedObject[], userPubkey })
// For review_tips: targetEventId points to user's review await this.createNotificationsForNewObjects({ type: params.type, notificationType: params.notificationType, objects: userObjects })
// For payment_notes: targetPubkey === userPubkey } catch (error) {
console.error(`[NotificationDetector] Error scanning ${params.type}:`, error)
if (type === 'purchase' || type === 'sponsoring' || type === 'payment_note') { }
return (obj as { targetPubkey?: string }).targetPubkey === this.userPubkey
} }
if (type === 'review' || type === 'review_tip') { private async createNotificationsForNewObjects(params: {
// Need to check if the target event belongs to the user type: string
// This is more complex and may require checking the article/review notificationType: NotificationType
// For now, we'll create notifications for all reviews/tips objects: CachedObject[]
// The UI can filter them if needed }): Promise<void> {
return true for (const obj of params.objects) {
} if (obj.createdAt * 1000 > this.lastScanTime) {
const eventId = obj.id.split(':')[1] ?? obj.id
return false
})
// Create notifications for objects created after last scan
for (const obj of userObjects) {
const cachedObj = obj
if (cachedObj.createdAt * 1000 > this.lastScanTime) {
const eventId = cachedObj.id.split(':')[1] ?? cachedObj.id
await notificationService.createNotification({ await notificationService.createNotification({
type: notificationType, type: params.notificationType,
objectType: type, objectType: params.type,
objectId: cachedObj.id, objectId: obj.id,
eventId, eventId,
data: { object: obj }, data: { object: obj },
}) })
} }
} }
} catch (error) {
console.error(`[NotificationDetector] Error scanning ${type}:`, error)
}
}
} }
/** /**
@ -251,4 +243,16 @@ class NotificationDetector {
} }
function filterUserRelatedObjects(params: { type: string; allObjects: CachedObject[]; userPubkey: string }): CachedObject[] {
return params.allObjects.filter((obj: CachedObject) => {
if (params.type === 'purchase' || params.type === 'sponsoring' || params.type === 'payment_note') {
return (obj as { targetPubkey?: string }).targetPubkey === params.userPubkey
}
if (params.type === 'review' || params.type === 'review_tip') {
return true
}
return false
})
}
export const notificationDetector = new NotificationDetector() export const notificationDetector = new NotificationDetector()

View File

@ -34,45 +34,10 @@ class ObjectCacheService {
private getDBHelper(objectType: ObjectType): IndexedDBHelper { private getDBHelper(objectType: ObjectType): IndexedDBHelper {
if (!this.dbHelpers.has(objectType)) { if (!this.dbHelpers.has(objectType)) {
const helper = createIndexedDBHelper({ const helper = createDbHelperForObjectType(objectType)
dbName: `${DB_PREFIX}${objectType}`,
version: DB_VERSION,
storeName: STORE_NAME,
keyPath: 'id',
indexes: [
{ name: 'hash', keyPath: 'hash', unique: false },
{ name: 'hashId', keyPath: 'hashId', unique: false }, // Legacy index
{ name: 'version', keyPath: 'version', unique: false },
{ name: 'index', keyPath: 'index', unique: false },
{ name: 'hidden', keyPath: 'hidden', unique: false },
{ name: 'cachedAt', keyPath: 'cachedAt', unique: false },
{ name: 'published', keyPath: 'published', unique: false },
],
onUpgrade: (_db: IDBDatabase, event: IDBVersionChangeEvent): void => {
// Migration: add new indexes if they don't exist
const target = event.target as IDBOpenDBRequest
const { transaction } = target
if (transaction) {
const store = transaction.objectStore(STORE_NAME)
if (!store.indexNames.contains('hash')) {
store.createIndex('hash', 'hash', { unique: false })
}
if (!store.indexNames.contains('index')) {
store.createIndex('index', 'index', { unique: false })
}
if (!store.indexNames.contains('published')) {
store.createIndex('published', 'published', { unique: false })
}
}
},
})
this.dbHelpers.set(objectType, helper) this.dbHelpers.set(objectType, helper)
} }
const helper = this.dbHelpers.get(objectType) return getRequiredDbHelper(this.dbHelpers, objectType)
if (!helper) {
throw new Error(`Database helper not found for ${objectType}`)
}
return helper
} }
/** /**
@ -90,7 +55,7 @@ class ObjectCacheService {
private async countObjectsWithHash(objectType: ObjectType, hash: string): Promise<number> { private async countObjectsWithHash(objectType: ObjectType, hash: string): Promise<number> {
try { try {
const helper = this.getDBHelper(objectType) const helper = this.getDBHelper(objectType)
return await helper.countByIndex('hash', IDBKeyRange.only(hash)) return helper.countByIndex('hash', IDBKeyRange.only(hash))
} catch (countError) { } catch (countError) {
console.error(`Error counting objects with hash ${hash}:`, countError) console.error(`Error counting objects with hash ${hash}:`, countError)
return 0 return 0
@ -114,40 +79,64 @@ class ObjectCacheService {
}): Promise<void> { }): Promise<void> {
try { try {
const helper = this.getDBHelper(params.objectType) const helper = this.getDBHelper(params.objectType)
const index = await this.resolveIndex(params.objectType, params.hash, params.index)
// If index is not provided, calculate it by counting objects with the same hash const id = buildObjectId(params.hash, index, params.version)
let finalIndex = params.index const published = await this.resolvePublishedForUpsert(helper, id, params.published)
if (finalIndex === undefined) { await helper.put(this.buildCachedObject(params, id, index, published))
const count = await this.countObjectsWithHash(params.objectType, params.hash) } catch (cacheError) {
finalIndex = count console.error(`Error caching ${params.objectType} object:`, cacheError)
}
} }
const id = buildObjectId(params.hash, finalIndex, params.version) private async resolveIndex(
objectType: ObjectType,
hash: string,
index: number | undefined
): Promise<number> {
if (index !== undefined) {
return index
}
return this.countObjectsWithHash(objectType, hash)
}
// Check if object already exists to preserve published status if updating private async resolvePublishedForUpsert(
helper: IndexedDBHelper,
id: string,
published: false | string[] | undefined
): Promise<false | string[]> {
const nextPublished = published ?? false
if (nextPublished !== false) {
return nextPublished
}
const existing = await helper.get<CachedObject>(id).catch(() => null) const existing = await helper.get<CachedObject>(id).catch(() => null)
return existing ? existing.published : false
}
// If updating and published is not provided, preserve existing published status private buildCachedObject(
const published = params.published ?? false params: {
const finalPublished = existing && published === false ? existing.published : published objectType: ObjectType
hash: string
const cached: CachedObject = { event: NostrEvent
parsed: unknown
version: number
hidden: boolean
},
id: string,
index: number,
published: false | string[]
): CachedObject {
return {
id, id,
hash: params.hash, hash: params.hash,
hashId: params.hash, // Legacy field for backward compatibility hashId: params.hash, // Legacy field for backward compatibility
index: finalIndex, index,
event: params.event, event: params.event,
parsed: params.parsed, parsed: params.parsed,
version: params.version, version: params.version,
hidden: params.hidden, hidden: params.hidden,
createdAt: params.event.created_at, createdAt: params.event.created_at,
cachedAt: Date.now(), cachedAt: Date.now(),
published: finalPublished, published,
}
await helper.put(cached)
} catch (cacheError) {
console.error(`Error caching ${params.objectType} object:`, cacheError)
} }
} }
@ -405,3 +394,55 @@ class ObjectCacheService {
} }
export const objectCache = new ObjectCacheService() export const objectCache = new ObjectCacheService()
function createDbHelperForObjectType(objectType: ObjectType): IndexedDBHelper {
return createIndexedDBHelper({
dbName: `${DB_PREFIX}${objectType}`,
version: DB_VERSION,
storeName: STORE_NAME,
keyPath: 'id',
indexes: getObjectCacheIndexes(),
onUpgrade: (_db: IDBDatabase, event: IDBVersionChangeEvent): void => {
handleObjectCacheUpgrade(event)
},
})
}
function getObjectCacheIndexes(): Array<{ name: string; keyPath: string; unique: boolean }> {
return [
{ name: 'hash', keyPath: 'hash', unique: false },
{ name: 'hashId', keyPath: 'hashId', unique: false },
{ name: 'version', keyPath: 'version', unique: false },
{ name: 'index', keyPath: 'index', unique: false },
{ name: 'hidden', keyPath: 'hidden', unique: false },
{ name: 'cachedAt', keyPath: 'cachedAt', unique: false },
{ name: 'published', keyPath: 'published', unique: false },
]
}
function handleObjectCacheUpgrade(event: IDBVersionChangeEvent): void {
// Migration: add new indexes if they don't exist
const target = event.target as IDBOpenDBRequest
const { transaction } = target
if (!transaction) {
return
}
const store = transaction.objectStore(STORE_NAME)
ensureIndex(store, 'hash', 'hash')
ensureIndex(store, 'index', 'index')
ensureIndex(store, 'published', 'published')
}
function ensureIndex(store: IDBObjectStore, name: string, keyPath: string): void {
if (!store.indexNames.contains(name)) {
store.createIndex(name, keyPath, { unique: false })
}
}
function getRequiredDbHelper(map: Map<ObjectType, IndexedDBHelper>, objectType: ObjectType): IndexedDBHelper {
const helper = map.get(objectType)
if (!helper) {
throw new Error(`Database helper not found for ${objectType}`)
}
return helper
}

View File

@ -8,6 +8,7 @@ import { finalizeEvent } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils' import { hexToBytes } from 'nostr-tools/utils'
import type { Purchase, ReviewTip, Sponsoring } from '@/types/nostr' import type { Purchase, ReviewTip, Sponsoring } from '@/types/nostr'
import { writeOrchestrator } from './writeOrchestrator' import { writeOrchestrator } from './writeOrchestrator'
import { getPublishRelays } from './relaySelection'
/** /**
* Publish an explicit payment note (kind 1) for a purchase * Publish an explicit payment note (kind 1) for a purchase
@ -24,108 +25,18 @@ export async function publishPurchaseNote(params: {
seriesId?: string seriesId?: string
payerPrivateKey: string payerPrivateKey: string
}): Promise<Event | null> { }): Promise<Event | null> {
let category: 'sciencefiction' | 'research' = 'sciencefiction' const category = mapPaymentCategory(params.category)
if (params.category === 'science-fiction') { const payload = await buildPurchaseNotePayload({ ...params, category })
category = 'sciencefiction' return publishPaymentNoteToRelays({
} else if (params.category === 'scientific-research') { payerPrivateKey: params.payerPrivateKey,
category = 'research'
}
const purchaseData = {
payerPubkey: params.payerPubkey,
articleId: params.articleId,
authorPubkey: params.authorPubkey,
amount: params.amount,
paymentHash: params.paymentHash,
}
const hashId = await generatePurchaseHashId(purchaseData)
const id = buildObjectId(hashId, 0, 0)
const tags = buildTags({
type: 'payment',
category,
id: hashId,
service: PLATFORM_SERVICE,
version: 0,
hidden: false,
payment: true,
paymentType: 'purchase',
amount: params.amount,
payerPubkey: params.payerPubkey,
recipientPubkey: params.authorPubkey,
paymentHash: params.paymentHash,
articleId: params.articleId,
...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
...(params.seriesId ? { seriesId: params.seriesId } : {}),
})
// Build JSON metadata
const paymentJson = JSON.stringify({
type: 'purchase',
id,
hash: hashId,
version: 0,
index: 0,
...purchaseData,
})
tags.push(['json', paymentJson])
// Build parsed Purchase object
const parsedPurchase: Purchase = {
id,
hash: hashId,
version: 0,
index: 0,
payerPubkey: params.payerPubkey,
articleId: params.articleId,
authorPubkey: params.authorPubkey,
amount: params.amount,
paymentHash: params.paymentHash,
createdAt: Math.floor(Date.now() / 1000),
kindType: 'purchase',
}
const eventTemplate: EventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
content: `Purchase confirmed: ${params.amount} sats for article ${params.articleId}`,
}
nostrService.setPrivateKey(params.payerPrivateKey)
writeOrchestrator.setPrivateKey(params.payerPrivateKey)
// Finalize event
const secretKey = hexToBytes(params.payerPrivateKey)
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: 'purchase', objectType: 'purchase',
hash: hashId, hash: payload.hashId,
event, eventTemplate: payload.eventTemplate,
parsed: parsedPurchase, parsed: payload.parsedPurchase,
version: 0, version: 0,
hidden: false, hidden: false,
index: 0, index: 0,
}, })
relays
)
if (!result.success) {
return null
}
return event
} }
/** /**
@ -146,120 +57,18 @@ export async function publishReviewTipNote(params: {
text?: string text?: string
payerPrivateKey: string payerPrivateKey: string
}): Promise<Event | null> { }): Promise<Event | null> {
let category: 'sciencefiction' | 'research' = 'sciencefiction' const category = mapPaymentCategory(params.category)
if (params.category === 'science-fiction') { const payload = await buildReviewTipNotePayload({ ...params, category })
category = 'sciencefiction' return publishPaymentNoteToRelays({
} else if (params.category === 'scientific-research') { payerPrivateKey: params.payerPrivateKey,
category = 'research'
}
const tipData = {
payerPubkey: params.payerPubkey,
articleId: params.articleId,
reviewId: params.reviewId,
reviewerPubkey: params.reviewerPubkey,
authorPubkey: params.authorPubkey,
amount: params.amount,
paymentHash: params.paymentHash,
}
const hashId = await generateReviewTipHashId(tipData)
const id = buildObjectId(hashId, 0, 0)
const tags = buildTags({
type: 'payment',
category,
id: hashId,
service: PLATFORM_SERVICE,
version: 0,
hidden: false,
payment: true,
paymentType: 'review_tip',
amount: params.amount,
payerPubkey: params.payerPubkey,
recipientPubkey: params.reviewerPubkey,
paymentHash: params.paymentHash,
articleId: params.articleId,
reviewId: params.reviewId,
...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
...(params.seriesId ? { seriesId: params.seriesId } : {}),
...(params.text ? { text: params.text } : {}),
})
// Build JSON metadata
const paymentJson = JSON.stringify({
type: 'review_tip',
id,
hash: hashId,
version: 0,
index: 0,
...tipData,
...(params.text ? { text: params.text } : {}),
})
tags.push(['json', paymentJson])
// Build parsed ReviewTip object
const parsedReviewTip: ReviewTip = {
id,
hash: hashId,
version: 0,
index: 0,
payerPubkey: params.payerPubkey,
articleId: params.articleId,
reviewId: params.reviewId,
reviewerPubkey: params.reviewerPubkey,
authorPubkey: params.authorPubkey,
amount: params.amount,
paymentHash: params.paymentHash,
createdAt: Math.floor(Date.now() / 1000),
...(params.text ? { text: params.text } : {}),
kindType: 'review_tip',
}
const content = params.text
? `Review tip confirmed: ${params.amount} sats for review ${params.reviewId}\n\n${params.text}`
: `Review tip confirmed: ${params.amount} sats for review ${params.reviewId}`
const eventTemplate: EventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
content,
}
nostrService.setPrivateKey(params.payerPrivateKey)
writeOrchestrator.setPrivateKey(params.payerPrivateKey)
// Finalize event
const secretKey = hexToBytes(params.payerPrivateKey)
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_tip', objectType: 'review_tip',
hash: hashId, hash: payload.hashId,
event, eventTemplate: payload.eventTemplate,
parsed: parsedReviewTip, parsed: payload.parsedReviewTip,
version: 0, version: 0,
hidden: false, hidden: false,
index: 0, index: 0,
}, })
relays
)
if (!result.success) {
return null
}
return event
} }
/** /**
@ -278,13 +87,240 @@ export async function publishSponsoringNote(params: {
transactionId?: string // Bitcoin transaction ID for mainnet payments transactionId?: string // Bitcoin transaction ID for mainnet payments
payerPrivateKey: string payerPrivateKey: string
}): Promise<Event | null> { }): Promise<Event | null> {
let category: 'sciencefiction' | 'research' = 'sciencefiction' const category = mapPaymentCategory(params.category)
if (params.category === 'science-fiction') { const payload = await buildSponsoringNotePayload({ ...params, category })
category = 'sciencefiction'
} else if (params.category === 'scientific-research') {
category = 'research'
}
return publishPaymentNoteToRelays({
payerPrivateKey: params.payerPrivateKey,
objectType: 'sponsoring',
hash: payload.hashId,
eventTemplate: payload.eventTemplate,
parsed: payload.parsedSponsoring,
version: 0,
hidden: false,
index: 0,
})
}
function mapPaymentCategory(
category: 'science-fiction' | 'scientific-research' | undefined
): 'sciencefiction' | 'research' {
if (category === 'scientific-research') {
return 'research'
}
return 'sciencefiction'
}
async function buildPurchaseNotePayload(params: {
articleId: string
authorPubkey: string
payerPubkey: string
amount: number
paymentHash: string
zapReceiptId?: string
category: 'sciencefiction' | 'research'
seriesId?: string
}): Promise<{ hashId: string; eventTemplate: EventTemplate; parsedPurchase: Purchase }> {
const purchaseData = {
payerPubkey: params.payerPubkey,
articleId: params.articleId,
authorPubkey: params.authorPubkey,
amount: params.amount,
paymentHash: params.paymentHash,
...(params.seriesId ? { seriesId: params.seriesId } : {}),
}
const hashId = await generatePurchaseHashId(purchaseData)
const id = buildObjectId(hashId, 0, 0)
const tags = buildPurchaseNoteTags({ ...params, hashId })
tags.push(['json', JSON.stringify({ type: 'purchase', id, hash: hashId, version: 0, index: 0, ...purchaseData })])
const parsedPurchase = buildParsedPurchase({ ...params, id, hashId })
return { hashId, eventTemplate: buildPaymentNoteTemplate(tags, `Purchase confirmed: ${params.amount} sats for article ${params.articleId}`), parsedPurchase }
}
function buildPurchaseNoteTags(params: {
articleId: string
authorPubkey: string
payerPubkey: string
amount: number
paymentHash: string
zapReceiptId?: string
category: 'sciencefiction' | 'research'
seriesId?: string
hashId: string
}): string[][] {
return buildTags({
type: 'payment',
category: params.category,
id: params.hashId,
service: PLATFORM_SERVICE,
version: 0,
hidden: false,
payment: true,
paymentType: 'purchase',
amount: params.amount,
payerPubkey: params.payerPubkey,
recipientPubkey: params.authorPubkey,
paymentHash: params.paymentHash,
articleId: params.articleId,
...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
...(params.seriesId ? { seriesId: params.seriesId } : {}),
})
}
function buildParsedPurchase(params: {
articleId: string
authorPubkey: string
payerPubkey: string
amount: number
paymentHash: string
id: string
hashId: string
seriesId?: string
}): Purchase {
return {
id: params.id,
hash: params.hashId,
version: 0,
index: 0,
payerPubkey: params.payerPubkey,
articleId: params.articleId,
authorPubkey: params.authorPubkey,
amount: params.amount,
paymentHash: params.paymentHash,
createdAt: Math.floor(Date.now() / 1000),
...(params.seriesId ? { seriesId: params.seriesId } : {}),
kindType: 'purchase',
}
}
async function buildReviewTipNotePayload(params: {
articleId: string
reviewId: string
authorPubkey: string
reviewerPubkey: string
payerPubkey: string
amount: number
paymentHash: string
zapReceiptId?: string
category: 'sciencefiction' | 'research'
seriesId?: string
text?: string
}): Promise<{ hashId: string; eventTemplate: EventTemplate; parsedReviewTip: ReviewTip }> {
const tipData = {
payerPubkey: params.payerPubkey,
articleId: params.articleId,
reviewId: params.reviewId,
reviewerPubkey: params.reviewerPubkey,
authorPubkey: params.authorPubkey,
amount: params.amount,
paymentHash: params.paymentHash,
...(params.seriesId ? { seriesId: params.seriesId } : {}),
...(params.text ? { text: params.text } : {}),
}
const hashId = await generateReviewTipHashId(tipData)
const id = buildObjectId(hashId, 0, 0)
const tags = buildReviewTipNoteTags({ ...params, hashId })
tags.push(['json', JSON.stringify({ type: 'review_tip', id, hash: hashId, version: 0, index: 0, ...tipData })])
const parsedReviewTip = buildParsedReviewTip({ ...params, id, hashId })
return { hashId, eventTemplate: buildPaymentNoteTemplate(tags, buildReviewTipNoteContent(params)), parsedReviewTip }
}
function buildReviewTipNoteTags(params: {
articleId: string
reviewId: string
authorPubkey: string
reviewerPubkey: string
payerPubkey: string
amount: number
paymentHash: string
zapReceiptId?: string
category: 'sciencefiction' | 'research'
seriesId?: string
text?: string
hashId: string
}): string[][] {
return buildTags({
type: 'payment',
category: params.category,
id: params.hashId,
service: PLATFORM_SERVICE,
version: 0,
hidden: false,
payment: true,
paymentType: 'review_tip',
amount: params.amount,
payerPubkey: params.payerPubkey,
recipientPubkey: params.reviewerPubkey,
paymentHash: params.paymentHash,
articleId: params.articleId,
reviewId: params.reviewId,
...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
...(params.seriesId ? { seriesId: params.seriesId } : {}),
...(params.text ? { text: params.text } : {}),
})
}
function buildReviewTipNoteContent(params: { amount: number; reviewId: string; text?: string }): string {
const prefix = `Review tip confirmed: ${params.amount} sats for review ${params.reviewId}`
return params.text ? `${prefix}\n\n${params.text}` : prefix
}
function buildParsedReviewTip(params: {
articleId: string
reviewId: string
authorPubkey: string
reviewerPubkey: string
payerPubkey: string
amount: number
paymentHash: string
seriesId?: string
text?: string
id: string
hashId: string
}): ReviewTip {
return {
id: params.id,
hash: params.hashId,
version: 0,
index: 0,
payerPubkey: params.payerPubkey,
articleId: params.articleId,
reviewId: params.reviewId,
reviewerPubkey: params.reviewerPubkey,
authorPubkey: params.authorPubkey,
amount: params.amount,
paymentHash: params.paymentHash,
createdAt: Math.floor(Date.now() / 1000),
...(params.seriesId ? { seriesId: params.seriesId } : {}),
...(params.text ? { text: params.text } : {}),
kindType: 'review_tip',
}
}
function buildPaymentNoteTemplate(tags: string[][], content: string): EventTemplate {
return {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
content,
}
}
async function buildSponsoringNotePayload(params: {
authorPubkey: string
payerPubkey: string
amount: number
paymentHash: string
category: 'sciencefiction' | 'research'
seriesId?: string
articleId?: string
text?: string
transactionId?: string
}): Promise<{
hashId: string
eventTemplate: EventTemplate
parsedSponsoring: Sponsoring
}> {
const sponsoringData = { const sponsoringData = {
payerPubkey: params.payerPubkey, payerPubkey: params.payerPubkey,
authorPubkey: params.authorPubkey, authorPubkey: params.authorPubkey,
@ -296,45 +332,9 @@ export async function publishSponsoringNote(params: {
const hashId = await generateSponsoringHashId(sponsoringData) const hashId = await generateSponsoringHashId(sponsoringData)
const id = buildObjectId(hashId, 0, 0) const id = buildObjectId(hashId, 0, 0)
const tags = buildSponsoringNoteTags({ ...params, hashId })
tags.push(['json', buildSponsoringPaymentJson({ ...params, sponsoringData, id, hashId })])
const tags = buildTags({
type: 'payment',
category,
id: hashId,
service: PLATFORM_SERVICE,
version: 0,
hidden: false,
payment: true,
paymentType: 'sponsoring',
amount: params.amount,
payerPubkey: params.payerPubkey,
recipientPubkey: params.authorPubkey,
paymentHash: params.paymentHash,
...(params.seriesId ? { seriesId: params.seriesId } : {}),
...(params.articleId ? { articleId: params.articleId } : {}),
...(params.text ? { text: params.text } : {}),
})
// Add transaction ID if provided (for Bitcoin mainnet payments)
if (params.transactionId) {
tags.push(['transaction_id', params.transactionId])
}
// Build JSON metadata
const paymentJson = JSON.stringify({
type: 'sponsoring',
id,
hash: hashId,
version: 0,
index: 0,
...sponsoringData,
...(params.text ? { text: params.text } : {}),
...(params.transactionId ? { transactionId: params.transactionId } : {}),
})
tags.push(['json', paymentJson])
// Build parsed Sponsoring object
const parsedSponsoring: Sponsoring = { const parsedSponsoring: Sponsoring = {
id, id,
hash: hashId, hash: hashId,
@ -351,40 +351,107 @@ export async function publishSponsoringNote(params: {
kindType: 'sponsoring', kindType: 'sponsoring',
} }
const content = params.text
? `Sponsoring confirmed: ${params.amount} sats for author ${params.authorPubkey.substring(0, 16)}...\n\n${params.text}`
: `Sponsoring confirmed: ${params.amount} sats for author ${params.authorPubkey.substring(0, 16)}...`
const eventTemplate: EventTemplate = { const eventTemplate: EventTemplate = {
kind: 1, kind: 1,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags, tags,
content, content: buildSponsoringNoteContent(params),
} }
return { hashId, eventTemplate, parsedSponsoring }
}
function buildSponsoringNoteContent(params: {
authorPubkey: string
amount: number
text?: string
}): string {
const prefix = `Sponsoring confirmed: ${params.amount} sats for author ${params.authorPubkey.substring(0, 16)}...`
return params.text ? `${prefix}\n\n${params.text}` : prefix
}
function buildSponsoringNoteTags(params: {
authorPubkey: string
payerPubkey: string
amount: number
paymentHash: string
category: 'sciencefiction' | 'research'
seriesId?: string
articleId?: string
text?: string
transactionId?: string
hashId: string
}): string[][] {
const tags = buildTags({
type: 'payment',
category: params.category,
id: params.hashId,
service: PLATFORM_SERVICE,
version: 0,
hidden: false,
payment: true,
paymentType: 'sponsoring',
amount: params.amount,
payerPubkey: params.payerPubkey,
recipientPubkey: params.authorPubkey,
paymentHash: params.paymentHash,
...(params.seriesId ? { seriesId: params.seriesId } : {}),
...(params.articleId ? { articleId: params.articleId } : {}),
...(params.text ? { text: params.text } : {}),
})
if (params.transactionId) {
tags.push(['transaction_id', params.transactionId])
}
return tags
}
function buildSponsoringPaymentJson(params: {
id: string
hashId: string
sponsoringData: Record<string, unknown>
text?: string
transactionId?: string
}): string {
return JSON.stringify({
type: 'sponsoring',
id: params.id,
hash: params.hashId,
version: 0,
index: 0,
...params.sponsoringData,
...(params.text ? { text: params.text } : {}),
...(params.transactionId ? { transactionId: params.transactionId } : {}),
})
}
async function publishPaymentNoteToRelays(params: {
payerPrivateKey: string
objectType: 'purchase' | 'review_tip' | 'sponsoring'
hash: string
eventTemplate: EventTemplate
parsed: Purchase | ReviewTip | Sponsoring
version: number
hidden: boolean
index: number
}): Promise<Event | null> {
nostrService.setPrivateKey(params.payerPrivateKey) nostrService.setPrivateKey(params.payerPrivateKey)
writeOrchestrator.setPrivateKey(params.payerPrivateKey) writeOrchestrator.setPrivateKey(params.payerPrivateKey)
// Finalize event
const secretKey = hexToBytes(params.payerPrivateKey) const secretKey = hexToBytes(params.payerPrivateKey)
const event = finalizeEvent(eventTemplate, secretKey) const event = finalizeEvent(params.eventTemplate, secretKey)
const relays = await getPublishRelays()
// 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( const result = await writeOrchestrator.writeAndPublish(
{ {
objectType: 'sponsoring', objectType: params.objectType,
hash: hashId, hash: params.hash,
event, event,
parsed: parsedSponsoring, parsed: params.parsed,
version: 0, version: params.version,
hidden: false, hidden: params.hidden,
index: 0, index: params.index,
}, },
relays relays
) )
@ -392,6 +459,5 @@ export async function publishSponsoringNote(params: {
if (!result.success) { if (!result.success) {
return null return null
} }
return event return event
} }

View File

@ -26,7 +26,10 @@ export async function sendPrivateContentAfterPayment(
const result = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, validation.authorPrivateKey) const result = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, validation.authorPrivateKey)
if (result.success && result.messageEventId) { if (!result.success || !result.messageEventId) {
return logPaymentResult(result, articleId, recipientPubkey, amount)
}
verifyPaymentAmount(amount, articleId) verifyPaymentAmount(amount, articleId)
const trackingData = createTrackingData({ const trackingData = createTrackingData({
@ -42,45 +45,54 @@ export async function sendPrivateContentAfterPayment(
await platformTracking.trackContentDelivery(trackingData, validation.authorPrivateKey) await platformTracking.trackContentDelivery(trackingData, validation.authorPrivateKey)
await triggerAutomaticTransfer(validation.storedContent.authorPubkey, articleId, amount) await triggerAutomaticTransfer(validation.storedContent.authorPubkey, articleId, amount)
// Publish explicit payment note (kind 1) with project tags await tryPublishPurchaseNote({
try { articleId,
const article = await nostrService.getArticleById(articleId) recipientPubkey,
if (!article) { amount,
...(zapReceiptId ? { zapReceiptId } : {}),
})
return logPaymentResult(result, articleId, recipientPubkey, amount) return logPaymentResult(result, articleId, recipientPubkey, amount)
}
async function tryPublishPurchaseNote(params: {
articleId: string
recipientPubkey: string
amount: number
zapReceiptId?: string
}): Promise<void> {
try {
const article = await nostrService.getArticleById(params.articleId)
if (!article) {
return
} }
const payerPrivateKey = nostrService.getPrivateKey() const payerPrivateKey = nostrService.getPrivateKey()
if (!payerPrivateKey) { if (!payerPrivateKey) {
return logPaymentResult(result, articleId, recipientPubkey, amount) return
} }
const paymentHash = await resolvePaymentHashForPurchaseNote({ const paymentHash = await resolvePaymentHashForPurchaseNote({
articlePaymentHash: article.paymentHash, articlePaymentHash: article.paymentHash,
articleId, articleId: params.articleId,
...(zapReceiptId ? { zapReceiptId } : {}), ...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
}) })
const category = normalizePurchaseNoteCategory(article.category) const category = normalizePurchaseNoteCategory(article.category)
await publishPurchaseNote({ await publishPurchaseNote({
articleId: article.id, articleId: article.id,
authorPubkey: article.pubkey, authorPubkey: article.pubkey,
payerPubkey: recipientPubkey, payerPubkey: params.recipientPubkey,
amount, amount: params.amount,
paymentHash, paymentHash,
...(zapReceiptId ? { zapReceiptId } : {}), ...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
...(category ? { category } : {}), ...(category ? { category } : {}),
...(article.seriesId ? { seriesId: article.seriesId } : {}), ...(article.seriesId ? { seriesId: article.seriesId } : {}),
payerPrivateKey, payerPrivateKey,
}) })
} catch (error) { } catch (error) {
console.error('Error publishing purchase note:', error) console.error('Error publishing purchase note:', error)
// Don't fail the payment if note publication fails
} }
return logPaymentResult(result, articleId, recipientPubkey, amount)
}
return logPaymentResult(result, articleId, recipientPubkey, amount)
} }
function normalizePurchaseNoteCategory(category: string | undefined): 'science-fiction' | 'scientific-research' | undefined { function normalizePurchaseNoteCategory(category: string | undefined): 'science-fiction' | 'scientific-research' | undefined {

View File

@ -13,6 +13,8 @@ import { extractTagsFromEvent } from './nostrTagSystem'
import { parsePresentationEvent } from './articlePublisherHelpersPresentation' import { parsePresentationEvent } from './articlePublisherHelpersPresentation'
import { parseArticleFromEvent, parseSeriesFromEvent, parseReviewFromEvent, parsePurchaseFromEvent, parseReviewTipFromEvent, parseSponsoringFromEvent } from './nostrEventParsing' import { parseArticleFromEvent, parseSeriesFromEvent, parseReviewFromEvent, parsePurchaseFromEvent, parseReviewTipFromEvent, parseSponsoringFromEvent } from './nostrEventParsing'
const TARGET_EVENT_ID = '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763'
class PlatformSyncService { class PlatformSyncService {
private syncInProgress = false private syncInProgress = false
private syncSubscription: { unsub: () => void } | null = null private syncSubscription: { unsub: () => void } | null = null
@ -145,45 +147,12 @@ class PlatformSyncService {
} }
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
sub.on('event', (event: Event): void => { sub.on('event', (event: Event): void => {
eventCount++ eventCount = this.handleRelaySyncEvent({
// Log every 100th event to track progress (reduced frequency since we'll get more events) event,
if (eventCount % 100 === 0) { relayUrl,
console.warn(`[PlatformSync] Received ${eventCount} events from relay ${relayUrl} (client-side filtering in progress)`) relayEvents,
} eventCount,
// Log target event for debugging (always log if we receive it)
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
console.warn(`[PlatformSync] ✅ Received target event from relay ${relayUrl} (event #${eventCount}):`, {
id: event.id,
created_at: event.created_at,
created_at_date: new Date(event.created_at * 1000).toISOString(),
pubkey: event.pubkey,
allTags: event.tags,
serviceTags: event.tags.filter((tag) => tag[0] === 'service'),
}) })
}
// Filter client-side: only process events with service='zapwall.fr'
const tags = extractTagsFromEvent(event)
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
console.warn(`[PlatformSync] Extracted tags for target event:`, {
extractedTags: tags,
hasServiceTag: tags.service === PLATFORM_SERVICE,
serviceValue: tags.service,
expectedService: PLATFORM_SERVICE,
})
}
if (tags.service === PLATFORM_SERVICE) {
relayEvents.push(event)
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
console.warn(`[PlatformSync] Target event accepted and added to relayEvents`)
}
} else if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
// Log events that match filter but don't have service tag
console.warn(`[PlatformSync] Event ${event.id} rejected: service tag is "${tags.service}", expected "${PLATFORM_SERVICE}"`)
}
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {
@ -247,6 +216,74 @@ class PlatformSyncService {
} }
} }
private handleRelaySyncEvent(params: {
event: Event
relayUrl: string
relayEvents: Event[]
eventCount: number
}): number {
const nextCount = params.eventCount + 1
this.logRelaySyncProgress({ relayUrl: params.relayUrl, eventCount: nextCount })
this.logTargetEventReceived({ relayUrl: params.relayUrl, event: params.event, eventCount: nextCount })
const tags = extractTagsFromEvent(params.event)
this.logTargetEventTags({ event: params.event, tags })
if (tags.service === PLATFORM_SERVICE) {
params.relayEvents.push(params.event)
this.logTargetEventAccepted(params.event)
} else {
this.logTargetEventRejected({ event: params.event, tags })
}
return nextCount
}
private logRelaySyncProgress(params: { relayUrl: string; eventCount: number }): void {
if (params.eventCount % 100 === 0) {
console.warn(`[PlatformSync] Received ${params.eventCount} events from relay ${params.relayUrl} (client-side filtering in progress)`)
}
}
private logTargetEventReceived(params: { relayUrl: string; event: Event; eventCount: number }): void {
if (params.event.id !== TARGET_EVENT_ID) {
return
}
console.warn(`[PlatformSync] ✅ Received target event from relay ${params.relayUrl} (event #${params.eventCount}):`, {
id: params.event.id,
created_at: params.event.created_at,
created_at_date: new Date(params.event.created_at * 1000).toISOString(),
pubkey: params.event.pubkey,
allTags: params.event.tags,
serviceTags: params.event.tags.filter((tag) => tag[0] === 'service'),
})
}
private logTargetEventTags(params: { event: Event; tags: ReturnType<typeof extractTagsFromEvent> }): void {
if (params.event.id !== TARGET_EVENT_ID) {
return
}
console.warn(`[PlatformSync] Extracted tags for target event:`, {
extractedTags: params.tags,
hasServiceTag: params.tags.service === PLATFORM_SERVICE,
serviceValue: params.tags.service,
expectedService: PLATFORM_SERVICE,
})
}
private logTargetEventAccepted(event: Event): void {
if (event.id === TARGET_EVENT_ID) {
console.warn(`[PlatformSync] Target event accepted and added to relayEvents`)
}
}
private logTargetEventRejected(params: { event: Event; tags: ReturnType<typeof extractTagsFromEvent> }): void {
if (params.event.id !== TARGET_EVENT_ID) {
return
}
console.warn(`[PlatformSync] Event ${params.event.id} rejected: service tag is "${params.tags.service}", expected "${PLATFORM_SERVICE}"`)
}
/** /**
* Process a single event and cache it * Process a single event and cache it
*/ */
@ -254,128 +291,202 @@ class PlatformSyncService {
const tags = extractTagsFromEvent(event) const tags = extractTagsFromEvent(event)
const { writeObjectToCache } = await import('./helpers/writeObjectHelper') const { writeObjectToCache } = await import('./helpers/writeObjectHelper')
// Log target event for debugging logTargetEventDebug({ event, tags })
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
console.warn(`[PlatformSync] Processing target event:`, {
id: event.id,
type: tags.type,
hidden: tags.hidden,
service: tags.service,
version: tags.version,
})
}
// Skip hidden events
if (tags.hidden) { if (tags.hidden) {
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') { logTargetEventSkipped({ event, tags })
console.warn(`[PlatformSync] Target event skipped: hidden=${tags.hidden}`)
}
return return
} }
// Try to parse and cache by type if (await this.tryCacheAuthorEvent({ event, tags, writeObjectToCache })) {
if (tags.type === 'author') { return
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
console.warn(`[PlatformSync] Attempting to parse target event as author presentation`)
} }
const parsed = await parsePresentationEvent(event) if (await this.tryCacheSeriesEvent({ event, tags, writeObjectToCache })) {
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') { return
console.warn(`[PlatformSync] parsePresentationEvent result for target event:`, {
parsed: parsed !== null,
hasHash: parsed?.hash !== undefined,
hash: parsed?.hash,
})
} }
if (parsed?.hash) { if (await this.tryCachePublicationEvent({ event, tags, writeObjectToCache })) {
await writeObjectToCache({ return
}
if (await this.tryCacheReviewEvent({ event, tags, writeObjectToCache })) {
return
}
await this.tryCacheZapReceiptEvent({ event, writeObjectToCache })
}
private async tryCacheAuthorEvent(params: {
event: Event
tags: ReturnType<typeof extractTagsFromEvent>
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
}): Promise<boolean> {
if (params.tags.type !== 'author') {
return false
}
logTargetEventAttempt({ event: params.event, message: 'Attempting to parse target event as author presentation' })
const parsed = await parsePresentationEvent(params.event)
logTargetEventParsed({ event: params.event, parsed })
if (!parsed?.hash) {
logTargetEventNotCached({ event: params.event, parsed })
return true
}
await params.writeObjectToCache({
objectType: 'author', objectType: 'author',
hash: parsed.hash, hash: parsed.hash,
event, event: params.event,
parsed, parsed,
version: tags.version, version: params.tags.version,
hidden: tags.hidden, hidden: params.tags.hidden,
index: parsed.index, index: parsed.index,
}) })
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') { logTargetEventCached({ event: params.event, hash: parsed.hash })
console.warn(`[PlatformSync] Target event cached successfully as author with hash:`, parsed.hash) return true
} }
} else if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
console.warn(`[PlatformSync] Target event NOT cached: parsed=${parsed !== null}, hasHash=${parsed?.hash !== undefined}`) private async tryCacheSeriesEvent(params: {
event: Event
tags: ReturnType<typeof extractTagsFromEvent>
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
}): Promise<boolean> {
if (params.tags.type !== 'series') {
return false
} }
} else if (tags.type === 'series') { const parsed = await parseSeriesFromEvent(params.event)
const parsed = await parseSeriesFromEvent(event) if (!parsed?.hash) {
if (parsed?.hash) { return true
await writeObjectToCache({ }
await params.writeObjectToCache({
objectType: 'series', objectType: 'series',
hash: parsed.hash, hash: parsed.hash,
event, event: params.event,
parsed, parsed,
version: tags.version, version: params.tags.version,
hidden: tags.hidden, hidden: params.tags.hidden,
index: parsed.index, index: parsed.index,
}) })
return true
} }
} else if (tags.type === 'publication') {
const parsed = await parseArticleFromEvent(event) private async tryCachePublicationEvent(params: {
if (parsed?.hash) { event: Event
await writeObjectToCache({ tags: ReturnType<typeof extractTagsFromEvent>
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
}): Promise<boolean> {
if (params.tags.type !== 'publication') {
return false
}
const parsed = await parseArticleFromEvent(params.event)
if (!parsed?.hash) {
return true
}
await params.writeObjectToCache({
objectType: 'publication', objectType: 'publication',
hash: parsed.hash, hash: parsed.hash,
event, event: params.event,
parsed, parsed,
version: tags.version, version: params.tags.version,
hidden: tags.hidden, hidden: params.tags.hidden,
index: parsed.index, index: parsed.index,
}) })
return true
} }
} else if (tags.type === 'quote') {
const parsed = await parseReviewFromEvent(event) private async tryCacheReviewEvent(params: {
if (parsed?.hash) { event: Event
await writeObjectToCache({ tags: ReturnType<typeof extractTagsFromEvent>
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
}): Promise<boolean> {
if (params.tags.type !== 'quote') {
return false
}
const parsed = await parseReviewFromEvent(params.event)
if (!parsed?.hash) {
return true
}
await params.writeObjectToCache({
objectType: 'review', objectType: 'review',
hash: parsed.hash, hash: parsed.hash,
event, event: params.event,
parsed, parsed,
version: tags.version, version: params.tags.version,
hidden: tags.hidden, hidden: params.tags.hidden,
index: parsed.index, index: parsed.index,
}) })
return true
} }
} else if (event.kind === 9735) {
// Zap receipts (kind 9735) can be sponsoring, purchase, or review_tip private async tryCacheZapReceiptEvent(params: {
const sponsoring = await parseSponsoringFromEvent(event) event: Event
if (sponsoring?.hash) { writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
await writeObjectToCache({ }): Promise<boolean> {
if (params.event.kind !== 9735) {
return false
}
if (await this.tryCacheSponsoringZapReceipt(params)) {
return true
}
if (await this.tryCachePurchaseZapReceipt(params)) {
return true
}
if (await this.tryCacheReviewTipZapReceipt(params)) {
return true
}
return true
}
private async tryCacheSponsoringZapReceipt(params: {
event: Event
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
}): Promise<boolean> {
const sponsoring = await parseSponsoringFromEvent(params.event)
if (!sponsoring?.hash) {
return false
}
await params.writeObjectToCache({
objectType: 'sponsoring', objectType: 'sponsoring',
hash: sponsoring.hash, hash: sponsoring.hash,
event, event: params.event,
parsed: sponsoring, parsed: sponsoring,
index: sponsoring.index, index: sponsoring.index,
}) })
} else { return true
const purchase = await parsePurchaseFromEvent(event) }
if (purchase?.hash) {
await writeObjectToCache({ private async tryCachePurchaseZapReceipt(params: {
event: Event
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
}): Promise<boolean> {
const purchase = await parsePurchaseFromEvent(params.event)
if (!purchase?.hash) {
return false
}
await params.writeObjectToCache({
objectType: 'purchase', objectType: 'purchase',
hash: purchase.hash, hash: purchase.hash,
event, event: params.event,
parsed: purchase, parsed: purchase,
index: purchase.index, index: purchase.index,
}) })
} else { return true
const reviewTip = await parseReviewTipFromEvent(event) }
if (reviewTip?.hash) {
await writeObjectToCache({ private async tryCacheReviewTipZapReceipt(params: {
event: Event
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
}): Promise<boolean> {
const reviewTip = await parseReviewTipFromEvent(params.event)
if (!reviewTip?.hash) {
return false
}
await params.writeObjectToCache({
objectType: 'review_tip', objectType: 'review_tip',
hash: reviewTip.hash, hash: reviewTip.hash,
event, event: params.event,
parsed: reviewTip, parsed: reviewTip,
index: reviewTip.index, index: reviewTip.index,
}) })
} return true
}
}
}
} }
/** /**
@ -453,3 +564,61 @@ class PlatformSyncService {
} }
export const platformSyncService = new PlatformSyncService() export const platformSyncService = new PlatformSyncService()
function isTargetDebugEvent(eventId: string): boolean {
return eventId === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763'
}
function logTargetEventDebug(params: { event: Event; tags: ReturnType<typeof extractTagsFromEvent> }): void {
if (!isTargetDebugEvent(params.event.id)) {
return
}
console.warn(`[PlatformSync] Processing target event:`, {
id: params.event.id,
type: params.tags.type,
hidden: params.tags.hidden,
service: params.tags.service,
version: params.tags.version,
})
}
function logTargetEventSkipped(params: { event: Event; tags: ReturnType<typeof extractTagsFromEvent> }): void {
if (!isTargetDebugEvent(params.event.id) || !params.tags.hidden) {
return
}
console.warn(`[PlatformSync] Target event skipped: hidden=${params.tags.hidden}`)
}
function logTargetEventAttempt(params: { event: Event; message: string }): void {
if (!isTargetDebugEvent(params.event.id)) {
return
}
console.warn(`[PlatformSync] ${params.message}`)
}
function logTargetEventParsed(params: { event: Event; parsed: unknown }): void {
if (!isTargetDebugEvent(params.event.id)) {
return
}
const parsedObj = params.parsed as { hash?: string } | null
console.warn(`[PlatformSync] parsePresentationEvent result for target event:`, {
parsed: parsedObj !== null,
hasHash: parsedObj?.hash !== undefined,
hash: parsedObj?.hash,
})
}
function logTargetEventCached(params: { event: Event; hash: string }): void {
if (!isTargetDebugEvent(params.event.id)) {
return
}
console.warn(`[PlatformSync] Target event cached successfully as author with hash:`, params.hash)
}
function logTargetEventNotCached(params: { event: Event; parsed: unknown }): void {
if (!isTargetDebugEvent(params.event.id)) {
return
}
const parsedObj = params.parsed as { hash?: string } | null
console.warn(`[PlatformSync] Target event NOT cached: parsed=${parsedObj !== null}, hasHash=${parsedObj?.hash !== undefined}`)
}

View File

@ -108,45 +108,55 @@ class PublishWorkerService {
this.processing = true this.processing = true
try { try {
// Load unpublished objects from all object types const objectTypes = getAllPublishableObjectTypes()
const objectTypes: ObjectType[] = ['author', 'series', 'publication', 'review', 'purchase', 'sponsoring', 'review_tip', 'payment_note'] const now = Date.now()
await this.refreshUnpublishedMap({ objectTypes, now })
await this.processQueuedObjects()
} catch (error) {
console.error('[PublishWorker] Error processing unpublished objects:', error)
} finally {
this.processing = false
}
}
for (const objectType of objectTypes) { private async refreshUnpublishedMap(params: { objectTypes: ObjectType[]; now: number }): Promise<void> {
for (const objectType of params.objectTypes) {
const unpublished = await objectCache.getUnpublished(objectType) const unpublished = await objectCache.getUnpublished(objectType)
this.upsertUnpublishedObjects({ objectType, unpublished, now: params.now })
}
}
for (const { id, event } of unpublished) { private upsertUnpublishedObjects(params: {
const key = `${objectType}:${id}` objectType: ObjectType
unpublished: Array<{ id: string; event: import('nostr-tools').Event }>
now: number
}): void {
for (const { id, event } of params.unpublished) {
const key = buildUnpublishedKey(params.objectType, id)
const existing = this.unpublishedObjects.get(key) const existing = this.unpublishedObjects.get(key)
// Skip if recently retried or max retries reached if (existing && existing.retryCount >= MAX_RETRIES_PER_OBJECT) {
const recentlyRetried = existing && Date.now() - existing.lastRetryAt < RETRY_DELAY_MS console.warn(`[PublishWorker] Max retries reached for ${params.objectType}:${id}, skipping`)
const maxRetriesReached = existing && existing.retryCount >= MAX_RETRIES_PER_OBJECT }
if (maxRetriesReached) { const shouldSkip = Boolean(existing && params.now - existing.lastRetryAt < RETRY_DELAY_MS)
console.warn(`[PublishWorker] Max retries reached for ${objectType}:${id}, skipping`) if (!shouldSkip && (!existing || existing.retryCount < MAX_RETRIES_PER_OBJECT)) {
} else if (!recentlyRetried) {
// Add or update in map
this.unpublishedObjects.set(key, { this.unpublishedObjects.set(key, {
objectType, objectType: params.objectType,
id, id,
event, event,
retryCount: existing?.retryCount ?? 0, retryCount: existing?.retryCount ?? 0,
lastRetryAt: Date.now(), lastRetryAt: params.now,
}) })
} }
} }
} }
// Process all unpublished objects private async processQueuedObjects(): Promise<void> {
const objectsToProcess = Array.from(this.unpublishedObjects.entries()) const objectsToProcess = Array.from(this.unpublishedObjects.entries())
for (const [key, obj] of objectsToProcess) { for (const [key, obj] of objectsToProcess) {
await this.attemptPublish({ key, obj }) await this.attemptPublish({ key, obj })
} }
} catch (error) {
console.error('[PublishWorker] Error processing unpublished objects:', error)
} finally {
this.processing = false
}
} }
/** /**
@ -239,3 +249,11 @@ class PublishWorkerService {
} }
export const publishWorker = new PublishWorkerService() export const publishWorker = new PublishWorkerService()
function getAllPublishableObjectTypes(): ObjectType[] {
return ['author', 'series', 'publication', 'review', 'purchase', 'sponsoring', 'review_tip', 'payment_note']
}
function buildUnpublishedKey(objectType: ObjectType, id: string): string {
return `${objectType}:${id}`
}

10
lib/relaySelection.ts Normal file
View File

@ -0,0 +1,10 @@
import { relaySessionManager } from './relaySessionManager'
import { getPrimaryRelay } from './config'
export async function getPublishRelays(): Promise<string[]> {
const activeRelays = await relaySessionManager.getActiveRelays()
if (activeRelays.length > 0) {
return activeRelays
}
return [await getPrimaryRelay()]
}

View File

@ -1,9 +1,13 @@
import { nostrService } from './nostr' import { nostrService } from './nostr'
import { PLATFORM_COMMISSIONS } from './platformCommissions' import { PLATFORM_COMMISSIONS } from './platformCommissions'
import type { Event } from 'nostr-tools' import type { Event as NostrEvent } from 'nostr-tools'
import { finalizeEvent } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils'
import { objectCache } from './objectCache' import { objectCache } from './objectCache'
import type { Review } from '@/types/nostr'
import { getPublishRelays } from './relaySelection'
export async function fetchOriginalReviewEvent(reviewId: string): Promise<Event | null> { export async function fetchOriginalReviewEvent(reviewId: string): Promise<NostrEvent | null> {
// Read only from IndexedDB cache // Read only from IndexedDB cache
const parsed = await objectCache.getById('review', reviewId) const parsed = await objectCache.getById('review', reviewId)
if (parsed) { if (parsed) {
@ -18,7 +22,7 @@ export async function fetchOriginalReviewEvent(reviewId: string): Promise<Event
return null return null
} }
export function buildRewardEvent(originalEvent: Event, reviewId: string): { export function buildRewardEvent(originalEvent: NostrEvent, reviewId: string): {
kind: number kind: number
created_at: number created_at: number
tags: string[][] tags: string[][]
@ -37,7 +41,7 @@ export function buildRewardEvent(originalEvent: Event, reviewId: string): {
} }
} }
export function checkIfAlreadyRewarded(originalEvent: Event, reviewId: string): boolean { export function checkIfAlreadyRewarded(originalEvent: NostrEvent, reviewId: string): boolean {
const alreadyRewarded = originalEvent.tags.some((tag) => tag[0] === 'rewarded' && tag[1] === 'true') const alreadyRewarded = originalEvent.tags.some((tag) => tag[0] === 'rewarded' && tag[1] === 'true')
if (alreadyRewarded) { if (alreadyRewarded) {
console.warn('Review already marked as rewarded', { console.warn('Review already marked as rewarded', {
@ -58,77 +62,17 @@ export async function publishRewardEvent(
reviewId: string reviewId: string
): Promise<void> { ): Promise<void> {
try { try {
// Get original review to extract hash and parsed data const { updatedParsed, hash, index, newVersion } = await buildRewardedReviewUpdate(reviewId)
const originalEvent = await fetchOriginalReviewEvent(reviewId) const privateKey = getPrivateKeyOrThrow()
if (!originalEvent) {
throw new Error('Original review event not found in cache')
}
const { parseReviewFromEvent } = await import('./nostrEventParsing')
const originalParsed = await parseReviewFromEvent(originalEvent)
if (!originalParsed) {
throw new Error('Failed to parse original review')
}
// Increment version for update
const newVersion = (originalParsed.version ?? 0) + 1
const {hash} = originalParsed
const index = originalParsed.index ?? 0
// Build updated parsed Review object
const updatedParsed = {
...originalParsed,
version: newVersion,
rewarded: true,
rewardAmount: PLATFORM_COMMISSIONS.review.total,
}
// Set private key in orchestrator
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
throw new Error('Private key required for signing')
}
const { writeOrchestrator } = await import('./writeOrchestrator') const { writeOrchestrator } = await import('./writeOrchestrator')
writeOrchestrator.setPrivateKey(privateKey) writeOrchestrator.setPrivateKey(privateKey)
const event = finalizeEvent(updatedEventTemplate, hexToBytes(privateKey))
// Finalize event const relays = await getPublishRelays()
const { finalizeEvent } = await import('nostr-tools')
const { hexToBytes } = await import('nostr-tools/utils')
const secretKey = hexToBytes(privateKey)
const event = finalizeEvent(updatedEventTemplate, 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( const result = await writeOrchestrator.writeAndPublish(
{ { objectType: 'review', hash, event, parsed: updatedParsed, version: newVersion, hidden: false, index },
objectType: 'review',
hash,
event,
parsed: updatedParsed,
version: newVersion,
hidden: false,
index,
},
relays relays
) )
logRewardPublishResult({ reviewId, eventId: event.id, success: result.success })
if (result.success) {
console.warn('Review updated with reward tag', {
reviewId,
updatedEventId: event.id,
timestamp: new Date().toISOString(),
})
} else {
console.error('Failed to publish updated review event', {
reviewId,
timestamp: new Date().toISOString(),
})
}
} catch (error) { } catch (error) {
console.error('Error publishing reward event', { console.error('Error publishing reward event', {
reviewId, reviewId,
@ -138,6 +82,62 @@ export async function publishRewardEvent(
} }
} }
function getPrivateKeyOrThrow(): string {
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
throw new Error('Private key required for signing')
}
return privateKey
}
async function buildRewardedReviewUpdate(reviewId: string): Promise<{
updatedParsed: Review
hash: string
index: number
newVersion: number
}> {
const originalEvent = await fetchOriginalReviewEvent(reviewId)
if (!originalEvent) {
throw new Error('Original review event not found in cache')
}
const { parseReviewFromEvent } = await import('./nostrEventParsing')
const originalParsed = await parseReviewFromEvent(originalEvent)
if (!originalParsed) {
throw new Error('Failed to parse original review')
}
const newVersion = (originalParsed.version ?? 0) + 1
return {
updatedParsed: buildRewardedParsedReview(originalParsed, newVersion),
hash: originalParsed.hash,
index: originalParsed.index ?? 0,
newVersion,
}
}
function buildRewardedParsedReview(originalParsed: Review, newVersion: number): Review {
return {
...originalParsed,
version: newVersion,
rewarded: true,
rewardAmount: PLATFORM_COMMISSIONS.review.total,
}
}
function logRewardPublishResult(params: { reviewId: string; eventId: string; success: boolean }): void {
if (params.success) {
console.warn('Review updated with reward tag', {
reviewId: params.reviewId,
updatedEventId: params.eventId,
timestamp: new Date().toISOString(),
})
return
}
console.error('Failed to publish updated review event', {
reviewId: params.reviewId,
timestamp: new Date().toISOString(),
})
}
export async function updateReviewWithReward(reviewId: string, authorPrivateKey: string): Promise<void> { export async function updateReviewWithReward(reviewId: string, authorPrivateKey: string): Promise<void> {
try { try {
const originalEvent = await fetchOriginalReviewEvent(reviewId) const originalEvent = await fetchOriginalReviewEvent(reviewId)

View File

@ -28,41 +28,7 @@ class ServiceWorkerSyncHandler {
try { try {
await swClient.register() await swClient.register()
this.registerMessageHandlers()
// Listen for sync requests from Service Worker
swClient.onMessage('SYNC_REQUEST', (data: unknown) => {
void (async (): Promise<void> => {
const syncData = data as { syncType: string; userPubkey?: string }
if (syncData.syncType === 'platform') {
await this.handlePlatformSyncRequest()
} else if (syncData.syncType === 'user' && syncData.userPubkey) {
await this.handleUserSyncRequest(syncData.userPubkey)
}
})()
})
// Listen for publish worker requests
swClient.onMessage('PUBLISH_WORKER_REQUEST', () => {
void (async (): Promise<void> => {
await this.handlePublishWorkerRequest()
})()
})
// Listen for publish requests
swClient.onMessage('PUBLISH_REQUEST', (data: unknown) => {
void (async (): Promise<void> => {
const publishData = data as { event: Event; relays: string[] }
await this.handlePublishRequest(publishData.event, publishData.relays)
})()
})
// Listen for notification detection requests
swClient.onMessage('NOTIFICATION_DETECT_REQUEST', (data: unknown) => {
void (async (): Promise<void> => {
const detectData = data as { userPubkey: string }
await this.handleNotificationDetectRequest(detectData.userPubkey)
})()
})
this.initialized = true this.initialized = true
console.warn('[SWSyncHandler] Initialized') console.warn('[SWSyncHandler] Initialized')
@ -71,6 +37,42 @@ class ServiceWorkerSyncHandler {
} }
} }
private registerMessageHandlers(): void {
swClient.onMessage('SYNC_REQUEST', (data: unknown) => {
void this.handleSyncRequestMessage(data)
})
swClient.onMessage('PUBLISH_WORKER_REQUEST', () => {
void this.handlePublishWorkerRequest()
})
swClient.onMessage('PUBLISH_REQUEST', (data: unknown) => {
void this.handlePublishRequestMessage(data)
})
swClient.onMessage('NOTIFICATION_DETECT_REQUEST', (data: unknown) => {
void this.handleNotificationDetectRequestMessage(data)
})
}
private async handleSyncRequestMessage(data: unknown): Promise<void> {
const syncData = data as { syncType: string; userPubkey?: string }
if (syncData.syncType === 'platform') {
await this.handlePlatformSyncRequest()
return
}
if (syncData.syncType === 'user' && syncData.userPubkey) {
await this.handleUserSyncRequest(syncData.userPubkey)
}
}
private async handlePublishRequestMessage(data: unknown): Promise<void> {
const publishData = data as { event: Event; relays: string[] }
await this.handlePublishRequest(publishData.event, publishData.relays)
}
private async handleNotificationDetectRequestMessage(data: unknown): Promise<void> {
const detectData = data as { userPubkey: string }
await this.handleNotificationDetectRequest(detectData.userPubkey)
}
/** /**
* Handle platform sync request from Service Worker * Handle platform sync request from Service Worker
*/ */
@ -141,44 +143,10 @@ class ServiceWorkerSyncHandler {
// Publish to specified relays via websocketService (routes to Service Worker) // Publish to specified relays via websocketService (routes to Service Worker)
const statuses = await websocketService.publishEvent(event, relays) const statuses = await websocketService.publishEvent(event, relays)
const successfulRelays: string[] = [] const successfulRelays = logPublishStatuses({ publishLog, eventId: event.id, relays, statuses })
statuses.forEach((status, index) => {
const relayUrl = relays[index]
if (!relayUrl) {
return
}
if (status.success) {
successfulRelays.push(relayUrl)
// Log successful publication
void publishLog.logPublication({
eventId: event.id,
relayUrl,
success: true,
})
} else {
const errorMessage = status.error ?? 'Unknown error'
console.error(`[SWSyncHandler] Relay ${relayUrl} failed:`, errorMessage)
// Log failed publication
void publishLog.logPublication({
eventId: event.id,
relayUrl,
success: false,
error: errorMessage,
})
}
})
// Update published status in IndexedDB // Update published status in IndexedDB
// Access private method via type assertion await updatePublishedStatusUnsafe(event.id, successfulRelays.length > 0 ? successfulRelays : false)
const nostrServiceAny = nostrService as unknown as {
updatePublishedStatus: (eventId: string, published: false | string[]) => Promise<void>
}
await nostrServiceAny.updatePublishedStatus(
event.id,
successfulRelays.length > 0 ? successfulRelays : false
)
} catch (error) { } catch (error) {
console.error('[SWSyncHandler] Error in publish request:', error) console.error('[SWSyncHandler] Error in publish request:', error)
} }
@ -186,3 +154,32 @@ class ServiceWorkerSyncHandler {
} }
export const swSyncHandler = new ServiceWorkerSyncHandler() export const swSyncHandler = new ServiceWorkerSyncHandler()
function logPublishStatuses(params: {
publishLog: { logPublication: (params: { eventId: string; relayUrl: string; success: boolean; error?: string }) => Promise<void> }
eventId: string
relays: string[]
statuses: Array<{ success: boolean; error?: string }>
}): string[] {
const successfulRelays: string[] = []
params.statuses.forEach((status, index) => {
const relayUrl = params.relays[index]
if (!relayUrl) {
return
}
if (status.success) {
successfulRelays.push(relayUrl)
void params.publishLog.logPublication({ eventId: params.eventId, relayUrl, success: true })
return
}
const errorMessage = status.error ?? 'Unknown error'
console.error(`[SWSyncHandler] Relay ${relayUrl} failed:`, errorMessage)
void params.publishLog.logPublication({ eventId: params.eventId, relayUrl, success: false, error: errorMessage })
})
return successfulRelays
}
async function updatePublishedStatusUnsafe(eventId: string, published: false | string[]): Promise<void> {
const service = nostrService as unknown as { updatePublishedStatus: (eventId: string, published: false | string[]) => Promise<void> }
await service.updatePublishedStatus(eventId, published)
}

View File

@ -1,17 +1,23 @@
/** /**
* User confirmation utility * User confirmation utility
* Wrapper for window.confirm() - note: this violates no-alert rule but is required * Non-blocking confirmation overlay to avoid `window.confirm()` (`no-alert`).
* for critical user confirmations that cannot be replaced with React modals. * Used for critical confirmations (e.g. destructive actions) without requiring
* This function should be used sparingly and only when absolutely necessary. * a React modal or additional global state.
*
* Technical justification: window.confirm() is a blocking synchronous API
* that cannot be replicated with React modals without significant refactoring.
* Used only for critical destructive actions (delete operations).
*/ */
export function userConfirm(message: string): Promise<boolean> { export function userConfirm(message: string): Promise<boolean> {
return confirmOverlay(message) return confirmOverlay(message)
} }
type ConfirmOverlayElements = {
overlay: HTMLDivElement
cancel: HTMLButtonElement
confirm: HTMLButtonElement
}
type ConfirmOverlayHandlerParams = ConfirmOverlayElements & {
resolve: (value: boolean) => void
}
function confirmOverlay(message: string): Promise<boolean> { function confirmOverlay(message: string): Promise<boolean> {
const doc = globalThis.document const doc = globalThis.document
if (!doc) { if (!doc) {
@ -19,6 +25,28 @@ function confirmOverlay(message: string): Promise<boolean> {
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const { overlay, cancel, confirm } = buildConfirmOverlay(doc, message)
doc.body.append(overlay)
overlay.focus()
attachConfirmOverlayHandlers({ overlay, cancel, confirm, resolve })
})
}
function buildConfirmOverlay(doc: Document, message: string): ConfirmOverlayElements {
const overlay = createOverlay(doc)
const panel = createPanel(doc)
const text = createText(doc, message)
const buttons = createButtonsContainer(doc)
const cancel = createCancelButton(doc)
const confirm = createConfirmButton(doc)
buttons.append(cancel, confirm)
panel.append(text, buttons)
overlay.append(panel)
return { overlay, cancel, confirm }
}
function createOverlay(doc: Document): HTMLDivElement {
const overlay = doc.createElement('div') const overlay = doc.createElement('div')
overlay.setAttribute('role', 'dialog') overlay.setAttribute('role', 'dialog')
overlay.setAttribute('aria-modal', 'true') overlay.setAttribute('aria-modal', 'true')
@ -30,7 +58,10 @@ function confirmOverlay(message: string): Promise<boolean> {
overlay.style.alignItems = 'center' overlay.style.alignItems = 'center'
overlay.style.justifyContent = 'center' overlay.style.justifyContent = 'center'
overlay.style.zIndex = '9999' overlay.style.zIndex = '9999'
return overlay
}
function createPanel(doc: Document): HTMLDivElement {
const panel = doc.createElement('div') const panel = doc.createElement('div')
panel.style.background = '#fff' panel.style.background = '#fff'
panel.style.borderRadius = '12px' panel.style.borderRadius = '12px'
@ -38,17 +69,26 @@ function confirmOverlay(message: string): Promise<boolean> {
panel.style.maxWidth = '520px' panel.style.maxWidth = '520px'
panel.style.width = 'calc(100% - 32px)' panel.style.width = 'calc(100% - 32px)'
panel.style.boxShadow = '0 10px 30px rgba(0,0,0,0.35)' panel.style.boxShadow = '0 10px 30px rgba(0,0,0,0.35)'
return panel
}
function createText(doc: Document, message: string): HTMLParagraphElement {
const text = doc.createElement('p') const text = doc.createElement('p')
text.textContent = message text.textContent = message
text.style.margin = '0 0 16px 0' text.style.margin = '0 0 16px 0'
text.style.color = '#111827' text.style.color = '#111827'
return text
}
function createButtonsContainer(doc: Document): HTMLDivElement {
const buttons = doc.createElement('div') const buttons = doc.createElement('div')
buttons.style.display = 'flex' buttons.style.display = 'flex'
buttons.style.gap = '12px' buttons.style.gap = '12px'
buttons.style.justifyContent = 'flex-end' buttons.style.justifyContent = 'flex-end'
return buttons
}
function createCancelButton(doc: Document): HTMLButtonElement {
const cancel = doc.createElement('button') const cancel = doc.createElement('button')
cancel.type = 'button' cancel.type = 'button'
cancel.textContent = 'Cancel' cancel.textContent = 'Cancel'
@ -56,7 +96,10 @@ function confirmOverlay(message: string): Promise<boolean> {
cancel.style.borderRadius = '10px' cancel.style.borderRadius = '10px'
cancel.style.border = '1px solid #e5e7eb' cancel.style.border = '1px solid #e5e7eb'
cancel.style.background = '#f3f4f6' cancel.style.background = '#f3f4f6'
return cancel
}
function createConfirmButton(doc: Document): HTMLButtonElement {
const confirm = doc.createElement('button') const confirm = doc.createElement('button')
confirm.type = 'button' confirm.type = 'button'
confirm.textContent = 'Confirm' confirm.textContent = 'Confirm'
@ -65,14 +108,11 @@ function confirmOverlay(message: string): Promise<boolean> {
confirm.style.border = '1px solid #ef4444' confirm.style.border = '1px solid #ef4444'
confirm.style.background = '#fee2e2' confirm.style.background = '#fee2e2'
confirm.style.color = '#991b1b' confirm.style.color = '#991b1b'
return confirm
}
buttons.append(cancel, confirm) function attachConfirmOverlayHandlers(params: ConfirmOverlayHandlerParams): void {
panel.append(text, buttons) const { overlay, cancel, confirm, resolve } = params
overlay.append(panel)
doc.body.append(overlay)
overlay.focus()
let resolved = false let resolved = false
const resolveOnce = (next: boolean): void => { const resolveOnce = (next: boolean): void => {
@ -84,15 +124,9 @@ function confirmOverlay(message: string): Promise<boolean> {
resolve(next) resolve(next)
} }
function onCancel(): void { const onCancel = (): void => resolveOnce(false)
resolveOnce(false) const onConfirm = (): void => resolveOnce(true)
} const onKeyDown = (e: KeyboardEvent): void => {
function onConfirm(): void {
resolveOnce(true)
}
function onKeyDown(e: KeyboardEvent): void {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
resolveOnce(false) resolveOnce(false)
@ -104,7 +138,7 @@ function confirmOverlay(message: string): Promise<boolean> {
} }
} }
function cleanup(): void { const cleanup = (): void => {
overlay.removeEventListener('keydown', onKeyDown) overlay.removeEventListener('keydown', onKeyDown)
cancel.removeEventListener('click', onCancel) cancel.removeEventListener('click', onCancel)
confirm.removeEventListener('click', onConfirm) confirm.removeEventListener('click', onConfirm)
@ -114,5 +148,4 @@ function confirmOverlay(message: string): Promise<boolean> {
cancel.addEventListener('click', onCancel) cancel.addEventListener('click', onCancel)
confirm.addEventListener('click', onConfirm) confirm.addEventListener('click', onConfirm)
overlay.addEventListener('keydown', onKeyDown) overlay.addEventListener('keydown', onKeyDown)
})
} }

View File

@ -146,55 +146,68 @@ class WebSocketService {
* Communicates with Service Worker via postMessage * Communicates with Service Worker via postMessage
*/ */
async publishEvent(event: Event, relays: string[]): Promise<{ success: boolean; error?: string }[]> { async publishEvent(event: Event, relays: string[]): Promise<{ success: boolean; error?: string }[]> {
const pool = await this.getPoolOrThrow()
this.ensureRelayStates(relays)
const results = await Promise.allSettled(pool.publish(relays, event))
return this.buildPublishStatuses({ eventId: event.id, relays, results })
}
private async getPoolOrThrow(): Promise<SimplePool> {
if (!this.pool) { if (!this.pool) {
await this.initialize() await this.initialize()
} }
if (!this.pool) { if (!this.pool) {
throw new Error('WebSocket service not initialized') throw new Error('WebSocket service not initialized')
} }
return this.pool
}
// Update connection states private ensureRelayStates(relays: string[]): void {
relays.forEach((relayUrl) => { relays.forEach((relayUrl) => {
if (!this.connectionStates.has(relayUrl)) { if (!this.connectionStates.has(relayUrl)) {
this.updateConnectionState(relayUrl, true) // Assume connected when publishing this.updateConnectionState(relayUrl, true)
} }
}) })
}
// Publish to relays private buildPublishStatuses(params: {
const pubs = this.pool.publish(relays, event) eventId: string
const results = await Promise.allSettled(pubs) relays: string[]
results: PromiseSettledResult<unknown>[]
}): Array<{ success: boolean; error?: string }> {
const statuses: Array<{ success: boolean; error?: string }> = [] const statuses: Array<{ success: boolean; error?: string }> = []
results.forEach((result, index) => { params.results.forEach((result, index) => {
const relayUrl = relays[index] const relayUrl = params.relays[index]
if (!relayUrl) { if (!relayUrl) {
return return
} }
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
statuses.push({ success: true }) statuses.push({ success: true })
this.updateConnectionState(relayUrl, true) this.handlePublishSuccess({ eventId: params.eventId, relayUrl })
// Notify Service Worker of successful publication via postMessage return
void swClient.sendMessage({ }
type: 'WEBSOCKET_PUBLISH_SUCCESS',
data: { eventId: event.id, relayUrl },
})
} else {
const error = result.reason instanceof Error ? result.reason.message : String(result.reason) const error = result.reason instanceof Error ? result.reason.message : String(result.reason)
statuses.push({ success: false, error }) statuses.push({ success: false, error })
this.updateConnectionState(relayUrl, false) this.handlePublishFailure({ eventId: params.eventId, relayUrl, error })
// Notify Service Worker of failed publication via postMessage })
return statuses
}
private handlePublishSuccess(params: { eventId: string; relayUrl: string }): void {
this.updateConnectionState(params.relayUrl, true)
void swClient.sendMessage({
type: 'WEBSOCKET_PUBLISH_SUCCESS',
data: { eventId: params.eventId, relayUrl: params.relayUrl },
})
}
private handlePublishFailure(params: { eventId: string; relayUrl: string; error: string }): void {
this.updateConnectionState(params.relayUrl, false)
void swClient.sendMessage({ void swClient.sendMessage({
type: 'WEBSOCKET_PUBLISH_FAILED', type: 'WEBSOCKET_PUBLISH_FAILED',
data: { eventId: event.id, relayUrl, error }, data: { eventId: params.eventId, relayUrl: params.relayUrl, error: params.error },
}) })
// Trigger reconnection void this.handleReconnection(params.relayUrl)
void this.handleReconnection(relayUrl)
}
})
return statuses
} }
/** /**

View File

@ -42,53 +42,59 @@ class WriteOrchestrator {
params: WriteObjectParams, params: WriteObjectParams,
relays: string[] relays: string[]
): Promise<{ success: boolean; eventId: string; published: false | string[] }> { ): Promise<{ success: boolean; eventId: string; published: false | string[] }> {
const { objectType, hash, event, parsed, version, hidden, index } = params const localWrite = this.writeLocally(params)
const networkPublish = this.publishToNetwork(params.event, relays)
const [networkResult, localResult] = await Promise.allSettled([networkPublish, localWrite])
const publishedRelays = this.readPublishedRelays(networkResult)
this.assertLocalWriteSucceeded(localResult)
const published = await this.persistPublishedStatus(params.objectType, params.hash, publishedRelays)
return { success: publishedRelays.length > 0, eventId: params.event.id, published }
}
// Écriture en parallèle : réseau et local indépendamment private async publishToNetwork(event: NostrEvent, relays: string[]): Promise<string[]> {
const [networkResult, localResult] = await Promise.allSettled([ const statuses = await websocketService.publishEvent(event, relays)
// 1. Publish to network via WebSocket service (en parallèle)
websocketService.publishEvent(event, relays).then((statuses) => {
return statuses return statuses
.map((status, statusIndex) => (status.success ? relays[statusIndex] : null)) .map((status, statusIndex) => (status.success ? relays[statusIndex] : null))
.filter((relay): relay is string => relay !== null) .filter((relay): relay is string => relay !== null)
}), }
// 2. Write to IndexedDB via Web Worker (en parallèle, avec published: false initialement)
writeService.writeObject({ private async writeLocally(params: WriteObjectParams): Promise<void> {
objectType, await writeService.writeObject({
hash, objectType: params.objectType,
event, hash: params.hash,
parsed, event: params.event,
version, parsed: params.parsed,
hidden, version: params.version,
...(index !== undefined ? { index } : {}), hidden: params.hidden,
...(params.index !== undefined ? { index: params.index } : {}),
published: false, published: false,
}), })
])
// Traiter le résultat réseau
let publishedRelays: string[] = []
if (networkResult.status === 'fulfilled') {
publishedRelays = networkResult.value
} else {
// Si réseau échoue, rien : un autre service worker réessaiera
console.warn('[WriteOrchestrator] Network publish failed, will retry later:', networkResult.reason)
} }
// Traiter le résultat local private readPublishedRelays(result: PromiseSettledResult<string[]>): string[] {
if (localResult.status === 'rejected') { if (result.status === 'fulfilled') {
console.error('[WriteOrchestrator] Local write failed:', localResult.reason) return result.value
throw new Error(`Failed to write to IndexedDB: ${localResult.reason}`) }
console.warn('[WriteOrchestrator] Network publish failed, will retry later:', result.reason)
return []
} }
// 3. Update published status in IndexedDB via Web Worker (même si réseau a échoué) private assertLocalWriteSucceeded(result: PromiseSettledResult<void>): void {
const publishedStatus: false | string[] = publishedRelays.length > 0 ? publishedRelays : false if (result.status === 'fulfilled') {
await writeService.updatePublished(objectType, hash, publishedStatus) return
return {
success: publishedRelays.length > 0,
eventId: event.id,
published: publishedStatus,
} }
console.error('[WriteOrchestrator] Local write failed:', result.reason)
throw new Error(`Failed to write to IndexedDB: ${String(result.reason)}`)
}
private async persistPublishedStatus(
objectType: ObjectType,
hash: string,
publishedRelays: string[]
): Promise<false | string[]> {
const published: false | string[] = publishedRelays.length > 0 ? publishedRelays : false
await writeService.updatePublished(objectType, hash, published)
return published
} }
/** /**

View File

@ -72,6 +72,13 @@ function readWorkerErrorData(value: unknown): WorkerErrorData {
} }
} }
function isWorkerErrorForOperation(errorData: WorkerErrorData, operation: string): boolean {
if (errorData.originalType === operation) {
return true
}
return errorData.taskId?.startsWith(operation) === true
}
class WriteService { class WriteService {
private writeWorker: Worker | null = null private writeWorker: Worker | null = null
private initPromise: Promise<void> | null = null private initPromise: Promise<void> | null = null
@ -99,19 +106,29 @@ class WriteService {
} }
private createWorker(): Promise<void> { private createWorker(): Promise<void> {
return new Promise((resolve, _reject) => { return new Promise((resolve) => {
if (typeof window === 'undefined' || !window.Worker) { this.createWorkerOrFallback(resolve)
// Fallback: write directly if Worker not available })
}
private createWorkerOrFallback(resolve: () => void): void {
if (!isWebWorkerAvailable()) {
console.warn('[WriteService] Web Workers not available, using direct writes') console.warn('[WriteService] Web Workers not available, using direct writes')
resolve() resolve()
return return
} }
try { try {
// Worker dans public/ pour Next.js
this.writeWorker = new Worker('/writeWorker.js', { type: 'classic' }) this.writeWorker = new Worker('/writeWorker.js', { type: 'classic' })
this.registerWorkerListeners(this.writeWorker, resolve)
} catch (error) {
console.warn('[WriteService] Failed to create worker, using direct writes:', error)
resolve()
}
}
this.writeWorker.addEventListener('message', (event: MessageEvent<unknown>) => { private registerWorkerListeners(worker: Worker, resolve: () => void): void {
worker.addEventListener('message', (event: MessageEvent<unknown>) => {
if (!isWorkerMessageEnvelope(event.data)) { if (!isWorkerMessageEnvelope(event.data)) {
console.error('[WriteService] Received invalid worker message envelope', { data: event.data }) console.error('[WriteService] Received invalid worker message envelope', { data: event.data })
return return
@ -121,25 +138,20 @@ class WriteService {
} }
}) })
this.writeWorker.addEventListener('error', (error) => { worker.addEventListener('error', (error) => {
console.error('[WriteService] Worker error:', error) console.error('[WriteService] Worker error:', error)
// Ne pas rejeter, utiliser fallback
console.warn('[WriteService] Falling back to direct writes') console.warn('[WriteService] Falling back to direct writes')
this.writeWorker = null this.writeWorker = null
resolve() resolve()
}) })
// Attendre que le worker soit prêt
const readyTimeout = setTimeout(() => { const readyTimeout = setTimeout(() => {
console.warn('[WriteService] Worker ready timeout, using direct writes') console.warn('[WriteService] Worker ready timeout, using direct writes')
if (this.writeWorker) { this.writeWorker?.terminate()
this.writeWorker.terminate()
this.writeWorker = null this.writeWorker = null
}
resolve() resolve()
}, 2000) }, 2000)
// Le worker est prêt quand il répond
const readyHandler = (event: MessageEvent<unknown>): void => { const readyHandler = (event: MessageEvent<unknown>): void => {
if (isWorkerMessageEnvelope(event.data) && event.data.type === 'WORKER_READY') { if (isWorkerMessageEnvelope(event.data) && event.data.type === 'WORKER_READY') {
clearTimeout(readyTimeout) clearTimeout(readyTimeout)
@ -147,13 +159,7 @@ class WriteService {
resolve() resolve()
} }
} }
worker.addEventListener('message', readyHandler)
this.writeWorker.addEventListener('message', readyHandler)
} catch (error) {
console.warn('[WriteService] Failed to create worker, using direct writes:', error)
resolve() // Fallback to direct writes
}
})
} }
/** /**
@ -256,23 +262,28 @@ class WriteService {
const responseType = event.data.type const responseType = event.data.type
const responseData = event.data.data const responseData = event.data.data
if (responseType === 'UPDATE_PUBLISHED_SUCCESS' && isRecord(responseData) && responseData.id === id) { if (responseType === 'UPDATE_PUBLISHED_SUCCESS') {
if (!isRecord(responseData) || responseData.id !== id) {
return
}
clearTimeout(timeout) clearTimeout(timeout)
this.writeWorker?.removeEventListener('message', handler) this.writeWorker?.removeEventListener('message', handler)
resolve() resolve()
} else if (responseType === 'ERROR') { return
}
if (responseType !== 'ERROR') {
return
}
const errorData = readWorkerErrorData(responseData) const errorData = readWorkerErrorData(responseData)
const { taskId } = errorData if (!isWorkerErrorForOperation(errorData, 'UPDATE_PUBLISHED')) {
const isUpdatePublished =
errorData.originalType === 'UPDATE_PUBLISHED' || taskId?.startsWith('UPDATE_PUBLISHED') === true
if (!isUpdatePublished) {
return return
} }
clearTimeout(timeout) clearTimeout(timeout)
this.writeWorker?.removeEventListener('message', handler) this.writeWorker?.removeEventListener('message', handler)
reject(new Error(errorData.error ?? 'Write worker error')) reject(new Error(errorData.error ?? 'Write worker error'))
} }
}
if (this.writeWorker) { if (this.writeWorker) {
this.writeWorker.addEventListener('message', handler) this.writeWorker.addEventListener('message', handler)
@ -314,23 +325,28 @@ class WriteService {
const responseType = event.data.type const responseType = event.data.type
const responseData = event.data.data const responseData = event.data.data
if (responseType === 'CREATE_NOTIFICATION_SUCCESS' && isRecord(responseData) && responseData.eventId === params.eventId) { if (responseType === 'CREATE_NOTIFICATION_SUCCESS') {
if (!isRecord(responseData) || responseData.eventId !== params.eventId) {
return
}
clearTimeout(timeout) clearTimeout(timeout)
this.writeWorker?.removeEventListener('message', handler) this.writeWorker?.removeEventListener('message', handler)
resolve() resolve()
} else if (responseType === 'ERROR') { return
}
if (responseType !== 'ERROR') {
return
}
const errorData = readWorkerErrorData(responseData) const errorData = readWorkerErrorData(responseData)
const { taskId } = errorData if (!isWorkerErrorForOperation(errorData, 'CREATE_NOTIFICATION')) {
const isCreateNotification =
errorData.originalType === 'CREATE_NOTIFICATION' || taskId?.startsWith('CREATE_NOTIFICATION') === true
if (!isCreateNotification) {
return return
} }
clearTimeout(timeout) clearTimeout(timeout)
this.writeWorker?.removeEventListener('message', handler) this.writeWorker?.removeEventListener('message', handler)
reject(new Error(errorData.error ?? 'Write worker error')) reject(new Error(errorData.error ?? 'Write worker error'))
} }
}
if (this.writeWorker) { if (this.writeWorker) {
this.writeWorker.addEventListener('message', handler) this.writeWorker.addEventListener('message', handler)
@ -411,4 +427,8 @@ class WriteService {
} }
} }
function isWebWorkerAvailable(): boolean {
return typeof window !== 'undefined' && Boolean(window.Worker)
}
export const writeService = new WriteService() export const writeService = new WriteService()

View File

@ -1,12 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
// Note: eslint configuration in next.config.js is no longer supported in Next.js 16+
// Use 'next lint --ignore-build-errors' or configure in .eslintrc.json instead
typescript: {
// Désactiver la vérification TypeScript lors du build
ignoreBuildErrors: true,
},
images: { images: {
remotePatterns: [ remotePatterns: [
{ {

View File

@ -74,351 +74,444 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(405).json({ error: 'Method not allowed' }) return res.status(405).json({ error: 'Method not allowed' })
} }
// Get target endpoint and auth token from query const { targetEndpoint, authToken } = readProxyQueryParams(req)
const targetEndpoint = (req.query.endpoint as string) ?? 'https://void.cat/upload' const currentUrl = new URL(targetEndpoint)
const authToken = req.query.auth as string | undefined
const fileField = await parseFileFromMultipartRequest(req)
if (!fileField) {
return res.status(400).json({ error: 'No file provided' })
}
try { try {
// Parse multipart form data const response = await makeRequestWithRedirects({
// formidable needs the raw Node.js IncomingMessage, which NextApiRequest extends targetEndpoint,
const form = new IncomingForm({ url: currentUrl,
maxFileSize: MAX_FILE_SIZE, file: fileField,
keepExtensions: true, authToken,
redirectCount: 0,
maxRedirects: 5,
}) })
return handleProxyResponse({ res, response, targetEndpoint })
} catch (error) {
return handleProxyError({ res, error, targetEndpoint, hostname: currentUrl.hostname, file: fileField })
} finally {
safeUnlink(fileField.filepath)
}
}
function readProxyQueryParams(req: NextApiRequest): { targetEndpoint: string; authToken: string | undefined } {
return {
targetEndpoint: (req.query.endpoint as string) ?? 'https://void.cat/upload',
authToken: req.query.auth as string | undefined,
}
}
async function parseFileFromMultipartRequest(req: NextApiRequest): Promise<FormidableFile | null> {
const form = new IncomingForm({ maxFileSize: MAX_FILE_SIZE, keepExtensions: true })
const parseResult = await new Promise<ParseResult>((resolve, reject) => { const parseResult = await new Promise<ParseResult>((resolve, reject) => {
form.parse(req, (err, fields, files) => { form.parse(req, (err, fields, files) => {
if (err) { if (err) {
console.error('Formidable parse error:', err) console.error('Formidable parse error:', err)
reject(err) reject(err)
} else { return
}
resolve({ fields, files }) resolve({ fields, files })
}
}) })
}) })
return getFirstFile(parseResult.files, 'file')
}
const { files } = parseResult function safeUnlink(filepath: string): void {
// Get the file from the parsed form
const fileField = getFirstFile(files, 'file')
if (!fileField) {
return res.status(400).json({ error: 'No file provided' })
}
// Forward to target endpoint using https/http native modules
// Support redirects (301, 302, 307, 308)
const currentUrl = new URL(targetEndpoint)
const MAX_REDIRECTS = 5
let response: { statusCode: number; statusMessage: string; body: string }
try { try {
response = await new Promise<{ statusCode: number; statusMessage: string; body: string }>((resolve, reject) => { fs.unlinkSync(filepath)
function makeRequest(url: URL, redirectCount: number, file: FormidableFile, token?: string): void { } catch (unlinkError) {
if (redirectCount > MAX_REDIRECTS) { console.error('Error deleting temp file:', unlinkError)
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`)) }
return }
interface ProxyUploadResponse {
statusCode: number
statusMessage: string
body: string
headers: http.IncomingHttpHeaders
finalUrl: string
}
async function makeRequestWithRedirects(params: {
targetEndpoint: string
url: URL
file: FormidableFile
authToken: string | undefined
redirectCount: number
maxRedirects: number
}): Promise<ProxyUploadResponse> {
if (params.redirectCount > params.maxRedirects) {
throw new Error(`Too many redirects (max ${params.maxRedirects})`)
} }
// Recreate FormData for each request (needed for redirects) const response = await makeRequestOnce({
const requestFormData = new FormData() targetEndpoint: params.targetEndpoint,
const fileStream = fs.createReadStream(file.filepath) url: params.url,
file: params.file,
// Use 'file' as field name (standard for NIP-95, but some endpoints may use different names) authToken: params.authToken,
// Note: nostrimg.com might expect a different field name - if issues persist, try 'image' or 'upload'
const fieldName = 'file'
requestFormData.append(fieldName, fileStream, {
filename: file.originalFilename ?? file.newFilename ?? 'upload',
contentType: file.mimetype ?? 'application/octet-stream',
}) })
const isHttps = url.protocol === 'https:' const redirectUrl = tryGetRedirectUrl({ url: params.url, response })
const clientModule = isHttps ? https : http if (!redirectUrl) {
const headers = getFormDataHeaders(requestFormData) return response
// Add standard headers that some endpoints require
headers['Accept'] = 'application/json'
headers['User-Agent'] = 'zapwall.fr/1.0'
// Add NIP-98 Authorization header if token is provided
if (token) {
headers['Authorization'] = `Nostr ${token}`
} }
// Log request details for debugging (only for problematic endpoints)
if (url.hostname.includes('nostrimg.com')) {
console.warn('NIP-95 proxy request to nostrimg.com:', {
url: url.toString(),
method: 'POST',
fieldName,
filename: file.originalFilename ?? file.newFilename ?? 'upload',
contentType: file.mimetype ?? 'application/octet-stream',
fileSize: file.size,
headers: {
'Content-Type': headers['content-type'],
'Accept': headers['Accept'],
'User-Agent': headers['User-Agent'],
'Authorization': token ? '[present]' : '[absent]',
},
})
}
const requestOptions: http.RequestOptions = {
hostname: url.hostname,
port: url.port ?? (isHttps ? 443 : 80),
path: url.pathname + url.search,
method: 'POST',
headers,
timeout: 30000, // 30 seconds timeout
}
const proxyRequest = clientModule.request(requestOptions, (proxyResponse: http.IncomingMessage) => {
// Handle redirects (301, 302, 307, 308)
const statusCode = proxyResponse.statusCode ?? 500
if ((statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) && proxyResponse.headers.location) {
const location = getRedirectLocation(proxyResponse.headers as unknown)
if (!location) {
reject(new Error('Redirect response missing location header'))
return
}
let redirectUrl: URL
try {
// Handle relative and absolute URLs
redirectUrl = new URL(location, url.toString())
console.warn('NIP-95 proxy redirect:', { console.warn('NIP-95 proxy redirect:', {
from: url.toString(), from: params.url.toString(),
to: redirectUrl.toString(), to: redirectUrl.toString(),
statusCode, statusCode: response.statusCode,
redirectCount: redirectCount + 1, redirectCount: params.redirectCount + 1,
}) })
// Drain the response before redirecting
proxyResponse.resume() return makeRequestWithRedirects({
// Make new request to redirect location (preserve auth token for redirects) ...params,
makeRequest(redirectUrl, redirectCount + 1, file, token) url: redirectUrl,
return redirectCount: params.redirectCount + 1,
})
}
function tryGetRedirectUrl(params: { url: URL; response: ProxyUploadResponse }): URL | null {
if (!isRedirectStatus(params.response.statusCode)) {
return null
}
const location = getRedirectLocation(params.response.headers as unknown)
if (!location) {
throw new Error('Redirect response missing location header')
}
try {
return new URL(location, params.url.toString())
} catch (urlError) { } catch (urlError) {
console.error('NIP-95 proxy invalid redirect URL:', { console.error('NIP-95 proxy invalid redirect URL:', {
location, location,
error: urlError instanceof Error ? urlError.message : 'Unknown error', error: urlError instanceof Error ? urlError.message : 'Unknown error',
}) })
reject(new Error(`Invalid redirect URL: ${location}`)) throw new Error(`Invalid redirect URL: ${location}`)
return
}
} }
}
function isRedirectStatus(statusCode: number): boolean {
return statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308
}
async function makeRequestOnce(params: {
targetEndpoint: string
url: URL
file: FormidableFile
authToken: string | undefined
}): Promise<ProxyUploadResponse> {
const { requestFormData, fileStream } = buildUploadFormData(params.file)
const headers = buildProxyRequestHeaders(requestFormData, params.authToken)
const { clientModule, requestOptions } = buildProxyRequestOptions({ url: params.url, headers })
return await sendFormDataRequest({
clientModule,
requestOptions,
requestFormData,
fileStream,
targetEndpoint: params.targetEndpoint,
hostname: params.url.hostname,
finalUrl: params.url.toString(),
filepath: params.file.filepath,
})
}
function buildUploadFormData(file: FormidableFile): { requestFormData: FormData; fileStream: fs.ReadStream } {
const requestFormData = new FormData()
const fileStream = fs.createReadStream(file.filepath)
requestFormData.append('file', fileStream, {
filename: file.originalFilename ?? file.newFilename ?? 'upload',
contentType: file.mimetype ?? 'application/octet-stream',
})
return { requestFormData, fileStream }
}
function buildProxyRequestHeaders(requestFormData: FormData, authToken: string | undefined): http.OutgoingHttpHeaders {
const headers = getFormDataHeaders(requestFormData)
headers['Accept'] = 'application/json'
headers['User-Agent'] = 'zapwall.fr/1.0'
if (authToken) {
headers['Authorization'] = `Nostr ${authToken}`
}
return headers
}
function buildProxyRequestOptions(params: { url: URL; headers: http.OutgoingHttpHeaders }): {
clientModule: typeof http | typeof https
requestOptions: http.RequestOptions
} {
const isHttps = params.url.protocol === 'https:'
return {
clientModule: isHttps ? https : http,
requestOptions: {
hostname: params.url.hostname,
port: params.url.port ?? (isHttps ? 443 : 80),
path: params.url.pathname + params.url.search,
method: 'POST',
headers: params.headers,
timeout: 30000,
},
}
}
async function sendFormDataRequest(params: {
clientModule: typeof http | typeof https
requestOptions: http.RequestOptions
requestFormData: FormData
fileStream: fs.ReadStream
targetEndpoint: string
hostname: string
finalUrl: string
filepath: string
}): Promise<ProxyUploadResponse> {
return await new Promise<ProxyUploadResponse>((resolve, reject) => {
const proxyRequest = params.clientModule.request(params.requestOptions, (proxyResponse: http.IncomingMessage) => {
void readProxyResponse({ proxyResponse, finalUrl: params.finalUrl }).then(resolve).catch(reject)
})
attachProxyRequestHandlers({
proxyRequest,
requestFormData: params.requestFormData,
fileStream: params.fileStream,
targetEndpoint: params.targetEndpoint,
hostname: params.hostname,
filepath: params.filepath,
reject,
})
params.requestFormData.pipe(proxyRequest)
})
}
function attachProxyRequestHandlers(params: {
proxyRequest: http.ClientRequest
requestFormData: FormData
fileStream: fs.ReadStream
targetEndpoint: string
hostname: string
filepath: string
reject: (error: unknown) => void
}): void {
params.proxyRequest.setTimeout(30000, () => {
params.proxyRequest.destroy()
params.reject(new Error('Request timeout after 30 seconds'))
})
params.proxyRequest.on('error', (error) => {
params.reject(handleProxyRequestError({ error, targetEndpoint: params.targetEndpoint, hostname: params.hostname }))
})
params.requestFormData.on('error', (error) => {
console.error('NIP-95 proxy FormData error:', { targetEndpoint: params.targetEndpoint, hostname: params.hostname, error })
params.reject(error)
})
params.fileStream.on('error', (error) => {
console.error('NIP-95 proxy file stream error:', { targetEndpoint: params.targetEndpoint, hostname: params.hostname, filepath: params.filepath, error })
params.reject(error)
})
}
async function readProxyResponse(params: { proxyResponse: http.IncomingMessage; finalUrl: string }): Promise<ProxyUploadResponse> {
const statusCode = params.proxyResponse.statusCode ?? 500
const body = await readIncomingMessageBody(params.proxyResponse)
return {
statusCode,
statusMessage: params.proxyResponse.statusMessage ?? 'Internal Server Error',
body,
headers: params.proxyResponse.headers,
finalUrl: params.finalUrl,
}
}
async function readIncomingMessageBody(message: http.IncomingMessage): Promise<string> {
return await new Promise<string>((resolve, reject) => {
let body = '' let body = ''
proxyResponse.setEncoding('utf8') message.setEncoding('utf8')
proxyResponse.on('data', (chunk) => { message.on('data', (chunk) => {
body += chunk body += chunk
}) })
proxyResponse.on('end', () => { message.on('end', () => resolve(body))
// Log response details for debugging problematic endpoints message.on('error', reject)
if (url.hostname.includes('nostrimg.com')) {
console.warn('NIP-95 proxy response from nostrimg.com:', {
url: url.toString(),
statusCode,
statusMessage: proxyResponse.statusMessage,
responseHeaders: {
'content-type': proxyResponse.headers['content-type'],
'content-length': proxyResponse.headers['content-length'],
},
bodyPreview: body.substring(0, 500),
bodyLength: body.length,
isHtml: body.trim().startsWith('<!DOCTYPE') || body.trim().startsWith('<html') || body.trim().startsWith('<!'),
}) })
} }
resolve({ function handleProxyRequestError(params: { error: unknown; targetEndpoint: string; hostname: string }): Error {
statusCode, const errorMessage = params.error instanceof Error ? params.error.message : 'Unknown request error'
statusMessage: proxyResponse.statusMessage ?? 'Internal Server Error', const errorCode = getErrnoCode(params.error)
body,
})
})
proxyResponse.on('error', (error) => {
reject(error)
})
})
// Set timeout on the request
proxyRequest.setTimeout(30000, () => {
proxyRequest.destroy()
reject(new Error('Request timeout after 30 seconds'))
})
proxyRequest.on('error', (error) => {
// Check for DNS errors specifically
const errorCode = getErrnoCode(error)
if (errorCode === 'ENOTFOUND' || errorCode === 'EAI_AGAIN') { if (errorCode === 'ENOTFOUND' || errorCode === 'EAI_AGAIN') {
console.error('NIP-95 proxy DNS error:', { console.error('NIP-95 proxy DNS error:', {
targetEndpoint, targetEndpoint: params.targetEndpoint,
hostname: url.hostname, hostname: params.hostname,
errorCode, errorCode,
errorMessage: error.message, errorMessage,
suggestion: 'Check DNS resolution or network connectivity on the server', suggestion: 'Check DNS resolution or network connectivity on the server',
}) })
reject(new Error(`DNS resolution failed for ${url.hostname}: ${error.message}`)) return new Error(`DNS resolution failed for ${params.hostname}: ${errorMessage}`)
} else {
reject(error)
} }
}) return params.error instanceof Error ? params.error : new Error(errorMessage)
}
requestFormData.on('error', (error) => { function handleProxyError(params: {
console.error('NIP-95 proxy FormData error:', { res: NextApiResponse
targetEndpoint, error: unknown
hostname: url.hostname, targetEndpoint: string
error: error instanceof Error ? error.message : 'Unknown FormData error', hostname: string
}) file: FormidableFile
reject(error) }): void {
}) const errorMessage = params.error instanceof Error ? params.error.message : 'Unknown request error'
fileStream.on('error', (error) => {
console.error('NIP-95 proxy file stream error:', {
targetEndpoint,
hostname: url.hostname,
filepath: file.filepath,
error: error instanceof Error ? error.message : 'Unknown file stream error',
})
reject(error)
})
requestFormData.pipe(proxyRequest)
}
makeRequest(currentUrl, 0, fileField, authToken)
})
} catch (requestError) {
// Clean up temporary file before returning error
try {
fs.unlinkSync(fileField.filepath)
} catch (unlinkError) {
console.error('Error deleting temp file:', unlinkError)
}
const errorMessage = requestError instanceof Error ? requestError.message : 'Unknown request error'
const isDnsError = errorMessage.includes('DNS resolution failed') || errorMessage.includes('ENOTFOUND') || errorMessage.includes('EAI_AGAIN') const isDnsError = errorMessage.includes('DNS resolution failed') || errorMessage.includes('ENOTFOUND') || errorMessage.includes('EAI_AGAIN')
console.error('NIP-95 proxy request error:', { console.error('NIP-95 proxy request error:', {
targetEndpoint, targetEndpoint: params.targetEndpoint,
hostname: currentUrl.hostname, hostname: params.hostname,
error: errorMessage, error: errorMessage,
isDnsError, isDnsError,
fileSize: fileField.size, fileSize: params.file.size,
fileName: fileField.originalFilename, fileName: params.file.originalFilename,
suggestion: isDnsError ? 'The server cannot resolve the domain name. Check DNS configuration and network connectivity.' : undefined, suggestion: isDnsError ? 'The server cannot resolve the domain name. Check DNS configuration and network connectivity.' : undefined,
}) })
// Return a more specific error message for DNS issues
if (isDnsError) { if (isDnsError) {
return res.status(500).json({ params.res.status(500).json({
error: `DNS resolution failed for ${currentUrl.hostname}. The server cannot resolve the domain name. Please check DNS configuration and network connectivity.`, error: `DNS resolution failed for ${params.hostname}. The server cannot resolve the domain name. Please check DNS configuration and network connectivity.`,
}) })
return
} }
return res.status(500).json({ params.res.status(500).json({ error: `Failed to connect to upload endpoint: ${errorMessage}` })
error: `Failed to connect to upload endpoint: ${errorMessage}`, }
function handleProxyResponse(params: {
res: NextApiResponse
response: ProxyUploadResponse
targetEndpoint: string
}): void {
if (params.response.statusCode < 200 || params.response.statusCode >= 300) {
return respondNonOk({ res: params.res, response: params.response, targetEndpoint: params.targetEndpoint })
}
if (isHtmlResponse(params.response.body)) {
return respondHtml({ res: params.res, response: params.response, targetEndpoint: params.targetEndpoint })
}
const parseResult = parseJsonSafe(params.response.body)
if (!parseResult.ok) {
console.error('NIP-95 proxy JSON parse error:', {
targetEndpoint: params.targetEndpoint,
bodyPreview: params.response.body.substring(0, 100),
}) })
params.res.status(500).json({
error: `Invalid upload response: Invalid JSON response. The endpoint may not be a valid NIP-95 upload endpoint.`,
})
return
} }
// Clean up temporary file params.res.status(200).json(parseResult.value)
try { }
fs.unlinkSync(fileField.filepath)
} catch (unlinkError) {
console.error('Error deleting temp file:', unlinkError)
}
if (response.statusCode < 200 || response.statusCode >= 300) { function respondNonOk(params: { res: NextApiResponse; response: ProxyUploadResponse; targetEndpoint: string }): void {
const errorText = response.body.substring(0, 200) // Limit log size const errorText = params.response.body.substring(0, 200)
console.error('NIP-95 proxy response error:', { console.error('NIP-95 proxy response error:', {
targetEndpoint, targetEndpoint: params.targetEndpoint,
finalUrl: currentUrl.toString(), finalUrl: params.response.finalUrl,
status: response.statusCode, status: params.response.statusCode,
statusText: response.statusMessage, statusText: params.response.statusMessage,
errorText, errorText,
}) })
// Provide more specific error messages for common HTTP status codes params.res.status(params.response.statusCode).json({
let userFriendlyError = errorText || `Upload failed: ${response.statusCode} ${response.statusMessage}` error: buildUserFriendlyHttpError(params.response.statusCode, params.response.statusMessage, errorText),
if (response.statusCode === 401) {
userFriendlyError = 'Authentication required. This endpoint requires authorization headers.'
} else if (response.statusCode === 403) {
userFriendlyError = 'Access forbidden. This endpoint may require authentication or have restrictions.'
} else if (response.statusCode === 405) {
userFriendlyError = 'Method not allowed. This endpoint may not support POST requests or the URL may be incorrect.'
} else if (response.statusCode === 413) {
userFriendlyError = 'File too large. The file exceeds the maximum size allowed by this endpoint.'
} else if (response.statusCode >= 500) {
userFriendlyError = `Server error (${response.statusCode}). The endpoint server encountered an error.`
}
return res.status(response.statusCode).json({
error: userFriendlyError,
}) })
}
function buildUserFriendlyHttpError(statusCode: number, statusMessage: string, errorText: string): string {
if (statusCode === 401) {
return 'Authentication required. This endpoint requires authorization headers.'
} }
if (statusCode === 403) {
return 'Access forbidden. This endpoint may require authentication or have restrictions.'
}
if (statusCode === 405) {
return 'Method not allowed. This endpoint may not support POST requests or the URL may be incorrect.'
}
if (statusCode === 413) {
return 'File too large. The file exceeds the maximum size allowed by this endpoint.'
}
if (statusCode >= 500) {
return `Server error (${statusCode}). The endpoint server encountered an error.`
}
return errorText || `Upload failed: ${statusCode} ${statusMessage}`
}
// Check if response is HTML (error page) instead of JSON function isHtmlResponse(body: string): boolean {
const trimmedBody = response.body.trim() const trimmedBody = body.trim()
const isHtml = trimmedBody.startsWith('<!DOCTYPE') || trimmedBody.startsWith('<html') || trimmedBody.startsWith('<!') return trimmedBody.startsWith('<!DOCTYPE') || trimmedBody.startsWith('<html') || trimmedBody.startsWith('<!')
}
if (isHtml) { function respondHtml(params: { res: NextApiResponse; response: ProxyUploadResponse; targetEndpoint: string }): void {
// Try to extract error message from HTML if possible const title = readHtmlTitle(params.response.body)
const titleMatch = response.body.match(/<title[^>]*>([^<]+)<\/title>/i) const h1 = readHtmlH1(params.response.body)
const h1Match = response.body.match(/<h1[^>]*>([^<]+)<\/h1>/i) const errorText = title ?? h1 ?? 'HTML error page returned'
const errorText = titleMatch?.[1] ?? h1Match?.[1] ?? 'HTML error page returned' const flags = detectHtmlErrorFlags({ body: params.response.body, title })
// Check if it's a 404 or other error page
const is404 = response.body.includes('404') || response.body.includes('Not Found') || titleMatch?.[1]?.includes('404') === true
const is403 = response.body.includes('403') || response.body.includes('Forbidden') || titleMatch?.[1]?.includes('403') === true
const is500 = response.body.includes('500') || response.body.includes('Internal Server Error') || titleMatch?.[1]?.includes('500') === true
console.error('NIP-95 proxy HTML response error:', { console.error('NIP-95 proxy HTML response error:', {
targetEndpoint, targetEndpoint: params.targetEndpoint,
finalUrl: currentUrl.toString(), finalUrl: params.response.finalUrl,
status: response.statusCode, status: params.response.statusCode,
errorText, errorText,
is404, is404: flags.is404,
is403, is403: flags.is403,
is500, is500: flags.is500,
bodyPreview: response.body.substring(0, 500), bodyPreview: params.response.body.substring(0, 500),
contentType: 'HTML (expected JSON)', contentType: 'HTML (expected JSON)',
suggestion: buildHtmlErrorSuggestion({ is404, is403, is500 }), suggestion: buildHtmlErrorSuggestion(flags),
}) })
let userMessage = `Endpoint returned an HTML error page instead of JSON` params.res.status(500).json({
if (is404) { error: buildHtmlUserMessage({ is404: flags.is404, is403: flags.is403, is500: flags.is500, finalUrl: params.response.finalUrl, errorText }),
userMessage = `Endpoint not found (404). The URL may be incorrect: ${currentUrl.toString()}`
} else if (is403) {
userMessage = `Access forbidden (403). The endpoint may require authentication or have restrictions.`
} else if (is500) {
userMessage = `Server error (500). The endpoint server encountered an error.`
} else {
userMessage = `Endpoint returned an HTML error page instead of JSON. The endpoint may be unavailable, the URL may be incorrect, or specific headers may be required. Error: ${errorText}`
}
return res.status(500).json({
error: userMessage,
}) })
} }
let result: unknown function buildHtmlUserMessage(params: { is404: boolean; is403: boolean; is500: boolean; finalUrl: string; errorText: string }): string {
if (params.is404) {
return `Endpoint not found (404). The URL may be incorrect: ${params.finalUrl}`
}
if (params.is403) {
return `Access forbidden (403). The endpoint may require authentication or have restrictions.`
}
if (params.is500) {
return `Server error (500). The endpoint server encountered an error.`
}
return `Endpoint returned an HTML error page instead of JSON. The endpoint may be unavailable, the URL may be incorrect, or specific headers may be required. Error: ${params.errorText}`
}
type JsonParseResult = { ok: true; value: unknown } | { ok: false }
function parseJsonSafe(body: string): JsonParseResult {
try { try {
result = JSON.parse(response.body) return { ok: true, value: JSON.parse(body) as unknown }
} catch (parseError) { } catch {
const errorMessage = parseError instanceof Error ? parseError.message : 'Invalid JSON response' return { ok: false }
console.error('NIP-95 proxy JSON parse error:', {
targetEndpoint,
error: errorMessage,
bodyPreview: response.body.substring(0, 100),
})
return res.status(500).json({
error: `Invalid upload response: ${errorMessage}. The endpoint may not be a valid NIP-95 upload endpoint.`,
})
} }
}
return res.status(200).json(result) function readHtmlTitle(body: string): string | undefined {
} catch (error) { return body.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]
console.error('NIP-95 proxy error:', error) }
return res.status(500).json({
error: error instanceof Error ? error.message : 'Internal server error', function readHtmlH1(body: string): string | undefined {
}) return body.match(/<h1[^>]*>([^<]+)<\/h1>/i)?.[1]
} }
function detectHtmlErrorFlags(params: { body: string; title: string | undefined }): { is404: boolean; is403: boolean; is500: boolean } {
const is404 = isHtmlErrorForCode({ body: params.body, title: params.title, code: '404', marker: 'Not Found' })
const is403 = isHtmlErrorForCode({ body: params.body, title: params.title, code: '403', marker: 'Forbidden' })
const is500 = isHtmlErrorForCode({ body: params.body, title: params.title, code: '500', marker: 'Internal Server Error' })
return { is404, is403, is500 }
}
function isHtmlErrorForCode(params: { body: string; title: string | undefined; code: string; marker: string }): boolean {
const titleHasCode = params.title?.includes(params.code) === true
return params.body.includes(params.code) || params.body.includes(params.marker) || titleHasCode
} }
function buildHtmlErrorSuggestion(params: { is404: boolean; is403: boolean; is500: boolean }): string { function buildHtmlErrorSuggestion(params: { is404: boolean; is403: boolean; is500: boolean }): string {

View File

@ -74,7 +74,13 @@ function AuthorPageHeader({ presentation }: { presentation: AuthorPresentationAr
) )
} }
function SponsoringSummary({ totalSponsoring, author, onSponsor }: { totalSponsoring: number; author: AuthorPresentationArticle | null; onSponsor: () => void }): React.ReactElement { type SponsoringSummaryProps = {
totalSponsoring: number
author: AuthorPresentationArticle | null
onSponsor: () => void
}
function SponsoringSummary({ totalSponsoring, author, onSponsor }: SponsoringSummaryProps): React.ReactElement {
const totalBTC = totalSponsoring / 100_000_000 const totalBTC = totalSponsoring / 100_000_000
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
@ -221,6 +227,16 @@ function useAuthorData(hashIdOrPubkey: string): {
return { presentation, series, totalSponsoring, loading, error, reload } return { presentation, series, totalSponsoring, loading, error, reload }
} }
type AuthorPageContentProps = {
presentation: AuthorPresentationArticle | null
series: Series[]
totalSponsoring: number
authorPubkey: string
loading: boolean
error: string | null
onSeriesCreated: () => void
}
function AuthorPageContent({ function AuthorPageContent({
presentation, presentation,
series, series,
@ -229,15 +245,7 @@ function AuthorPageContent({
loading, loading,
error, error,
onSeriesCreated, onSeriesCreated,
}: { }: AuthorPageContentProps): React.ReactElement {
presentation: AuthorPresentationArticle | null
series: Series[]
totalSponsoring: number
authorPubkey: string
loading: boolean
error: string | null
onSeriesCreated: () => void
}): React.ReactElement {
if (loading) { if (loading) {
return <p className="text-cyber-accent">{t('common.loading')}</p> return <p className="text-cyber-accent">{t('common.loading')}</p>
} }

View File

@ -51,7 +51,7 @@ function useRedirectWhenDisconnected(connected: boolean, pubkey: string | null):
}, [connected, pubkey, router]) }, [connected, pubkey, router])
} }
function useProfileController(): { type ProfileController = {
connected: boolean connected: boolean
currentPubkey: string | null currentPubkey: string | null
searchQuery: string searchQuery: string
@ -67,7 +67,9 @@ function useProfileController(): {
loadingProfile: boolean loadingProfile: boolean
selectedSeriesId: string | undefined selectedSeriesId: string | undefined
onSelectSeries: (seriesId: string | undefined) => void onSelectSeries: (seriesId: string | undefined) => void
} { }
function useProfileController(): ProfileController {
const { connected, pubkey: currentPubkey } = useNostrAuth() const { connected, pubkey: currentPubkey } = useNostrAuth()
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [filters, setFilters] = useState<ArticleFilters>({ const [filters, setFilters] = useState<ArticleFilters>({

View File

@ -157,18 +157,22 @@ function SeriesPublications({ articles }: { articles: Article[] }): React.ReactE
) )
} }
function useSeriesPageData(seriesId: string): { type SeriesAggregates = { sponsoring: number; purchases: number; reviewTips: number }
type SeriesPageData = {
series: Series | null series: Series | null
articles: Article[] articles: Article[]
aggregates: { sponsoring: number; purchases: number; reviewTips: number } | null aggregates: SeriesAggregates | null
loading: boolean loading: boolean
error: string | null error: string | null
} { }
function useSeriesPageData(seriesId: string): SeriesPageData {
const [series, setSeries] = useState<Series | null>(null) const [series, setSeries] = useState<Series | null>(null)
const [articles, setArticles] = useState<Article[]>([]) const [articles, setArticles] = useState<Article[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [aggregates, setAggregates] = useState<{ sponsoring: number; purchases: number; reviewTips: number } | null>(null) const [aggregates, setAggregates] = useState<SeriesAggregates | null>(null)
useEffect(() => { useEffect(() => {
if (!seriesId) { if (!seriesId) {

View File

@ -0,0 +1,225 @@
// This script is a local dev helper to identify functions likely violating ESLint `max-lines-per-function`
// without running ESLint. It uses the TypeScript AST to compute line spans.
//
// Usage:
// node scripts/findLongFunctions.js [--max 40] [--limit 50]
//
// Notes:
// - Counts *raw* line span (endLine - startLine + 1). ESLint uses a different metric
// (skipBlankLines/skipComments). This script is used to pick candidates efficiently.
//
// It is intentionally plain JS to avoid requiring a TS build step.
const fs = require('fs')
const path = require('path')
const ts = require('typescript')
/**
* @typedef {Readonly<{ file: string; name: string; startLine: number; endLine: number; span: number }>} LongFn
*/
/**
* @param {string} value
* @returns {number | undefined}
*/
function readNumberArg(value) {
const n = Number(value)
if (Number.isFinite(n) && n > 0) {
return n
}
return undefined
}
/**
* @param {string[]} argv
* @returns {{ max: number; limit: number }}
*/
function readArgs(argv) {
let max = 40
let limit = 50
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i]
const next = argv[i + 1]
if (token === '--max' && next) {
max = readNumberArg(next) ?? max
i += 1
continue
}
if (token === '--limit' && next) {
limit = readNumberArg(next) ?? limit
i += 1
}
}
return { max, limit }
}
/**
* @param {string} p
* @returns {boolean}
*/
function isIgnoredPath(p) {
const normalized = p.split(path.sep).join('/')
return (
normalized.includes('/node_modules/') ||
normalized.includes('/.next/') ||
normalized.includes('/dist/') ||
normalized.includes('/out/')
)
}
/**
* @param {string} dir
* @param {string[]} out
* @returns {void}
*/
function collectSourceFiles(dir, out) {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (isIgnoredPath(full)) {
continue
}
if (entry.isDirectory()) {
collectSourceFiles(full, out)
continue
}
if (!entry.isFile()) {
continue
}
if (full.endsWith('.ts') || full.endsWith('.tsx')) {
out.push(full)
}
}
}
/**
* @param {ts.Node} node
* @returns {string}
*/
function getReadableName(node) {
if (ts.isFunctionDeclaration(node) && node.name) {
return node.name.getText()
}
if (ts.isMethodDeclaration(node) && node.name) {
return node.name.getText()
}
if (ts.isConstructorDeclaration(node)) {
return 'constructor'
}
if (ts.isGetAccessorDeclaration(node) && node.name) {
return `get ${node.name.getText()}`
}
if (ts.isSetAccessorDeclaration(node) && node.name) {
return `set ${node.name.getText()}`
}
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
const parent = node.parent
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
return parent.name.text
}
if (ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name)) {
return parent.name.text
}
return '<anonymous>'
}
return '<unknown>'
}
/**
* @param {ts.Node} node
* @returns {node is (ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | ts.MethodDeclaration | ts.ConstructorDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration)}
*/
function isFunctionLikeWithBody(node) {
if (!ts.isFunctionLike(node)) {
return false
}
// Arrow functions may have expression bodies. We only flag block bodies for line spans.
// Function/method declarations use block bodies.
return Boolean(node.body)
}
/**
* @param {string} filename
* @param {number} max
* @returns {LongFn[]}
*/
function findLongFunctionsInFile(filename, max) {
const sourceText = fs.readFileSync(filename, 'utf8')
const sourceFile = ts.createSourceFile(
filename,
sourceText,
ts.ScriptTarget.Latest,
true,
filename.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
)
/** @type {LongFn[]} */
const results = []
/**
* @param {ts.Node} node
* @returns {void}
*/
function visit(node) {
if (isFunctionLikeWithBody(node)) {
const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile, false))
const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd())
const startLine = start.line + 1
const endLine = end.line + 1
const span = endLine - startLine + 1
if (span > max) {
results.push({
file: filename,
name: getReadableName(node),
startLine,
endLine,
span,
})
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return results
}
/**
* @param {LongFn} fn
* @returns {string}
*/
function formatFn(fn) {
const rel = path.relative(process.cwd(), fn.file).split(path.sep).join('/')
return `${fn.span.toString().padStart(4, ' ')} ${rel}:${fn.startLine}-${fn.endLine} ${fn.name}`
}
function main() {
const { max, limit } = readArgs(process.argv.slice(2))
/** @type {string[]} */
const files = []
for (const dir of ['components', 'hooks', 'lib', 'pages', 'types', 'scripts']) {
const full = path.join(process.cwd(), dir)
if (fs.existsSync(full)) {
collectSourceFiles(full, files)
}
}
/** @type {LongFn[]} */
const all = []
for (const file of files) {
const found = findLongFunctionsInFile(file, max)
all.push(...found)
}
all.sort((a, b) => b.span - a.span || a.file.localeCompare(b.file) || a.startLine - b.startLine)
const top = all.slice(0, limit)
console.log(`Found ${all.length} function-like nodes with raw span > ${max} lines.`)
console.log(`Showing top ${top.length} (sorted by span desc):\n`)
for (const fn of top) {
console.log(formatFn(fn))
}
}
main()