diff --git a/components/ArticleEditor.tsx b/components/ArticleEditor.tsx index 49ad3a5..3ed486f 100644 --- a/components/ArticleEditor.tsx +++ b/components/ArticleEditor.tsx @@ -12,7 +12,6 @@ interface ArticleEditorProps { defaultSeriesId?: string } - function SuccessMessage(): React.ReactElement { return (
@@ -34,7 +33,13 @@ export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSel ...(defaultSeriesId ? { seriesId: defaultSeriesId } : {}), }) - const submit = buildSubmitHandler(publishArticle, draft, onPublishSuccess, connect, connected) + const submit = buildSubmitHandler({ + publishArticle, + draft, + onPublishSuccess, + connect, + connected, + }) if (success) { return @@ -58,21 +63,25 @@ export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSel ) } -function buildSubmitHandler( - publishArticle: (draft: ArticleDraft) => Promise, - draft: ArticleDraft, - onPublishSuccess?: (articleId: string) => void, - connect?: () => Promise, +interface SubmitHandlerParams { + publishArticle: (draft: ArticleDraft) => Promise + draft: ArticleDraft + onPublishSuccess?: (articleId: string) => void + connect?: () => Promise connected?: boolean +} + +function buildSubmitHandler( + params: SubmitHandlerParams ): () => Promise { return async (): Promise => { - if (!connected && connect) { - await connect() + if (!params.connected && params.connect) { + await params.connect() return } - const articleId = await publishArticle(draft) + const articleId = await params.publishArticle(params.draft) if (articleId) { - onPublishSuccess?.(articleId) + params.onPublishSuccess?.(articleId) } } } diff --git a/components/ArticleEditorForm.tsx b/components/ArticleEditorForm.tsx index ed0d943..c29a6c3 100644 --- a/components/ArticleEditorForm.tsx +++ b/components/ArticleEditorForm.tsx @@ -1,15 +1,10 @@ import React from 'react' import type { ArticleDraft } from '@/lib/articlePublisher' -import type { ArticleCategory } from '@/types/nostr' -import { ArticleField } from './ArticleField' import { ArticleFormButtons } from './ArticleFormButtons' -import { CategorySelect } from './CategorySelect' -import { MarkdownEditor } from './MarkdownEditor' -import { MarkdownEditorTwoColumns } from './MarkdownEditorTwoColumns' -import type { MediaRef } from '@/types/nostr' -import { t } from '@/lib/i18n' - import type { RelayPublishStatus } from '@/lib/publishResult' +import { t } from '@/lib/i18n' +import { ArticleFieldsLeft } from './ArticleEditorFormFieldsLeft' +import { ArticleFieldsRight } from './ArticleEditorFormFieldsRight' interface ArticleEditorFormProps { draft: ArticleDraft @@ -23,25 +18,6 @@ interface ArticleEditorFormProps { onSelectSeries?: ((seriesId: string | undefined) => void) | undefined } -function CategoryField({ - value, - onChange, -}: { - value: ArticleDraft['category'] - onChange: (value: import('@/types/nostr').ArticleCategory | undefined) => void -}): React.ReactElement { - return ( - - ) -} - function ErrorAlert({ error }: { error: string | null }): React.ReactElement | null { if (!error) { return null @@ -53,199 +29,6 @@ function ErrorAlert({ error }: { error: string | null }): React.ReactElement | n ) } -function buildCategoryChangeHandler( - draft: ArticleDraft, - onDraftChange: (draft: ArticleDraft) => void -): (value: ArticleCategory | undefined) => void { - return (value) => { - if (value === 'science-fiction' || value === 'scientific-research' || value === undefined) { - const nextDraft: ArticleDraft = { ...draft } - if (value) { - nextDraft.category = value - } else { - delete (nextDraft as { category?: ArticleDraft['category'] }).category - } - onDraftChange(nextDraft) - } - } -} - -const ArticleFieldsLeft = ({ - draft, - onDraftChange, - seriesOptions, - onSelectSeries, -}: { - draft: ArticleDraft - onDraftChange: (draft: ArticleDraft) => void - seriesOptions?: { id: string; title: string }[] | undefined - onSelectSeries?: ((seriesId: string | undefined) => void) | undefined -}): React.ReactElement => ( -
- - {seriesOptions && ( - - )} - - -
-) - -function ArticleTitleField({ draft, onDraftChange }: { draft: ArticleDraft; onDraftChange: (draft: ArticleDraft) => void }): React.ReactElement { - return ( - onDraftChange({ ...draft, title: value as string })} - required - placeholder={t('article.editor.title.placeholder')} - /> - ) -} - -function ArticlePreviewField({ - draft, - onDraftChange, -}: { - draft: ArticleDraft - onDraftChange: (draft: ArticleDraft) => void -}): React.ReactElement { - return ( - onDraftChange({ ...draft, preview: value as string })} - required - type="textarea" - rows={4} - placeholder={t('article.editor.preview.placeholder')} - helpText={t('article.editor.preview.help')} - /> - ) -} - -function SeriesSelect({ - draft, - onDraftChange, - seriesOptions, - onSelectSeries, -}: { - draft: ArticleDraft - onDraftChange: (draft: ArticleDraft) => void - seriesOptions: { id: string; title: string }[] - onSelectSeries?: ((seriesId: string | undefined) => void) | undefined -}): React.ReactElement { - const handleChange = buildSeriesChangeHandler(draft, onDraftChange, onSelectSeries) - - return ( -
- - -
- ) -} - -function buildSeriesChangeHandler( - draft: ArticleDraft, - onDraftChange: (draft: ArticleDraft) => void, - onSelectSeries?: ((seriesId: string | undefined) => void) | undefined -): (e: React.ChangeEvent) => void { - return (e: React.ChangeEvent): void => { - const value = e.target.value || undefined - const nextDraft = { ...draft } - if (value) { - nextDraft.seriesId = value - } else { - delete (nextDraft as { seriesId?: string }).seriesId - } - onDraftChange(nextDraft) - onSelectSeries?.(value) - } -} - -const ArticleFieldsRight = ({ - draft, - onDraftChange, -}: { - draft: ArticleDraft - onDraftChange: (draft: ArticleDraft) => void -}): React.ReactElement => { - // Use two-column editor with pages for series publications - const useTwoColumns = draft.seriesId !== undefined - - return ( -
-
-
{t('article.editor.content.label')}
- {useTwoColumns ? ( - onDraftChange({ ...draft, content: value })} - {...(draft.pages ? { pages: draft.pages } : {})} - onPagesChange={(pages) => onDraftChange({ ...draft, pages })} - onMediaAdd={(media: MediaRef) => { - const nextMedia = [...(draft.media ?? []), media] - onDraftChange({ ...draft, media: nextMedia }) - }} - onBannerChange={(url: string) => { - onDraftChange({ ...draft, bannerUrl: url }) - }} - /> - ) : ( - onDraftChange({ ...draft, content: value })} - onMediaAdd={(media: MediaRef) => { - const nextMedia = [...(draft.media ?? []), media] - onDraftChange({ ...draft, media: nextMedia }) - }} - onBannerChange={(url: string) => { - onDraftChange({ ...draft, bannerUrl: url }) - }} - /> - )} -

- {t('article.editor.content.help')} -

