Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
5ae672ba72
  1. 148
      src/PageManager.tsx
  2. 11
      src/components/NoteList/index.tsx
  3. 6
      src/components/SimpleNoteFeed/index.tsx
  4. 22
      src/constants.ts
  5. 14
      src/hooks/useFetchCalendarRsvps.tsx
  6. 30
      src/hooks/useFetchProfile.tsx
  7. 10
      src/lib/spell-list-import.ts
  8. 56
      src/pages/primary/DiscussionsPage/index.tsx
  9. 121
      src/routes.tsx
  10. 52
      src/services/client-query.service.ts
  11. 143
      src/services/client-replaceable-events.service.ts
  12. 211
      src/services/client.service.ts
  13. 17
      src/services/navigation.service.ts

148
src/PageManager.tsx

@ -1,11 +1,8 @@ @@ -1,11 +1,8 @@
import Sidebar from '@/components/Sidebar'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import logger from '@/lib/logger'
import { ChevronLeft } from 'lucide-react'
import { NavigationService } from '@/services/navigation.service'
import NoteListPage from '@/pages/primary/NoteListPage'
import SecondaryNoteListPage from '@/pages/secondary/NoteListPage'
// Page imports needed for primary note view
import NoteDrawer from '@/components/NoteDrawer'
import SecondaryProfilePage from '@/pages/secondary/ProfilePage'
@ -28,6 +25,7 @@ import { @@ -28,6 +25,7 @@ import {
createRef,
isValidElement,
lazy,
type ReactElement,
type ReactNode,
RefObject,
Suspense,
@ -38,26 +36,39 @@ import { @@ -38,26 +36,39 @@ import {
useRef,
useState
} from 'react'
import BottomNavigationBar from './components/BottomNavigationBar'
import { useTranslation } from 'react-i18next'
import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog'
import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHelp'
import { normalizeUrl } from './lib/url'
import ExplorePage from './pages/primary/ExplorePage'
import MePage from './pages/primary/MePage'
import ProfilePage from './pages/primary/ProfilePage'
import RelayPage from './pages/primary/RelayPage'
import SearchPage from './pages/primary/SearchPage'
import RssPage from './pages/primary/RssPage'
import SettingsPrimaryPage from './pages/primary/SettingsPrimaryPage'
import modalManager from './services/modal-manager.service'
import { routes } from './routes'
import { useScreenSize } from './providers/ScreenSizeProvider'
/** Lazy-loaded so PageManager does not synchronously import SpellsPage (avoids HMR cycle: SpellsPage → PrimaryPageLayout → PageManager → SpellsPage). */
const SpellsPageLazy = lazy(() => import('./pages/primary/SpellsPage'))
import { routes } from './routes'
import modalManager from './services/modal-manager.service'
import CreateWalletGuideToast from './components/CreateWalletGuideToast'
import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHelp'
/** Lazy NoteList pages break: PageManager → … → NoteList → NoteCard → useSmartNoteNavigation → PageManager */
const NoteListPageLazy = lazy(() => import('@/pages/primary/NoteListPage'))
const SecondaryNoteListPageLazy = lazy(() => import('@/pages/secondary/NoteListPage'))
const primaryPageLazyFallback = (
<div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
Loading
</div>
)
/** Lazy primary pages: each may import PrimaryPageLayout → usePrimaryPage → would sync-import PageManager. */
const ExplorePageLazy = lazy(() => import('./pages/primary/ExplorePage'))
const MePageLazy = lazy(() => import('./pages/primary/MePage'))
const ProfilePageLazy = lazy(() => import('./pages/primary/ProfilePage'))
const RelayPageLazy = lazy(() => import('./pages/primary/RelayPage'))
const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage'))
const RssPageLazy = lazy(() => import('./pages/primary/RssPage'))
const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage'))
/** Lazy chrome: Sidebar / bottom bar / dialogs import hooks from PageManager — must not be sync-imported here. */
const SidebarLazy = lazy(() => import('@/components/Sidebar'))
const BottomNavigationBarLazy = lazy(() => import('@/components/BottomNavigationBar'))
const TooManyRelaysAlertDialogLazy = lazy(() => import('@/components/TooManyRelaysAlertDialog'))
const CreateWalletGuideToastLazy = lazy(() => import('@/components/CreateWalletGuideToast'))
type TPrimaryPageContext = {
navigate: (page: TPrimaryPageName, props?: object) => void
@ -96,22 +107,48 @@ const PRIMARY_PAGE_REF_MAP = { @@ -96,22 +107,48 @@ const PRIMARY_PAGE_REF_MAP = {
// Lazy function to create PRIMARY_PAGE_MAP to avoid circular dependency
// This is only evaluated when called, not at module load time
const getPrimaryPageMap = () => ({
home: <ExplorePage ref={PRIMARY_PAGE_REF_MAP.home} />,
feed: <NoteListPage ref={PRIMARY_PAGE_REF_MAP.feed} />,
me: <MePage ref={PRIMARY_PAGE_REF_MAP.me} />,
profile: <ProfilePage ref={PRIMARY_PAGE_REF_MAP.profile} />,
relay: <RelayPage ref={PRIMARY_PAGE_REF_MAP.relay} />,
search: <SearchPage ref={PRIMARY_PAGE_REF_MAP.search} />,
rss: <RssPage ref={PRIMARY_PAGE_REF_MAP.rss} />,
settings: <SettingsPrimaryPage ref={PRIMARY_PAGE_REF_MAP.settings} />,
home: (
<Suspense fallback={primaryPageLazyFallback}>
<ExplorePageLazy ref={PRIMARY_PAGE_REF_MAP.home} />
</Suspense>
),
feed: (
<Suspense fallback={primaryPageLazyFallback}>
<NoteListPageLazy ref={PRIMARY_PAGE_REF_MAP.feed} />
</Suspense>
),
me: (
<Suspense fallback={primaryPageLazyFallback}>
<MePageLazy ref={PRIMARY_PAGE_REF_MAP.me} />
</Suspense>
),
profile: (
<Suspense fallback={primaryPageLazyFallback}>
<ProfilePageLazy ref={PRIMARY_PAGE_REF_MAP.profile} />
</Suspense>
),
relay: (
<Suspense fallback={primaryPageLazyFallback}>
<RelayPageLazy ref={PRIMARY_PAGE_REF_MAP.relay} />
</Suspense>
),
search: (
<Suspense fallback={primaryPageLazyFallback}>
<SearchPageLazy ref={PRIMARY_PAGE_REF_MAP.search} />
</Suspense>
),
rss: (
<Suspense fallback={primaryPageLazyFallback}>
<RssPageLazy ref={PRIMARY_PAGE_REF_MAP.rss} />
</Suspense>
),
settings: (
<Suspense fallback={primaryPageLazyFallback}>
<SettingsPrimaryPageLazy ref={PRIMARY_PAGE_REF_MAP.settings} />
</Suspense>
),
spells: (
<Suspense
fallback={
<div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
Loading
</div>
}
>
<Suspense fallback={primaryPageLazyFallback}>
<SpellsPageLazy ref={PRIMARY_PAGE_REF_MAP.spells} />
</Suspense>
)
@ -443,7 +480,12 @@ export function useSmartHashtagNavigation() { @@ -443,7 +480,12 @@ export function useSmartHashtagNavigation() {
// Use a key based on the hashtag and navigation counter to force remounting when hashtag changes
// This ensures the component reads the new URL parameters when it mounts
// setPrimaryNoteView will increment the counter, so we use counter + 1 for the key
setPrimaryNoteView(<SecondaryNoteListPage key={key} hideTitlebar={true} />, 'hashtag')
setPrimaryNoteView(
<Suspense fallback={primaryPageLazyFallback}>
<SecondaryNoteListPageLazy key={key} hideTitlebar={true} />
</Suspense>,
'hashtag'
)
// Dispatch custom event as a fallback for components that might be reused
window.dispatchEvent(new CustomEvent('hashtag-navigation', { detail: { url: parsedUrl } }))
}
@ -1520,9 +1562,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1520,9 +1562,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
noteId={drawerNoteId}
/>
)}
<BottomNavigationBar />
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<Suspense fallback={null}>
<BottomNavigationBarLazy />
</Suspense>
<Suspense fallback={null}>
<TooManyRelaysAlertDialogLazy />
</Suspense>
<Suspense fallback={null}>
<CreateWalletGuideToastLazy />
</Suspense>
</NoteDrawerContext.Provider>
</PrimaryNoteViewContext.Provider>
</NotificationProvider>
@ -1562,7 +1610,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1562,7 +1610,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
maxWidth: '1920px'
}}
>
<Sidebar />
<Suspense fallback={null}>
<SidebarLazy />
</Suspense>
{(() => {
if (panelMode === 'double') {
// Double-pane mode: show feed on left (flexible, maintains width), secondary stack on right (1042px, same as drawer)
@ -1667,8 +1717,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1667,8 +1717,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</SheetContent>
</Sheet>
)}
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<Suspense fallback={null}>
<TooManyRelaysAlertDialogLazy />
</Suspense>
<Suspense fallback={null}>
<CreateWalletGuideToastLazy />
</Suspense>
</NoteDrawerContext.Provider>
</PrimaryNoteViewContext.Provider>
</NotificationProvider>
@ -1715,6 +1769,20 @@ function isCurrentPage(stack: TStackItem[], url: string) { @@ -1715,6 +1769,20 @@ function isCurrentPage(stack: TStackItem[], url: string) {
return currentPage.url === url
}
/** Route elements are `<Suspense><LazyPage /></Suspense>` — props must be applied to the lazy leaf, not Suspense. */
function cloneSecondaryRouteElement(
element: ReactElement,
props: Record<string, unknown>
): ReactElement {
if (element.type === Suspense) {
const inner = element.props.children
if (isValidElement(inner)) {
return cloneElement(element, undefined, cloneElement(inner, props as any))
}
}
return cloneElement(element, props as any)
}
function findAndCreateComponent(url: string, index: number) {
const path = url.split('?')[0].split('#')[0]
logger.component('PageManager', 'findAndCreateComponent called', { url, path, routes: routes.length })
@ -1739,7 +1807,7 @@ function findAndCreateComponent(url: string, index: number) { @@ -1739,7 +1807,7 @@ function findAndCreateComponent(url: string, index: number) {
logger.component('PageManager', 'Creating component with params', { params, index })
try {
const component = cloneElement(element, { ...params, index, ref } as any)
const component = cloneSecondaryRouteElement(element, { ...params, index, ref })
logger.component('PageManager', 'Component created successfully', { hasComponent: !!component })
return { component, ref }
} catch (error) {

11
src/components/NoteList/index.tsx

@ -161,6 +161,11 @@ const NoteList = forwardRef( @@ -161,6 +161,11 @@ const NoteList = forwardRef(
]
)
const shouldHideEventRef = useRef(shouldHideEvent)
useEffect(() => {
shouldHideEventRef.current = shouldHideEvent
}, [shouldHideEvent])
const filteredEvents = useMemo(() => {
const idSet = new Set<string>()
@ -292,6 +297,8 @@ const NoteList = forwardRef( @@ -292,6 +297,8 @@ const NoteList = forwardRef(
onEvents: (events: Event[], eosed: boolean) => {
if (events.length > 0) {
setEvents(events)
// Do not wait for full EOSE across many relays — otherwise loading/skeleton stays up for 10–30s+
setLoading(false)
// CRITICAL: Prefetch profiles for initial events (optimized for faster initial load)
// Only prefetch for first 50 events to reduce initial load time
@ -369,7 +376,7 @@ const NoteList = forwardRef( @@ -369,7 +376,7 @@ const NoteList = forwardRef(
if (!isReply && !showKind1OPs) return
}
if (event.kind === ExtendedKind.COMMENT && !showKind1111) return
if (shouldHideEvent(event)) return
if (shouldHideEventRef.current(event)) return
if (pubkey && event.pubkey === pubkey) {
// If the new event is from the current user, insert it directly into the feed
setEvents((oldEvents) =>
@ -414,7 +421,7 @@ const NoteList = forwardRef( @@ -414,7 +421,7 @@ const NoteList = forwardRef(
showKind1Replies,
showKind1111,
useFilterAsIs,
shouldHideEvent
areAlgoRelays
])
// Use refs to avoid dependency issues and ensure latest values in async callbacks

6
src/components/SimpleNoteFeed/index.tsx

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { RefreshCw } from 'lucide-react'
import { useNostr } from '@/providers/NostrProvider'
import { normalizeUrl } from '@/lib/url'
import { FAST_READ_RELAY_URLS } from '@/constants'
import { FAST_READ_RELAY_URLS, FIRST_RELAY_RESULT_GRACE_MS } from '@/constants'
import client from '@/services/client.service'
import { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
@ -87,7 +87,9 @@ const SimpleNoteFeed = forwardRef< @@ -87,7 +87,9 @@ const SimpleNoteFeed = forwardRef<
// Fetch events
logger.component('SimpleNoteFeed', 'Calling client.fetchEvents')
const { queryService } = await import('@/services/client.service')
const fetchedEvents = await queryService.fetchEvents(allRelays, [filter])
const fetchedEvents = await queryService.fetchEvents(allRelays, [filter], {
firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS
})
logger.component('SimpleNoteFeed', 'Fetched events', { count: fetchedEvents.length })

22
src/constants.ts

@ -14,6 +14,28 @@ export const DEFAULT_FAVORITE_RELAYS = [ @@ -14,6 +14,28 @@ export const DEFAULT_FAVORITE_RELAYS = [
'wss://nostr.land'
]
/** Multi-relay queries and timeline initial REQ: after the first event, wait this long then close (query) or finalize EOSE (live feed) while keeping the subscription open for new events. */
export const FIRST_RELAY_RESULT_GRACE_MS = 2000
/**
* Implicit query feed grace ({@link FIRST_RELAY_RESULT_GRACE_MS}) applies only when the largest `limit` among
* filters is at least this value. Omitting `limit` counts as 0 (no implicit grace).
*/
export const FEED_FIRST_RELAY_RESULT_GRACE_MIN_LIMIT = 200
/**
* Batched kind-0 queries (ReplaceableEventService) over many relays (inbox, favorites, cache, defaults).
* Too low causes empty profiles and NIP-05 gaps when relays are slow or many URLs are queried.
*/
export const METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS = 16000
export const METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS = 500
/**
* useFetchProfile: outer Promise.race on fetchProfileEvent and wait-for-shared-promise timeouts.
* Must be greater than {@link METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS} so the batch can finish first.
*/
export const PROFILE_FETCH_PROMISE_TIMEOUT_MS = 20000
export const RECOMMENDED_RELAYS = DEFAULT_FAVORITE_RELAYS.concat([])
export const RECOMMENDED_BLOSSOM_SERVERS = [

14
src/hooks/useFetchCalendarRsvps.tsx

@ -65,11 +65,15 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { @@ -65,11 +65,15 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
.then((relayUrls: string[] | undefined) => {
if (cancelled) return
const urls = relayUrls?.length ? relayUrls : Array.from(baseUrls)
return queryService.fetchEvents(urls, {
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP],
'#a': [coordinate],
limit: 200
})
return queryService.fetchEvents(
urls,
{
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP],
'#a': [coordinate],
limit: 200
},
{ firstRelayResultGraceMs: false }
)
})
.then((events) => {
if (cancelled) return

30
src/hooks/useFetchProfile.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import { PROFILE_FETCH_PROMISE_TIMEOUT_MS } from '@/constants'
import { getProfileFromEvent } from '@/lib/event-metadata'
import { userIdToPubkey } from '@/lib/pubkey'
import { useNostr } from '@/providers/NostrProvider'
@ -79,7 +80,7 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -79,7 +80,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
pubkey: pubkey.substring(0, 8)
})
resolve(null)
}, 5000) // 5 seconds
}, PROFILE_FETCH_PROMISE_TIMEOUT_MS)
})
const existingProfile = await Promise.race([existingPromise, timeoutPromise])
@ -135,8 +136,8 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -135,8 +136,8 @@ export function useFetchProfile(id?: string, skipCache = false) {
logger.warn('[useFetchProfile] Retry promise timeout, not starting duplicate fetch', {
pubkey: pubkey.substring(0, 8)
})
resolve(null)
}, 5000) // 5 seconds
resolve(null)
}, PROFILE_FETCH_PROMISE_TIMEOUT_MS)
})
const retryProfile = await Promise.race([retryPromise, timeoutPromise])
@ -183,12 +184,15 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -183,12 +184,15 @@ export function useFetchProfile(id?: string, skipCache = false) {
globalFetchingPubkeys.add(pubkey)
const startTime = Date.now()
// CRITICAL: Add timeout to prevent infinite hangs
// Use Promise.race to timeout after 5 seconds
// CRITICAL: Add timeout to prevent infinite hangs (must exceed batched metadata query globalTimeout)
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Profile fetch timeout after 5s for pubkey ${pubkey.substring(0, 8)}`))
}, 5000) // 5 second timeout
reject(
new Error(
`Profile fetch timeout after ${PROFILE_FETCH_PROMISE_TIMEOUT_MS}ms for pubkey ${pubkey.substring(0, 8)}`
)
)
}, PROFILE_FETCH_PROMISE_TIMEOUT_MS)
})
// Use fetchProfileEvent which includes author's relay list for better profile discovery
@ -208,11 +212,6 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -208,11 +212,6 @@ export function useFetchProfile(id?: string, skipCache = false) {
})
}
if (cancelled.current) {
logger.info('[useFetchProfile] Fetch cancelled after fetch', { pubkey })
return null
}
if (profileEvent) {
// getProfileFromEvent always returns a profile object (with fallback username)
const newProfile = getProfileFromEvent(profileEvent)
@ -221,8 +220,13 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -221,8 +220,13 @@ export function useFetchProfile(id?: string, skipCache = false) {
pubkey: pubkey.substring(0, 8),
username: newProfile.username,
hasAvatar: !!newProfile.avatar,
fetchTime: `${fetchTime}ms`
fetchTime: `${fetchTime}ms`,
unmounted: cancelled.current
})
// CRITICAL: Always return the profile from this shared promise, even when the
// originating hook cleaned up (list virtualization, Strict Mode, feed switch).
// Returning null here made every waiter treat the result like a timeout, applied
// cooldowns, and left avatars empty (especially busy feeds e.g. all-favorites).
return newProfile
}
// Only log warnings for missing profiles if skipCache is true (user explicitly requested)

10
src/lib/spell-list-import.ts

@ -180,8 +180,14 @@ export async function resolveSpellListATags( @@ -180,8 +180,14 @@ export async function resolveSpellListATags(
try {
const events =
relays.length > 0
? await queryService.fetchEvents(relays, filter, { globalTimeout: 12_000 })
: await queryService.fetchEvents([], filter, { globalTimeout: 12_000 })
? await queryService.fetchEvents(relays, filter, {
globalTimeout: 12_000,
firstRelayResultGraceMs: false
})
: await queryService.fetchEvents([], filter, {
globalTimeout: 12_000,
firstRelayResultGraceMs: false
})
if (!events.length) {
notices.push(`listImportATagNotFound:${at.slice(0, 48)}`)
return

56
src/pages/primary/DiscussionsPage/index.tsx

@ -422,12 +422,16 @@ const DiscussionsPage = forwardRef<TPageRef, { embedded?: boolean }>(function Di @@ -422,12 +422,16 @@ const DiscussionsPage = forwardRef<TPageRef, { embedded?: boolean }>(function Di
logger.debug('[DiscussionsPage] Using relays:', allRelays.slice(0, 10), '... (total:', allRelays.length, ')')
// Step 1: Fetch all discussion threads (kind 11)
const discussionThreads = await queryService.fetchEvents(allRelays, [
{
kinds: [11], // ExtendedKind.DISCUSSION
limit: 500 // Increased from 100 to load more threads per request
}
])
const discussionThreads = await queryService.fetchEvents(
allRelays,
[
{
kinds: [11], // ExtendedKind.DISCUSSION
limit: 500 // Increased from 100 to load more threads per request
}
],
{ firstRelayResultGraceMs: false }
)
logger.debug('[DiscussionsPage] Fetched', discussionThreads.length, 'discussion threads')
if (discussionThreads.length > 0) {
@ -456,20 +460,32 @@ const DiscussionsPage = forwardRef<TPageRef, { embedded?: boolean }>(function Di @@ -456,20 +460,32 @@ const DiscussionsPage = forwardRef<TPageRef, { embedded?: boolean }>(function Di
const allThreadIdsArray = Array.from(allThreadIds)
const [comments, reactions] = await Promise.all([
allThreadIdsArray.length > 0 ? queryService.fetchEvents(allRelays, [
{
kinds: [1111], // ExtendedKind.COMMENT
'#e': allThreadIdsArray,
limit: 500 // Increased from 100 to load more comments per request
}
]) : Promise.resolve([]),
allThreadIdsArray.length > 0 ? queryService.fetchEvents(allRelays, [
{
kinds: [kinds.Reaction],
'#e': allThreadIdsArray,
limit: 500 // Increased from 100 to load more reactions per request
}
]) : Promise.resolve([])
allThreadIdsArray.length > 0
? queryService.fetchEvents(
allRelays,
[
{
kinds: [1111], // ExtendedKind.COMMENT
'#e': allThreadIdsArray,
limit: 500 // Increased from 100 to load more comments per request
}
],
{ firstRelayResultGraceMs: false }
)
: Promise.resolve([]),
allThreadIdsArray.length > 0
? queryService.fetchEvents(
allRelays,
[
{
kinds: [kinds.Reaction],
'#e': allThreadIdsArray,
limit: 500 // Increased from 100 to load more reactions per request
}
],
{ firstRelayResultGraceMs: false }
)
: Promise.resolve([])
])
logger.debug('[DiscussionsPage] Fetched', comments.length, 'comments and', reactions.length, 'reactions for', allThreadIdsArray.length, 'threads (', threadIds.length, 'new,', (cachedDataBeforeFetch?.eventMap.size || 0), 'cached)')

121
src/routes.tsx

@ -1,58 +1,75 @@ @@ -1,58 +1,75 @@
import { match } from 'path-to-regexp'
import { isValidElement } from 'react'
import FollowingListPage from './pages/secondary/FollowingListPage'
import GeneralSettingsPage from './pages/secondary/GeneralSettingsPage'
import MuteListPage from './pages/secondary/MuteListPage'
import NoteListPage from './pages/secondary/NoteListPage'
import NotePage from './pages/secondary/NotePage'
import OthersRelaySettingsPage from './pages/secondary/OthersRelaySettingsPage'
import PostSettingsPage from './pages/secondary/PostSettingsPage'
import ProfileEditorPage from './pages/secondary/ProfileEditorPage'
import ProfileListPage from './pages/secondary/ProfileListPage'
import ProfilePage from './pages/secondary/ProfilePage'
import RelayPage from './pages/secondary/RelayPage'
import RelayReviewsPage from './pages/secondary/RelayReviewsPage'
import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
import CacheSettingsPage from './pages/secondary/CacheSettingsPage'
import RssFeedSettingsPage from './pages/secondary/RssFeedSettingsPage'
import SearchPage from './pages/secondary/SearchPage'
import SettingsPage from './pages/secondary/SettingsPage'
import TranslationPage from './pages/secondary/TranslationPage'
import WalletPage from './pages/secondary/WalletPage'
import FollowPacksRedirect from './pages/secondary/FollowPacksRedirect'
import {
isValidElement,
lazy,
Suspense,
type ComponentType,
type LazyExoticComponent,
type ReactElement
} from 'react'
/** Lazy + Suspense so importing `routes` does not sync-pull pages that depend on PageManager (breaks Vite HMR cycles). */
const FollowingListPageLazy = lazy(() => import('./pages/secondary/FollowingListPage'))
const GeneralSettingsPageLazy = lazy(() => import('./pages/secondary/GeneralSettingsPage'))
const MuteListPageLazy = lazy(() => import('./pages/secondary/MuteListPage'))
const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage'))
const NotePageLazy = lazy(() => import('./pages/secondary/NotePage'))
const OthersRelaySettingsPageLazy = lazy(() => import('./pages/secondary/OthersRelaySettingsPage'))
const PostSettingsPageLazy = lazy(() => import('./pages/secondary/PostSettingsPage'))
const ProfileEditorPageLazy = lazy(() => import('./pages/secondary/ProfileEditorPage'))
const ProfileListPageLazy = lazy(() => import('./pages/secondary/ProfileListPage'))
const ProfilePageLazy = lazy(() => import('./pages/secondary/ProfilePage'))
const RelayPageLazy = lazy(() => import('./pages/secondary/RelayPage'))
const RelayReviewsPageLazy = lazy(() => import('./pages/secondary/RelayReviewsPage'))
const RelaySettingsPageLazy = lazy(() => import('./pages/secondary/RelaySettingsPage'))
const CacheSettingsPageLazy = lazy(() => import('./pages/secondary/CacheSettingsPage'))
const RssFeedSettingsPageLazy = lazy(() => import('./pages/secondary/RssFeedSettingsPage'))
const SearchPageLazy = lazy(() => import('./pages/secondary/SearchPage'))
const SettingsPageLazy = lazy(() => import('./pages/secondary/SettingsPage'))
const TranslationPageLazy = lazy(() => import('./pages/secondary/TranslationPage'))
const WalletPageLazy = lazy(() => import('./pages/secondary/WalletPage'))
const FollowPacksRedirectLazy = lazy(() => import('./pages/secondary/FollowPacksRedirect'))
const routeSuspenseFallback = null
function SR(C: LazyExoticComponent<ComponentType<Record<string, unknown>>>): ReactElement {
return (
<Suspense fallback={routeSuspenseFallback}>
<C />
</Suspense>
)
}
const ROUTES = [
{ path: '/notes', element: <NoteListPage /> },
{ path: '/notes/:id', element: <NotePage /> },
// Contextual note routes (e.g., /discussions/notes/:id, /search/notes/:id)
{ path: '/discussions/notes/:id', element: <NotePage /> },
{ path: '/search/notes/:id', element: <NotePage /> },
{ path: '/profile/notes/:id', element: <NotePage /> },
{ path: '/explore/notes/:id', element: <NotePage /> },
{ path: '/home/notes/:id', element: <NotePage /> },
{ path: '/feed/notes/:id', element: <NotePage /> },
{ path: '/spells/notes/:id', element: <NotePage /> },
{ path: '/users', element: <ProfileListPage /> },
{ path: '/users/:id', element: <ProfilePage /> },
{ path: '/users/:id/following', element: <FollowingListPage /> },
{ path: '/users/:id/relays', element: <OthersRelaySettingsPage /> },
{ path: '/relays/:url', element: <RelayPage /> },
{ path: '/relays/:url/reviews', element: <RelayReviewsPage /> },
// Contextual relay routes (home = explore; legacy /explore)
{ path: '/home/relays/:url', element: <RelayPage /> },
{ path: '/explore/relays/:url', element: <RelayPage /> },
{ path: '/search', element: <SearchPage /> },
{ path: '/settings', element: <SettingsPage /> },
{ path: '/settings/relays', element: <RelaySettingsPage /> },
{ path: '/settings/cache', element: <CacheSettingsPage /> },
{ path: '/settings/wallet', element: <WalletPage /> },
{ path: '/settings/posts', element: <PostSettingsPage /> },
{ path: '/settings/general', element: <GeneralSettingsPage /> },
{ path: '/settings/translation', element: <TranslationPage /> },
{ path: '/settings/rss-feeds', element: <RssFeedSettingsPage /> },
{ path: '/profile-editor', element: <ProfileEditorPage /> },
{ path: '/mutes', element: <MuteListPage /> },
{ path: '/follow-packs', element: <FollowPacksRedirect /> }
{ path: '/notes', element: SR(NoteListPageLazy) },
{ path: '/notes/:id', element: SR(NotePageLazy) },
{ path: '/discussions/notes/:id', element: SR(NotePageLazy) },
{ path: '/search/notes/:id', element: SR(NotePageLazy) },
{ path: '/profile/notes/:id', element: SR(NotePageLazy) },
{ path: '/explore/notes/:id', element: SR(NotePageLazy) },
{ path: '/home/notes/:id', element: SR(NotePageLazy) },
{ path: '/feed/notes/:id', element: SR(NotePageLazy) },
{ path: '/spells/notes/:id', element: SR(NotePageLazy) },
{ path: '/users', element: SR(ProfileListPageLazy) },
{ path: '/users/:id', element: SR(ProfilePageLazy) },
{ path: '/users/:id/following', element: SR(FollowingListPageLazy) },
{ path: '/users/:id/relays', element: SR(OthersRelaySettingsPageLazy) },
{ path: '/relays/:url', element: SR(RelayPageLazy) },
{ path: '/relays/:url/reviews', element: SR(RelayReviewsPageLazy) },
{ path: '/home/relays/:url', element: SR(RelayPageLazy) },
{ path: '/explore/relays/:url', element: SR(RelayPageLazy) },
{ path: '/search', element: SR(SearchPageLazy) },
{ path: '/settings', element: SR(SettingsPageLazy) },
{ path: '/settings/relays', element: SR(RelaySettingsPageLazy) },
{ path: '/settings/cache', element: SR(CacheSettingsPageLazy) },
{ path: '/settings/wallet', element: SR(WalletPageLazy) },
{ path: '/settings/posts', element: SR(PostSettingsPageLazy) },
{ path: '/settings/general', element: SR(GeneralSettingsPageLazy) },
{ path: '/settings/translation', element: SR(TranslationPageLazy) },
{ path: '/settings/rss-feeds', element: SR(RssFeedSettingsPageLazy) },
{ path: '/profile-editor', element: SR(ProfileEditorPageLazy) },
{ path: '/mutes', element: SR(MuteListPageLazy) },
{ path: '/follow-packs', element: SR(FollowPacksRedirectLazy) }
]
export const routes = ROUTES.map(({ path, element }) => ({

52
src/services/client-query.service.ts

@ -1,4 +1,9 @@ @@ -1,4 +1,9 @@
import { KIND_1_BLOCKED_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import {
FEED_FIRST_RELAY_RESULT_GRACE_MIN_LIMIT,
FIRST_RELAY_RESULT_GRACE_MS,
KIND_1_BLOCKED_RELAY_URLS,
SEARCHABLE_RELAY_URLS
} from '@/constants'
import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import type { Filter, Event as NEvent } from 'nostr-tools'
@ -23,6 +28,13 @@ export interface QueryOptions { @@ -23,6 +28,13 @@ export interface QueryOptions {
replaceableRaceWaitMs?: number
/** For non-replaceable single events: return immediately on first match */
immediateReturn?: boolean
/**
* Multi-relay feed / batch: after first event, wait this many ms then close and return.
* `false` disables (wait for normal EOSE / global timeout). When omitted, implicit grace uses
* {@link FIRST_RELAY_RESULT_GRACE_MS} only if the largest filter `limit` is at least
* {@link FEED_FIRST_RELAY_RESULT_GRACE_MIN_LIMIT} (and not replaceableRace / immediateReturn / single-event fetch).
*/
firstRelayResultGraceMs?: number | false
}
export interface SubscribeCallbacks {
@ -115,7 +127,7 @@ export class QueryService { @@ -115,7 +127,7 @@ export class QueryService {
const eoseTimeout = options?.eoseTimeout ?? 500
const globalTimeout = options?.globalTimeout ?? 10000
const replaceableRace = options?.replaceableRace ?? false
const replaceableRaceWaitMs = options?.replaceableRaceWaitMs ?? 2000
const replaceableRaceWaitMs = options?.replaceableRaceWaitMs ?? FIRST_RELAY_RESULT_GRACE_MS
const immediateReturn = options?.immediateReturn ?? false
const isExternalSearch = eoseTimeout > 1000
@ -131,12 +143,25 @@ export class QueryService { @@ -131,12 +143,25 @@ export class QueryService {
})
}
const FIRST_RESULT_GRACE_MS = 1200
const filtersForGrace = Array.isArray(filter) ? filter : [filter]
const maxLimitForGrace = Math.max(...filtersForGrace.map((f) => (f.limit ?? 0) as number), 0)
const isSingleEventFetchForGrace = maxLimitForGrace === 1
const useImplicitFeedFirstRelayGrace =
maxLimitForGrace >= FEED_FIRST_RELAY_RESULT_GRACE_MIN_LIMIT && !isSingleEventFetchForGrace
const feedGraceMsResolved: number | null =
options?.firstRelayResultGraceMs === false
? null
: typeof options?.firstRelayResultGraceMs === 'number'
? options.firstRelayResultGraceMs
: !replaceableRace && !immediateReturn && useImplicitFeedFirstRelayGrace
? FIRST_RELAY_RESULT_GRACE_MS
: null
return await new Promise<NEvent[]>((resolve) => {
const events: NEvent[] = []
let resolveTimeout: ReturnType<typeof setTimeout> | null = null
let firstResultGraceTimeoutId: ReturnType<typeof setTimeout> | null = null
let feedFirstResultGraceTimeoutId: ReturnType<typeof setTimeout> | null = null
let replaceableRaceTimeoutId: ReturnType<typeof setTimeout> | null = null
let allEosed = false
let eventCount = 0
@ -178,6 +203,7 @@ export class QueryService { @@ -178,6 +203,7 @@ export class QueryService {
resolved = true
if (resolveTimeout) clearTimeout(resolveTimeout)
if (firstResultGraceTimeoutId) clearTimeout(firstResultGraceTimeoutId)
if (feedFirstResultGraceTimeoutId) clearTimeout(feedFirstResultGraceTimeoutId)
if (replaceableRaceTimeoutId) clearTimeout(replaceableRaceTimeoutId)
if (globalTimeoutId) clearTimeout(globalTimeoutId)
@ -220,11 +246,23 @@ export class QueryService { @@ -220,11 +246,23 @@ export class QueryService {
}, replaceableRaceWaitMs)
}
if (
feedGraceMsResolved != null &&
events.length >= 1 &&
!feedFirstResultGraceTimeoutId &&
!replaceableRace
) {
feedFirstResultGraceTimeoutId = setTimeout(() => {
feedFirstResultGraceTimeoutId = null
resolveWithEvents()
}, feedGraceMsResolved)
}
if (!replaceableRace && !immediateReturn && isSingleEventFetch && events.length === 1 && !firstResultGraceTimeoutId) {
firstResultGraceTimeoutId = setTimeout(() => {
firstResultGraceTimeoutId = null
resolveWithEvents()
}, FIRST_RESULT_GRACE_MS)
}, FIRST_RELAY_RESULT_GRACE_MS)
}
if (hasIdFilter && isSingleEventFetch && events.length > 0 && allEosed && !replaceableRace && !immediateReturn) {
@ -251,6 +289,7 @@ export class QueryService { @@ -251,6 +289,7 @@ export class QueryService {
}
if (firstResultGraceTimeoutId) clearTimeout(firstResultGraceTimeoutId)
if (feedFirstResultGraceTimeoutId) clearTimeout(feedFirstResultGraceTimeoutId)
if (resolveTimeout) clearTimeout(resolveTimeout)
resolveTimeout = setTimeout(() => resolveWithEvents(), eoseTimeout)
}
@ -446,8 +485,6 @@ export class QueryService { @@ -446,8 +485,6 @@ export class QueryService {
filter: Filter | Filter[],
options?: {
onevent?: (evt: NEvent) => void
eoseTimeout?: number
globalTimeout?: number
} & QueryOptions
): Promise<NEvent[]> {
let relays = Array.from(new Set(urls))
@ -461,6 +498,7 @@ export class QueryService { @@ -461,6 +498,7 @@ export class QueryService {
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url))
}
return this.query(relays, filter, options?.onevent, options)
const { onevent, ...queryOpts } = options ?? {}
return this.query(relays, filter, onevent, queryOpts)
}
}

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

@ -1,4 +1,10 @@ @@ -1,4 +1,10 @@
import { ExtendedKind, FAST_READ_RELAY_URLS, PROFILE_FETCH_RELAY_URLS } from '@/constants'
import {
ExtendedKind,
FAST_READ_RELAY_URLS,
METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS,
PROFILE_FETCH_RELAY_URLS
} from '@/constants'
import { kinds, nip19 } from 'nostr-tools'
import type { Event as NEvent, Filter } from 'nostr-tools'
import DataLoader from 'dataloader'
@ -105,7 +111,7 @@ export class ReplaceableEventService { @@ -105,7 +111,7 @@ export class ReplaceableEventService {
containingEventRelays: string[] = []
): Promise<NEvent | undefined> {
const cacheKey = d ? `${kind}:${pubkey}:${d}` : `${kind}:${pubkey}`
logger.info('[ReplaceableEventService] fetchReplaceableEvent start', {
logger.debug('[ReplaceableEventService] fetchReplaceableEvent start', {
pubkey,
kind,
d,
@ -119,14 +125,14 @@ export class ReplaceableEventService { @@ -119,14 +125,14 @@ export class ReplaceableEventService {
let event: NEvent | undefined
if (containingEventRelays.length > 0 && kind === kinds.Metadata && !d) {
// For profiles with containing event relays (author's relay list), check IndexedDB first, then query directly
logger.info('[ReplaceableEventService] Checking IndexedDB for profile with containing relays', {
logger.debug('[ReplaceableEventService] Checking IndexedDB for profile with containing relays', {
pubkey,
kind
})
try {
const indexedDbCached = await indexedDb.getReplaceableEvent(pubkey, kind, d)
if (indexedDbCached) {
logger.info('[ReplaceableEventService] Found in IndexedDB', {
logger.debug('[ReplaceableEventService] Found in IndexedDB', {
pubkey,
kind,
eventId: indexedDbCached.id
@ -144,27 +150,32 @@ export class ReplaceableEventService { @@ -144,27 +150,32 @@ export class ReplaceableEventService {
}
// Not in IndexedDB, fetch from network with custom relay list
logger.info('[ReplaceableEventService] Building relay list with containing event relays', {
logger.debug('[ReplaceableEventService] Building relay list with containing event relays', {
pubkey,
containingRelayCount: containingEventRelays.length
})
const relayUrls = await this.buildComprehensiveRelayListForAuthor(pubkey, kind, containingEventRelays, [])
logger.info('[ReplaceableEventService] Querying relays', {
logger.debug('[ReplaceableEventService] Querying relays', {
pubkey,
relayCount: relayUrls.length,
relays: relayUrls.slice(0, 5)
})
const startTime = Date.now()
const events = await this.queryService.query(relayUrls, {
authors: [pubkey],
kinds: [kind]
}, undefined, {
replaceableRace: true,
eoseTimeout: 100, // Reduced from 200ms for faster early returns
globalTimeout: 2000 // Reduced from 3000ms to prevent long waits when many relays are slow
})
const events = await this.queryService.query(
relayUrls,
{
authors: [pubkey],
kinds: [kind]
},
undefined,
{
replaceableRace: true,
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS
}
)
const queryTime = Date.now() - startTime
logger.info('[ReplaceableEventService] Query completed', {
logger.debug('[ReplaceableEventService] Query completed', {
pubkey,
eventCount: events.length,
queryTime: `${queryTime}ms`
@ -173,7 +184,7 @@ export class ReplaceableEventService { @@ -173,7 +184,7 @@ export class ReplaceableEventService {
event = sortedEvents.length > 0 ? sortedEvents[0] : undefined
} else {
// Use DataLoader for batching (IndexedDB checks and network fetches are batched)
logger.info('[ReplaceableEventService] Using DataLoader (batches IndexedDB + network)', {
logger.debug('[ReplaceableEventService] Using DataLoader (batches IndexedDB + network)', {
pubkey,
kind,
d
@ -183,7 +194,7 @@ export class ReplaceableEventService { @@ -183,7 +194,7 @@ export class ReplaceableEventService {
? await this.replaceableEventDataLoader.load({ pubkey, kind, d })
: await this.replaceableEventFromBigRelaysDataloader.load({ pubkey, kind })
const loadTime = Date.now() - startTime
logger.info('[ReplaceableEventService] DataLoader completed', {
logger.debug('[ReplaceableEventService] DataLoader completed', {
pubkey,
found: !!loadedEvent,
loadTime: `${loadTime}ms`
@ -192,7 +203,7 @@ export class ReplaceableEventService { @@ -192,7 +203,7 @@ export class ReplaceableEventService {
}
if (event) {
logger.info('[ReplaceableEventService] Event found', {
logger.debug('[ReplaceableEventService] Event found', {
pubkey,
kind,
eventId: event.id,
@ -225,7 +236,7 @@ export class ReplaceableEventService { @@ -225,7 +236,7 @@ export class ReplaceableEventService {
}
}
logger.info('[ReplaceableEventService] fetchReplaceableEvent returning undefined', {
logger.debug('[ReplaceableEventService] fetchReplaceableEvent returning undefined', {
pubkey,
kind
})
@ -316,7 +327,7 @@ export class ReplaceableEventService { @@ -316,7 +327,7 @@ export class ReplaceableEventService {
): Promise<(NEvent | null)[]> {
// CRITICAL: Reduce logging during rapid scrolling - only log large batches
if (params.length > 50) {
logger.info('[ReplaceableEventService] Large batch load function called', {
logger.debug('[ReplaceableEventService] Large batch load function called', {
paramCount: params.length,
kind: params[0]?.kind
})
@ -399,7 +410,7 @@ export class ReplaceableEventService { @@ -399,7 +410,7 @@ export class ReplaceableEventService {
// Only log at info level for large batches
if (missingParams.length > 50) {
logger.info('[ReplaceableEventService] Fetching missing events from network', {
logger.debug('[ReplaceableEventService] Fetching missing events from network', {
missingCount: missingParams.length,
totalCount: params.length
})
@ -427,14 +438,38 @@ export class ReplaceableEventService { @@ -427,14 +438,38 @@ export class ReplaceableEventService {
// For each pubkey, build comprehensive relay list
// CRITICAL FIX: For batch fetches, use default relays instead of fetching relay lists for each author
// Fetching relay lists for hundreds of authors causes infinite loops and browser crashes
// Use PROFILE_FETCH_RELAY_URLS + FAST_READ_RELAY_URLS for profiles, or FAST_READ_RELAY_URLS for other kinds
const relayUrls = kind === kinds.Metadata
? Array.from(new Set([...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS]))
: [...FAST_READ_RELAY_URLS]
// Use PROFILE_FETCH_RELAY_URLS + FAST_READ_RELAY_URLS for profiles, or FAST_READ_RELAY_URLS for other kinds.
// For metadata with a logged-in user, merge defaults with {@link buildComprehensiveRelayList}: inboxes (read),
// local/cache relays (10432), favorite relays (10012), plus profile + fast read — same idea as favorites feed
// / inbox-scoped discovery without per-author relay list fetches.
let relayUrls: string[]
if (kind === kinds.Metadata) {
const userPk = client.pubkey
if (userPk) {
try {
relayUrls = await buildComprehensiveRelayList({
userPubkey: userPk,
includeUserOwnRelays: false,
includeProfileFetchRelays: true,
includeFastReadRelays: true,
includeFavoriteRelays: true,
includeLocalRelays: true,
includeFastWriteRelays: false,
includeSearchableRelays: false
})
} catch {
relayUrls = Array.from(new Set([...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS]))
}
} else {
relayUrls = Array.from(new Set([...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS]))
}
} else {
relayUrls = [...FAST_READ_RELAY_URLS]
}
// Only log at info level for large batches
if (pubkeys.length > 50) {
logger.info('[ReplaceableEventService] Starting query for large batch', {
logger.debug('[ReplaceableEventService] Starting query for large batch', {
kind,
pubkeyCount: pubkeys.length,
relayCount: relayUrls.length
@ -446,17 +481,23 @@ export class ReplaceableEventService { @@ -446,17 +481,23 @@ export class ReplaceableEventService {
relayCount: relayUrls.length
})
}
const events = await this.queryService.query(relayUrls, {
authors: pubkeys,
kinds: [kind]
}, undefined, {
replaceableRace: true,
eoseTimeout: 100, // Reduced from 200ms for faster early returns
globalTimeout: 2000 // Reduced from 3000ms to prevent long waits when many relays are slow
})
const isMetadataBatch = kind === kinds.Metadata
const events = await this.queryService.query(
relayUrls,
{
authors: pubkeys,
kinds: [kind]
},
undefined,
{
replaceableRace: true,
eoseTimeout: isMetadataBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100,
globalTimeout: isMetadataBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000
}
)
// Only log at info level for large batches or if many events found
if (pubkeys.length > 50 || events.length > 100) {
logger.info('[ReplaceableEventService] Query completed for batch', {
logger.debug('[ReplaceableEventService] Query completed for batch', {
kind,
pubkeyCount: pubkeys.length,
eventCount: events.length
@ -488,7 +529,7 @@ export class ReplaceableEventService { @@ -488,7 +529,7 @@ export class ReplaceableEventService {
}
// Convert back to array, but limit to reasonable size
const limitedEvents = Array.from(eventsByPubkey.values()).slice(0, 500)
logger.info('[ReplaceableEventService] Limited batch size', {
logger.debug('[ReplaceableEventService] Limited batch size', {
originalCount: events.length,
limitedCount: limitedEvents.length
})
@ -550,7 +591,7 @@ export class ReplaceableEventService { @@ -550,7 +591,7 @@ export class ReplaceableEventService {
// Only log at info level for large batches
if (params.length > 50) {
logger.info('[ReplaceableEventService] Batch load function completed', {
logger.debug('[ReplaceableEventService] Batch load function completed', {
paramCount: params.length,
foundCount: results.filter(r => r !== null).length,
indexedDbCount: params.length - missingParams.length,
@ -645,17 +686,17 @@ export class ReplaceableEventService { @@ -645,17 +686,17 @@ export class ReplaceableEventService {
* Fetch profile event by id (hex, npub, nprofile)
*/
async fetchProfileEvent(id: string, _skipCache: boolean = false): Promise<NEvent | undefined> {
logger.info('[ReplaceableEventService] fetchProfileEvent start', { id })
logger.debug('[ReplaceableEventService] fetchProfileEvent start', { id })
let pubkey: string | undefined
let relays: string[] = []
if (/^[0-9a-f]{64}$/.test(id)) {
pubkey = id
logger.info('[ReplaceableEventService] ID is hex pubkey', { pubkey })
logger.debug('[ReplaceableEventService] ID is hex pubkey', { pubkey })
} else {
try {
const { data, type } = nip19.decode(id)
logger.info('[ReplaceableEventService] Decoded bech32 ID', { type })
logger.debug('[ReplaceableEventService] Decoded bech32 ID', { type })
switch (type) {
case 'npub':
pubkey = data
@ -663,7 +704,7 @@ export class ReplaceableEventService { @@ -663,7 +704,7 @@ export class ReplaceableEventService {
case 'nprofile':
pubkey = data.pubkey
if (data.relays) relays = data.relays
logger.info('[ReplaceableEventService] nprofile has relay hints', { relayCount: relays.length })
logger.debug('[ReplaceableEventService] nprofile has relay hints', { relayCount: relays.length })
break
}
} catch (error) {
@ -687,7 +728,7 @@ export class ReplaceableEventService { @@ -687,7 +728,7 @@ export class ReplaceableEventService {
// CRITICAL: Do NOT pass relay hints here - passing any relays bypasses DataLoader and creates individual subscriptions
// DataLoader already uses default relays internally and batches all profile fetches
// We'll use relay hints in Step 2/3 only if Step 1 fails
logger.info('[ReplaceableEventService] Step 1: Trying with DataLoader (checks cache first, uses default relays, batched)', {
logger.debug('[ReplaceableEventService] Step 1: Trying with DataLoader (checks cache first, uses default relays, batched)', {
pubkey,
relayHintCount: relayHints.length,
hasRelayHints: relayHints.length > 0
@ -698,7 +739,7 @@ export class ReplaceableEventService { @@ -698,7 +739,7 @@ export class ReplaceableEventService {
const profileEvent = await this.fetchReplaceableEvent(pubkey, kinds.Metadata, undefined, [])
if (profileEvent) {
logger.info('[ReplaceableEventService] Profile found with relay hints + default relays', {
logger.debug('[ReplaceableEventService] Profile found with relay hints + default relays', {
pubkey,
eventId: profileEvent.id
})
@ -710,7 +751,7 @@ export class ReplaceableEventService { @@ -710,7 +751,7 @@ export class ReplaceableEventService {
// This prevents creating many individual subscriptions when profiles aren't found
// If we have relay hints, it's worth trying author relays. Otherwise, Step 1 should be sufficient.
if (relayHints.length > 0) {
logger.info('[ReplaceableEventService] Step 2: Profile not found, but we have relay hints - fetching author relay list as fallback', {
logger.debug('[ReplaceableEventService] Step 2: Profile not found, but we have relay hints - fetching author relay list as fallback', {
pubkey,
relayHintCount: relayHints.length
})
@ -730,7 +771,7 @@ export class ReplaceableEventService { @@ -730,7 +771,7 @@ export class ReplaceableEventService {
})
authorRelayList = await Promise.race([relayListPromise, timeoutPromise])
const relayListTime = Date.now() - relayListStartTime
logger.info('[ReplaceableEventService] Author relay list fetched', {
logger.debug('[ReplaceableEventService] Author relay list fetched', {
pubkey,
hasRelayList: !!authorRelayList,
fetchTime: `${relayListTime}ms`
@ -757,7 +798,7 @@ export class ReplaceableEventService { @@ -757,7 +798,7 @@ export class ReplaceableEventService {
...FAST_READ_RELAY_URLS // Fast read relays
])]
logger.info('[ReplaceableEventService] Step 3: Trying with relay hints + author relays', {
logger.debug('[ReplaceableEventService] Step 3: Trying with relay hints + author relays', {
pubkey,
relayHintCount: relayHints.length,
authorRelayCount: authorRelays.length,
@ -773,7 +814,7 @@ export class ReplaceableEventService { @@ -773,7 +814,7 @@ export class ReplaceableEventService {
)
if (profileEventFromAuthorRelays) {
logger.info('[ReplaceableEventService] Profile found with relay hints + author relays', {
logger.debug('[ReplaceableEventService] Profile found with relay hints + author relays', {
pubkey,
eventId: profileEventFromAuthorRelays.id
})
@ -795,7 +836,7 @@ export class ReplaceableEventService { @@ -795,7 +836,7 @@ export class ReplaceableEventService {
// when user explicitly navigates to a profile page. For feed rendering, missing profiles are acceptable.
// Only run comprehensive search if we have relay hints (suggesting user intent to find this specific profile)
if (relayHints.length > 0) {
logger.info('[ReplaceableEventService] Step 3: Profile not found, trying comprehensive relay list (all available relays)', {
logger.debug('[ReplaceableEventService] Step 3: Profile not found, trying comprehensive relay list (all available relays)', {
pubkey,
hasRelayHints: relayHints.length > 0
})
@ -815,7 +856,7 @@ export class ReplaceableEventService { @@ -815,7 +856,7 @@ export class ReplaceableEventService {
includeLocalRelays: true // Include local/cache relays
})
logger.info('[ReplaceableEventService] Comprehensive relay list built', {
logger.debug('[ReplaceableEventService] Comprehensive relay list built', {
pubkey,
relayCount: comprehensiveRelays.length,
relays: comprehensiveRelays.slice(0, 10) // Log first 10 for debugging
@ -834,7 +875,7 @@ export class ReplaceableEventService { @@ -834,7 +875,7 @@ export class ReplaceableEventService {
})
const queryTime = Date.now() - startTime
logger.info('[ReplaceableEventService] Comprehensive search completed', {
logger.debug('[ReplaceableEventService] Comprehensive search completed', {
pubkey,
eventCount: events.length,
queryTime: `${queryTime}ms`,
@ -844,7 +885,7 @@ export class ReplaceableEventService { @@ -844,7 +885,7 @@ export class ReplaceableEventService {
if (events.length > 0) {
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const profileEvent = sortedEvents[0]
logger.info('[ReplaceableEventService] Profile found via comprehensive search', {
logger.debug('[ReplaceableEventService] Profile found via comprehensive search', {
pubkey,
eventId: profileEvent.id
})

211
src/services/client.service.ts

@ -1,4 +1,14 @@ @@ -1,4 +1,14 @@
import { FAST_READ_RELAY_URLS, ExtendedKind, FAST_WRITE_RELAY_URLS, KIND_1_BLOCKED_RELAY_URLS, NIP66_DISCOVERY_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, READ_ONLY_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import {
FAST_READ_RELAY_URLS,
ExtendedKind,
FAST_WRITE_RELAY_URLS,
FIRST_RELAY_RESULT_GRACE_MS,
KIND_1_BLOCKED_RELAY_URLS,
NIP66_DISCOVERY_RELAY_URLS,
PROFILE_FETCH_RELAY_URLS,
READ_ONLY_RELAY_URLS,
SEARCHABLE_RELAY_URLS
} from '@/constants'
/** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */
function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter {
@ -1004,6 +1014,15 @@ class ClientService extends EventTarget { @@ -1004,6 +1014,15 @@ class ClientService extends EventTarget {
return { url, filters: filtersForRelay }
})
// Kind-1 queries drop KIND_1_BLOCKED_RELAY_URLS; if every URL was removed, no subs run and
// oneose would never fire — timelines stay loading forever (e.g. favorites feed).
if (groupedRequests.length === 0) {
queueMicrotask(() => oneose?.(true))
return {
close: () => {}
}
}
const eosesReceived: boolean[] = []
const closesReceived: (string | undefined)[] = []
const handleEose = (i: number) => {
@ -1243,6 +1262,7 @@ class ClientService extends EventTarget { @@ -1243,6 +1262,7 @@ class ClientService extends EventTarget {
const PROGRESSIVE_INTERVAL_MS = 100 // Poll for more events while relays are still streaming
const MIN_NEW_EVENTS_AFTER_FIRST = 5 // After first paint, batch updates to limit re-renders
let progressiveIntervalId: ReturnType<typeof setInterval> | null = null
let firstRelayResultGraceTimer: ReturnType<typeof setTimeout> | null = null
const deliverProgressive = () => {
if (eosedAt || events.length === 0) return
const sortedEvents = [...events].sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
@ -1261,6 +1281,88 @@ class ClientService extends EventTarget { @@ -1261,6 +1281,88 @@ class ClientService extends EventTarget {
onEvents(needSort && useCache ? snap.concat(cachedEvents).slice(0, filter.limit) : snap, false)
}
}
const handleTimelineEose = (eosed: boolean) => {
if (eosed && eosedAt != null) return
if (eosed && !eosedAt) {
if (firstRelayResultGraceTimer != null) {
clearTimeout(firstRelayResultGraceTimer)
firstRelayResultGraceTimer = null
}
eosedAt = dayjs().unix()
if (progressiveIntervalId) {
clearInterval(progressiveIntervalId)
progressiveIntervalId = null
}
}
// (algo feeds) no need to sort and cache
if (!needSort) {
return onEvents([...events], !!eosedAt)
}
if (!eosed) {
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
// Only include cached events if caching is enabled
return onEvents([...(useCache ? events.concat(cachedEvents).slice(0, filter.limit) : events)], false)
}
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
// Only update timeline cache if caching is enabled
if (useCache) {
const timeline = that.timelines[key]
// no cache yet
if (!timeline || Array.isArray(timeline) || !timeline.refs.length) {
that.timelines[key] = {
refs: events.map((evt) => [evt.id, evt.created_at]),
filter,
urls
}
return onEvents([...events], true)
}
// Prevent concurrent requests from duplicating the same event
const firstRefCreatedAt = timeline.refs[0][1]
const newRefs = events
.filter((evt) => evt.created_at > firstRefCreatedAt)
.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
if (events.length >= filter.limit) {
// if new refs are more than limit, means old refs are too old, replace them
timeline.refs = newRefs
onEvents([...events], true)
} else {
// merge new refs with old refs
timeline.refs = newRefs.concat(timeline.refs)
onEvents([...events.concat(cachedEvents).slice(0, filter.limit)], true)
}
} else {
// No caching for initial load, but still need to initialize timeline.refs for loadMoreTimeline pagination
const timeline = that.timelines[key]
if (!timeline || Array.isArray(timeline)) {
// Initialize timeline with refs for pagination (even though we don't use cache for initial load)
that.timelines[key] = {
refs: events.map((evt) => [evt.id, evt.created_at]),
filter,
urls
}
} else {
// Update refs with new events for pagination tracking
const firstRefCreatedAt = timeline.refs.length > 0 ? timeline.refs[0][1] : dayjs().unix()
const newRefs = events
.filter((evt) => evt.created_at > firstRefCreatedAt)
.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
if (events.length >= filter.limit) {
timeline.refs = newRefs
} else {
timeline.refs = newRefs.concat(timeline.refs)
}
}
// Return events directly (no cache concatenation)
onEvents([...events], true)
}
}
const subCloser = this.subscribe(relays, since ? { ...filter, since } : filter, {
startLogin,
onevent: (evt: NEvent) => {
@ -1268,6 +1370,12 @@ class ClientService extends EventTarget { @@ -1268,6 +1370,12 @@ class ClientService extends EventTarget {
// not eosed yet, push to events
if (!eosedAt) {
events.push(evt)
if (firstRelayResultGraceTimer == null) {
firstRelayResultGraceTimer = setTimeout(() => {
firstRelayResultGraceTimer = null
handleTimelineEose(true)
}, FIRST_RELAY_RESULT_GRACE_MS)
}
// Deliver as soon as we have any event while waiting for EOSE (then batch further updates)
if (needSort && events.length >= 1 && !initialBatchScheduled) {
initialBatchScheduled = true
@ -1313,86 +1421,17 @@ class ClientService extends EventTarget { @@ -1313,86 +1421,17 @@ class ClientService extends EventTarget {
// insert the event to the right position
timeline.refs.splice(idx, 0, [evt.id, evt.created_at])
},
oneose: (eosed) => {
if (eosed && !eosedAt) {
eosedAt = dayjs().unix()
if (progressiveIntervalId) {
clearInterval(progressiveIntervalId)
progressiveIntervalId = null
}
}
// (algo feeds) no need to sort and cache
if (!needSort) {
return onEvents([...events], !!eosedAt)
}
if (!eosed) {
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
// Only include cached events if caching is enabled
return onEvents([...(useCache ? events.concat(cachedEvents).slice(0, filter.limit) : events)], false)
}
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
// Only update timeline cache if caching is enabled
if (useCache) {
const timeline = that.timelines[key]
// no cache yet
if (!timeline || Array.isArray(timeline) || !timeline.refs.length) {
that.timelines[key] = {
refs: events.map((evt) => [evt.id, evt.created_at]),
filter,
urls
}
return onEvents([...events], true)
}
// Prevent concurrent requests from duplicating the same event
const firstRefCreatedAt = timeline.refs[0][1]
const newRefs = events
.filter((evt) => evt.created_at > firstRefCreatedAt)
.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
if (events.length >= filter.limit) {
// if new refs are more than limit, means old refs are too old, replace them
timeline.refs = newRefs
onEvents([...events], true)
} else {
// merge new refs with old refs
timeline.refs = newRefs.concat(timeline.refs)
onEvents([...events.concat(cachedEvents).slice(0, filter.limit)], true)
}
} else {
// No caching for initial load, but still need to initialize timeline.refs for loadMoreTimeline pagination
const timeline = that.timelines[key]
if (!timeline || Array.isArray(timeline)) {
// Initialize timeline with refs for pagination (even though we don't use cache for initial load)
that.timelines[key] = {
refs: events.map((evt) => [evt.id, evt.created_at]),
filter,
urls
}
} else {
// Update refs with new events for pagination tracking
const firstRefCreatedAt = timeline.refs.length > 0 ? timeline.refs[0][1] : dayjs().unix()
const newRefs = events
.filter((evt) => evt.created_at > firstRefCreatedAt)
.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
if (events.length >= filter.limit) {
timeline.refs = newRefs
} else {
timeline.refs = newRefs.concat(timeline.refs)
}
}
// Return events directly (no cache concatenation)
onEvents([...events], true)
}
},
oneose: handleTimelineEose,
onclose: onClose
})
return {
timelineKey: key,
closer: () => {
if (firstRelayResultGraceTimer != null) {
clearTimeout(firstRelayResultGraceTimer)
firstRelayResultGraceTimer = null
}
if (progressiveIntervalId) {
clearInterval(progressiveIntervalId)
progressiveIntervalId = null
@ -1529,6 +1568,7 @@ class ClientService extends EventTarget { @@ -1529,6 +1568,7 @@ class ClientService extends EventTarget {
replaceableRace?: boolean
/** For non-replaceable single events: return immediately on first match */
immediateReturn?: boolean
firstRelayResultGraceMs?: number | false
}
) {
return this.queryService.query(urls, filter, onevent, options)
@ -1543,12 +1583,18 @@ class ClientService extends EventTarget { @@ -1543,12 +1583,18 @@ class ClientService extends EventTarget {
onevent,
cache = false,
eoseTimeout,
globalTimeout
globalTimeout,
firstRelayResultGraceMs,
replaceableRace,
immediateReturn
}: {
onevent?: (evt: NEvent) => void
cache?: boolean
eoseTimeout?: number
globalTimeout?: number
firstRelayResultGraceMs?: number | false
replaceableRace?: boolean
immediateReturn?: boolean
} = {}
) {
let relays = Array.from(new Set(urls))
@ -1559,12 +1605,13 @@ class ClientService extends EventTarget { @@ -1559,12 +1605,13 @@ class ClientService extends EventTarget {
const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url))
}
const events = await this.queryService.query(
relays,
filter,
onevent,
{ eoseTimeout, globalTimeout }
)
const events = await this.queryService.query(relays, filter, onevent, {
eoseTimeout,
globalTimeout,
firstRelayResultGraceMs,
replaceableRace,
immediateReturn
})
if (cache) {
events.forEach((evt) => {
this.addEventToCache(evt)

17
src/services/navigation.service.ts

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
* Handles all navigation logic in a clean, testable way.
*/
import React, { ReactNode } from 'react'
import React, { lazy, ReactNode, Suspense } from 'react'
// Page components
import SettingsPage from '@/pages/secondary/SettingsPage'
@ -21,7 +21,14 @@ import FollowingListPage from '@/pages/secondary/FollowingListPage' @@ -21,7 +21,14 @@ import FollowingListPage from '@/pages/secondary/FollowingListPage'
import MuteListPage from '@/pages/secondary/MuteListPage'
import OthersRelaySettingsPage from '@/pages/secondary/OthersRelaySettingsPage'
import SecondaryRelayPage from '@/pages/secondary/RelayPage'
import SecondaryNoteListPage from '@/pages/secondary/NoteListPage'
/** Lazy avoids: NavigationService → NoteListPage → NormalFeed → NoteList → PageManager → navigation.service */
const SecondaryNoteListPageLazy = lazy(() => import('@/pages/secondary/NoteListPage'))
const navLazyFallback = React.createElement(
'div',
{ className: 'flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground' },
'Loading…'
)
export type ViewType = 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null
@ -87,7 +94,11 @@ export class ComponentFactory { @@ -87,7 +94,11 @@ export class ComponentFactory {
}
static createHashtagPage(): ReactNode {
return React.createElement(SecondaryNoteListPage, { hideTitlebar: true })
return React.createElement(
Suspense,
{ fallback: navLazyFallback },
React.createElement(SecondaryNoteListPageLazy, { hideTitlebar: true })
)
}
static createFollowingListPage(profileId: string): ReactNode {

Loading…
Cancel
Save