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

16
src/components/HelpAndAccountMenu.tsx

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

26
src/components/InviteePicker/index.tsx

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

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

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

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

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

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

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

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

@ -1,3 +1,4 @@
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import { import {
MENTION_NPUB_DROPDOWN_LIMIT, MENTION_NPUB_DROPDOWN_LIMIT,
searchNpubsForMention, searchNpubsForMention,
@ -22,6 +23,8 @@ export const OPEN_NEVENT_PICKER_EVENT = 'open-nevent-picker'
let currentComponent: ReactRenderer<MentionListHandle, MentionListProps> | undefined let currentComponent: ReactRenderer<MentionListHandle, MentionListProps> | undefined
let currentQuery = '' let currentQuery = ''
let backgroundSearchController: AbortController | null = null 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. */ /** 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 { export function extendMentionRangeToEndOfWord(editor: Editor, range: { from: number; to: number }): number {
@ -83,29 +86,34 @@ const suggestion = {
return [{ id: NEVENT_NADDR_PICKER_ID, mode }] 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) { if (currentQuery !== q && backgroundSearchController) {
backgroundSearchController.abort() backgroundSearchController.abort()
backgroundSearchController = null backgroundSearchController = null
} }
currentQuery = q currentQuery = q
// Update component as results arrive (incremental updates)
const updateComponent = (npubs: string[]) => { const updateComponent = (npubs: string[]) => {
if (currentComponent && currentQuery === q) { if (currentComponent && currentQuery === q && generation === mentionSearchGeneration) {
const items: MentionListItem[] = npubs currentComponent.updateProps({ items: npubs })
currentComponent.updateProps({ items })
} }
} }
// Start search with callback - returns cached results immediately, then updates with relay results
backgroundSearchController = new AbortController() backgroundSearchController = new AbortController()
try { try {
const results = await searchNpubsForMention(query, MENTION_NPUB_DROPDOWN_LIMIT, updateComponent) const results = await searchNpubsForMention(query, MENTION_NPUB_DROPDOWN_LIMIT, updateComponent)
return results ?? [] if (generation === mentionSearchGeneration) resolve(results ?? [])
} catch { } catch {
return [] if (generation === mentionSearchGeneration) resolve([])
} }
}, SEARCH_QUERY_DEBOUNCE_MS)
})
}, },
render: () => { render: () => {

59
src/components/ProfileListBySearch/index.tsx

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

20
src/components/SearchBar/index.tsx

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

17
src/components/UserItem/index.tsx

@ -9,18 +9,29 @@ import type { TProfile } from '@/types'
export default function UserItem({ export default function UserItem({
pubkey, pubkey,
hideFollowButton, hideFollowButton,
hideNip05,
className, className,
prefetchedProfile prefetchedProfile,
deferRemoteAvatar = true
}: { }: {
pubkey: string pubkey: string
hideFollowButton?: boolean hideFollowButton?: boolean
/** Skip nip05 verification fetches (search dropdown rows). */
hideNip05?: boolean
className?: string className?: string
/** When the caller already loaded this profile (e.g. search index / DB), show it immediately. */ /** When the caller already loaded this profile (e.g. search index / DB), show it immediately. */
prefetchedProfile?: TProfile | null prefetchedProfile?: TProfile | null
/** Set false in search/mention dropdowns so profile pictures load without viewport deferral. */
deferRemoteAvatar?: boolean
}) { }) {
return ( return (
<div className={cn('flex gap-2 items-center h-14', className)}> <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"> <div className="w-full overflow-hidden">
<Username <Username
userId={pubkey} userId={pubkey}
@ -28,7 +39,7 @@ export default function UserItem({
className="font-semibold truncate max-w-full w-fit" className="font-semibold truncate max-w-full w-fit"
skeletonClassName="h-4" skeletonClassName="h-4"
/> />
<Nip05 pubkey={pubkey} /> {!hideNip05 && <Nip05 pubkey={pubkey} nip05={prefetchedProfile?.nip05} />}
</div> </div>
{!hideFollowButton && <FollowButton pubkey={pubkey} />} {!hideFollowButton && <FollowButton pubkey={pubkey} />}
</div> </div>

3
src/components/ui/ProfileSearchBar.tsx

@ -1,3 +1,4 @@
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Search, X } from 'lucide-react' import { Search, X } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -23,7 +24,7 @@ export default function ProfileSearchBar({
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
onSearch(query) onSearch(query)
}, 300) }, SEARCH_QUERY_DEBOUNCE_MS)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [query, onSearch]) }, [query, onSearch])

6
src/constants.ts

