From 9e76a9e18a92823397ff96fb4bd59c43ed635e29 Mon Sep 17 00:00:00 2001 From: Nicolas Cantu Date: Fri, 9 Jan 2026 13:13:24 +0100 Subject: [PATCH] lint fix --- components/DragHandle.tsx | 17 ++ components/Nip95ConfigManager.tsx | 19 +- components/RelayManager.tsx | 19 +- components/SyncProgressBar.tsx | 18 +- components/UnlockAccountModal.tsx | 20 +- components/UserArticles.tsx | 28 +- hooks/useArticles.ts | 32 +- hooks/useAuthorsProfiles.ts | 16 +- hooks/useNotifications.ts | 11 +- lib/articleInvoice.ts | 10 +- lib/articlePublisherHelpersPresentation.ts | 338 ++++++++++++++------- lib/helpers/eventCacheHelper.ts | 136 ++++++--- lib/hooks/useSyncProgress.ts | 16 +- lib/metadataExtractor.ts | 13 +- lib/nip95.ts | 179 ++++++----- lib/nostrEventParsing.ts | 240 +++++++++------ lib/notificationDetector.ts | 61 ++-- lib/objectCache.ts | 6 +- lib/platformCommissions.ts | 37 ++- pages/api/nip95-upload.ts | 27 +- pages/author/[pubkey].tsx | 6 +- pages/index.tsx | 8 +- pages/publish.tsx | 8 +- pages/purchase/[id].tsx | 2 +- pages/review-tip/[id].tsx | 4 +- 25 files changed, 768 insertions(+), 503 deletions(-) create mode 100644 components/DragHandle.tsx diff --git a/components/DragHandle.tsx b/components/DragHandle.tsx new file mode 100644 index 0000000..cbf39b9 --- /dev/null +++ b/components/DragHandle.tsx @@ -0,0 +1,17 @@ +export function DragHandle(): React.ReactElement { + return ( +
+ +
+ ) +} diff --git a/components/Nip95ConfigManager.tsx b/components/Nip95ConfigManager.tsx index 207d71b..58e55a4 100644 --- a/components/Nip95ConfigManager.tsx +++ b/components/Nip95ConfigManager.tsx @@ -3,6 +3,7 @@ import { configStorage } from '@/lib/configStorage' import type { Nip95Config } from '@/lib/configStorageTypes' import { t } from '@/lib/i18n' import { userConfirm } from '@/lib/userConfirm' +import { DragHandle } from './DragHandle' interface Nip95ConfigManagerProps { onConfigChange?: () => void @@ -122,24 +123,6 @@ export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps): void handleUpdatePriorities(newApis) } - function DragHandle(): React.ReactElement { - return ( -
- - - - - - - - - - - -
- ) - } - async function handleUpdateUrl(id: string, url: string): Promise { try { await configStorage.updateNip95Api(id, { url }) diff --git a/components/RelayManager.tsx b/components/RelayManager.tsx index 12de9d9..6971a7e 100644 --- a/components/RelayManager.tsx +++ b/components/RelayManager.tsx @@ -4,6 +4,7 @@ import type { RelayConfig } from '@/lib/configStorageTypes' import { t } from '@/lib/i18n' import { userConfirm } from '@/lib/userConfirm' import { relaySessionManager } from '@/lib/relaySessionManager' +import { DragHandle } from './DragHandle' interface RelayManagerProps { onConfigChange?: () => void @@ -147,24 +148,6 @@ export function RelayManager({ onConfigChange }: RelayManagerProps): React.React setDraggedId(null) } - function DragHandle(): React.ReactElement { - return ( -
- - - - - - - - - - - -
- ) - } - async function handleUpdateUrl(id: string, url: string): Promise { try { await configStorage.updateRelay(id, { url }) diff --git a/components/SyncProgressBar.tsx b/components/SyncProgressBar.tsx index 190c1c2..13bcb98 100644 --- a/components/SyncProgressBar.tsx +++ b/components/SyncProgressBar.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { nostrAuthService } from '@/lib/nostrAuth' import { getLastSyncDate, setLastSyncDate as setLastSyncDateStorage, getCurrentTimestamp, calculateDaysBetween } from '@/lib/syncStorage' import { MIN_EVENT_DATE } from '@/lib/platformConfig' @@ -15,13 +15,7 @@ export function SyncProgressBar(): React.ReactElement | null { const [connectionState, setConnectionState] = useState<{ connected: boolean; pubkey: string | null }>({ connected: false, pubkey: null }) const [error, setError] = useState(null) - const { syncProgress, isSyncing, startMonitoring, stopMonitoring } = useSyncProgress({ - onComplete: async () => { - await loadSyncStatus() - }, - }) - - async function loadSyncStatus(): Promise { + const loadSyncStatus = useCallback(async (): Promise => { try { const state = nostrAuthService.getState() if (!state.connected || !state.pubkey) { @@ -37,7 +31,11 @@ export function SyncProgressBar(): React.ReactElement | null { } catch (loadError) { console.error('Error loading sync status:', loadError) } - } + }, []) + + const { syncProgress, isSyncing, startMonitoring, stopMonitoring } = useSyncProgress({ + onComplete: loadSyncStatus, + }) useEffect(() => { // Check connection state @@ -113,7 +111,7 @@ export function SyncProgressBar(): React.ReactElement | null { console.warn('[SyncProgressBar] Skipping auto-sync:', { isRecentlySynced, isSyncing, hasPubkey: Boolean(connectionState.pubkey) }) } })() - }, [isInitialized, connectionState.connected, connectionState.pubkey, isSyncing]) + }, [isInitialized, connectionState.connected, connectionState.pubkey, isSyncing, loadSyncStatus, startMonitoring, stopMonitoring]) async function resynchronize(): Promise { try { diff --git a/components/UnlockAccountModal.tsx b/components/UnlockAccountModal.tsx index 42aebc7..0b4c8b3 100644 --- a/components/UnlockAccountModal.tsx +++ b/components/UnlockAccountModal.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useMemo } from 'react' import { nostrAuthService } from '@/lib/nostrAuth' import { getWordSuggestions } from '@/lib/keyManagementBIP39' @@ -20,26 +20,24 @@ function WordInputWithAutocomplete({ onFocus: () => void onBlur: () => void }): React.ReactElement { - const [suggestions, setSuggestions] = useState([]) const [showSuggestions, setShowSuggestions] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) const inputRef = useRef(null) const suggestionsRef = useRef(null) - useEffect(() => { - if (value.length > 0) { - const newSuggestions = getWordSuggestions(value, 5) - setSuggestions(newSuggestions) - setShowSuggestions(newSuggestions.length > 0) - setSelectedIndex(-1) - } else { - setSuggestions([]) - setShowSuggestions(false) + const suggestions = useMemo((): string[] => { + if (value.length === 0) { + return [] } + return getWordSuggestions(value, 5) }, [value]) const handleChange = (event: React.ChangeEvent): void => { const newValue = event.target.value.trim().toLowerCase() + setSelectedIndex(-1) + if (newValue.length === 0) { + setShowSuggestions(false) + } onChange(newValue) } diff --git a/components/UserArticles.tsx b/components/UserArticles.tsx index d9d6890..2da7f29 100644 --- a/components/UserArticles.tsx +++ b/components/UserArticles.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type Dispatch, type SetStateAction } from 'react' +import { useMemo, useState, type Dispatch, type SetStateAction } from 'react' import type { Article } from '@/types/nostr' import type { ArticleDraft } from '@/lib/articlePublisherTypes' import { useArticleEditing } from '@/hooks/useArticleEditing' @@ -63,12 +63,17 @@ function useUserArticlesController({ submitEdit: () => Promise deleteArticle: (id: string) => Promise } { - const [localArticles, setLocalArticles] = useState(articles) + const [deletedArticleIds, setDeletedArticleIds] = useState>(new Set()) + const [articleOverridesById, setArticleOverridesById] = useState>(new Map()) const [unlockedArticles, setUnlockedArticles] = useState>(new Set()) const [pendingDeleteId, setPendingDeleteId] = useState(null) const editingCtx = useArticleEditing(currentPubkey) - useEffect(() => setLocalArticles(articles), [articles]) + const localArticles = useMemo((): Article[] => { + return articles + .filter((a) => !deletedArticleIds.has(a.id)) + .map((a) => articleOverridesById.get(a.id) ?? a) + }, [articles, articleOverridesById, deletedArticleIds]) return { localArticles, @@ -76,12 +81,12 @@ function useUserArticlesController({ pendingDeleteId, requestDelete: (id: string) => setPendingDeleteId(id), handleUnlock: createHandleUnlock(onLoadContent, setUnlockedArticles), - handleDelete: createHandleDelete(editingCtx.deleteArticle, setLocalArticles, setPendingDeleteId), + handleDelete: createHandleDelete(editingCtx.deleteArticle, setDeletedArticleIds, setPendingDeleteId), handleEditSubmit: createHandleEditSubmit( editingCtx.submitEdit, editingCtx.editingDraft, currentPubkey, - setLocalArticles + setArticleOverridesById ), ...editingCtx, } @@ -101,13 +106,13 @@ function createHandleUnlock( function createHandleDelete( deleteArticle: (id: string) => Promise, - setLocalArticles: Dispatch>, + setDeletedArticleIds: Dispatch>>, setPendingDeleteId: Dispatch> ): (article: Article) => Promise { return async (article: Article): Promise => { const ok = await deleteArticle(article.id) if (ok) { - setLocalArticles((prev) => prev.filter((a) => a.id !== article.id)) + setDeletedArticleIds((prev) => new Set([...prev, article.id])) } setPendingDeleteId(null) } @@ -117,15 +122,16 @@ function createHandleEditSubmit( submitEdit: () => Promise, draft: ReturnType['editingDraft'], currentPubkey: string | null, - setLocalArticles: Dispatch> + setArticleOverridesById: Dispatch>> ): () => Promise { return async (): Promise => { const result = await submitEdit() if (result && draft) { const updated = buildUpdatedArticle(draft, currentPubkey ?? '', result.articleId) - setLocalArticles((prev) => { - const filtered = prev.filter((a) => a.id !== result.originalArticleId) - return [updated, ...filtered] + setArticleOverridesById((prev) => { + const next = new Map(prev) + next.set(result.originalArticleId, { ...updated, id: result.originalArticleId }) + return next }) } } diff --git a/hooks/useArticles.ts b/hooks/useArticles.ts index 7f69f63..08c49ad 100644 --- a/hooks/useArticles.ts +++ b/hooks/useArticles.ts @@ -20,10 +20,6 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters | const hasArticlesRef = useRef(false) useEffect(() => { - setLoading(true) - setError(null) - - // Load authors from cache first const loadAuthorsFromCache = async (): Promise => { try { const cachedAuthors = await objectCache.getAll('author') @@ -60,11 +56,18 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters | 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 sponsoringResult = sponsoringResults.find((r) => r?.authorId === article.id) - if (sponsoringResult && article.isPresentation) { - return { ...article, totalSponsoring: sponsoringResult.totalSponsoring } + const totalSponsoring = sponsoringByAuthorId.get(article.id) + if (totalSponsoring !== undefined && article.isPresentation) { + return { ...article, totalSponsoring } } return article }) @@ -78,19 +81,24 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters | setLoading(false) hasArticlesRef.current = false return false - } catch (error) { - console.error('Error loading authors from cache:', error) + } catch (loadError) { + console.error('Error loading authors from cache:', loadError) setLoading(false) return false } } - // Read only from IndexedDB cache - no network subscription - void loadAuthorsFromCache().then((hasCachedAuthors) => { + 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 diff --git a/hooks/useAuthorsProfiles.ts b/hooks/useAuthorsProfiles.ts index 270586c..210b310 100644 --- a/hooks/useAuthorsProfiles.ts +++ b/hooks/useAuthorsProfiles.ts @@ -16,13 +16,13 @@ export function useAuthorsProfiles(authorPubkeys: string[]): { const pubkeysKey = useMemo(() => [...authorPubkeys].sort().join(','), [authorPubkeys]) useEffect(() => { - if (authorPubkeys.length === 0) { - setProfiles(new Map()) - setLoading(false) - return - } - const loadProfiles = async (): Promise => { + if (authorPubkeys.length === 0) { + setProfiles(new Map()) + setLoading(false) + return + } + setLoading(true) const profilesMap = new Map() @@ -33,8 +33,8 @@ export function useAuthorsProfiles(authorPubkeys: string[]): { pubkey, profile: profile ?? { pubkey }, } - } catch (error) { - console.error(`Error loading profile for ${pubkey}:`, error) + } catch (loadError) { + console.error(`Error loading profile for ${pubkey}:`, loadError) return { pubkey, profile: { pubkey }, diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index bab0061..4d19fae 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -56,9 +56,8 @@ export function useNotifications(userPubkey: string | null): { void (async (): Promise => { try { await notificationService.markAsRead(notificationId) - setNotifications((prev) => - prev.map((n) => (n.id === notificationId ? { ...n, read: true } : n)) - ) + const storedNotifications = await notificationService.getAllNotifications(100) + setNotifications(storedNotifications) } catch (error) { console.error('[useNotifications] Error marking notification as read:', error) } @@ -75,7 +74,8 @@ export function useNotifications(userPubkey: string | null): { void (async (): Promise => { try { await notificationService.markAllAsRead() - setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) + const storedNotifications = await notificationService.getAllNotifications(100) + setNotifications(storedNotifications) } catch (error) { console.error('[useNotifications] Error marking all as read:', error) } @@ -91,7 +91,8 @@ export function useNotifications(userPubkey: string | null): { void (async (): Promise => { try { await notificationService.deleteNotification(notificationId) - setNotifications((prev) => prev.filter((n) => n.id !== notificationId)) + const storedNotifications = await notificationService.getAllNotifications(100) + setNotifications(storedNotifications) } catch (error) { console.error('[useNotifications] Error deleting notification:', error) } diff --git a/lib/articleInvoice.ts b/lib/articleInvoice.ts index 363eeb5..97004ce 100644 --- a/lib/articleInvoice.ts +++ b/lib/articleInvoice.ts @@ -87,8 +87,7 @@ async function buildPreviewTags( encryptedKey?: string } ): Promise { - // Map category to new system - const category = params.draft.category === 'science-fiction' ? 'sciencefiction' : params.draft.category === 'scientific-research' ? 'research' : 'sciencefiction' + const category = normalizePublicationCategory(params.draft.category) // Generate hash ID from publication data const hashId = await generatePublicationHashId({ @@ -148,3 +147,10 @@ async function buildPreviewTags( return newTags } + +function normalizePublicationCategory(category: ArticleDraft['category'] | undefined): 'sciencefiction' | 'research' { + if (category === 'scientific-research') { + return 'research' + } + return 'sciencefiction' +} diff --git a/lib/articlePublisherHelpersPresentation.ts b/lib/articlePublisherHelpersPresentation.ts index aa773ef..cf841ef 100644 --- a/lib/articlePublisherHelpersPresentation.ts +++ b/lib/articlePublisherHelpersPresentation.ts @@ -129,135 +129,243 @@ export async function parsePresentationEvent(event: Event): Promise { - const raw = profileData?.contentDescription ?? tags.description ?? '' - // Remove Bitcoin address from contentDescription if present (should not be visible) - return raw - .split('\n') - .filter((line) => !line.includes('Adresse Bitcoin mainnet (pour le sponsoring)')) - .join('\n') - .trim() - })(), // Required field - thumbnailUrl: ((): string => { - if (typeof profileData?.pictureUrl === 'string') { - return profileData.pictureUrl - } - if (typeof tags.pictureUrl === 'string') { - return tags.pictureUrl - } - return '' - })(), // Required field - createdAt: event.created_at, + event, + tags, + profileData, + originalCategory, + }) +} + +type PresentationProfileData = { + authorName?: string + presentation?: string + contentDescription?: string + mainnetAddress?: string + pictureUrl?: string + category?: string +} + +function readPresentationProfileData(jsonTag: string | undefined, content: string): PresentationProfileData | null { + if (jsonTag) { + return parsePresentationProfileJson(jsonTag) + } + + // Backward compatibility: invisible format (with zero-width characters) + const invisibleJsonMatch = content.match(/[\u200B\u200C]\[Metadata JSON\][\u200B\u200C]\n[\u200B\u200C](.+)[\u200B\u200C]$/s) + if (invisibleJsonMatch?.[1]) { + try { + const cleanedJson = invisibleJsonMatch[1].replace(/[\u200B\u200C\u200D\u200E\u200F]/g, '').trim() + return parsePresentationProfileJson(cleanedJson) + } catch (invisibleJsonError) { + console.error('Error parsing profile JSON from invisible content:', invisibleJsonError) + } + } + + // Backward compatibility: visible format + const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s) + if (jsonMatch?.[1]) { + return parsePresentationProfileJson(jsonMatch[1].trim()) + } + + return null +} + +function mapTagCategoryToOriginalCategory(category: unknown): 'science-fiction' | 'scientific-research' | undefined { + if (category === 'sciencefiction') { + return 'science-fiction' + } + if (category === 'research') { + return 'scientific-research' + } + return undefined +} + +async function resolvePresentationIdParts(params: { + tags: ReturnType + event: Event + profileData: PresentationProfileData | null +}): Promise<{ hash: string; version: number; index: number }> { + const version = typeof params.tags.version === 'number' ? params.tags.version : 0 + const index = 0 + + const fromIdTag = resolvePresentationIdPartsFromIdTag(params.tags.id, version, index) + if (fromIdTag) { + return fromIdTag + } + + const mainnetAddress = resolvePresentationMainnetAddressCandidate(params.profileData, params.tags) + const pictureUrl = resolvePresentationPictureUrlCandidate(params.profileData, params.tags) + + const hash = await generateAuthorHashId({ + pubkey: params.event.pubkey, + authorName: resolveOptionalString(params.profileData?.authorName), + presentation: resolveOptionalString(params.profileData?.presentation), + contentDescription: resolveOptionalString(params.profileData?.contentDescription), + mainnetAddress, + pictureUrl, + category: resolvePresentationHashCategory(params.profileData, params.tags), + }) + + return { hash, version, index } +} + +function resolvePresentationIdPartsFromIdTag( + idTag: string | undefined, + defaultVersion: number, + defaultIndex: number +): { hash: string; version: number; index: number } | undefined { + if (!idTag) { + return undefined + } + + const parsed = parseObjectId(idTag) + if (parsed.hash) { + return { + hash: parsed.hash, + version: parsed.version ?? defaultVersion, + index: parsed.index ?? defaultIndex, + } + } + + return { hash: idTag, version: defaultVersion, index: defaultIndex } +} + +function buildPresentationArticle(params: { + id: string + hash: string + version: number + index: number + event: Event + tags: ReturnType + profileData: PresentationProfileData | null + originalCategory: 'science-fiction' | 'scientific-research' | undefined +}): import('@/types/nostr').AuthorPresentationArticle { + const description = resolvePresentationDescription(params.profileData, params.tags) + const contentDescription = sanitizePresentationContentDescription(resolvePresentationContentDescriptionRaw(params.profileData, params.tags)) + const thumbnailUrl = resolvePresentationThumbnailUrl(params.profileData, params.tags) + const mainnetAddress = resolvePresentationMainnetAddress(params.profileData, params.tags) + const bannerUrl = resolvePresentationBannerUrl(params.profileData, params.tags) + const title = resolvePresentationTitle(params.tags) + const preview = resolvePresentationPreview(params.tags, params.event.content) + + return { + id: params.id, + hash: params.hash, + version: params.version, + index: params.index, + pubkey: params.event.pubkey, + title, + preview, + content: params.event.content, + description, + contentDescription, + thumbnailUrl, + createdAt: params.event.created_at, zapAmount: 0, paid: true, category: 'author-presentation', isPresentation: true, - mainnetAddress: profileData?.mainnetAddress ?? tags.mainnetAddress ?? '', - totalSponsoring: 0, // Will be calculated from cache when needed - originalCategory: articleCategory ?? 'science-fiction', // Store original category for filtering + mainnetAddress, + totalSponsoring: 0, + originalCategory: params.originalCategory ?? 'science-fiction', + ...(bannerUrl ? { bannerUrl } : {}), } +} - // Add bannerUrl if available - if (profileData?.pictureUrl !== undefined && profileData?.pictureUrl !== null) { - result.bannerUrl = profileData.pictureUrl - } else if (tags.pictureUrl !== undefined && tags.pictureUrl !== null && typeof tags.pictureUrl === 'string') { - result.bannerUrl = tags.pictureUrl +function resolvePresentationTitle(tags: ReturnType): string { + return typeof tags.title === 'string' && tags.title.length > 0 ? tags.title : 'Présentation' +} + +function resolvePresentationPreview(tags: ReturnType, content: string): string { + if (typeof tags.preview === 'string' && tags.preview.length > 0) { + return tags.preview } + return content.substring(0, 200) +} - return result +function resolvePresentationDescription(profileData: PresentationProfileData | null, tags: ReturnType): string { + if (typeof profileData?.presentation === 'string') { + return profileData.presentation + } + return typeof tags.description === 'string' ? tags.description : '' +} + +function resolvePresentationContentDescriptionRaw(profileData: PresentationProfileData | null, tags: ReturnType): string { + if (typeof profileData?.contentDescription === 'string') { + return profileData.contentDescription + } + return typeof tags.description === 'string' ? tags.description : '' +} + +function resolveOptionalString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function resolvePresentationMainnetAddressCandidate( + profileData: PresentationProfileData | null, + tags: ReturnType +): string | undefined { + if (typeof profileData?.mainnetAddress === 'string') { + return profileData.mainnetAddress + } + return typeof tags.mainnetAddress === 'string' ? tags.mainnetAddress : undefined +} + +function resolvePresentationPictureUrlCandidate( + profileData: PresentationProfileData | null, + tags: ReturnType +): string | undefined { + if (typeof profileData?.pictureUrl === 'string') { + return profileData.pictureUrl + } + return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : undefined +} + +function resolvePresentationHashCategory(profileData: PresentationProfileData | null, tags: ReturnType): string { + if (typeof profileData?.category === 'string') { + return profileData.category + } + return typeof tags.category === 'string' ? tags.category : 'sciencefiction' +} + +function sanitizePresentationContentDescription(raw: string): string { + return raw + .split('\n') + .filter((line) => !line.includes('Adresse Bitcoin mainnet (pour le sponsoring)')) + .join('\n') + .trim() +} + +function resolvePresentationThumbnailUrl(profileData: PresentationProfileData | null, tags: ReturnType): string { + if (typeof profileData?.pictureUrl === 'string') { + return profileData.pictureUrl + } + return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : '' +} + +function resolvePresentationBannerUrl(profileData: PresentationProfileData | null, tags: ReturnType): string | undefined { + if (typeof profileData?.pictureUrl === 'string' && profileData.pictureUrl.length > 0) { + return profileData.pictureUrl + } + return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : undefined +} + +function resolvePresentationMainnetAddress(profileData: PresentationProfileData | null, tags: ReturnType): string { + const fromProfile = profileData?.mainnetAddress + if (typeof fromProfile === 'string') { + return fromProfile + } + return typeof tags.mainnetAddress === 'string' ? tags.mainnetAddress : '' } function parsePresentationProfileJson(json: string): { diff --git a/lib/helpers/eventCacheHelper.ts b/lib/helpers/eventCacheHelper.ts index 5986428..a26a278 100644 --- a/lib/helpers/eventCacheHelper.ts +++ b/lib/helpers/eventCacheHelper.ts @@ -25,6 +25,89 @@ interface ExtractedObjectWithId { index?: number } +function groupEventsByHashId(events: Event[]): Map { + const eventsByHashId = new Map() + + for (const event of events) { + const tags = extractTagsFromEvent(event) + const { id } = tags + if (id) { + const parsed = parseObjectId(id) + const hash = parsed.hash ?? id + + const current = eventsByHashId.get(hash) + if (current) { + current.push(event) + } else { + eventsByHashId.set(hash, [event]) + } + } + } + + return eventsByHashId +} + +function resolveExtractedId(extracted: unknown, getHash: EventCacheConfig['getHash']): string | undefined { + const extractedHash = getHash ? getHash(extracted) : null + const extractedObj = extracted as ExtractedObjectWithId + return extractedHash ?? extractedObj.id +} + +function resolveHashAndIndex( + extractedId: string, + extracted: unknown, + getIndex: EventCacheConfig['getIndex'] +): { hash: string; index: number } { + const parsed = parseObjectId(extractedId) + const extractedObj = extracted as ExtractedObjectWithId + const hash = parsed.hash ?? extractedId + const index = getIndex ? getIndex(extracted) : (parsed.index ?? extractedObj.index ?? 0) + return { hash, index } +} + +function resolveVersionAndHidden( + latestEvent: Event, + getVersion: EventCacheConfig['getVersion'], + getHidden: EventCacheConfig['getHidden'] +): { version: number; hidden: boolean } { + const tags = extractTagsFromEvent(latestEvent) + const version = getVersion ? getVersion(latestEvent) : (tags.version ?? 0) + const hidden = getHidden ? getHidden(latestEvent) : (tags.hidden ?? false) + return { version, hidden } +} + +async function cacheLatestEventForHashGroup(hashEvents: Event[], config: EventCacheConfig): Promise { + const { objectType, extractor, getHash, getIndex, getVersion, getHidden } = config + + const latestEvent = getLatestVersion(hashEvents) + if (!latestEvent) { + return + } + + const extracted = await extractor(latestEvent) + if (!extracted) { + return + } + + const extractedId = resolveExtractedId(extracted, getHash) + if (!extractedId) { + return + } + + const { hash, index } = resolveHashAndIndex(extractedId, extracted, getIndex) + const { version, hidden } = resolveVersionAndHidden(latestEvent, getVersion, getHidden) + + await writeObjectToCache({ + objectType, + hash, + event: latestEvent, + parsed: extracted, + version, + hidden, + index, + }) +} + /** * Group events by hash ID and cache the latest version of each */ @@ -32,56 +115,9 @@ export async function groupAndCacheEventsByHash( events: Event[], config: EventCacheConfig ): Promise { - const { objectType, extractor, getHash, getIndex, getVersion, getHidden } = config - - // Group events by hash ID - const eventsByHashId = new Map() - for (const event of events) { - const tags = extractTagsFromEvent(event) - if (tags.id) { - // Extract hash from id (can be __ or just hash) - const parsed = parseObjectId(tags.id) - const hash = parsed.hash ?? tags.id - if (!eventsByHashId.has(hash)) { - eventsByHashId.set(hash, []) - } - const hashEvents = eventsByHashId.get(hash) - if (hashEvents) { - hashEvents.push(event) - } - } - } - - // Cache each object (latest version) - for (const [_hash, hashEvents] of eventsByHashId.entries()) { - const latestEvent = getLatestVersion(hashEvents) - if (latestEvent) { - const extracted = await extractor(latestEvent) - if (extracted) { - // Get hash, index, version, hidden - const extractedHash = getHash ? getHash(extracted) : null - const extractedObj = extracted as ExtractedObjectWithId - const extractedId = extractedHash ?? extractedObj.id - - if (extractedId) { - const publicationParsed = parseObjectId(extractedId) - const hash = publicationParsed.hash ?? extractedId - const index = getIndex ? getIndex(extracted) : publicationParsed.index ?? extractedObj.index ?? 0 - const version = getVersion ? getVersion(latestEvent) : extractTagsFromEvent(latestEvent).version ?? 0 - const hidden = getHidden ? getHidden(latestEvent) : extractTagsFromEvent(latestEvent).hidden ?? false - - await writeObjectToCache({ - objectType, - hash, - event: latestEvent, - parsed: extracted, - version, - hidden, - index, - }) - } - } - } + const eventsByHashId = groupEventsByHashId(events) + for (const hashEvents of eventsByHashId.values()) { + await cacheLatestEventForHashGroup(hashEvents, config) } } diff --git a/lib/hooks/useSyncProgress.ts b/lib/hooks/useSyncProgress.ts index 7b3d8e8..5eedfb0 100644 --- a/lib/hooks/useSyncProgress.ts +++ b/lib/hooks/useSyncProgress.ts @@ -3,7 +3,7 @@ * Centralizes the pattern of polling syncProgressManager and updating state */ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import type { SyncProgress } from '../helpers/syncProgressHelper' export interface UseSyncProgressOptions { @@ -32,7 +32,7 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr const onCompleteRef = useRef(onComplete) const isMonitoringRef = useRef(false) - function stopMonitoring(): void { + const stopMonitoring = useCallback((): void => { if (!isMonitoringRef.current) { return } @@ -49,14 +49,14 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr clearTimeout(timeoutRef.current) timeoutRef.current = null } - } + }, []) // Update onComplete ref when it changes useEffect(() => { onCompleteRef.current = onComplete }, [onComplete]) - const checkProgress = async (): Promise => { + const checkProgress = useCallback(async (): Promise => { const { syncProgressManager } = await import('../syncProgressManager') const currentProgress = syncProgressManager.getProgress() if (currentProgress) { @@ -69,9 +69,9 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr stopMonitoring() } } - } + }, [stopMonitoring]) - const startMonitoring = (): void => { + const startMonitoring = useCallback((): void => { if (isMonitoringRef.current) { return } @@ -89,14 +89,14 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr timeoutRef.current = setTimeout(() => { stopMonitoring() }, maxDuration) - } + }, [checkProgress, maxDuration, pollInterval, stopMonitoring]) // Cleanup on unmount useEffect(() => { return () => { stopMonitoring() } - }, []) + }, [stopMonitoring]) return { syncProgress, diff --git a/lib/metadataExtractor.ts b/lib/metadataExtractor.ts index dd8aa14..b6847a6 100644 --- a/lib/metadataExtractor.ts +++ b/lib/metadataExtractor.ts @@ -109,7 +109,8 @@ function extractMetadataJsonFromTag(event: { tags: string[][] }): Record tag[0] === 'json') if (jsonTag?.[1]) { try { - return JSON.parse(jsonTag[1]) + const parsed: unknown = JSON.parse(jsonTag[1]) + return isRecord(parsed) ? parsed : null } catch (e) { console.error('Error parsing JSON metadata from tag:', e) return null @@ -125,7 +126,8 @@ function extractMetadataJson(content: string): Record | null { try { // Remove zero-width characters from JSON const cleanedJson = invisibleJsonMatch[1].replace(/[\u200B\u200C\u200D\u200E\u200F]/g, '').trim() - return JSON.parse(cleanedJson) + const parsed: unknown = JSON.parse(cleanedJson) + return isRecord(parsed) ? parsed : null } catch (e) { console.error('Error parsing metadata JSON from invisible content:', e) } @@ -135,7 +137,8 @@ function extractMetadataJson(content: string): Record | null { const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s) if (jsonMatch?.[1]) { try { - return JSON.parse(jsonMatch[1].trim()) + const parsed: unknown = JSON.parse(jsonMatch[1].trim()) + return isRecord(parsed) ? parsed : null } catch (e) { console.error('Error parsing metadata JSON from content:', e) return null @@ -144,6 +147,10 @@ function extractMetadataJson(content: string): Record | null { return null } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + /** * Extract author from event */ diff --git a/lib/nip95.ts b/lib/nip95.ts index cb1bb72..2ea0940 100644 --- a/lib/nip95.ts +++ b/lib/nip95.ts @@ -58,22 +58,19 @@ function parseUploadResponse(result: unknown, endpoint: string): string { const obj = result as Record - // void.cat format: { ok: true, file: { id, url } } - if ('ok' in obj && obj.ok === true && 'file' in obj) { - const file = obj.file as Record - if (typeof file.url === 'string') { - return file.url - } + const fromVoidCat = readVoidCatUploadUrl(obj) + if (fromVoidCat) { + return fromVoidCat } - // nostrcheck.me format: { status: 'success', url: string } - if ('status' in obj && obj.status === 'success' && 'url' in obj && typeof obj.url === 'string') { - return obj.url + const fromNostrcheck = readNostrcheckUploadUrl(obj) + if (fromNostrcheck) { + return fromNostrcheck } - // Standard format: { url: string } - if ('url' in obj && typeof obj.url === 'string') { - return obj.url + const fromStandard = readStandardUploadUrl(obj) + if (fromStandard) { + return fromStandard } console.error('NIP-95 upload missing URL:', { @@ -83,6 +80,28 @@ function parseUploadResponse(result: unknown, endpoint: string): string { throw new Error('Upload response missing URL') } +function readVoidCatUploadUrl(obj: Record): string | undefined { + if (!('ok' in obj) || obj.ok !== true || !('file' in obj)) { + return undefined + } + if (typeof obj.file !== 'object' || obj.file === null) { + return undefined + } + const file = obj.file as Record + return typeof file.url === 'string' ? file.url : undefined +} + +function readNostrcheckUploadUrl(obj: Record): string | undefined { + if (!('status' in obj) || obj.status !== 'success') { + return undefined + } + return typeof obj.url === 'string' ? obj.url : undefined +} + +function readStandardUploadUrl(obj: Record): string | undefined { + return typeof obj.url === 'string' ? obj.url : undefined +} + /** * Try uploading to a single endpoint * Uses proxy API route for endpoints that have CORS issues @@ -139,70 +158,11 @@ export async function uploadNip95Media(file: File): Promise { let lastError: Error | null = null for (const endpoint of endpoints) { - try { - // Check if endpoint requires NIP-98 authentication (nostrcheck.me) - const needsAuth = endpoint.includes('nostrcheck.me') - let authToken: string | undefined - - if (needsAuth) { - if (!isNip98Available()) { - const pubkey = nostrService.getPublicKey() - const isUnlocked = nostrAuthService.isUnlocked() - if (!pubkey) { - console.warn('NIP-98 authentication required for nostrcheck.me but no account found. Please create or import an account.') - // Skip this endpoint - } else if (!isUnlocked) { - // Throw a special error that can be caught to trigger unlock modal - // This error should propagate to the caller, not be caught here - throw createUnlockRequiredError() - } else { - console.warn('NIP-98 authentication required for nostrcheck.me but not available. Skipping endpoint.') - // Skip this endpoint - } - } else { - try { - // Generate NIP-98 token for the actual endpoint (not the proxy) - // The token must be for the final destination URL - authToken = await generateNip98Token('POST', endpoint) - } catch (authError) { - console.error('Failed to generate NIP-98 token:', authError) - // Skip this endpoint if auth fails - } - } - } - - // Only proceed if we have auth token when needed, or if auth is not needed - if (!needsAuth || authToken) { - // Always use proxy to avoid CORS, 405, and name resolution issues - // Pass endpoint and auth token as query parameters to proxy - const proxyUrlParams = new URLSearchParams({ - endpoint, - }) - if (authToken) { - proxyUrlParams.set('auth', authToken) - } - const proxyUrl = `/api/nip95-upload?${proxyUrlParams.toString()}` - const url = await tryUploadEndpoint(proxyUrl, formData, true) - return { url, type: mediaType } - } - } catch (e) { - const error = e instanceof Error ? e : new Error(String(e)) - const errorMessage = error.message - - // If unlock is required, propagate the error immediately - if (errorMessage === 'UNLOCK_REQUIRED' || isUnlockRequiredError(error)) { - throw error - } - - console.error('NIP-95 upload endpoint error:', { - endpoint, - error: errorMessage, - fileSize: file.size, - fileType: file.type, - }) - lastError = error - // Continue to next endpoint + const upload = await attemptUploadToEndpoint({ endpoint, formData, mediaType, file }) + if (upload) { + return upload } + lastError = new Error(`Upload failed for endpoint: ${endpoint}`) } // All endpoints failed @@ -211,3 +171,72 @@ export async function uploadNip95Media(file: File): Promise { } throw new Error('Failed to upload: no endpoints available') } + +async function attemptUploadToEndpoint(params: { + endpoint: string + formData: FormData + mediaType: MediaRef['type'] + file: File +}): Promise { + try { + const needsAuth = params.endpoint.includes('nostrcheck.me') + const authToken = await resolveNip98AuthToken({ endpoint: params.endpoint, needsAuth }) + + if (needsAuth && !authToken) { + return null + } + + const proxyUrlParams = new URLSearchParams({ endpoint: params.endpoint }) + if (authToken) { + proxyUrlParams.set('auth', authToken) + } + const proxyUrl = `/api/nip95-upload?${proxyUrlParams.toString()}` + const url = await tryUploadEndpoint(proxyUrl, params.formData, true) + return { url, type: params.mediaType } + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)) + + if (error.message === 'UNLOCK_REQUIRED' || isUnlockRequiredError(error)) { + throw error + } + + console.error('NIP-95 upload endpoint error:', { + endpoint: params.endpoint, + error: error.message, + fileSize: params.file.size, + fileType: params.file.type, + }) + return null + } +} + +async function resolveNip98AuthToken(params: { endpoint: string; needsAuth: boolean }): Promise { + if (!params.needsAuth) { + return undefined + } + + if (!isNip98Available()) { + const pubkey = nostrService.getPublicKey() + if (!pubkey) { + console.warn('NIP-98 authentication required for nostrcheck.me but no account found. Please create or import an account.') + return undefined + } + + const isUnlocked = nostrAuthService.isUnlocked() + if (!isUnlocked) { + throw createUnlockRequiredError() + } + + console.warn('NIP-98 authentication required for nostrcheck.me but not available. Skipping endpoint.') + return undefined + } + + try { + // Generate NIP-98 token for the actual endpoint (not the proxy) + // The token must be for the final destination URL + return await generateNip98Token('POST', params.endpoint) + } catch (authError) { + console.error('Failed to generate NIP-98 token:', authError) + return undefined + } +} diff --git a/lib/nostrEventParsing.ts b/lib/nostrEventParsing.ts index 9595075..639c202 100644 --- a/lib/nostrEventParsing.ts +++ b/lib/nostrEventParsing.ts @@ -1,5 +1,5 @@ import type { Event } from 'nostr-tools' -import type { Article, KindType, Page, Purchase, Review, ReviewTip, Series, Sponsoring } from '@/types/nostr' +import type { Article, Page, Purchase, Review, ReviewTip, Series, Sponsoring } from '@/types/nostr' import { extractTagsFromEvent } from './nostrTagSystem' import { buildObjectId, parseObjectId } from './urlGenerator' import { generateHashId } from './hashIdGenerator' @@ -34,41 +34,21 @@ export async function parseSeriesFromEvent(event: Event): Promise if (!tags.title || !tags.description) { return null } - // Map category from new system to old system - let category: 'science-fiction' | 'scientific-research' = 'science-fiction' - if (tags.category === 'sciencefiction') { - category = 'science-fiction' - } else if (tags.category === 'research') { - category = 'scientific-research' - } + const category = mapNostrCategoryToLegacy(tags.category) ?? 'science-fiction' - // Extract hash, version, index from id tag or parse it - let hash: string - let version = tags.version ?? 0 - let index = 0 - - if (tags.id) { - const parsed = parseObjectId(tags.id) - const { hash: parsedHash, version: parsedVersion, index: parsedIndex } = parsed - if (parsedHash) { - hash = parsedHash - version = parsedVersion ?? version - index = parsedIndex ?? index - } else { - // If id is just a hash, use it directly - hash = tags.id - } - } else { - // Generate hash from series data - hash = await generateHashId({ + const { hash, version, index } = await resolveObjectIdParts({ + ...(tags.id ? { idTag: tags.id } : {}), + defaultVersion: tags.version ?? 0, + defaultIndex: 0, + generateHash: async (): Promise => generateHashId({ type: 'series', pubkey: event.pubkey, title: tags.title, description: tags.description, category: tags.category ?? 'sciencefiction', coverUrl: tags.coverUrl ?? '', - }) - } + }), + }) const id = buildObjectId(hash, index, version) @@ -172,36 +152,142 @@ function getPreviewContent(content: string, previewTag?: string): { previewConte return { previewContent } } -async function buildArticle(event: Event, tags: ReturnType, preview: string): Promise
{ - // Map category from new system to old system - let category: 'science-fiction' | 'scientific-research' | undefined - if (tags.category === 'sciencefiction') { - category = 'science-fiction' - } else if (tags.category === 'research') { - category = 'scientific-research' - } else { - category = undefined +function mapNostrCategoryToLegacy( + category: string | undefined +): 'science-fiction' | 'scientific-research' | undefined { + if (category === 'sciencefiction') { + return 'science-fiction' } + if (category === 'research') { + return 'scientific-research' + } + return undefined +} + +interface ObjectIdParts { + hash: string + version: number + index: number +} + +function parseObjectIdPartsFromTag(params: { idTag: string; defaultVersion: number; defaultIndex: number }): ObjectIdParts { + const parsed = parseObjectId(params.idTag) + const hash = parsed.hash ?? params.idTag + const version = parsed.version ?? params.defaultVersion + const index = parsed.index ?? params.defaultIndex + return { hash, version, index } +} + +async function resolveObjectIdParts(params: { + idTag?: string + defaultVersion: number + defaultIndex: number + generateHash: () => Promise +}): Promise { + if (params.idTag) { + return parseObjectIdPartsFromTag({ + idTag: params.idTag, + defaultVersion: params.defaultVersion, + defaultIndex: params.defaultIndex, + }) + } + + const hash = await params.generateHash() + return { hash, version: params.defaultVersion, index: params.defaultIndex } +} + +function resolveThumbnailUrl(tags: ReturnType): string { + if (typeof tags.bannerUrl === 'string') { + return tags.bannerUrl + } + if (typeof tags.pictureUrl === 'string') { + return tags.pictureUrl + } + return '' +} + +function parsePagesFromEventJsonTag(event: Event): Page[] | undefined { + const jsonTag = event.tags.find((tag) => tag[0] === 'json')?.[1] + if (!jsonTag) { + return undefined + } + + try { + const parsed: unknown = JSON.parse(jsonTag) + if (typeof parsed !== 'object' || parsed === null) { + return undefined + } + const metadata = parsed as { pages?: unknown } + if (!Array.isArray(metadata.pages)) { + return undefined + } + return metadata.pages as Page[] + } catch { + return undefined + } +} + +function buildArticlePaymentFields(tags: ReturnType): Partial
{ + const result: Partial
= {} + if (tags.invoice) { + result.invoice = tags.invoice + } + if (tags.paymentHash) { + result.paymentHash = tags.paymentHash + } + return result +} + +function buildArticleClassificationFields(params: { + tags: ReturnType + category: 'science-fiction' | 'scientific-research' | undefined + isPresentation: boolean +}): Partial
{ + const result: Partial
= {} + if (params.category) { + result.category = params.category + } + if (params.isPresentation) { + result.isPresentation = true + } + if (params.tags.type === 'publication' || params.tags.type === 'author') { + result.kindType = 'article' + } + return result +} + +function buildArticleOptionalMetaFields(params: { + tags: ReturnType + pages: Page[] | undefined +}): Partial
{ + const result: Partial
= {} + if (params.tags.mainnetAddress) { + result.mainnetAddress = params.tags.mainnetAddress + } + if (params.tags.totalSponsoring) { + result.totalSponsoring = params.tags.totalSponsoring + } + if (params.tags.seriesId) { + result.seriesId = params.tags.seriesId + } + if (params.tags.bannerUrl) { + result.bannerUrl = params.tags.bannerUrl + } + if (params.pages && params.pages.length > 0) { + result.pages = params.pages + } + return result +} + +async function buildArticle(event: Event, tags: ReturnType, preview: string): Promise
{ + const category = mapNostrCategoryToLegacy(tags.category) const isPresentation = tags.type === 'author' - // Extract hash, version, index from id tag or parse it - let hash: string - let version = tags.version ?? 0 - let index = 0 - - if (tags.id) { - const parsed = parseObjectId(tags.id) - if (parsed.hash) { - ;({ hash } = parsed) - version = parsed.version ?? version - index = parsed.index ?? index - } else { - // If id is just a hash, use it directly - hash = tags.id - } - } else { - // Generate hash from article data - hash = await generateHashId({ + const { hash, version, index } = await resolveObjectIdParts({ + ...(tags.id ? { idTag: tags.id } : {}), + defaultVersion: tags.version ?? 0, + defaultIndex: 0, + generateHash: async (): Promise => generateHashId({ type: isPresentation ? 'author' : 'publication', pubkey: event.pubkey, title: tags.title ?? 'Untitled', @@ -210,25 +296,12 @@ async function buildArticle(event: Event, tags: ReturnType tag[0] === 'json')?.[1] - if (jsonTag) { - const metadata = JSON.parse(jsonTag) as { pages?: Page[] } - const { pages: metadataPages } = metadata - if (metadataPages && Array.isArray(metadataPages)) { - pages = metadataPages - } - } - } catch { - // Ignore JSON parsing errors - } + const pages = parsePagesFromEventJsonTag(event) return { id, @@ -244,25 +317,10 @@ async function buildArticle(event: Event, tags: ReturnType { - if (typeof tags.bannerUrl === 'string') { - return tags.bannerUrl - } - if (typeof tags.pictureUrl === 'string') { - return tags.pictureUrl - } - return '' - })(), // Required field with default - ...(tags.invoice ? { invoice: tags.invoice } : {}), - ...(tags.paymentHash ? { paymentHash: tags.paymentHash } : {}), - ...(category ? { category } : {}), - ...(isPresentation ? { isPresentation: true } : {}), - ...(tags.mainnetAddress ? { mainnetAddress: tags.mainnetAddress } : {}), - ...(tags.totalSponsoring ? { totalSponsoring: tags.totalSponsoring } : {}), - ...(tags.seriesId ? { seriesId: tags.seriesId } : {}), - ...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl } : {}), - ...(pages && pages.length > 0 ? { pages } : {}), - ...(tags.type === 'publication' || tags.type === 'author' ? { kindType: 'article' as KindType } : {}), + thumbnailUrl: resolveThumbnailUrl(tags), // Required field with default + ...buildArticlePaymentFields(tags), + ...buildArticleClassificationFields({ tags, category, isPresentation }), + ...buildArticleOptionalMetaFields({ tags, pages }), } } diff --git a/lib/notificationDetector.ts b/lib/notificationDetector.ts index 76e7e9a..f452004 100644 --- a/lib/notificationDetector.ts +++ b/lib/notificationDetector.ts @@ -162,28 +162,11 @@ class NotificationDetector { }) for (const obj of userObjects) { - if (Array.isArray(obj.published) && obj.published.length > 0) { - const eventId = obj.id.split(':')[1] ?? obj.id - const existing = await notificationService.getNotificationByEventId(eventId) - const alreadyNotified = existing?.type === 'published' - const recentlyCreated = obj.createdAt * 1000 > oneHourAgo - - if (!alreadyNotified && recentlyCreated) { - const relays = obj.published - await notificationService.createNotification({ - type: 'published', - objectType, - objectId: obj.id, - eventId, - data: { - relays, - object: obj, - title: 'Publication réussie', - message: `Votre contenu a été publié sur ${relays.length} relais`, - }, - }) - } - } + await this.maybeCreatePublishedNotification({ + obj, + objectType, + oneHourAgo, + }) } } catch (error) { console.error(`[NotificationDetector] Error scanning published status for ${objectType}:`, error) @@ -194,6 +177,40 @@ class NotificationDetector { } } + private async maybeCreatePublishedNotification(params: { + obj: CachedObject + objectType: string + oneHourAgo: number + }): Promise { + if (!Array.isArray(params.obj.published) || params.obj.published.length === 0) { + return + } + + if (params.obj.createdAt * 1000 <= params.oneHourAgo) { + return + } + + const eventId = params.obj.id.split(':')[1] ?? params.obj.id + const existing = await notificationService.getNotificationByEventId(eventId) + if (existing?.type === 'published') { + return + } + + const relays = params.obj.published + await notificationService.createNotification({ + type: 'published', + objectType: params.objectType, + objectId: params.obj.id, + eventId, + data: { + relays, + object: params.obj, + title: 'Publication réussie', + message: `Votre contenu a été publié sur ${relays.length} relais`, + }, + }) + } + /** * Manually check for a specific object change */ diff --git a/lib/objectCache.ts b/lib/objectCache.ts index 5d10cf3..90bd573 100644 --- a/lib/objectCache.ts +++ b/lib/objectCache.ts @@ -239,14 +239,14 @@ class ObjectCacheService { * Get an object from cache by hash * Returns the latest non-hidden version */ - async get(objectType: ObjectType, hash: string): Promise { + async get(objectType: ObjectType, hash: string): Promise { try { const db = await this.initDB(objectType) const transaction = db.transaction(['objects'], 'readonly') const store = transaction.objectStore('objects') const hashIndex = store.index('hash') - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const request = hashIndex.openCursor(IDBKeyRange.only(hash)) const objects: CachedObject[] = [] @@ -282,7 +282,7 @@ class ObjectCacheService { /** * Get an object from cache by ID */ - async getById(objectType: ObjectType, id: string): Promise { + async getById(objectType: ObjectType, id: string): Promise { try { const helper = this.getDBHelper(objectType) const obj = await helper.get(id) diff --git a/lib/platformCommissions.ts b/lib/platformCommissions.ts index 19e52ef..00eb3b6 100644 --- a/lib/platformCommissions.ts +++ b/lib/platformCommissions.ts @@ -128,23 +128,28 @@ export function verifyPaymentSplit( authorAmount?: number, platformAmount?: number ): boolean { - switch (type) { - case 'article': { - const articleSplit = calculateArticleSplit(totalAmount) - return articleSplit.author === (authorAmount ?? 0) && articleSplit.platform === (platformAmount ?? 0) - } + const author = authorAmount ?? 0 + const platform = platformAmount ?? 0 - case 'review': { - const reviewSplit = calculateReviewSplit(totalAmount) - return reviewSplit.reviewer === (authorAmount ?? 0) && reviewSplit.platform === (platformAmount ?? 0) - } - - case 'sponsoring': { - const sponsoringSplit = calculateSponsoringSplit(totalAmount) - return sponsoringSplit.authorSats === (authorAmount ?? 0) && sponsoringSplit.platformSats === (platformAmount ?? 0) - } - - default: + if (type === 'article') { + const articleSplit = calculateArticleSplit(totalAmount) + if (articleSplit.author !== author) { return false + } + return articleSplit.platform === platform } + + if (type === 'review') { + const reviewSplit = calculateReviewSplit(totalAmount) + if (reviewSplit.reviewer !== author) { + return false + } + return reviewSplit.platform === platform + } + + const sponsoringSplit = calculateSponsoringSplit(totalAmount) + if (sponsoringSplit.authorSats !== author) { + return false + } + return sponsoringSplit.platformSats === platform } diff --git a/pages/api/nip95-upload.ts b/pages/api/nip95-upload.ts index 47991c6..e88077a 100644 --- a/pages/api/nip95-upload.ts +++ b/pages/api/nip95-upload.ts @@ -364,9 +364,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const errorText = titleMatch?.[1] ?? h1Match?.[1] ?? 'HTML error page returned' // Check if it's a 404 or other error page - const is404 = response.body.includes('404') || response.body.includes('Not Found') || titleMatch?.[1]?.includes('404') - const is403 = response.body.includes('403') || response.body.includes('Forbidden') || titleMatch?.[1]?.includes('403') - const is500 = response.body.includes('500') || response.body.includes('Internal Server Error') || titleMatch?.[1]?.includes('500') + const is404 = response.body.includes('404') || response.body.includes('Not Found') || titleMatch?.[1]?.includes('404') === true + const is403 = response.body.includes('403') || response.body.includes('Forbidden') || titleMatch?.[1]?.includes('403') === true + const is500 = response.body.includes('500') || response.body.includes('Internal Server Error') || titleMatch?.[1]?.includes('500') === true console.error('NIP-95 proxy HTML response error:', { targetEndpoint, @@ -378,13 +378,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) is500, bodyPreview: response.body.substring(0, 500), contentType: 'HTML (expected JSON)', - suggestion: is404 - ? 'The endpoint URL may be incorrect or the endpoint does not exist' - : is403 - ? 'The endpoint may require authentication or have access restrictions' - : is500 - ? 'The endpoint server encountered an error' - : 'The endpoint may not be a valid NIP-95 upload endpoint or may require specific headers', + suggestion: buildHtmlErrorSuggestion({ is404, is403, is500 }), }) let userMessage = `Endpoint returned an HTML error page instead of JSON` @@ -427,6 +421,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } +function buildHtmlErrorSuggestion(params: { is404: boolean; is403: boolean; is500: boolean }): string { + if (params.is404) { + return 'The endpoint URL may be incorrect or the endpoint does not exist' + } + if (params.is403) { + return 'The endpoint may require authentication or have access restrictions' + } + if (params.is500) { + return 'The endpoint server encountered an error' + } + return 'The endpoint may not be a valid NIP-95 upload endpoint or may require specific headers' +} + function getErrnoCode(error: unknown): string | undefined { if (typeof error !== 'object' || error === null) { return undefined diff --git a/pages/author/[pubkey].tsx b/pages/author/[pubkey].tsx index 80e9a8c..baef304 100644 --- a/pages/author/[pubkey].tsx +++ b/pages/author/[pubkey].tsx @@ -1,6 +1,6 @@ import { useRouter } from 'next/router' import Head from 'next/head' -import { useEffect, useState } from 'react' +import { useEffect, useState, useCallback } from 'react' import { fetchAuthorByHashId } from '@/lib/authorQueries' import { getSeriesByAuthor } from '@/lib/seriesQueries' import { getAuthorSponsoring } from '@/lib/sponsoring' @@ -194,7 +194,7 @@ function useAuthorData(hashIdOrPubkey: string): { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const reload = async (): Promise => { + const reload = useCallback(async (): Promise => { if (!hashIdOrPubkey) { return } @@ -212,7 +212,7 @@ function useAuthorData(hashIdOrPubkey: string): { } finally { setLoading(false) } - } + }, [hashIdOrPubkey]) useEffect(() => { void reload() diff --git a/pages/index.tsx b/pages/index.tsx index 45f3e4a..9257904 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -8,17 +8,15 @@ import type { ArticleFilters } from '@/components/ArticleFilters' import { HomeView } from '@/components/HomeView' function usePresentationArticles(allArticles: Article[]): Map { - const [presentationArticles, setPresentationArticles] = useState>(new Map()) - useEffect(() => { + return useMemo(() => { const presentations = new Map() allArticles.forEach((article) => { if (article.isPresentation && article.pubkey) { presentations.set(article.pubkey, article) } }) - setPresentationArticles(presentations) + return presentations }, [allArticles]) - return presentationArticles } function useHomeState(): { @@ -117,7 +115,7 @@ function useHomeController(): { unlockedArticles: Set handleUnlock: (article: Article) => Promise } { - const { } = useNostrAuth() + useNostrAuth() const { searchQuery, setSearchQuery, diff --git a/pages/publish.tsx b/pages/publish.tsx index 2b7a20d..09880f4 100644 --- a/pages/publish.tsx +++ b/pages/publish.tsx @@ -43,11 +43,11 @@ export default function PublishPage(): React.ReactElement { } useEffect(() => { - if (!pubkey) { - setSeriesOptions([]) - return - } const load = async (): Promise => { + if (!pubkey) { + setSeriesOptions([]) + return + } const items = await getSeriesByAuthor(pubkey) setSeriesOptions(items.map((s) => ({ id: s.id, title: s.title }))) } diff --git a/pages/purchase/[id].tsx b/pages/purchase/[id].tsx index 4fddc52..4b0428b 100644 --- a/pages/purchase/[id].tsx +++ b/pages/purchase/[id].tsx @@ -55,7 +55,7 @@ export default function PurchasePage(): React.ReactElement | null { {error &&

{error}

} {purchase && (
-

Paiement d'article

+

Paiement d'article

Montant : {purchase.amount} sats diff --git a/pages/review-tip/[id].tsx b/pages/review-tip/[id].tsx index 9bb24d2..5500d11 100644 --- a/pages/review-tip/[id].tsx +++ b/pages/review-tip/[id].tsx @@ -46,7 +46,7 @@ export default function ReviewTipPage(): React.ReactElement | null { return ( <> - Remerciement d'avis - zapwall.fr + Remerciement d'avis - zapwall.fr

@@ -55,7 +55,7 @@ export default function ReviewTipPage(): React.ReactElement | null { {error &&

{error}

} {reviewTip && (
-

Remerciement d'avis

+

Remerciement d'avis

Montant : {reviewTip.amount} sats