Browse Source

bug-fix mention searches

imwald
Silberengel 1 week ago
parent
commit
e5101fbaaf
  1. 2
      src/components/PostEditor/PostContent.tsx
  2. 27
      src/components/PostEditor/PostTextarea/Mention/MentionList.tsx
  3. 70
      src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx
  4. 68
      src/components/PostEditor/PostTextarea/Mention/NeventPickerProvider.tsx
  5. 8
      src/components/PostEditor/PostTextarea/Mention/nevent-picker-context.ts
  6. 133
      src/components/PostEditor/PostTextarea/Mention/suggestion.ts
  7. 2
      src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts
  8. 6
      src/components/PostEditor/PostTextarea/index.tsx
  9. 90
      src/components/PostEditor/PostTextarea/suggestion-popup.ts
  10. 23
      src/services/client-events.service.ts
  11. 120
      src/services/client.service.ts
  12. 9
      src/services/post-editor.service.ts

2
src/components/PostEditor/PostContent.tsx

@ -117,7 +117,7 @@ import {
parseNostrSpecAffectedKinds, parseNostrSpecAffectedKinds,
type NostrSpecAffectedKindRow type NostrSpecAffectedKindRow
} from '@/lib/nostr-spec-affected-kinds' } from '@/lib/nostr-spec-affected-kinds'
import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDialog' import { NeventPickerProvider } from './PostTextarea/Mention/NeventPickerProvider'
import Uploader from './Uploader' import Uploader from './Uploader'
import HighlightEditor, { HighlightData } from './HighlightEditor' import HighlightEditor, { HighlightData } from './HighlightEditor'
import EditOrCloneEventDialog from '../NoteOptions/EditOrCloneEventDialog' import EditOrCloneEventDialog from '../NoteOptions/EditOrCloneEventDialog'

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

