lint fix
This commit is contained in:
parent
620f5955ca
commit
f454553a66
@ -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,7 +247,37 @@ 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))
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
// Update authorName when profile changes
|
||||||
|
useEffect(() => {
|
||||||
|
syncAuthorNameIntoDraft({ existingAuthorName, draftAuthorName: draft.authorName, hasExistingPresentation: Boolean(existingPresentation), setDraft })
|
||||||
|
}, [existingAuthorName, existingPresentation, draft.authorName])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
await submitPresentationDraft({ draft, setValidationError, publishPresentation })
|
||||||
|
},
|
||||||
|
[draft, publishPresentation]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
await deletePresentationFlow({
|
||||||
|
existingPresentationId: existingPresentation?.id,
|
||||||
|
deletePresentation,
|
||||||
|
router,
|
||||||
|
setDeleting,
|
||||||
|
setValidationError,
|
||||||
|
})
|
||||||
|
}, [existingPresentation, deletePresentation, router])
|
||||||
|
|
||||||
|
return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitialDraft(existingPresentation: Article | null | undefined, existingAuthorName: string | undefined): AuthorPresentationDraft {
|
||||||
if (existingPresentation) {
|
if (existingPresentation) {
|
||||||
const { presentation, contentDescription } = extractPresentationData(existingPresentation)
|
const { presentation, contentDescription } = extractPresentationData(existingPresentation)
|
||||||
const authorName = existingPresentation.title.replace(/^Présentation de /, '') ?? existingAuthorName ?? ''
|
const authorName = existingPresentation.title.replace(/^Présentation de /, '') ?? existingAuthorName ?? ''
|
||||||
@ -274,58 +295,69 @@ function useAuthorPresentationState(pubkey: string | null, existingAuthorName?:
|
|||||||
contentDescription: '',
|
contentDescription: '',
|
||||||
mainnetAddress: '',
|
mainnetAddress: '',
|
||||||
}
|
}
|
||||||
})
|
|
||||||
const [validationError, setValidationError] = useState<string | null>(null)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
|
|
||||||
// Update authorName when profile changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (existingAuthorName && existingAuthorName !== draft.authorName && !existingPresentation) {
|
|
||||||
setDraft((prev) => ({ ...prev, authorName: existingAuthorName }))
|
|
||||||
}
|
}
|
||||||
}, [existingAuthorName, existingPresentation, draft.authorName])
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
function syncAuthorNameIntoDraft(params: {
|
||||||
async (e: FormEvent<HTMLFormElement>) => {
|
existingAuthorName: string | undefined
|
||||||
e.preventDefault()
|
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()
|
const address = draft.mainnetAddress.trim()
|
||||||
if (!ADDRESS_PATTERN.test(address)) {
|
if (!ADDRESS_PATTERN.test(address)) {
|
||||||
setValidationError(t('presentation.validation.invalidAddress'))
|
return t('presentation.validation.invalidAddress')
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (!draft.authorName.trim()) {
|
if (!draft.authorName.trim()) {
|
||||||
setValidationError(t('presentation.validation.authorNameRequired'))
|
return t('presentation.validation.authorNameRequired')
|
||||||
return
|
|
||||||
}
|
}
|
||||||
setValidationError(null)
|
return null
|
||||||
await publishPresentation(draft)
|
|
||||||
},
|
|
||||||
[draft, publishPresentation]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
|
||||||
if (!existingPresentation?.id) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'))
|
const confirmed = await userConfirm(t('presentation.delete.confirm'))
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
params.setDeleting(true)
|
||||||
setDeleting(true)
|
params.setValidationError(null)
|
||||||
setValidationError(null)
|
|
||||||
try {
|
try {
|
||||||
await deletePresentation(existingPresentation.id)
|
await params.deletePresentation(params.existingPresentationId)
|
||||||
await router.push('/')
|
await params.router.push('/')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setValidationError(e instanceof Error ? e.message : t('presentation.delete.error'))
|
params.setValidationError(e instanceof Error ? e.message : t('presentation.delete.error'))
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
params.setDeleting(false)
|
||||||
}
|
}
|
||||||
}, [existingPresentation, deletePresentation, router])
|
|
||||||
|
|
||||||
return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoAccountActionButtons({
|
function NoAccountActionButtons({
|
||||||
@ -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 })
|
||||||
|
const handleImportSuccess = (): void => {
|
||||||
|
setShowImportModal(false)
|
||||||
|
setShowUnlockModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoAccountCard
|
||||||
|
error={error}
|
||||||
|
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 {
|
try {
|
||||||
const { nostrAuthService } = await import('@/lib/nostrAuth')
|
const { nostrAuthService } = await import('@/lib/nostrAuth')
|
||||||
const result = await nostrAuthService.createAccount()
|
const result = await nostrAuthService.createAccount()
|
||||||
setRecoveryPhrase(result.recoveryPhrase)
|
params.setRecoveryPhrase(result.recoveryPhrase)
|
||||||
setNpub(result.npub)
|
params.setNpub(result.npub)
|
||||||
setShowRecoveryStep(true)
|
params.setShowRecoveryStep(true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : t('account.create.error.failed'))
|
params.setError(e instanceof Error ? e.message : t('account.create.error.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false)
|
params.setGenerating(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRecoveryContinue = (): void => {
|
function transitionToUnlock(params: { setShowRecoveryStep: (value: boolean) => void; setShowUnlockModal: (value: boolean) => void }): void {
|
||||||
setShowRecoveryStep(false)
|
params.setShowRecoveryStep(false)
|
||||||
setShowUnlockModal(true)
|
params.setShowUnlockModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUnlockSuccess = (): void => {
|
function resetNoAccountAfterUnlock(params: {
|
||||||
setShowUnlockModal(false)
|
setShowUnlockModal: (value: boolean) => void
|
||||||
setRecoveryPhrase([])
|
setRecoveryPhrase: (value: string[]) => void
|
||||||
setNpub('')
|
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) {
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -92,46 +92,30 @@ 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({
|
||||||
const file = readFirstFile(event)
|
onChange,
|
||||||
if (!file) {
|
setError,
|
||||||
return
|
setUploading,
|
||||||
}
|
setPendingFile,
|
||||||
|
setShowUnlockModal,
|
||||||
|
})
|
||||||
|
|
||||||
setError(null)
|
const handleUnlockSuccess = createHandleUnlockSuccess({
|
||||||
setUploading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await processFileUpload(file, onChange, setError)
|
|
||||||
} catch (uploadError) {
|
|
||||||
const uploadErr = normalizeError(uploadError)
|
|
||||||
if (isUnlockRequiredError(uploadErr)) {
|
|
||||||
setPendingFile(file)
|
|
||||||
setShowUnlockModal(true)
|
|
||||||
setError(null) // Don't show error, show unlock modal instead
|
|
||||||
} else {
|
|
||||||
setError(uploadErr.message ?? t('presentation.field.picture.error.uploadFailed'))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setUploading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUnlockSuccess = async (): Promise<void> => {
|
|
||||||
await retryPendingUpload({
|
|
||||||
pendingFile,
|
pendingFile,
|
||||||
onChange,
|
onChange,
|
||||||
setError,
|
setError,
|
||||||
@ -139,11 +123,56 @@ function useImageUpload(onChange: (url: string) => void): {
|
|||||||
setShowUnlockModal,
|
setShowUnlockModal,
|
||||||
setUploading,
|
setUploading,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
return { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess }
|
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)
|
||||||
|
if (!file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params.setError(null)
|
||||||
|
params.setUploading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await processFileUpload(file, params.onChange, params.setError)
|
||||||
|
} catch (uploadError) {
|
||||||
|
const uploadErr = normalizeError(uploadError)
|
||||||
|
if (isUnlockRequiredError(uploadErr)) {
|
||||||
|
params.setPendingFile(file)
|
||||||
|
params.setShowUnlockModal(true)
|
||||||
|
params.setError(null)
|
||||||
|
} else {
|
||||||
|
params.setError(uploadErr.message ?? t('presentation.field.picture.error.uploadFailed'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
params.setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHandleUnlockSuccess(params: {
|
||||||
|
pendingFile: File | null
|
||||||
|
onChange: (url: string) => void
|
||||||
|
setError: (error: string | null) => void
|
||||||
|
setPendingFile: (file: File | null) => void
|
||||||
|
setShowUnlockModal: (show: boolean) => void
|
||||||
|
setUploading: (uploading: boolean) => void
|
||||||
|
}): () => Promise<void> {
|
||||||
|
return async (): Promise<void> => {
|
||||||
|
await retryPendingUpload(params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readFirstFile(event: React.ChangeEvent<HTMLInputElement>): File | null {
|
function readFirstFile(event: React.ChangeEvent<HTMLInputElement>): File | null {
|
||||||
return event.target.files?.[0] ?? null
|
return event.target.files?.[0] ?? null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -146,24 +146,23 @@ 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)
|
||||||
@ -173,14 +172,12 @@ function createHandleCopy(params: {
|
|||||||
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()
|
||||||
@ -193,7 +190,6 @@ function createHandleOpenWallet(params: {
|
|||||||
params.setErrorMessage(error.message)
|
params.setErrorMessage(error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePaymentError(error: unknown): Error {
|
function normalizePaymentError(error: unknown): Error {
|
||||||
return error instanceof Error ? error : new Error(String(error))
|
return error instanceof Error ? error : new Error(String(error))
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,8 +61,27 @@ 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 result = await publishPresentationArticleWithDraft({ draft: params.draft, pubkey: params.pubkey, privateKey })
|
||||||
|
params.setSuccess(result.success === true)
|
||||||
|
if (!result.success) {
|
||||||
|
params.setError(result.error ?? 'Erreur lors de la publication')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : 'Erreur inconnue'
|
||||||
|
console.error('Error publishing presentation:', e)
|
||||||
|
params.setError(errorMessage)
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishPresentationArticleWithDraft(params: {
|
||||||
|
draft: AuthorPresentationDraft
|
||||||
|
pubkey: string
|
||||||
|
privateKey: string
|
||||||
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
const { title, preview, fullContent } = buildPresentationContent(params.draft)
|
const { title, preview, fullContent } = buildPresentationContent(params.draft)
|
||||||
const result = await articlePublisher.publishPresentationArticle(
|
return articlePublisher.publishPresentationArticle(
|
||||||
{
|
{
|
||||||
title,
|
title,
|
||||||
preview,
|
preview,
|
||||||
@ -73,20 +92,8 @@ async function publishAuthorPresentation(params: {
|
|||||||
...(params.draft.pictureUrl ? { pictureUrl: params.draft.pictureUrl } : {}),
|
...(params.draft.pictureUrl ? { pictureUrl: params.draft.pictureUrl } : {}),
|
||||||
},
|
},
|
||||||
params.pubkey,
|
params.pubkey,
|
||||||
privateKey
|
params.privateKey
|
||||||
)
|
)
|
||||||
if (result.success) {
|
|
||||||
params.setSuccess(true)
|
|
||||||
} else {
|
|
||||||
params.setError(result.error ?? 'Erreur lors de la publication')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : 'Erreur inconnue'
|
|
||||||
console.error('Error publishing presentation:', e)
|
|
||||||
params.setError(errorMessage)
|
|
||||||
} finally {
|
|
||||||
params.setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateProfileBestEffort(draft: AuthorPresentationDraft): Promise<void> {
|
async function updateProfileBestEffort(draft: AuthorPresentationDraft): Promise<void> {
|
||||||
|
|||||||
@ -20,7 +20,9 @@ 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> =>
|
||||||
|
loadDocImpl({
|
||||||
|
docId,
|
||||||
docs,
|
docs,
|
||||||
setLoading,
|
setLoading,
|
||||||
setSelectedDoc,
|
setSelectedDoc,
|
||||||
@ -41,20 +43,20 @@ 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)
|
||||||
@ -66,7 +68,6 @@ function createLoadDoc(params: {
|
|||||||
params.setLoading(false)
|
params.setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchDocContent(docFile: string): Promise<string> {
|
async function fetchDocContent(docFile: string): Promise<string> {
|
||||||
const locale = getLocale()
|
const locale = getLocale()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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)
|
||||||
|
return category
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSeriesCategoryToTag(category: NonNullable<ArticleDraft['category']>): 'sciencefiction' | 'research' {
|
||||||
|
return category === 'science-fiction' ? 'sciencefiction' : 'research'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSeriesPreview(preview: string | undefined, description: string): string {
|
||||||
|
return preview ?? description.substring(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParsedSeries(params: {
|
||||||
|
authorPubkey: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
preview?: string
|
preview: string
|
||||||
coverUrl?: string
|
coverUrl: string | undefined
|
||||||
authorPubkey: string
|
|
||||||
},
|
|
||||||
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)])
|
||||||
// Add JSON metadata as a tag
|
return { kind: 1, created_at: Math.floor(Date.now() / 1000), content: params.preview, tags }
|
||||||
tags.push(['json', seriesJson])
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: 1,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
content: params.preview ?? params.description.substring(0, 200),
|
|
||||||
tags,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSeriesJson(params: {
|
||||||
|
authorPubkey: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
preview: string
|
||||||
|
coverUrl: string | undefined
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
hashId: string
|
||||||
|
}): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'series',
|
||||||
|
pubkey: params.authorPubkey,
|
||||||
|
title: params.title,
|
||||||
|
description: params.description,
|
||||||
|
preview: params.preview,
|
||||||
|
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
|
||||||
|
category: params.category,
|
||||||
|
id: params.hashId,
|
||||||
|
version: 0,
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrivateKeyForSigning(authorPrivateKey: string | undefined): string {
|
||||||
|
const privateKey = authorPrivateKey ?? nostrService.getPrivateKey()
|
||||||
|
if (!privateKey) {
|
||||||
|
throw new Error('Private key required for signing')
|
||||||
|
}
|
||||||
|
writeOrchestrator.setPrivateKey(privateKey)
|
||||||
|
return privateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishReview(params: {
|
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,
|
|
||||||
success: true,
|
|
||||||
originalArticleId,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireUpdateCategory(draft: ArticleDraft): NonNullable<ArticleDraft['category']> {
|
||||||
|
requireCategory(draft.category)
|
||||||
|
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[]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
relayUrl,
|
relayUrl,
|
||||||
success: isSuccess,
|
success: isSuccess,
|
||||||
error: isSuccess ? undefined : 'Failed to publish',
|
error: isSuccess ? undefined : 'Failed to publish',
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return event
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
139
lib/nostr.ts
139
lib/nostr.ts
@ -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
|
||||||
|
|||||||
@ -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 }): {
|
||||||
|
|||||||
@ -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 = {
|
|
||||||
kind: 1,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags,
|
|
||||||
content: buildSponsoringNoteContent(params),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { hashId, eventTemplate, parsedSponsoring }
|
function buildSponsoringEventTemplate(params: { tags: string[][]; content: string }): EventTemplate {
|
||||||
|
return { kind: 1, created_at: Math.floor(Date.now() / 1000), tags: params.tags, content: params.content }
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSponsoringNoteContent(params: {
|
function buildSponsoringNoteContent(params: {
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
@ -167,85 +168,61 @@ class PublishWorkerService {
|
|||||||
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)
|
} catch (error) {
|
||||||
|
console.error(`[PublishWorker] Error publishing ${obj.objectType}:${obj.id}:`, error)
|
||||||
|
this.incrementRetryOrRemove({ key: params.key, fallbackObj: params.obj })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn(`[PublishWorker] Attempting to publish ${obj.objectType}:${obj.id} to ${activeRelays.length} relay(s)`)
|
private processPublishStatuses(params: {
|
||||||
|
obj: UnpublishedObject
|
||||||
// Publish to all active relays via websocketService (routes to Service Worker)
|
relays: string[]
|
||||||
const statuses = await websocketService.publishEvent(obj.event, activeRelays)
|
statuses: { success: boolean; error?: string }[]
|
||||||
|
}): string[] {
|
||||||
const successfulRelays: string[] = []
|
const successfulRelays: string[] = []
|
||||||
statuses.forEach((status, index) => {
|
params.statuses.forEach((status, index) => {
|
||||||
const relayUrl = activeRelays[index]
|
const relayUrl = params.relays[index]
|
||||||
if (!relayUrl) {
|
if (!relayUrl) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.success) {
|
if (status.success) {
|
||||||
successfulRelays.push(relayUrl)
|
successfulRelays.push(relayUrl)
|
||||||
// Log successful publication
|
void publishLog.logPublication({ eventId: params.obj.event.id, relayUrl, success: true, objectType: params.obj.objectType, objectId: params.obj.id })
|
||||||
void publishLog.logPublication({
|
return
|
||||||
eventId: obj.event.id,
|
}
|
||||||
relayUrl,
|
|
||||||
success: true,
|
|
||||||
objectType: obj.objectType,
|
|
||||||
objectId: obj.id,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const errorMessage = status.error ?? 'Unknown error'
|
const errorMessage = status.error ?? 'Unknown error'
|
||||||
console.warn(`[PublishWorker] Relay ${relayUrl} failed for ${obj.objectType}:${obj.id}:`, errorMessage)
|
console.warn(`[PublishWorker] Relay ${relayUrl} failed for ${params.obj.objectType}:${params.obj.id}:`, errorMessage)
|
||||||
relaySessionManager.markRelayFailed(relayUrl)
|
relaySessionManager.markRelayFailed(relayUrl)
|
||||||
// Log failed publication
|
void publishLog.logPublication({ eventId: params.obj.event.id, relayUrl, success: false, error: errorMessage, objectType: params.obj.objectType, objectId: params.obj.id })
|
||||||
void publishLog.logPublication({
|
|
||||||
eventId: obj.event.id,
|
|
||||||
relayUrl,
|
|
||||||
success: false,
|
|
||||||
error: errorMessage,
|
|
||||||
objectType: obj.objectType,
|
|
||||||
objectId: obj.id,
|
|
||||||
})
|
})
|
||||||
|
return successfulRelays
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
// Update published status via writeService
|
private async finalizePublishAttempt(params: { key: string; obj: UnpublishedObject; successfulRelays: string[] }): Promise<void> {
|
||||||
if (successfulRelays.length > 0) {
|
if (params.successfulRelays.length > 0) {
|
||||||
await writeService.updatePublished(obj.objectType, obj.id, successfulRelays)
|
await writeService.updatePublished(params.obj.objectType, params.obj.id, params.successfulRelays)
|
||||||
console.warn(`[PublishWorker] Successfully published ${obj.objectType}:${obj.id} to ${successfulRelays.length} relay(s)`)
|
console.warn(`[PublishWorker] Successfully published ${params.obj.objectType}:${params.obj.id} to ${params.successfulRelays.length} relay(s)`)
|
||||||
// Remove from unpublished map
|
|
||||||
this.unpublishedObjects.delete(params.key)
|
this.unpublishedObjects.delete(params.key)
|
||||||
} else {
|
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 current = this.unpublishedObjects.get(params.key)
|
||||||
const next = current
|
const base = current ?? params.fallbackObj
|
||||||
? { ...current, retryCount: current.retryCount + 1, lastRetryAt: Date.now() }
|
const next = { ...base, retryCount: base.retryCount + 1, lastRetryAt: Date.now() }
|
||||||
: { ...obj, retryCount: obj.retryCount + 1, lastRetryAt: Date.now() }
|
|
||||||
this.unpublishedObjects.set(params.key, next)
|
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}`)
|
||||||
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) {
|
if (next.retryCount >= MAX_RETRIES_PER_OBJECT) {
|
||||||
this.unpublishedObjects.delete(params.key)
|
this.unpublishedObjects.delete(params.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(`[PublishWorker] Error publishing ${obj.objectType}:${obj.id}:`, error)
|
|
||||||
const current = this.unpublishedObjects.get(params.key)
|
|
||||||
const next = current
|
|
||||||
? { ...current, retryCount: current.retryCount + 1, lastRetryAt: Date.now() }
|
|
||||||
: { ...params.obj, retryCount: params.obj.retryCount + 1, lastRetryAt: Date.now() }
|
|
||||||
this.unpublishedObjects.set(params.key, next)
|
|
||||||
|
|
||||||
if (next.retryCount >= MAX_RETRIES_PER_OBJECT) {
|
|
||||||
this.unpublishedObjects.delete(params.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const publishWorker = new PublishWorkerService()
|
export const publishWorker = new PublishWorkerService()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
await this.writeObjectDirect(params)
|
||||||
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
|
|
||||||
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,21 +190,85 @@ 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(() => {
|
await this.updatePublishedDirect({ objectType, id, published })
|
||||||
reject(new Error('Update published operation timeout'))
|
} catch (error) {
|
||||||
}, 10000)
|
console.error('[WriteService] Error updating published status:', 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 => {
|
const handler = (event: MessageEvent): void => {
|
||||||
if (!isWorkerMessageEnvelope(event.data)) {
|
if (!isWorkerMessageEnvelope(event.data)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const responseType = event.data.type
|
if (event.data.type === 'WRITE_OBJECT_SUCCESS' && isRecord(event.data.data) && event.data.data.hash === params.hash) {
|
||||||
const responseData = event.data.data
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (responseType === 'UPDATE_PUBLISHED_SUCCESS') {
|
private async writeObjectDirect(params: WriteObjectParams): Promise<void> {
|
||||||
if (!isRecord(responseData) || responseData.id !== id) {
|
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
|
return
|
||||||
}
|
}
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
@ -271,12 +276,10 @@ class WriteService {
|
|||||||
resolve()
|
resolve()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.data.type !== 'ERROR') {
|
||||||
if (responseType !== 'ERROR') {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const errorData = readWorkerErrorData(event.data.data)
|
||||||
const errorData = readWorkerErrorData(responseData)
|
|
||||||
if (!isWorkerErrorForOperation(errorData, 'UPDATE_PUBLISHED')) {
|
if (!isWorkerErrorForOperation(errorData, 'UPDATE_PUBLISHED')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -284,24 +287,16 @@ class WriteService {
|
|||||||
this.writeWorker?.removeEventListener('message', handler)
|
this.writeWorker?.removeEventListener('message', handler)
|
||||||
reject(new Error(errorData.error ?? 'Write worker error'))
|
reject(new Error(errorData.error ?? 'Write worker error'))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.writeWorker) {
|
if (this.writeWorker) {
|
||||||
this.writeWorker.addEventListener('message', handler)
|
this.writeWorker.addEventListener('message', handler)
|
||||||
this.writeWorker.postMessage({
|
this.writeWorker.postMessage({ type: 'UPDATE_PUBLISHED', data: { ...params } })
|
||||||
type: 'UPDATE_PUBLISHED',
|
|
||||||
data: { objectType, id, published },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Fallback: direct write
|
|
||||||
const { objectCache } = await import('./objectCache')
|
|
||||||
await objectCache.updatePublished(objectType, id, published)
|
|
||||||
|
|
||||||
} catch (error) {
|
private async updatePublishedDirect(params: { objectType: ObjectType; id: string; published: false | string[] }): Promise<void> {
|
||||||
console.error('[WriteService] Error updating published status:', error)
|
const { objectCache } = await import('./objectCache')
|
||||||
throw error
|
await objectCache.updatePublished(params.objectType, params.id, params.published)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user