lint fix wip
This commit is contained in:
parent
9e364d0313
commit
cdd923e981
@ -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 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
|
||||
* **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
|
||||
|
||||
@ -9,6 +9,7 @@ interface ArticleEditorProps {
|
||||
onCancel?: () => void
|
||||
seriesOptions?: { id: string; title: string }[]
|
||||
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 { loading, error, success, publishArticle } = useArticlePublishing(pubkey ?? null)
|
||||
const [draft, setDraft] = useState<ArticleDraft>({
|
||||
@ -30,6 +31,7 @@ export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSel
|
||||
content: '',
|
||||
zapAmount: 800,
|
||||
media: [],
|
||||
...(defaultSeriesId ? { seriesId: defaultSeriesId } : {}),
|
||||
})
|
||||
|
||||
const submit = buildSubmitHandler(publishArticle, draft, onPublishSuccess, connect, connected)
|
||||
|
||||
@ -1,15 +1,64 @@
|
||||
import type { Page } from '@/types/nostr'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { objectCache } from '@/lib/objectCache'
|
||||
|
||||
interface ArticlePagesProps {
|
||||
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) {
|
||||
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 (
|
||||
<div className="space-y-6 mt-6">
|
||||
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
|
||||
|
||||
@ -13,7 +13,7 @@ export function ArticlePreview({ article, loading, onUnlock }: ArticlePreviewPro
|
||||
<div>
|
||||
<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>
|
||||
{article.pages && article.pages.length > 0 && <ArticlePages pages={article.pages} />}
|
||||
{article.pages && article.pages.length > 0 && <ArticlePages pages={article.pages} articleId={article.id} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { Review, Article } from '@/types/nostr'
|
||||
import { getReviewsForArticle } from '@/lib/reviews'
|
||||
import { getReviewTipsForArticle } from '@/lib/reviewAggregation'
|
||||
@ -19,7 +19,7 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
|
||||
const [showReviewForm, setShowReviewForm] = useState(false)
|
||||
const [selectedReviewForTip, setSelectedReviewForTip] = useState<string | null>(null)
|
||||
|
||||
const loadReviews = async (): Promise<void> => {
|
||||
const loadReviews = useCallback(async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@ -29,16 +29,16 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
|
||||
])
|
||||
setReviews(list)
|
||||
setTips(tipsTotal)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur lors du chargement des critiques')
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : 'Erreur lors du chargement des critiques')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [article.id, authorPubkey])
|
||||
|
||||
useEffect(() => {
|
||||
void loadReviews()
|
||||
}, [article.id, authorPubkey])
|
||||
}, [loadReviews])
|
||||
|
||||
return (
|
||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
|
||||
@ -65,9 +65,14 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
|
||||
{!loading && !error && <ArticleReviewsList reviews={reviews} onTipReview={(reviewId) => {
|
||||
setSelectedReviewForTip(reviewId)
|
||||
}} />}
|
||||
{selectedReviewForTip && (
|
||||
{selectedReviewForTip && (() => {
|
||||
const review = reviews.find((r) => r.id === selectedReviewForTip)
|
||||
if (!review) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ReviewTipForm
|
||||
review={reviews.find((r) => r.id === selectedReviewForTip)!}
|
||||
review={review}
|
||||
article={article}
|
||||
onSuccess={() => {
|
||||
setSelectedReviewForTip(null)
|
||||
@ -77,7 +82,8 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
|
||||
setSelectedReviewForTip(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -79,8 +79,8 @@ export function CreateSeriesModal({ isOpen, onClose, onSuccess, authorPubkey }:
|
||||
})
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('series.create.error.publishFailed'))
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : t('series.create.error.publishFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -121,7 +121,9 @@ export function CreateSeriesModal({ isOpen, onClose, onSuccess, authorPubkey }:
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={(e) => {
|
||||
void handleSubmit(e)
|
||||
}} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="series-title" className="block text-sm font-medium text-neon-cyan mb-2">
|
||||
{t('series.create.field.title')}
|
||||
|
||||
78
components/GlobalSyncProgressBar.tsx
Normal file
78
components/GlobalSyncProgressBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -105,8 +105,8 @@ function useImageUpload(onChange: (url: string) => void): {
|
||||
const [showUnlockModal, setShowUnlockModal] = useState(false)
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null)
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = e.target.files?.[0]
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
@ -116,15 +116,15 @@ function useImageUpload(onChange: (url: string) => void): {
|
||||
|
||||
try {
|
||||
await processFileUpload(file, onChange, setError)
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
} catch (uploadError) {
|
||||
const uploadErr = uploadError instanceof Error ? uploadError : new Error(String(uploadError))
|
||||
// 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)
|
||||
setShowUnlockModal(true)
|
||||
setError(null) // Don't show error, show unlock modal instead
|
||||
} else {
|
||||
setError(error.message || t('presentation.field.picture.error.uploadFailed'))
|
||||
setError(uploadErr.message ?? t('presentation.field.picture.error.uploadFailed'))
|
||||
}
|
||||
} finally {
|
||||
setUploading(false)
|
||||
@ -140,8 +140,8 @@ function useImageUpload(onChange: (url: string) => void): {
|
||||
try {
|
||||
await processFileUpload(pendingFile, onChange, setError)
|
||||
setPendingFile(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('presentation.field.picture.error.uploadFailed'))
|
||||
} catch (retryError) {
|
||||
setError(retryError instanceof Error ? retryError.message : t('presentation.field.picture.error.uploadFailed'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
@ -175,7 +175,9 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image
|
||||
</div>
|
||||
{showUnlockModal && (
|
||||
<UnlockAccountModal
|
||||
onSuccess={handleUnlockSuccess}
|
||||
onSuccess={() => {
|
||||
void handleUnlockSuccess()
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowUnlockModal(false)
|
||||
}}
|
||||
|
||||
398
components/RelayManager.tsx
Normal file
398
components/RelayManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -60,8 +60,8 @@ export function ReviewForm({ article, onSuccess, onCancel }: ReviewFormProps): R
|
||||
setTitle('')
|
||||
setText('')
|
||||
onSuccess?.()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('review.form.error.publishFailed'))
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : t('review.form.error.publishFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -84,7 +84,9 @@ export function ReviewForm({ article, onSuccess, onCancel }: ReviewFormProps): R
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div>
|
||||
|
||||
@ -62,8 +62,8 @@ export function ReviewTipForm({ review, article, onSuccess, onCancel }: ReviewTi
|
||||
|
||||
setText('')
|
||||
onSuccess?.()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('reviewTip.form.error.paymentFailed'))
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : t('reviewTip.form.error.paymentFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -88,7 +88,9 @@ export function ReviewTipForm({ review, article, onSuccess, onCancel }: ReviewTi
|
||||
const split = calculateReviewSplit()
|
||||
|
||||
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>
|
||||
<p className="text-sm text-cyber-accent/70">
|
||||
{t('reviewTip.form.description', { amount: split.total, reviewer: split.reviewer, platform: split.platform })}
|
||||
|
||||
@ -77,8 +77,8 @@ export function SponsoringForm({ author, onSuccess, onCancel }: SponsoringFormPr
|
||||
|
||||
setText('')
|
||||
onSuccess?.()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('sponsoring.form.error.paymentFailed'))
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : t('sponsoring.form.error.paymentFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -109,7 +109,9 @@ export function SponsoringForm({ author, onSuccess, onCancel }: SponsoringFormPr
|
||||
}
|
||||
|
||||
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>
|
||||
<p className="text-sm text-cyber-accent/70">
|
||||
{t('sponsoring.form.description', { amount: '0.046' })}
|
||||
|
||||
@ -30,8 +30,8 @@ export function SyncProgressBar(): React.ReactElement | null {
|
||||
|
||||
setLastSyncDate(storedLastSyncDate)
|
||||
setTotalDays(days)
|
||||
} catch (error) {
|
||||
console.error('Error loading sync status:', error)
|
||||
} catch (loadError) {
|
||||
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)
|
||||
setIsSyncing(false)
|
||||
} catch (error) {
|
||||
console.error('[SyncProgressBar] Error during auto-sync:', error)
|
||||
} catch (autoSyncError) {
|
||||
console.error('[SyncProgressBar] Error during auto-sync:', autoSyncError)
|
||||
setIsSyncing(false)
|
||||
setError(error instanceof Error ? error.message : 'Erreur de synchronisation')
|
||||
setError(autoSyncError instanceof Error ? autoSyncError.message : 'Erreur de synchronisation')
|
||||
}
|
||||
} else {
|
||||
console.log('[SyncProgressBar] Skipping auto-sync:', { isRecentlySynced, isSyncing, hasPubkey: Boolean(connectionState.pubkey) })
|
||||
@ -151,8 +151,8 @@ export function SyncProgressBar(): React.ReactElement | null {
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error resynchronizing:', error)
|
||||
} catch (resyncError) {
|
||||
console.error('Error resynchronizing:', resyncError)
|
||||
setIsSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
49
docs/author-funds-specification.md
Normal file
49
docs/author-funds-specification.md
Normal 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
82
docs/nostr-event-order.md
Normal 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).
|
||||
57
docs/nostr-event-uniqueness.md
Normal file
57
docs/nostr-event-uniqueness.md
Normal 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.
|
||||
106
docs/payment-linking-system.md
Normal file
106
docs/payment-linking-system.md
Normal 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)
|
||||
@ -186,7 +186,7 @@ export class ArticlePublisher {
|
||||
const { parsePresentationEvent } = await import('./articlePublisherHelpers')
|
||||
const { extractTagsFromEvent } = await import('./nostrTagSystem')
|
||||
const { objectCache } = await import('./objectCache')
|
||||
const parsed = parsePresentationEvent(publishedEvent)
|
||||
const parsed = await parsePresentationEvent(publishedEvent)
|
||||
if (parsed) {
|
||||
const tags = extractTagsFromEvent(publishedEvent)
|
||||
const { id: tagId, version: tagVersion, hidden: tagHidden } = tags
|
||||
|
||||
@ -103,11 +103,11 @@ export async function fetchAuthorByHashId(
|
||||
})
|
||||
setTimeout(async (): Promise<void> => {
|
||||
// Get the latest version from all collected events
|
||||
const latestEvent = getLatestVersion(events)
|
||||
if (latestEvent) {
|
||||
const parsed = await parsePresentationEvent(latestEvent)
|
||||
if (parsed) {
|
||||
await finalize(parsed)
|
||||
const timeoutLatestEvent = getLatestVersion(events)
|
||||
if (timeoutLatestEvent) {
|
||||
const timeoutParsed = await parsePresentationEvent(timeoutLatestEvent)
|
||||
if (timeoutParsed) {
|
||||
await finalize(timeoutParsed)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,15 +98,28 @@ export class ConfigStorage {
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
const request = store.get('config')
|
||||
|
||||
request.onsuccess = () => {
|
||||
request.onsuccess = async () => {
|
||||
const result = request.result as { key: string; value: ConfigData } | undefined
|
||||
|
||||
if (!result?.value) {
|
||||
resolve(this.getDefaultConfig())
|
||||
// First time: initialize with defaults
|
||||
const defaultConfig = this.getDefaultConfig()
|
||||
await this.saveConfig(defaultConfig)
|
||||
resolve(defaultConfig)
|
||||
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 = () => {
|
||||
|
||||
@ -27,15 +27,31 @@ export interface ConfigData {
|
||||
|
||||
/**
|
||||
* 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[] = [
|
||||
{
|
||||
id: 'default',
|
||||
url: 'wss://relay.damus.io',
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{ id: 'default_1', url: 'wss://relay.damus.io', enabled: true, priority: 1, createdAt: now },
|
||||
{ id: 'default_2', url: 'wss://relay.nostr.band', enabled: true, priority: 2, createdAt: now },
|
||||
{ id: 'default_3', url: 'wss://relay.nostr.wine', enabled: true, priority: 3, createdAt: now },
|
||||
{ id: 'default_4', url: 'wss://cache1.primal.net', enabled: true, priority: 4, createdAt: now },
|
||||
{ id: 'default_5', url: 'wss://relay.bitcoiner.social', enabled: true, priority: 5, createdAt: 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[] = [
|
||||
|
||||
48
lib/nostr.ts
48
lib/nostr.ts
@ -76,12 +76,40 @@ class NostrService {
|
||||
const event = finalizeEvent(unsignedEvent, secretKey)
|
||||
|
||||
try {
|
||||
// Publish to all active relays (enabled and not marked inactive for this session)
|
||||
// Each event has a unique ID based on content, so publishing to multiple relays
|
||||
// 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
|
||||
} catch (e) {
|
||||
throw new Error(`Publish failed: ${e}`)
|
||||
} catch (publishError) {
|
||||
throw new Error(`Publish failed: ${publishError}`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,7 +155,8 @@ class NostrService {
|
||||
|
||||
const sub = this.createArticleSubscription(this.pool, limit)
|
||||
|
||||
sub.on('event', async (event: Event): Promise<void> => {
|
||||
sub.on('event', (event: Event): void => {
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
// Try to parse as regular article first
|
||||
let article = await parseArticleFromEvent(event)
|
||||
@ -141,9 +170,10 @@ class NostrService {
|
||||
if (article) {
|
||||
callback(article)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing article:', e)
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing article:', parseError)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
return (): void => {
|
||||
@ -200,11 +230,11 @@ class NostrService {
|
||||
}
|
||||
|
||||
return decryptArticleContentWithKey(event.content, decryptionKey)
|
||||
} catch (error) {
|
||||
} catch (decryptError) {
|
||||
console.error('Error decrypting article content', {
|
||||
eventId,
|
||||
authorPubkey,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
error: decryptError instanceof Error ? decryptError.message : 'Unknown error',
|
||||
})
|
||||
return null
|
||||
}
|
||||
@ -239,8 +269,8 @@ class NostrService {
|
||||
try {
|
||||
const profile = JSON.parse(event.content) as NostrProfile
|
||||
return { ...profile, pubkey }
|
||||
} catch (error) {
|
||||
console.error('Error parsing profile:', error)
|
||||
} catch (parseProfileError) {
|
||||
console.error('Error parsing profile:', parseProfileError)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import type { Event as NostrEvent } from 'nostr-tools'
|
||||
import type { AuthorPresentationArticle } from '@/types/nostr'
|
||||
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 {
|
||||
id: string // Format: <hash>_<index>_<version>
|
||||
@ -99,8 +99,8 @@ class ObjectCacheService {
|
||||
reject(request.error)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Error counting objects with hash ${hash}:`, error)
|
||||
} catch (countError) {
|
||||
console.error(`Error counting objects with hash ${hash}:`, countError)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@ -155,8 +155,8 @@ class ObjectCacheService {
|
||||
reject(request.error)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Error caching ${objectType} object:`, error)
|
||||
} catch (cacheError) {
|
||||
console.error(`Error caching ${objectType} object:`, cacheError)
|
||||
}
|
||||
}
|
||||
|
||||
@ -198,8 +198,8 @@ class ObjectCacheService {
|
||||
reject(request.error)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Error retrieving ${objectType} object from cache:`, error)
|
||||
} catch (retrieveError) {
|
||||
console.error(`Error retrieving ${objectType} object from cache:`, retrieveError)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@ -227,8 +227,8 @@ class ObjectCacheService {
|
||||
reject(request.error)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Error retrieving ${objectType} object by ID from cache:`, error)
|
||||
} catch (retrieveByIdError) {
|
||||
console.error(`Error retrieving ${objectType} object by ID from cache:`, retrieveByIdError)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@ -269,8 +269,8 @@ class ObjectCacheService {
|
||||
reject(request.error)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error retrieving author from cache by pubkey:', error)
|
||||
} catch (authorRetrieveError) {
|
||||
console.error('Error retrieving author from cache by pubkey:', authorRetrieveError)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@ -305,8 +305,8 @@ class ObjectCacheService {
|
||||
reject(request.error)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Error retrieving all ${objectType} objects from cache:`, error)
|
||||
} catch (getAllError) {
|
||||
console.error(`Error retrieving all ${objectType} objects from cache:`, getAllError)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@ -328,8 +328,8 @@ class ObjectCacheService {
|
||||
reject(request.error)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Error clearing ${objectType} cache:`, error)
|
||||
} catch (clearError) {
|
||||
console.error(`Error clearing ${objectType} cache:`, clearError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,10 +21,36 @@ export class PlatformTrackingService {
|
||||
if (!pool) {
|
||||
throw new Error('Pool not initialized')
|
||||
}
|
||||
|
||||
// Publish to all active relays (enabled and not marked inactive for this session)
|
||||
const { relaySessionManager } = await import('./relaySessionManager')
|
||||
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 {
|
||||
|
||||
@ -85,7 +85,8 @@ export async function getPurchaseById(purchaseId: string, timeoutMs: number = 50
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
sub.on('event', async (event: Event): Promise<void> => {
|
||||
sub.on('event', (event: Event): void => {
|
||||
void (async (): Promise<void> => {
|
||||
const purchaseParsed = await parsePurchaseFromEvent(event)
|
||||
if (purchaseParsed?.id === purchaseId) {
|
||||
// Cache the parsed purchase
|
||||
@ -94,6 +95,7 @@ export async function getPurchaseById(purchaseId: string, timeoutMs: number = 50
|
||||
}
|
||||
done(purchaseParsed)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
sub.on('eose', (): void => {
|
||||
@ -127,7 +129,8 @@ export function getPurchasesForArticle(articleId: string, timeoutMs: number = 50
|
||||
resolve(results)
|
||||
}
|
||||
|
||||
sub.on('event', async (event: Event): Promise<void> => {
|
||||
sub.on('event', (event: Event): void => {
|
||||
void (async (): Promise<void> => {
|
||||
const purchaseParsed = await parsePurchaseFromEvent(event)
|
||||
if (purchaseParsed?.articleId === articleId) {
|
||||
// Cache the parsed purchase
|
||||
@ -136,6 +139,7 @@ export function getPurchasesForArticle(articleId: string, timeoutMs: number = 50
|
||||
}
|
||||
results.push(purchaseParsed)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
sub.on('eose', () => done())
|
||||
@ -167,7 +171,8 @@ export function getPurchasesByPayer(payerPubkey: string, timeoutMs: number = 500
|
||||
resolve(results)
|
||||
}
|
||||
|
||||
sub.on('event', async (event: Event): Promise<void> => {
|
||||
sub.on('event', (event: Event): void => {
|
||||
void (async (): Promise<void> => {
|
||||
const purchaseParsed = await parsePurchaseFromEvent(event)
|
||||
if (purchaseParsed) {
|
||||
// Cache the parsed purchase
|
||||
@ -176,6 +181,7 @@ export function getPurchasesByPayer(payerPubkey: string, timeoutMs: number = 500
|
||||
}
|
||||
results.push(purchaseParsed)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
sub.on('eose', (): void => {
|
||||
|
||||
116
lib/relayRotation.ts
Normal file
116
lib/relayRotation.ts
Normal 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
105
lib/relaySessionManager.ts
Normal 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()
|
||||
}
|
||||
@ -92,7 +92,8 @@ export async function getReviewTipById(reviewTipId: string, timeoutMs: number =
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
sub.on('event', async (event: Event): Promise<void> => {
|
||||
sub.on('event', (event: Event): void => {
|
||||
void (async (): Promise<void> => {
|
||||
const reviewTipParsed = await parseReviewTipFromEvent(event)
|
||||
if (reviewTipParsed?.id === reviewTipId) {
|
||||
// Cache the parsed review tip
|
||||
@ -101,6 +102,7 @@ export async function getReviewTipById(reviewTipId: string, timeoutMs: number =
|
||||
}
|
||||
done(reviewTipParsed)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
sub.on('eose', (): void => {
|
||||
@ -134,7 +136,8 @@ export function getReviewTipsForArticle(articleId: string, timeoutMs: number = 5
|
||||
resolve(results)
|
||||
}
|
||||
|
||||
sub.on('event', async (event: Event): Promise<void> => {
|
||||
sub.on('event', (event: Event): void => {
|
||||
void (async (): Promise<void> => {
|
||||
const reviewTipParsed = await parseReviewTipFromEvent(event)
|
||||
if (reviewTipParsed?.articleId === articleId) {
|
||||
// Cache the parsed review tip
|
||||
@ -143,6 +146,7 @@ export function getReviewTipsForArticle(articleId: string, timeoutMs: number = 5
|
||||
}
|
||||
results.push(reviewTipParsed)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
sub.on('eose', (): void => {
|
||||
@ -176,7 +180,8 @@ export function getReviewTipsForReview(reviewId: string, timeoutMs: number = 500
|
||||
resolve(results)
|
||||
}
|
||||
|
||||
sub.on('event', async (event: Event): Promise<void> => {
|
||||
sub.on('event', (event: Event): void => {
|
||||
void (async (): Promise<void> => {
|
||||
const reviewTipParsed = await parseReviewTipFromEvent(event)
|
||||
if (reviewTipParsed?.reviewId === reviewId) {
|
||||
// Cache the parsed review tip
|
||||
@ -185,6 +190,7 @@ export function getReviewTipsForReview(reviewId: string, timeoutMs: number = 500
|
||||
}
|
||||
results.push(reviewTipParsed)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
sub.on('eose', (): void => {
|
||||
|
||||
@ -59,11 +59,13 @@ export function getReviewsForArticle(articleId: string, timeoutMs: number = 5000
|
||||
resolve(results)
|
||||
}
|
||||
|
||||
sub.on('event', async (event: Event): Promise<void> => {
|
||||
sub.on('event', (event: Event): void => {
|
||||
void (async (): Promise<void> => {
|
||||
const parsed = await parseReviewFromEvent(event)
|
||||
if (parsed) {
|
||||
results.push(parsed)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
sub.on('eose', (): void => {
|
||||
|
||||
@ -53,11 +53,13 @@ export function getSeriesByAuthor(authorPubkey: string, timeoutMs: number = 5000
|
||||
resolve(results)
|
||||
}
|
||||
|
||||
sub.on('event', async (event: Event): Promise<void> => {
|
||||
sub.on('event', (event: Event): void => {
|
||||
void (async (): Promise<void> => {
|
||||
const parsed = await parseSeriesFromEvent(event)
|
||||
if (parsed) {
|
||||
results.push(parsed)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
sub.on('eose', (): void => {
|
||||
|
||||
@ -92,7 +92,8 @@ export async function getSponsoringById(sponsoringId: string, timeoutMs: number
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
sub.on('event', async (event: Event) => {
|
||||
sub.on('event', (event: Event): void => {
|
||||
void (async (): Promise<void> => {
|
||||
const sponsoringParsed = await parseSponsoringFromEvent(event)
|
||||
if (sponsoringParsed?.id === sponsoringId) {
|
||||
// Cache the parsed sponsoring
|
||||
@ -101,6 +102,7 @@ export async function getSponsoringById(sponsoringId: string, timeoutMs: number
|
||||
}
|
||||
done(sponsoringParsed)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
sub.on('eose', (): void => {
|
||||
@ -134,7 +136,8 @@ export function getSponsoringByAuthor(authorPubkey: string, timeoutMs: number =
|
||||
resolve(results)
|
||||
}
|
||||
|
||||
sub.on('event', async (event: Event): Promise<void> => {
|
||||
sub.on('event', (event: Event): void => {
|
||||
void (async (): Promise<void> => {
|
||||
const sponsoringParsed = await parseSponsoringFromEvent(event)
|
||||
if (sponsoringParsed?.authorPubkey === authorPubkey) {
|
||||
// Cache the parsed sponsoring
|
||||
@ -143,6 +146,7 @@ export function getSponsoringByAuthor(authorPubkey: string, timeoutMs: number =
|
||||
}
|
||||
results.push(sponsoringParsed)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
sub.on('eose', (): void => {
|
||||
|
||||
@ -74,10 +74,35 @@ export class SponsoringTrackingService {
|
||||
if (!pool) {
|
||||
throw new Error('Pool not initialized')
|
||||
}
|
||||
const poolWithSub = pool
|
||||
|
||||
// Publish to all active relays (enabled and not marked inactive for this session)
|
||||
const { relaySessionManager } = await import('./relaySessionManager')
|
||||
const activeRelays = await relaySessionManager.getActiveRelays()
|
||||
|
||||
if (activeRelays.length === 0) {
|
||||
// Fallback to primary relay if no active relays
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
const pubs = poolWithSub.publish([relayUrl], event)
|
||||
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(
|
||||
|
||||
33
lib/syncProgressManager.ts
Normal file
33
lib/syncProgressManager.ts
Normal 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()
|
||||
@ -12,6 +12,7 @@ import { objectCache } from './objectCache'
|
||||
import { getLatestVersion } from './versionManager'
|
||||
import { buildTagFilter } from './nostrTagSystemFilter'
|
||||
import { getPrimaryRelaySync } from './config'
|
||||
import { tryWithRelayRotation } from './relayRotation'
|
||||
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
|
||||
import { parseObjectId } from './urlGenerator'
|
||||
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 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[] = []
|
||||
|
||||
@ -72,9 +104,9 @@ async function fetchAndCachePublications(
|
||||
if (latestEvent) {
|
||||
const extracted = await extractPublicationFromEvent(latestEvent)
|
||||
if (extracted) {
|
||||
const parsed = parseObjectId(extracted.id)
|
||||
const extractedHash = parsed.hash ?? extracted.id
|
||||
const extractedIndex = parsed.index ?? 0
|
||||
const publicationParsed = parseObjectId(extracted.id)
|
||||
const extractedHash = publicationParsed.hash ?? extracted.id
|
||||
const extractedIndex = publicationParsed.index ?? 0
|
||||
const tags = extractTagsFromEvent(latestEvent)
|
||||
await objectCache.set(
|
||||
'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 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[] = []
|
||||
|
||||
@ -151,8 +214,8 @@ async function fetchAndCacheSeries(
|
||||
const tags = extractTagsFromEvent(event)
|
||||
if (tags.id) {
|
||||
// Extract hash from id (can be <hash>_<index>_<version> or just hash)
|
||||
const parsed = parseObjectId(tags.id)
|
||||
const hash = parsed.hash ?? tags.id
|
||||
const seriesParsed = parseObjectId(tags.id)
|
||||
const hash = seriesParsed.hash ?? tags.id
|
||||
if (!eventsByHashId.has(hash)) {
|
||||
eventsByHashId.set(hash, [])
|
||||
}
|
||||
@ -166,9 +229,9 @@ async function fetchAndCacheSeries(
|
||||
if (latestEvent) {
|
||||
const extracted = await extractSeriesFromEvent(latestEvent)
|
||||
if (extracted) {
|
||||
const parsed = parseObjectId(extracted.id)
|
||||
const extractedHash = parsed.hash ?? extracted.id
|
||||
const extractedIndex = parsed.index ?? 0
|
||||
const publicationParsed = parseObjectId(extracted.id)
|
||||
const extractedHash = publicationParsed.hash ?? extracted.id
|
||||
const extractedIndex = publicationParsed.index ?? 0
|
||||
const tags = extractTagsFromEvent(latestEvent)
|
||||
await objectCache.set(
|
||||
'series',
|
||||
@ -189,15 +252,20 @@ async function fetchAndCacheSeries(
|
||||
sub.on('event', (event: Event): void => {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
if (tags.type === 'series' && !tags.hidden) {
|
||||
console.log('[Sync] Received series event:', event.id)
|
||||
events.push(event)
|
||||
}
|
||||
})
|
||||
|
||||
sub.on('eose', (): void => {
|
||||
console.log(`[Sync] EOSE for series, received ${events.length} events`)
|
||||
void done()
|
||||
})
|
||||
|
||||
setTimeout((): void => {
|
||||
if (!finished) {
|
||||
console.log(`[Sync] Timeout for series, received ${events.length} events`)
|
||||
}
|
||||
void done()
|
||||
}, 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 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[] = []
|
||||
|
||||
@ -252,14 +351,19 @@ async function fetchAndCachePurchases(
|
||||
}
|
||||
|
||||
sub.on('event', (event: Event): void => {
|
||||
console.log('[Sync] Received purchase event:', event.id)
|
||||
events.push(event)
|
||||
})
|
||||
|
||||
sub.on('eose', (): void => {
|
||||
console.log(`[Sync] EOSE for purchases, received ${events.length} events`)
|
||||
void done()
|
||||
})
|
||||
|
||||
setTimeout((): void => {
|
||||
if (!finished) {
|
||||
console.log(`[Sync] Timeout for purchases, received ${events.length} events`)
|
||||
}
|
||||
void done()
|
||||
}, 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 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[] = []
|
||||
|
||||
@ -314,14 +449,19 @@ async function fetchAndCacheSponsoring(
|
||||
}
|
||||
|
||||
sub.on('event', (event: Event): void => {
|
||||
console.log('[Sync] Received sponsoring event:', event.id)
|
||||
events.push(event)
|
||||
})
|
||||
|
||||
sub.on('eose', (): void => {
|
||||
console.log(`[Sync] EOSE for sponsoring, received ${events.length} events`)
|
||||
void done()
|
||||
})
|
||||
|
||||
setTimeout((): void => {
|
||||
if (!finished) {
|
||||
console.log(`[Sync] Timeout for sponsoring, received ${events.length} events`)
|
||||
}
|
||||
void done()
|
||||
}, 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 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[] = []
|
||||
|
||||
@ -376,14 +547,19 @@ async function fetchAndCacheReviewTips(
|
||||
}
|
||||
|
||||
sub.on('event', (event: Event): void => {
|
||||
console.log('[Sync] Received review tip event:', event.id)
|
||||
events.push(event)
|
||||
})
|
||||
|
||||
sub.on('eose', (): void => {
|
||||
console.log(`[Sync] EOSE for review tips, received ${events.length} events`)
|
||||
void done()
|
||||
})
|
||||
|
||||
setTimeout((): void => {
|
||||
if (!finished) {
|
||||
console.log(`[Sync] Timeout for review tips, received ${events.length} events`)
|
||||
}
|
||||
void done()
|
||||
}, 10000).unref?.()
|
||||
})
|
||||
@ -393,11 +569,135 @@ export interface SyncProgress {
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
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
|
||||
* 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 onProgress - Optional callback to report progress (currentStep, totalSteps, completed)
|
||||
*/
|
||||
@ -419,68 +719,79 @@ export async function syncUserContentToCache(
|
||||
const { setLastSyncDate, getCurrentTimestamp } = await import('./syncStorage')
|
||||
const currentTimestamp = getCurrentTimestamp()
|
||||
|
||||
const TOTAL_STEPS = 6
|
||||
const TOTAL_STEPS = 7
|
||||
|
||||
// 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) {
|
||||
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
|
||||
|
||||
// Fetch and cache author profile (already caches itself)
|
||||
console.log('[Sync] Step 1/6: Fetching author profile...')
|
||||
await fetchAuthorPresentationFromPool(poolWithSub, userPubkey)
|
||||
currentStep++
|
||||
// Helper function to update progress with current relay
|
||||
const updateProgress = (step: number, completed: boolean = false): void => {
|
||||
const currentRelay = syncProgressManager.getProgress()?.currentRelay ?? initialRelay
|
||||
const progressUpdate = { currentStep: step, totalSteps: TOTAL_STEPS, completed, currentRelay }
|
||||
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
|
||||
console.log('[Sync] Step 2/6: Fetching series...')
|
||||
console.log('[Sync] Step 2/7: Fetching series...')
|
||||
await fetchAndCacheSeries(poolWithSub, userPubkey)
|
||||
currentStep++
|
||||
if (onProgress) {
|
||||
onProgress({ currentStep, totalSteps: TOTAL_STEPS, completed: false })
|
||||
}
|
||||
updateProgress(currentStep)
|
||||
|
||||
// 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)
|
||||
currentStep++
|
||||
if (onProgress) {
|
||||
onProgress({ currentStep, totalSteps: TOTAL_STEPS, completed: false })
|
||||
}
|
||||
updateProgress(currentStep)
|
||||
|
||||
// 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)
|
||||
currentStep++
|
||||
if (onProgress) {
|
||||
onProgress({ currentStep, totalSteps: TOTAL_STEPS, completed: false })
|
||||
}
|
||||
updateProgress(currentStep)
|
||||
|
||||
// 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)
|
||||
currentStep++
|
||||
if (onProgress) {
|
||||
onProgress({ currentStep, totalSteps: TOTAL_STEPS, completed: false })
|
||||
}
|
||||
updateProgress(currentStep)
|
||||
|
||||
// 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)
|
||||
currentStep++
|
||||
if (onProgress) {
|
||||
onProgress({ currentStep, totalSteps: TOTAL_STEPS, completed: true })
|
||||
}
|
||||
updateProgress(currentStep)
|
||||
|
||||
// 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
|
||||
await setLastSyncDate(currentTimestamp)
|
||||
console.log('[Sync] Synchronization completed successfully')
|
||||
} catch (error) {
|
||||
console.error('Error syncing user content to cache:', error)
|
||||
throw error // Re-throw to allow UI to handle it
|
||||
} catch (syncError) {
|
||||
console.error('Error syncing user content to cache:', syncError)
|
||||
throw syncError // Re-throw to allow UI to handle it
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,9 @@ import { platformSyncService } from '@/lib/platformSync'
|
||||
import { nostrAuthService } from '@/lib/nostrAuth'
|
||||
import { syncUserContentToCache } from '@/lib/userContentSync'
|
||||
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 {
|
||||
// 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 {
|
||||
// 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
|
||||
React.useEffect(() => {
|
||||
// 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...')
|
||||
|
||||
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')
|
||||
syncProgressManager.setProgress(null)
|
||||
} catch (error) {
|
||||
console.error('[App] Error during user content sync:', error)
|
||||
syncProgressManager.setProgress(null)
|
||||
} finally {
|
||||
syncInProgress = false
|
||||
}
|
||||
@ -121,6 +136,7 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
<GlobalSyncProgressBar />
|
||||
<Component {...pageProps} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
@ -64,7 +64,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
let response: { statusCode: number; statusMessage: string; body: string }
|
||||
try {
|
||||
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) {
|
||||
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`))
|
||||
return
|
||||
@ -72,14 +72,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
// Recreate FormData for each request (needed for redirects)
|
||||
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)
|
||||
// Note: nostrimg.com might expect a different field name - if issues persist, try 'image' or 'upload'
|
||||
const fieldName = 'file'
|
||||
requestFormData.append(fieldName, fileStream, {
|
||||
filename: fileField.originalFilename || fileField.newFilename || 'upload',
|
||||
contentType: fileField.mimetype || 'application/octet-stream',
|
||||
filename: file.originalFilename ?? file.newFilename ?? 'upload',
|
||||
contentType: file.mimetype ?? 'application/octet-stream',
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
// Add NIP-98 Authorization header if token is provided
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Nostr ${authToken}`
|
||||
if (token) {
|
||||
headers['Authorization'] = `Nostr ${token}`
|
||||
}
|
||||
|
||||
// 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(),
|
||||
method: 'POST',
|
||||
fieldName,
|
||||
filename: fileField.originalFilename || fileField.newFilename || 'upload',
|
||||
contentType: fileField.mimetype || 'application/octet-stream',
|
||||
fileSize: fileField.size,
|
||||
filename: file.originalFilename ?? file.newFilename ?? 'upload',
|
||||
contentType: file.mimetype ?? 'application/octet-stream',
|
||||
fileSize: file.size,
|
||||
headers: {
|
||||
'Content-Type': headers['content-type'],
|
||||
'Accept': headers['Accept'],
|
||||
'User-Agent': headers['User-Agent'],
|
||||
'Authorization': 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
|
||||
proxyResponse.resume()
|
||||
// Make new request to redirect location (preserve auth token for redirects)
|
||||
makeRequest(redirectUrl, redirectCount + 1, fileField, authToken)
|
||||
makeRequest(redirectUrl, redirectCount + 1, file, token)
|
||||
return
|
||||
} catch (urlError) {
|
||||
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:', {
|
||||
targetEndpoint,
|
||||
hostname: url.hostname,
|
||||
filepath: fileField.filepath,
|
||||
filepath: file.filepath,
|
||||
error: error instanceof Error ? error.message : 'Unknown file stream error',
|
||||
})
|
||||
reject(error)
|
||||
|
||||
@ -10,6 +10,8 @@ import { ArticleCard } from '@/components/ArticleCard'
|
||||
import { t } from '@/lib/i18n'
|
||||
import Image from 'next/image'
|
||||
import { ArticleReviews } from '@/components/ArticleReviews'
|
||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||
import Link from 'next/link'
|
||||
|
||||
function SeriesHeader({ series }: { series: Series }): React.ReactElement {
|
||||
return (
|
||||
@ -60,6 +62,7 @@ export default function SeriesPage(): React.ReactElement | null {
|
||||
{series && (
|
||||
<>
|
||||
<SeriesHeader series={series} />
|
||||
<SeriesActions series={series} />
|
||||
<SeriesStats
|
||||
sponsoring={aggregates?.sponsoring ?? 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 {
|
||||
if (articles.length === 0) {
|
||||
return <p className="text-sm text-gray-600">Aucune publication pour cette série.</p>
|
||||
|
||||
180
pages/series/[id]/publish.tsx
Normal file
180
pages/series/[id]/publish.tsx
Normal 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 }
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { Nip95ConfigManager } from '@/components/Nip95ConfigManager'
|
||||
import { KeyManagementManager } from '@/components/KeyManagementManager'
|
||||
import { CacheUpdateManager } from '@/components/CacheUpdateManager'
|
||||
import { LanguageSettingsManager } from '@/components/LanguageSettingsManager'
|
||||
import { RelayManager } from '@/components/RelayManager'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
@ -23,6 +24,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
<div className="space-y-8">
|
||||
<LanguageSettingsManager />
|
||||
<KeyManagementManager />
|
||||
<RelayManager />
|
||||
<Nip95ConfigManager />
|
||||
<CacheUpdateManager />
|
||||
</div>
|
||||
|
||||
@ -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.missingFields=Please fill in all required fields
|
||||
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.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.completed=Everything is synchronized
|
||||
settings.sync.ready=Ready to synchronize
|
||||
settings.sync.syncing=Synchronizing
|
||||
settings.sync.connecting=Connecting...
|
||||
settings.nip95.title=NIP-95 Upload Endpoints
|
||||
settings.nip95.loading=Loading...
|
||||
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.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.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.description=Choose your preferred language for the interface
|
||||
settings.language.loading=Loading...
|
||||
@ -368,6 +400,8 @@ page.image.remove=Remove image
|
||||
page.image.alt=Page {{number}} image
|
||||
page.image.empty=No image
|
||||
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.empty=No notifications yet
|
||||
|
||||
@ -59,7 +59,12 @@ publication.price={{amount}} sats
|
||||
series.title=Séries
|
||||
series.empty=Aucune série publiée pour le moment.
|
||||
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.create.button=Créer une 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.completed=Tout est synchronisé
|
||||
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.description=Choisissez votre langue préférée pour l'interface
|
||||
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.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.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.repositoryGit=Repository Git
|
||||
@ -367,7 +399,9 @@ page.image.upload=Uploader une image
|
||||
page.image.remove=Supprimer l'image
|
||||
page.image.alt=Image page {{number}}
|
||||
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.empty=Aucune notification pour le moment
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user