lint fix wip

This commit is contained in:
Nicolas Cantu 2026-01-06 20:59:59 +01:00
parent 9e364d0313
commit cdd923e981
41 changed files with 2056 additions and 235 deletions

View File

@ -666,4 +666,4 @@ Dans tous les cas, aucun changement ne doit rester non commité plus de quelques
* **Commits avant PR** : Tous les commits doivent être faits avant la création d'une Pull Request * **Commits avant PR** : Tous les commits doivent être faits avant la création d'une Pull Request
* **Commits dans les PRs** : Les commits dans une PR doivent être organisés et logiques * **Commits dans les PRs** : Les commits dans une PR doivent être organisés et logiques
* **Squash si nécessaire** : Les commits peuvent être squashés dans une PR si cela améliore la lisibilité, mais chaque commit individuel doit rester valide * **Squash si nécessaire** : Les commits peuvent être squashés dans une PR si cela améliore la lisibilité, mais chaque commit individuel doit rester valide
* **Historique propre** : Maintenir un historique Git propre et lisible pour les contributeurs externes * **Historique propre** : Maintenir un historique Git propre et lisible pour les contributeurs externe

View File

@ -9,6 +9,7 @@ interface ArticleEditorProps {
onCancel?: () => void onCancel?: () => void
seriesOptions?: { id: string; title: string }[] seriesOptions?: { id: string; title: string }[]
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
defaultSeriesId?: string
} }
@ -21,7 +22,7 @@ function SuccessMessage(): React.ReactElement {
) )
} }
export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSelectSeries }: ArticleEditorProps): React.ReactElement { export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSelectSeries, defaultSeriesId }: ArticleEditorProps): React.ReactElement {
const { connected, pubkey, connect } = useNostrAuth() const { connected, pubkey, connect } = useNostrAuth()
const { loading, error, success, publishArticle } = useArticlePublishing(pubkey ?? null) const { loading, error, success, publishArticle } = useArticlePublishing(pubkey ?? null)
const [draft, setDraft] = useState<ArticleDraft>({ const [draft, setDraft] = useState<ArticleDraft>({
@ -30,6 +31,7 @@ export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSel
content: '', content: '',
zapAmount: 800, zapAmount: 800,
media: [], media: [],
...(defaultSeriesId ? { seriesId: defaultSeriesId } : {}),
}) })
const submit = buildSubmitHandler(publishArticle, draft, onPublishSuccess, connect, connected) const submit = buildSubmitHandler(publishArticle, draft, onPublishSuccess, connect, connected)

View File

@ -1,15 +1,64 @@
import type { Page } from '@/types/nostr' import type { Page } from '@/types/nostr'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useEffect, useState } from 'react'
import { objectCache } from '@/lib/objectCache'
interface ArticlePagesProps { interface ArticlePagesProps {
pages: Page[] pages: Page[]
articleId: string
} }
export function ArticlePages({ pages }: ArticlePagesProps): React.ReactElement | null { export function ArticlePages({ pages, articleId }: ArticlePagesProps): React.ReactElement | null {
const { pubkey } = useNostrAuth()
const [hasPurchased, setHasPurchased] = useState(false)
useEffect(() => {
const checkPurchase = async (): Promise<void> => {
if (!pubkey || !articleId) {
setHasPurchased(false)
return
}
try {
// Check if user has purchased this article from cache
const purchases = await objectCache.getAll('purchase')
const userPurchases = purchases.filter((p) => {
if (typeof p !== 'object' || p === null) {
return false
}
const purchase = p as { payerPubkey?: string; articleId?: string }
return purchase.payerPubkey === pubkey && purchase.articleId === articleId
})
setHasPurchased(userPurchases.length > 0)
} catch (error) {
console.error('Error checking purchase status:', error)
setHasPurchased(false)
}
}
void checkPurchase()
}, [pubkey, articleId])
if (!pages || pages.length === 0) { if (!pages || pages.length === 0) {
return null return null
} }
// If user hasn't purchased, show locked message
if (!hasPurchased) {
return (
<div className="space-y-6 mt-6">
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
<div className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark text-center">
<p className="text-cyber-accent mb-2">{t('article.pages.locked.title')}</p>
<p className="text-sm text-cyber-accent/70">{t('article.pages.locked.message', { count: pages.length })}</p>
</div>
</div>
)
}
// User has purchased, show all pages
return ( return (
<div className="space-y-6 mt-6"> <div className="space-y-6 mt-6">
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3> <h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>

View File

@ -13,7 +13,7 @@ export function ArticlePreview({ article, loading, onUnlock }: ArticlePreviewPro
<div> <div>
<p className="mb-2 text-cyber-accent">{article.preview}</p> <p className="mb-2 text-cyber-accent">{article.preview}</p>
<p className="text-sm text-cyber-accent/80 mt-4 whitespace-pre-wrap">{article.content}</p> <p className="text-sm text-cyber-accent/80 mt-4 whitespace-pre-wrap">{article.content}</p>
{article.pages && article.pages.length > 0 && <ArticlePages pages={article.pages} />} {article.pages && article.pages.length > 0 && <ArticlePages pages={article.pages} articleId={article.id} />}
</div> </div>
) )
} }

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import type { Review, Article } from '@/types/nostr' import type { Review, Article } from '@/types/nostr'
import { getReviewsForArticle } from '@/lib/reviews' import { getReviewsForArticle } from '@/lib/reviews'
import { getReviewTipsForArticle } from '@/lib/reviewAggregation' import { getReviewTipsForArticle } from '@/lib/reviewAggregation'
@ -19,7 +19,7 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
const [showReviewForm, setShowReviewForm] = useState(false) const [showReviewForm, setShowReviewForm] = useState(false)
const [selectedReviewForTip, setSelectedReviewForTip] = useState<string | null>(null) const [selectedReviewForTip, setSelectedReviewForTip] = useState<string | null>(null)
const loadReviews = async (): Promise<void> => { const loadReviews = useCallback(async (): Promise<void> => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
@ -29,16 +29,16 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
]) ])
setReviews(list) setReviews(list)
setTips(tipsTotal) setTips(tipsTotal)
} catch (e) { } catch (loadError) {
setError(e instanceof Error ? e.message : 'Erreur lors du chargement des critiques') setError(loadError instanceof Error ? loadError.message : 'Erreur lors du chargement des critiques')
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [article.id, authorPubkey])
useEffect(() => { useEffect(() => {
void loadReviews() void loadReviews()
}, [article.id, authorPubkey]) }, [loadReviews])
return ( return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"> <div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
@ -65,10 +65,15 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
{!loading && !error && <ArticleReviewsList reviews={reviews} onTipReview={(reviewId) => { {!loading && !error && <ArticleReviewsList reviews={reviews} onTipReview={(reviewId) => {
setSelectedReviewForTip(reviewId) setSelectedReviewForTip(reviewId)
}} />} }} />}
{selectedReviewForTip && ( {selectedReviewForTip && (() => {
<ReviewTipForm const review = reviews.find((r) => r.id === selectedReviewForTip)
review={reviews.find((r) => r.id === selectedReviewForTip)!} if (!review) {
article={article} return null
}
return (
<ReviewTipForm
review={review}
article={article}
onSuccess={() => { onSuccess={() => {
setSelectedReviewForTip(null) setSelectedReviewForTip(null)
void loadReviews() void loadReviews()
@ -76,8 +81,9 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
onCancel={() => { onCancel={() => {
setSelectedReviewForTip(null) setSelectedReviewForTip(null)
}} }}
/> />
)} )
})()}
</div> </div>
) )
} }

View File

@ -79,8 +79,8 @@ export function CreateSeriesModal({ isOpen, onClose, onSuccess, authorPubkey }:
}) })
onSuccess() onSuccess()
onClose() onClose()
} catch (e) { } catch (submitError) {
setError(e instanceof Error ? e.message : t('series.create.error.publishFailed')) setError(submitError instanceof Error ? submitError.message : t('series.create.error.publishFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -121,7 +121,9 @@ export function CreateSeriesModal({ isOpen, onClose, onSuccess, authorPubkey }:
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={(e) => {
void handleSubmit(e)
}} className="space-y-4">
<div> <div>
<label htmlFor="series-title" className="block text-sm font-medium text-neon-cyan mb-2"> <label htmlFor="series-title" className="block text-sm font-medium text-neon-cyan mb-2">
{t('series.create.field.title')} {t('series.create.field.title')}

View File

@ -0,0 +1,78 @@
import { useState, useEffect } from 'react'
import { syncProgressManager } from '@/lib/syncProgressManager'
import type { SyncProgress } from '@/lib/userContentSync'
import { t } from '@/lib/i18n'
export function GlobalSyncProgressBar(): React.ReactElement | null {
const [progress, setProgress] = useState<SyncProgress | null>(null)
useEffect(() => {
const unsubscribe = syncProgressManager.subscribe((newProgress) => {
setProgress(newProgress)
})
return () => {
unsubscribe()
}
}, [])
if (!progress || progress.completed) {
return null
}
// Extract relay name from URL (remove wss:// and truncate if too long)
const getRelayDisplayName = (relayUrl?: string): string => {
if (!relayUrl) {
return 'Connecting...'
}
const cleaned = relayUrl.replace(/^wss?:\/\//, '').replace(/\/$/, '')
if (cleaned.length > 50) {
return `${cleaned.substring(0, 47)}...`
}
return cleaned
}
// Spinning sync icon
const SyncIcon = (): React.ReactElement => (
<svg
className="animate-spin h-5 w-5 text-neon-cyan"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-cyber-darker border-b border-neon-cyan/30 shadow-lg">
<div className="container mx-auto px-4 py-3">
<div className="flex items-center gap-3">
<SyncIcon />
{progress.currentRelay && (
<span className="text-neon-cyan text-sm font-mono">
{getRelayDisplayName(progress.currentRelay)}
</span>
)}
{!progress.currentRelay && (
<span className="text-cyber-accent/70 text-sm italic">
{t('settings.sync.connecting')}
</span>
)}
</div>
</div>
</div>
)
}

View File

@ -105,8 +105,8 @@ function useImageUpload(onChange: (url: string) => void): {
const [showUnlockModal, setShowUnlockModal] = useState(false) const [showUnlockModal, setShowUnlockModal] = useState(false)
const [pendingFile, setPendingFile] = useState<File | null>(null) const [pendingFile, setPendingFile] = useState<File | null>(null)
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => { const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = e.target.files?.[0] const file = event.target.files?.[0]
if (!file) { if (!file) {
return return
} }
@ -116,15 +116,15 @@ function useImageUpload(onChange: (url: string) => void): {
try { try {
await processFileUpload(file, onChange, setError) await processFileUpload(file, onChange, setError)
} catch (e) { } catch (uploadError) {
const error = e instanceof Error ? e : new Error(String(e)) const uploadErr = uploadError instanceof Error ? uploadError : new Error(String(uploadError))
// Check if unlock is required // Check if unlock is required
if (error.message === 'UNLOCK_REQUIRED' || ('unlockRequired' in error && (error as { unlockRequired?: boolean }).unlockRequired)) { 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
} else { } else {
setError(error.message || t('presentation.field.picture.error.uploadFailed')) setError(uploadErr.message ?? t('presentation.field.picture.error.uploadFailed'))
} }
} finally { } finally {
setUploading(false) setUploading(false)
@ -140,8 +140,8 @@ function useImageUpload(onChange: (url: string) => void): {
try { try {
await processFileUpload(pendingFile, onChange, setError) await processFileUpload(pendingFile, onChange, setError)
setPendingFile(null) setPendingFile(null)
} catch (e) { } catch (retryError) {
setError(e instanceof Error ? e.message : t('presentation.field.picture.error.uploadFailed')) setError(retryError instanceof Error ? retryError.message : t('presentation.field.picture.error.uploadFailed'))
} finally { } finally {
setUploading(false) setUploading(false)
} }
@ -175,7 +175,9 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image
</div> </div>
{showUnlockModal && ( {showUnlockModal && (
<UnlockAccountModal <UnlockAccountModal
onSuccess={handleUnlockSuccess} onSuccess={() => {
void handleUnlockSuccess()
}}
onClose={() => { onClose={() => {
setShowUnlockModal(false) setShowUnlockModal(false)
}} }}

398
components/RelayManager.tsx Normal file
View File

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

View File

@ -60,8 +60,8 @@ export function ReviewForm({ article, onSuccess, onCancel }: ReviewFormProps): R
setTitle('') setTitle('')
setText('') setText('')
onSuccess?.() onSuccess?.()
} catch (e) { } catch (submitError) {
setError(e instanceof Error ? e.message : t('review.form.error.publishFailed')) setError(submitError instanceof Error ? submitError.message : t('review.form.error.publishFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -84,7 +84,9 @@ export function ReviewForm({ article, onSuccess, onCancel }: ReviewFormProps): R
} }
return ( return (
<form onSubmit={handleSubmit} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"> <form onSubmit={(e) => {
void handleSubmit(e)
}} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
<h3 className="text-lg font-semibold text-neon-cyan">{t('review.form.title')}</h3> <h3 className="text-lg font-semibold text-neon-cyan">{t('review.form.title')}</h3>
<div> <div>

View File

@ -62,8 +62,8 @@ export function ReviewTipForm({ review, article, onSuccess, onCancel }: ReviewTi
setText('') setText('')
onSuccess?.() onSuccess?.()
} catch (e) { } catch (submitError) {
setError(e instanceof Error ? e.message : t('reviewTip.form.error.paymentFailed')) setError(submitError instanceof Error ? submitError.message : t('reviewTip.form.error.paymentFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -88,7 +88,9 @@ export function ReviewTipForm({ review, article, onSuccess, onCancel }: ReviewTi
const split = calculateReviewSplit() const split = calculateReviewSplit()
return ( return (
<form onSubmit={handleSubmit} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"> <form onSubmit={(e) => {
void handleSubmit(e)
}} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
<h3 className="text-lg font-semibold text-neon-cyan">{t('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', { amount: split.total, reviewer: split.reviewer, platform: split.platform })} {t('reviewTip.form.description', { amount: split.total, reviewer: split.reviewer, platform: split.platform })}

View File

@ -77,8 +77,8 @@ export function SponsoringForm({ author, onSuccess, onCancel }: SponsoringFormPr
setText('') setText('')
onSuccess?.() onSuccess?.()
} catch (e) { } catch (submitError) {
setError(e instanceof Error ? e.message : t('sponsoring.form.error.paymentFailed')) setError(submitError instanceof Error ? submitError.message : t('sponsoring.form.error.paymentFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -109,7 +109,9 @@ export function SponsoringForm({ author, onSuccess, onCancel }: SponsoringFormPr
} }
return ( return (
<form onSubmit={handleSubmit} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"> <form onSubmit={(e) => {
void handleSubmit(e)
}} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3> <h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
<p className="text-sm text-cyber-accent/70"> <p className="text-sm text-cyber-accent/70">
{t('sponsoring.form.description', { amount: '0.046' })} {t('sponsoring.form.description', { amount: '0.046' })}

View File

@ -30,8 +30,8 @@ export function SyncProgressBar(): React.ReactElement | null {
setLastSyncDate(storedLastSyncDate) setLastSyncDate(storedLastSyncDate)
setTotalDays(days) setTotalDays(days)
} catch (error) { } catch (loadError) {
console.error('Error loading sync status:', error) console.error('Error loading sync status:', loadError)
} }
} }
@ -103,10 +103,10 @@ export function SyncProgressBar(): React.ReactElement | null {
}) })
// Check if sync completed successfully (if it didn't, isSyncing should still be false) // Check if sync completed successfully (if it didn't, isSyncing should still be false)
setIsSyncing(false) setIsSyncing(false)
} catch (error) { } catch (autoSyncError) {
console.error('[SyncProgressBar] Error during auto-sync:', error) console.error('[SyncProgressBar] Error during auto-sync:', autoSyncError)
setIsSyncing(false) setIsSyncing(false)
setError(error instanceof Error ? error.message : 'Erreur de synchronisation') setError(autoSyncError instanceof Error ? autoSyncError.message : 'Erreur de synchronisation')
} }
} else { } else {
console.log('[SyncProgressBar] Skipping auto-sync:', { isRecentlySynced, isSyncing, hasPubkey: Boolean(connectionState.pubkey) }) console.log('[SyncProgressBar] Skipping auto-sync:', { isRecentlySynced, isSyncing, hasPubkey: Boolean(connectionState.pubkey) })
@ -151,8 +151,8 @@ export function SyncProgressBar(): React.ReactElement | null {
} }
}) })
} }
} catch (error) { } catch (resyncError) {
console.error('Error resynchronizing:', error) console.error('Error resynchronizing:', resyncError)
setIsSyncing(false) setIsSyncing(false)
} }
} }