@ -505,6 +505,12 @@ export const SEARCHABLE_RELAY_URLS = [
'wss://nostr-pub.wellorder.net', '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 = [ export const PROFILE_RELAY_URLS = [
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://purplepag.es', 'wss://purplepag.es',

88
src/hooks/useSearchProfiles.tsx

@ -1,58 +1,76 @@
import { PROFILE_RELAY_URLS } from '@/constants' import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TProfile } from '@/types' import { TProfile } from '@/types'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
const PROFILE_SEARCH_RELAY_URLS = Array.from( export function useSearchProfiles(
new Set(PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)) search: string,
) limit: number,
debounceMs: number = SEARCH_QUERY_DEBOUNCE_MS
export function useSearchProfiles(search: string, limit: number) { ) {
const [debouncedSearch, setDebouncedSearch] = useState(() => search.trim())
const [isFetching, setIsFetching] = useState(false) const [isFetching, setIsFetching] = useState(false)
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [profiles, setProfiles] = useState<TProfile[]>([]) const [profiles, setProfiles] = useState<TProfile[]>([])
const abortRef = useRef<AbortController | null>(null)
const partialTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => { useEffect(() => {
const fetchProfiles = async () => { const trimmed = search.trim()
if (!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([]) setProfiles([])
setIsFetching(false) setIsFetching(false)
setError(null)
return return
} }
setIsFetching(true) setIsFetching(true)
setProfiles([]) setProfiles([])
setError(null)
try { try {
const profiles = await client.searchProfilesFromLocal(search, limit) const result = await client.searchProfilesStaged(
setProfiles(profiles) debouncedSearch,
if (profiles.length >= limit) { limit,
return (partial) => {
} if (cancelled || ac.signal.aborted) return
const existingPubkeys = new Set(profiles.map((profile) => profile.pubkey)) if (partialTimerRef.current) clearTimeout(partialTimerRef.current)
const fetchedProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, { partialTimerRef.current = setTimeout(() => {
search, if (!cancelled && !ac.signal.aborted) setProfiles([...partial])
limit }, 80)
}) },
if (fetchedProfiles.length) { ac.signal
fetchedProfiles.forEach((profile) => { )
if (existingPubkeys.has(profile.pubkey)) { if (!cancelled && !ac.signal.aborted) setProfiles(result)
return
}
existingPubkeys.add(profile.pubkey)
profiles.push(profile)
})
setProfiles([...profiles])
}
} catch (err) { } catch (err) {
setError(err as Error) if (!cancelled && !ac.signal.aborted) setError(err as Error)
} finally { } finally {
setIsFetching(false) if (!cancelled && !ac.signal.aborted) setIsFetching(false)
} }
} }
fetchProfiles() void run()
}, [search, limit]) 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(
} }
} }
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) { if (params.includeOtherStoresFullText) {
const cap = params.fullTextStoreHitCap ?? 260 const cap = params.fullTextStoreHitCap ?? 260
try { 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)) out.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
return out return out
} }

18
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)
})
})

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

