Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
8ef07a1309
  1. 25
      src/PageManager.tsx
  2. 2
      src/components/BottomNavigationBar/DiscussionsButton.tsx
  3. 3
      src/components/BottomNavigationBar/HomeButton.tsx
  4. 2
      src/components/BottomNavigationBar/NotificationsButton.tsx
  5. 3
      src/components/BottomNavigationBar/RssButton.tsx
  6. 3
      src/components/BottomNavigationBar/SearchButton.tsx
  7. 3
      src/components/BottomNavigationBar/SpellsButton.tsx
  8. 3
      src/components/Explore/ExploreFavoriteRelays.tsx
  9. 2
      src/components/HelpAndAccountMenu.tsx
  10. 26
      src/components/KeyboardShortcutsHelp/index.tsx
  11. 123
      src/components/NoteList/index.tsx
  12. 83
      src/components/NoteOptions/useMenuActions.tsx
  13. 10
      src/components/Profile/ProfileFeedWithPins.tsx
  14. 103
      src/components/Profile/ProfileMediaFeed.tsx
  15. 10
      src/components/Profile/ProfileTimeline.tsx
  16. 3
      src/components/Profile/index.tsx
  17. 2
      src/components/RelayList/index.tsx
  18. 3
      src/components/Sidebar/DiscussionsButton.tsx
  19. 3
      src/components/Sidebar/FeedButton.tsx
  20. 3
      src/components/Sidebar/HomeButton.tsx
  21. 2
      src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx
  22. 3
      src/components/Sidebar/NotificationButton.tsx
  23. 3
      src/components/Sidebar/RssButton.tsx
  24. 3
      src/components/Sidebar/SearchButton.tsx
  25. 3
      src/components/Sidebar/SpellsButton.tsx
  26. 22
      src/contexts/keyboard-shortcuts-help-context.tsx
  27. 26
      src/contexts/primary-page-context.tsx
  28. 158
      src/hooks/useProfilePins.tsx
  29. 3
      src/layouts/PrimaryPageLayout/index.tsx
  30. 35
      src/lib/account-list-relay-urls.ts
  31. 19
      src/lib/favorites-feed-relays.ts
  32. 19
      src/lib/relay-list-sanitize.ts
  33. 111
      src/lib/replaceable-list-latest.ts
  34. 25
      src/lib/spell-feed-request-identity.ts
  35. 3
      src/pages/primary/NoteListPage/index.tsx
  36. 2
      src/pages/primary/ProfilePage/index.tsx
  37. 2
      src/pages/primary/SearchPage/index.tsx
  38. 30
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  39. 2
      src/pages/primary/SpellsPage/index.tsx
  40. 50
      src/providers/BookmarksProvider.tsx
  41. 49
      src/providers/FollowListProvider.tsx
  42. 20
      src/providers/GroupListProvider.tsx
  43. 45
      src/providers/InterestListProvider.tsx
  44. 73
      src/providers/MuteListProvider.tsx
  45. 2
      src/providers/NostrProvider/index.tsx
  46. 46
      src/services/client-replaceable-events.service.ts
  47. 43
      src/services/client.service.ts
  48. 41
      src/services/indexed-db.service.ts

25
src/PageManager.tsx

