- {loading && (
+ {loading && events.length === 0 && (
{Array.from({ length: 6 }).map((_, i) => (
))}
)}
- {!loading && debouncedQuery && events.length === 0 && (
+ {relayPending && events.length > 0 && (
+
{t('Searching…')}
+ )}
+ {!loading && !relayPending && debouncedQuery && events.length === 0 && (
{t('No events found')}
)}
- {!loading &&
- events.map((ev: NEvent) => (
+ {events.map((ev: NEvent) => (
)
diff --git a/src/components/UserItem/index.tsx b/src/components/UserItem/index.tsx
index befa570d..e56c688b 100644
--- a/src/components/UserItem/index.tsx
+++ b/src/components/UserItem/index.tsx
@@ -9,18 +9,29 @@ import type { TProfile } from '@/types'
export default function UserItem({
pubkey,
hideFollowButton,
+ hideNip05,
className,
- prefetchedProfile
+ prefetchedProfile,
+ deferRemoteAvatar = true
}: {
pubkey: string
hideFollowButton?: boolean
+ /** Skip nip05 verification fetches (search dropdown rows). */
+ hideNip05?: boolean
className?: string
/** When the caller already loaded this profile (e.g. search index / DB), show it immediately. */
prefetchedProfile?: TProfile | null
+ /** Set false in search/mention dropdowns so profile pictures load without viewport deferral. */
+ deferRemoteAvatar?: boolean
}) {
return (
-
+
-
+ {!hideNip05 && }
{!hideFollowButton &&
}
diff --git a/src/components/ui/ProfileSearchBar.tsx b/src/components/ui/ProfileSearchBar.tsx
index 04a69fda..ce05f9fb 100644
--- a/src/components/ui/ProfileSearchBar.tsx
+++ b/src/components/ui/ProfileSearchBar.tsx
@@ -1,3 +1,4 @@
+import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import { Input } from '@/components/ui/input'
import { Search, X } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -23,7 +24,7 @@ export default function ProfileSearchBar({
useEffect(() => {
const timer = setTimeout(() => {
onSearch(query)
- }, 300)
+ }, SEARCH_QUERY_DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [query, onSearch])
diff --git a/src/constants.ts b/src/constants.ts
index bf32eaea..8e485a41 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -505,6 +505,12 @@ export const SEARCHABLE_RELAY_URLS = [
'wss://nostr-pub.wellorder.net',
]
+/**
+ * Wait after the last keystroke before profile / mention / picker search hits the network
+ * ({@link useSearchProfiles}, @-mention dropdown, event picker, etc.).
+ */
+export const SEARCH_QUERY_DEBOUNCE_MS = 550
+
export const PROFILE_RELAY_URLS = [
'wss://profiles.nostr1.com',
'wss://purplepag.es',
diff --git a/src/hooks/useSearchProfiles.tsx b/src/hooks/useSearchProfiles.tsx
index 12422afb..d26c3b3c 100644
--- a/src/hooks/useSearchProfiles.tsx
+++ b/src/hooks/useSearchProfiles.tsx
@@ -1,58 +1,76 @@
-import { PROFILE_RELAY_URLS } from '@/constants'
-import { normalizeUrl } from '@/lib/url'
+import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import client from '@/services/client.service'
import { TProfile } from '@/types'
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
-const PROFILE_SEARCH_RELAY_URLS = Array.from(
- new Set(PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean))
-)
-
-export function useSearchProfiles(search: string, limit: number) {
+export function useSearchProfiles(
+ search: string,
+ limit: number,
+ debounceMs: number = SEARCH_QUERY_DEBOUNCE_MS
+) {
+ const [debouncedSearch, setDebouncedSearch] = useState(() => search.trim())
const [isFetching, setIsFetching] = useState(false)
const [error, setError] = useState
(null)
const [profiles, setProfiles] = useState([])
+ const abortRef = useRef(null)
+ const partialTimerRef = useRef | null>(null)
+
+ useEffect(() => {
+ const trimmed = search.trim()
+ if (!trimmed) {
+ setDebouncedSearch('')
+ return
+ }
+ const timer = setTimeout(() => setDebouncedSearch(trimmed), debounceMs)
+ return () => clearTimeout(timer)
+ }, [search, debounceMs])
useEffect(() => {
- const fetchProfiles = async () => {
- if (!search.trim()) {
+ abortRef.current?.abort()
+ if (partialTimerRef.current) clearTimeout(partialTimerRef.current)
+ const ac = new AbortController()
+ abortRef.current = ac
+ let cancelled = false
+
+ const run = async () => {
+ if (!debouncedSearch) {
setProfiles([])
setIsFetching(false)
+ setError(null)
return
}
setIsFetching(true)
setProfiles([])
+ setError(null)
try {
- const profiles = await client.searchProfilesFromLocal(search, limit)
- setProfiles(profiles)
- if (profiles.length >= limit) {
- return
- }
- const existingPubkeys = new Set(profiles.map((profile) => profile.pubkey))
- const fetchedProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, {
- search,
- limit
- })
- if (fetchedProfiles.length) {
- fetchedProfiles.forEach((profile) => {
- if (existingPubkeys.has(profile.pubkey)) {
- return
- }
- existingPubkeys.add(profile.pubkey)
- profiles.push(profile)
- })
- setProfiles([...profiles])
- }
+ const result = await client.searchProfilesStaged(
+ debouncedSearch,
+ limit,
+ (partial) => {
+ if (cancelled || ac.signal.aborted) return
+ if (partialTimerRef.current) clearTimeout(partialTimerRef.current)
+ partialTimerRef.current = setTimeout(() => {
+ if (!cancelled && !ac.signal.aborted) setProfiles([...partial])
+ }, 80)
+ },
+ ac.signal
+ )
+ if (!cancelled && !ac.signal.aborted) setProfiles(result)
} catch (err) {
- setError(err as Error)
+ if (!cancelled && !ac.signal.aborted) setError(err as Error)
} finally {
- setIsFetching(false)
+ if (!cancelled && !ac.signal.aborted) setIsFetching(false)
}
}
- fetchProfiles()
- }, [search, limit])
+ void run()
+ return () => {
+ cancelled = true
+ if (partialTimerRef.current) clearTimeout(partialTimerRef.current)
+ ac.abort()
+ }
+ }, [debouncedSearch, limit])
- return { isFetching, error, profiles }
+ return { isFetching, error, profiles, debouncedSearch }
}
diff --git a/src/lib/local-nip50-search-merge.ts b/src/lib/local-nip50-search-merge.ts
index 65c31b0c..2a72dc0c 100644
--- a/src/lib/local-nip50-search-merge.ts
+++ b/src/lib/local-nip50-search-merge.ts
@@ -56,18 +56,6 @@ export async function collectLocalEventsForTextSearch(
}
}
- const idbOpts =
- params.archiveScanMaxMs !== undefined ? { archiveScanMaxMs: params.archiveScanMaxMs } : undefined
- const fromPubArchive = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(
- q,
- params.idbMergedLimit,
- kindsArr,
- idbOpts
- )
- for (const ev of fromPubArchive) {
- push(ev)
- }
-
if (params.includeOtherStoresFullText) {
const cap = params.fullTextStoreHitCap ?? 260
try {
@@ -80,6 +68,18 @@ export async function collectLocalEventsForTextSearch(
}
}
+ const idbOpts =
+ params.archiveScanMaxMs !== undefined ? { archiveScanMaxMs: params.archiveScanMaxMs } : undefined
+ const fromPubArchive = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(
+ q,
+ params.idbMergedLimit,
+ kindsArr,
+ idbOpts
+ )
+ for (const ev of fromPubArchive) {
+ push(ev)
+ }
+
out.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
return out
}
diff --git a/src/lib/merged-search-note-preview.test.ts b/src/lib/merged-search-note-preview.test.ts
new file mode 100644
index 00000000..293c70e0
--- /dev/null
+++ b/src/lib/merged-search-note-preview.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest'
+import { kinds } from 'nostr-tools'
+import { mergedSearchNoteHasPreviewBody } from './merged-search-note-preview'
+
+describe('mergedSearchNoteHasPreviewBody', () => {
+ it('treats hashtag-only kind 1 notes as visible', () => {
+ const ev = {
+ id: 'a'.repeat(64),
+ pubkey: 'b'.repeat(64),
+ created_at: 1_700_000_000,
+ kind: kinds.ShortTextNote,
+ tags: [['t', 'Palantir']],
+ content: '',
+ sig: 'sig'
+ }
+ expect(mergedSearchNoteHasPreviewBody(ev)).toBe(true)
+ })
+})
diff --git a/src/lib/merged-search-note-preview.ts b/src/lib/merged-search-note-preview.ts
index 28c65500..3e59beb9 100644
--- a/src/lib/merged-search-note-preview.ts
+++ b/src/lib/merged-search-note-preview.ts
@@ -20,6 +20,7 @@ export function mergedSearchNoteHasPreviewBody(ev: Event): boolean {
const k = ev.kind
if (k === kinds.ShortTextNote || k === ExtendedKind.COMMENT) {
if (ev.tags.some((t) => t[0] === 'subject' && String(t[1] ?? '').trim().length > 0)) return true
+ if (ev.tags.some((t) => t[0] === 't' && String(t[1] ?? '').trim().length > 0)) return true
return Boolean(ev.content?.trim().length)
}
if (k === kinds.Metadata) {
diff --git a/src/lib/profile-relay-search-filters.ts b/src/lib/profile-relay-search-filters.ts
index f8b17f89..dbe248b6 100644
--- a/src/lib/profile-relay-search-filters.ts
+++ b/src/lib/profile-relay-search-filters.ts
@@ -15,7 +15,13 @@ export function buildProfileKind0SearchFilters(opts: {
search: string
limit: number
until?: number
+ /**
+ * When false, only NIP-50 `search` (and pubkey `authors`) — avoids `#name` / `#nip05` REQ
+ * that many profile relays reject as "unrecognised filter item".
+ */
+ includeTagFilters?: boolean
}): Filter[] {
+ const includeTagFilters = opts.includeTagFilters !== false
const searchRaw = opts.search.trim()
if (!searchRaw) return []
@@ -45,7 +51,7 @@ export function buildProfileKind0SearchFilters(opts: {
add({ kinds: k, search: searchNorm, limit, ...time })
}
- if (searchRaw.includes('@')) {
+ if (includeTagFilters && searchRaw.includes('@')) {
const firstToken = (searchRaw.split(/\s+/)[0] ?? searchRaw).trim()
const nipVariants = new Set()
if (firstToken) {
@@ -66,6 +72,7 @@ export function buildProfileKind0SearchFilters(opts: {
const token = searchRaw.startsWith('@') ? searchRaw.slice(1).trim() : searchRaw.trim()
if (
+ includeTagFilters &&
token &&
!/\s/.test(token) &&
token.length <= 80 &&
diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts
index aee1a793..1fb2eb49 100644
--- a/src/services/client-events.service.ts
+++ b/src/services/client-events.service.ts
@@ -791,7 +791,8 @@ export class EventService {
const buf: NEvent[] = []
let scanned = 0
- for (const [, event] of this.sessionEventCache.entries()) {
+ // LRU order: most recently seen / published first (Map insertion order is not recency).
+ for (const event of this.sessionEventCache.values()) {
if (++scanned > SESSION_SEARCH_MAX_SCAN) break
if (shouldDropEventOnIngest(event)) continue
if (kindSet && !kindSet.has(event.kind)) continue
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 6e035b66..e4c0e58b 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -3661,7 +3661,18 @@ class ClientService extends EventTarget {
/** =========== Profile =========== */
- async searchProfiles(relayUrls: string[], filter: Filter): Promise {
+ async searchProfiles(
+ relayUrls: string[],
+ filter: Filter,
+ options?: {
+ relaysOnly?: boolean
+ includeTagFilters?: boolean
+ signal?: AbortSignal
+ /** Override query timeouts (profile-relay step uses shorter budgets). */
+ eoseTimeout?: number
+ globalTimeout?: number
+ }
+ ): Promise {
void this.ensureProfileSearchIndexFromIdb()
const searchStr = typeof filter.search === 'string' ? filter.search.trim() : ''
const normalizedAll = dedupeNormalizeRelayUrlsOrdered(
@@ -3676,7 +3687,7 @@ class ClientService extends EventTarget {
...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
])
let urls = normalizedAll
- if (searchStr.length > 0) {
+ if (searchStr.length > 0 && !options?.relaysOnly) {
const searchCapable = normalizedAll.filter(
(u) => searchableSet.has(u) || nip66Service.isRelaySearchable(u)
)
@@ -3693,7 +3704,8 @@ class ClientService extends EventTarget {
const built = buildProfileKind0SearchFilters({
search: searchStr,
limit: limitCap,
- until: filter.until
+ until: filter.until,
+ includeTagFilters: options?.includeTagFilters
})
return built.length > 0 ? built : [{ ...filter, kinds: [...METADATA_CO_FETCH_KINDS] }]
})()
@@ -3705,14 +3717,18 @@ class ClientService extends EventTarget {
(f) => typeof f.search === 'string' && f.search.trim().length > 0
)
const usesAuthorsLookup = filtersArr.some((f) => (f.authors?.length ?? 0) > 0)
+ if (options?.signal?.aborted) return []
+
const events = await this.queryService.query(urls, queryFilter, undefined, {
replaceableRace: false,
- eoseTimeout: usesNip50TextSearch ? 10_000 : 4500,
- globalTimeout: usesNip50TextSearch
- ? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000
- : 9000,
+ eoseTimeout:
+ options?.eoseTimeout ?? (usesNip50TextSearch ? 10_000 : 4500),
+ globalTimeout:
+ options?.globalTimeout ??
+ (usesNip50TextSearch ? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 : 9000),
relayOpSource: 'ClientService.searchProfiles',
- foreground: usesNip50TextSearch || usesAuthorsLookup
+ foreground: usesNip50TextSearch || usesAuthorsLookup,
+ signal: options?.signal
})
const byPk = new Map()
@@ -3733,6 +3749,106 @@ class ClientService extends EventTarget {
return profileEvents.map((profileEvent) => getProfileFromEvent(profileEvent))
}
+ private profileRelaySearchUrls(): string[] {
+ return dedupeNormalizeRelayUrlsOrdered(
+ PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
+ )
+ }
+
+ private nip50ProfileIndexRelayUrls(): string[] {
+ return dedupeNormalizeRelayUrlsOrdered([
+ ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u),
+ ...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u)
+ ])
+ }
+
+ private profileSearchStagedGeneration = 0
+ private profileSearchStagedAbort: AbortController | null = null
+
+ /**
+ * Staged profile discovery: local cache/DB → profile relays only → NIP-50 index relays
+ * only when the first two stages returned nothing. Calls `onUpdate` after each stage.
+ * Aborts the previous in-flight staged search when a new query starts.
+ */
+ async searchProfilesStaged(
+ query: string,
+ limit: number = 50,
+ onUpdate?: (profiles: TProfile[]) => void,
+ externalSignal?: AbortSignal
+ ): Promise {
+ const q = query.trim()
+ if (!q || limit <= 0) return []
+
+ this.profileSearchStagedAbort?.abort()
+ const runAbort = new AbortController()
+ this.profileSearchStagedAbort = runAbort
+ const generation = ++this.profileSearchStagedGeneration
+
+ const isStale = () =>
+ generation !== this.profileSearchStagedGeneration ||
+ runAbort.signal.aborted ||
+ externalSignal?.aborted === true
+
+ const seen = new Set()
+ const out: TProfile[] = []
+
+ const merge = (batch: TProfile[]) => {
+ for (const p of batch) {
+ const pk = p.pubkey.toLowerCase()
+ if (seen.has(pk)) continue
+ seen.add(pk)
+ out.push(p)
+ if (out.length >= limit) break
+ }
+ }
+
+ const emit = () => {
+ if (!isStale() && onUpdate) onUpdate(out.slice(0, limit))
+ }
+
+ const relaySignal = externalSignal
+ ? AbortSignal.any([runAbort.signal, externalSignal])
+ : runAbort.signal
+
+ merge(await this.searchProfilesFromLocal(q, limit))
+ if (isStale()) return out.slice(0, limit)
+ emit()
+ if (out.length >= limit) return out.slice(0, limit)
+
+ const needAfterLocal = limit - out.length
+ merge(
+ await this.searchProfiles(
+ this.profileRelaySearchUrls(),
+ { search: q, limit: needAfterLocal },
+ {
+ relaysOnly: true,
+ includeTagFilters: false,
+ signal: relaySignal,
+ eoseTimeout: 6_000,
+ globalTimeout: 9_000
+ }
+ )
+ )
+ if (isStale()) return out.slice(0, limit)
+ emit()
+ if (out.length >= limit) return out.slice(0, limit)
+ if (out.length > 0) return out.slice(0, limit)
+
+ const indexUrls = this.nip50ProfileIndexRelayUrls()
+ if (indexUrls.length > 0) {
+ merge(
+ await this.searchProfiles(
+ indexUrls,
+ { search: q, limit },
+ { signal: relaySignal, includeTagFilters: true }
+ )
+ )
+ if (!isStale()) emit()
+ }
+
+ return out.slice(0, limit)
+ }
+
async searchNpubsFromLocal(query: string, limit: number = 100) {
await this.ensureProfileSearchIndexFromIdb()
const seen = new Set()
@@ -3891,18 +4007,6 @@ class ClientService extends EventTarget {
if (np) addNpub(np)
}
- // Relay query starts immediately so it can run in parallel with local + follow work (slow relays).
- const profileSearchRelayUrls = dedupeNormalizeRelayUrlsOrdered(
- PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
- )
- const relayTask =
- q.length >= 1
- ? this.searchProfiles(profileSearchRelayUrls, {
- search: q,
- limit
- }).catch(() => [] as TProfile[])
- : Promise.resolve([] as TProfile[])
-
// 1. Local index first (FlexSearch + session) — fills the @-mention list immediately.
// Cap how many local hits we take so we never fill `limit` here alone; otherwise we returned
// early and skipped relay search entirely (bad for handle search beyond the local index).
@@ -3989,30 +4093,41 @@ class ClientService extends EventTarget {
return out
}
- // 3. Relay search — merge after local + follow so ordering stays local → follows → wider index.
- // relayTask was started at the beginning; do not await before return (first paint stays fast).
- if (q.length >= 1) {
- relayTask
- .then((relayProfiles) => {
- for (const p of relayProfiles) {
+ // 3. Profile relays only (purplepag.es, profiles.nostr1.com, …) — not NIP-50 index relays.
+ if (q.length >= 1 && out.length < limit) {
+ try {
+ const relayProfiles = await this.searchProfiles(
+ this.profileRelaySearchUrls(),
+ { search: q, limit: limit - out.length },
+ { relaysOnly: true, includeTagFilters: false, eoseTimeout: 6_000, globalTimeout: 9_000 }
+ )
+ for (const p of relayProfiles) {
+ const npub = pubkeyToNpub(p.pubkey)
+ if (!npub) continue
+ if (addNpub(npub)) updateIfNeeded()
+ if (out.length >= limit) break
+ }
+ } catch {
+ /* best-effort */
+ }
+ }
+
+ // 4. NIP-50 index relays only when local + profile relays found nothing.
+ if (q.length >= 1 && out.length === 0) {
+ const indexUrls = this.nip50ProfileIndexRelayUrls()
+ if (indexUrls.length > 0) {
+ try {
+ const indexProfiles = await this.searchProfiles(indexUrls, { search: q, limit })
+ for (const p of indexProfiles) {
const npub = pubkeyToNpub(p.pubkey)
if (!npub) continue
- if (addNpub(npub)) {
- updateIfNeeded()
- }
+ if (addNpub(npub)) updateIfNeeded()
if (out.length >= limit) break
}
-
- relayProfiles.forEach((p) => {
- const npub = pubkeyToNpub(p.pubkey)
- if (npub) {
- this.replaceableEventService.fetchProfileEvent(npub).catch(() => {})
- }
- })
- })
- .catch(() => {
- // relay search is best-effort
- })
+ } catch {
+ /* best-effort */
+ }
+ }
}
// Prime profile cache for cached results
diff --git a/src/services/mention-event-search.service.ts b/src/services/mention-event-search.service.ts
index 04b6debb..570f0d72 100644
--- a/src/services/mention-event-search.service.ts
+++ b/src/services/mention-event-search.service.ts
@@ -8,11 +8,12 @@ import {
citationPickerMatchesQuery,
tryParseCitationEventIdFromQuery
} from '@/lib/citation-picker-search'
-import { ExtendedKind, NIP71_VIDEO_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants'
+import { ExtendedKind, NIP71_VIDEO_KINDS, NIP_SEARCH_PAGE_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import { kinds, type Event as NEvent } from 'nostr-tools'
import client, { eventService, queryService } from './client.service'
import indexedDb from './indexed-db.service'
+import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
const DEFAULT_NOTES_LIMIT = 20
@@ -37,6 +38,9 @@ export const NEVENT_KINDS = [
ExtendedKind.CITATION_PROMPT
] as const
+/** Same NIP-50 kinds as Search → Notes (without kind-0 metadata rows in the picker). */
+export const PICKER_NEVENT_KINDS = NIP_SEARCH_PAGE_KINDS.filter((k) => k !== kinds.Metadata)
+
/** NIP-32 citation events only (Advanced lab citation picker, etc.). */
export const CITATION_PICKER_KINDS = [
ExtendedKind.CITATION_INTERNAL,
@@ -142,6 +146,8 @@ async function searchCitationEventsForPickerInternal(
/** Local DB + session budget for picker search (before relay NIP-50). */
const PICKER_LOCAL_DB_MERGE_CAP = 880
const PICKER_FULLTEXT_DB_CAP = 260
+/** IndexedDB archive scan for picker — keep short so the dialog is not stuck on skeletons. */
+const PICKER_ARCHIVE_SCAN_MAX_MS = 4_000
/**
* Search for events: session cache → IndexedDB (publication + archive + cross-store full text) → relays.
@@ -153,13 +159,18 @@ export async function searchEventsForPicker(
query: string,
limit: number = DEFAULT_NOTES_LIMIT,
mode: PickerSearchMode = 'nevent',
- kindFilter?: readonly number[]
+ kindFilter?: readonly number[],
+ onUpdate?: (events: NEvent[]) => void
): Promise {
const q = query.trim()
if (!q) return []
const kindsList =
- kindFilter && kindFilter.length > 0 ? [...kindFilter] : mode === 'nevent' ? [...NEVENT_KINDS] : [...NADDR_KINDS]
+ kindFilter && kindFilter.length > 0
+ ? [...kindFilter]
+ : mode === 'nevent'
+ ? [...PICKER_NEVENT_KINDS]
+ : [...NADDR_KINDS]
if (isCitationOnlyKindFilter(kindFilter)) {
return searchCitationEventsForPickerInternal(q, limit, kindsList)
@@ -174,45 +185,65 @@ export async function searchEventsForPicker(
out.push(evt)
}
+ const emit = () => {
+ if (onUpdate) onUpdate(out.slice(0, limit))
+ }
+
const sessionCap = Math.min(1500, Math.max(limit * 8, 200))
const localMergeTarget = Math.min(PICKER_LOCAL_DB_MERGE_CAP, Math.max(limit * 10, 240))
- const fromLocalMerged = await collectLocalEventsForTextSearch({
- query: q,
- allowedKinds: kindsList,
- sessionCap,
- idbMergedLimit: localMergeTarget,
- archiveScanMaxMs: 24_000,
- includeOtherStoresFullText: true,
- fullTextStoreHitCap: Math.min(PICKER_FULLTEXT_DB_CAP, Math.max(localMergeTarget, 200))
- })
- fromLocalMerged.forEach(addUnique)
-
- const userCentricRelayUrls = await buildCitationPickerSearchRelayUrls()
+ for (const ev of eventService.getSessionEventsMatchingSearch(q, sessionCap, kindsList)) {
+ addUnique(ev)
+ }
- if (out.length >= limit) return out.slice(0, limit)
+ const viewerPk = client.pubkey?.trim().toLowerCase()
+ if (viewerPk && /^[0-9a-f]{64}$/.test(viewerPk)) {
+ for (const ev of eventService.listSessionEventsAuthoredBy(viewerPk, {
+ kinds: kindsList,
+ limit: Math.min(400, sessionCap)
+ })) {
+ if (eventMatchesNip50LocalFullTextQuery(ev, q)) addUnique(ev)
+ }
+ }
+ emit()
- const need = limit - out.length
+ const userCentricRelayUrls = await buildCitationPickerSearchRelayUrls()
+ const relayLimit = Math.max(limit, 20)
const searchableNip50Layer = Array.from(
new Set(SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u.trim()).filter(Boolean))
).slice(0, 28)
- const [fromUserCentric, fromSearchableIndex] = await Promise.all([
+ const [fromLocalMerged, fromUserCentric, fromSearchableIndex] = await Promise.all([
+ collectLocalEventsForTextSearch({
+ query: q,
+ allowedKinds: kindsList,
+ sessionCap: 0,
+ idbMergedLimit: localMergeTarget,
+ archiveScanMaxMs: PICKER_ARCHIVE_SCAN_MAX_MS,
+ includeOtherStoresFullText: true,
+ fullTextStoreHitCap: Math.min(PICKER_FULLTEXT_DB_CAP, Math.max(localMergeTarget, 200))
+ }),
queryService.fetchEvents(
userCentricRelayUrls,
- { kinds: kindsList, search: q, limit: need },
+ { kinds: kindsList, search: q, limit: relayLimit },
{ eoseTimeout: 5000, globalTimeout: 8000 }
),
searchableNip50Layer.length > 0
? queryService.fetchEvents(
searchableNip50Layer,
- { kinds: kindsList, search: q, limit: need },
+ { kinds: kindsList, search: q, limit: relayLimit },
{ eoseTimeout: 6500, globalTimeout: 12_000 }
)
: Promise.resolve([] as NEvent[])
])
- fromUserCentric.forEach(addUnique)
- fromSearchableIndex.forEach(addUnique)
+
+ for (const ev of fromLocalMerged) addUnique(ev)
+ emit()
+
+ for (const ev of fromUserCentric) addUnique(ev)
+ for (const ev of fromSearchableIndex) addUnique(ev)
+ emit()
+
return out.slice(0, limit)
}