Browse Source

more efficiency gains

imwald
Silberengel 4 weeks ago
parent
commit
05ea8e47e2
  1. 77
      src/components/Explore/ExplorePopularRelays.tsx
  2. 7
      src/components/Explore/ExploreRelayReviews.tsx
  3. 5
      src/components/FollowingFavoriteRelayList/index.tsx
  4. 3
      src/i18n/locales/en.ts
  5. 54
      src/lib/explore-popular-relays.ts
  6. 41
      src/pages/primary/ExplorePage/index.tsx
  7. 6
      src/pages/primary/SpellsPage/index.tsx
  8. 73
      src/services/client.service.ts

77
src/components/Explore/ExplorePopularRelays.tsx

@ -0,0 +1,77 @@ @@ -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<string[]>([])
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 (
<p className="px-4 py-6 text-sm text-muted-foreground">{t('No relays in your lists yet.')}</p>
)
}
return (
<section className="min-w-0 pb-6" aria-label={t('Popular relays')}>
<h2 className="mb-2 px-4 text-base font-semibold tracking-tight">{t('Popular relays')}</h2>
<p className="mb-3 px-4 text-sm text-muted-foreground">
{t('From your mailbox, favorites, and cached relay lists on this device.')}
</p>
<ul className="grid min-w-0 gap-2 px-2 md:grid-cols-2 md:px-4">
{urls.map((url) => {
const key = normalizeAnyRelayUrl(url) || url
return (
<li key={key}>
<button
type="button"
className="flex w-full min-w-0 flex-col rounded-lg border bg-card px-3 py-2.5 text-left shadow-sm transition-colors hover:bg-accent/40"
onClick={() => navigateToRelay(toRelay(url))}
>
<span className="truncate font-mono text-sm font-semibold">{simplifyUrl(url)}</span>
<span className="mt-0.5 truncate text-xs text-muted-foreground">{url}</span>
</button>
</li>
)
})}
</ul>
</section>
)
}

7
src/components/Explore/ExploreRelayReviews.tsx

@ -10,6 +10,7 @@ import { @@ -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() { @@ -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() { @@ -212,7 +215,7 @@ export default function ExploreRelayReviews() {
const groups = new Map<string, Event[]>()
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)
}

5
src/components/FollowingFavoriteRelayList/index.tsx

@ -1,4 +1,5 @@ @@ -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() { @@ -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(() => {

3
src/i18n/locales/en.ts

@ -551,6 +551,9 @@ export default { @@ -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",

54
src/lib/explore-popular-relays.ts

@ -0,0 +1,54 @@ @@ -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<string, number>()
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)
}

41
src/pages/primary/ExplorePage/index.tsx

@ -1,7 +1,8 @@ @@ -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' @@ -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' @@ -31,18 +32,6 @@ import { toast } from 'sonner'
const RELAY_SUGGESTION_LIMIT = 20
function dedupeNormalizedRelayUrls(urls: string[]): string[] {
const seen = new Set<string>()
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<TPageRef>((_, ref) => { @@ -100,6 +89,11 @@ const ExplorePage = forwardRef<TPageRef>((_, 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<TPageRef>((_, ref) => { @@ -139,9 +133,8 @@ const ExplorePage = forwardRef<TPageRef>((_, ref) => {
<div className="min-w-0 pt-2">
{tab === 'explore' && (
<div key={contentRefreshKey} className="min-w-0">
<ExploreFavoriteRelays />
<ExploreRelaySearchSection />
<Explore />
<ExplorePopularRelays />
</div>
)}
{tab === 'reviews' && (
@ -194,16 +187,20 @@ function ExplorePageTitlebar({ onRefresh }: { onRefresh: () => void }) { @@ -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<string[]>([])
const [suggestOpen, setSuggestOpen] = useState(false)
const blurCloseTimer = useRef<ReturnType<typeof setTimeout> | 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 () => {

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

@ -367,6 +367,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -367,6 +367,11 @@ const SpellsPage = forwardRef<TPageRef>(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<TPageRef>(function SpellsPage( @@ -494,6 +499,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
loadSpells,
contactsSyncKey,
spellCatalogManualRefreshKey,
spellPickerOpen,
useGlobalRelayBootstrap
])

73
src/services/client.service.ts

@ -380,6 +380,10 @@ class ClientService extends EventTarget { @@ -380,6 +380,10 @@ class ClientService extends EventTarget {
* @see {@link runSessionPrewarm}
*/
private sessionPrewarmBaseCompleted = false
private profileSearchIndexWarmPromise: Promise<void> | null = null
private profileSearchIndexWarmed = false
/** Deferred follow-graph prefetch; cancelled on new session prewarm. */
private followingIndexPrefetchTimer: ReturnType<typeof setTimeout> | null = null
/** Per-pubkey cooldown for {@link prefetchAuthorCoreReplaceables} from feed ingest (avoid REQ storms). */
private authorCorePrefetchCooldownUntilMs = new Map<string, number>()
private static readonly AUTHOR_CORE_PREFETCH_COOLDOWN_MS = 6 * 60 * 1000
@ -489,56 +493,58 @@ class ClientService extends EventTarget { @@ -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<void> {
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<void> {
const signal = options.signal ?? new AbortController().signal
const t0 = typeof performance !== 'undefined' ? performance.now() : 0
const fastTasks: Promise<unknown>[] = []
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 { @@ -3642,6 +3648,7 @@ class ClientService extends EventTarget {
/** =========== Profile =========== */
async searchProfiles(relayUrls: string[], filter: Filter): Promise<TProfile[]> {
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 { @@ -3708,6 +3715,7 @@ class ClientService extends EventTarget {
}
async searchNpubsFromLocal(query: string, limit: number = 100) {
await this.ensureProfileSearchIndexFromIdb()
const seen = new Set<string>()
const out: string[] = []
const pushNpub = (npub: string) => {
@ -4008,6 +4016,7 @@ class ClientService extends EventTarget { @@ -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<TProfile[]> {
await this.ensureProfileSearchIndexFromIdb()
const q = query.trim()
if (!q) return []

Loading…
Cancel
Save