View File

@ -0,0 +1,49 @@
# Author Funds Collection Specification
**Author**: Équipe 4NK
**Date**: 2026-01-14
## Objectifs
1. Pour chaque auteur, collecter :
- Fonds perçus sur le mempool (Bitcoin mainnet pour sponsoring)
- Fonds perçus par la plateforme (commission)
2. Créer un lien entre :
- Paiement (zap receipt) ↔ Note de paiement ↔ Objet (publication/avis/auteur)
3. Clarification de la structure :
- **Publications** (plusieurs pages d'une série) sont les objets achetés
- **Séries** sont les objets commentés (reviews)
- **Avis/Commentaires** sont les objets récompensés (review tips)
- **Auteurs** sont les objets sponsorisés
- Les **publications** sont des notes (kind 1)
- Les **pages** sont dans le JSON des notes de publications (pas de notes séparées)
## Tags à ajouter
### Payment Notes (kind 1, type='payment')
- `publication_id` ou `article`: ID de la publication (pour achat)
- `series_id` ou `series`: ID de la série (si applicable)
- `review_id`: ID de l'avis (pour review tip)
- `author`: Pubkey de l'auteur (receveur)
- `zap_receipt`: ID du zap receipt (si Lightning)
- `transaction_id`: ID de la transaction Bitcoin (si mainnet sponsoring)
- `platform_commission`: Montant de la commission
- `author_funds`: Fonds reçus par l'auteur
### Zap Receipts (kind 9735)
Déjà présents :
- `#e`: Event ID (article ID pour purchases)
- `#p`: Pubkey (author pubkey)
- `kind_type`: Type de paiement
- `review_id`: Review ID (pour review tips)
- `series`: Series ID (optionnel)
## Prochaines étapes
1. ✅ Document de spécification créé
2. ⏳ Modifier les payment notes pour inclure les tags de liaison
3. ⏳ Créer un service pour collecter les fonds par auteur
4. ⏳ Intégrer la collecte des fonds du mempool pour sponsoring Bitcoin
5. ⏳ Créer une interface pour afficher les fonds par auteur

82
docs/nostr-event-order.md Normal file
View File

@ -0,0 +1,82 @@
# Ordre d'arrivée des notes depuis les relais
## Comment fonctionne la récupération avec rotation de relais
### 1. Sélection du relai
Avec `tryWithRelayRotation`, le système :
1. **Essaie les relais un par un** dans l'ordre de priorité configuré
2. **Crée une subscription** avec **un seul relai à la fois**
3. Si le premier relai échoue (timeout ou erreur de connexion), passe au suivant
4. Continue jusqu'à trouver un relai qui répond
### 2. Arrivée des événements
Une fois qu'une subscription est établie avec un relai qui fonctionne :
```
Subscription créée → Relai commence à envoyer les événements
```
Les événements arrivent **de manière asynchrone** via le callback `sub.on('event')` :
```
Event 1 reçu
Event 2 reçu
Event 3 reçu
...
Event N reçu
EOSE (End of Stored Events) → Le relai a fini d'envoyer
```
### 3. Ordre des événements
L'ordre d'arrivée des événements **n'est pas garanti** :
- ❌ **Pas nécessairement chronologique** : Le relai peut envoyer dans n'importe quel ordre
- ❌ **Pas nécessairement par ID** : Les événements peuvent arriver dans un ordre aléatoire
- ✅ **Déduplication automatique** : Les événements avec le même ID ne sont traités qu'une seule fois
### 4. Exemple concret
Si vous avez 3 relais configurés : `relay1`, `relay2`, `relay3`
**Scénario 1 : Relay1 fonctionne**
```
1. Essaie relay1 → ✅ Connexion réussie
2. Reçoit événements depuis relay1 uniquement
3. Tous les événements viennent de relay1
```
**Scénario 2 : Relay1 échoue, relay2 fonctionne**
```
1. Essaie relay1 → ❌ Timeout après 5 secondes
2. Essaie relay2 → ✅ Connexion réussie
3. Reçoit événements depuis relay2 uniquement
4. Tous les événements viennent de relay2
```
**Scénario 3 : Tous les relais fonctionnent**
```
1. Essaie relay1 → ✅ Connexion réussie (premier qui répond)
2. Reçoit événements depuis relay1 uniquement
3. relay2 et relay3 ne sont pas utilisés pour cette subscription
```
### 5. Pourquoi un seul relai à la fois ?
- **Efficacité** : Un seul relai suffit pour récupérer les événements
- **Simplicité** : Plus facile de gérer une seule source
- **Pas de doublons** : Les événements viennent d'une seule source, pas besoin de dédupliquer
### 6. Problèmes de rotation pendant la réception
⚠️ **Important** : Si un relai échoue **pendant** la réception des événements :
- La subscription est interrompue
- Les événements déjà reçus sont traités
- Un nouveau relai est essayé pour une nouvelle subscription
## Conclusion
Les notes arrivent **d'un seul relai à la fois**, dans un **ordre non garanti** (pas forcément chronologique), et sont traitées au fur et à mesure de leur arrivée jusqu'à recevoir EOSE (End of Stored Events).

View File

@ -0,0 +1,57 @@
# Pourquoi publier sur plusieurs relais ne crée pas de doublons
## Comment fonctionne l'identité d'un événement Nostr
Dans Nostr, chaque événement est **uniquement identifié** par :
1. **Son ID** : un hash SHA256 calculé à partir de :
- `kind` (type d'événement)
- `created_at` (timestamp)
- `pubkey` (clé publique de l'auteur)
- `tags` (tableau de tags)
- `content` (contenu de l'événement)
2. **Sa signature** : signature cryptographique de l'ID par la clé privée de l'auteur
## Pourquoi pas de doublons ?
### 1. Identité unique
Le même contenu génère **exactement le même ID**. Si vous publiez le même événement (même contenu, même timestamp, mêmes tags) sur 100 relais, c'est **exactement le même événement** avec le même ID.
### 2. Stockage par ID dans les relais
Les relais stockent les événements dans une base de données indexée par **ID d'événement**. Si un relais reçoit un événement avec un ID qu'il a déjà, il :
- **Ignore** l'événement (déjà stocké)
- **Ne crée pas de doublon**
### 3. Déduplication automatique
Quand un utilisateur s'abonne à plusieurs relais :
- Il peut recevoir le même événement de plusieurs sources
- Mais c'est **le même événement** (même ID)
- Les clients Nostr dédupliquent automatiquement basé sur l'ID
## Exemple concret
```
Événement publié sur relay1, relay2, relay3 :
- ID: abc123...
- Signature: def456...
- Contenu: "Mon article..."
```
Résultat :
- **Relay1** stocke : `abc123...` → "Mon article..."
- **Relay2** stocke : `abc123...` → "Mon article..."
- **Relay3** stocke : `abc123...` → "Mon article..."
C'est **le même événement** stocké 3 fois pour redondance, pas 3 événements différents.
## Avantages de publier sur plusieurs relais
1. **Redondance** : si un relais tombe, l'événement reste disponible sur les autres
2. **Performance** : les utilisateurs proches de différents relais obtiennent une latence réduite
3. **Résilience** : protège contre la censure si un relais supprime l'événement
4. **Découverte** : augmente les chances que votre contenu soit trouvé
## Conclusion
**Publier le même événement sur plusieurs relais est la pratique recommandée dans Nostr.** Cela ne crée pas de doublons car les relais utilisent l'ID comme clé unique. C'est similaire à uploader le même fichier sur plusieurs serveurs : c'est le même fichier, pas des copies différentes.

View File

@ -0,0 +1,106 @@
# Payment Linking System
**Author**: Équipe 4NK
**Date**: 2026-01-14
## Overview
This document describes the system for linking payments, payment notes, and related objects (publications, reviews, authors), and tracking funds received by authors and the platform.
## Structure
### Objects and Payments
- **Publications** (kind 1, type='publication'): Objects that are purchased
- Contains pages in JSON metadata (not separate notes)
- Linked via `articleId` tag in payment events
- Purchased via zap receipts (kind 9735) with `kind_type: purchase`
- **Series** (kind 1, type='series'): Objects that are reviewed
- Linked via `seriesId` tag in review payment events
- Reviews are about series
- **Reviews** (kind 1, type='quote'): Objects that are rewarded
- Linked via `reviewId` tag in payment events
- Rewarded via zap receipts (kind 9735) with `kind_type: review_tip`
- **Authors** (kind 1, type='author'): Objects that are sponsored
- Linked via `authorPubkey` / `#p` tag in payment events
- Sponsored via zap receipts (kind 9735) with `kind_type: sponsoring` (Lightning)
- Sponsored via Bitcoin mainnet transactions (mempool)
### Payment Flow
1. **Zap Receipt** (kind 9735) is created by Lightning wallet
- Contains payment hash, amount, recipient pubkey
- Tagged with `kind_type: purchase | review_tip | sponsoring`
- Links to object via tags: `article`, `review_id`, `author` (seriesId)
2. **Payment Note** (kind 1, type='payment') is published by platform/user
- Contains same information as zap receipt
- Links to zap receipt via `zap_receipt` tag
- Links to object via tags: `article`, `review_id`, `series`, `author`
3. **Funds Split**:
- **Author funds**: Amount received by author (after platform commission)
- **Platform funds**: Commission amount collected by platform
## Links
### Purchase Payment
- Zap Receipt (kind 9735) → `#e: [articleId]`, `#p: [authorPubkey]`, `kind_type: purchase`
- Payment Note (kind 1) → `article: [articleId]`, `recipient: [authorPubkey]`, `zap_receipt: [zapReceiptId]`
- Publication (kind 1) → `id: [articleId]`
### Review Tip Payment
- Zap Receipt (kind 9735) → `#p: [authorPubkey]`, `#e: [articleId]`, `kind_type: review_tip`, `review_id: [reviewId]`
- Payment Note (kind 1) → `recipient: [authorPubkey]`, `article: [articleId]`, `review_id: [reviewId]`, `zap_receipt: [zapReceiptId]`
- Review (kind 1) → `id: [reviewId]`, `article: [articleId]`
### Sponsoring Payment
- Zap Receipt (kind 9735) → `#p: [authorPubkey]`, `kind_type: sponsoring`
- OR Bitcoin Transaction (mempool) → verified via transaction ID
- Payment Note (kind 1) → `recipient: [authorPubkey]`, `zap_receipt: [zapReceiptId]` OR `transaction_id: [txId]`
- Author Presentation (kind 1) → `id: [authorPresentationId]`, `pubkey: [authorPubkey]`
## Funds Tracking
### For Each Author
**Funds Received** (author portion):
- Purchase payments: Total from zap receipts - platform commission
- Review tips: Total from zap receipts - platform commission
- Sponsoring (Lightning): Total from zap receipts - platform commission
- Sponsoring (Bitcoin mainnet): Total from verified transactions - platform commission
**Platform Funds** (commission):
- Purchase commission: 100 sats per purchase (from 800 sats total)
- Review tip commission: Variable per tip
- Sponsoring commission: Variable per sponsoring
### Implementation
1. Query all zap receipts for author (as recipient)
2. Query all payment notes for author (as recipient)
3. Query Bitcoin mainnet transactions for author's mainnet address
4. Calculate funds split for each payment
5. Aggregate totals
## Tags Enhancement
### Payment Notes should include:
- `publication_id` or `article`: Publication/Article ID
- `series_id` or `series`: Series ID (if applicable)
- `review_id`: Review ID (for review tips)
- `author`: Author pubkey (recipient)
- `zap_receipt`: Zap receipt ID (if Lightning)
- `transaction_id`: Bitcoin transaction ID (if mainnet sponsoring)
- `platform_commission`: Commission amount
- `author_funds`: Funds received by author
### Zap Receipts already include:
- `#e`: Event ID (article ID for purchases)
- `#p`: Pubkey (author pubkey)
- `kind_type`: Payment type
- `review_id`: Review ID (for review tips)
- `series`: Series ID (optional)

View File

@ -186,7 +186,7 @@ export class ArticlePublisher {
const { parsePresentationEvent } = await import('./articlePublisherHelpers') const { parsePresentationEvent } = await import('./articlePublisherHelpers')
const { extractTagsFromEvent } = await import('./nostrTagSystem') const { extractTagsFromEvent } = await import('./nostrTagSystem')
const { objectCache } = await import('./objectCache') const { objectCache } = await import('./objectCache')
const parsed = parsePresentationEvent(publishedEvent) const parsed = await parsePresentationEvent(publishedEvent)
if (parsed) { if (parsed) {
const tags = extractTagsFromEvent(publishedEvent) const tags = extractTagsFromEvent(publishedEvent)
const { id: tagId, version: tagVersion, hidden: tagHidden } = tags const { id: tagId, version: tagVersion, hidden: tagHidden } = tags

View File

@ -103,11 +103,11 @@ export async function fetchAuthorByHashId(
}) })
setTimeout(async (): Promise<void> => { setTimeout(async (): Promise<void> => {
// Get the latest version from all collected events // Get the latest version from all collected events
const latestEvent = getLatestVersion(events) const timeoutLatestEvent = getLatestVersion(events)
if (latestEvent) { if (timeoutLatestEvent) {
const parsed = await parsePresentationEvent(latestEvent) const timeoutParsed = await parsePresentationEvent(timeoutLatestEvent)
if (parsed) { if (timeoutParsed) {
await finalize(parsed) await finalize(timeoutParsed)
return return
} }
} }

View File

@ -98,15 +98,28 @@ export class ConfigStorage {
const store = transaction.objectStore(STORE_NAME) const store = transaction.objectStore(STORE_NAME)
const request = store.get('config') const request = store.get('config')
request.onsuccess = () => { request.onsuccess = async () => {
const result = request.result as { key: string; value: ConfigData } | undefined const result = request.result as { key: string; value: ConfigData } | undefined
if (!result?.value) { if (!result?.value) {
resolve(this.getDefaultConfig()) // First time: initialize with defaults
const defaultConfig = this.getDefaultConfig()
await this.saveConfig(defaultConfig)
resolve(defaultConfig)
return return
} }
resolve(result.value) // 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 = () => {

View File

@ -27,15 +27,31 @@ export interface ConfigData {
/** /**
* Default configuration values (hardcoded in the code) * Default configuration values (hardcoded in the code)
* All relays are enabled by default and sorted by priority
*/ */
const now = Date.now()
export const DEFAULT_RELAYS: RelayConfig[] = [ export const DEFAULT_RELAYS: RelayConfig[] = [
{ { id: 'default_1', url: 'wss://relay.damus.io', enabled: true, priority: 1, createdAt: now },
id: 'default', { id: 'default_2', url: 'wss://relay.nostr.band', enabled: true, priority: 2, createdAt: now },
url: 'wss://relay.damus.io', { id: 'default_3', url: 'wss://relay.nostr.wine', enabled: true, priority: 3, createdAt: now },
enabled: true, { id: 'default_4', url: 'wss://cache1.primal.net', enabled: true, priority: 4, createdAt: now },
priority: 1, { id: 'default_5', url: 'wss://relay.bitcoiner.social', enabled: true, priority: 5, createdAt: now },
createdAt: Date.now(), { id: 'default_6', url: 'wss://nostr.mutinywallet.com', enabled: true, priority: 6, createdAt: now },
}, { id: 'default_7', url: 'wss://relay.current.fyi', enabled: true, priority: 7, createdAt: now },
{ id: 'default_8', url: 'wss://eden.nostr.land', enabled: true, priority: 8, createdAt: now },
{ id: 'default_9', url: 'wss://filter.nostr1.com', enabled: true, priority: 9, createdAt: now },
{ id: 'default_10', url: 'wss://relay.nos.social', enabled: true, priority: 10, createdAt: now },
{ id: 'default_11', url: 'wss://relay.nostr.dev.br', enabled: true, priority: 11, createdAt: now },
{ id: 'default_12', url: 'wss://relay.nostr.inosta.cc', enabled: true, priority: 12, createdAt: now },
{ id: 'default_13', url: 'wss://nostr.land', enabled: true, priority: 13, createdAt: now },
{ id: 'default_14', url: 'wss://relay.nostr.pub', enabled: true, priority: 14, createdAt: now },
{ id: 'default_15', url: 'wss://nostr.rocks', enabled: true, priority: 15, createdAt: now },
{ id: 'default_16', url: 'wss://purplepag.es', enabled: true, priority: 16, createdAt: now },
{ id: 'default_17', url: 'wss://relay.nostr.info', enabled: true, priority: 17, createdAt: now },
{ id: 'default_18', url: 'wss://relay.nostrich.de', enabled: true, priority: 18, createdAt: now },
{ id: 'default_19', url: 'wss://relay.snort.social', enabled: true, priority: 19, createdAt: now },
{ id: 'default_20', url: 'wss://relay.wellorder.net', enabled: true, priority: 20, createdAt: now },
{ id: 'default_21', url: 'wss://wot.nostr.party', enabled: true, priority: 21, createdAt: now },
] ]
export const DEFAULT_NIP95_APIS: Nip95Config[] = [ export const DEFAULT_NIP95_APIS: Nip95Config[] = [

View File

@ -76,12 +76,40 @@ class NostrService {
const event = finalizeEvent(unsignedEvent, secretKey) const event = finalizeEvent(unsignedEvent, secretKey)
try { try {
const relayUrl = await getPrimaryRelay() // Publish to all active relays (enabled and not marked inactive for this session)
const pubs = this.pool.publish([relayUrl], event) // Each event has a unique ID based on content, so publishing to multiple relays
await Promise.all(pubs) // doesn't create duplicates - it's the same event stored redundantly
const { relaySessionManager } = await import('./relaySessionManager')
const activeRelays = await relaySessionManager.getActiveRelays()
if (activeRelays.length === 0) {
// Fallback to primary relay if no active relays
const relayUrl = await getPrimaryRelay()
const pubs = this.pool.publish([relayUrl], event)
await Promise.all(pubs)
} else {
// Publish to all active relays
console.log(`[NostrService] Publishing event ${event.id} to ${activeRelays.length} active relay(s)`)
const pubs = this.pool.publish(activeRelays, event)
// Track failed relays and mark them inactive for the session
const results = await Promise.allSettled(pubs)
results.forEach((result, index) => {
const relayUrl = activeRelays[index]
if (!relayUrl) {
return
}
if (result.status === 'rejected') {
const error = result.reason
console.error(`[NostrService] Relay ${relayUrl} failed during publish:`, error)
relaySessionManager.markRelayFailed(relayUrl)
}
})
}
return event return event
} catch (e) { } catch (publishError) {
throw new Error(`Publish failed: ${e}`) throw new Error(`Publish failed: ${publishError}`)
} }
} }
@ -127,8 +155,9 @@ class NostrService {
const sub = this.createArticleSubscription(this.pool, limit) const sub = this.createArticleSubscription(this.pool, limit)
sub.on('event', async (event: Event): Promise<void> => { sub.on('event', (event: Event): void => {
try { void (async (): Promise<void> => {
try {
// Try to parse as regular article first // Try to parse as regular article first
let article = await parseArticleFromEvent(event) let article = await parseArticleFromEvent(event)
// If not a regular article, try to parse as author presentation // If not a regular article, try to parse as author presentation
@ -141,9 +170,10 @@ class NostrService {
if (article) { if (article) {
callback(article) callback(article)
} }
} catch (e) { } catch (parseError) {
console.error('Error parsing article:', e) console.error('Error parsing article:', parseError)
} }
})()
}) })
return (): void => { return (): void => {
@ -200,11 +230,11 @@ class NostrService {
} }
return decryptArticleContentWithKey(event.content, decryptionKey) return decryptArticleContentWithKey(event.content, decryptionKey)
} catch (error) { } catch (decryptError) {
console.error('Error decrypting article content', { console.error('Error decrypting article content', {
eventId, eventId,
authorPubkey, authorPubkey,
error: error instanceof Error ? error.message : 'Unknown error', error: decryptError instanceof Error ? decryptError.message : 'Unknown error',
}) })
return null return null
} }
@ -239,8 +269,8 @@ class NostrService {
try { try {
const profile = JSON.parse(event.content) as NostrProfile const profile = JSON.parse(event.content) as NostrProfile
return { ...profile, pubkey } return { ...profile, pubkey }
} catch (error) { } catch (parseProfileError) {
console.error('Error parsing profile:', error) console.error('Error parsing profile:', parseProfileError)
return null return null
} }
} }

View File

@ -8,7 +8,7 @@ import type { Event as NostrEvent } from 'nostr-tools'
import type { AuthorPresentationArticle } from '@/types/nostr' import type { AuthorPresentationArticle } from '@/types/nostr'
import { buildObjectId } from './urlGenerator' import { buildObjectId } from './urlGenerator'
export type ObjectType = 'author' | 'series' | 'publication' | 'review' | 'purchase' | 'sponsoring' | 'review_tip' export type ObjectType = 'author' | 'series' | 'publication' | 'review' | 'purchase' | 'sponsoring' | 'review_tip' | 'payment_note'
interface CachedObject { interface CachedObject {
id: string // Format: <hash>_<index>_<version> id: string // Format: <hash>_<index>_<version>
@ -99,8 +99,8 @@ class ObjectCacheService {
reject(request.error) reject(request.error)
} }
}) })
} catch (error) { } catch (countError) {
console.error(`Error counting objects with hash ${hash}:`, error) console.error(`Error counting objects with hash ${hash}:`, countError)
return 0 return 0
} }
} }
@ -155,8 +155,8 @@ class ObjectCacheService {
reject(request.error) reject(request.error)
} }
}) })
} catch (error) { } catch (cacheError) {
console.error(`Error caching ${objectType} object:`, error) console.error(`Error caching ${objectType} object:`, cacheError)
} }
} }
@ -198,8 +198,8 @@ class ObjectCacheService {
reject(request.error) reject(request.error)
} }
}) })
} catch (error) { } catch (retrieveError) {
console.error(`Error retrieving ${objectType} object from cache:`, error) console.error(`Error retrieving ${objectType} object from cache:`, retrieveError)
return null return null
} }
} }
@ -227,8 +227,8 @@ class ObjectCacheService {
reject(request.error) reject(request.error)
} }
}) })
} catch (error) { } catch (retrieveByIdError) {
console.error(`Error retrieving ${objectType} object by ID from cache:`, error) console.error(`Error retrieving ${objectType} object by ID from cache:`, retrieveByIdError)
return null return null
} }
} }
@ -269,8 +269,8 @@ class ObjectCacheService {
reject(request.error) reject(request.error)
} }
}) })
} catch (error) { } catch (authorRetrieveError) {
console.error('Error retrieving author from cache by pubkey:', error) console.error('Error retrieving author from cache by pubkey:', authorRetrieveError)
return null return null
} }
} }
@ -305,8 +305,8 @@ class ObjectCacheService {
reject(request.error) reject(request.error)
} }
}) })
} catch (error) { } catch (getAllError) {
console.error(`Error retrieving all ${objectType} objects from cache:`, error) console.error(`Error retrieving all ${objectType} objects from cache:`, getAllError)
return [] return []
} }
} }
@ -328,8 +328,8 @@ class ObjectCacheService {
reject(request.error) reject(request.error)
} }
}) })
} catch (error) { } catch (clearError) {
console.error(`Error clearing ${objectType} cache:`, error) console.error(`Error clearing ${objectType} cache:`, clearError)
} }
} }
} }

