diff --git a/components/AuthorPresentationEditor.tsx b/components/AuthorPresentationEditor.tsx index 867cb15..5213f2d 100644 --- a/components/AuthorPresentationEditor.tsx +++ b/components/AuthorPresentationEditor.tsx @@ -186,17 +186,7 @@ function DeleteButton({ onDelete, deleting }: { onDelete: () => void; deleting: ) } -function PresentationForm({ - draft, - setDraft, - validationError, - error, - loading, - handleSubmit, - deleting, - handleDelete, - hasExistingPresentation, -}: { +type PresentationFormProps = { draft: AuthorPresentationDraft setDraft: (next: AuthorPresentationDraft) => void validationError: string | null @@ -206,43 +196,44 @@ function PresentationForm({ deleting: boolean handleDelete: () => void hasExistingPresentation: boolean -}): React.ReactElement { +} + +function PresentationForm(props: PresentationFormProps): React.ReactElement { return (
) => { - void handleSubmit(e) + void props.handleSubmit(e) }} className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark space-y-4" > - - + +
- {hasExistingPresentation && ( - { void handleDelete() }} deleting={deleting} /> + {props.hasExistingPresentation && ( + { void props.handleDelete() }} deleting={props.deleting} /> )}
) } +function getSubmitLabel(params: { loading: boolean; deleting: boolean; hasExistingPresentation: boolean }): string { + if (params.loading || params.deleting) { + return t('publish.publishing') + } + return params.hasExistingPresentation ? t('presentation.update.button') : t('publish.button') +} + function useAuthorPresentationState(pubkey: string | null, existingAuthorName?: string, existingPresentation?: Article | null): { draft: AuthorPresentationDraft setDraft: (next: AuthorPresentationDraft) => void @@ -256,78 +247,119 @@ function useAuthorPresentationState(pubkey: string | null, existingAuthorName?: } { const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey) const router = useRouter() - const [draft, setDraft] = useState(() => { - if (existingPresentation) { - const { presentation, contentDescription } = extractPresentationData(existingPresentation) - const authorName = existingPresentation.title.replace(/^Présentation de /, '') ?? existingAuthorName ?? '' - return { - authorName, - presentation, - contentDescription, - mainnetAddress: existingPresentation.mainnetAddress ?? '', - ...(existingPresentation.bannerUrl ? { pictureUrl: existingPresentation.bannerUrl } : {}), - } - } - return { - authorName: existingAuthorName ?? '', - presentation: '', - contentDescription: '', - mainnetAddress: '', - } - }) + const [draft, setDraft] = useState(() => buildInitialDraft(existingPresentation, existingAuthorName)) const [validationError, setValidationError] = useState(null) const [deleting, setDeleting] = useState(false) // Update authorName when profile changes useEffect(() => { - if (existingAuthorName && existingAuthorName !== draft.authorName && !existingPresentation) { - setDraft((prev) => ({ ...prev, authorName: existingAuthorName })) - } + syncAuthorNameIntoDraft({ existingAuthorName, draftAuthorName: draft.authorName, hasExistingPresentation: Boolean(existingPresentation), setDraft }) }, [existingAuthorName, existingPresentation, draft.authorName]) const handleSubmit = useCallback( async (e: FormEvent) => { e.preventDefault() - const address = draft.mainnetAddress.trim() - if (!ADDRESS_PATTERN.test(address)) { - setValidationError(t('presentation.validation.invalidAddress')) - return - } - if (!draft.authorName.trim()) { - setValidationError(t('presentation.validation.authorNameRequired')) - return - } - setValidationError(null) - await publishPresentation(draft) + await submitPresentationDraft({ draft, setValidationError, publishPresentation }) }, [draft, publishPresentation] ) const handleDelete = useCallback(async () => { - if (!existingPresentation?.id) { - return - } - - const confirmed = await userConfirm(t('presentation.delete.confirm')) - if (!confirmed) { - return - } - - setDeleting(true) - setValidationError(null) - try { - await deletePresentation(existingPresentation.id) - await router.push('/') - } catch (e) { - setValidationError(e instanceof Error ? e.message : t('presentation.delete.error')) - } finally { - setDeleting(false) - } + await deletePresentationFlow({ + existingPresentationId: existingPresentation?.id, + deletePresentation, + router, + setDeleting, + setValidationError, + }) }, [existingPresentation, deletePresentation, router]) return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete } } +function buildInitialDraft(existingPresentation: Article | null | undefined, existingAuthorName: string | undefined): AuthorPresentationDraft { + if (existingPresentation) { + const { presentation, contentDescription } = extractPresentationData(existingPresentation) + const authorName = existingPresentation.title.replace(/^Présentation de /, '') ?? existingAuthorName ?? '' + return { + authorName, + presentation, + contentDescription, + mainnetAddress: existingPresentation.mainnetAddress ?? '', + ...(existingPresentation.bannerUrl ? { pictureUrl: existingPresentation.bannerUrl } : {}), + } + } + return { + authorName: existingAuthorName ?? '', + presentation: '', + contentDescription: '', + mainnetAddress: '', + } +} + +function syncAuthorNameIntoDraft(params: { + existingAuthorName: string | undefined + draftAuthorName: string + hasExistingPresentation: boolean + setDraft: (updater: (prev: AuthorPresentationDraft) => AuthorPresentationDraft) => void +}): void { + if (!params.existingAuthorName || params.hasExistingPresentation || params.existingAuthorName === params.draftAuthorName) { + return + } + params.setDraft((prev) => ({ ...prev, authorName: params.existingAuthorName as string })) +} + +async function submitPresentationDraft(params: { + draft: AuthorPresentationDraft + setValidationError: (value: string | null) => void + publishPresentation: (draft: AuthorPresentationDraft) => Promise +}): Promise { + const error = validatePresentationDraft(params.draft) + if (error) { + params.setValidationError(error) + return + } + params.setValidationError(null) + await params.publishPresentation(params.draft) +} + +function validatePresentationDraft(draft: AuthorPresentationDraft): string | null { + const address = draft.mainnetAddress.trim() + if (!ADDRESS_PATTERN.test(address)) { + return t('presentation.validation.invalidAddress') + } + if (!draft.authorName.trim()) { + return t('presentation.validation.authorNameRequired') + } + return null +} + +async function deletePresentationFlow(params: { + existingPresentationId: string | undefined + deletePresentation: (articleId: string) => Promise + router: ReturnType + setDeleting: (value: boolean) => void + setValidationError: (value: string | null) => void +}): Promise { + if (!params.existingPresentationId) { + return + } + const confirmed = await userConfirm(t('presentation.delete.confirm')) + if (!confirmed) { + return + } + params.setDeleting(true) + params.setValidationError(null) + try { + await params.deletePresentation(params.existingPresentationId) + await params.router.push('/') + } catch (e) { + params.setValidationError(e instanceof Error ? e.message : t('presentation.delete.error')) + } finally { + params.setDeleting(false) + } +} + function NoAccountActionButtons({ onGenerate, onImport, @@ -362,122 +394,133 @@ function NoAccountView(): React.ReactElement { const [generating, setGenerating] = useState(false) const [error, setError] = useState(null) - const handleGenerate = async (): Promise => { - setGenerating(true) - setError(null) - try { - const { nostrAuthService } = await import('@/lib/nostrAuth') - const result = await nostrAuthService.createAccount() - setRecoveryPhrase(result.recoveryPhrase) - setNpub(result.npub) - setShowRecoveryStep(true) - } catch (e) { - setError(e instanceof Error ? e.message : t('account.create.error.failed')) - } finally { - setGenerating(false) - } - } - - const handleRecoveryContinue = (): void => { - setShowRecoveryStep(false) + const handleGenerate = (): Promise => generateNoAccount({ setGenerating, setError, setRecoveryPhrase, setNpub, setShowRecoveryStep }) + const handleRecoveryContinue = (): void => transitionToUnlock({ setShowRecoveryStep, setShowUnlockModal }) + const handleUnlockSuccess = (): void => resetNoAccountAfterUnlock({ setShowUnlockModal, setRecoveryPhrase, setNpub }) + const handleImportSuccess = (): void => { + setShowImportModal(false) setShowUnlockModal(true) } - const handleUnlockSuccess = (): void => { - setShowUnlockModal(false) - setRecoveryPhrase([]) - setNpub('') - } + return ( + { void handleGenerate() }} + onImport={() => setShowImportModal(true)} + modals={ + setShowImportModal(false)} + onImportSuccess={handleImportSuccess} + showRecoveryStep={showRecoveryStep} + recoveryPhrase={recoveryPhrase} + npub={npub} + onRecoveryContinue={handleRecoveryContinue} + showUnlockModal={showUnlockModal} + onUnlockSuccess={handleUnlockSuccess} + onCloseUnlock={() => setShowUnlockModal(false)} + /> + } + /> + ) +} +async function generateNoAccount(params: { + setGenerating: (value: boolean) => void + setError: (value: string | null) => void + setRecoveryPhrase: (value: string[]) => void + setNpub: (value: string) => void + setShowRecoveryStep: (value: boolean) => void +}): Promise { + params.setGenerating(true) + params.setError(null) + try { + const { nostrAuthService } = await import('@/lib/nostrAuth') + const result = await nostrAuthService.createAccount() + params.setRecoveryPhrase(result.recoveryPhrase) + params.setNpub(result.npub) + params.setShowRecoveryStep(true) + } catch (e) { + params.setError(e instanceof Error ? e.message : t('account.create.error.failed')) + } finally { + params.setGenerating(false) + } +} + +function transitionToUnlock(params: { setShowRecoveryStep: (value: boolean) => void; setShowUnlockModal: (value: boolean) => void }): void { + params.setShowRecoveryStep(false) + params.setShowUnlockModal(true) +} + +function resetNoAccountAfterUnlock(params: { + setShowUnlockModal: (value: boolean) => void + setRecoveryPhrase: (value: string[]) => void + setNpub: (value: string) => void +}): void { + params.setShowUnlockModal(false) + params.setRecoveryPhrase([]) + params.setNpub('') +} + +function NoAccountCard(params: { + error: string | null + generating: boolean + onGenerate: () => void + onImport: () => void + modals: React.ReactElement +}): React.ReactElement { return (
-

- Créez un compte ou importez votre clé secrète pour commencer -

- {error &&

{error}

} - { void handleGenerate() }} - onImport={() => setShowImportModal(true)} - /> - {generating && ( -

Génération du compte...

- )} - {showImportModal && ( - { - setShowImportModal(false) - setShowUnlockModal(true) - }} - onClose={() => setShowImportModal(false)} - initialStep="import" - /> - )} - {showRecoveryStep && ( - - )} - {showUnlockModal && ( - setShowUnlockModal(false)} - /> - )} +

Créez un compte ou importez votre clé secrète pour commencer