@ -9,6 +9,7 @@ import { SimpleUserAvatar } from '../../../UserAvatar'
import { SimpleUsername } from '../../../Username' import { SimpleUsername } from '../../../Username'
import type { PickerSearchMode } from '@/services/mention-event-search.service' import type { PickerSearchMode } from '@/services/mention-event-search.service'
import { NEVENT_NADDR_PICKER_ID } from './constants' import { NEVENT_NADDR_PICKER_ID } from './constants'
import { SUGGESTION_POPUP_Z_INDEX } from '../suggestion-popup'
export type MentionListItem = string | { id: string; mode?: PickerSearchMode } export type MentionListItem = string | { id: string; mode?: PickerSearchMode }
@ -20,6 +21,8 @@ export interface MentionListProps {
onSelectIndex?: (index: number) => void onSelectIndex?: (index: number) => void
/** When provided, used to detect if we're inside a dialog (for z-index). */ /** When provided, used to detect if we're inside a dialog (for z-index). */
editor?: Editor editor?: Editor
/** True while mention search is in flight (show placeholder instead of hiding the list). */
loading?: boolean
} }
export interface MentionListHandle { export interface MentionListHandle {
@ -29,7 +32,6 @@ export interface MentionListHandle {
const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) => { const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const items = props.items ?? [] const items = props.items ?? []
const inDialog = Boolean(props.editor?.view?.dom?.closest?.('[role="dialog"]'))
const [internalIndex, setInternalIndex] = useState<number>(0) const [internalIndex, setInternalIndex] = useState<number>(0)
const isControlled = props.selectedIndex !== undefined const isControlled = props.selectedIndex !== undefined
const selectedIndex = isControlled ? props.selectedIndex! : internalIndex const selectedIndex = isControlled ? props.selectedIndex! : internalIndex
@ -96,15 +98,32 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
})) }))
if (!items.length) { if (!items.length) {
return null if (!props.loading) {
return (
<div
className="border rounded-lg bg-background pointer-events-auto p-3 max-w-[min(calc(100vw-1.5rem),28rem)]"
style={{ zIndex: SUGGESTION_POPUP_Z_INDEX }}
>
<p className="text-sm text-muted-foreground">{t('No users found')}</p>
</div>
)
}
return (
<div
className="border rounded-lg bg-background pointer-events-auto p-3 max-w-[min(calc(100vw-1.5rem),28rem)]"
style={{ zIndex: SUGGESTION_POPUP_Z_INDEX }}
>
<p className="text-sm text-muted-foreground">{t('Searching…')}</p>
</div>
)
} }
return ( return (
<div <div
className={cn( className={cn(
'border rounded-lg bg-background pointer-events-auto flex flex-col min-h-0 max-h-[min(85dvh,calc(100dvh-6rem))] max-w-[min(calc(100vw-1.5rem),28rem)] overflow-x-hidden overflow-y-auto overscroll-contain popover-scroll-y', 'border rounded-lg bg-background pointer-events-auto flex flex-col min-h-0 max-h-[min(85dvh,calc(100dvh-6rem))] max-w-[min(calc(100vw-1.5rem),28rem)] overflow-x-hidden overflow-y-auto overscroll-contain popover-scroll-y'
inDialog ? 'z-[290]' : 'z-[110]'
)} )}
style={{ zIndex: SUGGESTION_POPUP_Z_INDEX }}
onWheel={(e: React.WheelEvent) => e.stopPropagation()} onWheel={(e: React.WheelEvent) => e.stopPropagation()}
onTouchMove={(e: React.TouchEvent) => e.stopPropagation()} onTouchMove={(e: React.TouchEvent) => e.stopPropagation()}
> >

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

@ -1,4 +1,3 @@
import * as React from 'react'
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants' 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 { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview'
@ -21,8 +20,6 @@ import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import type { Editor } from '@tiptap/core'
import { OPEN_NEVENT_PICKER_EVENT, extendMentionRangeToEndOfWord } from './suggestion'
type NeventNaddrPickerDialogProps = { type NeventNaddrPickerDialogProps = {
open: boolean open: boolean
@ -186,69 +183,4 @@ function NeventNaddrPickerDialog({
) )
} }
type NeventPickerContextValue = { export default NeventNaddrPickerDialog
openNeventPicker: (onSelected: (nostrLink: string) => void, initialMode?: PickerSearchMode) => void
}
export const NeventPickerContext = React.createContext<NeventPickerContextValue | null>(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<PickerSearchMode>('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 (
<NeventPickerContext.Provider value={value}>
{children}
<NeventNaddrPickerDialog
open={open}
onOpenChange={handleOpenChange}
onSelect={handleSelect}
initialMode={initialMode}
/>
</NeventPickerContext.Provider>
)
}

68
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<PickerSearchMode>('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 (
<NeventPickerContext.Provider value={value}>
{children}
<NeventNaddrPickerDialog
open={open}
onOpenChange={handleOpenChange}
onSelect={handleSelect}
initialMode={initialMode}
/>
</NeventPickerContext.Provider>
)
}

8
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<NeventPickerContextValue | null>(null)

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

@ -1,4 +1,3 @@
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants'
import { import {
MENTION_NPUB_DROPDOWN_LIMIT, MENTION_NPUB_DROPDOWN_LIMIT,
searchNpubsForMention, searchNpubsForMention,
@ -8,9 +7,9 @@ import postEditor from '@/services/post-editor.service'
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { ReactRenderer } from '@tiptap/react' import { ReactRenderer } from '@tiptap/react'
import { SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion' import { SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion'
import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js'
import MentionList, { MentionListHandle, MentionListProps, type MentionListItem } from './MentionList' import MentionList, { MentionListHandle, MentionListProps, type MentionListItem } from './MentionList'
import { NEVENT_NADDR_PICKER_ID } from './constants' import { NEVENT_NADDR_PICKER_ID } from './constants'
import { createSuggestionPopup } from '../suggestion-popup'
export type { PickerSearchMode } export type { PickerSearchMode }
@ -19,12 +18,8 @@ const MENTION_CHAR = '@'
export const OPEN_NEVENT_PICKER_EVENT = 'open-nevent-picker' export const OPEN_NEVENT_PICKER_EVENT = 'open-nevent-picker'
// Shared state for incremental updates
let currentComponent: ReactRenderer<MentionListHandle, MentionListProps> | undefined let currentComponent: ReactRenderer<MentionListHandle, MentionListProps> | undefined
let currentQuery = '' let currentQuery = ''
let pendingMentionItems: MentionListItem[] | null = null
let backgroundSearchController: AbortController | null = null
let mentionSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let mentionSearchGeneration = 0 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. */
@ -45,6 +40,17 @@ export function extendMentionRangeToEndOfWord(editor: Editor, range: { from: num
return pos return pos
} }
function mountPopup(
popup: ReturnType<typeof createSuggestionPopup>,
props: { editor: Editor; clientRect?: (() => DOMRect | null) | null },
component: ReactRenderer<MentionListHandle, MentionListProps>
) {
popup.ensure({
clientRect: props.clientRect,
content: component.element
})
}
const suggestion = { const suggestion = {
command: ({ command: ({
editor, editor,
@ -87,117 +93,68 @@ const suggestion = {
return [{ id: NEVENT_NADDR_PICKER_ID, mode }] return [{ id: NEVENT_NADDR_PICKER_ID, mode }]
} }
if (mentionSearchDebounceTimer) clearTimeout(mentionSearchDebounceTimer)
const generation = ++mentionSearchGeneration 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 currentQuery = q
const updateComponent = (npubs: string[]) => { const updateComponent = (npubs: string[]) => {
if (generation !== mentionSearchGeneration || currentQuery !== q) return if (generation !== mentionSearchGeneration || currentQuery !== q) return
pendingMentionItems = npubs
if (currentComponent) { if (currentComponent) {
currentComponent.updateProps({ items: npubs }) currentComponent.updateProps({ items: npubs, loading: false })
pendingMentionItems = null
} }
} }
backgroundSearchController = new AbortController() if (currentComponent) {
currentComponent.updateProps({ items: [], loading: true })
}
try { try {
const results = await searchNpubsForMention(query, MENTION_NPUB_DROPDOWN_LIMIT, updateComponent) const results = await searchNpubsForMention(query, MENTION_NPUB_DROPDOWN_LIMIT, updateComponent)
if (generation === mentionSearchGeneration) resolve(results ?? []) if (generation === mentionSearchGeneration) {
currentComponent?.updateProps({ items: results ?? [], loading: false })
return results ?? []
}
return []
} catch { } catch {
if (generation === mentionSearchGeneration) resolve([]) if (generation === mentionSearchGeneration) {
currentComponent?.updateProps({ items: [], loading: false })
}
return []
} }
}, SEARCH_QUERY_DEBOUNCE_MS)
})
}, },
render: () => { render: () => {
let component: ReactRenderer<MentionListHandle, MentionListProps> | undefined let component: ReactRenderer<MentionListHandle, MentionListProps> | undefined
let popup: Instance[] = [] let popup: ReturnType<typeof createSuggestionPopup> | undefined
let touchListener: (e: TouchEvent) => void
let closePopup: () => void let closePopup: () => void
let exited = false let exited = false
return { return {
onBeforeStart: () => { 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 = () => { closePopup = () => {
if (popup && popup[0]) { popup?.hide()
popup[0].hide()
}
} }
postEditor.addEventListener('closeSuggestionPopup', closePopup) postEditor.addEventListener('closeSuggestionPopup', closePopup)
}, },
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { onStart: (props: SuggestionProps<MentionListItem>) => {
popup = createSuggestionPopup(props.editor)
component = new ReactRenderer(MentionList, { component = new ReactRenderer(MentionList, {
props, props: { ...props, loading: true },
editor: props.editor editor: props.editor
}) })
// Store component reference for incremental updates
currentComponent = component currentComponent = component
mountPopup(popup, props, 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
}
})
}, },
onUpdate(props: SuggestionProps<MentionListItem>) { onUpdate(props: SuggestionProps<MentionListItem>) {
component?.updateProps(props) component?.updateProps(props)
if (popup && component) {
if (!props.clientRect) { mountPopup(popup, props, component)
return
} }
popup[0]?.setProps({
getReferenceClientRect: props.clientRect
} as Partial<Props>)
}, },
onKeyDown(props: SuggestionKeyDownProps) { onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') { if (props.event.key === 'Escape') {
popup[0]?.hide() popup?.hide()
return true return true
} }
return component?.ref?.onKeyDown(props) ?? false return component?.ref?.onKeyDown(props) ?? false
@ -207,26 +164,12 @@ const suggestion = {
if (exited) return if (exited) return
exited = true exited = true
postEditor.isSuggestionPopupOpen = false postEditor.isSuggestionPopupOpen = false
// Abort background search
if (backgroundSearchController) {
backgroundSearchController.abort()
backgroundSearchController = null
}
currentComponent = undefined currentComponent = undefined
currentQuery = '' currentQuery = ''
pendingMentionItems = null popup?.destroy()
popup = undefined
if (popup[0]) { component?.destroy()
popup[0].destroy()
popup = []
}
if (component) {
component.destroy()
component = undefined component = undefined
}
document.removeEventListener('touchstart', touchListener)
postEditor.removeEventListener('closeSuggestionPopup', closePopup) postEditor.removeEventListener('closeSuggestionPopup', closePopup)
} }
} }