-
- onDraftChange({ ...draft, zapAmount: value as number })} - required - type="number" - min={1} - helpText={t('article.editor.sponsoring.help')} - /> -
- ) -} - export function ArticleEditorForm({ draft, onDraftChange, diff --git a/components/ArticleEditorFormFieldsLeft.tsx b/components/ArticleEditorFormFieldsLeft.tsx new file mode 100644 index 0000000..313f2c7 --- /dev/null +++ b/components/ArticleEditorFormFieldsLeft.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import type { ArticleDraft } from '@/lib/articlePublisher' +import type { ArticleCategory } from '@/types/nostr' +import { ArticleField } from './ArticleField' +import { CategorySelect } from './CategorySelect' +import { t } from '@/lib/i18n' + +interface ArticleFieldsLeftProps { + draft: ArticleDraft + onDraftChange: (draft: ArticleDraft) => void + seriesOptions?: { id: string; title: string }[] | undefined + onSelectSeries?: ((seriesId: string | undefined) => void) | undefined +} + +export function ArticleFieldsLeft({ + draft, + onDraftChange, + seriesOptions, + onSelectSeries, +}: ArticleFieldsLeftProps): React.ReactElement { + return ( +
+ + {seriesOptions && ( + + )} + + +
+ ) +} + +interface CategoryFieldProps { + value: ArticleDraft['category'] + onChange: (value: ArticleCategory | undefined) => void +} + +function CategoryField({ value, onChange }: CategoryFieldProps): React.ReactElement { + return ( + + ) +} + +interface BuildCategoryChangeHandlerParams { + draft: ArticleDraft + onDraftChange: (draft: ArticleDraft) => void +} + +function buildCategoryChangeHandler( + params: BuildCategoryChangeHandlerParams +): (value: ArticleCategory | undefined) => void { + return (value): void => { + if (value !== 'science-fiction' && value !== 'scientific-research' && value !== undefined) { + return + } + + if (value) { + params.onDraftChange({ ...params.draft, category: value }) + return + } + + const { category: _category, ...rest } = params.draft + params.onDraftChange(rest) + } +} + +function ArticleTitleField({ + draft, + onDraftChange, +}: { + draft: ArticleDraft + onDraftChange: (draft: ArticleDraft) => void +}): React.ReactElement { + return ( + onDraftChange({ ...draft, title: value as string })} + required + placeholder={t('article.editor.title.placeholder')} + /> + ) +} + +function ArticlePreviewField({ + draft, + onDraftChange, +}: { + draft: ArticleDraft + onDraftChange: (draft: ArticleDraft) => void +}): React.ReactElement { + return ( + onDraftChange({ ...draft, preview: value as string })} + required + type="textarea" + rows={4} + placeholder={t('article.editor.preview.placeholder')} + helpText={t('article.editor.preview.help')} + /> + ) +} + +function SeriesSelect({ + draft, + onDraftChange, + seriesOptions, + onSelectSeries, +}: { + draft: ArticleDraft + onDraftChange: (draft: ArticleDraft) => void + seriesOptions: { id: string; title: string }[] + onSelectSeries?: ((seriesId: string | undefined) => void) | undefined +}): React.ReactElement { + const handleChange = buildSeriesChangeHandler({ draft, onDraftChange, onSelectSeries }) + + return ( +
+ + +
+ ) +} + +interface BuildSeriesChangeHandlerParams { + draft: ArticleDraft + onDraftChange: (draft: ArticleDraft) => void + onSelectSeries?: ((seriesId: string | undefined) => void) | undefined +} + +function buildSeriesChangeHandler( + params: BuildSeriesChangeHandlerParams +): (e: React.ChangeEvent) => void { + return (e): void => { + const value = e.target.value === '' ? undefined : e.target.value + + if (value) { + params.onDraftChange({ ...params.draft, seriesId: value }) + params.onSelectSeries?.(value) + return + } + + const { seriesId: _seriesId, ...rest } = params.draft + params.onDraftChange(rest) + params.onSelectSeries?.(undefined) + } +} diff --git a/components/ArticleEditorFormFieldsRight.tsx b/components/ArticleEditorFormFieldsRight.tsx new file mode 100644 index 0000000..be6579d --- /dev/null +++ b/components/ArticleEditorFormFieldsRight.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import type { ArticleDraft } from '@/lib/articlePublisher' +import type { MediaRef } from '@/types/nostr' +import { ArticleField } from './ArticleField' +import { MarkdownEditor } from './MarkdownEditor' +import { MarkdownEditorTwoColumns } from './MarkdownEditorTwoColumns' +import { t } from '@/lib/i18n' + +interface ArticleFieldsRightProps { + draft: ArticleDraft + onDraftChange: (draft: ArticleDraft) => void +} + +export function ArticleFieldsRight({ draft, onDraftChange }: ArticleFieldsRightProps): React.ReactElement { + return ( +
+ + +
+ ) +} + +function ContentEditorSection({ + draft, + onDraftChange, +}: { + draft: ArticleDraft + onDraftChange: (draft: ArticleDraft) => void +}): React.ReactElement { + const useTwoColumns = draft.seriesId !== undefined + return ( +
+
{t('article.editor.content.label')}
+ +

{t('article.editor.content.help')}

+
+ ) +} + +function ContentEditor({ + draft, + onDraftChange, + useTwoColumns, +}: { + draft: ArticleDraft + onDraftChange: (draft: ArticleDraft) => void + useTwoColumns: boolean +}): React.ReactElement { + const onMediaAdd = buildMediaAddHandler({ draft, onDraftChange }) + const onBannerChange = buildBannerChangeHandler({ draft, onDraftChange }) + const onContentChange = (value: string): void => onDraftChange({ ...draft, content: value }) + + if (useTwoColumns) { + return ( + onDraftChange({ ...draft, pages })} + onMediaAdd={onMediaAdd} + onBannerChange={onBannerChange} + /> + ) + } + + return ( + + ) +} + +interface BuildMediaAddHandlerParams { + draft: ArticleDraft + onDraftChange: (draft: ArticleDraft) => void +} + +function buildMediaAddHandler(params: BuildMediaAddHandlerParams): (media: MediaRef) => void { + return (media): void => { + const nextMedia = [...(params.draft.media ?? []), media] + params.onDraftChange({ ...params.draft, media: nextMedia }) + } +} + +interface BuildBannerChangeHandlerParams { + draft: ArticleDraft + onDraftChange: (draft: ArticleDraft) => void +} + +function buildBannerChangeHandler(params: BuildBannerChangeHandlerParams): (url: string) => void { + return (url): void => { + params.onDraftChange({ ...params.draft, bannerUrl: url }) + } +} + +function ZapAmountField({ + draft, + onDraftChange, +}: { + draft: ArticleDraft + onDraftChange: (draft: ArticleDraft) => void +}): React.ReactElement { + return ( + onDraftChange({ ...draft, zapAmount: value as number })} + required + type="number" + min={1} + helpText={t('article.editor.sponsoring.help')} + /> + ) +} diff --git a/components/ArticlePages.tsx b/components/ArticlePages.tsx index 8456c74..26e1f6a 100644 --- a/components/ArticlePages.tsx +++ b/components/ArticlePages.tsx @@ -11,54 +11,32 @@ interface ArticlePagesProps { export function ArticlePages({ pages, articleId }: ArticlePagesProps): React.ReactElement | null { const { pubkey } = useNostrAuth() - const [hasPurchased, setHasPurchased] = useState(false) - - useEffect(() => { - const checkPurchase = async (): Promise => { - if (!pubkey || !articleId) { - setHasPurchased(false) - return - } - - try { - // Check if user has purchased this article from cache - const purchases = await objectCache.getAll('purchase') - const userPurchases = purchases.filter((p) => { - if (typeof p !== 'object' || p === null) { - return false - } - const purchase = p as { payerPubkey?: string; articleId?: string } - return purchase.payerPubkey === pubkey && purchase.articleId === articleId - }) - - setHasPurchased(userPurchases.length > 0) - } catch (error) { - console.error('Error checking purchase status:', error) - setHasPurchased(false) - } - } - - void checkPurchase() - }, [pubkey, articleId]) + const hasPurchased = useHasPurchasedArticle({ pubkey, articleId }) if (!pages || pages.length === 0) { return null } - // If user hasn't purchased, show locked message - if (!hasPurchased) { - return ( -
-

{t('article.pages.title')}

-
-

{t('article.pages.locked.title')}

-

{t('article.pages.locked.message', { count: pages.length })}

-
-
- ) + if (!pubkey || !hasPurchased) { + return } - // User has purchased, show all pages + return +} + +function LockedPagesView({ pagesCount }: { pagesCount: number }): React.ReactElement { + return ( +
+

{t('article.pages.title')}

+
+

{t('article.pages.locked.title')}

+

{t('article.pages.locked.message', { count: pagesCount })}

+
+
+ ) +} + +function PurchasedPagesView({ pages }: { pages: Page[] }): React.ReactElement { return (

{t('article.pages.title')}

@@ -71,6 +49,53 @@ export function ArticlePages({ pages, articleId }: ArticlePagesProps): React.Rea ) } +function useHasPurchasedArticle({ + pubkey, + articleId, +}: { + pubkey: string | null + articleId: string +}): boolean { + const [hasPurchased, setHasPurchased] = useState(false) + + useEffect(() => { + if (!pubkey) { + return + } + + const checkPurchase = async (): Promise => { + try { + const purchases = await objectCache.getAll('purchase') + const userPurchases = purchases.filter((p) => isUserPurchase({ p, pubkey, articleId })) + setHasPurchased(userPurchases.length > 0) + } catch (error) { + console.error('Error checking purchase status:', error) + setHasPurchased(false) + } + } + + void checkPurchase() + }, [pubkey, articleId]) + + return hasPurchased +} + +function isUserPurchase({ + p, + pubkey, + articleId, +}: { + p: unknown + pubkey: string + articleId: string +}): boolean { + if (typeof p !== 'object' || p === null) { + return false + } + const purchase = p as { payerPubkey?: string; articleId?: string } + return purchase.payerPubkey === pubkey && purchase.articleId === articleId +} + function PageDisplay({ page }: { page: Page }): React.ReactElement { return (
diff --git a/components/ArticleReviews.tsx b/components/ArticleReviews.tsx index b1fd525..f90a300 100644 --- a/components/ArticleReviews.tsx +++ b/components/ArticleReviews.tsx @@ -12,20 +12,66 @@ interface ArticleReviewsProps { } export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps): React.ReactElement { + const data = useArticleReviewsData({ articleId: article.id, authorPubkey }) + const reviewForm = useReviewFormState({ reload: data.reload }) + const tipSelection = useReviewTipSelection({ article, reviews: data.reviews, reload: data.reload }) + + return ( +
+ + {reviewForm.show && ( + + )} + {data.loading &&

{t('common.loading')}

} + {data.error &&

{data.error}

} + {!data.loading && !data.error && data.reviews.length === 0 && !reviewForm.show && ( +

{t('review.empty')}

+ )} + {!data.loading && !data.error && } + +
+ ) +} + +interface ReviewTipSelectionController { + article: Article + selectedReviewForTip: Review | null + onTipSuccess: () => void + clear: () => void + select: (reviewId: string) => void +} + +interface ArticleReviewsData { + reviews: Review[] + tips: number + loading: boolean + error: string | null + reload: () => Promise +} + +function useArticleReviewsData({ + articleId, + authorPubkey, +}: { + articleId: string + authorPubkey: string +}): ArticleReviewsData { const [reviews, setReviews] = useState([]) const [tips, setTips] = useState(0) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [showReviewForm, setShowReviewForm] = useState(false) - const [selectedReviewForTip, setSelectedReviewForTip] = useState(null) - const loadReviews = useCallback(async (): Promise => { + const reload = useCallback(async (): Promise => { setLoading(true) setError(null) try { const [list, tipsTotal] = await Promise.all([ - getReviewsForArticle(article.id), - getReviewTipsForArticle({ authorPubkey, articleId: article.id }), + getReviewsForArticle(articleId), + getReviewTipsForArticle({ authorPubkey, articleId }), ]) setReviews(list) setTips(tipsTotal) @@ -34,57 +80,80 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps): } finally { setLoading(false) } - }, [article.id, authorPubkey]) + }, [articleId, authorPubkey]) useEffect(() => { - void loadReviews() - }, [loadReviews]) + void reload() + }, [reload]) + + return { + reviews, + tips, + loading, + error, + reload, + } +} + +function useReviewFormState({ reload }: { reload: () => Promise }): { + show: boolean + open: () => void + close: () => void + onSuccess: () => void +} { + const [show, setShow] = useState(false) + const open = useCallback((): void => setShow(true), []) + const close = useCallback((): void => setShow(false), []) + + const onSuccess = useCallback((): void => { + close() + void reload() + }, [close, reload]) + + return { show, open, close, onSuccess } +} + +function useReviewTipSelection({ + article, + reviews, + reload, +}: { + article: Article + reviews: Review[] + reload: () => Promise +}): ReviewTipSelectionController { + const [selectedReviewId, setSelectedReviewId] = useState(null) + + const select = useCallback((reviewId: string): void => setSelectedReviewId(reviewId), []) + const clear = useCallback((): void => setSelectedReviewId(null), []) + + const onTipSuccess = useCallback((): void => { + clear() + void reload() + }, [clear, reload]) + + const selectedReviewForTip = selectedReviewId ? findReviewById(reviews, selectedReviewId) : null + + return { article, selectedReviewForTip, onTipSuccess, clear, select } +} + +function findReviewById(reviews: Review[], reviewId: string): Review | null { + const review = reviews.find((r) => r.id === reviewId) + return review ?? null +} + +function SelectedReviewTipForm({ selection }: { selection: ReviewTipSelectionController }): React.ReactElement | null { + if (!selection.selectedReviewForTip) { + return null + } return ( -
- { - setShowReviewForm(true) - }} /> - {showReviewForm && ( - { - setShowReviewForm(false) - void loadReviews() - }} - onCancel={() => { - setShowReviewForm(false) - }} - /> - )} - {loading &&

{t('common.loading')}

} - {error &&

{error}

} - {!loading && !error && reviews.length === 0 && !showReviewForm && ( -

{t('review.empty')}

- )} - {!loading && !error && { - setSelectedReviewForTip(reviewId) - }} />} - {selectedReviewForTip && (() => { - const review = reviews.find((r) => r.id === selectedReviewForTip) - if (!review) { - return null - } - return ( - { - setSelectedReviewForTip(null) - void loadReviews() - }} - onCancel={() => { - setSelectedReviewForTip(null) - }} - /> - ) - })()} -
+ ) } diff --git a/components/AuthorFilterButton.tsx b/components/AuthorFilterButton.tsx index 8526300..e63c8c1 100644 --- a/components/AuthorFilterButton.tsx +++ b/components/AuthorFilterButton.tsx @@ -5,7 +5,7 @@ export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string return (
{getMnemonicIcons(value).map((icon, idx) => ( - + {icon} ))} diff --git a/components/AuthorFilterDropdown.tsx b/components/AuthorFilterDropdown.tsx index 6943249..1479f82 100644 --- a/components/AuthorFilterDropdown.tsx +++ b/components/AuthorFilterDropdown.tsx @@ -47,7 +47,7 @@ export function AuthorOption({ {displayName}
{mnemonicIcons.map((icon, idx) => ( - + {icon} ))} @@ -83,22 +83,24 @@ export function AllAuthorsOption({ ) } -export function createAuthorOptionProps( - pubkey: string, - value: string | null, - getDisplayName: (pubkey: string) => string, - getPicture: (pubkey: string) => string | undefined, - getMnemonicIcons: (pubkey: string) => string[], - onChange: (value: string | null) => void, +interface CreateAuthorOptionPropsParams { + pubkey: string + value: string | null + getDisplayName: (pubkey: string) => string + getPicture: (pubkey: string) => string | undefined + getMnemonicIcons: (pubkey: string) => string[] + onChange: (value: string | null) => void setIsOpen: (open: boolean) => void -): { +} + +export function createAuthorOptionProps(params: CreateAuthorOptionPropsParams): { displayName: string mnemonicIcons: string[] isSelected: boolean onSelect: () => void picture?: string } { - const pictureValue = getPicture(pubkey) + const pictureValue = params.getPicture(params.pubkey) const optionProps: { displayName: string mnemonicIcons: string[] @@ -106,12 +108,12 @@ export function createAuthorOptionProps( onSelect: () => void picture?: string } = { - displayName: getDisplayName(pubkey), - mnemonicIcons: getMnemonicIcons(pubkey), - isSelected: value === pubkey, + displayName: params.getDisplayName(params.pubkey), + mnemonicIcons: params.getMnemonicIcons(params.pubkey), + isSelected: params.value === params.pubkey, onSelect: () => { - onChange(pubkey) - setIsOpen(false) + params.onChange(params.pubkey) + params.setIsOpen(false) }, } if (pictureValue !== undefined) { @@ -142,7 +144,15 @@ export function AuthorList({ {authors.map((pubkey) => ( ))} diff --git a/components/ConnectButton.tsx b/components/ConnectButton.tsx index 659a201..27e36d6 100644 --- a/components/ConnectButton.tsx +++ b/components/ConnectButton.tsx @@ -1,10 +1,11 @@ -import { useState, useEffect } from 'react' import { useNostrAuth } from '@/hooks/useNostrAuth' import { ConnectedUserMenu } from './ConnectedUserMenu' import { RecoveryStep } from './CreateAccountModalSteps' import { UnlockAccountModal } from './UnlockAccountModal' import type { NostrProfile } from '@/types/nostr' import { t } from '@/lib/i18n' +import { getConnectButtonMode } from './connectButton/connectButtonMode' +import { useAutoConnect, useConnectButtonUiState } from './connectButton/useConnectButtonUiState' function ConnectForm({ onCreateAccount, @@ -38,14 +39,6 @@ function ConnectForm({ ) } -function useAutoConnect(accountExists: boolean | null, pubkey: string | null, showRecoveryStep: boolean, showUnlockModal: boolean, connect: () => Promise): void { - useEffect(() => { - if (accountExists === true && !pubkey && !showRecoveryStep && !showUnlockModal) { - void connect() - } - }, [accountExists, pubkey, showRecoveryStep, showUnlockModal, connect]) -} - function ConnectedState({ pubkey, profile, loading, disconnect }: { pubkey: string; profile: NostrProfile | null; loading: boolean; disconnect: () => Promise }): React.ReactElement { return ( {}} + onCreateAccount={noop} onUnlock={onUnlock} loading={loading} error={error} @@ -73,6 +66,10 @@ function UnlockState({ loading, error, onUnlock, onClose }: { loading: boolean; ) } +function noop(): void { + // Intentionally empty: UnlockState must render ConnectForm but "create account" is not available in this mode. +} + function DisconnectedState({ loading, @@ -107,79 +104,45 @@ function DisconnectedState({ export function ConnectButton(): React.ReactElement { const { connected, pubkey, profile, loading, error, connect, disconnect, accountExists, isUnlocked } = useNostrAuth() - const [showRecoveryStep, setShowRecoveryStep] = useState(false) - const [showUnlockModal, setShowUnlockModal] = useState(false) - const [recoveryPhrase, setRecoveryPhrase] = useState([]) - const [npub, setNpub] = useState('') - const [creatingAccount, setCreatingAccount] = useState(false) - const [createError, setCreateError] = useState(null) + const ui = useConnectButtonUiState() - useAutoConnect(accountExists, pubkey, false, showUnlockModal, connect) + useAutoConnect({ + accountExists, + pubkey, + showRecoveryStep: ui.showRecoveryStep, + showUnlockModal: ui.showUnlockModal, + connect, + }) - const handleCreateAccount = async (): Promise => { - setCreatingAccount(true) - setCreateError(null) - try { - const { nostrAuthService } = await import('@/lib/nostrAuth') - const result = await nostrAuthService.createAccount() - setRecoveryPhrase(result.recoveryPhrase) - setNpub(result.npub) - setShowRecoveryStep(true) - } catch (e) { - setCreateError(e instanceof Error ? e.message : 'Failed to create account') - } finally { - setCreatingAccount(false) - } - } + const mode = getConnectButtonMode({ + connected, + pubkey, + isUnlocked, + accountExists, + showUnlockModal: ui.showUnlockModal, + }) - const handleRecoveryContinue = (): void => { - setShowRecoveryStep(false) - setShowUnlockModal(true) - } - - const handleUnlockSuccess = (): void => { - setShowUnlockModal(false) - setRecoveryPhrase([]) - setNpub('') - } - - if (connected && pubkey && isUnlocked) { + if (mode === 'connected') { return } - if (accountExists === true && pubkey && !isUnlocked && !showUnlockModal) { - return ( - setShowUnlockModal(true)} - onClose={() => setShowUnlockModal(false)} - /> - ) + if (mode === 'unlock_required') { + return } return ( <> { void handleCreateAccount() }} + loading={loading ?? ui.creatingAccount} + error={error ?? ui.createError} + showUnlockModal={ui.showUnlockModal} + setShowUnlockModal={ui.setShowUnlockModal} + onCreateAccount={ui.onCreateAccount} /> - {showRecoveryStep && ( - - )} - {showUnlockModal && ( - setShowUnlockModal(false)} - /> + {ui.showRecoveryStep && ( + )} + {ui.showUnlockModal && } ) } diff --git a/components/CreateAccountModal.tsx b/components/CreateAccountModal.tsx index 087707f..33c7fc9 100644 --- a/components/CreateAccountModal.tsx +++ b/components/CreateAccountModal.tsx @@ -14,35 +14,37 @@ async function createAccountWithKey(key?: string): Promise<{ recoveryPhrase: str return nostrAuthService.createAccount(key) } -async function handleAccountCreation( - key: string | undefined, - setLoading: (loading: boolean) => void, - setError: (error: string | null) => void, - setRecoveryPhrase: (phrase: string[]) => void, - setNpub: (npub: string) => void, - setStep: (step: Step) => void, +interface HandleAccountCreationParams { + key: string | undefined + setLoading: (loading: boolean) => void + setError: (error: string | null) => void + setRecoveryPhrase: (phrase: string[]) => void + setNpub: (npub: string) => void + setStep: (step: Step) => void errorMessage: string -): Promise { - if (key !== undefined && !key.trim()) { - setError('Please enter a private key') +} + +async function handleAccountCreation(params: HandleAccountCreationParams): Promise { + if (params.key !== undefined && !params.key.trim()) { + params.setError('Please enter a private key') return } - setLoading(true) - setError(null) + params.setLoading(true) + params.setError(null) try { - const result = await createAccountWithKey(key?.trim()) - setRecoveryPhrase(result.recoveryPhrase) - setNpub(result.npub) - setStep('recovery') + const result = await createAccountWithKey(params.key?.trim()) + params.setRecoveryPhrase(result.recoveryPhrase) + params.setNpub(result.npub) + params.setStep('recovery') } catch (e) { - setError(e instanceof Error ? e.message : errorMessage) + params.setError(e instanceof Error ? e.message : params.errorMessage) } finally { - setLoading(false) + params.setLoading(false) } } -function useAccountCreation(initialStep: Step = 'choose'): { +interface AccountCreationState { step: Step setStep: (step: Step) => void importKey: string @@ -54,7 +56,9 @@ function useAccountCreation(initialStep: Step = 'choose'): { npub: string handleGenerate: () => Promise handleImport: () => Promise -} { +} + +function useAccountCreation(initialStep: Step = 'choose'): AccountCreationState { const [step, setStep] = useState(initialStep) const [importKey, setImportKey] = useState('') const [loading, setLoading] = useState(false) @@ -62,27 +66,29 @@ function useAccountCreation(initialStep: Step = 'choose'): { const [recoveryPhrase, setRecoveryPhrase] = useState([]) const [npub, setNpub] = useState('') - const handleGenerate = async (): Promise => { - await handleAccountCreation(undefined, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to create account') - } + const handleGenerate = (): Promise => + handleAccountCreation({ + key: undefined, + setLoading, + setError, + setRecoveryPhrase, + setNpub, + setStep, + errorMessage: 'Failed to create account', + }) - const handleImport = async (): Promise => { - await handleAccountCreation(importKey, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to import key') - } + const handleImport = (): Promise => + handleAccountCreation({ + key: importKey, + setLoading, + setError, + setRecoveryPhrase, + setNpub, + setStep, + errorMessage: 'Failed to import key', + }) - return { - step, - setStep, - importKey, - setImportKey, - loading, - error, - setError, - recoveryPhrase, - npub, - handleGenerate, - handleImport, - } + return { step, setStep, importKey, setImportKey, loading, error, setError, recoveryPhrase, npub, handleGenerate, handleImport } } function handleImportBack(setStep: (step: Step) => void, setError: (error: string | null) => void, setImportKey: (key: string) => void): void { @@ -91,45 +97,47 @@ function handleImportBack(setStep: (step: Step) => void, setError: (error: strin setImportKey('') } -function renderStep( - step: Step, - recoveryPhrase: string[], - npub: string, - importKey: string, - setImportKey: (key: string) => void, - loading: boolean, - error: string | null, - handleContinue: () => void, - handleImport: () => void, - setStep: (step: Step) => void, - setError: (error: string | null) => void, - handleGenerate: () => void, +interface RenderStepParams { + step: Step + recoveryPhrase: string[] + npub: string + importKey: string + setImportKey: (key: string) => void + loading: boolean + error: string | null + handleContinue: () => void + handleImport: () => void + setStep: (step: Step) => void + setError: (error: string | null) => void + handleGenerate: () => void onClose: () => void -): React.ReactElement { - if (step === 'recovery') { - return +} + +function renderStep(params: RenderStepParams): React.ReactElement { + if (params.step === 'recovery') { + return } - if (step === 'import') { + if (params.step === 'import') { return ( handleImportBack(setStep, setError, setImportKey)} + importKey={params.importKey} + setImportKey={params.setImportKey} + loading={params.loading} + error={params.error} + onImport={params.handleImport} + onBack={() => handleImportBack(params.setStep, params.setError, params.setImportKey)} /> ) } return ( setStep('import')} - onClose={onClose} + loading={params.loading} + error={params.error} + onGenerate={params.handleGenerate} + onImport={() => params.setStep('import')} + onClose={params.onClose} /> ) } @@ -154,7 +162,7 @@ export function CreateAccountModal({ onSuccess, onClose, initialStep = 'choose' onClose() } - return renderStep( + return renderStep({ step, recoveryPhrase, npub, @@ -163,10 +171,14 @@ export function CreateAccountModal({ onSuccess, onClose, initialStep = 'choose' loading, error, handleContinue, - () => { void handleImport() }, + handleImport: () => { + void handleImport() + }, setStep, setError, - () => { void handleGenerate() }, - onClose - ) + handleGenerate: () => { + void handleGenerate() + }, + onClose, + }) } diff --git a/components/CreateAccountModalComponents.tsx b/components/CreateAccountModalComponents.tsx index 978c18b..2798387 100644 --- a/components/CreateAccountModalComponents.tsx +++ b/components/CreateAccountModalComponents.tsx @@ -20,16 +20,17 @@ export function RecoveryPhraseDisplay({ copied: boolean onCopy: () => void }): React.ReactElement { + const recoveryItems = buildRecoveryPhraseItems(recoveryPhrase) return (
- {recoveryPhrase.map((word, index) => ( + {recoveryItems.map((item, index) => (
{index + 1}. - {word} + {item.word}
))}
@@ -45,6 +46,15 @@ export function RecoveryPhraseDisplay({ ) } +function buildRecoveryPhraseItems(recoveryPhrase: string[]): { key: string; word: string }[] { + const counts = new Map() + return recoveryPhrase.map((word) => { + const nextCount = (counts.get(word) ?? 0) + 1 + counts.set(word, nextCount) + return { key: `${word}-${nextCount}`, word } + }) +} + export function PublicKeyDisplay({ npub }: { npub: string }): React.ReactElement { return (
diff --git a/components/FundingGauge.tsx b/components/FundingGauge.tsx index 007915c..5649589 100644 --- a/components/FundingGauge.tsx +++ b/components/FundingGauge.tsx @@ -42,18 +42,52 @@ function FundingStats({ stats }: { stats: ReturnType + } + + return ( +
+
+

+ {t('home.funding.title')} - {t('home.funding.priority.ia')} +

+ +
+
+

+ {t('home.funding.certification.title')} - {t('home.funding.priority.ancrage')} +

+ +
+
+ ) +} + +function FundingGaugeLoading(): React.ReactElement { + return ( +
+

{t('common.loading')}

+
+ ) +} + +function useFundingGaugeState(): { + stats: ReturnType + certificationStats: ReturnType + loading: boolean +} { const [stats, setStats] = useState(estimatePlatformFunds()) const [certificationStats, setCertificationStats] = useState(estimatePlatformFunds()) const [loading, setLoading] = useState(true) useEffect(() => { - // In a real implementation, this would fetch actual data - // For now, we use the estimate const loadStats = async (): Promise => { try { const fundingStats = estimatePlatformFunds() setStats(fundingStats) - // Certification uses the same funding pool setCertificationStats(fundingStats) } catch (e) { console.error('Error loading funding stats:', e) @@ -64,28 +98,5 @@ export function FundingGauge(): React.ReactElement { void loadStats() }, []) - if (loading) { - return ( -
-

{t('common.loading')}

-
- ) - } - - return ( -
-
-

- {t('home.funding.title')} - {t('home.funding.priority.ia')} -

- -
-
-

- {t('home.funding.certification.title')} - {t('home.funding.priority.ancrage')} -

- -
-
- ) + return { stats, certificationStats, loading } } diff --git a/components/GlobalSyncProgressBar.tsx b/components/GlobalSyncProgressBar.tsx index 7b22480..e322a6c 100644 --- a/components/GlobalSyncProgressBar.tsx +++ b/components/GlobalSyncProgressBar.tsx @@ -46,19 +46,13 @@ function SyncIcon(): React.ReactElement { * Shows sync icon + relay name when syncing */ export function SyncStatus(): React.ReactElement | null { - const [progress, setProgress] = useState(null) + const [progress, setProgress] = useState(() => syncProgressManager.getProgress()) useEffect(() => { const unsubscribe = syncProgressManager.subscribe((newProgress) => { setProgress(newProgress) }) - // Check current progress immediately - const currentProgress = syncProgressManager.getProgress() - if (currentProgress) { - setProgress(currentProgress) - } - return () => { unsubscribe() } diff --git a/components/connectButton/connectButtonMode.ts b/components/connectButton/connectButtonMode.ts new file mode 100644 index 0000000..e185205 --- /dev/null +++ b/components/connectButton/connectButtonMode.ts @@ -0,0 +1,19 @@ +export type ConnectButtonMode = 'connected' | 'unlock_required' | 'default' + +export function getConnectButtonMode(params: { + connected: boolean + pubkey: string | null + isUnlocked: boolean + accountExists: boolean | null + showUnlockModal: boolean +}): ConnectButtonMode { + if (params.connected && params.pubkey && params.isUnlocked) { + return 'connected' + } + + if (params.accountExists === true && params.pubkey && !params.isUnlocked && !params.showUnlockModal) { + return 'unlock_required' + } + + return 'default' +} diff --git a/components/connectButton/useConnectButtonUiState.ts b/components/connectButton/useConnectButtonUiState.ts new file mode 100644 index 0000000..801348e --- /dev/null +++ b/components/connectButton/useConnectButtonUiState.ts @@ -0,0 +1,137 @@ +import { useEffect, useState } from 'react' + +export function useAutoConnect(params: { + accountExists: boolean | null + pubkey: string | null + showRecoveryStep: boolean + showUnlockModal: boolean + connect: () => Promise +}): void { + const { accountExists, pubkey, showRecoveryStep, showUnlockModal, connect } = params + useEffect(() => { + if (accountExists === true && !pubkey && !showRecoveryStep && !showUnlockModal) { + void connect() + } + }, [accountExists, pubkey, showRecoveryStep, showUnlockModal, connect]) +} + +export function useConnectButtonUiState(): { + showRecoveryStep: boolean + showUnlockModal: boolean + setShowUnlockModal: (show: boolean) => void + recoveryPhrase: string[] + npub: string + creatingAccount: boolean + createError: string | null + onCreateAccount: () => void + onRecoveryContinue: () => void + onUnlockSuccess: () => void + openUnlockModal: () => void + closeUnlockModal: () => void +} { + const unlockModal = useUnlockModalVisibility() + const recovery = useRecoveryStepState() + const [creatingAccount, setCreatingAccount] = useState(false) + const [createError, setCreateError] = useState(null) + + const onCreateAccount = (): void => { + void handleCreateAccount({ + setCreatingAccount, + setCreateError, + setRecoveryPhrase: recovery.setRecoveryPhrase, + setNpub: recovery.setNpub, + setShowRecoveryStep: recovery.setShowRecoveryStep, + }) + } + + const onRecoveryContinue = (): void => { + recovery.hideRecoveryStep() + unlockModal.openUnlockModal() + } + + const onUnlockSuccess = (): void => { + unlockModal.closeUnlockModal() + recovery.resetRecoveryData() + } + + return { + showRecoveryStep: recovery.showRecoveryStep, + showUnlockModal: unlockModal.showUnlockModal, + setShowUnlockModal: unlockModal.setShowUnlockModal, + recoveryPhrase: recovery.recoveryPhrase, + npub: recovery.npub, + creatingAccount, + createError, + onCreateAccount, + onRecoveryContinue, + onUnlockSuccess, + openUnlockModal: unlockModal.openUnlockModal, + closeUnlockModal: unlockModal.closeUnlockModal, + } +} + +function useUnlockModalVisibility(): { + showUnlockModal: boolean + setShowUnlockModal: (show: boolean) => void + openUnlockModal: () => void + closeUnlockModal: () => void +} { + const [showUnlockModal, setShowUnlockModal] = useState(false) + const openUnlockModal = (): void => setShowUnlockModal(true) + const closeUnlockModal = (): void => setShowUnlockModal(false) + return { showUnlockModal, setShowUnlockModal, openUnlockModal, closeUnlockModal } +} + +function useRecoveryStepState(): { + showRecoveryStep: boolean + recoveryPhrase: string[] + npub: string + setShowRecoveryStep: (show: boolean) => void + setRecoveryPhrase: (words: string[]) => void + setNpub: (npub: string) => void + hideRecoveryStep: () => void + resetRecoveryData: () => void +} { + const [showRecoveryStep, setShowRecoveryStep] = useState(false) + const [recoveryPhrase, setRecoveryPhrase] = useState([]) + const [npub, setNpub] = useState('') + + const hideRecoveryStep = (): void => setShowRecoveryStep(false) + const resetRecoveryData = (): void => { + setRecoveryPhrase([]) + setNpub('') + } + + return { + showRecoveryStep, + recoveryPhrase, + npub, + setShowRecoveryStep, + setRecoveryPhrase, + setNpub, + hideRecoveryStep, + resetRecoveryData, + } +} + +async function handleCreateAccount(params: { + setCreatingAccount: (creating: boolean) => void + setCreateError: (error: string | null) => void + setRecoveryPhrase: (words: string[]) => void + setNpub: (npub: string) => void + setShowRecoveryStep: (show: boolean) => void +}): Promise { + params.setCreatingAccount(true) + params.setCreateError(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.setCreateError(e instanceof Error ? e.message : 'Failed to create account') + } finally { + params.setCreatingAccount(false) + } +} diff --git a/hooks/useDocs.ts b/hooks/useDocs.ts index e8c7cde..3f1ed3b 100644 --- a/hooks/useDocs.ts +++ b/hooks/useDocs.ts @@ -21,7 +21,9 @@ export function useDocs(docs: DocLink[]): { const loadDoc = useCallback(async (docId: DocSection): Promise => { const doc = docs.find((d) => d.id === docId) - if (!doc) {return} + if (!doc) { + return + } setLoading(true) setSelectedDoc(docId) @@ -29,7 +31,7 @@ export function useDocs(docs: DocLink[]): { try { // Get current locale and pass it to the API const locale = getLocale() - const response = await fetch(`/api/docs/${doc.file}?locale=${locale}`) + const response = await globalThis.fetch(`/api/docs/${doc.file}?locale=${locale}`) if (response.ok) { const text = await response.text() setDocContent(text) diff --git a/hooks/useI18n.ts b/hooks/useI18n.ts index 28c9383..35d6a38 100644 --- a/hooks/useI18n.ts +++ b/hooks/useI18n.ts @@ -26,8 +26,8 @@ export function useI18n(locale: Locale = 'fr'): { const initialLocale = savedLocale && (savedLocale === 'fr' || savedLocale === 'en') ? savedLocale : locale // Load translations from files in public directory - const frResponse = await fetch('/locales/fr.txt') - const enResponse = await fetch('/locales/en.txt') + const frResponse = await globalThis.fetch('/locales/fr.txt') + const enResponse = await globalThis.fetch('/locales/en.txt') if (frResponse.ok) { const frText = await frResponse.text() diff --git a/lib/articleEncryption.ts b/lib/articleEncryption.ts index 9484b2b..1983bbc 100644 --- a/lib/articleEncryption.ts +++ b/lib/articleEncryption.ts @@ -20,7 +20,7 @@ export interface DecryptionKey { * Generate a random encryption key for AES-GCM */ function generateEncryptionKey(): string { - const keyBytes = crypto.getRandomValues(new Uint8Array(32)) + const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32)) return Array.from(keyBytes) .map((b) => b.toString(16).padStart(2, '0')) .join('') @@ -30,7 +30,7 @@ function generateEncryptionKey(): string { * Generate a random IV for AES-GCM */ function generateIV(): Uint8Array { - return crypto.getRandomValues(new Uint8Array(12)) + return globalThis.crypto.getRandomValues(new Uint8Array(12)) } /** @@ -60,7 +60,7 @@ function arrayBufferToHex(buffer: ArrayBuffer): string { */ async function prepareEncryptionKey(key: string): Promise { const keyBuffer = hexToArrayBuffer(key) - return crypto.subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt']) + return globalThis.crypto.subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt']) } function prepareIV(iv: Uint8Array): { view: Uint8Array; buffer: ArrayBuffer } { @@ -85,7 +85,7 @@ export async function encryptArticleContent(content: string): Promise<{ const encodedContent = encoder.encode(content) const { view: ivView, buffer: ivBuffer } = prepareIV(iv) - const encryptedBuffer = await crypto.subtle.encrypt( + const encryptedBuffer = await globalThis.crypto.subtle.encrypt( { name: 'AES-GCM', iv: ivView as Uint8Array }, cryptoKey, encodedContent @@ -109,7 +109,7 @@ export async function decryptArticleContent( const keyBuffer = hexToArrayBuffer(key) const ivBuffer = hexToArrayBuffer(iv) - const cryptoKey = await crypto.subtle.importKey( + const cryptoKey = await globalThis.crypto.subtle.importKey( 'raw', keyBuffer, { name: 'AES-GCM' }, @@ -119,7 +119,7 @@ export async function decryptArticleContent( const encryptedBuffer = hexToArrayBuffer(encryptedContent) - const decryptedBuffer = await crypto.subtle.decrypt( + const decryptedBuffer = await globalThis.crypto.subtle.decrypt( { name: 'AES-GCM', iv: ivBuffer, diff --git a/lib/articlePublisherHelpersPresentation.ts b/lib/articlePublisherHelpersPresentation.ts index 5f7507e..a741249 100644 --- a/lib/articlePublisherHelpersPresentation.ts +++ b/lib/articlePublisherHelpersPresentation.ts @@ -2,6 +2,7 @@ import { type Event } from 'nostr-tools' import { nip19 } from 'nostr-tools' import type { AuthorPresentationDraft } from './articlePublisher' import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' +import { createSubscription } from '@/types/nostr-tools-extended' import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem' import { getPrimaryRelaySync } from './config' import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig' @@ -297,7 +298,6 @@ export async function fetchAuthorPresentationFromPool( return new Promise((resolve) => { let resolved = false const relayUrl = getPrimaryRelaySync() - const { createSubscription } = require('@/types/nostr-tools-extended') const sub = createSubscription(pool, [relayUrl], filters) const events: Event[] = [] diff --git a/lib/articleStorage.ts b/lib/articleStorage.ts index d5de93f..3c522b9 100644 --- a/lib/articleStorage.ts +++ b/lib/articleStorage.ts @@ -28,12 +28,12 @@ async function getOrCreateMasterKey(): Promise { if (existing) { return existing } - const keyBytes = crypto.getRandomValues(new Uint8Array(32)) + const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32)) let binary = '' keyBytes.forEach((b) => { binary += String.fromCharCode(b) }) - const key = btoa(binary) + const key = globalThis.btoa(binary) await storageService.set(MASTER_KEY_STORAGE_KEY, key, 'article_storage') return key } diff --git a/lib/configStorage.ts b/lib/configStorage.ts index 2d2a085..3c83746 100644 --- a/lib/configStorage.ts +++ b/lib/configStorage.ts @@ -60,7 +60,7 @@ export class ConfigStorage { return } - const request = indexedDB.open(DB_NAME, DB_VERSION) + const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION) request.onerror = () => { reject(new Error(`Failed to open IndexedDB: ${request.error}`)) diff --git a/lib/hashIdGenerator.ts b/lib/hashIdGenerator.ts index a13cf41..4ce8828 100644 --- a/lib/hashIdGenerator.ts +++ b/lib/hashIdGenerator.ts @@ -38,7 +38,7 @@ export async function generateHashId(obj: Record): Promise b.toString(16).padStart(2, '0')).join('') } diff --git a/lib/hooks/useSyncProgress.ts b/lib/hooks/useSyncProgress.ts index 850b2e1..ce407d0 100644 --- a/lib/hooks/useSyncProgress.ts +++ b/lib/hooks/useSyncProgress.ts @@ -27,8 +27,8 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr const [syncProgress, setSyncProgress] = useState(null) const [isSyncing, setIsSyncing] = useState(false) - const intervalRef = useRef(null) - const timeoutRef = useRef(null) + const intervalRef = useRef | null>(null) + const timeoutRef = useRef | null>(null) const onCompleteRef = useRef(onComplete) const isMonitoringRef = useRef(false) @@ -105,4 +105,3 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr stopMonitoring, } } - diff --git a/lib/keyManagementBIP39.ts b/lib/keyManagementBIP39.ts index 418ca62..6247ce8 100644 --- a/lib/keyManagementBIP39.ts +++ b/lib/keyManagementBIP39.ts @@ -217,7 +217,7 @@ export const BIP39_WORDLIST = [ */ export function generateRecoveryPhrase(): string[] { const words: string[] = [] - const random = crypto.getRandomValues(new Uint32Array(4)) + const random = globalThis.crypto.getRandomValues(new Uint32Array(4)) for (let i = 0; i < 4; i += 1) { const randomValue = random[i] diff --git a/lib/keyManagementTwoLevel.ts b/lib/keyManagementTwoLevel.ts index 5b43c24..65fdad1 100644 --- a/lib/keyManagementTwoLevel.ts +++ b/lib/keyManagementTwoLevel.ts @@ -19,7 +19,7 @@ const PBKDF2_HASH = 'SHA-256' * Generate a random KEK (Key Encryption Key) */ async function generateKEK(): Promise { - return crypto.subtle.generateKey( + return globalThis.crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, // extractable ['encrypt', 'decrypt'] @@ -35,12 +35,12 @@ async function deriveKeyFromPhrase(phrase: string[]): Promise { const password = encoder.encode(phraseString) // Generate deterministic salt from phrase - const saltBuffer = await crypto.subtle.digest('SHA-256', password) + const saltBuffer = await globalThis.crypto.subtle.digest('SHA-256', password) const saltArray = new Uint8Array(saltBuffer) const salt = saltArray.slice(0, 32) // Import password as key material - const keyMaterial = await crypto.subtle.importKey( + const keyMaterial = await globalThis.crypto.subtle.importKey( 'raw', password, 'PBKDF2', @@ -49,7 +49,7 @@ async function deriveKeyFromPhrase(phrase: string[]): Promise { ) // Derive key using PBKDF2 - const derivedKey = await crypto.subtle.deriveKey( + const derivedKey = await globalThis.crypto.subtle.deriveKey( { name: 'PBKDF2', salt, @@ -69,7 +69,7 @@ async function deriveKeyFromPhrase(phrase: string[]): Promise { * Export KEK to raw bytes (for storage) */ async function exportKEK(kek: CryptoKey): Promise { - const exported = await crypto.subtle.exportKey('raw', kek) + const exported = await globalThis.crypto.subtle.exportKey('raw', kek) return new Uint8Array(exported) } @@ -81,7 +81,7 @@ async function importKEK(keyBytes: Uint8Array): Promise { const buffer = new ArrayBuffer(keyBytes.length) const view = new Uint8Array(buffer) view.set(keyBytes) - return crypto.subtle.importKey( + return globalThis.crypto.subtle.importKey( 'raw', buffer, { name: 'AES-GCM' }, @@ -99,9 +99,9 @@ async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise b.toString(16).padStart(2, '0')).join('')) - const iv = crypto.getRandomValues(new Uint8Array(12)) + const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)) - const encrypted = await crypto.subtle.encrypt( + const encrypted = await globalThis.crypto.subtle.encrypt( { name: 'AES-GCM', iv }, phraseKey, data @@ -114,7 +114,7 @@ async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise { binary += String.fromCharCode(b) }) - return btoa(binary) + return globalThis.btoa(binary) } return { @@ -130,7 +130,7 @@ async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string const phraseKey = await deriveKeyFromPhrase(recoveryPhrase) function fromBase64(value: string): Uint8Array { - const binary = atob(value) + const binary = globalThis.atob(value) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i) @@ -150,7 +150,7 @@ async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string const cipherView = new Uint8Array(cipherBuffer) cipherView.set(ciphertext) - const decrypted = await crypto.subtle.decrypt( + const decrypted = await globalThis.crypto.subtle.decrypt( { name: 'AES-GCM', iv: ivView }, phraseKey, cipherBuffer @@ -171,9 +171,9 @@ async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Promise { const encoder = new TextEncoder() const data = encoder.encode(privateKey) - const iv = crypto.getRandomValues(new Uint8Array(12)) + const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)) - const encrypted = await crypto.subtle.encrypt( + const encrypted = await globalThis.crypto.subtle.encrypt( { name: 'AES-GCM', iv }, kek, data @@ -186,7 +186,7 @@ async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Pro bytes.forEach((b) => { binary += String.fromCharCode(b) }) - return btoa(binary) + return globalThis.btoa(binary) } return { @@ -200,7 +200,7 @@ async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Pro */ async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, kek: CryptoKey): Promise { function fromBase64(value: string): Uint8Array { - const binary = atob(value) + const binary = globalThis.atob(value) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i) @@ -220,7 +220,7 @@ async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, k const cipherView = new Uint8Array(cipherBuffer) cipherView.set(ciphertext) - const decrypted = await crypto.subtle.decrypt( + const decrypted = await globalThis.crypto.subtle.decrypt( { name: 'AES-GCM', iv: ivView }, kek, cipherBuffer diff --git a/lib/mempoolSpaceApi.ts b/lib/mempoolSpaceApi.ts index e17e8c4..b12ab05 100644 --- a/lib/mempoolSpaceApi.ts +++ b/lib/mempoolSpaceApi.ts @@ -4,7 +4,7 @@ const MEMPOOL_API_BASE = 'https://mempool.space/api' export async function getTransaction(txid: string): Promise { try { - const response = await fetch(`${MEMPOOL_API_BASE}/tx/${txid}`) + const response = await globalThis.fetch(`${MEMPOOL_API_BASE}/tx/${txid}`) if (!response.ok) { if (response.status === 404) { @@ -28,7 +28,7 @@ export async function getTransaction(txid: string): Promise { try { - const response = await fetch(`${MEMPOOL_API_BASE}/blocks/tip/height`) + const response = await globalThis.fetch(`${MEMPOOL_API_BASE}/blocks/tip/height`) if (!response.ok) { return 0 } diff --git a/lib/nip95.ts b/lib/nip95.ts index dc7eff3..34627a8 100644 --- a/lib/nip95.ts +++ b/lib/nip95.ts @@ -77,7 +77,7 @@ function parseUploadResponse(result: unknown, endpoint: string): string { async function tryUploadEndpoint(endpoint: string, formData: FormData, useProxy: boolean = false): Promise { const targetUrl = useProxy ? endpoint : endpoint - const response = await fetch(targetUrl, { + const response = await globalThis.fetch(targetUrl, { method: 'POST', body: formData, // Don't set Content-Type manually - browser will set it with boundary automatically diff --git a/lib/nip98.ts b/lib/nip98.ts index d859e66..5ae2e00 100644 --- a/lib/nip98.ts +++ b/lib/nip98.ts @@ -63,7 +63,7 @@ export async function generateNip98Token(method: string, url: string, payloadHas // Encode event as base64 JSON const eventJson = JSON.stringify(signedEvent) const eventBytes = new TextEncoder().encode(eventJson) - const base64Token = btoa(String.fromCharCode(...eventBytes)) + const base64Token = globalThis.btoa(String.fromCharCode(...eventBytes)) return base64Token } diff --git a/lib/nostrPrivateMessages.ts b/lib/nostrPrivateMessages.ts index 519f0bb..9a3e8c4 100644 --- a/lib/nostrPrivateMessages.ts +++ b/lib/nostrPrivateMessages.ts @@ -2,6 +2,7 @@ import { Event, nip04 } from 'nostr-tools' import { SimplePool } from 'nostr-tools' import { decryptArticleContent, type DecryptionKey } from './articleEncryption' import { getPrimaryRelaySync } from './config' +import { createSubscription } from '@/types/nostr-tools-extended' function createPrivateMessageFilters(eventId: string, publicKey: string, authorPubkey: string): Array<{ kinds: number[] @@ -45,7 +46,6 @@ export function getPrivateContent( return new Promise((resolve) => { let resolved = false const relayUrl = getPrimaryRelaySync() - const { createSubscription } = require('@/types/nostr-tools-extended') const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, publicKey, authorPubkey)) const finalize = (result: string | null): void => { @@ -122,7 +122,6 @@ export async function getDecryptionKey( return new Promise((resolve) => { let resolved = false const relayUrl = getPrimaryRelaySync() - const { createSubscription } = require('@/types/nostr-tools-extended') const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey)) const finalize = (result: DecryptionKey | null): void => { diff --git a/lib/objectCache.ts b/lib/objectCache.ts index d01eed4..7bee63e 100644 --- a/lib/objectCache.ts +++ b/lib/objectCache.ts @@ -178,14 +178,19 @@ class ObjectCacheService { // Notify about published status change (false -> array of relays) if (oldPublished === false && Array.isArray(published) && published.length > 0) { const eventId = id.split(':')[1] ?? id - const { notificationDetector } = require('./notificationDetector') - void notificationDetector.checkObjectChange({ - objectType, - objectId: id, - eventId, - oldPublished, - newPublished: published, - }) + void import('./notificationDetector') + .then(({ notificationDetector }) => { + void notificationDetector.checkObjectChange({ + objectType, + objectId: id, + eventId, + oldPublished, + newPublished: published, + }) + }) + .catch((error) => { + console.error('Failed to notify published status change:', error) + }) } } catch (updateError) { console.error(`Error updating published status for ${objectType} object:`, updateError) diff --git a/lib/paymentPollingZapReceipt.ts b/lib/paymentPollingZapReceipt.ts index 3bb92f3..6f51df8 100644 --- a/lib/paymentPollingZapReceipt.ts +++ b/lib/paymentPollingZapReceipt.ts @@ -1,6 +1,7 @@ import { nostrService } from './nostr' import { getPrimaryRelaySync } from './config' import type { Event } from 'nostr-tools' +import { createSubscription } from '@/types/nostr-tools-extended' export function parseZapAmount(event: import('nostr-tools').Event): number { const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1] @@ -17,7 +18,6 @@ export function createZapReceiptSubscription(pool: import('nostr-tools').SimpleP }, ] const relayUrl = getPrimaryRelaySync() - const { createSubscription } = require('@/types/nostr-tools-extended') return createSubscription(pool, [relayUrl], filters) } diff --git a/lib/platformSync.ts b/lib/platformSync.ts index 4db8834..811e2d6 100644 --- a/lib/platformSync.ts +++ b/lib/platformSync.ts @@ -6,6 +6,7 @@ import type { Event } from 'nostr-tools' import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' +import { createSubscription } from '@/types/nostr-tools-extended' import { nostrService } from './nostr' import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig' import { extractTagsFromEvent } from './nostrTagSystem' @@ -109,7 +110,6 @@ class PlatformSyncService { try { console.warn(`[PlatformSync] Synchronizing from relay ${i + 1}/${activeRelays.length}: ${relayUrl}`) - const { createSubscription } = require('@/types/nostr-tools-extended') const sub = createSubscription(pool, [relayUrl], filters) const relayEvents: Event[] = [] diff --git a/lib/publishWorker.ts b/lib/publishWorker.ts index 57fe2d1..f300a48 100644 --- a/lib/publishWorker.ts +++ b/lib/publishWorker.ts @@ -22,7 +22,7 @@ interface UnpublishedObject { class PublishWorkerService { private isRunning = false - private intervalId: NodeJS.Timeout | null = null + private intervalId: ReturnType | null = null private unpublishedObjects: Map = new Map() private processing = false diff --git a/lib/writeOrchestrator.ts b/lib/writeOrchestrator.ts index 43365f8..d21d16d 100644 --- a/lib/writeOrchestrator.ts +++ b/lib/writeOrchestrator.ts @@ -5,7 +5,7 @@ import { websocketService } from './websocketService' import { writeService } from './writeService' -import type { Event, EventTemplate } from 'nostr-tools' +import type { EventTemplate } from 'nostr-tools' import { finalizeEvent } from 'nostr-tools' import { hexToBytes } from 'nostr-tools/utils' import type { ObjectType } from './objectCache' @@ -94,7 +94,7 @@ class WriteOrchestrator { version: number, hidden: boolean, index?: number - ): Promise<{ success: boolean; event: Event; published: false | string[] }> { + ): Promise<{ success: boolean; event: NostrEvent; published: false | string[] }> { if (!this.privateKey) { throw new Error('Private key not set') } @@ -124,7 +124,7 @@ class WriteOrchestrator { return { success: result.success, - event, + event: finalizedEvent, published: result.published, } } diff --git a/lib/zapAggregation.ts b/lib/zapAggregation.ts index 450abd0..6933835 100644 --- a/lib/zapAggregation.ts +++ b/lib/zapAggregation.ts @@ -1,6 +1,7 @@ import type { Event } from 'nostr-tools' import { nostrService } from './nostr' import type { Subscription } from '@/types/nostr-tools-extended' +import { createSubscription } from '@/types/nostr-tools-extended' import { getPrimaryRelaySync } from './config' interface ZapAggregationFilter { @@ -50,7 +51,6 @@ export function aggregateZapSats(params: ZapAggregationFilter): Promise const filters = buildFilters(params) const relay = getPrimaryRelaySync() const timeout = params.timeoutMs ?? 5000 - const { createSubscription } = require('@/types/nostr-tools-extended') const sub = createSubscription(pool, [relay], filters) return collectZap(sub, timeout) diff --git a/pages/_app.tsx b/pages/_app.tsx index 24d3e94..6632e42 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,5 +1,6 @@ import '@/styles/globals.css' import type { AppProps } from 'next/app' +import Router from 'next/router' import { useI18n } from '@/hooks/useI18n' import React from 'react' import { nostrAuthService } from '@/lib/nostrAuth' @@ -92,11 +93,10 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem } // Listen to route changes - const router = require('next/router').default - router.events?.on('routeChangeComplete', handleRouteChange) + Router.events?.on('routeChangeComplete', handleRouteChange) return () => { - router.events?.off('routeChangeComplete', handleRouteChange) + Router.events?.off('routeChangeComplete', handleRouteChange) void swClient.stopPlatformSync() } }, []) @@ -123,12 +123,11 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem void startUserSync() // Also listen to connection changes and route changes to sync when user connects or navigates - const router = require('next/router').default const handleRouteChange = (): void => { void startUserSync() } - router.events?.on('routeChangeComplete', handleRouteChange) + Router.events?.on('routeChangeComplete', handleRouteChange) const unsubscribe = nostrAuthService.subscribe((state) => { if (state.connected && state.pubkey) { @@ -137,7 +136,7 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem }) return () => { - router.events?.off('routeChangeComplete', handleRouteChange) + Router.events?.off('routeChangeComplete', handleRouteChange) unsubscribe() } }, []) diff --git a/pages/api/nip95-upload.ts b/pages/api/nip95-upload.ts index b10e708..a81c349 100644 --- a/pages/api/nip95-upload.ts +++ b/pages/api/nip95-upload.ts @@ -193,7 +193,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) proxyRequest.on('error', (error) => { // Check for DNS errors specifically - const errorCode = (error as NodeJS.ErrnoException).code + const errorCode = getErrnoCode(error) if (errorCode === 'ENOTFOUND' || errorCode === 'EAI_AGAIN') { console.error('NIP-95 proxy DNS error:', { targetEndpoint, @@ -373,3 +373,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }) } } + +function getErrnoCode(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null) { + return undefined + } + const maybe = error as { code?: unknown } + return typeof maybe.code === 'string' ? maybe.code : undefined +} diff --git a/public/sw.js b/public/sw.js index b502631..6b84a85 100644 --- a/public/sw.js +++ b/public/sw.js @@ -7,12 +7,17 @@ const CACHE_NAME = 'zapwall-sync-v1' const SYNC_INTERVAL_MS = 60000 // 1 minute const REPUBLISH_INTERVAL_MS = 30000 // 30 seconds +const self = globalThis +const caches = globalThis.caches +const setInterval = globalThis.setInterval +const clearInterval = globalThis.clearInterval + let syncInProgress = false let publishWorkerInterval = null let notificationDetectorInterval = null // Install event - cache resources -self.addEventListener('install', (event) => { +self.addEventListener('install', () => { console.log('[SW] Service Worker installing') self.skipWaiting() // Activate immediately }) diff --git a/public/writeWorker.js b/public/writeWorker.js index cfae019..057f7a9 100644 --- a/public/writeWorker.js +++ b/public/writeWorker.js @@ -18,6 +18,10 @@ const DB_VERSIONS = { payment_note: 3, } +const self = globalThis +const indexedDB = globalThis.indexedDB +const IDBKeyRange = globalThis.IDBKeyRange + // Pile d'écritures pour éviter les conflits const writeQueue = [] let processingQueue = false diff --git a/scripts/lintReportSummary.py b/scripts/lintReportSummary.py new file mode 100644 index 0000000..78c99a2 --- /dev/null +++ b/scripts/lintReportSummary.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +import re +import sys + + +@dataclass(frozen=True) +class RuleCounts: + errors: Counter[str] + warnings: Counter[str] + + +def _extract_rule_from_line(line: str) -> str | None: + # ESLint flat output lines look like: + # "\n 12:34 error Message... rule-name" + # Only parse "location lines" to avoid counting explanatory multiline blocks. + # Require at least two spaces before the rule token; otherwise we might capture the last + # word of the message (e.g. "renders") for multiline explanatory errors that omit rule ids. + m = re.match(r"^\s*\d+:\d+\s+(error|warning)\s+.+\s{2,}([@\w\-/]+)\s*$", line) + if not m: + return None + return m.group(2) + + +def parse_eslint_output(path: Path) -> RuleCounts: + errors: Counter[str] = Counter() + warnings: Counter[str] = Counter() + + with path.open("r", encoding="utf-8", errors="replace") as f: + for line in f: + rule = _extract_rule_from_line(line) + if not rule: + continue + + if " error " in line: + errors[rule] += 1 + continue + + if " warning " in line: + warnings[rule] += 1 + + return RuleCounts(errors=errors, warnings=warnings) + + +def _order_bucket(rule: str) -> int: + # User requested ordering: + # - file size last: max-lines + # - function size before last: max-lines-per-function + if rule == "max-lines": + return 2 + if rule == "max-lines-per-function": + return 1 + return 0 + + +def print_summary(counts: RuleCounts) -> None: + print("ERRORS by rule:") + for rule, cnt in sorted( + counts.errors.items(), + key=lambda kv: (_order_bucket(kv[0]), -kv[1], kv[0]), + ): + print(f"{cnt:4d} {rule}") + + print("\nWARNINGS by rule:") + for rule, cnt in sorted(counts.warnings.items(), key=lambda kv: (-kv[1], kv[0])): + print(f"{cnt:4d} {rule}") + + total_errors = sum(counts.errors.values()) + total_warnings = sum(counts.warnings.values()) + print(f"\nTotal errors: {total_errors} | Total warnings: {total_warnings}") + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print("Usage: python scripts/lintReportSummary.py ", file=sys.stderr) + return 2 + + path = Path(argv[1]) + if not path.exists(): + print(f"File not found: {path}", file=sys.stderr) + return 2 + + counts = parse_eslint_output(path) + print_summary(counts) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv))