15 changed files with 460 additions and 55 deletions
@ -0,0 +1,130 @@
@@ -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 @@
@@ -0,0 +1,6 @@
|
||||
import * as React from 'react' |
||||
import { NeventPickerContext } from './NeventNaddrPickerDialog' |
||||
|
||||
export function useNeventPicker() { |
||||
return React.useContext(NeventPickerContext) |
||||
} |
||||
@ -0,0 +1,103 @@
@@ -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