From 05ea8e47e297438eb3a75a150e5a298aa1afa29d Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 16 May 2026 20:19:42 +0200 Subject: [PATCH] more efficiency gains --- .../Explore/ExplorePopularRelays.tsx | 77 +++++++++++++++++++ .../Explore/ExploreRelayReviews.tsx | 7 +- .../FollowingFavoriteRelayList/index.tsx | 5 +- src/i18n/locales/en.ts | 3 + src/lib/explore-popular-relays.ts | 54 +++++++++++++ src/pages/primary/ExplorePage/index.tsx | 41 +++++----- src/pages/primary/SpellsPage/index.tsx | 6 ++ src/services/client.service.ts | 73 ++++++++++-------- 8 files changed, 209 insertions(+), 57 deletions(-) create mode 100644 src/components/Explore/ExplorePopularRelays.tsx create mode 100644 src/lib/explore-popular-relays.ts diff --git a/src/components/Explore/ExplorePopularRelays.tsx b/src/components/Explore/ExplorePopularRelays.tsx new file mode 100644 index 00000000..f7d9f33c --- /dev/null +++ b/src/components/Explore/ExplorePopularRelays.tsx @@ -0,0 +1,77 @@ +import { buildExplorePopularRelayUrls } from '@/lib/explore-popular-relays' +import { toRelay } from '@/lib/link' +import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url' +import { useSmartRelayNavigation } from '@/PageManager' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNostr } from '@/providers/NostrProvider' +import indexedDb from '@/services/indexed-db.service' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +/** + * Lightweight Explore relay list: URLs from the viewer's NIP-65 / favorites / defaults and optional + * cached NIP-66 data — no GitHub collections fetch and no NIP-11 storm on mount. + */ +export default function ExplorePopularRelays() { + const { t } = useTranslation() + const { relayList } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { navigateToRelay } = useSmartRelayNavigation() + const [nip66Cached, setNip66Cached] = useState([]) + + useEffect(() => { + let cancelled = false + void indexedDb + .getPublicLivelyRelayUrlsCache() + .then((c) => { + if (!cancelled && c?.urls?.length) setNip66Cached(c.urls) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, []) + + const urls = useMemo( + () => + buildExplorePopularRelayUrls({ + relayList, + favoriteRelays, + blockedRelays, + nip66CachedUrls: nip66Cached + }), + [relayList, favoriteRelays, blockedRelays, nip66Cached] + ) + + if (urls.length === 0) { + return ( +

{t('No relays in your lists yet.')}

+ ) + } + + return ( +
+

{t('Popular relays')}

+

+ {t('From your mailbox, favorites, and cached relay lists on this device.')} +

+
    + {urls.map((url) => { + const key = normalizeAnyRelayUrl(url) || url + return ( +
  • + +
  • + ) + })} +
+
+ ) +} diff --git a/src/components/Explore/ExploreRelayReviews.tsx b/src/components/Explore/ExploreRelayReviews.tsx index 871837ea..30b42f2f 100644 --- a/src/components/Explore/ExploreRelayReviews.tsx +++ b/src/components/Explore/ExploreRelayReviews.tsx @@ -10,6 +10,7 @@ import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { toRelay } from '@/lib/link' +import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays' import { normalizeAnyRelayUrl } from '@/lib/url' import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' import { useSmartRelayNavigation } from '@/PageManager' @@ -132,7 +133,9 @@ export default function ExploreRelayReviews() { blockedRelays ) const sliced = stacked.slice(0, EXPLORE_REVIEWS_MAX_RELAYS) - const normalized = sliced.map((u) => normalizeAnyRelayUrl(u) || u.trim()).filter(Boolean) + const normalized = sliced + .map((u) => normalizeAnyRelayUrl(u) || u.trim()) + .filter((u): u is string => Boolean(u) && isExploreBrowsableRelayUrl(u)) normalized.sort((a, b) => a.localeCompare(b)) return normalized // eslint-disable-next-line react-hooks/exhaustive-deps -- relayInputsKey is a content hash of favorites/blocked/NIP-65; relayList identity churn must not re-open REQ sockets. @@ -212,7 +215,7 @@ export default function ExploreRelayReviews() { const groups = new Map() for (const event of visible) { const url = getRelayUrlFromRelayReviewEvent(event) - if (!url) continue + if (!url || !isExploreBrowsableRelayUrl(url)) continue if (!groups.has(url)) groups.set(url, []) groups.get(url)!.push(event) } diff --git a/src/components/FollowingFavoriteRelayList/index.tsx b/src/components/FollowingFavoriteRelayList/index.tsx index 9b53f1ac..5d0351cd 100644 --- a/src/components/FollowingFavoriteRelayList/index.tsx +++ b/src/components/FollowingFavoriteRelayList/index.tsx @@ -1,4 +1,5 @@ import { useFetchRelayInfo } from '@/hooks' +import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays' import { toRelay } from '@/lib/link' import { useSmartRelayNavigation } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' @@ -23,7 +24,9 @@ export default function FollowingFavoriteRelayList() { const init = async () => { if (!pubkey) return - const relays = (await client.fetchFollowingFavoriteRelays(pubkey)) ?? [] + const relays = ((await client.fetchFollowingFavoriteRelays(pubkey)) ?? []).filter(([url]) => + isExploreBrowsableRelayUrl(url) + ) setRelays(relays) } init().finally(() => { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7e3afd5a..16ce03f5 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -551,6 +551,9 @@ export default { "no more replies": "no more replies", "Relay sets": "Relay sets", "Search for Relays": "Search for Relays", + "Popular relays": "Popular relays", + "From your mailbox, favorites, and cached relay lists on this device.": "From your mailbox, favorites, and cached relay lists on this device.", + "No relays in your lists yet.": "No relays in your lists yet.", "Using app default relays": "Using app default relays", "Following's Favorites": "Following's Favorites", "no more relays": "no more relays", diff --git a/src/lib/explore-popular-relays.ts b/src/lib/explore-popular-relays.ts new file mode 100644 index 00000000..1bc2db0a --- /dev/null +++ b/src/lib/explore-popular-relays.ts @@ -0,0 +1,54 @@ +import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants' +import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' +import { normalizeAnyRelayUrl } from '@/lib/url' +import type { ViewerRelayListLike } from '@/lib/viewer-relay-defaults' + +/** Public Explore UI: no loopback/LAN and no plain `ws://` (local dev / cache relays). */ +export function isExploreBrowsableRelayUrl(raw: string): boolean { + if (!urlIsNonLocalForRemoteViewer(raw)) return false + const n = (normalizeAnyRelayUrl(raw) || raw.trim()).toLowerCase() + return !n.startsWith('ws://') +} + +export type BuildExplorePopularRelayUrlsOptions = { + relayList: ViewerRelayListLike + favoriteRelays: readonly string[] + blockedRelays: readonly string[] + /** Cached NIP-66 lively list from IndexedDB (no network required). */ + nip66CachedUrls?: readonly string[] + max?: number +} + +/** + * Relay URLs for Explore: merge the viewer's lists + small defaults, rank by how often each URL + * appears across sources (proxy for "popular in your stack"). + */ +export function buildExplorePopularRelayUrls(options: BuildExplorePopularRelayUrlsOptions): string[] { + const blocked = new Set( + options.blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b.trim()).filter(Boolean) + ) + const counts = new Map() + + const bump = (raw: string) => { + if (!isExploreBrowsableRelayUrl(raw)) return + const k = normalizeAnyRelayUrl(raw) || raw.trim() + if (!k || blocked.has(k)) return + counts.set(k, (counts.get(k) ?? 0) + 1) + } + + const rl = options.relayList + for (const u of [...(rl?.read ?? []), ...(rl?.write ?? []), ...(rl?.httpRead ?? [])]) { + bump(u) + } + for (const u of options.favoriteRelays) bump(u) + for (const u of DEFAULT_FAVORITE_RELAYS) bump(u) + for (const u of FAST_READ_RELAY_URLS) bump(u) + for (const u of options.nip66CachedUrls ?? []) bump(u) + + const ranked = [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([url]) => url) + + const max = options.max ?? 48 + return ranked.slice(0, max) +} diff --git a/src/pages/primary/ExplorePage/index.tsx b/src/pages/primary/ExplorePage/index.tsx index 2054c1fc..4edefad9 100644 --- a/src/pages/primary/ExplorePage/index.tsx +++ b/src/pages/primary/ExplorePage/index.tsx @@ -1,7 +1,8 @@ -import Explore from '@/components/Explore' -import ExploreFavoriteRelays from '@/components/Explore/ExploreFavoriteRelays' +import ExplorePopularRelays from '@/components/Explore/ExplorePopularRelays' import ExploreRelayReviews from '@/components/Explore/ExploreRelayReviews' +import { buildExplorePopularRelayUrls } from '@/lib/explore-popular-relays' import FollowingFavoriteRelayList from '@/components/FollowingFavoriteRelayList' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import Tabs from '@/components/Tabs' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -13,7 +14,7 @@ import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { useSmartRelayNavigation } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' -import nip66Service from '@/services/nip66.service' +import client from '@/services/client.service' import { TPageRef } from '@/types' import { ArrowRight, Compass, Plus } from 'lucide-react' import { @@ -31,18 +32,6 @@ import { toast } from 'sonner' const RELAY_SUGGESTION_LIMIT = 20 -function dedupeNormalizedRelayUrls(urls: string[]): string[] { - const seen = new Set() - const out: string[] = [] - for (const u of urls) { - const k = normalizeAnyRelayUrl(u) || u.trim() - if (!k || seen.has(k)) continue - seen.add(k) - out.push(k) - } - return out -} - /** Lower rank = better match for ordering suggestions. */ function relaySuggestionRank(normalizedUrl: string, queryLower: string): number { const n = normalizedUrl.toLowerCase() @@ -100,6 +89,11 @@ const ExplorePage = forwardRef((_, ref) => { [bumpExploreContent] ) + useEffect(() => { + if (tab !== 'explore') return + client.scheduleNip66RelayDiscoveryFromExplore() + }, [tab]) + // Listen for tab restoration from PageManager useEffect(() => { const handleRestore = (e: CustomEvent<{ page: string; tab: string }>) => { @@ -139,9 +133,8 @@ const ExplorePage = forwardRef((_, ref) => {
{tab === 'explore' && (
- - +
)} {tab === 'reviews' && ( @@ -194,16 +187,20 @@ function ExplorePageTitlebar({ onRefresh }: { onRefresh: () => void }) { function ExploreRelaySearchSection() { const { t } = useTranslation() const { navigateToRelay } = useSmartRelayNavigation() + const { relayList } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [relayQuery, setRelayQuery] = useState('') - const [monitoringRelays, setMonitoringRelays] = useState([]) const [suggestOpen, setSuggestOpen] = useState(false) const blurCloseTimer = useRef | null>(null) - useEffect(() => { - nip66Service.getPublicLivelyRelayUrls().then((urls) => { - setMonitoringRelays(dedupeNormalizedRelayUrls(urls ?? [])) + const monitoringRelays = useMemo(() => { + return buildExplorePopularRelayUrls({ + relayList, + favoriteRelays, + blockedRelays, + max: 200 }) - }, []) + }, [relayList, favoriteRelays, blockedRelays]) useEffect(() => { return () => { diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index bd4926ac..2d1a6866 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -367,6 +367,11 @@ const SpellsPage = forwardRef(function SpellsPage( spellCatalogLastManualKeyRef.current = spellCatalogManualRefreshKey } + /** Avoid relay catalog SUB on every Spells visit; sync when the picker opens or user refreshes. */ + if (!manualBump && !spellPickerOpen) { + return + } + const idbSpellsP = indexedDb.getSpellEvents() if (!manualBump) { const cachedSpells = await idbSpellsP @@ -494,6 +499,7 @@ const SpellsPage = forwardRef(function SpellsPage( loadSpells, contactsSyncKey, spellCatalogManualRefreshKey, + spellPickerOpen, useGlobalRelayBootstrap ]) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 6d13e2ca..7f862de6 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -380,6 +380,10 @@ class ClientService extends EventTarget { * @see {@link runSessionPrewarm} */ private sessionPrewarmBaseCompleted = false + private profileSearchIndexWarmPromise: Promise | null = null + private profileSearchIndexWarmed = false + /** Deferred follow-graph prefetch; cancelled on new session prewarm. */ + private followingIndexPrefetchTimer: ReturnType | null = null /** Per-pubkey cooldown for {@link prefetchAuthorCoreReplaceables} from feed ingest (avoid REQ storms). */ private authorCorePrefetchCooldownUntilMs = new Map() private static readonly AUTHOR_CORE_PREFETCH_COOLDOWN_MS = 6 * 60 * 1000 @@ -489,56 +493,58 @@ class ClientService extends EventTarget { * the heavy follow-list relay fetch runs **after** this returns (see {@link runSessionPrewarm}) so the * session gate and live-activities prewarm hook are not held for minutes on large follow graphs. */ + /** + * Build FlexSearch @-mention index from IndexedDB on first use (not at session start). + */ + async ensureProfileSearchIndexFromIdb(): Promise { + if (this.profileSearchIndexWarmed) return + if (!this.profileSearchIndexWarmPromise) { + this.profileSearchIndexWarmPromise = this.prewarmProfileSearchIndexFromIdb() + .catch(() => {}) + .finally(() => { + this.profileSearchIndexWarmed = true + }) + } + await this.profileSearchIndexWarmPromise + } + async runSessionPrewarm(options: { pubkey: string | null; signal?: AbortSignal }): Promise { const signal = options.signal ?? new AbortController().signal - const t0 = typeof performance !== 'undefined' ? performance.now() : 0 - const fastTasks: Promise[] = [] if (!this.sessionPrewarmBaseCompleted) { this.sessionPrewarmBaseCompleted = true - fastTasks.push(this.prewarmProfileSearchIndexFromIdb()) - /** NIP-66 discovery hits extra relays; defer so first feed/session work is not competing for sockets. */ - if (typeof window !== 'undefined') { - window.setTimeout(() => { - void this.fetchNip66RelayDiscovery() - }, 12_000) - } else { - void this.fetchNip66RelayDiscovery() - } - } - - if (fastTasks.length === 0 && !options.pubkey) { - notifySessionInteractivePrewarmComplete() - return } - logger.info('[client] Session prewarm batch started (interactive)', { - hasPubkey: !!options.pubkey, - fastTaskCount: fastTasks.length - }) - const fastResults = await Promise.allSettled(fastTasks) - logger.info('[client] Session prewarm batch finished (interactive)', { - ms: typeof performance !== 'undefined' ? Math.round(performance.now() - t0) : undefined, - fastResults: fastResults.map((r) => r.status) - }) + /** Unblock sidebar/widgets immediately — no IndexedDB scan or NIP-66 at startup. */ notifySessionInteractivePrewarmComplete() if (options.pubkey) { const pk = options.pubkey - /** Defer: follow graph pulls compete with first feed REQs; same hydrate {@link AbortSignal} still applies. */ - void Promise.resolve().then(async () => { - try { - await this.initUserIndexFromFollowings(pk, signal) - } catch (err) { + if (this.followingIndexPrefetchTimer != null) { + clearTimeout(this.followingIndexPrefetchTimer) + } + /** Idle follow-graph prefetch only after the first minute (feeds win the connection pool). */ + this.followingIndexPrefetchTimer = setTimeout(() => { + this.followingIndexPrefetchTimer = null + if (signal.aborted) return + void this.initUserIndexFromFollowings(pk, signal).catch((err) => { logger.debug('[client] Prewarm: following index background pass failed', { pubkeySlice: pk.slice(0, 12), err: err instanceof Error ? err.message : String(err) }) - } - }) + }) + }, 60_000) } } + /** NIP-66 discovery for Explore / publish hints — call when the user opens Explore, not at boot. */ + scheduleNip66RelayDiscoveryFromExplore(): void { + if (typeof window === 'undefined') return + window.setTimeout(() => { + void this.fetchNip66RelayDiscovery() + }, 500) + } + // Update signer in query service when it changes setSigner(signer: ISigner | undefined, signerType: TSignerType | undefined) { this.signer = signer @@ -3642,6 +3648,7 @@ class ClientService extends EventTarget { /** =========== Profile =========== */ async searchProfiles(relayUrls: string[], filter: Filter): Promise { + void this.ensureProfileSearchIndexFromIdb() const searchStr = typeof filter.search === 'string' ? filter.search.trim() : '' const normalizedAll = dedupeNormalizeRelayUrlsOrdered( relayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean) @@ -3708,6 +3715,7 @@ class ClientService extends EventTarget { } async searchNpubsFromLocal(query: string, limit: number = 100) { + await this.ensureProfileSearchIndexFromIdb() const seen = new Set() const out: string[] = [] const pushNpub = (npub: string) => { @@ -4008,6 +4016,7 @@ class ClientService extends EventTarget { * Profile search local sources: IndexedDB kind-0 cache first, then FlexSearch/session npubs + fetchProfile. */ async searchProfilesFromLocal(query: string, limit: number = 100): Promise { + await this.ensureProfileSearchIndexFromIdb() const q = query.trim() if (!q) return []