+ {params.error &&

{params.error}

} + + {params.generating &&

Génération du compte...

} + {params.modals}
) } -function AuthorPresentationFormView({ - pubkey, - profile, -}: { +function NoAccountModals(params: { + showImportModal: boolean + onImportSuccess: () => void + onCloseImport: () => void + showRecoveryStep: boolean + recoveryPhrase: string[] + npub: string + onRecoveryContinue: () => void + showUnlockModal: boolean + onUnlockSuccess: () => void + onCloseUnlock: () => void +}): React.ReactElement { + return ( + <> + {params.showImportModal && } + {params.showRecoveryStep && } + {params.showUnlockModal && } + + ) +} + +function AuthorPresentationFormView(props: { pubkey: string | null profile: { name?: string; pubkey: string } | null }): React.ReactElement { - const { checkPresentationExists } = useAuthorPresentation(pubkey) - const [existingPresentation, setExistingPresentation] = useState
(null) - const [loadingPresentation, setLoadingPresentation] = useState(true) + const { checkPresentationExists } = useAuthorPresentation(props.pubkey) + const presentation = useExistingPresentation({ pubkey: props.pubkey, checkPresentationExists }) + const state = useAuthorPresentationState(props.pubkey, props.profile?.name, presentation.existingPresentation) - useEffect(() => { - const load = async (): Promise => { - if (!pubkey) { - setLoadingPresentation(false) - return - } - try { - const presentation = await checkPresentationExists() - setExistingPresentation(presentation) - } catch (e) { - console.error('Error loading presentation:', e) - } finally { - setLoadingPresentation(false) - } - } - void load() - }, [pubkey, checkPresentationExists]) - - const state = useAuthorPresentationState(pubkey, profile?.name, existingPresentation) - - if (!pubkey) { + if (!props.pubkey) { return } - - if (loadingPresentation) { - return ( -
-

{t('common.loading')}

-
- ) + if (presentation.loadingPresentation) { + return } - if (state.success) { - return + return } - return ( { void state.handleDelete() }} - hasExistingPresentation={existingPresentation !== null && existingPresentation !== undefined} + hasExistingPresentation={presentation.existingPresentation !== null} /> ) } +function LoadingNotice(): React.ReactElement { + return ( +
+

{t('common.loading')}

+
+ ) +} + +function useExistingPresentation(params: { + pubkey: string | null + checkPresentationExists: () => Promise
+}): { existingPresentation: Article | null; loadingPresentation: boolean } { + const [existingPresentation, setExistingPresentation] = useState
(null) + const [loadingPresentation, setLoadingPresentation] = useState(true) + const { pubkey, checkPresentationExists } = params + + useEffect(() => { + void loadExistingPresentation({ pubkey, checkPresentationExists, setExistingPresentation, setLoadingPresentation }) + }, [pubkey, checkPresentationExists]) + + return { existingPresentation, loadingPresentation } +} + +async function loadExistingPresentation(params: { + pubkey: string | null + checkPresentationExists: () => Promise
+ setExistingPresentation: (value: Article | null) => void + setLoadingPresentation: (value: boolean) => void +}): Promise { + if (!params.pubkey) { + params.setLoadingPresentation(false) + return + } + try { + params.setExistingPresentation(await params.checkPresentationExists()) + } catch (e) { + console.error('Error loading presentation:', e) + } finally { + params.setLoadingPresentation(false) + } +} + function useAutoLoadPubkey(accountExists: boolean | null, pubkey: string | null, connect: () => Promise): void { useEffect(() => { if (accountExists === true && !pubkey) { diff --git a/components/HomeView.tsx b/components/HomeView.tsx index c71425c..d58c3ad 100644 --- a/components/HomeView.tsx +++ b/components/HomeView.tsx @@ -57,55 +57,46 @@ function ArticlesHero({ ) } -function HomeContent({ - searchQuery, - setSearchQuery, - selectedCategory, - setSelectedCategory, - filters, - setFilters, - articles, - allArticles, - authors, - allAuthors, - loading, - error, - onUnlock, - unlockedArticles, -}: HomeViewProps): React.ReactElement { - const shouldShowFilters = !loading && allArticles.length > 0 - const shouldShowAuthors = selectedCategory !== null && selectedCategory !== 'all' +function HomeContent(props: HomeViewProps): React.ReactElement { + const shouldShowFilters = !props.loading && props.allArticles.length > 0 + const shouldShowAuthors = props.selectedCategory !== null && props.selectedCategory !== 'all' // 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 isInitialLoad = props.loading && props.allArticles.length === 0 && props.allAuthors.length === 0 return (
{shouldShowFilters && !shouldShowAuthors && ( - + )}
) diff --git a/components/ImageUploadField.tsx b/components/ImageUploadField.tsx index 9912ef5..f681fb8 100644 --- a/components/ImageUploadField.tsx +++ b/components/ImageUploadField.tsx @@ -92,56 +92,85 @@ async function processFileUpload(file: File, onChange: (url: string) => void, se } } -function useImageUpload(onChange: (url: string) => void): { +type ImageUploadState = { uploading: boolean error: string | null handleFileSelect: (e: React.ChangeEvent) => Promise showUnlockModal: boolean setShowUnlockModal: (show: boolean) => void handleUnlockSuccess: () => Promise -} { +} + +function useImageUpload(onChange: (url: string) => void): ImageUploadState { const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) const [showUnlockModal, setShowUnlockModal] = useState(false) const [pendingFile, setPendingFile] = useState(null) - const handleFileSelect = async (event: React.ChangeEvent): Promise => { + const handleFileSelect = createHandleFileSelect({ + onChange, + setError, + setUploading, + setPendingFile, + setShowUnlockModal, + }) + + const handleUnlockSuccess = createHandleUnlockSuccess({ + pendingFile, + onChange, + setError, + setPendingFile, + setShowUnlockModal, + setUploading, + }) + + return { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess } +} + +function createHandleFileSelect(params: { + onChange: (url: string) => void + setError: (error: string | null) => void + setUploading: (uploading: boolean) => void + setPendingFile: (file: File | null) => void + setShowUnlockModal: (show: boolean) => void +}): (event: React.ChangeEvent) => Promise { + return async (event: React.ChangeEvent): Promise => { const file = readFirstFile(event) if (!file) { return } - setError(null) - setUploading(true) + params.setError(null) + params.setUploading(true) try { - await processFileUpload(file, onChange, setError) + await processFileUpload(file, params.onChange, params.setError) } catch (uploadError) { const uploadErr = normalizeError(uploadError) if (isUnlockRequiredError(uploadErr)) { - setPendingFile(file) - setShowUnlockModal(true) - setError(null) // Don't show error, show unlock modal instead + params.setPendingFile(file) + params.setShowUnlockModal(true) + params.setError(null) } else { - setError(uploadErr.message ?? t('presentation.field.picture.error.uploadFailed')) + params.setError(uploadErr.message ?? t('presentation.field.picture.error.uploadFailed')) } } finally { - setUploading(false) + params.setUploading(false) } } +} - const handleUnlockSuccess = async (): Promise => { - await retryPendingUpload({ - pendingFile, - onChange, - setError, - setPendingFile, - setShowUnlockModal, - setUploading, - }) +function createHandleUnlockSuccess(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 { + return async (): Promise => { + await retryPendingUpload(params) } - - return { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess } } function readFirstFile(event: React.ChangeEvent): File | null { diff --git a/components/LanguageSettingsManager.tsx b/components/LanguageSettingsManager.tsx index f46599c..5e56cab 100644 --- a/components/LanguageSettingsManager.tsx +++ b/components/LanguageSettingsManager.tsx @@ -34,29 +34,32 @@ export function LanguageSettingsManager(): React.ReactElement { void loadLocaleIntoState({ setCurrentLocale, setLoading }) }, []) - const handleLocaleChange = async (locale: Locale): Promise => { - await applyLocaleChange({ locale, setCurrentLocale }) - } - const onLocaleClick = (locale: Locale): void => { - void handleLocaleChange(locale) + void applyLocaleChange({ locale, setCurrentLocale }) } - if (loading) { + return +} + +function LanguageSettingsPanel(params: { + loading: boolean + currentLocale: Locale + onLocaleClick: (locale: Locale) => void +}): React.ReactElement { + if (params.loading) { return (
{t('settings.language.loading')}
) } - return (

{t('settings.language.title')}

{t('settings.language.description')}

- - + +
) diff --git a/components/MarkdownEditorTwoColumns.tsx b/components/MarkdownEditorTwoColumns.tsx index 1ec5722..102b9e1 100644 --- a/components/MarkdownEditorTwoColumns.tsx +++ b/components/MarkdownEditorTwoColumns.tsx @@ -12,23 +12,17 @@ interface MarkdownEditorTwoColumnsProps { onBannerChange?: (url: string) => void } -export function MarkdownEditorTwoColumns({ - value, - onChange, - pages = [], - onPagesChange, - onMediaAdd, - onBannerChange, -}: MarkdownEditorTwoColumnsProps): React.ReactElement { +export function MarkdownEditorTwoColumns(props: MarkdownEditorTwoColumnsProps): React.ReactElement { const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) - const pagesHandlers = createPagesHandlers({ pages, onPagesChange }) + const pages = props.pages ?? [] + const pagesHandlers = createPagesHandlers({ pages, onPagesChange: props.onPagesChange }) const handleImageUpload = createImageUploadHandler({ setError, setUploading, - onMediaAdd, - onBannerChange, + onMediaAdd: props.onMediaAdd, + onBannerChange: props.onBannerChange, onSetPageImageUrl: pagesHandlers.setPageContent, }) @@ -40,13 +34,13 @@ export function MarkdownEditorTwoColumns({ }} uploading={uploading} error={error} - {...(onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})} + {...(props.onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})} />
- - + +
- {onPagesChange && ( + {props.onPagesChange && ( voi const timeRemaining = useInvoiceTimer(invoice.expiresAt) const handleCopy = useCallback( - createHandleCopy({ invoice: invoice.invoice, setCopied, setErrorMessage }), + (): Promise => copyInvoiceToClipboard({ invoice: invoice.invoice, setCopied, setErrorMessage }), [invoice.invoice] ) const handleOpenWallet = useCallback( - createHandleOpenWallet({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage }), + (): Promise => openWalletForInvoice({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage }), [invoice.invoice, onPaymentComplete] ) return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } } -function createHandleCopy(params: { +async function copyInvoiceToClipboard(params: { invoice: string setCopied: (value: boolean) => void setErrorMessage: (value: string | null) => void -}): () => Promise { - return async (): Promise => { - try { - await navigator.clipboard.writeText(params.invoice) - params.setCopied(true) - scheduleCopiedReset(params.setCopied) - } catch (e) { - console.error('Failed to copy:', e) - params.setErrorMessage(t('payment.modal.copyFailed')) - } +}): Promise { + try { + await navigator.clipboard.writeText(params.invoice) + params.setCopied(true) + scheduleCopiedReset(params.setCopied) + } catch (e) { + console.error('Failed to copy:', e) + params.setErrorMessage(t('payment.modal.copyFailed')) } } -function createHandleOpenWallet(params: { +async function openWalletForInvoice(params: { invoice: string onPaymentComplete: () => void setErrorMessage: (value: string | null) => void -}): () => Promise { - return async (): Promise => { - try { - await payWithWebLN(params.invoice) - params.onPaymentComplete() - } catch (e) { - const error = normalizePaymentError(e) - if (isUserCancellationError(error)) { - return - } - console.error('Payment failed:', error) - params.setErrorMessage(error.message) +}): Promise { + try { + await payWithWebLN(params.invoice) + params.onPaymentComplete() + } catch (e) { + const error = normalizePaymentError(e) + if (isUserCancellationError(error)) { + return } + console.error('Payment failed:', error) + params.setErrorMessage(error.message) } } diff --git a/hooks/useArticlePayment.ts b/hooks/useArticlePayment.ts index dc7eac5..aee41a4 100644 --- a/hooks/useArticlePayment.ts +++ b/hooks/useArticlePayment.ts @@ -24,39 +24,13 @@ export function useArticlePayment( const [paymentInvoice, setPaymentInvoice] = useState(null) const [paymentHash, setPaymentHash] = useState(null) - const handleUnlock = (): Promise => - unlockArticlePayment({ - article, - pubkey, - connect, - onUnlockSuccess, - setLoading, - setError, - setPaymentInvoice, - setPaymentHash, - }) + const handleUnlock = (): Promise => unlockArticlePayment({ article, pubkey, connect, onUnlockSuccess, setLoading, setError, setPaymentInvoice, setPaymentHash }) - const handlePaymentComplete = (): Promise => - checkPaymentAndUnlock({ - article, - pubkey, - paymentHash, - onUnlockSuccess, - setError, - setPaymentInvoice, - setPaymentHash, - }) + const handlePaymentComplete = (): Promise => checkPaymentAndUnlock({ article, pubkey, paymentHash, onUnlockSuccess, setError, setPaymentInvoice, setPaymentHash }) const handleCloseModal = (): void => resetPaymentModalState({ setPaymentInvoice, setPaymentHash }) - return { - loading, - error, - paymentInvoice, - handleUnlock, - handlePaymentComplete, - handleCloseModal, - } + return { loading, error, paymentInvoice, handleUnlock, handlePaymentComplete, handleCloseModal } } async function unlockArticlePayment(params: { @@ -81,25 +55,15 @@ async function unlockArticlePayment(params: { 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) { + const paymentResult = await paymentService.createArticlePayment({ article: params.article, userPubkey: params.pubkey }) + const ok = readPaymentResult(paymentResult) + if (!ok) { 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, - }) + params.setPaymentInvoice(ok.invoice) + params.setPaymentHash(ok.paymentHash) + void checkPaymentAndUnlock({ article: params.article, pubkey: params.pubkey, paymentHash: ok.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) @@ -109,6 +73,13 @@ async function unlockArticlePayment(params: { } } +function readPaymentResult(value: Awaited>): { invoice: AlbyInvoice; paymentHash: string } | null { + if (!value.success || !value.invoice || !value.paymentHash) { + return null + } + return { invoice: value.invoice, paymentHash: value.paymentHash } +} + async function ensureConnectedOrError(params: { connect: (() => Promise) | undefined setLoading: (value: boolean) => void diff --git a/hooks/useArticles.ts b/hooks/useArticles.ts index 08c49ad..bf9724e 100644 --- a/hooks/useArticles.ts +++ b/hooks/useArticles.ts @@ -19,138 +19,140 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters | const [error, setError] = useState(null) const hasArticlesRef = useRef(false) - useEffect(() => { - const loadAuthorsFromCache = async (): Promise => { - try { - const cachedAuthors = await objectCache.getAll('author') - const authors = cachedAuthors as Article[] - - // Display authors immediately (with existing totalSponsoring if available) - if (authors.length > 0) { - setArticles((prev) => { - // Merge with existing articles, avoiding duplicates - const existingIds = new Set(prev.map((a) => a.id)) - const newAuthors = authors.filter((a) => !existingIds.has(a.id)) - const merged = [...prev, ...newAuthors].sort((a, b) => b.createdAt - a.createdAt) - hasArticlesRef.current = merged.length > 0 - return merged - }) - setLoading(false) - - // Calculate totalSponsoring asynchronously from cache (non-blocking) - // Only update authors that don't have totalSponsoring yet - const authorsNeedingSponsoring = authors.filter( - (author) => author.isPresentation && author.pubkey && author.totalSponsoring === undefined - ) - - if (authorsNeedingSponsoring.length > 0) { - // Load sponsoring from cache in parallel (fast, no network) - const sponsoringPromises = authorsNeedingSponsoring.map(async (author) => { - if (author.pubkey) { - const totalSponsoring = await getAuthorSponsoring(author.pubkey, true) - return { authorId: author.id, totalSponsoring } - } - return null - }) - - const sponsoringResults = await Promise.all(sponsoringPromises) - - // Update articles with sponsoring amounts - const sponsoringByAuthorId = new Map() - sponsoringResults.forEach((result) => { - if (result) { - sponsoringByAuthorId.set(result.authorId, result.totalSponsoring) - } - }) - - setArticles((prev) => - prev.map((article) => { - const totalSponsoring = sponsoringByAuthorId.get(article.id) - if (totalSponsoring !== undefined && article.isPresentation) { - return { ...article, totalSponsoring } - } - return article - }) - ) - } - - return true - } - - // Cache is empty - stop loading immediately, no network requests needed - setLoading(false) - hasArticlesRef.current = false - return false - } catch (loadError) { - console.error('Error loading authors from cache:', loadError) - setLoading(false) - return false - } - } - - const load = async (): Promise => { - setLoading(true) - setError(null) - - const hasCachedAuthors = await loadAuthorsFromCache() - if (!hasCachedAuthors) { - setError(t('common.error.noContent')) - } - } - - void load() - - return () => { - // No cleanup needed - no network subscription - } - }, []) - - const loadArticleContent = async (articleId: string, authorPubkey: string): Promise
=> { - 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 - } + useLoadAuthorsFromCache({ setArticles, setLoading, setError, hasArticlesRef }) + const loadArticleContent = createLoadArticleContent({ setArticles, setError }) // Apply filters and sorting const filteredArticles = useMemo(() => { - const effectiveFilters = - filters ?? - ({ - authorPubkey: null, - sortBy: 'newest', - category: 'all', - } as const) - + const effectiveFilters = filters ?? buildDefaultFilters() if (!filters && !searchQuery.trim()) { return articles } - return applyFiltersAndSort(articles, searchQuery, effectiveFilters) }, [articles, searchQuery, filters]) - return { - articles: filteredArticles, - allArticles: articles, // Return all articles for filters component - loading, - error, - loadArticleContent, + return { articles: filteredArticles, allArticles: articles, loading, error, loadArticleContent } +} + +function buildDefaultFilters(): { authorPubkey: null; sortBy: 'newest'; category: 'all' } { + return { authorPubkey: null, sortBy: 'newest', category: 'all' } +} + +function useLoadAuthorsFromCache(params: { + setArticles: (value: Article[] | ((prev: Article[]) => Article[])) => void + setLoading: (value: boolean) => void + setError: (value: string | null) => void + hasArticlesRef: { current: boolean } +}): void { + const { setArticles, setLoading, setError, hasArticlesRef } = params + useEffect(() => { + void loadInitialAuthors({ setArticles, setLoading, setError, hasArticlesRef }) + }, [setArticles, setLoading, setError, hasArticlesRef]) +} + +async function loadInitialAuthors(params: { + setArticles: (value: Article[] | ((prev: Article[]) => Article[])) => void + setLoading: (value: boolean) => void + setError: (value: string | null) => void + hasArticlesRef: { current: boolean } +}): Promise { + params.setLoading(true) + params.setError(null) + const hasCachedAuthors = await loadAuthorsFromCache(params) + if (!hasCachedAuthors) { + params.setError(t('common.error.noContent')) + } +} + +async function loadAuthorsFromCache(params: { + setArticles: (value: Article[] | ((prev: Article[]) => Article[])) => void + setLoading: (value: boolean) => void + hasArticlesRef: { current: boolean } +}): Promise { + try { + const authors = (await objectCache.getAll('author')) as Article[] + if (authors.length === 0) { + params.setLoading(false) + const {hasArticlesRef} = params + hasArticlesRef.current = false + return false + } + params.setArticles((prev) => { + const merged = mergeAuthorsIntoArticles({ prev, authors }) + const {hasArticlesRef} = params + hasArticlesRef.current = merged.length > 0 + return merged + }) + params.setLoading(false) + void updateAuthorsSponsoringFromCache({ authors, setArticles: params.setArticles }) + return true + } catch (loadError) { + console.error('Error loading authors from cache:', loadError) + params.setLoading(false) + return false + } +} + +function mergeAuthorsIntoArticles(params: { + prev: Article[] + authors: Article[] +}): Article[] { + const existingIds = new Set(params.prev.map((a) => a.id)) + const newAuthors = params.authors.filter((a) => !existingIds.has(a.id)) + return [...params.prev, ...newAuthors].sort((a, b) => b.createdAt - a.createdAt) +} + +async function updateAuthorsSponsoringFromCache(params: { + authors: Article[] + setArticles: (value: Article[] | ((prev: Article[]) => Article[])) => void +}): Promise { + const authorsNeedingSponsoring = params.authors.filter((a) => a.isPresentation && a.pubkey && a.totalSponsoring === undefined) + if (authorsNeedingSponsoring.length === 0) { + return + } + const sponsoringByAuthorId = await loadSponsoringByAuthorId(authorsNeedingSponsoring) + params.setArticles((prev) => applySponsoringToArticles({ prev, sponsoringByAuthorId })) +} + +async function loadSponsoringByAuthorId(authors: Article[]): Promise> { + const sponsoringResults = await Promise.all(authors.map((author) => loadAuthorSponsoring(author))) + return new Map(sponsoringResults.filter((r): r is { authorId: string; totalSponsoring: number } => Boolean(r)).map((r) => [r.authorId, r.totalSponsoring])) +} + +async function loadAuthorSponsoring(author: Article): Promise<{ authorId: string; totalSponsoring: number } | null> { + if (!author.pubkey) { + return null + } + const totalSponsoring = await getAuthorSponsoring(author.pubkey, true) + return { authorId: author.id, totalSponsoring } +} + +function applySponsoringToArticles(params: { prev: Article[]; sponsoringByAuthorId: Map }): Article[] { + return params.prev.map((article) => { + const totalSponsoring = params.sponsoringByAuthorId.get(article.id) + return totalSponsoring !== undefined && article.isPresentation ? { ...article, totalSponsoring } : article + }) +} + +function createLoadArticleContent(params: { + setArticles: (value: Article[] | ((prev: Article[]) => Article[])) => void + setError: (value: string | null) => void +}): (articleId: string, authorPubkey: string) => Promise
{ + return async (articleId: string, authorPubkey: string): Promise
=> { + try { + const article = await nostrService.getArticleById(articleId) + if (!article) { + return null + } + const decryptedContent = await nostrService.getDecryptedArticleContent(articleId, authorPubkey) + if (decryptedContent) { + params.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) + params.setError(e instanceof Error ? e.message : 'Failed to load article') + return null + } } } diff --git a/hooks/useAuthorPresentation.ts b/hooks/useAuthorPresentation.ts index e3567fb..ea3423a 100644 --- a/hooks/useAuthorPresentation.ts +++ b/hooks/useAuthorPresentation.ts @@ -61,23 +61,9 @@ async function publishAuthorPresentation(params: { 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 { + const result = await publishPresentationArticleWithDraft({ draft: params.draft, pubkey: params.pubkey, privateKey }) + params.setSuccess(result.success === true) + if (!result.success) { params.setError(result.error ?? 'Erreur lors de la publication') } } catch (e) { @@ -89,6 +75,27 @@ async function publishAuthorPresentation(params: { } } +async function publishPresentationArticleWithDraft(params: { + draft: AuthorPresentationDraft + pubkey: string + privateKey: string +}): Promise<{ success: boolean; error?: string }> { + const { title, preview, fullContent } = buildPresentationContent(params.draft) + return 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, + params.privateKey + ) +} + async function updateProfileBestEffort(draft: AuthorPresentationDraft): Promise { const profileUpdates: Partial = { name: draft.authorName.trim(), diff --git a/hooks/useDocs.ts b/hooks/useDocs.ts index 849164e..472b9f1 100644 --- a/hooks/useDocs.ts +++ b/hooks/useDocs.ts @@ -20,12 +20,14 @@ export function useDocs(docs: DocLink[]): { const [loading, setLoading] = useState(false) const loadDoc = useCallback( - createLoadDoc({ - docs, - setLoading, - setSelectedDoc, - setDocContent, - }), + (docId: DocSection): Promise => + loadDocImpl({ + docId, + docs, + setLoading, + setSelectedDoc, + setDocContent, + }), [docs] ) @@ -41,30 +43,29 @@ export function useDocs(docs: DocLink[]): { } } -function createLoadDoc(params: { +async function loadDocImpl(params: { + docId: DocSection docs: DocLink[] setSelectedDoc: (doc: DocSection) => void setDocContent: (value: string) => void setLoading: (value: boolean) => void -}): (docId: DocSection) => Promise { - return async (docId: DocSection): Promise => { - const doc = params.docs.find((d) => d.id === docId) - if (!doc) { - return - } +}): Promise { + const doc = params.docs.find((d) => d.id === params.docId) + if (!doc) { + return + } - params.setLoading(true) - params.setSelectedDoc(docId) + params.setLoading(true) + params.setSelectedDoc(params.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) - } + 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) } } diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index 1ace505..1939044 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -16,59 +16,72 @@ export function useNotifications(userPubkey: string | null): { const [notifications, setNotifications] = useState([]) const [loading, setLoading] = useState(true) - useEffect(() => { - if (!userPubkey) { - return - } - - void loadAndSetNotifications({ setNotifications, setLoading }) - const interval = setInterval(() => { - void loadAndSetNotifications({ setNotifications, setLoading }) - }, POLL_INTERVAL_MS) - return () => clearInterval(interval) - }, [userPubkey]) + useNotificationsPoller({ userPubkey, setNotifications, setLoading }) 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 markAsReadAndRefresh({ notificationId, setNotifications }) - }, - [userPubkey] - ) - - const markAllAsReadHandler = useCallback((): void => { - if (!userPubkey) { - return - } - void markAllAsReadAndRefresh({ setNotifications }) - }, [userPubkey]) - - const deleteNotificationHandler = useCallback( - (notificationId: string): void => { - if (!userPubkey) { - return - } - void deleteNotificationAndRefresh({ notificationId, setNotifications }) - }, - [userPubkey] - ) + const actions = useNotificationActions({ userPubkey, setNotifications }) return { notifications: effectiveNotifications, unreadCount, loading: effectiveLoading, - markAsRead, - markAllAsRead: markAllAsReadHandler, - deleteNotification: deleteNotificationHandler, + markAsRead: actions.markAsRead, + markAllAsRead: actions.markAllAsRead, + deleteNotification: actions.deleteNotification, } } +function useNotificationsPoller(params: { + userPubkey: string | null + setNotifications: (value: Notification[]) => void + setLoading: (value: boolean) => void +}): void { + useEffect(() => { + if (!params.userPubkey) { + return + } + void loadAndSetNotifications({ setNotifications: params.setNotifications, setLoading: params.setLoading }) + const interval = setInterval(() => { + void loadAndSetNotifications({ setNotifications: params.setNotifications, setLoading: params.setLoading }) + }, POLL_INTERVAL_MS) + return () => clearInterval(interval) + }, [params.userPubkey, params.setNotifications, params.setLoading]) +} + +function useNotificationActions(params: { + userPubkey: string | null + setNotifications: (value: Notification[]) => void +}): { + markAsRead: (notificationId: string) => void + markAllAsRead: () => void + deleteNotification: (notificationId: string) => void +} { + const markAsRead = useCallback((notificationId: string): void => { + if (!params.userPubkey) { + return + } + void markAsReadAndRefresh({ notificationId, setNotifications: params.setNotifications }) + }, [params.userPubkey, params.setNotifications]) + + const markAllAsRead = useCallback((): void => { + if (!params.userPubkey) { + return + } + void markAllAsReadAndRefresh({ setNotifications: params.setNotifications }) + }, [params.userPubkey, params.setNotifications]) + + const deleteNotification = useCallback((notificationId: string): void => { + if (!params.userPubkey) { + return + } + void deleteNotificationAndRefresh({ notificationId, setNotifications: params.setNotifications }) + }, [params.userPubkey, params.setNotifications]) + + return { markAsRead, markAllAsRead, deleteNotification } +} + async function loadAndSetNotifications(params: { setNotifications: (value: Notification[]) => void setLoading: (value: boolean) => void diff --git a/hooks/useUserArticles.ts b/hooks/useUserArticles.ts index fcaad82..2609717 100644 --- a/hooks/useUserArticles.ts +++ b/hooks/useUserArticles.ts @@ -49,13 +49,7 @@ export function useUserArticles( setError, }) - return { - articles: filteredArticles, - allArticles: articles, - loading, - error, - loadArticleContent, - } + return { articles: filteredArticles, allArticles: articles, loading, error, loadArticleContent } } function useLoadUserArticlesFromCache(params: { diff --git a/lib/articleMutations.ts b/lib/articleMutations.ts index 8b50a5a..f7251b8 100644 --- a/lib/articleMutations.ts +++ b/lib/articleMutations.ts @@ -9,8 +9,10 @@ import type { ArticleDraft, PublishedArticle } from './articlePublisher' import type { AlbyInvoice } from '@/types/alby' import type { Article, Review, Series } from '@/types/nostr' import { writeOrchestrator } from './writeOrchestrator' -import { finalizeEvent } from 'nostr-tools' +import { finalizeEvent, type EventTemplate } from 'nostr-tools' import { hexToBytes } from 'nostr-tools/utils' +import { buildParsedArticleFromDraft as buildParsedArticleFromDraftCore } from './articleDraftToParsedArticle' +import { getPublishRelays } from './relaySelection' export interface ArticleUpdateResult extends PublishedArticle { originalArticleId: string @@ -45,55 +47,7 @@ async function buildParsedArticleFromDraft( invoice: AlbyInvoice, authorPubkey: string ): Promise<{ article: Article; hash: string; version: number; index: number }> { - const category = mapDraftCategoryToTag(draft.category) - - const hashId = await generatePublicationHashId({ - pubkey: authorPubkey, - title: draft.title, - preview: draft.preview, - category, - seriesId: draft.seriesId ?? undefined, - bannerUrl: draft.bannerUrl ?? undefined, - zapAmount: draft.zapAmount, - }) - - const hash = hashId - const version = 0 - const index = 0 - const id = buildObjectId(hash, index, version) - - const article: Article = { - id, - hash, - version, - index, - pubkey: authorPubkey, - title: draft.title, - preview: draft.preview, - content: '', - description: draft.preview, - contentDescription: draft.preview, - createdAt: Math.floor(Date.now() / 1000), - zapAmount: draft.zapAmount, - paid: false, - thumbnailUrl: draft.bannerUrl ?? '', - invoice: invoice.invoice, - ...(invoice.paymentHash ? { paymentHash: invoice.paymentHash } : {}), - ...(draft.category ? { category: draft.category } : {}), - ...(draft.seriesId ? { seriesId: draft.seriesId } : {}), - ...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}), - ...(draft.pages && draft.pages.length > 0 ? { pages: draft.pages } : {}), - kindType: 'article', - } - - return { article, hash, version, index } -} - -function mapDraftCategoryToTag(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' { - if (category === 'scientific-research') { - return 'research' - } - return 'sciencefiction' + return buildParsedArticleFromDraftCore({ draft, invoice, authorPubkey }) } interface PublishPreviewWithInvoiceParams { @@ -108,20 +62,7 @@ interface PublishPreviewWithInvoiceParams { async function publishPreviewWithInvoice( params: PublishPreviewWithInvoiceParams ): Promise { - // Build parsed article object (use custom article if provided, e.g., for updates with version) - let article: Article - let hash: string - let version: number - let index: number - - if (params.customArticle) { - ;({ hash, version } = params.customArticle) - article = params.customArticle - index = params.customArticle.index ?? 0 - } else { - const built = await buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey) - ;({ article, hash, version, index } = built) - } + const payload = await resolvePublicationPayload(params) // Build event template const previewEventTemplate = await createPreviewEvent({ @@ -143,31 +84,44 @@ async function publishPreviewWithInvoice( const secretKey = hexToBytes(privateKey) const event = finalizeEvent(previewEventTemplate, 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()] + return publishPublicationToRelays({ event, payload }) +} - // Publish via writeOrchestrator (parallel network + local write) +async function resolvePublicationPayload(params: PublishPreviewWithInvoiceParams): Promise<{ + article: Article + hash: string + version: number + index: number +}> { + if (params.customArticle) { + return { + article: params.customArticle, + hash: params.customArticle.hash, + version: params.customArticle.version, + index: params.customArticle.index ?? 0, + } + } + return buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey) +} + +async function publishPublicationToRelays(params: { + event: import('nostr-tools').Event + payload: { article: Article; hash: string; version: number; index: number } +}): Promise { + const relays = await getPublishRelays() const result = await writeOrchestrator.writeAndPublish( { objectType: 'publication', - hash, - event, - parsed: article, - version, + hash: params.payload.hash, + event: params.event, + parsed: params.payload.article, + version: params.payload.version, hidden: false, - index, + index: params.payload.index, }, relays ) - - if (!result.success) { - return null - } - - return event + return result.success ? params.event : null } export async function publishSeries(params: { @@ -180,72 +134,49 @@ export async function publishSeries(params: { authorPrivateKey?: string }): Promise { ensureKeys(params.authorPubkey, params.authorPrivateKey) - const {category} = params - requireCategory(category) + const category = requireSeriesCategory(params.category) + const newCategory = mapSeriesCategoryToTag(category) + const preview = buildSeriesPreview(params.preview, params.description) - // Map category to new system - const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research' - - // Generate hash ID from series data const hashId = await generateSeriesHashId({ pubkey: params.authorPubkey, title: params.title, description: params.description, category: newCategory, - coverUrl: params.coverUrl ?? undefined, + ...(params.coverUrl ? { coverUrl: params.coverUrl } : {}), }) - const hash = hashId - const version = 0 - const index = 0 - const id = buildObjectId(hash, index, version) - - // Build parsed Series object - const parsedSeries: Series = { - id, - hash, - version, - index, - pubkey: params.authorPubkey, + const parsedSeries = buildParsedSeries({ + authorPubkey: params.authorPubkey, title: params.title, description: params.description, - preview: params.preview ?? params.description.substring(0, 200), - thumbnailUrl: params.coverUrl ?? '', + preview, + coverUrl: params.coverUrl, category, - ...(params.coverUrl ? { coverUrl: params.coverUrl } : {}), - kindType: 'series', - } + hashId, + }) - // Build event template - const eventTemplate = await buildSeriesEvent(params, category) + const eventTemplate = buildSeriesEventTemplate({ + authorPubkey: params.authorPubkey, + title: params.title, + description: params.description, + preview, + coverUrl: params.coverUrl, + category: newCategory, + hashId, + }) - // Set private key in orchestrator - const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey() - if (!privateKey) { - throw new Error('Private key required for signing') - } - writeOrchestrator.setPrivateKey(privateKey) - - // Finalize event - const secretKey = hexToBytes(privateKey) - 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 event = finalizeEvent(eventTemplate, hexToBytes(getPrivateKeyForSigning(params.authorPrivateKey))) + const relays = await getPublishRelays() const result = await writeOrchestrator.writeAndPublish( { objectType: 'series', - hash, + hash: parsedSeries.hash, event, parsed: parsedSeries, - version, + version: parsedSeries.version, hidden: false, - index, + index: parsedSeries.index, }, relays ) @@ -257,70 +188,104 @@ export async function publishSeries(params: { return parsedSeries } -async function buildSeriesEvent( - params: { - title: string - description: string - preview?: string - coverUrl?: string - authorPubkey: string - }, +function requireSeriesCategory(category: ArticleDraft['category']): NonNullable { + requireCategory(category) + return category +} + +function mapSeriesCategoryToTag(category: NonNullable): 'sciencefiction' | 'research' { + return category === 'science-fiction' ? 'sciencefiction' : 'research' +} + +function buildSeriesPreview(preview: string | undefined, description: string): string { + return preview ?? description.substring(0, 200) +} + +function buildParsedSeries(params: { + authorPubkey: string + title: string + description: string + preview: string + coverUrl: string | undefined category: NonNullable -): Promise<{ - kind: number - created_at: number - content: string - tags: string[][] -}> { - // Map category to new system - const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research' - - // Generate hash ID from series data - const hashId = await generateSeriesHashId({ + hashId: string +}): Series { + const hash = params.hashId + const version = 0 + const index = 0 + const id = buildObjectId(hash, index, version) + return { + id, + hash, + version, + index, pubkey: params.authorPubkey, title: params.title, description: params.description, - category: newCategory, - coverUrl: params.coverUrl ?? undefined, - }) - - // Build JSON metadata - const seriesJson = JSON.stringify({ - type: 'series', - pubkey: params.authorPubkey, - title: params.title, - description: params.description, - preview: params.preview ?? params.description.substring(0, 200), - coverUrl: params.coverUrl, - category: newCategory, - id: hashId, - version: 0, - index: 0, - }) + preview: params.preview, + thumbnailUrl: params.coverUrl ?? '', + category: params.category, + ...(params.coverUrl ? { coverUrl: params.coverUrl } : {}), + kindType: 'series', + } +} +function buildSeriesEventTemplate(params: { + authorPubkey: string + title: string + description: string + preview: string + coverUrl: string | undefined + category: 'sciencefiction' | 'research' + hashId: string +}): EventTemplate { const tags = buildTags({ type: 'series', - category: newCategory, - id: hashId, + category: params.category, + id: params.hashId, service: PLATFORM_SERVICE, - version: 0, // New object + version: 0, hidden: false, paywall: false, title: params.title, description: params.description, - preview: params.preview ?? params.description.substring(0, 200), + preview: params.preview, ...(params.coverUrl ? { coverUrl: params.coverUrl } : {}), }) + tags.push(['json', buildSeriesJson(params)]) + return { kind: 1, created_at: Math.floor(Date.now() / 1000), content: params.preview, tags } +} - // Add JSON metadata as a tag - tags.push(['json', seriesJson]) +function buildSeriesJson(params: { + authorPubkey: string + title: string + description: string + preview: string + coverUrl: string | undefined + category: 'sciencefiction' | 'research' + hashId: string +}): string { + return JSON.stringify({ + type: 'series', + pubkey: params.authorPubkey, + title: params.title, + description: params.description, + preview: params.preview, + ...(params.coverUrl ? { coverUrl: params.coverUrl } : {}), + category: params.category, + id: params.hashId, + version: 0, + index: 0, + }) +} - return { - kind: 1, - created_at: Math.floor(Date.now() / 1000), - content: params.preview ?? params.description.substring(0, 200), - tags, +function getPrivateKeyForSigning(authorPrivateKey: string | undefined): string { + const privateKey = authorPrivateKey ?? nostrService.getPrivateKey() + if (!privateKey) { + throw new Error('Private key required for signing') } + writeOrchestrator.setPrivateKey(privateKey) + return privateKey } export async function publishReview(params: { @@ -529,62 +494,53 @@ async function publishUpdate( authorPubkey: string, originalArticleId: string ): Promise { - const {category} = draft - requireCategory(category) - - // Get original article from IndexedDB to retrieve current version - const { objectCache } = await import('./objectCache') - const originalArticle = await objectCache.getById('publication', originalArticleId) as Article | null - + const category = requireUpdateCategory(draft) + const originalArticle = await loadOriginalArticleForUpdate(originalArticleId) if (!originalArticle) { return updateFailure(originalArticleId, 'Original article not found in cache') } - - // Verify user is the author if (originalArticle.pubkey !== authorPubkey) { return updateFailure(originalArticleId, 'Only the author can update this article') } const presentationId = await ensurePresentation(authorPubkey) const invoice = await createArticleInvoice(draft) - const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research' - - // Use current version from original article + const newCategory = mapPublicationCategoryToTag(category) const currentVersion = originalArticle.version ?? 0 - const updateTags = await buildUpdateTags({ - draft, - originalArticleId, - newCategory, - authorPubkey, - currentVersion, - }) - // Build parsed article with incremented version - const { article } = await buildParsedArticleFromDraft(draft, invoice, authorPubkey) - const updatedArticle: Article = { - ...article, - version: currentVersion + 1, // Increment version for update - } - - const publishedEvent = await publishPreviewWithInvoice({ - draft, - invoice, - authorPubkey, - presentationId, - extraTags: updateTags, - customArticle: updatedArticle, - }) + const updateTags = await buildUpdateTags({ draft, originalArticleId, newCategory, authorPubkey, currentVersion }) + const updatedArticle = await buildUpdatedArticleForUpdate({ draft, invoice, authorPubkey, currentVersion }) + const publishedEvent = await publishPreviewWithInvoice({ draft, invoice, authorPubkey, presentationId, extraTags: updateTags, customArticle: updatedArticle }) if (!publishedEvent) { return updateFailure(originalArticleId, 'Failed to publish article update') } + await storePrivateContent({ articleId: publishedEvent.id, content: draft.content, authorPubkey, invoice }) - return { - articleId: publishedEvent.id, - previewEventId: publishedEvent.id, - invoice, - success: true, - originalArticleId, - } + return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true, originalArticleId } +} + +function requireUpdateCategory(draft: ArticleDraft): NonNullable { + requireCategory(draft.category) + return draft.category +} + +async function loadOriginalArticleForUpdate(originalArticleId: string): Promise
{ + const { objectCache } = await import('./objectCache') + return (await objectCache.getById('publication', originalArticleId)) as Article | null +} + +function mapPublicationCategoryToTag(category: NonNullable): 'sciencefiction' | 'research' { + return category === 'science-fiction' ? 'sciencefiction' : 'research' +} + +async function buildUpdatedArticleForUpdate(params: { + draft: ArticleDraft + invoice: AlbyInvoice + authorPubkey: string + currentVersion: number +}): Promise
{ + const { article } = await buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey) + return { ...article, version: params.currentVersion + 1 } } export async function publishArticleUpdate( @@ -622,7 +578,7 @@ export async function deleteArticleEvent(articleId: string, authorPubkey: string const deletePayload = await buildDeletedArticlePayload({ originalParsed, deleteEventTemplate }) const event = await finalizeEventTemplate({ template: deleteEventTemplate, authorPrivateKey }) - const relays = await getActiveRelaysOrPrimary() + const relays = await getPublishRelays() await publishDeletion({ event, relays, payload: deletePayload }) } @@ -691,16 +647,6 @@ async function finalizeEventTemplate(params: { return finalizeNostrEvent(params.template, secretKey) } -async function getActiveRelaysOrPrimary(): Promise { - const { relaySessionManager } = await import('./relaySessionManager') - const activeRelays = await relaySessionManager.getActiveRelays() - if (activeRelays.length > 0) { - return activeRelays - } - const { getPrimaryRelay } = await import('./config') - return [await getPrimaryRelay()] -} - async function publishDeletion(params: { event: import('nostr-tools').Event relays: string[] diff --git a/lib/articlePublisherHelpersPresentation.ts b/lib/articlePublisherHelpersPresentation.ts index 87486f8..e01b11f 100644 --- a/lib/articlePublisherHelpersPresentation.ts +++ b/lib/articlePublisherHelpersPresentation.ts @@ -396,7 +396,7 @@ function readOptionalStringFields( keys: TKeys ): Partial> { const result: Partial> = {} - for (const key of keys) { + for (const key of keys as ReadonlyArray) { const value = obj[key] if (typeof value === 'string') { result[key] = value diff --git a/lib/articlePublisherPublish.ts b/lib/articlePublisherPublish.ts index d3c3b76..b0cdd5a 100644 --- a/lib/articlePublisherPublish.ts +++ b/lib/articlePublisherPublish.ts @@ -8,9 +8,9 @@ import type { PublishResult } from './publishResult' import { writeOrchestrator } from './writeOrchestrator' import { finalizeEvent } from 'nostr-tools' import { hexToBytes } from 'nostr-tools/utils' -import { generatePublicationHashId } from './hashIdGenerator' -import { buildObjectId } from './urlGenerator' import type { Article } from '@/types/nostr' +import { buildParsedArticleFromDraft as buildParsedArticleFromDraftCore } from './articleDraftToParsedArticle' +import { getPublishRelays } from './relaySelection' export function buildFailure(error?: string): PublishedArticle { const base: PublishedArticle = { @@ -26,55 +26,11 @@ async function buildParsedArticleFromDraft( invoice: AlbyInvoice, authorPubkey: string ): Promise<{ article: Article; hash: string; version: number; index: number }> { - const category = mapDraftCategoryToTag(draft.category) - - const hashId = await generatePublicationHashId({ - pubkey: authorPubkey, - title: draft.title, - preview: draft.preview, - category, - seriesId: draft.seriesId ?? undefined, - bannerUrl: draft.bannerUrl ?? undefined, - zapAmount: draft.zapAmount, + return buildParsedArticleFromDraftCore({ + draft, + invoice, + authorPubkey, }) - - const hash = hashId - const version = 0 - const index = 0 - const id = buildObjectId(hash, index, version) - - const article: Article = { - id, - hash, - version, - index, - pubkey: authorPubkey, - title: draft.title, - preview: draft.preview, - content: '', - description: draft.preview, - contentDescription: draft.preview, - createdAt: Math.floor(Date.now() / 1000), - zapAmount: draft.zapAmount, - paid: false, - thumbnailUrl: draft.bannerUrl ?? '', - invoice: invoice.invoice, - ...(invoice.paymentHash ? { paymentHash: invoice.paymentHash } : {}), - ...(draft.category ? { category: draft.category } : {}), - ...(draft.seriesId ? { seriesId: draft.seriesId } : {}), - ...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}), - ...(draft.pages && draft.pages.length > 0 ? { pages: draft.pages } : {}), - kindType: 'article', - } - - return { article, hash, version, index } -} - -function mapDraftCategoryToTag(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' { - if (category === 'scientific-research') { - return 'research' - } - return 'sciencefiction' } interface PublishPreviewParams { @@ -91,39 +47,41 @@ interface PublishPreviewParams { export async function publishPreview( params: PublishPreviewParams ): Promise { - const { draft, invoice, authorPubkey, presentationId, extraTags, encryptedContent, encryptedKey, returnStatus } = params - // Build parsed article object - const { article, hash, version, index } = await buildParsedArticleFromDraft(draft, invoice, authorPubkey) + const published = await publishPreviewToRelays(params) + if (!published) { + return null + } + if (params.returnStatus) { + return { + event: published.event, + relayStatuses: buildRelayStatuses({ relays: published.relays, published: published.published }), + } + } + return published.event +} - // Build event template +async function publishPreviewToRelays(params: PublishPreviewParams): Promise<{ + event: import('nostr-tools').Event + relays: string[] + published: false | string[] +} | null> { + const { article, hash, version, index } = await buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey) const previewEventTemplate = await createPreviewEvent({ - draft, - invoice, - authorPubkey, - authorPresentationId: presentationId, - ...(extraTags ? { extraTags } : {}), - ...(encryptedContent ? { encryptedContent } : {}), - ...(encryptedKey ? { encryptedKey } : {}), + draft: params.draft, + invoice: params.invoice, + authorPubkey: params.authorPubkey, + authorPresentationId: params.presentationId, + ...(params.extraTags ? { extraTags: params.extraTags } : {}), + ...(params.encryptedContent ? { encryptedContent: params.encryptedContent } : {}), + ...(params.encryptedKey ? { encryptedKey: params.encryptedKey } : {}), }) - // Set private key in orchestrator - const privateKey = nostrService.getPrivateKey() - if (!privateKey) { - throw new Error('Private key required for signing') - } + const privateKey = getPrivateKeyOrThrow() writeOrchestrator.setPrivateKey(privateKey) - - // Finalize event const secretKey = hexToBytes(privateKey) const event = finalizeEvent(previewEventTemplate, 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 relays = await getPublishRelays() const result = await writeOrchestrator.writeAndPublish( { objectType: 'publication', @@ -136,27 +94,29 @@ export async function publishPreview( }, relays ) - if (!result.success) { return null } + return { event, relays, published: result.published } +} - if (returnStatus) { - // Return PublishResult format - return { - event, - relayStatuses: relays.map((relayUrl, _idx) => { - const isSuccess = typeof result.published === 'object' && result.published.includes(relayUrl) - return { - relayUrl, - success: isSuccess, - error: isSuccess ? undefined : 'Failed to publish', - } - }), - } +function getPrivateKeyOrThrow(): string { + const privateKey = nostrService.getPrivateKey() + if (!privateKey) { + throw new Error('Private key required for signing') } + return privateKey +} - return event +function buildRelayStatuses(params: { relays: string[]; published: false | string[] }): import('./publishResult').RelayPublishStatus[] { + return params.relays.map((relayUrl) => { + const isSuccess = Array.isArray(params.published) && params.published.includes(relayUrl) + return { + relayUrl, + success: isSuccess, + error: isSuccess ? undefined : 'Failed to publish', + } + }) } export function buildArticleExtraTags(draft: ArticleDraft, _category: NonNullable): string[][] { @@ -179,62 +139,91 @@ export async function encryptAndPublish( presentationId: string } ): Promise { - const { draft, authorPubkey, authorPrivateKeyForEncryption, category, presentationId } = params - const { encryptedContent, key, iv } = await encryptArticleContent(draft.content) - const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey) - const invoice = await createArticleInvoice(draft) - const extraTags = buildArticleExtraTags(draft, category) - const publishResult = await publishPreview({ - draft, - invoice, - authorPubkey, - presentationId, - extraTags, - encryptedContent, - encryptedKey, - returnStatus: true, - }) - - if (!publishResult) { - return buildFailure('Failed to publish article') - } - - // Handle both old format (Event | null) and new format (PublishResult) - let event: import('nostr-tools').Event | null = null - let relayStatuses: import('./publishResult').RelayPublishStatus[] | undefined - - if (publishResult && 'event' in publishResult && 'relayStatuses' in publishResult) { - // New format with statuses - ;({ event, relayStatuses } = publishResult) - } else if (publishResult && 'id' in publishResult) { - // Old format (Event) - event = publishResult - } - - if (!event) { + const encrypted = await encryptDraftForPublishing(params) + const publishResult = await publishEncryptedPreview({ ...params, ...encrypted }) + const normalized = normalizePublishResult(publishResult) + if (!normalized) { return buildFailure('Failed to publish article') } await storePrivateContent({ - articleId: event.id, - content: draft.content, - authorPubkey, - invoice, - decryptionKey: key, - decryptionIV: iv, + articleId: normalized.event.id, + content: params.draft.content, + authorPubkey: params.authorPubkey, + invoice: encrypted.invoice, + decryptionKey: encrypted.key, + decryptionIV: encrypted.iv, }) + console.warn('Article published with encrypted content', { - articleId: event.id, - authorPubkey, + articleId: normalized.event.id, + authorPubkey: params.authorPubkey, timestamp: new Date().toISOString(), - relayStatuses, + relayStatuses: normalized.relayStatuses, }) return { - articleId: event.id, - previewEventId: event.id, - invoice, + articleId: normalized.event.id, + previewEventId: normalized.event.id, + invoice: encrypted.invoice, success: true, - relayStatuses, + ...(normalized.relayStatuses ? { relayStatuses: normalized.relayStatuses } : {}), } } + +async function encryptDraftForPublishing(params: { + draft: ArticleDraft + authorPubkey: string + authorPrivateKeyForEncryption: string +}): Promise<{ + encryptedContent: string + encryptedKey: string + key: string + iv: string + invoice: AlbyInvoice +}> { + const { encryptedContent, key, iv } = await encryptArticleContent(params.draft.content) + const encryptedKey = await encryptDecryptionKey(key, iv, params.authorPrivateKeyForEncryption, params.authorPubkey) + const invoice = await createArticleInvoice(params.draft) + return { encryptedContent, encryptedKey, key, iv, invoice } +} + +async function publishEncryptedPreview(params: { + draft: ArticleDraft + invoice: AlbyInvoice + authorPubkey: string + category: NonNullable + presentationId: string + encryptedContent: string + encryptedKey: string +}): Promise { + const extraTags = buildArticleExtraTags(params.draft, params.category) + return publishPreview({ + draft: params.draft, + invoice: params.invoice, + authorPubkey: params.authorPubkey, + presentationId: params.presentationId, + extraTags, + encryptedContent: params.encryptedContent, + encryptedKey: params.encryptedKey, + returnStatus: true, + }) +} + +function normalizePublishResult( + value: import('nostr-tools').Event | null | PublishResult +): { event: import('nostr-tools').Event; relayStatuses: import('./publishResult').RelayPublishStatus[] | undefined } | null { + if (!value) { + return null + } + if ('event' in value && 'relayStatuses' in value) { + if (!value.event) { + return null + } + return { event: value.event, relayStatuses: value.relayStatuses } + } + if ('id' in value) { + return { event: value, relayStatuses: undefined } + } + return null +} diff --git a/lib/automaticTransfer.ts b/lib/automaticTransfer.ts index ce571d8..ad9912b 100644 --- a/lib/automaticTransfer.ts +++ b/lib/automaticTransfer.ts @@ -92,14 +92,14 @@ export class AutomaticTransferService { }) } - private async transferPortion(params: { + private async transferPortion(params: { type: 'article' | 'review' id: string pubkey: string recipient: string paymentAmount: number - computeSplit: (amount: number) => { platform: number } & Record - getRecipientAmount: (split: { platform: number } & Record) => number + computeSplit: (amount: number) => TSplit + getRecipientAmount: (split: TSplit) => number missingRecipientError: string errorLogMessage: string }): Promise { diff --git a/lib/nostr.ts b/lib/nostr.ts index 64466b5..4533f83 100644 --- a/lib/nostr.ts +++ b/lib/nostr.ts @@ -89,96 +89,7 @@ class NostrService { const relayStatuses: RelayPublishStatus[] = [] // Start publishing asynchronously (don't await - non-blocking) - void (async (): Promise => { - try { - if (activeRelays.length === 0) { - // Fallback to primary relay if no active relays - const relayUrl = await getPrimaryRelay() - if (!this.pool) { - throw new Error('Pool not initialized') - } - const pubs = this.pool.publish([relayUrl], event) - const results = await Promise.allSettled(pubs) - - const successfulRelays: string[] = [] - const { publishLog } = await import('./publishLog') - - results.forEach((result) => { - if (result.status === 'fulfilled') { - successfulRelays.push(relayUrl) - // Log successful publication - void publishLog.logPublication({ - eventId: event.id, - relayUrl, - success: true, - }) - } else { - const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason) - console.error(`[NostrService] Relay ${relayUrl} failed during publish:`, result.reason) - relaySessionManager.markRelayFailed(relayUrl) - // Log failed publication - void publishLog.logPublication({ - eventId: event.id, - relayUrl, - success: false, - error: errorMessage, - }) - } - }) - - // Update published status in IndexedDB - await this.updatePublishedStatus(event.id, successfulRelays.length > 0 ? successfulRelays : false) - } else { - // Publish to all active relays - console.warn(`[NostrService] Publishing event ${event.id} to ${activeRelays.length} active relay(s)`) - if (!this.pool) { - throw new Error('Pool not initialized') - } - const pubs = this.pool.publish(activeRelays, event) - - // Track failed relays and mark them inactive for the session - const results = await Promise.allSettled(pubs) - const successfulRelays: string[] = [] - - const { publishLog } = await import('./publishLog') - - results.forEach((result, index) => { - const relayUrl = activeRelays[index] - if (!relayUrl) { - return - } - - if (result.status === 'fulfilled') { - successfulRelays.push(relayUrl) - // Log successful publication - void publishLog.logPublication({ - eventId: event.id, - relayUrl, - success: true, - }) - } else { - const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason) - console.error(`[NostrService] Relay ${relayUrl} failed during publish:`, result.reason) - relaySessionManager.markRelayFailed(relayUrl) - // Log failed publication - void publishLog.logPublication({ - eventId: event.id, - relayUrl, - success: false, - error: errorMessage, - }) - } - }) - - // Update published status in IndexedDB - await this.updatePublishedStatus(event.id, successfulRelays.length > 0 ? successfulRelays : false) - } - } catch (publishError) { - console.error(`[NostrService] Error during publish (non-blocking):`, publishError) - // Mark as not published if all relays failed - await this.updatePublishedStatus(event.id, false) - } - })() + void this.publishEventNonBlocking({ event, activeRelays, relaySessionManager }) // Build statuses for return (synchronous, before network completes) if (returnStatus) { @@ -199,6 +110,54 @@ class NostrService { return event } + private async publishEventNonBlocking(params: { + event: Event + activeRelays: string[] + relaySessionManager: typeof import('./relaySessionManager').relaySessionManager + }): Promise { + try { + const successfulRelays = await this.publishToRelaysAndLog({ + event: params.event, + relays: params.activeRelays.length > 0 ? params.activeRelays : [await getPrimaryRelay()], + relaySessionManager: params.relaySessionManager, + }) + await this.updatePublishedStatus(params.event.id, successfulRelays.length > 0 ? successfulRelays : false) + } catch (publishError) { + console.error(`[NostrService] Error during publish (non-blocking):`, publishError) + await this.updatePublishedStatus(params.event.id, false) + } + } + + private async publishToRelaysAndLog(params: { + event: Event + relays: string[] + relaySessionManager: typeof import('./relaySessionManager').relaySessionManager + }): Promise { + if (!this.pool) { + throw new Error('Pool not initialized') + } + const pubs = this.pool.publish(params.relays, params.event) + const results = await Promise.allSettled(pubs) + const successfulRelays: string[] = [] + const { publishLog } = await import('./publishLog') + results.forEach((result, index) => { + const relayUrl = params.relays[index] + if (!relayUrl) { + return + } + if (result.status === 'fulfilled') { + successfulRelays.push(relayUrl) + void publishLog.logPublication({ eventId: params.event.id, relayUrl, success: true }) + return + } + const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason) + console.error(`[NostrService] Relay ${relayUrl} failed during publish:`, result.reason) + params.relaySessionManager.markRelayFailed(relayUrl) + void publishLog.logPublication({ eventId: params.event.id, relayUrl, success: false, error: errorMessage }) + }) + return successfulRelays + } + private createArticleSubscription(pool: SimplePool, limit: number): ReturnType { // Subscribe to both 'publication' and 'author' type events // Authors are identified by tag type='author' in the tag system diff --git a/lib/nostrTagSystemExtract.ts b/lib/nostrTagSystemExtract.ts index 5e17d28..0387e6d 100644 --- a/lib/nostrTagSystemExtract.ts +++ b/lib/nostrTagSystemExtract.ts @@ -50,13 +50,6 @@ export function extractCommonTags(findTag: (key: string) => string | undefined, } } -function addOptionalString(target: Record, key: string, value: string | undefined): Record { - 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 @@ -117,24 +110,29 @@ function buildOptionalCommonTagFields(base: { reviewerPubkey: string | undefined json: string | undefined }): Record { - let optional: Record = {} - 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 + return filterNonEmptyStrings({ + id: base.id, + service: base.service, + title: base.title, + preview: base.preview, + description: base.description, + mainnetAddress: base.mainnetAddress, + pictureUrl: base.pictureUrl, + seriesId: base.seriesId, + coverUrl: base.coverUrl, + bannerUrl: base.bannerUrl, + invoice: base.invoice, + paymentHash: base.paymentHash, + encryptedKey: base.encryptedKey, + articleId: base.articleId, + reviewerPubkey: base.reviewerPubkey, + json: base.json, + }) +} + +function filterNonEmptyStrings(values: Record): Record { + const entries = Object.entries(values).filter(([, value]) => typeof value === 'string' && value.length > 0) + return Object.fromEntries(entries) as Record } function buildOptionalNumericFields(base: { totalSponsoring: number | undefined; zapAmount: number | undefined }): { diff --git a/lib/paymentNotes.ts b/lib/paymentNotes.ts index 3239f3f..e2e43b2 100644 --- a/lib/paymentNotes.ts +++ b/lib/paymentNotes.ts @@ -321,7 +321,25 @@ async function buildSponsoringNotePayload(params: { eventTemplate: EventTemplate parsedSponsoring: Sponsoring }> { - const sponsoringData = { + const sponsoringData = buildSponsoringHashInput(params) + const hashId = await generateSponsoringHashId(sponsoringData) + const id = buildObjectId(hashId, 0, 0) + const tags = buildSponsoringNoteTags({ ...params, hashId }) + tags.push(['json', buildSponsoringPaymentJson({ ...params, sponsoringData, id, hashId })]) + const parsedSponsoring = buildParsedSponsoring({ ...params, id, hashId }) + const eventTemplate = buildSponsoringEventTemplate({ tags, content: buildSponsoringNoteContent(params) }) + return { hashId, eventTemplate, parsedSponsoring } +} + +function buildSponsoringHashInput(params: { + payerPubkey: string + authorPubkey: string + amount: number + paymentHash: string + seriesId?: string + articleId?: string +}): Parameters[0] { + return { payerPubkey: params.payerPubkey, authorPubkey: params.authorPubkey, amount: params.amount, @@ -329,15 +347,22 @@ async function buildSponsoringNotePayload(params: { ...(params.seriesId ? { seriesId: params.seriesId } : {}), ...(params.articleId ? { articleId: params.articleId } : {}), } +} - const hashId = await generateSponsoringHashId(sponsoringData) - const id = buildObjectId(hashId, 0, 0) - const tags = buildSponsoringNoteTags({ ...params, hashId }) - tags.push(['json', buildSponsoringPaymentJson({ ...params, sponsoringData, id, hashId })]) - - const parsedSponsoring: Sponsoring = { - id, - hash: hashId, +function buildParsedSponsoring(params: { + authorPubkey: string + payerPubkey: string + amount: number + paymentHash: string + seriesId?: string + articleId?: string + text?: string + id: string + hashId: string +}): Sponsoring { + return { + id: params.id, + hash: params.hashId, version: 0, index: 0, payerPubkey: params.payerPubkey, @@ -350,15 +375,10 @@ async function buildSponsoringNotePayload(params: { ...(params.text ? { text: params.text } : {}), kindType: 'sponsoring', } +} - const eventTemplate: EventTemplate = { - kind: 1, - created_at: Math.floor(Date.now() / 1000), - tags, - content: buildSponsoringNoteContent(params), - } - - return { hashId, eventTemplate, parsedSponsoring } +function buildSponsoringEventTemplate(params: { tags: string[][]; content: string }): EventTemplate { + return { kind: 1, created_at: Math.floor(Date.now() / 1000), tags: params.tags, content: params.content } } function buildSponsoringNoteContent(params: { diff --git a/lib/platformTracking.ts b/lib/platformTracking.ts index 28a85a6..200151d 100644 --- a/lib/platformTracking.ts +++ b/lib/platformTracking.ts @@ -98,63 +98,11 @@ export class PlatformTrackingService { */ async getArticleDeliveries(articleId: string): Promise { try { - const { websocketService } = await import('./websocketService') const { getPrimaryRelaySync } = await import('./config') - const { swClient } = await import('./swClient') - - const filters = [ - { - kinds: [getTrackingKind()], - '#p': [this.platformPubkey], - '#article': [articleId], - limit: 100, - }, - ] - const relayUrl = getPrimaryRelaySync() - - return new Promise((resolve) => { - const deliveries: ContentDeliveryTracking[] = [] - let resolved = false - let unsubscribe: (() => void) | null = null - let eoseReceived = false - - const finalize = (): void => { - if (resolved) { - return - } - resolved = true - if (unsubscribe) { - unsubscribe() - } - resolve(deliveries) - } - - // Subscribe via websocketService (routes to Service Worker) - void websocketService.subscribe([relayUrl], filters, (event: Event) => { - const delivery = parseTrackingEvent(event) - if (delivery) { - deliveries.push(delivery) - } - }).then((unsub) => { - unsubscribe = unsub - }) - - // Listen for EOSE via Service Worker messages - const handleEOSE = (data: unknown): void => { - const eoseData = data as { relays: string[] } - if (eoseData.relays.includes(relayUrl) && !eoseReceived) { - eoseReceived = true - finalize() - } - } - swClient.onMessage('WEBSOCKET_EOSE', handleEOSE) - - setTimeout(() => { - if (!eoseReceived) { - finalize() - } - }, 5000) + return queryDeliveries({ + relayUrl, + filters: [buildArticleDeliveryFilter({ platformPubkey: this.platformPubkey, articleId })], }) } catch (error) { console.error('Error querying article deliveries', { @@ -171,63 +119,11 @@ export class PlatformTrackingService { */ async getRecipientDeliveries(recipientPubkey: string): Promise { try { - const { websocketService } = await import('./websocketService') const { getPrimaryRelaySync } = await import('./config') - const { swClient } = await import('./swClient') - - const filters = [ - { - kinds: [getTrackingKind()], - '#p': [this.platformPubkey], - '#recipient': [recipientPubkey], - limit: 100, - }, - ] - const relayUrl = getPrimaryRelaySync() - - return new Promise((resolve) => { - const deliveries: ContentDeliveryTracking[] = [] - let resolved = false - let unsubscribe: (() => void) | null = null - let eoseReceived = false - - const finalize = (): void => { - if (resolved) { - return - } - resolved = true - if (unsubscribe) { - unsubscribe() - } - resolve(deliveries) - } - - // Subscribe via websocketService (routes to Service Worker) - void websocketService.subscribe([relayUrl], filters, (event: Event) => { - const delivery = parseTrackingEvent(event) - if (delivery) { - deliveries.push(delivery) - } - }).then((unsub) => { - unsubscribe = unsub - }) - - // Listen for EOSE via Service Worker messages - const handleEOSE = (data: unknown): void => { - const eoseData = data as { relays: string[] } - if (eoseData.relays.includes(relayUrl) && !eoseReceived) { - eoseReceived = true - finalize() - } - } - swClient.onMessage('WEBSOCKET_EOSE', handleEOSE) - - setTimeout(() => { - if (!eoseReceived) { - finalize() - } - }, 5000) + return queryDeliveries({ + relayUrl, + filters: [buildRecipientDeliveryFilter({ platformPubkey: this.platformPubkey, recipientPubkey })], }) } catch (error) { console.error('Error querying recipient deliveries', { @@ -240,3 +136,128 @@ export class PlatformTrackingService { } export const platformTracking = new PlatformTrackingService() + +function buildArticleDeliveryFilter(params: { platformPubkey: string; articleId: string }): Record { + return { + kinds: [getTrackingKind()], + '#p': [params.platformPubkey], + '#article': [params.articleId], + limit: 100, + } +} + +function buildRecipientDeliveryFilter(params: { platformPubkey: string; recipientPubkey: string }): Record { + return { + kinds: [getTrackingKind()], + '#p': [params.platformPubkey], + '#recipient': [params.recipientPubkey], + limit: 100, + } +} + +async function queryDeliveries(params: { + relayUrl: string + filters: Record[] +}): Promise { + const { websocketService } = await import('./websocketService') + const { swClient } = await import('./swClient') + return createDeliveryQueryPromise({ websocketService, swClient, relayUrl: params.relayUrl, filters: params.filters }) +} + +function createDeliveryQueryPromise(params: { + websocketService: typeof import('./websocketService').websocketService + swClient: typeof import('./swClient').swClient + relayUrl: string + filters: Record[] +}): Promise { + return new Promise((resolve) => { + const state = createDeliveryQueryState({ resolve }) + startDeliverySubscription({ websocketService: params.websocketService, relayUrl: params.relayUrl, filters: params.filters, state }) + attachEoseListener({ swClient: params.swClient, relayUrl: params.relayUrl, state, timeoutMs: 5000 }) + }) +} + +function createDeliveryQueryState(params: { resolve: (value: ContentDeliveryTracking[]) => void }): { + deliveries: ContentDeliveryTracking[] + addDelivery: (delivery: ContentDeliveryTracking) => void + finalize: () => void + setUnsubscribe: (unsub: (() => void) | null) => void + markEoseReceived: () => void + isEoseReceived: () => boolean +} { + const deliveries: ContentDeliveryTracking[] = [] + let resolved = false + let unsubscribe: (() => void) | null = null + let eoseReceived = false + + const finalize = (): void => { + if (resolved) { + return + } + resolved = true + unsubscribe?.() + params.resolve(deliveries) + } + + return { + deliveries, + addDelivery: (delivery): void => { + deliveries.push(delivery) + }, + finalize, + setUnsubscribe: (unsub): void => { + unsubscribe = unsub + }, + markEoseReceived: (): void => { + eoseReceived = true + }, + isEoseReceived: (): boolean => eoseReceived, + } +} + +function startDeliverySubscription(params: { + websocketService: typeof import('./websocketService').websocketService + relayUrl: string + filters: Record[] + state: { addDelivery: (delivery: ContentDeliveryTracking) => void; setUnsubscribe: (unsub: (() => void) | null) => void } +}): void { + void params.websocketService.subscribe([params.relayUrl], params.filters, (event: Event) => { + const delivery = parseTrackingEvent(event) + if (delivery) { + params.state.addDelivery(delivery) + } + }).then((unsub) => { + params.state.setUnsubscribe(unsub) + }) +} + +function attachEoseListener(params: { + swClient: typeof import('./swClient').swClient + relayUrl: string + timeoutMs: number + state: { finalize: () => void; markEoseReceived: () => void; isEoseReceived: () => boolean } +}): void { + const unsubscribeEose = params.swClient.onMessage('WEBSOCKET_EOSE', (data: unknown): void => { + const relays = readEoseRelays(data) + if (relays && relays.includes(params.relayUrl) && !params.state.isEoseReceived()) { + params.state.markEoseReceived() + unsubscribeEose() + params.state.finalize() + } + }) + + setTimeout(() => { + if (!params.state.isEoseReceived()) { + unsubscribeEose() + params.state.finalize() + } + }, params.timeoutMs) +} + +function readEoseRelays(data: unknown): string[] | null { + if (typeof data !== 'object' || data === null) { + return null + } + const maybe = data as { relays?: unknown } + return Array.isArray(maybe.relays) && maybe.relays.every((r) => typeof r === 'string') ? (maybe.relays) : null +} diff --git a/lib/publishWorker.ts b/lib/publishWorker.ts index f069444..6e45159 100644 --- a/lib/publishWorker.ts +++ b/lib/publishWorker.ts @@ -7,6 +7,7 @@ import { objectCache, type ObjectType } from './objectCache' import { relaySessionManager } from './relaySessionManager' import { publishLog } from './publishLog' import { writeService } from './writeService' +import { getPublishRelays } from './relaySelection' const REPUBLISH_INTERVAL_MS = 30000 // 30 seconds const MAX_RETRIES_PER_OBJECT = 10 @@ -164,86 +165,62 @@ class PublishWorkerService { * Uses websocketService to route events to Service Worker */ private async attemptPublish(params: { key: string; obj: UnpublishedObject }): Promise { - const {obj} = params + const { obj } = params try { const { websocketService } = await import('./websocketService') - - const activeRelays = await relaySessionManager.getActiveRelays() - if (activeRelays.length === 0) { - const { getPrimaryRelaySync } = await import('./config') - const relayUrl = getPrimaryRelaySync() - activeRelays.push(relayUrl) - } - - console.warn(`[PublishWorker] Attempting to publish ${obj.objectType}:${obj.id} to ${activeRelays.length} relay(s)`) - - // Publish to all active relays via websocketService (routes to Service Worker) - const statuses = await websocketService.publishEvent(obj.event, activeRelays) - - const successfulRelays: string[] = [] - statuses.forEach((status, index) => { - const relayUrl = activeRelays[index] - if (!relayUrl) { - return - } - - if (status.success) { - successfulRelays.push(relayUrl) - // Log successful publication - void publishLog.logPublication({ - eventId: obj.event.id, - relayUrl, - success: true, - objectType: obj.objectType, - objectId: obj.id, - }) - } else { - const errorMessage = status.error ?? 'Unknown error' - console.warn(`[PublishWorker] Relay ${relayUrl} failed for ${obj.objectType}:${obj.id}:`, errorMessage) - relaySessionManager.markRelayFailed(relayUrl) - // Log failed publication - void publishLog.logPublication({ - eventId: obj.event.id, - relayUrl, - success: false, - error: errorMessage, - objectType: obj.objectType, - objectId: obj.id, - }) - } - }) - - // Update published status via writeService - if (successfulRelays.length > 0) { - await writeService.updatePublished(obj.objectType, obj.id, successfulRelays) - console.warn(`[PublishWorker] Successfully published ${obj.objectType}:${obj.id} to ${successfulRelays.length} relay(s)`) - // Remove from unpublished map - this.unpublishedObjects.delete(params.key) - } else { - const current = this.unpublishedObjects.get(params.key) - const next = current - ? { ...current, retryCount: current.retryCount + 1, lastRetryAt: Date.now() } - : { ...obj, retryCount: obj.retryCount + 1, lastRetryAt: Date.now() } - this.unpublishedObjects.set(params.key, next) - - console.warn(`[PublishWorker] All relays failed for ${obj.objectType}:${obj.id}, retry count: ${next.retryCount}/${MAX_RETRIES_PER_OBJECT}`) - - // Remove if max retries reached - if (next.retryCount >= MAX_RETRIES_PER_OBJECT) { - this.unpublishedObjects.delete(params.key) - } - } + const relays = await getPublishRelays() + console.warn(`[PublishWorker] Attempting to publish ${obj.objectType}:${obj.id} to ${relays.length} relay(s)`) + const statuses = await websocketService.publishEvent(obj.event, relays) + const successfulRelays = this.processPublishStatuses({ obj, relays, statuses }) + await this.finalizePublishAttempt({ key: params.key, obj, successfulRelays }) } catch (error) { console.error(`[PublishWorker] Error publishing ${obj.objectType}:${obj.id}:`, error) - const current = this.unpublishedObjects.get(params.key) - const next = current - ? { ...current, retryCount: current.retryCount + 1, lastRetryAt: Date.now() } - : { ...params.obj, retryCount: params.obj.retryCount + 1, lastRetryAt: Date.now() } - this.unpublishedObjects.set(params.key, next) + this.incrementRetryOrRemove({ key: params.key, fallbackObj: params.obj }) + } + } - if (next.retryCount >= MAX_RETRIES_PER_OBJECT) { - this.unpublishedObjects.delete(params.key) + private processPublishStatuses(params: { + obj: UnpublishedObject + relays: string[] + statuses: { success: boolean; error?: string }[] + }): string[] { + const successfulRelays: string[] = [] + params.statuses.forEach((status, index) => { + const relayUrl = params.relays[index] + if (!relayUrl) { + return } + if (status.success) { + successfulRelays.push(relayUrl) + void publishLog.logPublication({ eventId: params.obj.event.id, relayUrl, success: true, objectType: params.obj.objectType, objectId: params.obj.id }) + return + } + const errorMessage = status.error ?? 'Unknown error' + console.warn(`[PublishWorker] Relay ${relayUrl} failed for ${params.obj.objectType}:${params.obj.id}:`, errorMessage) + relaySessionManager.markRelayFailed(relayUrl) + void publishLog.logPublication({ eventId: params.obj.event.id, relayUrl, success: false, error: errorMessage, objectType: params.obj.objectType, objectId: params.obj.id }) + }) + return successfulRelays + } + + private async finalizePublishAttempt(params: { key: string; obj: UnpublishedObject; successfulRelays: string[] }): Promise { + if (params.successfulRelays.length > 0) { + await writeService.updatePublished(params.obj.objectType, params.obj.id, params.successfulRelays) + console.warn(`[PublishWorker] Successfully published ${params.obj.objectType}:${params.obj.id} to ${params.successfulRelays.length} relay(s)`) + this.unpublishedObjects.delete(params.key) + return + } + this.incrementRetryOrRemove({ key: params.key, fallbackObj: params.obj }) + } + + private incrementRetryOrRemove(params: { key: string; fallbackObj: UnpublishedObject }): void { + const current = this.unpublishedObjects.get(params.key) + const base = current ?? params.fallbackObj + const next = { ...base, retryCount: base.retryCount + 1, lastRetryAt: Date.now() } + this.unpublishedObjects.set(params.key, next) + console.warn(`[PublishWorker] All relays failed for ${next.objectType}:${next.id}, retry count: ${next.retryCount}/${MAX_RETRIES_PER_OBJECT}`) + if (next.retryCount >= MAX_RETRIES_PER_OBJECT) { + this.unpublishedObjects.delete(params.key) } } } diff --git a/lib/userConfirm.ts b/lib/userConfirm.ts index 7b697cd..e5d17fc 100644 --- a/lib/userConfirm.ts +++ b/lib/userConfirm.ts @@ -115,7 +115,7 @@ function attachConfirmOverlayHandlers(params: ConfirmOverlayHandlerParams): void const { overlay, cancel, confirm, resolve } = params let resolved = false - const resolveOnce = (next: boolean): void => { + function resolveOnce(next: boolean): void { if (resolved) { return } @@ -124,9 +124,15 @@ function attachConfirmOverlayHandlers(params: ConfirmOverlayHandlerParams): void resolve(next) } - const onCancel = (): void => resolveOnce(false) - const onConfirm = (): void => resolveOnce(true) - const onKeyDown = (e: KeyboardEvent): void => { + function onCancel(): void { + resolveOnce(false) + } + + function onConfirm(): void { + resolveOnce(true) + } + + function onKeyDown(e: KeyboardEvent): void { if (e.key === 'Escape') { e.preventDefault() resolveOnce(false) @@ -138,7 +144,7 @@ function attachConfirmOverlayHandlers(params: ConfirmOverlayHandlerParams): void } } - const cleanup = (): void => { + function cleanup(): void { overlay.removeEventListener('keydown', onKeyDown) cancel.removeEventListener('click', onCancel) confirm.removeEventListener('click', onConfirm) diff --git a/lib/writeService.ts b/lib/writeService.ts index d106b4b..3ac664f 100644 --- a/lib/writeService.ts +++ b/lib/writeService.ts @@ -168,69 +168,10 @@ class WriteService { async writeObject(params: WriteObjectParams): Promise { try { await this.init() - - const published = params.published ?? false - if (this.writeWorker) { - // Send to worker - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Write operation timeout')) - }, 10000) - - const handler = (event: MessageEvent): void => { - if (!isWorkerMessageEnvelope(event.data)) { - return - } - const responseType = event.data.type - const responseData = event.data.data - - if (responseType === 'WRITE_OBJECT_SUCCESS' && isRecord(responseData) && responseData.hash === params.hash) { - clearTimeout(timeout) - this.writeWorker?.removeEventListener('message', handler) - resolve() - } else if (responseType === 'ERROR') { - const errorData = readWorkerErrorData(responseData) - if (errorData.originalType !== 'WRITE_OBJECT') { - return - } - clearTimeout(timeout) - this.writeWorker?.removeEventListener('message', handler) - reject(new Error(errorData.error ?? 'Write worker error')) - } - } - - if (this.writeWorker) { - this.writeWorker.addEventListener('message', handler) - this.writeWorker.postMessage({ - type: 'WRITE_OBJECT', - data: { - objectType: params.objectType, - hash: params.hash, - event: params.event, - parsed: params.parsed, - version: params.version, - hidden: params.hidden, - index: params.index, - published, - }, - }) - } - }) + return this.postWriteObjectToWorker(params) } - // Fallback: direct write - const { objectCache } = await import('./objectCache') - await objectCache.set({ - objectType: params.objectType, - hash: params.hash, - event: params.event, - parsed: params.parsed, - version: params.version, - hidden: params.hidden, - ...(params.index !== undefined ? { index: params.index } : {}), - ...(params.published !== undefined ? { published: params.published } : {}), - }) - + await this.writeObjectDirect(params) } catch (error) { console.error('[WriteService] Error writing object:', error) throw error @@ -249,61 +190,115 @@ class WriteService { await this.init() if (this.writeWorker) { - // Send to worker - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Update published operation timeout')) - }, 10000) - - const handler = (event: MessageEvent): void => { - if (!isWorkerMessageEnvelope(event.data)) { - return - } - const responseType = event.data.type - const responseData = event.data.data - - if (responseType === 'UPDATE_PUBLISHED_SUCCESS') { - if (!isRecord(responseData) || responseData.id !== id) { - return - } - clearTimeout(timeout) - this.writeWorker?.removeEventListener('message', handler) - resolve() - return - } - - if (responseType !== 'ERROR') { - return - } - - const errorData = readWorkerErrorData(responseData) - if (!isWorkerErrorForOperation(errorData, 'UPDATE_PUBLISHED')) { - return - } - clearTimeout(timeout) - this.writeWorker?.removeEventListener('message', handler) - reject(new Error(errorData.error ?? 'Write worker error')) - } - - if (this.writeWorker) { - this.writeWorker.addEventListener('message', handler) - this.writeWorker.postMessage({ - type: 'UPDATE_PUBLISHED', - data: { objectType, id, published }, - }) - } - }) + return this.postUpdatePublishedToWorker({ objectType, id, published }) } - // Fallback: direct write - const { objectCache } = await import('./objectCache') - await objectCache.updatePublished(objectType, id, published) - + await this.updatePublishedDirect({ objectType, id, published }) } catch (error) { console.error('[WriteService] Error updating published status:', error) throw error } } + private postWriteObjectToWorker(params: WriteObjectParams): Promise { + const published = params.published ?? false + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Write operation timeout')), 10000) + const handler = (event: MessageEvent): void => { + if (!isWorkerMessageEnvelope(event.data)) { + return + } + if (event.data.type === 'WRITE_OBJECT_SUCCESS' && isRecord(event.data.data) && event.data.data.hash === params.hash) { + clearTimeout(timeout) + this.writeWorker?.removeEventListener('message', handler) + resolve() + return + } + if (event.data.type === 'ERROR') { + const errorData = readWorkerErrorData(event.data.data) + if (errorData.originalType !== 'WRITE_OBJECT') { + return + } + clearTimeout(timeout) + this.writeWorker?.removeEventListener('message', handler) + reject(new Error(errorData.error ?? 'Write worker error')) + } + } + if (this.writeWorker) { + this.writeWorker.addEventListener('message', handler) + this.writeWorker.postMessage({ + type: 'WRITE_OBJECT', + data: { + objectType: params.objectType, + hash: params.hash, + event: params.event, + parsed: params.parsed, + version: params.version, + hidden: params.hidden, + index: params.index, + published, + }, + }) + } + }) + } + + private async writeObjectDirect(params: WriteObjectParams): Promise { + const { objectCache } = await import('./objectCache') + await objectCache.set({ + objectType: params.objectType, + hash: params.hash, + event: params.event, + parsed: params.parsed, + version: params.version, + hidden: params.hidden, + ...(params.index !== undefined ? { index: params.index } : {}), + ...(params.published !== undefined ? { published: params.published } : {}), + }) + } + + private postUpdatePublishedToWorker(params: { + objectType: ObjectType + id: string + published: false | string[] + }): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Update published operation timeout')), 10000) + const handler = (event: MessageEvent): void => { + if (!isWorkerMessageEnvelope(event.data)) { + return + } + if (event.data.type === 'UPDATE_PUBLISHED_SUCCESS') { + if (!isRecord(event.data.data) || event.data.data.id !== params.id) { + return + } + clearTimeout(timeout) + this.writeWorker?.removeEventListener('message', handler) + resolve() + return + } + if (event.data.type !== 'ERROR') { + return + } + const errorData = readWorkerErrorData(event.data.data) + if (!isWorkerErrorForOperation(errorData, 'UPDATE_PUBLISHED')) { + return + } + clearTimeout(timeout) + this.writeWorker?.removeEventListener('message', handler) + reject(new Error(errorData.error ?? 'Write worker error')) + } + if (this.writeWorker) { + this.writeWorker.addEventListener('message', handler) + this.writeWorker.postMessage({ type: 'UPDATE_PUBLISHED', data: { ...params } }) + } + }) + } + + private async updatePublishedDirect(params: { objectType: ObjectType; id: string; published: false | string[] }): Promise { + const { objectCache } = await import('./objectCache') + await objectCache.updatePublished(params.objectType, params.id, params.published) + } + /** * Create notification (via Web Worker) */ diff --git a/pages/api/nip95-upload.ts b/pages/api/nip95-upload.ts index 02ee002..63cebce 100644 --- a/pages/api/nip95-upload.ts +++ b/pages/api/nip95-upload.ts @@ -207,7 +207,7 @@ async function makeRequestOnce(params: { const { requestFormData, fileStream } = buildUploadFormData(params.file) const headers = buildProxyRequestHeaders(requestFormData, params.authToken) const { clientModule, requestOptions } = buildProxyRequestOptions({ url: params.url, headers }) - return await sendFormDataRequest({ + return sendFormDataRequest({ clientModule, requestOptions, requestFormData, @@ -267,7 +267,7 @@ async function sendFormDataRequest(params: { finalUrl: string filepath: string }): Promise { - return await new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const proxyRequest = params.clientModule.request(params.requestOptions, (proxyResponse: http.IncomingMessage) => { void readProxyResponse({ proxyResponse, finalUrl: params.finalUrl }).then(resolve).catch(reject) }) @@ -323,7 +323,7 @@ async function readProxyResponse(params: { proxyResponse: http.IncomingMessage; } async function readIncomingMessageBody(message: http.IncomingMessage): Promise { - return await new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { let body = '' message.setEncoding('utf8') message.on('data', (chunk) => {