View File

@ -21,10 +21,36 @@ export class PlatformTrackingService {
if (!pool) { if (!pool) {
throw new Error('Pool not initialized') throw new Error('Pool not initialized')
} }
const { getPrimaryRelaySync } = await import('./config')
const relayUrl = getPrimaryRelaySync() // Publish to all active relays (enabled and not marked inactive for this session)
const pubs = pool.publish([relayUrl], event) const { relaySessionManager } = await import('./relaySessionManager')
await Promise.all(pubs) const activeRelays = await relaySessionManager.getActiveRelays()
if (activeRelays.length === 0) {
// Fallback to primary relay if no active relays
const { getPrimaryRelaySync } = await import('./config')
const relayUrl = getPrimaryRelaySync()
const pubs = pool.publish([relayUrl], event)
await Promise.all(pubs)
} else {
// Publish to all active relays
console.log(`[PlatformTracking] Publishing tracking event ${event.id} to ${activeRelays.length} active relay(s)`)
const pubs = pool.publish(activeRelays, event)
// Track failed relays and mark them inactive for the session
const results = await Promise.allSettled(pubs)
results.forEach((result, index) => {
const relayUrl = activeRelays[index]
if (!relayUrl) {
return
}
if (result.status === 'rejected') {
const error = result.reason
console.error(`[PlatformTracking] Relay ${relayUrl} failed during publish:`, error)
relaySessionManager.markRelayFailed(relayUrl)
}
})
}
} }
private validateTrackingPool(): { pool: SimplePoolWithSub; authorPubkey: string } | null { private validateTrackingPool(): { pool: SimplePoolWithSub; authorPubkey: string } | null {

View File

@ -85,15 +85,17 @@ export async function getPurchaseById(purchaseId: string, timeoutMs: number = 50
resolve(value) resolve(value)
} }
sub.on('event', async (event: Event): Promise<void> => { sub.on('event', (event: Event): void => {
const purchaseParsed = await parsePurchaseFromEvent(event) void (async (): Promise<void> => {
if (purchaseParsed?.id === purchaseId) { const purchaseParsed = await parsePurchaseFromEvent(event)
// Cache the parsed purchase if (purchaseParsed?.id === purchaseId) {
if (purchaseParsed.hash) { // Cache the parsed purchase
await objectCache.set('purchase', purchaseParsed.hash, event, purchaseParsed, 0, false, purchaseParsed.index) if (purchaseParsed.hash) {
await objectCache.set('purchase', purchaseParsed.hash, event, purchaseParsed, 0, false, purchaseParsed.index)
}
done(purchaseParsed)
} }
done(purchaseParsed) })()
}
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {
@ -127,15 +129,17 @@ export function getPurchasesForArticle(articleId: string, timeoutMs: number = 50
resolve(results) resolve(results)
} }
sub.on('event', async (event: Event): Promise<void> => { sub.on('event', (event: Event): void => {
const purchaseParsed = await parsePurchaseFromEvent(event) void (async (): Promise<void> => {
if (purchaseParsed?.articleId === articleId) { const purchaseParsed = await parsePurchaseFromEvent(event)
// Cache the parsed purchase if (purchaseParsed?.articleId === articleId) {
if (purchaseParsed.hash) { // Cache the parsed purchase
await objectCache.set('purchase', purchaseParsed.hash, event, purchaseParsed, 0, false, purchaseParsed.index) if (purchaseParsed.hash) {
await objectCache.set('purchase', purchaseParsed.hash, event, purchaseParsed, 0, false, purchaseParsed.index)
}
results.push(purchaseParsed)
} }
results.push(purchaseParsed) })()
}
}) })
sub.on('eose', () => done()) sub.on('eose', () => done())
@ -167,15 +171,17 @@ export function getPurchasesByPayer(payerPubkey: string, timeoutMs: number = 500
resolve(results) resolve(results)
} }
sub.on('event', async (event: Event): Promise<void> => { sub.on('event', (event: Event): void => {
const purchaseParsed = await parsePurchaseFromEvent(event) void (async (): Promise<void> => {
if (purchaseParsed) { const purchaseParsed = await parsePurchaseFromEvent(event)
// Cache the parsed purchase if (purchaseParsed) {
if (purchaseParsed.hash) { // Cache the parsed purchase
await objectCache.set('purchase', purchaseParsed.hash, event, purchaseParsed, 0, false, purchaseParsed.index ?? 0) if (purchaseParsed.hash) {
await objectCache.set('purchase', purchaseParsed.hash, event, purchaseParsed, 0, false, purchaseParsed.index ?? 0)
}
results.push(purchaseParsed)
} }
results.push(purchaseParsed) })()
}
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {

116
lib/relayRotation.ts Normal file
View File

@ -0,0 +1,116 @@
/**
* Relay rotation utility
* Tries relays in sequence, rotating through the list on failure
* No retry on individual relay, just move to next and loop
* Relays that fail are marked inactive for the session
*/
import type { SimplePool } from 'nostr-tools'
import type { Filter } from 'nostr-tools'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import { createSubscription } from '@/types/nostr-tools-extended'
import { relaySessionManager } from './relaySessionManager'
/**
* Try to execute an operation with relay rotation
* Tries each relay in sequence, moving to next on failure
* Loops back to first relay after trying all
*/
export async function tryWithRelayRotation<T>(
pool: SimplePool,
operation: (relayUrl: string, pool: SimplePoolWithSub) => Promise<T>,
timeout: number = 10000
): Promise<T> {
// Get active relays (enabled and not marked inactive for this session)
const activeRelays = await relaySessionManager.getActiveRelays()
if (activeRelays.length === 0) {
throw new Error('No active relays available')
}
let lastError: Error | null = null
let attempts = 0
const maxAttempts = activeRelays.length * 2 // Try all active relays twice (loop once)
while (attempts < maxAttempts) {
// Get current active relays (may have changed if some were marked inactive)
const currentActiveRelays = await relaySessionManager.getActiveRelays()
if (currentActiveRelays.length === 0) {
throw new Error('No active relays available')
}
const relayIndex = attempts % currentActiveRelays.length
const relayUrl = currentActiveRelays[relayIndex]
if (!relayUrl) {
throw new Error('Invalid relay configuration')
}
// Skip if relay was marked failed during the loop (it will be at the bottom now)
// We continue to use it but it's lower priority
try {
console.log(`[RelayRotation] Trying relay ${relayIndex + 1}/${currentActiveRelays.length}: ${relayUrl}`)
// Notify progress manager that we're switching to a new relay (reset to 0 for this relay)
const { syncProgressManager } = await import('./syncProgressManager')
const currentProgress = syncProgressManager.getProgress()
if (currentProgress) {
syncProgressManager.setProgress({
...currentProgress,
currentStep: 0, // Reset to 0 when changing relay
currentRelay: relayUrl,
})
}
const poolWithSub = pool as unknown as SimplePoolWithSub
const result = await Promise.race([
operation(relayUrl, poolWithSub),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)
),
])
console.log(`[RelayRotation] Success with relay: ${relayUrl}`)
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.warn(`[RelayRotation] Relay ${relayUrl} failed: ${errorMessage}`)
// Mark relay as failed (move to bottom of priority list)
relaySessionManager.markRelayFailed(relayUrl)
lastError = error instanceof Error ? error : new Error(String(error))
attempts++
// If we've tried all relays once, loop back
if (attempts < maxAttempts) {
continue
}
}
}
// If we get here, all relays failed
throw lastError ?? new Error('All relays failed')
}
/**
* Create a subscription with relay rotation
* Tries each relay until one succeeds
*/
export async function createSubscriptionWithRotation(
pool: SimplePool,
filters: Filter[],
timeout: number = 10000
): Promise<{
subscription: import('@/types/nostr-tools-extended').Subscription
relayUrl: string
}> {
return tryWithRelayRotation(
pool,
async (relayUrl, poolWithSub) => {
const subscription = createSubscription(poolWithSub, [relayUrl], filters)
return { subscription, relayUrl }
},
timeout
)
}

105
lib/relaySessionManager.ts Normal file
View File

@ -0,0 +1,105 @@
/**
* Relay session manager
* Tracks which relays are active/inactive during the current browser session
* Relays that fail (connection or publish errors) are moved to the bottom of the priority list
* All relays are reset to active on page load
*/
import { getEnabledRelays } from './config'
class RelaySessionManager {
private failedRelays: Set<string> = new Set() // Relays that have failed (moved to bottom)
/**
* Initialize: reset all relays to active at session start
*/
public async initialize(): Promise<void> {
this.failedRelays.clear()
console.log('[RelaySessionManager] Session initialized - all relays active')
}
/**
* Get active relays (enabled relays, with failed ones moved to the bottom)
* Failed relays are still active but prioritized last
*/
public async getActiveRelays(): Promise<string[]> {
const enabledRelays = await getEnabledRelays()
// Separate working relays from failed ones
const workingRelays: string[] = []
const failedRelaysList: string[] = []
for (const relay of enabledRelays) {
if (this.failedRelays.has(relay)) {
failedRelaysList.push(relay)
} else {
workingRelays.push(relay)
}
}
// Return working relays first, then failed ones at the bottom
return [...workingRelays, ...failedRelaysList]
}
/**
* Mark a relay as failed (move it to the bottom of the priority list)
*/
public markRelayFailed(relayUrl: string): void {
if (!this.failedRelays.has(relayUrl)) {
this.failedRelays.add(relayUrl)
console.warn(`[RelaySessionManager] Relay moved to bottom of priority list: ${relayUrl}`)
}
}
/**
* Check if a relay has failed (is at the bottom of the list)
*/
public isRelayFailed(relayUrl: string): boolean {
return this.failedRelays.has(relayUrl)
}
/**
* Get list of failed relays for this session
*/
public getFailedRelays(): string[] {
return Array.from(this.failedRelays)
}
/**
* Get count of active relays (including failed ones at the bottom)
*/
public async getActiveRelayCount(): Promise<number> {
const activeRelays = await this.getActiveRelays()
return activeRelays.length
}
/**
* Legacy method name for compatibility (now moves to bottom instead of deactivating)
*/
public markRelayInactive(relayUrl: string): void {
this.markRelayFailed(relayUrl)
}
/**
* Legacy method name for compatibility (failed relays are still "active" but at bottom)
*/
public isRelayActive(_relayUrl: string): boolean {
// All enabled relays are considered "active", even if failed
// They're just at the bottom of the priority list
return true
}
/**
* Legacy method name for compatibility
*/
public getInactiveRelays(): string[] {
return this.getFailedRelays()
}
}
export const relaySessionManager = new RelaySessionManager()
// Initialize on module load (page load)
if (typeof window !== 'undefined') {
void relaySessionManager.initialize()
}

View File

@ -92,15 +92,17 @@ export async function getReviewTipById(reviewTipId: string, timeoutMs: number =
resolve(value) resolve(value)
} }
sub.on('event', async (event: Event): Promise<void> => { sub.on('event', (event: Event): void => {
const reviewTipParsed = await parseReviewTipFromEvent(event) void (async (): Promise<void> => {
if (reviewTipParsed?.id === reviewTipId) { const reviewTipParsed = await parseReviewTipFromEvent(event)
// Cache the parsed review tip if (reviewTipParsed?.id === reviewTipId) {
if (reviewTipParsed.hash) { // Cache the parsed review tip
await objectCache.set('review_tip', reviewTipParsed.hash, event, reviewTipParsed, 0, false, reviewTipParsed.index ?? 0) if (reviewTipParsed.hash) {
await objectCache.set('review_tip', reviewTipParsed.hash, event, reviewTipParsed, 0, false, reviewTipParsed.index ?? 0)
}
done(reviewTipParsed)
} }
done(reviewTipParsed) })()
}
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {
@ -134,15 +136,17 @@ export function getReviewTipsForArticle(articleId: string, timeoutMs: number = 5
resolve(results) resolve(results)
} }
sub.on('event', async (event: Event): Promise<void> => { sub.on('event', (event: Event): void => {
const reviewTipParsed = await parseReviewTipFromEvent(event) void (async (): Promise<void> => {
if (reviewTipParsed?.articleId === articleId) { const reviewTipParsed = await parseReviewTipFromEvent(event)
// Cache the parsed review tip if (reviewTipParsed?.articleId === articleId) {
if (reviewTipParsed.hash) { // Cache the parsed review tip
await objectCache.set('review_tip', reviewTipParsed.hash, event, reviewTipParsed, 0, false, reviewTipParsed.index ?? 0) if (reviewTipParsed.hash) {
await objectCache.set('review_tip', reviewTipParsed.hash, event, reviewTipParsed, 0, false, reviewTipParsed.index ?? 0)
}
results.push(reviewTipParsed)
} }
results.push(reviewTipParsed) })()
}
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {
@ -176,15 +180,17 @@ export function getReviewTipsForReview(reviewId: string, timeoutMs: number = 500
resolve(results) resolve(results)
} }
sub.on('event', async (event: Event): Promise<void> => { sub.on('event', (event: Event): void => {
const reviewTipParsed = await parseReviewTipFromEvent(event) void (async (): Promise<void> => {
if (reviewTipParsed?.reviewId === reviewId) { const reviewTipParsed = await parseReviewTipFromEvent(event)
// Cache the parsed review tip if (reviewTipParsed?.reviewId === reviewId) {
if (reviewTipParsed.hash) { // Cache the parsed review tip
await objectCache.set('review_tip', reviewTipParsed.hash, event, reviewTipParsed, 0, false, reviewTipParsed.index ?? 0) if (reviewTipParsed.hash) {
await objectCache.set('review_tip', reviewTipParsed.hash, event, reviewTipParsed, 0, false, reviewTipParsed.index ?? 0)
}
results.push(reviewTipParsed)
} }
results.push(reviewTipParsed) })()
}
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {

View File

@ -59,11 +59,13 @@ export function getReviewsForArticle(articleId: string, timeoutMs: number = 5000
resolve(results) resolve(results)
} }
sub.on('event', async (event: Event): Promise<void> => { sub.on('event', (event: Event): void => {
const parsed = await parseReviewFromEvent(event) void (async (): Promise<void> => {
if (parsed) { const parsed = await parseReviewFromEvent(event)
results.push(parsed) if (parsed) {
} results.push(parsed)
}
})()
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {

View File

@ -53,11 +53,13 @@ export function getSeriesByAuthor(authorPubkey: string, timeoutMs: number = 5000
resolve(results) resolve(results)
} }
sub.on('event', async (event: Event): Promise<void> => { sub.on('event', (event: Event): void => {
const parsed = await parseSeriesFromEvent(event) void (async (): Promise<void> => {
if (parsed) { const parsed = await parseSeriesFromEvent(event)
results.push(parsed) if (parsed) {
} results.push(parsed)
}
})()
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {

View File

@ -92,15 +92,17 @@ export async function getSponsoringById(sponsoringId: string, timeoutMs: number
resolve(value) resolve(value)
} }
sub.on('event', async (event: Event) => { sub.on('event', (event: Event): void => {
const sponsoringParsed = await parseSponsoringFromEvent(event) void (async (): Promise<void> => {
if (sponsoringParsed?.id === sponsoringId) { const sponsoringParsed = await parseSponsoringFromEvent(event)
// Cache the parsed sponsoring if (sponsoringParsed?.id === sponsoringId) {
if (sponsoringParsed.hash) { // Cache the parsed sponsoring
await objectCache.set('sponsoring', sponsoringParsed.hash, event, sponsoringParsed, 0, false, sponsoringParsed.index ?? 0) if (sponsoringParsed.hash) {
await objectCache.set('sponsoring', sponsoringParsed.hash, event, sponsoringParsed, 0, false, sponsoringParsed.index ?? 0)
}
done(sponsoringParsed)
} }
done(sponsoringParsed) })()
}
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {
@ -134,15 +136,17 @@ export function getSponsoringByAuthor(authorPubkey: string, timeoutMs: number =
resolve(results) resolve(results)
} }
sub.on('event', async (event: Event): Promise<void> => { sub.on('event', (event: Event): void => {
const sponsoringParsed = await parseSponsoringFromEvent(event) void (async (): Promise<void> => {
if (sponsoringParsed?.authorPubkey === authorPubkey) { const sponsoringParsed = await parseSponsoringFromEvent(event)
// Cache the parsed sponsoring if (sponsoringParsed?.authorPubkey === authorPubkey) {
if (sponsoringParsed.hash) { // Cache the parsed sponsoring
await objectCache.set('sponsoring', sponsoringParsed.hash, event, sponsoringParsed, 0, false, sponsoringParsed.index ?? 0) if (sponsoringParsed.hash) {
await objectCache.set('sponsoring', sponsoringParsed.hash, event, sponsoringParsed, 0, false, sponsoringParsed.index ?? 0)
}
results.push(sponsoringParsed)
} }
results.push(sponsoringParsed) })()
}
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {

View File

@ -74,10 +74,35 @@ export class SponsoringTrackingService {
if (!pool) { if (!pool) {
throw new Error('Pool not initialized') throw new Error('Pool not initialized')
} }
const poolWithSub = pool
const relayUrl = getPrimaryRelaySync() // Publish to all active relays (enabled and not marked inactive for this session)
const pubs = poolWithSub.publish([relayUrl], event) const { relaySessionManager } = await import('./relaySessionManager')
await Promise.all(pubs) const activeRelays = await relaySessionManager.getActiveRelays()
if (activeRelays.length === 0) {
// Fallback to primary relay if no active relays
const relayUrl = getPrimaryRelaySync()
const pubs = pool.publish([relayUrl], event)
await Promise.all(pubs)
} else {
// Publish to all active relays
console.log(`[SponsoringTracking] Publishing tracking event ${event.id} to ${activeRelays.length} active relay(s)`)
const pubs = pool.publish(activeRelays, event)
// Track failed relays and mark them inactive for the session
const results = await Promise.allSettled(pubs)
results.forEach((result, index) => {
const relayUrl = activeRelays[index]
if (!relayUrl) {
return
}
if (result.status === 'rejected') {
const error = result.reason
console.error(`[SponsoringTracking] Relay ${relayUrl} failed during publish:`, error)
relaySessionManager.markRelayFailed(relayUrl)
}
})
}
} }
async trackSponsoringPayment( async trackSponsoringPayment(

View File

@ -0,0 +1,33 @@
/**
* Global sync progress manager
* Stores sync progress state that can be accessed from any component
*/
import type { SyncProgress } from './userContentSync'
type SyncProgressListener = (progress: SyncProgress | null) => void
class SyncProgressManager {
private progress: SyncProgress | null = null
private listeners: Set<SyncProgressListener> = new Set()
setProgress(progress: SyncProgress | null): void {
this.progress = progress
this.listeners.forEach((listener) => listener(progress))
}
getProgress(): SyncProgress | null {
return this.progress
}
subscribe(listener: SyncProgressListener): () => void {
this.listeners.add(listener)
// Immediately call with current progress
listener(this.progress)
return () => {
this.listeners.delete(listener)
}
}
}
export const syncProgressManager = new SyncProgressManager()

View File

@ -12,6 +12,7 @@ import { objectCache } from './objectCache'
import { getLatestVersion } from './versionManager' import { getLatestVersion } from './versionManager'
import { buildTagFilter } from './nostrTagSystemFilter' import { buildTagFilter } from './nostrTagSystemFilter'
import { getPrimaryRelaySync } from './config' import { getPrimaryRelaySync } from './config'
import { tryWithRelayRotation } from './relayRotation'
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig' import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
import { parseObjectId } from './urlGenerator' import { parseObjectId } from './urlGenerator'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
@ -35,9 +36,40 @@ async function fetchAndCachePublications(
}, },
] ]
const relayUrl = getPrimaryRelaySync() // Try relays with rotation (no retry on failure, just move to next)
const { createSubscription } = require('@/types/nostr-tools-extended') const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], filters) let sub: ReturnType<typeof createSubscription> | null = null
let usedRelayUrl = ''
try {
const result = await tryWithRelayRotation(
pool as unknown as import('nostr-tools').SimplePool,
async (relayUrl, poolWithSub) => {
usedRelayUrl = relayUrl
// Notify progress manager that we're starting with a new relay (reset step counter)
const { syncProgressManager } = await import('./syncProgressManager')
const currentProgress = syncProgressManager.getProgress()
if (currentProgress) {
syncProgressManager.setProgress({
...currentProgress,
currentStep: 0,
currentRelay: relayUrl,
})
}
return createSubscription(poolWithSub, [relayUrl], filters)
},
5000 // 5 second timeout per relay
)
sub = result
} catch (rotationError) {
// Fallback to primary relay if rotation fails
usedRelayUrl = getPrimaryRelaySync()
sub = createSubscription(pool, [usedRelayUrl], filters)
}
if (!sub) {
throw new Error('Failed to create subscription')
}
const events: Event[] = [] const events: Event[] = []
@ -72,9 +104,9 @@ async function fetchAndCachePublications(
if (latestEvent) { if (latestEvent) {
const extracted = await extractPublicationFromEvent(latestEvent) const extracted = await extractPublicationFromEvent(latestEvent)
if (extracted) { if (extracted) {
const parsed = parseObjectId(extracted.id) const publicationParsed = parseObjectId(extracted.id)
const extractedHash = parsed.hash ?? extracted.id const extractedHash = publicationParsed.hash ?? extracted.id
const extractedIndex = parsed.index ?? 0 const extractedIndex = publicationParsed.index ?? 0
const tags = extractTagsFromEvent(latestEvent) const tags = extractTagsFromEvent(latestEvent)
await objectCache.set( await objectCache.set(
'publication', 'publication',
@ -129,9 +161,40 @@ async function fetchAndCacheSeries(
}, },
] ]
const relayUrl = getPrimaryRelaySync() // Try relays with rotation (no retry on failure, just move to next)
const { createSubscription } = require('@/types/nostr-tools-extended') const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], filters) let sub: ReturnType<typeof createSubscription> | null = null
let usedRelayUrl = ''
try {
const result = await tryWithRelayRotation(
pool as unknown as import('nostr-tools').SimplePool,
async (relayUrl, poolWithSub) => {
usedRelayUrl = relayUrl
// Notify progress manager that we're starting with a new relay (reset step counter)
const { syncProgressManager } = await import('./syncProgressManager')
const currentProgress = syncProgressManager.getProgress()
if (currentProgress) {
syncProgressManager.setProgress({
...currentProgress,
currentStep: 0,
currentRelay: relayUrl,
})
}
return createSubscription(poolWithSub, [relayUrl], filters)
},
5000 // 5 second timeout per relay
)
sub = result
} catch (rotationError) {
// Fallback to primary relay if rotation fails
usedRelayUrl = getPrimaryRelaySync()
sub = createSubscription(pool, [usedRelayUrl], filters)
}
if (!sub) {
throw new Error('Failed to create subscription')
}
const events: Event[] = [] const events: Event[] = []
@ -151,8 +214,8 @@ async function fetchAndCacheSeries(
const tags = extractTagsFromEvent(event) const tags = extractTagsFromEvent(event)
if (tags.id) { if (tags.id) {
// Extract hash from id (can be <hash>_<index>_<version> or just hash) // Extract hash from id (can be <hash>_<index>_<version> or just hash)
const parsed = parseObjectId(tags.id) const seriesParsed = parseObjectId(tags.id)
const hash = parsed.hash ?? tags.id const hash = seriesParsed.hash ?? tags.id
if (!eventsByHashId.has(hash)) { if (!eventsByHashId.has(hash)) {
eventsByHashId.set(hash, []) eventsByHashId.set(hash, [])
} }
@ -166,9 +229,9 @@ async function fetchAndCacheSeries(
if (latestEvent) { if (latestEvent) {
const extracted = await extractSeriesFromEvent(latestEvent) const extracted = await extractSeriesFromEvent(latestEvent)
if (extracted) { if (extracted) {
const parsed = parseObjectId(extracted.id) const publicationParsed = parseObjectId(extracted.id)
const extractedHash = parsed.hash ?? extracted.id const extractedHash = publicationParsed.hash ?? extracted.id
const extractedIndex = parsed.index ?? 0 const extractedIndex = publicationParsed.index ?? 0
const tags = extractTagsFromEvent(latestEvent) const tags = extractTagsFromEvent(latestEvent)
await objectCache.set( await objectCache.set(
'series', 'series',
@ -189,15 +252,20 @@ async function fetchAndCacheSeries(
sub.on('event', (event: Event): void => { sub.on('event', (event: Event): void => {
const tags = extractTagsFromEvent(event) const tags = extractTagsFromEvent(event)
if (tags.type === 'series' && !tags.hidden) { if (tags.type === 'series' && !tags.hidden) {
console.log('[Sync] Received series event:', event.id)
events.push(event) events.push(event)
} }
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {
console.log(`[Sync] EOSE for series, received ${events.length} events`)
void done() void done()
}) })
setTimeout((): void => { setTimeout((): void => {
if (!finished) {
console.log(`[Sync] Timeout for series, received ${events.length} events`)
}
void done() void done()
}, 10000).unref?.() }, 10000).unref?.()
}) })
@ -220,9 +288,40 @@ async function fetchAndCachePurchases(
}, },
] ]
const relayUrl = getPrimaryRelaySync() // Try relays with rotation (no retry on failure, just move to next)
const { createSubscription } = require('@/types/nostr-tools-extended') const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], filters) let sub: ReturnType<typeof createSubscription> | null = null
let usedRelayUrl = ''
try {
const result = await tryWithRelayRotation(
pool as unknown as import('nostr-tools').SimplePool,
async (relayUrl, poolWithSub) => {
usedRelayUrl = relayUrl
// Notify progress manager that we're starting with a new relay (reset step counter)
const { syncProgressManager } = await import('./syncProgressManager')
const currentProgress = syncProgressManager.getProgress()
if (currentProgress) {
syncProgressManager.setProgress({
...currentProgress,
currentStep: 0,
currentRelay: relayUrl,
})
}
return createSubscription(poolWithSub, [relayUrl], filters)
},
5000 // 5 second timeout per relay
)
sub = result
} catch (rotationError) {
// Fallback to primary relay if rotation fails
usedRelayUrl = getPrimaryRelaySync()
sub = createSubscription(pool, [usedRelayUrl], filters)
}
if (!sub) {
throw new Error('Failed to create subscription')
}
const events: Event[] = [] const events: Event[] = []
@ -252,14 +351,19 @@ async function fetchAndCachePurchases(
} }
sub.on('event', (event: Event): void => { sub.on('event', (event: Event): void => {
console.log('[Sync] Received purchase event:', event.id)
events.push(event) events.push(event)
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {
console.log(`[Sync] EOSE for purchases, received ${events.length} events`)
void done() void done()
}) })
setTimeout((): void => { setTimeout((): void => {
if (!finished) {
console.log(`[Sync] Timeout for purchases, received ${events.length} events`)
}
void done() void done()
}, 10000).unref?.() }, 10000).unref?.()
}) })
@ -282,9 +386,40 @@ async function fetchAndCacheSponsoring(
}, },
] ]
const relayUrl = getPrimaryRelaySync() // Try relays with rotation (no retry on failure, just move to next)
const { createSubscription } = require('@/types/nostr-tools-extended') const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], filters) let sub: ReturnType<typeof createSubscription> | null = null
let usedRelayUrl = ''
try {
const result = await tryWithRelayRotation(
pool as unknown as import('nostr-tools').SimplePool,
async (relayUrl, poolWithSub) => {
usedRelayUrl = relayUrl
// Notify progress manager that we're starting with a new relay (reset step counter)
const { syncProgressManager } = await import('./syncProgressManager')
const currentProgress = syncProgressManager.getProgress()
if (currentProgress) {
syncProgressManager.setProgress({
...currentProgress,
currentStep: 0,
currentRelay: relayUrl,
})
}
return createSubscription(poolWithSub, [relayUrl], filters)
},
5000 // 5 second timeout per relay
)
sub = result
} catch (rotationError) {
// Fallback to primary relay if rotation fails
usedRelayUrl = getPrimaryRelaySync()
sub = createSubscription(pool, [usedRelayUrl], filters)
}
if (!sub) {
throw new Error('Failed to create subscription')
}
const events: Event[] = [] const events: Event[] = []
@ -314,14 +449,19 @@ async function fetchAndCacheSponsoring(
} }
sub.on('event', (event: Event): void => { sub.on('event', (event: Event): void => {
console.log('[Sync] Received sponsoring event:', event.id)
events.push(event) events.push(event)
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {
console.log(`[Sync] EOSE for sponsoring, received ${events.length} events`)
void done() void done()
}) })
setTimeout((): void => { setTimeout((): void => {
if (!finished) {
console.log(`[Sync] Timeout for sponsoring, received ${events.length} events`)
}
void done() void done()
}, 10000).unref?.() }, 10000).unref?.()
}) })
@ -344,9 +484,40 @@ async function fetchAndCacheReviewTips(
}, },
] ]
const relayUrl = getPrimaryRelaySync() // Try relays with rotation (no retry on failure, just move to next)
const { createSubscription } = require('@/types/nostr-tools-extended') const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], filters) let sub: ReturnType<typeof createSubscription> | null = null
let usedRelayUrl = ''
try {
const result = await tryWithRelayRotation(
pool as unknown as import('nostr-tools').SimplePool,
async (relayUrl, poolWithSub) => {
usedRelayUrl = relayUrl
// Notify progress manager that we're starting with a new relay (reset step counter)
const { syncProgressManager } = await import('./syncProgressManager')
const currentProgress = syncProgressManager.getProgress()
if (currentProgress) {
syncProgressManager.setProgress({
...currentProgress,
currentStep: 0,
currentRelay: relayUrl,
})
}
return createSubscription(poolWithSub, [relayUrl], filters)
},
5000 // 5 second timeout per relay
)
sub = result
} catch (rotationError) {
// Fallback to primary relay if rotation fails
usedRelayUrl = getPrimaryRelaySync()
sub = createSubscription(pool, [usedRelayUrl], filters)
}
if (!sub) {
throw new Error('Failed to create subscription')
}
const events: Event[] = [] const events: Event[] = []
@ -376,14 +547,19 @@ async function fetchAndCacheReviewTips(
} }
sub.on('event', (event: Event): void => { sub.on('event', (event: Event): void => {
console.log('[Sync] Received review tip event:', event.id)
events.push(event) events.push(event)
}) })
sub.on('eose', (): void => { sub.on('eose', (): void => {
console.log(`[Sync] EOSE for review tips, received ${events.length} events`)
void done() void done()
}) })
setTimeout((): void => { setTimeout((): void => {
if (!finished) {
console.log(`[Sync] Timeout for review tips, received ${events.length} events`)
}
void done() void done()
}, 10000).unref?.() }, 10000).unref?.()
}) })
@ -393,11 +569,135 @@ export interface SyncProgress {
currentStep: number currentStep: number
totalSteps: number totalSteps: number
completed: boolean completed: boolean
currentRelay?: string // URL of the relay currently being used
}
/**
* Fetch all payment notes (kind 1 with type='payment') by a user and cache them
*/
async function fetchAndCachePaymentNotes(
pool: SimplePoolWithSub,
userPubkey: string
): Promise<void> {
// Payment notes are kind 1 with type='payment'
// They can be: as payer (authors) or as recipient (#recipient tag)
const filters = [
{
kinds: [1],
authors: [userPubkey],
'#payment': [''],
'#service': [PLATFORM_SERVICE],
since: MIN_EVENT_DATE,
limit: 1000,
},
{
kinds: [1],
'#recipient': [userPubkey],
'#payment': [''],
'#service': [PLATFORM_SERVICE],
since: MIN_EVENT_DATE,
limit: 1000,
},
]
// Try relays with rotation (no retry on failure, just move to next)
const { createSubscription } = require('@/types/nostr-tools-extended')
let subscriptions: Array<ReturnType<typeof createSubscription>> = []
let usedRelayUrl = ''
try {
const result = await tryWithRelayRotation(
pool as unknown as import('nostr-tools').SimplePool,
async (relayUrl, poolWithSub) => {
usedRelayUrl = relayUrl
// Notify progress manager that we're starting with a new relay (reset step counter)
const { syncProgressManager } = await import('./syncProgressManager')
const currentProgress = syncProgressManager.getProgress()
if (currentProgress) {
syncProgressManager.setProgress({
...currentProgress,
currentStep: 0,
currentRelay: relayUrl,
})
}
// Create subscriptions for both filters (payer and recipient)
return filters.map((filter) => createSubscription(poolWithSub, [relayUrl], [filter]))
},
5000 // 5 second timeout per relay
)
subscriptions = result.flat()
} catch (rotationError) {
// Fallback to primary relay if rotation fails
usedRelayUrl = getPrimaryRelaySync()
subscriptions = filters.map((filter) => createSubscription(pool, [usedRelayUrl], [filter]))
}
if (subscriptions.length === 0) {
throw new Error('Failed to create subscriptions')
}
const events: Event[] = []
return new Promise<void>((resolve) => {
let finished = false
let eoseCount = 0
const done = async (): Promise<void> => {
if (finished) {
return
}
finished = true
subscriptions.forEach((sub) => sub.unsub())
for (const event of events) {
const tags = extractTagsFromEvent(event)
if (tags.type === 'payment' && tags.payment) {
// Cache the payment note event
// Use event.id as hash since payment notes don't have a separate hash system
await objectCache.set('payment_note', event.id, event, {
id: event.id,
type: 'payment_note',
eventId: event.id,
}, 0, false, 0)
}
}
resolve()
}
subscriptions.forEach((sub) => {
sub.on('event', (event: Event): void => {
const tags = extractTagsFromEvent(event)
if (tags.type === 'payment' && tags.payment) {
console.log('[Sync] Received payment note event:', event.id)
// Deduplicate events (same event might match both filters)
if (!events.some((e) => e.id === event.id)) {
events.push(event)
}
}
})
sub.on('eose', (): void => {
eoseCount++
if (eoseCount >= subscriptions.length) {
console.log(`[Sync] EOSE for payment notes, received ${events.length} events`)
void done()
}
})
})
setTimeout((): void => {
if (!finished) {
console.log(`[Sync] Timeout for payment notes, received ${events.length} events`)
}
void done()
}, 10000).unref?.()
})
} }
/** /**
* Synchronize all user content to IndexedDB cache * Synchronize all user content to IndexedDB cache
* Fetches profile, series, publications, purchases, sponsoring, and review tips and caches them * Fetches profile, series, publications, purchases, sponsoring, review tips, and payment notes and caches them
* @param userPubkey - The user's public key * @param userPubkey - The user's public key
* @param onProgress - Optional callback to report progress (currentStep, totalSteps, completed) * @param onProgress - Optional callback to report progress (currentStep, totalSteps, completed)
*/ */
@ -419,68 +719,79 @@ export async function syncUserContentToCache(
const { setLastSyncDate, getCurrentTimestamp } = await import('./syncStorage') const { setLastSyncDate, getCurrentTimestamp } = await import('./syncStorage')
const currentTimestamp = getCurrentTimestamp() const currentTimestamp = getCurrentTimestamp()
const TOTAL_STEPS = 6 const TOTAL_STEPS = 7
// Report initial progress // Report initial progress
const { relaySessionManager } = await import('./relaySessionManager')
const { syncProgressManager } = await import('./syncProgressManager')
const activeRelays = await relaySessionManager.getActiveRelays()
const initialRelay = activeRelays[0] ?? 'Connecting...'
if (onProgress) { if (onProgress) {
onProgress({ currentStep: 0, totalSteps: TOTAL_STEPS, completed: false }) onProgress({ currentStep: 0, totalSteps: TOTAL_STEPS, completed: false, currentRelay: initialRelay })
} }
syncProgressManager.setProgress({ currentStep: 0, totalSteps: TOTAL_STEPS, completed: false, currentRelay: initialRelay })
let currentStep = 0 let currentStep = 0
// Fetch and cache author profile (already caches itself) // Helper function to update progress with current relay
console.log('[Sync] Step 1/6: Fetching author profile...') const updateProgress = (step: number, completed: boolean = false): void => {
await fetchAuthorPresentationFromPool(poolWithSub, userPubkey) const currentRelay = syncProgressManager.getProgress()?.currentRelay ?? initialRelay
currentStep++ const progressUpdate = { currentStep: step, totalSteps: TOTAL_STEPS, completed, currentRelay }
if (onProgress) { if (onProgress) {
onProgress({ currentStep, totalSteps: TOTAL_STEPS, completed: false }) onProgress(progressUpdate)
}
syncProgressManager.setProgress(progressUpdate)
} }
// Fetch and cache author profile (already caches itself)
console.log('[Sync] Step 1/7: Fetching author profile...')
await fetchAuthorPresentationFromPool(poolWithSub, userPubkey)
console.log('[Sync] Step 1/7: Author profile fetch completed')
currentStep++
updateProgress(currentStep)
// Fetch and cache all series // Fetch and cache all series
console.log('[Sync] Step 2/6: Fetching series...') console.log('[Sync] Step 2/7: Fetching series...')
await fetchAndCacheSeries(poolWithSub, userPubkey) await fetchAndCacheSeries(poolWithSub, userPubkey)
currentStep++ currentStep++
if (onProgress) { updateProgress(currentStep)
onProgress({ currentStep, totalSteps: TOTAL_STEPS, completed: false })
}
// Fetch and cache all publications // Fetch and cache all publications
console.log('[Sync] Step 3/6: Fetching publications...') console.log('[Sync] Step 3/7: Fetching publications...')
await fetchAndCachePublications(poolWithSub, userPubkey) await fetchAndCachePublications(poolWithSub, userPubkey)
currentStep++ currentStep++
if (onProgress) { updateProgress(currentStep)
onProgress({ currentStep, totalSteps: TOTAL_STEPS, completed: false })
}
// Fetch and cache all purchases (as payer) // Fetch and cache all purchases (as payer)
console.log('[Sync] Step 4/6: Fetching purchases...') console.log('[Sync] Step 4/7: Fetching purchases...')
await fetchAndCachePurchases(poolWithSub, userPubkey) await fetchAndCachePurchases(poolWithSub, userPubkey)
currentStep++ currentStep++
if (onProgress) { updateProgress(currentStep)
onProgress({ currentStep, totalSteps: TOTAL_STEPS, completed: false })
}
// Fetch and cache all sponsoring (as author) // Fetch and cache all sponsoring (as author)
console.log('[Sync] Step 5/6: Fetching sponsoring...') console.log('[Sync] Step 5/7: Fetching sponsoring...')
await fetchAndCacheSponsoring(poolWithSub, userPubkey) await fetchAndCacheSponsoring(poolWithSub, userPubkey)
currentStep++ currentStep++
if (onProgress) { updateProgress(currentStep)
onProgress({ currentStep, totalSteps: TOTAL_STEPS, completed: false })
}
// Fetch and cache all review tips (as author) // Fetch and cache all review tips (as author)
console.log('[Sync] Step 6/6: Fetching review tips...') console.log('[Sync] Step 6/7: Fetching review tips...')
await fetchAndCacheReviewTips(poolWithSub, userPubkey) await fetchAndCacheReviewTips(poolWithSub, userPubkey)
currentStep++ currentStep++
if (onProgress) { updateProgress(currentStep)
onProgress({ currentStep, totalSteps: TOTAL_STEPS, completed: true })
} // Fetch and cache all payment notes (kind 1 with type='payment')
console.log('[Sync] Step 7/7: Fetching payment notes...')
await fetchAndCachePaymentNotes(poolWithSub, userPubkey)
currentStep++
updateProgress(currentStep, true)
// Store the current timestamp as last sync date // Store the current timestamp as last sync date
await setLastSyncDate(currentTimestamp) await setLastSyncDate(currentTimestamp)
console.log('[Sync] Synchronization completed successfully') console.log('[Sync] Synchronization completed successfully')
} catch (error) { } catch (syncError) {
console.error('Error syncing user content to cache:', error) console.error('Error syncing user content to cache:', syncError)
throw error // Re-throw to allow UI to handle it throw syncError // Re-throw to allow UI to handle it
} }
} }

View File

@ -6,6 +6,9 @@ import { platformSyncService } from '@/lib/platformSync'
import { nostrAuthService } from '@/lib/nostrAuth' import { nostrAuthService } from '@/lib/nostrAuth'
import { syncUserContentToCache } from '@/lib/userContentSync' import { syncUserContentToCache } from '@/lib/userContentSync'
import { getLastSyncDate, getCurrentTimestamp } from '@/lib/syncStorage' import { getLastSyncDate, getCurrentTimestamp } from '@/lib/syncStorage'
import { syncProgressManager } from '@/lib/syncProgressManager'
import { GlobalSyncProgressBar } from '@/components/GlobalSyncProgressBar'
import { relaySessionManager } from '@/lib/relaySessionManager'
function I18nProvider({ children }: { children: React.ReactNode }): React.ReactElement { function I18nProvider({ children }: { children: React.ReactNode }): React.ReactElement {
// Get saved locale from localStorage or default to French // Get saved locale from localStorage or default to French
@ -45,6 +48,11 @@ function I18nProvider({ children }: { children: React.ReactNode }): React.ReactE
} }
export default function App({ Component, pageProps }: AppProps): React.ReactElement { export default function App({ Component, pageProps }: AppProps): React.ReactElement {
// Initialize relay session manager on app mount (reset all relays to active)
React.useEffect(() => {
void relaySessionManager.initialize()
}, [])
// Start platform sync on app mount and resume on each page navigation // Start platform sync on app mount and resume on each page navigation
React.useEffect(() => { React.useEffect(() => {
// Start continuous sync (runs periodically in background) // Start continuous sync (runs periodically in background)
@ -95,10 +103,17 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
console.log('[App] Starting user content sync...') console.log('[App] Starting user content sync...')
try { try {
await syncUserContentToCache(state.pubkey) await syncUserContentToCache(state.pubkey, (progress) => {
syncProgressManager.setProgress(progress)
if (progress.completed) {
syncProgressManager.setProgress(null)
}
})
console.log('[App] User content sync completed') console.log('[App] User content sync completed')
syncProgressManager.setProgress(null)
} catch (error) { } catch (error) {
console.error('[App] Error during user content sync:', error) console.error('[App] Error during user content sync:', error)
syncProgressManager.setProgress(null)
} finally { } finally {
syncInProgress = false syncInProgress = false
} }
@ -121,6 +136,7 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
return ( return (
<I18nProvider> <I18nProvider>
<GlobalSyncProgressBar />
<Component {...pageProps} /> <Component {...pageProps} />
</I18nProvider> </I18nProvider>
) )

View File

@ -64,7 +64,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
let response: { statusCode: number; statusMessage: string; body: string } let response: { statusCode: number; statusMessage: string; body: string }
try { try {
response = await new Promise<{ statusCode: number; statusMessage: string; body: string }>((resolve, reject) => { response = await new Promise<{ statusCode: number; statusMessage: string; body: string }>((resolve, reject) => {
function makeRequest(url: URL, redirectCount: number, fileField: FormidableFile, authToken?: string): void { function makeRequest(url: URL, redirectCount: number, file: FormidableFile, token?: string): void {
if (redirectCount > MAX_REDIRECTS) { if (redirectCount > MAX_REDIRECTS) {
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`)) reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`))
return return
@ -72,14 +72,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Recreate FormData for each request (needed for redirects) // Recreate FormData for each request (needed for redirects)
const requestFormData = new FormData() const requestFormData = new FormData()
const fileStream = fs.createReadStream(fileField.filepath) const fileStream = fs.createReadStream(file.filepath)
// Use 'file' as field name (standard for NIP-95, but some endpoints may use different names) // Use 'file' as field name (standard for NIP-95, but some endpoints may use different names)
// Note: nostrimg.com might expect a different field name - if issues persist, try 'image' or 'upload' // Note: nostrimg.com might expect a different field name - if issues persist, try 'image' or 'upload'
const fieldName = 'file' const fieldName = 'file'
requestFormData.append(fieldName, fileStream, { requestFormData.append(fieldName, fileStream, {
filename: fileField.originalFilename || fileField.newFilename || 'upload', filename: file.originalFilename ?? file.newFilename ?? 'upload',
contentType: fileField.mimetype || 'application/octet-stream', contentType: file.mimetype ?? 'application/octet-stream',
}) })
const isHttps = url.protocol === 'https:' const isHttps = url.protocol === 'https:'
@ -91,8 +91,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
headers['User-Agent'] = 'zapwall.fr/1.0' headers['User-Agent'] = 'zapwall.fr/1.0'
// Add NIP-98 Authorization header if token is provided // Add NIP-98 Authorization header if token is provided
if (authToken) { if (token) {
headers['Authorization'] = `Nostr ${authToken}` headers['Authorization'] = `Nostr ${token}`
} }
// Log request details for debugging (only for problematic endpoints) // Log request details for debugging (only for problematic endpoints)
@ -101,14 +101,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
url: url.toString(), url: url.toString(),
method: 'POST', method: 'POST',
fieldName, fieldName,
filename: fileField.originalFilename || fileField.newFilename || 'upload', filename: file.originalFilename ?? file.newFilename ?? 'upload',
contentType: fileField.mimetype || 'application/octet-stream', contentType: file.mimetype ?? 'application/octet-stream',
fileSize: fileField.size, fileSize: file.size,
headers: { headers: {
'Content-Type': headers['content-type'], 'Content-Type': headers['content-type'],
'Accept': headers['Accept'], 'Accept': headers['Accept'],
'User-Agent': headers['User-Agent'], 'User-Agent': headers['User-Agent'],
'Authorization': authToken ? '[present]' : '[absent]', 'Authorization': token ? '[present]' : '[absent]',
}, },
}) })
} }
@ -140,7 +140,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Drain the response before redirecting // Drain the response before redirecting
proxyResponse.resume() proxyResponse.resume()
// Make new request to redirect location (preserve auth token for redirects) // Make new request to redirect location (preserve auth token for redirects)
makeRequest(redirectUrl, redirectCount + 1, fileField, authToken) makeRequest(redirectUrl, redirectCount + 1, file, token)
return return
} catch (urlError) { } catch (urlError) {
console.error('NIP-95 proxy invalid redirect URL:', { console.error('NIP-95 proxy invalid redirect URL:', {
@ -221,7 +221,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
console.error('NIP-95 proxy file stream error:', { console.error('NIP-95 proxy file stream error:', {
targetEndpoint, targetEndpoint,
hostname: url.hostname, hostname: url.hostname,
filepath: fileField.filepath, filepath: file.filepath,
error: error instanceof Error ? error.message : 'Unknown file stream error', error: error instanceof Error ? error.message : 'Unknown file stream error',
}) })
reject(error) reject(error)

View File

@ -10,6 +10,8 @@ import { ArticleCard } from '@/components/ArticleCard'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import Image from 'next/image' import Image from 'next/image'
import { ArticleReviews } from '@/components/ArticleReviews' import { ArticleReviews } from '@/components/ArticleReviews'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import Link from 'next/link'
function SeriesHeader({ series }: { series: Series }): React.ReactElement { function SeriesHeader({ series }: { series: Series }): React.ReactElement {
return ( return (
@ -60,6 +62,7 @@ export default function SeriesPage(): React.ReactElement | null {
{series && ( {series && (
<> <>
<SeriesHeader series={series} /> <SeriesHeader series={series} />
<SeriesActions series={series} />
<SeriesStats <SeriesStats
sponsoring={aggregates?.sponsoring ?? 0} sponsoring={aggregates?.sponsoring ?? 0}
purchases={aggregates?.purchases ?? 0} purchases={aggregates?.purchases ?? 0}
@ -74,6 +77,26 @@ export default function SeriesPage(): React.ReactElement | null {
) )
} }
function SeriesActions({ series }: { series: Series }): React.ReactElement | null {
const { pubkey } = useNostrAuth()
// Only show "Create Publication" button if user is the author
if (!pubkey || pubkey !== series.pubkey) {
return null
}
return (
<div className="mb-4">
<Link
href={`/series/${series.id}/publish`}
className="inline-block px-4 py-2 bg-neon-cyan text-cyber-darker rounded-lg hover:bg-neon-cyan/90 transition-colors font-medium"
>
{t('series.createPublication')}
</Link>
</div>
)
}
function SeriesPublications({ articles }: { articles: Article[] }): React.ReactElement { function SeriesPublications({ articles }: { articles: Article[] }): React.ReactElement {
if (articles.length === 0) { if (articles.length === 0) {
return <p className="text-sm text-gray-600">Aucune publication pour cette série.</p> return <p className="text-sm text-gray-600">Aucune publication pour cette série.</p>

View File

@ -0,0 +1,180 @@
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { ArticleEditor } from '@/components/ArticleEditor'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { getSeriesById } from '@/lib/seriesQueries'
import type { Series } from '@/types/nostr'
import { t } from '@/lib/i18n'
import Image from 'next/image'
function PublishHeader({ series }: { series: Series }): React.ReactElement {
return (
<Head>
<title>{t('series.publish.title', { series: series.title })} - zapwall.fr</title>
<meta name="description" content={t('series.publish.description')} />
</Head>
)
}
function SeriesHeader({ series }: { series: Series }): React.ReactElement {
return (
<div className="space-y-3 mb-6">
{series.coverUrl && (
<div className="relative w-full h-32">
<Image
src={series.coverUrl}
alt={series.title}
fill
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover rounded"
/>
</div>
)}
<h1 className="text-2xl font-bold">{series.title}</h1>
<p className="text-sm text-gray-600">{t('series.publish.subtitle')}</p>
</div>
)
}
export default function SeriesPublishPage(): React.ReactElement | null {
const router = useRouter()
const { id } = router.query
const seriesId = typeof id === 'string' ? id : ''
const { pubkey } = useNostrAuth()
const { series, loading, error, isAuthor } = useSeriesPublishPageData(seriesId, pubkey ?? null)
if (!seriesId) {
return null
}
if (loading) {
return (
<main className="min-h-screen bg-gray-50">
<div className="w-full px-4 py-8">
<p className="text-sm text-gray-600">{t('common.loading')}</p>
</div>
</main>
)
}
if (error || !series) {
return (
<main className="min-h-screen bg-gray-50">
<div className="w-full px-4 py-8">
<p className="text-sm text-red-600">{error ?? 'Série introuvable'}</p>
<button
onClick={() => {
void router.back()
}}
className="mt-4 text-blue-600 hover:text-blue-700 text-sm"
>
{t('common.back')}
</button>
</div>
</main>
)
}
if (!isAuthor) {
return (
<main className="min-h-screen bg-gray-50">
<div className="w-full px-4 py-8">
<p className="text-sm text-red-600">{t('series.publish.error.notAuthor')}</p>
<button
onClick={() => {
void router.push(`/series/${seriesId}`)
}}
className="mt-4 text-blue-600 hover:text-blue-700 text-sm"
>
{t('common.back')}
</button>
</div>
</main>
)
}
const handlePublishSuccess = (): void => {
setTimeout(() => {
void router.push(`/series/${seriesId}`)
}, 2000)
}
return (
<>
<PublishHeader series={series} />
<main className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<div className="w-full px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">zapwall4Science</h1>
</div>
</header>
<div className="w-full px-4 py-8">
<button
onClick={() => {
void router.push(`/series/${seriesId}`)
}}
className="text-blue-600 hover:text-blue-700 text-sm font-medium mb-4"
>
{t('common.back')}
</button>
<SeriesHeader series={series} />
<ArticleEditor
onPublishSuccess={handlePublishSuccess}
onCancel={() => {
void router.push(`/series/${seriesId}`)
}}
seriesOptions={[{ id: series.id, title: series.title }]}
onSelectSeries={() => {
// Series is already selected and cannot be changed
}}
defaultSeriesId={series.id}
/>
</div>
</main>
</>
)
}
function useSeriesPublishPageData(
seriesId: string,
userPubkey: string | null
): {
series: Series | null
loading: boolean
error: string | null
isAuthor: boolean
} {
const [series, setSeries] = useState<Series | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!seriesId) {
return
}
const load = async (): Promise<void> => {
setLoading(true)
setError(null)
try {
const s = await getSeriesById(seriesId)
if (!s) {
setError('Série introuvable')
setLoading(false)
return
}
setSeries(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur lors du chargement de la série')
} finally {
setLoading(false)
}
}
void load()
}, [seriesId])
const isAuthor = series !== null && userPubkey !== null && series.pubkey === userPubkey
return { series, loading, error, isAuthor }
}

View File

@ -5,6 +5,7 @@ import { Nip95ConfigManager } from '@/components/Nip95ConfigManager'
import { KeyManagementManager } from '@/components/KeyManagementManager' import { KeyManagementManager } from '@/components/KeyManagementManager'
import { CacheUpdateManager } from '@/components/CacheUpdateManager' import { CacheUpdateManager } from '@/components/CacheUpdateManager'
import { LanguageSettingsManager } from '@/components/LanguageSettingsManager' import { LanguageSettingsManager } from '@/components/LanguageSettingsManager'
import { RelayManager } from '@/components/RelayManager'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
export default function SettingsPage(): React.ReactElement { export default function SettingsPage(): React.ReactElement {
@ -23,6 +24,7 @@ export default function SettingsPage(): React.ReactElement {
<div className="space-y-8"> <div className="space-y-8">
<LanguageSettingsManager /> <LanguageSettingsManager />
<KeyManagementManager /> <KeyManagementManager />
<RelayManager />
<Nip95ConfigManager /> <Nip95ConfigManager />
<CacheUpdateManager /> <CacheUpdateManager />
</div> </div>

View File

@ -74,6 +74,11 @@ series.create.field.cover.help=Cover image for the series (optional, max 5MB, fo
series.create.error.notAuthor=You must be the author of this page and have unlocked your account to create a series series.create.error.notAuthor=You must be the author of this page and have unlocked your account to create a series
series.create.error.missingFields=Please fill in all required fields series.create.error.missingFields=Please fill in all required fields
series.create.error.publishFailed=Error publishing series series.create.error.publishFailed=Error publishing series
series.createPublication=Create a publication
series.publish.title=Create a publication for {series}
series.publish.subtitle=Add pages to your series
series.publish.error.notAuthor=You are not the author of this series
series.publish.description=Create a new publication for this series
# Author page # Author page
author.title=Author page author.title=Author page
@ -242,6 +247,8 @@ settings.sync.daysRange=From {{startDate}} to {{endDate}} ({{days}} days)
settings.sync.progress=Step {{current}} of {{total}} settings.sync.progress=Step {{current}} of {{total}}
settings.sync.completed=Everything is synchronized settings.sync.completed=Everything is synchronized
settings.sync.ready=Ready to synchronize settings.sync.ready=Ready to synchronize
settings.sync.syncing=Synchronizing
settings.sync.connecting=Connecting...
settings.nip95.title=NIP-95 Upload Endpoints settings.nip95.title=NIP-95 Upload Endpoints
settings.nip95.loading=Loading... settings.nip95.loading=Loading...
settings.nip95.error.loadFailed=Failed to load NIP-95 APIs settings.nip95.error.loadFailed=Failed to load NIP-95 APIs
@ -274,6 +281,31 @@ settings.nip95.list.editUrl=Click to edit URL
settings.nip95.note.title=Note: settings.nip95.note.title=Note:
settings.nip95.note.priority=Endpoints are tried in priority order (lower number = higher priority). Only enabled endpoints will be used for uploads. settings.nip95.note.priority=Endpoints are tried in priority order (lower number = higher priority). Only enabled endpoints will be used for uploads.
settings.nip95.note.fallback=If an endpoint fails, the next enabled endpoint will be tried automatically. settings.nip95.note.fallback=If an endpoint fails, the next enabled endpoint will be tried automatically.
settings.relay.title=Nostr Relays
settings.relay.loading=Loading...
settings.relay.error.loadFailed=Failed to load relays
settings.relay.error.updateFailed=Failed to update relay
settings.relay.error.priorityFailed=Failed to update priority
settings.relay.error.urlFailed=Failed to update URL
settings.relay.error.addFailed=Failed to add relay
settings.relay.error.removeFailed=Failed to remove relay
settings.relay.error.invalidUrl=Invalid URL format
settings.relay.error.urlRequired=URL is required
settings.relay.addButton=Add relay
settings.relay.add.url=Relay URL
settings.relay.add.placeholder=wss://relay.example.com
settings.relay.add.add=Add
settings.relay.add.cancel=Cancel
settings.relay.list.enabled=Enabled
settings.relay.list.disabled=Disabled
settings.relay.list.priorityLabel=Priority {{priority}} (ID: {{id}})
settings.relay.list.editUrl=Click to edit URL
settings.relay.list.remove=Remove
settings.relay.remove.confirm=Are you sure you want to remove this relay?
settings.relay.empty=No relays configured
settings.relay.note.title=Note:
settings.relay.note.priority=Relays are tried in priority order (lower number = higher priority). Only enabled relays will be used to fetch notes.
settings.relay.note.rotation=If a relay fails, the next enabled relay will be tried automatically. Once all relays have been tried, the system loops back to the first one.
settings.language.title=Preferred Language settings.language.title=Preferred Language
settings.language.description=Choose your preferred language for the interface settings.language.description=Choose your preferred language for the interface
settings.language.loading=Loading... settings.language.loading=Loading...
@ -368,6 +400,8 @@ page.image.remove=Remove image
page.image.alt=Page {{number}} image page.image.alt=Page {{number}} image
page.image.empty=No image page.image.empty=No image
article.pages.title=A5 Pages article.pages.title=A5 Pages
article.pages.locked.title=Locked pages
article.pages.locked.message=This publication contains {count} page(s). Purchase it to unlock all pages.
# Notification # Notification
notification.empty=No notifications yet notification.empty=No notifications yet

View File

@ -59,7 +59,12 @@ publication.price={{amount}} sats
series.title=Séries series.title=Séries
series.empty=Aucune série publiée pour le moment. series.empty=Aucune série publiée pour le moment.
series.view=Voir la série series.view=Voir la série
series.publications=Publications de la série series.publications=Publications
series.createPublication=Créer une publication
series.publish.title=Créer une publication pour {series}
series.publish.subtitle=Ajoutez des pages à votre série
series.publish.error.notAuthor=Vous n'êtes pas l'auteur de cette série
series.publish.description=Créer une nouvelle publication pour cette série
series.publications.empty=Aucune publication pour cette série. series.publications.empty=Aucune publication pour cette série.
series.create.button=Créer une série series.create.button=Créer une série
series.create.title=Créer une nouvelle série series.create.title=Créer une nouvelle série
@ -242,6 +247,8 @@ settings.sync.daysRange=Du {{startDate}} au {{endDate}} ({{days}} jours)
settings.sync.progress=Étape {{current}} sur {{total}} settings.sync.progress=Étape {{current}} sur {{total}}
settings.sync.completed=Tout est synchronisé settings.sync.completed=Tout est synchronisé
settings.sync.ready=Prêt à synchroniser settings.sync.ready=Prêt à synchroniser
settings.sync.syncing=Synchronisation en cours
settings.sync.connecting=Connexion...
settings.language.title=Langue de préférence settings.language.title=Langue de préférence
settings.language.description=Choisissez votre langue préférée pour l'interface settings.language.description=Choisissez votre langue préférée pour l'interface
settings.language.loading=Chargement... settings.language.loading=Chargement...
@ -279,6 +286,31 @@ settings.nip95.list.editUrl=Cliquer pour modifier l'URL
settings.nip95.note.title=Note : settings.nip95.note.title=Note :
settings.nip95.note.priority=Les endpoints sont essayés dans l'ordre de priorité (nombre plus bas = priorité plus haute). Seuls les endpoints activés seront utilisés pour les uploads. settings.nip95.note.priority=Les endpoints sont essayés dans l'ordre de priorité (nombre plus bas = priorité plus haute). Seuls les endpoints activés seront utilisés pour les uploads.
settings.nip95.note.fallback=Si un endpoint échoue, le prochain endpoint activé sera essayé automatiquement. settings.nip95.note.fallback=Si un endpoint échoue, le prochain endpoint activé sera essayé automatiquement.
settings.relay.title=Relais Nostr
settings.relay.loading=Chargement...
settings.relay.error.loadFailed=Échec du chargement des relais
settings.relay.error.updateFailed=Échec de la mise à jour du relais
settings.relay.error.priorityFailed=Échec de la mise à jour de la priorité
settings.relay.error.urlFailed=Échec de la mise à jour de l'URL
settings.relay.error.addFailed=Échec de l'ajout du relais
settings.relay.error.removeFailed=Échec de la suppression du relais
settings.relay.error.invalidUrl=Format d'URL invalide
settings.relay.error.urlRequired=L'URL est requise
settings.relay.addButton=Ajouter un relais
settings.relay.add.url=URL du relais
settings.relay.add.placeholder=wss://relay.example.com
settings.relay.add.add=Ajouter
settings.relay.add.cancel=Annuler
settings.relay.list.enabled=Activé
settings.relay.list.disabled=Désactivé
settings.relay.list.priorityLabel=Priorité {{priority}} (ID: {{id}})
settings.relay.list.editUrl=Cliquer pour modifier l'URL
settings.relay.list.remove=Supprimer
settings.relay.remove.confirm=Êtes-vous sûr de vouloir supprimer ce relais ?
settings.relay.empty=Aucun relais configuré
settings.relay.note.title=Note :
settings.relay.note.priority=Les relais sont essayés dans l'ordre de priorité (nombre plus bas = priorité plus haute). Seuls les relais activés seront utilisés pour récupérer les notes.
settings.relay.note.rotation=Si un relais échoue, le prochain relais activé sera essayé automatiquement. Une fois tous les relais essayés, le système repart du premier et boucle.
# Common UI # Common UI
common.repositoryGit=Repository Git common.repositoryGit=Repository Git
@ -367,7 +399,9 @@ page.image.upload=Uploader une image
page.image.remove=Supprimer l'image page.image.remove=Supprimer l'image
page.image.alt=Image page {{number}} page.image.alt=Image page {{number}}
page.image.empty=Aucune image page.image.empty=Aucune image
article.pages.title=Pages A5 article.pages.title=Pages
article.pages.locked.title=Pages verrouillées
article.pages.locked.message=Cette publication contient {count} page(s). Achetez-la pour débloquer toutes les pages. A5
# Notification # Notification
notification.empty=Aucune notification pour le moment notification.empty=Aucune notification pour le moment