This commit is contained in:
Nicolas Cantu 2026-01-10 10:50:47 +01:00
parent 620f5955ca
commit f454553a66
24 changed files with 1333 additions and 1336 deletions

View File

@ -186,17 +186,7 @@ function DeleteButton({ onDelete, deleting }: { onDelete: () => void; deleting:
) )
} }
function PresentationForm({ type PresentationFormProps = {
draft,
setDraft,
validationError,
error,
loading,
handleSubmit,
deleting,
handleDelete,
hasExistingPresentation,
}: {
draft: AuthorPresentationDraft draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void setDraft: (next: AuthorPresentationDraft) => void
validationError: string | null validationError: string | null
@ -206,43 +196,44 @@ function PresentationForm({
deleting: boolean deleting: boolean
handleDelete: () => void handleDelete: () => void
hasExistingPresentation: boolean hasExistingPresentation: boolean
}): React.ReactElement { }
function PresentationForm(props: PresentationFormProps): React.ReactElement {
return ( return (
<form <form
onSubmit={(e: FormEvent<HTMLFormElement>) => { onSubmit={(e: FormEvent<HTMLFormElement>) => {
void handleSubmit(e) void props.handleSubmit(e)
}} }}
className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark space-y-4" className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark space-y-4"
> >
<PresentationFormHeader /> <PresentationFormHeader />
<PresentationFields draft={draft} onChange={setDraft} /> <PresentationFields draft={props.draft} onChange={props.setDraft} />
<ValidationError message={validationError ?? error} /> <ValidationError message={props.validationError ?? props.error} />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex-1"> <div className="flex-1">
<button <button
type="submit" type="submit"
disabled={loading ?? deleting} disabled={props.loading ?? props.deleting}
className="w-full px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50 disabled:cursor-not-allowed" className="w-full px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50 disabled:cursor-not-allowed"
> >
{(() => { {getSubmitLabel(props)}
if (loading ?? deleting) {
return t('publish.publishing')
}
if (hasExistingPresentation === true) {
return t('presentation.update.button')
}
return t('publish.button')
})()}
</button> </button>
</div> </div>
{hasExistingPresentation && ( {props.hasExistingPresentation && (
<DeleteButton onDelete={() => { void handleDelete() }} deleting={deleting} /> <DeleteButton onDelete={() => { void props.handleDelete() }} deleting={props.deleting} />
)} )}
</div> </div>
</form> </form>
) )
} }
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): { function useAuthorPresentationState(pubkey: string | null, existingAuthorName?: string, existingPresentation?: Article | null): {
draft: AuthorPresentationDraft draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void setDraft: (next: AuthorPresentationDraft) => void
@ -256,78 +247,119 @@ function useAuthorPresentationState(pubkey: string | null, existingAuthorName?:
} { } {
const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey) const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey)
const router = useRouter() const router = useRouter()
const [draft, setDraft] = useState<AuthorPresentationDraft>(() => { const [draft, setDraft] = useState<AuthorPresentationDraft>(() => buildInitialDraft(existingPresentation, existingAuthorName))
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 [validationError, setValidationError] = useState<string | null>(null) const [validationError, setValidationError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
// Update authorName when profile changes // Update authorName when profile changes
useEffect(() => { useEffect(() => {
if (existingAuthorName && existingAuthorName !== draft.authorName && !existingPresentation) { syncAuthorNameIntoDraft({ existingAuthorName, draftAuthorName: draft.authorName, hasExistingPresentation: Boolean(existingPresentation), setDraft })
setDraft((prev) => ({ ...prev, authorName: existingAuthorName }))
}
}, [existingAuthorName, existingPresentation, draft.authorName]) }, [existingAuthorName, existingPresentation, draft.authorName])
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => { async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
const address = draft.mainnetAddress.trim() await submitPresentationDraft({ draft, setValidationError, publishPresentation })
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)
}, },
[draft, publishPresentation] [draft, publishPresentation]
) )
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
if (!existingPresentation?.id) { await deletePresentationFlow({
return existingPresentationId: existingPresentation?.id,
} deletePresentation,
router,
const confirmed = await userConfirm(t('presentation.delete.confirm')) setDeleting,
if (!confirmed) { setValidationError,
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)
}
}, [existingPresentation, deletePresentation, router]) }, [existingPresentation, deletePresentation, router])
return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete } 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<void>
}): Promise<void> {
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<void>
router: ReturnType<typeof useRouter>
setDeleting: (value: boolean) => void
setValidationError: (value: string | null) => void
}): Promise<void> {
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({ function NoAccountActionButtons({
onGenerate, onGenerate,
onImport, onImport,
@ -362,122 +394,133 @@ function NoAccountView(): React.ReactElement {
const [generating, setGenerating] = useState(false) const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const handleGenerate = async (): Promise<void> => { const handleGenerate = (): Promise<void> => generateNoAccount({ setGenerating, setError, setRecoveryPhrase, setNpub, setShowRecoveryStep })
setGenerating(true) const handleRecoveryContinue = (): void => transitionToUnlock({ setShowRecoveryStep, setShowUnlockModal })
setError(null) const handleUnlockSuccess = (): void => resetNoAccountAfterUnlock({ setShowUnlockModal, setRecoveryPhrase, setNpub })
try { const handleImportSuccess = (): void => {
const { nostrAuthService } = await import('@/lib/nostrAuth') setShowImportModal(false)
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)
setShowUnlockModal(true) setShowUnlockModal(true)
} }
const handleUnlockSuccess = (): void => { return (
setShowUnlockModal(false) <NoAccountCard
setRecoveryPhrase([]) error={error}
setNpub('') generating={generating}
} onGenerate={() => { void handleGenerate() }}
onImport={() => setShowImportModal(true)}
modals={
<NoAccountModals
showImportModal={showImportModal}
onCloseImport={() => 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<void> {
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 ( return (
<div className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50"> <div className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<p className="text-center text-cyber-accent mb-2"> <p className="text-center text-cyber-accent mb-2">Créez un compte ou importez votre clé secrète pour commencer</p>
Créez un compte ou importez votre clé secrète pour commencer {params.error && <p className="text-sm text-red-400">{params.error}</p>}
</p> <NoAccountActionButtons onGenerate={params.onGenerate} onImport={params.onImport} />
{error && <p className="text-sm text-red-400">{error}</p>} {params.generating && <p className="text-cyber-accent text-sm">Génération du compte...</p>}
<NoAccountActionButtons {params.modals}
onGenerate={() => { void handleGenerate() }}
onImport={() => setShowImportModal(true)}
/>
{generating && (
<p className="text-cyber-accent text-sm">Génération du compte...</p>
)}
{showImportModal && (
<CreateAccountModal
onSuccess={() => {
setShowImportModal(false)
setShowUnlockModal(true)
}}
onClose={() => setShowImportModal(false)}
initialStep="import"
/>
)}
{showRecoveryStep && (
<RecoveryStep
recoveryPhrase={recoveryPhrase}
npub={npub}
onContinue={handleRecoveryContinue}
/>
)}
{showUnlockModal && (
<UnlockAccountModal
onSuccess={handleUnlockSuccess}
onClose={() => setShowUnlockModal(false)}
/>
)}
</div> </div>
</div> </div>
) )
} }
function AuthorPresentationFormView({ function NoAccountModals(params: {
pubkey, showImportModal: boolean
profile, onImportSuccess: () => void
}: { onCloseImport: () => void
showRecoveryStep: boolean
recoveryPhrase: string[]
npub: string
onRecoveryContinue: () => void
showUnlockModal: boolean
onUnlockSuccess: () => void
onCloseUnlock: () => void
}): React.ReactElement {
return (
<>
{params.showImportModal && <CreateAccountModal onSuccess={params.onImportSuccess} onClose={params.onCloseImport} initialStep="import" />}
{params.showRecoveryStep && <RecoveryStep recoveryPhrase={params.recoveryPhrase} npub={params.npub} onContinue={params.onRecoveryContinue} />}
{params.showUnlockModal && <UnlockAccountModal onSuccess={params.onUnlockSuccess} onClose={params.onCloseUnlock} />}
</>
)
}
function AuthorPresentationFormView(props: {
pubkey: string | null pubkey: string | null
profile: { name?: string; pubkey: string } | null profile: { name?: string; pubkey: string } | null
}): React.ReactElement { }): React.ReactElement {
const { checkPresentationExists } = useAuthorPresentation(pubkey) const { checkPresentationExists } = useAuthorPresentation(props.pubkey)
const [existingPresentation, setExistingPresentation] = useState<Article | null>(null) const presentation = useExistingPresentation({ pubkey: props.pubkey, checkPresentationExists })
const [loadingPresentation, setLoadingPresentation] = useState(true) const state = useAuthorPresentationState(props.pubkey, props.profile?.name, presentation.existingPresentation)
useEffect(() => { if (!props.pubkey) {
const load = async (): Promise<void> => {
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) {
return <NoAccountView /> return <NoAccountView />
} }
if (presentation.loadingPresentation) {
if (loadingPresentation) { return <LoadingNotice />
return (
<div className="text-center py-12">
<p className="text-cyber-accent/70">{t('common.loading')}</p>
</div>
)
} }
if (state.success) { if (state.success) {
return <SuccessNotice pubkey={pubkey} /> return <SuccessNotice pubkey={props.pubkey} />
} }
return ( return (
<PresentationForm <PresentationForm
draft={state.draft} draft={state.draft}
@ -488,11 +531,53 @@ function AuthorPresentationFormView({
handleSubmit={state.handleSubmit} handleSubmit={state.handleSubmit}
deleting={state.deleting} deleting={state.deleting}
handleDelete={() => { void state.handleDelete() }} handleDelete={() => { void state.handleDelete() }}
hasExistingPresentation={existingPresentation !== null && existingPresentation !== undefined} hasExistingPresentation={presentation.existingPresentation !== null}
/> />
) )
} }
function LoadingNotice(): React.ReactElement {
return (
<div className="text-center py-12">
<p className="text-cyber-accent/70">{t('common.loading')}</p>
</div>
)
}
function useExistingPresentation(params: {
pubkey: string | null
checkPresentationExists: () => Promise<Article | null>
}): { existingPresentation: Article | null; loadingPresentation: boolean } {
const [existingPresentation, setExistingPresentation] = useState<Article | null>(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<Article | null>
setExistingPresentation: (value: Article | null) => void
setLoadingPresentation: (value: boolean) => void
}): Promise<void> {
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>): void { function useAutoLoadPubkey(accountExists: boolean | null, pubkey: string | null, connect: () => Promise<void>): void {
useEffect(() => { useEffect(() => {
if (accountExists === true && !pubkey) { if (accountExists === true && !pubkey) {

View File

@ -57,55 +57,46 @@ function ArticlesHero({
) )
} }
function HomeContent({ function HomeContent(props: HomeViewProps): React.ReactElement {
searchQuery, const shouldShowFilters = !props.loading && props.allArticles.length > 0
setSearchQuery, const shouldShowAuthors = props.selectedCategory !== null && props.selectedCategory !== 'all'
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'
// At startup, we don't know yet if we're loading articles or authors // At startup, we don't know yet if we're loading articles or authors
// Use a generic loading message until we have content // Use a generic loading message until we have content
const isInitialLoad = loading && allArticles.length === 0 && allAuthors.length === 0 const isInitialLoad = props.loading && props.allArticles.length === 0 && props.allAuthors.length === 0
return ( return (
<div className="w-full px-4 py-8"> <div className="w-full px-4 py-8">
<ArticlesHero <ArticlesHero
searchQuery={searchQuery} searchQuery={props.searchQuery}
setSearchQuery={setSearchQuery} setSearchQuery={props.setSearchQuery}
selectedCategory={selectedCategory} selectedCategory={props.selectedCategory}
setSelectedCategory={setSelectedCategory} setSelectedCategory={props.setSelectedCategory}
/> />
{shouldShowFilters && !shouldShowAuthors && ( {shouldShowFilters && !shouldShowAuthors && (
<ArticleFiltersComponent filters={filters} onFiltersChange={setFilters} articles={allArticles} /> <ArticleFiltersComponent filters={props.filters} onFiltersChange={props.setFilters} articles={props.allArticles} />
)} )}
<HomeMainList <HomeMainList
isInitialLoad={isInitialLoad} isInitialLoad={isInitialLoad}
shouldShowAuthors={shouldShowAuthors} shouldShowAuthors={shouldShowAuthors}
articlesListProps={buildArticlesListProps({ articlesListProps={buildArticlesListProps({
articles, articles: props.articles,
allArticles, allArticles: props.allArticles,
loading, loading: props.loading,
isInitialLoad, isInitialLoad,
error, error: props.error,
onUnlock, onUnlock: props.onUnlock,
unlockedArticles, unlockedArticles: props.unlockedArticles,
})}
authorsListProps={buildAuthorsListProps({
authors: props.authors,
allAuthors: props.allAuthors,
loading: props.loading,
isInitialLoad,
error: props.error,
})} })}
authorsListProps={buildAuthorsListProps({ authors, allAuthors, loading, isInitialLoad, error })}
/> />
</div> </div>
) )

View File

@ -92,56 +92,85 @@ async function processFileUpload(file: File, onChange: (url: string) => void, se
} }
} }
function useImageUpload(onChange: (url: string) => void): { type ImageUploadState = {
uploading: boolean uploading: boolean
error: string | null error: string | null
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void> handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>
showUnlockModal: boolean showUnlockModal: boolean
setShowUnlockModal: (show: boolean) => void setShowUnlockModal: (show: boolean) => void
handleUnlockSuccess: () => Promise<void> handleUnlockSuccess: () => Promise<void>
} { }
function useImageUpload(onChange: (url: string) => void): ImageUploadState {
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showUnlockModal, setShowUnlockModal] = useState(false) const [showUnlockModal, setShowUnlockModal] = useState(false)
const [pendingFile, setPendingFile] = useState<File | null>(null) const [pendingFile, setPendingFile] = useState<File | null>(null)
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => { 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<HTMLInputElement>) => Promise<void> {
return async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = readFirstFile(event) const file = readFirstFile(event)
if (!file) { if (!file) {
return return
} }
setError(null) params.setError(null)
setUploading(true) params.setUploading(true)
try { try {
await processFileUpload(file, onChange, setError) await processFileUpload(file, params.onChange, params.setError)
} catch (uploadError) { } catch (uploadError) {
const uploadErr = normalizeError(uploadError) const uploadErr = normalizeError(uploadError)
if (isUnlockRequiredError(uploadErr)) { if (isUnlockRequiredError(uploadErr)) {
setPendingFile(file) params.setPendingFile(file)
setShowUnlockModal(true) params.setShowUnlockModal(true)
setError(null) // Don't show error, show unlock modal instead params.setError(null)
} else { } else {
setError(uploadErr.message ?? t('presentation.field.picture.error.uploadFailed')) params.setError(uploadErr.message ?? t('presentation.field.picture.error.uploadFailed'))
} }
} finally { } finally {
setUploading(false) params.setUploading(false)
} }
} }
}
const handleUnlockSuccess = async (): Promise<void> => { function createHandleUnlockSuccess(params: {
await retryPendingUpload({ pendingFile: File | null
pendingFile, onChange: (url: string) => void
onChange, setError: (error: string | null) => void
setError, setPendingFile: (file: File | null) => void
setPendingFile, setShowUnlockModal: (show: boolean) => void
setShowUnlockModal, setUploading: (uploading: boolean) => void
setUploading, }): () => Promise<void> {
}) return async (): Promise<void> => {
await retryPendingUpload(params)
} }
return { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess }
} }
function readFirstFile(event: React.ChangeEvent<HTMLInputElement>): File | null { function readFirstFile(event: React.ChangeEvent<HTMLInputElement>): File | null {

View File

@ -34,29 +34,32 @@ export function LanguageSettingsManager(): React.ReactElement {
void loadLocaleIntoState({ setCurrentLocale, setLoading }) void loadLocaleIntoState({ setCurrentLocale, setLoading })
}, []) }, [])
const handleLocaleChange = async (locale: Locale): Promise<void> => {
await applyLocaleChange({ locale, setCurrentLocale })
}
const onLocaleClick = (locale: Locale): void => { const onLocaleClick = (locale: Locale): void => {
void handleLocaleChange(locale) void applyLocaleChange({ locale, setCurrentLocale })
} }
if (loading) { return <LanguageSettingsPanel loading={loading} currentLocale={currentLocale} onLocaleClick={onLocaleClick} />
}
function LanguageSettingsPanel(params: {
loading: boolean
currentLocale: Locale
onLocaleClick: (locale: Locale) => void
}): React.ReactElement {
if (params.loading) {
return ( return (
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6"> <div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<div>{t('settings.language.loading')}</div> <div>{t('settings.language.loading')}</div>
</div> </div>
) )
} }
return ( return (
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6"> <div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<h2 className="text-2xl font-bold text-neon-cyan mb-4">{t('settings.language.title')}</h2> <h2 className="text-2xl font-bold text-neon-cyan mb-4">{t('settings.language.title')}</h2>
<p className="text-cyber-accent mb-4 text-sm">{t('settings.language.description')}</p> <p className="text-cyber-accent mb-4 text-sm">{t('settings.language.description')}</p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<LocaleOption locale="fr" label={t('settings.language.french')} currentLocale={currentLocale} onClick={onLocaleClick} /> <LocaleOption locale="fr" label={t('settings.language.french')} currentLocale={params.currentLocale} onClick={params.onLocaleClick} />
<LocaleOption locale="en" label={t('settings.language.english')} currentLocale={currentLocale} onClick={onLocaleClick} /> <LocaleOption locale="en" label={t('settings.language.english')} currentLocale={params.currentLocale} onClick={params.onLocaleClick} />
</div> </div>
</div> </div>
) )

View File

@ -12,23 +12,17 @@ interface MarkdownEditorTwoColumnsProps {
onBannerChange?: (url: string) => void onBannerChange?: (url: string) => void
} }
export function MarkdownEditorTwoColumns({ export function MarkdownEditorTwoColumns(props: MarkdownEditorTwoColumnsProps): React.ReactElement {
value,
onChange,
pages = [],
onPagesChange,
onMediaAdd,
onBannerChange,
}: MarkdownEditorTwoColumnsProps): React.ReactElement {
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const pagesHandlers = createPagesHandlers({ pages, onPagesChange }) const pages = props.pages ?? []
const pagesHandlers = createPagesHandlers({ pages, onPagesChange: props.onPagesChange })
const handleImageUpload = createImageUploadHandler({ const handleImageUpload = createImageUploadHandler({
setError, setError,
setUploading, setUploading,
onMediaAdd, onMediaAdd: props.onMediaAdd,
onBannerChange, onBannerChange: props.onBannerChange,
onSetPageImageUrl: pagesHandlers.setPageContent, onSetPageImageUrl: pagesHandlers.setPageContent,
}) })
@ -40,13 +34,13 @@ export function MarkdownEditorTwoColumns({
}} }}
uploading={uploading} uploading={uploading}
error={error} error={error}
{...(onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})} {...(props.onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})}
/> />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<EditorColumn value={value} onChange={onChange} /> <EditorColumn value={props.value} onChange={props.onChange} />
<PreviewColumn value={value} /> <PreviewColumn value={props.value} />
</div> </div>
{onPagesChange && ( {props.onPagesChange && (
<PagesManager <PagesManager
pages={pages} pages={pages}
onPageContentChange={pagesHandlers.setPageContent} onPageContentChange={pagesHandlers.setPageContent}

View File

@ -146,52 +146,48 @@ function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => voi
const timeRemaining = useInvoiceTimer(invoice.expiresAt) const timeRemaining = useInvoiceTimer(invoice.expiresAt)
const handleCopy = useCallback( const handleCopy = useCallback(
createHandleCopy({ invoice: invoice.invoice, setCopied, setErrorMessage }), (): Promise<void> => copyInvoiceToClipboard({ invoice: invoice.invoice, setCopied, setErrorMessage }),
[invoice.invoice] [invoice.invoice]
) )
const handleOpenWallet = useCallback( const handleOpenWallet = useCallback(
createHandleOpenWallet({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage }), (): Promise<void> => openWalletForInvoice({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage }),
[invoice.invoice, onPaymentComplete] [invoice.invoice, onPaymentComplete]
) )
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
} }
function createHandleCopy(params: { async function copyInvoiceToClipboard(params: {
invoice: string invoice: string
setCopied: (value: boolean) => void setCopied: (value: boolean) => void
setErrorMessage: (value: string | null) => void setErrorMessage: (value: string | null) => void
}): () => Promise<void> { }): Promise<void> {
return async (): Promise<void> => { try {
try { await navigator.clipboard.writeText(params.invoice)
await navigator.clipboard.writeText(params.invoice) params.setCopied(true)
params.setCopied(true) scheduleCopiedReset(params.setCopied)
scheduleCopiedReset(params.setCopied) } catch (e) {
} catch (e) { console.error('Failed to copy:', e)
console.error('Failed to copy:', e) params.setErrorMessage(t('payment.modal.copyFailed'))
params.setErrorMessage(t('payment.modal.copyFailed'))
}
} }
} }
function createHandleOpenWallet(params: { async function openWalletForInvoice(params: {
invoice: string invoice: string
onPaymentComplete: () => void onPaymentComplete: () => void
setErrorMessage: (value: string | null) => void setErrorMessage: (value: string | null) => void
}): () => Promise<void> { }): Promise<void> {
return async (): Promise<void> => { try {
try { await payWithWebLN(params.invoice)
await payWithWebLN(params.invoice) params.onPaymentComplete()
params.onPaymentComplete() } catch (e) {
} catch (e) { const error = normalizePaymentError(e)
const error = normalizePaymentError(e) if (isUserCancellationError(error)) {
if (isUserCancellationError(error)) { return
return
}
console.error('Payment failed:', error)
params.setErrorMessage(error.message)
} }
console.error('Payment failed:', error)
params.setErrorMessage(error.message)
} }
} }

View File

@ -24,39 +24,13 @@ export function useArticlePayment(
const [paymentInvoice, setPaymentInvoice] = useState<AlbyInvoice | null>(null) const [paymentInvoice, setPaymentInvoice] = useState<AlbyInvoice | null>(null)
const [paymentHash, setPaymentHash] = useState<string | null>(null) const [paymentHash, setPaymentHash] = useState<string | null>(null)
const handleUnlock = (): Promise<void> => const handleUnlock = (): Promise<void> => unlockArticlePayment({ article, pubkey, connect, onUnlockSuccess, setLoading, setError, setPaymentInvoice, setPaymentHash })
unlockArticlePayment({
article,
pubkey,
connect,
onUnlockSuccess,
setLoading,
setError,
setPaymentInvoice,
setPaymentHash,
})
const handlePaymentComplete = (): Promise<void> => const handlePaymentComplete = (): Promise<void> => checkPaymentAndUnlock({ article, pubkey, paymentHash, onUnlockSuccess, setError, setPaymentInvoice, setPaymentHash })
checkPaymentAndUnlock({
article,
pubkey,
paymentHash,
onUnlockSuccess,
setError,
setPaymentInvoice,
setPaymentHash,
})
const handleCloseModal = (): void => resetPaymentModalState({ setPaymentInvoice, setPaymentHash }) const handleCloseModal = (): void => resetPaymentModalState({ setPaymentInvoice, setPaymentHash })
return { return { loading, error, paymentInvoice, handleUnlock, handlePaymentComplete, handleCloseModal }
loading,
error,
paymentInvoice,
handleUnlock,
handlePaymentComplete,
handleCloseModal,
}
} }
async function unlockArticlePayment(params: { async function unlockArticlePayment(params: {
@ -81,25 +55,15 @@ async function unlockArticlePayment(params: {
params.setLoading(true) params.setLoading(true)
params.setError(null) params.setError(null)
try { try {
const paymentResult = await paymentService.createArticlePayment({ const paymentResult = await paymentService.createArticlePayment({ article: params.article, userPubkey: params.pubkey })
article: params.article, const ok = readPaymentResult(paymentResult)
userPubkey: params.pubkey, if (!ok) {
})
if (!paymentResult.success || !paymentResult.invoice || !paymentResult.paymentHash) {
params.setError(paymentResult.error ?? 'Failed to create payment invoice') params.setError(paymentResult.error ?? 'Failed to create payment invoice')
return return
} }
params.setPaymentInvoice(paymentResult.invoice) params.setPaymentInvoice(ok.invoice)
params.setPaymentHash(paymentResult.paymentHash) params.setPaymentHash(ok.paymentHash)
void checkPaymentAndUnlock({ void checkPaymentAndUnlock({ article: params.article, pubkey: params.pubkey, paymentHash: ok.paymentHash, onUnlockSuccess: params.onUnlockSuccess, setError: params.setError, setPaymentInvoice: params.setPaymentInvoice, setPaymentHash: params.setPaymentHash })
article: params.article,
pubkey: params.pubkey,
paymentHash: paymentResult.paymentHash,
onUnlockSuccess: params.onUnlockSuccess,
setError: params.setError,
setPaymentInvoice: params.setPaymentInvoice,
setPaymentHash: params.setPaymentHash,
})
} catch (e) { } catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to process payment' const errorMessage = e instanceof Error ? e.message : 'Failed to process payment'
console.error('Payment processing error:', e) console.error('Payment processing error:', e)
@ -109,6 +73,13 @@ async function unlockArticlePayment(params: {
} }
} }
function readPaymentResult(value: Awaited<ReturnType<typeof paymentService.createArticlePayment>>): { 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: { async function ensureConnectedOrError(params: {
connect: (() => Promise<void>) | undefined connect: (() => Promise<void>) | undefined
setLoading: (value: boolean) => void setLoading: (value: boolean) => void

View File

@ -19,138 +19,140 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const hasArticlesRef = useRef(false) const hasArticlesRef = useRef(false)
useEffect(() => { useLoadAuthorsFromCache({ setArticles, setLoading, setError, hasArticlesRef })
const loadAuthorsFromCache = async (): Promise<boolean> => { const loadArticleContent = createLoadArticleContent({ setArticles, setError })
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<string, number>()
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<void> => {
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<Article | null> => {
try {
const article = await nostrService.getArticleById(articleId)
if (article) {
// Try to decrypt article content using decryption key from private messages
const decryptedContent = await nostrService.getDecryptedArticleContent(articleId, authorPubkey)
if (decryptedContent) {
setArticles((prev) =>
prev.map((a) =>
(a.id === articleId
? { ...a, content: decryptedContent, paid: true }
: a)
)
)
}
return article
}
} catch (e) {
console.error('Error loading article content:', e)
setError(e instanceof Error ? e.message : 'Failed to load article')
}
return null
}
// Apply filters and sorting // Apply filters and sorting
const filteredArticles = useMemo(() => { const filteredArticles = useMemo(() => {
const effectiveFilters = const effectiveFilters = filters ?? buildDefaultFilters()
filters ??
({
authorPubkey: null,
sortBy: 'newest',
category: 'all',
} as const)
if (!filters && !searchQuery.trim()) { if (!filters && !searchQuery.trim()) {
return articles return articles
} }
return applyFiltersAndSort(articles, searchQuery, effectiveFilters) return applyFiltersAndSort(articles, searchQuery, effectiveFilters)
}, [articles, searchQuery, filters]) }, [articles, searchQuery, filters])
return { return { articles: filteredArticles, allArticles: articles, loading, error, loadArticleContent }
articles: filteredArticles, }
allArticles: articles, // Return all articles for filters component
loading, function buildDefaultFilters(): { authorPubkey: null; sortBy: 'newest'; category: 'all' } {
error, return { authorPubkey: null, sortBy: 'newest', category: 'all' }
loadArticleContent, }
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<void> {
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<boolean> {
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<void> {
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<Map<string, number>> {
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<string, number> }): 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<Article | null> {
return async (articleId: string, authorPubkey: string): Promise<Article | null> => {
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
}
} }
} }

View File

@ -61,23 +61,9 @@ async function publishAuthorPresentation(params: {
try { try {
const privateKey = getPrivateKeyOrThrow('Clé privée requise pour publier. Veuillez vous connecter avec un portefeuille Nostr qui fournit des capacités de signature.') 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) await updateProfileBestEffort(params.draft)
const { title, preview, fullContent } = buildPresentationContent(params.draft) const result = await publishPresentationArticleWithDraft({ draft: params.draft, pubkey: params.pubkey, privateKey })
const result = await articlePublisher.publishPresentationArticle( params.setSuccess(result.success === true)
{ if (!result.success) {
title,
preview,
content: fullContent,
presentation: params.draft.presentation,
contentDescription: params.draft.contentDescription,
mainnetAddress: params.draft.mainnetAddress,
...(params.draft.pictureUrl ? { pictureUrl: params.draft.pictureUrl } : {}),
},
params.pubkey,
privateKey
)
if (result.success) {
params.setSuccess(true)
} else {
params.setError(result.error ?? 'Erreur lors de la publication') params.setError(result.error ?? 'Erreur lors de la publication')
} }
} catch (e) { } 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<void> { async function updateProfileBestEffort(draft: AuthorPresentationDraft): Promise<void> {
const profileUpdates: Partial<NostrProfile> = { const profileUpdates: Partial<NostrProfile> = {
name: draft.authorName.trim(), name: draft.authorName.trim(),

View File

@ -20,12 +20,14 @@ export function useDocs(docs: DocLink[]): {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const loadDoc = useCallback( const loadDoc = useCallback(
createLoadDoc({ (docId: DocSection): Promise<void> =>
docs, loadDocImpl({
setLoading, docId,
setSelectedDoc, docs,
setDocContent, setLoading,
}), setSelectedDoc,
setDocContent,
}),
[docs] [docs]
) )
@ -41,30 +43,29 @@ export function useDocs(docs: DocLink[]): {
} }
} }
function createLoadDoc(params: { async function loadDocImpl(params: {
docId: DocSection
docs: DocLink[] docs: DocLink[]
setSelectedDoc: (doc: DocSection) => void setSelectedDoc: (doc: DocSection) => void
setDocContent: (value: string) => void setDocContent: (value: string) => void
setLoading: (value: boolean) => void setLoading: (value: boolean) => void
}): (docId: DocSection) => Promise<void> { }): Promise<void> {
return async (docId: DocSection): Promise<void> => { const doc = params.docs.find((d) => d.id === params.docId)
const doc = params.docs.find((d) => d.id === docId) if (!doc) {
if (!doc) { return
return }
}
params.setLoading(true) params.setLoading(true)
params.setSelectedDoc(docId) params.setSelectedDoc(params.docId)
try { try {
const text = await fetchDocContent(doc.file) const text = await fetchDocContent(doc.file)
params.setDocContent(text) params.setDocContent(text)
} catch (error) { } catch (error) {
console.error('[useDocs] Error loading doc:', error) console.error('[useDocs] Error loading doc:', error)
params.setDocContent(await buildDocLoadErrorMarkdown()) params.setDocContent(await buildDocLoadErrorMarkdown())
} finally { } finally {
params.setLoading(false) params.setLoading(false)
}
} }
} }

View File

@ -16,59 +16,72 @@ export function useNotifications(userPubkey: string | null): {
const [notifications, setNotifications] = useState<Notification[]>([]) const [notifications, setNotifications] = useState<Notification[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useNotificationsPoller({ userPubkey, setNotifications, setLoading })
if (!userPubkey) {
return
}
void loadAndSetNotifications({ setNotifications, setLoading })
const interval = setInterval(() => {
void loadAndSetNotifications({ setNotifications, setLoading })
}, POLL_INTERVAL_MS)
return () => clearInterval(interval)
}, [userPubkey])
const effectiveNotifications = userPubkey ? notifications : [] const effectiveNotifications = userPubkey ? notifications : []
const effectiveLoading = userPubkey ? loading : false const effectiveLoading = userPubkey ? loading : false
const unreadCount = effectiveNotifications.filter((n) => !n.read).length const unreadCount = effectiveNotifications.filter((n) => !n.read).length
const actions = useNotificationActions({ userPubkey, setNotifications })
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]
)
return { return {
notifications: effectiveNotifications, notifications: effectiveNotifications,
unreadCount, unreadCount,
loading: effectiveLoading, loading: effectiveLoading,
markAsRead, markAsRead: actions.markAsRead,
markAllAsRead: markAllAsReadHandler, markAllAsRead: actions.markAllAsRead,
deleteNotification: deleteNotificationHandler, 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: { async function loadAndSetNotifications(params: {
setNotifications: (value: Notification[]) => void setNotifications: (value: Notification[]) => void
setLoading: (value: boolean) => void setLoading: (value: boolean) => void

View File

@ -49,13 +49,7 @@ export function useUserArticles(
setError, setError,
}) })
return { return { articles: filteredArticles, allArticles: articles, loading, error, loadArticleContent }
articles: filteredArticles,
allArticles: articles,
loading,
error,
loadArticleContent,
}
} }
function useLoadUserArticlesFromCache(params: { function useLoadUserArticlesFromCache(params: {

View File

@ -9,8 +9,10 @@ import type { ArticleDraft, PublishedArticle } from './articlePublisher'
import type { AlbyInvoice } from '@/types/alby' import type { AlbyInvoice } from '@/types/alby'
import type { Article, Review, Series } from '@/types/nostr' import type { Article, Review, Series } from '@/types/nostr'
import { writeOrchestrator } from './writeOrchestrator' import { writeOrchestrator } from './writeOrchestrator'
import { finalizeEvent } from 'nostr-tools' import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils' import { hexToBytes } from 'nostr-tools/utils'
import { buildParsedArticleFromDraft as buildParsedArticleFromDraftCore } from './articleDraftToParsedArticle'
import { getPublishRelays } from './relaySelection'
export interface ArticleUpdateResult extends PublishedArticle { export interface ArticleUpdateResult extends PublishedArticle {
originalArticleId: string originalArticleId: string
@ -45,55 +47,7 @@ async function buildParsedArticleFromDraft(
invoice: AlbyInvoice, invoice: AlbyInvoice,
authorPubkey: string authorPubkey: string
): Promise<{ article: Article; hash: string; version: number; index: number }> { ): Promise<{ article: Article; hash: string; version: number; index: number }> {
const category = mapDraftCategoryToTag(draft.category) return buildParsedArticleFromDraftCore({ draft, invoice, authorPubkey })
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'
} }
interface PublishPreviewWithInvoiceParams { interface PublishPreviewWithInvoiceParams {
@ -108,20 +62,7 @@ interface PublishPreviewWithInvoiceParams {
async function publishPreviewWithInvoice( async function publishPreviewWithInvoice(
params: PublishPreviewWithInvoiceParams params: PublishPreviewWithInvoiceParams
): Promise<import('nostr-tools').Event | null> { ): Promise<import('nostr-tools').Event | null> {
// Build parsed article object (use custom article if provided, e.g., for updates with version) const payload = await resolvePublicationPayload(params)
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)
}
// Build event template // Build event template
const previewEventTemplate = await createPreviewEvent({ const previewEventTemplate = await createPreviewEvent({
@ -143,31 +84,44 @@ async function publishPreviewWithInvoice(
const secretKey = hexToBytes(privateKey) const secretKey = hexToBytes(privateKey)
const event = finalizeEvent(previewEventTemplate, secretKey) const event = finalizeEvent(previewEventTemplate, secretKey)
// Get active relays return publishPublicationToRelays({ event, payload })
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) 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<import('nostr-tools').Event | null> {
const relays = await getPublishRelays()
const result = await writeOrchestrator.writeAndPublish( const result = await writeOrchestrator.writeAndPublish(
{ {
objectType: 'publication', objectType: 'publication',
hash, hash: params.payload.hash,
event, event: params.event,
parsed: article, parsed: params.payload.article,
version, version: params.payload.version,
hidden: false, hidden: false,
index, index: params.payload.index,
}, },
relays relays
) )
return result.success ? params.event : null
if (!result.success) {
return null
}
return event
} }
export async function publishSeries(params: { export async function publishSeries(params: {
@ -180,72 +134,49 @@ export async function publishSeries(params: {
authorPrivateKey?: string authorPrivateKey?: string
}): Promise<Series> { }): Promise<Series> {
ensureKeys(params.authorPubkey, params.authorPrivateKey) ensureKeys(params.authorPubkey, params.authorPrivateKey)
const {category} = params const category = requireSeriesCategory(params.category)
requireCategory(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({ const hashId = await generateSeriesHashId({
pubkey: params.authorPubkey, pubkey: params.authorPubkey,
title: params.title, title: params.title,
description: params.description, description: params.description,
category: newCategory, category: newCategory,
coverUrl: params.coverUrl ?? undefined, ...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
}) })
const hash = hashId const parsedSeries = buildParsedSeries({
const version = 0 authorPubkey: params.authorPubkey,
const index = 0
const id = buildObjectId(hash, index, version)
// Build parsed Series object
const parsedSeries: Series = {
id,
hash,
version,
index,
pubkey: params.authorPubkey,
title: params.title, title: params.title,
description: params.description, description: params.description,
preview: params.preview ?? params.description.substring(0, 200), preview,
thumbnailUrl: params.coverUrl ?? '', coverUrl: params.coverUrl,
category, category,
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}), hashId,
kindType: 'series', })
}
// Build event template const eventTemplate = buildSeriesEventTemplate({
const eventTemplate = await buildSeriesEvent(params, category) authorPubkey: params.authorPubkey,
title: params.title,
description: params.description,
preview,
coverUrl: params.coverUrl,
category: newCategory,
hashId,
})
// Set private key in orchestrator const event = finalizeEvent(eventTemplate, hexToBytes(getPrivateKeyForSigning(params.authorPrivateKey)))
const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey() const relays = await getPublishRelays()
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 result = await writeOrchestrator.writeAndPublish( const result = await writeOrchestrator.writeAndPublish(
{ {
objectType: 'series', objectType: 'series',
hash, hash: parsedSeries.hash,
event, event,
parsed: parsedSeries, parsed: parsedSeries,
version, version: parsedSeries.version,
hidden: false, hidden: false,
index, index: parsedSeries.index,
}, },
relays relays
) )
@ -257,70 +188,104 @@ export async function publishSeries(params: {
return parsedSeries return parsedSeries
} }
async function buildSeriesEvent( function requireSeriesCategory(category: ArticleDraft['category']): NonNullable<ArticleDraft['category']> {
params: { requireCategory(category)
title: string return category
description: string }
preview?: string
coverUrl?: string function mapSeriesCategoryToTag(category: NonNullable<ArticleDraft['category']>): 'sciencefiction' | 'research' {
authorPubkey: string 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<ArticleDraft['category']> category: NonNullable<ArticleDraft['category']>
): Promise<{ hashId: string
kind: number }): Series {
created_at: number const hash = params.hashId
content: string const version = 0
tags: string[][] const index = 0
}> { const id = buildObjectId(hash, index, version)
// Map category to new system return {
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research' id,
hash,
// Generate hash ID from series data version,
const hashId = await generateSeriesHashId({ index,
pubkey: params.authorPubkey, pubkey: params.authorPubkey,
title: params.title, title: params.title,
description: params.description, description: params.description,
category: newCategory, preview: params.preview,
coverUrl: params.coverUrl ?? undefined, thumbnailUrl: params.coverUrl ?? '',
}) category: params.category,
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
// Build JSON metadata kindType: 'series',
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,
})
function buildSeriesEventTemplate(params: {
authorPubkey: string
title: string
description: string
preview: string
coverUrl: string | undefined
category: 'sciencefiction' | 'research'
hashId: string
}): EventTemplate {
const tags = buildTags({ const tags = buildTags({
type: 'series', type: 'series',
category: newCategory, category: params.category,
id: hashId, id: params.hashId,
service: PLATFORM_SERVICE, service: PLATFORM_SERVICE,
version: 0, // New object version: 0,
hidden: false, hidden: false,
paywall: false, paywall: false,
title: params.title, title: params.title,
description: params.description, description: params.description,
preview: params.preview ?? params.description.substring(0, 200), preview: params.preview,
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}), ...(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 function buildSeriesJson(params: {
tags.push(['json', seriesJson]) 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 { function getPrivateKeyForSigning(authorPrivateKey: string | undefined): string {
kind: 1, const privateKey = authorPrivateKey ?? nostrService.getPrivateKey()
created_at: Math.floor(Date.now() / 1000), if (!privateKey) {
content: params.preview ?? params.description.substring(0, 200), throw new Error('Private key required for signing')
tags,
} }
writeOrchestrator.setPrivateKey(privateKey)
return privateKey
} }
export async function publishReview(params: { export async function publishReview(params: {
@ -529,62 +494,53 @@ async function publishUpdate(
authorPubkey: string, authorPubkey: string,
originalArticleId: string originalArticleId: string
): Promise<ArticleUpdateResult> { ): Promise<ArticleUpdateResult> {
const {category} = draft const category = requireUpdateCategory(draft)
requireCategory(category) const originalArticle = await loadOriginalArticleForUpdate(originalArticleId)
// Get original article from IndexedDB to retrieve current version
const { objectCache } = await import('./objectCache')
const originalArticle = await objectCache.getById('publication', originalArticleId) as Article | null
if (!originalArticle) { if (!originalArticle) {
return updateFailure(originalArticleId, 'Original article not found in cache') return updateFailure(originalArticleId, 'Original article not found in cache')
} }
// Verify user is the author
if (originalArticle.pubkey !== authorPubkey) { if (originalArticle.pubkey !== authorPubkey) {
return updateFailure(originalArticleId, 'Only the author can update this article') return updateFailure(originalArticleId, 'Only the author can update this article')
} }
const presentationId = await ensurePresentation(authorPubkey) const presentationId = await ensurePresentation(authorPubkey)
const invoice = await createArticleInvoice(draft) const invoice = await createArticleInvoice(draft)
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research' const newCategory = mapPublicationCategoryToTag(category)
// Use current version from original article
const currentVersion = originalArticle.version ?? 0 const currentVersion = originalArticle.version ?? 0
const updateTags = await buildUpdateTags({
draft,
originalArticleId,
newCategory,
authorPubkey,
currentVersion,
})
// Build parsed article with incremented version const updateTags = await buildUpdateTags({ draft, originalArticleId, newCategory, authorPubkey, currentVersion })
const { article } = await buildParsedArticleFromDraft(draft, invoice, authorPubkey) const updatedArticle = await buildUpdatedArticleForUpdate({ draft, invoice, authorPubkey, currentVersion })
const updatedArticle: Article = { const publishedEvent = await publishPreviewWithInvoice({ draft, invoice, authorPubkey, presentationId, extraTags: updateTags, customArticle: updatedArticle })
...article,
version: currentVersion + 1, // Increment version for update
}
const publishedEvent = await publishPreviewWithInvoice({
draft,
invoice,
authorPubkey,
presentationId,
extraTags: updateTags,
customArticle: updatedArticle,
})
if (!publishedEvent) { if (!publishedEvent) {
return updateFailure(originalArticleId, 'Failed to publish article update') return updateFailure(originalArticleId, 'Failed to publish article update')
} }
await storePrivateContent({ articleId: publishedEvent.id, content: draft.content, authorPubkey, invoice }) await storePrivateContent({ articleId: publishedEvent.id, content: draft.content, authorPubkey, invoice })
return { return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true, originalArticleId }
articleId: publishedEvent.id, }
previewEventId: publishedEvent.id,
invoice, function requireUpdateCategory(draft: ArticleDraft): NonNullable<ArticleDraft['category']> {
success: true, requireCategory(draft.category)
originalArticleId, return draft.category
} }
async function loadOriginalArticleForUpdate(originalArticleId: string): Promise<Article | null> {
const { objectCache } = await import('./objectCache')
return (await objectCache.getById('publication', originalArticleId)) as Article | null
}
function mapPublicationCategoryToTag(category: NonNullable<ArticleDraft['category']>): 'sciencefiction' | 'research' {
return category === 'science-fiction' ? 'sciencefiction' : 'research'
}
async function buildUpdatedArticleForUpdate(params: {
draft: ArticleDraft
invoice: AlbyInvoice
authorPubkey: string
currentVersion: number
}): Promise<Article> {
const { article } = await buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey)
return { ...article, version: params.currentVersion + 1 }
} }
export async function publishArticleUpdate( export async function publishArticleUpdate(
@ -622,7 +578,7 @@ export async function deleteArticleEvent(articleId: string, authorPubkey: string
const deletePayload = await buildDeletedArticlePayload({ originalParsed, deleteEventTemplate }) const deletePayload = await buildDeletedArticlePayload({ originalParsed, deleteEventTemplate })
const event = await finalizeEventTemplate({ template: deleteEventTemplate, authorPrivateKey }) const event = await finalizeEventTemplate({ template: deleteEventTemplate, authorPrivateKey })
const relays = await getActiveRelaysOrPrimary() const relays = await getPublishRelays()
await publishDeletion({ event, relays, payload: deletePayload }) await publishDeletion({ event, relays, payload: deletePayload })
} }
@ -691,16 +647,6 @@ async function finalizeEventTemplate(params: {
return finalizeNostrEvent(params.template, secretKey) return finalizeNostrEvent(params.template, secretKey)
} }
async function getActiveRelaysOrPrimary(): Promise<string[]> {
const { relaySessionManager } = await import('./relaySessionManager')
const activeRelays = await relaySessionManager.getActiveRelays()
if (activeRelays.length > 0) {
return activeRelays
}
const { getPrimaryRelay } = await import('./config')
return [await getPrimaryRelay()]
}
async function publishDeletion(params: { async function publishDeletion(params: {
event: import('nostr-tools').Event event: import('nostr-tools').Event
relays: string[] relays: string[]

View File

@ -396,7 +396,7 @@ function readOptionalStringFields<TKeys extends readonly string[]>(
keys: TKeys keys: TKeys
): Partial<Record<TKeys[number], string>> { ): Partial<Record<TKeys[number], string>> {
const result: Partial<Record<TKeys[number], string>> = {} const result: Partial<Record<TKeys[number], string>> = {}
for (const key of keys) { for (const key of keys as ReadonlyArray<TKeys[number]>) {
const value = obj[key] const value = obj[key]
if (typeof value === 'string') { if (typeof value === 'string') {
result[key] = value result[key] = value

View File

@ -8,9 +8,9 @@ import type { PublishResult } from './publishResult'
import { writeOrchestrator } from './writeOrchestrator' import { writeOrchestrator } from './writeOrchestrator'
import { finalizeEvent } from 'nostr-tools' import { finalizeEvent } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils' import { hexToBytes } from 'nostr-tools/utils'
import { generatePublicationHashId } from './hashIdGenerator'
import { buildObjectId } from './urlGenerator'
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { buildParsedArticleFromDraft as buildParsedArticleFromDraftCore } from './articleDraftToParsedArticle'
import { getPublishRelays } from './relaySelection'
export function buildFailure(error?: string): PublishedArticle { export function buildFailure(error?: string): PublishedArticle {
const base: PublishedArticle = { const base: PublishedArticle = {
@ -26,55 +26,11 @@ async function buildParsedArticleFromDraft(
invoice: AlbyInvoice, invoice: AlbyInvoice,
authorPubkey: string authorPubkey: string
): Promise<{ article: Article; hash: string; version: number; index: number }> { ): Promise<{ article: Article; hash: string; version: number; index: number }> {
const category = mapDraftCategoryToTag(draft.category) return buildParsedArticleFromDraftCore({
draft,
const hashId = await generatePublicationHashId({ invoice,
pubkey: authorPubkey, 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'
} }
interface PublishPreviewParams { interface PublishPreviewParams {
@ -91,39 +47,41 @@ interface PublishPreviewParams {
export async function publishPreview( export async function publishPreview(
params: PublishPreviewParams params: PublishPreviewParams
): Promise<import('nostr-tools').Event | null | PublishResult> { ): Promise<import('nostr-tools').Event | null | PublishResult> {
const { draft, invoice, authorPubkey, presentationId, extraTags, encryptedContent, encryptedKey, returnStatus } = params const published = await publishPreviewToRelays(params)
// Build parsed article object if (!published) {
const { article, hash, version, index } = await buildParsedArticleFromDraft(draft, invoice, authorPubkey) 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({ const previewEventTemplate = await createPreviewEvent({
draft, draft: params.draft,
invoice, invoice: params.invoice,
authorPubkey, authorPubkey: params.authorPubkey,
authorPresentationId: presentationId, authorPresentationId: params.presentationId,
...(extraTags ? { extraTags } : {}), ...(params.extraTags ? { extraTags: params.extraTags } : {}),
...(encryptedContent ? { encryptedContent } : {}), ...(params.encryptedContent ? { encryptedContent: params.encryptedContent } : {}),
...(encryptedKey ? { encryptedKey } : {}), ...(params.encryptedKey ? { encryptedKey: params.encryptedKey } : {}),
}) })
// Set private key in orchestrator const privateKey = getPrivateKeyOrThrow()
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
throw new Error('Private key required for signing')
}
writeOrchestrator.setPrivateKey(privateKey) writeOrchestrator.setPrivateKey(privateKey)
// Finalize event
const secretKey = hexToBytes(privateKey) const secretKey = hexToBytes(privateKey)
const event = finalizeEvent(previewEventTemplate, secretKey) const event = finalizeEvent(previewEventTemplate, secretKey)
// Get active relays const relays = await getPublishRelays()
const { relaySessionManager } = await import('./relaySessionManager')
const activeRelays = await relaySessionManager.getActiveRelays()
const { getPrimaryRelay } = await import('./config')
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
// Publish via writeOrchestrator (parallel network + local write)
const result = await writeOrchestrator.writeAndPublish( const result = await writeOrchestrator.writeAndPublish(
{ {
objectType: 'publication', objectType: 'publication',
@ -136,27 +94,29 @@ export async function publishPreview(
}, },
relays relays
) )
if (!result.success) { if (!result.success) {
return null return null
} }
return { event, relays, published: result.published }
}
if (returnStatus) { function getPrivateKeyOrThrow(): string {
// Return PublishResult format const privateKey = nostrService.getPrivateKey()
return { if (!privateKey) {
event, throw new Error('Private key required for signing')
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',
}
}),
}
} }
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<ArticleDraft['category']>): string[][] { export function buildArticleExtraTags(draft: ArticleDraft, _category: NonNullable<ArticleDraft['category']>): string[][] {
@ -179,62 +139,91 @@ export async function encryptAndPublish(
presentationId: string presentationId: string
} }
): Promise<PublishedArticle> { ): Promise<PublishedArticle> {
const { draft, authorPubkey, authorPrivateKeyForEncryption, category, presentationId } = params const encrypted = await encryptDraftForPublishing(params)
const { encryptedContent, key, iv } = await encryptArticleContent(draft.content) const publishResult = await publishEncryptedPreview({ ...params, ...encrypted })
const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey) const normalized = normalizePublishResult(publishResult)
const invoice = await createArticleInvoice(draft) if (!normalized) {
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) {
return buildFailure('Failed to publish article') return buildFailure('Failed to publish article')
} }
await storePrivateContent({ await storePrivateContent({
articleId: event.id, articleId: normalized.event.id,
content: draft.content, content: params.draft.content,
authorPubkey, authorPubkey: params.authorPubkey,
invoice, invoice: encrypted.invoice,
decryptionKey: key, decryptionKey: encrypted.key,
decryptionIV: iv, decryptionIV: encrypted.iv,
}) })
console.warn('Article published with encrypted content', { console.warn('Article published with encrypted content', {
articleId: event.id, articleId: normalized.event.id,
authorPubkey, authorPubkey: params.authorPubkey,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
relayStatuses, relayStatuses: normalized.relayStatuses,
}) })
return { return {
articleId: event.id, articleId: normalized.event.id,
previewEventId: event.id, previewEventId: normalized.event.id,
invoice, invoice: encrypted.invoice,
success: true, 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<ArticleDraft['category']>
presentationId: string
encryptedContent: string
encryptedKey: string
}): Promise<import('nostr-tools').Event | null | PublishResult> {
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
}

View File

@ -92,14 +92,14 @@ export class AutomaticTransferService {
}) })
} }
private async transferPortion(params: { private async transferPortion<TSplit extends { platform: number }>(params: {
type: 'article' | 'review' type: 'article' | 'review'
id: string id: string
pubkey: string pubkey: string
recipient: string recipient: string
paymentAmount: number paymentAmount: number
computeSplit: (amount: number) => { platform: number } & Record<string, number> computeSplit: (amount: number) => TSplit
getRecipientAmount: (split: { platform: number } & Record<string, number>) => number getRecipientAmount: (split: TSplit) => number
missingRecipientError: string missingRecipientError: string
errorLogMessage: string errorLogMessage: string
}): Promise<TransferResult> { }): Promise<TransferResult> {

View File

@ -89,96 +89,7 @@ class NostrService {
const relayStatuses: RelayPublishStatus[] = [] const relayStatuses: RelayPublishStatus[] = []
// Start publishing asynchronously (don't await - non-blocking) // Start publishing asynchronously (don't await - non-blocking)
void (async (): Promise<void> => { void this.publishEventNonBlocking({ event, activeRelays, relaySessionManager })
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)
}
})()
// Build statuses for return (synchronous, before network completes) // Build statuses for return (synchronous, before network completes)
if (returnStatus) { if (returnStatus) {
@ -199,6 +110,54 @@ class NostrService {
return event return event
} }
private async publishEventNonBlocking(params: {
event: Event
activeRelays: string[]
relaySessionManager: typeof import('./relaySessionManager').relaySessionManager
}): Promise<void> {
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<string[]> {
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<typeof createSubscription> { private createArticleSubscription(pool: SimplePool, limit: number): ReturnType<typeof createSubscription> {
// Subscribe to both 'publication' and 'author' type events // Subscribe to both 'publication' and 'author' type events
// Authors are identified by tag type='author' in the tag system // Authors are identified by tag type='author' in the tag system

View File

@ -50,13 +50,6 @@ export function extractCommonTags(findTag: (key: string) => string | undefined,
} }
} }
function addOptionalString(target: Record<string, string>, key: string, value: string | undefined): Record<string, string> {
if (typeof value === 'string' && value.length > 0) {
return { ...target, [key]: value }
}
return target
}
function readCommonTagBase(findTag: (key: string) => string | undefined): { function readCommonTagBase(findTag: (key: string) => string | undefined): {
id: string | undefined id: string | undefined
service: string | undefined service: string | undefined
@ -117,24 +110,29 @@ function buildOptionalCommonTagFields(base: {
reviewerPubkey: string | undefined reviewerPubkey: string | undefined
json: string | undefined json: string | undefined
}): Record<string, string> { }): Record<string, string> {
let optional: Record<string, string> = {} return filterNonEmptyStrings({
optional = addOptionalString(optional, 'id', base.id) id: base.id,
optional = addOptionalString(optional, 'service', base.service) service: base.service,
optional = addOptionalString(optional, 'title', base.title) title: base.title,
optional = addOptionalString(optional, 'preview', base.preview) preview: base.preview,
optional = addOptionalString(optional, 'description', base.description) description: base.description,
optional = addOptionalString(optional, 'mainnetAddress', base.mainnetAddress) mainnetAddress: base.mainnetAddress,
optional = addOptionalString(optional, 'pictureUrl', base.pictureUrl) pictureUrl: base.pictureUrl,
optional = addOptionalString(optional, 'seriesId', base.seriesId) seriesId: base.seriesId,
optional = addOptionalString(optional, 'coverUrl', base.coverUrl) coverUrl: base.coverUrl,
optional = addOptionalString(optional, 'bannerUrl', base.bannerUrl) bannerUrl: base.bannerUrl,
optional = addOptionalString(optional, 'invoice', base.invoice) invoice: base.invoice,
optional = addOptionalString(optional, 'paymentHash', base.paymentHash) paymentHash: base.paymentHash,
optional = addOptionalString(optional, 'encryptedKey', base.encryptedKey) encryptedKey: base.encryptedKey,
optional = addOptionalString(optional, 'articleId', base.articleId) articleId: base.articleId,
optional = addOptionalString(optional, 'reviewerPubkey', base.reviewerPubkey) reviewerPubkey: base.reviewerPubkey,
optional = addOptionalString(optional, 'json', base.json) json: base.json,
return optional })
}
function filterNonEmptyStrings(values: Record<string, string | undefined>): Record<string, string> {
const entries = Object.entries(values).filter(([, value]) => typeof value === 'string' && value.length > 0)
return Object.fromEntries(entries) as Record<string, string>
} }
function buildOptionalNumericFields(base: { totalSponsoring: number | undefined; zapAmount: number | undefined }): { function buildOptionalNumericFields(base: { totalSponsoring: number | undefined; zapAmount: number | undefined }): {

View File

@ -321,7 +321,25 @@ async function buildSponsoringNotePayload(params: {
eventTemplate: EventTemplate eventTemplate: EventTemplate
parsedSponsoring: Sponsoring 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<typeof generateSponsoringHashId>[0] {
return {
payerPubkey: params.payerPubkey, payerPubkey: params.payerPubkey,
authorPubkey: params.authorPubkey, authorPubkey: params.authorPubkey,
amount: params.amount, amount: params.amount,
@ -329,15 +347,22 @@ async function buildSponsoringNotePayload(params: {
...(params.seriesId ? { seriesId: params.seriesId } : {}), ...(params.seriesId ? { seriesId: params.seriesId } : {}),
...(params.articleId ? { articleId: params.articleId } : {}), ...(params.articleId ? { articleId: params.articleId } : {}),
} }
}
const hashId = await generateSponsoringHashId(sponsoringData) function buildParsedSponsoring(params: {
const id = buildObjectId(hashId, 0, 0) authorPubkey: string
const tags = buildSponsoringNoteTags({ ...params, hashId }) payerPubkey: string
tags.push(['json', buildSponsoringPaymentJson({ ...params, sponsoringData, id, hashId })]) amount: number
paymentHash: string
const parsedSponsoring: Sponsoring = { seriesId?: string
id, articleId?: string
hash: hashId, text?: string
id: string
hashId: string
}): Sponsoring {
return {
id: params.id,
hash: params.hashId,
version: 0, version: 0,
index: 0, index: 0,
payerPubkey: params.payerPubkey, payerPubkey: params.payerPubkey,
@ -350,15 +375,10 @@ async function buildSponsoringNotePayload(params: {
...(params.text ? { text: params.text } : {}), ...(params.text ? { text: params.text } : {}),
kindType: 'sponsoring', kindType: 'sponsoring',
} }
}
const eventTemplate: EventTemplate = { function buildSponsoringEventTemplate(params: { tags: string[][]; content: string }): EventTemplate {
kind: 1, return { kind: 1, created_at: Math.floor(Date.now() / 1000), tags: params.tags, content: params.content }
created_at: Math.floor(Date.now() / 1000),
tags,
content: buildSponsoringNoteContent(params),
}
return { hashId, eventTemplate, parsedSponsoring }
} }
function buildSponsoringNoteContent(params: { function buildSponsoringNoteContent(params: {

View File

@ -98,63 +98,11 @@ export class PlatformTrackingService {
*/ */
async getArticleDeliveries(articleId: string): Promise<ContentDeliveryTracking[]> { async getArticleDeliveries(articleId: string): Promise<ContentDeliveryTracking[]> {
try { try {
const { websocketService } = await import('./websocketService')
const { getPrimaryRelaySync } = await import('./config') const { getPrimaryRelaySync } = await import('./config')
const { swClient } = await import('./swClient')
const filters = [
{
kinds: [getTrackingKind()],
'#p': [this.platformPubkey],
'#article': [articleId],
limit: 100,
},
]
const relayUrl = getPrimaryRelaySync() const relayUrl = getPrimaryRelaySync()
return queryDeliveries({
return new Promise((resolve) => { relayUrl,
const deliveries: ContentDeliveryTracking[] = [] filters: [buildArticleDeliveryFilter({ platformPubkey: this.platformPubkey, articleId })],
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)
}) })
} catch (error) { } catch (error) {
console.error('Error querying article deliveries', { console.error('Error querying article deliveries', {
@ -171,63 +119,11 @@ export class PlatformTrackingService {
*/ */
async getRecipientDeliveries(recipientPubkey: string): Promise<ContentDeliveryTracking[]> { async getRecipientDeliveries(recipientPubkey: string): Promise<ContentDeliveryTracking[]> {
try { try {
const { websocketService } = await import('./websocketService')
const { getPrimaryRelaySync } = await import('./config') const { getPrimaryRelaySync } = await import('./config')
const { swClient } = await import('./swClient')
const filters = [
{
kinds: [getTrackingKind()],
'#p': [this.platformPubkey],
'#recipient': [recipientPubkey],
limit: 100,
},
]
const relayUrl = getPrimaryRelaySync() const relayUrl = getPrimaryRelaySync()
return queryDeliveries({
return new Promise((resolve) => { relayUrl,
const deliveries: ContentDeliveryTracking[] = [] filters: [buildRecipientDeliveryFilter({ platformPubkey: this.platformPubkey, recipientPubkey })],
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)
}) })
} catch (error) { } catch (error) {
console.error('Error querying recipient deliveries', { console.error('Error querying recipient deliveries', {
@ -240,3 +136,128 @@ export class PlatformTrackingService {
} }
export const platformTracking = new PlatformTrackingService() export const platformTracking = new PlatformTrackingService()
function buildArticleDeliveryFilter(params: { platformPubkey: string; articleId: string }): Record<string, unknown> {
return {
kinds: [getTrackingKind()],
'#p': [params.platformPubkey],
'#article': [params.articleId],
limit: 100,
}
}
function buildRecipientDeliveryFilter(params: { platformPubkey: string; recipientPubkey: string }): Record<string, unknown> {
return {
kinds: [getTrackingKind()],
'#p': [params.platformPubkey],
'#recipient': [params.recipientPubkey],
limit: 100,
}
}
async function queryDeliveries(params: {
relayUrl: string
filters: Record<string, unknown>[]
}): Promise<ContentDeliveryTracking[]> {
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<string, unknown>[]
}): Promise<ContentDeliveryTracking[]> {
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<string, unknown>[]
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
}

View File

@ -7,6 +7,7 @@ import { objectCache, type ObjectType } from './objectCache'
import { relaySessionManager } from './relaySessionManager' import { relaySessionManager } from './relaySessionManager'
import { publishLog } from './publishLog' import { publishLog } from './publishLog'
import { writeService } from './writeService' import { writeService } from './writeService'
import { getPublishRelays } from './relaySelection'
const REPUBLISH_INTERVAL_MS = 30000 // 30 seconds const REPUBLISH_INTERVAL_MS = 30000 // 30 seconds
const MAX_RETRIES_PER_OBJECT = 10 const MAX_RETRIES_PER_OBJECT = 10
@ -164,86 +165,62 @@ class PublishWorkerService {
* Uses websocketService to route events to Service Worker * Uses websocketService to route events to Service Worker
*/ */
private async attemptPublish(params: { key: string; obj: UnpublishedObject }): Promise<void> { private async attemptPublish(params: { key: string; obj: UnpublishedObject }): Promise<void> {
const {obj} = params const { obj } = params
try { try {
const { websocketService } = await import('./websocketService') const { websocketService } = await import('./websocketService')
const relays = await getPublishRelays()
const activeRelays = await relaySessionManager.getActiveRelays() console.warn(`[PublishWorker] Attempting to publish ${obj.objectType}:${obj.id} to ${relays.length} relay(s)`)
if (activeRelays.length === 0) { const statuses = await websocketService.publishEvent(obj.event, relays)
const { getPrimaryRelaySync } = await import('./config') const successfulRelays = this.processPublishStatuses({ obj, relays, statuses })
const relayUrl = getPrimaryRelaySync() await this.finalizePublishAttempt({ key: params.key, obj, successfulRelays })
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)
}
}
} catch (error) { } catch (error) {
console.error(`[PublishWorker] Error publishing ${obj.objectType}:${obj.id}:`, error) console.error(`[PublishWorker] Error publishing ${obj.objectType}:${obj.id}:`, error)
const current = this.unpublishedObjects.get(params.key) this.incrementRetryOrRemove({ key: params.key, fallbackObj: params.obj })
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)
if (next.retryCount >= MAX_RETRIES_PER_OBJECT) { private processPublishStatuses(params: {
this.unpublishedObjects.delete(params.key) 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<void> {
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)
} }
} }
} }

View File

@ -115,7 +115,7 @@ function attachConfirmOverlayHandlers(params: ConfirmOverlayHandlerParams): void
const { overlay, cancel, confirm, resolve } = params const { overlay, cancel, confirm, resolve } = params
let resolved = false let resolved = false
const resolveOnce = (next: boolean): void => { function resolveOnce(next: boolean): void {
if (resolved) { if (resolved) {
return return
} }
@ -124,9 +124,15 @@ function attachConfirmOverlayHandlers(params: ConfirmOverlayHandlerParams): void
resolve(next) resolve(next)
} }
const onCancel = (): void => resolveOnce(false) function onCancel(): void {
const onConfirm = (): void => resolveOnce(true) resolveOnce(false)
const onKeyDown = (e: KeyboardEvent): void => { }
function onConfirm(): void {
resolveOnce(true)
}
function onKeyDown(e: KeyboardEvent): void {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
resolveOnce(false) resolveOnce(false)
@ -138,7 +144,7 @@ function attachConfirmOverlayHandlers(params: ConfirmOverlayHandlerParams): void
} }
} }
const cleanup = (): void => { function cleanup(): void {
overlay.removeEventListener('keydown', onKeyDown) overlay.removeEventListener('keydown', onKeyDown)
cancel.removeEventListener('click', onCancel) cancel.removeEventListener('click', onCancel)
confirm.removeEventListener('click', onConfirm) confirm.removeEventListener('click', onConfirm)

View File

@ -168,69 +168,10 @@ class WriteService {
async writeObject(params: WriteObjectParams): Promise<void> { async writeObject(params: WriteObjectParams): Promise<void> {
try { try {
await this.init() await this.init()
const published = params.published ?? false
if (this.writeWorker) { if (this.writeWorker) {
// Send to worker return this.postWriteObjectToWorker(params)
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,
},
})
}
})
} }
// Fallback: direct write await this.writeObjectDirect(params)
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 } : {}),
})
} catch (error) { } catch (error) {
console.error('[WriteService] Error writing object:', error) console.error('[WriteService] Error writing object:', error)
throw error throw error
@ -249,61 +190,115 @@ class WriteService {
await this.init() await this.init()
if (this.writeWorker) { if (this.writeWorker) {
// Send to worker return this.postUpdatePublishedToWorker({ objectType, id, published })
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 },
})
}
})
} }
// Fallback: direct write await this.updatePublishedDirect({ objectType, id, published })
const { objectCache } = await import('./objectCache')
await objectCache.updatePublished(objectType, id, published)
} catch (error) { } catch (error) {
console.error('[WriteService] Error updating published status:', error) console.error('[WriteService] Error updating published status:', error)
throw error throw error
} }
} }
private postWriteObjectToWorker(params: WriteObjectParams): Promise<void> {
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<void> {
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<void> {
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<void> {
const { objectCache } = await import('./objectCache')
await objectCache.updatePublished(params.objectType, params.id, params.published)
}
/** /**
* Create notification (via Web Worker) * Create notification (via Web Worker)
*/ */

View File

@ -207,7 +207,7 @@ async function makeRequestOnce(params: {
const { requestFormData, fileStream } = buildUploadFormData(params.file) const { requestFormData, fileStream } = buildUploadFormData(params.file)
const headers = buildProxyRequestHeaders(requestFormData, params.authToken) const headers = buildProxyRequestHeaders(requestFormData, params.authToken)
const { clientModule, requestOptions } = buildProxyRequestOptions({ url: params.url, headers }) const { clientModule, requestOptions } = buildProxyRequestOptions({ url: params.url, headers })
return await sendFormDataRequest({ return sendFormDataRequest({
clientModule, clientModule,
requestOptions, requestOptions,
requestFormData, requestFormData,
@ -267,7 +267,7 @@ async function sendFormDataRequest(params: {
finalUrl: string finalUrl: string
filepath: string filepath: string
}): Promise<ProxyUploadResponse> { }): Promise<ProxyUploadResponse> {
return await new Promise<ProxyUploadResponse>((resolve, reject) => { return new Promise<ProxyUploadResponse>((resolve, reject) => {
const proxyRequest = params.clientModule.request(params.requestOptions, (proxyResponse: http.IncomingMessage) => { const proxyRequest = params.clientModule.request(params.requestOptions, (proxyResponse: http.IncomingMessage) => {
void readProxyResponse({ proxyResponse, finalUrl: params.finalUrl }).then(resolve).catch(reject) 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<string> { async function readIncomingMessageBody(message: http.IncomingMessage): Promise<string> {
return await new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
let body = '' let body = ''
message.setEncoding('utf8') message.setEncoding('utf8')
message.on('data', (chunk) => { message.on('data', (chunk) => {