diff --git a/components/AuthorPresentationEditor.tsx b/components/AuthorPresentationEditor.tsx
index 867cb15..5213f2d 100644
--- a/components/AuthorPresentationEditor.tsx
+++ b/components/AuthorPresentationEditor.tsx
@@ -186,17 +186,7 @@ function DeleteButton({ onDelete, deleting }: { onDelete: () => void; deleting:
)
}
-function PresentationForm({
- draft,
- setDraft,
- validationError,
- error,
- loading,
- handleSubmit,
- deleting,
- handleDelete,
- hasExistingPresentation,
-}: {
+type PresentationFormProps = {
draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void
validationError: string | null
@@ -206,43 +196,44 @@ function PresentationForm({
deleting: boolean
handleDelete: () => void
hasExistingPresentation: boolean
-}): React.ReactElement {
+}
+
+function PresentationForm(props: PresentationFormProps): React.ReactElement {
return (
)
}
+function getSubmitLabel(params: { loading: boolean; deleting: boolean; hasExistingPresentation: boolean }): string {
+ if (params.loading || params.deleting) {
+ return t('publish.publishing')
+ }
+ return params.hasExistingPresentation ? t('presentation.update.button') : t('publish.button')
+}
+
function useAuthorPresentationState(pubkey: string | null, existingAuthorName?: string, existingPresentation?: Article | null): {
draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void
@@ -256,78 +247,119 @@ function useAuthorPresentationState(pubkey: string | null, existingAuthorName?:
} {
const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey)
const router = useRouter()
- const [draft, setDraft] = useState(() => {
- if (existingPresentation) {
- const { presentation, contentDescription } = extractPresentationData(existingPresentation)
- const authorName = existingPresentation.title.replace(/^Présentation de /, '') ?? existingAuthorName ?? ''
- return {
- authorName,
- presentation,
- contentDescription,
- mainnetAddress: existingPresentation.mainnetAddress ?? '',
- ...(existingPresentation.bannerUrl ? { pictureUrl: existingPresentation.bannerUrl } : {}),
- }
- }
- return {
- authorName: existingAuthorName ?? '',
- presentation: '',
- contentDescription: '',
- mainnetAddress: '',
- }
- })
+ const [draft, setDraft] = useState(() => buildInitialDraft(existingPresentation, existingAuthorName))
const [validationError, setValidationError] = useState(null)
const [deleting, setDeleting] = useState(false)
// Update authorName when profile changes
useEffect(() => {
- if (existingAuthorName && existingAuthorName !== draft.authorName && !existingPresentation) {
- setDraft((prev) => ({ ...prev, authorName: existingAuthorName }))
- }
+ syncAuthorNameIntoDraft({ existingAuthorName, draftAuthorName: draft.authorName, hasExistingPresentation: Boolean(existingPresentation), setDraft })
}, [existingAuthorName, existingPresentation, draft.authorName])
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault()
- const address = draft.mainnetAddress.trim()
- if (!ADDRESS_PATTERN.test(address)) {
- setValidationError(t('presentation.validation.invalidAddress'))
- return
- }
- if (!draft.authorName.trim()) {
- setValidationError(t('presentation.validation.authorNameRequired'))
- return
- }
- setValidationError(null)
- await publishPresentation(draft)
+ await submitPresentationDraft({ draft, setValidationError, publishPresentation })
},
[draft, publishPresentation]
)
const handleDelete = useCallback(async () => {
- if (!existingPresentation?.id) {
- return
- }
-
- const confirmed = await userConfirm(t('presentation.delete.confirm'))
- if (!confirmed) {
- return
- }
-
- setDeleting(true)
- setValidationError(null)
- try {
- await deletePresentation(existingPresentation.id)
- await router.push('/')
- } catch (e) {
- setValidationError(e instanceof Error ? e.message : t('presentation.delete.error'))
- } finally {
- setDeleting(false)
- }
+ await deletePresentationFlow({
+ existingPresentationId: existingPresentation?.id,
+ deletePresentation,
+ router,
+ setDeleting,
+ setValidationError,
+ })
}, [existingPresentation, deletePresentation, router])
return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete }
}
+function buildInitialDraft(existingPresentation: Article | null | undefined, existingAuthorName: string | undefined): AuthorPresentationDraft {
+ if (existingPresentation) {
+ const { presentation, contentDescription } = extractPresentationData(existingPresentation)
+ const authorName = existingPresentation.title.replace(/^Présentation de /, '') ?? existingAuthorName ?? ''
+ return {
+ authorName,
+ presentation,
+ contentDescription,
+ mainnetAddress: existingPresentation.mainnetAddress ?? '',
+ ...(existingPresentation.bannerUrl ? { pictureUrl: existingPresentation.bannerUrl } : {}),
+ }
+ }
+ return {
+ authorName: existingAuthorName ?? '',
+ presentation: '',
+ contentDescription: '',
+ mainnetAddress: '',
+ }
+}
+
+function syncAuthorNameIntoDraft(params: {
+ existingAuthorName: string | undefined
+ draftAuthorName: string
+ hasExistingPresentation: boolean
+ setDraft: (updater: (prev: AuthorPresentationDraft) => AuthorPresentationDraft) => void
+}): void {
+ if (!params.existingAuthorName || params.hasExistingPresentation || params.existingAuthorName === params.draftAuthorName) {
+ return
+ }
+ params.setDraft((prev) => ({ ...prev, authorName: params.existingAuthorName as string }))
+}
+
+async function submitPresentationDraft(params: {
+ draft: AuthorPresentationDraft
+ setValidationError: (value: string | null) => void
+ publishPresentation: (draft: AuthorPresentationDraft) => Promise
+}): Promise {
+ const error = validatePresentationDraft(params.draft)
+ if (error) {
+ params.setValidationError(error)
+ return
+ }
+ params.setValidationError(null)
+ await params.publishPresentation(params.draft)
+}
+
+function validatePresentationDraft(draft: AuthorPresentationDraft): string | null {
+ const address = draft.mainnetAddress.trim()
+ if (!ADDRESS_PATTERN.test(address)) {
+ return t('presentation.validation.invalidAddress')
+ }
+ if (!draft.authorName.trim()) {
+ return t('presentation.validation.authorNameRequired')
+ }
+ return null
+}
+
+async function deletePresentationFlow(params: {
+ existingPresentationId: string | undefined
+ deletePresentation: (articleId: string) => Promise
+ router: ReturnType
+ setDeleting: (value: boolean) => void
+ setValidationError: (value: string | null) => void
+}): Promise {
+ if (!params.existingPresentationId) {
+ return
+ }
+ const confirmed = await userConfirm(t('presentation.delete.confirm'))
+ if (!confirmed) {
+ return
+ }
+ params.setDeleting(true)
+ params.setValidationError(null)
+ try {
+ await params.deletePresentation(params.existingPresentationId)
+ await params.router.push('/')
+ } catch (e) {
+ params.setValidationError(e instanceof Error ? e.message : t('presentation.delete.error'))
+ } finally {
+ params.setDeleting(false)
+ }
+}
+
function NoAccountActionButtons({
onGenerate,
onImport,
@@ -362,122 +394,133 @@ function NoAccountView(): React.ReactElement {
const [generating, setGenerating] = useState(false)
const [error, setError] = useState(null)
- const handleGenerate = async (): Promise => {
- setGenerating(true)
- setError(null)
- try {
- const { nostrAuthService } = await import('@/lib/nostrAuth')
- const result = await nostrAuthService.createAccount()
- setRecoveryPhrase(result.recoveryPhrase)
- setNpub(result.npub)
- setShowRecoveryStep(true)
- } catch (e) {
- setError(e instanceof Error ? e.message : t('account.create.error.failed'))
- } finally {
- setGenerating(false)
- }
- }
-
- const handleRecoveryContinue = (): void => {
- setShowRecoveryStep(false)
+ const handleGenerate = (): Promise => generateNoAccount({ setGenerating, setError, setRecoveryPhrase, setNpub, setShowRecoveryStep })
+ const handleRecoveryContinue = (): void => transitionToUnlock({ setShowRecoveryStep, setShowUnlockModal })
+ const handleUnlockSuccess = (): void => resetNoAccountAfterUnlock({ setShowUnlockModal, setRecoveryPhrase, setNpub })
+ const handleImportSuccess = (): void => {
+ setShowImportModal(false)
setShowUnlockModal(true)
}
- const handleUnlockSuccess = (): void => {
- setShowUnlockModal(false)
- setRecoveryPhrase([])
- setNpub('')
- }
+ return (
+ { void handleGenerate() }}
+ onImport={() => setShowImportModal(true)}
+ modals={
+ setShowImportModal(false)}
+ onImportSuccess={handleImportSuccess}
+ showRecoveryStep={showRecoveryStep}
+ recoveryPhrase={recoveryPhrase}
+ npub={npub}
+ onRecoveryContinue={handleRecoveryContinue}
+ showUnlockModal={showUnlockModal}
+ onUnlockSuccess={handleUnlockSuccess}
+ onCloseUnlock={() => setShowUnlockModal(false)}
+ />
+ }
+ />
+ )
+}
+async function generateNoAccount(params: {
+ setGenerating: (value: boolean) => void
+ setError: (value: string | null) => void
+ setRecoveryPhrase: (value: string[]) => void
+ setNpub: (value: string) => void
+ setShowRecoveryStep: (value: boolean) => void
+}): Promise {
+ params.setGenerating(true)
+ params.setError(null)
+ try {
+ const { nostrAuthService } = await import('@/lib/nostrAuth')
+ const result = await nostrAuthService.createAccount()
+ params.setRecoveryPhrase(result.recoveryPhrase)
+ params.setNpub(result.npub)
+ params.setShowRecoveryStep(true)
+ } catch (e) {
+ params.setError(e instanceof Error ? e.message : t('account.create.error.failed'))
+ } finally {
+ params.setGenerating(false)
+ }
+}
+
+function transitionToUnlock(params: { setShowRecoveryStep: (value: boolean) => void; setShowUnlockModal: (value: boolean) => void }): void {
+ params.setShowRecoveryStep(false)
+ params.setShowUnlockModal(true)
+}
+
+function resetNoAccountAfterUnlock(params: {
+ setShowUnlockModal: (value: boolean) => void
+ setRecoveryPhrase: (value: string[]) => void
+ setNpub: (value: string) => void
+}): void {
+ params.setShowUnlockModal(false)
+ params.setRecoveryPhrase([])
+ params.setNpub('')
+}
+
+function NoAccountCard(params: {
+ error: string | null
+ generating: boolean
+ onGenerate: () => void
+ onImport: () => void
+ modals: React.ReactElement
+}): React.ReactElement {
return (
-
- Créez un compte ou importez votre clé secrète pour commencer
-
- {error &&
{error}
}
-
{ void handleGenerate() }}
- onImport={() => setShowImportModal(true)}
- />
- {generating && (
- Génération du compte...
- )}
- {showImportModal && (
- {
- setShowImportModal(false)
- setShowUnlockModal(true)
- }}
- onClose={() => setShowImportModal(false)}
- initialStep="import"
- />
- )}
- {showRecoveryStep && (
-
- )}
- {showUnlockModal && (
- setShowUnlockModal(false)}
- />
- )}
+ Créez un compte ou importez votre clé secrète pour commencer
+ {params.error && {params.error}
}
+
+ {params.generating && Génération du compte...
}
+ {params.modals}
)
}
-function AuthorPresentationFormView({
- pubkey,
- profile,
-}: {
+function NoAccountModals(params: {
+ showImportModal: boolean
+ onImportSuccess: () => void
+ onCloseImport: () => void
+ showRecoveryStep: boolean
+ recoveryPhrase: string[]
+ npub: string
+ onRecoveryContinue: () => void
+ showUnlockModal: boolean
+ onUnlockSuccess: () => void
+ onCloseUnlock: () => void
+}): React.ReactElement {
+ return (
+ <>
+ {params.showImportModal && }
+ {params.showRecoveryStep && }
+ {params.showUnlockModal && }
+ >
+ )
+}
+
+function AuthorPresentationFormView(props: {
pubkey: string | null
profile: { name?: string; pubkey: string } | null
}): React.ReactElement {
- const { checkPresentationExists } = useAuthorPresentation(pubkey)
- const [existingPresentation, setExistingPresentation] = useState(null)
- const [loadingPresentation, setLoadingPresentation] = useState(true)
+ const { checkPresentationExists } = useAuthorPresentation(props.pubkey)
+ const presentation = useExistingPresentation({ pubkey: props.pubkey, checkPresentationExists })
+ const state = useAuthorPresentationState(props.pubkey, props.profile?.name, presentation.existingPresentation)
- useEffect(() => {
- const load = async (): Promise => {
- if (!pubkey) {
- setLoadingPresentation(false)
- return
- }
- try {
- const presentation = await checkPresentationExists()
- setExistingPresentation(presentation)
- } catch (e) {
- console.error('Error loading presentation:', e)
- } finally {
- setLoadingPresentation(false)
- }
- }
- void load()
- }, [pubkey, checkPresentationExists])
-
- const state = useAuthorPresentationState(pubkey, profile?.name, existingPresentation)
-
- if (!pubkey) {
+ if (!props.pubkey) {
return
}
-
- if (loadingPresentation) {
- return (
-
-
{t('common.loading')}
-
- )
+ if (presentation.loadingPresentation) {
+ return
}
-
if (state.success) {
- return
+ return
}
-
return (
{ void state.handleDelete() }}
- hasExistingPresentation={existingPresentation !== null && existingPresentation !== undefined}
+ hasExistingPresentation={presentation.existingPresentation !== null}
/>
)
}
+function LoadingNotice(): React.ReactElement {
+ return (
+
+
{t('common.loading')}
+
+ )
+}
+
+function useExistingPresentation(params: {
+ pubkey: string | null
+ checkPresentationExists: () => Promise
+}): { existingPresentation: Article | null; loadingPresentation: boolean } {
+ const [existingPresentation, setExistingPresentation] = useState(null)
+ const [loadingPresentation, setLoadingPresentation] = useState(true)
+ const { pubkey, checkPresentationExists } = params
+
+ useEffect(() => {
+ void loadExistingPresentation({ pubkey, checkPresentationExists, setExistingPresentation, setLoadingPresentation })
+ }, [pubkey, checkPresentationExists])
+
+ return { existingPresentation, loadingPresentation }
+}
+
+async function loadExistingPresentation(params: {
+ pubkey: string | null
+ checkPresentationExists: () => Promise
+ setExistingPresentation: (value: Article | null) => void
+ setLoadingPresentation: (value: boolean) => void
+}): Promise {
+ if (!params.pubkey) {
+ params.setLoadingPresentation(false)
+ return
+ }
+ try {
+ params.setExistingPresentation(await params.checkPresentationExists())
+ } catch (e) {
+ console.error('Error loading presentation:', e)
+ } finally {
+ params.setLoadingPresentation(false)
+ }
+}
+
function useAutoLoadPubkey(accountExists: boolean | null, pubkey: string | null, connect: () => Promise): void {
useEffect(() => {
if (accountExists === true && !pubkey) {
diff --git a/components/HomeView.tsx b/components/HomeView.tsx
index c71425c..d58c3ad 100644
--- a/components/HomeView.tsx
+++ b/components/HomeView.tsx
@@ -57,55 +57,46 @@ function ArticlesHero({
)
}
-function HomeContent({
- searchQuery,
- setSearchQuery,
- selectedCategory,
- setSelectedCategory,
- filters,
- setFilters,
- articles,
- allArticles,
- authors,
- allAuthors,
- loading,
- error,
- onUnlock,
- unlockedArticles,
-}: HomeViewProps): React.ReactElement {
- const shouldShowFilters = !loading && allArticles.length > 0
- const shouldShowAuthors = selectedCategory !== null && selectedCategory !== 'all'
+function HomeContent(props: HomeViewProps): React.ReactElement {
+ const shouldShowFilters = !props.loading && props.allArticles.length > 0
+ const shouldShowAuthors = props.selectedCategory !== null && props.selectedCategory !== 'all'
// At startup, we don't know yet if we're loading articles or authors
// Use a generic loading message until we have content
- const isInitialLoad = loading && allArticles.length === 0 && allAuthors.length === 0
+ const isInitialLoad = props.loading && props.allArticles.length === 0 && props.allAuthors.length === 0
return (
{shouldShowFilters && !shouldShowAuthors && (
-
+
)}
)
diff --git a/components/ImageUploadField.tsx b/components/ImageUploadField.tsx
index 9912ef5..f681fb8 100644
--- a/components/ImageUploadField.tsx
+++ b/components/ImageUploadField.tsx
@@ -92,56 +92,85 @@ async function processFileUpload(file: File, onChange: (url: string) => void, se
}
}
-function useImageUpload(onChange: (url: string) => void): {
+type ImageUploadState = {
uploading: boolean
error: string | null
handleFileSelect: (e: React.ChangeEvent) => Promise
showUnlockModal: boolean
setShowUnlockModal: (show: boolean) => void
handleUnlockSuccess: () => Promise
-} {
+}
+
+function useImageUpload(onChange: (url: string) => void): ImageUploadState {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState(null)
const [showUnlockModal, setShowUnlockModal] = useState(false)
const [pendingFile, setPendingFile] = useState(null)
- const handleFileSelect = async (event: React.ChangeEvent): Promise => {
+ const handleFileSelect = createHandleFileSelect({
+ onChange,
+ setError,
+ setUploading,
+ setPendingFile,
+ setShowUnlockModal,
+ })
+
+ const handleUnlockSuccess = createHandleUnlockSuccess({
+ pendingFile,
+ onChange,
+ setError,
+ setPendingFile,
+ setShowUnlockModal,
+ setUploading,
+ })
+
+ return { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess }
+}
+
+function createHandleFileSelect(params: {
+ onChange: (url: string) => void
+ setError: (error: string | null) => void
+ setUploading: (uploading: boolean) => void
+ setPendingFile: (file: File | null) => void
+ setShowUnlockModal: (show: boolean) => void
+}): (event: React.ChangeEvent) => Promise {
+ return async (event: React.ChangeEvent): Promise => {
const file = readFirstFile(event)
if (!file) {
return
}
- setError(null)
- setUploading(true)
+ params.setError(null)
+ params.setUploading(true)
try {
- await processFileUpload(file, onChange, setError)
+ await processFileUpload(file, params.onChange, params.setError)
} catch (uploadError) {
const uploadErr = normalizeError(uploadError)
if (isUnlockRequiredError(uploadErr)) {
- setPendingFile(file)
- setShowUnlockModal(true)
- setError(null) // Don't show error, show unlock modal instead
+ params.setPendingFile(file)
+ params.setShowUnlockModal(true)
+ params.setError(null)
} else {
- setError(uploadErr.message ?? t('presentation.field.picture.error.uploadFailed'))
+ params.setError(uploadErr.message ?? t('presentation.field.picture.error.uploadFailed'))
}
} finally {
- setUploading(false)
+ params.setUploading(false)
}
}
+}
- const handleUnlockSuccess = async (): Promise => {
- await retryPendingUpload({
- pendingFile,
- onChange,
- setError,
- setPendingFile,
- setShowUnlockModal,
- setUploading,
- })
+function createHandleUnlockSuccess(params: {
+ pendingFile: File | null
+ onChange: (url: string) => void
+ setError: (error: string | null) => void
+ setPendingFile: (file: File | null) => void
+ setShowUnlockModal: (show: boolean) => void
+ setUploading: (uploading: boolean) => void
+}): () => Promise {
+ return async (): Promise => {
+ await retryPendingUpload(params)
}
-
- return { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess }
}
function readFirstFile(event: React.ChangeEvent): File | null {
diff --git a/components/LanguageSettingsManager.tsx b/components/LanguageSettingsManager.tsx
index f46599c..5e56cab 100644
--- a/components/LanguageSettingsManager.tsx
+++ b/components/LanguageSettingsManager.tsx
@@ -34,29 +34,32 @@ export function LanguageSettingsManager(): React.ReactElement {
void loadLocaleIntoState({ setCurrentLocale, setLoading })
}, [])
- const handleLocaleChange = async (locale: Locale): Promise => {
- await applyLocaleChange({ locale, setCurrentLocale })
- }
-
const onLocaleClick = (locale: Locale): void => {
- void handleLocaleChange(locale)
+ void applyLocaleChange({ locale, setCurrentLocale })
}
- if (loading) {
+ return
+}
+
+function LanguageSettingsPanel(params: {
+ loading: boolean
+ currentLocale: Locale
+ onLocaleClick: (locale: Locale) => void
+}): React.ReactElement {
+ if (params.loading) {
return (
{t('settings.language.loading')}
)
}
-
return (
{t('settings.language.title')}
{t('settings.language.description')}
-
-
+
+
)
diff --git a/components/MarkdownEditorTwoColumns.tsx b/components/MarkdownEditorTwoColumns.tsx
index 1ec5722..102b9e1 100644
--- a/components/MarkdownEditorTwoColumns.tsx
+++ b/components/MarkdownEditorTwoColumns.tsx
@@ -12,23 +12,17 @@ interface MarkdownEditorTwoColumnsProps {
onBannerChange?: (url: string) => void
}
-export function MarkdownEditorTwoColumns({
- value,
- onChange,
- pages = [],
- onPagesChange,
- onMediaAdd,
- onBannerChange,
-}: MarkdownEditorTwoColumnsProps): React.ReactElement {
+export function MarkdownEditorTwoColumns(props: MarkdownEditorTwoColumnsProps): React.ReactElement {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState(null)
- const pagesHandlers = createPagesHandlers({ pages, onPagesChange })
+ const pages = props.pages ?? []
+ const pagesHandlers = createPagesHandlers({ pages, onPagesChange: props.onPagesChange })
const handleImageUpload = createImageUploadHandler({
setError,
setUploading,
- onMediaAdd,
- onBannerChange,
+ onMediaAdd: props.onMediaAdd,
+ onBannerChange: props.onBannerChange,
onSetPageImageUrl: pagesHandlers.setPageContent,
})
@@ -40,13 +34,13 @@ export function MarkdownEditorTwoColumns({
}}
uploading={uploading}
error={error}
- {...(onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})}
+ {...(props.onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})}
/>
- {onPagesChange && (
+ {props.onPagesChange && (
voi
const timeRemaining = useInvoiceTimer(invoice.expiresAt)
const handleCopy = useCallback(
- createHandleCopy({ invoice: invoice.invoice, setCopied, setErrorMessage }),
+ (): Promise => copyInvoiceToClipboard({ invoice: invoice.invoice, setCopied, setErrorMessage }),
[invoice.invoice]
)
const handleOpenWallet = useCallback(
- createHandleOpenWallet({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage }),
+ (): Promise => openWalletForInvoice({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage }),
[invoice.invoice, onPaymentComplete]
)
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
}
-function createHandleCopy(params: {
+async function copyInvoiceToClipboard(params: {
invoice: string
setCopied: (value: boolean) => void
setErrorMessage: (value: string | null) => void
-}): () => Promise {
- return async (): Promise => {
- try {
- await navigator.clipboard.writeText(params.invoice)
- params.setCopied(true)
- scheduleCopiedReset(params.setCopied)
- } catch (e) {
- console.error('Failed to copy:', e)
- params.setErrorMessage(t('payment.modal.copyFailed'))
- }
+}): Promise {
+ try {
+ await navigator.clipboard.writeText(params.invoice)
+ params.setCopied(true)
+ scheduleCopiedReset(params.setCopied)
+ } catch (e) {
+ console.error('Failed to copy:', e)
+ params.setErrorMessage(t('payment.modal.copyFailed'))
}
}
-function createHandleOpenWallet(params: {
+async function openWalletForInvoice(params: {
invoice: string
onPaymentComplete: () => void
setErrorMessage: (value: string | null) => void
-}): () => Promise {
- return async (): Promise => {
- try {
- await payWithWebLN(params.invoice)
- params.onPaymentComplete()
- } catch (e) {
- const error = normalizePaymentError(e)
- if (isUserCancellationError(error)) {
- return
- }
- console.error('Payment failed:', error)
- params.setErrorMessage(error.message)
+}): Promise {
+ try {
+ await payWithWebLN(params.invoice)
+ params.onPaymentComplete()
+ } catch (e) {
+ const error = normalizePaymentError(e)
+ if (isUserCancellationError(error)) {
+ return
}
+ console.error('Payment failed:', error)
+ params.setErrorMessage(error.message)
}
}
diff --git a/hooks/useArticlePayment.ts b/hooks/useArticlePayment.ts
index dc7eac5..aee41a4 100644
--- a/hooks/useArticlePayment.ts
+++ b/hooks/useArticlePayment.ts
@@ -24,39 +24,13 @@ export function useArticlePayment(
const [paymentInvoice, setPaymentInvoice] = useState(null)
const [paymentHash, setPaymentHash] = useState(null)
- const handleUnlock = (): Promise =>
- unlockArticlePayment({
- article,
- pubkey,
- connect,
- onUnlockSuccess,
- setLoading,
- setError,
- setPaymentInvoice,
- setPaymentHash,
- })
+ const handleUnlock = (): Promise => unlockArticlePayment({ article, pubkey, connect, onUnlockSuccess, setLoading, setError, setPaymentInvoice, setPaymentHash })
- const handlePaymentComplete = (): Promise =>
- checkPaymentAndUnlock({
- article,
- pubkey,
- paymentHash,
- onUnlockSuccess,
- setError,
- setPaymentInvoice,
- setPaymentHash,
- })
+ const handlePaymentComplete = (): Promise => checkPaymentAndUnlock({ article, pubkey, paymentHash, onUnlockSuccess, setError, setPaymentInvoice, setPaymentHash })
const handleCloseModal = (): void => resetPaymentModalState({ setPaymentInvoice, setPaymentHash })
- return {
- loading,
- error,
- paymentInvoice,
- handleUnlock,
- handlePaymentComplete,
- handleCloseModal,
- }
+ return { loading, error, paymentInvoice, handleUnlock, handlePaymentComplete, handleCloseModal }
}
async function unlockArticlePayment(params: {
@@ -81,25 +55,15 @@ async function unlockArticlePayment(params: {
params.setLoading(true)
params.setError(null)
try {
- const paymentResult = await paymentService.createArticlePayment({
- article: params.article,
- userPubkey: params.pubkey,
- })
- if (!paymentResult.success || !paymentResult.invoice || !paymentResult.paymentHash) {
+ const paymentResult = await paymentService.createArticlePayment({ article: params.article, userPubkey: params.pubkey })
+ const ok = readPaymentResult(paymentResult)
+ if (!ok) {
params.setError(paymentResult.error ?? 'Failed to create payment invoice')
return
}
- params.setPaymentInvoice(paymentResult.invoice)
- params.setPaymentHash(paymentResult.paymentHash)
- void checkPaymentAndUnlock({
- article: params.article,
- pubkey: params.pubkey,
- paymentHash: paymentResult.paymentHash,
- onUnlockSuccess: params.onUnlockSuccess,
- setError: params.setError,
- setPaymentInvoice: params.setPaymentInvoice,
- setPaymentHash: params.setPaymentHash,
- })
+ params.setPaymentInvoice(ok.invoice)
+ params.setPaymentHash(ok.paymentHash)
+ void checkPaymentAndUnlock({ article: params.article, pubkey: params.pubkey, paymentHash: ok.paymentHash, onUnlockSuccess: params.onUnlockSuccess, setError: params.setError, setPaymentInvoice: params.setPaymentInvoice, setPaymentHash: params.setPaymentHash })
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to process payment'
console.error('Payment processing error:', e)
@@ -109,6 +73,13 @@ async function unlockArticlePayment(params: {
}
}
+function readPaymentResult(value: Awaited>): { invoice: AlbyInvoice; paymentHash: string } | null {
+ if (!value.success || !value.invoice || !value.paymentHash) {
+ return null
+ }
+ return { invoice: value.invoice, paymentHash: value.paymentHash }
+}
+
async function ensureConnectedOrError(params: {
connect: (() => Promise) | undefined
setLoading: (value: boolean) => void
diff --git a/hooks/useArticles.ts b/hooks/useArticles.ts
index 08c49ad..bf9724e 100644
--- a/hooks/useArticles.ts
+++ b/hooks/useArticles.ts
@@ -19,138 +19,140 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
const [error, setError] = useState(null)
const hasArticlesRef = useRef(false)
- useEffect(() => {
- const loadAuthorsFromCache = async (): Promise => {
- try {
- const cachedAuthors = await objectCache.getAll('author')
- const authors = cachedAuthors as Article[]
-
- // Display authors immediately (with existing totalSponsoring if available)
- if (authors.length > 0) {
- setArticles((prev) => {
- // Merge with existing articles, avoiding duplicates
- const existingIds = new Set(prev.map((a) => a.id))
- const newAuthors = authors.filter((a) => !existingIds.has(a.id))
- const merged = [...prev, ...newAuthors].sort((a, b) => b.createdAt - a.createdAt)
- hasArticlesRef.current = merged.length > 0
- return merged
- })
- setLoading(false)
-
- // Calculate totalSponsoring asynchronously from cache (non-blocking)
- // Only update authors that don't have totalSponsoring yet
- const authorsNeedingSponsoring = authors.filter(
- (author) => author.isPresentation && author.pubkey && author.totalSponsoring === undefined
- )
-
- if (authorsNeedingSponsoring.length > 0) {
- // Load sponsoring from cache in parallel (fast, no network)
- const sponsoringPromises = authorsNeedingSponsoring.map(async (author) => {
- if (author.pubkey) {
- const totalSponsoring = await getAuthorSponsoring(author.pubkey, true)
- return { authorId: author.id, totalSponsoring }
- }
- return null
- })
-
- const sponsoringResults = await Promise.all(sponsoringPromises)
-
- // Update articles with sponsoring amounts
- const sponsoringByAuthorId = new Map()
- sponsoringResults.forEach((result) => {
- if (result) {
- sponsoringByAuthorId.set(result.authorId, result.totalSponsoring)
- }
- })
-
- setArticles((prev) =>
- prev.map((article) => {
- const totalSponsoring = sponsoringByAuthorId.get(article.id)
- if (totalSponsoring !== undefined && article.isPresentation) {
- return { ...article, totalSponsoring }
- }
- return article
- })
- )
- }
-
- return true
- }
-
- // Cache is empty - stop loading immediately, no network requests needed
- setLoading(false)
- hasArticlesRef.current = false
- return false
- } catch (loadError) {
- console.error('Error loading authors from cache:', loadError)
- setLoading(false)
- return false
- }
- }
-
- const load = async (): Promise => {
- setLoading(true)
- setError(null)
-
- const hasCachedAuthors = await loadAuthorsFromCache()
- if (!hasCachedAuthors) {
- setError(t('common.error.noContent'))
- }
- }
-
- void load()
-
- return () => {
- // No cleanup needed - no network subscription
- }
- }, [])
-
- const loadArticleContent = async (articleId: string, authorPubkey: string): Promise => {
- try {
- const article = await nostrService.getArticleById(articleId)
- if (article) {
- // Try to decrypt article content using decryption key from private messages
- const decryptedContent = await nostrService.getDecryptedArticleContent(articleId, authorPubkey)
- if (decryptedContent) {
- setArticles((prev) =>
- prev.map((a) =>
- (a.id === articleId
- ? { ...a, content: decryptedContent, paid: true }
- : a)
- )
- )
- }
- return article
- }
- } catch (e) {
- console.error('Error loading article content:', e)
- setError(e instanceof Error ? e.message : 'Failed to load article')
- }
- return null
- }
+ useLoadAuthorsFromCache({ setArticles, setLoading, setError, hasArticlesRef })
+ const loadArticleContent = createLoadArticleContent({ setArticles, setError })
// Apply filters and sorting
const filteredArticles = useMemo(() => {
- const effectiveFilters =
- filters ??
- ({
- authorPubkey: null,
- sortBy: 'newest',
- category: 'all',
- } as const)
-
+ const effectiveFilters = filters ?? buildDefaultFilters()
if (!filters && !searchQuery.trim()) {
return articles
}
-
return applyFiltersAndSort(articles, searchQuery, effectiveFilters)
}, [articles, searchQuery, filters])
- return {
- articles: filteredArticles,
- allArticles: articles, // Return all articles for filters component
- loading,
- error,
- loadArticleContent,
+ return { articles: filteredArticles, allArticles: articles, loading, error, loadArticleContent }
+}
+
+function buildDefaultFilters(): { authorPubkey: null; sortBy: 'newest'; category: 'all' } {
+ return { authorPubkey: null, sortBy: 'newest', category: 'all' }
+}
+
+function useLoadAuthorsFromCache(params: {
+ setArticles: (value: Article[] | ((prev: Article[]) => Article[])) => void
+ setLoading: (value: boolean) => void
+ setError: (value: string | null) => void
+ hasArticlesRef: { current: boolean }
+}): void {
+ const { setArticles, setLoading, setError, hasArticlesRef } = params
+ useEffect(() => {
+ void loadInitialAuthors({ setArticles, setLoading, setError, hasArticlesRef })
+ }, [setArticles, setLoading, setError, hasArticlesRef])
+}
+
+async function loadInitialAuthors(params: {
+ setArticles: (value: Article[] | ((prev: Article[]) => Article[])) => void
+ setLoading: (value: boolean) => void
+ setError: (value: string | null) => void
+ hasArticlesRef: { current: boolean }
+}): Promise {
+ params.setLoading(true)
+ params.setError(null)
+ const hasCachedAuthors = await loadAuthorsFromCache(params)
+ if (!hasCachedAuthors) {
+ params.setError(t('common.error.noContent'))
+ }
+}
+
+async function loadAuthorsFromCache(params: {
+ setArticles: (value: Article[] | ((prev: Article[]) => Article[])) => void
+ setLoading: (value: boolean) => void
+ hasArticlesRef: { current: boolean }
+}): Promise {
+ try {
+ const authors = (await objectCache.getAll('author')) as Article[]
+ if (authors.length === 0) {
+ params.setLoading(false)
+ const {hasArticlesRef} = params
+ hasArticlesRef.current = false
+ return false
+ }
+ params.setArticles((prev) => {
+ const merged = mergeAuthorsIntoArticles({ prev, authors })
+ const {hasArticlesRef} = params
+ hasArticlesRef.current = merged.length > 0
+ return merged
+ })
+ params.setLoading(false)
+ void updateAuthorsSponsoringFromCache({ authors, setArticles: params.setArticles })
+ return true
+ } catch (loadError) {
+ console.error('Error loading authors from cache:', loadError)
+ params.setLoading(false)
+ return false
+ }
+}
+
+function mergeAuthorsIntoArticles(params: {
+ prev: Article[]
+ authors: Article[]
+}): Article[] {
+ const existingIds = new Set(params.prev.map((a) => a.id))
+ const newAuthors = params.authors.filter((a) => !existingIds.has(a.id))
+ return [...params.prev, ...newAuthors].sort((a, b) => b.createdAt - a.createdAt)
+}
+
+async function updateAuthorsSponsoringFromCache(params: {
+ authors: Article[]
+ setArticles: (value: Article[] | ((prev: Article[]) => Article[])) => void
+}): Promise {
+ const authorsNeedingSponsoring = params.authors.filter((a) => a.isPresentation && a.pubkey && a.totalSponsoring === undefined)
+ if (authorsNeedingSponsoring.length === 0) {
+ return
+ }
+ const sponsoringByAuthorId = await loadSponsoringByAuthorId(authorsNeedingSponsoring)
+ params.setArticles((prev) => applySponsoringToArticles({ prev, sponsoringByAuthorId }))
+}
+
+async function loadSponsoringByAuthorId(authors: Article[]): Promise