@ -20,6 +20,7 @@ export function mergedSearchNoteHasPreviewBody(ev: Event): boolean {
const k = ev.kind const k = ev.kind
if (k === kinds.ShortTextNote || k === ExtendedKind.COMMENT) { 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] === '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) return Boolean(ev.content?.trim().length)
} }
if (k === kinds.Metadata) { if (k === kinds.Metadata) {

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

@ -15,7 +15,13 @@ export function buildProfileKind0SearchFilters(opts: {
search: string search: string
limit: number limit: number
until?: 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[] { }): Filter[] {
const includeTagFilters = opts.includeTagFilters !== false
const searchRaw = opts.search.trim() const searchRaw = opts.search.trim()
if (!searchRaw) return [] if (!searchRaw) return []
@ -45,7 +51,7 @@ export function buildProfileKind0SearchFilters(opts: {
add({ kinds: k, search: searchNorm, limit, ...time }) add({ kinds: k, search: searchNorm, limit, ...time })
} }
if (searchRaw.includes('@')) { if (includeTagFilters && searchRaw.includes('@')) {
const firstToken = (searchRaw.split(/\s+/)[0] ?? searchRaw).trim() const firstToken = (searchRaw.split(/\s+/)[0] ?? searchRaw).trim()
const nipVariants = new Set<string>() const nipVariants = new Set<string>()
if (firstToken) { if (firstToken) {
@ -66,6 +72,7 @@ export function buildProfileKind0SearchFilters(opts: {
const token = searchRaw.startsWith('@') ? searchRaw.slice(1).trim() : searchRaw.trim() const token = searchRaw.startsWith('@') ? searchRaw.slice(1).trim() : searchRaw.trim()
if ( if (
includeTagFilters &&
token && token &&
!/\s/.test(token) && !/\s/.test(token) &&
token.length <= 80 && token.length <= 80 &&

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

@ -791,7 +791,8 @@ export class EventService {
const buf: NEvent[] = [] const buf: NEvent[] = []
let scanned = 0 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 (++scanned > SESSION_SEARCH_MAX_SCAN) break
if (shouldDropEventOnIngest(event)) continue if (shouldDropEventOnIngest(event)) continue
if (kindSet && !kindSet.has(event.kind)) continue if (kindSet && !kindSet.has(event.kind)) continue

187
src/services/client.service.ts

@ -3661,7 +3661,18 @@ class ClientService extends EventTarget {
/** =========== Profile =========== */ /** =========== 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() void this.ensureProfileSearchIndexFromIdb()
const searchStr = typeof filter.search === 'string' ? filter.search.trim() : '' const searchStr = typeof filter.search === 'string' ? filter.search.trim() : ''
const normalizedAll = dedupeNormalizeRelayUrlsOrdered( const normalizedAll = dedupeNormalizeRelayUrlsOrdered(
@ -3676,7 +3687,7 @@ class ClientService extends EventTarget {
...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) ...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
]) ])
let urls = normalizedAll let urls = normalizedAll
if (searchStr.length > 0) { if (searchStr.length > 0 && !options?.relaysOnly) {
const searchCapable = normalizedAll.filter( const searchCapable = normalizedAll.filter(
(u) => searchableSet.has(u) || nip66Service.isRelaySearchable(u) (u) => searchableSet.has(u) || nip66Service.isRelaySearchable(u)
) )
@ -3693,7 +3704,8 @@ class ClientService extends EventTarget {
const built = buildProfileKind0SearchFilters({ const built = buildProfileKind0SearchFilters({
search: searchStr, search: searchStr,
limit: limitCap, limit: limitCap,
until: filter.until until: filter.until,
includeTagFilters: options?.includeTagFilters
}) })
return built.length > 0 ? built : [{ ...filter, kinds: [...METADATA_CO_FETCH_KINDS] }] 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 (f) => typeof f.search === 'string' && f.search.trim().length > 0
) )
const usesAuthorsLookup = filtersArr.some((f) => (f.authors?.length ?? 0) > 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, { const events = await this.queryService.query(urls, queryFilter, undefined, {
replaceableRace: false, replaceableRace: false,
eoseTimeout: usesNip50TextSearch ? 10_000 : 4500, eoseTimeout:
globalTimeout: usesNip50TextSearch options?.eoseTimeout ?? (usesNip50TextSearch ? 10_000 : 4500),
? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 globalTimeout:
: 9000, options?.globalTimeout ??
(usesNip50TextSearch ? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 : 9000),
relayOpSource: 'ClientService.searchProfiles', relayOpSource: 'ClientService.searchProfiles',
foreground: usesNip50TextSearch || usesAuthorsLookup foreground: usesNip50TextSearch || usesAuthorsLookup,
signal: options?.signal
}) })
const byPk = new Map<string, NEvent>() const byPk = new Map<string, NEvent>()
@ -3733,6 +3749,106 @@ class ClientService extends EventTarget {
return profileEvents.map((profileEvent) => getProfileFromEvent(profileEvent)) 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) { async searchNpubsFromLocal(query: string, limit: number = 100) {
await this.ensureProfileSearchIndexFromIdb() await this.ensureProfileSearchIndexFromIdb()
const seen = new Set<string>() const seen = new Set<string>()
@ -3891,18 +4007,6 @@ class ClientService extends EventTarget {
if (np) addNpub(np) 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. // 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 // 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). // early and skipped relay search entirely (bad for handle search beyond the local index).
@ -3989,30 +4093,41 @@ class ClientService extends EventTarget {
return out return out
} }
// 3. Relay search — merge after local + follow so ordering stays local → follows → wider index. // 3. Profile relays only (purplepag.es, profiles.nostr1.com, …) — not NIP-50 index relays.
// relayTask was started at the beginning; do not await before return (first paint stays fast). if (q.length >= 1 && out.length < limit) {
if (q.length >= 1) { try {
relayTask const relayProfiles = await this.searchProfiles(
.then((relayProfiles) => { this.profileRelaySearchUrls(),
{ search: q, limit: limit - out.length },
{ relaysOnly: true, includeTagFilters: false, eoseTimeout: 6_000, globalTimeout: 9_000 }
)
for (const p of relayProfiles) { for (const p of relayProfiles) {
const npub = pubkeyToNpub(p.pubkey) const npub = pubkeyToNpub(p.pubkey)
if (!npub) continue if (!npub) continue
if (addNpub(npub)) { if (addNpub(npub)) updateIfNeeded()
updateIfNeeded()
}
if (out.length >= limit) break 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) const npub = pubkeyToNpub(p.pubkey)
if (npub) { if (!npub) continue
this.replaceableEventService.fetchProfileEvent(npub).catch(() => {}) if (addNpub(npub)) updateIfNeeded()
if (out.length >= limit) break
}
} catch {
/* best-effort */
}
} }
})
})
.catch(() => {
// relay search is best-effort
})
} }
// Prime profile cache for cached results // Prime profile cache for cached results

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

@ -8,11 +8,12 @@ import {
citationPickerMatchesQuery, citationPickerMatchesQuery,
tryParseCitationEventIdFromQuery tryParseCitationEventIdFromQuery
} from '@/lib/citation-picker-search' } 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 { 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 { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
const DEFAULT_NOTES_LIMIT = 20 const DEFAULT_NOTES_LIMIT = 20
@ -37,6 +38,9 @@ export const NEVENT_KINDS = [
ExtendedKind.CITATION_PROMPT ExtendedKind.CITATION_PROMPT
] as const ] 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.). */ /** NIP-32 citation events only (Advanced lab citation picker, etc.). */
export const CITATION_PICKER_KINDS = [ export const CITATION_PICKER_KINDS = [
ExtendedKind.CITATION_INTERNAL, ExtendedKind.CITATION_INTERNAL,
@ -142,6 +146,8 @@ async function searchCitationEventsForPickerInternal(
/** Local DB + session budget for picker search (before relay NIP-50). */ /** Local DB + session budget for picker search (before relay NIP-50). */
const PICKER_LOCAL_DB_MERGE_CAP = 880 const PICKER_LOCAL_DB_MERGE_CAP = 880
const PICKER_FULLTEXT_DB_CAP = 260 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. * Search for events: session cache IndexedDB (publication + archive + cross-store full text) relays.
@ -153,13 +159,18 @@ export async function searchEventsForPicker(
query: string, query: string,
limit: number = DEFAULT_NOTES_LIMIT, limit: number = DEFAULT_NOTES_LIMIT,
mode: PickerSearchMode = 'nevent', mode: PickerSearchMode = 'nevent',
kindFilter?: readonly number[] kindFilter?: readonly number[],
onUpdate?: (events: NEvent[]) => void
): Promise<NEvent[]> { ): Promise<NEvent[]> {
const q = query.trim() const q = query.trim()
if (!q) return [] if (!q) return []
const kindsList = 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)) { if (isCitationOnlyKindFilter(kindFilter)) {
return searchCitationEventsForPickerInternal(q, limit, kindsList) return searchCitationEventsForPickerInternal(q, limit, kindsList)
@ -174,45 +185,65 @@ export async function searchEventsForPicker(
out.push(evt) out.push(evt)
} }
const emit = () => {
if (onUpdate) onUpdate(out.slice(0, limit))
}
const sessionCap = Math.min(1500, Math.max(limit * 8, 200)) 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 localMergeTarget = Math.min(PICKER_LOCAL_DB_MERGE_CAP, Math.max(limit * 10, 240))
const fromLocalMerged = await collectLocalEventsForTextSearch({ for (const ev of eventService.getSessionEventsMatchingSearch(q, sessionCap, kindsList)) {
query: q, addUnique(ev)
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()
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( const searchableNip50Layer = Array.from(
new Set(SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u.trim()).filter(Boolean)) new Set(SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u.trim()).filter(Boolean))
).slice(0, 28) ).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( queryService.fetchEvents(
userCentricRelayUrls, userCentricRelayUrls,
{ kinds: kindsList, search: q, limit: need }, { kinds: kindsList, search: q, limit: relayLimit },
{ eoseTimeout: 5000, globalTimeout: 8000 } { eoseTimeout: 5000, globalTimeout: 8000 }
), ),
searchableNip50Layer.length > 0 searchableNip50Layer.length > 0
? queryService.fetchEvents( ? queryService.fetchEvents(
searchableNip50Layer, searchableNip50Layer,
{ kinds: kindsList, search: q, limit: need }, { kinds: kindsList, search: q, limit: relayLimit },
{ eoseTimeout: 6500, globalTimeout: 12_000 } { eoseTimeout: 6500, globalTimeout: 12_000 }
) )
: Promise.resolve([] as NEvent[]) : 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) return out.slice(0, limit)
} }

Loading…
Cancel
Save