lint fix
This commit is contained in:
parent
9e76a9e18a
commit
620f5955ca
@ -79,15 +79,6 @@ function HomeContent({
|
|||||||
// At startup, we don't know yet if we're loading articles or authors
|
// At startup, we don't know yet if we're loading articles or authors
|
||||||
// Use a generic loading message until we have content
|
// Use a generic loading message until we have content
|
||||||
const isInitialLoad = loading && allArticles.length === 0 && allAuthors.length === 0
|
const isInitialLoad = loading && allArticles.length === 0 && allAuthors.length === 0
|
||||||
const articlesListProps = {
|
|
||||||
articles,
|
|
||||||
allArticles,
|
|
||||||
loading: loading && !isInitialLoad, // Don't show loading if it's the initial generic state
|
|
||||||
error,
|
|
||||||
onUnlock,
|
|
||||||
unlockedArticles
|
|
||||||
}
|
|
||||||
const authorsListProps = { authors, allAuthors, loading: loading && !isInitialLoad, error }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-4 py-8">
|
<div className="w-full px-4 py-8">
|
||||||
@ -102,23 +93,77 @@ function HomeContent({
|
|||||||
<ArticleFiltersComponent filters={filters} onFiltersChange={setFilters} articles={allArticles} />
|
<ArticleFiltersComponent filters={filters} onFiltersChange={setFilters} articles={allArticles} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(() => {
|
<HomeMainList
|
||||||
if (isInitialLoad) {
|
isInitialLoad={isInitialLoad}
|
||||||
return (
|
shouldShowAuthors={shouldShowAuthors}
|
||||||
<div className="text-center py-12">
|
articlesListProps={buildArticlesListProps({
|
||||||
<p className="text-cyber-accent/70">{t('common.loading')}</p>
|
articles,
|
||||||
</div>
|
allArticles,
|
||||||
)
|
loading,
|
||||||
}
|
isInitialLoad,
|
||||||
if (shouldShowAuthors) {
|
error,
|
||||||
return <AuthorsList {...authorsListProps} />
|
onUnlock,
|
||||||
}
|
unlockedArticles,
|
||||||
return <ArticlesList {...articlesListProps} />
|
})}
|
||||||
})()}
|
authorsListProps={buildAuthorsListProps({ authors, allAuthors, loading, isInitialLoad, error })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HomeMainList(params: {
|
||||||
|
isInitialLoad: boolean
|
||||||
|
shouldShowAuthors: boolean
|
||||||
|
articlesListProps: Parameters<typeof ArticlesList>[0]
|
||||||
|
authorsListProps: Parameters<typeof AuthorsList>[0]
|
||||||
|
}): React.ReactElement {
|
||||||
|
if (params.isInitialLoad) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-cyber-accent/70">{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (params.shouldShowAuthors) {
|
||||||
|
return <AuthorsList {...params.authorsListProps} />
|
||||||
|
}
|
||||||
|
return <ArticlesList {...params.articlesListProps} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArticlesListProps(params: {
|
||||||
|
articles: Article[]
|
||||||
|
allArticles: Article[]
|
||||||
|
loading: boolean
|
||||||
|
isInitialLoad: boolean
|
||||||
|
error: string | null
|
||||||
|
onUnlock: (article: Article) => void
|
||||||
|
unlockedArticles: Set<string>
|
||||||
|
}): Parameters<typeof ArticlesList>[0] {
|
||||||
|
return {
|
||||||
|
articles: params.articles,
|
||||||
|
allArticles: params.allArticles,
|
||||||
|
loading: params.loading && !params.isInitialLoad,
|
||||||
|
error: params.error,
|
||||||
|
onUnlock: params.onUnlock,
|
||||||
|
unlockedArticles: params.unlockedArticles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthorsListProps(params: {
|
||||||
|
authors: Article[]
|
||||||
|
allAuthors: Article[]
|
||||||
|
loading: boolean
|
||||||
|
isInitialLoad: boolean
|
||||||
|
error: string | null
|
||||||
|
}): Parameters<typeof AuthorsList>[0] {
|
||||||
|
return {
|
||||||
|
authors: params.authors,
|
||||||
|
allAuthors: params.allAuthors,
|
||||||
|
loading: params.loading && !params.isInitialLoad,
|
||||||
|
error: params.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function HomeView(props: HomeViewProps): React.ReactElement {
|
export function HomeView(props: HomeViewProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -106,7 +106,7 @@ function useImageUpload(onChange: (url: string) => void): {
|
|||||||
const [pendingFile, setPendingFile] = useState<File | null>(null)
|
const [pendingFile, setPendingFile] = useState<File | null>(null)
|
||||||
|
|
||||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||||
const file = event.target.files?.[0]
|
const file = readFirstFile(event)
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -117,9 +117,8 @@ function useImageUpload(onChange: (url: string) => void): {
|
|||||||
try {
|
try {
|
||||||
await processFileUpload(file, onChange, setError)
|
await processFileUpload(file, onChange, setError)
|
||||||
} catch (uploadError) {
|
} catch (uploadError) {
|
||||||
const uploadErr = uploadError instanceof Error ? uploadError : new Error(String(uploadError))
|
const uploadErr = normalizeError(uploadError)
|
||||||
// Check if unlock is required
|
if (isUnlockRequiredError(uploadErr)) {
|
||||||
if (uploadErr.message === 'UNLOCK_REQUIRED' || ('unlockRequired' in uploadErr && (uploadErr as { unlockRequired?: boolean }).unlockRequired)) {
|
|
||||||
setPendingFile(file)
|
setPendingFile(file)
|
||||||
setShowUnlockModal(true)
|
setShowUnlockModal(true)
|
||||||
setError(null) // Don't show error, show unlock modal instead
|
setError(null) // Don't show error, show unlock modal instead
|
||||||
@ -132,25 +131,62 @@ function useImageUpload(onChange: (url: string) => void): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleUnlockSuccess = async (): Promise<void> => {
|
const handleUnlockSuccess = async (): Promise<void> => {
|
||||||
setShowUnlockModal(false)
|
await retryPendingUpload({
|
||||||
if (pendingFile) {
|
pendingFile,
|
||||||
// Retry upload after unlock
|
onChange,
|
||||||
setUploading(true)
|
setError,
|
||||||
setError(null)
|
setPendingFile,
|
||||||
try {
|
setShowUnlockModal,
|
||||||
await processFileUpload(pendingFile, onChange, setError)
|
setUploading,
|
||||||
setPendingFile(null)
|
})
|
||||||
} catch (retryError) {
|
|
||||||
setError(retryError instanceof Error ? retryError.message : t('presentation.field.picture.error.uploadFailed'))
|
|
||||||
} finally {
|
|
||||||
setUploading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess }
|
return { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readFirstFile(event: React.ChangeEvent<HTMLInputElement>): File | null {
|
||||||
|
return event.target.files?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeError(error: unknown): Error {
|
||||||
|
return error instanceof Error ? error : new Error(String(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnlockRequiredError(error: Error): boolean {
|
||||||
|
if (error.message === 'UNLOCK_REQUIRED') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (typeof error === 'object' && error !== null && 'unlockRequired' in error) {
|
||||||
|
return (error as { unlockRequired?: boolean }).unlockRequired === true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryPendingUpload(params: {
|
||||||
|
pendingFile: File | null
|
||||||
|
onChange: (url: string) => void
|
||||||
|
setError: (error: string | null) => void
|
||||||
|
setPendingFile: (file: File | null) => void
|
||||||
|
setShowUnlockModal: (show: boolean) => void
|
||||||
|
setUploading: (uploading: boolean) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
params.setShowUnlockModal(false)
|
||||||
|
if (!params.pendingFile) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params.setUploading(true)
|
||||||
|
params.setError(null)
|
||||||
|
try {
|
||||||
|
await processFileUpload(params.pendingFile, params.onChange, params.setError)
|
||||||
|
params.setPendingFile(null)
|
||||||
|
} catch (retryError) {
|
||||||
|
params.setError(retryError instanceof Error ? retryError.message : t('presentation.field.picture.error.uploadFailed'))
|
||||||
|
} finally {
|
||||||
|
params.setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ImageUploadField({ id, label, value, onChange, helpText }: ImageUploadFieldProps): React.ReactElement {
|
export function ImageUploadField({ id, label, value, onChange, helpText }: ImageUploadFieldProps): React.ReactElement {
|
||||||
const { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess } = useImageUpload(onChange)
|
const { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess } = useImageUpload(onChange)
|
||||||
const displayLabel = label ?? t('presentation.field.picture')
|
const displayLabel = label ?? t('presentation.field.picture')
|
||||||
|
|||||||
@ -11,8 +11,6 @@ interface PublicKeys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function KeyManagementManager(): React.ReactElement {
|
export function KeyManagementManager(): React.ReactElement {
|
||||||
console.warn('[KeyManagementManager] Component rendered')
|
|
||||||
|
|
||||||
const [publicKeys, setPublicKeys] = useState<PublicKeys | null>(null)
|
const [publicKeys, setPublicKeys] = useState<PublicKeys | null>(null)
|
||||||
const [accountExists, setAccountExists] = useState(false)
|
const [accountExists, setAccountExists] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -71,13 +69,32 @@ export function KeyManagementManager(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
} catch {
|
} catch {
|
||||||
// Not a valid URL, try to extract nsec from text
|
return extractKeyFromText(url)
|
||||||
const nsecMatch = url.match(/nsec1[a-z0-9]+/i)
|
}
|
||||||
if (nsecMatch) {
|
}
|
||||||
return nsecMatch[0]
|
|
||||||
|
function extractKeyFromText(text: string): string {
|
||||||
|
const nsec = extractNsec(text)
|
||||||
|
if (nsec) {
|
||||||
|
return nsec
|
||||||
|
}
|
||||||
|
return text.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNsec(text: string): string | null {
|
||||||
|
const nsecMatch = text.match(/nsec1[a-z0-9]+/i)
|
||||||
|
return nsecMatch?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPrivateKeyFormat(key: string): boolean {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(key)
|
||||||
|
if (decoded.type !== 'nsec') {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
// Assume it's already a key (hex or nsec)
|
return typeof decoded.data === 'string' || decoded.data instanceof Uint8Array
|
||||||
return url.trim()
|
} catch {
|
||||||
|
return /^[0-9a-f]{64}$/i.test(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,22 +112,9 @@ export function KeyManagementManager(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate key format
|
// Validate key format
|
||||||
try {
|
if (!isValidPrivateKeyFormat(extractedKey)) {
|
||||||
// Try to decode as nsec
|
setError(t('settings.keyManagement.import.error.invalid'))
|
||||||
const decoded = nip19.decode(extractedKey)
|
return
|
||||||
if (decoded.type !== 'nsec') {
|
|
||||||
throw new Error('Invalid nsec format')
|
|
||||||
}
|
|
||||||
// decoded.data can be string (hex) or Uint8Array, both are valid
|
|
||||||
if (typeof decoded.data !== 'string' && !(decoded.data instanceof Uint8Array)) {
|
|
||||||
throw new Error('Invalid nsec format')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If decoding failed, assume it's hex, validate length (64 hex chars = 32 bytes)
|
|
||||||
if (!/^[0-9a-f]{64}$/i.test(extractedKey)) {
|
|
||||||
setError(t('settings.keyManagement.import.error.invalid'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If account exists, show warning
|
// If account exists, show warning
|
||||||
@ -216,211 +220,340 @@ export function KeyManagementManager(): React.ReactElement {
|
|||||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
||||||
<h2 className="text-2xl font-bold text-neon-cyan mb-4">{t('settings.keyManagement.title')}</h2>
|
<h2 className="text-2xl font-bold text-neon-cyan mb-4">{t('settings.keyManagement.title')}</h2>
|
||||||
|
|
||||||
{error && (
|
<KeyManagementErrorBanner error={error} />
|
||||||
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4 mb-4">
|
|
||||||
<p className="text-red-400">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Public Keys Display */}
|
<KeyManagementPublicKeysPanel
|
||||||
{publicKeys && (
|
publicKeys={publicKeys}
|
||||||
<div className="space-y-4 mb-6">
|
copiedNpub={copiedNpub}
|
||||||
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
|
copiedPublicKey={copiedPublicKey}
|
||||||
<div className="flex justify-between items-start mb-2">
|
onCopyNpub={handleCopyNpub}
|
||||||
<p className="text-neon-blue font-semibold">{t('settings.keyManagement.publicKey.npub')}</p>
|
onCopyPublicKey={handleCopyPublicKey}
|
||||||
<button
|
/>
|
||||||
onClick={() => {
|
|
||||||
void handleCopyNpub()
|
|
||||||
}}
|
|
||||||
className="px-3 py-1 text-xs bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded transition-colors"
|
|
||||||
>
|
|
||||||
{copiedNpub ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-neon-cyan text-sm font-mono break-all">{publicKeys.npub}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<p className="text-neon-blue font-semibold">{t('settings.keyManagement.publicKey.hex')}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void handleCopyPublicKey()
|
|
||||||
}}
|
|
||||||
className="px-3 py-1 text-xs bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded transition-colors"
|
|
||||||
>
|
|
||||||
{copiedPublicKey ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-neon-cyan text-sm font-mono break-all">{publicKeys.publicKey}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sync Progress Bar - Always show if connected, even if publicKeys not loaded yet */}
|
{/* Sync Progress Bar - Always show if connected, even if publicKeys not loaded yet */}
|
||||||
{(() => {
|
<SyncProgressBar />
|
||||||
console.warn('[KeyManagementManager] Rendering SyncProgressBar')
|
|
||||||
return <SyncProgressBar />
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{!publicKeys && !accountExists && (
|
<KeyManagementNoAccountBanner publicKeys={publicKeys} accountExists={accountExists} />
|
||||||
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4 mb-6">
|
|
||||||
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.noAccount.title')}</p>
|
|
||||||
<p className="text-yellow-300/90 text-sm">
|
|
||||||
{t('settings.keyManagement.noAccount.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Import Form */}
|
<KeyManagementImportButton
|
||||||
{!showImportForm && (
|
accountExists={accountExists}
|
||||||
<button
|
showImportForm={showImportForm}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowImportForm(true)
|
setShowImportForm(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
}}
|
}}
|
||||||
className="w-full py-3 px-6 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
/>
|
||||||
>
|
|
||||||
{accountExists ? t('settings.keyManagement.import.button.replace') : t('settings.keyManagement.import.button.new')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showImportForm && (
|
<KeyManagementImportForm
|
||||||
<div className="space-y-4">
|
accountExists={accountExists}
|
||||||
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
|
showImportForm={showImportForm}
|
||||||
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.import.warning.title')}</p>
|
showReplaceWarning={showReplaceWarning}
|
||||||
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.description') }} />
|
importing={importing}
|
||||||
{accountExists && (
|
importKey={importKey}
|
||||||
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.replace') }} />
|
onChangeImportKey={(value) => {
|
||||||
)}
|
setImportKey(value)
|
||||||
</div>
|
setError(null)
|
||||||
|
}}
|
||||||
<div>
|
onCancel={() => {
|
||||||
<label htmlFor="importKey" className="block text-sm font-medium text-cyber-accent mb-2">
|
setShowImportForm(false)
|
||||||
{t('settings.keyManagement.import.label')}
|
setImportKey('')
|
||||||
</label>
|
setError(null)
|
||||||
<textarea
|
}}
|
||||||
id="importKey"
|
onImport={() => {
|
||||||
value={importKey}
|
void handleImport()
|
||||||
onChange={(e) => {
|
}}
|
||||||
setImportKey(e.target.value)
|
onDismissReplaceWarning={() => {
|
||||||
setError(null)
|
setShowReplaceWarning(false)
|
||||||
}}
|
}}
|
||||||
placeholder={t('settings.keyManagement.import.placeholder')}
|
onConfirmReplace={() => {
|
||||||
className="w-full px-3 py-2 bg-cyber-dark border border-neon-cyan/30 rounded-lg font-mono text-sm text-neon-cyan focus:border-neon-cyan focus:outline-none"
|
void performImport(extractKeyFromUrl(importKey.trim()) ?? importKey.trim())
|
||||||
rows={4}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-cyber-accent/70 mt-2">
|
|
||||||
{t('settings.keyManagement.import.help')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showReplaceWarning && (
|
|
||||||
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4">
|
|
||||||
<p className="text-red-400 font-semibold mb-2">{t('settings.keyManagement.replace.warning.title')}</p>
|
|
||||||
<p className="text-red-300/90 text-sm mb-4">
|
|
||||||
{t('settings.keyManagement.replace.warning.description')}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowReplaceWarning(false)
|
|
||||||
}}
|
|
||||||
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
|
||||||
>
|
|
||||||
{t('settings.keyManagement.replace.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void performImport(extractKeyFromUrl(importKey.trim()) ?? importKey.trim())
|
|
||||||
}}
|
|
||||||
disabled={importing}
|
|
||||||
className="flex-1 py-2 px-4 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg font-medium transition-all border border-red-400/50 hover:shadow-glow-red disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{importing ? t('settings.keyManagement.replace.replacing') : t('settings.keyManagement.replace.confirm')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!showReplaceWarning && (
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowImportForm(false)
|
|
||||||
setImportKey('')
|
|
||||||
setError(null)
|
|
||||||
}}
|
|
||||||
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
|
||||||
>
|
|
||||||
{t('settings.keyManagement.import.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void handleImport()
|
|
||||||
}}
|
|
||||||
disabled={importing}
|
|
||||||
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{importing ? t('settings.keyManagement.import.importing') : t('settings.keyManagement.import.import')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recovery Phrase Display (after import) */}
|
{/* Recovery Phrase Display (after import) */}
|
||||||
{recoveryPhrase && newNpub && (
|
<KeyManagementRecoveryPanel
|
||||||
<div className="mt-6 space-y-4">
|
recoveryPhrase={recoveryPhrase}
|
||||||
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
|
newNpub={newNpub}
|
||||||
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.recovery.warning.title')}</p>
|
copiedRecoveryPhrase={copiedRecoveryPhrase}
|
||||||
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part1') }} />
|
onCopyRecoveryPhrase={handleCopyRecoveryPhrase}
|
||||||
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part2') }} />
|
onDone={() => {
|
||||||
<p className="text-yellow-300/90 text-sm mt-2">
|
setRecoveryPhrase(null)
|
||||||
{t('settings.keyManagement.recovery.warning.part3')}
|
setNewNpub(null)
|
||||||
</p>
|
void loadKeys()
|
||||||
</div>
|
}}
|
||||||
|
/>
|
||||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
{recoveryPhrase.map((word, index) => (
|
|
||||||
<div
|
|
||||||
key={`recovery-word-${index}-${word}`}
|
|
||||||
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
|
|
||||||
>
|
|
||||||
<span className="text-cyber-accent/70 text-sm mr-2">{index + 1}.</span>
|
|
||||||
<span className="font-semibold text-neon-cyan">{word}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void handleCopyRecoveryPhrase()
|
|
||||||
}}
|
|
||||||
className="w-full py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
{copiedRecoveryPhrase ? t('settings.keyManagement.recovery.copied') : t('settings.keyManagement.recovery.copy')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
|
|
||||||
<p className="text-neon-blue font-semibold mb-2">{t('settings.keyManagement.recovery.newNpub')}</p>
|
|
||||||
<p className="text-neon-cyan text-sm font-mono break-all">{newNpub}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setRecoveryPhrase(null)
|
|
||||||
setNewNpub(null)
|
|
||||||
void loadKeys()
|
|
||||||
}}
|
|
||||||
className="w-full py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
|
||||||
>
|
|
||||||
{t('settings.keyManagement.recovery.done')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function KeyManagementErrorBanner(params: { error: string | null }): React.ReactElement | null {
|
||||||
|
if (!params.error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4 mb-4">
|
||||||
|
<p className="text-red-400">{params.error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementPublicKeysPanel(params: {
|
||||||
|
publicKeys: PublicKeys | null
|
||||||
|
copiedNpub: boolean
|
||||||
|
copiedPublicKey: boolean
|
||||||
|
onCopyNpub: () => Promise<void>
|
||||||
|
onCopyPublicKey: () => Promise<void>
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!params.publicKeys) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<KeyManagementKeyCard
|
||||||
|
label={t('settings.keyManagement.publicKey.npub')}
|
||||||
|
value={params.publicKeys.npub}
|
||||||
|
copied={params.copiedNpub}
|
||||||
|
onCopy={params.onCopyNpub}
|
||||||
|
/>
|
||||||
|
<KeyManagementKeyCard
|
||||||
|
label={t('settings.keyManagement.publicKey.hex')}
|
||||||
|
value={params.publicKeys.publicKey}
|
||||||
|
copied={params.copiedPublicKey}
|
||||||
|
onCopy={params.onCopyPublicKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementKeyCard(params: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
copied: boolean
|
||||||
|
onCopy: () => Promise<void>
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<p className="text-neon-blue font-semibold">{params.label}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void params.onCopy()
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 text-xs bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded transition-colors"
|
||||||
|
>
|
||||||
|
{params.copied ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-neon-cyan text-sm font-mono break-all">{params.value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementNoAccountBanner(params: {
|
||||||
|
publicKeys: PublicKeys | null
|
||||||
|
accountExists: boolean
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (params.publicKeys || params.accountExists) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.noAccount.title')}</p>
|
||||||
|
<p className="text-yellow-300/90 text-sm">{t('settings.keyManagement.noAccount.description')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementImportButton(params: {
|
||||||
|
accountExists: boolean
|
||||||
|
showImportForm: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (params.showImportForm) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={params.onClick}
|
||||||
|
className="w-full py-3 px-6 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
||||||
|
>
|
||||||
|
{params.accountExists ? t('settings.keyManagement.import.button.replace') : t('settings.keyManagement.import.button.new')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementImportForm(params: {
|
||||||
|
accountExists: boolean
|
||||||
|
showImportForm: boolean
|
||||||
|
showReplaceWarning: boolean
|
||||||
|
importing: boolean
|
||||||
|
importKey: string
|
||||||
|
onChangeImportKey: (value: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
onImport: () => void
|
||||||
|
onDismissReplaceWarning: () => void
|
||||||
|
onConfirmReplace: () => void
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!params.showImportForm) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
|
||||||
|
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.import.warning.title')}</p>
|
||||||
|
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.description') }} />
|
||||||
|
{params.accountExists && (
|
||||||
|
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.replace') }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="importKey" className="block text-sm font-medium text-cyber-accent mb-2">
|
||||||
|
{t('settings.keyManagement.import.label')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="importKey"
|
||||||
|
value={params.importKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
params.onChangeImportKey(e.target.value)
|
||||||
|
}}
|
||||||
|
placeholder={t('settings.keyManagement.import.placeholder')}
|
||||||
|
className="w-full px-3 py-2 bg-cyber-dark border border-neon-cyan/30 rounded-lg font-mono text-sm text-neon-cyan focus:border-neon-cyan focus:outline-none"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-cyber-accent/70 mt-2">{t('settings.keyManagement.import.help')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<KeyManagementReplaceWarning
|
||||||
|
show={params.showReplaceWarning}
|
||||||
|
importing={params.importing}
|
||||||
|
onCancel={params.onDismissReplaceWarning}
|
||||||
|
onConfirm={params.onConfirmReplace}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KeyManagementImportFormActions
|
||||||
|
show={!params.showReplaceWarning}
|
||||||
|
importing={params.importing}
|
||||||
|
onCancel={params.onCancel}
|
||||||
|
onImport={params.onImport}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementReplaceWarning(params: {
|
||||||
|
show: boolean
|
||||||
|
importing: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!params.show) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4">
|
||||||
|
<p className="text-red-400 font-semibold mb-2">{t('settings.keyManagement.replace.warning.title')}</p>
|
||||||
|
<p className="text-red-300/90 text-sm mb-4">{t('settings.keyManagement.replace.warning.description')}</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={params.onCancel}
|
||||||
|
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t('settings.keyManagement.replace.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={params.onConfirm}
|
||||||
|
disabled={params.importing}
|
||||||
|
className="flex-1 py-2 px-4 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg font-medium transition-all border border-red-400/50 hover:shadow-glow-red disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{params.importing ? t('settings.keyManagement.replace.replacing') : t('settings.keyManagement.replace.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementImportFormActions(params: {
|
||||||
|
show: boolean
|
||||||
|
importing: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onImport: () => void
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!params.show) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={params.onCancel}
|
||||||
|
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t('settings.keyManagement.import.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={params.onImport}
|
||||||
|
disabled={params.importing}
|
||||||
|
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{params.importing ? t('settings.keyManagement.import.importing') : t('settings.keyManagement.import.import')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementRecoveryPanel(params: {
|
||||||
|
recoveryPhrase: string[] | null
|
||||||
|
newNpub: string | null
|
||||||
|
copiedRecoveryPhrase: boolean
|
||||||
|
onCopyRecoveryPhrase: () => Promise<void>
|
||||||
|
onDone: () => void
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!params.recoveryPhrase || !params.newNpub) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
|
||||||
|
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.recovery.warning.title')}</p>
|
||||||
|
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part1') }} />
|
||||||
|
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part2') }} />
|
||||||
|
<p className="text-yellow-300/90 text-sm mt-2">{t('settings.keyManagement.recovery.warning.part3')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
{params.recoveryPhrase.map((word, index) => (
|
||||||
|
<div
|
||||||
|
key={`recovery-word-${index}-${word}`}
|
||||||
|
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
|
||||||
|
>
|
||||||
|
<span className="text-cyber-accent/70 text-sm mr-2">{index + 1}.</span>
|
||||||
|
<span className="font-semibold text-neon-cyan">{word}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void params.onCopyRecoveryPhrase()
|
||||||
|
}}
|
||||||
|
className="w-full py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{params.copiedRecoveryPhrase ? t('settings.keyManagement.recovery.copied') : t('settings.keyManagement.recovery.copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
|
||||||
|
<p className="text-neon-blue font-semibold mb-2">{t('settings.keyManagement.recovery.newNpub')}</p>
|
||||||
|
<p className="text-neon-cyan text-sm font-mono break-all">{params.newNpub}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={params.onDone}
|
||||||
|
className="w-full py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
||||||
|
>
|
||||||
|
{t('settings.keyManagement.recovery.done')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -31,36 +31,11 @@ export function LanguageSettingsManager(): React.ReactElement {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadLocale = async (): Promise<void> => {
|
void loadLocaleIntoState({ setCurrentLocale, setLoading })
|
||||||
try {
|
|
||||||
// Migrate from localStorage if needed
|
|
||||||
await localeStorage.migrateFromLocalStorage()
|
|
||||||
|
|
||||||
// Load from IndexedDB
|
|
||||||
const savedLocale = await localeStorage.getLocale()
|
|
||||||
if (savedLocale) {
|
|
||||||
setLocale(savedLocale)
|
|
||||||
setCurrentLocale(savedLocale)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error loading locale:', e)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void loadLocale()
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleLocaleChange = async (locale: Locale): Promise<void> => {
|
const handleLocaleChange = async (locale: Locale): Promise<void> => {
|
||||||
setLocale(locale)
|
await applyLocaleChange({ locale, setCurrentLocale })
|
||||||
setCurrentLocale(locale)
|
|
||||||
try {
|
|
||||||
await localeStorage.saveLocale(locale)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error saving locale:', e)
|
|
||||||
}
|
|
||||||
// Force page reload to update all translations
|
|
||||||
window.location.reload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onLocaleClick = (locale: Locale): void => {
|
const onLocaleClick = (locale: Locale): void => {
|
||||||
@ -86,3 +61,32 @@ export function LanguageSettingsManager(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadLocaleIntoState(params: {
|
||||||
|
setCurrentLocale: (locale: Locale) => void
|
||||||
|
setLoading: (loading: boolean) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await localeStorage.migrateFromLocalStorage()
|
||||||
|
const savedLocale = await localeStorage.getLocale()
|
||||||
|
if (savedLocale) {
|
||||||
|
setLocale(savedLocale)
|
||||||
|
params.setCurrentLocale(savedLocale)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading locale:', e)
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyLocaleChange(params: { locale: Locale; setCurrentLocale: (locale: Locale) => void }): Promise<void> {
|
||||||
|
setLocale(params.locale)
|
||||||
|
params.setCurrentLocale(params.locale)
|
||||||
|
try {
|
||||||
|
await localeStorage.saveLocale(params.locale)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error saving locale:', e)
|
||||||
|
}
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|||||||
@ -23,96 +23,38 @@ export function MarkdownEditorTwoColumns({
|
|||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleAddPage = (type: 'markdown' | 'image'): void => {
|
const pagesHandlers = createPagesHandlers({ pages, onPagesChange })
|
||||||
if (!onPagesChange) {
|
const handleImageUpload = createImageUploadHandler({
|
||||||
return
|
setError,
|
||||||
}
|
setUploading,
|
||||||
const newPage: Page = {
|
onMediaAdd,
|
||||||
number: pages.length + 1,
|
onBannerChange,
|
||||||
type,
|
onSetPageImageUrl: pagesHandlers.setPageContent,
|
||||||
content: type === 'markdown' ? '' : '',
|
})
|
||||||
}
|
|
||||||
onPagesChange([...pages, newPage])
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePageContentChange = (pageNumber: number, content: string): void => {
|
|
||||||
if (!onPagesChange) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const updatedPages = pages.map((p) => (p.number === pageNumber ? { ...p, content } : p))
|
|
||||||
onPagesChange(updatedPages)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePageTypeChange = (pageNumber: number, type: 'markdown' | 'image'): void => {
|
|
||||||
if (!onPagesChange) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const updatedPages = pages.map((p) => (p.number === pageNumber ? { ...p, type, content: '' } : p))
|
|
||||||
onPagesChange(updatedPages)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemovePage = (pageNumber: number): void => {
|
|
||||||
if (!onPagesChange) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const updatedPages = pages
|
|
||||||
.filter((p) => p.number !== pageNumber)
|
|
||||||
.map((p, index) => ({ ...p, number: index + 1 }))
|
|
||||||
onPagesChange(updatedPages)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleImageUpload = async (file: File, pageNumber?: number): Promise<void> => {
|
|
||||||
setError(null)
|
|
||||||
setUploading(true)
|
|
||||||
try {
|
|
||||||
const media = await uploadNip95Media(file)
|
|
||||||
if (media.type === 'image') {
|
|
||||||
if (pageNumber !== undefined && onPagesChange) {
|
|
||||||
handlePageContentChange(pageNumber, media.url)
|
|
||||||
} else {
|
|
||||||
onBannerChange?.(media.url)
|
|
||||||
}
|
|
||||||
onMediaAdd?.(media)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : t('upload.error.failed'))
|
|
||||||
} finally {
|
|
||||||
setUploading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<MarkdownToolbar
|
<MarkdownToolbar
|
||||||
onFileSelected={(file) => {
|
onFileSelected={(file) => {
|
||||||
void handleImageUpload(file)
|
void handleImageUpload({ file })
|
||||||
}}
|
}}
|
||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
error={error}
|
error={error}
|
||||||
{...(onPagesChange ? { onAddPage: handleAddPage } : {})}
|
{...(onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<EditorColumn value={value} onChange={onChange} />
|
||||||
<label className="block text-sm font-semibold text-gray-800">{t('markdown.editor')}</label>
|
<PreviewColumn value={value} />
|
||||||
<textarea
|
|
||||||
className="w-full border rounded p-3 h-96 font-mono text-sm"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder={t('markdown.placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-semibold text-gray-800">{t('markdown.preview')}</label>
|
|
||||||
<MarkdownPreview value={value} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{onPagesChange && (
|
{onPagesChange && (
|
||||||
<PagesManager
|
<PagesManager
|
||||||
pages={pages}
|
pages={pages}
|
||||||
onPageContentChange={handlePageContentChange}
|
onPageContentChange={pagesHandlers.setPageContent}
|
||||||
onPageTypeChange={handlePageTypeChange}
|
onPageTypeChange={pagesHandlers.setPageType}
|
||||||
onRemovePage={handleRemovePage}
|
onRemovePage={pagesHandlers.removePage}
|
||||||
onImageUpload={handleImageUpload}
|
onImageUpload={async (file, pageNumber) => {
|
||||||
|
await handleImageUpload({ file, pageNumber })
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -132,40 +74,9 @@ function MarkdownToolbar({
|
|||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<label className="px-3 py-1 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700">
|
<ToolbarUploadButton onFileSelected={onFileSelected} />
|
||||||
{t('markdown.upload.media')}
|
<ToolbarAddPageButtons onAddPage={onAddPage} />
|
||||||
<input
|
<ToolbarStatus uploading={uploading} error={error} />
|
||||||
type="file"
|
|
||||||
accept=".png,.jpg,.jpeg,.webp"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
onFileSelected(file)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{onAddPage && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
|
|
||||||
onClick={() => onAddPage('markdown')}
|
|
||||||
>
|
|
||||||
{t('page.add.markdown')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700"
|
|
||||||
onClick={() => onAddPage('image')}
|
|
||||||
>
|
|
||||||
{t('page.add.image')}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{uploading && <span className="text-sm text-gray-500">{t('markdown.upload.uploading')}</span>}
|
|
||||||
{error && <span className="text-sm text-red-600">{error}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -229,66 +140,246 @@ function PageEditor({
|
|||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg p-4 space-y-3">
|
<div className="border rounded-lg p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<PageEditorHeader page={page} onTypeChange={onTypeChange} onRemove={onRemove} />
|
||||||
<h4 className="font-semibold">
|
<PageEditorBody page={page} onContentChange={onContentChange} onImageUpload={onImageUpload} />
|
||||||
{t('page.number', { number: page.number })} - {t(`page.type.${page.type}`)}
|
</div>
|
||||||
</h4>
|
)
|
||||||
<div className="flex items-center gap-2">
|
}
|
||||||
<select
|
|
||||||
value={page.type}
|
function EditorColumn(params: { value: string; onChange: (value: string) => void }): React.ReactElement {
|
||||||
onChange={(e) => onTypeChange(e.target.value as 'markdown' | 'image')}
|
return (
|
||||||
className="text-sm border rounded px-2 py-1"
|
<div className="space-y-2">
|
||||||
>
|
<label className="block text-sm font-semibold text-gray-800">{t('markdown.editor')}</label>
|
||||||
<option value="markdown">{t('page.type.markdown')}</option>
|
<textarea
|
||||||
<option value="image">{t('page.type.image')}</option>
|
className="w-full border rounded p-3 h-96 font-mono text-sm"
|
||||||
</select>
|
value={params.value}
|
||||||
|
onChange={(e) => params.onChange(e.target.value)}
|
||||||
|
placeholder={t('markdown.placeholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewColumn(params: { value: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-semibold text-gray-800">{t('markdown.preview')}</label>
|
||||||
|
<MarkdownPreview value={params.value} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarUploadButton(params: { onFileSelected: (file: File) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<label className="px-3 py-1 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700">
|
||||||
|
{t('markdown.upload.media')}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".png,.jpg,.jpeg,.webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
params.onFileSelected(file)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarAddPageButtons(params: { onAddPage: ((type: 'markdown' | 'image') => void) | undefined }): React.ReactElement | null {
|
||||||
|
if (!params.onAddPage) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
|
||||||
|
onClick={() => params.onAddPage?.('markdown')}
|
||||||
|
>
|
||||||
|
{t('page.add.markdown')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700"
|
||||||
|
onClick={() => params.onAddPage?.('image')}
|
||||||
|
>
|
||||||
|
{t('page.add.image')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarStatus(params: { uploading: boolean; error: string | null }): React.ReactElement | null {
|
||||||
|
if (!params.uploading && !params.error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{params.uploading ? <span className="text-sm text-gray-500">{t('markdown.upload.uploading')}</span> : null}
|
||||||
|
{params.error ? <span className="text-sm text-red-600">{params.error}</span> : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageEditorHeader(params: {
|
||||||
|
page: Page
|
||||||
|
onTypeChange: (type: 'markdown' | 'image') => void
|
||||||
|
onRemove: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-semibold">
|
||||||
|
{t('page.number', { number: params.page.number })} - {t(`page.type.${params.page.type}`)}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={params.page.type}
|
||||||
|
onChange={(e) => params.onTypeChange(e.target.value as 'markdown' | 'image')}
|
||||||
|
className="text-sm border rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<option value="markdown">{t('page.type.markdown')}</option>
|
||||||
|
<option value="image">{t('page.type.image')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||||
|
onClick={params.onRemove}
|
||||||
|
>
|
||||||
|
{t('page.remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageEditorBody(params: {
|
||||||
|
page: Page
|
||||||
|
onContentChange: (content: string) => void
|
||||||
|
onImageUpload: (file: File) => Promise<void>
|
||||||
|
}): React.ReactElement {
|
||||||
|
if (params.page.type === 'markdown') {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className="w-full border rounded p-2 h-48 font-mono text-sm"
|
||||||
|
value={params.page.content}
|
||||||
|
onChange={(e) => params.onContentChange(e.target.value)}
|
||||||
|
placeholder={t('page.markdown.placeholder')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PageEditorImageBody page={params.page} onContentChange={params.onContentChange} onImageUpload={params.onImageUpload} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageEditorImageBody(params: {
|
||||||
|
page: Page
|
||||||
|
onContentChange: (content: string) => void
|
||||||
|
onImageUpload: (file: File) => Promise<void>
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{params.page.content ? (
|
||||||
|
<div className="relative">
|
||||||
|
<img src={params.page.content} alt={t('page.image.alt', { number: params.page.number })} className="max-w-full h-auto rounded" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
className="absolute top-2 right-2 px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||||
onClick={onRemove}
|
onClick={() => params.onContentChange('')}
|
||||||
>
|
>
|
||||||
{t('page.remove')}
|
{t('page.image.remove')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{page.type === 'markdown' ? (
|
|
||||||
<textarea
|
|
||||||
className="w-full border rounded p-2 h-48 font-mono text-sm"
|
|
||||||
value={page.content}
|
|
||||||
onChange={(e) => onContentChange(e.target.value)}
|
|
||||||
placeholder={t('page.markdown.placeholder')}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<PageImageUploadButton onFileSelected={params.onImageUpload} />
|
||||||
{page.content ? (
|
|
||||||
<div className="relative">
|
|
||||||
<img src={page.content} alt={t('page.image.alt', { number: page.number })} className="max-w-full h-auto rounded" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute top-2 right-2 px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
|
||||||
onClick={() => onContentChange('')}
|
|
||||||
>
|
|
||||||
{t('page.image.remove')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<label className="block px-3 py-2 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700 text-center">
|
|
||||||
{t('page.image.upload')}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".png,.jpg,.jpeg,.webp"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
void onImageUpload(file)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PageImageUploadButton(params: { onFileSelected: (file: File) => Promise<void> }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<label className="block px-3 py-2 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700 text-center">
|
||||||
|
{t('page.image.upload')}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".png,.jpg,.jpeg,.webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
void params.onFileSelected(file)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPagesHandlers(params: {
|
||||||
|
pages: Page[]
|
||||||
|
onPagesChange: ((pages: Page[]) => void) | undefined
|
||||||
|
}): { addPage: (type: 'markdown' | 'image') => void; setPageContent: (pageNumber: number, content: string) => void; setPageType: (pageNumber: number, type: 'markdown' | 'image') => void; removePage: (pageNumber: number) => void } {
|
||||||
|
const update = (next: Page[]): void => {
|
||||||
|
params.onPagesChange?.(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addPage: (type) => {
|
||||||
|
if (!params.onPagesChange) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newPage: Page = { number: params.pages.length + 1, type, content: '' }
|
||||||
|
update([...params.pages, newPage])
|
||||||
|
},
|
||||||
|
setPageContent: (pageNumber, content) => {
|
||||||
|
if (!params.onPagesChange) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, content } : p)))
|
||||||
|
},
|
||||||
|
setPageType: (pageNumber, type) => {
|
||||||
|
if (!params.onPagesChange) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, type, content: '' } : p)))
|
||||||
|
},
|
||||||
|
removePage: (pageNumber) => {
|
||||||
|
if (!params.onPagesChange) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
update(params.pages.filter((p) => p.number !== pageNumber).map((p, idx) => ({ ...p, number: idx + 1 })))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createImageUploadHandler(params: {
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setUploading: (value: boolean) => void
|
||||||
|
onMediaAdd: ((media: MediaRef) => void) | undefined
|
||||||
|
onBannerChange: ((url: string) => void) | undefined
|
||||||
|
onSetPageImageUrl: (pageNumber: number, url: string) => void
|
||||||
|
}): (args: { file: File; pageNumber?: number }) => Promise<void> {
|
||||||
|
return async (args): Promise<void> => {
|
||||||
|
params.setError(null)
|
||||||
|
params.setUploading(true)
|
||||||
|
try {
|
||||||
|
const media = await uploadNip95Media(args.file)
|
||||||
|
if (media.type !== 'image') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (args.pageNumber !== undefined) {
|
||||||
|
params.onSetPageImageUrl(args.pageNumber, media.url)
|
||||||
|
} else {
|
||||||
|
params.onBannerChange?.(media.url)
|
||||||
|
}
|
||||||
|
params.onMediaAdd?.(media)
|
||||||
|
} catch (e) {
|
||||||
|
params.setError(e instanceof Error ? e.message : t('upload.error.failed'))
|
||||||
|
} finally {
|
||||||
|
params.setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -60,41 +60,61 @@ export function PageHeader(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<header className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan">
|
<header className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan">
|
||||||
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
|
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
<div className="flex items-center gap-2">
|
<HeaderLeft />
|
||||||
<Link href="/" className="text-2xl font-bold text-neon-cyan text-glow-cyan font-mono hover:text-neon-green transition-colors">
|
<HeaderRight />
|
||||||
{t('home.title')}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/docs"
|
|
||||||
className="text-cyber-accent hover:text-neon-cyan transition-colors"
|
|
||||||
title={t('nav.documentation')}
|
|
||||||
>
|
|
||||||
<DocsIcon />
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/funding"
|
|
||||||
className="text-cyber-accent hover:text-neon-cyan transition-colors"
|
|
||||||
title={t('funding.title')}
|
|
||||||
>
|
|
||||||
<FundingIcon />
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href="https://git.4nkweb.com/4nk/story-research-zapwall"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-cyber-accent hover:text-neon-cyan transition-colors"
|
|
||||||
title={t('common.repositoryGit')}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<GitIcon />
|
|
||||||
</a>
|
|
||||||
<KeyIndicator />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<LanguageSelector />
|
|
||||||
<ConditionalPublishButton />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HeaderLeft(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href="/" className="text-2xl font-bold text-neon-cyan text-glow-cyan font-mono hover:text-neon-green transition-colors">
|
||||||
|
{t('home.title')}
|
||||||
|
</Link>
|
||||||
|
<HeaderLinks />
|
||||||
|
<KeyIndicator />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderLinks(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/docs"
|
||||||
|
className="text-cyber-accent hover:text-neon-cyan transition-colors"
|
||||||
|
title={t('nav.documentation')}
|
||||||
|
>
|
||||||
|
<DocsIcon />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/funding"
|
||||||
|
className="text-cyber-accent hover:text-neon-cyan transition-colors"
|
||||||
|
title={t('funding.title')}
|
||||||
|
>
|
||||||
|
<FundingIcon />
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href="https://git.4nkweb.com/4nk/story-research-zapwall"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-cyber-accent hover:text-neon-cyan transition-colors"
|
||||||
|
title={t('common.repositoryGit')}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<GitIcon />
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderRight(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<LanguageSelector />
|
||||||
|
<ConditionalPublishButton />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -130,50 +130,90 @@ function ExpiredNotice({ show }: { show: boolean }): React.ReactElement | null {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => void): {
|
type PaymentModalState = {
|
||||||
copied: boolean
|
copied: boolean
|
||||||
errorMessage: string | null
|
errorMessage: string | null
|
||||||
paymentUrl: string
|
paymentUrl: string
|
||||||
timeRemaining: number | null
|
timeRemaining: number | null
|
||||||
handleCopy: () => Promise<void>
|
handleCopy: () => Promise<void>
|
||||||
handleOpenWallet: () => Promise<void>
|
handleOpenWallet: () => Promise<void>
|
||||||
} {
|
}
|
||||||
|
|
||||||
|
function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => void): PaymentModalState {
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
const paymentUrl = `lightning:${invoice.invoice}`
|
const paymentUrl = `lightning:${invoice.invoice}`
|
||||||
const timeRemaining = useInvoiceTimer(invoice.expiresAt)
|
const timeRemaining = useInvoiceTimer(invoice.expiresAt)
|
||||||
|
|
||||||
const handleCopy = useCallback(async (): Promise<void> => {
|
const handleCopy = useCallback(
|
||||||
|
createHandleCopy({ invoice: invoice.invoice, setCopied, setErrorMessage }),
|
||||||
|
[invoice.invoice]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOpenWallet = useCallback(
|
||||||
|
createHandleOpenWallet({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage }),
|
||||||
|
[invoice.invoice, onPaymentComplete]
|
||||||
|
)
|
||||||
|
|
||||||
|
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHandleCopy(params: {
|
||||||
|
invoice: string
|
||||||
|
setCopied: (value: boolean) => void
|
||||||
|
setErrorMessage: (value: string | null) => void
|
||||||
|
}): () => Promise<void> {
|
||||||
|
return async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(invoice.invoice)
|
await navigator.clipboard.writeText(params.invoice)
|
||||||
setCopied(true)
|
params.setCopied(true)
|
||||||
setTimeout(() => setCopied(false), 2000)
|
scheduleCopiedReset(params.setCopied)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to copy:', e)
|
console.error('Failed to copy:', e)
|
||||||
setErrorMessage(t('payment.modal.copyFailed'))
|
params.setErrorMessage(t('payment.modal.copyFailed'))
|
||||||
}
|
}
|
||||||
}, [invoice.invoice])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleOpenWallet = useCallback(async (): Promise<void> => {
|
function createHandleOpenWallet(params: {
|
||||||
|
invoice: string
|
||||||
|
onPaymentComplete: () => void
|
||||||
|
setErrorMessage: (value: string | null) => void
|
||||||
|
}): () => Promise<void> {
|
||||||
|
return async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const alby = getAlbyService()
|
await payWithWebLN(params.invoice)
|
||||||
if (!isWebLNAvailable()) {
|
params.onPaymentComplete()
|
||||||
throw new Error(t('payment.modal.weblnNotAvailable'))
|
|
||||||
}
|
|
||||||
await alby.enable()
|
|
||||||
await alby.sendPayment(invoice.invoice)
|
|
||||||
onPaymentComplete()
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = e instanceof Error ? e : new Error(String(e))
|
const error = normalizePaymentError(e)
|
||||||
if (error.message.includes('user rejected') || error.message.includes('cancelled')) {
|
if (isUserCancellationError(error)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error('Payment failed:', error)
|
console.error('Payment failed:', error)
|
||||||
setErrorMessage(error.message)
|
params.setErrorMessage(error.message)
|
||||||
}
|
}
|
||||||
}, [invoice.invoice, onPaymentComplete])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
|
function normalizePaymentError(error: unknown): Error {
|
||||||
|
return error instanceof Error ? error : new Error(String(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCopiedReset(setCopied: (value: boolean) => void): void {
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUserCancellationError(error: Error): boolean {
|
||||||
|
return error.message.includes('user rejected') || error.message.includes('cancelled')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function payWithWebLN(invoice: string): Promise<void> {
|
||||||
|
const alby = getAlbyService()
|
||||||
|
if (!isWebLNAvailable()) {
|
||||||
|
throw new Error(t('payment.modal.weblnNotAvailable'))
|
||||||
|
}
|
||||||
|
await alby.enable()
|
||||||
|
await alby.sendPayment(invoice)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps): React.ReactElement {
|
export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps): React.ReactElement {
|
||||||
|
|||||||
@ -7,8 +7,6 @@ import { t } from '@/lib/i18n'
|
|||||||
import { useSyncProgress } from '@/lib/hooks/useSyncProgress'
|
import { useSyncProgress } from '@/lib/hooks/useSyncProgress'
|
||||||
|
|
||||||
export function SyncProgressBar(): React.ReactElement | null {
|
export function SyncProgressBar(): React.ReactElement | null {
|
||||||
console.warn('[SyncProgressBar] Component function called')
|
|
||||||
|
|
||||||
const [lastSyncDate, setLastSyncDate] = useState<number | null>(null)
|
const [lastSyncDate, setLastSyncDate] = useState<number | null>(null)
|
||||||
const [totalDays, setTotalDays] = useState<number>(0)
|
const [totalDays, setTotalDays] = useState<number>(0)
|
||||||
const [isInitialized, setIsInitialized] = useState(false)
|
const [isInitialized, setIsInitialized] = useState(false)
|
||||||
@ -78,39 +76,14 @@ export function SyncProgressBar(): React.ReactElement | null {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
void runAutoSyncCheck({
|
||||||
console.warn('[SyncProgressBar] Starting sync check...')
|
connection: { connected: connectionState.connected, pubkey: connectionState.pubkey },
|
||||||
await loadSyncStatus()
|
isSyncing,
|
||||||
|
loadSyncStatus,
|
||||||
// Auto-start sync if not recently synced
|
startMonitoring,
|
||||||
const storedLastSyncDate = await getLastSyncDate()
|
stopMonitoring,
|
||||||
const currentTimestamp = getCurrentTimestamp()
|
setError,
|
||||||
const isRecentlySynced = storedLastSyncDate >= currentTimestamp - 3600
|
})
|
||||||
|
|
||||||
console.warn('[SyncProgressBar] Sync status:', { storedLastSyncDate, currentTimestamp, isRecentlySynced, isSyncing })
|
|
||||||
|
|
||||||
// Only auto-start if not recently synced
|
|
||||||
if (!isRecentlySynced && !isSyncing && connectionState.pubkey) {
|
|
||||||
console.warn('[SyncProgressBar] Starting auto-sync...')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { swClient } = await import('@/lib/swClient')
|
|
||||||
const isReady = await swClient.isReady()
|
|
||||||
if (isReady) {
|
|
||||||
await swClient.startUserSync(connectionState.pubkey)
|
|
||||||
startMonitoring()
|
|
||||||
} else {
|
|
||||||
stopMonitoring()
|
|
||||||
}
|
|
||||||
} catch (autoSyncError) {
|
|
||||||
console.error('[SyncProgressBar] Error during auto-sync:', autoSyncError)
|
|
||||||
stopMonitoring()
|
|
||||||
setError(autoSyncError instanceof Error ? autoSyncError.message : 'Erreur de synchronisation')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('[SyncProgressBar] Skipping auto-sync:', { isRecentlySynced, isSyncing, hasPubkey: Boolean(connectionState.pubkey) })
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, [isInitialized, connectionState.connected, connectionState.pubkey, isSyncing, loadSyncStatus, startMonitoring, stopMonitoring])
|
}, [isInitialized, connectionState.connected, connectionState.pubkey, isSyncing, loadSyncStatus, startMonitoring, stopMonitoring])
|
||||||
|
|
||||||
async function resynchronize(): Promise<void> {
|
async function resynchronize(): Promise<void> {
|
||||||
@ -156,18 +129,13 @@ export function SyncProgressBar(): React.ReactElement | null {
|
|||||||
|
|
||||||
// Don't show if not initialized or not connected
|
// Don't show if not initialized or not connected
|
||||||
if (!isInitialized || !connectionState.connected || !connectionState.pubkey) {
|
if (!isInitialized || !connectionState.connected || !connectionState.pubkey) {
|
||||||
console.warn('[SyncProgressBar] Not rendering:', { isInitialized, connected: connectionState.connected, pubkey: connectionState.pubkey })
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn('[SyncProgressBar] Rendering component')
|
|
||||||
|
|
||||||
// Check if sync is recently completed (within last hour)
|
// Check if sync is recently completed (within last hour)
|
||||||
const isRecentlySynced = lastSyncDate !== null && lastSyncDate >= getCurrentTimestamp() - 3600
|
const isRecentlySynced = lastSyncDate !== null && lastSyncDate >= getCurrentTimestamp() - 3600
|
||||||
|
|
||||||
const progressPercentage = syncProgress && syncProgress.totalSteps > 0
|
const progressPercentage = computeProgressPercentage(syncProgress)
|
||||||
? Math.min(100, (syncProgress.currentStep / syncProgress.totalSteps) * 100)
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const formatDate = (timestamp: number): string => {
|
const formatDate = (timestamp: number): string => {
|
||||||
const date = new Date(timestamp * 1000)
|
const date = new Date(timestamp * 1000)
|
||||||
@ -187,80 +155,192 @@ export function SyncProgressBar(): React.ReactElement | null {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-4 mt-6">
|
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-4 mt-6">
|
||||||
{error && (
|
<SyncErrorBanner
|
||||||
<div className="mb-4 bg-red-900/30 border border-red-500/50 rounded p-3 text-red-300 text-sm">
|
error={error}
|
||||||
{error}
|
onDismiss={() => {
|
||||||
<button
|
setError(null)
|
||||||
onClick={() => {
|
}}
|
||||||
setError(null)
|
/>
|
||||||
}}
|
|
||||||
className="ml-2 text-red-400 hover:text-red-200"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="text-lg font-semibold text-neon-cyan">
|
<h3 className="text-lg font-semibold text-neon-cyan">
|
||||||
{t('settings.sync.title')}
|
{t('settings.sync.title')}
|
||||||
</h3>
|
</h3>
|
||||||
{!isSyncing && (
|
<SyncResyncButton
|
||||||
<button
|
isSyncing={isSyncing}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void resynchronize()
|
void resynchronize()
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1 text-xs bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded border border-neon-cyan/50 hover:border-neon-cyan transition-colors"
|
/>
|
||||||
>
|
|
||||||
{t('settings.sync.resync')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{totalDays > 0 && (
|
<SyncDateRange
|
||||||
<div className="mb-2">
|
totalDays={totalDays}
|
||||||
<p className="text-sm text-cyber-accent">
|
startDate={formatDate(startDate)}
|
||||||
{t('settings.sync.daysRange', {
|
endDate={formatDate(endDate)}
|
||||||
startDate: formatDate(startDate),
|
/>
|
||||||
endDate: formatDate(endDate),
|
|
||||||
days: totalDays,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSyncing && syncProgress && (
|
<SyncProgressSection
|
||||||
<div className="space-y-2">
|
isSyncing={isSyncing}
|
||||||
<div className="flex items-center justify-between text-sm">
|
syncProgress={syncProgress}
|
||||||
<span className="text-cyber-accent">
|
progressPercentage={progressPercentage}
|
||||||
{t('settings.sync.progress', {
|
/>
|
||||||
current: syncProgress.currentStep,
|
|
||||||
total: syncProgress.totalSteps,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span className="text-neon-cyan font-semibold">
|
|
||||||
{Math.round(progressPercentage)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-cyber-dark rounded-full h-2 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="bg-neon-cyan h-full transition-all duration-300"
|
|
||||||
style={{ width: `${progressPercentage}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isSyncing && totalDays === 0 && isRecentlySynced && (
|
<SyncStatusMessage
|
||||||
<p className="text-sm text-green-400">
|
isSyncing={isSyncing}
|
||||||
{t('settings.sync.completed')}
|
totalDays={totalDays}
|
||||||
</p>
|
isRecentlySynced={isRecentlySynced}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{!isSyncing && totalDays === 0 && !isRecentlySynced && (
|
|
||||||
<p className="text-sm text-cyber-accent">
|
|
||||||
{t('settings.sync.ready')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeProgressPercentage(syncProgress: ReturnType<typeof useSyncProgress>['syncProgress']): number {
|
||||||
|
if (!syncProgress || syncProgress.totalSteps <= 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return Math.min(100, (syncProgress.currentStep / syncProgress.totalSteps) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncErrorBanner(params: { error: string | null; onDismiss: () => void }): React.ReactElement | null {
|
||||||
|
if (!params.error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mb-4 bg-red-900/30 border border-red-500/50 rounded p-3 text-red-300 text-sm">
|
||||||
|
{params.error}
|
||||||
|
<button onClick={params.onDismiss} className="ml-2 text-red-400 hover:text-red-200">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncResyncButton(params: { isSyncing: boolean; onClick: () => void }): React.ReactElement | null {
|
||||||
|
if (params.isSyncing) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={params.onClick}
|
||||||
|
className="px-3 py-1 text-xs bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded border border-neon-cyan/50 hover:border-neon-cyan transition-colors"
|
||||||
|
>
|
||||||
|
{t('settings.sync.resync')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncDateRange(params: { totalDays: number; startDate: string; endDate: string }): React.ReactElement | null {
|
||||||
|
if (params.totalDays <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mb-2">
|
||||||
|
<p className="text-sm text-cyber-accent">
|
||||||
|
{t('settings.sync.daysRange', {
|
||||||
|
startDate: params.startDate,
|
||||||
|
endDate: params.endDate,
|
||||||
|
days: params.totalDays,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncProgressSection(params: {
|
||||||
|
isSyncing: boolean
|
||||||
|
syncProgress: ReturnType<typeof useSyncProgress>['syncProgress']
|
||||||
|
progressPercentage: number
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!params.isSyncing || !params.syncProgress) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-cyber-accent">
|
||||||
|
{t('settings.sync.progress', {
|
||||||
|
current: params.syncProgress.currentStep,
|
||||||
|
total: params.syncProgress.totalSteps,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className="text-neon-cyan font-semibold">{Math.round(params.progressPercentage)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-cyber-dark rounded-full h-2 overflow-hidden">
|
||||||
|
<div className="bg-neon-cyan h-full transition-all duration-300" style={{ width: `${params.progressPercentage}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncStatusMessage(params: { isSyncing: boolean; totalDays: number; isRecentlySynced: boolean }): React.ReactElement | null {
|
||||||
|
if (params.isSyncing || params.totalDays !== 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (params.isRecentlySynced) {
|
||||||
|
return <p className="text-sm text-green-400">{t('settings.sync.completed')}</p>
|
||||||
|
}
|
||||||
|
return <p className="text-sm text-cyber-accent">{t('settings.sync.ready')}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAutoSyncCheck(params: {
|
||||||
|
connection: { connected: boolean; pubkey: string | null }
|
||||||
|
isSyncing: boolean
|
||||||
|
loadSyncStatus: () => Promise<void>
|
||||||
|
startMonitoring: () => void
|
||||||
|
stopMonitoring: () => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
console.warn('[SyncProgressBar] Starting sync check...')
|
||||||
|
await params.loadSyncStatus()
|
||||||
|
|
||||||
|
const shouldStart = await shouldAutoStartSync({
|
||||||
|
isSyncing: params.isSyncing,
|
||||||
|
pubkey: params.connection.pubkey,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!shouldStart || !params.connection.pubkey) {
|
||||||
|
console.warn('[SyncProgressBar] Skipping auto-sync:', { shouldStart, isSyncing: params.isSyncing, hasPubkey: Boolean(params.connection.pubkey) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[SyncProgressBar] Starting auto-sync...')
|
||||||
|
await startAutoSync({
|
||||||
|
pubkey: params.connection.pubkey,
|
||||||
|
startMonitoring: params.startMonitoring,
|
||||||
|
stopMonitoring: params.stopMonitoring,
|
||||||
|
setError: params.setError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shouldAutoStartSync(params: { isSyncing: boolean; pubkey: string | null }): Promise<boolean> {
|
||||||
|
if (params.isSyncing || !params.pubkey) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const storedLastSyncDate = await getLastSyncDate()
|
||||||
|
const currentTimestamp = getCurrentTimestamp()
|
||||||
|
const isRecentlySynced = storedLastSyncDate >= currentTimestamp - 3600
|
||||||
|
console.warn('[SyncProgressBar] Sync status:', { storedLastSyncDate, currentTimestamp, isRecentlySynced })
|
||||||
|
return !isRecentlySynced
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startAutoSync(params: {
|
||||||
|
pubkey: string
|
||||||
|
startMonitoring: () => void
|
||||||
|
stopMonitoring: () => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { swClient } = await import('@/lib/swClient')
|
||||||
|
const isReady = await swClient.isReady()
|
||||||
|
if (!isReady) {
|
||||||
|
params.stopMonitoring()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await swClient.startUserSync(params.pubkey)
|
||||||
|
params.startMonitoring()
|
||||||
|
} catch (autoSyncError) {
|
||||||
|
console.error('[SyncProgressBar] Error during auto-sync:', autoSyncError)
|
||||||
|
params.stopMonitoring()
|
||||||
|
params.setError(autoSyncError instanceof Error ? autoSyncError.message : 'Erreur de synchronisation')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -15,6 +15,25 @@ interface UserArticlesProps {
|
|||||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserArticlesController = {
|
||||||
|
localArticles: Article[]
|
||||||
|
unlockedArticles: Set<string>
|
||||||
|
pendingDeleteId: string | null
|
||||||
|
requestDelete: (id: string) => void
|
||||||
|
handleUnlock: (article: Article) => Promise<void>
|
||||||
|
handleDelete: (article: Article) => Promise<void>
|
||||||
|
handleEditSubmit: () => Promise<void>
|
||||||
|
editingDraft: ArticleDraft | null
|
||||||
|
editingArticleId: string | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
updateDraft: (draft: ArticleDraft) => void
|
||||||
|
startEditing: (article: Article) => Promise<void>
|
||||||
|
cancelEditing: () => void
|
||||||
|
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>
|
||||||
|
deleteArticle: (id: string) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
export function UserArticles({
|
export function UserArticles({
|
||||||
articles,
|
articles,
|
||||||
loading,
|
loading,
|
||||||
@ -45,24 +64,7 @@ function useUserArticlesController({
|
|||||||
articles: Article[]
|
articles: Article[]
|
||||||
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
|
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
|
||||||
currentPubkey: string | null
|
currentPubkey: string | null
|
||||||
}): {
|
}): UserArticlesController {
|
||||||
localArticles: Article[]
|
|
||||||
unlockedArticles: Set<string>
|
|
||||||
pendingDeleteId: string | null
|
|
||||||
requestDelete: (id: string) => void
|
|
||||||
handleUnlock: (article: Article) => Promise<void>
|
|
||||||
handleDelete: (article: Article) => Promise<void>
|
|
||||||
handleEditSubmit: () => Promise<void>
|
|
||||||
editingDraft: ArticleDraft | null
|
|
||||||
editingArticleId: string | null
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
updateDraft: (draft: ArticleDraft) => void
|
|
||||||
startEditing: (article: Article) => Promise<void>
|
|
||||||
cancelEditing: () => void
|
|
||||||
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>
|
|
||||||
deleteArticle: (id: string) => Promise<boolean>
|
|
||||||
} {
|
|
||||||
const [deletedArticleIds, setDeletedArticleIds] = useState<Set<string>>(new Set())
|
const [deletedArticleIds, setDeletedArticleIds] = useState<Set<string>>(new Set())
|
||||||
const [articleOverridesById, setArticleOverridesById] = useState<Map<string, Article>>(new Map())
|
const [articleOverridesById, setArticleOverridesById] = useState<Map<string, Article>>(new Map())
|
||||||
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
|
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
|
||||||
@ -200,7 +202,7 @@ function UserArticlesLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createLayoutProps(
|
function createLayoutProps(
|
||||||
controller: ReturnType<typeof useUserArticlesController>,
|
controller: UserArticlesController,
|
||||||
view: {
|
view: {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
@ -240,7 +242,7 @@ function createLayoutProps(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEditPanelProps(controller: ReturnType<typeof useUserArticlesController>): {
|
function buildEditPanelProps(controller: UserArticlesController): {
|
||||||
draft: ArticleDraft | null
|
draft: ArticleDraft | null
|
||||||
editingArticleId: string | null
|
editingArticleId: string | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@ -260,16 +262,7 @@ function buildEditPanelProps(controller: ReturnType<typeof useUserArticlesContro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildListProps(
|
type UserArticlesListProps = {
|
||||||
controller: ReturnType<typeof useUserArticlesController>,
|
|
||||||
view: {
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
showEmptyMessage: boolean
|
|
||||||
currentPubkey: string | null
|
|
||||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
||||||
}
|
|
||||||
): {
|
|
||||||
articles: Article[]
|
articles: Article[]
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
@ -283,22 +276,28 @@ function buildListProps(
|
|||||||
pendingDeleteId: string | null
|
pendingDeleteId: string | null
|
||||||
requestDelete: (articleId: string) => void
|
requestDelete: (articleId: string) => void
|
||||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||||
} {
|
}
|
||||||
|
|
||||||
|
function buildListProps(
|
||||||
|
controller: UserArticlesController,
|
||||||
|
view: {
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
showEmptyMessage: boolean
|
||||||
|
currentPubkey: string | null
|
||||||
|
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||||
|
}
|
||||||
|
): UserArticlesListProps {
|
||||||
|
const handlers = buildUserArticlesHandlers(controller)
|
||||||
return {
|
return {
|
||||||
articles: controller.localArticles,
|
articles: controller.localArticles,
|
||||||
loading: view.loading,
|
loading: view.loading,
|
||||||
error: view.error,
|
error: view.error,
|
||||||
showEmptyMessage: view.showEmptyMessage,
|
showEmptyMessage: view.showEmptyMessage,
|
||||||
unlockedArticles: controller.unlockedArticles,
|
unlockedArticles: controller.unlockedArticles,
|
||||||
onUnlock: (a: Article) => {
|
onUnlock: handlers.onUnlock,
|
||||||
void controller.handleUnlock(a)
|
onEdit: handlers.onEdit,
|
||||||
},
|
onDelete: handlers.onDelete,
|
||||||
onEdit: (a: Article) => {
|
|
||||||
void controller.startEditing(a)
|
|
||||||
},
|
|
||||||
onDelete: (a: Article) => {
|
|
||||||
void controller.handleDelete(a)
|
|
||||||
},
|
|
||||||
editingArticleId: controller.editingArticleId,
|
editingArticleId: controller.editingArticleId,
|
||||||
currentPubkey: view.currentPubkey,
|
currentPubkey: view.currentPubkey,
|
||||||
pendingDeleteId: controller.pendingDeleteId,
|
pendingDeleteId: controller.pendingDeleteId,
|
||||||
@ -306,3 +305,21 @@ function buildListProps(
|
|||||||
...(view.onSelectSeries ? { onSelectSeries: view.onSelectSeries } : {}),
|
...(view.onSelectSeries ? { onSelectSeries: view.onSelectSeries } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildUserArticlesHandlers(controller: UserArticlesController): {
|
||||||
|
onUnlock: (article: Article) => void
|
||||||
|
onEdit: (article: Article) => void
|
||||||
|
onDelete: (article: Article) => void
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
onUnlock: (a: Article): void => {
|
||||||
|
void controller.handleUnlock(a)
|
||||||
|
},
|
||||||
|
onEdit: (a: Article): void => {
|
||||||
|
void controller.startEditing(a)
|
||||||
|
},
|
||||||
|
onDelete: (a: Article): void => {
|
||||||
|
void controller.handleDelete(a)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export function useAutoConnect(params: {
|
|||||||
}, [accountExists, pubkey, showRecoveryStep, showUnlockModal, connect])
|
}, [accountExists, pubkey, showRecoveryStep, showUnlockModal, connect])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useConnectButtonUiState(): {
|
type ConnectButtonUiState = {
|
||||||
showRecoveryStep: boolean
|
showRecoveryStep: boolean
|
||||||
showUnlockModal: boolean
|
showUnlockModal: boolean
|
||||||
setShowUnlockModal: (show: boolean) => void
|
setShowUnlockModal: (show: boolean) => void
|
||||||
@ -28,7 +28,9 @@ export function useConnectButtonUiState(): {
|
|||||||
onUnlockSuccess: () => void
|
onUnlockSuccess: () => void
|
||||||
openUnlockModal: () => void
|
openUnlockModal: () => void
|
||||||
closeUnlockModal: () => void
|
closeUnlockModal: () => void
|
||||||
} {
|
}
|
||||||
|
|
||||||
|
export function useConnectButtonUiState(): ConnectButtonUiState {
|
||||||
const unlockModal = useUnlockModalVisibility()
|
const unlockModal = useUnlockModalVisibility()
|
||||||
const recovery = useRecoveryStepState()
|
const recovery = useRecoveryStepState()
|
||||||
const [creatingAccount, setCreatingAccount] = useState(false)
|
const [creatingAccount, setCreatingAccount] = useState(false)
|
||||||
|
|||||||
@ -8,8 +8,21 @@ export function ReviewFormView(params: { ctrl: ReviewFormController; onCancel?:
|
|||||||
onSubmit={(e) => void params.ctrl.handleSubmit(e)}
|
onSubmit={(e) => void params.ctrl.handleSubmit(e)}
|
||||||
className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"
|
className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-semibold text-neon-cyan">{t('review.form.title')}</h3>
|
<ReviewFormHeader />
|
||||||
|
<ReviewFormFields ctrl={params.ctrl} />
|
||||||
|
{params.ctrl.error ? <ErrorBox message={params.ctrl.error} /> : null}
|
||||||
|
<ReviewFormActions loading={params.ctrl.loading} onCancel={params.onCancel} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewFormHeader(): React.ReactElement {
|
||||||
|
return <h3 className="text-lg font-semibold text-neon-cyan">{t('review.form.title')}</h3>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewFormFields(params: { ctrl: ReviewFormController }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="review-title"
|
id="review-title"
|
||||||
label={t('review.form.title.label')}
|
label={t('review.form.title.label')}
|
||||||
@ -40,28 +53,30 @@ export function ReviewFormView(params: { ctrl: ReviewFormController; onCancel?:
|
|||||||
helpText={t('review.form.text.help')}
|
helpText={t('review.form.text.help')}
|
||||||
optionalLabel={`(${t('common.optional')})`}
|
optionalLabel={`(${t('common.optional')})`}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{params.ctrl.error ? <ErrorBox message={params.ctrl.error} /> : null}
|
function ReviewFormActions(params: { loading: boolean; onCancel?: (() => void) | undefined }): React.ReactElement {
|
||||||
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={params.loading}
|
||||||
|
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{params.loading ? t('common.loading') : t('review.form.submit')}
|
||||||
|
</button>
|
||||||
|
{params.onCancel ? (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={params.ctrl.loading}
|
onClick={params.onCancel}
|
||||||
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
|
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
|
||||||
>
|
>
|
||||||
{params.ctrl.loading ? t('common.loading') : t('review.form.submit')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
{params.onCancel ? (
|
) : null}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
|
||||||
onClick={params.onCancel}
|
|
||||||
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,51 +8,72 @@ export function ReviewTipFormView(params: { ctrl: ReviewTipFormController; onCan
|
|||||||
onSubmit={(e) => void params.ctrl.handleSubmit(e)}
|
onSubmit={(e) => void params.ctrl.handleSubmit(e)}
|
||||||
className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"
|
className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"
|
||||||
>
|
>
|
||||||
|
<ReviewTipFormHeader split={params.ctrl.split} />
|
||||||
|
<ReviewTipTextField value={params.ctrl.text} onChange={params.ctrl.setText} />
|
||||||
|
{params.ctrl.error ? <ErrorBox message={params.ctrl.error} /> : null}
|
||||||
|
<ReviewTipFormActions amount={params.ctrl.split.total} loading={params.ctrl.loading} onCancel={params.onCancel} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewTipFormHeader(params: { split: { total: number; reviewer: number; platform: number } }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<h3 className="text-lg font-semibold text-neon-cyan">{t('reviewTip.form.title')}</h3>
|
<h3 className="text-lg font-semibold text-neon-cyan">{t('reviewTip.form.title')}</h3>
|
||||||
<p className="text-sm text-cyber-accent/70">
|
<p className="text-sm text-cyber-accent/70">
|
||||||
{t('reviewTip.form.description', {
|
{t('reviewTip.form.description', {
|
||||||
amount: params.ctrl.split.total,
|
amount: params.split.total,
|
||||||
reviewer: params.ctrl.split.reviewer,
|
reviewer: params.split.reviewer,
|
||||||
platform: params.ctrl.split.platform,
|
platform: params.split.platform,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<div>
|
function ReviewTipTextField(params: { value: string; onChange: (value: string) => void }): React.ReactElement {
|
||||||
<label htmlFor="review-tip-text" className="block text-sm font-medium text-cyber-accent mb-1">
|
return (
|
||||||
{t('reviewTip.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
|
<div>
|
||||||
</label>
|
<label htmlFor="review-tip-text" className="block text-sm font-medium text-cyber-accent mb-1">
|
||||||
<textarea
|
{t('reviewTip.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
|
||||||
id="review-tip-text"
|
</label>
|
||||||
value={params.ctrl.text}
|
<textarea
|
||||||
onChange={(e) => params.ctrl.setText(e.target.value)}
|
id="review-tip-text"
|
||||||
placeholder={t('reviewTip.form.text.placeholder')}
|
value={params.value}
|
||||||
rows={3}
|
onChange={(e) => params.onChange(e.target.value)}
|
||||||
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
|
placeholder={t('reviewTip.form.text.placeholder')}
|
||||||
/>
|
rows={3}
|
||||||
<p className="text-xs text-cyber-accent/70 mt-1">{t('reviewTip.form.text.help')}</p>
|
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
|
||||||
</div>
|
/>
|
||||||
|
<p className="text-xs text-cyber-accent/70 mt-1">{t('reviewTip.form.text.help')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{params.ctrl.error ? <ErrorBox message={params.ctrl.error} /> : null}
|
function ReviewTipFormActions(params: {
|
||||||
|
amount: number
|
||||||
<div className="flex gap-2">
|
loading: boolean
|
||||||
|
onCancel?: (() => void) | undefined
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={params.loading}
|
||||||
|
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{params.loading ? t('common.loading') : t('reviewTip.form.submit', { amount: params.amount })}
|
||||||
|
</button>
|
||||||
|
{params.onCancel ? (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={params.ctrl.loading}
|
onClick={params.onCancel}
|
||||||
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
|
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
|
||||||
>
|
>
|
||||||
{params.ctrl.loading ? t('common.loading') : t('reviewTip.form.submit', { amount: params.ctrl.split.total })}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
{params.onCancel ? (
|
) : null}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
|
||||||
onClick={params.onCancel}
|
|
||||||
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ interface EditState {
|
|||||||
articleId: string | null
|
articleId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useArticleEditing(authorPubkey: string | null): {
|
type UseArticleEditingResult = {
|
||||||
editingDraft: ArticleDraft | null
|
editingDraft: ArticleDraft | null
|
||||||
editingArticleId: string | null
|
editingArticleId: string | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@ -20,95 +20,25 @@ export function useArticleEditing(authorPubkey: string | null): {
|
|||||||
submitEdit: () => Promise<ArticleUpdateResult | null>
|
submitEdit: () => Promise<ArticleUpdateResult | null>
|
||||||
deleteArticle: (articleId: string) => Promise<boolean>
|
deleteArticle: (articleId: string) => Promise<boolean>
|
||||||
updateDraft: (draft: ArticleDraft | null) => void
|
updateDraft: (draft: ArticleDraft | null) => void
|
||||||
} {
|
}
|
||||||
|
|
||||||
|
export function useArticleEditing(authorPubkey: string | null): UseArticleEditingResult {
|
||||||
const [state, setState] = useState<EditState>({ draft: null, articleId: null })
|
const [state, setState] = useState<EditState>({ draft: null, articleId: null })
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const updateDraft = (draft: ArticleDraft | null): void => {
|
const updateDraft = (draft: ArticleDraft | null): void => setState((prev) => ({ ...prev, draft }))
|
||||||
setState((prev) => ({ ...prev, draft }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const startEditing = async (article: Article): Promise<void> => {
|
const startEditing = (article: Article): Promise<void> =>
|
||||||
if (!authorPubkey) {
|
startEditingArticle({ authorPubkey, article, setState, setLoading, setError })
|
||||||
setError('Connect your Nostr wallet to edit')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const stored = await getStoredContent(article.id)
|
|
||||||
if (!stored) {
|
|
||||||
setError('Private content not available locally. Please republish from original device.')
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setState({
|
|
||||||
articleId: article.id,
|
|
||||||
draft: {
|
|
||||||
title: article.title,
|
|
||||||
preview: article.preview,
|
|
||||||
content: stored.content,
|
|
||||||
zapAmount: article.zapAmount,
|
|
||||||
...(article.category === 'science-fiction' || article.category === 'scientific-research'
|
|
||||||
? { category: article.category }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load draft')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelEditing = (): void => {
|
const cancelEditing = (): void => resetEditingState({ setState, setError })
|
||||||
setState({ draft: null, articleId: null })
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitEdit = async (): Promise<ArticleUpdateResult | null> => {
|
const submitEdit = (): Promise<ArticleUpdateResult | null> =>
|
||||||
if (!authorPubkey || !state.articleId || !state.draft) {
|
submitArticleEdit({ authorPubkey, state, setState, setLoading, setError })
|
||||||
setError('Missing data for update')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const privateKey = nostrService.getPrivateKey() ?? undefined
|
|
||||||
const result = await publishArticleUpdate(state.articleId, state.draft, authorPubkey, privateKey)
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error ?? 'Update failed')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Update failed')
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
setState({ draft: null, articleId: null })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteArticle = async (articleId: string): Promise<boolean> => {
|
const deleteArticle = (articleId: string): Promise<boolean> =>
|
||||||
if (!authorPubkey) {
|
deleteArticleById({ authorPubkey, articleId, setLoading, setError })
|
||||||
setError('Connect your Nostr wallet to delete')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const privateKey = nostrService.getPrivateKey() ?? undefined
|
|
||||||
await deleteArticleEvent(articleId, authorPubkey, privateKey)
|
|
||||||
return true
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Delete failed')
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
editingDraft: state.draft,
|
editingDraft: state.draft,
|
||||||
@ -122,3 +52,102 @@ export function useArticleEditing(authorPubkey: string | null): {
|
|||||||
updateDraft,
|
updateDraft,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetEditingState(params: {
|
||||||
|
setState: (value: EditState) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): void {
|
||||||
|
params.setState({ draft: null, articleId: null })
|
||||||
|
params.setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startEditingArticle(params: {
|
||||||
|
authorPubkey: string | null
|
||||||
|
article: Article
|
||||||
|
setState: (value: EditState) => void
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!params.authorPubkey) {
|
||||||
|
params.setError('Connect your Nostr wallet to edit')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setError(null)
|
||||||
|
try {
|
||||||
|
const stored = await getStoredContent(params.article.id)
|
||||||
|
if (!stored) {
|
||||||
|
params.setError('Private content not available locally. Please republish from original device.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.setState({ articleId: params.article.id, draft: buildDraftForEdit(params.article, stored.content) })
|
||||||
|
} catch (e) {
|
||||||
|
params.setError(e instanceof Error ? e.message : 'Failed to load draft')
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDraftForEdit(article: Article, content: string): ArticleDraft {
|
||||||
|
return {
|
||||||
|
title: article.title,
|
||||||
|
preview: article.preview,
|
||||||
|
content,
|
||||||
|
zapAmount: article.zapAmount,
|
||||||
|
...(article.category === 'science-fiction' || article.category === 'scientific-research' ? { category: article.category } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitArticleEdit(params: {
|
||||||
|
authorPubkey: string | null
|
||||||
|
state: EditState
|
||||||
|
setState: (value: EditState) => void
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): Promise<ArticleUpdateResult | null> {
|
||||||
|
if (!params.authorPubkey || !params.state.articleId || !params.state.draft) {
|
||||||
|
params.setError('Missing data for update')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setError(null)
|
||||||
|
try {
|
||||||
|
const privateKey = nostrService.getPrivateKey() ?? undefined
|
||||||
|
const result = await publishArticleUpdate(params.state.articleId, params.state.draft, params.authorPubkey, privateKey)
|
||||||
|
if (!result.success) {
|
||||||
|
params.setError(result.error ?? 'Update failed')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
params.setError(e instanceof Error ? e.message : 'Update failed')
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
params.setState({ draft: null, articleId: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteArticleById(params: {
|
||||||
|
authorPubkey: string | null
|
||||||
|
articleId: string
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (!params.authorPubkey) {
|
||||||
|
params.setError('Connect your Nostr wallet to delete')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setError(null)
|
||||||
|
try {
|
||||||
|
const privateKey = nostrService.getPrivateKey() ?? undefined
|
||||||
|
await deleteArticleEvent(params.articleId, params.authorPubkey, privateKey)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
params.setError(e instanceof Error ? e.message : 'Delete failed')
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -4,99 +4,50 @@ import type { AlbyInvoice } from '@/types/alby'
|
|||||||
import { paymentService } from '@/lib/payment'
|
import { paymentService } from '@/lib/payment'
|
||||||
import { nostrService } from '@/lib/nostr'
|
import { nostrService } from '@/lib/nostr'
|
||||||
|
|
||||||
export function useArticlePayment(
|
type UseArticlePaymentResult = {
|
||||||
article: Article,
|
|
||||||
pubkey: string | null,
|
|
||||||
onUnlockSuccess?: () => void,
|
|
||||||
connect?: () => Promise<void>
|
|
||||||
): {
|
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
paymentInvoice: AlbyInvoice | null
|
paymentInvoice: AlbyInvoice | null
|
||||||
handleUnlock: () => Promise<void>
|
handleUnlock: () => Promise<void>
|
||||||
handlePaymentComplete: () => Promise<void>
|
handlePaymentComplete: () => Promise<void>
|
||||||
handleCloseModal: () => void
|
handleCloseModal: () => void
|
||||||
} {
|
}
|
||||||
|
|
||||||
|
export function useArticlePayment(
|
||||||
|
article: Article,
|
||||||
|
pubkey: string | null,
|
||||||
|
onUnlockSuccess?: () => void,
|
||||||
|
connect?: () => Promise<void>
|
||||||
|
): UseArticlePaymentResult {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [paymentInvoice, setPaymentInvoice] = useState<AlbyInvoice | null>(null)
|
const [paymentInvoice, setPaymentInvoice] = useState<AlbyInvoice | null>(null)
|
||||||
const [paymentHash, setPaymentHash] = useState<string | null>(null)
|
const [paymentHash, setPaymentHash] = useState<string | null>(null)
|
||||||
|
|
||||||
const checkPaymentStatus = async (hash: string, userPubkey: string): Promise<void> => {
|
const handleUnlock = (): Promise<void> =>
|
||||||
try {
|
unlockArticlePayment({
|
||||||
const hasPaid = await paymentService.waitForArticlePayment({
|
article,
|
||||||
paymentHash: hash,
|
pubkey,
|
||||||
articleId: article.id,
|
connect,
|
||||||
articlePubkey: article.pubkey,
|
onUnlockSuccess,
|
||||||
amount: article.zapAmount,
|
setLoading,
|
||||||
recipientPubkey: userPubkey,
|
setError,
|
||||||
timeout: 300000,
|
setPaymentInvoice,
|
||||||
})
|
setPaymentHash,
|
||||||
|
})
|
||||||
|
|
||||||
if (hasPaid) {
|
const handlePaymentComplete = (): Promise<void> =>
|
||||||
const content = await nostrService.getPrivateContent(article.id, article.pubkey)
|
checkPaymentAndUnlock({
|
||||||
if (content) {
|
article,
|
||||||
setPaymentInvoice(null)
|
pubkey,
|
||||||
setPaymentHash(null)
|
paymentHash,
|
||||||
onUnlockSuccess?.()
|
onUnlockSuccess,
|
||||||
} else {
|
setError,
|
||||||
setError('Content not available. Please contact the author.')
|
setPaymentInvoice,
|
||||||
}
|
setPaymentHash,
|
||||||
}
|
})
|
||||||
} catch (e) {
|
|
||||||
console.error('Payment check error:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUnlock = async (): Promise<void> => {
|
const handleCloseModal = (): void => resetPaymentModalState({ setPaymentInvoice, setPaymentHash })
|
||||||
if (!pubkey) {
|
|
||||||
if (connect) {
|
|
||||||
setLoading(true)
|
|
||||||
await connect()
|
|
||||||
setLoading(false)
|
|
||||||
} else {
|
|
||||||
setError('Please connect with Nostr first')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const paymentResult = await paymentService.createArticlePayment({
|
|
||||||
article,
|
|
||||||
userPubkey: pubkey,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!paymentResult.success || !paymentResult.invoice || !paymentResult.paymentHash) {
|
|
||||||
setError(paymentResult.error ?? 'Failed to create payment invoice')
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setPaymentInvoice(paymentResult.invoice)
|
|
||||||
setPaymentHash(paymentResult.paymentHash)
|
|
||||||
setLoading(false)
|
|
||||||
void checkPaymentStatus(paymentResult.paymentHash, pubkey)
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : 'Failed to process payment'
|
|
||||||
console.error('Payment processing error:', e)
|
|
||||||
setError(errorMessage)
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePaymentComplete = async (): Promise<void> => {
|
|
||||||
if (paymentHash && pubkey) {
|
|
||||||
await checkPaymentStatus(paymentHash, pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCloseModal = (): void => {
|
|
||||||
setPaymentInvoice(null)
|
|
||||||
setPaymentHash(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
@ -107,3 +58,114 @@ export function useArticlePayment(
|
|||||||
handleCloseModal,
|
handleCloseModal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function unlockArticlePayment(params: {
|
||||||
|
article: Article
|
||||||
|
pubkey: string | null
|
||||||
|
connect: (() => Promise<void>) | undefined
|
||||||
|
onUnlockSuccess: (() => void) | undefined
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setPaymentInvoice: (value: AlbyInvoice | null) => void
|
||||||
|
setPaymentHash: (value: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!params.pubkey) {
|
||||||
|
await ensureConnectedOrError({
|
||||||
|
connect: params.connect,
|
||||||
|
setLoading: params.setLoading,
|
||||||
|
setError: params.setError,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setError(null)
|
||||||
|
try {
|
||||||
|
const paymentResult = await paymentService.createArticlePayment({
|
||||||
|
article: params.article,
|
||||||
|
userPubkey: params.pubkey,
|
||||||
|
})
|
||||||
|
if (!paymentResult.success || !paymentResult.invoice || !paymentResult.paymentHash) {
|
||||||
|
params.setError(paymentResult.error ?? 'Failed to create payment invoice')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.setPaymentInvoice(paymentResult.invoice)
|
||||||
|
params.setPaymentHash(paymentResult.paymentHash)
|
||||||
|
void checkPaymentAndUnlock({
|
||||||
|
article: params.article,
|
||||||
|
pubkey: params.pubkey,
|
||||||
|
paymentHash: paymentResult.paymentHash,
|
||||||
|
onUnlockSuccess: params.onUnlockSuccess,
|
||||||
|
setError: params.setError,
|
||||||
|
setPaymentInvoice: params.setPaymentInvoice,
|
||||||
|
setPaymentHash: params.setPaymentHash,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : 'Failed to process payment'
|
||||||
|
console.error('Payment processing error:', e)
|
||||||
|
params.setError(errorMessage)
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureConnectedOrError(params: {
|
||||||
|
connect: (() => Promise<void>) | undefined
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!params.connect) {
|
||||||
|
params.setError('Please connect with Nostr first')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.setLoading(true)
|
||||||
|
try {
|
||||||
|
await params.connect()
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPaymentAndUnlock(params: {
|
||||||
|
article: Article
|
||||||
|
pubkey: string | null
|
||||||
|
paymentHash: string | null
|
||||||
|
onUnlockSuccess: (() => void) | undefined
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setPaymentInvoice: (value: AlbyInvoice | null) => void
|
||||||
|
setPaymentHash: (value: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!params.paymentHash || !params.pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const hasPaid = await paymentService.waitForArticlePayment({
|
||||||
|
paymentHash: params.paymentHash,
|
||||||
|
articleId: params.article.id,
|
||||||
|
articlePubkey: params.article.pubkey,
|
||||||
|
amount: params.article.zapAmount,
|
||||||
|
recipientPubkey: params.pubkey,
|
||||||
|
timeout: 300000,
|
||||||
|
})
|
||||||
|
if (!hasPaid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const content = await nostrService.getPrivateContent(params.article.id, params.article.pubkey)
|
||||||
|
if (!content) {
|
||||||
|
params.setError('Content not available. Please contact the author.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resetPaymentModalState({ setPaymentInvoice: params.setPaymentInvoice, setPaymentHash: params.setPaymentHash })
|
||||||
|
params.onUnlockSuccess?.()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Payment check error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPaymentModalState(params: {
|
||||||
|
setPaymentInvoice: (value: AlbyInvoice | null) => void
|
||||||
|
setPaymentHash: (value: string | null) => void
|
||||||
|
}): void {
|
||||||
|
params.setPaymentInvoice(null)
|
||||||
|
params.setPaymentHash(null)
|
||||||
|
}
|
||||||
|
|||||||
@ -12,118 +12,27 @@ interface AuthorPresentationDraft {
|
|||||||
pictureUrl?: string
|
pictureUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuthorPresentation(pubkey: string | null): {
|
type UseAuthorPresentationResult = {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
success: boolean
|
success: boolean
|
||||||
publishPresentation: (draft: AuthorPresentationDraft) => Promise<void>
|
publishPresentation: (draft: AuthorPresentationDraft) => Promise<void>
|
||||||
checkPresentationExists: () => Promise<Article | null>
|
checkPresentationExists: () => Promise<Article | null>
|
||||||
deletePresentation: (articleId: string) => Promise<void>
|
deletePresentation: (articleId: string) => Promise<void>
|
||||||
} {
|
}
|
||||||
|
|
||||||
|
export function useAuthorPresentation(pubkey: string | null): UseAuthorPresentationResult {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
const publishPresentation = async (draft: AuthorPresentationDraft): Promise<void> => {
|
const publishPresentation = (draft: AuthorPresentationDraft): Promise<void> =>
|
||||||
if (!pubkey) {
|
publishAuthorPresentation({ pubkey, draft, setLoading, setError, setSuccess })
|
||||||
setError('Clé publique non disponible')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
const checkPresentationExists = (): Promise<Article | null> => checkPresentation({ pubkey })
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
const deletePresentation = (articleId: string): Promise<void> =>
|
||||||
const privateKey = nostrService.getPrivateKey()
|
deleteAuthorPresentation({ pubkey, articleId, setLoading, setError })
|
||||||
if (!privateKey) {
|
|
||||||
setError('Clé privée requise pour publier. Veuillez vous connecter avec un portefeuille Nostr qui fournit des capacités de signature.')
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Nostr profile (kind 0) with author name and picture
|
|
||||||
const profileUpdates: Partial<NostrProfile> = {
|
|
||||||
name: draft.authorName.trim(),
|
|
||||||
...(draft.pictureUrl ? { picture: draft.pictureUrl } : {}),
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await nostrService.updateProfile(profileUpdates)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error updating profile:', e)
|
|
||||||
// Continue with article publication even if profile update fails
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create presentation article
|
|
||||||
const title = `Présentation de ${draft.authorName.trim()}`
|
|
||||||
const preview = draft.presentation.substring(0, 200)
|
|
||||||
const fullContent = `${draft.presentation}\n\n---\n\nDescription du contenu :\n${draft.contentDescription}`
|
|
||||||
|
|
||||||
const result = await articlePublisher.publishPresentationArticle(
|
|
||||||
{
|
|
||||||
title,
|
|
||||||
preview,
|
|
||||||
content: fullContent,
|
|
||||||
presentation: draft.presentation,
|
|
||||||
contentDescription: draft.contentDescription,
|
|
||||||
mainnetAddress: draft.mainnetAddress,
|
|
||||||
...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
|
|
||||||
},
|
|
||||||
pubkey,
|
|
||||||
privateKey
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setSuccess(true)
|
|
||||||
} else {
|
|
||||||
setError(result.error ?? 'Erreur lors de la publication')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : 'Erreur inconnue'
|
|
||||||
console.error('Error publishing presentation:', e)
|
|
||||||
setError(errorMessage)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkPresentationExists = async (): Promise<Article | null> => {
|
|
||||||
if (!pubkey) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return articlePublisher.getAuthorPresentation(pubkey)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error checking presentation:', e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletePresentation = async (articleId: string): Promise<void> => {
|
|
||||||
if (!pubkey) {
|
|
||||||
throw new Error('Clé publique non disponible')
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const privateKey = nostrService.getPrivateKey()
|
|
||||||
if (!privateKey) {
|
|
||||||
throw new Error('Clé privée requise pour supprimer. Veuillez vous connecter avec un portefeuille Nostr qui fournit des capacités de signature.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const { deleteArticleEvent } = await import('@/lib/articleMutations')
|
|
||||||
await deleteArticleEvent(articleId, pubkey, privateKey)
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : 'Erreur inconnue'
|
|
||||||
console.error('Error deleting presentation:', e)
|
|
||||||
setError(errorMessage)
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
@ -134,3 +43,114 @@ export function useAuthorPresentation(pubkey: string | null): {
|
|||||||
deletePresentation,
|
deletePresentation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function publishAuthorPresentation(params: {
|
||||||
|
pubkey: string | null
|
||||||
|
draft: AuthorPresentationDraft
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setSuccess: (value: boolean) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!params.pubkey) {
|
||||||
|
params.setError('Clé publique non disponible')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setError(null)
|
||||||
|
try {
|
||||||
|
const privateKey = getPrivateKeyOrThrow('Clé privée requise pour publier. Veuillez vous connecter avec un portefeuille Nostr qui fournit des capacités de signature.')
|
||||||
|
await updateProfileBestEffort(params.draft)
|
||||||
|
const { title, preview, fullContent } = buildPresentationContent(params.draft)
|
||||||
|
const result = await articlePublisher.publishPresentationArticle(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
preview,
|
||||||
|
content: fullContent,
|
||||||
|
presentation: params.draft.presentation,
|
||||||
|
contentDescription: params.draft.contentDescription,
|
||||||
|
mainnetAddress: params.draft.mainnetAddress,
|
||||||
|
...(params.draft.pictureUrl ? { pictureUrl: params.draft.pictureUrl } : {}),
|
||||||
|
},
|
||||||
|
params.pubkey,
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
params.setSuccess(true)
|
||||||
|
} else {
|
||||||
|
params.setError(result.error ?? 'Erreur lors de la publication')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : 'Erreur inconnue'
|
||||||
|
console.error('Error publishing presentation:', e)
|
||||||
|
params.setError(errorMessage)
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfileBestEffort(draft: AuthorPresentationDraft): Promise<void> {
|
||||||
|
const profileUpdates: Partial<NostrProfile> = {
|
||||||
|
name: draft.authorName.trim(),
|
||||||
|
...(draft.pictureUrl ? { picture: draft.pictureUrl } : {}),
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await nostrService.updateProfile(profileUpdates)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating profile:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPresentationContent(draft: AuthorPresentationDraft): { title: string; preview: string; fullContent: string } {
|
||||||
|
const title = `Présentation de ${draft.authorName.trim()}`
|
||||||
|
const preview = draft.presentation.substring(0, 200)
|
||||||
|
const fullContent = `${draft.presentation}\n\n---\n\nDescription du contenu :\n${draft.contentDescription}`
|
||||||
|
return { title, preview, fullContent }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPresentation(params: { pubkey: string | null }): Promise<Article | null> {
|
||||||
|
if (!params.pubkey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return articlePublisher.getAuthorPresentation(params.pubkey)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error checking presentation:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAuthorPresentation(params: {
|
||||||
|
pubkey: string | null
|
||||||
|
articleId: string
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!params.pubkey) {
|
||||||
|
throw new Error('Clé publique non disponible')
|
||||||
|
}
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setError(null)
|
||||||
|
try {
|
||||||
|
const privateKey = getPrivateKeyOrThrow(
|
||||||
|
'Clé privée requise pour supprimer. Veuillez vous connecter avec un portefeuille Nostr qui fournit des capacités de signature.'
|
||||||
|
)
|
||||||
|
const { deleteArticleEvent } = await import('@/lib/articleMutations')
|
||||||
|
await deleteArticleEvent(params.articleId, params.pubkey, privateKey)
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : 'Erreur inconnue'
|
||||||
|
console.error('Error deleting presentation:', e)
|
||||||
|
params.setError(errorMessage)
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrivateKeyOrThrow(message: string): string {
|
||||||
|
const privateKey = nostrService.getPrivateKey()
|
||||||
|
if (!privateKey) {
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
return privateKey
|
||||||
|
}
|
||||||
|
|||||||
@ -16,43 +16,51 @@ export function useAuthorsProfiles(authorPubkeys: string[]): {
|
|||||||
const pubkeysKey = useMemo(() => [...authorPubkeys].sort().join(','), [authorPubkeys])
|
const pubkeysKey = useMemo(() => [...authorPubkeys].sort().join(','), [authorPubkeys])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadProfiles = async (): Promise<void> => {
|
void loadAndSetProfiles({ authorPubkeys, setProfiles, setLoading })
|
||||||
if (authorPubkeys.length === 0) {
|
|
||||||
setProfiles(new Map())
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
const profilesMap = new Map<string, AuthorProfile>()
|
|
||||||
|
|
||||||
const profilePromises = authorPubkeys.map(async (pubkey) => {
|
|
||||||
try {
|
|
||||||
const profile = await nostrService.getProfile(pubkey)
|
|
||||||
return {
|
|
||||||
pubkey,
|
|
||||||
profile: profile ?? { pubkey },
|
|
||||||
}
|
|
||||||
} catch (loadError) {
|
|
||||||
console.error(`Error loading profile for ${pubkey}:`, loadError)
|
|
||||||
return {
|
|
||||||
pubkey,
|
|
||||||
profile: { pubkey },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const results = await Promise.all(profilePromises)
|
|
||||||
results.forEach(({ pubkey, profile }) => {
|
|
||||||
profilesMap.set(pubkey, profile)
|
|
||||||
})
|
|
||||||
|
|
||||||
setProfiles(profilesMap)
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadProfiles()
|
|
||||||
}, [pubkeysKey, authorPubkeys])
|
}, [pubkeysKey, authorPubkeys])
|
||||||
|
|
||||||
return { profiles, loading }
|
return { profiles, loading }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAndSetProfiles(params: {
|
||||||
|
authorPubkeys: string[]
|
||||||
|
setProfiles: (value: Map<string, AuthorProfile>) => void
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
if (params.authorPubkeys.length === 0) {
|
||||||
|
params.setProfiles(new Map())
|
||||||
|
params.setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params.setLoading(true)
|
||||||
|
const profilesMap = await loadProfilesMap(params.authorPubkeys)
|
||||||
|
params.setProfiles(profilesMap)
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfilesMap(authorPubkeys: string[]): Promise<Map<string, AuthorProfile>> {
|
||||||
|
const results = await Promise.all(authorPubkeys.map(loadSingleProfile))
|
||||||
|
const map = new Map<string, AuthorProfile>()
|
||||||
|
results.forEach(({ pubkey, profile }) => {
|
||||||
|
map.set(pubkey, profile)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSingleProfile(pubkey: string): Promise<{ pubkey: string; profile: AuthorProfile }> {
|
||||||
|
try {
|
||||||
|
const profile = await nostrService.getProfile(pubkey)
|
||||||
|
return { pubkey, profile: ensureAuthorProfile(pubkey, profile) }
|
||||||
|
} catch (loadError) {
|
||||||
|
console.error(`Error loading profile for ${pubkey}:`, loadError)
|
||||||
|
return { pubkey, profile: { pubkey } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAuthorProfile(pubkey: string, profile: NostrProfile | null): AuthorProfile {
|
||||||
|
if (!profile) {
|
||||||
|
return { pubkey }
|
||||||
|
}
|
||||||
|
return { ...profile, pubkey }
|
||||||
|
}
|
||||||
|
|||||||
@ -19,35 +19,15 @@ export function useDocs(docs: DocLink[]): {
|
|||||||
const [docContent, setDocContent] = useState<string>('')
|
const [docContent, setDocContent] = useState<string>('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const loadDoc = useCallback(async (docId: DocSection): Promise<void> => {
|
const loadDoc = useCallback(
|
||||||
const doc = docs.find((d) => d.id === docId)
|
createLoadDoc({
|
||||||
if (!doc) {
|
docs,
|
||||||
return
|
setLoading,
|
||||||
}
|
setSelectedDoc,
|
||||||
|
setDocContent,
|
||||||
setLoading(true)
|
}),
|
||||||
setSelectedDoc(docId)
|
[docs]
|
||||||
|
)
|
||||||
try {
|
|
||||||
// Get current locale and pass it to the API
|
|
||||||
const locale = getLocale()
|
|
||||||
const response = await globalThis.fetch(`/api/docs/${doc.file}?locale=${locale}`)
|
|
||||||
if (response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
setDocContent(text)
|
|
||||||
} else {
|
|
||||||
// Import t dynamically to avoid circular dependency
|
|
||||||
const { t } = await import('@/lib/i18n')
|
|
||||||
setDocContent(`# ${t('docs.error')}\n\n${t('docs.error.loadFailed')}`)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Import t dynamically to avoid circular dependency
|
|
||||||
const { t } = await import('@/lib/i18n')
|
|
||||||
setDocContent(`# ${t('docs.error')}\n\n${t('docs.error.loadFailed')}`)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [docs])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadDoc('user-guide')
|
void loadDoc('user-guide')
|
||||||
@ -60,3 +40,45 @@ export function useDocs(docs: DocLink[]): {
|
|||||||
loadDoc,
|
loadDoc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createLoadDoc(params: {
|
||||||
|
docs: DocLink[]
|
||||||
|
setSelectedDoc: (doc: DocSection) => void
|
||||||
|
setDocContent: (value: string) => void
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
}): (docId: DocSection) => Promise<void> {
|
||||||
|
return async (docId: DocSection): Promise<void> => {
|
||||||
|
const doc = params.docs.find((d) => d.id === docId)
|
||||||
|
if (!doc) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setSelectedDoc(docId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await fetchDocContent(doc.file)
|
||||||
|
params.setDocContent(text)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useDocs] Error loading doc:', error)
|
||||||
|
params.setDocContent(await buildDocLoadErrorMarkdown())
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDocContent(docFile: string): Promise<string> {
|
||||||
|
const locale = getLocale()
|
||||||
|
const response = await globalThis.fetch(`/api/docs/${docFile}?locale=${locale}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('DOC_FETCH_FAILED')
|
||||||
|
}
|
||||||
|
return response.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildDocLoadErrorMarkdown(): Promise<string> {
|
||||||
|
// Import t dynamically to avoid circular dependency
|
||||||
|
const { t } = await import('@/lib/i18n')
|
||||||
|
return `# ${t('docs.error')}\n\n${t('docs.error.loadFailed')}`
|
||||||
|
}
|
||||||
|
|||||||
@ -10,45 +10,66 @@ export function useI18n(locale: Locale = 'fr'): {
|
|||||||
const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale())
|
const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async (): Promise<void> => {
|
void initializeI18n({
|
||||||
try {
|
locale,
|
||||||
// Get saved locale from IndexedDB or use provided locale
|
setLoaded,
|
||||||
let savedLocale: Locale | null = null
|
setCurrentLocale,
|
||||||
try {
|
})
|
||||||
// Migrate from localStorage if needed
|
|
||||||
const { localeStorage } = await import('@/lib/localeStorage')
|
|
||||||
await localeStorage.migrateFromLocalStorage()
|
|
||||||
// Load from IndexedDB
|
|
||||||
savedLocale = await localeStorage.getLocale()
|
|
||||||
} catch {
|
|
||||||
// Fallback to provided locale
|
|
||||||
}
|
|
||||||
const initialLocale = savedLocale && (savedLocale === 'fr' || savedLocale === 'en') ? savedLocale : locale
|
|
||||||
|
|
||||||
// Load translations from files in public directory
|
|
||||||
const frResponse = await globalThis.fetch('/locales/fr.txt')
|
|
||||||
const enResponse = await globalThis.fetch('/locales/en.txt')
|
|
||||||
|
|
||||||
if (frResponse.ok) {
|
|
||||||
const frText = await frResponse.text()
|
|
||||||
loadTranslations('fr', frText)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enResponse.ok) {
|
|
||||||
const enText = await enResponse.text()
|
|
||||||
loadTranslations('en', enText)
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocale(initialLocale)
|
|
||||||
setCurrentLocale(initialLocale)
|
|
||||||
setLoaded(true)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error loading translations:', e)
|
|
||||||
setLoaded(true) // Continue even if translations fail to load
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
}, [locale])
|
}, [locale])
|
||||||
|
|
||||||
return { loaded, locale: currentLocale, t }
|
return { loaded, locale: currentLocale, t }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initializeI18n(params: {
|
||||||
|
locale: Locale
|
||||||
|
setLoaded: (value: boolean) => void
|
||||||
|
setCurrentLocale: (value: Locale) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const savedLocale = await readSavedLocale()
|
||||||
|
const initialLocale = isSupportedLocale(savedLocale) ? savedLocale : params.locale
|
||||||
|
await loadAllTranslations()
|
||||||
|
setLocale(initialLocale)
|
||||||
|
params.setCurrentLocale(initialLocale)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading translations:', e)
|
||||||
|
} finally {
|
||||||
|
params.setLoaded(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedLocale(value: unknown): value is Locale {
|
||||||
|
return value === 'fr' || value === 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSavedLocale(): Promise<Locale | null> {
|
||||||
|
try {
|
||||||
|
const { localeStorage } = await import('@/lib/localeStorage')
|
||||||
|
await localeStorage.migrateFromLocalStorage()
|
||||||
|
return localeStorage.getLocale()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllTranslations(): Promise<void> {
|
||||||
|
const [frText, enText] = await Promise.all([
|
||||||
|
fetchTranslationText('/locales/fr.txt'),
|
||||||
|
fetchTranslationText('/locales/en.txt'),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (frText) {
|
||||||
|
void loadTranslations('fr', frText)
|
||||||
|
}
|
||||||
|
if (enText) {
|
||||||
|
void loadTranslations('en', enText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTranslationText(url: string): Promise<string | undefined> {
|
||||||
|
const response = await globalThis.fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return response.text()
|
||||||
|
}
|
||||||
|
|||||||
@ -2,52 +2,84 @@ import { useState, useEffect } from 'react'
|
|||||||
import { nostrAuthService } from '@/lib/nostrAuth'
|
import { nostrAuthService } from '@/lib/nostrAuth'
|
||||||
import type { NostrConnectState } from '@/types/nostr'
|
import type { NostrConnectState } from '@/types/nostr'
|
||||||
|
|
||||||
export function useNostrAuth(): NostrConnectState & {
|
type UseNostrAuthResult = NostrConnectState & {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
connect: () => Promise<void>
|
connect: () => Promise<void>
|
||||||
disconnect: () => Promise<void>
|
disconnect: () => Promise<void>
|
||||||
accountExists: boolean | null
|
accountExists: boolean | null
|
||||||
isUnlocked: boolean
|
isUnlocked: boolean
|
||||||
} {
|
}
|
||||||
|
|
||||||
|
function useAuthState(): NostrConnectState {
|
||||||
const [state, setState] = useState<NostrConnectState>(nostrAuthService.getState())
|
const [state, setState] = useState<NostrConnectState>(nostrAuthService.getState())
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [accountExists, setAccountExists] = useState<boolean | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = nostrAuthService.subscribe((newState) => {
|
const unsubscribe = nostrAuthService.subscribe(setState)
|
||||||
setState(newState)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check if account exists on mount
|
|
||||||
nostrAuthService.accountExists().then(setAccountExists).catch(() => setAccountExists(false))
|
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [])
|
}, [])
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
const connect = async (): Promise<void> => {
|
function useAccountExistsStatus(): boolean | null {
|
||||||
setLoading(true)
|
const [accountExists, setAccountExists] = useState<boolean | null>(null)
|
||||||
setError(null)
|
useEffect(() => {
|
||||||
try {
|
const load = async (): Promise<void> => {
|
||||||
await nostrAuthService.connect()
|
try {
|
||||||
} catch (e) {
|
setAccountExists(await nostrAuthService.accountExists())
|
||||||
setError(e instanceof Error ? e.message : 'Connection failed')
|
} catch {
|
||||||
} finally {
|
setAccountExists(false)
|
||||||
setLoading(false)
|
}
|
||||||
}
|
}
|
||||||
}
|
void load()
|
||||||
|
}, [])
|
||||||
|
return accountExists
|
||||||
|
}
|
||||||
|
|
||||||
const disconnect = async (): Promise<void> => {
|
function createAuthActions(params: {
|
||||||
setLoading(true)
|
setLoading: (next: boolean) => void
|
||||||
try {
|
setError: (next: string | null) => void
|
||||||
nostrAuthService.disconnect()
|
}): { connect: () => Promise<void>; disconnect: () => Promise<void> } {
|
||||||
} catch (e) {
|
return {
|
||||||
setError(e instanceof Error ? e.message : 'Disconnection failed')
|
connect: () => connectAuth(params),
|
||||||
} finally {
|
disconnect: () => disconnectAuth(params),
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectAuth(params: {
|
||||||
|
setLoading: (next: boolean) => void
|
||||||
|
setError: (next: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setError(null)
|
||||||
|
try {
|
||||||
|
await nostrAuthService.connect()
|
||||||
|
} catch (e) {
|
||||||
|
params.setError(e instanceof Error ? e.message : 'Connection failed')
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectAuth(params: {
|
||||||
|
setLoading: (next: boolean) => void
|
||||||
|
setError: (next: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
params.setLoading(true)
|
||||||
|
try {
|
||||||
|
nostrAuthService.disconnect()
|
||||||
|
} catch (e) {
|
||||||
|
params.setError(e instanceof Error ? e.message : 'Disconnection failed')
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNostrAuth(): UseNostrAuthResult {
|
||||||
|
const state = useAuthState()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const accountExists = useAccountExistsStatus()
|
||||||
|
const { connect, disconnect } = createAuthActions({ setLoading, setError })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import { notificationService } from '@/lib/notificationService'
|
import { notificationService } from '@/lib/notificationService'
|
||||||
import type { Notification } from '@/lib/notificationService'
|
import type { Notification } from '@/lib/notificationService'
|
||||||
|
|
||||||
|
const MAX_NOTIFICATIONS = 100
|
||||||
|
const POLL_INTERVAL_MS = 30_000
|
||||||
|
|
||||||
export function useNotifications(userPubkey: string | null): {
|
export function useNotifications(userPubkey: string | null): {
|
||||||
notifications: Notification[]
|
notifications: Notification[]
|
||||||
unreadCount: number
|
unreadCount: number
|
||||||
@ -13,55 +16,28 @@ export function useNotifications(userPubkey: string | null): {
|
|||||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
// Load stored notifications on mount and refresh periodically
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userPubkey) {
|
if (!userPubkey) {
|
||||||
setNotifications([])
|
|
||||||
setLoading(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadNotifications = async (): Promise<void> => {
|
void loadAndSetNotifications({ setNotifications, setLoading })
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
const storedNotifications = await notificationService.getAllNotifications(100)
|
|
||||||
setNotifications(storedNotifications)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[useNotifications] Error loading notifications:', error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadNotifications()
|
|
||||||
|
|
||||||
// Refresh notifications every 30 seconds
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
void loadNotifications()
|
void loadAndSetNotifications({ setNotifications, setLoading })
|
||||||
}, 30000)
|
}, POLL_INTERVAL_MS)
|
||||||
|
return () => clearInterval(interval)
|
||||||
return () => {
|
|
||||||
clearInterval(interval)
|
|
||||||
}
|
|
||||||
}, [userPubkey])
|
}, [userPubkey])
|
||||||
|
|
||||||
const unreadCount = notifications.filter((n) => !n.read).length
|
const effectiveNotifications = userPubkey ? notifications : []
|
||||||
|
const effectiveLoading = userPubkey ? loading : false
|
||||||
|
const unreadCount = effectiveNotifications.filter((n) => !n.read).length
|
||||||
|
|
||||||
const markAsRead = useCallback(
|
const markAsRead = useCallback(
|
||||||
(notificationId: string): void => {
|
(notificationId: string): void => {
|
||||||
if (!userPubkey) {
|
if (!userPubkey) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
void markAsReadAndRefresh({ notificationId, setNotifications })
|
||||||
void (async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await notificationService.markAsRead(notificationId)
|
|
||||||
const storedNotifications = await notificationService.getAllNotifications(100)
|
|
||||||
setNotifications(storedNotifications)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[useNotifications] Error marking notification as read:', error)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
},
|
},
|
||||||
[userPubkey]
|
[userPubkey]
|
||||||
)
|
)
|
||||||
@ -70,16 +46,7 @@ export function useNotifications(userPubkey: string | null): {
|
|||||||
if (!userPubkey) {
|
if (!userPubkey) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
void markAllAsReadAndRefresh({ setNotifications })
|
||||||
void (async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await notificationService.markAllAsRead()
|
|
||||||
const storedNotifications = await notificationService.getAllNotifications(100)
|
|
||||||
setNotifications(storedNotifications)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[useNotifications] Error marking all as read:', error)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, [userPubkey])
|
}, [userPubkey])
|
||||||
|
|
||||||
const deleteNotificationHandler = useCallback(
|
const deleteNotificationHandler = useCallback(
|
||||||
@ -87,26 +54,70 @@ export function useNotifications(userPubkey: string | null): {
|
|||||||
if (!userPubkey) {
|
if (!userPubkey) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
void deleteNotificationAndRefresh({ notificationId, setNotifications })
|
||||||
void (async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await notificationService.deleteNotification(notificationId)
|
|
||||||
const storedNotifications = await notificationService.getAllNotifications(100)
|
|
||||||
setNotifications(storedNotifications)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[useNotifications] Error deleting notification:', error)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
},
|
},
|
||||||
[userPubkey]
|
[userPubkey]
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notifications,
|
notifications: effectiveNotifications,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
loading,
|
loading: effectiveLoading,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
markAllAsRead: markAllAsReadHandler,
|
markAllAsRead: markAllAsReadHandler,
|
||||||
deleteNotification: deleteNotificationHandler,
|
deleteNotification: deleteNotificationHandler,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAndSetNotifications(params: {
|
||||||
|
setNotifications: (value: Notification[]) => void
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setNotifications(await notificationService.getAllNotifications(MAX_NOTIFICATIONS))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useNotifications] Error loading notifications:', error)
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshNotifications(params: { setNotifications: (value: Notification[]) => void }): Promise<void> {
|
||||||
|
params.setNotifications(await notificationService.getAllNotifications(MAX_NOTIFICATIONS))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAsReadAndRefresh(params: {
|
||||||
|
notificationId: string
|
||||||
|
setNotifications: (value: Notification[]) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await notificationService.markAsRead(params.notificationId)
|
||||||
|
await refreshNotifications({ setNotifications: params.setNotifications })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useNotifications] Error marking notification as read:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllAsReadAndRefresh(params: {
|
||||||
|
setNotifications: (value: Notification[]) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await notificationService.markAllAsRead()
|
||||||
|
await refreshNotifications({ setNotifications: params.setNotifications })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useNotifications] Error marking all as read:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNotificationAndRefresh(params: {
|
||||||
|
notificationId: string
|
||||||
|
setNotifications: (value: Notification[]) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await notificationService.deleteNotification(params.notificationId)
|
||||||
|
await refreshNotifications({ setNotifications: params.setNotifications })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useNotifications] Error deleting notification:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
|
||||||
import { nostrService } from '@/lib/nostr'
|
import { nostrService } from '@/lib/nostr'
|
||||||
import type { Article } from '@/types/nostr'
|
import type { Article } from '@/types/nostr'
|
||||||
import { applyFiltersAndSort } from '@/lib/articleFiltering'
|
import { applyFiltersAndSort } from '@/lib/articleFiltering'
|
||||||
@ -24,86 +24,30 @@ export function useUserArticles(
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const hasArticlesRef = useRef(false)
|
const hasArticlesRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useLoadUserArticlesFromCache({
|
||||||
if (!userPubkey) {
|
userPubkey,
|
||||||
setLoading(false)
|
setArticles,
|
||||||
return
|
setLoading,
|
||||||
}
|
setError,
|
||||||
|
hasArticlesRef,
|
||||||
setLoading(true)
|
})
|
||||||
setError(null)
|
|
||||||
|
|
||||||
// Read only from IndexedDB cache - no network subscription
|
|
||||||
void (async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const allPublications = await objectCache.getAll('publication')
|
|
||||||
const allAuthors = await objectCache.getAll('author')
|
|
||||||
const allArticles = [...allPublications, ...allAuthors] as Article[]
|
|
||||||
|
|
||||||
// Filter by user pubkey
|
|
||||||
const userArticles = allArticles.filter((article) => article.pubkey === userPubkey)
|
|
||||||
|
|
||||||
// Sort by creation date descending
|
|
||||||
const sortedArticles = userArticles.sort((a, b) => b.createdAt - a.createdAt)
|
|
||||||
|
|
||||||
setArticles(sortedArticles)
|
|
||||||
hasArticlesRef.current = sortedArticles.length > 0
|
|
||||||
if (sortedArticles.length === 0) {
|
|
||||||
setError('Aucun contenu trouvé')
|
|
||||||
}
|
|
||||||
} catch (loadError) {
|
|
||||||
console.error('Error loading user articles from cache:', loadError)
|
|
||||||
setError('Erreur lors du chargement des articles')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// No cleanup needed - no network subscription
|
|
||||||
}
|
|
||||||
}, [userPubkey])
|
|
||||||
|
|
||||||
// Apply filters and sorting
|
// Apply filters and sorting
|
||||||
const filteredArticles = useMemo(() => {
|
const filteredArticles = useMemo(() => {
|
||||||
const effectiveFilters =
|
const effectiveFilters = buildDefaultFilters(filters)
|
||||||
filters ??
|
|
||||||
({
|
|
||||||
authorPubkey: null,
|
|
||||||
sortBy: 'newest',
|
|
||||||
category: 'all',
|
|
||||||
} as const)
|
|
||||||
|
|
||||||
if (!filters && !searchQuery.trim()) {
|
if (!filters && !searchQuery.trim()) {
|
||||||
return articles
|
return articles
|
||||||
}
|
}
|
||||||
|
|
||||||
return applyFiltersAndSort(articles, searchQuery, effectiveFilters)
|
return applyFiltersAndSort(articles, searchQuery, effectiveFilters)
|
||||||
}, [articles, searchQuery, filters])
|
}, [articles, searchQuery, filters])
|
||||||
|
|
||||||
const loadArticleContent = async (articleId: string, authorPubkey: string): Promise<Article | null> => {
|
const loadArticleContent = (articleId: string, authorPubkey: string): Promise<Article | null> =>
|
||||||
try {
|
loadAndDecryptUserArticle({
|
||||||
const article = await nostrService.getArticleById(articleId)
|
articleId,
|
||||||
if (article) {
|
authorPubkey,
|
||||||
// Try to decrypt article content using decryption key from private messages
|
setArticles,
|
||||||
const decryptedContent = await nostrService.getDecryptedArticleContent(articleId, authorPubkey)
|
setError,
|
||||||
if (decryptedContent) {
|
})
|
||||||
setArticles((prev) =>
|
|
||||||
prev.map((a) =>
|
|
||||||
(a.id === articleId
|
|
||||||
? { ...a, content: decryptedContent, paid: true }
|
|
||||||
: a)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return article
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error loading article content:', e)
|
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load article')
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
articles: filteredArticles,
|
articles: filteredArticles,
|
||||||
@ -113,3 +57,86 @@ export function useUserArticles(
|
|||||||
loadArticleContent,
|
loadArticleContent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useLoadUserArticlesFromCache(params: {
|
||||||
|
userPubkey: string
|
||||||
|
setArticles: (value: Article[]) => void
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
hasArticlesRef: MutableRefObject<boolean>
|
||||||
|
}): void {
|
||||||
|
const { userPubkey, setArticles, setLoading, setError, hasArticlesRef } = params
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userPubkey) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void loadUserArticlesFromCache({ userPubkey, setArticles, setLoading, setError, hasArticlesRef })
|
||||||
|
}, [userPubkey, setArticles, setLoading, setError, hasArticlesRef])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUserArticlesFromCache(params: {
|
||||||
|
userPubkey: string
|
||||||
|
setArticles: (value: Article[]) => void
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
hasArticlesRef: MutableRefObject<boolean>
|
||||||
|
}): Promise<void> {
|
||||||
|
const {hasArticlesRef} = params
|
||||||
|
try {
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setError(null)
|
||||||
|
const all = (await readArticlesFromCache()).filter((a) => a.pubkey === params.userPubkey)
|
||||||
|
const sorted = all.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
|
params.setArticles(sorted)
|
||||||
|
hasArticlesRef.current = sorted.length > 0
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
params.setError('Aucun contenu trouvé')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading user articles from cache:', e)
|
||||||
|
params.setError('Erreur lors du chargement des articles')
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readArticlesFromCache(): Promise<Article[]> {
|
||||||
|
const [publications, authors] = await Promise.all([
|
||||||
|
objectCache.getAll('publication'),
|
||||||
|
objectCache.getAll('author'),
|
||||||
|
])
|
||||||
|
return [...publications, ...authors] as Article[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultFilters(filters: ArticleFilters | null): ArticleFilters {
|
||||||
|
if (filters) {
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
return { authorPubkey: null, sortBy: 'newest', category: 'all' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAndDecryptUserArticle(params: {
|
||||||
|
articleId: string
|
||||||
|
authorPubkey: string
|
||||||
|
setArticles: Dispatch<SetStateAction<Article[]>>
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): Promise<Article | null> {
|
||||||
|
try {
|
||||||
|
const article = await nostrService.getArticleById(params.articleId)
|
||||||
|
if (!article) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const decrypted = await nostrService.getDecryptedArticleContent(params.articleId, params.authorPubkey)
|
||||||
|
if (decrypted) {
|
||||||
|
params.setArticles((prev) =>
|
||||||
|
prev.map((a) => (a.id === params.articleId ? { ...a, content: decrypted, paid: true } : a))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return article
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading article content:', e)
|
||||||
|
params.setError(e instanceof Error ? e.message : 'Failed to load article')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
70
lib/articleDraftToParsedArticle.ts
Normal file
70
lib/articleDraftToParsedArticle.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import type { ArticleDraft } from './articlePublisherTypes'
|
||||||
|
import type { AlbyInvoice } from '@/types/alby'
|
||||||
|
import type { Article } from '@/types/nostr'
|
||||||
|
import { generatePublicationHashId } from './hashIdGenerator'
|
||||||
|
import { buildObjectId } from './urlGenerator'
|
||||||
|
|
||||||
|
export async function buildParsedArticleFromDraft(params: {
|
||||||
|
draft: ArticleDraft
|
||||||
|
invoice: AlbyInvoice
|
||||||
|
authorPubkey: string
|
||||||
|
}): Promise<{ article: Article; hash: string; version: number; index: number }> {
|
||||||
|
const categoryTag = mapDraftCategoryToTag(params.draft.category)
|
||||||
|
const hashId = await generatePublicationHashId(buildPublicationHashInput({ draft: params.draft, authorPubkey: params.authorPubkey, categoryTag }))
|
||||||
|
|
||||||
|
const hash = hashId
|
||||||
|
const version = 0
|
||||||
|
const index = 0
|
||||||
|
const id = buildObjectId(hash, index, version)
|
||||||
|
|
||||||
|
const article: Article = {
|
||||||
|
id,
|
||||||
|
hash,
|
||||||
|
version,
|
||||||
|
index,
|
||||||
|
pubkey: params.authorPubkey,
|
||||||
|
title: params.draft.title,
|
||||||
|
preview: params.draft.preview,
|
||||||
|
content: '',
|
||||||
|
description: params.draft.preview,
|
||||||
|
contentDescription: params.draft.preview,
|
||||||
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
|
zapAmount: params.draft.zapAmount,
|
||||||
|
paid: false,
|
||||||
|
thumbnailUrl: params.draft.bannerUrl ?? '',
|
||||||
|
invoice: params.invoice.invoice,
|
||||||
|
...(params.invoice.paymentHash ? { paymentHash: params.invoice.paymentHash } : {}),
|
||||||
|
...(params.draft.category ? { category: params.draft.category } : {}),
|
||||||
|
...(params.draft.seriesId ? { seriesId: params.draft.seriesId } : {}),
|
||||||
|
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
|
||||||
|
...(params.draft.pages && params.draft.pages.length > 0 ? { pages: params.draft.pages } : {}),
|
||||||
|
kindType: 'article',
|
||||||
|
}
|
||||||
|
|
||||||
|
return { article, hash, version, index }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDraftCategoryToTag(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' {
|
||||||
|
if (category === 'scientific-research') {
|
||||||
|
return 'research'
|
||||||
|
}
|
||||||
|
return 'sciencefiction'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicationHashInput(params: {
|
||||||
|
draft: ArticleDraft
|
||||||
|
authorPubkey: string
|
||||||
|
categoryTag: 'sciencefiction' | 'research'
|
||||||
|
}): Parameters<typeof generatePublicationHashId>[0] {
|
||||||
|
return {
|
||||||
|
pubkey: params.authorPubkey,
|
||||||
|
title: params.draft.title,
|
||||||
|
preview: params.draft.preview,
|
||||||
|
category: params.categoryTag,
|
||||||
|
...(params.draft.seriesId ? { seriesId: params.draft.seriesId } : {}),
|
||||||
|
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
|
||||||
|
zapAmount: params.draft.zapAmount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -88,27 +88,57 @@ async function buildPreviewTags(
|
|||||||
}
|
}
|
||||||
): Promise<string[][]> {
|
): Promise<string[][]> {
|
||||||
const category = normalizePublicationCategory(params.draft.category)
|
const category = normalizePublicationCategory(params.draft.category)
|
||||||
|
const hashId = await generatePublicationHashId(
|
||||||
|
buildPublicationHashParams({ draft: params.draft, authorPubkey: params.authorPubkey, category })
|
||||||
|
)
|
||||||
|
|
||||||
// Generate hash ID from publication data
|
const tags = buildPublicationTags({
|
||||||
const hashId = await generatePublicationHashId({
|
draft: params.draft,
|
||||||
|
invoice: params.invoice,
|
||||||
|
authorPubkey: params.authorPubkey,
|
||||||
|
category,
|
||||||
|
hashId,
|
||||||
|
encryptedKey: params.encryptedKey,
|
||||||
|
})
|
||||||
|
tags.push(['json', buildPublicationJson({ draft: params.draft, invoice: params.invoice, authorPubkey: params.authorPubkey, category, hashId })])
|
||||||
|
if (params.extraTags && params.extraTags.length > 0) {
|
||||||
|
tags.push(...params.extraTags)
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicationHashParams(params: {
|
||||||
|
draft: ArticleDraft
|
||||||
|
authorPubkey: string
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
}): Parameters<typeof generatePublicationHashId>[0] {
|
||||||
|
return {
|
||||||
pubkey: params.authorPubkey,
|
pubkey: params.authorPubkey,
|
||||||
title: params.draft.title,
|
title: params.draft.title,
|
||||||
preview: params.draft.preview,
|
preview: params.draft.preview,
|
||||||
category,
|
category: params.category,
|
||||||
seriesId: params.draft.seriesId ?? undefined,
|
|
||||||
bannerUrl: params.draft.bannerUrl ?? undefined,
|
|
||||||
zapAmount: params.draft.zapAmount,
|
zapAmount: params.draft.zapAmount,
|
||||||
})
|
...(params.draft.seriesId ? { seriesId: params.draft.seriesId } : {}),
|
||||||
|
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build tags using new system
|
function buildPublicationTags(params: {
|
||||||
const newTags = buildTags({
|
draft: ArticleDraft
|
||||||
|
invoice: AlbyInvoice
|
||||||
|
authorPubkey: string
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
hashId: string
|
||||||
|
encryptedKey: string | undefined
|
||||||
|
}): string[][] {
|
||||||
|
return buildTags({
|
||||||
type: 'publication',
|
type: 'publication',
|
||||||
category,
|
category: params.category,
|
||||||
id: hashId,
|
id: params.hashId,
|
||||||
service: PLATFORM_SERVICE,
|
service: PLATFORM_SERVICE,
|
||||||
version: 0, // New object
|
version: 0,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
paywall: true, // Publications are paid
|
paywall: true,
|
||||||
title: params.draft.title,
|
title: params.draft.title,
|
||||||
preview: params.draft.preview,
|
preview: params.draft.preview,
|
||||||
zapAmount: params.draft.zapAmount,
|
zapAmount: params.draft.zapAmount,
|
||||||
@ -118,34 +148,31 @@ async function buildPreviewTags(
|
|||||||
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
|
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
|
||||||
...(params.encryptedKey ? { encryptedKey: params.encryptedKey } : {}),
|
...(params.encryptedKey ? { encryptedKey: params.encryptedKey } : {}),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Build JSON metadata
|
function buildPublicationJson(params: {
|
||||||
const publicationJson = JSON.stringify({
|
draft: ArticleDraft
|
||||||
|
invoice: AlbyInvoice
|
||||||
|
authorPubkey: string
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
hashId: string
|
||||||
|
}): string {
|
||||||
|
return JSON.stringify({
|
||||||
type: 'publication',
|
type: 'publication',
|
||||||
pubkey: params.authorPubkey,
|
pubkey: params.authorPubkey,
|
||||||
title: params.draft.title,
|
title: params.draft.title,
|
||||||
preview: params.draft.preview,
|
preview: params.draft.preview,
|
||||||
category,
|
category: params.category,
|
||||||
seriesId: params.draft.seriesId,
|
seriesId: params.draft.seriesId,
|
||||||
bannerUrl: params.draft.bannerUrl,
|
bannerUrl: params.draft.bannerUrl,
|
||||||
zapAmount: params.draft.zapAmount,
|
zapAmount: params.draft.zapAmount,
|
||||||
invoice: params.invoice.invoice,
|
invoice: params.invoice.invoice,
|
||||||
paymentHash: params.invoice.paymentHash,
|
paymentHash: params.invoice.paymentHash,
|
||||||
id: hashId,
|
id: params.hashId,
|
||||||
version: 0,
|
version: 0,
|
||||||
index: 0,
|
index: 0,
|
||||||
...(params.draft.pages && params.draft.pages.length > 0 ? { pages: params.draft.pages } : {}),
|
...(params.draft.pages && params.draft.pages.length > 0 ? { pages: params.draft.pages } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add JSON metadata as a tag
|
|
||||||
newTags.push(['json', publicationJson])
|
|
||||||
|
|
||||||
// Add any extra tags (for backward compatibility)
|
|
||||||
if (params.extraTags && params.extraTags.length > 0) {
|
|
||||||
newTags.push(...params.extraTags)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newTags
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePublicationCategory(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' {
|
function normalizePublicationCategory(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' {
|
||||||
|
|||||||
@ -45,14 +45,7 @@ async function buildParsedArticleFromDraft(
|
|||||||
invoice: AlbyInvoice,
|
invoice: AlbyInvoice,
|
||||||
authorPubkey: string
|
authorPubkey: string
|
||||||
): Promise<{ article: Article; hash: string; version: number; index: number }> {
|
): Promise<{ article: Article; hash: string; version: number; index: number }> {
|
||||||
let category: string
|
const category = mapDraftCategoryToTag(draft.category)
|
||||||
if (draft.category === 'science-fiction') {
|
|
||||||
category = 'sciencefiction'
|
|
||||||
} else if (draft.category === 'scientific-research') {
|
|
||||||
category = 'research'
|
|
||||||
} else {
|
|
||||||
category = 'sciencefiction'
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashId = await generatePublicationHashId({
|
const hashId = await generatePublicationHashId({
|
||||||
pubkey: authorPubkey,
|
pubkey: authorPubkey,
|
||||||
@ -96,6 +89,13 @@ async function buildParsedArticleFromDraft(
|
|||||||
return { article, hash, version, index }
|
return { article, hash, version, index }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapDraftCategoryToTag(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' {
|
||||||
|
if (category === 'scientific-research') {
|
||||||
|
return 'research'
|
||||||
|
}
|
||||||
|
return 'sciencefiction'
|
||||||
|
}
|
||||||
|
|
||||||
interface PublishPreviewWithInvoiceParams {
|
interface PublishPreviewWithInvoiceParams {
|
||||||
draft: ArticleDraft
|
draft: ArticleDraft
|
||||||
invoice: AlbyInvoice
|
invoice: AlbyInvoice
|
||||||
@ -614,81 +614,111 @@ function updateFailure(originalArticleId: string, error?: string): ArticleUpdate
|
|||||||
export async function deleteArticleEvent(articleId: string, authorPubkey: string, authorPrivateKey?: string): Promise<void> {
|
export async function deleteArticleEvent(articleId: string, authorPubkey: string, authorPrivateKey?: string): Promise<void> {
|
||||||
ensureKeys(authorPubkey, authorPrivateKey)
|
ensureKeys(authorPubkey, authorPrivateKey)
|
||||||
|
|
||||||
// Get original event from IndexedDB cache
|
const originalEvent = await getOriginalPublicationEventOrThrow(articleId)
|
||||||
|
assertAuthorOwnsEvent({ eventPubkey: originalEvent.pubkey, authorPubkey })
|
||||||
|
|
||||||
|
const deleteEventTemplate = await buildDeleteEventTemplateOrThrow({ originalEvent, authorPubkey })
|
||||||
|
const originalParsed = await parseOriginalArticleOrThrow(originalEvent)
|
||||||
|
const deletePayload = await buildDeletedArticlePayload({ originalParsed, deleteEventTemplate })
|
||||||
|
|
||||||
|
const event = await finalizeEventTemplate({ template: deleteEventTemplate, authorPrivateKey })
|
||||||
|
const relays = await getActiveRelaysOrPrimary()
|
||||||
|
await publishDeletion({ event, relays, payload: deletePayload })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOriginalPublicationEventOrThrow(articleId: string): Promise<import('nostr-tools').Event> {
|
||||||
const { objectCache } = await import('./objectCache')
|
const { objectCache } = await import('./objectCache')
|
||||||
const originalEvent = await objectCache.getEventById('publication', articleId)
|
const originalEvent = await objectCache.getEventById('publication', articleId)
|
||||||
|
|
||||||
if (!originalEvent) {
|
if (!originalEvent) {
|
||||||
throw new Error('Article not found in cache')
|
throw new Error('Article not found in cache')
|
||||||
}
|
}
|
||||||
|
return originalEvent
|
||||||
|
}
|
||||||
|
|
||||||
// Verify user is the author
|
function assertAuthorOwnsEvent(params: { eventPubkey: string; authorPubkey: string }): void {
|
||||||
if (originalEvent.pubkey !== authorPubkey) {
|
if (params.eventPubkey !== params.authorPubkey) {
|
||||||
throw new Error('Only the author can delete this article')
|
throw new Error('Only the author can delete this article')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build delete event (new version with hidden=true)
|
async function buildDeleteEventTemplateOrThrow(params: {
|
||||||
|
originalEvent: import('nostr-tools').Event
|
||||||
|
authorPubkey: string
|
||||||
|
}): Promise<import('nostr-tools').EventTemplate> {
|
||||||
const { buildDeleteEvent } = await import('./objectModification')
|
const { buildDeleteEvent } = await import('./objectModification')
|
||||||
const deleteEventTemplate = await buildDeleteEvent(originalEvent, authorPubkey)
|
const template = await buildDeleteEvent(params.originalEvent, params.authorPubkey)
|
||||||
|
if (!template) {
|
||||||
if (!deleteEventTemplate) {
|
|
||||||
throw new Error('Failed to build delete event')
|
throw new Error('Failed to build delete event')
|
||||||
}
|
}
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
// Parse the original article to get hash/version/index
|
async function parseOriginalArticleOrThrow(originalEvent: import('nostr-tools').Event): Promise<Article> {
|
||||||
const { parseArticleFromEvent } = await import('./nostrEventParsing')
|
const { parseArticleFromEvent } = await import('./nostrEventParsing')
|
||||||
const originalParsed = await parseArticleFromEvent(originalEvent)
|
const parsed = await parseArticleFromEvent(originalEvent)
|
||||||
if (!originalParsed) {
|
if (!parsed) {
|
||||||
throw new Error('Failed to parse original article')
|
throw new Error('Failed to parse original article')
|
||||||
}
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
// Increment version for deletion
|
async function buildDeletedArticlePayload(params: {
|
||||||
|
originalParsed: Article
|
||||||
|
deleteEventTemplate: import('nostr-tools').EventTemplate
|
||||||
|
}): Promise<{ hash: string; index: number; version: number; parsed: Article }> {
|
||||||
const { extractTagsFromEvent } = await import('./nostrTagSystem')
|
const { extractTagsFromEvent } = await import('./nostrTagSystem')
|
||||||
const tags = extractTagsFromEvent(deleteEventTemplate)
|
const tags = extractTagsFromEvent(params.deleteEventTemplate)
|
||||||
const newVersion = tags.version ?? originalParsed.version + 1
|
const version = tags.version ?? params.originalParsed.version + 1
|
||||||
const {hash} = originalParsed
|
const index = params.originalParsed.index ?? 0
|
||||||
const index = originalParsed.index ?? 0
|
const parsed: Article = { ...params.originalParsed, version }
|
||||||
|
return { hash: params.originalParsed.hash, index, version, parsed }
|
||||||
|
}
|
||||||
|
|
||||||
// Build updated parsed Article object with hidden flag
|
async function finalizeEventTemplate(params: {
|
||||||
const deletedArticle: Article = {
|
template: import('nostr-tools').EventTemplate
|
||||||
...originalParsed,
|
authorPrivateKey: string | undefined
|
||||||
version: newVersion,
|
}): Promise<import('nostr-tools').Event> {
|
||||||
}
|
const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey()
|
||||||
|
|
||||||
// Set private key in orchestrator
|
|
||||||
const privateKey = authorPrivateKey ?? nostrService.getPrivateKey()
|
|
||||||
if (!privateKey) {
|
if (!privateKey) {
|
||||||
throw new Error('Private key required for signing')
|
throw new Error('Private key required for signing')
|
||||||
}
|
}
|
||||||
const { writeOrchestrator: writeOrchestratorInstance } = await import('./writeOrchestrator')
|
const { writeOrchestrator: writeOrchestratorInstance } = await import('./writeOrchestrator')
|
||||||
writeOrchestratorInstance.setPrivateKey(privateKey)
|
writeOrchestratorInstance.setPrivateKey(privateKey)
|
||||||
|
|
||||||
// Finalize event
|
|
||||||
const { finalizeEvent: finalizeNostrEvent } = await import('nostr-tools')
|
const { finalizeEvent: finalizeNostrEvent } = await import('nostr-tools')
|
||||||
const { hexToBytes: hexToBytesUtil } = await import('nostr-tools/utils')
|
const { hexToBytes: hexToBytesUtil } = await import('nostr-tools/utils')
|
||||||
const secretKey = hexToBytesUtil(privateKey)
|
const secretKey = hexToBytesUtil(privateKey)
|
||||||
const event = finalizeNostrEvent(deleteEventTemplate, secretKey)
|
return finalizeNostrEvent(params.template, secretKey)
|
||||||
|
}
|
||||||
|
|
||||||
// Get active relays
|
async function getActiveRelaysOrPrimary(): Promise<string[]> {
|
||||||
const { relaySessionManager } = await import('./relaySessionManager')
|
const { relaySessionManager } = await import('./relaySessionManager')
|
||||||
const activeRelays = await relaySessionManager.getActiveRelays()
|
const activeRelays = await relaySessionManager.getActiveRelays()
|
||||||
|
if (activeRelays.length > 0) {
|
||||||
|
return activeRelays
|
||||||
|
}
|
||||||
const { getPrimaryRelay } = await import('./config')
|
const { getPrimaryRelay } = await import('./config')
|
||||||
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
|
return [await getPrimaryRelay()]
|
||||||
|
}
|
||||||
|
|
||||||
// Publish via writeOrchestrator (parallel network + local write)
|
async function publishDeletion(params: {
|
||||||
|
event: import('nostr-tools').Event
|
||||||
|
relays: string[]
|
||||||
|
payload: { hash: string; index: number; version: number; parsed: Article }
|
||||||
|
}): Promise<void> {
|
||||||
|
const { writeOrchestrator: writeOrchestratorInstance } = await import('./writeOrchestrator')
|
||||||
const result = await writeOrchestratorInstance.writeAndPublish(
|
const result = await writeOrchestratorInstance.writeAndPublish(
|
||||||
{
|
{
|
||||||
objectType: 'publication',
|
objectType: 'publication',
|
||||||
hash,
|
hash: params.payload.hash,
|
||||||
event,
|
event: params.event,
|
||||||
parsed: deletedArticle,
|
parsed: params.payload.parsed,
|
||||||
version: newVersion,
|
version: params.payload.version,
|
||||||
hidden: true, // Mark as hidden (deleted)
|
hidden: true,
|
||||||
index,
|
index: params.payload.index,
|
||||||
},
|
},
|
||||||
relays
|
params.relays
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error('Failed to publish delete event')
|
throw new Error('Failed to publish delete event')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { finalizeEvent } from 'nostr-tools'
|
|||||||
import { hexToBytes } from 'nostr-tools/utils'
|
import { hexToBytes } from 'nostr-tools/utils'
|
||||||
import { generateAuthorHashId } from './hashIdGenerator'
|
import { generateAuthorHashId } from './hashIdGenerator'
|
||||||
import { buildObjectId } from './urlGenerator'
|
import { buildObjectId } from './urlGenerator'
|
||||||
|
import { extractAuthorNameFromTitle, parseAuthorPresentationDraft } from './authorPresentationParsing'
|
||||||
|
|
||||||
export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
|
export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
|
||||||
|
|
||||||
@ -179,108 +180,7 @@ export class ArticlePublisher {
|
|||||||
authorPrivateKey: string
|
authorPrivateKey: string
|
||||||
): Promise<PublishedArticle> {
|
): Promise<PublishedArticle> {
|
||||||
try {
|
try {
|
||||||
nostrService.setPublicKey(authorPubkey)
|
return publishPresentationArticleCore({ draft, authorPubkey, authorPrivateKey })
|
||||||
nostrService.setPrivateKey(authorPrivateKey)
|
|
||||||
|
|
||||||
// Extract author name from title (format: "Présentation de <name>")
|
|
||||||
const authorName = draft.title.replace(/^Présentation de /, '').trim() ?? 'Auteur'
|
|
||||||
|
|
||||||
// Extract presentation and contentDescription from draft.content
|
|
||||||
const separator = '\n\n---\n\nDescription du contenu :\n'
|
|
||||||
const separatorIndex = draft.content.indexOf(separator)
|
|
||||||
const presentation = separatorIndex !== -1 ? draft.content.substring(0, separatorIndex) : draft.presentation
|
|
||||||
let contentDescription = separatorIndex !== -1 ? draft.content.substring(separatorIndex + separator.length) : draft.contentDescription
|
|
||||||
|
|
||||||
// Remove Bitcoin address from contentDescription if present
|
|
||||||
if (contentDescription) {
|
|
||||||
contentDescription = contentDescription
|
|
||||||
.split('\n')
|
|
||||||
.filter((line) => !line.includes('Adresse Bitcoin mainnet (pour le sponsoring)'))
|
|
||||||
.join('\n')
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
const category = 'sciencefiction'
|
|
||||||
const version = 0
|
|
||||||
const index = 0
|
|
||||||
|
|
||||||
// Generate hash ID
|
|
||||||
const hashId = await generateAuthorHashId({
|
|
||||||
pubkey: authorPubkey,
|
|
||||||
authorName,
|
|
||||||
presentation,
|
|
||||||
contentDescription,
|
|
||||||
mainnetAddress: draft.mainnetAddress ?? undefined,
|
|
||||||
pictureUrl: draft.pictureUrl ?? undefined,
|
|
||||||
category,
|
|
||||||
})
|
|
||||||
|
|
||||||
const hash = hashId
|
|
||||||
const id = buildObjectId(hash, index, version)
|
|
||||||
|
|
||||||
// Build parsed AuthorPresentationArticle object
|
|
||||||
const parsedAuthor: import('@/types/nostr').AuthorPresentationArticle = {
|
|
||||||
id,
|
|
||||||
hash,
|
|
||||||
version,
|
|
||||||
index,
|
|
||||||
pubkey: authorPubkey,
|
|
||||||
title: draft.title,
|
|
||||||
preview: draft.preview,
|
|
||||||
content: draft.content,
|
|
||||||
description: presentation,
|
|
||||||
contentDescription,
|
|
||||||
thumbnailUrl: draft.pictureUrl ?? '',
|
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
|
||||||
zapAmount: 0,
|
|
||||||
paid: true,
|
|
||||||
category: 'author-presentation',
|
|
||||||
isPresentation: true,
|
|
||||||
mainnetAddress: draft.mainnetAddress ?? '',
|
|
||||||
totalSponsoring: 0,
|
|
||||||
originalCategory: 'science-fiction',
|
|
||||||
...(draft.pictureUrl ? { bannerUrl: draft.pictureUrl } : {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build event template
|
|
||||||
const eventTemplate = await buildPresentationEvent({ draft, authorPubkey, authorName, category, version, index })
|
|
||||||
|
|
||||||
// Set private key in orchestrator
|
|
||||||
writeOrchestrator.setPrivateKey(authorPrivateKey)
|
|
||||||
|
|
||||||
// Finalize event
|
|
||||||
const secretKey = hexToBytes(authorPrivateKey)
|
|
||||||
const event = finalizeEvent(eventTemplate, secretKey)
|
|
||||||
|
|
||||||
// Get active relays
|
|
||||||
const { relaySessionManager } = await import('./relaySessionManager')
|
|
||||||
const activeRelays = await relaySessionManager.getActiveRelays()
|
|
||||||
const { getPrimaryRelay } = await import('./config')
|
|
||||||
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
|
|
||||||
|
|
||||||
// Publish via writeOrchestrator (parallel network + local write)
|
|
||||||
const result = await writeOrchestrator.writeAndPublish(
|
|
||||||
{
|
|
||||||
objectType: 'author',
|
|
||||||
hash,
|
|
||||||
event,
|
|
||||||
parsed: parsedAuthor,
|
|
||||||
version,
|
|
||||||
hidden: false,
|
|
||||||
index,
|
|
||||||
},
|
|
||||||
relays
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return buildFailure('Failed to publish presentation article')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
articleId: event.id,
|
|
||||||
previewEventId: event.id,
|
|
||||||
success: true,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error publishing presentation article:', error)
|
console.error('Error publishing presentation article:', error)
|
||||||
return buildFailure(error instanceof Error ? error.message : 'Unknown error')
|
return buildFailure(error instanceof Error ? error.message : 'Unknown error')
|
||||||
@ -309,3 +209,120 @@ export class ArticlePublisher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const articlePublisher = new ArticlePublisher()
|
export const articlePublisher = new ArticlePublisher()
|
||||||
|
|
||||||
|
async function publishPresentationArticleCore(params: {
|
||||||
|
draft: AuthorPresentationDraft
|
||||||
|
authorPubkey: string
|
||||||
|
authorPrivateKey: string
|
||||||
|
}): Promise<PublishedArticle> {
|
||||||
|
nostrService.setPublicKey(params.authorPubkey)
|
||||||
|
nostrService.setPrivateKey(params.authorPrivateKey)
|
||||||
|
|
||||||
|
const authorName = extractAuthorNameFromTitle(params.draft.title)
|
||||||
|
const { presentation, contentDescription } = parseAuthorPresentationDraft(params.draft)
|
||||||
|
|
||||||
|
const category = 'sciencefiction'
|
||||||
|
const version = 0
|
||||||
|
const index = 0
|
||||||
|
|
||||||
|
const hashId = await generateAuthorHashId({
|
||||||
|
pubkey: params.authorPubkey,
|
||||||
|
authorName,
|
||||||
|
presentation,
|
||||||
|
contentDescription,
|
||||||
|
mainnetAddress: params.draft.mainnetAddress ?? undefined,
|
||||||
|
pictureUrl: params.draft.pictureUrl ?? undefined,
|
||||||
|
category,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hash = hashId
|
||||||
|
const id = buildObjectId(hash, index, version)
|
||||||
|
|
||||||
|
const parsedAuthor = buildParsedAuthorPresentation({
|
||||||
|
draft: params.draft,
|
||||||
|
authorPubkey: params.authorPubkey,
|
||||||
|
id,
|
||||||
|
hash,
|
||||||
|
version,
|
||||||
|
index,
|
||||||
|
presentation,
|
||||||
|
contentDescription,
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventTemplate = await buildPresentationEvent({
|
||||||
|
draft: params.draft,
|
||||||
|
authorPubkey: params.authorPubkey,
|
||||||
|
authorName,
|
||||||
|
category,
|
||||||
|
version,
|
||||||
|
index,
|
||||||
|
})
|
||||||
|
|
||||||
|
writeOrchestrator.setPrivateKey(params.authorPrivateKey)
|
||||||
|
const secretKey = hexToBytes(params.authorPrivateKey)
|
||||||
|
const event = finalizeEvent(eventTemplate, secretKey)
|
||||||
|
|
||||||
|
const relays = await getActiveRelaysOrPrimary()
|
||||||
|
const result = await writeOrchestrator.writeAndPublish(
|
||||||
|
{
|
||||||
|
objectType: 'author',
|
||||||
|
hash,
|
||||||
|
event,
|
||||||
|
parsed: parsedAuthor,
|
||||||
|
version,
|
||||||
|
hidden: false,
|
||||||
|
index,
|
||||||
|
},
|
||||||
|
relays
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return buildFailure('Failed to publish presentation article')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { articleId: event.id, previewEventId: event.id, success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParsedAuthorPresentation(params: {
|
||||||
|
draft: AuthorPresentationDraft
|
||||||
|
authorPubkey: string
|
||||||
|
id: string
|
||||||
|
hash: string
|
||||||
|
version: number
|
||||||
|
index: number
|
||||||
|
presentation: string
|
||||||
|
contentDescription: string
|
||||||
|
}): import('@/types/nostr').AuthorPresentationArticle {
|
||||||
|
return {
|
||||||
|
id: params.id,
|
||||||
|
hash: params.hash,
|
||||||
|
version: params.version,
|
||||||
|
index: params.index,
|
||||||
|
pubkey: params.authorPubkey,
|
||||||
|
title: params.draft.title,
|
||||||
|
preview: params.draft.preview,
|
||||||
|
content: params.draft.content,
|
||||||
|
description: params.presentation,
|
||||||
|
contentDescription: params.contentDescription,
|
||||||
|
thumbnailUrl: params.draft.pictureUrl ?? '',
|
||||||
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
|
zapAmount: 0,
|
||||||
|
paid: true,
|
||||||
|
category: 'author-presentation',
|
||||||
|
isPresentation: true,
|
||||||
|
mainnetAddress: params.draft.mainnetAddress ?? '',
|
||||||
|
totalSponsoring: 0,
|
||||||
|
originalCategory: 'science-fiction',
|
||||||
|
...(params.draft.pictureUrl ? { bannerUrl: params.draft.pictureUrl } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActiveRelaysOrPrimary(): Promise<string[]> {
|
||||||
|
const { relaySessionManager } = await import('./relaySessionManager')
|
||||||
|
const activeRelays = await relaySessionManager.getActiveRelays()
|
||||||
|
if (activeRelays.length > 0) {
|
||||||
|
return activeRelays
|
||||||
|
}
|
||||||
|
const { getPrimaryRelay } = await import('./config')
|
||||||
|
return [await getPrimaryRelay()]
|
||||||
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { generateAuthorHashId } from './hashIdGenerator'
|
|||||||
import { generateObjectUrl, buildObjectId, parseObjectId } from './urlGenerator'
|
import { generateObjectUrl, buildObjectId, parseObjectId } from './urlGenerator'
|
||||||
import { getLatestVersion } from './versionManager'
|
import { getLatestVersion } from './versionManager'
|
||||||
import { objectCache } from './objectCache'
|
import { objectCache } from './objectCache'
|
||||||
|
import { parseAuthorPresentationDraft } from './authorPresentationParsing'
|
||||||
|
|
||||||
interface BuildPresentationEventParams {
|
interface BuildPresentationEventParams {
|
||||||
draft: AuthorPresentationDraft
|
draft: AuthorPresentationDraft
|
||||||
@ -31,22 +32,8 @@ export async function buildPresentationEvent(
|
|||||||
const category = params.category ?? 'sciencefiction'
|
const category = params.category ?? 'sciencefiction'
|
||||||
const version = params.version ?? 0
|
const version = params.version ?? 0
|
||||||
const index = params.index ?? 0
|
const index = params.index ?? 0
|
||||||
// Extract presentation and contentDescription from draft.content
|
|
||||||
// Format: "${presentation}\n\n---\n\nDescription du contenu :\n${contentDescription}"
|
|
||||||
const separator = '\n\n---\n\nDescription du contenu :\n'
|
|
||||||
const separatorIndex = params.draft.content.indexOf(separator)
|
|
||||||
const presentation = separatorIndex !== -1 ? params.draft.content.substring(0, separatorIndex) : params.draft.presentation
|
|
||||||
let contentDescription = separatorIndex !== -1 ? params.draft.content.substring(separatorIndex + separator.length) : params.draft.contentDescription
|
|
||||||
|
|
||||||
// Remove Bitcoin address from contentDescription if present (should not be visible in note content)
|
const { presentation, contentDescription } = parseAuthorPresentationDraft(params.draft)
|
||||||
// Remove lines matching "Adresse Bitcoin mainnet (pour le sponsoring) : ..."
|
|
||||||
if (contentDescription) {
|
|
||||||
contentDescription = contentDescription
|
|
||||||
.split('\n')
|
|
||||||
.filter((line) => !line.includes('Adresse Bitcoin mainnet (pour le sponsoring)'))
|
|
||||||
.join('\n')
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate hash ID from author data first (needed for URL)
|
// Generate hash ID from author data first (needed for URL)
|
||||||
const hashId = await generateAuthorHashId({
|
const hashId = await generateAuthorHashId({
|
||||||
@ -65,13 +52,11 @@ export async function buildPresentationEvent(
|
|||||||
// Encode pubkey to npub (for metadata JSON)
|
// Encode pubkey to npub (for metadata JSON)
|
||||||
const npub = nip19.npubEncode(params.authorPubkey)
|
const npub = nip19.npubEncode(params.authorPubkey)
|
||||||
|
|
||||||
// Build visible content message
|
const linkWithPreview = buildProfileLink({
|
||||||
// If picture exists, use it as preview image for the link (markdown format)
|
profileUrl,
|
||||||
// Note: The image will display at full size in most Nostr clients, not as a thumbnail
|
authorName: params.authorName,
|
||||||
const {draft} = params
|
pictureUrl: params.draft.pictureUrl,
|
||||||
const linkWithPreview = draft.pictureUrl
|
})
|
||||||
? `[](${profileUrl})`
|
|
||||||
: profileUrl
|
|
||||||
|
|
||||||
const visibleContent = [
|
const visibleContent = [
|
||||||
'Nouveau profil auteur publié sur zapwall.fr (plateforme de publications scientifiques)',
|
'Nouveau profil auteur publié sur zapwall.fr (plateforme de publications scientifiques)',
|
||||||
@ -87,8 +72,8 @@ export async function buildPresentationEvent(
|
|||||||
pubkey: params.authorPubkey,
|
pubkey: params.authorPubkey,
|
||||||
presentation,
|
presentation,
|
||||||
contentDescription,
|
contentDescription,
|
||||||
mainnetAddress: draft.mainnetAddress,
|
mainnetAddress: params.draft.mainnetAddress,
|
||||||
pictureUrl: draft.pictureUrl,
|
pictureUrl: params.draft.pictureUrl,
|
||||||
category,
|
category,
|
||||||
url: profileUrl,
|
url: profileUrl,
|
||||||
version,
|
version,
|
||||||
@ -104,10 +89,10 @@ export async function buildPresentationEvent(
|
|||||||
version,
|
version,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
paywall: false,
|
paywall: false,
|
||||||
title: draft.title,
|
title: params.draft.title,
|
||||||
preview: draft.preview,
|
preview: params.draft.preview,
|
||||||
mainnetAddress: draft.mainnetAddress,
|
mainnetAddress: params.draft.mainnetAddress,
|
||||||
...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
|
...(params.draft.pictureUrl ? { pictureUrl: params.draft.pictureUrl } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add JSON metadata as a tag (not in visible content)
|
// Add JSON metadata as a tag (not in visible content)
|
||||||
@ -121,6 +106,13 @@ export async function buildPresentationEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildProfileLink(params: { profileUrl: string; authorName: string; pictureUrl: string | undefined }): string {
|
||||||
|
if (params.pictureUrl) {
|
||||||
|
return `[](${params.profileUrl})`
|
||||||
|
}
|
||||||
|
return params.profileUrl
|
||||||
|
}
|
||||||
|
|
||||||
export async function parsePresentationEvent(event: Event): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
export async function parsePresentationEvent(event: Event): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
||||||
const tags = extractTagsFromEvent(event)
|
const tags = extractTagsFromEvent(event)
|
||||||
|
|
||||||
@ -383,41 +375,36 @@ function parsePresentationProfileJson(json: string): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const obj = parsed as Record<string, unknown>
|
const obj = parsed as Record<string, unknown>
|
||||||
const result: {
|
return {
|
||||||
authorName?: string
|
...readOptionalStringFields(obj, [
|
||||||
presentation?: string
|
'authorName',
|
||||||
contentDescription?: string
|
'presentation',
|
||||||
mainnetAddress?: string
|
'contentDescription',
|
||||||
pictureUrl?: string
|
'mainnetAddress',
|
||||||
category?: string
|
'pictureUrl',
|
||||||
} = {}
|
'category',
|
||||||
|
]),
|
||||||
if (typeof obj.authorName === 'string') {
|
|
||||||
result.authorName = obj.authorName
|
|
||||||
}
|
}
|
||||||
if (typeof obj.presentation === 'string') {
|
|
||||||
result.presentation = obj.presentation
|
|
||||||
}
|
|
||||||
if (typeof obj.contentDescription === 'string') {
|
|
||||||
result.contentDescription = obj.contentDescription
|
|
||||||
}
|
|
||||||
if (typeof obj.mainnetAddress === 'string') {
|
|
||||||
result.mainnetAddress = obj.mainnetAddress
|
|
||||||
}
|
|
||||||
if (typeof obj.pictureUrl === 'string') {
|
|
||||||
result.pictureUrl = obj.pictureUrl
|
|
||||||
}
|
|
||||||
if (typeof obj.category === 'string') {
|
|
||||||
result.category = obj.category
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing presentation profile JSON:', error)
|
console.error('Error parsing presentation profile JSON:', error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalStringFields<TKeys extends readonly string[]>(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
keys: TKeys
|
||||||
|
): Partial<Record<TKeys[number], string>> {
|
||||||
|
const result: Partial<Record<TKeys[number], string>> = {}
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = obj[key]
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAuthorPresentationFromPool(
|
export async function fetchAuthorPresentationFromPool(
|
||||||
pool: SimplePoolWithSub,
|
pool: SimplePoolWithSub,
|
||||||
pubkey: string
|
pubkey: string
|
||||||
|
|||||||
@ -26,14 +26,7 @@ async function buildParsedArticleFromDraft(
|
|||||||
invoice: AlbyInvoice,
|
invoice: AlbyInvoice,
|
||||||
authorPubkey: string
|
authorPubkey: string
|
||||||
): Promise<{ article: Article; hash: string; version: number; index: number }> {
|
): Promise<{ article: Article; hash: string; version: number; index: number }> {
|
||||||
let category: string
|
const category = mapDraftCategoryToTag(draft.category)
|
||||||
if (draft.category === 'science-fiction') {
|
|
||||||
category = 'sciencefiction'
|
|
||||||
} else if (draft.category === 'scientific-research') {
|
|
||||||
category = 'research'
|
|
||||||
} else {
|
|
||||||
category = 'sciencefiction'
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashId = await generatePublicationHashId({
|
const hashId = await generatePublicationHashId({
|
||||||
pubkey: authorPubkey,
|
pubkey: authorPubkey,
|
||||||
@ -77,6 +70,13 @@ async function buildParsedArticleFromDraft(
|
|||||||
return { article, hash, version, index }
|
return { article, hash, version, index }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapDraftCategoryToTag(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' {
|
||||||
|
if (category === 'scientific-research') {
|
||||||
|
return 'research'
|
||||||
|
}
|
||||||
|
return 'sciencefiction'
|
||||||
|
}
|
||||||
|
|
||||||
interface PublishPreviewParams {
|
interface PublishPreviewParams {
|
||||||
draft: ArticleDraft
|
draft: ArticleDraft
|
||||||
invoice: AlbyInvoice
|
invoice: AlbyInvoice
|
||||||
|
|||||||
32
lib/authorPresentationParsing.ts
Normal file
32
lib/authorPresentationParsing.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import type { AuthorPresentationDraft } from './articlePublisher'
|
||||||
|
|
||||||
|
const CONTENT_SEPARATOR = '\n\n---\n\nDescription du contenu :\n'
|
||||||
|
const MAINNET_ADDRESS_LABEL = 'Adresse Bitcoin mainnet (pour le sponsoring)'
|
||||||
|
|
||||||
|
export function extractAuthorNameFromTitle(title: string): string {
|
||||||
|
const result = title.replace(/^Présentation de /, '').trim()
|
||||||
|
return result.length > 0 ? result : 'Auteur'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAuthorPresentationDraft(draft: AuthorPresentationDraft): {
|
||||||
|
presentation: string
|
||||||
|
contentDescription: string
|
||||||
|
} {
|
||||||
|
const separatorIndex = draft.content.indexOf(CONTENT_SEPARATOR)
|
||||||
|
const presentation = separatorIndex !== -1 ? draft.content.substring(0, separatorIndex) : draft.presentation
|
||||||
|
const rawContentDescription =
|
||||||
|
separatorIndex !== -1 ? draft.content.substring(separatorIndex + CONTENT_SEPARATOR.length) : draft.contentDescription
|
||||||
|
|
||||||
|
return {
|
||||||
|
presentation,
|
||||||
|
contentDescription: stripMainnetAddressLine(rawContentDescription),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMainnetAddressLine(value: string): string {
|
||||||
|
return value
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !line.includes(MAINNET_ADDRESS_LABEL))
|
||||||
|
.join('\n')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
@ -57,48 +57,17 @@ export class AutomaticTransferService {
|
|||||||
articlePubkey: string,
|
articlePubkey: string,
|
||||||
paymentAmount: number
|
paymentAmount: number
|
||||||
): Promise<TransferResult> {
|
): Promise<TransferResult> {
|
||||||
try {
|
return this.transferPortion({
|
||||||
const split = calculateArticleSplit(paymentAmount)
|
type: 'article',
|
||||||
|
id: articleId,
|
||||||
if (!authorLightningAddress) {
|
pubkey: articlePubkey,
|
||||||
return {
|
recipient: authorLightningAddress,
|
||||||
success: false,
|
paymentAmount,
|
||||||
error: 'Author Lightning address not available',
|
computeSplit: calculateArticleSplit,
|
||||||
amount: split.author,
|
getRecipientAmount: (split) => split.author,
|
||||||
recipient: authorLightningAddress,
|
missingRecipientError: 'Author Lightning address not available',
|
||||||
}
|
errorLogMessage: 'Error transferring author portion',
|
||||||
}
|
})
|
||||||
|
|
||||||
this.logTransferRequired({
|
|
||||||
type: 'article',
|
|
||||||
id: articleId,
|
|
||||||
pubkey: articlePubkey,
|
|
||||||
amount: split.author,
|
|
||||||
recipient: authorLightningAddress,
|
|
||||||
platformCommission: split.platform,
|
|
||||||
})
|
|
||||||
this.trackTransferRequirement({
|
|
||||||
type: 'article',
|
|
||||||
id: articleId,
|
|
||||||
recipientPubkey: articlePubkey,
|
|
||||||
amount: split.author,
|
|
||||||
recipientAddress: authorLightningAddress,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
amount: split.author,
|
|
||||||
recipient: authorLightningAddress,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error transferring author portion', {
|
|
||||||
articleId,
|
|
||||||
articlePubkey,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
return this.buildTransferError(error, authorLightningAddress)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -110,50 +79,96 @@ export class AutomaticTransferService {
|
|||||||
reviewerPubkey: string,
|
reviewerPubkey: string,
|
||||||
paymentAmount: number
|
paymentAmount: number
|
||||||
): Promise<TransferResult> {
|
): Promise<TransferResult> {
|
||||||
try {
|
return this.transferPortion({
|
||||||
const split = calculateReviewSplit(paymentAmount)
|
type: 'review',
|
||||||
|
id: reviewId,
|
||||||
|
pubkey: reviewerPubkey,
|
||||||
|
recipient: reviewerLightningAddress,
|
||||||
|
paymentAmount,
|
||||||
|
computeSplit: calculateReviewSplit,
|
||||||
|
getRecipientAmount: (split) => split.reviewer,
|
||||||
|
missingRecipientError: 'Reviewer Lightning address not available',
|
||||||
|
errorLogMessage: 'Error transferring reviewer portion',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!reviewerLightningAddress) {
|
private async transferPortion(params: {
|
||||||
return {
|
type: 'article' | 'review'
|
||||||
success: false,
|
id: string
|
||||||
error: 'Reviewer Lightning address not available',
|
pubkey: string
|
||||||
amount: split.reviewer,
|
recipient: string
|
||||||
recipient: reviewerLightningAddress,
|
paymentAmount: number
|
||||||
}
|
computeSplit: (amount: number) => { platform: number } & Record<string, number>
|
||||||
|
getRecipientAmount: (split: { platform: number } & Record<string, number>) => number
|
||||||
|
missingRecipientError: string
|
||||||
|
errorLogMessage: string
|
||||||
|
}): Promise<TransferResult> {
|
||||||
|
try {
|
||||||
|
const split = params.computeSplit(params.paymentAmount)
|
||||||
|
const recipientAmount = params.getRecipientAmount(split)
|
||||||
|
|
||||||
|
if (!params.recipient) {
|
||||||
|
return this.buildMissingRecipientResult({
|
||||||
|
error: params.missingRecipientError,
|
||||||
|
recipient: params.recipient,
|
||||||
|
amount: recipientAmount,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logTransferRequired({
|
this.logAndTrackTransferRequirement({
|
||||||
type: 'review',
|
type: params.type,
|
||||||
id: reviewId,
|
id: params.id,
|
||||||
pubkey: reviewerPubkey,
|
pubkey: params.pubkey,
|
||||||
amount: split.reviewer,
|
recipient: params.recipient,
|
||||||
recipient: reviewerLightningAddress,
|
amount: recipientAmount,
|
||||||
platformCommission: split.platform,
|
platformCommission: split.platform,
|
||||||
})
|
})
|
||||||
this.trackTransferRequirement({
|
|
||||||
type: 'review',
|
|
||||||
id: reviewId,
|
|
||||||
recipientPubkey: reviewerPubkey,
|
|
||||||
amount: split.reviewer,
|
|
||||||
recipientAddress: reviewerLightningAddress,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return { success: true, amount: recipientAmount, recipient: params.recipient }
|
||||||
success: true,
|
|
||||||
amount: split.reviewer,
|
|
||||||
recipient: reviewerLightningAddress,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error transferring reviewer portion', {
|
this.logTransferError({ message: params.errorLogMessage, id: params.id, pubkey: params.pubkey, error })
|
||||||
reviewId,
|
return this.buildTransferError(error, params.recipient)
|
||||||
reviewerPubkey,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
return this.buildTransferError(error, reviewerLightningAddress)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildMissingRecipientResult(params: { error: string; recipient: string; amount: number }): TransferResult {
|
||||||
|
return { success: false, error: params.error, amount: params.amount, recipient: params.recipient }
|
||||||
|
}
|
||||||
|
|
||||||
|
private logAndTrackTransferRequirement(params: {
|
||||||
|
type: 'article' | 'review'
|
||||||
|
id: string
|
||||||
|
pubkey: string
|
||||||
|
recipient: string
|
||||||
|
amount: number
|
||||||
|
platformCommission: number
|
||||||
|
}): void {
|
||||||
|
this.logTransferRequired({
|
||||||
|
type: params.type,
|
||||||
|
id: params.id,
|
||||||
|
pubkey: params.pubkey,
|
||||||
|
amount: params.amount,
|
||||||
|
recipient: params.recipient,
|
||||||
|
platformCommission: params.platformCommission,
|
||||||
|
})
|
||||||
|
this.trackTransferRequirement({
|
||||||
|
type: params.type,
|
||||||
|
id: params.id,
|
||||||
|
recipientPubkey: params.pubkey,
|
||||||
|
amount: params.amount,
|
||||||
|
recipientAddress: params.recipient,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private logTransferError(params: { message: string; id: string; pubkey: string; error: unknown }): void {
|
||||||
|
console.error(params.message, {
|
||||||
|
id: params.id,
|
||||||
|
pubkey: params.pubkey,
|
||||||
|
error: params.error instanceof Error ? params.error.message : 'Unknown error',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track transfer requirement for later processing
|
* Track transfer requirement for later processing
|
||||||
* In production, this would be stored in a database or queue
|
* In production, this would be stored in a database or queue
|
||||||
|
|||||||
@ -85,54 +85,59 @@ export class ConfigStorage {
|
|||||||
* Get all configuration from IndexedDB or return defaults
|
* Get all configuration from IndexedDB or return defaults
|
||||||
*/
|
*/
|
||||||
async getConfig(): Promise<ConfigData> {
|
async getConfig(): Promise<ConfigData> {
|
||||||
|
await this.init()
|
||||||
|
const {db} = this
|
||||||
|
if (!db) {
|
||||||
|
return this.getDefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.init()
|
const stored = await this.readStoredConfig(db)
|
||||||
|
if (!stored) {
|
||||||
if (!this.db) {
|
const defaults = this.getDefaultConfig()
|
||||||
return this.getDefaultConfig()
|
await this.saveConfig(defaults)
|
||||||
|
return defaults
|
||||||
}
|
}
|
||||||
|
return await this.migrateConfigIfNeeded(stored)
|
||||||
const {db} = this
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const transaction = db.transaction([STORE_NAME], 'readonly')
|
|
||||||
const store = transaction.objectStore(STORE_NAME)
|
|
||||||
const request = store.get('config')
|
|
||||||
|
|
||||||
request.onsuccess = async () => {
|
|
||||||
const result = request.result as { key: string; value: ConfigData } | undefined
|
|
||||||
|
|
||||||
if (!result?.value) {
|
|
||||||
// First time: initialize with defaults
|
|
||||||
const defaultConfig = this.getDefaultConfig()
|
|
||||||
await this.saveConfig(defaultConfig)
|
|
||||||
resolve(defaultConfig)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate: if relays array is empty or only has old default, add all defaults
|
|
||||||
const existingConfig = result.value
|
|
||||||
if (existingConfig.relays.length === 0 || (existingConfig.relays.length === 1 && existingConfig.relays[0]?.id === 'default')) {
|
|
||||||
const defaultConfig = this.getDefaultConfig()
|
|
||||||
existingConfig.relays = defaultConfig.relays
|
|
||||||
await this.saveConfig(existingConfig)
|
|
||||||
resolve(existingConfig)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(existingConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onerror = () => {
|
|
||||||
console.warn('Failed to read config from IndexedDB, using defaults')
|
|
||||||
resolve(this.getDefaultConfig())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting config from IndexedDB:', error)
|
console.error('Error getting config from IndexedDB:', error)
|
||||||
return this.getDefaultConfig()
|
return this.getDefaultConfig()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readStoredConfig(db: IDBDatabase): Promise<ConfigData | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const transaction = db.transaction([STORE_NAME], 'readonly')
|
||||||
|
const store = transaction.objectStore(STORE_NAME)
|
||||||
|
const request = store.get('config')
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const result = request.result as { key: string; value: ConfigData } | undefined
|
||||||
|
resolve(result?.value ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.warn('Failed to read config from IndexedDB, using defaults')
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async migrateConfigIfNeeded(config: ConfigData): Promise<ConfigData> {
|
||||||
|
const shouldReplaceRelays =
|
||||||
|
config.relays.length === 0 ||
|
||||||
|
(config.relays.length === 1 && config.relays[0]?.id === 'default')
|
||||||
|
|
||||||
|
if (!shouldReplaceRelays) {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = this.getDefaultConfig()
|
||||||
|
const migrated = { ...config, relays: defaults.relays }
|
||||||
|
await this.saveConfig(migrated)
|
||||||
|
return migrated
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save configuration to IndexedDB
|
* Save configuration to IndexedDB
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -33,95 +33,121 @@ export interface SyncSubscriptionResult {
|
|||||||
export async function createSyncSubscription(
|
export async function createSyncSubscription(
|
||||||
config: SyncSubscriptionConfig
|
config: SyncSubscriptionConfig
|
||||||
): Promise<SyncSubscriptionResult> {
|
): Promise<SyncSubscriptionResult> {
|
||||||
const { pool, filters, onEvent, onComplete, timeout = 10000, updateProgress, eventFilter } = config
|
const timeout = config.timeout ?? 10000
|
||||||
|
const { subscription, relayUrl } = await createSubscriptionWithRelayRotation({
|
||||||
const events: Event[] = []
|
pool: config.pool,
|
||||||
let sub: ReturnType<typeof createSubscription> | null = null
|
filters: config.filters,
|
||||||
let usedRelayUrl = ''
|
updateProgress: config.updateProgress,
|
||||||
|
})
|
||||||
// Try relays with rotation
|
return collectSubscriptionEvents({
|
||||||
try {
|
subscription,
|
||||||
const result = await tryWithRelayRotation(
|
relayUrl,
|
||||||
pool as unknown as SimplePool,
|
timeout,
|
||||||
async (relayUrl, poolWithSub) => {
|
onEvent: config.onEvent,
|
||||||
usedRelayUrl = relayUrl
|
onComplete: config.onComplete,
|
||||||
|
eventFilter: config.eventFilter,
|
||||||
// Update progress if callback provided
|
})
|
||||||
if (updateProgress) {
|
}
|
||||||
updateProgress(relayUrl)
|
|
||||||
} else {
|
async function createSubscriptionWithRelayRotation(params: {
|
||||||
// Default: notify progress manager
|
pool: SimplePoolWithSub
|
||||||
const { syncProgressManager } = await import('../syncProgressManager')
|
filters: Filter[]
|
||||||
const currentProgress = syncProgressManager.getProgress()
|
updateProgress: ((relayUrl: string) => void) | undefined
|
||||||
if (currentProgress) {
|
}): Promise<{ subscription: ReturnType<typeof createSubscription>; relayUrl: string }> {
|
||||||
syncProgressManager.setProgress({
|
try {
|
||||||
...currentProgress,
|
let usedRelayUrl = ''
|
||||||
currentStep: 0,
|
const subscription = await tryWithRelayRotation(
|
||||||
currentRelay: relayUrl,
|
params.pool as unknown as SimplePool,
|
||||||
})
|
async (relayUrl, poolWithSub) => {
|
||||||
}
|
usedRelayUrl = relayUrl
|
||||||
}
|
await updateSyncProgress(relayUrl, params.updateProgress)
|
||||||
|
return createSubscription(poolWithSub, [relayUrl], params.filters)
|
||||||
return createSubscription(poolWithSub, [relayUrl], filters)
|
},
|
||||||
},
|
5000
|
||||||
5000 // 5 second timeout per relay
|
)
|
||||||
)
|
if (!usedRelayUrl) {
|
||||||
sub = result
|
throw new Error('Relay rotation did not return a relay URL')
|
||||||
} catch {
|
}
|
||||||
// Fallback to primary relay if rotation fails
|
return { subscription, relayUrl: usedRelayUrl }
|
||||||
usedRelayUrl = getPrimaryRelaySync()
|
} catch (error) {
|
||||||
sub = createSubscription(pool, [usedRelayUrl], filters)
|
console.warn('[createSyncSubscription] Relay rotation failed, falling back to primary relay:', error)
|
||||||
}
|
const relayUrl = getPrimaryRelaySync()
|
||||||
|
return { subscription: createSubscription(params.pool, [relayUrl], params.filters), relayUrl }
|
||||||
if (!sub) {
|
}
|
||||||
throw new Error('Failed to create subscription')
|
}
|
||||||
}
|
|
||||||
|
async function updateSyncProgress(relayUrl: string, updateProgress: ((relayUrl: string) => void) | undefined): Promise<void> {
|
||||||
return new Promise<SyncSubscriptionResult>((resolve) => {
|
if (updateProgress) {
|
||||||
let finished = false
|
updateProgress(relayUrl)
|
||||||
|
return
|
||||||
const done = async (): Promise<void> => {
|
}
|
||||||
if (finished) {
|
const { syncProgressManager } = await import('../syncProgressManager')
|
||||||
return
|
const currentProgress = syncProgressManager.getProgress()
|
||||||
}
|
if (!currentProgress) {
|
||||||
finished = true
|
return
|
||||||
sub?.unsub()
|
}
|
||||||
|
syncProgressManager.setProgress({
|
||||||
// Call onComplete callback if provided
|
...currentProgress,
|
||||||
if (onComplete) {
|
currentStep: 0,
|
||||||
await onComplete(events)
|
currentRelay: relayUrl,
|
||||||
}
|
})
|
||||||
|
}
|
||||||
resolve({
|
|
||||||
subscription: sub,
|
async function collectSubscriptionEvents(params: {
|
||||||
relayUrl: usedRelayUrl,
|
subscription: ReturnType<typeof createSubscription>
|
||||||
events,
|
relayUrl: string
|
||||||
})
|
timeout: number
|
||||||
}
|
onEvent: ((event: Event) => void | Promise<void>) | undefined
|
||||||
|
onComplete: ((events: Event[]) => void | Promise<void>) | undefined
|
||||||
// Handle events
|
eventFilter: ((event: Event) => boolean) | undefined
|
||||||
sub.on('event', (event: Event): void => {
|
}): Promise<SyncSubscriptionResult> {
|
||||||
// Apply event filter if provided
|
const events: Event[] = []
|
||||||
if (eventFilter && !eventFilter(event)) {
|
return new Promise<SyncSubscriptionResult>((resolve) => {
|
||||||
return
|
const finalize = createFinalizeHandler({ subscription: params.subscription, relayUrl: params.relayUrl, events, resolve, onComplete: params.onComplete })
|
||||||
}
|
registerSubscriptionEventHandlers({ subscription: params.subscription, events, onEvent: params.onEvent, eventFilter: params.eventFilter, finalize })
|
||||||
|
const timeoutId = setTimeout((): void => void finalize(), params.timeout)
|
||||||
events.push(event)
|
timeoutId.unref?.()
|
||||||
|
})
|
||||||
// Call onEvent callback if provided
|
}
|
||||||
if (onEvent) {
|
|
||||||
void onEvent(event)
|
function createFinalizeHandler(params: {
|
||||||
}
|
subscription: ReturnType<typeof createSubscription>
|
||||||
})
|
relayUrl: string
|
||||||
|
events: Event[]
|
||||||
// Handle end of stream
|
resolve: (value: SyncSubscriptionResult) => void
|
||||||
sub.on('eose', (): void => {
|
onComplete: ((events: Event[]) => void | Promise<void>) | undefined
|
||||||
void done()
|
}): () => Promise<void> {
|
||||||
})
|
let finished = false
|
||||||
|
return async (): Promise<void> => {
|
||||||
// Timeout fallback
|
if (finished) {
|
||||||
setTimeout((): void => {
|
return
|
||||||
void done()
|
}
|
||||||
}, timeout).unref?.()
|
finished = true
|
||||||
|
params.subscription.unsub()
|
||||||
|
if (params.onComplete) {
|
||||||
|
await params.onComplete(params.events)
|
||||||
|
}
|
||||||
|
params.resolve({ subscription: params.subscription, relayUrl: params.relayUrl, events: params.events })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSubscriptionEventHandlers(params: {
|
||||||
|
subscription: ReturnType<typeof createSubscription>
|
||||||
|
events: Event[]
|
||||||
|
onEvent: ((event: Event) => void | Promise<void>) | undefined
|
||||||
|
eventFilter: ((event: Event) => boolean) | undefined
|
||||||
|
finalize: () => Promise<void>
|
||||||
|
}): void {
|
||||||
|
params.subscription.on('event', (event: Event): void => {
|
||||||
|
if (params.eventFilter && !params.eventFilter(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.events.push(event)
|
||||||
|
if (params.onEvent) {
|
||||||
|
void params.onEvent(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
params.subscription.on('eose', (): void => {
|
||||||
|
void params.finalize()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -160,38 +160,11 @@ export async function extractAuthorFromEvent(event: Event): Promise<ExtractedAut
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract from tag first (new format)
|
const metadata = getMetadataFromEvent(event)
|
||||||
let metadata = extractMetadataJsonFromTag(event)
|
|
||||||
|
|
||||||
// Fallback to content format (for backward compatibility)
|
|
||||||
metadata ??= extractMetadataJson(event.content)
|
|
||||||
|
|
||||||
if (metadata?.type === 'author') {
|
if (metadata?.type === 'author') {
|
||||||
const authorData = {
|
const authorData = buildAuthorDataFromMetadata({ event, tags, metadata })
|
||||||
pubkey: (metadata.pubkey as string) ?? event.pubkey,
|
|
||||||
authorName: (metadata.authorName as string) ?? '',
|
|
||||||
presentation: (metadata.presentation as string) ?? '',
|
|
||||||
contentDescription: (metadata.contentDescription as string) ?? '',
|
|
||||||
mainnetAddress: metadata.mainnetAddress as string | undefined,
|
|
||||||
pictureUrl: metadata.pictureUrl as string | undefined,
|
|
||||||
category: (metadata.category as string) ?? tags.category ?? 'sciencefiction',
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = await generateAuthorHashId(authorData)
|
const id = await generateAuthorHashId(authorData)
|
||||||
|
return buildExtractedAuthor({ eventId: event.id, id, data: authorData, metadata })
|
||||||
return {
|
|
||||||
type: 'author',
|
|
||||||
id,
|
|
||||||
pubkey: authorData.pubkey,
|
|
||||||
authorName: authorData.authorName,
|
|
||||||
presentation: authorData.presentation,
|
|
||||||
contentDescription: authorData.contentDescription,
|
|
||||||
category: authorData.category,
|
|
||||||
eventId: event.id,
|
|
||||||
...(authorData.mainnetAddress ? { mainnetAddress: authorData.mainnetAddress } : {}),
|
|
||||||
...(authorData.pictureUrl ? { pictureUrl: authorData.pictureUrl } : {}),
|
|
||||||
...(metadata.url ? { url: metadata.url as string } : {}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: extract from tags and visible content
|
// Fallback: extract from tags and visible content
|
||||||
@ -199,6 +172,72 @@ export async function extractAuthorFromEvent(event: Event): Promise<ExtractedAut
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAuthorDataFromMetadata(params: {
|
||||||
|
event: Event
|
||||||
|
tags: ReturnType<typeof extractTagsFromEvent>
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
}): {
|
||||||
|
pubkey: string
|
||||||
|
authorName: string
|
||||||
|
presentation: string
|
||||||
|
contentDescription: string
|
||||||
|
mainnetAddress?: string
|
||||||
|
pictureUrl?: string
|
||||||
|
category: string
|
||||||
|
} {
|
||||||
|
const pubkey = firstString(params.metadata.pubkey, params.event.pubkey) ?? params.event.pubkey
|
||||||
|
const mainnetAddress = firstString(params.metadata.mainnetAddress)
|
||||||
|
const pictureUrl = firstString(params.metadata.pictureUrl)
|
||||||
|
return {
|
||||||
|
pubkey,
|
||||||
|
authorName: firstString(params.metadata.authorName) ?? '',
|
||||||
|
presentation: firstString(params.metadata.presentation) ?? '',
|
||||||
|
contentDescription: firstString(params.metadata.contentDescription) ?? '',
|
||||||
|
...(mainnetAddress ? { mainnetAddress } : {}),
|
||||||
|
...(pictureUrl ? { pictureUrl } : {}),
|
||||||
|
category: firstString(params.metadata.category, params.tags.category, 'sciencefiction') ?? 'sciencefiction',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExtractedAuthor(params: {
|
||||||
|
eventId: string
|
||||||
|
id: string
|
||||||
|
data: {
|
||||||
|
pubkey: string
|
||||||
|
authorName: string
|
||||||
|
presentation: string
|
||||||
|
contentDescription: string
|
||||||
|
mainnetAddress?: string
|
||||||
|
pictureUrl?: string
|
||||||
|
category: string
|
||||||
|
}
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
}): ExtractedAuthor {
|
||||||
|
const url = firstString(params.metadata.url)
|
||||||
|
return {
|
||||||
|
type: 'author',
|
||||||
|
id: params.id,
|
||||||
|
pubkey: params.data.pubkey,
|
||||||
|
authorName: params.data.authorName,
|
||||||
|
presentation: params.data.presentation,
|
||||||
|
contentDescription: params.data.contentDescription,
|
||||||
|
category: params.data.category,
|
||||||
|
eventId: params.eventId,
|
||||||
|
...(params.data.mainnetAddress ? { mainnetAddress: params.data.mainnetAddress } : {}),
|
||||||
|
...(params.data.pictureUrl ? { pictureUrl: params.data.pictureUrl } : {}),
|
||||||
|
...(url ? { url } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstString(...values: unknown[]): string | undefined {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract series from event
|
* Extract series from event
|
||||||
*/
|
*/
|
||||||
@ -208,35 +247,11 @@ export async function extractSeriesFromEvent(event: Event): Promise<ExtractedSer
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract from tag first (new format)
|
const metadata = getMetadataFromEvent(event)
|
||||||
let metadata = extractMetadataJsonFromTag(event)
|
|
||||||
|
|
||||||
// Fallback to content format (for backward compatibility)
|
|
||||||
metadata ??= extractMetadataJson(event.content)
|
|
||||||
|
|
||||||
if (metadata?.type === 'series') {
|
if (metadata?.type === 'series') {
|
||||||
const seriesData = {
|
const seriesData = buildSeriesDataFromMetadata({ event, tags, metadata })
|
||||||
pubkey: (metadata.pubkey as string) ?? event.pubkey,
|
|
||||||
title: (metadata.title as string) ?? (tags.title as string) ?? '',
|
|
||||||
description: (metadata.description as string) ?? '',
|
|
||||||
preview: (metadata.preview as string) ?? (tags.preview as string) ?? event.content.substring(0, 200),
|
|
||||||
coverUrl: (metadata.coverUrl as string) ?? (tags.coverUrl as string) ?? undefined,
|
|
||||||
category: (metadata.category as string) ?? tags.category ?? 'sciencefiction',
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = await generateSeriesHashId(seriesData)
|
const id = await generateSeriesHashId(seriesData)
|
||||||
|
return buildExtractedSeries({ eventId: event.id, id, data: seriesData })
|
||||||
return {
|
|
||||||
type: 'series',
|
|
||||||
id,
|
|
||||||
pubkey: seriesData.pubkey,
|
|
||||||
title: seriesData.title,
|
|
||||||
description: seriesData.description,
|
|
||||||
category: seriesData.category,
|
|
||||||
eventId: event.id,
|
|
||||||
...(seriesData.coverUrl ? { coverUrl: seriesData.coverUrl } : {}),
|
|
||||||
...(seriesData.preview ? { preview: seriesData.preview } : {}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: extract from tags
|
// Fallback: extract from tags
|
||||||
@ -246,23 +261,15 @@ export async function extractSeriesFromEvent(event: Event): Promise<ExtractedSer
|
|||||||
title: tags.title,
|
title: tags.title,
|
||||||
description: tags.description,
|
description: tags.description,
|
||||||
preview: (tags.preview as string) ?? event.content.substring(0, 200),
|
preview: (tags.preview as string) ?? event.content.substring(0, 200),
|
||||||
coverUrl: tags.coverUrl,
|
|
||||||
category: tags.category ?? 'sciencefiction',
|
category: tags.category ?? 'sciencefiction',
|
||||||
}
|
}
|
||||||
|
const seriesDataWithOptionals = {
|
||||||
const id = await generateSeriesHashId(seriesData)
|
...seriesData,
|
||||||
|
...(tags.coverUrl ? { coverUrl: tags.coverUrl } : {}),
|
||||||
return {
|
|
||||||
type: 'series',
|
|
||||||
id,
|
|
||||||
pubkey: seriesData.pubkey,
|
|
||||||
title: seriesData.title,
|
|
||||||
description: seriesData.description,
|
|
||||||
category: seriesData.category,
|
|
||||||
eventId: event.id,
|
|
||||||
...(seriesData.coverUrl ? { coverUrl: seriesData.coverUrl } : {}),
|
|
||||||
...(seriesData.preview ? { preview: seriesData.preview } : {}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const id = await generateSeriesHashId(seriesDataWithOptionals)
|
||||||
|
return buildExtractedSeries({ eventId: event.id, id, data: seriesDataWithOptionals })
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@ -277,41 +284,12 @@ export async function extractPublicationFromEvent(event: Event): Promise<Extract
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract from tag first (new format)
|
const metadata = getMetadataFromEvent(event)
|
||||||
let metadata = extractMetadataJsonFromTag(event)
|
|
||||||
|
|
||||||
// Fallback to content format (for backward compatibility)
|
|
||||||
metadata ??= extractMetadataJson(event.content)
|
|
||||||
|
|
||||||
if (metadata?.type === 'publication') {
|
if (metadata?.type === 'publication') {
|
||||||
const publicationData = {
|
const publicationData = buildPublicationDataFromMetadata({ event, tags, metadata })
|
||||||
pubkey: (metadata.pubkey as string) ?? event.pubkey,
|
|
||||||
title: (metadata.title as string) ?? (tags.title as string) ?? '',
|
|
||||||
preview: (metadata.preview as string) ?? (tags.preview as string) ?? event.content.substring(0, 200),
|
|
||||||
category: (metadata.category as string) ?? tags.category ?? 'sciencefiction',
|
|
||||||
seriesId: (metadata.seriesId as string) ?? tags.seriesId ?? undefined,
|
|
||||||
bannerUrl: (metadata.bannerUrl as string) ?? tags.bannerUrl ?? undefined,
|
|
||||||
zapAmount: (metadata.zapAmount as number) ?? tags.zapAmount ?? 800,
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = await generatePublicationHashId(publicationData)
|
const id = await generatePublicationHashId(publicationData)
|
||||||
|
const pages = readPublicationPages(metadata)
|
||||||
// Extract pages from metadata if present
|
return buildExtractedPublication({ eventId: event.id, id, data: publicationData, pages })
|
||||||
const pages = metadata.pages as Array<{ number: number; type: 'markdown' | 'image'; content: string }> | undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'publication',
|
|
||||||
id,
|
|
||||||
pubkey: publicationData.pubkey,
|
|
||||||
title: publicationData.title,
|
|
||||||
preview: publicationData.preview,
|
|
||||||
category: publicationData.category,
|
|
||||||
zapAmount: publicationData.zapAmount,
|
|
||||||
eventId: event.id,
|
|
||||||
...(publicationData.seriesId ? { seriesId: publicationData.seriesId } : {}),
|
|
||||||
...(publicationData.bannerUrl ? { bannerUrl: publicationData.bannerUrl } : {}),
|
|
||||||
...(pages && pages.length > 0 ? { pages } : {}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: extract from tags
|
// Fallback: extract from tags
|
||||||
@ -321,25 +299,16 @@ export async function extractPublicationFromEvent(event: Event): Promise<Extract
|
|||||||
title: tags.title,
|
title: tags.title,
|
||||||
preview: (tags.preview as string) ?? event.content.substring(0, 200),
|
preview: (tags.preview as string) ?? event.content.substring(0, 200),
|
||||||
category: tags.category ?? 'sciencefiction',
|
category: tags.category ?? 'sciencefiction',
|
||||||
seriesId: tags.seriesId,
|
|
||||||
bannerUrl: tags.bannerUrl,
|
|
||||||
zapAmount: tags.zapAmount ?? 800,
|
zapAmount: tags.zapAmount ?? 800,
|
||||||
}
|
}
|
||||||
|
const publicationDataWithOptionals = {
|
||||||
const id = await generatePublicationHashId(publicationData)
|
...publicationData,
|
||||||
|
...(tags.seriesId ? { seriesId: tags.seriesId } : {}),
|
||||||
return {
|
...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl } : {}),
|
||||||
type: 'publication',
|
|
||||||
id,
|
|
||||||
pubkey: publicationData.pubkey,
|
|
||||||
title: publicationData.title,
|
|
||||||
preview: publicationData.preview,
|
|
||||||
category: publicationData.category,
|
|
||||||
zapAmount: publicationData.zapAmount,
|
|
||||||
eventId: event.id,
|
|
||||||
...(publicationData.seriesId ? { seriesId: publicationData.seriesId } : {}),
|
|
||||||
...(publicationData.bannerUrl ? { bannerUrl: publicationData.bannerUrl } : {}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const id = await generatePublicationHashId(publicationDataWithOptionals)
|
||||||
|
return buildExtractedPublication({ eventId: event.id, id, data: publicationDataWithOptionals, pages: undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@ -354,217 +323,402 @@ export async function extractReviewFromEvent(event: Event): Promise<ExtractedRev
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract from tag first (new format)
|
const metadata = getMetadataFromEvent(event)
|
||||||
let metadata = extractMetadataJsonFromTag(event)
|
const fromMetadata = await extractReviewFromMetadata({ event, tags, metadata })
|
||||||
|
if (fromMetadata) {
|
||||||
// Fallback to content format (for backward compatibility)
|
return fromMetadata
|
||||||
metadata ??= extractMetadataJson(event.content)
|
|
||||||
|
|
||||||
if (metadata?.type === 'review') {
|
|
||||||
const reviewData = {
|
|
||||||
pubkey: (metadata.pubkey as string) ?? event.pubkey,
|
|
||||||
articleId: (metadata.articleId as string) ?? (tags.articleId as string) ?? '',
|
|
||||||
reviewerPubkey: (metadata.reviewerPubkey as string) ?? (tags.reviewerPubkey as string) ?? event.pubkey,
|
|
||||||
content: (metadata.content as string) ?? event.content,
|
|
||||||
title: (metadata.title as string) ?? (tags.title as string) ?? undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!reviewData.articleId || !reviewData.reviewerPubkey) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = await generateReviewHashId(reviewData)
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'review',
|
|
||||||
id,
|
|
||||||
...reviewData,
|
|
||||||
eventId: event.id,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: extract from tags
|
return extractReviewFromTags({ event, tags })
|
||||||
if (tags.articleId && tags.reviewerPubkey) {
|
}
|
||||||
const reviewData = {
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
articleId: tags.articleId,
|
|
||||||
reviewerPubkey: tags.reviewerPubkey,
|
|
||||||
content: event.content,
|
|
||||||
title: tags.title,
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = await generateReviewHashId({
|
async function extractReviewFromMetadata(params: {
|
||||||
pubkey: reviewData.pubkey,
|
event: Event
|
||||||
articleId: reviewData.articleId,
|
tags: ReturnType<typeof extractTagsFromEvent>
|
||||||
reviewerPubkey: reviewData.reviewerPubkey,
|
metadata: Record<string, unknown> | null
|
||||||
content: reviewData.content,
|
}): Promise<ExtractedReview | null> {
|
||||||
...(reviewData.title ? { title: reviewData.title } : {}),
|
if (params.metadata?.type !== 'review') {
|
||||||
})
|
return null
|
||||||
|
}
|
||||||
|
const reviewData = buildReviewDataFromMetadata({ event: params.event, tags: params.tags, metadata: params.metadata })
|
||||||
|
if (!reviewData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const id = await generateReviewHashId(reviewData)
|
||||||
|
return { type: 'review', id, ...reviewData, eventId: params.event.id }
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
async function extractReviewFromTags(params: {
|
||||||
type: 'review',
|
event: Event
|
||||||
id,
|
tags: ReturnType<typeof extractTagsFromEvent>
|
||||||
pubkey: reviewData.pubkey,
|
}): Promise<ExtractedReview | null> {
|
||||||
articleId: reviewData.articleId,
|
if (!params.tags.articleId || !params.tags.reviewerPubkey) {
|
||||||
reviewerPubkey: reviewData.reviewerPubkey,
|
return null
|
||||||
content: reviewData.content,
|
}
|
||||||
eventId: event.id,
|
const base = {
|
||||||
...(reviewData.title ? { title: reviewData.title } : {}),
|
pubkey: params.event.pubkey,
|
||||||
}
|
articleId: params.tags.articleId,
|
||||||
|
reviewerPubkey: params.tags.reviewerPubkey,
|
||||||
|
content: params.event.content,
|
||||||
|
...(params.tags.title ? { title: params.tags.title } : {}),
|
||||||
|
}
|
||||||
|
return buildExtractedReviewFromTags({ ...base, eventId: params.event.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildExtractedReviewFromTags(params: {
|
||||||
|
pubkey: string
|
||||||
|
articleId: string
|
||||||
|
reviewerPubkey: string
|
||||||
|
content: string
|
||||||
|
title?: string
|
||||||
|
eventId: string
|
||||||
|
}): Promise<ExtractedReview> {
|
||||||
|
const id = await generateReviewHashId({
|
||||||
|
pubkey: params.pubkey,
|
||||||
|
articleId: params.articleId,
|
||||||
|
reviewerPubkey: params.reviewerPubkey,
|
||||||
|
content: params.content,
|
||||||
|
...(params.title ? { title: params.title } : {}),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
type: 'review',
|
||||||
|
id,
|
||||||
|
pubkey: params.pubkey,
|
||||||
|
articleId: params.articleId,
|
||||||
|
reviewerPubkey: params.reviewerPubkey,
|
||||||
|
content: params.content,
|
||||||
|
eventId: params.eventId,
|
||||||
|
...(params.title ? { title: params.title } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetadataFromEvent(event: Event): Record<string, unknown> | null {
|
||||||
|
return extractMetadataJsonFromTag(event) ?? extractMetadataJson(event.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSeriesDataFromMetadata(params: {
|
||||||
|
event: Event
|
||||||
|
tags: ReturnType<typeof extractTagsFromEvent>
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
}): { pubkey: string; title: string; description: string; preview: string; coverUrl?: string; category: string } {
|
||||||
|
const title = firstString(params.metadata.title, params.tags.title) ?? ''
|
||||||
|
const preview = firstString(params.metadata.preview, params.tags.preview) ?? params.event.content.substring(0, 200)
|
||||||
|
const pubkey = firstString(params.metadata.pubkey, params.event.pubkey) ?? params.event.pubkey
|
||||||
|
const coverUrl = firstString(params.metadata.coverUrl, params.tags.coverUrl)
|
||||||
|
|
||||||
|
const result: { pubkey: string; title: string; description: string; preview: string; coverUrl?: string; category: string } = {
|
||||||
|
pubkey,
|
||||||
|
title,
|
||||||
|
description: firstString(params.metadata.description) ?? '',
|
||||||
|
preview,
|
||||||
|
category: firstString(params.metadata.category, params.tags.category, 'sciencefiction') ?? 'sciencefiction',
|
||||||
|
}
|
||||||
|
if (coverUrl) {
|
||||||
|
result.coverUrl = coverUrl
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExtractedSeries(params: {
|
||||||
|
eventId: string
|
||||||
|
id: string
|
||||||
|
data: { pubkey: string; title: string; description: string; preview: string; coverUrl?: string; category: string }
|
||||||
|
}): ExtractedSeries {
|
||||||
|
return {
|
||||||
|
type: 'series',
|
||||||
|
id: params.id,
|
||||||
|
pubkey: params.data.pubkey,
|
||||||
|
title: params.data.title,
|
||||||
|
description: params.data.description,
|
||||||
|
category: params.data.category,
|
||||||
|
eventId: params.eventId,
|
||||||
|
...(params.data.coverUrl ? { coverUrl: params.data.coverUrl } : {}),
|
||||||
|
...(params.data.preview ? { preview: params.data.preview } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicationDataFromMetadata(params: {
|
||||||
|
event: Event
|
||||||
|
tags: ReturnType<typeof extractTagsFromEvent>
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
}): {
|
||||||
|
pubkey: string
|
||||||
|
title: string
|
||||||
|
preview: string
|
||||||
|
category: string
|
||||||
|
seriesId?: string
|
||||||
|
bannerUrl?: string
|
||||||
|
zapAmount: number
|
||||||
|
} {
|
||||||
|
const result: {
|
||||||
|
pubkey: string
|
||||||
|
title: string
|
||||||
|
preview: string
|
||||||
|
category: string
|
||||||
|
seriesId?: string
|
||||||
|
bannerUrl?: string
|
||||||
|
zapAmount: number
|
||||||
|
} = {
|
||||||
|
pubkey: firstString(params.metadata.pubkey, params.event.pubkey) ?? params.event.pubkey,
|
||||||
|
title: firstString(params.metadata.title, params.tags.title) ?? '',
|
||||||
|
preview: firstString(params.metadata.preview, params.tags.preview) ?? params.event.content.substring(0, 200),
|
||||||
|
category: firstString(params.metadata.category, params.tags.category, 'sciencefiction') ?? 'sciencefiction',
|
||||||
|
zapAmount: firstNumber(params.metadata.zapAmount, params.tags.zapAmount) ?? 800,
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
const seriesId = firstString(params.metadata.seriesId, params.tags.seriesId)
|
||||||
|
if (seriesId) {
|
||||||
|
result.seriesId = seriesId
|
||||||
|
}
|
||||||
|
const bannerUrl = firstString(params.metadata.bannerUrl, params.tags.bannerUrl)
|
||||||
|
if (bannerUrl) {
|
||||||
|
result.bannerUrl = bannerUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPublicationPages(metadata: Record<string, unknown>): Array<{ number: number; type: 'markdown' | 'image'; content: string }> | undefined {
|
||||||
|
const pages = metadata.pages as Array<{ number: number; type: 'markdown' | 'image'; content: string }> | undefined
|
||||||
|
return pages && Array.isArray(pages) && pages.length > 0 ? pages : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExtractedPublication(params: {
|
||||||
|
eventId: string
|
||||||
|
id: string
|
||||||
|
data: {
|
||||||
|
pubkey: string
|
||||||
|
title: string
|
||||||
|
preview: string
|
||||||
|
category: string
|
||||||
|
seriesId?: string
|
||||||
|
bannerUrl?: string
|
||||||
|
zapAmount: number
|
||||||
|
}
|
||||||
|
pages: Array<{ number: number; type: 'markdown' | 'image'; content: string }> | undefined
|
||||||
|
}): ExtractedPublication {
|
||||||
|
return {
|
||||||
|
type: 'publication',
|
||||||
|
id: params.id,
|
||||||
|
pubkey: params.data.pubkey,
|
||||||
|
title: params.data.title,
|
||||||
|
preview: params.data.preview,
|
||||||
|
category: params.data.category,
|
||||||
|
zapAmount: params.data.zapAmount,
|
||||||
|
eventId: params.eventId,
|
||||||
|
...(params.data.seriesId ? { seriesId: params.data.seriesId } : {}),
|
||||||
|
...(params.data.bannerUrl ? { bannerUrl: params.data.bannerUrl } : {}),
|
||||||
|
...(params.pages ? { pages: params.pages } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReviewDataFromMetadata(params: {
|
||||||
|
event: Event
|
||||||
|
tags: ReturnType<typeof extractTagsFromEvent>
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
}): { pubkey: string; articleId: string; reviewerPubkey: string; content: string; title?: string } | null {
|
||||||
|
const articleId = firstString(params.metadata.articleId, params.tags.articleId) ?? ''
|
||||||
|
const reviewerPubkey = firstString(params.metadata.reviewerPubkey, params.tags.reviewerPubkey, params.event.pubkey) ?? params.event.pubkey
|
||||||
|
if (!articleId || !reviewerPubkey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const title = firstString(params.metadata.title, params.tags.title)
|
||||||
|
const base: { pubkey: string; articleId: string; reviewerPubkey: string; content: string; title?: string } = {
|
||||||
|
pubkey: firstString(params.metadata.pubkey, params.event.pubkey) ?? params.event.pubkey,
|
||||||
|
articleId,
|
||||||
|
reviewerPubkey,
|
||||||
|
content: firstString(params.metadata.content, params.event.content) ?? params.event.content,
|
||||||
|
}
|
||||||
|
if (title) {
|
||||||
|
base.title = title
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNumber(...values: unknown[]): number | undefined {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract purchase from zap receipt (kind 9735)
|
* Extract purchase from zap receipt (kind 9735)
|
||||||
*/
|
*/
|
||||||
export async function extractPurchaseFromEvent(event: Event): Promise<ExtractedPurchase | null> {
|
export async function extractPurchaseFromEvent(event: Event): Promise<ExtractedPurchase | null> {
|
||||||
if (event.kind !== 9735) {
|
const kind = readZapReceiptKind(event)
|
||||||
|
if (kind !== 'purchase') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for purchase kind_type tag
|
const authorPubkey = readTagValue(event, 'p')
|
||||||
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'purchase')
|
const articleId = readTagValue(event, 'e')
|
||||||
if (!kindTypeTag) {
|
const amountSats = readAmountSats(event)
|
||||||
|
if (!authorPubkey || !articleId || amountSats === undefined) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1]
|
|
||||||
const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1]
|
|
||||||
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
|
|
||||||
const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1]
|
|
||||||
|
|
||||||
if (!pTag || !eTag || !amountTag) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const amount = parseInt(amountTag, 10) / 1000 // Convert millisats to sats
|
|
||||||
const paymentHash = paymentHashTag ?? event.id // Use event.id as fallback
|
|
||||||
|
|
||||||
const purchaseData = {
|
const purchaseData = {
|
||||||
payerPubkey: event.pubkey,
|
payerPubkey: event.pubkey,
|
||||||
articleId: eTag,
|
articleId,
|
||||||
authorPubkey: pTag,
|
authorPubkey,
|
||||||
amount,
|
amount: amountSats,
|
||||||
paymentHash,
|
paymentHash: readPaymentHash(event),
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = await generatePurchaseHashId(purchaseData)
|
const id = await generatePurchaseHashId(purchaseData)
|
||||||
|
return { type: 'purchase', id, ...purchaseData, eventId: event.id }
|
||||||
return {
|
|
||||||
type: 'purchase',
|
|
||||||
id,
|
|
||||||
...purchaseData,
|
|
||||||
eventId: event.id,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract review tip from zap receipt (kind 9735)
|
* Extract review tip from zap receipt (kind 9735)
|
||||||
*/
|
*/
|
||||||
export async function extractReviewTipFromEvent(event: Event): Promise<ExtractedReviewTip | null> {
|
export async function extractReviewTipFromEvent(event: Event): Promise<ExtractedReviewTip | null> {
|
||||||
if (event.kind !== 9735) {
|
const kind = readZapReceiptKind(event)
|
||||||
|
if (kind !== 'review_tip') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'review_tip')
|
const authorPubkey = readTagValue(event, 'p')
|
||||||
if (!kindTypeTag) {
|
const articleId = readTagValue(event, 'e')
|
||||||
|
const reviewId = readTagValue(event, 'review_id')
|
||||||
|
const reviewerPubkey = readTagValue(event, 'reviewer')
|
||||||
|
const amountSats = readAmountSats(event)
|
||||||
|
if (!authorPubkey || !articleId || !reviewId || !reviewerPubkey || amountSats === undefined) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1]
|
|
||||||
const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1]
|
|
||||||
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
|
|
||||||
const reviewerTag = event.tags.find((tag) => tag[0] === 'reviewer')?.[1]
|
|
||||||
const reviewIdTag = event.tags.find((tag) => tag[0] === 'review_id')?.[1]
|
|
||||||
const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1]
|
|
||||||
|
|
||||||
if (!pTag || !eTag || !amountTag || !reviewerTag || !reviewIdTag) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const amount = parseInt(amountTag, 10) / 1000
|
|
||||||
const paymentHash = paymentHashTag ?? event.id
|
|
||||||
|
|
||||||
const tipData = {
|
const tipData = {
|
||||||
payerPubkey: event.pubkey,
|
payerPubkey: event.pubkey,
|
||||||
articleId: eTag,
|
articleId,
|
||||||
reviewId: reviewIdTag,
|
reviewId,
|
||||||
reviewerPubkey: reviewerTag,
|
reviewerPubkey,
|
||||||
authorPubkey: pTag,
|
authorPubkey,
|
||||||
amount,
|
amount: amountSats,
|
||||||
paymentHash,
|
paymentHash: readPaymentHash(event),
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = await generateReviewTipHashId(tipData)
|
const id = await generateReviewTipHashId(tipData)
|
||||||
|
return { type: 'review_tip', id, ...tipData, eventId: event.id }
|
||||||
return {
|
|
||||||
type: 'review_tip',
|
|
||||||
id,
|
|
||||||
...tipData,
|
|
||||||
eventId: event.id,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract sponsoring from zap receipt (kind 9735)
|
* Extract sponsoring from zap receipt (kind 9735)
|
||||||
*/
|
*/
|
||||||
export async function extractSponsoringFromEvent(event: Event): Promise<ExtractedSponsoring | null> {
|
export async function extractSponsoringFromEvent(event: Event): Promise<ExtractedSponsoring | null> {
|
||||||
if (event.kind !== 9735) {
|
const kind = readZapReceiptKind(event)
|
||||||
|
if (kind !== 'sponsoring') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'sponsoring')
|
const authorPubkey = readTagValue(event, 'p')
|
||||||
if (!kindTypeTag) {
|
const amountSats = readAmountSats(event)
|
||||||
|
if (!authorPubkey || amountSats === undefined) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1]
|
const sponsoringData = buildSponsoringData({ event, authorPubkey, amountSats })
|
||||||
const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1]
|
const id = await generateSponsoringHashId(buildSponsoringHashInput(sponsoringData))
|
||||||
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
|
return buildExtractedSponsoring({ id, eventId: event.id, sponsoringData })
|
||||||
const seriesTag = event.tags.find((tag) => tag[0] === 'series')?.[1]
|
}
|
||||||
const articleTag = event.tags.find((tag) => tag[0] === 'article')?.[1]
|
|
||||||
const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1]
|
|
||||||
|
|
||||||
if (!pTag || !amountTag) {
|
function buildSponsoringData(params: { event: Event; authorPubkey: string; amountSats: number }): {
|
||||||
return null
|
payerPubkey: string
|
||||||
|
authorPubkey: string
|
||||||
|
seriesId: string | undefined
|
||||||
|
articleId: string | undefined
|
||||||
|
amount: number
|
||||||
|
paymentHash: string
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
payerPubkey: params.event.pubkey,
|
||||||
|
authorPubkey: params.authorPubkey,
|
||||||
|
seriesId: readTagValue(params.event, 'series'),
|
||||||
|
articleId: resolveSponsoringArticleId({
|
||||||
|
articleTag: readTagValue(params.event, 'article'),
|
||||||
|
eTag: readTagValue(params.event, 'e'),
|
||||||
|
}),
|
||||||
|
amount: params.amountSats,
|
||||||
|
paymentHash: readPaymentHash(params.event),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const amount = parseInt(amountTag, 10) / 1000
|
function buildSponsoringHashInput(params: {
|
||||||
const paymentHash = paymentHashTag ?? event.id
|
payerPubkey: string
|
||||||
|
authorPubkey: string
|
||||||
const sponsoringData = {
|
seriesId: string | undefined
|
||||||
payerPubkey: event.pubkey,
|
articleId: string | undefined
|
||||||
authorPubkey: pTag,
|
amount: number
|
||||||
seriesId: seriesTag,
|
paymentHash: string
|
||||||
articleId: articleTag ?? eTag, // Use eTag as fallback for articleId
|
}): Parameters<typeof generateSponsoringHashId>[0] {
|
||||||
amount,
|
return {
|
||||||
paymentHash,
|
payerPubkey: params.payerPubkey,
|
||||||
|
authorPubkey: params.authorPubkey,
|
||||||
|
amount: params.amount,
|
||||||
|
paymentHash: params.paymentHash,
|
||||||
|
...(params.seriesId ? { seriesId: params.seriesId } : {}),
|
||||||
|
...(params.articleId ? { articleId: params.articleId } : {}),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const id = await generateSponsoringHashId({
|
function buildExtractedSponsoring(params: {
|
||||||
payerPubkey: sponsoringData.payerPubkey,
|
id: string
|
||||||
authorPubkey: sponsoringData.authorPubkey,
|
eventId: string
|
||||||
amount: sponsoringData.amount,
|
sponsoringData: {
|
||||||
paymentHash: sponsoringData.paymentHash,
|
payerPubkey: string
|
||||||
...(sponsoringData.seriesId ? { seriesId: sponsoringData.seriesId } : {}),
|
authorPubkey: string
|
||||||
...(sponsoringData.articleId ? { articleId: sponsoringData.articleId } : {}),
|
seriesId: string | undefined
|
||||||
})
|
articleId: string | undefined
|
||||||
|
amount: number
|
||||||
|
paymentHash: string
|
||||||
|
}
|
||||||
|
}): ExtractedSponsoring {
|
||||||
return {
|
return {
|
||||||
type: 'sponsoring',
|
type: 'sponsoring',
|
||||||
id,
|
id: params.id,
|
||||||
payerPubkey: sponsoringData.payerPubkey,
|
payerPubkey: params.sponsoringData.payerPubkey,
|
||||||
authorPubkey: sponsoringData.authorPubkey,
|
authorPubkey: params.sponsoringData.authorPubkey,
|
||||||
amount: sponsoringData.amount,
|
amount: params.sponsoringData.amount,
|
||||||
paymentHash: sponsoringData.paymentHash,
|
paymentHash: params.sponsoringData.paymentHash,
|
||||||
eventId: event.id,
|
eventId: params.eventId,
|
||||||
...(sponsoringData.seriesId ? { seriesId: sponsoringData.seriesId } : {}),
|
...(params.sponsoringData.seriesId ? { seriesId: params.sponsoringData.seriesId } : {}),
|
||||||
...(sponsoringData.articleId ? { articleId: sponsoringData.articleId } : {}),
|
...(params.sponsoringData.articleId ? { articleId: params.sponsoringData.articleId } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readTagValue(event: Event, key: string): string | undefined {
|
||||||
|
return event.tags.find((tag) => tag[0] === key)?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function readZapReceiptKind(event: Event): string | undefined {
|
||||||
|
if (event.kind !== 9735) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return readTagValue(event, 'kind_type')
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAmountSats(event: Event): number | undefined {
|
||||||
|
const amountTag = readTagValue(event, 'amount')
|
||||||
|
if (!amountTag) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const millisats = parseInt(amountTag, 10)
|
||||||
|
if (Number.isNaN(millisats)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return millisats / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPaymentHash(event: Event): string {
|
||||||
|
return readTagValue(event, 'payment_hash') ?? event.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSponsoringArticleId(params: { articleTag: string | undefined; eTag: string | undefined }): string | undefined {
|
||||||
|
return params.articleTag ?? params.eTag
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract all objects from an event
|
* Extract all objects from an event
|
||||||
*/
|
*/
|
||||||
|
|||||||
58
lib/nip98.ts
58
lib/nip98.ts
@ -18,54 +18,62 @@ import { nostrAuthService } from './nostrAuth'
|
|||||||
* @returns Base64-encoded signed event token
|
* @returns Base64-encoded signed event token
|
||||||
*/
|
*/
|
||||||
export async function generateNip98Token(method: string, url: string, payloadHash?: string): Promise<string> {
|
export async function generateNip98Token(method: string, url: string, payloadHash?: string): Promise<string> {
|
||||||
|
const pubkey = getPubkeyOrThrow()
|
||||||
|
const privateKey = getPrivateKeyOrThrow()
|
||||||
|
const eventTemplate = buildNip98EventTemplate({ method, url, payloadHash, pubkey })
|
||||||
|
const signedEvent = finalizeEvent(eventTemplate, hexToBytes(privateKey))
|
||||||
|
return encodeEventAsBase64Json(signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPubkeyOrThrow(): string {
|
||||||
const pubkey = nostrService.getPublicKey()
|
const pubkey = nostrService.getPublicKey()
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
throw new Error('Public key required for NIP-98 authentication. Please unlock your account.')
|
throw new Error('Public key required for NIP-98 authentication. Please unlock your account.')
|
||||||
}
|
}
|
||||||
|
return pubkey
|
||||||
|
}
|
||||||
|
|
||||||
// Check if private key is available (unlocked)
|
function getPrivateKeyOrThrow(): string {
|
||||||
if (!nostrAuthService.isUnlocked()) {
|
if (!nostrAuthService.isUnlocked()) {
|
||||||
throw new Error('Private key required for NIP-98 authentication. Please unlock your account with your recovery phrase.')
|
throw new Error(
|
||||||
|
'Private key required for NIP-98 authentication. Please unlock your account with your recovery phrase.'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const privateKey = nostrAuthService.getPrivateKey()
|
const privateKey = nostrAuthService.getPrivateKey()
|
||||||
if (!privateKey) {
|
if (!privateKey) {
|
||||||
throw new Error('Private key not available. Please unlock your account.')
|
throw new Error('Private key not available. Please unlock your account.')
|
||||||
}
|
}
|
||||||
|
return privateKey
|
||||||
|
}
|
||||||
|
|
||||||
// Parse URL to get components
|
function buildNip98EventTemplate(params: {
|
||||||
const urlObj = new URL(url)
|
method: string
|
||||||
|
url: string
|
||||||
|
payloadHash: string | undefined
|
||||||
|
pubkey: string
|
||||||
|
}): EventTemplate & { pubkey: string } {
|
||||||
|
const urlObj = new URL(params.url)
|
||||||
const path = urlObj.pathname + urlObj.search
|
const path = urlObj.pathname + urlObj.search
|
||||||
|
|
||||||
// Build event template for NIP-98
|
|
||||||
const tags: string[][] = [
|
const tags: string[][] = [
|
||||||
['u', urlObj.origin + path],
|
['u', urlObj.origin + path],
|
||||||
['method', method],
|
['method', params.method],
|
||||||
]
|
]
|
||||||
|
if (params.payloadHash) {
|
||||||
// Add payload hash if provided (for POST/PUT requests)
|
tags.push(['payload', params.payloadHash])
|
||||||
if (payloadHash) {
|
|
||||||
tags.push(['payload', payloadHash])
|
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
const eventTemplate: EventTemplate & { pubkey: string } = {
|
kind: 27235,
|
||||||
kind: 27235, // NIP-98 kind for HTTP auth
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags,
|
tags,
|
||||||
content: '',
|
content: '',
|
||||||
pubkey,
|
pubkey: params.pubkey,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sign the event directly with the private key (no plugin needed)
|
function encodeEventAsBase64Json(event: unknown): string {
|
||||||
const secretKey = hexToBytes(privateKey)
|
const eventJson = JSON.stringify(event)
|
||||||
const signedEvent = finalizeEvent(eventTemplate, secretKey)
|
|
||||||
|
|
||||||
// Encode event as base64 JSON
|
|
||||||
const eventJson = JSON.stringify(signedEvent)
|
|
||||||
const eventBytes = new TextEncoder().encode(eventJson)
|
const eventBytes = new TextEncoder().encode(eventJson)
|
||||||
const base64Token = globalThis.btoa(String.fromCharCode(...eventBytes))
|
return globalThis.btoa(String.fromCharCode(...eventBytes))
|
||||||
|
|
||||||
return base64Token
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -27,46 +27,34 @@ export async function parseArticleFromEvent(event: Event): Promise<Article | nul
|
|||||||
export async function parseSeriesFromEvent(event: Event): Promise<Series | null> {
|
export async function parseSeriesFromEvent(event: Event): Promise<Series | null> {
|
||||||
try {
|
try {
|
||||||
const tags = extractTagsFromEvent(event)
|
const tags = extractTagsFromEvent(event)
|
||||||
// Check if it's a series type (tag is 'series' in English)
|
const input = readSeriesInput({ tags, eventContent: event.content })
|
||||||
if (tags.type !== 'series') {
|
if (!input) {
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (!tags.title || !tags.description) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const category = mapNostrCategoryToLegacy(tags.category) ?? 'science-fiction'
|
const category = mapNostrCategoryToLegacy(tags.category) ?? 'science-fiction'
|
||||||
|
|
||||||
const { hash, version, index } = await resolveObjectIdParts({
|
const { hash, version, index } = await resolveObjectIdParts({
|
||||||
...(tags.id ? { idTag: tags.id } : {}),
|
...(input.idTag ? { idTag: input.idTag } : {}),
|
||||||
defaultVersion: tags.version ?? 0,
|
defaultVersion: input.defaultVersion,
|
||||||
defaultIndex: 0,
|
defaultIndex: 0,
|
||||||
generateHash: async (): Promise<string> => generateHashId({
|
generateHash: async (): Promise<string> => generateHashId({
|
||||||
type: 'series',
|
type: 'series',
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
title: tags.title,
|
title: input.title,
|
||||||
description: tags.description,
|
description: input.description,
|
||||||
category: tags.category ?? 'sciencefiction',
|
category: input.categoryTag,
|
||||||
coverUrl: tags.coverUrl ?? '',
|
coverUrl: input.coverUrl,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const id = buildObjectId(hash, index, version)
|
return buildSeriesFromParsed({
|
||||||
|
event,
|
||||||
const series: Series = {
|
input,
|
||||||
id,
|
|
||||||
hash,
|
hash,
|
||||||
version,
|
version,
|
||||||
index,
|
index,
|
||||||
pubkey: event.pubkey,
|
|
||||||
title: tags.title,
|
|
||||||
description: tags.description,
|
|
||||||
preview: (tags.preview) ?? event.content.substring(0, 200),
|
|
||||||
thumbnailUrl: tags.coverUrl ?? '', // Use coverUrl as thumbnail if available
|
|
||||||
category,
|
category,
|
||||||
...(tags.coverUrl ? { coverUrl: tags.coverUrl } : {}),
|
})
|
||||||
}
|
|
||||||
series.kindType = 'series'
|
|
||||||
return series
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing series:', e)
|
console.error('Error parsing series:', e)
|
||||||
return null
|
return null
|
||||||
@ -76,75 +64,147 @@ export async function parseSeriesFromEvent(event: Event): Promise<Series | null>
|
|||||||
export async function parseReviewFromEvent(event: Event): Promise<Review | null> {
|
export async function parseReviewFromEvent(event: Event): Promise<Review | null> {
|
||||||
try {
|
try {
|
||||||
const tags = extractTagsFromEvent(event)
|
const tags = extractTagsFromEvent(event)
|
||||||
// Check if it's a quote type (reviews are quotes, tag is 'quote' in English)
|
const input = readReviewInput(tags)
|
||||||
if (tags.type !== 'quote') {
|
if (!input) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const {articleId} = tags
|
|
||||||
const reviewer = tags.reviewerPubkey
|
|
||||||
if (!articleId || !reviewer) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const rewardedTag = event.tags.find((tag) => tag[0] === 'rewarded' && tag[1] === 'true')
|
|
||||||
const rewardAmountTag = event.tags.find((tag) => tag[0] === 'reward_amount')
|
|
||||||
|
|
||||||
// Extract hash, version, index from id tag or parse it
|
const { hash, version, index } = await resolveObjectIdParts({
|
||||||
let hash: string
|
...(input.idTag ? { idTag: input.idTag } : {}),
|
||||||
let version = tags.version ?? 0
|
defaultVersion: input.defaultVersion,
|
||||||
let index = 0
|
defaultIndex: 0,
|
||||||
|
generateHash: async (): Promise<string> => generateHashId({
|
||||||
if (tags.id) {
|
|
||||||
const parsed = parseObjectId(tags.id)
|
|
||||||
const { hash: parsedHash, version: parsedVersion, index: parsedIndex } = parsed
|
|
||||||
if (parsedHash) {
|
|
||||||
hash = parsedHash
|
|
||||||
version = parsedVersion ?? version
|
|
||||||
index = parsedIndex ?? index
|
|
||||||
} else {
|
|
||||||
// If id is just a hash, use it directly
|
|
||||||
hash = tags.id
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Generate hash from review data
|
|
||||||
hash = await generateHashId({
|
|
||||||
type: 'quote',
|
type: 'quote',
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
articleId,
|
articleId: input.articleId,
|
||||||
reviewerPubkey: reviewer,
|
reviewerPubkey: input.reviewerPubkey,
|
||||||
content: event.content,
|
content: event.content,
|
||||||
title: tags.title ?? '',
|
title: input.title ?? '',
|
||||||
})
|
}),
|
||||||
}
|
})
|
||||||
|
|
||||||
const id = buildObjectId(hash, index, version)
|
const rewardInfo = readRewardInfo(event.tags)
|
||||||
|
const text = readTextTag(event.tags)
|
||||||
// Extract text from tags if present
|
return buildReviewFromParsed({ event, input, hash, version, index, rewardInfo, text })
|
||||||
const textTag = event.tags.find((tag) => tag[0] === 'text')?.[1]
|
|
||||||
|
|
||||||
const review: Review = {
|
|
||||||
id,
|
|
||||||
hash,
|
|
||||||
version,
|
|
||||||
index,
|
|
||||||
articleId,
|
|
||||||
authorPubkey: event.pubkey,
|
|
||||||
reviewerPubkey: reviewer,
|
|
||||||
content: event.content,
|
|
||||||
description: tags.description ?? '', // Required field with default
|
|
||||||
createdAt: event.created_at,
|
|
||||||
...(tags.title ? { title: tags.title } : {}),
|
|
||||||
...(textTag ? { text: textTag } : {}),
|
|
||||||
...(rewardedTag ? { rewarded: true } : {}),
|
|
||||||
...(rewardAmountTag ? { rewardAmount: parseInt(rewardAmountTag[1] ?? '0', 10) } : {}),
|
|
||||||
}
|
|
||||||
review.kindType = 'review'
|
|
||||||
return review
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing review:', e)
|
console.error('Error parsing review:', e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSeriesFromParsed(params: {
|
||||||
|
event: Event
|
||||||
|
input: { title: string; description: string; preview: string; coverUrl: string }
|
||||||
|
hash: string
|
||||||
|
version: number
|
||||||
|
index: number
|
||||||
|
category: Series['category']
|
||||||
|
}): Series {
|
||||||
|
const id = buildObjectId(params.hash, params.index, params.version)
|
||||||
|
const series: Series = {
|
||||||
|
id,
|
||||||
|
hash: params.hash,
|
||||||
|
version: params.version,
|
||||||
|
index: params.index,
|
||||||
|
pubkey: params.event.pubkey,
|
||||||
|
title: params.input.title,
|
||||||
|
description: params.input.description,
|
||||||
|
preview: params.input.preview,
|
||||||
|
thumbnailUrl: params.input.coverUrl,
|
||||||
|
category: params.category,
|
||||||
|
...(params.input.coverUrl ? { coverUrl: params.input.coverUrl } : {}),
|
||||||
|
}
|
||||||
|
series.kindType = 'series'
|
||||||
|
return series
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReviewFromParsed(params: {
|
||||||
|
event: Event
|
||||||
|
input: { articleId: string; reviewerPubkey: string; title: string | undefined; description: string }
|
||||||
|
hash: string
|
||||||
|
version: number
|
||||||
|
index: number
|
||||||
|
rewardInfo: { rewarded: boolean; rewardAmount: number | undefined }
|
||||||
|
text: string | undefined
|
||||||
|
}): Review {
|
||||||
|
const id = buildObjectId(params.hash, params.index, params.version)
|
||||||
|
const review: Review = {
|
||||||
|
id,
|
||||||
|
hash: params.hash,
|
||||||
|
version: params.version,
|
||||||
|
index: params.index,
|
||||||
|
articleId: params.input.articleId,
|
||||||
|
authorPubkey: params.event.pubkey,
|
||||||
|
reviewerPubkey: params.input.reviewerPubkey,
|
||||||
|
content: params.event.content,
|
||||||
|
description: params.input.description,
|
||||||
|
createdAt: params.event.created_at,
|
||||||
|
...(params.input.title ? { title: params.input.title } : {}),
|
||||||
|
...(params.text ? { text: params.text } : {}),
|
||||||
|
...(params.rewardInfo.rewarded ? { rewarded: true } : {}),
|
||||||
|
...(params.rewardInfo.rewardAmount !== undefined ? { rewardAmount: params.rewardInfo.rewardAmount } : {}),
|
||||||
|
}
|
||||||
|
review.kindType = 'review'
|
||||||
|
return review
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSeriesInput(params: { tags: ReturnType<typeof extractTagsFromEvent>; eventContent: string }): {
|
||||||
|
idTag: string | undefined
|
||||||
|
defaultVersion: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
preview: string
|
||||||
|
coverUrl: string
|
||||||
|
categoryTag: string
|
||||||
|
} | null {
|
||||||
|
if (params.tags.type !== 'series' || !params.tags.title || !params.tags.description) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
idTag: params.tags.id,
|
||||||
|
defaultVersion: params.tags.version ?? 0,
|
||||||
|
title: params.tags.title,
|
||||||
|
description: params.tags.description,
|
||||||
|
preview: params.tags.preview ?? params.eventContent.substring(0, 200),
|
||||||
|
coverUrl: params.tags.coverUrl ?? '',
|
||||||
|
categoryTag: params.tags.category ?? 'sciencefiction',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readReviewInput(tags: ReturnType<typeof extractTagsFromEvent>): {
|
||||||
|
idTag: string | undefined
|
||||||
|
defaultVersion: number
|
||||||
|
articleId: string
|
||||||
|
reviewerPubkey: string
|
||||||
|
title: string | undefined
|
||||||
|
description: string
|
||||||
|
} | null {
|
||||||
|
if (tags.type !== 'quote' || !tags.articleId || !tags.reviewerPubkey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
idTag: tags.id,
|
||||||
|
defaultVersion: tags.version ?? 0,
|
||||||
|
articleId: tags.articleId,
|
||||||
|
reviewerPubkey: tags.reviewerPubkey,
|
||||||
|
title: tags.title,
|
||||||
|
description: tags.description ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRewardInfo(eventTags: string[][]): { rewarded: boolean; rewardAmount: number | undefined } {
|
||||||
|
const rewarded = eventTags.some((tag) => tag[0] === 'rewarded' && tag[1] === 'true')
|
||||||
|
const rewardAmountTag = eventTags.find((tag) => tag[0] === 'reward_amount')?.[1]
|
||||||
|
const rewardAmountRaw = rewardAmountTag ? parseInt(rewardAmountTag, 10) : NaN
|
||||||
|
const rewardAmount = Number.isNaN(rewardAmountRaw) ? undefined : rewardAmountRaw
|
||||||
|
return { rewarded, rewardAmount }
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTextTag(eventTags: string[][]): string | undefined {
|
||||||
|
return eventTags.find((tag) => tag[0] === 'text')?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function getPreviewContent(content: string, previewTag?: string): { previewContent: string } {
|
function getPreviewContent(content: string, previewTag?: string): { previewContent: string } {
|
||||||
const lines = content.split('\n')
|
const lines = content.split('\n')
|
||||||
|
|||||||
@ -42,47 +42,108 @@ export function extractCommonTags(findTag: (key: string) => string | undefined,
|
|||||||
reviewerPubkey?: string
|
reviewerPubkey?: string
|
||||||
json?: string
|
json?: string
|
||||||
} {
|
} {
|
||||||
const id = findTag('id')
|
const base = readCommonTagBase(findTag)
|
||||||
const service = findTag('service')
|
|
||||||
const title = findTag('title')
|
|
||||||
const preview = findTag('preview')
|
|
||||||
const description = findTag('description')
|
|
||||||
const mainnetAddress = findTag('mainnet_address')
|
|
||||||
const totalSponsoring = parseNumericTag(findTag, 'total_sponsoring')
|
|
||||||
const pictureUrl = findTag('picture')
|
|
||||||
const seriesId = findTag('series')
|
|
||||||
const coverUrl = findTag('cover')
|
|
||||||
const bannerUrl = findTag('banner')
|
|
||||||
const zapAmount = parseNumericTag(findTag, 'zap')
|
|
||||||
const invoice = findTag('invoice')
|
|
||||||
const paymentHash = findTag('payment_hash')
|
|
||||||
const encryptedKey = findTag('encrypted_key')
|
|
||||||
const articleId = findTag('article')
|
|
||||||
const reviewerPubkey = findTag('reviewer')
|
|
||||||
const json = findTag('json')
|
|
||||||
return {
|
return {
|
||||||
...(id ? { id } : {}),
|
...buildCoreCommonTagFields(findTag, hasTag),
|
||||||
...(service ? { service } : {}),
|
...buildOptionalCommonTagFields(base),
|
||||||
version: parseNumericTag(findTag, 'version') ?? 0, // Default to 0 if not present
|
...buildOptionalNumericFields(base),
|
||||||
hidden: findTag('hidden') === 'true', // true only if tag exists and value is 'true'
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOptionalString(target: Record<string, string>, key: string, value: string | undefined): Record<string, string> {
|
||||||
|
if (typeof value === 'string' && value.length > 0) {
|
||||||
|
return { ...target, [key]: value }
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCommonTagBase(findTag: (key: string) => string | undefined): {
|
||||||
|
id: string | undefined
|
||||||
|
service: string | undefined
|
||||||
|
title: string | undefined
|
||||||
|
preview: string | undefined
|
||||||
|
description: string | undefined
|
||||||
|
mainnetAddress: string | undefined
|
||||||
|
totalSponsoring: number | undefined
|
||||||
|
pictureUrl: string | undefined
|
||||||
|
seriesId: string | undefined
|
||||||
|
coverUrl: string | undefined
|
||||||
|
bannerUrl: string | undefined
|
||||||
|
zapAmount: number | undefined
|
||||||
|
invoice: string | undefined
|
||||||
|
paymentHash: string | undefined
|
||||||
|
encryptedKey: string | undefined
|
||||||
|
articleId: string | undefined
|
||||||
|
reviewerPubkey: string | undefined
|
||||||
|
json: string | undefined
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
id: findTag('id'), service: findTag('service'), title: findTag('title'), preview: findTag('preview'),
|
||||||
|
description: findTag('description'), mainnetAddress: findTag('mainnet_address'), pictureUrl: findTag('picture'),
|
||||||
|
seriesId: findTag('series'), coverUrl: findTag('cover'), bannerUrl: findTag('banner'), invoice: findTag('invoice'),
|
||||||
|
paymentHash: findTag('payment_hash'), encryptedKey: findTag('encrypted_key'), articleId: findTag('article'),
|
||||||
|
reviewerPubkey: findTag('reviewer'), json: findTag('json'),
|
||||||
|
totalSponsoring: parseNumericTag(findTag, 'total_sponsoring'), zapAmount: parseNumericTag(findTag, 'zap'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCoreCommonTagFields(
|
||||||
|
findTag: (key: string) => string | undefined,
|
||||||
|
hasTag: (key: string) => boolean
|
||||||
|
): { version: number; hidden: boolean; paywall: boolean; payment: boolean } {
|
||||||
|
return {
|
||||||
|
version: parseNumericTag(findTag, 'version') ?? 0,
|
||||||
|
hidden: findTag('hidden') === 'true',
|
||||||
paywall: hasTag('paywall'),
|
paywall: hasTag('paywall'),
|
||||||
payment: hasTag('payment'),
|
payment: hasTag('payment'),
|
||||||
...(title ? { title } : {}),
|
}
|
||||||
...(preview ? { preview } : {}),
|
}
|
||||||
...(description ? { description } : {}),
|
|
||||||
...(mainnetAddress ? { mainnetAddress } : {}),
|
function buildOptionalCommonTagFields(base: {
|
||||||
...(totalSponsoring !== undefined ? { totalSponsoring } : {}),
|
id: string | undefined
|
||||||
...(pictureUrl ? { pictureUrl } : {}),
|
service: string | undefined
|
||||||
...(seriesId ? { seriesId } : {}),
|
title: string | undefined
|
||||||
...(coverUrl ? { coverUrl } : {}),
|
preview: string | undefined
|
||||||
...(bannerUrl ? { bannerUrl } : {}),
|
description: string | undefined
|
||||||
...(zapAmount !== undefined ? { zapAmount } : {}),
|
mainnetAddress: string | undefined
|
||||||
...(invoice ? { invoice } : {}),
|
pictureUrl: string | undefined
|
||||||
...(paymentHash ? { paymentHash } : {}),
|
seriesId: string | undefined
|
||||||
...(encryptedKey ? { encryptedKey } : {}),
|
coverUrl: string | undefined
|
||||||
...(articleId ? { articleId } : {}),
|
bannerUrl: string | undefined
|
||||||
...(reviewerPubkey ? { reviewerPubkey } : {}),
|
invoice: string | undefined
|
||||||
...(json ? { json } : {}),
|
paymentHash: string | undefined
|
||||||
|
encryptedKey: string | undefined
|
||||||
|
articleId: string | undefined
|
||||||
|
reviewerPubkey: string | undefined
|
||||||
|
json: string | undefined
|
||||||
|
}): Record<string, string> {
|
||||||
|
let optional: Record<string, string> = {}
|
||||||
|
optional = addOptionalString(optional, 'id', base.id)
|
||||||
|
optional = addOptionalString(optional, 'service', base.service)
|
||||||
|
optional = addOptionalString(optional, 'title', base.title)
|
||||||
|
optional = addOptionalString(optional, 'preview', base.preview)
|
||||||
|
optional = addOptionalString(optional, 'description', base.description)
|
||||||
|
optional = addOptionalString(optional, 'mainnetAddress', base.mainnetAddress)
|
||||||
|
optional = addOptionalString(optional, 'pictureUrl', base.pictureUrl)
|
||||||
|
optional = addOptionalString(optional, 'seriesId', base.seriesId)
|
||||||
|
optional = addOptionalString(optional, 'coverUrl', base.coverUrl)
|
||||||
|
optional = addOptionalString(optional, 'bannerUrl', base.bannerUrl)
|
||||||
|
optional = addOptionalString(optional, 'invoice', base.invoice)
|
||||||
|
optional = addOptionalString(optional, 'paymentHash', base.paymentHash)
|
||||||
|
optional = addOptionalString(optional, 'encryptedKey', base.encryptedKey)
|
||||||
|
optional = addOptionalString(optional, 'articleId', base.articleId)
|
||||||
|
optional = addOptionalString(optional, 'reviewerPubkey', base.reviewerPubkey)
|
||||||
|
optional = addOptionalString(optional, 'json', base.json)
|
||||||
|
return optional
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOptionalNumericFields(base: { totalSponsoring: number | undefined; zapAmount: number | undefined }): {
|
||||||
|
totalSponsoring?: number
|
||||||
|
zapAmount?: number
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
...(base.totalSponsoring !== undefined ? { totalSponsoring: base.totalSponsoring } : {}),
|
||||||
|
...(base.zapAmount !== undefined ? { zapAmount: base.zapAmount } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -79,41 +79,60 @@ export function checkZapReceipt(
|
|||||||
userPubkey: string
|
userPubkey: string
|
||||||
}
|
}
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!params.pool) {
|
return setupZapReceiptCheck(params)
|
||||||
return Promise.resolve(false)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
function setupZapReceiptCheck(params: {
|
||||||
|
pool: SimplePool
|
||||||
|
targetPubkey: string
|
||||||
|
targetEventId: string
|
||||||
|
amount: number
|
||||||
|
userPubkey: string
|
||||||
|
}): Promise<boolean> {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
let resolved = false
|
|
||||||
const relayUrl = getPrimaryRelaySync()
|
const relayUrl = getPrimaryRelaySync()
|
||||||
const sub = createSubscription(params.pool, [relayUrl], createZapFilters(params.targetPubkey, params.targetEventId, params.userPubkey))
|
const sub = createSubscription(params.pool, [relayUrl], createZapFilters(params.targetPubkey, params.targetEventId, params.userPubkey))
|
||||||
|
const state = createZapReceiptState({ sub, resolve })
|
||||||
const finalize = (value: boolean): void => {
|
registerZapReceiptHandlers({ sub, params, state })
|
||||||
if (resolved) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolved = true
|
|
||||||
sub.unsub()
|
|
||||||
resolve(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedRef = { current: resolved }
|
|
||||||
sub.on('event', (event: Event): void => {
|
|
||||||
handleZapReceiptEvent({
|
|
||||||
event,
|
|
||||||
targetEventId: params.targetEventId,
|
|
||||||
targetPubkey: params.targetPubkey,
|
|
||||||
userPubkey: params.userPubkey,
|
|
||||||
amount: params.amount,
|
|
||||||
finalize,
|
|
||||||
resolved: resolvedRef,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const end = (): void => {
|
|
||||||
finalize(false)
|
|
||||||
}
|
|
||||||
sub.on('eose', end)
|
|
||||||
setTimeout(end, 3000)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createZapReceiptState(params: {
|
||||||
|
sub: import('@/types/nostr-tools-extended').Subscription
|
||||||
|
resolve: (value: boolean) => void
|
||||||
|
}): { resolvedRef: { current: boolean }; finalize: (value: boolean) => void } {
|
||||||
|
const resolvedRef = { current: false }
|
||||||
|
const finalize = (value: boolean): void => {
|
||||||
|
if (resolvedRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolvedRef.current = true
|
||||||
|
params.sub.unsub()
|
||||||
|
params.resolve(value)
|
||||||
|
}
|
||||||
|
return { resolvedRef, finalize }
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerZapReceiptHandlers(params: {
|
||||||
|
sub: import('@/types/nostr-tools-extended').Subscription
|
||||||
|
params: { targetPubkey: string; targetEventId: string; amount: number; userPubkey: string }
|
||||||
|
state: { resolvedRef: { current: boolean }; finalize: (value: boolean) => void }
|
||||||
|
}): void {
|
||||||
|
params.sub.on('event', (event: Event): void => {
|
||||||
|
handleZapReceiptEvent({
|
||||||
|
event,
|
||||||
|
targetEventId: params.params.targetEventId,
|
||||||
|
targetPubkey: params.params.targetPubkey,
|
||||||
|
userPubkey: params.params.userPubkey,
|
||||||
|
amount: params.params.amount,
|
||||||
|
finalize: params.state.finalize,
|
||||||
|
resolved: params.state.resolvedRef,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const end = (): void => {
|
||||||
|
params.state.finalize(false)
|
||||||
|
}
|
||||||
|
params.sub.on('eose', end)
|
||||||
|
setTimeout(end, 3000)
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,14 @@ import { objectCache } from './objectCache'
|
|||||||
import { notificationService, type NotificationType } from './notificationService'
|
import { notificationService, type NotificationType } from './notificationService'
|
||||||
import type { CachedObject } from './objectCache'
|
import type { CachedObject } from './objectCache'
|
||||||
|
|
||||||
|
const USER_OBJECT_NOTIFICATION_TYPES: Array<{ type: string; notificationType: NotificationType }> = [
|
||||||
|
{ type: 'purchase', notificationType: 'purchase' },
|
||||||
|
{ type: 'review', notificationType: 'review' },
|
||||||
|
{ type: 'sponsoring', notificationType: 'sponsoring' },
|
||||||
|
{ type: 'review_tip', notificationType: 'review_tip' },
|
||||||
|
{ type: 'payment_note', notificationType: 'payment_note' },
|
||||||
|
]
|
||||||
|
|
||||||
interface ObjectChange {
|
interface ObjectChange {
|
||||||
objectType: string
|
objectType: string
|
||||||
objectId: string
|
objectId: string
|
||||||
@ -85,56 +93,40 @@ class NotificationDetector {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectTypes: Array<{ type: string; notificationType: NotificationType }> = [
|
for (const cfg of USER_OBJECT_NOTIFICATION_TYPES) {
|
||||||
{ type: 'purchase', notificationType: 'purchase' },
|
await this.scanUserObjectsOfType(cfg)
|
||||||
{ type: 'review', notificationType: 'review' },
|
}
|
||||||
{ type: 'sponsoring', notificationType: 'sponsoring' },
|
}
|
||||||
{ type: 'review_tip', notificationType: 'review_tip' },
|
|
||||||
{ type: 'payment_note', notificationType: 'payment_note' },
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const { type, notificationType } of objectTypes) {
|
private async scanUserObjectsOfType(params: { type: string; notificationType: NotificationType }): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const allObjects = await objectCache.getAll(type as Parameters<typeof objectCache.getAll>[0])
|
const {userPubkey} = this
|
||||||
const userObjects = (allObjects as CachedObject[]).filter((obj: CachedObject) => {
|
if (!userPubkey) {
|
||||||
// Check if object is related to the user
|
return
|
||||||
// For purchases: targetPubkey === userPubkey
|
}
|
||||||
// For reviews: targetEventId points to user's article
|
const allObjects = await objectCache.getAll(params.type as Parameters<typeof objectCache.getAll>[0])
|
||||||
// For sponsoring: targetPubkey === userPubkey
|
const userObjects = filterUserRelatedObjects({ type: params.type, allObjects: allObjects as CachedObject[], userPubkey })
|
||||||
// For review_tips: targetEventId points to user's review
|
await this.createNotificationsForNewObjects({ type: params.type, notificationType: params.notificationType, objects: userObjects })
|
||||||
// For payment_notes: targetPubkey === userPubkey
|
} catch (error) {
|
||||||
|
console.error(`[NotificationDetector] Error scanning ${params.type}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'purchase' || type === 'sponsoring' || type === 'payment_note') {
|
private async createNotificationsForNewObjects(params: {
|
||||||
return (obj as { targetPubkey?: string }).targetPubkey === this.userPubkey
|
type: string
|
||||||
}
|
notificationType: NotificationType
|
||||||
|
objects: CachedObject[]
|
||||||
if (type === 'review' || type === 'review_tip') {
|
}): Promise<void> {
|
||||||
// Need to check if the target event belongs to the user
|
for (const obj of params.objects) {
|
||||||
// This is more complex and may require checking the article/review
|
if (obj.createdAt * 1000 > this.lastScanTime) {
|
||||||
// For now, we'll create notifications for all reviews/tips
|
const eventId = obj.id.split(':')[1] ?? obj.id
|
||||||
// The UI can filter them if needed
|
await notificationService.createNotification({
|
||||||
return true
|
type: params.notificationType,
|
||||||
}
|
objectType: params.type,
|
||||||
|
objectId: obj.id,
|
||||||
return false
|
eventId,
|
||||||
|
data: { object: obj },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create notifications for objects created after last scan
|
|
||||||
for (const obj of userObjects) {
|
|
||||||
const cachedObj = obj
|
|
||||||
if (cachedObj.createdAt * 1000 > this.lastScanTime) {
|
|
||||||
const eventId = cachedObj.id.split(':')[1] ?? cachedObj.id
|
|
||||||
await notificationService.createNotification({
|
|
||||||
type: notificationType,
|
|
||||||
objectType: type,
|
|
||||||
objectId: cachedObj.id,
|
|
||||||
eventId,
|
|
||||||
data: { object: obj },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[NotificationDetector] Error scanning ${type}:`, error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -251,4 +243,16 @@ class NotificationDetector {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterUserRelatedObjects(params: { type: string; allObjects: CachedObject[]; userPubkey: string }): CachedObject[] {
|
||||||
|
return params.allObjects.filter((obj: CachedObject) => {
|
||||||
|
if (params.type === 'purchase' || params.type === 'sponsoring' || params.type === 'payment_note') {
|
||||||
|
return (obj as { targetPubkey?: string }).targetPubkey === params.userPubkey
|
||||||
|
}
|
||||||
|
if (params.type === 'review' || params.type === 'review_tip') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const notificationDetector = new NotificationDetector()
|
export const notificationDetector = new NotificationDetector()
|
||||||
|
|||||||
@ -34,45 +34,10 @@ class ObjectCacheService {
|
|||||||
|
|
||||||
private getDBHelper(objectType: ObjectType): IndexedDBHelper {
|
private getDBHelper(objectType: ObjectType): IndexedDBHelper {
|
||||||
if (!this.dbHelpers.has(objectType)) {
|
if (!this.dbHelpers.has(objectType)) {
|
||||||
const helper = createIndexedDBHelper({
|
const helper = createDbHelperForObjectType(objectType)
|
||||||
dbName: `${DB_PREFIX}${objectType}`,
|
|
||||||
version: DB_VERSION,
|
|
||||||
storeName: STORE_NAME,
|
|
||||||
keyPath: 'id',
|
|
||||||
indexes: [
|
|
||||||
{ name: 'hash', keyPath: 'hash', unique: false },
|
|
||||||
{ name: 'hashId', keyPath: 'hashId', unique: false }, // Legacy index
|
|
||||||
{ name: 'version', keyPath: 'version', unique: false },
|
|
||||||
{ name: 'index', keyPath: 'index', unique: false },
|
|
||||||
{ name: 'hidden', keyPath: 'hidden', unique: false },
|
|
||||||
{ name: 'cachedAt', keyPath: 'cachedAt', unique: false },
|
|
||||||
{ name: 'published', keyPath: 'published', unique: false },
|
|
||||||
],
|
|
||||||
onUpgrade: (_db: IDBDatabase, event: IDBVersionChangeEvent): void => {
|
|
||||||
// Migration: add new indexes if they don't exist
|
|
||||||
const target = event.target as IDBOpenDBRequest
|
|
||||||
const { transaction } = target
|
|
||||||
if (transaction) {
|
|
||||||
const store = transaction.objectStore(STORE_NAME)
|
|
||||||
if (!store.indexNames.contains('hash')) {
|
|
||||||
store.createIndex('hash', 'hash', { unique: false })
|
|
||||||
}
|
|
||||||
if (!store.indexNames.contains('index')) {
|
|
||||||
store.createIndex('index', 'index', { unique: false })
|
|
||||||
}
|
|
||||||
if (!store.indexNames.contains('published')) {
|
|
||||||
store.createIndex('published', 'published', { unique: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
this.dbHelpers.set(objectType, helper)
|
this.dbHelpers.set(objectType, helper)
|
||||||
}
|
}
|
||||||
const helper = this.dbHelpers.get(objectType)
|
return getRequiredDbHelper(this.dbHelpers, objectType)
|
||||||
if (!helper) {
|
|
||||||
throw new Error(`Database helper not found for ${objectType}`)
|
|
||||||
}
|
|
||||||
return helper
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -90,7 +55,7 @@ class ObjectCacheService {
|
|||||||
private async countObjectsWithHash(objectType: ObjectType, hash: string): Promise<number> {
|
private async countObjectsWithHash(objectType: ObjectType, hash: string): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const helper = this.getDBHelper(objectType)
|
const helper = this.getDBHelper(objectType)
|
||||||
return await helper.countByIndex('hash', IDBKeyRange.only(hash))
|
return helper.countByIndex('hash', IDBKeyRange.only(hash))
|
||||||
} catch (countError) {
|
} catch (countError) {
|
||||||
console.error(`Error counting objects with hash ${hash}:`, countError)
|
console.error(`Error counting objects with hash ${hash}:`, countError)
|
||||||
return 0
|
return 0
|
||||||
@ -114,43 +79,67 @@ class ObjectCacheService {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const helper = this.getDBHelper(params.objectType)
|
const helper = this.getDBHelper(params.objectType)
|
||||||
|
const index = await this.resolveIndex(params.objectType, params.hash, params.index)
|
||||||
// If index is not provided, calculate it by counting objects with the same hash
|
const id = buildObjectId(params.hash, index, params.version)
|
||||||
let finalIndex = params.index
|
const published = await this.resolvePublishedForUpsert(helper, id, params.published)
|
||||||
if (finalIndex === undefined) {
|
await helper.put(this.buildCachedObject(params, id, index, published))
|
||||||
const count = await this.countObjectsWithHash(params.objectType, params.hash)
|
|
||||||
finalIndex = count
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = buildObjectId(params.hash, finalIndex, params.version)
|
|
||||||
|
|
||||||
// Check if object already exists to preserve published status if updating
|
|
||||||
const existing = await helper.get<CachedObject>(id).catch(() => null)
|
|
||||||
|
|
||||||
// If updating and published is not provided, preserve existing published status
|
|
||||||
const published = params.published ?? false
|
|
||||||
const finalPublished = existing && published === false ? existing.published : published
|
|
||||||
|
|
||||||
const cached: CachedObject = {
|
|
||||||
id,
|
|
||||||
hash: params.hash,
|
|
||||||
hashId: params.hash, // Legacy field for backward compatibility
|
|
||||||
index: finalIndex,
|
|
||||||
event: params.event,
|
|
||||||
parsed: params.parsed,
|
|
||||||
version: params.version,
|
|
||||||
hidden: params.hidden,
|
|
||||||
createdAt: params.event.created_at,
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
published: finalPublished,
|
|
||||||
}
|
|
||||||
|
|
||||||
await helper.put(cached)
|
|
||||||
} catch (cacheError) {
|
} catch (cacheError) {
|
||||||
console.error(`Error caching ${params.objectType} object:`, cacheError)
|
console.error(`Error caching ${params.objectType} object:`, cacheError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveIndex(
|
||||||
|
objectType: ObjectType,
|
||||||
|
hash: string,
|
||||||
|
index: number | undefined
|
||||||
|
): Promise<number> {
|
||||||
|
if (index !== undefined) {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
return this.countObjectsWithHash(objectType, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolvePublishedForUpsert(
|
||||||
|
helper: IndexedDBHelper,
|
||||||
|
id: string,
|
||||||
|
published: false | string[] | undefined
|
||||||
|
): Promise<false | string[]> {
|
||||||
|
const nextPublished = published ?? false
|
||||||
|
if (nextPublished !== false) {
|
||||||
|
return nextPublished
|
||||||
|
}
|
||||||
|
const existing = await helper.get<CachedObject>(id).catch(() => null)
|
||||||
|
return existing ? existing.published : false
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCachedObject(
|
||||||
|
params: {
|
||||||
|
objectType: ObjectType
|
||||||
|
hash: string
|
||||||
|
event: NostrEvent
|
||||||
|
parsed: unknown
|
||||||
|
version: number
|
||||||
|
hidden: boolean
|
||||||
|
},
|
||||||
|
id: string,
|
||||||
|
index: number,
|
||||||
|
published: false | string[]
|
||||||
|
): CachedObject {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
hash: params.hash,
|
||||||
|
hashId: params.hash, // Legacy field for backward compatibility
|
||||||
|
index,
|
||||||
|
event: params.event,
|
||||||
|
parsed: params.parsed,
|
||||||
|
version: params.version,
|
||||||
|
hidden: params.hidden,
|
||||||
|
createdAt: params.event.created_at,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
published,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update published status for an object
|
* Update published status for an object
|
||||||
*/
|
*/
|
||||||
@ -405,3 +394,55 @@ class ObjectCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const objectCache = new ObjectCacheService()
|
export const objectCache = new ObjectCacheService()
|
||||||
|
|
||||||
|
function createDbHelperForObjectType(objectType: ObjectType): IndexedDBHelper {
|
||||||
|
return createIndexedDBHelper({
|
||||||
|
dbName: `${DB_PREFIX}${objectType}`,
|
||||||
|
version: DB_VERSION,
|
||||||
|
storeName: STORE_NAME,
|
||||||
|
keyPath: 'id',
|
||||||
|
indexes: getObjectCacheIndexes(),
|
||||||
|
onUpgrade: (_db: IDBDatabase, event: IDBVersionChangeEvent): void => {
|
||||||
|
handleObjectCacheUpgrade(event)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getObjectCacheIndexes(): Array<{ name: string; keyPath: string; unique: boolean }> {
|
||||||
|
return [
|
||||||
|
{ name: 'hash', keyPath: 'hash', unique: false },
|
||||||
|
{ name: 'hashId', keyPath: 'hashId', unique: false },
|
||||||
|
{ name: 'version', keyPath: 'version', unique: false },
|
||||||
|
{ name: 'index', keyPath: 'index', unique: false },
|
||||||
|
{ name: 'hidden', keyPath: 'hidden', unique: false },
|
||||||
|
{ name: 'cachedAt', keyPath: 'cachedAt', unique: false },
|
||||||
|
{ name: 'published', keyPath: 'published', unique: false },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleObjectCacheUpgrade(event: IDBVersionChangeEvent): void {
|
||||||
|
// Migration: add new indexes if they don't exist
|
||||||
|
const target = event.target as IDBOpenDBRequest
|
||||||
|
const { transaction } = target
|
||||||
|
if (!transaction) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const store = transaction.objectStore(STORE_NAME)
|
||||||
|
ensureIndex(store, 'hash', 'hash')
|
||||||
|
ensureIndex(store, 'index', 'index')
|
||||||
|
ensureIndex(store, 'published', 'published')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureIndex(store: IDBObjectStore, name: string, keyPath: string): void {
|
||||||
|
if (!store.indexNames.contains(name)) {
|
||||||
|
store.createIndex(name, keyPath, { unique: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredDbHelper(map: Map<ObjectType, IndexedDBHelper>, objectType: ObjectType): IndexedDBHelper {
|
||||||
|
const helper = map.get(objectType)
|
||||||
|
if (!helper) {
|
||||||
|
throw new Error(`Database helper not found for ${objectType}`)
|
||||||
|
}
|
||||||
|
return helper
|
||||||
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { finalizeEvent } from 'nostr-tools'
|
|||||||
import { hexToBytes } from 'nostr-tools/utils'
|
import { hexToBytes } from 'nostr-tools/utils'
|
||||||
import type { Purchase, ReviewTip, Sponsoring } from '@/types/nostr'
|
import type { Purchase, ReviewTip, Sponsoring } from '@/types/nostr'
|
||||||
import { writeOrchestrator } from './writeOrchestrator'
|
import { writeOrchestrator } from './writeOrchestrator'
|
||||||
|
import { getPublishRelays } from './relaySelection'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish an explicit payment note (kind 1) for a purchase
|
* Publish an explicit payment note (kind 1) for a purchase
|
||||||
@ -24,108 +25,18 @@ export async function publishPurchaseNote(params: {
|
|||||||
seriesId?: string
|
seriesId?: string
|
||||||
payerPrivateKey: string
|
payerPrivateKey: string
|
||||||
}): Promise<Event | null> {
|
}): Promise<Event | null> {
|
||||||
let category: 'sciencefiction' | 'research' = 'sciencefiction'
|
const category = mapPaymentCategory(params.category)
|
||||||
if (params.category === 'science-fiction') {
|
const payload = await buildPurchaseNotePayload({ ...params, category })
|
||||||
category = 'sciencefiction'
|
return publishPaymentNoteToRelays({
|
||||||
} else if (params.category === 'scientific-research') {
|
payerPrivateKey: params.payerPrivateKey,
|
||||||
category = 'research'
|
objectType: 'purchase',
|
||||||
}
|
hash: payload.hashId,
|
||||||
|
eventTemplate: payload.eventTemplate,
|
||||||
const purchaseData = {
|
parsed: payload.parsedPurchase,
|
||||||
payerPubkey: params.payerPubkey,
|
|
||||||
articleId: params.articleId,
|
|
||||||
authorPubkey: params.authorPubkey,
|
|
||||||
amount: params.amount,
|
|
||||||
paymentHash: params.paymentHash,
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashId = await generatePurchaseHashId(purchaseData)
|
|
||||||
const id = buildObjectId(hashId, 0, 0)
|
|
||||||
|
|
||||||
const tags = buildTags({
|
|
||||||
type: 'payment',
|
|
||||||
category,
|
|
||||||
id: hashId,
|
|
||||||
service: PLATFORM_SERVICE,
|
|
||||||
version: 0,
|
version: 0,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
payment: true,
|
|
||||||
paymentType: 'purchase',
|
|
||||||
amount: params.amount,
|
|
||||||
payerPubkey: params.payerPubkey,
|
|
||||||
recipientPubkey: params.authorPubkey,
|
|
||||||
paymentHash: params.paymentHash,
|
|
||||||
articleId: params.articleId,
|
|
||||||
...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
|
|
||||||
...(params.seriesId ? { seriesId: params.seriesId } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build JSON metadata
|
|
||||||
const paymentJson = JSON.stringify({
|
|
||||||
type: 'purchase',
|
|
||||||
id,
|
|
||||||
hash: hashId,
|
|
||||||
version: 0,
|
|
||||||
index: 0,
|
index: 0,
|
||||||
...purchaseData,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
tags.push(['json', paymentJson])
|
|
||||||
|
|
||||||
// Build parsed Purchase object
|
|
||||||
const parsedPurchase: Purchase = {
|
|
||||||
id,
|
|
||||||
hash: hashId,
|
|
||||||
version: 0,
|
|
||||||
index: 0,
|
|
||||||
payerPubkey: params.payerPubkey,
|
|
||||||
articleId: params.articleId,
|
|
||||||
authorPubkey: params.authorPubkey,
|
|
||||||
amount: params.amount,
|
|
||||||
paymentHash: params.paymentHash,
|
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
|
||||||
kindType: 'purchase',
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventTemplate: EventTemplate = {
|
|
||||||
kind: 1,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags,
|
|
||||||
content: `Purchase confirmed: ${params.amount} sats for article ${params.articleId}`,
|
|
||||||
}
|
|
||||||
|
|
||||||
nostrService.setPrivateKey(params.payerPrivateKey)
|
|
||||||
writeOrchestrator.setPrivateKey(params.payerPrivateKey)
|
|
||||||
|
|
||||||
// Finalize event
|
|
||||||
const secretKey = hexToBytes(params.payerPrivateKey)
|
|
||||||
const event = finalizeEvent(eventTemplate, secretKey)
|
|
||||||
|
|
||||||
// Get active relays
|
|
||||||
const { relaySessionManager } = await import('./relaySessionManager')
|
|
||||||
const activeRelays = await relaySessionManager.getActiveRelays()
|
|
||||||
const { getPrimaryRelay } = await import('./config')
|
|
||||||
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
|
|
||||||
|
|
||||||
// Publish via writeOrchestrator (parallel network + local write)
|
|
||||||
const result = await writeOrchestrator.writeAndPublish(
|
|
||||||
{
|
|
||||||
objectType: 'purchase',
|
|
||||||
hash: hashId,
|
|
||||||
event,
|
|
||||||
parsed: parsedPurchase,
|
|
||||||
version: 0,
|
|
||||||
hidden: false,
|
|
||||||
index: 0,
|
|
||||||
},
|
|
||||||
relays
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return event
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -146,120 +57,18 @@ export async function publishReviewTipNote(params: {
|
|||||||
text?: string
|
text?: string
|
||||||
payerPrivateKey: string
|
payerPrivateKey: string
|
||||||
}): Promise<Event | null> {
|
}): Promise<Event | null> {
|
||||||
let category: 'sciencefiction' | 'research' = 'sciencefiction'
|
const category = mapPaymentCategory(params.category)
|
||||||
if (params.category === 'science-fiction') {
|
const payload = await buildReviewTipNotePayload({ ...params, category })
|
||||||
category = 'sciencefiction'
|
return publishPaymentNoteToRelays({
|
||||||
} else if (params.category === 'scientific-research') {
|
payerPrivateKey: params.payerPrivateKey,
|
||||||
category = 'research'
|
objectType: 'review_tip',
|
||||||
}
|
hash: payload.hashId,
|
||||||
|
eventTemplate: payload.eventTemplate,
|
||||||
const tipData = {
|
parsed: payload.parsedReviewTip,
|
||||||
payerPubkey: params.payerPubkey,
|
|
||||||
articleId: params.articleId,
|
|
||||||
reviewId: params.reviewId,
|
|
||||||
reviewerPubkey: params.reviewerPubkey,
|
|
||||||
authorPubkey: params.authorPubkey,
|
|
||||||
amount: params.amount,
|
|
||||||
paymentHash: params.paymentHash,
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashId = await generateReviewTipHashId(tipData)
|
|
||||||
const id = buildObjectId(hashId, 0, 0)
|
|
||||||
|
|
||||||
const tags = buildTags({
|
|
||||||
type: 'payment',
|
|
||||||
category,
|
|
||||||
id: hashId,
|
|
||||||
service: PLATFORM_SERVICE,
|
|
||||||
version: 0,
|
version: 0,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
payment: true,
|
|
||||||
paymentType: 'review_tip',
|
|
||||||
amount: params.amount,
|
|
||||||
payerPubkey: params.payerPubkey,
|
|
||||||
recipientPubkey: params.reviewerPubkey,
|
|
||||||
paymentHash: params.paymentHash,
|
|
||||||
articleId: params.articleId,
|
|
||||||
reviewId: params.reviewId,
|
|
||||||
...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
|
|
||||||
...(params.seriesId ? { seriesId: params.seriesId } : {}),
|
|
||||||
...(params.text ? { text: params.text } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build JSON metadata
|
|
||||||
const paymentJson = JSON.stringify({
|
|
||||||
type: 'review_tip',
|
|
||||||
id,
|
|
||||||
hash: hashId,
|
|
||||||
version: 0,
|
|
||||||
index: 0,
|
index: 0,
|
||||||
...tipData,
|
|
||||||
...(params.text ? { text: params.text } : {}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
tags.push(['json', paymentJson])
|
|
||||||
|
|
||||||
// Build parsed ReviewTip object
|
|
||||||
const parsedReviewTip: ReviewTip = {
|
|
||||||
id,
|
|
||||||
hash: hashId,
|
|
||||||
version: 0,
|
|
||||||
index: 0,
|
|
||||||
payerPubkey: params.payerPubkey,
|
|
||||||
articleId: params.articleId,
|
|
||||||
reviewId: params.reviewId,
|
|
||||||
reviewerPubkey: params.reviewerPubkey,
|
|
||||||
authorPubkey: params.authorPubkey,
|
|
||||||
amount: params.amount,
|
|
||||||
paymentHash: params.paymentHash,
|
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
|
||||||
...(params.text ? { text: params.text } : {}),
|
|
||||||
kindType: 'review_tip',
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = params.text
|
|
||||||
? `Review tip confirmed: ${params.amount} sats for review ${params.reviewId}\n\n${params.text}`
|
|
||||||
: `Review tip confirmed: ${params.amount} sats for review ${params.reviewId}`
|
|
||||||
|
|
||||||
const eventTemplate: EventTemplate = {
|
|
||||||
kind: 1,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags,
|
|
||||||
content,
|
|
||||||
}
|
|
||||||
|
|
||||||
nostrService.setPrivateKey(params.payerPrivateKey)
|
|
||||||
writeOrchestrator.setPrivateKey(params.payerPrivateKey)
|
|
||||||
|
|
||||||
// Finalize event
|
|
||||||
const secretKey = hexToBytes(params.payerPrivateKey)
|
|
||||||
const event = finalizeEvent(eventTemplate, secretKey)
|
|
||||||
|
|
||||||
// Get active relays
|
|
||||||
const { relaySessionManager } = await import('./relaySessionManager')
|
|
||||||
const activeRelays = await relaySessionManager.getActiveRelays()
|
|
||||||
const { getPrimaryRelay } = await import('./config')
|
|
||||||
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
|
|
||||||
|
|
||||||
// Publish via writeOrchestrator (parallel network + local write)
|
|
||||||
const result = await writeOrchestrator.writeAndPublish(
|
|
||||||
{
|
|
||||||
objectType: 'review_tip',
|
|
||||||
hash: hashId,
|
|
||||||
event,
|
|
||||||
parsed: parsedReviewTip,
|
|
||||||
version: 0,
|
|
||||||
hidden: false,
|
|
||||||
index: 0,
|
|
||||||
},
|
|
||||||
relays
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return event
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -278,13 +87,240 @@ export async function publishSponsoringNote(params: {
|
|||||||
transactionId?: string // Bitcoin transaction ID for mainnet payments
|
transactionId?: string // Bitcoin transaction ID for mainnet payments
|
||||||
payerPrivateKey: string
|
payerPrivateKey: string
|
||||||
}): Promise<Event | null> {
|
}): Promise<Event | null> {
|
||||||
let category: 'sciencefiction' | 'research' = 'sciencefiction'
|
const category = mapPaymentCategory(params.category)
|
||||||
if (params.category === 'science-fiction') {
|
const payload = await buildSponsoringNotePayload({ ...params, category })
|
||||||
category = 'sciencefiction'
|
|
||||||
} else if (params.category === 'scientific-research') {
|
|
||||||
category = 'research'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
return publishPaymentNoteToRelays({
|
||||||
|
payerPrivateKey: params.payerPrivateKey,
|
||||||
|
objectType: 'sponsoring',
|
||||||
|
hash: payload.hashId,
|
||||||
|
eventTemplate: payload.eventTemplate,
|
||||||
|
parsed: payload.parsedSponsoring,
|
||||||
|
version: 0,
|
||||||
|
hidden: false,
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPaymentCategory(
|
||||||
|
category: 'science-fiction' | 'scientific-research' | undefined
|
||||||
|
): 'sciencefiction' | 'research' {
|
||||||
|
if (category === 'scientific-research') {
|
||||||
|
return 'research'
|
||||||
|
}
|
||||||
|
return 'sciencefiction'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPurchaseNotePayload(params: {
|
||||||
|
articleId: string
|
||||||
|
authorPubkey: string
|
||||||
|
payerPubkey: string
|
||||||
|
amount: number
|
||||||
|
paymentHash: string
|
||||||
|
zapReceiptId?: string
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
seriesId?: string
|
||||||
|
}): Promise<{ hashId: string; eventTemplate: EventTemplate; parsedPurchase: Purchase }> {
|
||||||
|
const purchaseData = {
|
||||||
|
payerPubkey: params.payerPubkey,
|
||||||
|
articleId: params.articleId,
|
||||||
|
authorPubkey: params.authorPubkey,
|
||||||
|
amount: params.amount,
|
||||||
|
paymentHash: params.paymentHash,
|
||||||
|
...(params.seriesId ? { seriesId: params.seriesId } : {}),
|
||||||
|
}
|
||||||
|
const hashId = await generatePurchaseHashId(purchaseData)
|
||||||
|
const id = buildObjectId(hashId, 0, 0)
|
||||||
|
const tags = buildPurchaseNoteTags({ ...params, hashId })
|
||||||
|
tags.push(['json', JSON.stringify({ type: 'purchase', id, hash: hashId, version: 0, index: 0, ...purchaseData })])
|
||||||
|
const parsedPurchase = buildParsedPurchase({ ...params, id, hashId })
|
||||||
|
return { hashId, eventTemplate: buildPaymentNoteTemplate(tags, `Purchase confirmed: ${params.amount} sats for article ${params.articleId}`), parsedPurchase }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPurchaseNoteTags(params: {
|
||||||
|
articleId: string
|
||||||
|
authorPubkey: string
|
||||||
|
payerPubkey: string
|
||||||
|
amount: number
|
||||||
|
paymentHash: string
|
||||||
|
zapReceiptId?: string
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
seriesId?: string
|
||||||
|
hashId: string
|
||||||
|
}): string[][] {
|
||||||
|
return buildTags({
|
||||||
|
type: 'payment',
|
||||||
|
category: params.category,
|
||||||
|
id: params.hashId,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
|
version: 0,
|
||||||
|
hidden: false,
|
||||||
|
payment: true,
|
||||||
|
paymentType: 'purchase',
|
||||||
|
amount: params.amount,
|
||||||
|
payerPubkey: params.payerPubkey,
|
||||||
|
recipientPubkey: params.authorPubkey,
|
||||||
|
paymentHash: params.paymentHash,
|
||||||
|
articleId: params.articleId,
|
||||||
|
...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
|
||||||
|
...(params.seriesId ? { seriesId: params.seriesId } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParsedPurchase(params: {
|
||||||
|
articleId: string
|
||||||
|
authorPubkey: string
|
||||||
|
payerPubkey: string
|
||||||
|
amount: number
|
||||||
|
paymentHash: string
|
||||||
|
id: string
|
||||||
|
hashId: string
|
||||||
|
seriesId?: string
|
||||||
|
}): Purchase {
|
||||||
|
return {
|
||||||
|
id: params.id,
|
||||||
|
hash: params.hashId,
|
||||||
|
version: 0,
|
||||||
|
index: 0,
|
||||||
|
payerPubkey: params.payerPubkey,
|
||||||
|
articleId: params.articleId,
|
||||||
|
authorPubkey: params.authorPubkey,
|
||||||
|
amount: params.amount,
|
||||||
|
paymentHash: params.paymentHash,
|
||||||
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
|
...(params.seriesId ? { seriesId: params.seriesId } : {}),
|
||||||
|
kindType: 'purchase',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildReviewTipNotePayload(params: {
|
||||||
|
articleId: string
|
||||||
|
reviewId: string
|
||||||
|
authorPubkey: string
|
||||||
|
reviewerPubkey: string
|
||||||
|
payerPubkey: string
|
||||||
|
amount: number
|
||||||
|
paymentHash: string
|
||||||
|
zapReceiptId?: string
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
seriesId?: string
|
||||||
|
text?: string
|
||||||
|
}): Promise<{ hashId: string; eventTemplate: EventTemplate; parsedReviewTip: ReviewTip }> {
|
||||||
|
const tipData = {
|
||||||
|
payerPubkey: params.payerPubkey,
|
||||||
|
articleId: params.articleId,
|
||||||
|
reviewId: params.reviewId,
|
||||||
|
reviewerPubkey: params.reviewerPubkey,
|
||||||
|
authorPubkey: params.authorPubkey,
|
||||||
|
amount: params.amount,
|
||||||
|
paymentHash: params.paymentHash,
|
||||||
|
...(params.seriesId ? { seriesId: params.seriesId } : {}),
|
||||||
|
...(params.text ? { text: params.text } : {}),
|
||||||
|
}
|
||||||
|
const hashId = await generateReviewTipHashId(tipData)
|
||||||
|
const id = buildObjectId(hashId, 0, 0)
|
||||||
|
const tags = buildReviewTipNoteTags({ ...params, hashId })
|
||||||
|
tags.push(['json', JSON.stringify({ type: 'review_tip', id, hash: hashId, version: 0, index: 0, ...tipData })])
|
||||||
|
const parsedReviewTip = buildParsedReviewTip({ ...params, id, hashId })
|
||||||
|
return { hashId, eventTemplate: buildPaymentNoteTemplate(tags, buildReviewTipNoteContent(params)), parsedReviewTip }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReviewTipNoteTags(params: {
|
||||||
|
articleId: string
|
||||||
|
reviewId: string
|
||||||
|
authorPubkey: string
|
||||||
|
reviewerPubkey: string
|
||||||
|
payerPubkey: string
|
||||||
|
amount: number
|
||||||
|
paymentHash: string
|
||||||
|
zapReceiptId?: string
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
seriesId?: string
|
||||||
|
text?: string
|
||||||
|
hashId: string
|
||||||
|
}): string[][] {
|
||||||
|
return buildTags({
|
||||||
|
type: 'payment',
|
||||||
|
category: params.category,
|
||||||
|
id: params.hashId,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
|
version: 0,
|
||||||
|
hidden: false,
|
||||||
|
payment: true,
|
||||||
|
paymentType: 'review_tip',
|
||||||
|
amount: params.amount,
|
||||||
|
payerPubkey: params.payerPubkey,
|
||||||
|
recipientPubkey: params.reviewerPubkey,
|
||||||
|
paymentHash: params.paymentHash,
|
||||||
|
articleId: params.articleId,
|
||||||
|
reviewId: params.reviewId,
|
||||||
|
...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
|
||||||
|
...(params.seriesId ? { seriesId: params.seriesId } : {}),
|
||||||
|
...(params.text ? { text: params.text } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReviewTipNoteContent(params: { amount: number; reviewId: string; text?: string }): string {
|
||||||
|
const prefix = `Review tip confirmed: ${params.amount} sats for review ${params.reviewId}`
|
||||||
|
return params.text ? `${prefix}\n\n${params.text}` : prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParsedReviewTip(params: {
|
||||||
|
articleId: string
|
||||||
|
reviewId: string
|
||||||
|
authorPubkey: string
|
||||||
|
reviewerPubkey: string
|
||||||
|
payerPubkey: string
|
||||||
|
amount: number
|
||||||
|
paymentHash: string
|
||||||
|
seriesId?: string
|
||||||
|
text?: string
|
||||||
|
id: string
|
||||||
|
hashId: string
|
||||||
|
}): ReviewTip {
|
||||||
|
return {
|
||||||
|
id: params.id,
|
||||||
|
hash: params.hashId,
|
||||||
|
version: 0,
|
||||||
|
index: 0,
|
||||||
|
payerPubkey: params.payerPubkey,
|
||||||
|
articleId: params.articleId,
|
||||||
|
reviewId: params.reviewId,
|
||||||
|
reviewerPubkey: params.reviewerPubkey,
|
||||||
|
authorPubkey: params.authorPubkey,
|
||||||
|
amount: params.amount,
|
||||||
|
paymentHash: params.paymentHash,
|
||||||
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
|
...(params.seriesId ? { seriesId: params.seriesId } : {}),
|
||||||
|
...(params.text ? { text: params.text } : {}),
|
||||||
|
kindType: 'review_tip',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPaymentNoteTemplate(tags: string[][], content: string): EventTemplate {
|
||||||
|
return {
|
||||||
|
kind: 1,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSponsoringNotePayload(params: {
|
||||||
|
authorPubkey: string
|
||||||
|
payerPubkey: string
|
||||||
|
amount: number
|
||||||
|
paymentHash: string
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
seriesId?: string
|
||||||
|
articleId?: string
|
||||||
|
text?: string
|
||||||
|
transactionId?: string
|
||||||
|
}): Promise<{
|
||||||
|
hashId: string
|
||||||
|
eventTemplate: EventTemplate
|
||||||
|
parsedSponsoring: Sponsoring
|
||||||
|
}> {
|
||||||
const sponsoringData = {
|
const sponsoringData = {
|
||||||
payerPubkey: params.payerPubkey,
|
payerPubkey: params.payerPubkey,
|
||||||
authorPubkey: params.authorPubkey,
|
authorPubkey: params.authorPubkey,
|
||||||
@ -296,45 +332,9 @@ export async function publishSponsoringNote(params: {
|
|||||||
|
|
||||||
const hashId = await generateSponsoringHashId(sponsoringData)
|
const hashId = await generateSponsoringHashId(sponsoringData)
|
||||||
const id = buildObjectId(hashId, 0, 0)
|
const id = buildObjectId(hashId, 0, 0)
|
||||||
|
const tags = buildSponsoringNoteTags({ ...params, hashId })
|
||||||
|
tags.push(['json', buildSponsoringPaymentJson({ ...params, sponsoringData, id, hashId })])
|
||||||
|
|
||||||
const tags = buildTags({
|
|
||||||
type: 'payment',
|
|
||||||
category,
|
|
||||||
id: hashId,
|
|
||||||
service: PLATFORM_SERVICE,
|
|
||||||
version: 0,
|
|
||||||
hidden: false,
|
|
||||||
payment: true,
|
|
||||||
paymentType: 'sponsoring',
|
|
||||||
amount: params.amount,
|
|
||||||
payerPubkey: params.payerPubkey,
|
|
||||||
recipientPubkey: params.authorPubkey,
|
|
||||||
paymentHash: params.paymentHash,
|
|
||||||
...(params.seriesId ? { seriesId: params.seriesId } : {}),
|
|
||||||
...(params.articleId ? { articleId: params.articleId } : {}),
|
|
||||||
...(params.text ? { text: params.text } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add transaction ID if provided (for Bitcoin mainnet payments)
|
|
||||||
if (params.transactionId) {
|
|
||||||
tags.push(['transaction_id', params.transactionId])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build JSON metadata
|
|
||||||
const paymentJson = JSON.stringify({
|
|
||||||
type: 'sponsoring',
|
|
||||||
id,
|
|
||||||
hash: hashId,
|
|
||||||
version: 0,
|
|
||||||
index: 0,
|
|
||||||
...sponsoringData,
|
|
||||||
...(params.text ? { text: params.text } : {}),
|
|
||||||
...(params.transactionId ? { transactionId: params.transactionId } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
tags.push(['json', paymentJson])
|
|
||||||
|
|
||||||
// Build parsed Sponsoring object
|
|
||||||
const parsedSponsoring: Sponsoring = {
|
const parsedSponsoring: Sponsoring = {
|
||||||
id,
|
id,
|
||||||
hash: hashId,
|
hash: hashId,
|
||||||
@ -351,40 +351,107 @@ export async function publishSponsoringNote(params: {
|
|||||||
kindType: 'sponsoring',
|
kindType: 'sponsoring',
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = params.text
|
|
||||||
? `Sponsoring confirmed: ${params.amount} sats for author ${params.authorPubkey.substring(0, 16)}...\n\n${params.text}`
|
|
||||||
: `Sponsoring confirmed: ${params.amount} sats for author ${params.authorPubkey.substring(0, 16)}...`
|
|
||||||
|
|
||||||
const eventTemplate: EventTemplate = {
|
const eventTemplate: EventTemplate = {
|
||||||
kind: 1,
|
kind: 1,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags,
|
tags,
|
||||||
content,
|
content: buildSponsoringNoteContent(params),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { hashId, eventTemplate, parsedSponsoring }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSponsoringNoteContent(params: {
|
||||||
|
authorPubkey: string
|
||||||
|
amount: number
|
||||||
|
text?: string
|
||||||
|
}): string {
|
||||||
|
const prefix = `Sponsoring confirmed: ${params.amount} sats for author ${params.authorPubkey.substring(0, 16)}...`
|
||||||
|
return params.text ? `${prefix}\n\n${params.text}` : prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSponsoringNoteTags(params: {
|
||||||
|
authorPubkey: string
|
||||||
|
payerPubkey: string
|
||||||
|
amount: number
|
||||||
|
paymentHash: string
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
seriesId?: string
|
||||||
|
articleId?: string
|
||||||
|
text?: string
|
||||||
|
transactionId?: string
|
||||||
|
hashId: string
|
||||||
|
}): string[][] {
|
||||||
|
const tags = buildTags({
|
||||||
|
type: 'payment',
|
||||||
|
category: params.category,
|
||||||
|
id: params.hashId,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
|
version: 0,
|
||||||
|
hidden: false,
|
||||||
|
payment: true,
|
||||||
|
paymentType: 'sponsoring',
|
||||||
|
amount: params.amount,
|
||||||
|
payerPubkey: params.payerPubkey,
|
||||||
|
recipientPubkey: params.authorPubkey,
|
||||||
|
paymentHash: params.paymentHash,
|
||||||
|
...(params.seriesId ? { seriesId: params.seriesId } : {}),
|
||||||
|
...(params.articleId ? { articleId: params.articleId } : {}),
|
||||||
|
...(params.text ? { text: params.text } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (params.transactionId) {
|
||||||
|
tags.push(['transaction_id', params.transactionId])
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSponsoringPaymentJson(params: {
|
||||||
|
id: string
|
||||||
|
hashId: string
|
||||||
|
sponsoringData: Record<string, unknown>
|
||||||
|
text?: string
|
||||||
|
transactionId?: string
|
||||||
|
}): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'sponsoring',
|
||||||
|
id: params.id,
|
||||||
|
hash: params.hashId,
|
||||||
|
version: 0,
|
||||||
|
index: 0,
|
||||||
|
...params.sponsoringData,
|
||||||
|
...(params.text ? { text: params.text } : {}),
|
||||||
|
...(params.transactionId ? { transactionId: params.transactionId } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishPaymentNoteToRelays(params: {
|
||||||
|
payerPrivateKey: string
|
||||||
|
objectType: 'purchase' | 'review_tip' | 'sponsoring'
|
||||||
|
hash: string
|
||||||
|
eventTemplate: EventTemplate
|
||||||
|
parsed: Purchase | ReviewTip | Sponsoring
|
||||||
|
version: number
|
||||||
|
hidden: boolean
|
||||||
|
index: number
|
||||||
|
}): Promise<Event | null> {
|
||||||
nostrService.setPrivateKey(params.payerPrivateKey)
|
nostrService.setPrivateKey(params.payerPrivateKey)
|
||||||
writeOrchestrator.setPrivateKey(params.payerPrivateKey)
|
writeOrchestrator.setPrivateKey(params.payerPrivateKey)
|
||||||
|
|
||||||
// Finalize event
|
|
||||||
const secretKey = hexToBytes(params.payerPrivateKey)
|
const secretKey = hexToBytes(params.payerPrivateKey)
|
||||||
const event = finalizeEvent(eventTemplate, secretKey)
|
const event = finalizeEvent(params.eventTemplate, secretKey)
|
||||||
|
const relays = await getPublishRelays()
|
||||||
|
|
||||||
// Get active relays
|
|
||||||
const { relaySessionManager } = await import('./relaySessionManager')
|
|
||||||
const activeRelays = await relaySessionManager.getActiveRelays()
|
|
||||||
const { getPrimaryRelay } = await import('./config')
|
|
||||||
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
|
|
||||||
|
|
||||||
// Publish via writeOrchestrator (parallel network + local write)
|
|
||||||
const result = await writeOrchestrator.writeAndPublish(
|
const result = await writeOrchestrator.writeAndPublish(
|
||||||
{
|
{
|
||||||
objectType: 'sponsoring',
|
objectType: params.objectType,
|
||||||
hash: hashId,
|
hash: params.hash,
|
||||||
event,
|
event,
|
||||||
parsed: parsedSponsoring,
|
parsed: params.parsed,
|
||||||
version: 0,
|
version: params.version,
|
||||||
hidden: false,
|
hidden: params.hidden,
|
||||||
index: 0,
|
index: params.index,
|
||||||
},
|
},
|
||||||
relays
|
relays
|
||||||
)
|
)
|
||||||
@ -392,6 +459,5 @@ export async function publishSponsoringNote(params: {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,63 +26,75 @@ export async function sendPrivateContentAfterPayment(
|
|||||||
|
|
||||||
const result = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, validation.authorPrivateKey)
|
const result = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, validation.authorPrivateKey)
|
||||||
|
|
||||||
if (result.success && result.messageEventId) {
|
if (!result.success || !result.messageEventId) {
|
||||||
verifyPaymentAmount(amount, articleId)
|
|
||||||
|
|
||||||
const trackingData = createTrackingData({
|
|
||||||
articleId,
|
|
||||||
authorPubkey: validation.storedContent.authorPubkey,
|
|
||||||
recipientPubkey,
|
|
||||||
messageEventId: result.messageEventId,
|
|
||||||
amount,
|
|
||||||
verified: result.verified ?? false,
|
|
||||||
...(zapReceiptId ? { zapReceiptId } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
await platformTracking.trackContentDelivery(trackingData, validation.authorPrivateKey)
|
|
||||||
await triggerAutomaticTransfer(validation.storedContent.authorPubkey, articleId, amount)
|
|
||||||
|
|
||||||
// Publish explicit payment note (kind 1) with project tags
|
|
||||||
try {
|
|
||||||
const article = await nostrService.getArticleById(articleId)
|
|
||||||
if (!article) {
|
|
||||||
return logPaymentResult(result, articleId, recipientPubkey, amount)
|
|
||||||
}
|
|
||||||
|
|
||||||
const payerPrivateKey = nostrService.getPrivateKey()
|
|
||||||
if (!payerPrivateKey) {
|
|
||||||
return logPaymentResult(result, articleId, recipientPubkey, amount)
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentHash = await resolvePaymentHashForPurchaseNote({
|
|
||||||
articlePaymentHash: article.paymentHash,
|
|
||||||
articleId,
|
|
||||||
...(zapReceiptId ? { zapReceiptId } : {}),
|
|
||||||
})
|
|
||||||
const category = normalizePurchaseNoteCategory(article.category)
|
|
||||||
|
|
||||||
await publishPurchaseNote({
|
|
||||||
articleId: article.id,
|
|
||||||
authorPubkey: article.pubkey,
|
|
||||||
payerPubkey: recipientPubkey,
|
|
||||||
amount,
|
|
||||||
paymentHash,
|
|
||||||
...(zapReceiptId ? { zapReceiptId } : {}),
|
|
||||||
...(category ? { category } : {}),
|
|
||||||
...(article.seriesId ? { seriesId: article.seriesId } : {}),
|
|
||||||
payerPrivateKey,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error publishing purchase note:', error)
|
|
||||||
// Don't fail the payment if note publication fails
|
|
||||||
}
|
|
||||||
|
|
||||||
return logPaymentResult(result, articleId, recipientPubkey, amount)
|
return logPaymentResult(result, articleId, recipientPubkey, amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verifyPaymentAmount(amount, articleId)
|
||||||
|
|
||||||
|
const trackingData = createTrackingData({
|
||||||
|
articleId,
|
||||||
|
authorPubkey: validation.storedContent.authorPubkey,
|
||||||
|
recipientPubkey,
|
||||||
|
messageEventId: result.messageEventId,
|
||||||
|
amount,
|
||||||
|
verified: result.verified ?? false,
|
||||||
|
...(zapReceiptId ? { zapReceiptId } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await platformTracking.trackContentDelivery(trackingData, validation.authorPrivateKey)
|
||||||
|
await triggerAutomaticTransfer(validation.storedContent.authorPubkey, articleId, amount)
|
||||||
|
|
||||||
|
await tryPublishPurchaseNote({
|
||||||
|
articleId,
|
||||||
|
recipientPubkey,
|
||||||
|
amount,
|
||||||
|
...(zapReceiptId ? { zapReceiptId } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
return logPaymentResult(result, articleId, recipientPubkey, amount)
|
return logPaymentResult(result, articleId, recipientPubkey, amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tryPublishPurchaseNote(params: {
|
||||||
|
articleId: string
|
||||||
|
recipientPubkey: string
|
||||||
|
amount: number
|
||||||
|
zapReceiptId?: string
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const article = await nostrService.getArticleById(params.articleId)
|
||||||
|
if (!article) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payerPrivateKey = nostrService.getPrivateKey()
|
||||||
|
if (!payerPrivateKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentHash = await resolvePaymentHashForPurchaseNote({
|
||||||
|
articlePaymentHash: article.paymentHash,
|
||||||
|
articleId: params.articleId,
|
||||||
|
...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
|
||||||
|
})
|
||||||
|
const category = normalizePurchaseNoteCategory(article.category)
|
||||||
|
|
||||||
|
await publishPurchaseNote({
|
||||||
|
articleId: article.id,
|
||||||
|
authorPubkey: article.pubkey,
|
||||||
|
payerPubkey: params.recipientPubkey,
|
||||||
|
amount: params.amount,
|
||||||
|
paymentHash,
|
||||||
|
...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
|
||||||
|
...(category ? { category } : {}),
|
||||||
|
...(article.seriesId ? { seriesId: article.seriesId } : {}),
|
||||||
|
payerPrivateKey,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error publishing purchase note:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePurchaseNoteCategory(category: string | undefined): 'science-fiction' | 'scientific-research' | undefined {
|
function normalizePurchaseNoteCategory(category: string | undefined): 'science-fiction' | 'scientific-research' | undefined {
|
||||||
if (category === 'science-fiction' || category === 'scientific-research') {
|
if (category === 'science-fiction' || category === 'scientific-research') {
|
||||||
return category
|
return category
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import { extractTagsFromEvent } from './nostrTagSystem'
|
|||||||
import { parsePresentationEvent } from './articlePublisherHelpersPresentation'
|
import { parsePresentationEvent } from './articlePublisherHelpersPresentation'
|
||||||
import { parseArticleFromEvent, parseSeriesFromEvent, parseReviewFromEvent, parsePurchaseFromEvent, parseReviewTipFromEvent, parseSponsoringFromEvent } from './nostrEventParsing'
|
import { parseArticleFromEvent, parseSeriesFromEvent, parseReviewFromEvent, parsePurchaseFromEvent, parseReviewTipFromEvent, parseSponsoringFromEvent } from './nostrEventParsing'
|
||||||
|
|
||||||
|
const TARGET_EVENT_ID = '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763'
|
||||||
|
|
||||||
class PlatformSyncService {
|
class PlatformSyncService {
|
||||||
private syncInProgress = false
|
private syncInProgress = false
|
||||||
private syncSubscription: { unsub: () => void } | null = null
|
private syncSubscription: { unsub: () => void } | null = null
|
||||||
@ -145,45 +147,12 @@ class PlatformSyncService {
|
|||||||
}
|
}
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
sub.on('event', (event: Event): void => {
|
sub.on('event', (event: Event): void => {
|
||||||
eventCount++
|
eventCount = this.handleRelaySyncEvent({
|
||||||
// Log every 100th event to track progress (reduced frequency since we'll get more events)
|
event,
|
||||||
if (eventCount % 100 === 0) {
|
relayUrl,
|
||||||
console.warn(`[PlatformSync] Received ${eventCount} events from relay ${relayUrl} (client-side filtering in progress)`)
|
relayEvents,
|
||||||
}
|
eventCount,
|
||||||
|
})
|
||||||
// Log target event for debugging (always log if we receive it)
|
|
||||||
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
|
|
||||||
console.warn(`[PlatformSync] ✅ Received target event from relay ${relayUrl} (event #${eventCount}):`, {
|
|
||||||
id: event.id,
|
|
||||||
created_at: event.created_at,
|
|
||||||
created_at_date: new Date(event.created_at * 1000).toISOString(),
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
allTags: event.tags,
|
|
||||||
serviceTags: event.tags.filter((tag) => tag[0] === 'service'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter client-side: only process events with service='zapwall.fr'
|
|
||||||
const tags = extractTagsFromEvent(event)
|
|
||||||
|
|
||||||
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
|
|
||||||
console.warn(`[PlatformSync] Extracted tags for target event:`, {
|
|
||||||
extractedTags: tags,
|
|
||||||
hasServiceTag: tags.service === PLATFORM_SERVICE,
|
|
||||||
serviceValue: tags.service,
|
|
||||||
expectedService: PLATFORM_SERVICE,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tags.service === PLATFORM_SERVICE) {
|
|
||||||
relayEvents.push(event)
|
|
||||||
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
|
|
||||||
console.warn(`[PlatformSync] Target event accepted and added to relayEvents`)
|
|
||||||
}
|
|
||||||
} else if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
|
|
||||||
// Log events that match filter but don't have service tag
|
|
||||||
console.warn(`[PlatformSync] Event ${event.id} rejected: service tag is "${tags.service}", expected "${PLATFORM_SERVICE}"`)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
sub.on('eose', (): void => {
|
sub.on('eose', (): void => {
|
||||||
@ -247,6 +216,74 @@ class PlatformSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleRelaySyncEvent(params: {
|
||||||
|
event: Event
|
||||||
|
relayUrl: string
|
||||||
|
relayEvents: Event[]
|
||||||
|
eventCount: number
|
||||||
|
}): number {
|
||||||
|
const nextCount = params.eventCount + 1
|
||||||
|
this.logRelaySyncProgress({ relayUrl: params.relayUrl, eventCount: nextCount })
|
||||||
|
this.logTargetEventReceived({ relayUrl: params.relayUrl, event: params.event, eventCount: nextCount })
|
||||||
|
|
||||||
|
const tags = extractTagsFromEvent(params.event)
|
||||||
|
this.logTargetEventTags({ event: params.event, tags })
|
||||||
|
|
||||||
|
if (tags.service === PLATFORM_SERVICE) {
|
||||||
|
params.relayEvents.push(params.event)
|
||||||
|
this.logTargetEventAccepted(params.event)
|
||||||
|
} else {
|
||||||
|
this.logTargetEventRejected({ event: params.event, tags })
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private logRelaySyncProgress(params: { relayUrl: string; eventCount: number }): void {
|
||||||
|
if (params.eventCount % 100 === 0) {
|
||||||
|
console.warn(`[PlatformSync] Received ${params.eventCount} events from relay ${params.relayUrl} (client-side filtering in progress)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private logTargetEventReceived(params: { relayUrl: string; event: Event; eventCount: number }): void {
|
||||||
|
if (params.event.id !== TARGET_EVENT_ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.warn(`[PlatformSync] ✅ Received target event from relay ${params.relayUrl} (event #${params.eventCount}):`, {
|
||||||
|
id: params.event.id,
|
||||||
|
created_at: params.event.created_at,
|
||||||
|
created_at_date: new Date(params.event.created_at * 1000).toISOString(),
|
||||||
|
pubkey: params.event.pubkey,
|
||||||
|
allTags: params.event.tags,
|
||||||
|
serviceTags: params.event.tags.filter((tag) => tag[0] === 'service'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private logTargetEventTags(params: { event: Event; tags: ReturnType<typeof extractTagsFromEvent> }): void {
|
||||||
|
if (params.event.id !== TARGET_EVENT_ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.warn(`[PlatformSync] Extracted tags for target event:`, {
|
||||||
|
extractedTags: params.tags,
|
||||||
|
hasServiceTag: params.tags.service === PLATFORM_SERVICE,
|
||||||
|
serviceValue: params.tags.service,
|
||||||
|
expectedService: PLATFORM_SERVICE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private logTargetEventAccepted(event: Event): void {
|
||||||
|
if (event.id === TARGET_EVENT_ID) {
|
||||||
|
console.warn(`[PlatformSync] Target event accepted and added to relayEvents`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private logTargetEventRejected(params: { event: Event; tags: ReturnType<typeof extractTagsFromEvent> }): void {
|
||||||
|
if (params.event.id !== TARGET_EVENT_ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.warn(`[PlatformSync] Event ${params.event.id} rejected: service tag is "${params.tags.service}", expected "${PLATFORM_SERVICE}"`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a single event and cache it
|
* Process a single event and cache it
|
||||||
*/
|
*/
|
||||||
@ -254,128 +291,202 @@ class PlatformSyncService {
|
|||||||
const tags = extractTagsFromEvent(event)
|
const tags = extractTagsFromEvent(event)
|
||||||
const { writeObjectToCache } = await import('./helpers/writeObjectHelper')
|
const { writeObjectToCache } = await import('./helpers/writeObjectHelper')
|
||||||
|
|
||||||
// Log target event for debugging
|
logTargetEventDebug({ event, tags })
|
||||||
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
|
|
||||||
console.warn(`[PlatformSync] Processing target event:`, {
|
|
||||||
id: event.id,
|
|
||||||
type: tags.type,
|
|
||||||
hidden: tags.hidden,
|
|
||||||
service: tags.service,
|
|
||||||
version: tags.version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip hidden events
|
|
||||||
if (tags.hidden) {
|
if (tags.hidden) {
|
||||||
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
|
logTargetEventSkipped({ event, tags })
|
||||||
console.warn(`[PlatformSync] Target event skipped: hidden=${tags.hidden}`)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse and cache by type
|
if (await this.tryCacheAuthorEvent({ event, tags, writeObjectToCache })) {
|
||||||
if (tags.type === 'author') {
|
return
|
||||||
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
|
|
||||||
console.warn(`[PlatformSync] Attempting to parse target event as author presentation`)
|
|
||||||
}
|
|
||||||
const parsed = await parsePresentationEvent(event)
|
|
||||||
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
|
|
||||||
console.warn(`[PlatformSync] parsePresentationEvent result for target event:`, {
|
|
||||||
parsed: parsed !== null,
|
|
||||||
hasHash: parsed?.hash !== undefined,
|
|
||||||
hash: parsed?.hash,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (parsed?.hash) {
|
|
||||||
await writeObjectToCache({
|
|
||||||
objectType: 'author',
|
|
||||||
hash: parsed.hash,
|
|
||||||
event,
|
|
||||||
parsed,
|
|
||||||
version: tags.version,
|
|
||||||
hidden: tags.hidden,
|
|
||||||
index: parsed.index,
|
|
||||||
})
|
|
||||||
if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
|
|
||||||
console.warn(`[PlatformSync] Target event cached successfully as author with hash:`, parsed.hash)
|
|
||||||
}
|
|
||||||
} else if (event.id === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763') {
|
|
||||||
console.warn(`[PlatformSync] Target event NOT cached: parsed=${parsed !== null}, hasHash=${parsed?.hash !== undefined}`)
|
|
||||||
}
|
|
||||||
} else if (tags.type === 'series') {
|
|
||||||
const parsed = await parseSeriesFromEvent(event)
|
|
||||||
if (parsed?.hash) {
|
|
||||||
await writeObjectToCache({
|
|
||||||
objectType: 'series',
|
|
||||||
hash: parsed.hash,
|
|
||||||
event,
|
|
||||||
parsed,
|
|
||||||
version: tags.version,
|
|
||||||
hidden: tags.hidden,
|
|
||||||
index: parsed.index,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else if (tags.type === 'publication') {
|
|
||||||
const parsed = await parseArticleFromEvent(event)
|
|
||||||
if (parsed?.hash) {
|
|
||||||
await writeObjectToCache({
|
|
||||||
objectType: 'publication',
|
|
||||||
hash: parsed.hash,
|
|
||||||
event,
|
|
||||||
parsed,
|
|
||||||
version: tags.version,
|
|
||||||
hidden: tags.hidden,
|
|
||||||
index: parsed.index,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else if (tags.type === 'quote') {
|
|
||||||
const parsed = await parseReviewFromEvent(event)
|
|
||||||
if (parsed?.hash) {
|
|
||||||
await writeObjectToCache({
|
|
||||||
objectType: 'review',
|
|
||||||
hash: parsed.hash,
|
|
||||||
event,
|
|
||||||
parsed,
|
|
||||||
version: tags.version,
|
|
||||||
hidden: tags.hidden,
|
|
||||||
index: parsed.index,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else if (event.kind === 9735) {
|
|
||||||
// Zap receipts (kind 9735) can be sponsoring, purchase, or review_tip
|
|
||||||
const sponsoring = await parseSponsoringFromEvent(event)
|
|
||||||
if (sponsoring?.hash) {
|
|
||||||
await writeObjectToCache({
|
|
||||||
objectType: 'sponsoring',
|
|
||||||
hash: sponsoring.hash,
|
|
||||||
event,
|
|
||||||
parsed: sponsoring,
|
|
||||||
index: sponsoring.index,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const purchase = await parsePurchaseFromEvent(event)
|
|
||||||
if (purchase?.hash) {
|
|
||||||
await writeObjectToCache({
|
|
||||||
objectType: 'purchase',
|
|
||||||
hash: purchase.hash,
|
|
||||||
event,
|
|
||||||
parsed: purchase,
|
|
||||||
index: purchase.index,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const reviewTip = await parseReviewTipFromEvent(event)
|
|
||||||
if (reviewTip?.hash) {
|
|
||||||
await writeObjectToCache({
|
|
||||||
objectType: 'review_tip',
|
|
||||||
hash: reviewTip.hash,
|
|
||||||
event,
|
|
||||||
parsed: reviewTip,
|
|
||||||
index: reviewTip.index,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (await this.tryCacheSeriesEvent({ event, tags, writeObjectToCache })) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (await this.tryCachePublicationEvent({ event, tags, writeObjectToCache })) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (await this.tryCacheReviewEvent({ event, tags, writeObjectToCache })) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.tryCacheZapReceiptEvent({ event, writeObjectToCache })
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryCacheAuthorEvent(params: {
|
||||||
|
event: Event
|
||||||
|
tags: ReturnType<typeof extractTagsFromEvent>
|
||||||
|
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (params.tags.type !== 'author') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
logTargetEventAttempt({ event: params.event, message: 'Attempting to parse target event as author presentation' })
|
||||||
|
const parsed = await parsePresentationEvent(params.event)
|
||||||
|
logTargetEventParsed({ event: params.event, parsed })
|
||||||
|
|
||||||
|
if (!parsed?.hash) {
|
||||||
|
logTargetEventNotCached({ event: params.event, parsed })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
await params.writeObjectToCache({
|
||||||
|
objectType: 'author',
|
||||||
|
hash: parsed.hash,
|
||||||
|
event: params.event,
|
||||||
|
parsed,
|
||||||
|
version: params.tags.version,
|
||||||
|
hidden: params.tags.hidden,
|
||||||
|
index: parsed.index,
|
||||||
|
})
|
||||||
|
logTargetEventCached({ event: params.event, hash: parsed.hash })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryCacheSeriesEvent(params: {
|
||||||
|
event: Event
|
||||||
|
tags: ReturnType<typeof extractTagsFromEvent>
|
||||||
|
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (params.tags.type !== 'series') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const parsed = await parseSeriesFromEvent(params.event)
|
||||||
|
if (!parsed?.hash) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
await params.writeObjectToCache({
|
||||||
|
objectType: 'series',
|
||||||
|
hash: parsed.hash,
|
||||||
|
event: params.event,
|
||||||
|
parsed,
|
||||||
|
version: params.tags.version,
|
||||||
|
hidden: params.tags.hidden,
|
||||||
|
index: parsed.index,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryCachePublicationEvent(params: {
|
||||||
|
event: Event
|
||||||
|
tags: ReturnType<typeof extractTagsFromEvent>
|
||||||
|
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (params.tags.type !== 'publication') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const parsed = await parseArticleFromEvent(params.event)
|
||||||
|
if (!parsed?.hash) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
await params.writeObjectToCache({
|
||||||
|
objectType: 'publication',
|
||||||
|
hash: parsed.hash,
|
||||||
|
event: params.event,
|
||||||
|
parsed,
|
||||||
|
version: params.tags.version,
|
||||||
|
hidden: params.tags.hidden,
|
||||||
|
index: parsed.index,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryCacheReviewEvent(params: {
|
||||||
|
event: Event
|
||||||
|
tags: ReturnType<typeof extractTagsFromEvent>
|
||||||
|
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (params.tags.type !== 'quote') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const parsed = await parseReviewFromEvent(params.event)
|
||||||
|
if (!parsed?.hash) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
await params.writeObjectToCache({
|
||||||
|
objectType: 'review',
|
||||||
|
hash: parsed.hash,
|
||||||
|
event: params.event,
|
||||||
|
parsed,
|
||||||
|
version: params.tags.version,
|
||||||
|
hidden: params.tags.hidden,
|
||||||
|
index: parsed.index,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryCacheZapReceiptEvent(params: {
|
||||||
|
event: Event
|
||||||
|
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (params.event.kind !== 9735) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await this.tryCacheSponsoringZapReceipt(params)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (await this.tryCachePurchaseZapReceipt(params)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (await this.tryCacheReviewTipZapReceipt(params)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryCacheSponsoringZapReceipt(params: {
|
||||||
|
event: Event
|
||||||
|
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const sponsoring = await parseSponsoringFromEvent(params.event)
|
||||||
|
if (!sponsoring?.hash) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await params.writeObjectToCache({
|
||||||
|
objectType: 'sponsoring',
|
||||||
|
hash: sponsoring.hash,
|
||||||
|
event: params.event,
|
||||||
|
parsed: sponsoring,
|
||||||
|
index: sponsoring.index,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryCachePurchaseZapReceipt(params: {
|
||||||
|
event: Event
|
||||||
|
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const purchase = await parsePurchaseFromEvent(params.event)
|
||||||
|
if (!purchase?.hash) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await params.writeObjectToCache({
|
||||||
|
objectType: 'purchase',
|
||||||
|
hash: purchase.hash,
|
||||||
|
event: params.event,
|
||||||
|
parsed: purchase,
|
||||||
|
index: purchase.index,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryCacheReviewTipZapReceipt(params: {
|
||||||
|
event: Event
|
||||||
|
writeObjectToCache: typeof import('./helpers/writeObjectHelper').writeObjectToCache
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const reviewTip = await parseReviewTipFromEvent(params.event)
|
||||||
|
if (!reviewTip?.hash) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await params.writeObjectToCache({
|
||||||
|
objectType: 'review_tip',
|
||||||
|
hash: reviewTip.hash,
|
||||||
|
event: params.event,
|
||||||
|
parsed: reviewTip,
|
||||||
|
index: reviewTip.index,
|
||||||
|
})
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -453,3 +564,61 @@ class PlatformSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const platformSyncService = new PlatformSyncService()
|
export const platformSyncService = new PlatformSyncService()
|
||||||
|
|
||||||
|
function isTargetDebugEvent(eventId: string): boolean {
|
||||||
|
return eventId === '527d83e0af20bf23c3e104974090ccc21536ece72c24eb784b3642890f63b763'
|
||||||
|
}
|
||||||
|
|
||||||
|
function logTargetEventDebug(params: { event: Event; tags: ReturnType<typeof extractTagsFromEvent> }): void {
|
||||||
|
if (!isTargetDebugEvent(params.event.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.warn(`[PlatformSync] Processing target event:`, {
|
||||||
|
id: params.event.id,
|
||||||
|
type: params.tags.type,
|
||||||
|
hidden: params.tags.hidden,
|
||||||
|
service: params.tags.service,
|
||||||
|
version: params.tags.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function logTargetEventSkipped(params: { event: Event; tags: ReturnType<typeof extractTagsFromEvent> }): void {
|
||||||
|
if (!isTargetDebugEvent(params.event.id) || !params.tags.hidden) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.warn(`[PlatformSync] Target event skipped: hidden=${params.tags.hidden}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logTargetEventAttempt(params: { event: Event; message: string }): void {
|
||||||
|
if (!isTargetDebugEvent(params.event.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.warn(`[PlatformSync] ${params.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logTargetEventParsed(params: { event: Event; parsed: unknown }): void {
|
||||||
|
if (!isTargetDebugEvent(params.event.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parsedObj = params.parsed as { hash?: string } | null
|
||||||
|
console.warn(`[PlatformSync] parsePresentationEvent result for target event:`, {
|
||||||
|
parsed: parsedObj !== null,
|
||||||
|
hasHash: parsedObj?.hash !== undefined,
|
||||||
|
hash: parsedObj?.hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function logTargetEventCached(params: { event: Event; hash: string }): void {
|
||||||
|
if (!isTargetDebugEvent(params.event.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.warn(`[PlatformSync] Target event cached successfully as author with hash:`, params.hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logTargetEventNotCached(params: { event: Event; parsed: unknown }): void {
|
||||||
|
if (!isTargetDebugEvent(params.event.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parsedObj = params.parsed as { hash?: string } | null
|
||||||
|
console.warn(`[PlatformSync] Target event NOT cached: parsed=${parsedObj !== null}, hasHash=${parsedObj?.hash !== undefined}`)
|
||||||
|
}
|
||||||
|
|||||||
@ -108,40 +108,10 @@ class PublishWorkerService {
|
|||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load unpublished objects from all object types
|
const objectTypes = getAllPublishableObjectTypes()
|
||||||
const objectTypes: ObjectType[] = ['author', 'series', 'publication', 'review', 'purchase', 'sponsoring', 'review_tip', 'payment_note']
|
const now = Date.now()
|
||||||
|
await this.refreshUnpublishedMap({ objectTypes, now })
|
||||||
for (const objectType of objectTypes) {
|
await this.processQueuedObjects()
|
||||||
const unpublished = await objectCache.getUnpublished(objectType)
|
|
||||||
|
|
||||||
for (const { id, event } of unpublished) {
|
|
||||||
const key = `${objectType}:${id}`
|
|
||||||
const existing = this.unpublishedObjects.get(key)
|
|
||||||
|
|
||||||
// Skip if recently retried or max retries reached
|
|
||||||
const recentlyRetried = existing && Date.now() - existing.lastRetryAt < RETRY_DELAY_MS
|
|
||||||
const maxRetriesReached = existing && existing.retryCount >= MAX_RETRIES_PER_OBJECT
|
|
||||||
|
|
||||||
if (maxRetriesReached) {
|
|
||||||
console.warn(`[PublishWorker] Max retries reached for ${objectType}:${id}, skipping`)
|
|
||||||
} else if (!recentlyRetried) {
|
|
||||||
// Add or update in map
|
|
||||||
this.unpublishedObjects.set(key, {
|
|
||||||
objectType,
|
|
||||||
id,
|
|
||||||
event,
|
|
||||||
retryCount: existing?.retryCount ?? 0,
|
|
||||||
lastRetryAt: Date.now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process all unpublished objects
|
|
||||||
const objectsToProcess = Array.from(this.unpublishedObjects.entries())
|
|
||||||
for (const [key, obj] of objectsToProcess) {
|
|
||||||
await this.attemptPublish({ key, obj })
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[PublishWorker] Error processing unpublished objects:', error)
|
console.error('[PublishWorker] Error processing unpublished objects:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@ -149,6 +119,46 @@ class PublishWorkerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refreshUnpublishedMap(params: { objectTypes: ObjectType[]; now: number }): Promise<void> {
|
||||||
|
for (const objectType of params.objectTypes) {
|
||||||
|
const unpublished = await objectCache.getUnpublished(objectType)
|
||||||
|
this.upsertUnpublishedObjects({ objectType, unpublished, now: params.now })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private upsertUnpublishedObjects(params: {
|
||||||
|
objectType: ObjectType
|
||||||
|
unpublished: Array<{ id: string; event: import('nostr-tools').Event }>
|
||||||
|
now: number
|
||||||
|
}): void {
|
||||||
|
for (const { id, event } of params.unpublished) {
|
||||||
|
const key = buildUnpublishedKey(params.objectType, id)
|
||||||
|
const existing = this.unpublishedObjects.get(key)
|
||||||
|
|
||||||
|
if (existing && existing.retryCount >= MAX_RETRIES_PER_OBJECT) {
|
||||||
|
console.warn(`[PublishWorker] Max retries reached for ${params.objectType}:${id}, skipping`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldSkip = Boolean(existing && params.now - existing.lastRetryAt < RETRY_DELAY_MS)
|
||||||
|
if (!shouldSkip && (!existing || existing.retryCount < MAX_RETRIES_PER_OBJECT)) {
|
||||||
|
this.unpublishedObjects.set(key, {
|
||||||
|
objectType: params.objectType,
|
||||||
|
id,
|
||||||
|
event,
|
||||||
|
retryCount: existing?.retryCount ?? 0,
|
||||||
|
lastRetryAt: params.now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processQueuedObjects(): Promise<void> {
|
||||||
|
const objectsToProcess = Array.from(this.unpublishedObjects.entries())
|
||||||
|
for (const [key, obj] of objectsToProcess) {
|
||||||
|
await this.attemptPublish({ key, obj })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to publish an unpublished object
|
* Attempt to publish an unpublished object
|
||||||
* Uses websocketService to route events to Service Worker
|
* Uses websocketService to route events to Service Worker
|
||||||
@ -239,3 +249,11 @@ class PublishWorkerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const publishWorker = new PublishWorkerService()
|
export const publishWorker = new PublishWorkerService()
|
||||||
|
|
||||||
|
function getAllPublishableObjectTypes(): ObjectType[] {
|
||||||
|
return ['author', 'series', 'publication', 'review', 'purchase', 'sponsoring', 'review_tip', 'payment_note']
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUnpublishedKey(objectType: ObjectType, id: string): string {
|
||||||
|
return `${objectType}:${id}`
|
||||||
|
}
|
||||||
|
|||||||
10
lib/relaySelection.ts
Normal file
10
lib/relaySelection.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { relaySessionManager } from './relaySessionManager'
|
||||||
|
import { getPrimaryRelay } from './config'
|
||||||
|
|
||||||
|
export async function getPublishRelays(): Promise<string[]> {
|
||||||
|
const activeRelays = await relaySessionManager.getActiveRelays()
|
||||||
|
if (activeRelays.length > 0) {
|
||||||
|
return activeRelays
|
||||||
|
}
|
||||||
|
return [await getPrimaryRelay()]
|
||||||
|
}
|
||||||
@ -1,9 +1,13 @@
|
|||||||
import { nostrService } from './nostr'
|
import { nostrService } from './nostr'
|
||||||
import { PLATFORM_COMMISSIONS } from './platformCommissions'
|
import { PLATFORM_COMMISSIONS } from './platformCommissions'
|
||||||
import type { Event } from 'nostr-tools'
|
import type { Event as NostrEvent } from 'nostr-tools'
|
||||||
|
import { finalizeEvent } from 'nostr-tools'
|
||||||
|
import { hexToBytes } from 'nostr-tools/utils'
|
||||||
import { objectCache } from './objectCache'
|
import { objectCache } from './objectCache'
|
||||||
|
import type { Review } from '@/types/nostr'
|
||||||
|
import { getPublishRelays } from './relaySelection'
|
||||||
|
|
||||||
export async function fetchOriginalReviewEvent(reviewId: string): Promise<Event | null> {
|
export async function fetchOriginalReviewEvent(reviewId: string): Promise<NostrEvent | null> {
|
||||||
// Read only from IndexedDB cache
|
// Read only from IndexedDB cache
|
||||||
const parsed = await objectCache.getById('review', reviewId)
|
const parsed = await objectCache.getById('review', reviewId)
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
@ -18,7 +22,7 @@ export async function fetchOriginalReviewEvent(reviewId: string): Promise<Event
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRewardEvent(originalEvent: Event, reviewId: string): {
|
export function buildRewardEvent(originalEvent: NostrEvent, reviewId: string): {
|
||||||
kind: number
|
kind: number
|
||||||
created_at: number
|
created_at: number
|
||||||
tags: string[][]
|
tags: string[][]
|
||||||
@ -37,7 +41,7 @@ export function buildRewardEvent(originalEvent: Event, reviewId: string): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkIfAlreadyRewarded(originalEvent: Event, reviewId: string): boolean {
|
export function checkIfAlreadyRewarded(originalEvent: NostrEvent, reviewId: string): boolean {
|
||||||
const alreadyRewarded = originalEvent.tags.some((tag) => tag[0] === 'rewarded' && tag[1] === 'true')
|
const alreadyRewarded = originalEvent.tags.some((tag) => tag[0] === 'rewarded' && tag[1] === 'true')
|
||||||
if (alreadyRewarded) {
|
if (alreadyRewarded) {
|
||||||
console.warn('Review already marked as rewarded', {
|
console.warn('Review already marked as rewarded', {
|
||||||
@ -58,77 +62,17 @@ export async function publishRewardEvent(
|
|||||||
reviewId: string
|
reviewId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Get original review to extract hash and parsed data
|
const { updatedParsed, hash, index, newVersion } = await buildRewardedReviewUpdate(reviewId)
|
||||||
const originalEvent = await fetchOriginalReviewEvent(reviewId)
|
const privateKey = getPrivateKeyOrThrow()
|
||||||
if (!originalEvent) {
|
|
||||||
throw new Error('Original review event not found in cache')
|
|
||||||
}
|
|
||||||
|
|
||||||
const { parseReviewFromEvent } = await import('./nostrEventParsing')
|
|
||||||
const originalParsed = await parseReviewFromEvent(originalEvent)
|
|
||||||
if (!originalParsed) {
|
|
||||||
throw new Error('Failed to parse original review')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment version for update
|
|
||||||
const newVersion = (originalParsed.version ?? 0) + 1
|
|
||||||
const {hash} = originalParsed
|
|
||||||
const index = originalParsed.index ?? 0
|
|
||||||
|
|
||||||
// Build updated parsed Review object
|
|
||||||
const updatedParsed = {
|
|
||||||
...originalParsed,
|
|
||||||
version: newVersion,
|
|
||||||
rewarded: true,
|
|
||||||
rewardAmount: PLATFORM_COMMISSIONS.review.total,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set private key in orchestrator
|
|
||||||
const privateKey = nostrService.getPrivateKey()
|
|
||||||
if (!privateKey) {
|
|
||||||
throw new Error('Private key required for signing')
|
|
||||||
}
|
|
||||||
const { writeOrchestrator } = await import('./writeOrchestrator')
|
const { writeOrchestrator } = await import('./writeOrchestrator')
|
||||||
writeOrchestrator.setPrivateKey(privateKey)
|
writeOrchestrator.setPrivateKey(privateKey)
|
||||||
|
const event = finalizeEvent(updatedEventTemplate, hexToBytes(privateKey))
|
||||||
// Finalize event
|
const relays = await getPublishRelays()
|
||||||
const { finalizeEvent } = await import('nostr-tools')
|
|
||||||
const { hexToBytes } = await import('nostr-tools/utils')
|
|
||||||
const secretKey = hexToBytes(privateKey)
|
|
||||||
const event = finalizeEvent(updatedEventTemplate, secretKey)
|
|
||||||
|
|
||||||
// Get active relays
|
|
||||||
const { relaySessionManager } = await import('./relaySessionManager')
|
|
||||||
const activeRelays = await relaySessionManager.getActiveRelays()
|
|
||||||
const { getPrimaryRelay } = await import('./config')
|
|
||||||
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
|
|
||||||
|
|
||||||
// Publish via writeOrchestrator (parallel network + local write)
|
|
||||||
const result = await writeOrchestrator.writeAndPublish(
|
const result = await writeOrchestrator.writeAndPublish(
|
||||||
{
|
{ objectType: 'review', hash, event, parsed: updatedParsed, version: newVersion, hidden: false, index },
|
||||||
objectType: 'review',
|
|
||||||
hash,
|
|
||||||
event,
|
|
||||||
parsed: updatedParsed,
|
|
||||||
version: newVersion,
|
|
||||||
hidden: false,
|
|
||||||
index,
|
|
||||||
},
|
|
||||||
relays
|
relays
|
||||||
)
|
)
|
||||||
|
logRewardPublishResult({ reviewId, eventId: event.id, success: result.success })
|
||||||
if (result.success) {
|
|
||||||
console.warn('Review updated with reward tag', {
|
|
||||||
reviewId,
|
|
||||||
updatedEventId: event.id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
console.error('Failed to publish updated review event', {
|
|
||||||
reviewId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error publishing reward event', {
|
console.error('Error publishing reward event', {
|
||||||
reviewId,
|
reviewId,
|
||||||
@ -138,6 +82,62 @@ export async function publishRewardEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPrivateKeyOrThrow(): string {
|
||||||
|
const privateKey = nostrService.getPrivateKey()
|
||||||
|
if (!privateKey) {
|
||||||
|
throw new Error('Private key required for signing')
|
||||||
|
}
|
||||||
|
return privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildRewardedReviewUpdate(reviewId: string): Promise<{
|
||||||
|
updatedParsed: Review
|
||||||
|
hash: string
|
||||||
|
index: number
|
||||||
|
newVersion: number
|
||||||
|
}> {
|
||||||
|
const originalEvent = await fetchOriginalReviewEvent(reviewId)
|
||||||
|
if (!originalEvent) {
|
||||||
|
throw new Error('Original review event not found in cache')
|
||||||
|
}
|
||||||
|
const { parseReviewFromEvent } = await import('./nostrEventParsing')
|
||||||
|
const originalParsed = await parseReviewFromEvent(originalEvent)
|
||||||
|
if (!originalParsed) {
|
||||||
|
throw new Error('Failed to parse original review')
|
||||||
|
}
|
||||||
|
const newVersion = (originalParsed.version ?? 0) + 1
|
||||||
|
return {
|
||||||
|
updatedParsed: buildRewardedParsedReview(originalParsed, newVersion),
|
||||||
|
hash: originalParsed.hash,
|
||||||
|
index: originalParsed.index ?? 0,
|
||||||
|
newVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRewardedParsedReview(originalParsed: Review, newVersion: number): Review {
|
||||||
|
return {
|
||||||
|
...originalParsed,
|
||||||
|
version: newVersion,
|
||||||
|
rewarded: true,
|
||||||
|
rewardAmount: PLATFORM_COMMISSIONS.review.total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logRewardPublishResult(params: { reviewId: string; eventId: string; success: boolean }): void {
|
||||||
|
if (params.success) {
|
||||||
|
console.warn('Review updated with reward tag', {
|
||||||
|
reviewId: params.reviewId,
|
||||||
|
updatedEventId: params.eventId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('Failed to publish updated review event', {
|
||||||
|
reviewId: params.reviewId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateReviewWithReward(reviewId: string, authorPrivateKey: string): Promise<void> {
|
export async function updateReviewWithReward(reviewId: string, authorPrivateKey: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const originalEvent = await fetchOriginalReviewEvent(reviewId)
|
const originalEvent = await fetchOriginalReviewEvent(reviewId)
|
||||||
|
|||||||
@ -28,41 +28,7 @@ class ServiceWorkerSyncHandler {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await swClient.register()
|
await swClient.register()
|
||||||
|
this.registerMessageHandlers()
|
||||||
// Listen for sync requests from Service Worker
|
|
||||||
swClient.onMessage('SYNC_REQUEST', (data: unknown) => {
|
|
||||||
void (async (): Promise<void> => {
|
|
||||||
const syncData = data as { syncType: string; userPubkey?: string }
|
|
||||||
if (syncData.syncType === 'platform') {
|
|
||||||
await this.handlePlatformSyncRequest()
|
|
||||||
} else if (syncData.syncType === 'user' && syncData.userPubkey) {
|
|
||||||
await this.handleUserSyncRequest(syncData.userPubkey)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Listen for publish worker requests
|
|
||||||
swClient.onMessage('PUBLISH_WORKER_REQUEST', () => {
|
|
||||||
void (async (): Promise<void> => {
|
|
||||||
await this.handlePublishWorkerRequest()
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Listen for publish requests
|
|
||||||
swClient.onMessage('PUBLISH_REQUEST', (data: unknown) => {
|
|
||||||
void (async (): Promise<void> => {
|
|
||||||
const publishData = data as { event: Event; relays: string[] }
|
|
||||||
await this.handlePublishRequest(publishData.event, publishData.relays)
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Listen for notification detection requests
|
|
||||||
swClient.onMessage('NOTIFICATION_DETECT_REQUEST', (data: unknown) => {
|
|
||||||
void (async (): Promise<void> => {
|
|
||||||
const detectData = data as { userPubkey: string }
|
|
||||||
await this.handleNotificationDetectRequest(detectData.userPubkey)
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
console.warn('[SWSyncHandler] Initialized')
|
console.warn('[SWSyncHandler] Initialized')
|
||||||
@ -71,6 +37,42 @@ class ServiceWorkerSyncHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private registerMessageHandlers(): void {
|
||||||
|
swClient.onMessage('SYNC_REQUEST', (data: unknown) => {
|
||||||
|
void this.handleSyncRequestMessage(data)
|
||||||
|
})
|
||||||
|
swClient.onMessage('PUBLISH_WORKER_REQUEST', () => {
|
||||||
|
void this.handlePublishWorkerRequest()
|
||||||
|
})
|
||||||
|
swClient.onMessage('PUBLISH_REQUEST', (data: unknown) => {
|
||||||
|
void this.handlePublishRequestMessage(data)
|
||||||
|
})
|
||||||
|
swClient.onMessage('NOTIFICATION_DETECT_REQUEST', (data: unknown) => {
|
||||||
|
void this.handleNotificationDetectRequestMessage(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSyncRequestMessage(data: unknown): Promise<void> {
|
||||||
|
const syncData = data as { syncType: string; userPubkey?: string }
|
||||||
|
if (syncData.syncType === 'platform') {
|
||||||
|
await this.handlePlatformSyncRequest()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (syncData.syncType === 'user' && syncData.userPubkey) {
|
||||||
|
await this.handleUserSyncRequest(syncData.userPubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handlePublishRequestMessage(data: unknown): Promise<void> {
|
||||||
|
const publishData = data as { event: Event; relays: string[] }
|
||||||
|
await this.handlePublishRequest(publishData.event, publishData.relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleNotificationDetectRequestMessage(data: unknown): Promise<void> {
|
||||||
|
const detectData = data as { userPubkey: string }
|
||||||
|
await this.handleNotificationDetectRequest(detectData.userPubkey)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle platform sync request from Service Worker
|
* Handle platform sync request from Service Worker
|
||||||
*/
|
*/
|
||||||
@ -141,44 +143,10 @@ class ServiceWorkerSyncHandler {
|
|||||||
|
|
||||||
// Publish to specified relays via websocketService (routes to Service Worker)
|
// Publish to specified relays via websocketService (routes to Service Worker)
|
||||||
const statuses = await websocketService.publishEvent(event, relays)
|
const statuses = await websocketService.publishEvent(event, relays)
|
||||||
const successfulRelays: string[] = []
|
const successfulRelays = logPublishStatuses({ publishLog, eventId: event.id, relays, statuses })
|
||||||
|
|
||||||
statuses.forEach((status, index) => {
|
|
||||||
const relayUrl = relays[index]
|
|
||||||
if (!relayUrl) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.success) {
|
|
||||||
successfulRelays.push(relayUrl)
|
|
||||||
// Log successful publication
|
|
||||||
void publishLog.logPublication({
|
|
||||||
eventId: event.id,
|
|
||||||
relayUrl,
|
|
||||||
success: true,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const errorMessage = status.error ?? 'Unknown error'
|
|
||||||
console.error(`[SWSyncHandler] Relay ${relayUrl} failed:`, errorMessage)
|
|
||||||
// Log failed publication
|
|
||||||
void publishLog.logPublication({
|
|
||||||
eventId: event.id,
|
|
||||||
relayUrl,
|
|
||||||
success: false,
|
|
||||||
error: errorMessage,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update published status in IndexedDB
|
// Update published status in IndexedDB
|
||||||
// Access private method via type assertion
|
await updatePublishedStatusUnsafe(event.id, successfulRelays.length > 0 ? successfulRelays : false)
|
||||||
const nostrServiceAny = nostrService as unknown as {
|
|
||||||
updatePublishedStatus: (eventId: string, published: false | string[]) => Promise<void>
|
|
||||||
}
|
|
||||||
await nostrServiceAny.updatePublishedStatus(
|
|
||||||
event.id,
|
|
||||||
successfulRelays.length > 0 ? successfulRelays : false
|
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SWSyncHandler] Error in publish request:', error)
|
console.error('[SWSyncHandler] Error in publish request:', error)
|
||||||
}
|
}
|
||||||
@ -186,3 +154,32 @@ class ServiceWorkerSyncHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const swSyncHandler = new ServiceWorkerSyncHandler()
|
export const swSyncHandler = new ServiceWorkerSyncHandler()
|
||||||
|
|
||||||
|
function logPublishStatuses(params: {
|
||||||
|
publishLog: { logPublication: (params: { eventId: string; relayUrl: string; success: boolean; error?: string }) => Promise<void> }
|
||||||
|
eventId: string
|
||||||
|
relays: string[]
|
||||||
|
statuses: Array<{ success: boolean; error?: string }>
|
||||||
|
}): string[] {
|
||||||
|
const successfulRelays: string[] = []
|
||||||
|
params.statuses.forEach((status, index) => {
|
||||||
|
const relayUrl = params.relays[index]
|
||||||
|
if (!relayUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status.success) {
|
||||||
|
successfulRelays.push(relayUrl)
|
||||||
|
void params.publishLog.logPublication({ eventId: params.eventId, relayUrl, success: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const errorMessage = status.error ?? 'Unknown error'
|
||||||
|
console.error(`[SWSyncHandler] Relay ${relayUrl} failed:`, errorMessage)
|
||||||
|
void params.publishLog.logPublication({ eventId: params.eventId, relayUrl, success: false, error: errorMessage })
|
||||||
|
})
|
||||||
|
return successfulRelays
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePublishedStatusUnsafe(eventId: string, published: false | string[]): Promise<void> {
|
||||||
|
const service = nostrService as unknown as { updatePublishedStatus: (eventId: string, published: false | string[]) => Promise<void> }
|
||||||
|
await service.updatePublishedStatus(eventId, published)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,17 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* User confirmation utility
|
* User confirmation utility
|
||||||
* Wrapper for window.confirm() - note: this violates no-alert rule but is required
|
* Non-blocking confirmation overlay to avoid `window.confirm()` (`no-alert`).
|
||||||
* for critical user confirmations that cannot be replaced with React modals.
|
* Used for critical confirmations (e.g. destructive actions) without requiring
|
||||||
* This function should be used sparingly and only when absolutely necessary.
|
* a React modal or additional global state.
|
||||||
*
|
|
||||||
* Technical justification: window.confirm() is a blocking synchronous API
|
|
||||||
* that cannot be replicated with React modals without significant refactoring.
|
|
||||||
* Used only for critical destructive actions (delete operations).
|
|
||||||
*/
|
*/
|
||||||
export function userConfirm(message: string): Promise<boolean> {
|
export function userConfirm(message: string): Promise<boolean> {
|
||||||
return confirmOverlay(message)
|
return confirmOverlay(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConfirmOverlayElements = {
|
||||||
|
overlay: HTMLDivElement
|
||||||
|
cancel: HTMLButtonElement
|
||||||
|
confirm: HTMLButtonElement
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmOverlayHandlerParams = ConfirmOverlayElements & {
|
||||||
|
resolve: (value: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
function confirmOverlay(message: string): Promise<boolean> {
|
function confirmOverlay(message: string): Promise<boolean> {
|
||||||
const doc = globalThis.document
|
const doc = globalThis.document
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
@ -19,100 +25,127 @@ function confirmOverlay(message: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const overlay = doc.createElement('div')
|
const { overlay, cancel, confirm } = buildConfirmOverlay(doc, message)
|
||||||
overlay.setAttribute('role', 'dialog')
|
|
||||||
overlay.setAttribute('aria-modal', 'true')
|
|
||||||
overlay.tabIndex = -1
|
|
||||||
overlay.style.position = 'fixed'
|
|
||||||
overlay.style.inset = '0'
|
|
||||||
overlay.style.background = 'rgba(0,0,0,0.6)'
|
|
||||||
overlay.style.display = 'flex'
|
|
||||||
overlay.style.alignItems = 'center'
|
|
||||||
overlay.style.justifyContent = 'center'
|
|
||||||
overlay.style.zIndex = '9999'
|
|
||||||
|
|
||||||
const panel = doc.createElement('div')
|
|
||||||
panel.style.background = '#fff'
|
|
||||||
panel.style.borderRadius = '12px'
|
|
||||||
panel.style.padding = '16px'
|
|
||||||
panel.style.maxWidth = '520px'
|
|
||||||
panel.style.width = 'calc(100% - 32px)'
|
|
||||||
panel.style.boxShadow = '0 10px 30px rgba(0,0,0,0.35)'
|
|
||||||
|
|
||||||
const text = doc.createElement('p')
|
|
||||||
text.textContent = message
|
|
||||||
text.style.margin = '0 0 16px 0'
|
|
||||||
text.style.color = '#111827'
|
|
||||||
|
|
||||||
const buttons = doc.createElement('div')
|
|
||||||
buttons.style.display = 'flex'
|
|
||||||
buttons.style.gap = '12px'
|
|
||||||
buttons.style.justifyContent = 'flex-end'
|
|
||||||
|
|
||||||
const cancel = doc.createElement('button')
|
|
||||||
cancel.type = 'button'
|
|
||||||
cancel.textContent = 'Cancel'
|
|
||||||
cancel.style.padding = '8px 12px'
|
|
||||||
cancel.style.borderRadius = '10px'
|
|
||||||
cancel.style.border = '1px solid #e5e7eb'
|
|
||||||
cancel.style.background = '#f3f4f6'
|
|
||||||
|
|
||||||
const confirm = doc.createElement('button')
|
|
||||||
confirm.type = 'button'
|
|
||||||
confirm.textContent = 'Confirm'
|
|
||||||
confirm.style.padding = '8px 12px'
|
|
||||||
confirm.style.borderRadius = '10px'
|
|
||||||
confirm.style.border = '1px solid #ef4444'
|
|
||||||
confirm.style.background = '#fee2e2'
|
|
||||||
confirm.style.color = '#991b1b'
|
|
||||||
|
|
||||||
buttons.append(cancel, confirm)
|
|
||||||
panel.append(text, buttons)
|
|
||||||
overlay.append(panel)
|
|
||||||
doc.body.append(overlay)
|
doc.body.append(overlay)
|
||||||
|
|
||||||
overlay.focus()
|
overlay.focus()
|
||||||
|
attachConfirmOverlayHandlers({ overlay, cancel, confirm, resolve })
|
||||||
let resolved = false
|
|
||||||
|
|
||||||
const resolveOnce = (next: boolean): void => {
|
|
||||||
if (resolved) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolved = true
|
|
||||||
cleanup()
|
|
||||||
resolve(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCancel(): void {
|
|
||||||
resolveOnce(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onConfirm(): void {
|
|
||||||
resolveOnce(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent): void {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
resolveOnce(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
resolveOnce(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup(): void {
|
|
||||||
overlay.removeEventListener('keydown', onKeyDown)
|
|
||||||
cancel.removeEventListener('click', onCancel)
|
|
||||||
confirm.removeEventListener('click', onConfirm)
|
|
||||||
overlay.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel.addEventListener('click', onCancel)
|
|
||||||
confirm.addEventListener('click', onConfirm)
|
|
||||||
overlay.addEventListener('keydown', onKeyDown)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildConfirmOverlay(doc: Document, message: string): ConfirmOverlayElements {
|
||||||
|
const overlay = createOverlay(doc)
|
||||||
|
const panel = createPanel(doc)
|
||||||
|
const text = createText(doc, message)
|
||||||
|
const buttons = createButtonsContainer(doc)
|
||||||
|
const cancel = createCancelButton(doc)
|
||||||
|
const confirm = createConfirmButton(doc)
|
||||||
|
buttons.append(cancel, confirm)
|
||||||
|
panel.append(text, buttons)
|
||||||
|
overlay.append(panel)
|
||||||
|
|
||||||
|
return { overlay, cancel, confirm }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOverlay(doc: Document): HTMLDivElement {
|
||||||
|
const overlay = doc.createElement('div')
|
||||||
|
overlay.setAttribute('role', 'dialog')
|
||||||
|
overlay.setAttribute('aria-modal', 'true')
|
||||||
|
overlay.tabIndex = -1
|
||||||
|
overlay.style.position = 'fixed'
|
||||||
|
overlay.style.inset = '0'
|
||||||
|
overlay.style.background = 'rgba(0,0,0,0.6)'
|
||||||
|
overlay.style.display = 'flex'
|
||||||
|
overlay.style.alignItems = 'center'
|
||||||
|
overlay.style.justifyContent = 'center'
|
||||||
|
overlay.style.zIndex = '9999'
|
||||||
|
return overlay
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPanel(doc: Document): HTMLDivElement {
|
||||||
|
const panel = doc.createElement('div')
|
||||||
|
panel.style.background = '#fff'
|
||||||
|
panel.style.borderRadius = '12px'
|
||||||
|
panel.style.padding = '16px'
|
||||||
|
panel.style.maxWidth = '520px'
|
||||||
|
panel.style.width = 'calc(100% - 32px)'
|
||||||
|
panel.style.boxShadow = '0 10px 30px rgba(0,0,0,0.35)'
|
||||||
|
return panel
|
||||||
|
}
|
||||||
|
|
||||||
|
function createText(doc: Document, message: string): HTMLParagraphElement {
|
||||||
|
const text = doc.createElement('p')
|
||||||
|
text.textContent = message
|
||||||
|
text.style.margin = '0 0 16px 0'
|
||||||
|
text.style.color = '#111827'
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
function createButtonsContainer(doc: Document): HTMLDivElement {
|
||||||
|
const buttons = doc.createElement('div')
|
||||||
|
buttons.style.display = 'flex'
|
||||||
|
buttons.style.gap = '12px'
|
||||||
|
buttons.style.justifyContent = 'flex-end'
|
||||||
|
return buttons
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCancelButton(doc: Document): HTMLButtonElement {
|
||||||
|
const cancel = doc.createElement('button')
|
||||||
|
cancel.type = 'button'
|
||||||
|
cancel.textContent = 'Cancel'
|
||||||
|
cancel.style.padding = '8px 12px'
|
||||||
|
cancel.style.borderRadius = '10px'
|
||||||
|
cancel.style.border = '1px solid #e5e7eb'
|
||||||
|
cancel.style.background = '#f3f4f6'
|
||||||
|
return cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConfirmButton(doc: Document): HTMLButtonElement {
|
||||||
|
const confirm = doc.createElement('button')
|
||||||
|
confirm.type = 'button'
|
||||||
|
confirm.textContent = 'Confirm'
|
||||||
|
confirm.style.padding = '8px 12px'
|
||||||
|
confirm.style.borderRadius = '10px'
|
||||||
|
confirm.style.border = '1px solid #ef4444'
|
||||||
|
confirm.style.background = '#fee2e2'
|
||||||
|
confirm.style.color = '#991b1b'
|
||||||
|
return confirm
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachConfirmOverlayHandlers(params: ConfirmOverlayHandlerParams): void {
|
||||||
|
const { overlay, cancel, confirm, resolve } = params
|
||||||
|
let resolved = false
|
||||||
|
|
||||||
|
const resolveOnce = (next: boolean): void => {
|
||||||
|
if (resolved) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolved = true
|
||||||
|
cleanup()
|
||||||
|
resolve(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = (): void => resolveOnce(false)
|
||||||
|
const onConfirm = (): void => resolveOnce(true)
|
||||||
|
const onKeyDown = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
resolveOnce(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
resolveOnce(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
overlay.removeEventListener('keydown', onKeyDown)
|
||||||
|
cancel.removeEventListener('click', onCancel)
|
||||||
|
confirm.removeEventListener('click', onConfirm)
|
||||||
|
overlay.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel.addEventListener('click', onCancel)
|
||||||
|
confirm.addEventListener('click', onConfirm)
|
||||||
|
overlay.addEventListener('keydown', onKeyDown)
|
||||||
|
}
|
||||||
|
|||||||
@ -146,57 +146,70 @@ class WebSocketService {
|
|||||||
* Communicates with Service Worker via postMessage
|
* Communicates with Service Worker via postMessage
|
||||||
*/
|
*/
|
||||||
async publishEvent(event: Event, relays: string[]): Promise<{ success: boolean; error?: string }[]> {
|
async publishEvent(event: Event, relays: string[]): Promise<{ success: boolean; error?: string }[]> {
|
||||||
|
const pool = await this.getPoolOrThrow()
|
||||||
|
this.ensureRelayStates(relays)
|
||||||
|
const results = await Promise.allSettled(pool.publish(relays, event))
|
||||||
|
return this.buildPublishStatuses({ eventId: event.id, relays, results })
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPoolOrThrow(): Promise<SimplePool> {
|
||||||
if (!this.pool) {
|
if (!this.pool) {
|
||||||
await this.initialize()
|
await this.initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.pool) {
|
if (!this.pool) {
|
||||||
throw new Error('WebSocket service not initialized')
|
throw new Error('WebSocket service not initialized')
|
||||||
}
|
}
|
||||||
|
return this.pool
|
||||||
|
}
|
||||||
|
|
||||||
// Update connection states
|
private ensureRelayStates(relays: string[]): void {
|
||||||
relays.forEach((relayUrl) => {
|
relays.forEach((relayUrl) => {
|
||||||
if (!this.connectionStates.has(relayUrl)) {
|
if (!this.connectionStates.has(relayUrl)) {
|
||||||
this.updateConnectionState(relayUrl, true) // Assume connected when publishing
|
this.updateConnectionState(relayUrl, true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Publish to relays
|
private buildPublishStatuses(params: {
|
||||||
const pubs = this.pool.publish(relays, event)
|
eventId: string
|
||||||
const results = await Promise.allSettled(pubs)
|
relays: string[]
|
||||||
|
results: PromiseSettledResult<unknown>[]
|
||||||
|
}): Array<{ success: boolean; error?: string }> {
|
||||||
const statuses: Array<{ success: boolean; error?: string }> = []
|
const statuses: Array<{ success: boolean; error?: string }> = []
|
||||||
results.forEach((result, index) => {
|
params.results.forEach((result, index) => {
|
||||||
const relayUrl = relays[index]
|
const relayUrl = params.relays[index]
|
||||||
if (!relayUrl) {
|
if (!relayUrl) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'fulfilled') {
|
if (result.status === 'fulfilled') {
|
||||||
statuses.push({ success: true })
|
statuses.push({ success: true })
|
||||||
this.updateConnectionState(relayUrl, true)
|
this.handlePublishSuccess({ eventId: params.eventId, relayUrl })
|
||||||
// Notify Service Worker of successful publication via postMessage
|
return
|
||||||
void swClient.sendMessage({
|
|
||||||
type: 'WEBSOCKET_PUBLISH_SUCCESS',
|
|
||||||
data: { eventId: event.id, relayUrl },
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const error = result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
||||||
statuses.push({ success: false, error })
|
|
||||||
this.updateConnectionState(relayUrl, false)
|
|
||||||
// Notify Service Worker of failed publication via postMessage
|
|
||||||
void swClient.sendMessage({
|
|
||||||
type: 'WEBSOCKET_PUBLISH_FAILED',
|
|
||||||
data: { eventId: event.id, relayUrl, error },
|
|
||||||
})
|
|
||||||
// Trigger reconnection
|
|
||||||
void this.handleReconnection(relayUrl)
|
|
||||||
}
|
}
|
||||||
|
const error = result.reason instanceof Error ? result.reason.message : String(result.reason)
|
||||||
|
statuses.push({ success: false, error })
|
||||||
|
this.handlePublishFailure({ eventId: params.eventId, relayUrl, error })
|
||||||
})
|
})
|
||||||
|
|
||||||
return statuses
|
return statuses
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handlePublishSuccess(params: { eventId: string; relayUrl: string }): void {
|
||||||
|
this.updateConnectionState(params.relayUrl, true)
|
||||||
|
void swClient.sendMessage({
|
||||||
|
type: 'WEBSOCKET_PUBLISH_SUCCESS',
|
||||||
|
data: { eventId: params.eventId, relayUrl: params.relayUrl },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePublishFailure(params: { eventId: string; relayUrl: string; error: string }): void {
|
||||||
|
this.updateConnectionState(params.relayUrl, false)
|
||||||
|
void swClient.sendMessage({
|
||||||
|
type: 'WEBSOCKET_PUBLISH_FAILED',
|
||||||
|
data: { eventId: params.eventId, relayUrl: params.relayUrl, error: params.error },
|
||||||
|
})
|
||||||
|
void this.handleReconnection(params.relayUrl)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to events from relays
|
* Subscribe to events from relays
|
||||||
* Rôle secondaire : Routage des messages
|
* Rôle secondaire : Routage des messages
|
||||||
|
|||||||
@ -42,53 +42,59 @@ class WriteOrchestrator {
|
|||||||
params: WriteObjectParams,
|
params: WriteObjectParams,
|
||||||
relays: string[]
|
relays: string[]
|
||||||
): Promise<{ success: boolean; eventId: string; published: false | string[] }> {
|
): Promise<{ success: boolean; eventId: string; published: false | string[] }> {
|
||||||
const { objectType, hash, event, parsed, version, hidden, index } = params
|
const localWrite = this.writeLocally(params)
|
||||||
|
const networkPublish = this.publishToNetwork(params.event, relays)
|
||||||
|
const [networkResult, localResult] = await Promise.allSettled([networkPublish, localWrite])
|
||||||
|
const publishedRelays = this.readPublishedRelays(networkResult)
|
||||||
|
this.assertLocalWriteSucceeded(localResult)
|
||||||
|
const published = await this.persistPublishedStatus(params.objectType, params.hash, publishedRelays)
|
||||||
|
return { success: publishedRelays.length > 0, eventId: params.event.id, published }
|
||||||
|
}
|
||||||
|
|
||||||
// Écriture en parallèle : réseau et local indépendamment
|
private async publishToNetwork(event: NostrEvent, relays: string[]): Promise<string[]> {
|
||||||
const [networkResult, localResult] = await Promise.allSettled([
|
const statuses = await websocketService.publishEvent(event, relays)
|
||||||
// 1. Publish to network via WebSocket service (en parallèle)
|
return statuses
|
||||||
websocketService.publishEvent(event, relays).then((statuses) => {
|
.map((status, statusIndex) => (status.success ? relays[statusIndex] : null))
|
||||||
return statuses
|
.filter((relay): relay is string => relay !== null)
|
||||||
.map((status, statusIndex) => (status.success ? relays[statusIndex] : null))
|
}
|
||||||
.filter((relay): relay is string => relay !== null)
|
|
||||||
}),
|
|
||||||
// 2. Write to IndexedDB via Web Worker (en parallèle, avec published: false initialement)
|
|
||||||
writeService.writeObject({
|
|
||||||
objectType,
|
|
||||||
hash,
|
|
||||||
event,
|
|
||||||
parsed,
|
|
||||||
version,
|
|
||||||
hidden,
|
|
||||||
...(index !== undefined ? { index } : {}),
|
|
||||||
published: false,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Traiter le résultat réseau
|
private async writeLocally(params: WriteObjectParams): Promise<void> {
|
||||||
let publishedRelays: string[] = []
|
await writeService.writeObject({
|
||||||
if (networkResult.status === 'fulfilled') {
|
objectType: params.objectType,
|
||||||
publishedRelays = networkResult.value
|
hash: params.hash,
|
||||||
} else {
|
event: params.event,
|
||||||
// Si réseau échoue, rien : un autre service worker réessaiera
|
parsed: params.parsed,
|
||||||
console.warn('[WriteOrchestrator] Network publish failed, will retry later:', networkResult.reason)
|
version: params.version,
|
||||||
|
hidden: params.hidden,
|
||||||
|
...(params.index !== undefined ? { index: params.index } : {}),
|
||||||
|
published: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private readPublishedRelays(result: PromiseSettledResult<string[]>): string[] {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
return result.value
|
||||||
}
|
}
|
||||||
|
console.warn('[WriteOrchestrator] Network publish failed, will retry later:', result.reason)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
// Traiter le résultat local
|
private assertLocalWriteSucceeded(result: PromiseSettledResult<void>): void {
|
||||||
if (localResult.status === 'rejected') {
|
if (result.status === 'fulfilled') {
|
||||||
console.error('[WriteOrchestrator] Local write failed:', localResult.reason)
|
return
|
||||||
throw new Error(`Failed to write to IndexedDB: ${localResult.reason}`)
|
|
||||||
}
|
}
|
||||||
|
console.error('[WriteOrchestrator] Local write failed:', result.reason)
|
||||||
|
throw new Error(`Failed to write to IndexedDB: ${String(result.reason)}`)
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Update published status in IndexedDB via Web Worker (même si réseau a échoué)
|
private async persistPublishedStatus(
|
||||||
const publishedStatus: false | string[] = publishedRelays.length > 0 ? publishedRelays : false
|
objectType: ObjectType,
|
||||||
await writeService.updatePublished(objectType, hash, publishedStatus)
|
hash: string,
|
||||||
|
publishedRelays: string[]
|
||||||
return {
|
): Promise<false | string[]> {
|
||||||
success: publishedRelays.length > 0,
|
const published: false | string[] = publishedRelays.length > 0 ? publishedRelays : false
|
||||||
eventId: event.id,
|
await writeService.updatePublished(objectType, hash, published)
|
||||||
published: publishedStatus,
|
return published
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -72,6 +72,13 @@ function readWorkerErrorData(value: unknown): WorkerErrorData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWorkerErrorForOperation(errorData: WorkerErrorData, operation: string): boolean {
|
||||||
|
if (errorData.originalType === operation) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return errorData.taskId?.startsWith(operation) === true
|
||||||
|
}
|
||||||
|
|
||||||
class WriteService {
|
class WriteService {
|
||||||
private writeWorker: Worker | null = null
|
private writeWorker: Worker | null = null
|
||||||
private initPromise: Promise<void> | null = null
|
private initPromise: Promise<void> | null = null
|
||||||
@ -99,61 +106,60 @@ class WriteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createWorker(): Promise<void> {
|
private createWorker(): Promise<void> {
|
||||||
return new Promise((resolve, _reject) => {
|
return new Promise((resolve) => {
|
||||||
if (typeof window === 'undefined' || !window.Worker) {
|
this.createWorkerOrFallback(resolve)
|
||||||
// Fallback: write directly if Worker not available
|
})
|
||||||
console.warn('[WriteService] Web Workers not available, using direct writes')
|
}
|
||||||
resolve()
|
|
||||||
|
private createWorkerOrFallback(resolve: () => void): void {
|
||||||
|
if (!isWebWorkerAvailable()) {
|
||||||
|
console.warn('[WriteService] Web Workers not available, using direct writes')
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.writeWorker = new Worker('/writeWorker.js', { type: 'classic' })
|
||||||
|
this.registerWorkerListeners(this.writeWorker, resolve)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WriteService] Failed to create worker, using direct writes:', error)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerWorkerListeners(worker: Worker, resolve: () => void): void {
|
||||||
|
worker.addEventListener('message', (event: MessageEvent<unknown>) => {
|
||||||
|
if (!isWorkerMessageEnvelope(event.data)) {
|
||||||
|
console.error('[WriteService] Received invalid worker message envelope', { data: event.data })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.data.type === 'ERROR') {
|
||||||
try {
|
console.error('[WriteService] Worker error:', event.data.data)
|
||||||
// Worker dans public/ pour Next.js
|
|
||||||
this.writeWorker = new Worker('/writeWorker.js', { type: 'classic' })
|
|
||||||
|
|
||||||
this.writeWorker.addEventListener('message', (event: MessageEvent<unknown>) => {
|
|
||||||
if (!isWorkerMessageEnvelope(event.data)) {
|
|
||||||
console.error('[WriteService] Received invalid worker message envelope', { data: event.data })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.data.type === 'ERROR') {
|
|
||||||
console.error('[WriteService] Worker error:', event.data.data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.writeWorker.addEventListener('error', (error) => {
|
|
||||||
console.error('[WriteService] Worker error:', error)
|
|
||||||
// Ne pas rejeter, utiliser fallback
|
|
||||||
console.warn('[WriteService] Falling back to direct writes')
|
|
||||||
this.writeWorker = null
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Attendre que le worker soit prêt
|
|
||||||
const readyTimeout = setTimeout(() => {
|
|
||||||
console.warn('[WriteService] Worker ready timeout, using direct writes')
|
|
||||||
if (this.writeWorker) {
|
|
||||||
this.writeWorker.terminate()
|
|
||||||
this.writeWorker = null
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
}, 2000)
|
|
||||||
|
|
||||||
// Le worker est prêt quand il répond
|
|
||||||
const readyHandler = (event: MessageEvent<unknown>): void => {
|
|
||||||
if (isWorkerMessageEnvelope(event.data) && event.data.type === 'WORKER_READY') {
|
|
||||||
clearTimeout(readyTimeout)
|
|
||||||
this.writeWorker?.removeEventListener('message', readyHandler)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.writeWorker.addEventListener('message', readyHandler)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[WriteService] Failed to create worker, using direct writes:', error)
|
|
||||||
resolve() // Fallback to direct writes
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
worker.addEventListener('error', (error) => {
|
||||||
|
console.error('[WriteService] Worker error:', error)
|
||||||
|
console.warn('[WriteService] Falling back to direct writes')
|
||||||
|
this.writeWorker = null
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
const readyTimeout = setTimeout(() => {
|
||||||
|
console.warn('[WriteService] Worker ready timeout, using direct writes')
|
||||||
|
this.writeWorker?.terminate()
|
||||||
|
this.writeWorker = null
|
||||||
|
resolve()
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
const readyHandler = (event: MessageEvent<unknown>): void => {
|
||||||
|
if (isWorkerMessageEnvelope(event.data) && event.data.type === 'WORKER_READY') {
|
||||||
|
clearTimeout(readyTimeout)
|
||||||
|
this.writeWorker?.removeEventListener('message', readyHandler)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
worker.addEventListener('message', readyHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -256,22 +262,27 @@ class WriteService {
|
|||||||
const responseType = event.data.type
|
const responseType = event.data.type
|
||||||
const responseData = event.data.data
|
const responseData = event.data.data
|
||||||
|
|
||||||
if (responseType === 'UPDATE_PUBLISHED_SUCCESS' && isRecord(responseData) && responseData.id === id) {
|
if (responseType === 'UPDATE_PUBLISHED_SUCCESS') {
|
||||||
clearTimeout(timeout)
|
if (!isRecord(responseData) || responseData.id !== id) {
|
||||||
this.writeWorker?.removeEventListener('message', handler)
|
|
||||||
resolve()
|
|
||||||
} else if (responseType === 'ERROR') {
|
|
||||||
const errorData = readWorkerErrorData(responseData)
|
|
||||||
const { taskId } = errorData
|
|
||||||
const isUpdatePublished =
|
|
||||||
errorData.originalType === 'UPDATE_PUBLISHED' || taskId?.startsWith('UPDATE_PUBLISHED') === true
|
|
||||||
if (!isUpdatePublished) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
this.writeWorker?.removeEventListener('message', handler)
|
this.writeWorker?.removeEventListener('message', handler)
|
||||||
reject(new Error(errorData.error ?? 'Write worker error'))
|
resolve()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (responseType !== 'ERROR') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorData = readWorkerErrorData(responseData)
|
||||||
|
if (!isWorkerErrorForOperation(errorData, 'UPDATE_PUBLISHED')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearTimeout(timeout)
|
||||||
|
this.writeWorker?.removeEventListener('message', handler)
|
||||||
|
reject(new Error(errorData.error ?? 'Write worker error'))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.writeWorker) {
|
if (this.writeWorker) {
|
||||||
@ -314,22 +325,27 @@ class WriteService {
|
|||||||
const responseType = event.data.type
|
const responseType = event.data.type
|
||||||
const responseData = event.data.data
|
const responseData = event.data.data
|
||||||
|
|
||||||
if (responseType === 'CREATE_NOTIFICATION_SUCCESS' && isRecord(responseData) && responseData.eventId === params.eventId) {
|
if (responseType === 'CREATE_NOTIFICATION_SUCCESS') {
|
||||||
clearTimeout(timeout)
|
if (!isRecord(responseData) || responseData.eventId !== params.eventId) {
|
||||||
this.writeWorker?.removeEventListener('message', handler)
|
|
||||||
resolve()
|
|
||||||
} else if (responseType === 'ERROR') {
|
|
||||||
const errorData = readWorkerErrorData(responseData)
|
|
||||||
const { taskId } = errorData
|
|
||||||
const isCreateNotification =
|
|
||||||
errorData.originalType === 'CREATE_NOTIFICATION' || taskId?.startsWith('CREATE_NOTIFICATION') === true
|
|
||||||
if (!isCreateNotification) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
this.writeWorker?.removeEventListener('message', handler)
|
this.writeWorker?.removeEventListener('message', handler)
|
||||||
reject(new Error(errorData.error ?? 'Write worker error'))
|
resolve()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (responseType !== 'ERROR') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorData = readWorkerErrorData(responseData)
|
||||||
|
if (!isWorkerErrorForOperation(errorData, 'CREATE_NOTIFICATION')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearTimeout(timeout)
|
||||||
|
this.writeWorker?.removeEventListener('message', handler)
|
||||||
|
reject(new Error(errorData.error ?? 'Write worker error'))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.writeWorker) {
|
if (this.writeWorker) {
|
||||||
@ -411,4 +427,8 @@ class WriteService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWebWorkerAvailable(): boolean {
|
||||||
|
return typeof window !== 'undefined' && Boolean(window.Worker)
|
||||||
|
}
|
||||||
|
|
||||||
export const writeService = new WriteService()
|
export const writeService = new WriteService()
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// Note: eslint configuration in next.config.js is no longer supported in Next.js 16+
|
|
||||||
// Use 'next lint --ignore-build-errors' or configure in .eslintrc.json instead
|
|
||||||
typescript: {
|
|
||||||
// Désactiver la vérification TypeScript lors du build
|
|
||||||
ignoreBuildErrors: true,
|
|
||||||
},
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -74,353 +74,446 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
return res.status(405).json({ error: 'Method not allowed' })
|
return res.status(405).json({ error: 'Method not allowed' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get target endpoint and auth token from query
|
const { targetEndpoint, authToken } = readProxyQueryParams(req)
|
||||||
const targetEndpoint = (req.query.endpoint as string) ?? 'https://void.cat/upload'
|
const currentUrl = new URL(targetEndpoint)
|
||||||
const authToken = req.query.auth as string | undefined
|
|
||||||
|
const fileField = await parseFileFromMultipartRequest(req)
|
||||||
|
if (!fileField) {
|
||||||
|
return res.status(400).json({ error: 'No file provided' })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse multipart form data
|
const response = await makeRequestWithRedirects({
|
||||||
// formidable needs the raw Node.js IncomingMessage, which NextApiRequest extends
|
targetEndpoint,
|
||||||
const form = new IncomingForm({
|
url: currentUrl,
|
||||||
maxFileSize: MAX_FILE_SIZE,
|
file: fileField,
|
||||||
keepExtensions: true,
|
authToken,
|
||||||
|
redirectCount: 0,
|
||||||
|
maxRedirects: 5,
|
||||||
})
|
})
|
||||||
|
return handleProxyResponse({ res, response, targetEndpoint })
|
||||||
const parseResult = await new Promise<ParseResult>((resolve, reject) => {
|
|
||||||
form.parse(req, (err, fields, files) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Formidable parse error:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
resolve({ fields, files })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const { files } = parseResult
|
|
||||||
|
|
||||||
// Get the file from the parsed form
|
|
||||||
const fileField = getFirstFile(files, 'file')
|
|
||||||
if (!fileField) {
|
|
||||||
return res.status(400).json({ error: 'No file provided' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward to target endpoint using https/http native modules
|
|
||||||
// Support redirects (301, 302, 307, 308)
|
|
||||||
const currentUrl = new URL(targetEndpoint)
|
|
||||||
const MAX_REDIRECTS = 5
|
|
||||||
|
|
||||||
let response: { statusCode: number; statusMessage: string; body: string }
|
|
||||||
try {
|
|
||||||
response = await new Promise<{ statusCode: number; statusMessage: string; body: string }>((resolve, reject) => {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recreate FormData for each request (needed for redirects)
|
|
||||||
const requestFormData = new FormData()
|
|
||||||
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: file.originalFilename ?? file.newFilename ?? 'upload',
|
|
||||||
contentType: file.mimetype ?? 'application/octet-stream',
|
|
||||||
})
|
|
||||||
|
|
||||||
const isHttps = url.protocol === 'https:'
|
|
||||||
const clientModule = isHttps ? https : http
|
|
||||||
const headers = getFormDataHeaders(requestFormData)
|
|
||||||
|
|
||||||
// Add standard headers that some endpoints require
|
|
||||||
headers['Accept'] = 'application/json'
|
|
||||||
headers['User-Agent'] = 'zapwall.fr/1.0'
|
|
||||||
|
|
||||||
// Add NIP-98 Authorization header if token is provided
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Nostr ${token}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log request details for debugging (only for problematic endpoints)
|
|
||||||
if (url.hostname.includes('nostrimg.com')) {
|
|
||||||
console.warn('NIP-95 proxy request to nostrimg.com:', {
|
|
||||||
url: url.toString(),
|
|
||||||
method: 'POST',
|
|
||||||
fieldName,
|
|
||||||
filename: file.originalFilename ?? file.newFilename ?? 'upload',
|
|
||||||
contentType: file.mimetype ?? 'application/octet-stream',
|
|
||||||
fileSize: file.size,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': headers['content-type'],
|
|
||||||
'Accept': headers['Accept'],
|
|
||||||
'User-Agent': headers['User-Agent'],
|
|
||||||
'Authorization': token ? '[present]' : '[absent]',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestOptions: http.RequestOptions = {
|
|
||||||
hostname: url.hostname,
|
|
||||||
port: url.port ?? (isHttps ? 443 : 80),
|
|
||||||
path: url.pathname + url.search,
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
timeout: 30000, // 30 seconds timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
const proxyRequest = clientModule.request(requestOptions, (proxyResponse: http.IncomingMessage) => {
|
|
||||||
// Handle redirects (301, 302, 307, 308)
|
|
||||||
const statusCode = proxyResponse.statusCode ?? 500
|
|
||||||
if ((statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) && proxyResponse.headers.location) {
|
|
||||||
const location = getRedirectLocation(proxyResponse.headers as unknown)
|
|
||||||
if (!location) {
|
|
||||||
reject(new Error('Redirect response missing location header'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let redirectUrl: URL
|
|
||||||
try {
|
|
||||||
// Handle relative and absolute URLs
|
|
||||||
redirectUrl = new URL(location, url.toString())
|
|
||||||
console.warn('NIP-95 proxy redirect:', {
|
|
||||||
from: url.toString(),
|
|
||||||
to: redirectUrl.toString(),
|
|
||||||
statusCode,
|
|
||||||
redirectCount: redirectCount + 1,
|
|
||||||
})
|
|
||||||
// Drain the response before redirecting
|
|
||||||
proxyResponse.resume()
|
|
||||||
// Make new request to redirect location (preserve auth token for redirects)
|
|
||||||
makeRequest(redirectUrl, redirectCount + 1, file, token)
|
|
||||||
return
|
|
||||||
} catch (urlError) {
|
|
||||||
console.error('NIP-95 proxy invalid redirect URL:', {
|
|
||||||
location,
|
|
||||||
error: urlError instanceof Error ? urlError.message : 'Unknown error',
|
|
||||||
})
|
|
||||||
reject(new Error(`Invalid redirect URL: ${location}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = ''
|
|
||||||
proxyResponse.setEncoding('utf8')
|
|
||||||
proxyResponse.on('data', (chunk) => {
|
|
||||||
body += chunk
|
|
||||||
})
|
|
||||||
proxyResponse.on('end', () => {
|
|
||||||
// Log response details for debugging problematic endpoints
|
|
||||||
if (url.hostname.includes('nostrimg.com')) {
|
|
||||||
console.warn('NIP-95 proxy response from nostrimg.com:', {
|
|
||||||
url: url.toString(),
|
|
||||||
statusCode,
|
|
||||||
statusMessage: proxyResponse.statusMessage,
|
|
||||||
responseHeaders: {
|
|
||||||
'content-type': proxyResponse.headers['content-type'],
|
|
||||||
'content-length': proxyResponse.headers['content-length'],
|
|
||||||
},
|
|
||||||
bodyPreview: body.substring(0, 500),
|
|
||||||
bodyLength: body.length,
|
|
||||||
isHtml: body.trim().startsWith('<!DOCTYPE') || body.trim().startsWith('<html') || body.trim().startsWith('<!'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
statusCode,
|
|
||||||
statusMessage: proxyResponse.statusMessage ?? 'Internal Server Error',
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
proxyResponse.on('error', (error) => {
|
|
||||||
reject(error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set timeout on the request
|
|
||||||
proxyRequest.setTimeout(30000, () => {
|
|
||||||
proxyRequest.destroy()
|
|
||||||
reject(new Error('Request timeout after 30 seconds'))
|
|
||||||
})
|
|
||||||
|
|
||||||
proxyRequest.on('error', (error) => {
|
|
||||||
// Check for DNS errors specifically
|
|
||||||
const errorCode = getErrnoCode(error)
|
|
||||||
if (errorCode === 'ENOTFOUND' || errorCode === 'EAI_AGAIN') {
|
|
||||||
console.error('NIP-95 proxy DNS error:', {
|
|
||||||
targetEndpoint,
|
|
||||||
hostname: url.hostname,
|
|
||||||
errorCode,
|
|
||||||
errorMessage: error.message,
|
|
||||||
suggestion: 'Check DNS resolution or network connectivity on the server',
|
|
||||||
})
|
|
||||||
reject(new Error(`DNS resolution failed for ${url.hostname}: ${error.message}`))
|
|
||||||
} else {
|
|
||||||
reject(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
requestFormData.on('error', (error) => {
|
|
||||||
console.error('NIP-95 proxy FormData error:', {
|
|
||||||
targetEndpoint,
|
|
||||||
hostname: url.hostname,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown FormData error',
|
|
||||||
})
|
|
||||||
reject(error)
|
|
||||||
})
|
|
||||||
|
|
||||||
fileStream.on('error', (error) => {
|
|
||||||
console.error('NIP-95 proxy file stream error:', {
|
|
||||||
targetEndpoint,
|
|
||||||
hostname: url.hostname,
|
|
||||||
filepath: file.filepath,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown file stream error',
|
|
||||||
})
|
|
||||||
reject(error)
|
|
||||||
})
|
|
||||||
|
|
||||||
requestFormData.pipe(proxyRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
makeRequest(currentUrl, 0, fileField, authToken)
|
|
||||||
})
|
|
||||||
} catch (requestError) {
|
|
||||||
// Clean up temporary file before returning error
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(fileField.filepath)
|
|
||||||
} catch (unlinkError) {
|
|
||||||
console.error('Error deleting temp file:', unlinkError)
|
|
||||||
}
|
|
||||||
const errorMessage = requestError instanceof Error ? requestError.message : 'Unknown request error'
|
|
||||||
const isDnsError = errorMessage.includes('DNS resolution failed') || errorMessage.includes('ENOTFOUND') || errorMessage.includes('EAI_AGAIN')
|
|
||||||
|
|
||||||
console.error('NIP-95 proxy request error:', {
|
|
||||||
targetEndpoint,
|
|
||||||
hostname: currentUrl.hostname,
|
|
||||||
error: errorMessage,
|
|
||||||
isDnsError,
|
|
||||||
fileSize: fileField.size,
|
|
||||||
fileName: fileField.originalFilename,
|
|
||||||
suggestion: isDnsError ? 'The server cannot resolve the domain name. Check DNS configuration and network connectivity.' : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Return a more specific error message for DNS issues
|
|
||||||
if (isDnsError) {
|
|
||||||
return res.status(500).json({
|
|
||||||
error: `DNS resolution failed for ${currentUrl.hostname}. The server cannot resolve the domain name. Please check DNS configuration and network connectivity.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(500).json({
|
|
||||||
error: `Failed to connect to upload endpoint: ${errorMessage}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up temporary file
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(fileField.filepath)
|
|
||||||
} catch (unlinkError) {
|
|
||||||
console.error('Error deleting temp file:', unlinkError)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
||||||
const errorText = response.body.substring(0, 200) // Limit log size
|
|
||||||
console.error('NIP-95 proxy response error:', {
|
|
||||||
targetEndpoint,
|
|
||||||
finalUrl: currentUrl.toString(),
|
|
||||||
status: response.statusCode,
|
|
||||||
statusText: response.statusMessage,
|
|
||||||
errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Provide more specific error messages for common HTTP status codes
|
|
||||||
let userFriendlyError = errorText || `Upload failed: ${response.statusCode} ${response.statusMessage}`
|
|
||||||
if (response.statusCode === 401) {
|
|
||||||
userFriendlyError = 'Authentication required. This endpoint requires authorization headers.'
|
|
||||||
} else if (response.statusCode === 403) {
|
|
||||||
userFriendlyError = 'Access forbidden. This endpoint may require authentication or have restrictions.'
|
|
||||||
} else if (response.statusCode === 405) {
|
|
||||||
userFriendlyError = 'Method not allowed. This endpoint may not support POST requests or the URL may be incorrect.'
|
|
||||||
} else if (response.statusCode === 413) {
|
|
||||||
userFriendlyError = 'File too large. The file exceeds the maximum size allowed by this endpoint.'
|
|
||||||
} else if (response.statusCode >= 500) {
|
|
||||||
userFriendlyError = `Server error (${response.statusCode}). The endpoint server encountered an error.`
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(response.statusCode).json({
|
|
||||||
error: userFriendlyError,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if response is HTML (error page) instead of JSON
|
|
||||||
const trimmedBody = response.body.trim()
|
|
||||||
const isHtml = trimmedBody.startsWith('<!DOCTYPE') || trimmedBody.startsWith('<html') || trimmedBody.startsWith('<!')
|
|
||||||
|
|
||||||
if (isHtml) {
|
|
||||||
// Try to extract error message from HTML if possible
|
|
||||||
const titleMatch = response.body.match(/<title[^>]*>([^<]+)<\/title>/i)
|
|
||||||
const h1Match = response.body.match(/<h1[^>]*>([^<]+)<\/h1>/i)
|
|
||||||
const errorText = titleMatch?.[1] ?? h1Match?.[1] ?? 'HTML error page returned'
|
|
||||||
|
|
||||||
// Check if it's a 404 or other error page
|
|
||||||
const is404 = response.body.includes('404') || response.body.includes('Not Found') || titleMatch?.[1]?.includes('404') === true
|
|
||||||
const is403 = response.body.includes('403') || response.body.includes('Forbidden') || titleMatch?.[1]?.includes('403') === true
|
|
||||||
const is500 = response.body.includes('500') || response.body.includes('Internal Server Error') || titleMatch?.[1]?.includes('500') === true
|
|
||||||
|
|
||||||
console.error('NIP-95 proxy HTML response error:', {
|
|
||||||
targetEndpoint,
|
|
||||||
finalUrl: currentUrl.toString(),
|
|
||||||
status: response.statusCode,
|
|
||||||
errorText,
|
|
||||||
is404,
|
|
||||||
is403,
|
|
||||||
is500,
|
|
||||||
bodyPreview: response.body.substring(0, 500),
|
|
||||||
contentType: 'HTML (expected JSON)',
|
|
||||||
suggestion: buildHtmlErrorSuggestion({ is404, is403, is500 }),
|
|
||||||
})
|
|
||||||
|
|
||||||
let userMessage = `Endpoint returned an HTML error page instead of JSON`
|
|
||||||
if (is404) {
|
|
||||||
userMessage = `Endpoint not found (404). The URL may be incorrect: ${currentUrl.toString()}`
|
|
||||||
} else if (is403) {
|
|
||||||
userMessage = `Access forbidden (403). The endpoint may require authentication or have restrictions.`
|
|
||||||
} else if (is500) {
|
|
||||||
userMessage = `Server error (500). The endpoint server encountered an error.`
|
|
||||||
} else {
|
|
||||||
userMessage = `Endpoint returned an HTML error page instead of JSON. The endpoint may be unavailable, the URL may be incorrect, or specific headers may be required. Error: ${errorText}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(500).json({
|
|
||||||
error: userMessage,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: unknown
|
|
||||||
try {
|
|
||||||
result = JSON.parse(response.body)
|
|
||||||
} catch (parseError) {
|
|
||||||
const errorMessage = parseError instanceof Error ? parseError.message : 'Invalid JSON response'
|
|
||||||
console.error('NIP-95 proxy JSON parse error:', {
|
|
||||||
targetEndpoint,
|
|
||||||
error: errorMessage,
|
|
||||||
bodyPreview: response.body.substring(0, 100),
|
|
||||||
})
|
|
||||||
return res.status(500).json({
|
|
||||||
error: `Invalid upload response: ${errorMessage}. The endpoint may not be a valid NIP-95 upload endpoint.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json(result)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('NIP-95 proxy error:', error)
|
return handleProxyError({ res, error, targetEndpoint, hostname: currentUrl.hostname, file: fileField })
|
||||||
return res.status(500).json({
|
} finally {
|
||||||
error: error instanceof Error ? error.message : 'Internal server error',
|
safeUnlink(fileField.filepath)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readProxyQueryParams(req: NextApiRequest): { targetEndpoint: string; authToken: string | undefined } {
|
||||||
|
return {
|
||||||
|
targetEndpoint: (req.query.endpoint as string) ?? 'https://void.cat/upload',
|
||||||
|
authToken: req.query.auth as string | undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseFileFromMultipartRequest(req: NextApiRequest): Promise<FormidableFile | null> {
|
||||||
|
const form = new IncomingForm({ maxFileSize: MAX_FILE_SIZE, keepExtensions: true })
|
||||||
|
const parseResult = await new Promise<ParseResult>((resolve, reject) => {
|
||||||
|
form.parse(req, (err, fields, files) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Formidable parse error:', err)
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve({ fields, files })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return getFirstFile(parseResult.files, 'file')
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeUnlink(filepath: string): void {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filepath)
|
||||||
|
} catch (unlinkError) {
|
||||||
|
console.error('Error deleting temp file:', unlinkError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProxyUploadResponse {
|
||||||
|
statusCode: number
|
||||||
|
statusMessage: string
|
||||||
|
body: string
|
||||||
|
headers: http.IncomingHttpHeaders
|
||||||
|
finalUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeRequestWithRedirects(params: {
|
||||||
|
targetEndpoint: string
|
||||||
|
url: URL
|
||||||
|
file: FormidableFile
|
||||||
|
authToken: string | undefined
|
||||||
|
redirectCount: number
|
||||||
|
maxRedirects: number
|
||||||
|
}): Promise<ProxyUploadResponse> {
|
||||||
|
if (params.redirectCount > params.maxRedirects) {
|
||||||
|
throw new Error(`Too many redirects (max ${params.maxRedirects})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await makeRequestOnce({
|
||||||
|
targetEndpoint: params.targetEndpoint,
|
||||||
|
url: params.url,
|
||||||
|
file: params.file,
|
||||||
|
authToken: params.authToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
const redirectUrl = tryGetRedirectUrl({ url: params.url, response })
|
||||||
|
if (!redirectUrl) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('NIP-95 proxy redirect:', {
|
||||||
|
from: params.url.toString(),
|
||||||
|
to: redirectUrl.toString(),
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
redirectCount: params.redirectCount + 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
return makeRequestWithRedirects({
|
||||||
|
...params,
|
||||||
|
url: redirectUrl,
|
||||||
|
redirectCount: params.redirectCount + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryGetRedirectUrl(params: { url: URL; response: ProxyUploadResponse }): URL | null {
|
||||||
|
if (!isRedirectStatus(params.response.statusCode)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const location = getRedirectLocation(params.response.headers as unknown)
|
||||||
|
if (!location) {
|
||||||
|
throw new Error('Redirect response missing location header')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(location, params.url.toString())
|
||||||
|
} catch (urlError) {
|
||||||
|
console.error('NIP-95 proxy invalid redirect URL:', {
|
||||||
|
location,
|
||||||
|
error: urlError instanceof Error ? urlError.message : 'Unknown error',
|
||||||
|
})
|
||||||
|
throw new Error(`Invalid redirect URL: ${location}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRedirectStatus(statusCode: number): boolean {
|
||||||
|
return statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeRequestOnce(params: {
|
||||||
|
targetEndpoint: string
|
||||||
|
url: URL
|
||||||
|
file: FormidableFile
|
||||||
|
authToken: string | undefined
|
||||||
|
}): Promise<ProxyUploadResponse> {
|
||||||
|
const { requestFormData, fileStream } = buildUploadFormData(params.file)
|
||||||
|
const headers = buildProxyRequestHeaders(requestFormData, params.authToken)
|
||||||
|
const { clientModule, requestOptions } = buildProxyRequestOptions({ url: params.url, headers })
|
||||||
|
return await sendFormDataRequest({
|
||||||
|
clientModule,
|
||||||
|
requestOptions,
|
||||||
|
requestFormData,
|
||||||
|
fileStream,
|
||||||
|
targetEndpoint: params.targetEndpoint,
|
||||||
|
hostname: params.url.hostname,
|
||||||
|
finalUrl: params.url.toString(),
|
||||||
|
filepath: params.file.filepath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUploadFormData(file: FormidableFile): { requestFormData: FormData; fileStream: fs.ReadStream } {
|
||||||
|
const requestFormData = new FormData()
|
||||||
|
const fileStream = fs.createReadStream(file.filepath)
|
||||||
|
requestFormData.append('file', fileStream, {
|
||||||
|
filename: file.originalFilename ?? file.newFilename ?? 'upload',
|
||||||
|
contentType: file.mimetype ?? 'application/octet-stream',
|
||||||
|
})
|
||||||
|
return { requestFormData, fileStream }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProxyRequestHeaders(requestFormData: FormData, authToken: string | undefined): http.OutgoingHttpHeaders {
|
||||||
|
const headers = getFormDataHeaders(requestFormData)
|
||||||
|
headers['Accept'] = 'application/json'
|
||||||
|
headers['User-Agent'] = 'zapwall.fr/1.0'
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Nostr ${authToken}`
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProxyRequestOptions(params: { url: URL; headers: http.OutgoingHttpHeaders }): {
|
||||||
|
clientModule: typeof http | typeof https
|
||||||
|
requestOptions: http.RequestOptions
|
||||||
|
} {
|
||||||
|
const isHttps = params.url.protocol === 'https:'
|
||||||
|
return {
|
||||||
|
clientModule: isHttps ? https : http,
|
||||||
|
requestOptions: {
|
||||||
|
hostname: params.url.hostname,
|
||||||
|
port: params.url.port ?? (isHttps ? 443 : 80),
|
||||||
|
path: params.url.pathname + params.url.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: params.headers,
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFormDataRequest(params: {
|
||||||
|
clientModule: typeof http | typeof https
|
||||||
|
requestOptions: http.RequestOptions
|
||||||
|
requestFormData: FormData
|
||||||
|
fileStream: fs.ReadStream
|
||||||
|
targetEndpoint: string
|
||||||
|
hostname: string
|
||||||
|
finalUrl: string
|
||||||
|
filepath: string
|
||||||
|
}): Promise<ProxyUploadResponse> {
|
||||||
|
return await new Promise<ProxyUploadResponse>((resolve, reject) => {
|
||||||
|
const proxyRequest = params.clientModule.request(params.requestOptions, (proxyResponse: http.IncomingMessage) => {
|
||||||
|
void readProxyResponse({ proxyResponse, finalUrl: params.finalUrl }).then(resolve).catch(reject)
|
||||||
|
})
|
||||||
|
attachProxyRequestHandlers({
|
||||||
|
proxyRequest,
|
||||||
|
requestFormData: params.requestFormData,
|
||||||
|
fileStream: params.fileStream,
|
||||||
|
targetEndpoint: params.targetEndpoint,
|
||||||
|
hostname: params.hostname,
|
||||||
|
filepath: params.filepath,
|
||||||
|
reject,
|
||||||
|
})
|
||||||
|
params.requestFormData.pipe(proxyRequest)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachProxyRequestHandlers(params: {
|
||||||
|
proxyRequest: http.ClientRequest
|
||||||
|
requestFormData: FormData
|
||||||
|
fileStream: fs.ReadStream
|
||||||
|
targetEndpoint: string
|
||||||
|
hostname: string
|
||||||
|
filepath: string
|
||||||
|
reject: (error: unknown) => void
|
||||||
|
}): void {
|
||||||
|
params.proxyRequest.setTimeout(30000, () => {
|
||||||
|
params.proxyRequest.destroy()
|
||||||
|
params.reject(new Error('Request timeout after 30 seconds'))
|
||||||
|
})
|
||||||
|
params.proxyRequest.on('error', (error) => {
|
||||||
|
params.reject(handleProxyRequestError({ error, targetEndpoint: params.targetEndpoint, hostname: params.hostname }))
|
||||||
|
})
|
||||||
|
params.requestFormData.on('error', (error) => {
|
||||||
|
console.error('NIP-95 proxy FormData error:', { targetEndpoint: params.targetEndpoint, hostname: params.hostname, error })
|
||||||
|
params.reject(error)
|
||||||
|
})
|
||||||
|
params.fileStream.on('error', (error) => {
|
||||||
|
console.error('NIP-95 proxy file stream error:', { targetEndpoint: params.targetEndpoint, hostname: params.hostname, filepath: params.filepath, error })
|
||||||
|
params.reject(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readProxyResponse(params: { proxyResponse: http.IncomingMessage; finalUrl: string }): Promise<ProxyUploadResponse> {
|
||||||
|
const statusCode = params.proxyResponse.statusCode ?? 500
|
||||||
|
const body = await readIncomingMessageBody(params.proxyResponse)
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
statusMessage: params.proxyResponse.statusMessage ?? 'Internal Server Error',
|
||||||
|
body,
|
||||||
|
headers: params.proxyResponse.headers,
|
||||||
|
finalUrl: params.finalUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readIncomingMessageBody(message: http.IncomingMessage): Promise<string> {
|
||||||
|
return await new Promise<string>((resolve, reject) => {
|
||||||
|
let body = ''
|
||||||
|
message.setEncoding('utf8')
|
||||||
|
message.on('data', (chunk) => {
|
||||||
|
body += chunk
|
||||||
|
})
|
||||||
|
message.on('end', () => resolve(body))
|
||||||
|
message.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProxyRequestError(params: { error: unknown; targetEndpoint: string; hostname: string }): Error {
|
||||||
|
const errorMessage = params.error instanceof Error ? params.error.message : 'Unknown request error'
|
||||||
|
const errorCode = getErrnoCode(params.error)
|
||||||
|
if (errorCode === 'ENOTFOUND' || errorCode === 'EAI_AGAIN') {
|
||||||
|
console.error('NIP-95 proxy DNS error:', {
|
||||||
|
targetEndpoint: params.targetEndpoint,
|
||||||
|
hostname: params.hostname,
|
||||||
|
errorCode,
|
||||||
|
errorMessage,
|
||||||
|
suggestion: 'Check DNS resolution or network connectivity on the server',
|
||||||
|
})
|
||||||
|
return new Error(`DNS resolution failed for ${params.hostname}: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
return params.error instanceof Error ? params.error : new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProxyError(params: {
|
||||||
|
res: NextApiResponse
|
||||||
|
error: unknown
|
||||||
|
targetEndpoint: string
|
||||||
|
hostname: string
|
||||||
|
file: FormidableFile
|
||||||
|
}): void {
|
||||||
|
const errorMessage = params.error instanceof Error ? params.error.message : 'Unknown request error'
|
||||||
|
const isDnsError = errorMessage.includes('DNS resolution failed') || errorMessage.includes('ENOTFOUND') || errorMessage.includes('EAI_AGAIN')
|
||||||
|
|
||||||
|
console.error('NIP-95 proxy request error:', {
|
||||||
|
targetEndpoint: params.targetEndpoint,
|
||||||
|
hostname: params.hostname,
|
||||||
|
error: errorMessage,
|
||||||
|
isDnsError,
|
||||||
|
fileSize: params.file.size,
|
||||||
|
fileName: params.file.originalFilename,
|
||||||
|
suggestion: isDnsError ? 'The server cannot resolve the domain name. Check DNS configuration and network connectivity.' : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isDnsError) {
|
||||||
|
params.res.status(500).json({
|
||||||
|
error: `DNS resolution failed for ${params.hostname}. The server cannot resolve the domain name. Please check DNS configuration and network connectivity.`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params.res.status(500).json({ error: `Failed to connect to upload endpoint: ${errorMessage}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProxyResponse(params: {
|
||||||
|
res: NextApiResponse
|
||||||
|
response: ProxyUploadResponse
|
||||||
|
targetEndpoint: string
|
||||||
|
}): void {
|
||||||
|
if (params.response.statusCode < 200 || params.response.statusCode >= 300) {
|
||||||
|
return respondNonOk({ res: params.res, response: params.response, targetEndpoint: params.targetEndpoint })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlResponse(params.response.body)) {
|
||||||
|
return respondHtml({ res: params.res, response: params.response, targetEndpoint: params.targetEndpoint })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseResult = parseJsonSafe(params.response.body)
|
||||||
|
if (!parseResult.ok) {
|
||||||
|
console.error('NIP-95 proxy JSON parse error:', {
|
||||||
|
targetEndpoint: params.targetEndpoint,
|
||||||
|
bodyPreview: params.response.body.substring(0, 100),
|
||||||
|
})
|
||||||
|
params.res.status(500).json({
|
||||||
|
error: `Invalid upload response: Invalid JSON response. The endpoint may not be a valid NIP-95 upload endpoint.`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params.res.status(200).json(parseResult.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function respondNonOk(params: { res: NextApiResponse; response: ProxyUploadResponse; targetEndpoint: string }): void {
|
||||||
|
const errorText = params.response.body.substring(0, 200)
|
||||||
|
console.error('NIP-95 proxy response error:', {
|
||||||
|
targetEndpoint: params.targetEndpoint,
|
||||||
|
finalUrl: params.response.finalUrl,
|
||||||
|
status: params.response.statusCode,
|
||||||
|
statusText: params.response.statusMessage,
|
||||||
|
errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
params.res.status(params.response.statusCode).json({
|
||||||
|
error: buildUserFriendlyHttpError(params.response.statusCode, params.response.statusMessage, errorText),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserFriendlyHttpError(statusCode: number, statusMessage: string, errorText: string): string {
|
||||||
|
if (statusCode === 401) {
|
||||||
|
return 'Authentication required. This endpoint requires authorization headers.'
|
||||||
|
}
|
||||||
|
if (statusCode === 403) {
|
||||||
|
return 'Access forbidden. This endpoint may require authentication or have restrictions.'
|
||||||
|
}
|
||||||
|
if (statusCode === 405) {
|
||||||
|
return 'Method not allowed. This endpoint may not support POST requests or the URL may be incorrect.'
|
||||||
|
}
|
||||||
|
if (statusCode === 413) {
|
||||||
|
return 'File too large. The file exceeds the maximum size allowed by this endpoint.'
|
||||||
|
}
|
||||||
|
if (statusCode >= 500) {
|
||||||
|
return `Server error (${statusCode}). The endpoint server encountered an error.`
|
||||||
|
}
|
||||||
|
return errorText || `Upload failed: ${statusCode} ${statusMessage}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHtmlResponse(body: string): boolean {
|
||||||
|
const trimmedBody = body.trim()
|
||||||
|
return trimmedBody.startsWith('<!DOCTYPE') || trimmedBody.startsWith('<html') || trimmedBody.startsWith('<!')
|
||||||
|
}
|
||||||
|
|
||||||
|
function respondHtml(params: { res: NextApiResponse; response: ProxyUploadResponse; targetEndpoint: string }): void {
|
||||||
|
const title = readHtmlTitle(params.response.body)
|
||||||
|
const h1 = readHtmlH1(params.response.body)
|
||||||
|
const errorText = title ?? h1 ?? 'HTML error page returned'
|
||||||
|
const flags = detectHtmlErrorFlags({ body: params.response.body, title })
|
||||||
|
|
||||||
|
console.error('NIP-95 proxy HTML response error:', {
|
||||||
|
targetEndpoint: params.targetEndpoint,
|
||||||
|
finalUrl: params.response.finalUrl,
|
||||||
|
status: params.response.statusCode,
|
||||||
|
errorText,
|
||||||
|
is404: flags.is404,
|
||||||
|
is403: flags.is403,
|
||||||
|
is500: flags.is500,
|
||||||
|
bodyPreview: params.response.body.substring(0, 500),
|
||||||
|
contentType: 'HTML (expected JSON)',
|
||||||
|
suggestion: buildHtmlErrorSuggestion(flags),
|
||||||
|
})
|
||||||
|
|
||||||
|
params.res.status(500).json({
|
||||||
|
error: buildHtmlUserMessage({ is404: flags.is404, is403: flags.is403, is500: flags.is500, finalUrl: params.response.finalUrl, errorText }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHtmlUserMessage(params: { is404: boolean; is403: boolean; is500: boolean; finalUrl: string; errorText: string }): string {
|
||||||
|
if (params.is404) {
|
||||||
|
return `Endpoint not found (404). The URL may be incorrect: ${params.finalUrl}`
|
||||||
|
}
|
||||||
|
if (params.is403) {
|
||||||
|
return `Access forbidden (403). The endpoint may require authentication or have restrictions.`
|
||||||
|
}
|
||||||
|
if (params.is500) {
|
||||||
|
return `Server error (500). The endpoint server encountered an error.`
|
||||||
|
}
|
||||||
|
return `Endpoint returned an HTML error page instead of JSON. The endpoint may be unavailable, the URL may be incorrect, or specific headers may be required. Error: ${params.errorText}`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonParseResult = { ok: true; value: unknown } | { ok: false }
|
||||||
|
|
||||||
|
function parseJsonSafe(body: string): JsonParseResult {
|
||||||
|
try {
|
||||||
|
return { ok: true, value: JSON.parse(body) as unknown }
|
||||||
|
} catch {
|
||||||
|
return { ok: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHtmlTitle(body: string): string | undefined {
|
||||||
|
return body.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHtmlH1(body: string): string | undefined {
|
||||||
|
return body.match(/<h1[^>]*>([^<]+)<\/h1>/i)?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectHtmlErrorFlags(params: { body: string; title: string | undefined }): { is404: boolean; is403: boolean; is500: boolean } {
|
||||||
|
const is404 = isHtmlErrorForCode({ body: params.body, title: params.title, code: '404', marker: 'Not Found' })
|
||||||
|
const is403 = isHtmlErrorForCode({ body: params.body, title: params.title, code: '403', marker: 'Forbidden' })
|
||||||
|
const is500 = isHtmlErrorForCode({ body: params.body, title: params.title, code: '500', marker: 'Internal Server Error' })
|
||||||
|
return { is404, is403, is500 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHtmlErrorForCode(params: { body: string; title: string | undefined; code: string; marker: string }): boolean {
|
||||||
|
const titleHasCode = params.title?.includes(params.code) === true
|
||||||
|
return params.body.includes(params.code) || params.body.includes(params.marker) || titleHasCode
|
||||||
|
}
|
||||||
|
|
||||||
function buildHtmlErrorSuggestion(params: { is404: boolean; is403: boolean; is500: boolean }): string {
|
function buildHtmlErrorSuggestion(params: { is404: boolean; is403: boolean; is500: boolean }): string {
|
||||||
if (params.is404) {
|
if (params.is404) {
|
||||||
return 'The endpoint URL may be incorrect or the endpoint does not exist'
|
return 'The endpoint URL may be incorrect or the endpoint does not exist'
|
||||||
|
|||||||
@ -74,7 +74,13 @@ function AuthorPageHeader({ presentation }: { presentation: AuthorPresentationAr
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SponsoringSummary({ totalSponsoring, author, onSponsor }: { totalSponsoring: number; author: AuthorPresentationArticle | null; onSponsor: () => void }): React.ReactElement {
|
type SponsoringSummaryProps = {
|
||||||
|
totalSponsoring: number
|
||||||
|
author: AuthorPresentationArticle | null
|
||||||
|
onSponsor: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SponsoringSummary({ totalSponsoring, author, onSponsor }: SponsoringSummaryProps): React.ReactElement {
|
||||||
const totalBTC = totalSponsoring / 100_000_000
|
const totalBTC = totalSponsoring / 100_000_000
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
|
||||||
@ -221,6 +227,16 @@ function useAuthorData(hashIdOrPubkey: string): {
|
|||||||
return { presentation, series, totalSponsoring, loading, error, reload }
|
return { presentation, series, totalSponsoring, loading, error, reload }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthorPageContentProps = {
|
||||||
|
presentation: AuthorPresentationArticle | null
|
||||||
|
series: Series[]
|
||||||
|
totalSponsoring: number
|
||||||
|
authorPubkey: string
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
onSeriesCreated: () => void
|
||||||
|
}
|
||||||
|
|
||||||
function AuthorPageContent({
|
function AuthorPageContent({
|
||||||
presentation,
|
presentation,
|
||||||
series,
|
series,
|
||||||
@ -229,15 +245,7 @@ function AuthorPageContent({
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
onSeriesCreated,
|
onSeriesCreated,
|
||||||
}: {
|
}: AuthorPageContentProps): React.ReactElement {
|
||||||
presentation: AuthorPresentationArticle | null
|
|
||||||
series: Series[]
|
|
||||||
totalSponsoring: number
|
|
||||||
authorPubkey: string
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
onSeriesCreated: () => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="text-cyber-accent">{t('common.loading')}</p>
|
return <p className="text-cyber-accent">{t('common.loading')}</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,7 +51,7 @@ function useRedirectWhenDisconnected(connected: boolean, pubkey: string | null):
|
|||||||
}, [connected, pubkey, router])
|
}, [connected, pubkey, router])
|
||||||
}
|
}
|
||||||
|
|
||||||
function useProfileController(): {
|
type ProfileController = {
|
||||||
connected: boolean
|
connected: boolean
|
||||||
currentPubkey: string | null
|
currentPubkey: string | null
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
@ -67,7 +67,9 @@ function useProfileController(): {
|
|||||||
loadingProfile: boolean
|
loadingProfile: boolean
|
||||||
selectedSeriesId: string | undefined
|
selectedSeriesId: string | undefined
|
||||||
onSelectSeries: (seriesId: string | undefined) => void
|
onSelectSeries: (seriesId: string | undefined) => void
|
||||||
} {
|
}
|
||||||
|
|
||||||
|
function useProfileController(): ProfileController {
|
||||||
const { connected, pubkey: currentPubkey } = useNostrAuth()
|
const { connected, pubkey: currentPubkey } = useNostrAuth()
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [filters, setFilters] = useState<ArticleFilters>({
|
const [filters, setFilters] = useState<ArticleFilters>({
|
||||||
|
|||||||
@ -157,18 +157,22 @@ function SeriesPublications({ articles }: { articles: Article[] }): React.ReactE
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSeriesPageData(seriesId: string): {
|
type SeriesAggregates = { sponsoring: number; purchases: number; reviewTips: number }
|
||||||
|
|
||||||
|
type SeriesPageData = {
|
||||||
series: Series | null
|
series: Series | null
|
||||||
articles: Article[]
|
articles: Article[]
|
||||||
aggregates: { sponsoring: number; purchases: number; reviewTips: number } | null
|
aggregates: SeriesAggregates | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
} {
|
}
|
||||||
|
|
||||||
|
function useSeriesPageData(seriesId: string): SeriesPageData {
|
||||||
const [series, setSeries] = useState<Series | null>(null)
|
const [series, setSeries] = useState<Series | null>(null)
|
||||||
const [articles, setArticles] = useState<Article[]>([])
|
const [articles, setArticles] = useState<Article[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [aggregates, setAggregates] = useState<{ sponsoring: number; purchases: number; reviewTips: number } | null>(null)
|
const [aggregates, setAggregates] = useState<SeriesAggregates | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!seriesId) {
|
if (!seriesId) {
|
||||||
|
|||||||
225
scripts/findLongFunctions.js
Normal file
225
scripts/findLongFunctions.js
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
// This script is a local dev helper to identify functions likely violating ESLint `max-lines-per-function`
|
||||||
|
// without running ESLint. It uses the TypeScript AST to compute line spans.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node scripts/findLongFunctions.js [--max 40] [--limit 50]
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// - Counts *raw* line span (endLine - startLine + 1). ESLint uses a different metric
|
||||||
|
// (skipBlankLines/skipComments). This script is used to pick candidates efficiently.
|
||||||
|
//
|
||||||
|
// It is intentionally plain JS to avoid requiring a TS build step.
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Readonly<{ file: string; name: string; startLine: number; endLine: number; span: number }>} LongFn
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {number | undefined}
|
||||||
|
*/
|
||||||
|
function readNumberArg(value) {
|
||||||
|
const n = Number(value)
|
||||||
|
if (Number.isFinite(n) && n > 0) {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} argv
|
||||||
|
* @returns {{ max: number; limit: number }}
|
||||||
|
*/
|
||||||
|
function readArgs(argv) {
|
||||||
|
let max = 40
|
||||||
|
let limit = 50
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i]
|
||||||
|
const next = argv[i + 1]
|
||||||
|
if (token === '--max' && next) {
|
||||||
|
max = readNumberArg(next) ?? max
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (token === '--limit' && next) {
|
||||||
|
limit = readNumberArg(next) ?? limit
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { max, limit }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} p
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isIgnoredPath(p) {
|
||||||
|
const normalized = p.split(path.sep).join('/')
|
||||||
|
return (
|
||||||
|
normalized.includes('/node_modules/') ||
|
||||||
|
normalized.includes('/.next/') ||
|
||||||
|
normalized.includes('/dist/') ||
|
||||||
|
normalized.includes('/out/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} dir
|
||||||
|
* @param {string[]} out
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function collectSourceFiles(dir, out) {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(dir, entry.name)
|
||||||
|
if (isIgnoredPath(full)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
collectSourceFiles(full, out)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (full.endsWith('.ts') || full.endsWith('.tsx')) {
|
||||||
|
out.push(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ts.Node} node
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getReadableName(node) {
|
||||||
|
if (ts.isFunctionDeclaration(node) && node.name) {
|
||||||
|
return node.name.getText()
|
||||||
|
}
|
||||||
|
if (ts.isMethodDeclaration(node) && node.name) {
|
||||||
|
return node.name.getText()
|
||||||
|
}
|
||||||
|
if (ts.isConstructorDeclaration(node)) {
|
||||||
|
return 'constructor'
|
||||||
|
}
|
||||||
|
if (ts.isGetAccessorDeclaration(node) && node.name) {
|
||||||
|
return `get ${node.name.getText()}`
|
||||||
|
}
|
||||||
|
if (ts.isSetAccessorDeclaration(node) && node.name) {
|
||||||
|
return `set ${node.name.getText()}`
|
||||||
|
}
|
||||||
|
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
||||||
|
const parent = node.parent
|
||||||
|
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
|
||||||
|
return parent.name.text
|
||||||
|
}
|
||||||
|
if (ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name)) {
|
||||||
|
return parent.name.text
|
||||||
|
}
|
||||||
|
return '<anonymous>'
|
||||||
|
}
|
||||||
|
return '<unknown>'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ts.Node} node
|
||||||
|
* @returns {node is (ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | ts.MethodDeclaration | ts.ConstructorDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration)}
|
||||||
|
*/
|
||||||
|
function isFunctionLikeWithBody(node) {
|
||||||
|
if (!ts.isFunctionLike(node)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Arrow functions may have expression bodies. We only flag block bodies for line spans.
|
||||||
|
// Function/method declarations use block bodies.
|
||||||
|
return Boolean(node.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} filename
|
||||||
|
* @param {number} max
|
||||||
|
* @returns {LongFn[]}
|
||||||
|
*/
|
||||||
|
function findLongFunctionsInFile(filename, max) {
|
||||||
|
const sourceText = fs.readFileSync(filename, 'utf8')
|
||||||
|
const sourceFile = ts.createSourceFile(
|
||||||
|
filename,
|
||||||
|
sourceText,
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
filename.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
||||||
|
)
|
||||||
|
|
||||||
|
/** @type {LongFn[]} */
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ts.Node} node
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function visit(node) {
|
||||||
|
if (isFunctionLikeWithBody(node)) {
|
||||||
|
const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile, false))
|
||||||
|
const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd())
|
||||||
|
const startLine = start.line + 1
|
||||||
|
const endLine = end.line + 1
|
||||||
|
const span = endLine - startLine + 1
|
||||||
|
if (span > max) {
|
||||||
|
results.push({
|
||||||
|
file: filename,
|
||||||
|
name: getReadableName(node),
|
||||||
|
startLine,
|
||||||
|
endLine,
|
||||||
|
span,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(sourceFile)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {LongFn} fn
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatFn(fn) {
|
||||||
|
const rel = path.relative(process.cwd(), fn.file).split(path.sep).join('/')
|
||||||
|
return `${fn.span.toString().padStart(4, ' ')} ${rel}:${fn.startLine}-${fn.endLine} ${fn.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const { max, limit } = readArgs(process.argv.slice(2))
|
||||||
|
/** @type {string[]} */
|
||||||
|
const files = []
|
||||||
|
for (const dir of ['components', 'hooks', 'lib', 'pages', 'types', 'scripts']) {
|
||||||
|
const full = path.join(process.cwd(), dir)
|
||||||
|
if (fs.existsSync(full)) {
|
||||||
|
collectSourceFiles(full, files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {LongFn[]} */
|
||||||
|
const all = []
|
||||||
|
for (const file of files) {
|
||||||
|
const found = findLongFunctionsInFile(file, max)
|
||||||
|
all.push(...found)
|
||||||
|
}
|
||||||
|
|
||||||
|
all.sort((a, b) => b.span - a.span || a.file.localeCompare(b.file) || a.startLine - b.startLine)
|
||||||
|
const top = all.slice(0, limit)
|
||||||
|
|
||||||
|
console.log(`Found ${all.length} function-like nodes with raw span > ${max} lines.`)
|
||||||
|
console.log(`Showing top ${top.length} (sorted by span desc):\n`)
|
||||||
|
for (const fn of top) {
|
||||||
|
console.log(formatFn(fn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
Loading…
x
Reference in New Issue
Block a user