15 changed files with 460 additions and 55 deletions
@ -0,0 +1,130 @@ |
|||||||
|
/** |
||||||
|
* Shared toolbar icon buttons for mention (npub) search and event/address (nevent/naddr) search. |
||||||
|
* Must be rendered inside NeventPickerProvider. |
||||||
|
*/ |
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { Input } from '@/components/ui/input' |
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
||||||
|
import { SimpleUsername } from '@/components/Username' |
||||||
|
import { searchNpubsForMention } from '@/services/mention-event-search.service' |
||||||
|
import { AtSign, FileSearch } from 'lucide-react' |
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { useNeventPicker } from './useNeventPicker' |
||||||
|
|
||||||
|
type ButtonVariant = 'ghost' | 'outline' |
||||||
|
|
||||||
|
export function MentionAndEventToolbarButtons({ |
||||||
|
insertAtCursor, |
||||||
|
buttonClassName, |
||||||
|
variant = 'outline' |
||||||
|
}: { |
||||||
|
insertAtCursor: (text: string) => void |
||||||
|
/** Optional class for the icon buttons (e.g. for consistency with surrounding toolbar). */ |
||||||
|
buttonClassName?: string |
||||||
|
/** Button variant to match surrounding toolbar (e.g. 'ghost' for PostEditor). */ |
||||||
|
variant?: ButtonVariant |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const neventPicker = useNeventPicker() |
||||||
|
const [mentionOpen, setMentionOpen] = useState(false) |
||||||
|
const [mentionQuery, setMentionQuery] = useState('') |
||||||
|
const [mentionResults, setMentionResults] = useState<string[]>([]) |
||||||
|
const [mentionLoading, setMentionLoading] = useState(false) |
||||||
|
const mentionDebounceRef = useRef<ReturnType<typeof setTimeout>>() |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!mentionOpen) return |
||||||
|
const q = mentionQuery.trim() |
||||||
|
if (!q) { |
||||||
|
setMentionResults([]) |
||||||
|
return |
||||||
|
} |
||||||
|
mentionDebounceRef.current = setTimeout(() => { |
||||||
|
setMentionLoading(true) |
||||||
|
searchNpubsForMention(q, 20) |
||||||
|
.then((list) => { |
||||||
|
setMentionResults(list ?? []) |
||||||
|
}) |
||||||
|
.finally(() => setMentionLoading(false)) |
||||||
|
}, 200) |
||||||
|
return () => { |
||||||
|
if (mentionDebounceRef.current) clearTimeout(mentionDebounceRef.current) |
||||||
|
} |
||||||
|
}, [mentionOpen, mentionQuery]) |
||||||
|
|
||||||
|
const closeMention = useCallback(() => { |
||||||
|
setMentionOpen(false) |
||||||
|
setMentionQuery('') |
||||||
|
setMentionResults([]) |
||||||
|
}, []) |
||||||
|
|
||||||
|
const selectNpub = useCallback( |
||||||
|
(npub: string) => { |
||||||
|
insertAtCursor(`nostr:${npub} `) |
||||||
|
closeMention() |
||||||
|
}, |
||||||
|
[insertAtCursor, closeMention] |
||||||
|
) |
||||||
|
|
||||||
|
const defaultButtonClass = 'h-8 w-8' |
||||||
|
const btnClass = buttonClassName ?? defaultButtonClass |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<Popover open={mentionOpen} onOpenChange={(open) => (open ? setMentionOpen(true) : closeMention())}> |
||||||
|
<PopoverTrigger asChild> |
||||||
|
<Button |
||||||
|
type="button" |
||||||
|
variant={variant} |
||||||
|
size="icon" |
||||||
|
title={t('Insert mention')} |
||||||
|
className={btnClass} |
||||||
|
> |
||||||
|
<AtSign className="h-4 w-4" /> |
||||||
|
</Button> |
||||||
|
</PopoverTrigger> |
||||||
|
<PopoverContent className="w-80 p-2 z-[10000]" align="start" side="bottom" sideOffset={4}> |
||||||
|
<Input |
||||||
|
placeholder={t('Search for user…')} |
||||||
|
value={mentionQuery} |
||||||
|
onChange={(e) => setMentionQuery(e.target.value)} |
||||||
|
className="mb-2" |
||||||
|
autoFocus |
||||||
|
/> |
||||||
|
<div className="max-h-60 overflow-y-auto space-y-0.5"> |
||||||
|
{mentionLoading && ( |
||||||
|
<div className="py-4 text-center text-sm text-muted-foreground">{t('Searching…')}</div> |
||||||
|
)} |
||||||
|
{!mentionLoading && mentionQuery.trim() && mentionResults.length === 0 && ( |
||||||
|
<div className="py-4 text-center text-sm text-muted-foreground">{t('No users found')}</div> |
||||||
|
)} |
||||||
|
{!mentionLoading && |
||||||
|
mentionResults.map((npub) => ( |
||||||
|
<Button |
||||||
|
key={npub} |
||||||
|
type="button" |
||||||
|
variant="ghost" |
||||||
|
className="w-full justify-start text-left h-auto py-2 font-normal" |
||||||
|
onClick={() => selectNpub(npub)} |
||||||
|
> |
||||||
|
<SimpleUsername userId={npub} className="text-sm truncate" /> |
||||||
|
</Button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</PopoverContent> |
||||||
|
</Popover> |
||||||
|
<Button |
||||||
|
type="button" |
||||||
|
variant={variant} |
||||||
|
size="icon" |
||||||
|
title={t('Insert event or address')} |
||||||
|
className={btnClass} |
||||||
|
onClick={() => neventPicker?.openNeventPicker((link) => insertAtCursor(link + ' '))} |
||||||
|
> |
||||||
|
<FileSearch className="h-4 w-4" /> |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
import * as React from 'react' |
||||||
|
import { NeventPickerContext } from './NeventNaddrPickerDialog' |
||||||
|
|
||||||
|
export function useNeventPicker() { |
||||||
|
return React.useContext(NeventPickerContext) |
||||||
|
} |
||||||
@ -0,0 +1,103 @@ |
|||||||
|
/** |
||||||
|
* Unified search for mentions (npubs) and event/note picker (nevent/naddr). |
||||||
|
* Both use the same pattern: cache first, then IndexedDB, then relays, up to limit. |
||||||
|
*/ |
||||||
|
|
||||||
|
import { ExtendedKind, SEARCHABLE_RELAY_URLS } from '@/constants' |
||||||
|
import { kinds, type Event as NEvent } from 'nostr-tools' |
||||||
|
import client from './client.service' |
||||||
|
import indexedDb from './indexed-db.service' |
||||||
|
|
||||||
|
const DEFAULT_NOTES_LIMIT = 20 |
||||||
|
const DEFAULT_NPUBS_LIMIT = 100 |
||||||
|
|
||||||
|
/** Kinds for nevent search: notes, threads, long-form, etc. */ |
||||||
|
export const NEVENT_KINDS = [ |
||||||
|
kinds.ShortTextNote, |
||||||
|
ExtendedKind.PICTURE,
|
||||||
|
ExtendedKind.VIDEO,
|
||||||
|
ExtendedKind.SHORT_VIDEO,
|
||||||
|
ExtendedKind.POLL,
|
||||||
|
ExtendedKind.COMMENT,
|
||||||
|
ExtendedKind.VOICE,
|
||||||
|
ExtendedKind.VOICE_COMMENT,
|
||||||
|
ExtendedKind.PUBLIC_MESSAGE,
|
||||||
|
ExtendedKind.DISCUSSION, |
||||||
|
ExtendedKind.CITATION_INTERNAL,
|
||||||
|
ExtendedKind.CITATION_EXTERNAL,
|
||||||
|
ExtendedKind.CITATION_HARDCOPY,
|
||||||
|
ExtendedKind.CITATION_PROMPT,
|
||||||
|
] as const |
||||||
|
|
||||||
|
/** Kinds for naddr search: calendar, publications, wiki, etc. */ |
||||||
|
export const NADDR_KINDS = [ |
||||||
|
ExtendedKind.CALENDAR_EVENT_DATE,
|
||||||
|
ExtendedKind.CALENDAR_EVENT_TIME,
|
||||||
|
ExtendedKind.PUBLICATION,
|
||||||
|
ExtendedKind.WIKI_ARTICLE,
|
||||||
|
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
|
||||||
|
ExtendedKind.PUBLICATION_CONTENT, |
||||||
|
kinds.LongFormArticle, |
||||||
|
] as const |
||||||
|
|
||||||
|
export type PickerSearchMode = 'nevent' | 'naddr' |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for events: session cache → IndexedDB → relays. Merges and dedupes by event id, up to limit. |
||||||
|
* @param mode - 'nevent' uses NEVENT_KINDS (1,11,20,21,22,9802), 'naddr' uses NADDR_KINDS (30023,30817,30818,30040). |
||||||
|
*/ |
||||||
|
export async function searchEventsForPicker( |
||||||
|
query: string, |
||||||
|
limit: number = DEFAULT_NOTES_LIMIT, |
||||||
|
mode: PickerSearchMode = 'nevent' |
||||||
|
): Promise<NEvent[]> { |
||||||
|
const q = query.trim() |
||||||
|
if (!q) return [] |
||||||
|
|
||||||
|
const kindsList = mode === 'nevent' ? [...NEVENT_KINDS] : [...NADDR_KINDS] |
||||||
|
const seen = new Set<string>() |
||||||
|
const out: NEvent[] = [] |
||||||
|
|
||||||
|
const addUnique = (evt: NEvent) => { |
||||||
|
if (seen.has(evt.id)) return |
||||||
|
seen.add(evt.id) |
||||||
|
out.push(evt) |
||||||
|
} |
||||||
|
|
||||||
|
const fromSession = client.getSessionEventsMatchingSearch(q, limit, kindsList) |
||||||
|
fromSession.forEach(addUnique) |
||||||
|
if (out.length >= limit) return out.slice(0, limit) |
||||||
|
|
||||||
|
const fromIdb = await indexedDb.getCachedEventsForSearch(q, limit - out.length, kindsList) |
||||||
|
fromIdb.forEach(addUnique) |
||||||
|
if (out.length >= limit) return out.slice(0, limit) |
||||||
|
|
||||||
|
const fromRelays = await client.fetchEvents( |
||||||
|
SEARCHABLE_RELAY_URLS, |
||||||
|
{ kinds: kindsList, search: q, limit: limit - out.length }, |
||||||
|
{ eoseTimeout: 5000, globalTimeout: 8000 } |
||||||
|
) |
||||||
|
fromRelays.forEach(addUnique) |
||||||
|
return out.slice(0, limit) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @deprecated Use searchEventsForPicker(query, limit, 'nevent') instead. |
||||||
|
*/ |
||||||
|
export async function searchNotesForPicker( |
||||||
|
query: string, |
||||||
|
limit: number = DEFAULT_NOTES_LIMIT |
||||||
|
): Promise<NEvent[]> { |
||||||
|
return searchEventsForPicker(query, limit, 'nevent') |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for npubs for @-mentions. Uses same pattern as note search: cache (follow + local index) then relays. |
||||||
|
* Delegates to client which already does follow-list → local index → relay search. |
||||||
|
*/ |
||||||
|
export async function searchNpubsForMention( |
||||||
|
query: string, |
||||||
|
limit: number = DEFAULT_NPUBS_LIMIT |
||||||
|
): Promise<string[]> { |
||||||
|
return client.searchNpubsForMention(query, limit) |
||||||
|
} |
||||||
Loading…
Reference in new issue