Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
2e4d6a49e7
  1. 16
      src/components/AlexandriaEventsSearchEmptyCta.tsx
  2. 6
      src/components/NormalFeed/index.tsx
  3. 31
      src/components/NoteList/index.tsx
  4. 12
      src/components/ProfileListBySearch/index.tsx
  5. 53
      src/components/SearchResult/FullTextSearchByRelay.tsx
  6. 15
      src/components/SearchResult/index.tsx
  7. 116
      src/lib/alexandria-events-search-url.ts
  8. 85
      src/lib/local-nip50-search-merge.ts
  9. 24
      src/lib/nip50-local-text-match.ts
  10. 105
      src/lib/profile-metadata-search.ts
  11. 40
      src/lib/profile-relay-search-filters.ts
  12. 17
      src/pages/secondary/NoteListPage/index.tsx
  13. 24
      src/services/client-events.service.ts
  14. 13
      src/services/client.service.ts
  15. 38
      src/services/indexed-db.service.ts
  16. 38
      src/services/mention-event-search.service.ts

16
src/components/AlexandriaEventsSearchEmptyCta.tsx

@ -0,0 +1,16 @@
import { Button } from '@/components/ui/button'
import { BookOpen } from 'lucide-react'
import { useTranslation } from 'react-i18next'
/** Shown when local/relay search finished with zero events; opens Alexandria with a matching query. */
export function AlexandriaEventsSearchEmptyCta({ href }: { href: string }) {
const { t } = useTranslation()
return (
<Button variant="outline" size="sm" className="mt-3 gap-2" asChild>
<a href={href} target="_blank" rel="noopener noreferrer">
<BookOpen className="h-4 w-4 shrink-0" aria-hidden />
{t('Search on Alexandria')}
</a>
</Button>
)
}

6
src/components/NormalFeed/index.tsx

@ -106,6 +106,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
oneShotMergedCap?: number oneShotMergedCap?: number
/** When every relay in the subscribe wave fails before EOSE, merge a one-shot fetch from default read relays (home multi-relay feeds). */ /** When every relay in the subscribe wave fails before EOSE, merge a one-shot fetch from default read relays (home multi-relay feeds). */
timelinePublicReadFallback?: boolean timelinePublicReadFallback?: boolean
/** When the feed is empty and terminal, {@link NoteList} can show an Alexandria search link (hashtag / d-tag pages). */
alexandriaEmptyUrl?: string | null
}>(function NormalFeed( }>(function NormalFeed(
{ {
subRequests, subRequests,
@ -138,7 +140,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
extraShouldHideEvent, extraShouldHideEvent,
extraShouldHideRepliesEvent, extraShouldHideRepliesEvent,
oneShotMergedCap, oneShotMergedCap,
timelinePublicReadFallback = false timelinePublicReadFallback = false,
alexandriaEmptyUrl = null
}, },
ref ref
) { ) {
@ -378,6 +381,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
} }
oneShotMergedCap={oneShotMergedCap} oneShotMergedCap={oneShotMergedCap}
timelinePublicReadFallback={timelinePublicReadFallback && listMode === 'postsAndReplies'} timelinePublicReadFallback={timelinePublicReadFallback && listMode === 'postsAndReplies'}
alexandriaEmptyUrl={alexandriaEmptyUrl}
/> />
</div> </div>
</> </>

31
src/components/NoteList/index.tsx

@ -1,4 +1,5 @@
import NewNotesButton from '@/components/NewNotesButton' import NewNotesButton from '@/components/NewNotesButton'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import { import {
ExtendedKind, ExtendedKind,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
@ -25,6 +26,8 @@ import {
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url' import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
@ -399,6 +402,7 @@ function applyProgressiveSessionSearchLayer(params: ProgressiveSearchLocalLayerO
const { warmQ, isStale, kindsForWarm, warmMatch, afterSort, setEvents, setLoading } = params const { warmQ, isStale, kindsForWarm, warmMatch, afterSort, setEvents, setLoading } = params
const cap = FEED_FULL_SEARCH_MERGE_CAP const cap = FEED_FULL_SEARCH_MERGE_CAP
let boot = client.getSessionEventsMatchingSearch(warmQ, cap, kindsForWarm) let boot = client.getSessionEventsMatchingSearch(warmQ, cap, kindsForWarm)
boot = boot.filter((ev) => eventMatchesNip50LocalFullTextQuery(ev, warmQ))
if (warmMatch) boot = boot.filter(warmMatch) if (warmMatch) boot = boot.filter(warmMatch)
const sortCreated = (evs: Event[]) => [...evs].sort((a, b) => b.created_at - a.created_at) const sortCreated = (evs: Event[]) => [...evs].sort((a, b) => b.created_at - a.created_at)
const finalizeOrder = (evs: Event[]) => (afterSort ? [...evs].sort(afterSort) : sortCreated(evs)) const finalizeOrder = (evs: Event[]) => (afterSort ? [...evs].sort(afterSort) : sortCreated(evs))
@ -413,14 +417,17 @@ function startProgressiveIdbSearchLayer(params: ProgressiveSearchLocalLayerOpts)
const cap = FEED_FULL_SEARCH_MERGE_CAP const cap = FEED_FULL_SEARCH_MERGE_CAP
void (async () => { void (async () => {
try { try {
const idbE = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch( const local = await collectLocalEventsForTextSearch({
warmQ, query: warmQ,
cap, allowedKinds: kindsForWarm,
kindsForWarm, sessionCap: 0,
{ archiveScanMaxMs: PROGRESSIVE_IDB_ARCHIVE_SCAN_MAX_MS } idbMergedLimit: cap,
) archiveScanMaxMs: PROGRESSIVE_IDB_ARCHIVE_SCAN_MAX_MS,
includeOtherStoresFullText: true,
fullTextStoreHitCap: Math.min(400, Math.max(cap, 120))
})
if (isStale()) return if (isStale()) return
const idbUse = warmMatch ? idbE.filter(warmMatch) : idbE const idbUse = warmMatch ? local.filter(warmMatch) : local
if (idbUse.length) { if (idbUse.length) {
setEvents((prev) => mergeProgressiveSearchEvents(prev, idbUse, afterSort)) setEvents((prev) => mergeProgressiveSearchEvents(prev, idbUse, afterSort))
setLoading(false) setLoading(false)
@ -763,7 +770,12 @@ const NoteList = forwardRef(
* When true (multi-relay home feeds): if every relay in the subscribe wave fails before EOSE, run one * When true (multi-relay home feeds): if every relay in the subscribe wave fails before EOSE, run one
* {@link client.fetchEvents} against {@link FAST_READ_RELAY_URLS} so the feed is not stuck on stale cache only. * {@link client.fetchEvents} against {@link FAST_READ_RELAY_URLS} so the feed is not stuck on stale cache only.
*/ */
timelinePublicReadFallback = false timelinePublicReadFallback = false,
/**
* When set and the timeline is empty (after relays finish), show a link to Alexandria with a matching query
* (hashtag / d-tag browse from {@link NormalFeed}).
*/
alexandriaEmptyUrl = null
}: { }: {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
showKinds: number[] showKinds: number[]
@ -820,6 +832,8 @@ const NoteList = forwardRef(
/** When true, render events as an Instagram-style 3-column square media grid. */ /** When true, render events as an Instagram-style 3-column square media grid. */
gridLayout?: boolean gridLayout?: boolean
timelinePublicReadFallback?: boolean timelinePublicReadFallback?: boolean
/** Optional Alexandria `/events` URL when this feed’s timeline is empty (search / tag browse). */
alexandriaEmptyUrl?: string | null
}, },
ref ref
) => { ) => {
@ -4456,6 +4470,7 @@ const NoteList = forwardRef(
role="status" role="status"
> >
<p>{t('No posts loaded for this feed. Try refreshing.')}</p> <p>{t('No posts loaded for this feed. Try refreshing.')}</p>
{alexandriaEmptyUrl ? <AlexandriaEventsSearchEmptyCta href={alexandriaEmptyUrl} /> : null}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

12
src/components/ProfileListBySearch/index.tsx

@ -1,6 +1,7 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { PROFILE_FETCH_RELAY_URLS } from '@/constants' import { PROFILE_FETCH_RELAY_URLS } from '@/constants'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -9,6 +10,7 @@ import dayjs from 'dayjs'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import UserItem, { UserItemSkeleton } from '../UserItem' import UserItem, { UserItemSkeleton } from '../UserItem'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
const LIMIT = 50 const LIMIT = 50
@ -176,7 +178,15 @@ export function ProfileListBySearch({ search }: { search: string }) {
<p className="py-6 text-center text-sm text-muted-foreground">{t('Profile search failed')}</p> <p className="py-6 text-center text-sm text-muted-foreground">{t('Profile search failed')}</p>
)} )}
{phase === 'ready' && empty && ( {phase === 'ready' && empty && (
<p className="py-6 text-center text-sm text-muted-foreground">{t('Profile search no results')}</p> <div className="flex flex-col items-center py-6 text-center text-sm text-muted-foreground">
<p>{t('Profile search no results')}</p>
{(() => {
const trimmed = search.trim()
if (!trimmed) return null
const href = buildAlexandriaEventsSearchUrlForTSearchParams({ type: 'profiles', search })
return href ? <AlexandriaEventsSearchEmptyCta href={href} /> : null
})()}
</div>
)} )}
{pubkeys.map((pubkey, index) => ( {pubkeys.map((pubkey, index) => (
<div <div

53
src/components/SearchResult/FullTextSearchByRelay.tsx

@ -4,16 +4,17 @@ import { Skeleton } from '@/components/ui/skeleton'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { compareEventsForDTagQuery } from '@/lib/dtag-search' import { compareEventsForDTagQuery } from '@/lib/dtag-search'
import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview' import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext'
import client from '@/services/client.service' import client from '@/services/client.service'
import { NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS } from '@/services/client-query.service' import { NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS } from '@/services/client-query.service'
import indexedDb from '@/services/indexed-db.service'
import { relayHostForSubscribeLog } from '@/services/relay-operation-log.service' import { relayHostForSubscribeLog } from '@/services/relay-operation-log.service'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
import type { Event, Filter } from 'nostr-tools' import type { Event, Filter } from 'nostr-tools'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import { buildAlexandriaEventsSearchUrlFromNotesQuery } from '@/lib/alexandria-events-search-url'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -249,6 +250,10 @@ export default function FullTextSearchByRelay({
const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls]) const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls])
const q = searchQuery.trim() const q = searchQuery.trim()
const alexandriaEmptyHref = useMemo(
() => (q ? buildAlexandriaEventsSearchUrlFromNotesQuery(q) : null),
[q]
)
const searchProfileResetKey = useMemo( const searchProfileResetKey = useMemo(
() => `${q}\n${normalizedRelays.join('\n')}`, () => `${q}\n${normalizedRelays.join('\n')}`,
[q, normalizedRelays] [q, normalizedRelays]
@ -401,34 +406,17 @@ export default function FullTextSearchByRelay({
} }
void (async () => { void (async () => {
const fromSession = client.getSessionEventsMatchingSearch(q, 220, kindsArr) const mergedLocal = await collectLocalEventsForTextSearch({
let fromIdb: Event[] = [] query: q,
try { allowedKinds: kindsArr,
fromIdb = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(q, 120, kindsArr, { sessionCap: 220,
archiveScanMaxMs: 15_000 idbMergedLimit: 120,
}) archiveScanMaxMs: 15_000,
} catch { includeOtherStoresFullText: true,
fromIdb = [] fullTextStoreHitCap: 260
} })
if (myRun !== runGeneration.current || abort.signal.aborted) return if (myRun !== runGeneration.current || abort.signal.aborted) return
const seen = new Set<string>() const mergedLocalMatching = mergedLocal.filter((e) => mergedSearchNoteHasPreviewBody(e))
const mergedLocal: Event[] = []
for (const e of fromSession) {
if (seen.has(e.id)) continue
seen.add(e.id)
mergedLocal.push(e)
}
for (const e of fromIdb) {
if (seen.has(e.id)) continue
seen.add(e.id)
mergedLocal.push(e)
}
const mergedLocalMatching = mergedLocal.filter(
(e) =>
kindsArr.includes(e.kind) &&
eventMatchesNip50LocalFullTextQuery(e, q) &&
mergedSearchNoteHasPreviewBody(e)
)
if (mergedLocalMatching.length === 0) return if (mergedLocalMatching.length === 0) return
applyMergedUpdate((map) => { applyMergedUpdate((map) => {
for (const ev of mergedLocalMatching) { for (const ev of mergedLocalMatching) {
@ -619,9 +607,10 @@ export default function FullTextSearchByRelay({
</SearchMergedProfileProvider> </SearchMergedProfileProvider>
{allTerminal && mergedHits.length === 0 && ( {allTerminal && mergedHits.length === 0 && (
<p className="text-sm text-muted-foreground" role="status"> <div className="flex flex-col items-start gap-0" role="status">
{t('Full-text search empty merged')} <p className="text-sm text-muted-foreground">{t('Full-text search empty merged')}</p>
</p> {alexandriaEmptyHref ? <AlexandriaEventsSearchEmptyCta href={alexandriaEmptyHref} /> : null}
</div>
)} )}
{allTerminal && mergedHits.length > 0 && ( {allTerminal && mergedHits.length > 0 && (

15
src/components/SearchResult/index.tsx

@ -9,6 +9,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { buildAlexandriaEventsUrlForHashtagParam } from '@/lib/alexandria-events-search-url'
import { useLayoutEffect, useMemo } from 'react' import { useLayoutEffect, useMemo } from 'react'
function relayDedupeKey(url: string): string { function relayDedupeKey(url: string): string {
@ -73,6 +74,14 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
[combinedRelays, searchableKeySet] [combinedRelays, searchableKeySet]
) )
const alexandriaEmptyUrlForHashtag = useMemo(
() =>
searchParams?.type === 'hashtag'
? buildAlexandriaEventsUrlForHashtagParam(searchParams.search)
: null,
[searchParams?.type, searchParams?.search]
)
if (!searchParams) { if (!searchParams) {
return null return null
} }
@ -98,7 +107,11 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
...(nonSearchableRelays.length > 0 ? [{ urls: nonSearchableRelays, filter: hashtagFilter }] : []) ...(nonSearchableRelays.length > 0 ? [{ urls: nonSearchableRelays, filter: hashtagFilter }] : [])
] ]
return ( return (
<NormalFeed timelinePublicReadFallback subRequests={subRequests} /> <NormalFeed
timelinePublicReadFallback
subRequests={subRequests}
alexandriaEmptyUrl={alexandriaEmptyUrlForHashtag}
/>
) )
} }
return <Relay url={searchParams.search} /> return <Relay url={searchParams.search} />

116
src/lib/alexandria-events-search-url.ts

@ -0,0 +1,116 @@
import { normalizeToDTag, parseAdvancedSearch } from '@/lib/search-parser'
import type { TSearchParams } from '@/types'
import { nip19 } from 'nostr-tools'
export const ALEXANDRIA_NEXT_EVENTS_BASE = 'https://next-alexandria.gitcitadel.eu/events'
const NIP05_STANDALONE = /^\s*[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\s*$/
/** `id` query for Alexandria: hex for note/nevent; full bech32 for naddr. */
function nip19ToAlexandriaIdValue(bech32Input: string): string | null {
try {
let id = bech32Input.trim()
if (id.startsWith('nostr:')) id = id.slice(6)
const decoded = nip19.decode(id)
if (decoded.type === 'note' || decoded.type === 'nevent') {
const d = decoded.data as string | { id: string }
const hex = typeof d === 'string' ? d : d.id
return hex ? hex.toLowerCase() : null
}
if (decoded.type === 'naddr') {
return id
}
return null
} catch {
return null
}
}
/**
* Maps a free-form notes / NIP-50 style query to Alexandria `/events` query params
* (`d`, `t`, `n`, `q`, `id`) per https://next-alexandria.gitcitadel.eu/events.
*/
export function buildAlexandriaEventsSearchUrlFromNotesQuery(query: string): string | null {
const q = query.trim()
if (!q) return null
const lower = q.toLowerCase()
if (lower.startsWith('d:')) {
const inner = q.slice(2).trim()
const d = normalizeToDTag(inner) || inner.toLowerCase().replace(/\s+/g, '-').replace(/^-+|-+$/g, '')
if (!d) return null
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?d=${encodeURIComponent(d)}`
}
if (lower.startsWith('t:')) {
const inner = q.slice(2).trim().toLowerCase()
if (!inner) return null
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?t=${encodeURIComponent(inner)}`
}
if (lower.startsWith('n:')) {
const inner = q.slice(2).trim()
if (!inner) return null
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?n=${encodeURIComponent(inner)}`
}
if (q.startsWith('#')) {
const inner = q.slice(1).trim().toLowerCase()
if (!inner) return null
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?t=${encodeURIComponent(inner)}`
}
if (/^[0-9a-f]{64}$/i.test(q)) {
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?id=${encodeURIComponent(q.toLowerCase())}`
}
const nip19Id = nip19ToAlexandriaIdValue(q)
if (nip19Id) {
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?id=${encodeURIComponent(nip19Id)}`
}
if (NIP05_STANDALONE.test(q)) {
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?q=${encodeURIComponent(q.trim())}`
}
const adv = parseAdvancedSearch(q)
if (adv.hashtag) {
const raw = Array.isArray(adv.hashtag) ? adv.hashtag[0] : adv.hashtag
const tag = raw?.toString().trim().toLowerCase()
if (tag) return `${ALEXANDRIA_NEXT_EVENTS_BASE}?t=${encodeURIComponent(tag)}`
}
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?q=${encodeURIComponent(q)}`
}
export function buildAlexandriaEventsSearchUrlForTSearchParams(params: TSearchParams): string | null {
if (params.type === 'hashtag') {
const tag = params.search?.trim().toLowerCase()
if (!tag) return null
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?t=${encodeURIComponent(tag)}`
}
if (params.type === 'profiles') {
let n = params.search.trim()
if (n.toLowerCase().startsWith('n:')) n = n.slice(2).trim()
if (!n) return null
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?n=${encodeURIComponent(n)}`
}
if (params.type === 'notes') {
return buildAlexandriaEventsSearchUrlFromNotesQuery(params.search)
}
return null
}
export function buildAlexandriaEventsUrlForHashtagParam(tag: string): string | null {
const t = tag.trim().toLowerCase()
if (!t) return null
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?t=${encodeURIComponent(t)}`
}
export function buildAlexandriaEventsUrlForDTagParam(d: string): string | null {
const v = d.trim()
if (!v) return null
const normalized = normalizeToDTag(v) || v
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?d=${encodeURIComponent(normalized)}`
}

85
src/lib/local-nip50-search-merge.ts

@ -0,0 +1,85 @@
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import indexedDb from '@/services/indexed-db.service'
import { eventService } from '@/services/client.service'
import type { Event } from 'nostr-tools'
export type CollectLocalTextSearchParams = {
query: string
/** Kind filter (same semantics as NIP-50 `kinds` on relays). */
allowedKinds: readonly number[]
/**
* Session LRU scan cap for {@link EventService.getSessionEventsMatchingSearch}.
* Use `0` when the caller already merged the session layer synchronously.
*/
sessionCap: number
/** `limit` passed to {@link IndexedDbService.getCachedAndArchivedEventsMatchingLocalSearch}. */
idbMergedLimit: number
archiveScanMaxMs?: number
/**
* When true, also scan nonevent-archive stores via {@link IndexedDbService.searchAllCachedEventsFullText}
* (same extra coverage as the mention / citation picker path).
*/
includeOtherStoresFullText?: boolean
/** Max rows from {@link IndexedDbService.searchAllCachedEventsFullText} when enabled. */
fullTextStoreHitCap?: number
}
/**
* Merges local session + publication + event-archive (and optionally other IndexedDB stores) for the same
* text query and kind filter, deduped by id, sorted newest-first. Every row must satisfy
* {@link eventMatchesNip50LocalFullTextQuery} (defense in depth on top of store-specific scans).
*/
export async function collectLocalEventsForTextSearch(
params: CollectLocalTextSearchParams
): Promise<Event[]> {
const q = params.query.trim()
if (!q) return []
const kindsArr = [...params.allowedKinds]
if (kindsArr.length === 0) return []
const kindSet = new Set(kindsArr)
const seen = new Set<string>()
const out: Event[] = []
const push = (ev: Event) => {
if (!kindSet.has(ev.kind)) return
if (!eventMatchesNip50LocalFullTextQuery(ev, q)) return
if (seen.has(ev.id)) return
seen.add(ev.id)
out.push(ev)
}
if (params.sessionCap > 0) {
for (const ev of eventService.getSessionEventsMatchingSearch(q, params.sessionCap, kindsArr)) {
push(ev)
}
}
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 {
const hits = await indexedDb.searchAllCachedEventsFullText(q, { limit: cap })
for (const hit of hits) {
if (hit.value) push(hit.value as Event)
}
} catch {
/* optional cross-store scan */
}
}
out.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
return out
}

24
src/lib/nip50-local-text-match.ts

@ -1,3 +1,4 @@
import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
@ -15,6 +16,8 @@ export function eventMatchesNip50LocalFullTextQuery(ev: Event, query: string): b
const decodedAuthor = decodeProfileSearchQueryToPubkeyHex(raw) const decodedAuthor = decodeProfileSearchQueryToPubkeyHex(raw)
if (decodedAuthor && ev.pubkey.toLowerCase() === decodedAuthor) return true if (decodedAuthor && ev.pubkey.toLowerCase() === decodedAuthor) return true
if (ev.kind === kinds.Metadata && profileKind0MatchesSearchQuery(ev, raw)) return true
if (ev.id.toLowerCase().includes(q)) return true if (ev.id.toLowerCase().includes(q)) return true
if (ev.pubkey.toLowerCase().includes(q)) return true if (ev.pubkey.toLowerCase().includes(q)) return true
if (String(ev.kind).includes(q)) return true if (String(ev.kind).includes(q)) return true
@ -26,26 +29,5 @@ export function eventMatchesNip50LocalFullTextQuery(ev: Event, query: string): b
} }
} }
if (ev.kind === kinds.Metadata) {
try {
const o = JSON.parse(ev.content || '{}') as {
name?: unknown
display_name?: unknown
about?: unknown
nip05?: unknown
}
const pick = (v: unknown) => (typeof v === 'string' ? v.toLowerCase() : '')
const nip05 = pick(o.nip05)
const blob = [pick(o.name), pick(o.display_name), pick(o.about), nip05]
.filter(Boolean)
.join(' ')
if (blob.includes(q)) return true
const qNeedle = q.startsWith('@') ? q.slice(1) : q
if (q.startsWith('@') && qNeedle.length > 0 && blob.includes(qNeedle)) return true
} catch {
/* ignore invalid profile JSON */
}
}
return false return false
} }

105
src/lib/profile-metadata-search.ts

@ -0,0 +1,105 @@
import { splitNip05Identifier } from '@/lib/nip05'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
/**
* Trim, lowercase, and collapse whitespace for case-insensitive profile / NIP-05 matching.
*/
export function normalizeProfileSearchQueryForMatch(raw: string): string {
return raw.trim().toLowerCase().replace(/\s+/g, ' ')
}
/** Every `nip05` tag value plus JSON `nip05` (string or string[]). */
export function collectNip05ValuesFromKind0(ev: Event): string[] {
const set = new Set<string>()
for (const t of ev.tags ?? []) {
if (!Array.isArray(t) || t.length < 2) continue
if (String(t[0]).toLowerCase() !== 'nip05') continue
for (let i = 1; i < t.length; i++) {
const v = String(t[i] ?? '').trim()
if (v) set.add(v)
}
}
try {
const o = JSON.parse(ev.content || '{}') as Record<string, unknown>
const n = o.nip05
if (typeof n === 'string' && n.trim()) set.add(n.trim())
else if (Array.isArray(n)) {
for (const x of n) {
if (typeof x === 'string' && x.trim()) set.add(x.trim())
}
}
} catch {
/* ignore */
}
return [...set]
}
function haystackMatchesNeedle(haystack: string, needle: string, needleNoAt: string): boolean {
const h = haystack.toLowerCase()
if (needle && h.includes(needle)) return true
if (needleNoAt && needleNoAt.length > 0 && h.includes(needleNoAt)) return true
return false
}
/**
* True when kind-0 `ev` matches a profile search string: pubkey / npub decode, raw JSON `content`,
* JSON name / display_name / about, every `nip05` tag, and JSON `nip05` (including multiple values).
*/
export function profileKind0MatchesSearchQuery(ev: Event, rawQuery: string): boolean {
if (ev.kind !== kinds.Metadata) return false
const trimmed = rawQuery.trim()
if (!trimmed) return false
const decodedPk = decodeProfileSearchQueryToPubkeyHex(trimmed)
if (decodedPk && ev.pubkey.toLowerCase() === decodedPk) return true
const needle = normalizeProfileSearchQueryForMatch(trimmed)
if (!needle) return false
const needleNoAt = needle.startsWith('@') ? needle.slice(1).trim() : needle
const pkLower = ev.pubkey.toLowerCase()
if (pkLower.includes(needle) || (needleNoAt && pkLower.includes(needleNoAt))) return true
const contentRaw = ev.content ?? ''
if (haystackMatchesNeedle(contentRaw, needle, needleNoAt)) return true
for (const nip of collectNip05ValuesFromKind0(ev)) {
const nl = nip.toLowerCase()
if (nl === needle || (needleNoAt && nl === needleNoAt)) return true
if (haystackMatchesNeedle(nip, needle, needleNoAt)) return true
const sp = splitNip05Identifier(nip)
if (sp) {
const compact = `${sp.name}@${sp.domain}`.toLowerCase()
const spaced = `${sp.name} ${sp.domain}`.toLowerCase()
if (
compact.includes(needle) ||
needle.includes(compact) ||
spaced.includes(needle) ||
needle.includes(spaced)
) {
return true
}
if (needleNoAt) {
if (compact.includes(needleNoAt) || spaced.includes(needleNoAt)) return true
}
}
}
try {
const profileObj = JSON.parse(ev.content || '{}') as Record<string, unknown>
const blobs = [
typeof profileObj.display_name === 'string' ? profileObj.display_name : '',
typeof profileObj.name === 'string' ? profileObj.name : '',
typeof profileObj.about === 'string' ? profileObj.about : ''
]
for (const p of blobs) {
if (haystackMatchesNeedle(p, needle, needleNoAt)) return true
}
} catch {
return false
}
return false
}

40
src/lib/profile-relay-search-filters.ts

@ -1,5 +1,7 @@
import type { Filter } from 'nostr-tools' import type { Filter } from 'nostr-tools'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { splitNip05Identifier } from '@/lib/nip05'
import { normalizeProfileSearchQueryForMatch } from '@/lib/profile-metadata-search'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
/** /**
@ -14,19 +16,21 @@ export function buildProfileKind0SearchFilters(opts: {
limit: number limit: number
until?: number until?: number
}): Filter[] { }): Filter[] {
const search = opts.search.trim() const searchRaw = opts.search.trim()
if (!search) return [] if (!searchRaw) return []
const limit = Math.max(1, Math.min(opts.limit ?? 50, 500)) const limit = Math.max(1, Math.min(opts.limit ?? 50, 500))
const time = const time =
typeof opts.until === 'number' && opts.until > 0 ? ({ until: opts.until } as Pick<Filter, 'until'>) : {} typeof opts.until === 'number' && opts.until > 0 ? ({ until: opts.until } as Pick<Filter, 'until'>) : {}
const k = [kinds.Metadata] as number[] const k = [kinds.Metadata] as number[]
const pubkeyHex = decodeProfileSearchQueryToPubkeyHex(search) const pubkeyHex = decodeProfileSearchQueryToPubkeyHex(searchRaw)
if (pubkeyHex) { if (pubkeyHex) {
return [{ kinds: k, authors: [pubkeyHex], limit, ...time }] return [{ kinds: k, authors: [pubkeyHex], limit, ...time }]
} }
const searchNorm = normalizeProfileSearchQueryForMatch(searchRaw)
const seen = new Set<string>() const seen = new Set<string>()
const out: Filter[] = [] const out: Filter[] = []
const add = (f: Filter) => { const add = (f: Filter) => {
@ -36,17 +40,31 @@ export function buildProfileKind0SearchFilters(opts: {
out.push(f) out.push(f)
} }
add({ kinds: k, search, limit, ...time }) add({ kinds: k, search: searchRaw, limit, ...time })
if (searchNorm.length > 0 && searchNorm !== searchRaw) {
add({ kinds: k, search: searchNorm, limit, ...time })
}
if (search.includes('@')) { if (searchRaw.includes('@')) {
const firstToken = search.split(/\s+/)[0] ?? search const firstToken = (searchRaw.split(/\s+/)[0] ?? searchRaw).trim()
const nipLower = firstToken.trim().toLowerCase() const nipVariants = new Set<string>()
if (nipLower) add({ kinds: k, '#nip05': [nipLower], limit, ...time }) if (firstToken) {
const nipExact = firstToken.trim() nipVariants.add(firstToken.toLowerCase())
if (nipExact && nipExact !== nipLower) add({ kinds: k, '#nip05': [nipExact], limit, ...time }) nipVariants.add(firstToken)
}
if (searchNorm) nipVariants.add(searchNorm)
const sp = splitNip05Identifier(firstToken)
if (sp) {
nipVariants.add(`${sp.name}@${sp.domain}`.toLowerCase())
nipVariants.add(`${sp.name}@${sp.domain}`)
}
for (const v of nipVariants) {
if (!v) continue
add({ kinds: k, '#nip05': [v], limit, ...time })
}
} }
const token = search.startsWith('@') ? search.slice(1).trim() : search.trim() const token = searchRaw.startsWith('@') ? searchRaw.slice(1).trim() : searchRaw.trim()
if ( if (
token && token &&
!/\s/.test(token) && !/\s/.test(token) &&

17
src/pages/secondary/NoteListPage/index.tsx

@ -16,6 +16,10 @@ import {
} from '@/lib/favorites-feed-relays' } from '@/lib/favorites-feed-relays'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toProfileList } from '@/lib/link' import { toProfileList } from '@/lib/link'
import {
buildAlexandriaEventsUrlForDTagParam,
buildAlexandriaEventsUrlForHashtagParam
} from '@/lib/alexandria-events-search-url'
import { compareEventsForDTagQuery, eventMatchesDTagLooseQuery } from '@/lib/dtag-search' import { compareEventsForDTagQuery, eventMatchesDTagLooseQuery } from '@/lib/dtag-search'
import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05' import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
@ -63,6 +67,16 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
>(null) >(null)
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([]) const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const alexandriaEmptyUrl = useMemo(() => {
if (!data) return null
if (data.type === 'dtag' && data.dtag) return buildAlexandriaEventsUrlForDTagParam(data.dtag)
if (data.type === 'hashtag' || data.type === 'hashtagSearch') {
const t = new URLSearchParams(window.location.search).get('t') ?? ''
return buildAlexandriaEventsUrlForHashtagParam(t)
}
return null
}, [data])
// Get hashtag from URL if this is a hashtag page // Get hashtag from URL if this is a hashtag page
const hashtag = useMemo(() => { const hashtag = useMemo(() => {
if (data?.type === 'hashtag' || data?.type === 'hashtagSearch') { if (data?.type === 'hashtag' || data?.type === 'hashtagSearch') {
@ -355,9 +369,10 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
oneShotAfterMergeComparator={(a, b) => compareEventsForDTagQuery(data.dtag!, a, b)} oneShotAfterMergeComparator={(a, b) => compareEventsForDTagQuery(data.dtag!, a, b)}
extraShouldHideEvent={(ev) => !eventMatchesDTagLooseQuery(data.dtag!, ev)} extraShouldHideEvent={(ev) => !eventMatchesDTagLooseQuery(data.dtag!, ev)}
oneShotMergedCap={400} oneShotMergedCap={400}
alexandriaEmptyUrl={alexandriaEmptyUrl}
/> />
) : ( ) : (
<NormalFeed ref={feedRef} subRequests={subRequests} /> <NormalFeed ref={feedRef} subRequests={subRequests} alexandriaEmptyUrl={alexandriaEmptyUrl} />
) )
} }

24
src/services/client-events.service.ts

@ -35,6 +35,7 @@ import {
import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config' import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config'
import { isCalendarEventKind } from '@/lib/calendar-event' import { isCalendarEventKind } from '@/lib/calendar-event'
import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match' import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match'
@ -636,31 +637,14 @@ export class EventService {
* Pubkeys whose session-cached kind 0 matches a name / display_name / nip-05 substring (for search without IDB). * Pubkeys whose session-cached kind 0 matches a name / display_name / nip-05 substring (for search without IDB).
*/ */
searchSessionProfilePubkeys(query: string, limit: number): string[] { searchSessionProfilePubkeys(query: string, limit: number): string[] {
const q = query.trim().toLowerCase() const q = query.trim()
if (!q || limit <= 0) return [] if (!q || limit <= 0) return []
const out: string[] = [] const out: string[] = []
for (const ev of this.sessionMetadataByPubkey.values()) { for (const ev of this.sessionMetadataByPubkey.values()) {
if (shouldDropEventOnIngest(ev)) continue if (shouldDropEventOnIngest(ev)) continue
if (out.length >= limit) break if (out.length >= limit) break
try { if (profileKind0MatchesSearchQuery(ev, q)) {
const o = JSON.parse(ev.content) as Record<string, unknown> out.push(ev.pubkey.toLowerCase())
const nip05 =
typeof o.nip05 === 'string'
? o.nip05
.split('@')
.map((s: string) => s.trim())
.join(' ')
: ''
const blob = [o.display_name, o.name, nip05]
.map((x) => (typeof x === 'string' ? x : ''))
.join(' ')
.toLowerCase()
const qNeedle = q.startsWith('@') ? q.slice(1) : q
if (blob.includes(q) || (qNeedle.length > 0 && blob.includes(qNeedle))) {
out.push(ev.pubkey.toLowerCase())
}
} catch {
/* invalid JSON */
} }
} }
return out return out

13
src/services/client.service.ts

@ -109,6 +109,7 @@ import {
import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning'
import { buildDeletionRelayUrls, dispatchTombstonesUpdated } from '@/lib/tombstone-events' import { buildDeletionRelayUrls, dispatchTombstonesUpdated } from '@/lib/tombstone-events'
import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
import { collectNip05ValuesFromKind0 } from '@/lib/profile-metadata-search'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag'
import { import {
@ -3906,15 +3907,9 @@ class ClientService extends EventTarget {
private async addUsernameToIndex(profileEvent: NEvent) { private async addUsernameToIndex(profileEvent: NEvent) {
try { try {
const profileObj = JSON.parse(profileEvent.content) const profileObj = JSON.parse(profileEvent.content)
const text = [ const nip05All = collectNip05ValuesFromKind0(profileEvent).join(' ')
profileObj.display_name?.trim() ?? '', const text = [profileObj.display_name?.trim() ?? '', profileObj.name?.trim() ?? '', nip05All].join(' ')
profileObj.name?.trim() ?? '', if (!text.trim()) return
profileObj.nip05
?.split('@')
.map((s: string) => s.trim())
.join(' ') ?? ''
].join(' ')
if (!text) return
await this.userIndex.addAsync(profileEvent.pubkey, text) await this.userIndex.addAsync(profileEvent.pubkey, text)
} catch { } catch {

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

@ -21,7 +21,7 @@ import {
} from '@/lib/event' } from '@/lib/event'
import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match' import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match'
@ -78,36 +78,6 @@ function isLikelyCachedNostrEvent(v: unknown): v is Event {
) )
} }
/** Kind 0 JSON fields for profile search (display name, handle, NIP-05, pasted npub/nprofile). */
function profileMetadataMatchesQuery(ev: Event, qRaw: string): boolean {
const qLower = qRaw.trim().toLowerCase()
if (!qLower || ev.kind !== kinds.Metadata) return false
if (ev.pubkey.toLowerCase().includes(qLower)) return true
const decodedPk = decodeProfileSearchQueryToPubkeyHex(qRaw)
if (decodedPk && ev.pubkey.toLowerCase() === decodedPk) return true
try {
const profileObj = JSON.parse(ev.content) as Record<string, unknown>
const nip05Raw = profileObj.nip05
const nip05 =
typeof nip05Raw === 'string'
? nip05Raw
.split('@')
.map((s: string) => s.trim())
.join(' ')
: ''
const text = [
typeof profileObj.display_name === 'string' ? profileObj.display_name.trim() : '',
typeof profileObj.name === 'string' ? profileObj.name.trim() : '',
nip05
]
.join(' ')
.toLowerCase()
return text.includes(qLower)
} catch {
return false
}
}
export const StoreNames = { export const StoreNames = {
PROFILE_EVENTS: 'profileEvents', PROFILE_EVENTS: 'profileEvents',
RELAY_LIST_EVENTS: 'relayListEvents', RELAY_LIST_EVENTS: 'relayListEvents',
@ -799,8 +769,8 @@ class IndexedDbService {
} }
/** /**
* Scan cached kind-0 rows for a handle / display name / NIP-05 substring (case-insensitive). * Scan cached kind-0 rows for pubkey / npub / name / about / NIP-05 in JSON `content`, JSON `nip05`,
* Newest replaceable wins per pubkey. * and every `nip05` tag (see {@link profileKind0MatchesSearchQuery}). Newest replaceable wins per pubkey.
*/ */
async searchProfileEventsInCache(query: string, limit: number): Promise<Event[]> { async searchProfileEventsInCache(query: string, limit: number): Promise<Event[]> {
const qLower = query.trim().toLowerCase() const qLower = query.trim().toLowerCase()
@ -824,7 +794,7 @@ class IndexedDbService {
} }
const row = cursor.value as TValue<Event> const row = cursor.value as TValue<Event>
const value = row?.value const value = row?.value
if (value && profileMetadataMatchesQuery(value, query.trim())) { if (value && profileKind0MatchesSearchQuery(value, query.trim())) {
const pk = value.pubkey.toLowerCase() const pk = value.pubkey.toLowerCase()
const prev = byPubkey.get(pk) const prev = byPubkey.get(pk)
if (!prev || value.created_at > prev.created_at) { if (!prev || value.created_at > prev.created_at) {

38
src/services/mention-event-search.service.ts

@ -13,6 +13,7 @@ import { normalizeUrl } from '@/lib/url'
import { kinds, type Event as NEvent } from 'nostr-tools' import { kinds, type Event as NEvent } from 'nostr-tools'
import client, { eventService, queryService } from './client.service' import client, { eventService, queryService } from './client.service'
import indexedDb from './indexed-db.service' import indexedDb from './indexed-db.service'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
const DEFAULT_NOTES_LIMIT = 20 const DEFAULT_NOTES_LIMIT = 20
@ -175,33 +176,20 @@ export async function searchEventsForPicker(
} }
const sessionCap = Math.min(1500, Math.max(limit * 8, 200)) const sessionCap = Math.min(1500, Math.max(limit * 8, 200))
const fromSession = eventService.getSessionEventsMatchingSearch(q, sessionCap, kindsList)
fromSession.forEach(addUnique)
if (out.length >= limit) return out.slice(0, limit)
const localMergeTarget = Math.min(PICKER_LOCAL_DB_MERGE_CAP, Math.max(limit * 10, 240)) const localMergeTarget = Math.min(PICKER_LOCAL_DB_MERGE_CAP, Math.max(limit * 10, 240))
const [fromLocalDb, userCentricRelayUrls] = await Promise.all([ const fromLocalMerged = await collectLocalEventsForTextSearch({
indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(q, localMergeTarget, kindsList, { query: q,
archiveScanMaxMs: 24_000 allowedKinds: kindsList,
}), sessionCap,
buildCitationPickerSearchRelayUrls() idbMergedLimit: localMergeTarget,
]) archiveScanMaxMs: 24_000,
fromLocalDb.forEach(addUnique) includeOtherStoresFullText: true,
fullTextStoreHitCap: Math.min(PICKER_FULLTEXT_DB_CAP, Math.max(localMergeTarget, 200))
try { })
const fullTextHits = await indexedDb.searchAllCachedEventsFullText(q, { fromLocalMerged.forEach(addUnique)
limit: Math.min(PICKER_FULLTEXT_DB_CAP, Math.max(localMergeTarget, 200))
}) const userCentricRelayUrls = await buildCitationPickerSearchRelayUrls()
const kindSet = new Set(kindsList)
for (const hit of fullTextHits) {
const ev = hit.value
if (ev && kindSet.has(ev.kind)) addUnique(ev as NEvent)
if (out.length >= limit) break
}
} catch {
/* best-effort: other stores optional */
}
if (out.length >= limit) return out.slice(0, limit) if (out.length >= limit) return out.slice(0, limit)

Loading…
Cancel
Save