From e5101fbaaf5ded3004249bd8659524e870e796d2 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 5 Jun 2026 12:30:56 +0200 Subject: [PATCH] bug-fix mention searches --- src/components/PostEditor/PostContent.tsx | 2 +- .../PostTextarea/Mention/MentionList.tsx | 27 ++- .../Mention/NeventNaddrPickerDialog.tsx | 70 +------- .../Mention/NeventPickerProvider.tsx | 68 ++++++++ .../Mention/nevent-picker-context.ts | 8 + .../PostTextarea/Mention/suggestion.ts | 155 ++++++------------ .../PostTextarea/Mention/useNeventPicker.ts | 2 +- .../PostEditor/PostTextarea/index.tsx | 6 + .../PostTextarea/suggestion-popup.ts | 90 ++++++++++ src/services/client-events.service.ts | 23 +++ src/services/client.service.ts | 122 +++++++++++--- src/services/post-editor.service.ts | 9 + 12 files changed, 379 insertions(+), 203 deletions(-) create mode 100644 src/components/PostEditor/PostTextarea/Mention/NeventPickerProvider.tsx create mode 100644 src/components/PostEditor/PostTextarea/Mention/nevent-picker-context.ts create mode 100644 src/components/PostEditor/PostTextarea/suggestion-popup.ts diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 1cc6f11b..9f9237fa 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -117,7 +117,7 @@ import { parseNostrSpecAffectedKinds, type NostrSpecAffectedKindRow } from '@/lib/nostr-spec-affected-kinds' -import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDialog' +import { NeventPickerProvider } from './PostTextarea/Mention/NeventPickerProvider' import Uploader from './Uploader' import HighlightEditor, { HighlightData } from './HighlightEditor' import EditOrCloneEventDialog from '../NoteOptions/EditOrCloneEventDialog' diff --git a/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx index f91408fd..c797ec86 100644 --- a/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx @@ -9,6 +9,7 @@ import { SimpleUserAvatar } from '../../../UserAvatar' import { SimpleUsername } from '../../../Username' import type { PickerSearchMode } from '@/services/mention-event-search.service' import { NEVENT_NADDR_PICKER_ID } from './constants' +import { SUGGESTION_POPUP_Z_INDEX } from '../suggestion-popup' export type MentionListItem = string | { id: string; mode?: PickerSearchMode } @@ -20,6 +21,8 @@ export interface MentionListProps { onSelectIndex?: (index: number) => void /** When provided, used to detect if we're inside a dialog (for z-index). */ editor?: Editor + /** True while mention search is in flight (show placeholder instead of hiding the list). */ + loading?: boolean } export interface MentionListHandle { @@ -29,7 +32,6 @@ export interface MentionListHandle { const MentionList = forwardRef((props, ref) => { const { t } = useTranslation() const items = props.items ?? [] - const inDialog = Boolean(props.editor?.view?.dom?.closest?.('[role="dialog"]')) const [internalIndex, setInternalIndex] = useState(0) const isControlled = props.selectedIndex !== undefined const selectedIndex = isControlled ? props.selectedIndex! : internalIndex @@ -96,15 +98,32 @@ const MentionList = forwardRef((props, ref) })) if (!items.length) { - return null + if (!props.loading) { + return ( +
+

{t('No users found')}

+
+ ) + } + return ( +
+

{t('Searching…')}

+
+ ) } return (
e.stopPropagation()} onTouchMove={(e: React.TouchEvent) => e.stopPropagation()} > diff --git a/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx b/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx index 9bc9618c..53b776cf 100644 --- a/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx @@ -1,4 +1,3 @@ -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' @@ -21,8 +20,6 @@ import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Skeleton } from '@/components/ui/skeleton' import { Search } from 'lucide-react' -import type { Editor } from '@tiptap/core' -import { OPEN_NEVENT_PICKER_EVENT, extendMentionRangeToEndOfWord } from './suggestion' type NeventNaddrPickerDialogProps = { open: boolean @@ -186,69 +183,4 @@ function NeventNaddrPickerDialog({ ) } -type NeventPickerContextValue = { - openNeventPicker: (onSelected: (nostrLink: string) => void, initialMode?: PickerSearchMode) => void -} - -export const NeventPickerContext = React.createContext(null) - -export function NeventPickerProvider({ children }: { children: React.ReactNode }) { - const [open, setOpen] = useState(false) - const [onSelectedRef, setOnSelectedRef] = useState<((link: string) => void) | null>(null) - const [initialMode, setInitialMode] = useState('nevent') - - useEffect(() => { - const handler = (e: Event) => { - const { editor, range, initialMode: detailMode } = (e as CustomEvent<{ - editor: Editor - range: { from: number; to: number } - initialMode?: PickerSearchMode - }>).detail - const to = extendMentionRangeToEndOfWord(editor, range) - setOnSelectedRef(() => (link: string) => { - editor.chain().focus().insertContentAt({ from: range.from, to }, link + ' ').run() - }) - setInitialMode(detailMode ?? 'nevent') - setOpen(true) - } - window.addEventListener(OPEN_NEVENT_PICKER_EVENT, handler) - return () => window.removeEventListener(OPEN_NEVENT_PICKER_EVENT, handler) - }, []) - - const openNeventPicker = useCallback((onSelected: (nostrLink: string) => void, mode?: PickerSearchMode) => { - setOnSelectedRef(() => onSelected) - setInitialMode(mode ?? 'nevent') - setOpen(true) - }, []) - - const handleSelect = useCallback( - (link: string) => { - onSelectedRef?.(link) - setOnSelectedRef(null) - }, - [onSelectedRef] - ) - - const handleOpenChange = useCallback((next: boolean) => { - if (!next) { - setOnSelectedRef(null) - setInitialMode('nevent') - } - setOpen(next) - }, []) - - const value = React.useMemo(() => ({ openNeventPicker }), [openNeventPicker]) - - return ( - - {children} - - - ) -} - +export default NeventNaddrPickerDialog diff --git a/src/components/PostEditor/PostTextarea/Mention/NeventPickerProvider.tsx b/src/components/PostEditor/PostTextarea/Mention/NeventPickerProvider.tsx new file mode 100644 index 00000000..7e5f88b7 --- /dev/null +++ b/src/components/PostEditor/PostTextarea/Mention/NeventPickerProvider.tsx @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { Editor } from '@tiptap/core' +import type { PickerSearchMode } from '@/services/mention-event-search.service' +import NeventNaddrPickerDialog from './NeventNaddrPickerDialog' +import { NeventPickerContext } from './nevent-picker-context' +import { OPEN_NEVENT_PICKER_EVENT, extendMentionRangeToEndOfWord } from './suggestion' + +export function NeventPickerProvider({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useState(false) + const [onSelectedRef, setOnSelectedRef] = useState<((link: string) => void) | null>(null) + const [initialMode, setInitialMode] = useState('nevent') + + useEffect(() => { + const handler = (e: Event) => { + const { editor, range, initialMode: detailMode } = ( + e as CustomEvent<{ + editor: Editor + range: { from: number; to: number } + initialMode?: PickerSearchMode + }> + ).detail + const to = extendMentionRangeToEndOfWord(editor, range) + setOnSelectedRef(() => (link: string) => { + editor.chain().focus().insertContentAt({ from: range.from, to }, link + ' ').run() + }) + setInitialMode(detailMode ?? 'nevent') + setOpen(true) + } + window.addEventListener(OPEN_NEVENT_PICKER_EVENT, handler) + return () => window.removeEventListener(OPEN_NEVENT_PICKER_EVENT, handler) + }, []) + + const openNeventPicker = useCallback((onSelected: (nostrLink: string) => void, mode?: PickerSearchMode) => { + setOnSelectedRef(() => onSelected) + setInitialMode(mode ?? 'nevent') + setOpen(true) + }, []) + + const handleSelect = useCallback( + (link: string) => { + onSelectedRef?.(link) + setOnSelectedRef(null) + }, + [onSelectedRef] + ) + + const handleOpenChange = useCallback((next: boolean) => { + if (!next) { + setOnSelectedRef(null) + setInitialMode('nevent') + } + setOpen(next) + }, []) + + const value = useMemo(() => ({ openNeventPicker }), [openNeventPicker]) + + return ( + + {children} + + + ) +} diff --git a/src/components/PostEditor/PostTextarea/Mention/nevent-picker-context.ts b/src/components/PostEditor/PostTextarea/Mention/nevent-picker-context.ts new file mode 100644 index 00000000..2c90449b --- /dev/null +++ b/src/components/PostEditor/PostTextarea/Mention/nevent-picker-context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react' +import type { PickerSearchMode } from '@/services/mention-event-search.service' + +export type NeventPickerContextValue = { + openNeventPicker: (onSelected: (nostrLink: string) => void, initialMode?: PickerSearchMode) => void +} + +export const NeventPickerContext = createContext(null) diff --git a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts index 2a375b2f..e13af9cb 100644 --- a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts +++ b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts @@ -1,4 +1,3 @@ -import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants' import { MENTION_NPUB_DROPDOWN_LIMIT, searchNpubsForMention, @@ -8,9 +7,9 @@ import postEditor from '@/services/post-editor.service' import type { Editor } from '@tiptap/core' import { ReactRenderer } from '@tiptap/react' import { SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion' -import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js' import MentionList, { MentionListHandle, MentionListProps, type MentionListItem } from './MentionList' import { NEVENT_NADDR_PICKER_ID } from './constants' +import { createSuggestionPopup } from '../suggestion-popup' export type { PickerSearchMode } @@ -19,12 +18,8 @@ const MENTION_CHAR = '@' export const OPEN_NEVENT_PICKER_EVENT = 'open-nevent-picker' -// Shared state for incremental updates let currentComponent: ReactRenderer | undefined let currentQuery = '' -let pendingMentionItems: MentionListItem[] | null = null -let backgroundSearchController: AbortController | null = null -let mentionSearchDebounceTimer: ReturnType | 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. */ @@ -45,6 +40,17 @@ export function extendMentionRangeToEndOfWord(editor: Editor, range: { from: num return pos } +function mountPopup( + popup: ReturnType, + props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }, + component: ReactRenderer +) { + popup.ensure({ + clientRect: props.clientRect, + content: component.element + }) +} + const suggestion = { command: ({ editor, @@ -86,118 +92,69 @@ const suggestion = { const mode: PickerSearchMode = q === 'naddr' || q.startsWith('naddr') ? 'naddr' : 'nevent' return [{ id: NEVENT_NADDR_PICKER_ID, mode }] } - - if (mentionSearchDebounceTimer) clearTimeout(mentionSearchDebounceTimer) + const generation = ++mentionSearchGeneration + currentQuery = q - return new Promise((resolve) => { - mentionSearchDebounceTimer = setTimeout(async () => { - if (generation !== mentionSearchGeneration) return + const updateComponent = (npubs: string[]) => { + if (generation !== mentionSearchGeneration || currentQuery !== q) return + if (currentComponent) { + currentComponent.updateProps({ items: npubs, loading: false }) + } + } - if (currentQuery !== q && backgroundSearchController) { - backgroundSearchController.abort() - backgroundSearchController = null - } - currentQuery = q - - const updateComponent = (npubs: string[]) => { - if (generation !== mentionSearchGeneration || currentQuery !== q) return - pendingMentionItems = npubs - if (currentComponent) { - currentComponent.updateProps({ items: npubs }) - pendingMentionItems = null - } - } + if (currentComponent) { + currentComponent.updateProps({ items: [], loading: true }) + } - backgroundSearchController = new AbortController() - try { - const results = await searchNpubsForMention(query, MENTION_NPUB_DROPDOWN_LIMIT, updateComponent) - if (generation === mentionSearchGeneration) resolve(results ?? []) - } catch { - if (generation === mentionSearchGeneration) resolve([]) - } - }, SEARCH_QUERY_DEBOUNCE_MS) - }) + try { + const results = await searchNpubsForMention(query, MENTION_NPUB_DROPDOWN_LIMIT, updateComponent) + if (generation === mentionSearchGeneration) { + currentComponent?.updateProps({ items: results ?? [], loading: false }) + return results ?? [] + } + return [] + } catch { + if (generation === mentionSearchGeneration) { + currentComponent?.updateProps({ items: [], loading: false }) + } + return [] + } }, render: () => { let component: ReactRenderer | undefined - let popup: Instance[] = [] - let touchListener: (e: TouchEvent) => void + let popup: ReturnType | undefined let closePopup: () => void let exited = false return { onBeforeStart: () => { - touchListener = (e: TouchEvent) => { - if (popup && popup[0] && postEditor.isSuggestionPopupOpen) { - const popupElement = popup[0].popper - if (popupElement && !popupElement.contains(e.target as Node)) { - popup[0].hide() - } - } - } - document.addEventListener('touchstart', touchListener) - closePopup = () => { - if (popup && popup[0]) { - popup[0].hide() - } + popup?.hide() } postEditor.addEventListener('closeSuggestionPopup', closePopup) }, - onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { + onStart: (props: SuggestionProps) => { + popup = createSuggestionPopup(props.editor) component = new ReactRenderer(MentionList, { - props, + props: { ...props, loading: true }, editor: props.editor }) - - // Store component reference for incremental updates currentComponent = component - - if (pendingMentionItems) { - component.updateProps({ items: pendingMentionItems }) - pendingMentionItems = null - } - - if (!props.clientRect) { - return - } - - popup = tippy('body', { - getReferenceClientRect: props.clientRect as GetReferenceClientRect, - appendTo: () => document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: 'manual', - placement: 'bottom-start', - hideOnClick: true, - touch: true, - onShow() { - postEditor.isSuggestionPopupOpen = true - }, - onHide() { - postEditor.isSuggestionPopupOpen = false - } - }) + mountPopup(popup, props, component) }, onUpdate(props: SuggestionProps) { component?.updateProps(props) - - if (!props.clientRect) { - return + if (popup && component) { + mountPopup(popup, props, component) } - - popup[0]?.setProps({ - getReferenceClientRect: props.clientRect - } as Partial) }, onKeyDown(props: SuggestionKeyDownProps) { if (props.event.key === 'Escape') { - popup[0]?.hide() + popup?.hide() return true } return component?.ref?.onKeyDown(props) ?? false @@ -207,26 +164,12 @@ const suggestion = { if (exited) return exited = true postEditor.isSuggestionPopupOpen = false - - // Abort background search - if (backgroundSearchController) { - backgroundSearchController.abort() - backgroundSearchController = null - } currentComponent = undefined currentQuery = '' - pendingMentionItems = null - - if (popup[0]) { - popup[0].destroy() - popup = [] - } - if (component) { - component.destroy() - component = undefined - } - - document.removeEventListener('touchstart', touchListener) + popup?.destroy() + popup = undefined + component?.destroy() + component = undefined postEditor.removeEventListener('closeSuggestionPopup', closePopup) } } diff --git a/src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts b/src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts index fcbfa4aa..7851b0f4 100644 --- a/src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts +++ b/src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts @@ -1,5 +1,5 @@ import * as React from 'react' -import { NeventPickerContext } from './NeventNaddrPickerDialog' +import { NeventPickerContext } from './nevent-picker-context' export function useNeventPicker() { return React.useContext(NeventPickerContext) diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index 64086152..dd81626f 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -3,6 +3,7 @@ import { parseEditorJsonToText, plainTextToTipTapDoc } from '@/lib/tiptap' import { cn } from '@/lib/utils' import customEmojiService from '@/services/custom-emoji.service' import postEditorCache from '@/services/post-editor-cache.service' +import postEditorService from '@/services/post-editor.service' import { TEmoji } from '@/types' import Document from '@tiptap/extension-document' import { HardBreak } from '@tiptap/extension-hard-break' @@ -216,6 +217,11 @@ const PostTextarea = forwardRef< editorRef.current = editor + useEffect(() => { + postEditorService.setReplyParentEvent(parentEvent) + return () => postEditorService.setReplyParentEvent(undefined) + }, [parentEvent]) + useEffect(() => { if (!editor) return editor.setOptions({ diff --git a/src/components/PostEditor/PostTextarea/suggestion-popup.ts b/src/components/PostEditor/PostTextarea/suggestion-popup.ts new file mode 100644 index 00000000..55b002e2 --- /dev/null +++ b/src/components/PostEditor/PostTextarea/suggestion-popup.ts @@ -0,0 +1,90 @@ +import postEditor from '@/services/post-editor.service' +import type { Editor } from '@tiptap/core' +import tippy, { type GetReferenceClientRect, type Instance, type Props } from 'tippy.js' + +/** Above Radix Sheet/Dialog (`z-50`) so @-mention / emoji lists stay visible on mobile. */ +export const SUGGESTION_POPUP_Z_INDEX = 350 + +export type SuggestionPopupController = { + ensure: (props: { + clientRect?: (() => DOMRect | null) | null + content: Element + }) => void + hide: () => void + destroy: () => void +} + +export function createSuggestionPopup(editor: Editor): SuggestionPopupController { + let popup: Instance | undefined + let touchListener: ((e: TouchEvent) => void) | undefined + + const destroy = () => { + if (touchListener) { + document.removeEventListener('touchstart', touchListener) + touchListener = undefined + } + if (popup) { + popup.destroy() + popup = undefined + } + postEditor.isSuggestionPopupOpen = false + } + + const ensure = (props: { + clientRect?: (() => DOMRect | null) | null + content: Element + }) => { + if (!props.clientRect) return + + if (!touchListener) { + touchListener = (e: TouchEvent) => { + if (!popup || !postEditor.isSuggestionPopupOpen) return + const target = e.target as Node + if (popup.popper?.contains(target)) return + const editorEl = editor.view?.dom + if (editorEl?.contains(target)) return + popup.hide() + } + document.addEventListener('touchstart', touchListener, { passive: true }) + } + + const rectProps = { + getReferenceClientRect: props.clientRect as GetReferenceClientRect + } + + if (popup) { + popup.setProps({ + ...rectProps, + content: props.content + } as Partial) + if (!popup.state.isVisible) popup.show() + return + } + + popup = tippy(document.body, { + ...rectProps, + appendTo: () => document.body, + content: props.content, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + hideOnClick: false, + maxWidth: 'none', + zIndex: SUGGESTION_POPUP_Z_INDEX, + touch: true, + onShow() { + postEditor.isSuggestionPopupOpen = true + }, + onHide() { + postEditor.isSuggestionPopupOpen = false + } + }) + } + + return { + ensure, + hide: () => popup?.hide(), + destroy + } +} diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index ec069468..763edccd 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -832,6 +832,29 @@ export class EventService { return out } + /** + * Pubkeys from notes / replies already in the session LRU (authors + `p`/`P` tags). + * Used by @-mention search so thread participants match without a relay round-trip. + */ + collectSessionMentionCandidatePubkeys(maxPubkeys = 400): string[] { + const pks = new Set() + for (const ev of this.sessionEventCache.values()) { + if (shouldDropEventOnIngest(ev)) continue + if (pks.size >= maxPubkeys) break + const author = ev.pubkey.trim().toLowerCase() + if (/^[0-9a-f]{64}$/.test(author)) pks.add(author) + for (const t of ev.tags ?? []) { + if (pks.size >= maxPubkeys) break + if (!Array.isArray(t) || t.length < 2) continue + const tag = String(t[0]) + if (tag !== 'p' && tag !== 'P') continue + const pk = String(t[1] ?? '').trim().toLowerCase() + if (/^[0-9a-f]{64}$/.test(pk)) pks.add(pk) + } + } + return [...pks] + } + /** * Get events from session cache matching search (newest {@link Event.created_at} first). * Scans up to {@link SESSION_SEARCH_MAX_SCAN} entries; only rows where {@link eventMatchesGeneralSearchQuery} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 947f5fee..78b3163a 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -165,7 +165,7 @@ import { import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { buildDeletionRelayUrls, dispatchTombstonesUpdated } from '@/lib/tombstone-events' import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' -import { collectNip05ValuesFromKind0 } from '@/lib/profile-metadata-search' +import { collectNip05ValuesFromKind0, profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { filterRelaysForEventPublish, isReadOnlyRelayUrl } from '@/lib/relay-publish-filter' @@ -241,6 +241,7 @@ import { } from 'nostr-tools' import { AbstractRelay } from 'nostr-tools/abstract-relay' import indexedDb from './indexed-db.service' +import postEditorService from './post-editor.service' import { preloadGifsIntoIdbCache } from './gif.service' import { invalidateArchiveFootprintCache } from './event-archive.service' import { notifySessionInteractivePrewarmComplete } from './session-interactive-prewarm-bridge' @@ -4269,6 +4270,69 @@ class ClientService extends EventTarget { return out.slice(0, limit) } + /** Match @-mention query against authors visible in the current session (thread / feed). */ + private async searchNpubsFromSessionAuthors(query: string, limit: number): Promise { + const q = query.trim() + if (!q || limit <= 0) return [] + + const candidatePubkeys = this.eventService.collectSessionMentionCandidatePubkeys() + const out: string[] = [] + const seen = new Set() + + for (const pk of candidatePubkeys) { + if (out.length >= limit) break + let meta = this.eventService.getSessionMetadataForPubkey(pk) + if (!meta) { + try { + meta = (await indexedDb.getReplaceableEvent(pk, kinds.Metadata)) ?? undefined + } catch { + meta = undefined + } + } + if (!meta || !profileKind0MatchesSearchQuery(meta, q)) continue + const npub = pubkeyToNpub(pk) + if (!npub || seen.has(npub)) continue + seen.add(npub) + out.push(npub) + } + return out + } + + /** Reply-thread participants (parent author + `p` tags) for @-mention autocomplete. */ + private async searchNpubsFromReplyParent(query: string, limit: number): Promise { + const parent = postEditorService.replyParentEvent + if (!parent || limit <= 0) return [] + + const q = query.trim() + const pks = new Set() + const author = parent.pubkey.trim().toLowerCase() + if (/^[0-9a-f]{64}$/.test(author)) pks.add(author) + for (const t of parent.tags ?? []) { + if (!Array.isArray(t) || t.length < 2) continue + if (t[0] !== 'p' && t[0] !== 'P') continue + const pk = String(t[1] ?? '').trim().toLowerCase() + if (/^[0-9a-f]{64}$/.test(pk)) pks.add(pk) + } + + const out: string[] = [] + for (const pk of pks) { + if (out.length >= limit) break + let meta = this.eventService.getSessionMetadataForPubkey(pk) + if (!meta) { + try { + meta = (await indexedDb.getReplaceableEvent(pk, kinds.Metadata)) ?? undefined + } catch { + meta = undefined + } + } + if (!meta) continue + if (q && !profileKind0MatchesSearchQuery(meta, q)) continue + const npub = pubkeyToNpub(pk) + if (npub) out.push(npub) + } + return out + } + async searchNpubsFromLocal(query: string, limit: number = 100) { await this.ensureProfileSearchIndexFromIdb() const seen = new Set() @@ -4425,30 +4489,39 @@ class ClientService extends EventTarget { ).catch(() => [] as TProfile[]) : Promise.resolve([] as TProfile[]) - const matchProfileText = (p: TProfile) => - ((p.username ?? '') + ' ' + (p.original_username ?? '') + ' ' + (p.nip05 ?? '')).toLowerCase() - const directPk = decodeProfileSearchQueryToPubkeyHex(q) if (directPk) { const np = pubkeyToNpub(directPk) if (np) addNpub(np) } - // 1. Local index first (FlexSearch + session) — fills the @-mention list immediately. + // 1. Local sources first — IndexedDB substring, session thread authors, FlexSearch. // 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). const localCap = Math.min(limit, 24) - let local: string[] = [] - try { - local = await this.searchNpubsFromLocal(q, localCap) - } catch { - // FlexSearch / session search should not throw; if it does, still return relay + follow hits. - local = [] + const [replyParentNpubs, localNpubs, idbProfiles, sessionAuthorNpubs] = await Promise.all([ + this.searchNpubsFromReplyParent(q, localCap).catch(() => [] as string[]), + this.searchNpubsFromLocal(q, localCap).catch(() => [] as string[]), + this.searchProfilesFromIndexedDBCache(q, localCap).catch(() => [] as TProfile[]), + this.searchNpubsFromSessionAuthors(q, localCap).catch(() => [] as string[]) + ]) + + for (const npub of replyParentNpubs) { + if (addNpub(npub)) updateIfNeeded() + if (out.length >= limit) break } - for (const npub of local) { - if (addNpub(npub)) { - updateIfNeeded() - } + + for (const p of idbProfiles) { + const np = pubkeyToNpub(p.pubkey) + if (np && addNpub(np)) updateIfNeeded() + if (out.length >= limit) break + } + for (const npub of sessionAuthorNpubs) { + if (addNpub(npub)) updateIfNeeded() + if (out.length >= limit) break + } + for (const npub of localNpubs) { + if (addNpub(npub)) updateIfNeeded() if (out.length >= limit) break } @@ -4459,9 +4532,8 @@ class ClientService extends EventTarget { return out } - // 2. Follow list — must never block TipTap `items()`: no await here. - // Previously we awaited merge when the follow list was in IDB; that ran up to 80 parallel - // getReplaceableEvent(metadata) calls and could stall Firefox for seconds with no dropdown. + // 2. Follow list — batch IDB read; wait briefly so follows appear before relay fallback. + let followMergeWork: Promise = Promise.resolve() if (this.pubkey && qLower.length >= 1) { const pk = this.pubkey.trim().toLowerCase() const viewerPubkey = this.pubkey @@ -4472,7 +4544,7 @@ class ClientService extends EventTarget { const followPubkeys = getPubkeysFromPTags(followListEvent.tags) .map((hex) => hex.trim().toLowerCase()) .filter((hex) => /^[0-9a-f]{64}$/.test(hex)) - .slice(0, 80) + .slice(0, 200) if (followPubkeys.length === 0) return const events = await indexedDb.getManyReplaceableEvents(followPubkeys, kinds.Metadata) @@ -4480,10 +4552,9 @@ class ClientService extends EventTarget { if (out.length >= limit) break const ev = events[i] if (!ev) continue - const p = getProfileFromEvent(ev) const npub = pubkeyToNpub(followPubkeys[i]!) if (!npub) continue - if (!matchProfileText(p).includes(qLower)) continue + if (!profileKind0MatchesSearchQuery(ev, q)) continue if (addNpub(npub)) { updateIfNeeded() } @@ -4493,7 +4564,7 @@ class ClientService extends EventTarget { } } - void (async () => { + followMergeWork = (async () => { try { const cachedFollow = await indexedDb.getReplaceableEvent(pk, kinds.Contacts) if (cachedFollow) { @@ -4511,6 +4582,13 @@ class ClientService extends EventTarget { } } })() + + if (out.length < limit) { + await Promise.race([ + followMergeWork, + new Promise((resolve) => setTimeout(resolve, 1_500)) + ]) + } } if (out.length >= limit) { diff --git a/src/services/post-editor.service.ts b/src/services/post-editor.service.ts index 765ef75c..e01eab2e 100644 --- a/src/services/post-editor.service.ts +++ b/src/services/post-editor.service.ts @@ -1,3 +1,5 @@ +import type { Event } from 'nostr-tools' + class PostEditorService extends EventTarget { static instance: PostEditorService @@ -36,6 +38,13 @@ class PostEditorService extends EventTarget { requestOpenNewPost() { this.dispatchEvent(new CustomEvent('requestOpenNewPost')) } + + /** Parent note when replying — used by @-mention search to surface thread participants. */ + replyParentEvent?: Event + + setReplyParentEvent(event?: Event) { + this.replyParentEvent = event + } } const instance = new PostEditorService()