diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 86aedf3b..0acd4c48 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -39,6 +39,11 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHelp' +import { + PrimaryPageContext, + usePrimaryPage, + type PrimaryPageContextValue +} from '@/contexts/primary-page-context' import { normalizeUrl } from './lib/url' import modalManager from './services/modal-manager.service' import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article' @@ -73,14 +78,6 @@ const BottomNavigationBarLazy = lazy(() => import('@/components/BottomNavigation const TooManyRelaysAlertDialogLazy = lazy(() => import('@/components/TooManyRelaysAlertDialog')) const CreateWalletGuideToastLazy = lazy(() => import('@/components/CreateWalletGuideToast')) -type TPrimaryPageContext = { - navigate: (page: TPrimaryPageName, props?: object) => void - current: TPrimaryPageName | null - /** Props passed to the current primary page (e.g. `{ spell: 'discussions' }` for spells). */ - currentPageProps: object | undefined - display: boolean -} - type TStackItem = { index: number url: string @@ -197,7 +194,7 @@ function mergePrimaryPageEntry( return [...prev, { name: entry.name, element, props: entry.props }] } -export const PrimaryPageContext = createContext(undefined) +export { PrimaryPageContext, usePrimaryPage } const PrimaryNoteViewContext = createContext<{ setPrimaryNoteView: (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => void @@ -217,14 +214,6 @@ const NoteDrawerContext = createContext<{ drawerNoteId: string | null } | undefined>(undefined) -export function usePrimaryPage() { - const context = useContext(PrimaryPageContext) - if (!context) { - throw new Error('usePrimaryPage must be used within a PrimaryPageContext.Provider') - } - return context -} - export { useSecondaryPage } export function usePrimaryNoteView() { @@ -1563,7 +1552,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { window.history.go(-stackLength) } - const primaryPageContextValue: TPrimaryPageContext = { + const primaryPageContextValue: PrimaryPageContextValue = { navigate: navigatePrimaryPage, current: currentPrimaryPage, currentPageProps, diff --git a/src/components/BottomNavigationBar/DiscussionsButton.tsx b/src/components/BottomNavigationBar/DiscussionsButton.tsx index f2fb2fbc..575d6bfe 100644 --- a/src/components/BottomNavigationBar/DiscussionsButton.tsx +++ b/src/components/BottomNavigationBar/DiscussionsButton.tsx @@ -1,4 +1,4 @@ -import { usePrimaryPage } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' import { MessageCircle } from 'lucide-react' import BottomNavigationBarItem from './BottomNavigationBarItem' diff --git a/src/components/BottomNavigationBar/HomeButton.tsx b/src/components/BottomNavigationBar/HomeButton.tsx index cea74ff1..5b92bde1 100644 --- a/src/components/BottomNavigationBar/HomeButton.tsx +++ b/src/components/BottomNavigationBar/HomeButton.tsx @@ -1,5 +1,6 @@ import { cn } from '@/lib/utils' -import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { usePrimaryNoteView } from '@/PageManager' import { Star } from 'lucide-react' import BottomNavigationBarItem from './BottomNavigationBarItem' diff --git a/src/components/BottomNavigationBar/NotificationsButton.tsx b/src/components/BottomNavigationBar/NotificationsButton.tsx index 30007b77..2b349c63 100644 --- a/src/components/BottomNavigationBar/NotificationsButton.tsx +++ b/src/components/BottomNavigationBar/NotificationsButton.tsx @@ -1,4 +1,4 @@ -import { usePrimaryPage } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' import { useNostr } from '@/providers/NostrProvider' import { Bell } from 'lucide-react' import BottomNavigationBarItem from './BottomNavigationBarItem' diff --git a/src/components/BottomNavigationBar/RssButton.tsx b/src/components/BottomNavigationBar/RssButton.tsx index 2937ca69..f92ea7ac 100644 --- a/src/components/BottomNavigationBar/RssButton.tsx +++ b/src/components/BottomNavigationBar/RssButton.tsx @@ -1,4 +1,5 @@ -import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { usePrimaryNoteView } from '@/PageManager' import storage from '@/services/local-storage.service' import { Rss } from 'lucide-react' import BottomNavigationBarItem from './BottomNavigationBarItem' diff --git a/src/components/BottomNavigationBar/SearchButton.tsx b/src/components/BottomNavigationBar/SearchButton.tsx index 441abfdd..44e025b0 100644 --- a/src/components/BottomNavigationBar/SearchButton.tsx +++ b/src/components/BottomNavigationBar/SearchButton.tsx @@ -1,4 +1,5 @@ -import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { usePrimaryNoteView } from '@/PageManager' import { Search } from 'lucide-react' import BottomNavigationBarItem from './BottomNavigationBarItem' diff --git a/src/components/BottomNavigationBar/SpellsButton.tsx b/src/components/BottomNavigationBar/SpellsButton.tsx index 5d49a783..9eb24f32 100644 --- a/src/components/BottomNavigationBar/SpellsButton.tsx +++ b/src/components/BottomNavigationBar/SpellsButton.tsx @@ -1,4 +1,5 @@ -import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { usePrimaryNoteView } from '@/PageManager' import { Wand2 } from 'lucide-react' import BottomNavigationBarItem from './BottomNavigationBarItem' diff --git a/src/components/Explore/ExploreFavoriteRelays.tsx b/src/components/Explore/ExploreFavoriteRelays.tsx index 84dab479..f8bdd9ce 100644 --- a/src/components/Explore/ExploreFavoriteRelays.tsx +++ b/src/components/Explore/ExploreFavoriteRelays.tsx @@ -4,7 +4,8 @@ import { DEFAULT_FAVORITE_RELAYS } from '@/constants' import { useFetchRelayInfo } from '@/hooks' import { toRelay, toRelaySettings } from '@/lib/link' import { normalizeUrl, simplifyUrl } from '@/lib/url' -import { usePrimaryPage, useSecondaryPage, useSmartRelayNavigation } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { useSecondaryPage, useSmartRelayNavigation } from '@/PageManager' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { cn } from '@/lib/utils' import { Newspaper, Settings } from 'lucide-react' diff --git a/src/components/HelpAndAccountMenu.tsx b/src/components/HelpAndAccountMenu.tsx index 5727b1af..0944bd01 100644 --- a/src/components/HelpAndAccountMenu.tsx +++ b/src/components/HelpAndAccountMenu.tsx @@ -15,7 +15,7 @@ import { import { Skeleton } from '@/components/ui/skeleton' import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey' import { cn } from '@/lib/utils' -import { usePrimaryPage } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' import { useNostr } from '@/providers/NostrProvider' import { ArrowDownUp, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react' import { useMemo, useState, type ReactNode } from 'react' diff --git a/src/components/KeyboardShortcutsHelp/index.tsx b/src/components/KeyboardShortcutsHelp/index.tsx index 0f86bac3..fff7e79b 100644 --- a/src/components/KeyboardShortcutsHelp/index.tsx +++ b/src/components/KeyboardShortcutsHelp/index.tsx @@ -17,31 +17,15 @@ import { import { cn } from '@/lib/utils' import postEditorService from '@/services/post-editor.service' import { CircleHelp } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, - type ReactNode -} from 'react' + KeyboardShortcutsHelpContext, + useKeyboardShortcutsHelp +} from '@/contexts/keyboard-shortcuts-help-context' import { useTranslation } from 'react-i18next' import readmeMarkdown from '../../../README.md?raw' -type KeyboardShortcutsHelpContextValue = { - openHelp: () => void -} - -const KeyboardShortcutsHelpContext = createContext(null) - -export function useKeyboardShortcutsHelp(): KeyboardShortcutsHelpContextValue { - const ctx = useContext(KeyboardShortcutsHelpContext) - if (!ctx) { - throw new Error('useKeyboardShortcutsHelp must be used within KeyboardShortcutsHelpProvider') - } - return ctx -} +export { useKeyboardShortcutsHelp } from '@/contexts/keyboard-shortcuts-help-context' function Kbd({ children }: { children: ReactNode }) { return ( diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index f38ec15a..299d9459 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -10,8 +10,10 @@ import { import { shouldFilterEvent } from '@/lib/event-filtering' import { isRelayUrlStrictSupersetIdentityKey, + isSpellSubRequestsSameFiltersDifferentRelays, stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity' +import logger from '@/lib/logger' import { normalizeUrl } from '@/lib/url' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { isTouchDevice } from '@/lib/utils' @@ -86,6 +88,12 @@ const NoteList = forwardRef( * Re-subscribe when URLs change but **merge** new timeline batches into existing rows by event id instead of clearing. */ preserveTimelineOnSubRequestsChange = false, + /** + * With {@link preserveTimelineOnSubRequestsChange}: when relay URLs change but each subrequest’s canonical + * filter string is unchanged (e.g. profile Medien provisional stack → NIP-65 stack), keep visible rows and + * avoid a loading reset. + */ + mergeTimelineWhenSubRequestFiltersMatch = false, /** * Spells / one-shot feeds: when the initial fetch finishes with zero rows, show explicit empty copy * (see list footer). Does not end loading early — loading stays until EOSE, first events, or safety timeouts. @@ -101,10 +109,21 @@ const NoteList = forwardRef( * (except Following). Refresh re-fetches. */ oneShotFetch = false, + /** Override {@link client.fetchEvents} / query global timeout (default 14s). */ + oneShotGlobalTimeoutMs = 14_000, + /** Override post-EOSE settle delay before resolving (default 2s). */ + oneShotEoseTimeoutMs = 2_000, + /** + * When `false`, do not resolve shortly after the first event (lets every relay finish EOSE first). + * Use for wide multi-relay one-shot REQs so slow mirrors are not cut off. + */ + oneShotFirstRelayGraceMs, /** Max events kept after merging one-shot REQ batches (default 100). */ oneShotMergedCap, /** Initial visible rows and each “reveal more” step when scrolling cached events (default first {@link SHOW_COUNT}, then 2× per step). */ - revealBatchSize + revealBatchSize, + /** When set with {@link oneShotFetch}, logs fetch + filter diagnostics to the console (e.g. faux spells). */ + oneShotDebugLabel }: { subRequests: TFeedSubRequest[] showKinds: number[] @@ -122,6 +141,7 @@ const NoteList = forwardRef( extraShouldHideEvent?: (evt: Event) => boolean feedSubscriptionKey?: string preserveTimelineOnSubRequestsChange?: boolean + mergeTimelineWhenSubRequestFiltersMatch?: boolean /** When set (e.g. spells), use explicit empty-feed copy after load completes with no rows. */ spellFetchTimeoutMs?: number spellFeedInstrumentToken?: number @@ -129,6 +149,10 @@ const NoteList = forwardRef( oneShotFetch?: boolean oneShotMergedCap?: number revealBatchSize?: number + oneShotDebugLabel?: string + oneShotGlobalTimeoutMs?: number + oneShotEoseTimeoutMs?: number + oneShotFirstRelayGraceMs?: number | false }, ref ) => { @@ -456,6 +480,11 @@ const NoteList = forwardRef( useEffect(() => { const currentSubRequests = subRequestsRef.current if (!currentSubRequests.length) { + if (oneShotDebugLabel) { + logger.info(`[${oneShotDebugLabel}] no subRequests — skipping timeline fetch`, { + feedKey: timelineSubscriptionKey + }) + } setLoading(false) setEvents([]) // Return a no-op closer function to satisfy the cleanup function @@ -471,7 +500,9 @@ const NoteList = forwardRef( preserveTimelineOnSubRequestsChange && !userPulledRefresh && (prevSubKey === subRequestsKey || - isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey)) + isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) || + (mergeTimelineWhenSubRequestFiltersMatch && + isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey))) prevSubRequestsKeyForTimelineRef.current = subRequestsKey /** False after cleanup so stale timeline callbacks cannot overwrite state after switching feeds (e.g. Spells discussions → notifications). */ @@ -523,6 +554,11 @@ const NoteList = forwardRef( const invalidFilters = mappedSubRequests.filter(({ filter }) => !filter.kinds || filter.kinds.length === 0) if (invalidFilters.length > 0) { // Don't subscribe with invalid filters - this would return no events + if (oneShotDebugLabel) { + logger.warn(`[${oneShotDebugLabel}] abort: filter missing kinds`, { + subRequestsKey: timelineSubscriptionKey + }) + } setLoading(false) setEvents([]) // Return a no-op closer function to satisfy the cleanup function @@ -536,13 +572,16 @@ const NoteList = forwardRef( } setHasMore(false) try { + const firstRelayGraceResolved = + oneShotFirstRelayGraceMs === undefined + ? FIRST_RELAY_RESULT_GRACE_MS + : oneShotFirstRelayGraceMs const batches = await Promise.all( mappedSubRequests.map(({ urls, filter }) => client.fetchEvents(urls, filter, { - // Was `false`, which disabled feed grace and forced a wait for every relay EOSE (very slow). - firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS, - globalTimeout: 14_000, - eoseTimeout: 2_000, + firstRelayResultGraceMs: firstRelayGraceResolved, + globalTimeout: oneShotGlobalTimeoutMs, + eoseTimeout: oneShotEoseTimeoutMs, cache: true }) ) @@ -559,9 +598,34 @@ const NoteList = forwardRef( const merged = [...byId.values()] .sort((a, b) => b.created_at - a.created_at) .slice(0, cap) + if (oneShotDebugLabel) { + const f0 = mappedSubRequests[0]?.filter + const batchEventCounts = batches.map((b) => b.length) + const rawTotal = batchEventCounts.reduce((s, n) => s + n, 0) + logger.info(`[${oneShotDebugLabel}] one-shot fetch merged`, { + relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length), + batchEventCounts, + rawTotal, + dedupedCount: byId.size, + afterCap: merged.length, + cap, + filterAuthors: f0?.authors, + filterKinds: f0?.kinds, + filterLimit: f0?.limit, + ...(rawTotal === 0 + ? { + emptyHint: + 'All sub-batches returned 0 events: relays may not index these kinds for this author, the query may have timed out before slow relays EOSEd, or posts are kind 1 with links (this tab uses kinds 20/21/22/1222 only).' + } + : {}) + }) + } setEvents(merged) lastEventsForTimelinePrefetchRef.current = merged - } catch { + } catch (err) { + if (oneShotDebugLabel) { + logger.warn(`[${oneShotDebugLabel}] one-shot fetch threw`, err) + } if (effectActive) setEvents([]) } finally { if (effectActive) { @@ -574,8 +638,9 @@ const NoteList = forwardRef( } const totalRelayUrls = mappedSubRequests.reduce((n, r) => n + r.urls.length, 0) - // Explore-style feeds merge many read relays; subscribeTimeline awaits every ensureRelay — 5s often loses the race. - const subscribeSetupRaceMs = totalRelayUrls > 24 ? 30_000 : 5000 + // Wide REQ batches open many sockets; a short race rejects and drops the subscription before first paint. + const subscribeSetupRaceMs = + totalRelayUrls > 24 ? 30_000 : totalRelayUrls > 8 ? 15_000 : 5000 let closer: (() => void) | undefined let timelineKey: string | undefined @@ -734,6 +799,7 @@ const NoteList = forwardRef( timelineSubscriptionKey, subRequestsKey, preserveTimelineOnSubRequestsChange, + mergeTimelineWhenSubRequestFiltersMatch, refreshCount, showKindsKey, showKind1OPs, @@ -743,7 +809,44 @@ const NoteList = forwardRef( areAlgoRelays, oneShotFetch, oneShotMergedCap, - revealBatchSize + revealBatchSize, + oneShotDebugLabel, + oneShotGlobalTimeoutMs, + oneShotEoseTimeoutMs, + oneShotFirstRelayGraceMs + ]) + + const oneShotDebugPrevLoadingRef = useRef(false) + useEffect(() => { + if (!oneShotDebugLabel || !oneShotFetch) return + const wasLoading = oneShotDebugPrevLoadingRef.current + oneShotDebugPrevLoadingRef.current = loading + if (!wasLoading || loading) return + + const kind1s = events.filter((e) => e.kind === kinds.ShortTextNote) + const kind1HiddenByExtra = kind1s.filter((e) => extraShouldHideEvent?.(e) === true).length + const kindCounts: Record = {} + for (const e of events) { + kindCounts[e.kind] = (kindCounts[e.kind] ?? 0) + 1 + } + logger.info(`[${oneShotDebugLabel}] one-shot load settled (UI filters)`, { + timelineSubscriptionKey, + eventsInState: events.length, + filteredVisibleRows: filteredEvents.length, + showCount, + kindCounts, + kind1Count: kind1s.length, + kind1HiddenByExtraShouldHide: kind1HiddenByExtra + }) + }, [ + oneShotDebugLabel, + oneShotFetch, + loading, + events, + filteredEvents.length, + showCount, + timelineSubscriptionKey, + extraShouldHideEvent ]) useEffect(() => { diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 1b2b7291..e3e5f792 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -6,6 +6,7 @@ import { toAlexandria } from '@/lib/link' import logger from '@/lib/logger' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { normalizeUrl, simplifyUrl } from '@/lib/url' +import { buildPinListTagsAfterToggle, fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' import { generateBech32IdFromATag } from '@/lib/tag' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -13,7 +14,7 @@ import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import client from '@/services/client.service' -import { eventService, queryService } from '@/services/client.service' +import { eventService } from '@/services/client.service' import { nip66Service } from '@/services/nip66.service' import { Bell, @@ -40,7 +41,7 @@ import { useMemo, useState, useEffect, useContext } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import RelayIcon from '../RelayIcon' -import { PrimaryPageContext } from '@/PageManager' +import { PrimaryPageContext } from '@/contexts/primary-page-context' import { showPublishingFeedback } from '@/lib/publishing-feedback' import type { TEditOrCloneMode } from './EditOrCloneEventDialog' @@ -148,18 +149,20 @@ export function useMenuActions({ const comprehensiveRelays = Array.from(new Set(normalizedRelays)) - // Try to fetch pin list event from comprehensive relay list first - let pinListEvent = null - try { - const pinListEvents = await queryService.fetchEvents(comprehensiveRelays, { - authors: [pubkey], - kinds: [10001], // Pin list kind - limit: 1 - }) - pinListEvent = pinListEvents[0] || null - } catch (error) { - logger.component('PinStatus', 'Error fetching pin list from comprehensive relays, falling back to default method', { error: (error as Error).message }) - pinListEvent = await client.fetchPinListEvent(pubkey) + let pinListEvent: Event | null | undefined = await fetchLatestReplaceableListEvent( + pubkey, + 10001, + comprehensiveRelays + ) + if (!pinListEvent) { + try { + pinListEvent = (await client.fetchPinListEvent(pubkey)) ?? null + } catch (error) { + logger.component('PinStatus', 'Error fetching pin list fallback', { + error: (error as Error).message + }) + pinListEvent = null + } } if (pinListEvent) { @@ -192,46 +195,20 @@ export function useMenuActions({ const comprehensiveRelays = Array.from(new Set(normalizedRelays)) - // Try to fetch pin list event from comprehensive relay list first - let pinListEvent = null - try { - const pinListEvents = await queryService.fetchEvents(comprehensiveRelays, { - authors: [pubkey], - kinds: [10001], // Pin list kind - limit: 1 - }) - pinListEvent = pinListEvents[0] || null - } catch (error) { - logger.component('PinNote', 'Error fetching pin list from comprehensive relays, falling back to default method', { error: (error as Error).message }) - pinListEvent = await client.fetchPinListEvent(pubkey) - } - - logger.component('PinNote', 'Current pin list event', { hasEvent: !!pinListEvent }) - - // Get existing event IDs, excluding the one we're toggling - const existingEventIds = (pinListEvent?.tags || []) - .filter(tag => tag[0] === 'e' && tag[1]) - .map(tag => tag[1]) - .filter(id => id !== event.id) - - logger.component('PinNote', 'Existing event IDs (excluding current)', { count: existingEventIds.length }) - logger.component('PinNote', 'Current event ID', { eventId: event.id }) - logger.component('PinNote', 'Is currently pinned', { isPinned }) - - let newTags: string[][] - let successMessage: string - - if (isPinned) { - // Unpin: just keep the existing tags without this event - newTags = existingEventIds.map(id => ['e', id]) - successMessage = t('Note unpinned') - logger.component('PinNote', 'Unpinning - new tags', { count: newTags.length }) - } else { - // Pin: add this event to the existing list - newTags = [...existingEventIds.map(id => ['e', id]), ['e', event.id]] - successMessage = t('Note pinned') - logger.component('PinNote', 'Pinning - new tags', { count: newTags.length }) + let latestPinList = await fetchLatestReplaceableListEvent(pubkey, 10001, comprehensiveRelays) + if (!latestPinList) { + try { + latestPinList = (await client.fetchPinListEvent(pubkey)) ?? undefined + } catch (error) { + logger.component('PinNote', 'Pin list fallback fetch failed', { error: (error as Error).message }) + } } + + logger.component('PinNote', 'Current pin list event', { hasEvent: !!latestPinList }) + + const newTags = buildPinListTagsAfterToggle(latestPinList ?? null, event.id, !isPinned) + const successMessage = isPinned ? t('Note unpinned') : t('Note pinned') + logger.component('PinNote', 'Pin list tag count after merge', { count: newTags.length }) // Create and publish the new pin list event logger.component('PinNote', 'Publishing new pin list event', { tagCount: newTags.length, relayCount: comprehensiveRelays.length }) diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx index 8eea4270..e1dc54a9 100644 --- a/src/components/Profile/ProfileFeedWithPins.tsx +++ b/src/components/Profile/ProfileFeedWithPins.tsx @@ -11,6 +11,7 @@ import { useKindFilter } from '@/providers/KindFilterProvider' import { useZap } from '@/providers/ZapProvider' import client from '@/services/client.service' import storage from '@/services/local-storage.service' +import { RefreshCw } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -211,7 +212,14 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string /> {isRefreshing && ( -
🔄 {t('Refreshing posts...')}
+
+ + {t('Refreshing posts...')} +
)} {searchQuery.trim() && (
diff --git a/src/components/Profile/ProfileMediaFeed.tsx b/src/components/Profile/ProfileMediaFeed.tsx index a61a8094..5ce19d80 100644 --- a/src/components/Profile/ProfileMediaFeed.tsx +++ b/src/components/Profile/ProfileMediaFeed.tsx @@ -1,15 +1,12 @@ import NoteList, { type TNoteListRef } from '@/components/NoteList' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' +import logger from '@/lib/logger' +import { normalizeHexPubkey } from '@/lib/pubkey' import { computeSpellSubRequestsIdentityKey } from '@/lib/spell-feed-request-identity' -import { - buildProfileMediaSubRequests, - MEDIA_SPELL_KINDS, - PROFILE_MEDIA_REQ_LIMIT -} from '@/pages/primary/SpellsPage/fauxSpellFeeds' +import { buildProfileMediaSubRequests, PROFILE_MEDIA_TAB_KINDS } from '@/pages/primary/SpellsPage/fauxSpellFeeds' import { normalizeUrl } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import client from '@/services/client.service' -import { NoteCardLoadingSkeleton } from '@/components/NoteCard' import { forwardRef, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -19,6 +16,8 @@ function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]) return `${fav}\u0000${blk}` } +const MEDIA_LOG = '[ProfileMedia]' + const ProfileMediaFeed = forwardRef(({ pubkey }, ref) => { const { t } = useTranslation() const { favoriteRelays, blockedRelays } = useFavoriteRelays() @@ -27,35 +26,64 @@ const ProfileMediaFeed = forwardRef(({ pubkey [favoriteRelays, blockedRelays] ) - /** `null` = still resolving viewed profile NIP-65 + merged relay stack (same as pins / main profile feed). */ - const [profileRelayUrls, setProfileRelayUrls] = useState(null) + /** + * Start REQ immediately with the same stack as “no NIP-65 yet” (favorites + fast-read), then refine when + * {@link client.fetchRelayList} returns — avoids an empty/skeleton Medien tab while Posts already shows cache. + */ + const provisionalProfileRelayUrls = useMemo(() => { + if (!pubkey?.trim()) return [] as string[] + return buildProfilePageReadRelayUrls( + favoriteRelays, + blockedRelays, + { read: [] as string[], write: [] as string[] }, + false + ) + }, [pubkey, relayListsKey, favoriteRelays, blockedRelays]) + + const [refinedProfileRelayUrls, setRefinedProfileRelayUrls] = useState(null) useEffect(() => { const pk = pubkey?.trim() if (!pk) { - setProfileRelayUrls([]) + logger.debug(`${MEDIA_LOG} empty pubkey — no relay resolution`) + setRefinedProfileRelayUrls([]) return } let cancelled = false - setProfileRelayUrls(null) + setRefinedProfileRelayUrls(null) void (async () => { const authorRl = await client.fetchRelayList(pk).catch(() => ({ read: [] as string[], write: [] as string[] })) if (cancelled) return - setProfileRelayUrls( - buildProfilePageReadRelayUrls(favoriteRelays, blockedRelays, authorRl, false) + const profileStack = buildProfilePageReadRelayUrls( + favoriteRelays, + blockedRelays, + authorRl, + false ) + const hexPk = normalizeHexPubkey(pk) + logger.debug(`${MEDIA_LOG} NIP-65 stack resolved for media tab`, { + pubkey: hexPk.slice(0, 8), + authorReadCount: authorRl.read?.length ?? 0, + authorWriteCount: authorRl.write?.length ?? 0, + profileRelayCount: profileStack.length, + profileRelaysSample: profileStack.slice(0, 4) + }) + logger.debug(`${MEDIA_LOG} full profile relay stack`, { profileRelays: profileStack }) + setRefinedProfileRelayUrls(profileStack) })() return () => { cancelled = true } }, [pubkey, relayListsKey, favoriteRelays, blockedRelays]) + const profileRelayUrls = refinedProfileRelayUrls ?? provisionalProfileRelayUrls + const subRequests = useMemo(() => { const pk = pubkey?.trim() - if (!pk || profileRelayUrls === null) return [] + if (!pk) return [] return buildProfileMediaSubRequests(profileRelayUrls, blockedRelays, pk) }, [pubkey, profileRelayUrls, blockedRelays]) @@ -64,7 +92,29 @@ const ProfileMediaFeed = forwardRef(({ pubkey [subRequests] ) - const showKinds = useMemo(() => [...MEDIA_SPELL_KINDS], []) + useEffect(() => { + const pk = pubkey?.trim() + if (!pk) return + if (!subRequests.length) { + logger.debug(`${MEDIA_LOG} buildProfileMediaSubRequests returned no URLs (blocked or empty stacks)`, { + pubkey: normalizeHexPubkey(pk).slice(0, 8), + profileRelayCount: profileRelayUrls.length + }) + return + } + const sr = subRequests[0]! + logger.debug(`${MEDIA_LOG} subRequests ready for NoteList`, { + pubkey: normalizeHexPubkey(pk).slice(0, 8), + feedSubscriptionKey, + relayCount: sr.urls.length, + filterAuthors: sr.filter.authors, + filterKinds: sr.filter.kinds, + filterLimit: sr.filter.limit + }) + logger.debug(`${MEDIA_LOG} augmented relay URLs`, { urls: sr.urls }) + }, [pubkey, profileRelayUrls, subRequests, feedSubscriptionKey, refinedProfileRelayUrls]) + + const showKinds = useMemo(() => [...PROFILE_MEDIA_TAB_KINDS], []) if (!pubkey?.trim()) { return ( @@ -74,21 +124,6 @@ const ProfileMediaFeed = forwardRef(({ pubkey ) } - if (profileRelayUrls === null) { - return ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- ) - } - if (!subRequests.length) { return (
@@ -105,9 +140,15 @@ const ProfileMediaFeed = forwardRef(({ pubkey feedSubscriptionKey={feedSubscriptionKey} showKinds={showKinds} useFilterAsIs - oneShotFetch - oneShotMergedCap={PROFILE_MEDIA_REQ_LIMIT} + /** + * Provisional relay stack (favorites + fast read) then NIP-65 refinement changes URLs without changing the + * REQ filter — merge so we do not wipe rows or re-enter a long loading state. + */ + preserveTimelineOnSubRequestsChange + mergeTimelineWhenSubRequestFiltersMatch + /** Same live {@link client.subscribeTimeline} path as {@link useProfileTimeline} on the Posts tab; filter is native media kinds only. */ revealBatchSize={20} + filterMutedNotes={false} showKind1OPs showKind1Replies showKind1111 diff --git a/src/components/Profile/ProfileTimeline.tsx b/src/components/Profile/ProfileTimeline.tsx index 1b9c8836..ae036b87 100644 --- a/src/components/Profile/ProfileTimeline.tsx +++ b/src/components/Profile/ProfileTimeline.tsx @@ -1,5 +1,6 @@ import NoteCard from '@/components/NoteCard' import { CALENDAR_EVENT_KINDS } from '@/constants' +import { RefreshCw } from 'lucide-react' import { Skeleton } from '@/components/ui/skeleton' import { Event } from 'nostr-tools' import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useRef } from 'react' @@ -172,7 +173,14 @@ const ProfileTimeline = forwardRef< return (
{isRefreshing && ( -
🔄 {refreshLabel}
+
+ + {refreshLabel} +
)} {(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && (
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 75f3b91a..4932758e 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -16,7 +16,8 @@ import { kinds, type NostrEvent } from 'nostr-tools' import { getPaymentInfoFromEvent } from '@/lib/event-metadata' import { toProfileEditor } from '@/lib/link' import { generateImageByPubkey } from '@/lib/pubkey' -import { usePrimaryPage, useSecondaryPage } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' import { replaceableEventService } from '@/services/client.service' diff --git a/src/components/RelayList/index.tsx b/src/components/RelayList/index.tsx index 731fa1c9..5f35a5fc 100644 --- a/src/components/RelayList/index.tsx +++ b/src/components/RelayList/index.tsx @@ -1,4 +1,4 @@ -import { usePrimaryPage } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' import relayInfoService from '@/services/relay-info.service' import { TRelayInfo } from '@/types' import { useEffect, useRef, useState } from 'react' diff --git a/src/components/Sidebar/DiscussionsButton.tsx b/src/components/Sidebar/DiscussionsButton.tsx index 8f75d237..db4d75df 100644 --- a/src/components/Sidebar/DiscussionsButton.tsx +++ b/src/components/Sidebar/DiscussionsButton.tsx @@ -1,4 +1,5 @@ -import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { usePrimaryNoteView } from '@/PageManager' import { MessageCircle } from 'lucide-react' import { useTranslation } from 'react-i18next' import SidebarItem from './SidebarItem' diff --git a/src/components/Sidebar/FeedButton.tsx b/src/components/Sidebar/FeedButton.tsx index e0ee5208..149b659b 100644 --- a/src/components/Sidebar/FeedButton.tsx +++ b/src/components/Sidebar/FeedButton.tsx @@ -1,4 +1,5 @@ -import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { usePrimaryNoteView } from '@/PageManager' import { Compass } from 'lucide-react' import { useTranslation } from 'react-i18next' import SidebarItem from './SidebarItem' diff --git a/src/components/Sidebar/HomeButton.tsx b/src/components/Sidebar/HomeButton.tsx index 54ec7bc4..d358ee5c 100644 --- a/src/components/Sidebar/HomeButton.tsx +++ b/src/components/Sidebar/HomeButton.tsx @@ -1,5 +1,6 @@ import { cn } from '@/lib/utils' -import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { usePrimaryNoteView } from '@/PageManager' import { Star } from 'lucide-react' import { useTranslation } from 'react-i18next' import SidebarItem from './SidebarItem' diff --git a/src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx b/src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx index 05b69775..b96dfe81 100644 --- a/src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx +++ b/src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx @@ -1,4 +1,4 @@ -import { useKeyboardShortcutsHelp } from '@/components/KeyboardShortcutsHelp' +import { useKeyboardShortcutsHelp } from '@/contexts/keyboard-shortcuts-help-context' import { CircleHelp } from 'lucide-react' import SidebarItem from './SidebarItem' diff --git a/src/components/Sidebar/NotificationButton.tsx b/src/components/Sidebar/NotificationButton.tsx index 254e9480..8edf0167 100644 --- a/src/components/Sidebar/NotificationButton.tsx +++ b/src/components/Sidebar/NotificationButton.tsx @@ -1,4 +1,5 @@ -import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { usePrimaryNoteView } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import { Bell } from 'lucide-react' import SidebarItem from './SidebarItem' diff --git a/src/components/Sidebar/RssButton.tsx b/src/components/Sidebar/RssButton.tsx index 4ba95ebc..73a38706 100644 --- a/src/components/Sidebar/RssButton.tsx +++ b/src/components/Sidebar/RssButton.tsx @@ -1,4 +1,5 @@ -import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { usePrimaryNoteView } from '@/PageManager' import { Rss } from 'lucide-react' import SidebarItem from './SidebarItem' import storage from '@/services/local-storage.service' diff --git a/src/components/Sidebar/SearchButton.tsx b/src/components/Sidebar/SearchButton.tsx index f1bdcdcb..ef96718e 100644 --- a/src/components/Sidebar/SearchButton.tsx +++ b/src/components/Sidebar/SearchButton.tsx @@ -1,4 +1,5 @@ -import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { usePrimaryNoteView } from '@/PageManager' import { Search } from 'lucide-react' import SidebarItem from './SidebarItem' diff --git a/src/components/Sidebar/SpellsButton.tsx b/src/components/Sidebar/SpellsButton.tsx index 1ad174b2..1c0c0122 100644 --- a/src/components/Sidebar/SpellsButton.tsx +++ b/src/components/Sidebar/SpellsButton.tsx @@ -1,4 +1,5 @@ -import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { usePrimaryNoteView } from '@/PageManager' import { Wand2 } from 'lucide-react' import SidebarItem from './SidebarItem' diff --git a/src/contexts/keyboard-shortcuts-help-context.tsx b/src/contexts/keyboard-shortcuts-help-context.tsx new file mode 100644 index 00000000..fc6c83ea --- /dev/null +++ b/src/contexts/keyboard-shortcuts-help-context.tsx @@ -0,0 +1,22 @@ +import { createContext, useContext } from 'react' + +/** + * Dedicated module so lazy chunks (e.g. Sidebar) share the same context as PageManager's + * KeyboardShortcutsHelpProvider. Importing the hook from the heavy KeyboardShortcutsHelp barrel + * in a separate chunk can duplicate the module and break Provider matching. + */ +export type KeyboardShortcutsHelpContextValue = { + openHelp: () => void +} + +export const KeyboardShortcutsHelpContext = createContext( + null +) + +export function useKeyboardShortcutsHelp(): KeyboardShortcutsHelpContextValue { + const ctx = useContext(KeyboardShortcutsHelpContext) + if (!ctx) { + throw new Error('useKeyboardShortcutsHelp must be used within KeyboardShortcutsHelpProvider') + } + return ctx +} diff --git a/src/contexts/primary-page-context.tsx b/src/contexts/primary-page-context.tsx new file mode 100644 index 00000000..aa01a516 --- /dev/null +++ b/src/contexts/primary-page-context.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext } from 'react' +import type { TPrimaryPageName } from '@/PageManager' + +/** + * Lives in a dedicated module so lazy chunks (e.g. Sidebar) share the same context instance as + * PageManager. Importing `usePrimaryPage` from PageManager into those chunks can duplicate the + * module and break Provider matching ("must be used within PrimaryPageContext.Provider"). + * Use `import type` only so this file does not create a runtime dependency on PageManager. + */ +export type PrimaryPageContextValue = { + navigate: (page: TPrimaryPageName, props?: object) => void + current: TPrimaryPageName | null + /** Props passed to the current primary page (e.g. `{ spell: 'discussions' }` for spells). */ + currentPageProps: object | undefined + display: boolean +} + +export const PrimaryPageContext = createContext(undefined) + +export function usePrimaryPage(): PrimaryPageContextValue { + const context = useContext(PrimaryPageContext) + if (!context) { + throw new Error('usePrimaryPage must be used within a PrimaryPageContext.Provider') + } + return context +} diff --git a/src/hooks/useProfilePins.tsx b/src/hooks/useProfilePins.tsx index c9ab0f22..2b71b40c 100644 --- a/src/hooks/useProfilePins.tsx +++ b/src/hooks/useProfilePins.tsx @@ -1,14 +1,18 @@ import { Event } from 'nostr-tools' import { + buildProfileAugmentedReadRelayUrls, buildProfilePageReadRelayUrls, PROFILE_PAGE_PINS_RESOLVE_LIMIT } from '@/lib/favorites-feed-relays' -import logger from '@/lib/logger' +import { + METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, + METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS +} from '@/constants' +import { normalizeHexPubkey } from '@/lib/pubkey' import { normalizeUrl } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import client from '@/services/client.service' -import { queryService } from '@/services/client.service' -import { useCallback, useEffect, useMemo, useState } from 'react' +import client, { eventService, queryService } from '@/services/client.service' +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' const CACHE_DURATION = 5 * 60 * 1000 @@ -25,33 +29,41 @@ function orderPinEvents(pinList: Event, eventsById: Map): Event[] const eIds = pinList.tags .filter((tag) => tag[0] === 'e' && tag[1]) - .map((tag) => tag[1]) + .map((tag) => tag[1]!.toLowerCase()) .reverse() for (const id of eIds) { const ev = eventsById.get(id) - if (ev && !seen.has(ev.id)) { - ordered.push(ev) - seen.add(ev.id) + if (ev) { + const k = ev.id.toLowerCase() + if (!seen.has(k)) { + ordered.push(ev) + seen.add(k) + } } } - const aTags = pinList.tags.filter((tag) => tag[0] === 'a' && tag[1]).map((tag) => tag[1]) + const aTags = pinList.tags.filter((tag) => tag[0] === 'a' && tag[1]).map((tag) => tag[1]!) for (const coord of aTags) { + const want = coord.toLowerCase() const ev = [...eventsById.values()].find((e) => { const d = e.tags.find((t) => t[0] === 'd')?.[1] ?? '' - return `${e.kind}:${e.pubkey}:${d}` === coord + return `${e.kind}:${e.pubkey}:${d}`.toLowerCase() === want }) - if (ev && !seen.has(ev.id)) { - ordered.push(ev) - seen.add(ev.id) + if (ev) { + const k = ev.id.toLowerCase() + if (!seen.has(k)) { + ordered.push(ev) + seen.add(k) + } } } for (const ev of eventsById.values()) { - if (!seen.has(ev.id)) { + const k = ev.id.toLowerCase() + if (!seen.has(k)) { ordered.push(ev) - seen.add(ev.id) + seen.add(k) } } @@ -73,6 +85,26 @@ export function useProfilePins(pubkey: string | undefined) { const [pinEvents, setPinEvents] = useState([]) const [loadingPins, setLoadingPins] = useState(false) + /** Same-tab paint: show cached pins before async relay work (matches timeline showing memory cache). */ + useLayoutEffect(() => { + if (!pubkey) { + setPinEvents([]) + return + } + const cacheKey = `${pubkey}-pins-profile` + const cached = pinsCache.get(cacheKey) + if ( + cached && + cached.events.length > 0 && + Date.now() - cached.lastUpdated < CACHE_DURATION + ) { + setPinEvents(cached.events) + cached.events.forEach((e) => client.addEventToCache(e)) + } else { + setPinEvents([]) + } + }, [pubkey]) + const loadPins = useCallback( async (forceRefresh = false) => { if (!pubkey) { @@ -82,7 +114,13 @@ export function useProfilePins(pubkey: string | undefined) { const cacheKey = `${pubkey}-pins-profile` if (!forceRefresh) { const cached = pinsCache.get(cacheKey) - if (cached && Date.now() - cached.lastUpdated < CACHE_DURATION) { + // Only reuse cache for non-empty pin rows. Empty was previously cached on transient relay + // failures / races, which hid pins for CACHE_DURATION with no refetch. + if ( + cached && + cached.events.length > 0 && + Date.now() - cached.lastUpdated < CACHE_DURATION + ) { setPinEvents(cached.events) cached.events.forEach((e) => client.addEventToCache(e)) return @@ -91,10 +129,14 @@ export function useProfilePins(pubkey: string | undefined) { setLoadingPins(true) try { - const authorRl = await client.fetchRelayList(pubkey).catch(() => ({ - read: [] as string[], - write: [] as string[] - })) + const pk = normalizeHexPubkey(pubkey) + const [authorRl, pinListEarly] = await Promise.all([ + client.fetchRelayList(pk).catch(() => ({ + read: [] as string[], + write: [] as string[] + })), + client.fetchPinListEvent(pk).catch(() => undefined) + ]) // Same stack as profile feed: viewed npub NIP-65 read+write → your favorites → FAST_READ_RELAY_URLS, // deduped, blocked stripped, max PROFILE_PAGE_FEED_MAX_RELAYS (6). Relays here accept `#d` on REQ. const profileRelays = buildProfilePageReadRelayUrls( @@ -103,22 +145,41 @@ export function useProfilePins(pubkey: string | undefined) { authorRl, false ) - if (!profileRelays.length) { + const pinsResolveRelays = buildProfileAugmentedReadRelayUrls(profileRelays, blockedRelays) + if (!pinsResolveRelays.length) { setPinEvents([]) - pinsCache.set(cacheKey, { events: [], lastUpdated: Date.now() }) return } - const pinListEvents = await queryService.fetchEvents(profileRelays, { - authors: [pubkey], - kinds: [10001], - limit: 1 - }) - const pinList: Event | null = pinListEvents[0] || null + let pinList: Event | null = pinListEarly ?? null + + if (!pinList) { + try { + const rows = await queryService.fetchEvents( + pinsResolveRelays, + { authors: [pk], kinds: [10001], limit: 1 }, + { + replaceableRace: true, + eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, + globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS + } + ) + pinList = + rows.length > 0 + ? rows.reduce((best, e) => (e.created_at > best.created_at ? e : best)) + : null + } catch { + pinList = null + } + } - if (!pinList?.tags?.length) { + if (!pinList) { + setPinEvents([]) + return + } + + if (!pinList.tags?.length) { setPinEvents([]) - pinsCache.set(cacheKey, { events: [], lastUpdated: Date.now() }) return } @@ -127,16 +188,30 @@ export function useProfilePins(pubkey: string | undefined) { const aTags: string[] = [] for (const tag of pinList.tags) { if (eventIds.length + aTags.length >= max) break - if (tag[0] === 'e' && tag[1]) eventIds.push(tag[1]) + if (tag[0] === 'e' && tag[1]) eventIds.push(tag[1].toLowerCase()) else if (tag[0] === 'a' && tag[1]) aTags.push(tag[1]) } - const eventPromises: Promise[] = [] + const byId = new Map() if (eventIds.length > 0) { - eventPromises.push( - queryService.fetchEvents(profileRelays, { ids: eventIds, limit: max }) - ) + const sessionHits = await Promise.all(eventIds.map((id) => eventService.fetchEvent(id))) + for (let i = 0; i < eventIds.length; i++) { + const ev = sessionHits[i] + if (ev) byId.set(ev.id.toLowerCase(), ev) + } + const missing = eventIds.filter((id) => !byId.has(id)) + if (missing.length > 0) { + const rows = await queryService.fetchEvents(pinsResolveRelays, { + ids: missing, + limit: max + }) + for (const e of rows) { + byId.set(e.id.toLowerCase(), e) + } + } } + + const eventPromises: Promise[] = [] if (aTags.length > 0) { const aTagFetches = aTags.map(async (aTagRaw) => { const parts = aTagRaw.trim().split(':') @@ -148,7 +223,7 @@ export function useProfilePins(pubkey: string | undefined) { const filter = d ? { authors: [author], kinds: [kind], limit: 1, '#d': [d] as [string] } : { authors: [author], kinds: [kind], limit: 1 } - const events = await queryService.fetchEvents(profileRelays, filter) + const events = await queryService.fetchEvents(pinsResolveRelays, filter) return events[0] ?? null }) eventPromises.push( @@ -159,17 +234,16 @@ export function useProfilePins(pubkey: string | undefined) { const eventArrays = await Promise.all(eventPromises) const flat = eventArrays.flat() flat.forEach((e) => client.addEventToCache(e)) - - const byId = new Map() for (const e of flat) { - byId.set(e.id, e) + byId.set(e.id.toLowerCase(), e) } const ordered = orderPinEvents(pinList, byId).slice(0, PROFILE_PAGE_PINS_RESOLVE_LIMIT) setPinEvents(ordered) - pinsCache.set(cacheKey, { events: ordered, lastUpdated: Date.now() }) - } catch (e) { - logger.warn('[useProfilePins] Failed to load pins', e) + if (ordered.length > 0) { + pinsCache.set(cacheKey, { events: ordered, lastUpdated: Date.now() }) + } + } catch { setPinEvents([]) } finally { setLoadingPins(false) diff --git a/src/layouts/PrimaryPageLayout/index.tsx b/src/layouts/PrimaryPageLayout/index.tsx index 093f35e1..80c3ea6f 100644 --- a/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/layouts/PrimaryPageLayout/index.tsx @@ -1,6 +1,7 @@ import ScrollToTopButton from '@/components/ScrollToTopButton' import { Titlebar } from '@/components/Titlebar' -import { TPrimaryPageName, usePrimaryPage } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import type { TPrimaryPageName } from '@/PageManager' import { DeepBrowsingProvider } from '@/providers/DeepBrowsingProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { diff --git a/src/lib/account-list-relay-urls.ts b/src/lib/account-list-relay-urls.ts new file mode 100644 index 00000000..da2f037b --- /dev/null +++ b/src/lib/account-list-relay-urls.ts @@ -0,0 +1,35 @@ +import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' +import { buildPrioritizedReadRelayUrls, buildPrioritizedWriteRelayUrls } from '@/lib/relay-url-priority' +import { normalizeUrl } from '@/lib/url' +import client from '@/services/client.service' + +/** + * Read + write relay stack for merging replaceable list events (pins, bookmarks, follows, …) + * before publishing an update — same idea as {@link BookmarksProvider}'s comprehensive list. + */ +export async function buildAccountListRelayUrlsForMerge(options: { + accountPubkey: string + favoriteRelays: string[] + blockedRelays: string[] +}): Promise { + const { accountPubkey, favoriteRelays, blockedRelays } = options + const myRelayList = await client.fetchRelayList(accountPubkey) + const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays) + const read = buildPrioritizedReadRelayUrls({ + userReadRelays: myRelayList.read ?? [], + userWriteRelays: myRelayList.write ?? [], + favoriteRelays: favoritesTier, + blockedRelays, + maxRelays: 100, + applyKind1BlockedFilter: false + }) + const write = buildPrioritizedWriteRelayUrls({ + userWriteRelays: myRelayList.write ?? [], + favoriteRelays: favoritesTier, + blockedRelays, + maxRelays: 100, + applyKind1BlockedFilter: false + }) + const merged = [...read, ...write] + return [...new Set(merged.map((u) => normalizeUrl(u) || u).filter(Boolean))] +} diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index 9b9d1d96..7d0173f0 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -1,4 +1,4 @@ -import { DEFAULT_FAVORITE_RELAYS } from '@/constants' +import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS, READ_ONLY_RELAY_URLS } from '@/constants' import type { TFeedSubRequest } from '@/types' import { normalizeUrl } from '@/lib/url' import type { Filter } from 'nostr-tools' @@ -67,6 +67,23 @@ export function mergeRelayUrlLayers(layers: string[][], blockedRelays: string[]) return out } +/** + * Profile pins + media: prepend {@link READ_ONLY_RELAY_URLS} and {@link FAST_READ_RELAY_URLS} to the + * capped NIP-65 stack so REQ still hits aggregators when the author’s six relays fill the profile cap alone. + */ +export const PROFILE_AUGMENTED_READ_MAX_RELAYS = 16 + +export function buildProfileAugmentedReadRelayUrls( + profileRelayUrls: string[], + blockedRelays: string[], + maxRelays: number = PROFILE_AUGMENTED_READ_MAX_RELAYS +): string[] { + const readOnlyLayer = READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) + const fastReadLayer = FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) + const merged = mergeRelayUrlLayers([readOnlyLayer, fastReadLayer, profileRelayUrls], blockedRelays) + return merged.slice(0, maxRelays) +} + export type ReadRelayPriorityOptions = { /** User NIP-65 write list — local URLs are promoted with inboxes for REQ. */ userWriteRelays?: string[] diff --git a/src/lib/relay-list-sanitize.ts b/src/lib/relay-list-sanitize.ts new file mode 100644 index 00000000..7484ab2d --- /dev/null +++ b/src/lib/relay-list-sanitize.ts @@ -0,0 +1,19 @@ +import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url' +import type { TRelayList } from '@/types' + +/** + * Remove LAN / loopback relay URLs (e.g. ws://localhost:4869, 192.168.x.x). + * Use for **another user's** NIP-65 list so we never open their private cache relays; + * the viewer's own list should not be passed through this (they may use local cache relays). + */ +export function stripLocalNetworkRelaysFromRelayList(list: TRelayList): TRelayList { + const keepUrl = (u: string): boolean => { + const n = normalizeUrl(u) || u + return Boolean(n && !isLocalNetworkUrl(n)) + } + return { + write: list.write.filter(keepUrl), + read: list.read.filter(keepUrl), + originalRelays: list.originalRelays.filter((r) => keepUrl(r.url)) + } +} diff --git a/src/lib/replaceable-list-latest.ts b/src/lib/replaceable-list-latest.ts new file mode 100644 index 00000000..c5d634fd --- /dev/null +++ b/src/lib/replaceable-list-latest.ts @@ -0,0 +1,111 @@ +import { METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS } from '@/constants' +import { normalizeHexPubkey } from '@/lib/pubkey' +import { normalizeUrl } from '@/lib/url' +import { queryService } from '@/services/client.service' +import type { Event } from 'nostr-tools' + +/** + * REQ across relays with {@link replaceableRace}, then keep the newest `created_at` row for this author+kind. + * Use before appending to pin / bookmark / follow / mute / interest lists so merges don’t drop remote state. + */ +export async function fetchLatestReplaceableListEvent( + pubkeyHex: string, + kind: number, + relayUrls: string[] +): Promise { + const pk = normalizeHexPubkey(pubkeyHex) + const urls = [...new Set(relayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean))] + if (!urls.length) return undefined + const rows = await queryService.fetchEvents( + urls, + { authors: [pk], kinds: [kind], limit: 80 }, + { + replaceableRace: true, + eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, + globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS + } + ) + if (!rows.length) return undefined + return rows.reduce((best, e) => (e.created_at > best.created_at ? e : best)) +} + +function orderedUniqueEHexIds(tags: string[][]): string[] { + const seen = new Set() + const out: string[] = [] + for (const t of tags) { + if (t[0] === 'e' && t[1] && /^[0-9a-f]{64}$/i.test(t[1])) { + const id = t[1].toLowerCase() + if (!seen.has(id)) { + seen.add(id) + out.push(id) + } + } + } + return out +} + +/** + * Next pin list (kind 10001) tags: preserve non-`e`/`a` tags and `a` pins, merge `e` hex ids with dedupe. + */ +export function buildPinListTagsAfterToggle( + latest: Event | null | undefined, + noteHexId: string, + shouldPin: boolean +): string[][] { + const tags = latest?.tags ?? [] + const meta = tags.filter((t) => t[0] !== 'e' && t[0] !== 'a') + const aKeep = tags.filter((t) => t[0] === 'a' && t[1]) + let eIds = orderedUniqueEHexIds(tags) + const id = noteHexId.toLowerCase() + if (shouldPin) { + if (!eIds.includes(id)) eIds = [...eIds, id] + } else { + eIds = eIds.filter((x) => x !== id) + } + return [...meta, ...aKeep, ...eIds.map((eid) => ['e', eid] as string[])] +} + +/** Dedupe `p` tags (case-insensitive hex), preserve other tags and first-seen `p` casing. */ +function dedupePTags(tags: string[][]): string[][] { + const nonP = tags.filter((t) => t[0] !== 'p') + const seen = new Set() + const pOut: string[][] = [] + for (const t of tags) { + if (t[0] === 'p' && t[1]) { + const k = t[1].toLowerCase() + if (!seen.has(k)) { + seen.add(k) + pOut.push(['p', t[1]]) + } + } + } + return [...nonP, ...pOut] +} + +/** Append `p` pubkey if missing; dedupe all `p` tags. */ +export function dedupePTagsAppendPubkey(tags: string[][], pubkey: string): string[][] { + const pk = pubkey.toLowerCase() + const nonP = tags.filter((t) => t[0] !== 'p') + const seen = new Set() + const pOut: string[][] = [] + for (const t of tags) { + if (t[0] === 'p' && t[1]) { + const k = t[1].toLowerCase() + if (!seen.has(k)) { + seen.add(k) + pOut.push(['p', t[1]]) + } + } + } + if (!seen.has(pk)) { + pOut.push(['p', pubkey]) + } + return [...nonP, ...pOut] +} + +/** Remove every `p` tag matching pubkey (case-insensitive); dedupe remaining `p` tags. */ +export function removePubkeyFromPTags(tags: string[][], pubkey: string): string[][] { + const pk = pubkey.toLowerCase() + const filtered = tags.filter((t) => !(t[0] === 'p' && t[1]?.toLowerCase() === pk)) + return dedupePTags(filtered) +} diff --git a/src/lib/spell-feed-request-identity.ts b/src/lib/spell-feed-request-identity.ts index 2d746cac..1f28ba69 100644 --- a/src/lib/spell-feed-request-identity.ts +++ b/src/lib/spell-feed-request-identity.ts @@ -51,3 +51,28 @@ export function isRelayUrlStrictSupersetIdentityKey(prevKey: string | null, next return false } } + +/** + * True when parsed {@link computeSpellSubRequestsIdentityKey} payloads match per-slot REQ `filter` strings + * but relay URL lists may differ (reorder, NIP-65 refinement, different cap slices). + * Use with {@link preserveTimelineOnSubRequestsChange} so a provisional relay stack can hand off to a refined + * stack without clearing rows or flashing the loading state. + */ +export function isSpellSubRequestsSameFiltersDifferentRelays( + prevKey: string | null, + nextKey: string +): boolean { + if (!prevKey || prevKey === nextKey) return false + try { + type Item = { urls: string[]; filter: string } + const prev = JSON.parse(prevKey) as Item[] + const next = JSON.parse(nextKey) as Item[] + if (!Array.isArray(prev) || !Array.isArray(next) || prev.length !== next.length) return false + for (let i = 0; i < prev.length; i++) { + if (prev[i].filter !== next[i].filter) return false + } + return true + } catch { + return false + } +} diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index 9784e90d..f876ad15 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -26,7 +26,8 @@ import { useTranslation } from 'react-i18next' import HelpAndAccountMenu from '@/components/HelpAndAccountMenu' import FollowingFeed from './FollowingFeed' import RelaysFeed from './RelaysFeed' -import { usePrimaryNoteView, usePrimaryPage } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { usePrimaryNoteView } from '@/PageManager' const NoteListPage = forwardRef((_, ref) => { const { t } = useTranslation() diff --git a/src/pages/primary/ProfilePage/index.tsx b/src/pages/primary/ProfilePage/index.tsx index 40b5d7dc..e487e619 100644 --- a/src/pages/primary/ProfilePage/index.tsx +++ b/src/pages/primary/ProfilePage/index.tsx @@ -2,7 +2,7 @@ import Profile from '@/components/Profile' import { RefreshButton } from '@/components/RefreshButton' import { Button } from '@/components/ui/button' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' -import { usePrimaryPage } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' import { useNostr } from '@/providers/NostrProvider' import { TPageRef } from '@/types' import { Settings, UserRound } from 'lucide-react' diff --git a/src/pages/primary/SearchPage/index.tsx b/src/pages/primary/SearchPage/index.tsx index c8aacd93..e4c4dc8c 100644 --- a/src/pages/primary/SearchPage/index.tsx +++ b/src/pages/primary/SearchPage/index.tsx @@ -4,7 +4,7 @@ import SearchBar, { TSearchBarRef } from '@/components/SearchBar' import SearchResult from '@/components/SearchResult' import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' -import { usePrimaryPage } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' import { useNostr } from '@/providers/NostrProvider' import { TPageRef, TSearchParams } from '@/types' import { BookOpen } from 'lucide-react' diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index f38a55a8..af4a7236 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -9,8 +9,9 @@ * kind list vs full profile kinds. */ import { ExtendedKind, FAST_READ_RELAY_URLS, PROFILE_FEED_KINDS, READ_ONLY_RELAY_URLS } from '@/constants' -import { mergeRelayUrlLayers } from '@/lib/favorites-feed-relays' +import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays' import { normalizeTopic } from '@/lib/discussion-topics' +import { userIdToPubkey } from '@/lib/pubkey' import { normalizeUrl } from '@/lib/url' import type { TFeedSubRequest } from '@/types' import { type Event, type Filter, kinds } from 'nostr-tools' @@ -22,12 +23,8 @@ export const FAUX_SPELL_EVENT_LIMIT = 200 /** Profile Media tab: single REQ `limit` (matches merged cap in NoteList one-shot). */ export const PROFILE_MEDIA_REQ_LIMIT = 200 -/** - * More sockets than {@link FAUX_SPELL_MAX_RELAYS}: profile media must query read aggregators plus the - * author stack. {@link appendCuratedReadOnlyRelays} + {@link applyFauxSpellCapsToSubRequests} used to put - * aggr *after* six NIP-65 relays, then slice to six — so aggr was never hit and media was often missing. - */ -export const PROFILE_MEDIA_MAX_RELAYS = 16 +/** Max relay URLs per Medien REQ after read-only + fast-read layers (see {@link buildProfileMediaSubRequests}). */ +export const PROFILE_MEDIA_MAX_RELAYS = 10 /** * Trim relay lists and filter limits (and bookmark `ids`) so faux feeds stay cheap to open. @@ -100,6 +97,11 @@ export const MEDIA_SPELL_KINDS = [ ExtendedKind.VOICE ] as const +/** + * Profile Medien tab: NIP native media only (picture, video, short video, voice) — same as {@link MEDIA_SPELL_KINDS}. + */ +export const PROFILE_MEDIA_TAB_KINDS = [...MEDIA_SPELL_KINDS] as const + /** Notifications faux spell: `#p` = you, narrow kinds — see module docstring. */ export function buildMentionsSpellFilter(pubkey: string): Filter { const pk = /^[0-9a-f]{64}$/i.test(pubkey.trim()) ? pubkey.trim().toLowerCase() : pubkey.trim() @@ -121,26 +123,24 @@ export function buildMediaSpellFilter(): Filter { return { kinds: [...MEDIA_SPELL_KINDS], limit: FAUX_SPELL_EVENT_LIMIT } } -/** Media kinds for a single profile (same as {@link MEDIA_SPELL_KINDS}, scoped by `authors`). */ +/** Media kinds for a single profile ({@link PROFILE_MEDIA_TAB_KINDS}, scoped by `authors`). */ export function buildProfileMediaSpellFilter(pubkey: string): Filter { - const pk = /^[0-9a-f]{64}$/i.test(pubkey.trim()) ? pubkey.trim().toLowerCase() : pubkey.trim() + const decoded = userIdToPubkey(pubkey.trim()) + const pk = /^[0-9a-f]{64}$/i.test(decoded) ? decoded.toLowerCase() : pubkey.trim().toLowerCase() return { authors: [pk], - kinds: [...MEDIA_SPELL_KINDS], + kinds: [...PROFILE_MEDIA_TAB_KINDS], limit: PROFILE_MEDIA_REQ_LIMIT } } -/** Read-only + {@link FAST_READ_RELAY_URLS} before the author’s six-relay stack so major mirrors are always queried. */ +/** Read-only + {@link FAST_READ_RELAY_URLS} before the author-only base stack; capped at {@link PROFILE_MEDIA_MAX_RELAYS}. */ export function buildProfileMediaSubRequests( profileRelayUrls: string[], blockedRelays: string[], pubkey: string ): TFeedSubRequest[] { - const readOnlyLayer = READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) - const fastReadLayer = FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) - const merged = mergeRelayUrlLayers([readOnlyLayer, fastReadLayer, profileRelayUrls], blockedRelays) - const urls = merged.slice(0, PROFILE_MEDIA_MAX_RELAYS) + const urls = buildProfileAugmentedReadRelayUrls(profileRelayUrls, blockedRelays, PROFILE_MEDIA_MAX_RELAYS) if (!urls.length) return [] return [{ urls, filter: buildProfileMediaSpellFilter(pubkey) }] } diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 80d078e9..8ee0d775 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -20,7 +20,7 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/u import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' -import { usePrimaryPage } from '@/PageManager' +import { usePrimaryPage } from '@/contexts/primary-page-context' import logger from '@/lib/logger' import { showPublishingError } from '@/lib/publishing-feedback' import { cn } from '@/lib/utils' diff --git a/src/providers/BookmarksProvider.tsx b/src/providers/BookmarksProvider.tsx index b8005ebd..9962efc5 100644 --- a/src/providers/BookmarksProvider.tsx +++ b/src/providers/BookmarksProvider.tsx @@ -1,10 +1,9 @@ +import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { buildATag, buildETag, createBookmarkDraftEvent } from '@/lib/draft-event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' -import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' -import { buildPrioritizedReadRelayUrls, buildPrioritizedWriteRelayUrls } from '@/lib/relay-url-priority' +import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' import logger from '@/lib/logger' import client from '@/services/client.service' -import { replaceableEventService } from '@/services/client.service' import { kinds } from 'nostr-tools' import { Event } from 'nostr-tools' import { createContext, useCallback, useContext } from 'react' @@ -30,32 +29,24 @@ export function BookmarksProvider({ children }: { children: React.ReactNode }) { const { pubkey: accountPubkey, publish, updateBookmarkListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() - // Build comprehensive relay list for publishing (same as ProfileFeed) const buildComprehensiveRelayList = useCallback(async () => { - const myRelayList = accountPubkey ? await client.fetchRelayList(accountPubkey) : { write: [], read: [] } - const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays) - const read = buildPrioritizedReadRelayUrls({ - userReadRelays: myRelayList.read ?? [], - userWriteRelays: myRelayList.write ?? [], - favoriteRelays: favoritesTier, - blockedRelays, - maxRelays: 100, - applyKind1BlockedFilter: false + if (!accountPubkey) return [] as string[] + return buildAccountListRelayUrlsForMerge({ + accountPubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays }) - const write = buildPrioritizedWriteRelayUrls({ - userWriteRelays: myRelayList.write ?? [], - favoriteRelays: favoritesTier, - blockedRelays, - maxRelays: 100, - applyKind1BlockedFilter: false - }) - return [...new Set([...read, ...write])] }, [accountPubkey, favoriteRelays, blockedRelays]) const addBookmark = async (event: Event) => { if (!accountPubkey) return - const bookmarkListEvent = await replaceableEventService.fetchReplaceableEvent(accountPubkey, kinds.BookmarkList) ?? null + const comprehensiveRelays = await buildComprehensiveRelayList() + let bookmarkListEvent = + (await fetchLatestReplaceableListEvent(accountPubkey, kinds.BookmarkList, comprehensiveRelays)) ?? null + if (!bookmarkListEvent) { + bookmarkListEvent = (await client.fetchBookmarkListEvent(accountPubkey)) ?? null + } const currentTags = bookmarkListEvent?.tags || [] const isReplaceable = isReplaceableEvent(event.kind) const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id @@ -74,9 +65,7 @@ export function BookmarksProvider({ children }: { children: React.ReactNode }) { [...currentTags, isReplaceable ? buildATag(event) : buildETag(event.id, event.pubkey)], bookmarkListEvent?.content ) - - // Use the same comprehensive relay list as pins for publishing - const comprehensiveRelays = await buildComprehensiveRelayList() + logger.component('BookmarksProvider', 'Publishing to comprehensive relays', { count: comprehensiveRelays.length }) const newBookmarkEvent = await publish(newBookmarkDraftEvent, { @@ -88,7 +77,12 @@ export function BookmarksProvider({ children }: { children: React.ReactNode }) { const removeBookmark = async (event: Event) => { if (!accountPubkey) return - const bookmarkListEvent = await replaceableEventService.fetchReplaceableEvent(accountPubkey, kinds.BookmarkList) ?? null + const comprehensiveRelays = await buildComprehensiveRelayList() + let bookmarkListEvent = + (await fetchLatestReplaceableListEvent(accountPubkey, kinds.BookmarkList, comprehensiveRelays)) ?? null + if (!bookmarkListEvent) { + bookmarkListEvent = (await client.fetchBookmarkListEvent(accountPubkey)) ?? null + } if (!bookmarkListEvent) return const isReplaceable = isReplaceableEvent(event.kind) @@ -100,9 +94,7 @@ export function BookmarksProvider({ children }: { children: React.ReactNode }) { if (newTags.length === bookmarkListEvent.tags.length) return const newBookmarkDraftEvent = createBookmarkDraftEvent(newTags, bookmarkListEvent.content) - - // Use the same comprehensive relay list as pins for publishing - const comprehensiveRelays = await buildComprehensiveRelayList() + logger.component('BookmarksProvider', 'Publishing to comprehensive relays', { count: comprehensiveRelays.length }) const newBookmarkEvent = await publish(newBookmarkDraftEvent, { diff --git a/src/providers/FollowListProvider.tsx b/src/providers/FollowListProvider.tsx index 53c439a9..c7d7cdae 100644 --- a/src/providers/FollowListProvider.tsx +++ b/src/providers/FollowListProvider.tsx @@ -1,10 +1,17 @@ +import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { createFollowListDraftEvent } from '@/lib/draft-event' +import { + dedupePTagsAppendPubkey, + fetchLatestReplaceableListEvent, + removePubkeyFromPTags +} from '@/lib/replaceable-list-latest' import { getPubkeysFromPTags } from '@/lib/tag' -import { replaceableEventService } from '@/services/client.service' +import client from '@/services/client.service' import { kinds } from 'nostr-tools' -import { createContext, useContext, useMemo } from 'react' +import { createContext, useContext, useMemo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useNostr } from './NostrProvider' +import { useFavoriteRelays } from './FavoriteRelaysProvider' type TFollowListContext = { followings: string[] @@ -25,26 +32,39 @@ export const useFollowList = () => { export function FollowListProvider({ children }: { children: React.ReactNode }) { const { t } = useTranslation() const { pubkey: accountPubkey, followListEvent, publish, updateFollowListEvent } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() const followings = useMemo( () => (followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []), [followListEvent] ) + const buildMergeRelays = useCallback(async () => { + if (!accountPubkey) return [] as string[] + return buildAccountListRelayUrlsForMerge({ + accountPubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays + }) + }, [accountPubkey, favoriteRelays, blockedRelays]) + const follow = async (pubkey: string) => { if (!accountPubkey) return - const followListEvent = await replaceableEventService.fetchReplaceableEvent(accountPubkey, kinds.Contacts) ?? null - if (!followListEvent) { + const relays = await buildMergeRelays() + let latest = + (await fetchLatestReplaceableListEvent(accountPubkey, kinds.Contacts, relays)) ?? null + if (!latest) { + latest = (await client.fetchFollowListEvent(accountPubkey)) ?? null + } + if (!latest) { const result = confirm(t('FollowListNotFoundConfirmation')) if (!result) { return } } - const newFollowListDraftEvent = createFollowListDraftEvent( - (followListEvent?.tags ?? []).concat([['p', pubkey]]), - followListEvent?.content - ) + const mergedTags = dedupePTagsAppendPubkey(latest?.tags ?? [], pubkey) + const newFollowListDraftEvent = createFollowListDraftEvent(mergedTags, latest?.content) const newFollowListEvent = await publish(newFollowListDraftEvent) await updateFollowListEvent(newFollowListEvent) } @@ -52,12 +72,17 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) const unfollow = async (pubkey: string) => { if (!accountPubkey) return - const followListEvent = await replaceableEventService.fetchReplaceableEvent(accountPubkey, kinds.Contacts) ?? null - if (!followListEvent) return + const relays = await buildMergeRelays() + let latest = + (await fetchLatestReplaceableListEvent(accountPubkey, kinds.Contacts, relays)) ?? null + if (!latest) { + latest = (await client.fetchFollowListEvent(accountPubkey)) ?? null + } + if (!latest) return const newFollowListDraftEvent = createFollowListDraftEvent( - followListEvent.tags.filter(([tagName, tagValue]) => tagName !== 'p' || tagValue !== pubkey), - followListEvent.content + removePubkeyFromPTags(latest.tags, pubkey), + latest.content ) const newFollowListEvent = await publish(newFollowListDraftEvent) await updateFollowListEvent(newFollowListEvent) diff --git a/src/providers/GroupListProvider.tsx b/src/providers/GroupListProvider.tsx index 897bc247..6e1beffe 100644 --- a/src/providers/GroupListProvider.tsx +++ b/src/providers/GroupListProvider.tsx @@ -3,9 +3,9 @@ import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { ExtendedKind } from '@/constants' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' +import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' import { buildPrioritizedReadRelayUrls } from '@/lib/relay-url-priority' import client from '@/services/client.service' -import { queryService } from '@/services/client.service' import logger from '@/lib/logger' interface GroupListContextType { @@ -58,17 +58,13 @@ export function GroupListProvider({ children }: { children: React.ReactNode }) { // Get comprehensive relay list const allRelays = await buildComprehensiveRelayList() - // Fetch group list event (kind 10009) - const groupListEvents = await queryService.fetchEvents(allRelays, [ - { - kinds: [ExtendedKind.GROUP_LIST], - authors: [accountPubkey], - limit: 1 - } - ]) - - if (groupListEvents.length > 0) { - const groupListEvent = groupListEvents[0] + const groupListEvent = await fetchLatestReplaceableListEvent( + accountPubkey, + ExtendedKind.GROUP_LIST, + allRelays + ) + + if (groupListEvent) { logger.debug('[GroupListProvider] Found group list event:', groupListEvent.id.substring(0, 8)) // Extract groups from a-tags (group coordinates) diff --git a/src/providers/InterestListProvider.tsx b/src/providers/InterestListProvider.tsx index c6b007f6..c1b7c5fc 100644 --- a/src/providers/InterestListProvider.tsx +++ b/src/providers/InterestListProvider.tsx @@ -1,7 +1,7 @@ +import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { createInterestListDraftEvent } from '@/lib/draft-event' import { normalizeTopic } from '@/lib/discussion-topics' -import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' -import { buildPrioritizedReadRelayUrls, buildPrioritizedWriteRelayUrls } from '@/lib/relay-url-priority' +import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' import logger from '@/lib/logger' import client from '@/services/client.service' import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' @@ -37,26 +37,15 @@ export function InterestListProvider({ children }: { children: React.ReactNode } const subscribedTopics = useMemo(() => new Set(topics), [topics]) const [changing, setChanging] = useState(false) - // Build comprehensive relay list for publishing (same as ProfileFeed) + const INTEREST_LIST_KIND = 10015 + const buildComprehensiveRelayList = useCallback(async () => { - const myRelayList = accountPubkey ? await client.fetchRelayList(accountPubkey) : { write: [], read: [] } - const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays) - const read = buildPrioritizedReadRelayUrls({ - userReadRelays: myRelayList.read ?? [], - userWriteRelays: myRelayList.write ?? [], - favoriteRelays: favoritesTier, - blockedRelays, - maxRelays: 100, - applyKind1BlockedFilter: false - }) - const write = buildPrioritizedWriteRelayUrls({ - userWriteRelays: myRelayList.write ?? [], - favoriteRelays: favoritesTier, - blockedRelays, - maxRelays: 100, - applyKind1BlockedFilter: false + if (!accountPubkey) return [] as string[] + return buildAccountListRelayUrlsForMerge({ + accountPubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays }) - return [...new Set([...read, ...write])] }, [accountPubkey, favoriteRelays, blockedRelays]) useEffect(() => { @@ -113,7 +102,12 @@ export function InterestListProvider({ children }: { children: React.ReactNode } setChanging(true) try { logger.component('InterestListProvider', 'Fetching existing interest list event') - const interestListEvent = await client.fetchInterestListEvent(accountPubkey) + const relays = await buildComprehensiveRelayList() + let interestListEvent = + (await fetchLatestReplaceableListEvent(accountPubkey, INTEREST_LIST_KIND, relays)) ?? null + if (!interestListEvent) { + interestListEvent = (await client.fetchInterestListEvent(accountPubkey)) ?? null + } logger.component('InterestListProvider', 'Existing interest list event', { hasEvent: !!interestListEvent }) const currentTopics = interestListEvent @@ -129,7 +123,7 @@ export function InterestListProvider({ children }: { children: React.ReactNode } return } - const newTopics = [...currentTopics, normalizedTopic] + const newTopics = Array.from(new Set([...currentTopics, normalizedTopic])) logger.component('InterestListProvider', 'Creating new interest list with topics', { topics: newTopics }) const newInterestListEvent = await publishNewInterestListEvent(newTopics) @@ -159,7 +153,12 @@ export function InterestListProvider({ children }: { children: React.ReactNode } setChanging(true) try { - const interestListEvent = await client.fetchInterestListEvent(accountPubkey) + const relays = await buildComprehensiveRelayList() + let interestListEvent = + (await fetchLatestReplaceableListEvent(accountPubkey, INTEREST_LIST_KIND, relays)) ?? null + if (!interestListEvent) { + interestListEvent = (await client.fetchInterestListEvent(accountPubkey)) ?? null + } if (!interestListEvent) return const currentTopics = interestListEvent.tags diff --git a/src/providers/MuteListProvider.tsx b/src/providers/MuteListProvider.tsx index 62fcb697..bffb21e7 100644 --- a/src/providers/MuteListProvider.tsx +++ b/src/providers/MuteListProvider.tsx @@ -1,6 +1,12 @@ +import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { createMuteListDraftEvent } from '@/lib/draft-event' +import { + dedupePTagsAppendPubkey, + fetchLatestReplaceableListEvent, + removePubkeyFromPTags +} from '@/lib/replaceable-list-latest' import { getPubkeysFromPTags } from '@/lib/tag' -import { replaceableEventService } from '@/services/client.service' +import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import { kinds } from 'nostr-tools' import dayjs from 'dayjs' @@ -10,6 +16,7 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { z } from 'zod' import { useNostr } from './NostrProvider' +import { useFavoriteRelays } from './FavoriteRelaysProvider' import logger from '@/lib/logger' type TMuteListContext = { @@ -44,6 +51,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { nip04Decrypt, nip04Encrypt } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [tags, setTags] = useState([]) const [privateTags, setPrivateTags] = useState([]) const publicMutePubkeySet = useMemo(() => new Set(getPubkeysFromPTags(tags)), [tags]) @@ -106,6 +114,18 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { [publicMutePubkeySet, privateMutePubkeySet] ) + const loadLatestMuteListEvent = useCallback(async (): Promise => { + if (!accountPubkey) return null + const relays = await buildAccountListRelayUrlsForMerge({ + accountPubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays + }) + const fromNetwork = await fetchLatestReplaceableListEvent(accountPubkey, kinds.Mutelist, relays) + if (fromNetwork) return fromNetwork + return (await client.fetchMuteListEvent(accountPubkey)) ?? null + }, [accountPubkey, favoriteRelays, blockedRelays]) + const publishNewMuteListEvent = async (tags: string[][], content?: string) => { if (dayjs().unix() === muteListEvent?.created_at) { await new Promise((resolve) => setTimeout(resolve, 1000)) @@ -116,7 +136,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { return event } - const checkMuteListEvent = (muteListEvent: Event | null) => { + const checkMuteListEvent = (muteListEvent: Event | null | undefined) => { if (!muteListEvent) { const result = confirm(t('MuteListNotFoundConfirmation')) @@ -131,15 +151,17 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { setChanging(true) try { - const muteListEvent = await replaceableEventService.fetchReplaceableEvent(accountPubkey, kinds.Mutelist) ?? null + const muteListEvent = await loadLatestMuteListEvent() checkMuteListEvent(muteListEvent) if ( muteListEvent && - muteListEvent.tags.some(([tagName, tagValue]) => tagName === 'p' && tagValue === pubkey) + muteListEvent.tags.some( + ([tagName, tagValue]) => tagName === 'p' && tagValue?.toLowerCase() === pubkey.toLowerCase() + ) ) { return } - const newTags = (muteListEvent?.tags ?? []).concat([['p', pubkey]]) + const newTags = dedupePTagsAppendPubkey(muteListEvent?.tags ?? [], pubkey) const newMuteListEvent = await publishNewMuteListEvent(newTags, muteListEvent?.content) const privateTags = await getPrivateTags(newMuteListEvent) await updateMuteListEvent(newMuteListEvent, privateTags) @@ -155,14 +177,18 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { setChanging(true) try { - const muteListEvent = await replaceableEventService.fetchReplaceableEvent(accountPubkey, kinds.Mutelist) ?? null + const muteListEvent = await loadLatestMuteListEvent() checkMuteListEvent(muteListEvent) const privateTags = muteListEvent ? await getPrivateTags(muteListEvent) : [] - if (privateTags.some(([tagName, tagValue]) => tagName === 'p' && tagValue === pubkey)) { + if ( + privateTags.some( + ([tagName, tagValue]) => tagName === 'p' && tagValue?.toLowerCase() === pubkey.toLowerCase() + ) + ) { return } - const newPrivateTags = privateTags.concat([['p', pubkey]]) + const newPrivateTags = dedupePTagsAppendPubkey(privateTags, pubkey) const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) const newMuteListEvent = await publishNewMuteListEvent(muteListEvent?.tags ?? [], cipherText) await updateMuteListEvent(newMuteListEvent, newPrivateTags) @@ -178,18 +204,20 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { setChanging(true) try { - const muteListEvent = await replaceableEventService.fetchReplaceableEvent(accountPubkey, kinds.Mutelist) ?? null + const muteListEvent = await loadLatestMuteListEvent() if (!muteListEvent) return const privateTags = await getPrivateTags(muteListEvent) - const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) + const newPrivateTags = privateTags.filter( + (tag) => !(tag[0] === 'p' && tag[1]?.toLowerCase() === pubkey.toLowerCase()) + ) let cipherText = muteListEvent.content if (newPrivateTags.length !== privateTags.length) { cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) } const newMuteListEvent = await publishNewMuteListEvent( - muteListEvent.tags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey), + removePubkeyFromPTags(muteListEvent.tags, pubkey), cipherText ) await updateMuteListEvent(newMuteListEvent, newPrivateTags) @@ -203,20 +231,20 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { setChanging(true) try { - const muteListEvent = await replaceableEventService.fetchReplaceableEvent(accountPubkey, kinds.Mutelist) ?? null + const muteListEvent = await loadLatestMuteListEvent() if (!muteListEvent) return const privateTags = await getPrivateTags(muteListEvent) - const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) + const newPrivateTags = privateTags.filter( + (tag) => !(tag[0] === 'p' && tag[1]?.toLowerCase() === pubkey.toLowerCase()) + ) if (newPrivateTags.length === privateTags.length) { return } const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) const newMuteListEvent = await publishNewMuteListEvent( - muteListEvent.tags - .filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) - .concat([['p', pubkey]]), + dedupePTagsAppendPubkey(removePubkeyFromPTags(muteListEvent.tags, pubkey), pubkey), cipherText ) await updateMuteListEvent(newMuteListEvent, newPrivateTags) @@ -230,18 +258,21 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { setChanging(true) try { - const muteListEvent = await replaceableEventService.fetchReplaceableEvent(accountPubkey, kinds.Mutelist) ?? null + const muteListEvent = await loadLatestMuteListEvent() if (!muteListEvent) return - const newTags = muteListEvent.tags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) + const newTags = removePubkeyFromPTags(muteListEvent.tags, pubkey) if (newTags.length === muteListEvent.tags.length) { return } const privateTags = await getPrivateTags(muteListEvent) - const newPrivateTags = privateTags - .filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) - .concat([['p', pubkey]]) + const newPrivateTags = dedupePTagsAppendPubkey( + privateTags.filter( + (tag) => !(tag[0] === 'p' && tag[1]?.toLowerCase() === pubkey.toLowerCase()) + ), + pubkey + ) const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) const newMuteListEvent = await publishNewMuteListEvent(newTags, cipherText) await updateMuteListEvent(newMuteListEvent, newPrivateTags) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index c56c81ed..8170d836 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -919,6 +919,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { if (publishResult.successCount >= 1) { client.addEventToCache(event) client.emitNewEvent(event) + // Replaceable list events (pins, cache relays, …) must hit IndexedDB + DataLoader, not only RAM + void replaceableEventService.updateReplaceableEventCache(event).catch(() => {}) } // If publishing failed completely, throw an error so the form doesn't close diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 3344f108..81dbbd7e 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -4,7 +4,8 @@ import { MAX_CONCURRENT_RELAY_CONNECTIONS, METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS, - PROFILE_FETCH_RELAY_URLS + PROFILE_FETCH_RELAY_URLS, + READ_ONLY_RELAY_URLS } from '@/constants' import { kinds, nip19 } from 'nostr-tools' import type { Event as NEvent, Filter } from 'nostr-tools' @@ -488,6 +489,16 @@ export class ReplaceableEventService { } } else if (kind === ExtendedKind.FAVORITE_RELAYS) { relayUrls = await buildExploreProfileAndUserRelayList(client.pubkey) + } else if (kind === 10001) { + // Pin lists (NIP-51): same pitfall as profile media — FAST_READ alone misses aggr / profile mirrors, + // and 100ms EOSE loses the race when several relays are down. + relayUrls = Array.from( + new Set( + [...READ_ONLY_RELAY_URLS, ...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS].map( + (u) => normalizeUrl(u) || u + ) + ) + ).filter(Boolean) } else { relayUrls = [...FAST_READ_RELAY_URLS] } @@ -506,7 +517,7 @@ export class ReplaceableEventService { relayCount: relayUrls.length }) } - const isMetadataBatch = kind === kinds.Metadata + const isSlowReplaceableBatch = kind === kinds.Metadata || kind === 10001 const events = await this.queryService.query( relayUrls, { @@ -516,8 +527,8 @@ export class ReplaceableEventService { undefined, { replaceableRace: true, - eoseTimeout: isMetadataBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100, - globalTimeout: isMetadataBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000 + eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100, + globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000 } ) // Only log at info level for large batches or if many events found @@ -601,15 +612,14 @@ export class ReplaceableEventService { }) ) - // Step 3: Save network-fetched events to IndexedDB and mark missing ones as null + // Step 3: Persist hits only. Do not write negative cache rows (`value: null`) — optional kinds + // (e.g. 10432 cache relays, 10001 pins) are missing for most pubkeys and would flood IndexedDB. await Promise.allSettled( missingParams.map(async ({ pubkey, kind }) => { const key = `${pubkey}:${kind}` const event = eventsMap.get(key) if (event) { await indexedDb.putReplaceableEvent(event) - } else { - await indexedDb.putNullReplaceableEvent(pubkey, kind) } }) ) @@ -681,12 +691,10 @@ export class ReplaceableEventService { const eventKey = `${pubkey}:${kind}:${d ?? ''}` const event = eventsMap.get(eventKey) if (event) { - indexedDb.putReplaceableEvent(event) + void indexedDb.putReplaceableEvent(event) return event - } else { - indexedDb.putNullReplaceableEvent(pubkey, kind, d) - return null } + return null }) } @@ -694,13 +702,25 @@ export class ReplaceableEventService { * Private: Update cache for replaceable event from big relays */ private async updateReplaceableEventFromBigRelaysCache(event: NEvent): Promise { + if (!indexedDb.hasReplaceableEventStoreForKind(event.kind)) { + return + } + const d = event.tags.find((t) => t[0] === 'd')?.[1] this.replaceableEventFromBigRelaysDataloader.clear({ pubkey: event.pubkey, kind: event.kind }) this.replaceableEventFromBigRelaysDataloader.prime( { pubkey: event.pubkey, kind: event.kind }, Promise.resolve(event) ) - // Store in IndexedDB - await indexedDb.putReplaceableEvent(event) + this.replaceableEventDataLoader.clear({ pubkey: event.pubkey, kind: event.kind, d }) + this.replaceableEventDataLoader.prime( + { pubkey: event.pubkey, kind: event.kind, d }, + Promise.resolve(event) + ) + try { + await indexedDb.putReplaceableEvent(event) + } catch { + // Tombstone or validation — in-memory loaders still primed for this session + } } /** diff --git a/src/services/client.service.ts b/src/services/client.service.ts index ec907524..b2730ad7 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -21,7 +21,7 @@ import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' import { dispatchTombstonesUpdated } from '@/lib/tombstone-events' -import { isValidPubkey, pubkeyToNpub } from '@/lib/pubkey' +import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { buildPrioritizedWriteRelayUrls, @@ -29,6 +29,7 @@ import { mergeRelayPriorityLayers, relayUrlsLocalsFirst } from '@/lib/relay-url-priority' +import { stripLocalNetworkRelaysFromRelayList } from '@/lib/relay-list-sanitize' import { isLocalNetworkUrl, normalizeUrl, simplifyUrl } from '@/lib/url' import { isSafari } from '@/lib/utils' import { @@ -107,7 +108,8 @@ class ClientService extends EventTarget { | string[] | undefined > = {} - private relayListRequestCache = new Map>() // Cache in-flight relay list requests + /** In-flight {@link fetchRelayList} dedupe: key = viewer pubkey + target pubkey (sanitization depends on viewer). */ + private relayListRequestCache = new Map>() private userIndex = new FlexSearch.Index({ tokenize: 'forward' }) @@ -2235,8 +2237,15 @@ class ClientService extends EventTarget { return event ?? null } - clearRelayListCache(pubkey: string) { - this.relayListRequestCache.delete(pubkey) + clearRelayListCache(targetPubkey: string) { + const suffix = `\x1e${targetPubkey}` + for (const k of this.relayListRequestCache.keys()) { + if (k.endsWith(suffix)) this.relayListRequestCache.delete(k) + } + } + + private relayListRequestCacheKey(targetPubkey: string): string { + return `${this.pubkey ?? ''}\x1e${targetPubkey}` } /** @@ -2253,7 +2262,8 @@ class ClientService extends EventTarget { async fetchRelayList(pubkey: string): Promise { // Deduplicate concurrent requests for the same pubkey's relay list - const existingRequest = this.relayListRequestCache.get(pubkey) + const cacheKey = this.relayListRequestCacheKey(pubkey) + const existingRequest = this.relayListRequestCache.get(cacheKey) if (existingRequest) { logger.debug('[FetchRelayList] Using cached in-flight request', { pubkey }) return existingRequest @@ -2280,11 +2290,11 @@ class ClientService extends EventTarget { }) throw error } finally { - this.relayListRequestCache.delete(pubkey) + this.relayListRequestCache.delete(cacheKey) } })() - this.relayListRequestCache.set(pubkey, requestPromise) + this.relayListRequestCache.set(cacheKey, requestPromise) return requestPromise } @@ -2303,7 +2313,10 @@ class ClientService extends EventTarget { // Fetch cache relays from multiple sources: FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, and user's inboxes/outboxes const cacheRelayEvents = await this.fetchCacheRelayEventsFromMultipleSources(pubkeys, relayEvents, storedRelayEvents) - return pubkeys.map((_pubkey, index) => { + return pubkeys.map((targetPubkey, index) => { + const isOwnRelayList = + this.pubkey != null && hexPubkeysEqual(this.pubkey, userIdToPubkey(targetPubkey)) + // Use stored cache relay event if available (for offline), otherwise use fetched one const storedCacheEvent = storedCacheRelayEvents[index] const cacheEvent = cacheRelayEvents[index] || storedCacheEvent @@ -2318,9 +2331,8 @@ class ClientService extends EventTarget { originalRelays: [] } - // Merge cache relays (kind 10432) into the relay list - // Prioritize cache relays by placing them first in the list (for offline functionality) - if (cacheEvent) { + // Merge kind 10432 (cache relays) only for the logged-in user — never use someone else's local relays. + if (isOwnRelayList && cacheEvent) { const cacheRelayList = getRelayListFromEvent(cacheEvent) // Merge read relays - cache relays first, then others (for offline priority) @@ -2347,10 +2359,9 @@ class ClientService extends EventTarget { } } - // If no cache event, return original relay list or default (with cache as fallback) + // If no merged cache path, return original relay list or default (with own cache as fallback only) if (!relayEvent) { - // Check if we have a stored cache relay event as fallback - if (storedCacheEvent) { + if (isOwnRelayList && storedCacheEvent) { const cacheRelayList = getRelayListFromEvent(storedCacheEvent) return { write: cacheRelayList.write.length > 0 ? cacheRelayList.write : PROFILE_FETCH_RELAY_URLS, @@ -2365,6 +2376,10 @@ class ClientService extends EventTarget { } } + if (!isOwnRelayList) { + return stripLocalNetworkRelaysFromRelayList(relayList) + } + return relayList }) } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 9defd225..d44558de 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -235,44 +235,9 @@ class IndexedDbService { ); } - async putNullReplaceableEvent(pubkey: string, kind: number, d?: string) { - const storeName = this.getStoreNameByKind(kind) - if (!storeName) { - return Promise.reject('store name not found') - } - await this.initPromise - return new Promise((resolve, reject) => { - if (!this.db) { - return resolve(undefined) - } - const transaction = this.db.transaction(storeName, 'readwrite') - const store = transaction.objectStore(storeName) - - const key = this.getReplaceableEventKey(pubkey, d) - const getRequest = store.get(key) - getRequest.onsuccess = () => { - const oldValue = getRequest.result as TValue | undefined - if (oldValue) { - transaction.commit() - return resolve(oldValue.value) - } - const putRequest = store.put(this.formatValue(key, null)) - putRequest.onsuccess = () => { - transaction.commit() - resolve(null) - } - - putRequest.onerror = (event) => { - transaction.commit() - reject(event) - } - } - - getRequest.onerror = (event) => { - transaction.commit() - reject(event) - } - }) + /** Whether {@link putReplaceableEvent} persists this kind (profile, lists, publications, …). */ + hasReplaceableEventStoreForKind(kind: number): boolean { + return this.getStoreNameByKind(kind) !== undefined } async putReplaceableEvent(event: Event): Promise {