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
|
||||
// Use a generic loading message until we have content
|
||||
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 (
|
||||
<div className="w-full px-4 py-8">
|
||||
@ -102,23 +93,77 @@ function HomeContent({
|
||||
<ArticleFiltersComponent filters={filters} onFiltersChange={setFilters} articles={allArticles} />
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
if (isInitialLoad) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-cyber-accent/70">{t('common.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (shouldShowAuthors) {
|
||||
return <AuthorsList {...authorsListProps} />
|
||||
}
|
||||
return <ArticlesList {...articlesListProps} />
|
||||
})()}
|
||||
<HomeMainList
|
||||
isInitialLoad={isInitialLoad}
|
||||
shouldShowAuthors={shouldShowAuthors}
|
||||
articlesListProps={buildArticlesListProps({
|
||||
articles,
|
||||
allArticles,
|
||||
loading,
|
||||
isInitialLoad,
|
||||
error,
|
||||
onUnlock,
|
||||
unlockedArticles,
|
||||
})}
|
||||
authorsListProps={buildAuthorsListProps({ authors, allAuthors, loading, isInitialLoad, error })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeMainList(params: {
|
||||
isInitialLoad: boolean
|
||||
shouldShowAuthors: boolean
|
||||
articlesListProps: Parameters<typeof ArticlesList>[0]
|
||||
authorsListProps: Parameters<typeof AuthorsList>[0]
|
||||
}): React.ReactElement {
|
||||
if (params.isInitialLoad) {
|
||||
return (
|
||||
<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 {
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -106,7 +106,7 @@ function useImageUpload(onChange: (url: string) => void): {
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null)
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = event.target.files?.[0]
|
||||
const file = readFirstFile(event)
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
@ -117,9 +117,8 @@ function useImageUpload(onChange: (url: string) => void): {
|
||||
try {
|
||||
await processFileUpload(file, onChange, setError)
|
||||
} catch (uploadError) {
|
||||
const uploadErr = uploadError instanceof Error ? uploadError : new Error(String(uploadError))
|
||||
// Check if unlock is required
|
||||
if (uploadErr.message === 'UNLOCK_REQUIRED' || ('unlockRequired' in uploadErr && (uploadErr as { unlockRequired?: boolean }).unlockRequired)) {
|
||||
const uploadErr = normalizeError(uploadError)
|
||||
if (isUnlockRequiredError(uploadErr)) {
|
||||
setPendingFile(file)
|
||||
setShowUnlockModal(true)
|
||||
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> => {
|
||||
setShowUnlockModal(false)
|
||||
if (pendingFile) {
|
||||
// Retry upload after unlock
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await processFileUpload(pendingFile, onChange, setError)
|
||||
setPendingFile(null)
|
||||
} catch (retryError) {
|
||||
setError(retryError instanceof Error ? retryError.message : t('presentation.field.picture.error.uploadFailed'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
await retryPendingUpload({
|
||||
pendingFile,
|
||||
onChange,
|
||||
setError,
|
||||
setPendingFile,
|
||||
setShowUnlockModal,
|
||||
setUploading,
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
const { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess } = useImageUpload(onChange)
|
||||
const displayLabel = label ?? t('presentation.field.picture')
|
||||
|
||||
@ -11,8 +11,6 @@ interface PublicKeys {
|
||||
}
|
||||
|
||||
export function KeyManagementManager(): React.ReactElement {
|
||||
console.warn('[KeyManagementManager] Component rendered')
|
||||
|
||||
const [publicKeys, setPublicKeys] = useState<PublicKeys | null>(null)
|
||||
const [accountExists, setAccountExists] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -71,13 +69,32 @@ export function KeyManagementManager(): React.ReactElement {
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
// Not a valid URL, try to extract nsec from text
|
||||
const nsecMatch = url.match(/nsec1[a-z0-9]+/i)
|
||||
if (nsecMatch) {
|
||||
return nsecMatch[0]
|
||||
return extractKeyFromText(url)
|
||||
}
|
||||
}
|
||||
|
||||
function extractKeyFromText(text: string): string {
|
||||
const nsec = extractNsec(text)
|
||||
if (nsec) {
|
||||
return nsec
|
||||
}
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
function extractNsec(text: string): string | null {
|
||||
const nsecMatch = text.match(/nsec1[a-z0-9]+/i)
|
||||
return nsecMatch?.[0] ?? null
|
||||
}
|
||||
|
||||
function isValidPrivateKeyFormat(key: string): boolean {
|
||||
try {
|
||||
const decoded = nip19.decode(key)
|
||||
if (decoded.type !== 'nsec') {
|
||||
return false
|
||||
}
|
||||
// Assume it's already a key (hex or nsec)
|
||||
return url.trim()
|
||||
return typeof decoded.data === 'string' || decoded.data instanceof Uint8Array
|
||||
} catch {
|
||||
return /^[0-9a-f]{64}$/i.test(key)
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,22 +112,9 @@ export function KeyManagementManager(): React.ReactElement {
|
||||
}
|
||||
|
||||
// Validate key format
|
||||
try {
|
||||
// Try to decode as nsec
|
||||
const decoded = nip19.decode(extractedKey)
|
||||
if (decoded.type !== 'nsec') {
|
||||
throw new Error('Invalid nsec format')
|
||||
}
|
||||
// decoded.data can be string (hex) or Uint8Array, both are valid
|
||||
if (typeof decoded.data !== 'string' && !(decoded.data instanceof Uint8Array)) {
|
||||
throw new Error('Invalid nsec format')
|
||||
}
|
||||
} catch {
|
||||
// If decoding failed, assume it's hex, validate length (64 hex chars = 32 bytes)
|
||||
if (!/^[0-9a-f]{64}$/i.test(extractedKey)) {
|
||||
setError(t('settings.keyManagement.import.error.invalid'))
|
||||
return
|
||||
}
|
||||
if (!isValidPrivateKeyFormat(extractedKey)) {
|
||||
setError(t('settings.keyManagement.import.error.invalid'))
|
||||
return
|
||||
}
|
||||
|
||||
// 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">
|
||||
<h2 className="text-2xl font-bold text-neon-cyan mb-4">{t('settings.keyManagement.title')}</h2>
|
||||
|
||||
{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>
|
||||
)}
|
||||
<KeyManagementErrorBanner error={error} />
|
||||
|
||||
{/* Public Keys Display */}
|
||||
{publicKeys && (
|
||||
<div className="space-y-4 mb-6">
|
||||
<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.npub')}</p>
|
||||
<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>
|
||||
)}
|
||||
<KeyManagementPublicKeysPanel
|
||||
publicKeys={publicKeys}
|
||||
copiedNpub={copiedNpub}
|
||||
copiedPublicKey={copiedPublicKey}
|
||||
onCopyNpub={handleCopyNpub}
|
||||
onCopyPublicKey={handleCopyPublicKey}
|
||||
/>
|
||||
|
||||
{/* Sync Progress Bar - Always show if connected, even if publicKeys not loaded yet */}
|
||||
{(() => {
|
||||
console.warn('[KeyManagementManager] Rendering SyncProgressBar')
|
||||
return <SyncProgressBar />
|
||||
})()}
|
||||
<SyncProgressBar />
|
||||
|
||||
{!publicKeys && !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>
|
||||
)}
|
||||
<KeyManagementNoAccountBanner publicKeys={publicKeys} accountExists={accountExists} />
|
||||
|
||||
{/* Import Form */}
|
||||
{!showImportForm && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowImportForm(true)
|
||||
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>
|
||||
)}
|
||||
<KeyManagementImportButton
|
||||
accountExists={accountExists}
|
||||
showImportForm={showImportForm}
|
||||
onClick={() => {
|
||||
setShowImportForm(true)
|
||||
setError(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
{showImportForm && (
|
||||
<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') }} />
|
||||
{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={importKey}
|
||||
onChange={(e) => {
|
||||
setImportKey(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
<KeyManagementImportForm
|
||||
accountExists={accountExists}
|
||||
showImportForm={showImportForm}
|
||||
showReplaceWarning={showReplaceWarning}
|
||||
importing={importing}
|
||||
importKey={importKey}
|
||||
onChangeImportKey={(value) => {
|
||||
setImportKey(value)
|
||||
setError(null)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowImportForm(false)
|
||||
setImportKey('')
|
||||
setError(null)
|
||||
}}
|
||||
onImport={() => {
|
||||
void handleImport()
|
||||
}}
|
||||
onDismissReplaceWarning={() => {
|
||||
setShowReplaceWarning(false)
|
||||
}}
|
||||
onConfirmReplace={() => {
|
||||
void performImport(extractKeyFromUrl(importKey.trim()) ?? importKey.trim())
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Recovery Phrase Display (after import) */}
|
||||
{recoveryPhrase && newNpub && (
|
||||
<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">
|
||||
{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>
|
||||
)}
|
||||
<KeyManagementRecoveryPanel
|
||||
recoveryPhrase={recoveryPhrase}
|
||||
newNpub={newNpub}
|
||||
copiedRecoveryPhrase={copiedRecoveryPhrase}
|
||||
onCopyRecoveryPhrase={handleCopyRecoveryPhrase}
|
||||
onDone={() => {
|
||||
setRecoveryPhrase(null)
|
||||
setNewNpub(null)
|
||||
void loadKeys()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementErrorBanner(params: { error: string | null }): React.ReactElement | null {
|
||||
if (!params.error) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4 mb-4">
|
||||
<p className="text-red-400">{params.error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementPublicKeysPanel(params: {
|
||||
publicKeys: PublicKeys | null
|
||||
copiedNpub: boolean
|
||||
copiedPublicKey: boolean
|
||||
onCopyNpub: () => Promise<void>
|
||||
onCopyPublicKey: () => Promise<void>
|
||||
}): React.ReactElement | null {
|
||||
if (!params.publicKeys) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4 mb-6">
|
||||
<KeyManagementKeyCard
|
||||
label={t('settings.keyManagement.publicKey.npub')}
|
||||
value={params.publicKeys.npub}
|
||||
copied={params.copiedNpub}
|
||||
onCopy={params.onCopyNpub}
|
||||
/>
|
||||
<KeyManagementKeyCard
|
||||
label={t('settings.keyManagement.publicKey.hex')}
|
||||
value={params.publicKeys.publicKey}
|
||||
copied={params.copiedPublicKey}
|
||||
onCopy={params.onCopyPublicKey}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementKeyCard(params: {
|
||||
label: string
|
||||
value: string
|
||||
copied: boolean
|
||||
onCopy: () => Promise<void>
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<p className="text-neon-blue font-semibold">{params.label}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
void params.onCopy()
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded transition-colors"
|
||||
>
|
||||
{params.copied ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-neon-cyan text-sm font-mono break-all">{params.value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementNoAccountBanner(params: {
|
||||
publicKeys: PublicKeys | null
|
||||
accountExists: boolean
|
||||
}): React.ReactElement | null {
|
||||
if (params.publicKeys || params.accountExists) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4 mb-6">
|
||||
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.noAccount.title')}</p>
|
||||
<p className="text-yellow-300/90 text-sm">{t('settings.keyManagement.noAccount.description')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementImportButton(params: {
|
||||
accountExists: boolean
|
||||
showImportForm: boolean
|
||||
onClick: () => void
|
||||
}): React.ReactElement | null {
|
||||
if (params.showImportForm) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={params.onClick}
|
||||
className="w-full py-3 px-6 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
||||
>
|
||||
{params.accountExists ? t('settings.keyManagement.import.button.replace') : t('settings.keyManagement.import.button.new')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementImportForm(params: {
|
||||
accountExists: boolean
|
||||
showImportForm: boolean
|
||||
showReplaceWarning: boolean
|
||||
importing: boolean
|
||||
importKey: string
|
||||
onChangeImportKey: (value: string) => void
|
||||
onCancel: () => void
|
||||
onImport: () => void
|
||||
onDismissReplaceWarning: () => void
|
||||
onConfirmReplace: () => void
|
||||
}): React.ReactElement | null {
|
||||
if (!params.showImportForm) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
|
||||
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.import.warning.title')}</p>
|
||||
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.description') }} />
|
||||
{params.accountExists && (
|
||||
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.replace') }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="importKey" className="block text-sm font-medium text-cyber-accent mb-2">
|
||||
{t('settings.keyManagement.import.label')}
|
||||
</label>
|
||||
<textarea
|
||||
id="importKey"
|
||||
value={params.importKey}
|
||||
onChange={(e) => {
|
||||
params.onChangeImportKey(e.target.value)
|
||||
}}
|
||||
placeholder={t('settings.keyManagement.import.placeholder')}
|
||||
className="w-full px-3 py-2 bg-cyber-dark border border-neon-cyan/30 rounded-lg font-mono text-sm text-neon-cyan focus:border-neon-cyan focus:outline-none"
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-sm text-cyber-accent/70 mt-2">{t('settings.keyManagement.import.help')}</p>
|
||||
</div>
|
||||
|
||||
<KeyManagementReplaceWarning
|
||||
show={params.showReplaceWarning}
|
||||
importing={params.importing}
|
||||
onCancel={params.onDismissReplaceWarning}
|
||||
onConfirm={params.onConfirmReplace}
|
||||
/>
|
||||
|
||||
<KeyManagementImportFormActions
|
||||
show={!params.showReplaceWarning}
|
||||
importing={params.importing}
|
||||
onCancel={params.onCancel}
|
||||
onImport={params.onImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementReplaceWarning(params: {
|
||||
show: boolean
|
||||
importing: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}): React.ReactElement | null {
|
||||
if (!params.show) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4">
|
||||
<p className="text-red-400 font-semibold mb-2">{t('settings.keyManagement.replace.warning.title')}</p>
|
||||
<p className="text-red-300/90 text-sm mb-4">{t('settings.keyManagement.replace.warning.description')}</p>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={params.onCancel}
|
||||
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{t('settings.keyManagement.replace.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={params.onConfirm}
|
||||
disabled={params.importing}
|
||||
className="flex-1 py-2 px-4 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg font-medium transition-all border border-red-400/50 hover:shadow-glow-red disabled:opacity-50"
|
||||
>
|
||||
{params.importing ? t('settings.keyManagement.replace.replacing') : t('settings.keyManagement.replace.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementImportFormActions(params: {
|
||||
show: boolean
|
||||
importing: boolean
|
||||
onCancel: () => void
|
||||
onImport: () => void
|
||||
}): React.ReactElement | null {
|
||||
if (!params.show) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={params.onCancel}
|
||||
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{t('settings.keyManagement.import.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={params.onImport}
|
||||
disabled={params.importing}
|
||||
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
|
||||
>
|
||||
{params.importing ? t('settings.keyManagement.import.importing') : t('settings.keyManagement.import.import')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementRecoveryPanel(params: {
|
||||
recoveryPhrase: string[] | null
|
||||
newNpub: string | null
|
||||
copiedRecoveryPhrase: boolean
|
||||
onCopyRecoveryPhrase: () => Promise<void>
|
||||
onDone: () => void
|
||||
}): React.ReactElement | null {
|
||||
if (!params.recoveryPhrase || !params.newNpub) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
|
||||
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.recovery.warning.title')}</p>
|
||||
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part1') }} />
|
||||
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part2') }} />
|
||||
<p className="text-yellow-300/90 text-sm mt-2">{t('settings.keyManagement.recovery.warning.part3')}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
{params.recoveryPhrase.map((word, index) => (
|
||||
<div
|
||||
key={`recovery-word-${index}-${word}`}
|
||||
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
|
||||
>
|
||||
<span className="text-cyber-accent/70 text-sm mr-2">{index + 1}.</span>
|
||||
<span className="font-semibold text-neon-cyan">{word}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
void params.onCopyRecoveryPhrase()
|
||||
}}
|
||||
className="w-full py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{params.copiedRecoveryPhrase ? t('settings.keyManagement.recovery.copied') : t('settings.keyManagement.recovery.copy')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
|
||||
<p className="text-neon-blue font-semibold mb-2">{t('settings.keyManagement.recovery.newNpub')}</p>
|
||||
<p className="text-neon-cyan text-sm font-mono break-all">{params.newNpub}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={params.onDone}
|
||||
className="w-full py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
||||
>
|
||||
{t('settings.keyManagement.recovery.done')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -31,36 +31,11 @@ export function LanguageSettingsManager(): React.ReactElement {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const loadLocale = async (): Promise<void> => {
|
||||
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()
|
||||
void loadLocaleIntoState({ setCurrentLocale, setLoading })
|
||||
}, [])
|
||||
|
||||
const handleLocaleChange = async (locale: Locale): Promise<void> => {
|
||||
setLocale(locale)
|
||||
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()
|
||||
await applyLocaleChange({ locale, setCurrentLocale })
|
||||
}
|
||||
|
||||
const onLocaleClick = (locale: Locale): void => {
|
||||
@ -86,3 +61,32 @@ export function LanguageSettingsManager(): React.ReactElement {
|
||||
</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 [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleAddPage = (type: 'markdown' | 'image'): void => {
|
||||
if (!onPagesChange) {
|
||||
return
|
||||
}
|
||||
const newPage: Page = {
|
||||
number: pages.length + 1,
|
||||
type,
|
||||
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)
|
||||
}
|
||||
}
|
||||
const pagesHandlers = createPagesHandlers({ pages, onPagesChange })
|
||||
const handleImageUpload = createImageUploadHandler({
|
||||
setError,
|
||||
setUploading,
|
||||
onMediaAdd,
|
||||
onBannerChange,
|
||||
onSetPageImageUrl: pagesHandlers.setPageContent,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MarkdownToolbar
|
||||
onFileSelected={(file) => {
|
||||
void handleImageUpload(file)
|
||||
void handleImageUpload({ file })
|
||||
}}
|
||||
uploading={uploading}
|
||||
error={error}
|
||||
{...(onPagesChange ? { onAddPage: handleAddPage } : {})}
|
||||
{...(onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-semibold text-gray-800">{t('markdown.editor')}</label>
|
||||
<textarea
|
||||
className="w-full border rounded p-3 h-96 font-mono text-sm"
|
||||
value={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>
|
||||
<EditorColumn value={value} onChange={onChange} />
|
||||
<PreviewColumn value={value} />
|
||||
</div>
|
||||
{onPagesChange && (
|
||||
<PagesManager
|
||||
pages={pages}
|
||||
onPageContentChange={handlePageContentChange}
|
||||
onPageTypeChange={handlePageTypeChange}
|
||||
onRemovePage={handleRemovePage}
|
||||
onImageUpload={handleImageUpload}
|
||||
onPageContentChange={pagesHandlers.setPageContent}
|
||||
onPageTypeChange={pagesHandlers.setPageType}
|
||||
onRemovePage={pagesHandlers.removePage}
|
||||
onImageUpload={async (file, pageNumber) => {
|
||||
await handleImageUpload({ file, pageNumber })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -132,40 +74,9 @@ function MarkdownToolbar({
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<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">
|
||||
{t('markdown.upload.media')}
|
||||
<input
|
||||
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>}
|
||||
<ToolbarUploadButton onFileSelected={onFileSelected} />
|
||||
<ToolbarAddPageButtons onAddPage={onAddPage} />
|
||||
<ToolbarStatus uploading={uploading} error={error} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -229,66 +140,246 @@ function PageEditor({
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-semibold">
|
||||
{t('page.number', { number: page.number })} - {t(`page.type.${page.type}`)}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={page.type}
|
||||
onChange={(e) => 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>
|
||||
<PageEditorHeader page={page} onTypeChange={onTypeChange} onRemove={onRemove} />
|
||||
<PageEditorBody page={page} onContentChange={onContentChange} onImageUpload={onImageUpload} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditorColumn(params: { value: string; onChange: (value: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-semibold text-gray-800">{t('markdown.editor')}</label>
|
||||
<textarea
|
||||
className="w-full border rounded p-3 h-96 font-mono text-sm"
|
||||
value={params.value}
|
||||
onChange={(e) => params.onChange(e.target.value)}
|
||||
placeholder={t('markdown.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewColumn(params: { value: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-semibold text-gray-800">{t('markdown.preview')}</label>
|
||||
<MarkdownPreview value={params.value} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarUploadButton(params: { onFileSelected: (file: File) => void }): React.ReactElement {
|
||||
return (
|
||||
<label className="px-3 py-1 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700">
|
||||
{t('markdown.upload.media')}
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
params.onFileSelected(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarAddPageButtons(params: { onAddPage: ((type: 'markdown' | 'image') => void) | undefined }): React.ReactElement | null {
|
||||
if (!params.onAddPage) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
|
||||
onClick={() => params.onAddPage?.('markdown')}
|
||||
>
|
||||
{t('page.add.markdown')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700"
|
||||
onClick={() => params.onAddPage?.('image')}
|
||||
>
|
||||
{t('page.add.image')}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarStatus(params: { uploading: boolean; error: string | null }): React.ReactElement | null {
|
||||
if (!params.uploading && !params.error) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{params.uploading ? <span className="text-sm text-gray-500">{t('markdown.upload.uploading')}</span> : null}
|
||||
{params.error ? <span className="text-sm text-red-600">{params.error}</span> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PageEditorHeader(params: {
|
||||
page: Page
|
||||
onTypeChange: (type: 'markdown' | 'image') => void
|
||||
onRemove: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-semibold">
|
||||
{t('page.number', { number: params.page.number })} - {t(`page.type.${params.page.type}`)}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={params.page.type}
|
||||
onChange={(e) => params.onTypeChange(e.target.value as 'markdown' | 'image')}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="markdown">{t('page.type.markdown')}</option>
|
||||
<option value="image">{t('page.type.image')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||
onClick={params.onRemove}
|
||||
>
|
||||
{t('page.remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PageEditorBody(params: {
|
||||
page: Page
|
||||
onContentChange: (content: string) => void
|
||||
onImageUpload: (file: File) => Promise<void>
|
||||
}): React.ReactElement {
|
||||
if (params.page.type === 'markdown') {
|
||||
return (
|
||||
<textarea
|
||||
className="w-full border rounded p-2 h-48 font-mono text-sm"
|
||||
value={params.page.content}
|
||||
onChange={(e) => params.onContentChange(e.target.value)}
|
||||
placeholder={t('page.markdown.placeholder')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <PageEditorImageBody page={params.page} onContentChange={params.onContentChange} onImageUpload={params.onImageUpload} />
|
||||
}
|
||||
|
||||
function PageEditorImageBody(params: {
|
||||
page: Page
|
||||
onContentChange: (content: string) => void
|
||||
onImageUpload: (file: File) => Promise<void>
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{params.page.content ? (
|
||||
<div className="relative">
|
||||
<img src={params.page.content} alt={t('page.image.alt', { number: params.page.number })} className="max-w-full h-auto rounded" />
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||
onClick={onRemove}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||
onClick={() => params.onContentChange('')}
|
||||
>
|
||||
{t('page.remove')}
|
||||
{t('page.image.remove')}
|
||||
</button>
|
||||
</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">
|
||||
{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>
|
||||
<PageImageUploadButton onFileSelected={params.onImageUpload} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PageImageUploadButton(params: { onFileSelected: (file: File) => Promise<void> }): React.ReactElement {
|
||||
return (
|
||||
<label className="block px-3 py-2 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700 text-center">
|
||||
{t('page.image.upload')}
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
void params.onFileSelected(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function createPagesHandlers(params: {
|
||||
pages: Page[]
|
||||
onPagesChange: ((pages: Page[]) => void) | undefined
|
||||
}): { addPage: (type: 'markdown' | 'image') => void; setPageContent: (pageNumber: number, content: string) => void; setPageType: (pageNumber: number, type: 'markdown' | 'image') => void; removePage: (pageNumber: number) => void } {
|
||||
const update = (next: Page[]): void => {
|
||||
params.onPagesChange?.(next)
|
||||
}
|
||||
|
||||
return {
|
||||
addPage: (type) => {
|
||||
if (!params.onPagesChange) {
|
||||
return
|
||||
}
|
||||
const newPage: Page = { number: params.pages.length + 1, type, content: '' }
|
||||
update([...params.pages, newPage])
|
||||
},
|
||||
setPageContent: (pageNumber, content) => {
|
||||
if (!params.onPagesChange) {
|
||||
return
|
||||
}
|
||||
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, content } : p)))
|
||||
},
|
||||
setPageType: (pageNumber, type) => {
|
||||
if (!params.onPagesChange) {
|
||||
return
|
||||
}
|
||||
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, type, content: '' } : p)))
|
||||
},
|
||||
removePage: (pageNumber) => {
|
||||
if (!params.onPagesChange) {
|
||||
return
|
||||
}
|
||||
update(params.pages.filter((p) => p.number !== pageNumber).map((p, idx) => ({ ...p, number: idx + 1 })))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createImageUploadHandler(params: {
|
||||
setError: (value: string | null) => void
|
||||
setUploading: (value: boolean) => void
|
||||
onMediaAdd: ((media: MediaRef) => void) | undefined
|
||||
onBannerChange: ((url: string) => void) | undefined
|
||||
onSetPageImageUrl: (pageNumber: number, url: string) => void
|
||||
}): (args: { file: File; pageNumber?: number }) => Promise<void> {
|
||||
return async (args): Promise<void> => {
|
||||
params.setError(null)
|
||||
params.setUploading(true)
|
||||
try {
|
||||
const media = await uploadNip95Media(args.file)
|
||||
if (media.type !== 'image') {
|
||||
return
|
||||
}
|
||||
if (args.pageNumber !== undefined) {
|
||||
params.onSetPageImageUrl(args.pageNumber, media.url)
|
||||
} else {
|
||||
params.onBannerChange?.(media.url)
|
||||
}
|
||||
params.onMediaAdd?.(media)
|
||||
} catch (e) {
|
||||
params.setError(e instanceof Error ? e.message : t('upload.error.failed'))
|
||||
} finally {
|
||||
params.setUploading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,41 +60,61 @@ export function PageHeader(): React.ReactElement {
|
||||
return (
|
||||
<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="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>
|
||||
<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>
|
||||
<HeaderLeft />
|
||||
<HeaderRight />
|
||||
</div>
|
||||
</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
|
||||
errorMessage: string | null
|
||||
paymentUrl: string
|
||||
timeRemaining: number | null
|
||||
handleCopy: () => Promise<void>
|
||||
handleOpenWallet: () => Promise<void>
|
||||
} {
|
||||
}
|
||||
|
||||
function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => void): PaymentModalState {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const paymentUrl = `lightning:${invoice.invoice}`
|
||||
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 {
|
||||
await navigator.clipboard.writeText(invoice.invoice)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
await navigator.clipboard.writeText(params.invoice)
|
||||
params.setCopied(true)
|
||||
scheduleCopiedReset(params.setCopied)
|
||||
} catch (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 {
|
||||
const alby = getAlbyService()
|
||||
if (!isWebLNAvailable()) {
|
||||
throw new Error(t('payment.modal.weblnNotAvailable'))
|
||||
}
|
||||
await alby.enable()
|
||||
await alby.sendPayment(invoice.invoice)
|
||||
onPaymentComplete()
|
||||
await payWithWebLN(params.invoice)
|
||||
params.onPaymentComplete()
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
if (error.message.includes('user rejected') || error.message.includes('cancelled')) {
|
||||
const error = normalizePaymentError(e)
|
||||
if (isUserCancellationError(error)) {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
|
||||
@ -7,8 +7,6 @@ import { t } from '@/lib/i18n'
|
||||
import { useSyncProgress } from '@/lib/hooks/useSyncProgress'
|
||||
|
||||
export function SyncProgressBar(): React.ReactElement | null {
|
||||
console.warn('[SyncProgressBar] Component function called')
|
||||
|
||||
const [lastSyncDate, setLastSyncDate] = useState<number | null>(null)
|
||||
const [totalDays, setTotalDays] = useState<number>(0)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
@ -78,39 +76,14 @@ export function SyncProgressBar(): React.ReactElement | null {
|
||||
return
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
console.warn('[SyncProgressBar] Starting sync check...')
|
||||
await loadSyncStatus()
|
||||
|
||||
// Auto-start sync if not recently synced
|
||||
const storedLastSyncDate = await getLastSyncDate()
|
||||
const currentTimestamp = getCurrentTimestamp()
|
||||
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) })
|
||||
}
|
||||
})()
|
||||
void runAutoSyncCheck({
|
||||
connection: { connected: connectionState.connected, pubkey: connectionState.pubkey },
|
||||
isSyncing,
|
||||
loadSyncStatus,
|
||||
startMonitoring,
|
||||
stopMonitoring,
|
||||
setError,
|
||||
})
|
||||
}, [isInitialized, connectionState.connected, connectionState.pubkey, isSyncing, loadSyncStatus, startMonitoring, stopMonitoring])
|
||||
|
||||
async function resynchronize(): Promise<void> {
|
||||
@ -156,18 +129,13 @@ export function SyncProgressBar(): React.ReactElement | null {
|
||||
|
||||
// Don't show if not initialized or not connected
|
||||
if (!isInitialized || !connectionState.connected || !connectionState.pubkey) {
|
||||
console.warn('[SyncProgressBar] Not rendering:', { isInitialized, connected: connectionState.connected, pubkey: connectionState.pubkey })
|
||||
return null
|
||||
}
|
||||
|
||||
console.warn('[SyncProgressBar] Rendering component')
|
||||
|
||||
// Check if sync is recently completed (within last hour)
|
||||
const isRecentlySynced = lastSyncDate !== null && lastSyncDate >= getCurrentTimestamp() - 3600
|
||||
|
||||
const progressPercentage = syncProgress && syncProgress.totalSteps > 0
|
||||
? Math.min(100, (syncProgress.currentStep / syncProgress.totalSteps) * 100)
|
||||
: 0
|
||||
const progressPercentage = computeProgressPercentage(syncProgress)
|
||||
|
||||
const formatDate = (timestamp: number): string => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
@ -187,80 +155,192 @@ export function SyncProgressBar(): React.ReactElement | null {
|
||||
|
||||
return (
|
||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-4 mt-6">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-900/30 border border-red-500/50 rounded p-3 text-red-300 text-sm">
|
||||
{error}
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
}}
|
||||
className="ml-2 text-red-400 hover:text-red-200"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<SyncErrorBanner
|
||||
error={error}
|
||||
onDismiss={() => {
|
||||
setError(null)
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-neon-cyan">
|
||||
{t('settings.sync.title')}
|
||||
</h3>
|
||||
{!isSyncing && (
|
||||
<button
|
||||
onClick={() => {
|
||||
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>
|
||||
)}
|
||||
<SyncResyncButton
|
||||
isSyncing={isSyncing}
|
||||
onClick={() => {
|
||||
void resynchronize()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{totalDays > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className="text-sm text-cyber-accent">
|
||||
{t('settings.sync.daysRange', {
|
||||
startDate: formatDate(startDate),
|
||||
endDate: formatDate(endDate),
|
||||
days: totalDays,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<SyncDateRange
|
||||
totalDays={totalDays}
|
||||
startDate={formatDate(startDate)}
|
||||
endDate={formatDate(endDate)}
|
||||
/>
|
||||
|
||||
{isSyncing && syncProgress && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-cyber-accent">
|
||||
{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>
|
||||
)}
|
||||
<SyncProgressSection
|
||||
isSyncing={isSyncing}
|
||||
syncProgress={syncProgress}
|
||||
progressPercentage={progressPercentage}
|
||||
/>
|
||||
|
||||
{!isSyncing && totalDays === 0 && isRecentlySynced && (
|
||||
<p className="text-sm text-green-400">
|
||||
{t('settings.sync.completed')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isSyncing && totalDays === 0 && !isRecentlySynced && (
|
||||
<p className="text-sm text-cyber-accent">
|
||||
{t('settings.sync.ready')}
|
||||
</p>
|
||||
)}
|
||||
<SyncStatusMessage
|
||||
isSyncing={isSyncing}
|
||||
totalDays={totalDays}
|
||||
isRecentlySynced={isRecentlySynced}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function computeProgressPercentage(syncProgress: ReturnType<typeof useSyncProgress>['syncProgress']): number {
|
||||
if (!syncProgress || syncProgress.totalSteps <= 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.min(100, (syncProgress.currentStep / syncProgress.totalSteps) * 100)
|
||||
}
|
||||
|
||||
function SyncErrorBanner(params: { error: string | null; onDismiss: () => void }): React.ReactElement | null {
|
||||
if (!params.error) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="mb-4 bg-red-900/30 border border-red-500/50 rounded p-3 text-red-300 text-sm">
|
||||
{params.error}
|
||||
<button onClick={params.onDismiss} className="ml-2 text-red-400 hover:text-red-200">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SyncResyncButton(params: { isSyncing: boolean; onClick: () => void }): React.ReactElement | null {
|
||||
if (params.isSyncing) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={params.onClick}
|
||||
className="px-3 py-1 text-xs bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded border border-neon-cyan/50 hover:border-neon-cyan transition-colors"
|
||||
>
|
||||
{t('settings.sync.resync')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SyncDateRange(params: { totalDays: number; startDate: string; endDate: string }): React.ReactElement | null {
|
||||
if (params.totalDays <= 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<p className="text-sm text-cyber-accent">
|
||||
{t('settings.sync.daysRange', {
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
days: params.totalDays,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SyncProgressSection(params: {
|
||||
isSyncing: boolean
|
||||
syncProgress: ReturnType<typeof useSyncProgress>['syncProgress']
|
||||
progressPercentage: number
|
||||
}): React.ReactElement | null {
|
||||
if (!params.isSyncing || !params.syncProgress) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-cyber-accent">
|
||||
{t('settings.sync.progress', {
|
||||
current: params.syncProgress.currentStep,
|
||||
total: params.syncProgress.totalSteps,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-neon-cyan font-semibold">{Math.round(params.progressPercentage)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-cyber-dark rounded-full h-2 overflow-hidden">
|
||||
<div className="bg-neon-cyan h-full transition-all duration-300" style={{ width: `${params.progressPercentage}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SyncStatusMessage(params: { isSyncing: boolean; totalDays: number; isRecentlySynced: boolean }): React.ReactElement | null {
|
||||
if (params.isSyncing || params.totalDays !== 0) {
|
||||
return null
|
||||
}
|
||||
if (params.isRecentlySynced) {
|
||||
return <p className="text-sm text-green-400">{t('settings.sync.completed')}</p>
|
||||
}
|
||||
return <p className="text-sm text-cyber-accent">{t('settings.sync.ready')}</p>
|
||||
}
|
||||
|
||||
async function runAutoSyncCheck(params: {
|
||||
connection: { connected: boolean; pubkey: string | null }
|
||||
isSyncing: boolean
|
||||
loadSyncStatus: () => Promise<void>
|
||||
startMonitoring: () => void
|
||||
stopMonitoring: () => void
|
||||
setError: (value: string | null) => void
|
||||
}): Promise<void> {
|
||||
console.warn('[SyncProgressBar] Starting sync check...')
|
||||
await params.loadSyncStatus()
|
||||
|
||||
const shouldStart = await shouldAutoStartSync({
|
||||
isSyncing: params.isSyncing,
|
||||
pubkey: params.connection.pubkey,
|
||||
})
|
||||
|
||||
if (!shouldStart || !params.connection.pubkey) {
|
||||
console.warn('[SyncProgressBar] Skipping auto-sync:', { shouldStart, isSyncing: params.isSyncing, hasPubkey: Boolean(params.connection.pubkey) })
|
||||
return
|
||||
}
|
||||
|
||||
console.warn('[SyncProgressBar] Starting auto-sync...')
|
||||
await startAutoSync({
|
||||
pubkey: params.connection.pubkey,
|
||||
startMonitoring: params.startMonitoring,
|
||||
stopMonitoring: params.stopMonitoring,
|
||||
setError: params.setError,
|
||||
})
|
||||
}
|
||||
|
||||
async function shouldAutoStartSync(params: { isSyncing: boolean; pubkey: string | null }): Promise<boolean> {
|
||||
if (params.isSyncing || !params.pubkey) {
|
||||
return false
|
||||
}
|
||||
const storedLastSyncDate = await getLastSyncDate()
|
||||
const currentTimestamp = getCurrentTimestamp()
|
||||
const isRecentlySynced = storedLastSyncDate >= currentTimestamp - 3600
|
||||
console.warn('[SyncProgressBar] Sync status:', { storedLastSyncDate, currentTimestamp, isRecentlySynced })
|
||||
return !isRecentlySynced
|
||||
}
|
||||
|
||||
async function startAutoSync(params: {
|
||||
pubkey: string
|
||||
startMonitoring: () => void
|
||||
stopMonitoring: () => void
|
||||
setError: (value: string | null) => void
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { swClient } = await import('@/lib/swClient')
|
||||
const isReady = await swClient.isReady()
|
||||
if (!isReady) {
|
||||
params.stopMonitoring()
|
||||
return
|
||||
}
|
||||
await swClient.startUserSync(params.pubkey)
|
||||
params.startMonitoring()
|
||||
} catch (autoSyncError) {
|
||||
console.error('[SyncProgressBar] Error during auto-sync:', autoSyncError)
|
||||
params.stopMonitoring()
|
||||
params.setError(autoSyncError instanceof Error ? autoSyncError.message : 'Erreur de synchronisation')
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,25 @@ interface UserArticlesProps {
|
||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||
}
|
||||
|
||||
type UserArticlesController = {
|
||||
localArticles: Article[]
|
||||
unlockedArticles: Set<string>
|
||||
pendingDeleteId: string | null
|
||||
requestDelete: (id: string) => void
|
||||
handleUnlock: (article: Article) => Promise<void>
|
||||
handleDelete: (article: Article) => Promise<void>
|
||||
handleEditSubmit: () => Promise<void>
|
||||
editingDraft: ArticleDraft | null
|
||||
editingArticleId: string | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
updateDraft: (draft: ArticleDraft) => void
|
||||
startEditing: (article: Article) => Promise<void>
|
||||
cancelEditing: () => void
|
||||
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>
|
||||
deleteArticle: (id: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function UserArticles({
|
||||
articles,
|
||||
loading,
|
||||
@ -45,24 +64,7 @@ function useUserArticlesController({
|
||||
articles: Article[]
|
||||
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
|
||||
currentPubkey: string | null
|
||||
}): {
|
||||
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>
|
||||
} {
|
||||
}): UserArticlesController {
|
||||
const [deletedArticleIds, setDeletedArticleIds] = useState<Set<string>>(new Set())
|
||||
const [articleOverridesById, setArticleOverridesById] = useState<Map<string, Article>>(new Map())
|
||||
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
|
||||
@ -200,7 +202,7 @@ function UserArticlesLayout({
|
||||
}
|
||||
|
||||
function createLayoutProps(
|
||||
controller: ReturnType<typeof useUserArticlesController>,
|
||||
controller: UserArticlesController,
|
||||
view: {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
@ -240,7 +242,7 @@ function createLayoutProps(
|
||||
}
|
||||
}
|
||||
|
||||
function buildEditPanelProps(controller: ReturnType<typeof useUserArticlesController>): {
|
||||
function buildEditPanelProps(controller: UserArticlesController): {
|
||||
draft: ArticleDraft | null
|
||||
editingArticleId: string | null
|
||||
loading: boolean
|
||||
@ -260,16 +262,7 @@ function buildEditPanelProps(controller: ReturnType<typeof useUserArticlesContro
|
||||
}
|
||||
}
|
||||
|
||||
function buildListProps(
|
||||
controller: ReturnType<typeof useUserArticlesController>,
|
||||
view: {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
showEmptyMessage: boolean
|
||||
currentPubkey: string | null
|
||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||
}
|
||||
): {
|
||||
type UserArticlesListProps = {
|
||||
articles: Article[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
@ -283,22 +276,28 @@ function buildListProps(
|
||||
pendingDeleteId: string | null
|
||||
requestDelete: (articleId: string) => void
|
||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||
} {
|
||||
}
|
||||
|
||||
function buildListProps(
|
||||
controller: UserArticlesController,
|
||||
view: {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
showEmptyMessage: boolean
|
||||
currentPubkey: string | null
|
||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||
}
|
||||
): UserArticlesListProps {
|
||||
const handlers = buildUserArticlesHandlers(controller)
|
||||
return {
|
||||
articles: controller.localArticles,
|
||||
loading: view.loading,
|
||||
error: view.error,
|
||||
showEmptyMessage: view.showEmptyMessage,
|
||||
unlockedArticles: controller.unlockedArticles,
|
||||
onUnlock: (a: Article) => {
|
||||
void controller.handleUnlock(a)
|
||||
},
|
||||
onEdit: (a: Article) => {
|
||||
void controller.startEditing(a)
|
||||
},
|
||||
onDelete: (a: Article) => {
|
||||
void controller.handleDelete(a)
|
||||
},
|
||||
onUnlock: handlers.onUnlock,
|
||||
onEdit: handlers.onEdit,
|
||||
onDelete: handlers.onDelete,
|
||||
editingArticleId: controller.editingArticleId,
|
||||
currentPubkey: view.currentPubkey,
|
||||
pendingDeleteId: controller.pendingDeleteId,
|
||||
@ -306,3 +305,21 @@ function buildListProps(
|
||||
...(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])
|
||||
}
|
||||
|
||||
export function useConnectButtonUiState(): {
|
||||
type ConnectButtonUiState = {
|
||||
showRecoveryStep: boolean
|
||||
showUnlockModal: boolean
|
||||
setShowUnlockModal: (show: boolean) => void
|
||||
@ -28,7 +28,9 @@ export function useConnectButtonUiState(): {
|
||||
onUnlockSuccess: () => void
|
||||
openUnlockModal: () => void
|
||||
closeUnlockModal: () => void
|
||||
} {
|
||||
}
|
||||
|
||||
export function useConnectButtonUiState(): ConnectButtonUiState {
|
||||
const unlockModal = useUnlockModalVisibility()
|
||||
const recovery = useRecoveryStepState()
|
||||
const [creatingAccount, setCreatingAccount] = useState(false)
|
||||
|
||||
@ -8,8 +8,21 @@ export function ReviewFormView(params: { ctrl: ReviewFormController; onCancel?:
|
||||
onSubmit={(e) => void params.ctrl.handleSubmit(e)}
|
||||
className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-neon-cyan">{t('review.form.title')}</h3>
|
||||
<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
|
||||
id="review-title"
|
||||
label={t('review.form.title.label')}
|
||||
@ -40,28 +53,30 @@ export function ReviewFormView(params: { ctrl: ReviewFormController; onCancel?:
|
||||
helpText={t('review.form.text.help')}
|
||||
optionalLabel={`(${t('common.optional')})`}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{params.ctrl.error ? <ErrorBox message={params.ctrl.error} /> : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
function ReviewFormActions(params: { 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('review.form.submit')}
|
||||
</button>
|
||||
{params.onCancel ? (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={params.ctrl.loading}
|
||||
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
|
||||
type="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"
|
||||
>
|
||||
{params.ctrl.loading ? t('common.loading') : t('review.form.submit')}
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
{params.onCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onCancel}
|
||||
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -8,51 +8,72 @@ export function ReviewTipFormView(params: { ctrl: ReviewTipFormController; onCan
|
||||
onSubmit={(e) => void params.ctrl.handleSubmit(e)}
|
||||
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>
|
||||
<p className="text-sm text-cyber-accent/70">
|
||||
{t('reviewTip.form.description', {
|
||||
amount: params.ctrl.split.total,
|
||||
reviewer: params.ctrl.split.reviewer,
|
||||
platform: params.ctrl.split.platform,
|
||||
amount: params.split.total,
|
||||
reviewer: params.split.reviewer,
|
||||
platform: params.split.platform,
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<div>
|
||||
<label htmlFor="review-tip-text" className="block text-sm font-medium text-cyber-accent mb-1">
|
||||
{t('reviewTip.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="review-tip-text"
|
||||
value={params.ctrl.text}
|
||||
onChange={(e) => params.ctrl.setText(e.target.value)}
|
||||
placeholder={t('reviewTip.form.text.placeholder')}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs text-cyber-accent/70 mt-1">{t('reviewTip.form.text.help')}</p>
|
||||
</div>
|
||||
function ReviewTipTextField(params: { value: string; onChange: (value: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="review-tip-text" className="block text-sm font-medium text-cyber-accent mb-1">
|
||||
{t('reviewTip.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="review-tip-text"
|
||||
value={params.value}
|
||||
onChange={(e) => params.onChange(e.target.value)}
|
||||
placeholder={t('reviewTip.form.text.placeholder')}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
|
||||
/>
|
||||
<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}
|
||||
|
||||
<div className="flex gap-2">
|
||||
function ReviewTipFormActions(params: {
|
||||
amount: number
|
||||
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
|
||||
type="submit"
|
||||
disabled={params.ctrl.loading}
|
||||
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
|
||||
type="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"
|
||||
>
|
||||
{params.ctrl.loading ? t('common.loading') : t('reviewTip.form.submit', { amount: params.ctrl.split.total })}
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
{params.onCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onCancel}
|
||||
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ interface EditState {
|
||||
articleId: string | null
|
||||
}
|
||||
|
||||
export function useArticleEditing(authorPubkey: string | null): {
|
||||
type UseArticleEditingResult = {
|
||||
editingDraft: ArticleDraft | null
|
||||
editingArticleId: string | null
|
||||
loading: boolean
|
||||
@ -20,95 +20,25 @@ export function useArticleEditing(authorPubkey: string | null): {
|
||||
submitEdit: () => Promise<ArticleUpdateResult | null>
|
||||
deleteArticle: (articleId: string) => Promise<boolean>
|
||||
updateDraft: (draft: ArticleDraft | null) => void
|
||||
} {
|
||||
}
|
||||
|
||||
export function useArticleEditing(authorPubkey: string | null): UseArticleEditingResult {
|
||||
const [state, setState] = useState<EditState>({ draft: null, articleId: null })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const updateDraft = (draft: ArticleDraft | null): void => {
|
||||
setState((prev) => ({ ...prev, draft }))
|
||||
}
|
||||
const updateDraft = (draft: ArticleDraft | null): void => setState((prev) => ({ ...prev, draft }))
|
||||
|
||||
const startEditing = async (article: Article): Promise<void> => {
|
||||
if (!authorPubkey) {
|
||||
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 startEditing = (article: Article): Promise<void> =>
|
||||
startEditingArticle({ authorPubkey, article, setState, setLoading, setError })
|
||||
|
||||
const cancelEditing = (): void => {
|
||||
setState({ draft: null, articleId: null })
|
||||
setError(null)
|
||||
}
|
||||
const cancelEditing = (): void => resetEditingState({ setState, setError })
|
||||
|
||||
const submitEdit = async (): Promise<ArticleUpdateResult | null> => {
|
||||
if (!authorPubkey || !state.articleId || !state.draft) {
|
||||
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 submitEdit = (): Promise<ArticleUpdateResult | null> =>
|
||||
submitArticleEdit({ authorPubkey, state, setState, setLoading, setError })
|
||||
|
||||
const deleteArticle = async (articleId: string): Promise<boolean> => {
|
||||
if (!authorPubkey) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
const deleteArticle = (articleId: string): Promise<boolean> =>
|
||||
deleteArticleById({ authorPubkey, articleId, setLoading, setError })
|
||||
|
||||
return {
|
||||
editingDraft: state.draft,
|
||||
@ -122,3 +52,102 @@ export function useArticleEditing(authorPubkey: string | null): {
|
||||
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 { nostrService } from '@/lib/nostr'
|
||||
|
||||
export function useArticlePayment(
|
||||
article: Article,
|
||||
pubkey: string | null,
|
||||
onUnlockSuccess?: () => void,
|
||||
connect?: () => Promise<void>
|
||||
): {
|
||||
type UseArticlePaymentResult = {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
paymentInvoice: AlbyInvoice | null
|
||||
handleUnlock: () => Promise<void>
|
||||
handlePaymentComplete: () => Promise<void>
|
||||
handleCloseModal: () => void
|
||||
} {
|
||||
}
|
||||
|
||||
export function useArticlePayment(
|
||||
article: Article,
|
||||
pubkey: string | null,
|
||||
onUnlockSuccess?: () => void,
|
||||
connect?: () => Promise<void>
|
||||
): UseArticlePaymentResult {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [paymentInvoice, setPaymentInvoice] = useState<AlbyInvoice | null>(null)
|
||||
const [paymentHash, setPaymentHash] = useState<string | null>(null)
|
||||
|
||||
const checkPaymentStatus = async (hash: string, userPubkey: string): Promise<void> => {
|
||||
try {
|
||||
const hasPaid = await paymentService.waitForArticlePayment({
|
||||
paymentHash: hash,
|
||||
articleId: article.id,
|
||||
articlePubkey: article.pubkey,
|
||||
amount: article.zapAmount,
|
||||
recipientPubkey: userPubkey,
|
||||
timeout: 300000,
|
||||
})
|
||||
const handleUnlock = (): Promise<void> =>
|
||||
unlockArticlePayment({
|
||||
article,
|
||||
pubkey,
|
||||
connect,
|
||||
onUnlockSuccess,
|
||||
setLoading,
|
||||
setError,
|
||||
setPaymentInvoice,
|
||||
setPaymentHash,
|
||||
})
|
||||
|
||||
if (hasPaid) {
|
||||
const content = await nostrService.getPrivateContent(article.id, article.pubkey)
|
||||
if (content) {
|
||||
setPaymentInvoice(null)
|
||||
setPaymentHash(null)
|
||||
onUnlockSuccess?.()
|
||||
} else {
|
||||
setError('Content not available. Please contact the author.')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Payment check error:', e)
|
||||
}
|
||||
}
|
||||
const handlePaymentComplete = (): Promise<void> =>
|
||||
checkPaymentAndUnlock({
|
||||
article,
|
||||
pubkey,
|
||||
paymentHash,
|
||||
onUnlockSuccess,
|
||||
setError,
|
||||
setPaymentInvoice,
|
||||
setPaymentHash,
|
||||
})
|
||||
|
||||
const handleUnlock = async (): Promise<void> => {
|
||||
if (!pubkey) {
|
||||
if (connect) {
|
||||
setLoading(true)
|
||||
await connect()
|
||||
setLoading(false)
|
||||
} else {
|
||||
setError('Please connect with Nostr first')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const paymentResult = await paymentService.createArticlePayment({
|
||||
article,
|
||||
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)
|
||||
}
|
||||
const handleCloseModal = (): void => resetPaymentModalState({ setPaymentInvoice, setPaymentHash })
|
||||
|
||||
return {
|
||||
loading,
|
||||
@ -107,3 +58,114 @@ export function useArticlePayment(
|
||||
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
|
||||
}
|
||||
|
||||
export function useAuthorPresentation(pubkey: string | null): {
|
||||
type UseAuthorPresentationResult = {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
success: boolean
|
||||
publishPresentation: (draft: AuthorPresentationDraft) => Promise<void>
|
||||
checkPresentationExists: () => Promise<Article | null>
|
||||
deletePresentation: (articleId: string) => Promise<void>
|
||||
} {
|
||||
}
|
||||
|
||||
export function useAuthorPresentation(pubkey: string | null): UseAuthorPresentationResult {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const publishPresentation = async (draft: AuthorPresentationDraft): Promise<void> => {
|
||||
if (!pubkey) {
|
||||
setError('Clé publique non disponible')
|
||||
return
|
||||
}
|
||||
const publishPresentation = (draft: AuthorPresentationDraft): Promise<void> =>
|
||||
publishAuthorPresentation({ pubkey, draft, setLoading, setError, setSuccess })
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const checkPresentationExists = (): Promise<Article | null> => checkPresentation({ pubkey })
|
||||
|
||||
try {
|
||||
const privateKey = nostrService.getPrivateKey()
|
||||
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)
|
||||
}
|
||||
}
|
||||
const deletePresentation = (articleId: string): Promise<void> =>
|
||||
deleteAuthorPresentation({ pubkey, articleId, setLoading, setError })
|
||||
|
||||
return {
|
||||
loading,
|
||||
@ -134,3 +43,114 @@ export function useAuthorPresentation(pubkey: string | null): {
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
const loadProfiles = async (): Promise<void> => {
|
||||
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()
|
||||
void loadAndSetProfiles({ authorPubkeys, setProfiles, setLoading })
|
||||
}, [pubkeysKey, authorPubkeys])
|
||||
|
||||
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 [loading, setLoading] = useState(false)
|
||||
|
||||
const loadDoc = useCallback(async (docId: DocSection): Promise<void> => {
|
||||
const doc = docs.find((d) => d.id === docId)
|
||||
if (!doc) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setSelectedDoc(docId)
|
||||
|
||||
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])
|
||||
const loadDoc = useCallback(
|
||||
createLoadDoc({
|
||||
docs,
|
||||
setLoading,
|
||||
setSelectedDoc,
|
||||
setDocContent,
|
||||
}),
|
||||
[docs]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadDoc('user-guide')
|
||||
@ -60,3 +40,45 @@ export function useDocs(docs: DocLink[]): {
|
||||
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())
|
||||
|
||||
useEffect(() => {
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
// Get saved locale from IndexedDB or use provided locale
|
||||
let savedLocale: Locale | null = null
|
||||
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()
|
||||
void initializeI18n({
|
||||
locale,
|
||||
setLoaded,
|
||||
setCurrentLocale,
|
||||
})
|
||||
}, [locale])
|
||||
|
||||
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 type { NostrConnectState } from '@/types/nostr'
|
||||
|
||||
export function useNostrAuth(): NostrConnectState & {
|
||||
type UseNostrAuthResult = NostrConnectState & {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
connect: () => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
accountExists: boolean | null
|
||||
isUnlocked: boolean
|
||||
} {
|
||||
}
|
||||
|
||||
function useAuthState(): NostrConnectState {
|
||||
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(() => {
|
||||
const unsubscribe = nostrAuthService.subscribe((newState) => {
|
||||
setState(newState)
|
||||
})
|
||||
|
||||
// Check if account exists on mount
|
||||
nostrAuthService.accountExists().then(setAccountExists).catch(() => setAccountExists(false))
|
||||
|
||||
const unsubscribe = nostrAuthService.subscribe(setState)
|
||||
return unsubscribe
|
||||
}, [])
|
||||
return state
|
||||
}
|
||||
|
||||
const connect = async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await nostrAuthService.connect()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Connection failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
function useAccountExistsStatus(): boolean | null {
|
||||
const [accountExists, setAccountExists] = useState<boolean | null>(null)
|
||||
useEffect(() => {
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
setAccountExists(await nostrAuthService.accountExists())
|
||||
} catch {
|
||||
setAccountExists(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
void load()
|
||||
}, [])
|
||||
return accountExists
|
||||
}
|
||||
|
||||
const disconnect = async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
try {
|
||||
nostrAuthService.disconnect()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Disconnection failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
function createAuthActions(params: {
|
||||
setLoading: (next: boolean) => void
|
||||
setError: (next: string | null) => void
|
||||
}): { connect: () => Promise<void>; disconnect: () => Promise<void> } {
|
||||
return {
|
||||
connect: () => connectAuth(params),
|
||||
disconnect: () => disconnectAuth(params),
|
||||
}
|
||||
}
|
||||
|
||||
async function connectAuth(params: {
|
||||
setLoading: (next: boolean) => void
|
||||
setError: (next: string | null) => void
|
||||
}): Promise<void> {
|
||||
params.setLoading(true)
|
||||
params.setError(null)
|
||||
try {
|
||||
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 {
|
||||
...state,
|
||||
|
||||
@ -2,6 +2,9 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { notificationService } 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): {
|
||||
notifications: Notification[]
|
||||
unreadCount: number
|
||||
@ -13,55 +16,28 @@ export function useNotifications(userPubkey: string | null): {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Load stored notifications on mount and refresh periodically
|
||||
useEffect(() => {
|
||||
if (!userPubkey) {
|
||||
setNotifications([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const loadNotifications = async (): Promise<void> => {
|
||||
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
|
||||
void loadAndSetNotifications({ setNotifications, setLoading })
|
||||
const interval = setInterval(() => {
|
||||
void loadNotifications()
|
||||
}, 30000)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
void loadAndSetNotifications({ setNotifications, setLoading })
|
||||
}, POLL_INTERVAL_MS)
|
||||
return () => clearInterval(interval)
|
||||
}, [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(
|
||||
(notificationId: string): void => {
|
||||
if (!userPubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})()
|
||||
void markAsReadAndRefresh({ notificationId, setNotifications })
|
||||
},
|
||||
[userPubkey]
|
||||
)
|
||||
@ -70,16 +46,7 @@ export function useNotifications(userPubkey: string | null): {
|
||||
if (!userPubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})()
|
||||
void markAllAsReadAndRefresh({ setNotifications })
|
||||
}, [userPubkey])
|
||||
|
||||
const deleteNotificationHandler = useCallback(
|
||||
@ -87,26 +54,70 @@ export function useNotifications(userPubkey: string | null): {
|
||||
if (!userPubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})()
|
||||
void deleteNotificationAndRefresh({ notificationId, setNotifications })
|
||||
},
|
||||
[userPubkey]
|
||||
)
|
||||
|
||||
return {
|
||||
notifications,
|
||||
notifications: effectiveNotifications,
|
||||
unreadCount,
|
||||
loading,
|
||||
loading: effectiveLoading,
|
||||
markAsRead,
|
||||
markAllAsRead: markAllAsReadHandler,
|
||||
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 type { Article } from '@/types/nostr'
|
||||
import { applyFiltersAndSort } from '@/lib/articleFiltering'
|
||||
@ -24,86 +24,30 @@ export function useUserArticles(
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const hasArticlesRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!userPubkey) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
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])
|
||||
useLoadUserArticlesFromCache({
|
||||
userPubkey,
|
||||
setArticles,
|
||||
setLoading,
|
||||
setError,
|
||||
hasArticlesRef,
|
||||
})
|
||||
|
||||
// Apply filters and sorting
|
||||
const filteredArticles = useMemo(() => {
|
||||
const effectiveFilters =
|
||||
filters ??
|
||||
({
|
||||
authorPubkey: null,
|
||||
sortBy: 'newest',
|
||||
category: 'all',
|
||||
} as const)
|
||||
|
||||
const effectiveFilters = buildDefaultFilters(filters)
|
||||
if (!filters && !searchQuery.trim()) {
|
||||
return articles
|
||||
}
|
||||
|
||||
return applyFiltersAndSort(articles, searchQuery, effectiveFilters)
|
||||
}, [articles, searchQuery, filters])
|
||||
|
||||
const loadArticleContent = async (articleId: string, authorPubkey: string): Promise<Article | null> => {
|
||||
try {
|
||||
const article = await nostrService.getArticleById(articleId)
|
||||
if (article) {
|
||||
// Try to decrypt article content using decryption key from private messages
|
||||
const decryptedContent = await nostrService.getDecryptedArticleContent(articleId, authorPubkey)
|
||||
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
|
||||
}
|
||||
const loadArticleContent = (articleId: string, authorPubkey: string): Promise<Article | null> =>
|
||||
loadAndDecryptUserArticle({
|
||||
articleId,
|
||||
authorPubkey,
|
||||
setArticles,
|
||||
setError,
|
||||
})
|
||||
|
||||
return {
|
||||
articles: filteredArticles,
|
||||
@ -113,3 +57,86 @@ export function useUserArticles(
|
||||
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[][]> {
|
||||
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 hashId = await generatePublicationHashId({
|
||||
const tags = buildPublicationTags({
|
||||
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,
|
||||
title: params.draft.title,
|
||||
preview: params.draft.preview,
|
||||
category,
|
||||
seriesId: params.draft.seriesId ?? undefined,
|
||||
bannerUrl: params.draft.bannerUrl ?? undefined,
|
||||
category: params.category,
|
||||
zapAmount: params.draft.zapAmount,
|
||||
})
|
||||
...(params.draft.seriesId ? { seriesId: params.draft.seriesId } : {}),
|
||||
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// Build tags using new system
|
||||
const newTags = buildTags({
|
||||
function buildPublicationTags(params: {
|
||||
draft: ArticleDraft
|
||||
invoice: AlbyInvoice
|
||||
authorPubkey: string
|
||||
category: 'sciencefiction' | 'research'
|
||||
hashId: string
|
||||
encryptedKey: string | undefined
|
||||
}): string[][] {
|
||||
return buildTags({
|
||||
type: 'publication',
|
||||
category,
|
||||
id: hashId,
|
||||
category: params.category,
|
||||
id: params.hashId,
|
||||
service: PLATFORM_SERVICE,
|
||||
version: 0, // New object
|
||||
version: 0,
|
||||
hidden: false,
|
||||
paywall: true, // Publications are paid
|
||||
paywall: true,
|
||||
title: params.draft.title,
|
||||
preview: params.draft.preview,
|
||||
zapAmount: params.draft.zapAmount,
|
||||
@ -118,34 +148,31 @@ async function buildPreviewTags(
|
||||
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
|
||||
...(params.encryptedKey ? { encryptedKey: params.encryptedKey } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// Build JSON metadata
|
||||
const publicationJson = JSON.stringify({
|
||||
function buildPublicationJson(params: {
|
||||
draft: ArticleDraft
|
||||
invoice: AlbyInvoice
|
||||
authorPubkey: string
|
||||
category: 'sciencefiction' | 'research'
|
||||
hashId: string
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
type: 'publication',
|
||||
pubkey: params.authorPubkey,
|
||||
title: params.draft.title,
|
||||
preview: params.draft.preview,
|
||||
category,
|
||||
category: params.category,
|
||||
seriesId: params.draft.seriesId,
|
||||
bannerUrl: params.draft.bannerUrl,
|
||||
zapAmount: params.draft.zapAmount,
|
||||
invoice: params.invoice.invoice,
|
||||
paymentHash: params.invoice.paymentHash,
|
||||
id: hashId,
|
||||
id: params.hashId,
|
||||
version: 0,
|
||||
index: 0,
|
||||
...(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' {
|
||||
|
||||
@ -45,14 +45,7 @@ async function buildParsedArticleFromDraft(
|
||||
invoice: AlbyInvoice,
|
||||
authorPubkey: string
|
||||
): Promise<{ article: Article; hash: string; version: number; index: number }> {
|
||||
let category: string
|
||||
if (draft.category === 'science-fiction') {
|
||||
category = 'sciencefiction'
|
||||
} else if (draft.category === 'scientific-research') {
|
||||
category = 'research'
|
||||
} else {
|
||||
category = 'sciencefiction'
|
||||
}
|
||||
const category = mapDraftCategoryToTag(draft.category)
|
||||
|
||||
const hashId = await generatePublicationHashId({
|
||||
pubkey: authorPubkey,
|
||||
@ -96,6 +89,13 @@ async function buildParsedArticleFromDraft(
|
||||
return { article, hash, version, index }
|
||||
}
|
||||
|
||||
function mapDraftCategoryToTag(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' {
|
||||
if (category === 'scientific-research') {
|
||||
return 'research'
|
||||
}
|
||||
return 'sciencefiction'
|
||||
}
|
||||
|
||||
interface PublishPreviewWithInvoiceParams {
|
||||
draft: ArticleDraft
|
||||
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> {
|
||||
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 originalEvent = await objectCache.getEventById('publication', articleId)
|
||||
|
||||
if (!originalEvent) {
|
||||
throw new Error('Article not found in cache')
|
||||
}
|
||||
return originalEvent
|
||||
}
|
||||
|
||||
// Verify user is the author
|
||||
if (originalEvent.pubkey !== authorPubkey) {
|
||||
function assertAuthorOwnsEvent(params: { eventPubkey: string; authorPubkey: string }): void {
|
||||
if (params.eventPubkey !== params.authorPubkey) {
|
||||
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 deleteEventTemplate = await buildDeleteEvent(originalEvent, authorPubkey)
|
||||
|
||||
if (!deleteEventTemplate) {
|
||||
const template = await buildDeleteEvent(params.originalEvent, params.authorPubkey)
|
||||
if (!template) {
|
||||
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 originalParsed = await parseArticleFromEvent(originalEvent)
|
||||
if (!originalParsed) {
|
||||
const parsed = await parseArticleFromEvent(originalEvent)
|
||||
if (!parsed) {
|
||||
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 tags = extractTagsFromEvent(deleteEventTemplate)
|
||||
const newVersion = tags.version ?? originalParsed.version + 1
|
||||
const {hash} = originalParsed
|
||||
const index = originalParsed.index ?? 0
|
||||
const tags = extractTagsFromEvent(params.deleteEventTemplate)
|
||||
const version = tags.version ?? params.originalParsed.version + 1
|
||||
const index = params.originalParsed.index ?? 0
|
||||
const parsed: Article = { ...params.originalParsed, version }
|
||||
return { hash: params.originalParsed.hash, index, version, parsed }
|
||||
}
|
||||
|
||||
// Build updated parsed Article object with hidden flag
|
||||
const deletedArticle: Article = {
|
||||
...originalParsed,
|
||||
version: newVersion,
|
||||
}
|
||||
|
||||
// Set private key in orchestrator
|
||||
const privateKey = authorPrivateKey ?? nostrService.getPrivateKey()
|
||||
async function finalizeEventTemplate(params: {
|
||||
template: import('nostr-tools').EventTemplate
|
||||
authorPrivateKey: string | undefined
|
||||
}): Promise<import('nostr-tools').Event> {
|
||||
const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey()
|
||||
if (!privateKey) {
|
||||
throw new Error('Private key required for signing')
|
||||
}
|
||||
const { writeOrchestrator: writeOrchestratorInstance } = await import('./writeOrchestrator')
|
||||
writeOrchestratorInstance.setPrivateKey(privateKey)
|
||||
|
||||
// Finalize event
|
||||
const { finalizeEvent: finalizeNostrEvent } = await import('nostr-tools')
|
||||
const { hexToBytes: hexToBytesUtil } = await import('nostr-tools/utils')
|
||||
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 activeRelays = await relaySessionManager.getActiveRelays()
|
||||
if (activeRelays.length > 0) {
|
||||
return activeRelays
|
||||
}
|
||||
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(
|
||||
{
|
||||
objectType: 'publication',
|
||||
hash,
|
||||
event,
|
||||
parsed: deletedArticle,
|
||||
version: newVersion,
|
||||
hidden: true, // Mark as hidden (deleted)
|
||||
index,
|
||||
hash: params.payload.hash,
|
||||
event: params.event,
|
||||
parsed: params.payload.parsed,
|
||||
version: params.payload.version,
|
||||
hidden: true,
|
||||
index: params.payload.index,
|
||||
},
|
||||
relays
|
||||
params.relays
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to publish delete event')
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import { finalizeEvent } from 'nostr-tools'
|
||||
import { hexToBytes } from 'nostr-tools/utils'
|
||||
import { generateAuthorHashId } from './hashIdGenerator'
|
||||
import { buildObjectId } from './urlGenerator'
|
||||
import { extractAuthorNameFromTitle, parseAuthorPresentationDraft } from './authorPresentationParsing'
|
||||
|
||||
export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
|
||||
|
||||
@ -179,108 +180,7 @@ export class ArticlePublisher {
|
||||
authorPrivateKey: string
|
||||
): Promise<PublishedArticle> {
|
||||
try {
|
||||
nostrService.setPublicKey(authorPubkey)
|
||||
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,
|
||||
}
|
||||
return publishPresentationArticleCore({ draft, authorPubkey, authorPrivateKey })
|
||||
} catch (error) {
|
||||
console.error('Error publishing presentation article:', error)
|
||||
return buildFailure(error instanceof Error ? error.message : 'Unknown error')
|
||||
@ -309,3 +209,120 @@ export class 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 { getLatestVersion } from './versionManager'
|
||||
import { objectCache } from './objectCache'
|
||||
import { parseAuthorPresentationDraft } from './authorPresentationParsing'
|
||||
|
||||
interface BuildPresentationEventParams {
|
||||
draft: AuthorPresentationDraft
|
||||
@ -31,22 +32,8 @@ export async function buildPresentationEvent(
|
||||
const category = params.category ?? 'sciencefiction'
|
||||
const version = params.version ?? 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)
|
||||
// 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()
|
||||
}
|
||||
const { presentation, contentDescription } = parseAuthorPresentationDraft(params.draft)
|
||||
|
||||
// Generate hash ID from author data first (needed for URL)
|
||||
const hashId = await generateAuthorHashId({
|
||||
@ -65,13 +52,11 @@ export async function buildPresentationEvent(
|
||||
// Encode pubkey to npub (for metadata JSON)
|
||||
const npub = nip19.npubEncode(params.authorPubkey)
|
||||
|
||||
// Build visible content message
|
||||
// If picture exists, use it as preview image for the link (markdown format)
|
||||
// Note: The image will display at full size in most Nostr clients, not as a thumbnail
|
||||
const {draft} = params
|
||||
const linkWithPreview = draft.pictureUrl
|
||||
? `[](${profileUrl})`
|
||||
: profileUrl
|
||||
const linkWithPreview = buildProfileLink({
|
||||
profileUrl,
|
||||
authorName: params.authorName,
|
||||
pictureUrl: params.draft.pictureUrl,
|
||||
})
|
||||
|
||||
const visibleContent = [
|
||||
'Nouveau profil auteur publié sur zapwall.fr (plateforme de publications scientifiques)',
|
||||
@ -87,8 +72,8 @@ export async function buildPresentationEvent(
|
||||
pubkey: params.authorPubkey,
|
||||
presentation,
|
||||
contentDescription,
|
||||
mainnetAddress: draft.mainnetAddress,
|
||||
pictureUrl: draft.pictureUrl,
|
||||
mainnetAddress: params.draft.mainnetAddress,
|
||||
pictureUrl: params.draft.pictureUrl,
|
||||
category,
|
||||
url: profileUrl,
|
||||
version,
|
||||
@ -104,10 +89,10 @@ export async function buildPresentationEvent(
|
||||
version,
|
||||
hidden: false,
|
||||
paywall: false,
|
||||
title: draft.title,
|
||||
preview: draft.preview,
|
||||
mainnetAddress: draft.mainnetAddress,
|
||||
...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
|
||||
title: params.draft.title,
|
||||
preview: params.draft.preview,
|
||||
mainnetAddress: params.draft.mainnetAddress,
|
||||
...(params.draft.pictureUrl ? { pictureUrl: params.draft.pictureUrl } : {}),
|
||||
})
|
||||
|
||||
// Add JSON metadata as a tag (not in visible content)
|
||||
@ -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> {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
|
||||
@ -383,41 +375,36 @@ function parsePresentationProfileJson(json: string): {
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>
|
||||
const result: {
|
||||
authorName?: string
|
||||
presentation?: string
|
||||
contentDescription?: string
|
||||
mainnetAddress?: string
|
||||
pictureUrl?: string
|
||||
category?: string
|
||||
} = {}
|
||||
|
||||
if (typeof obj.authorName === 'string') {
|
||||
result.authorName = obj.authorName
|
||||
return {
|
||||
...readOptionalStringFields(obj, [
|
||||
'authorName',
|
||||
'presentation',
|
||||
'contentDescription',
|
||||
'mainnetAddress',
|
||||
'pictureUrl',
|
||||
'category',
|
||||
]),
|
||||
}
|
||||
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) {
|
||||
console.error('Error parsing presentation profile JSON:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readOptionalStringFields<TKeys extends readonly string[]>(
|
||||
obj: Record<string, unknown>,
|
||||
keys: TKeys
|
||||
): Partial<Record<TKeys[number], string>> {
|
||||
const result: Partial<Record<TKeys[number], string>> = {}
|
||||
for (const key of keys) {
|
||||
const value = obj[key]
|
||||
if (typeof value === 'string') {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function fetchAuthorPresentationFromPool(
|
||||
pool: SimplePoolWithSub,
|
||||
pubkey: string
|
||||
|
||||
@ -26,14 +26,7 @@ async function buildParsedArticleFromDraft(
|
||||
invoice: AlbyInvoice,
|
||||
authorPubkey: string
|
||||
): Promise<{ article: Article; hash: string; version: number; index: number }> {
|
||||
let category: string
|
||||
if (draft.category === 'science-fiction') {
|
||||
category = 'sciencefiction'
|
||||
} else if (draft.category === 'scientific-research') {
|
||||
category = 'research'
|
||||
} else {
|
||||
category = 'sciencefiction'
|
||||
}
|
||||
const category = mapDraftCategoryToTag(draft.category)
|
||||
|
||||
const hashId = await generatePublicationHashId({
|
||||
pubkey: authorPubkey,
|
||||
@ -77,6 +70,13 @@ async function buildParsedArticleFromDraft(
|
||||
return { article, hash, version, index }
|
||||
}
|
||||
|
||||
function mapDraftCategoryToTag(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' {
|
||||
if (category === 'scientific-research') {
|
||||
return 'research'
|
||||
}
|
||||
return 'sciencefiction'
|
||||
}
|
||||
|
||||
interface PublishPreviewParams {
|
||||
draft: ArticleDraft
|
||||
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,
|
||||
paymentAmount: number
|
||||
): Promise<TransferResult> {
|
||||
try {
|
||||
const split = calculateArticleSplit(paymentAmount)
|
||||
|
||||
if (!authorLightningAddress) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Author Lightning address not available',
|
||||
amount: split.author,
|
||||
recipient: authorLightningAddress,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return this.transferPortion({
|
||||
type: 'article',
|
||||
id: articleId,
|
||||
pubkey: articlePubkey,
|
||||
recipient: authorLightningAddress,
|
||||
paymentAmount,
|
||||
computeSplit: calculateArticleSplit,
|
||||
getRecipientAmount: (split) => split.author,
|
||||
missingRecipientError: 'Author Lightning address not available',
|
||||
errorLogMessage: 'Error transferring author portion',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@ -110,50 +79,96 @@ export class AutomaticTransferService {
|
||||
reviewerPubkey: string,
|
||||
paymentAmount: number
|
||||
): Promise<TransferResult> {
|
||||
try {
|
||||
const split = calculateReviewSplit(paymentAmount)
|
||||
return this.transferPortion({
|
||||
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) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Reviewer Lightning address not available',
|
||||
amount: split.reviewer,
|
||||
recipient: reviewerLightningAddress,
|
||||
}
|
||||
private async transferPortion(params: {
|
||||
type: 'article' | 'review'
|
||||
id: string
|
||||
pubkey: string
|
||||
recipient: string
|
||||
paymentAmount: number
|
||||
computeSplit: (amount: number) => { platform: number } & Record<string, number>
|
||||
getRecipientAmount: (split: { platform: number } & Record<string, number>) => number
|
||||
missingRecipientError: string
|
||||
errorLogMessage: string
|
||||
}): Promise<TransferResult> {
|
||||
try {
|
||||
const split = params.computeSplit(params.paymentAmount)
|
||||
const recipientAmount = params.getRecipientAmount(split)
|
||||
|
||||
if (!params.recipient) {
|
||||
return this.buildMissingRecipientResult({
|
||||
error: params.missingRecipientError,
|
||||
recipient: params.recipient,
|
||||
amount: recipientAmount,
|
||||
})
|
||||
}
|
||||
|
||||
this.logTransferRequired({
|
||||
type: 'review',
|
||||
id: reviewId,
|
||||
pubkey: reviewerPubkey,
|
||||
amount: split.reviewer,
|
||||
recipient: reviewerLightningAddress,
|
||||
this.logAndTrackTransferRequirement({
|
||||
type: params.type,
|
||||
id: params.id,
|
||||
pubkey: params.pubkey,
|
||||
recipient: params.recipient,
|
||||
amount: recipientAmount,
|
||||
platformCommission: split.platform,
|
||||
})
|
||||
this.trackTransferRequirement({
|
||||
type: 'review',
|
||||
id: reviewId,
|
||||
recipientPubkey: reviewerPubkey,
|
||||
amount: split.reviewer,
|
||||
recipientAddress: reviewerLightningAddress,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
amount: split.reviewer,
|
||||
recipient: reviewerLightningAddress,
|
||||
}
|
||||
return { success: true, amount: recipientAmount, recipient: params.recipient }
|
||||
} catch (error) {
|
||||
console.error('Error transferring reviewer portion', {
|
||||
reviewId,
|
||||
reviewerPubkey,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return this.buildTransferError(error, reviewerLightningAddress)
|
||||
this.logTransferError({ message: params.errorLogMessage, id: params.id, pubkey: params.pubkey, error })
|
||||
return this.buildTransferError(error, params.recipient)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* 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
|
||||
*/
|
||||
async getConfig(): Promise<ConfigData> {
|
||||
await this.init()
|
||||
const {db} = this
|
||||
if (!db) {
|
||||
return this.getDefaultConfig()
|
||||
}
|
||||
|
||||
try {
|
||||
await this.init()
|
||||
|
||||
if (!this.db) {
|
||||
return this.getDefaultConfig()
|
||||
const stored = await this.readStoredConfig(db)
|
||||
if (!stored) {
|
||||
const defaults = this.getDefaultConfig()
|
||||
await this.saveConfig(defaults)
|
||||
return defaults
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
})
|
||||
return await this.migrateConfigIfNeeded(stored)
|
||||
} catch (error) {
|
||||
console.error('Error getting config from IndexedDB:', error)
|
||||
return this.getDefaultConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private readStoredConfig(db: IDBDatabase): Promise<ConfigData | null> {
|
||||
return new Promise((resolve) => {
|
||||
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
|
||||
*/
|
||||
|
||||
@ -33,95 +33,121 @@ export interface SyncSubscriptionResult {
|
||||
export async function createSyncSubscription(
|
||||
config: SyncSubscriptionConfig
|
||||
): Promise<SyncSubscriptionResult> {
|
||||
const { pool, filters, onEvent, onComplete, timeout = 10000, updateProgress, eventFilter } = config
|
||||
|
||||
const events: Event[] = []
|
||||
let sub: ReturnType<typeof createSubscription> | null = null
|
||||
let usedRelayUrl = ''
|
||||
|
||||
// Try relays with rotation
|
||||
try {
|
||||
const result = await tryWithRelayRotation(
|
||||
pool as unknown as SimplePool,
|
||||
async (relayUrl, poolWithSub) => {
|
||||
usedRelayUrl = relayUrl
|
||||
|
||||
// Update progress if callback provided
|
||||
if (updateProgress) {
|
||||
updateProgress(relayUrl)
|
||||
} else {
|
||||
// Default: notify progress manager
|
||||
const { syncProgressManager } = await import('../syncProgressManager')
|
||||
const currentProgress = syncProgressManager.getProgress()
|
||||
if (currentProgress) {
|
||||
syncProgressManager.setProgress({
|
||||
...currentProgress,
|
||||
currentStep: 0,
|
||||
currentRelay: relayUrl,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return createSubscription(poolWithSub, [relayUrl], filters)
|
||||
},
|
||||
5000 // 5 second timeout per relay
|
||||
)
|
||||
sub = result
|
||||
} catch {
|
||||
// Fallback to primary relay if rotation fails
|
||||
usedRelayUrl = getPrimaryRelaySync()
|
||||
sub = createSubscription(pool, [usedRelayUrl], filters)
|
||||
}
|
||||
|
||||
if (!sub) {
|
||||
throw new Error('Failed to create subscription')
|
||||
}
|
||||
|
||||
return new Promise<SyncSubscriptionResult>((resolve) => {
|
||||
let finished = false
|
||||
|
||||
const done = async (): Promise<void> => {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
sub?.unsub()
|
||||
|
||||
// Call onComplete callback if provided
|
||||
if (onComplete) {
|
||||
await onComplete(events)
|
||||
}
|
||||
|
||||
resolve({
|
||||
subscription: sub,
|
||||
relayUrl: usedRelayUrl,
|
||||
events,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle events
|
||||
sub.on('event', (event: Event): void => {
|
||||
// Apply event filter if provided
|
||||
if (eventFilter && !eventFilter(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
events.push(event)
|
||||
|
||||
// Call onEvent callback if provided
|
||||
if (onEvent) {
|
||||
void onEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle end of stream
|
||||
sub.on('eose', (): void => {
|
||||
void done()
|
||||
})
|
||||
|
||||
// Timeout fallback
|
||||
setTimeout((): void => {
|
||||
void done()
|
||||
}, timeout).unref?.()
|
||||
const timeout = config.timeout ?? 10000
|
||||
const { subscription, relayUrl } = await createSubscriptionWithRelayRotation({
|
||||
pool: config.pool,
|
||||
filters: config.filters,
|
||||
updateProgress: config.updateProgress,
|
||||
})
|
||||
return collectSubscriptionEvents({
|
||||
subscription,
|
||||
relayUrl,
|
||||
timeout,
|
||||
onEvent: config.onEvent,
|
||||
onComplete: config.onComplete,
|
||||
eventFilter: config.eventFilter,
|
||||
})
|
||||
}
|
||||
|
||||
async function createSubscriptionWithRelayRotation(params: {
|
||||
pool: SimplePoolWithSub
|
||||
filters: Filter[]
|
||||
updateProgress: ((relayUrl: string) => void) | undefined
|
||||
}): Promise<{ subscription: ReturnType<typeof createSubscription>; relayUrl: string }> {
|
||||
try {
|
||||
let usedRelayUrl = ''
|
||||
const subscription = await tryWithRelayRotation(
|
||||
params.pool as unknown as SimplePool,
|
||||
async (relayUrl, poolWithSub) => {
|
||||
usedRelayUrl = relayUrl
|
||||
await updateSyncProgress(relayUrl, params.updateProgress)
|
||||
return createSubscription(poolWithSub, [relayUrl], params.filters)
|
||||
},
|
||||
5000
|
||||
)
|
||||
if (!usedRelayUrl) {
|
||||
throw new Error('Relay rotation did not return a relay URL')
|
||||
}
|
||||
return { subscription, relayUrl: usedRelayUrl }
|
||||
} catch (error) {
|
||||
console.warn('[createSyncSubscription] Relay rotation failed, falling back to primary relay:', error)
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
return { subscription: createSubscription(params.pool, [relayUrl], params.filters), relayUrl }
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSyncProgress(relayUrl: string, updateProgress: ((relayUrl: string) => void) | undefined): Promise<void> {
|
||||
if (updateProgress) {
|
||||
updateProgress(relayUrl)
|
||||
return
|
||||
}
|
||||
const { syncProgressManager } = await import('../syncProgressManager')
|
||||
const currentProgress = syncProgressManager.getProgress()
|
||||
if (!currentProgress) {
|
||||
return
|
||||
}
|
||||
syncProgressManager.setProgress({
|
||||
...currentProgress,
|
||||
currentStep: 0,
|
||||
currentRelay: relayUrl,
|
||||
})
|
||||
}
|
||||
|
||||
async function collectSubscriptionEvents(params: {
|
||||
subscription: ReturnType<typeof createSubscription>
|
||||
relayUrl: string
|
||||
timeout: number
|
||||
onEvent: ((event: Event) => void | Promise<void>) | undefined
|
||||
onComplete: ((events: Event[]) => void | Promise<void>) | undefined
|
||||
eventFilter: ((event: Event) => boolean) | undefined
|
||||
}): Promise<SyncSubscriptionResult> {
|
||||
const events: Event[] = []
|
||||
return new Promise<SyncSubscriptionResult>((resolve) => {
|
||||
const finalize = createFinalizeHandler({ subscription: params.subscription, relayUrl: params.relayUrl, events, resolve, onComplete: params.onComplete })
|
||||
registerSubscriptionEventHandlers({ subscription: params.subscription, events, onEvent: params.onEvent, eventFilter: params.eventFilter, finalize })
|
||||
const timeoutId = setTimeout((): void => void finalize(), params.timeout)
|
||||
timeoutId.unref?.()
|
||||
})
|
||||
}
|
||||
|
||||
function createFinalizeHandler(params: {
|
||||
subscription: ReturnType<typeof createSubscription>
|
||||
relayUrl: string
|
||||
events: Event[]
|
||||
resolve: (value: SyncSubscriptionResult) => void
|
||||
onComplete: ((events: Event[]) => void | Promise<void>) | undefined
|
||||
}): () => Promise<void> {
|
||||
let finished = false
|
||||
return async (): Promise<void> => {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Try to extract from tag first (new format)
|
||||
let metadata = extractMetadataJsonFromTag(event)
|
||||
|
||||
// Fallback to content format (for backward compatibility)
|
||||
metadata ??= extractMetadataJson(event.content)
|
||||
|
||||
const metadata = getMetadataFromEvent(event)
|
||||
if (metadata?.type === 'author') {
|
||||
const authorData = {
|
||||
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 authorData = buildAuthorDataFromMetadata({ event, tags, metadata })
|
||||
const id = await generateAuthorHashId(authorData)
|
||||
|
||||
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 } : {}),
|
||||
}
|
||||
return buildExtractedAuthor({ eventId: event.id, id, data: authorData, metadata })
|
||||
}
|
||||
|
||||
// Fallback: extract from tags and visible content
|
||||
@ -199,6 +172,72 @@ export async function extractAuthorFromEvent(event: Event): Promise<ExtractedAut
|
||||
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
|
||||
*/
|
||||
@ -208,35 +247,11 @@ export async function extractSeriesFromEvent(event: Event): Promise<ExtractedSer
|
||||
return null
|
||||
}
|
||||
|
||||
// Try to extract from tag first (new format)
|
||||
let metadata = extractMetadataJsonFromTag(event)
|
||||
|
||||
// Fallback to content format (for backward compatibility)
|
||||
metadata ??= extractMetadataJson(event.content)
|
||||
|
||||
const metadata = getMetadataFromEvent(event)
|
||||
if (metadata?.type === 'series') {
|
||||
const seriesData = {
|
||||
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 seriesData = buildSeriesDataFromMetadata({ event, tags, metadata })
|
||||
const id = await generateSeriesHashId(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 } : {}),
|
||||
}
|
||||
return buildExtractedSeries({ eventId: event.id, id, data: seriesData })
|
||||
}
|
||||
|
||||
// Fallback: extract from tags
|
||||
@ -246,23 +261,15 @@ export async function extractSeriesFromEvent(event: Event): Promise<ExtractedSer
|
||||
title: tags.title,
|
||||
description: tags.description,
|
||||
preview: (tags.preview as string) ?? event.content.substring(0, 200),
|
||||
coverUrl: tags.coverUrl,
|
||||
category: tags.category ?? 'sciencefiction',
|
||||
}
|
||||
|
||||
const id = await generateSeriesHashId(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 } : {}),
|
||||
const seriesDataWithOptionals = {
|
||||
...seriesData,
|
||||
...(tags.coverUrl ? { coverUrl: tags.coverUrl } : {}),
|
||||
}
|
||||
|
||||
const id = await generateSeriesHashId(seriesDataWithOptionals)
|
||||
return buildExtractedSeries({ eventId: event.id, id, data: seriesDataWithOptionals })
|
||||
}
|
||||
|
||||
return null
|
||||
@ -277,41 +284,12 @@ export async function extractPublicationFromEvent(event: Event): Promise<Extract
|
||||
return null
|
||||
}
|
||||
|
||||
// Try to extract from tag first (new format)
|
||||
let metadata = extractMetadataJsonFromTag(event)
|
||||
|
||||
// Fallback to content format (for backward compatibility)
|
||||
metadata ??= extractMetadataJson(event.content)
|
||||
|
||||
const metadata = getMetadataFromEvent(event)
|
||||
if (metadata?.type === 'publication') {
|
||||
const publicationData = {
|
||||
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 publicationData = buildPublicationDataFromMetadata({ event, tags, metadata })
|
||||
const id = await generatePublicationHashId(publicationData)
|
||||
|
||||
// Extract pages from metadata if present
|
||||
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 } : {}),
|
||||
}
|
||||
const pages = readPublicationPages(metadata)
|
||||
return buildExtractedPublication({ eventId: event.id, id, data: publicationData, pages })
|
||||
}
|
||||
|
||||
// Fallback: extract from tags
|
||||
@ -321,25 +299,16 @@ export async function extractPublicationFromEvent(event: Event): Promise<Extract
|
||||
title: tags.title,
|
||||
preview: (tags.preview as string) ?? event.content.substring(0, 200),
|
||||
category: tags.category ?? 'sciencefiction',
|
||||
seriesId: tags.seriesId,
|
||||
bannerUrl: tags.bannerUrl,
|
||||
zapAmount: tags.zapAmount ?? 800,
|
||||
}
|
||||
|
||||
const id = await generatePublicationHashId(publicationData)
|
||||
|
||||
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 } : {}),
|
||||
const publicationDataWithOptionals = {
|
||||
...publicationData,
|
||||
...(tags.seriesId ? { seriesId: tags.seriesId } : {}),
|
||||
...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl } : {}),
|
||||
}
|
||||
|
||||
const id = await generatePublicationHashId(publicationDataWithOptionals)
|
||||
return buildExtractedPublication({ eventId: event.id, id, data: publicationDataWithOptionals, pages: undefined })
|
||||
}
|
||||
|
||||
return null
|
||||
@ -354,217 +323,402 @@ export async function extractReviewFromEvent(event: Event): Promise<ExtractedRev
|
||||
return null
|
||||
}
|
||||
|
||||
// Try to extract from tag first (new format)
|
||||
let metadata = extractMetadataJsonFromTag(event)
|
||||
|
||||
// Fallback to content format (for backward compatibility)
|
||||
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,
|
||||
}
|
||||
const metadata = getMetadataFromEvent(event)
|
||||
const fromMetadata = await extractReviewFromMetadata({ event, tags, metadata })
|
||||
if (fromMetadata) {
|
||||
return fromMetadata
|
||||
}
|
||||
|
||||
// Fallback: extract from tags
|
||||
if (tags.articleId && tags.reviewerPubkey) {
|
||||
const reviewData = {
|
||||
pubkey: event.pubkey,
|
||||
articleId: tags.articleId,
|
||||
reviewerPubkey: tags.reviewerPubkey,
|
||||
content: event.content,
|
||||
title: tags.title,
|
||||
}
|
||||
return extractReviewFromTags({ event, tags })
|
||||
}
|
||||
|
||||
const id = await generateReviewHashId({
|
||||
pubkey: reviewData.pubkey,
|
||||
articleId: reviewData.articleId,
|
||||
reviewerPubkey: reviewData.reviewerPubkey,
|
||||
content: reviewData.content,
|
||||
...(reviewData.title ? { title: reviewData.title } : {}),
|
||||
})
|
||||
async function extractReviewFromMetadata(params: {
|
||||
event: Event
|
||||
tags: ReturnType<typeof extractTagsFromEvent>
|
||||
metadata: Record<string, unknown> | null
|
||||
}): Promise<ExtractedReview | null> {
|
||||
if (params.metadata?.type !== 'review') {
|
||||
return null
|
||||
}
|
||||
const reviewData = buildReviewDataFromMetadata({ event: params.event, tags: params.tags, metadata: params.metadata })
|
||||
if (!reviewData) {
|
||||
return null
|
||||
}
|
||||
const id = await generateReviewHashId(reviewData)
|
||||
return { type: 'review', id, ...reviewData, eventId: params.event.id }
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'review',
|
||||
id,
|
||||
pubkey: reviewData.pubkey,
|
||||
articleId: reviewData.articleId,
|
||||
reviewerPubkey: reviewData.reviewerPubkey,
|
||||
content: reviewData.content,
|
||||
eventId: event.id,
|
||||
...(reviewData.title ? { title: reviewData.title } : {}),
|
||||
}
|
||||
async function extractReviewFromTags(params: {
|
||||
event: Event
|
||||
tags: ReturnType<typeof extractTagsFromEvent>
|
||||
}): Promise<ExtractedReview | null> {
|
||||
if (!params.tags.articleId || !params.tags.reviewerPubkey) {
|
||||
return null
|
||||
}
|
||||
const base = {
|
||||
pubkey: params.event.pubkey,
|
||||
articleId: params.tags.articleId,
|
||||
reviewerPubkey: params.tags.reviewerPubkey,
|
||||
content: params.event.content,
|
||||
...(params.tags.title ? { title: params.tags.title } : {}),
|
||||
}
|
||||
return buildExtractedReviewFromTags({ ...base, eventId: params.event.id })
|
||||
}
|
||||
|
||||
async function buildExtractedReviewFromTags(params: {
|
||||
pubkey: string
|
||||
articleId: string
|
||||
reviewerPubkey: string
|
||||
content: string
|
||||
title?: string
|
||||
eventId: string
|
||||
}): Promise<ExtractedReview> {
|
||||
const id = await generateReviewHashId({
|
||||
pubkey: params.pubkey,
|
||||
articleId: params.articleId,
|
||||
reviewerPubkey: params.reviewerPubkey,
|
||||
content: params.content,
|
||||
...(params.title ? { title: params.title } : {}),
|
||||
})
|
||||
return {
|
||||
type: 'review',
|
||||
id,
|
||||
pubkey: params.pubkey,
|
||||
articleId: params.articleId,
|
||||
reviewerPubkey: params.reviewerPubkey,
|
||||
content: params.content,
|
||||
eventId: params.eventId,
|
||||
...(params.title ? { title: params.title } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function getMetadataFromEvent(event: Event): Record<string, unknown> | null {
|
||||
return extractMetadataJsonFromTag(event) ?? extractMetadataJson(event.content)
|
||||
}
|
||||
|
||||
function buildSeriesDataFromMetadata(params: {
|
||||
event: Event
|
||||
tags: ReturnType<typeof extractTagsFromEvent>
|
||||
metadata: Record<string, unknown>
|
||||
}): { pubkey: string; title: string; description: string; preview: string; coverUrl?: string; category: string } {
|
||||
const title = firstString(params.metadata.title, params.tags.title) ?? ''
|
||||
const preview = firstString(params.metadata.preview, params.tags.preview) ?? params.event.content.substring(0, 200)
|
||||
const pubkey = firstString(params.metadata.pubkey, params.event.pubkey) ?? params.event.pubkey
|
||||
const coverUrl = firstString(params.metadata.coverUrl, params.tags.coverUrl)
|
||||
|
||||
const result: { pubkey: string; title: string; description: string; preview: string; coverUrl?: string; category: string } = {
|
||||
pubkey,
|
||||
title,
|
||||
description: firstString(params.metadata.description) ?? '',
|
||||
preview,
|
||||
category: firstString(params.metadata.category, params.tags.category, 'sciencefiction') ?? 'sciencefiction',
|
||||
}
|
||||
if (coverUrl) {
|
||||
result.coverUrl = coverUrl
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function buildExtractedSeries(params: {
|
||||
eventId: string
|
||||
id: string
|
||||
data: { pubkey: string; title: string; description: string; preview: string; coverUrl?: string; category: string }
|
||||
}): ExtractedSeries {
|
||||
return {
|
||||
type: 'series',
|
||||
id: params.id,
|
||||
pubkey: params.data.pubkey,
|
||||
title: params.data.title,
|
||||
description: params.data.description,
|
||||
category: params.data.category,
|
||||
eventId: params.eventId,
|
||||
...(params.data.coverUrl ? { coverUrl: params.data.coverUrl } : {}),
|
||||
...(params.data.preview ? { preview: params.data.preview } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function buildPublicationDataFromMetadata(params: {
|
||||
event: Event
|
||||
tags: ReturnType<typeof extractTagsFromEvent>
|
||||
metadata: Record<string, unknown>
|
||||
}): {
|
||||
pubkey: string
|
||||
title: string
|
||||
preview: string
|
||||
category: string
|
||||
seriesId?: string
|
||||
bannerUrl?: string
|
||||
zapAmount: number
|
||||
} {
|
||||
const result: {
|
||||
pubkey: string
|
||||
title: string
|
||||
preview: string
|
||||
category: string
|
||||
seriesId?: string
|
||||
bannerUrl?: string
|
||||
zapAmount: number
|
||||
} = {
|
||||
pubkey: firstString(params.metadata.pubkey, params.event.pubkey) ?? params.event.pubkey,
|
||||
title: firstString(params.metadata.title, params.tags.title) ?? '',
|
||||
preview: firstString(params.metadata.preview, params.tags.preview) ?? params.event.content.substring(0, 200),
|
||||
category: firstString(params.metadata.category, params.tags.category, 'sciencefiction') ?? 'sciencefiction',
|
||||
zapAmount: firstNumber(params.metadata.zapAmount, params.tags.zapAmount) ?? 800,
|
||||
}
|
||||
|
||||
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)
|
||||
*/
|
||||
export async function extractPurchaseFromEvent(event: Event): Promise<ExtractedPurchase | null> {
|
||||
if (event.kind !== 9735) {
|
||||
const kind = readZapReceiptKind(event)
|
||||
if (kind !== 'purchase') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check for purchase kind_type tag
|
||||
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'purchase')
|
||||
if (!kindTypeTag) {
|
||||
const authorPubkey = readTagValue(event, 'p')
|
||||
const articleId = readTagValue(event, 'e')
|
||||
const amountSats = readAmountSats(event)
|
||||
if (!authorPubkey || !articleId || amountSats === undefined) {
|
||||
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 = {
|
||||
payerPubkey: event.pubkey,
|
||||
articleId: eTag,
|
||||
authorPubkey: pTag,
|
||||
amount,
|
||||
paymentHash,
|
||||
articleId,
|
||||
authorPubkey,
|
||||
amount: amountSats,
|
||||
paymentHash: readPaymentHash(event),
|
||||
}
|
||||
|
||||
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)
|
||||
*/
|
||||
export async function extractReviewTipFromEvent(event: Event): Promise<ExtractedReviewTip | null> {
|
||||
if (event.kind !== 9735) {
|
||||
const kind = readZapReceiptKind(event)
|
||||
if (kind !== 'review_tip') {
|
||||
return null
|
||||
}
|
||||
|
||||
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'review_tip')
|
||||
if (!kindTypeTag) {
|
||||
const authorPubkey = readTagValue(event, 'p')
|
||||
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
|
||||
}
|
||||
|
||||
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 = {
|
||||
payerPubkey: event.pubkey,
|
||||
articleId: eTag,
|
||||
reviewId: reviewIdTag,
|
||||
reviewerPubkey: reviewerTag,
|
||||
authorPubkey: pTag,
|
||||
amount,
|
||||
paymentHash,
|
||||
articleId,
|
||||
reviewId,
|
||||
reviewerPubkey,
|
||||
authorPubkey,
|
||||
amount: amountSats,
|
||||
paymentHash: readPaymentHash(event),
|
||||
}
|
||||
|
||||
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)
|
||||
*/
|
||||
export async function extractSponsoringFromEvent(event: Event): Promise<ExtractedSponsoring | null> {
|
||||
if (event.kind !== 9735) {
|
||||
const kind = readZapReceiptKind(event)
|
||||
if (kind !== 'sponsoring') {
|
||||
return null
|
||||
}
|
||||
|
||||
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'sponsoring')
|
||||
if (!kindTypeTag) {
|
||||
const authorPubkey = readTagValue(event, 'p')
|
||||
const amountSats = readAmountSats(event)
|
||||
if (!authorPubkey || amountSats === undefined) {
|
||||
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 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]
|
||||
const sponsoringData = buildSponsoringData({ event, authorPubkey, amountSats })
|
||||
const id = await generateSponsoringHashId(buildSponsoringHashInput(sponsoringData))
|
||||
return buildExtractedSponsoring({ id, eventId: event.id, sponsoringData })
|
||||
}
|
||||
|
||||
if (!pTag || !amountTag) {
|
||||
return null
|
||||
function buildSponsoringData(params: { event: Event; authorPubkey: string; amountSats: number }): {
|
||||
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
|
||||
const paymentHash = paymentHashTag ?? event.id
|
||||
|
||||
const sponsoringData = {
|
||||
payerPubkey: event.pubkey,
|
||||
authorPubkey: pTag,
|
||||
seriesId: seriesTag,
|
||||
articleId: articleTag ?? eTag, // Use eTag as fallback for articleId
|
||||
amount,
|
||||
paymentHash,
|
||||
function buildSponsoringHashInput(params: {
|
||||
payerPubkey: string
|
||||
authorPubkey: string
|
||||
seriesId: string | undefined
|
||||
articleId: string | undefined
|
||||
amount: number
|
||||
paymentHash: string
|
||||
}): Parameters<typeof generateSponsoringHashId>[0] {
|
||||
return {
|
||||
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({
|
||||
payerPubkey: sponsoringData.payerPubkey,
|
||||
authorPubkey: sponsoringData.authorPubkey,
|
||||
amount: sponsoringData.amount,
|
||||
paymentHash: sponsoringData.paymentHash,
|
||||
...(sponsoringData.seriesId ? { seriesId: sponsoringData.seriesId } : {}),
|
||||
...(sponsoringData.articleId ? { articleId: sponsoringData.articleId } : {}),
|
||||
})
|
||||
|
||||
function buildExtractedSponsoring(params: {
|
||||
id: string
|
||||
eventId: string
|
||||
sponsoringData: {
|
||||
payerPubkey: string
|
||||
authorPubkey: string
|
||||
seriesId: string | undefined
|
||||
articleId: string | undefined
|
||||
amount: number
|
||||
paymentHash: string
|
||||
}
|
||||
}): ExtractedSponsoring {
|
||||
return {
|
||||
type: 'sponsoring',
|
||||
id,
|
||||
payerPubkey: sponsoringData.payerPubkey,
|
||||
authorPubkey: sponsoringData.authorPubkey,
|
||||
amount: sponsoringData.amount,
|
||||
paymentHash: sponsoringData.paymentHash,
|
||||
eventId: event.id,
|
||||
...(sponsoringData.seriesId ? { seriesId: sponsoringData.seriesId } : {}),
|
||||
...(sponsoringData.articleId ? { articleId: sponsoringData.articleId } : {}),
|
||||
id: params.id,
|
||||
payerPubkey: params.sponsoringData.payerPubkey,
|
||||
authorPubkey: params.sponsoringData.authorPubkey,
|
||||
amount: params.sponsoringData.amount,
|
||||
paymentHash: params.sponsoringData.paymentHash,
|
||||
eventId: params.eventId,
|
||||
...(params.sponsoringData.seriesId ? { seriesId: params.sponsoringData.seriesId } : {}),
|
||||
...(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
|
||||
*/
|
||||
|
||||
58
lib/nip98.ts
58
lib/nip98.ts
@ -18,54 +18,62 @@ import { nostrAuthService } from './nostrAuth'
|
||||
* @returns Base64-encoded signed event token
|
||||
*/
|
||||
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()
|
||||
if (!pubkey) {
|
||||
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()) {
|
||||
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()
|
||||
if (!privateKey) {
|
||||
throw new Error('Private key not available. Please unlock your account.')
|
||||
}
|
||||
return privateKey
|
||||
}
|
||||
|
||||
// Parse URL to get components
|
||||
const urlObj = new URL(url)
|
||||
function buildNip98EventTemplate(params: {
|
||||
method: string
|
||||
url: string
|
||||
payloadHash: string | undefined
|
||||
pubkey: string
|
||||
}): EventTemplate & { pubkey: string } {
|
||||
const urlObj = new URL(params.url)
|
||||
const path = urlObj.pathname + urlObj.search
|
||||
|
||||
// Build event template for NIP-98
|
||||
const tags: string[][] = [
|
||||
['u', urlObj.origin + path],
|
||||
['method', method],
|
||||
['method', params.method],
|
||||
]
|
||||
|
||||
// Add payload hash if provided (for POST/PUT requests)
|
||||
if (payloadHash) {
|
||||
tags.push(['payload', payloadHash])
|
||||
if (params.payloadHash) {
|
||||
tags.push(['payload', params.payloadHash])
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate & { pubkey: string } = {
|
||||
kind: 27235, // NIP-98 kind for HTTP auth
|
||||
return {
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: '',
|
||||
pubkey,
|
||||
pubkey: params.pubkey,
|
||||
}
|
||||
}
|
||||
|
||||
// Sign the event directly with the private key (no plugin needed)
|
||||
const secretKey = hexToBytes(privateKey)
|
||||
const signedEvent = finalizeEvent(eventTemplate, secretKey)
|
||||
|
||||
// Encode event as base64 JSON
|
||||
const eventJson = JSON.stringify(signedEvent)
|
||||
function encodeEventAsBase64Json(event: unknown): string {
|
||||
const eventJson = JSON.stringify(event)
|
||||
const eventBytes = new TextEncoder().encode(eventJson)
|
||||
const base64Token = globalThis.btoa(String.fromCharCode(...eventBytes))
|
||||
|
||||
return base64Token
|
||||
return globalThis.btoa(String.fromCharCode(...eventBytes))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -27,46 +27,34 @@ export async function parseArticleFromEvent(event: Event): Promise<Article | nul
|
||||
export async function parseSeriesFromEvent(event: Event): Promise<Series | null> {
|
||||
try {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
// Check if it's a series type (tag is 'series' in English)
|
||||
if (tags.type !== 'series') {
|
||||
return null
|
||||
}
|
||||
if (!tags.title || !tags.description) {
|
||||
const input = readSeriesInput({ tags, eventContent: event.content })
|
||||
if (!input) {
|
||||
return null
|
||||
}
|
||||
const category = mapNostrCategoryToLegacy(tags.category) ?? 'science-fiction'
|
||||
|
||||
const { hash, version, index } = await resolveObjectIdParts({
|
||||
...(tags.id ? { idTag: tags.id } : {}),
|
||||
defaultVersion: tags.version ?? 0,
|
||||
...(input.idTag ? { idTag: input.idTag } : {}),
|
||||
defaultVersion: input.defaultVersion,
|
||||
defaultIndex: 0,
|
||||
generateHash: async (): Promise<string> => generateHashId({
|
||||
type: 'series',
|
||||
pubkey: event.pubkey,
|
||||
title: tags.title,
|
||||
description: tags.description,
|
||||
category: tags.category ?? 'sciencefiction',
|
||||
coverUrl: tags.coverUrl ?? '',
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
category: input.categoryTag,
|
||||
coverUrl: input.coverUrl,
|
||||
}),
|
||||
})
|
||||
|
||||
const id = buildObjectId(hash, index, version)
|
||||
|
||||
const series: Series = {
|
||||
id,
|
||||
return buildSeriesFromParsed({
|
||||
event,
|
||||
input,
|
||||
hash,
|
||||
version,
|
||||
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,
|
||||
...(tags.coverUrl ? { coverUrl: tags.coverUrl } : {}),
|
||||
}
|
||||
series.kindType = 'series'
|
||||
return series
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Error parsing series:', e)
|
||||
return null
|
||||
@ -76,75 +64,147 @@ export async function parseSeriesFromEvent(event: Event): Promise<Series | null>
|
||||
export async function parseReviewFromEvent(event: Event): Promise<Review | null> {
|
||||
try {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
// Check if it's a quote type (reviews are quotes, tag is 'quote' in English)
|
||||
if (tags.type !== 'quote') {
|
||||
const input = readReviewInput(tags)
|
||||
if (!input) {
|
||||
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
|
||||
let hash: string
|
||||
let version = tags.version ?? 0
|
||||
let index = 0
|
||||
|
||||
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({
|
||||
const { hash, version, index } = await resolveObjectIdParts({
|
||||
...(input.idTag ? { idTag: input.idTag } : {}),
|
||||
defaultVersion: input.defaultVersion,
|
||||
defaultIndex: 0,
|
||||
generateHash: async (): Promise<string> => generateHashId({
|
||||
type: 'quote',
|
||||
pubkey: event.pubkey,
|
||||
articleId,
|
||||
reviewerPubkey: reviewer,
|
||||
articleId: input.articleId,
|
||||
reviewerPubkey: input.reviewerPubkey,
|
||||
content: event.content,
|
||||
title: tags.title ?? '',
|
||||
})
|
||||
}
|
||||
title: input.title ?? '',
|
||||
}),
|
||||
})
|
||||
|
||||
const id = buildObjectId(hash, index, version)
|
||||
|
||||
// Extract text from tags if present
|
||||
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
|
||||
const rewardInfo = readRewardInfo(event.tags)
|
||||
const text = readTextTag(event.tags)
|
||||
return buildReviewFromParsed({ event, input, hash, version, index, rewardInfo, text })
|
||||
} catch (e) {
|
||||
console.error('Error parsing review:', e)
|
||||
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 } {
|
||||
const lines = content.split('\n')
|
||||
|
||||
@ -42,47 +42,108 @@ export function extractCommonTags(findTag: (key: string) => string | undefined,
|
||||
reviewerPubkey?: string
|
||||
json?: string
|
||||
} {
|
||||
const id = findTag('id')
|
||||
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')
|
||||
const base = readCommonTagBase(findTag)
|
||||
return {
|
||||
...(id ? { id } : {}),
|
||||
...(service ? { service } : {}),
|
||||
version: parseNumericTag(findTag, 'version') ?? 0, // Default to 0 if not present
|
||||
hidden: findTag('hidden') === 'true', // true only if tag exists and value is 'true'
|
||||
...buildCoreCommonTagFields(findTag, hasTag),
|
||||
...buildOptionalCommonTagFields(base),
|
||||
...buildOptionalNumericFields(base),
|
||||
}
|
||||
}
|
||||
|
||||
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'),
|
||||
payment: hasTag('payment'),
|
||||
...(title ? { title } : {}),
|
||||
...(preview ? { preview } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(mainnetAddress ? { mainnetAddress } : {}),
|
||||
...(totalSponsoring !== undefined ? { totalSponsoring } : {}),
|
||||
...(pictureUrl ? { pictureUrl } : {}),
|
||||
...(seriesId ? { seriesId } : {}),
|
||||
...(coverUrl ? { coverUrl } : {}),
|
||||
...(bannerUrl ? { bannerUrl } : {}),
|
||||
...(zapAmount !== undefined ? { zapAmount } : {}),
|
||||
...(invoice ? { invoice } : {}),
|
||||
...(paymentHash ? { paymentHash } : {}),
|
||||
...(encryptedKey ? { encryptedKey } : {}),
|
||||
...(articleId ? { articleId } : {}),
|
||||
...(reviewerPubkey ? { reviewerPubkey } : {}),
|
||||
...(json ? { json } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function buildOptionalCommonTagFields(base: {
|
||||
id: string | undefined
|
||||
service: string | undefined
|
||||
title: string | undefined
|
||||
preview: string | undefined
|
||||
description: string | undefined
|
||||
mainnetAddress: string | undefined
|
||||
pictureUrl: string | undefined
|
||||
seriesId: string | undefined
|
||||
coverUrl: string | undefined
|
||||
bannerUrl: string | undefined
|
||||
invoice: string | undefined
|
||||
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
|
||||
}
|
||||
): Promise<boolean> {
|
||||
if (!params.pool) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return setupZapReceiptCheck(params)
|
||||
}
|
||||
|
||||
function setupZapReceiptCheck(params: {
|
||||
pool: SimplePool
|
||||
targetPubkey: string
|
||||
targetEventId: string
|
||||
amount: number
|
||||
userPubkey: string
|
||||
}): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let resolved = false
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
const sub = createSubscription(params.pool, [relayUrl], createZapFilters(params.targetPubkey, params.targetEventId, params.userPubkey))
|
||||
|
||||
const finalize = (value: boolean): void => {
|
||||
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)
|
||||
const state = createZapReceiptState({ sub, resolve })
|
||||
registerZapReceiptHandlers({ sub, params, state })
|
||||
})
|
||||
}
|
||||
|
||||
function createZapReceiptState(params: {
|
||||
sub: import('@/types/nostr-tools-extended').Subscription
|
||||
resolve: (value: boolean) => void
|
||||
}): { resolvedRef: { current: boolean }; finalize: (value: boolean) => void } {
|
||||
const resolvedRef = { current: false }
|
||||
const finalize = (value: boolean): void => {
|
||||
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 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 {
|
||||
objectType: string
|
||||
objectId: string
|
||||
@ -85,56 +93,40 @@ class NotificationDetector {
|
||||
return
|
||||
}
|
||||
|
||||
const objectTypes: 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' },
|
||||
]
|
||||
for (const cfg of USER_OBJECT_NOTIFICATION_TYPES) {
|
||||
await this.scanUserObjectsOfType(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
for (const { type, notificationType } of objectTypes) {
|
||||
try {
|
||||
const allObjects = await objectCache.getAll(type as Parameters<typeof objectCache.getAll>[0])
|
||||
const userObjects = (allObjects as CachedObject[]).filter((obj: CachedObject) => {
|
||||
// Check if object is related to the user
|
||||
// For purchases: targetPubkey === userPubkey
|
||||
// For reviews: targetEventId points to user's article
|
||||
// For sponsoring: targetPubkey === userPubkey
|
||||
// For review_tips: targetEventId points to user's review
|
||||
// For payment_notes: targetPubkey === userPubkey
|
||||
private async scanUserObjectsOfType(params: { type: string; notificationType: NotificationType }): Promise<void> {
|
||||
try {
|
||||
const {userPubkey} = this
|
||||
if (!userPubkey) {
|
||||
return
|
||||
}
|
||||
const allObjects = await objectCache.getAll(params.type as Parameters<typeof objectCache.getAll>[0])
|
||||
const userObjects = filterUserRelatedObjects({ type: params.type, allObjects: allObjects as CachedObject[], userPubkey })
|
||||
await this.createNotificationsForNewObjects({ type: params.type, notificationType: params.notificationType, objects: userObjects })
|
||||
} catch (error) {
|
||||
console.error(`[NotificationDetector] Error scanning ${params.type}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'purchase' || type === 'sponsoring' || type === 'payment_note') {
|
||||
return (obj as { targetPubkey?: string }).targetPubkey === this.userPubkey
|
||||
}
|
||||
|
||||
if (type === 'review' || type === 'review_tip') {
|
||||
// Need to check if the target event belongs to the user
|
||||
// This is more complex and may require checking the article/review
|
||||
// For now, we'll create notifications for all reviews/tips
|
||||
// The UI can filter them if needed
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
private async createNotificationsForNewObjects(params: {
|
||||
type: string
|
||||
notificationType: NotificationType
|
||||
objects: CachedObject[]
|
||||
}): Promise<void> {
|
||||
for (const obj of params.objects) {
|
||||
if (obj.createdAt * 1000 > this.lastScanTime) {
|
||||
const eventId = obj.id.split(':')[1] ?? obj.id
|
||||
await notificationService.createNotification({
|
||||
type: params.notificationType,
|
||||
objectType: params.type,
|
||||
objectId: obj.id,
|
||||
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()
|
||||
|
||||
@ -34,45 +34,10 @@ class ObjectCacheService {
|
||||
|
||||
private getDBHelper(objectType: ObjectType): IndexedDBHelper {
|
||||
if (!this.dbHelpers.has(objectType)) {
|
||||
const helper = createIndexedDBHelper({
|
||||
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 })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
const helper = createDbHelperForObjectType(objectType)
|
||||
this.dbHelpers.set(objectType, helper)
|
||||
}
|
||||
const helper = this.dbHelpers.get(objectType)
|
||||
if (!helper) {
|
||||
throw new Error(`Database helper not found for ${objectType}`)
|
||||
}
|
||||
return helper
|
||||
return getRequiredDbHelper(this.dbHelpers, objectType)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -90,7 +55,7 @@ class ObjectCacheService {
|
||||
private async countObjectsWithHash(objectType: ObjectType, hash: string): Promise<number> {
|
||||
try {
|
||||
const helper = this.getDBHelper(objectType)
|
||||
return await helper.countByIndex('hash', IDBKeyRange.only(hash))
|
||||
return helper.countByIndex('hash', IDBKeyRange.only(hash))
|
||||
} catch (countError) {
|
||||
console.error(`Error counting objects with hash ${hash}:`, countError)
|
||||
return 0
|
||||
@ -114,43 +79,67 @@ class ObjectCacheService {
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const helper = this.getDBHelper(params.objectType)
|
||||
|
||||
// If index is not provided, calculate it by counting objects with the same hash
|
||||
let finalIndex = params.index
|
||||
if (finalIndex === undefined) {
|
||||
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)
|
||||
const index = await this.resolveIndex(params.objectType, params.hash, params.index)
|
||||
const id = buildObjectId(params.hash, index, params.version)
|
||||
const published = await this.resolvePublishedForUpsert(helper, id, params.published)
|
||||
await helper.put(this.buildCachedObject(params, id, index, published))
|
||||
} catch (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
|
||||
*/
|
||||
@ -405,3 +394,55 @@ class 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 type { Purchase, ReviewTip, Sponsoring } from '@/types/nostr'
|
||||
import { writeOrchestrator } from './writeOrchestrator'
|
||||
import { getPublishRelays } from './relaySelection'
|
||||
|
||||
/**
|
||||
* Publish an explicit payment note (kind 1) for a purchase
|
||||
@ -24,108 +25,18 @@ export async function publishPurchaseNote(params: {
|
||||
seriesId?: string
|
||||
payerPrivateKey: string
|
||||
}): Promise<Event | null> {
|
||||
let category: 'sciencefiction' | 'research' = 'sciencefiction'
|
||||
if (params.category === 'science-fiction') {
|
||||
category = 'sciencefiction'
|
||||
} else if (params.category === 'scientific-research') {
|
||||
category = 'research'
|
||||
}
|
||||
|
||||
const purchaseData = {
|
||||
payerPubkey: params.payerPubkey,
|
||||
articleId: params.articleId,
|
||||
authorPubkey: params.authorPubkey,
|
||||
amount: params.amount,
|
||||
paymentHash: params.paymentHash,
|
||||
}
|
||||
|
||||
const hashId = await generatePurchaseHashId(purchaseData)
|
||||
const id = buildObjectId(hashId, 0, 0)
|
||||
|
||||
const tags = buildTags({
|
||||
type: 'payment',
|
||||
category,
|
||||
id: hashId,
|
||||
service: PLATFORM_SERVICE,
|
||||
const category = mapPaymentCategory(params.category)
|
||||
const payload = await buildPurchaseNotePayload({ ...params, category })
|
||||
return publishPaymentNoteToRelays({
|
||||
payerPrivateKey: params.payerPrivateKey,
|
||||
objectType: 'purchase',
|
||||
hash: payload.hashId,
|
||||
eventTemplate: payload.eventTemplate,
|
||||
parsed: payload.parsedPurchase,
|
||||
version: 0,
|
||||
hidden: false,
|
||||
payment: true,
|
||||
paymentType: 'purchase',
|
||||
amount: params.amount,
|
||||
payerPubkey: params.payerPubkey,
|
||||
recipientPubkey: params.authorPubkey,
|
||||
paymentHash: params.paymentHash,
|
||||
articleId: params.articleId,
|
||||
...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
|
||||
...(params.seriesId ? { seriesId: params.seriesId } : {}),
|
||||
})
|
||||
|
||||
// Build JSON metadata
|
||||
const paymentJson = JSON.stringify({
|
||||
type: 'purchase',
|
||||
id,
|
||||
hash: hashId,
|
||||
version: 0,
|
||||
index: 0,
|
||||
...purchaseData,
|
||||
})
|
||||
|
||||
tags.push(['json', paymentJson])
|
||||
|
||||
// Build parsed Purchase object
|
||||
const parsedPurchase: Purchase = {
|
||||
id,
|
||||
hash: hashId,
|
||||
version: 0,
|
||||
index: 0,
|
||||
payerPubkey: params.payerPubkey,
|
||||
articleId: params.articleId,
|
||||
authorPubkey: params.authorPubkey,
|
||||
amount: params.amount,
|
||||
paymentHash: params.paymentHash,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
kindType: 'purchase',
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: `Purchase confirmed: ${params.amount} sats for article ${params.articleId}`,
|
||||
}
|
||||
|
||||
nostrService.setPrivateKey(params.payerPrivateKey)
|
||||
writeOrchestrator.setPrivateKey(params.payerPrivateKey)
|
||||
|
||||
// Finalize event
|
||||
const secretKey = hexToBytes(params.payerPrivateKey)
|
||||
const event = finalizeEvent(eventTemplate, secretKey)
|
||||
|
||||
// Get active relays
|
||||
const { relaySessionManager } = await import('./relaySessionManager')
|
||||
const activeRelays = await relaySessionManager.getActiveRelays()
|
||||
const { getPrimaryRelay } = await import('./config')
|
||||
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
|
||||
|
||||
// Publish via writeOrchestrator (parallel network + local write)
|
||||
const result = await writeOrchestrator.writeAndPublish(
|
||||
{
|
||||
objectType: 'purchase',
|
||||
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
|
||||
payerPrivateKey: string
|
||||
}): Promise<Event | null> {
|
||||
let category: 'sciencefiction' | 'research' = 'sciencefiction'
|
||||
if (params.category === 'science-fiction') {
|
||||
category = 'sciencefiction'
|
||||
} else if (params.category === 'scientific-research') {
|
||||
category = 'research'
|
||||
}
|
||||
|
||||
const tipData = {
|
||||
payerPubkey: params.payerPubkey,
|
||||
articleId: params.articleId,
|
||||
reviewId: params.reviewId,
|
||||
reviewerPubkey: params.reviewerPubkey,
|
||||
authorPubkey: params.authorPubkey,
|
||||
amount: params.amount,
|
||||
paymentHash: params.paymentHash,
|
||||
}
|
||||
|
||||
const hashId = await generateReviewTipHashId(tipData)
|
||||
const id = buildObjectId(hashId, 0, 0)
|
||||
|
||||
const tags = buildTags({
|
||||
type: 'payment',
|
||||
category,
|
||||
id: hashId,
|
||||
service: PLATFORM_SERVICE,
|
||||
const category = mapPaymentCategory(params.category)
|
||||
const payload = await buildReviewTipNotePayload({ ...params, category })
|
||||
return publishPaymentNoteToRelays({
|
||||
payerPrivateKey: params.payerPrivateKey,
|
||||
objectType: 'review_tip',
|
||||
hash: payload.hashId,
|
||||
eventTemplate: payload.eventTemplate,
|
||||
parsed: payload.parsedReviewTip,
|
||||
version: 0,
|
||||
hidden: false,
|
||||
payment: true,
|
||||
paymentType: 'review_tip',
|
||||
amount: params.amount,
|
||||
payerPubkey: params.payerPubkey,
|
||||
recipientPubkey: params.reviewerPubkey,
|
||||
paymentHash: params.paymentHash,
|
||||
articleId: params.articleId,
|
||||
reviewId: params.reviewId,
|
||||
...(params.zapReceiptId ? { zapReceiptId: params.zapReceiptId } : {}),
|
||||
...(params.seriesId ? { seriesId: params.seriesId } : {}),
|
||||
...(params.text ? { text: params.text } : {}),
|
||||
})
|
||||
|
||||
// Build JSON metadata
|
||||
const paymentJson = JSON.stringify({
|
||||
type: 'review_tip',
|
||||
id,
|
||||
hash: hashId,
|
||||
version: 0,
|
||||
index: 0,
|
||||
...tipData,
|
||||
...(params.text ? { text: params.text } : {}),
|
||||
})
|
||||
|
||||
tags.push(['json', paymentJson])
|
||||
|
||||
// Build parsed ReviewTip object
|
||||
const parsedReviewTip: ReviewTip = {
|
||||
id,
|
||||
hash: hashId,
|
||||
version: 0,
|
||||
index: 0,
|
||||
payerPubkey: params.payerPubkey,
|
||||
articleId: params.articleId,
|
||||
reviewId: params.reviewId,
|
||||
reviewerPubkey: params.reviewerPubkey,
|
||||
authorPubkey: params.authorPubkey,
|
||||
amount: params.amount,
|
||||
paymentHash: params.paymentHash,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
...(params.text ? { text: params.text } : {}),
|
||||
kindType: 'review_tip',
|
||||
}
|
||||
|
||||
const content = params.text
|
||||
? `Review tip confirmed: ${params.amount} sats for review ${params.reviewId}\n\n${params.text}`
|
||||
: `Review tip confirmed: ${params.amount} sats for review ${params.reviewId}`
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content,
|
||||
}
|
||||
|
||||
nostrService.setPrivateKey(params.payerPrivateKey)
|
||||
writeOrchestrator.setPrivateKey(params.payerPrivateKey)
|
||||
|
||||
// Finalize event
|
||||
const secretKey = hexToBytes(params.payerPrivateKey)
|
||||
const event = finalizeEvent(eventTemplate, secretKey)
|
||||
|
||||
// Get active relays
|
||||
const { relaySessionManager } = await import('./relaySessionManager')
|
||||
const activeRelays = await relaySessionManager.getActiveRelays()
|
||||
const { getPrimaryRelay } = await import('./config')
|
||||
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
|
||||
|
||||
// Publish via writeOrchestrator (parallel network + local write)
|
||||
const result = await writeOrchestrator.writeAndPublish(
|
||||
{
|
||||
objectType: 'review_tip',
|
||||
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
|
||||
payerPrivateKey: string
|
||||
}): Promise<Event | null> {
|
||||
let category: 'sciencefiction' | 'research' = 'sciencefiction'
|
||||
if (params.category === 'science-fiction') {
|
||||
category = 'sciencefiction'
|
||||
} else if (params.category === 'scientific-research') {
|
||||
category = 'research'
|
||||
}
|
||||
const category = mapPaymentCategory(params.category)
|
||||
const payload = await buildSponsoringNotePayload({ ...params, category })
|
||||
|
||||
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,
|
||||