@ -39,6 +39,11 @@ import { @@ -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 @@ -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( @@ -197,7 +194,7 @@ function mergePrimaryPageEntry(
return [...prev, { name: entry.name, element, props: entry.props }]
}
export const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(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<{ @@ -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 }) { @@ -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,

2
src/components/BottomNavigationBar/DiscussionsButton.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { usePrimaryPage } from '@/PageManager'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { MessageCircle } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'

3
src/components/BottomNavigationBar/HomeButton.tsx

@ -1,5 +1,6 @@ @@ -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'

2
src/components/BottomNavigationBar/NotificationsButton.tsx

@ -1,4 +1,4 @@ @@ -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'

3
src/components/BottomNavigationBar/RssButton.tsx

@ -1,4 +1,5 @@ @@ -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'

3
src/components/BottomNavigationBar/SearchButton.tsx

@ -1,4 +1,5 @@ @@ -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'

3
src/components/BottomNavigationBar/SpellsButton.tsx

@ -1,4 +1,5 @@ @@ -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'

3
src/components/Explore/ExploreFavoriteRelays.tsx

@ -4,7 +4,8 @@ import { DEFAULT_FAVORITE_RELAYS } from '@/constants' @@ -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'

2
src/components/HelpAndAccountMenu.tsx

@ -15,7 +15,7 @@ import { @@ -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'

26
src/components/KeyboardShortcutsHelp/index.tsx

@ -17,31 +17,15 @@ import { @@ -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<KeyboardShortcutsHelpContextValue | null>(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 (

123
src/components/NoteList/index.tsx

@ -10,8 +10,10 @@ import { @@ -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( @@ -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 subrequests 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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -734,6 +799,7 @@ const NoteList = forwardRef(
timelineSubscriptionKey,
subRequestsKey,
preserveTimelineOnSubRequestsChange,
mergeTimelineWhenSubRequestFiltersMatch,
refreshCount,
showKindsKey,
showKind1OPs,
@ -743,7 +809,44 @@ const NoteList = forwardRef( @@ -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<number, number> = {}
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(() => {

83
src/components/NoteOptions/useMenuActions.tsx

@ -6,6 +6,7 @@ import { toAlexandria } from '@/lib/link' @@ -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' @@ -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' @@ -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({ @@ -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({ @@ -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 })

10
src/components/Profile/ProfileFeedWithPins.tsx

@ -11,6 +11,7 @@ import { useKindFilter } from '@/providers/KindFilterProvider' @@ -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 @@ -211,7 +212,14 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
/>
</div>
{isRefreshing && (
<div className="px-4 py-2 text-center text-sm text-green-500">🔄 {t('Refreshing posts...')}</div>
<div
className="flex items-center justify-center gap-2 px-4 py-2 text-center text-sm text-green-500"
role="status"
aria-live="polite"
>
<RefreshCw className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
{t('Refreshing posts...')}
</div>
)}
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">

103
src/components/Profile/ProfileMediaFeed.tsx

@ -1,15 +1,12 @@ @@ -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[]) @@ -19,6 +16,8 @@ function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[])
return `${fav}\u0000${blk}`
}
const MEDIA_LOG = '[ProfileMedia]'
const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey }, ref) => {
const { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
@ -27,35 +26,64 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey @@ -27,35 +26,64 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey
[favoriteRelays, blockedRelays]
)
/** `null` = still resolving viewed profile NIP-65 + merged relay stack (same as pins / main profile feed). */
const [profileRelayUrls, setProfileRelayUrls] = useState<string[] | null>(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<string[] | null>(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<TNoteListRef, { pubkey: string }>(({ pubkey @@ -64,7 +92,29 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ 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<TNoteListRef, { pubkey: string }>(({ pubkey @@ -74,21 +124,6 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey
)
}
if (profileRelayUrls === null) {
return (
<div
className="min-h-[min(40vh,320px)] space-y-2 px-1 py-4"
role="status"
aria-live="polite"
aria-busy="true"
>
{Array.from({ length: 4 }).map((_, i) => (
<NoteCardLoadingSkeleton key={i} />
))}
</div>
)
}
if (!subRequests.length) {
return (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
@ -105,9 +140,15 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey @@ -105,9 +140,15 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ 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

10
src/components/Profile/ProfileTimeline.tsx

@ -1,5 +1,6 @@ @@ -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< @@ -172,7 +173,14 @@ const ProfileTimeline = forwardRef<
return (
<div style={{ marginTop: topSpace || 0 }}>
{isRefreshing && (
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 {refreshLabel}</div>
<div
className="flex items-center justify-center gap-2 px-4 py-2 text-center text-sm text-green-500"
role="status"
aria-live="polite"
>
<RefreshCw className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
{refreshLabel}
</div>
)}
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && (
<div className="px-4 py-2 text-sm text-muted-foreground">

3
src/components/Profile/index.tsx

@ -16,7 +16,8 @@ import { kinds, type NostrEvent } from 'nostr-tools' @@ -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'

2
src/components/RelayList/index.tsx

@ -1,4 +1,4 @@ @@ -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'

3
src/components/Sidebar/DiscussionsButton.tsx

@ -1,4 +1,5 @@ @@ -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'

3
src/components/Sidebar/FeedButton.tsx

@ -1,4 +1,5 @@ @@ -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'

3
src/components/Sidebar/HomeButton.tsx

@ -1,5 +1,6 @@ @@ -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'

2
src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx

@ -1,4 +1,4 @@ @@ -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'

3
src/components/Sidebar/NotificationButton.tsx

@ -1,4 +1,5 @@ @@ -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'

3
src/components/Sidebar/RssButton.tsx

@ -1,4 +1,5 @@ @@ -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'

3
src/components/Sidebar/SearchButton.tsx

@ -1,4 +1,5 @@ @@ -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'

3
src/components/Sidebar/SpellsButton.tsx

@ -1,4 +1,5 @@ @@ -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'

22
src/contexts/keyboard-shortcuts-help-context.tsx

@ -0,0 +1,22 @@ @@ -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<KeyboardShortcutsHelpContextValue | null>(
null
)
export function useKeyboardShortcutsHelp(): KeyboardShortcutsHelpContextValue {
const ctx = useContext(KeyboardShortcutsHelpContext)
if (!ctx) {
throw new Error('useKeyboardShortcutsHelp must be used within KeyboardShortcutsHelpProvider')
}
return ctx
}

26
src/contexts/primary-page-context.tsx

@ -0,0 +1,26 @@ @@ -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<PrimaryPageContextValue | undefined>(undefined)
export function usePrimaryPage(): PrimaryPageContextValue {
const context = useContext(PrimaryPageContext)
if (!context) {
throw new Error('usePrimaryPage must be used within a PrimaryPageContext.Provider')
}
return context
}

158
src/hooks/useProfilePins.tsx

@ -1,14 +1,18 @@ @@ -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<string, Event>): Event[] @@ -25,33 +29,41 @@ function orderPinEvents(pinList: Event, eventsById: Map<string, Event>): 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) { @@ -73,6 +85,26 @@ export function useProfilePins(pubkey: string | undefined) {
const [pinEvents, setPinEvents] = useState<Event[]>([])
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) { @@ -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) { @@ -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) { @@ -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) { @@ -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<Event[]>[] = []
const byId = new Map<string, Event>()
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<Event[]>[] = []
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) { @@ -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) { @@ -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<string, Event>()
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)

3
src/layouts/PrimaryPageLayout/index.tsx

@ -1,6 +1,7 @@ @@ -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 {

35
src/lib/account-list-relay-urls.ts

@ -0,0 +1,35 @@ @@ -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<string[]> {
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))]
}

19
src/lib/favorites-feed-relays.ts

@ -1,4 +1,4 @@ @@ -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[]) @@ -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 authors 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[]

19
src/lib/relay-list-sanitize.ts

@ -0,0 +1,19 @@ @@ -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))
}
}

111
src/lib/replaceable-list-latest.ts

@ -0,0 +1,111 @@ @@ -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 dont drop remote state.
*/
export async function fetchLatestReplaceableListEvent(
pubkeyHex: string,
kind: number,
relayUrls: string[]
): Promise<Event | undefined> {
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<string>()
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<string>()
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<string>()
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)
}

25
src/lib/spell-feed-request-identity.ts

@ -51,3 +51,28 @@ export function isRelayUrlStrictSupersetIdentityKey(prevKey: string | null, next @@ -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
}
}

3
src/pages/primary/NoteListPage/index.tsx

@ -26,7 +26,8 @@ import { useTranslation } from 'react-i18next' @@ -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<TPageRef>((_, ref) => {
const { t } = useTranslation()

2
src/pages/primary/ProfilePage/index.tsx

@ -2,7 +2,7 @@ import Profile from '@/components/Profile' @@ -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'

2
src/pages/primary/SearchPage/index.tsx

@ -4,7 +4,7 @@ import SearchBar, { TSearchBarRef } from '@/components/SearchBar' @@ -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'

30
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -9,8 +9,9 @@ @@ -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 @@ -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 = [ @@ -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 { @@ -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) }]
}

2
src/pages/primary/SpellsPage/index.tsx

@ -20,7 +20,7 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/u @@ -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'

50
src/providers/BookmarksProvider.tsx

@ -1,10 +1,9 @@ @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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, {

49
src/providers/FollowListProvider.tsx

@ -1,10 +1,17 @@ @@ -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 = () => { @@ -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 }) @@ -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)

20
src/providers/GroupListProvider.tsx

@ -3,9 +3,9 @@ import { useNostr } from '@/providers/NostrProvider' @@ -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 }) { @@ -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)

45
src/providers/InterestListProvider.tsx

@ -1,7 +1,7 @@ @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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

73
src/providers/MuteListProvider.tsx

@ -1,6 +1,12 @@ @@ -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' @@ -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 }) { @@ -44,6 +51,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
nip04Decrypt,
nip04Encrypt
} = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [tags, setTags] = useState<string[][]>([])
const [privateTags, setPrivateTags] = useState<string[][]>([])
const publicMutePubkeySet = useMemo(() => new Set(getPubkeysFromPTags(tags)), [tags])
@ -106,6 +114,18 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { @@ -106,6 +114,18 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
[publicMutePubkeySet, privateMutePubkeySet]
)
const loadLatestMuteListEvent = useCallback(async (): Promise<Event | null> => {
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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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)

2
src/providers/NostrProvider/index.tsx

@ -919,6 +919,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -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

46
src/services/client-replaceable-events.service.ts

@ -4,7 +4,8 @@ import { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -694,13 +702,25 @@ export class ReplaceableEventService {
* Private: Update cache for replaceable event from big relays
*/
private async updateReplaceableEventFromBigRelaysCache(event: NEvent): Promise<void> {
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
}
}
/**

43
src/services/client.service.ts

@ -21,7 +21,7 @@ import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' @@ -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 { @@ -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 { @@ -107,7 +108,8 @@ class ClientService extends EventTarget {
| string[]
| undefined
> = {}
private relayListRequestCache = new Map<string, Promise<TRelayList>>() // Cache in-flight relay list requests
/** In-flight {@link fetchRelayList} dedupe: key = viewer pubkey + target pubkey (sanitization depends on viewer). */
private relayListRequestCache = new Map<string, Promise<TRelayList>>()
private userIndex = new FlexSearch.Index({
tokenize: 'forward'
})
@ -2235,8 +2237,15 @@ class ClientService extends EventTarget { @@ -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 { @@ -2253,7 +2262,8 @@ class ClientService extends EventTarget {
async fetchRelayList(pubkey: string): Promise<TRelayList> {
// 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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -2365,6 +2376,10 @@ class ClientService extends EventTarget {
}
}
if (!isOwnRelayList) {
return stripLocalNetworkRelaysFromRelayList(relayList)
}
return relayList
})
}

41
src/services/indexed-db.service.ts

@ -235,44 +235,9 @@ class IndexedDbService { @@ -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<Event> | 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<Event> {

Loading…
Cancel
Save