2
src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts

@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import { NeventPickerContext } from './NeventNaddrPickerDialog' import { NeventPickerContext } from './nevent-picker-context'
export function useNeventPicker() { export function useNeventPicker() {
return React.useContext(NeventPickerContext) return React.useContext(NeventPickerContext)

6
src/components/PostEditor/PostTextarea/index.tsx

@ -3,6 +3,7 @@ import { parseEditorJsonToText, plainTextToTipTapDoc } from '@/lib/tiptap'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
import postEditorCache from '@/services/post-editor-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
import postEditorService from '@/services/post-editor.service'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import Document from '@tiptap/extension-document' import Document from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break' import { HardBreak } from '@tiptap/extension-hard-break'
@ -216,6 +217,11 @@ const PostTextarea = forwardRef<
editorRef.current = editor editorRef.current = editor
useEffect(() => {
postEditorService.setReplyParentEvent(parentEvent)
return () => postEditorService.setReplyParentEvent(undefined)
}, [parentEvent])
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) return
editor.setOptions({ editor.setOptions({

90
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<Props>)
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
}
}

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

@ -832,6 +832,29 @@ export class EventService {
return out 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<string>()
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). * 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} * Scans up to {@link SESSION_SEARCH_MAX_SCAN} entries; only rows where {@link eventMatchesGeneralSearchQuery}

120
src/services/client.service.ts

@ -165,7 +165,7 @@ import {
import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning'
import { buildDeletionRelayUrls, dispatchTombstonesUpdated } from '@/lib/tombstone-events' import { buildDeletionRelayUrls, dispatchTombstonesUpdated } from '@/lib/tombstone-events'
import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
import { collectNip05ValuesFromKind0 } from '@/lib/profile-metadata-search' import { collectNip05ValuesFromKind0, profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag'
import { filterRelaysForEventPublish, isReadOnlyRelayUrl } from '@/lib/relay-publish-filter' import { filterRelaysForEventPublish, isReadOnlyRelayUrl } from '@/lib/relay-publish-filter'
@ -241,6 +241,7 @@ import {
} from 'nostr-tools' } from 'nostr-tools'
import { AbstractRelay } from 'nostr-tools/abstract-relay' import { AbstractRelay } from 'nostr-tools/abstract-relay'
import indexedDb from './indexed-db.service' import indexedDb from './indexed-db.service'
import postEditorService from './post-editor.service'
import { preloadGifsIntoIdbCache } from './gif.service' import { preloadGifsIntoIdbCache } from './gif.service'
import { invalidateArchiveFootprintCache } from './event-archive.service' import { invalidateArchiveFootprintCache } from './event-archive.service'
import { notifySessionInteractivePrewarmComplete } from './session-interactive-prewarm-bridge' import { notifySessionInteractivePrewarmComplete } from './session-interactive-prewarm-bridge'
@ -4269,6 +4270,69 @@ class ClientService extends EventTarget {
return out.slice(0, limit) 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<string[]> {
const q = query.trim()
if (!q || limit <= 0) return []
const candidatePubkeys = this.eventService.collectSessionMentionCandidatePubkeys()
const out: string[] = []
const seen = new Set<string>()
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<string[]> {
const parent = postEditorService.replyParentEvent
if (!parent || limit <= 0) return []
const q = query.trim()
const pks = new Set<string>()
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) { async searchNpubsFromLocal(query: string, limit: number = 100) {
await this.ensureProfileSearchIndexFromIdb() await this.ensureProfileSearchIndexFromIdb()
const seen = new Set<string>() const seen = new Set<string>()
@ -4425,30 +4489,39 @@ class ClientService extends EventTarget {
).catch(() => [] as TProfile[]) ).catch(() => [] as TProfile[])
: Promise.resolve([] as TProfile[]) : Promise.resolve([] as TProfile[])
const matchProfileText = (p: TProfile) =>
((p.username ?? '') + ' ' + (p.original_username ?? '') + ' ' + (p.nip05 ?? '')).toLowerCase()
const directPk = decodeProfileSearchQueryToPubkeyHex(q) const directPk = decodeProfileSearchQueryToPubkeyHex(q)
if (directPk) { if (directPk) {
const np = pubkeyToNpub(directPk) const np = pubkeyToNpub(directPk)
if (np) addNpub(np) 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 // 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).
const localCap = Math.min(limit, 24) const localCap = Math.min(limit, 24)
let local: string[] = [] const [replyParentNpubs, localNpubs, idbProfiles, sessionAuthorNpubs] = await Promise.all([
try { this.searchNpubsFromReplyParent(q, localCap).catch(() => [] as string[]),
local = await this.searchNpubsFromLocal(q, localCap) this.searchNpubsFromLocal(q, localCap).catch(() => [] as string[]),
} catch { this.searchProfilesFromIndexedDBCache(q, localCap).catch(() => [] as TProfile[]),
// FlexSearch / session search should not throw; if it does, still return relay + follow hits. this.searchNpubsFromSessionAuthors(q, localCap).catch(() => [] as string[])
local = [] ])
for (const npub of replyParentNpubs) {
if (addNpub(npub)) updateIfNeeded()
if (out.length >= limit) break
} }
for (const npub of local) {
if (addNpub(npub)) { for (const p of idbProfiles) {
updateIfNeeded() 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 if (out.length >= limit) break
} }
@ -4459,9 +4532,8 @@ class ClientService extends EventTarget {
return out return out
} }
// 2. Follow list — must never block TipTap `items()`: no await here. // 2. Follow list — batch IDB read; wait briefly so follows appear before relay fallback.
// Previously we awaited merge when the follow list was in IDB; that ran up to 80 parallel let followMergeWork: Promise<void> = Promise.resolve()
// getReplaceableEvent(metadata) calls and could stall Firefox for seconds with no dropdown.
if (this.pubkey && qLower.length >= 1) { if (this.pubkey && qLower.length >= 1) {
const pk = this.pubkey.trim().toLowerCase() const pk = this.pubkey.trim().toLowerCase()
const viewerPubkey = this.pubkey const viewerPubkey = this.pubkey
@ -4472,7 +4544,7 @@ class ClientService extends EventTarget {
const followPubkeys = getPubkeysFromPTags(followListEvent.tags) const followPubkeys = getPubkeysFromPTags(followListEvent.tags)
.map((hex) => hex.trim().toLowerCase()) .map((hex) => hex.trim().toLowerCase())
.filter((hex) => /^[0-9a-f]{64}$/.test(hex)) .filter((hex) => /^[0-9a-f]{64}$/.test(hex))
.slice(0, 80) .slice(0, 200)
if (followPubkeys.length === 0) return if (followPubkeys.length === 0) return
const events = await indexedDb.getManyReplaceableEvents(followPubkeys, kinds.Metadata) const events = await indexedDb.getManyReplaceableEvents(followPubkeys, kinds.Metadata)
@ -4480,10 +4552,9 @@ class ClientService extends EventTarget {
if (out.length >= limit) break if (out.length >= limit) break
const ev = events[i] const ev = events[i]
if (!ev) continue if (!ev) continue
const p = getProfileFromEvent(ev)
const npub = pubkeyToNpub(followPubkeys[i]!) const npub = pubkeyToNpub(followPubkeys[i]!)
if (!npub) continue if (!npub) continue
if (!matchProfileText(p).includes(qLower)) continue if (!profileKind0MatchesSearchQuery(ev, q)) continue
if (addNpub(npub)) { if (addNpub(npub)) {
updateIfNeeded() updateIfNeeded()
} }
@ -4493,7 +4564,7 @@ class ClientService extends EventTarget {
} }
} }
void (async () => { followMergeWork = (async () => {
try { try {
const cachedFollow = await indexedDb.getReplaceableEvent(pk, kinds.Contacts) const cachedFollow = await indexedDb.getReplaceableEvent(pk, kinds.Contacts)
if (cachedFollow) { if (cachedFollow) {
@ -4511,6 +4582,13 @@ class ClientService extends EventTarget {
} }
} }
})() })()
if (out.length < limit) {
await Promise.race([
followMergeWork,
new Promise<void>((resolve) => setTimeout(resolve, 1_500))
])
}
} }
if (out.length >= limit) { if (out.length >= limit) {

9
src/services/post-editor.service.ts

@ -1,3 +1,5 @@
import type { Event } from 'nostr-tools'
class PostEditorService extends EventTarget { class PostEditorService extends EventTarget {
static instance: PostEditorService static instance: PostEditorService
@ -36,6 +38,13 @@ class PostEditorService extends EventTarget {
requestOpenNewPost() { requestOpenNewPost() {
this.dispatchEvent(new CustomEvent('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() const instance = new PostEditorService()

Loading…
Cancel
Save