Browse Source

fix the in-event search

imwald
Silberengel 3 weeks ago
parent
commit
6a74b6e069
  1. 3
      src/components/AdvancedEventLab/AdvancedLabCitationPickerDialog.tsx
  2. 16
      src/components/HelpAndAccountMenu.tsx
  3. 26
      src/components/InviteePicker/index.tsx
  4. 63
      src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx
  5. 2
      src/components/PostEditor/PostTextarea/Mention/MentionList.tsx
  6. 33
      src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx
  7. 24
      src/components/PostEditor/PostTextarea/Mention/suggestion.ts
  8. 59
      src/components/ProfileListBySearch/index.tsx
  9. 20
      src/components/SearchBar/index.tsx
  10. 17
      src/components/UserItem/index.tsx
  11. 3
      src/components/ui/ProfileSearchBar.tsx
  12. 6
      src/constants.ts
  13. 88
      src/hooks/useSearchProfiles.tsx
  14. 24
      src/lib/local-nip50-search-merge.ts
  15. 18
      src/lib/merged-search-note-preview.test.ts
  16. 1
      src/lib/merged-search-note-preview.ts
  17. 9
      src/lib/profile-relay-search-filters.ts
  18. 3
      src/services/client-events.service.ts
  19. 187
      src/services/client.service.ts
  20. 75
      src/services/mention-event-search.service.ts

3
src/components/AdvancedEventLab/AdvancedLabCitationPickerDialog.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import { Button } from '@/components/ui/button'
import {
Dialog,
@ -62,7 +63,7 @@ export function AdvancedLabCitationPickerDialog({ @@ -62,7 +63,7 @@ export function AdvancedLabCitationPickerDialog({
useEffect(() => {
if (!open) return
const timer = setTimeout(() => setDebouncedQuery(query.trim()), 300)
const timer = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_QUERY_DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [open, query])

16
src/components/HelpAndAccountMenu.tsx

@ -14,7 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton' @@ -14,7 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { isVideo } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useCacheBrowserOptional } from '../contexts/cache-browser-context'
import { useCacheBrowser } from '@/contexts/cache-browser-context'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useFetchProfile } from '@/hooks/useFetchProfile'
import { useNostr } from '@/providers/NostrProvider'
@ -31,7 +31,7 @@ function AccountDropdownItems({ @@ -31,7 +31,7 @@ function AccountDropdownItems({
}: {
onSwitchAccount: () => void
onLogoutClick: () => void
onBrowseCache?: () => void
onBrowseCache: () => void
}) {
const { t } = useTranslation()
const { navigate } = usePrimaryPage()
@ -46,12 +46,10 @@ function AccountDropdownItems({ @@ -46,12 +46,10 @@ function AccountDropdownItems({
<Settings className="size-4" />
{t('Settings')}
</DropdownMenuItem>
{onBrowseCache ? (
<DropdownMenuItem onClick={onBrowseCache}>
<Database className="size-4" />
{t('Browse Cache')}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSwitchAccount}>
<ArrowDownUp className="size-4" />
@ -72,7 +70,7 @@ function SidebarAccountMenu({ @@ -72,7 +70,7 @@ function SidebarAccountMenu({
}: {
onSwitchAccount: () => void
onLogoutClick: () => void
onBrowseCache?: () => void
onBrowseCache: () => void
}) {
const { t } = useTranslation()
const { account, profile } = useNostr()
@ -136,7 +134,7 @@ function TitlebarAccountMenu({ @@ -136,7 +134,7 @@ function TitlebarAccountMenu({
}: {
onSwitchAccount: () => void
onLogoutClick: () => void
onBrowseCache?: () => void
onBrowseCache: () => void
}) {
const { t } = useTranslation()
const { account, profile } = useNostr()
@ -193,7 +191,7 @@ function TitlebarAccountMenu({ @@ -193,7 +191,7 @@ function TitlebarAccountMenu({
export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr()
const onBrowseCache = useCacheBrowserOptional()?.openBrowseCache
const { openBrowseCache } = useCacheBrowser()
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
@ -204,13 +202,13 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun @@ -204,13 +202,13 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun
<SidebarAccountMenu
onSwitchAccount={() => setLoginDialogOpen(true)}
onLogoutClick={() => setLogoutDialogOpen(true)}
onBrowseCache={onBrowseCache}
onBrowseCache={openBrowseCache}
/>
) : (
<TitlebarAccountMenu
onSwitchAccount={() => setLoginDialogOpen(true)}
onLogoutClick={() => setLogoutDialogOpen(true)}
onBrowseCache={onBrowseCache}
onBrowseCache={openBrowseCache}
/>
)
} else if (variant === 'sidebar') {

26
src/components/InviteePicker/index.tsx

@ -4,13 +4,12 @@ import { inviteInputToHexPubkey } from '@/lib/pubkey' @@ -4,13 +4,12 @@ import { inviteInputToHexPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { X } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username'
import Nip05 from '../Nip05'
const SEARCH_DEBOUNCE_MS = 300
const SEARCH_LIMIT = 10
export function InviteePicker({
@ -32,14 +31,7 @@ export function InviteePicker({ @@ -32,14 +31,7 @@ export function InviteePicker({
const { t } = useTranslation()
const { pubkey: myPubkey } = useNostr()
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
useEffect(() => {
const id = setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS)
return () => clearTimeout(id)
}, [search])
const { profiles, isFetching } = useSearchProfiles(debouncedSearch, SEARCH_LIMIT)
const { profiles, isFetching } = useSearchProfiles(search, SEARCH_LIMIT)
const selectedSet = new Set(value)
const atLimit = max != null && value.length >= max
const filteredProfiles = profiles.filter((p) => !selectedSet.has(p.pubkey) && p.pubkey !== myPubkey)
@ -70,7 +62,12 @@ export function InviteePicker({ @@ -70,7 +62,12 @@ export function InviteePicker({
key={pubkey}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-sm"
>
<SimpleUserAvatar userId={pubkey} className="size-5 shrink-0" />
<SimpleUserAvatar
userId={pubkey}
prefetchedProfile={profiles.find((p) => p.pubkey === pubkey)}
deferRemoteAvatar={false}
className="size-5 shrink-0"
/>
<SimpleUsername userId={pubkey} className="max-w-[120px] truncate" />
<button
type="button"
@ -119,7 +116,12 @@ export function InviteePicker({ @@ -119,7 +116,12 @@ export function InviteePicker({
className="flex w-full cursor-pointer items-center gap-2 p-2 text-left text-sm outline-none hover:bg-accent hover:text-accent-foreground"
onClick={() => addInvitee(profile.pubkey)}
>
<SimpleUserAvatar userId={profile.pubkey} className="size-8 shrink-0" />
<SimpleUserAvatar
userId={profile.pubkey}
prefetchedProfile={profile}
deferRemoteAvatar={false}
className="size-8 shrink-0"
/>
<div className="min-w-0 flex-1">
<SimpleUsername userId={profile.pubkey} className="font-medium truncate" />
<Nip05 pubkey={profile.pubkey} />

63
src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx

@ -6,13 +6,11 @@ @@ -6,13 +6,11 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { SimpleUsername } from '@/components/Username'
import {
MENTION_NPUB_DROPDOWN_LIMIT,
searchNpubsForMention
} from '@/services/mention-event-search.service'
import UserItem, { UserItemSkeleton } from '@/components/UserItem'
import { useSearchProfiles } from '@/hooks'
import { MENTION_NPUB_DROPDOWN_LIMIT } from '@/services/mention-event-search.service'
import { AtSign, FileSearch } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNeventPicker } from './useNeventPicker'
@ -33,34 +31,12 @@ export function MentionAndEventToolbarButtons({ @@ -33,34 +31,12 @@ export function MentionAndEventToolbarButtons({
const neventPicker = useNeventPicker()
const [mentionOpen, setMentionOpen] = useState(false)
const [mentionQuery, setMentionQuery] = useState('')
const [mentionResults, setMentionResults] = useState<string[]>([])
const [mentionLoading, setMentionLoading] = useState(false)
const mentionDebounceRef = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
if (!mentionOpen) return
const q = mentionQuery.trim()
if (!q) {
setMentionResults([])
return
}
mentionDebounceRef.current = setTimeout(() => {
setMentionLoading(true)
searchNpubsForMention(q, MENTION_NPUB_DROPDOWN_LIMIT)
.then((list) => {
setMentionResults(list ?? [])
})
.finally(() => setMentionLoading(false))
}, 200)
return () => {
if (mentionDebounceRef.current) clearTimeout(mentionDebounceRef.current)
}
}, [mentionOpen, mentionQuery])
const { profiles, isFetching: mentionLoading, debouncedSearch: debouncedMentionQuery } =
useSearchProfiles(mentionOpen ? mentionQuery : '', MENTION_NPUB_DROPDOWN_LIMIT)
const closeMention = useCallback(() => {
setMentionOpen(false)
setMentionQuery('')
setMentionResults([])
}, [])
const selectNpub = useCallback(
@ -97,22 +73,31 @@ export function MentionAndEventToolbarButtons({ @@ -97,22 +73,31 @@ export function MentionAndEventToolbarButtons({
autoFocus
/>
<div className="max-h-60 overflow-y-auto space-y-0.5">
{mentionLoading && (
<div className="py-4 text-center text-sm text-muted-foreground">{t('Searching…')}</div>
{mentionLoading && profiles.length === 0 && (
<div className="px-1 py-2 space-y-1">
<UserItemSkeleton hideFollowButton />
<UserItemSkeleton hideFollowButton />
</div>
)}
{!mentionLoading && mentionQuery.trim() && mentionResults.length === 0 && (
{!mentionLoading && debouncedMentionQuery && profiles.length === 0 && (
<div className="py-4 text-center text-sm text-muted-foreground">{t('No users found')}</div>
)}
{!mentionLoading &&
mentionResults.map((npub) => (
{profiles.map((profile) => (
<Button
key={npub}
key={profile.pubkey}
type="button"
variant="ghost"
className="w-full justify-start text-left h-auto py-2 font-normal"
onClick={() => selectNpub(npub)}
className="w-full justify-start text-left h-auto py-1 px-1 font-normal"
onClick={() => selectNpub(profile.npub)}
>
<SimpleUsername userId={npub} className="text-sm truncate" />
<UserItem
pubkey={profile.pubkey}
hideFollowButton
hideNip05
prefetchedProfile={profile}
deferRemoteAvatar={false}
className="pointer-events-none w-full"
/>
</Button>
))}
</div>

2
src/components/PostEditor/PostTextarea/Mention/MentionList.tsx

@ -125,7 +125,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) @@ -125,7 +125,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
</span>
) : (
<>
<SimpleUserAvatar userId={getItemId(item)} />
<SimpleUserAvatar userId={getItemId(item)} deferRemoteAvatar={false} />
<div className="flex-1 w-0">
<SimpleUsername userId={getItemId(item)} className="font-semibold truncate" />
<Nip05 pubkey={userIdToPubkey(getItemId(item))} />

33
src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
import * as React from 'react'
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import { getNoteBech32Id } from '@/lib/event'
import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview'
import client from '@/services/client.service'
import {
searchEventsForPicker,
@ -42,18 +44,20 @@ function NeventNaddrPickerDialog({ @@ -42,18 +44,20 @@ function NeventNaddrPickerDialog({
const [debouncedQuery, setDebouncedQuery] = useState('')
const [events, setEvents] = useState<NEvent[]>([])
const [loading, setLoading] = useState(false)
const [relayPending, setRelayPending] = useState(false)
useEffect(() => {
if (!open) return
setQuery('')
setDebouncedQuery('')
setEvents([])
setRelayPending(false)
if (initialMode !== undefined) setMode(initialMode)
}, [open, initialMode])
useEffect(() => {
if (!open) return
const t = setTimeout(() => setDebouncedQuery(query.trim()), 300)
const t = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_QUERY_DEBOUNCE_MS)
return () => clearTimeout(t)
}, [open, query])
@ -61,17 +65,28 @@ function NeventNaddrPickerDialog({ @@ -61,17 +65,28 @@ function NeventNaddrPickerDialog({
if (!open || !debouncedQuery) {
setEvents([])
setLoading(false)
setRelayPending(false)
return
}
let cancelled = false
setLoading(true)
searchEventsForPicker(debouncedQuery, 20, mode, undefined)
setRelayPending(true)
setEvents([])
searchEventsForPicker(debouncedQuery, 20, mode, undefined, (partial) => {
if (cancelled) return
const visible = partial.filter(mergedSearchNoteHasPreviewBody).slice(0, 15) as NEvent[]
setEvents(visible)
if (visible.length > 0) setLoading(false)
})
.then((list) => {
if (cancelled) return
setEvents(list.slice(0, 15) as NEvent[])
setEvents(list.filter(mergedSearchNoteHasPreviewBody).slice(0, 15) as NEvent[])
})
.finally(() => {
if (!cancelled) setLoading(false)
if (!cancelled) {
setLoading(false)
setRelayPending(false)
}
})
return () => {
cancelled = true
@ -136,20 +151,22 @@ function NeventNaddrPickerDialog({ @@ -136,20 +151,22 @@ function NeventNaddrPickerDialog({
</div>
<div className="min-h-[200px] max-h-[50vh] border rounded-md overflow-y-auto overflow-x-hidden">
<div className="p-2 space-y-1">
{loading && (
{loading && events.length === 0 && (
<div className="space-y-2 p-2" role="status" aria-busy="true" aria-live="polite">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-md" />
))}
</div>
)}
{!loading && debouncedQuery && events.length === 0 && (
{relayPending && events.length > 0 && (
<p className="text-xs text-muted-foreground text-center py-1">{t('Searching…')}</p>
)}
{!loading && !relayPending && debouncedQuery && events.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-6">
{t('No events found')}
</p>
)}
{!loading &&
events.map((ev: NEvent) => (
{events.map((ev: NEvent) => (
<Button
key={ev.id}
variant="ghost"

24
src/components/PostEditor/PostTextarea/Mention/suggestion.ts

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import {
MENTION_NPUB_DROPDOWN_LIMIT,
searchNpubsForMention,
@ -22,6 +23,8 @@ export const OPEN_NEVENT_PICKER_EVENT = 'open-nevent-picker' @@ -22,6 +23,8 @@ export const OPEN_NEVENT_PICKER_EVENT = 'open-nevent-picker'
let currentComponent: ReactRenderer<MentionListHandle, MentionListProps> | undefined
let currentQuery = ''
let backgroundSearchController: AbortController | null = null
let mentionSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let mentionSearchGeneration = 0
/** Extend range.to to include any trailing word chars (handle, NIP-05) so the full @handle is replaced. Exported for nevent picker. */
export function extendMentionRangeToEndOfWord(editor: Editor, range: { from: number; to: number }): number {
@ -83,29 +86,34 @@ const suggestion = { @@ -83,29 +86,34 @@ const suggestion = {
return [{ id: NEVENT_NADDR_PICKER_ID, mode }]
}
// Abort previous background search if query changed
if (mentionSearchDebounceTimer) clearTimeout(mentionSearchDebounceTimer)
const generation = ++mentionSearchGeneration
return new Promise<MentionListItem[]>((resolve) => {
mentionSearchDebounceTimer = setTimeout(async () => {
if (generation !== mentionSearchGeneration) return
if (currentQuery !== q && backgroundSearchController) {
backgroundSearchController.abort()
backgroundSearchController = null
}
currentQuery = q
// Update component as results arrive (incremental updates)
const updateComponent = (npubs: string[]) => {
if (currentComponent && currentQuery === q) {
const items: MentionListItem[] = npubs
currentComponent.updateProps({ items })
if (currentComponent && currentQuery === q && generation === mentionSearchGeneration) {
currentComponent.updateProps({ items: npubs })
}
}
// Start search with callback - returns cached results immediately, then updates with relay results
backgroundSearchController = new AbortController()
try {
const results = await searchNpubsForMention(query, MENTION_NPUB_DROPDOWN_LIMIT, updateComponent)
return results ?? []
if (generation === mentionSearchGeneration) resolve(results ?? [])
} catch {
return []
if (generation === mentionSearchGeneration) resolve([])
}
}, SEARCH_QUERY_DEBOUNCE_MS)
})
},
render: () => {

59
src/components/ProfileListBySearch/index.tsx

@ -38,6 +38,7 @@ export function ProfileListBySearch({ @@ -38,6 +38,7 @@ export function ProfileListBySearch({
/** Initial page: must not read `pubkeySet` from state — it is still the previous search until the next paint. */
useEffect(() => {
const ac = new AbortController()
let cancelled = false
const untilStart = dayjs().unix()
@ -52,15 +53,29 @@ export function ProfileListBySearch({ @@ -52,15 +53,29 @@ export function ProfileListBySearch({
const seen = new Set<string>()
const batch: string[] = []
const cached = await client.searchProfilesFromIndexedDBCache(search, LIMIT)
if (cancelled) return
for (const p of cached) {
const pk = p.pubkey.toLowerCase()
const mergeProfiles = (profiles: Awaited<ReturnType<typeof client.searchProfilesStaged>>) => {
for (const profile of profiles) {
const pk = profile.pubkey.toLowerCase()
if (seen.has(pk)) continue
seen.add(pk)
batch.push(p.pubkey)
batch.push(profile.pubkey)
}
}
const staged = await client.searchProfilesStaged(
search,
LIMIT,
(partial) => {
if (cancelled) return
mergeProfiles(partial)
setPubkeys([...batch])
if (partial.length > 0) setPhase('ready')
},
ac.signal
)
if (cancelled) return
mergeProfiles(staged)
const directPk = decodeProfileSearchQueryToPubkeyHex(search)
if (directPk && !seen.has(directPk)) {
seen.add(directPk)
@ -68,32 +83,15 @@ export function ProfileListBySearch({ @@ -68,32 +83,15 @@ export function ProfileListBySearch({
void client.fetchProfileEvent(directPk).catch(() => {})
}
const relayProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, {
search,
until: untilStart,
limit: LIMIT
})
if (cancelled) return
for (const profile of relayProfiles) {
const pk = profile.pubkey.toLowerCase()
if (seen.has(pk)) continue
seen.add(pk)
batch.push(profile.pubkey)
}
let nextUntil = untilStart
if (relayProfiles.length > 0) {
const last = relayProfiles[relayProfiles.length - 1]!
const ca = last.created_at
if (typeof ca === 'number' && ca > 0) {
nextUntil = ca - 1
}
for (const p of staged) {
const ca = p.created_at
if (typeof ca === 'number' && ca > 0 && ca < nextUntil) nextUntil = ca - 1
}
setPubkeys(batch)
setUntil(nextUntil)
setHasMore(relayProfiles.length >= LIMIT)
setHasMore(staged.length >= LIMIT)
setEmpty(batch.length === 0)
setPhase('ready')
} catch {
@ -107,6 +105,7 @@ export function ProfileListBySearch({ @@ -107,6 +105,7 @@ export function ProfileListBySearch({
return () => {
cancelled = true
ac.abort()
}
}, [search])
@ -114,11 +113,15 @@ export function ProfileListBySearch({ @@ -114,11 +113,15 @@ export function ProfileListBySearch({
if (loadMoreInFlight.current || !hasMore) return
loadMoreInFlight.current = true
try {
const relayProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, {
const relayProfiles = await client.searchProfiles(
PROFILE_SEARCH_RELAY_URLS,
{
search,
until: untilRef.current,
limit: LIMIT
})
},
{ relaysOnly: true, includeTagFilters: false, eoseTimeout: 6_000, globalTimeout: 9_000 }
)
if (relayProfiles.length === 0) {
setHasMore(false)

20
src/components/SearchBar/index.tsx

@ -39,9 +39,11 @@ const SearchBar = forwardRef< @@ -39,9 +39,11 @@ const SearchBar = forwardRef<
const { navigateToNote } = useSmartNoteNavigation()
const { navigateToHashtag } = useSmartHashtagNavigation()
const { isSmallScreen } = useScreenSize()
const [debouncedInput, setDebouncedInput] = useState(input)
const { profiles, isFetching: isFetchingProfiles } = useSearchProfiles(debouncedInput, 5)
const [searching, setSearching] = useState(false)
const { profiles, isFetching: isFetchingProfiles, debouncedSearch } = useSearchProfiles(
searching ? input : '',
5
)
const [displayList, setDisplayList] = useState(false)
const [selectableOptions, setSelectableOptions] = useState<TSearchParams[]>([])
const [selectedIndex, setSelectedIndex] = useState(-1)
@ -79,16 +81,6 @@ const SearchBar = forwardRef< @@ -79,16 +81,6 @@ const SearchBar = forwardRef<
setSelectedIndex(-1)
}, [input])
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedInput(input)
}, 500)
return () => {
clearTimeout(handler)
}
}, [input])
const blur = () => {
setSearching(false)
searchInputRef.current?.blur()
@ -179,7 +171,7 @@ const SearchBar = forwardRef< @@ -179,7 +171,7 @@ const SearchBar = forwardRef<
profile
}))
] as TSearchParams[])
}, [input, debouncedInput, profiles])
}, [input, debouncedSearch, profiles])
const list = useMemo(() => {
if (selectableOptions.length <= 0) {
@ -542,8 +534,10 @@ function ProfileItem({ @@ -542,8 +534,10 @@ function ProfileItem({
<UserItem
pubkey={userId}
hideFollowButton
hideNip05
className="pointer-events-none"
prefetchedProfile={prefetchedProfile}
deferRemoteAvatar={false}
/>
</div>
)

17
src/components/UserItem/index.tsx

@ -9,18 +9,29 @@ import type { TProfile } from '@/types' @@ -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 (
<div className={cn('flex gap-2 items-center h-14', className)}>
<UserAvatar userId={pubkey} prefetchedProfile={prefetchedProfile ?? undefined} className="shrink-0" />
<UserAvatar
userId={pubkey}
prefetchedProfile={prefetchedProfile ?? undefined}
deferRemoteAvatar={deferRemoteAvatar}
className="shrink-0"
/>
<div className="w-full overflow-hidden">
<Username
userId={pubkey}
@ -28,7 +39,7 @@ export default function UserItem({ @@ -28,7 +39,7 @@ export default function UserItem({
className="font-semibold truncate max-w-full w-fit"
skeletonClassName="h-4"
/>
<Nip05 pubkey={pubkey} />
{!hideNip05 && <Nip05 pubkey={pubkey} nip05={prefetchedProfile?.nip05} />}
</div>
{!hideFollowButton && <FollowButton pubkey={pubkey} />}
</div>

3
src/components/ui/ProfileSearchBar.tsx

@ -1,3 +1,4 @@ @@ -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({ @@ -23,7 +24,7 @@ export default function ProfileSearchBar({
useEffect(() => {
const timer = setTimeout(() => {
onSearch(query)
}, 300)
}, SEARCH_QUERY_DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [query, onSearch])

6
src/constants.ts

@ -505,6 +505,12 @@ export const SEARCHABLE_RELAY_URLS = [ @@ -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',

88
src/hooks/useSearchProfiles.tsx

@ -1,58 +1,76 @@ @@ -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<Error | null>(null)
const [profiles, setProfiles] = useState<TProfile[]>([])
const abortRef = useRef<AbortController | null>(null)
const partialTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const fetchProfiles = async () => {
if (!search.trim()) {
const trimmed = search.trim()
if (!trimmed) {
setDebouncedSearch('')
return
}
const timer = setTimeout(() => setDebouncedSearch(trimmed), debounceMs)
return () => clearTimeout(timer)
}, [search, debounceMs])
useEffect(() => {
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 }
}

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

@ -56,18 +56,6 @@ export async function collectLocalEventsForTextSearch( @@ -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( @@ -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
}

18
src/lib/merged-search-note-preview.test.ts

@ -0,0 +1,18 @@ @@ -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)
})
})

1
src/lib/merged-search-note-preview.ts

@ -20,6 +20,7 @@ export function mergedSearchNoteHasPreviewBody(ev: Event): boolean { @@ -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) {

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

@ -15,7 +15,13 @@ export function buildProfileKind0SearchFilters(opts: { @@ -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: { @@ -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<string>()
if (firstToken) {
@ -66,6 +72,7 @@ export function buildProfileKind0SearchFilters(opts: { @@ -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 &&

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

@ -791,7 +791,8 @@ export class EventService { @@ -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

187
src/services/client.service.ts

@ -3661,7 +3661,18 @@ class ClientService extends EventTarget { @@ -3661,7 +3661,18 @@ class ClientService extends EventTarget {
/** =========== Profile =========== */
async searchProfiles(relayUrls: string[], filter: Filter): Promise<TProfile[]> {
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<TProfile[]> {
void this.ensureProfileSearchIndexFromIdb()
const searchStr = typeof filter.search === 'string' ? filter.search.trim() : ''
const normalizedAll = dedupeNormalizeRelayUrlsOrdered(
@ -3676,7 +3687,7 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 { @@ -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<string, NEvent>()
@ -3733,6 +3749,106 @@ class ClientService extends EventTarget { @@ -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<TProfile[]> {
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<string>()
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<string>()
@ -3891,18 +4007,6 @@ class ClientService extends EventTarget { @@ -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 { @@ -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) => {
// 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 (addNpub(npub)) updateIfNeeded()
if (out.length >= limit) break
}
} catch {
/* best-effort */
}
}
relayProfiles.forEach((p) => {
// 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) {
this.replaceableEventService.fetchProfileEvent(npub).catch(() => {})
if (!npub) continue
if (addNpub(npub)) updateIfNeeded()
if (out.length >= limit) break
}
} catch {
/* best-effort */
}
}
})
})
.catch(() => {
// relay search is best-effort
})
}
// Prime profile cache for cached results

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

@ -8,11 +8,12 @@ import { @@ -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 = [ @@ -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( @@ -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( @@ -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<NEvent[]> {
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( @@ -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)
}

Loading…
Cancel
Save