From a07f21e01f919b9dd5c0ed1517b1b7d8db369ed7 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 18 Mar 2026 16:07:34 +0100 Subject: [PATCH] bug-fixing --- package-lock.json | 4 +- package.json | 2 +- src/components/PostEditor/PostContent.tsx | 7 +- .../Mention/MentionAndEventToolbarButtons.tsx | 130 ++++++++++++++++++ .../PostTextarea/Mention/MentionList.tsx | 30 ++-- .../PostTextarea/Mention/MentionNode.tsx | 2 +- .../Mention/NeventNaddrPickerDialog.tsx | 89 ++++++++---- .../PostTextarea/Mention/suggestion.ts | 26 +++- .../PostTextarea/Mention/useNeventPicker.ts | 6 + .../TextareaWithMentionAutocomplete/index.tsx | 24 ++-- .../DiscussionsPage/CreateThreadDialog.tsx | 21 ++- src/providers/NotificationProvider.tsx | 2 +- src/services/client.service.ts | 21 +++ src/services/indexed-db.service.ts | 48 +++++++ src/services/mention-event-search.service.ts | 103 ++++++++++++++ 15 files changed, 460 insertions(+), 55 deletions(-) create mode 100644 src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx create mode 100644 src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts create mode 100644 src/services/mention-event-search.service.ts diff --git a/package-lock.json b/package-lock.json index bb5e5b23..3531c414 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "17.0.1", + "version": "18.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "17.0.1", + "version": "18.0.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index bcd63e8b..ba389d73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jumble-imwald", - "version": "17.0.1", + "version": "18.0.0", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "private": true, "type": "module", diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index d4fef91e..a693c0af 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -61,6 +61,7 @@ import PostOptions from './PostOptions' import PostRelaySelector from './PostRelaySelector' import PostTextarea, { TPostTextareaHandle } from './PostTextarea' import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDialog' +import { MentionAndEventToolbarButtons } from './PostTextarea/Mention/MentionAndEventToolbarButtons' import Uploader from './Uploader' import HighlightEditor, { HighlightData } from './HighlightEditor' @@ -2103,7 +2104,6 @@ export default function PostContent({ } /> - {isPoll && ( )} + textareaRef.current?.insertText(text)} + variant="ghost" + /> + + + setMentionQuery(e.target.value)} + className="mb-2" + autoFocus + /> +
+ {mentionLoading && ( +
{t('Searching…')}
+ )} + {!mentionLoading && mentionQuery.trim() && mentionResults.length === 0 && ( +
{t('No users found')}
+ )} + {!mentionLoading && + mentionResults.map((npub) => ( + + ))} +
+
+ + + + ) +} diff --git a/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx index 9dccaee6..fd3c4b8b 100644 --- a/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx @@ -7,11 +7,14 @@ import { useTranslation } from 'react-i18next' import Nip05 from '../../../Nip05' import { SimpleUserAvatar } from '../../../UserAvatar' import { SimpleUsername } from '../../../Username' +import type { PickerSearchMode } from '@/services/mention-event-search.service' import { NEVENT_NADDR_PICKER_ID } from './constants' +export type MentionListItem = string | { id: string; mode?: PickerSearchMode } + export interface MentionListProps { - items: string[] - command: (payload: { id: string; label?: string }) => void + items: MentionListItem[] + command: (payload: { id: string; label?: string; mode?: PickerSearchMode }) => void /** When provided, selection is controlled by parent (e.g. for plain textarea @-mentions). */ selectedIndex?: number onSelectIndex?: (index: number) => void @@ -32,15 +35,22 @@ const MentionList = forwardRef((props, ref) const selectedIndex = isControlled ? props.selectedIndex! : internalIndex const setSelectedIndex = isControlled ? (n: number) => props.onSelectIndex?.(n) : setInternalIndex + const getItemId = (item: MentionListItem): string => + typeof item === 'string' ? item : item.id + + const getItemMode = (item: MentionListItem): PickerSearchMode | undefined => + typeof item === 'object' && item && 'mode' in item ? item.mode : undefined + const selectItem = (index: number) => { const item = items[index] if (item) { + const id = getItemId(item) const label = - item === NEVENT_NADDR_PICKER_ID + id === NEVENT_NADDR_PICKER_ID ? t('Search for event or address…') - : formatNpub(item) - props.command({ id: item, label }) + : formatNpub(id) + props.command({ id, label, mode: getItemMode(item) }) } } @@ -104,21 +114,21 @@ const MentionList = forwardRef((props, ref) 'cursor-pointer text-start items-center m-1 p-2 outline-none transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 rounded-md', selectedIndex === index && 'bg-accent text-accent-foreground' )} - key={item} + key={getItemId(item)} onClick={() => selectItem(index)} onMouseEnter={() => setSelectedIndex(index)} >
- {item === NEVENT_NADDR_PICKER_ID ? ( + {getItemId(item) === NEVENT_NADDR_PICKER_ID ? ( {t('Search for event or address…')} ) : ( <> - +
- - + +
)} diff --git a/src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx b/src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx index bddf4713..3ed80ae6 100644 --- a/src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx @@ -4,7 +4,7 @@ import { cn } from '@/lib/utils' import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react' import { useCallback } from 'react' import { NEVENT_NADDR_PICKER_ID } from './constants' -import { useNeventPicker } from './NeventNaddrPickerDialog' +import { useNeventPicker } from './useNeventPicker' export default function MentionNode(props: NodeViewRendererProps & { selected: boolean }) { const id = props.node.attrs.id as string diff --git a/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx b/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx index 81cd02d4..84dc458a 100644 --- a/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx @@ -1,7 +1,10 @@ import * as React from 'react' -import { SEARCHABLE_RELAY_URLS } from '@/constants' import { getNoteBech32Id } from '@/lib/event' import client from '@/services/client.service' +import { + searchEventsForPicker, + type PickerSearchMode +} from '@/services/mention-event-search.service' import { Button } from '@/components/ui/button' import { Dialog, @@ -10,9 +13,8 @@ import { DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' -import { ScrollArea } from '@/components/ui/scroll-area' import { SimpleUsername } from '@/components/Username' -import { kinds, nip19, type Event as NEvent } from 'nostr-tools' +import { nip19, type Event as NEvent } from 'nostr-tools' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Loader2, Search } from 'lucide-react' @@ -23,14 +25,18 @@ type NeventNaddrPickerDialogProps = { open: boolean onOpenChange: (open: boolean) => void onSelect: (nostrLink: string) => void + /** When provided, the dialog opens with this tab selected (e.g. from @naddr vs @nevent). */ + initialMode?: PickerSearchMode } export function NeventNaddrPickerDialog({ open, onOpenChange, - onSelect + onSelect, + initialMode }: NeventNaddrPickerDialogProps) { const { t } = useTranslation() + const [mode, setMode] = useState(initialMode ?? 'nevent') const [query, setQuery] = useState('') const [debouncedQuery, setDebouncedQuery] = useState('') const [events, setEvents] = useState([]) @@ -41,7 +47,8 @@ export function NeventNaddrPickerDialog({ setQuery('') setDebouncedQuery('') setEvents([]) - }, [open]) + if (initialMode !== undefined) setMode(initialMode) + }, [open, initialMode]) useEffect(() => { if (!open) return @@ -57,8 +64,7 @@ export function NeventNaddrPickerDialog({ } let cancelled = false setLoading(true) - client - .fetchEvents(SEARCHABLE_RELAY_URLS, { kinds: [kinds.ShortTextNote], search: debouncedQuery, limit: 20 }, { eoseTimeout: 5000, globalTimeout: 8000 }) + searchEventsForPicker(debouncedQuery, 20, mode) .then((list) => { if (cancelled) return setEvents(list.slice(0, 15) as NEvent[]) @@ -69,7 +75,7 @@ export function NeventNaddrPickerDialog({ return () => { cancelled = true } - }, [open, debouncedQuery]) + }, [open, debouncedQuery, mode]) const handleSelect = useCallback( (event: NEvent) => { @@ -89,23 +95,45 @@ export function NeventNaddrPickerDialog({ return ( {t('Search for event or address…')} +
+ + +
setQuery(e.target.value)} className="pl-9" autoFocus />
- +
{loading && (
@@ -113,7 +141,9 @@ export function NeventNaddrPickerDialog({
)} {!loading && debouncedQuery && events.length === 0 && ( -

{t('No notes found')}

+

+ {t('No events found')} +

)} {!loading && events.map((ev: NEvent) => ( @@ -130,41 +160,44 @@ export function NeventNaddrPickerDialog({ ))}
- +
) } type NeventPickerContextValue = { - openNeventPicker: (onSelected: (nostrLink: string) => void) => void + openNeventPicker: (onSelected: (nostrLink: string) => void, initialMode?: PickerSearchMode) => void } -const NeventPickerContext = React.createContext(null) - -export function useNeventPicker(): NeventPickerContextValue | null { - return React.useContext(NeventPickerContext) -} +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 } = (e as CustomEvent<{ editor: Editor; range: { from: number; to: number } }>).detail + 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) => { + const openNeventPicker = useCallback((onSelected: (nostrLink: string) => void, mode?: PickerSearchMode) => { setOnSelectedRef(() => onSelected) + setInitialMode(mode ?? 'nevent') setOpen(true) }, []) @@ -177,7 +210,10 @@ export function NeventPickerProvider({ children }: { children: React.ReactNode } ) const handleOpenChange = useCallback((next: boolean) => { - if (!next) setOnSelectedRef(null) + if (!next) { + setOnSelectedRef(null) + setInitialMode('nevent') + } setOpen(next) }, []) @@ -186,7 +222,12 @@ export function NeventPickerProvider({ children }: { children: React.ReactNode } return ( {children} - + ) } diff --git a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts index 61fc8eb5..4264b125 100644 --- a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts +++ b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts @@ -1,4 +1,7 @@ -import client from '@/services/client.service' +import { + searchNpubsForMention, + type PickerSearchMode +} from '@/services/mention-event-search.service' import postEditor from '@/services/post-editor.service' import type { Editor } from '@tiptap/core' import { ReactRenderer } from '@tiptap/react' @@ -9,6 +12,8 @@ import { NEVENT_NADDR_PICKER_ID } from './constants' export { NEVENT_NADDR_PICKER_ID } from './constants' +export type { PickerSearchMode } + const MENTION_EXTENSION_NAME = 'mention' const MENTION_CHAR = '@' @@ -33,11 +38,21 @@ export function extendMentionRangeToEndOfWord(editor: Editor, range: { from: num } const suggestion = { - command: ({ editor, range, props }: { editor: Editor; range: { from: number; to: number }; props: { id: string; label?: string } }) => { + command: ({ + editor, + range, + props + }: { + editor: Editor + range: { from: number; to: number } + props: { id: string; label?: string; mode?: PickerSearchMode } + }) => { if (props.id === NEVENT_NADDR_PICKER_ID) { postEditor.closeSuggestionPopup() window.dispatchEvent( - new CustomEvent(OPEN_NEVENT_PICKER_EVENT, { detail: { editor, range } }) + new CustomEvent(OPEN_NEVENT_PICKER_EVENT, { + detail: { editor, range, initialMode: props.mode ?? 'nevent' } + }) ) return } @@ -59,9 +74,10 @@ const suggestion = { items: async ({ query }: { query: string }) => { const q = query.trim().toLowerCase() if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) { - return [NEVENT_NADDR_PICKER_ID] + const mode: PickerSearchMode = q === 'naddr' || q.startsWith('naddr') ? 'naddr' : 'nevent' + return [{ id: NEVENT_NADDR_PICKER_ID, mode }] } - const result = await client.searchNpubsForMention(query, 20) + const result = await searchNpubsForMention(query, 20) return result ?? [] }, diff --git a/src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts b/src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts new file mode 100644 index 00000000..fcbfa4aa --- /dev/null +++ b/src/components/PostEditor/PostTextarea/Mention/useNeventPicker.ts @@ -0,0 +1,6 @@ +import * as React from 'react' +import { NeventPickerContext } from './NeventNaddrPickerDialog' + +export function useNeventPicker() { + return React.useContext(NeventPickerContext) +} diff --git a/src/components/TextareaWithMentionAutocomplete/index.tsx b/src/components/TextareaWithMentionAutocomplete/index.tsx index 054da71b..7c364091 100644 --- a/src/components/TextareaWithMentionAutocomplete/index.tsx +++ b/src/components/TextareaWithMentionAutocomplete/index.tsx @@ -1,9 +1,12 @@ import { Textarea } from '@/components/ui/textarea' import MentionList from '@/components/PostEditor/PostTextarea/Mention/MentionList' import { NEVENT_NADDR_PICKER_ID } from '@/components/PostEditor/PostTextarea/Mention/constants' -import { useNeventPicker } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog' +import { useNeventPicker } from '@/components/PostEditor/PostTextarea/Mention/useNeventPicker' import { EmojiList } from '@/components/PostEditor/PostTextarea/Emoji/EmojiList' -import client from '@/services/client.service' +import { + searchNpubsForMention, + type PickerSearchMode +} from '@/services/mention-event-search.service' import customEmojiService from '@/services/custom-emoji.service' import { searchStandardEmojiShortcodes } from '@/lib/emoji-content' import { createPortal } from 'react-dom' @@ -19,6 +22,8 @@ export type TextareaWithMentionAutocompleteProps = Omit< > & { value: string onChange: (value: string) => void + /** When provided, used to open the nevent/naddr picker when user selects that option. Use when context may be unavailable (e.g. modal). */ + onOpenNeventPicker?: (onSelected: (link: string) => void, initialMode?: PickerSearchMode) => void } /** @@ -29,6 +34,7 @@ const TextareaWithMentionAutocomplete = forwardRef { + const initialMode: PickerSearchMode = + mentionQuery.trim().toLowerCase().startsWith('naddr') ? 'naddr' : 'nevent' + openPicker((link: string) => { const insert = link + ' ' onChange(before + insert + after) setTimeout(() => { @@ -106,7 +115,7 @@ const TextareaWithMentionAutocomplete = forwardRef { - client - .searchNpubsForMention(mentionQuery.trim(), MENTION_LIMIT) + searchNpubsForMention(mentionQuery.trim(), MENTION_LIMIT) .then((npubs) => { const q = mentionQueryRef.current.trim().toLowerCase() if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) { diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index a76da551..765cf080 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -10,7 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Checkbox } from '@/components/ui/checkbox' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Hash, X, Users, Film, Image, Zap, Settings, Book, Eye, Edit3, ChevronDown, Check, ImageUp, Smile } from 'lucide-react' -import { useState, useEffect, useMemo, useRef, useCallback } from 'react' +import { forwardRef, useState, useEffect, useMemo, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -30,8 +30,24 @@ import GifPicker from '@/components/GifPicker' import EmojiPickerDialog from '@/components/EmojiPickerDialog' import Uploader from '@/components/PostEditor/Uploader' import { NeventPickerProvider } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog' +import { useNeventPicker } from '@/components/PostEditor/PostTextarea/Mention/useNeventPicker' import logger from '@/lib/logger' import postEditorCache from '@/services/post-editor-cache.service' +import { MentionAndEventToolbarButtons } from '@/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons' + +/** Wraps the textarea so it receives the nevent/naddr picker from context (must be rendered inside NeventPickerProvider). */ +const ThreadContentTextarea = forwardRef>( + function ThreadContentTextarea(props, ref) { + const neventPicker = useNeventPicker() + return ( + + ) + } +) // Utility functions for thread creation function extractImagesFromContent(content: string): string[] { @@ -745,8 +761,9 @@ export default function CreateThreadDialog({ {t('Insert emoji')} +
- { if (isMountedRef.current) { - logger.info('[NotificationProvider] Reconnecting after close...') + logger.debug('[NotificationProvider] Reconnecting after close...') subscribe() } }, 15_000) // Increased from 5s to 15s diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 3f53ae3f..54ca902c 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1536,6 +1536,27 @@ class ClientService extends EventTarget { // Replaceable events are not stored in memory; they go to IndexedDB via putReplaceableEvent elsewhere } + /** + * Return events from session cache whose kind is in the allowed set and content/tags match the query (case-insensitive). + * Used by mention-event-search.service for cache-first event search (nevent/naddr picker). + */ + getSessionEventsMatchingSearch(query: string, limit: number, allowedKinds: number[]): NEvent[] { + const q = query.trim().toLowerCase() + if (!q || allowedKinds.length === 0) return [] + const kindSet = new Set(allowedKinds) + const out: NEvent[] = [] + const values = [...this.sessionEventCache.values()] + for (const evt of values) { + if (out.length >= limit) break + if (!kindSet.has(evt.kind)) continue + const content = (evt.content ?? '').toLowerCase() + const tagsStr = (evt.tags ?? []).flat().join(' ').toLowerCase() + if (!content.includes(q) && !tagsStr.includes(q)) continue + out.push(evt) + } + return out + } + private async fetchEventById(relayUrls: string[], id: string): Promise { const event = await this.fetchEventFromBigRelaysDataloader.load(id) if (event) { diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 01129beb..3bf731c9 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -972,6 +972,54 @@ class IndexedDbService { }) } + /** + * Iterate PUBLICATION_EVENTS and return events whose kind is in allowedKinds and content or tags + * match the search query (case-insensitive). Used by nevent/naddr picker to show cached events first. + */ + async getCachedEventsForSearch(query: string, limit: number, allowedKinds: number[]): Promise { + await this.initPromise + if (!this.db || !this.db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) { + return [] + } + const q = query.trim().toLowerCase() + if (!q || allowedKinds.length === 0) return [] + + const kindSet = new Set(allowedKinds) + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly') + const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS) + const request = store.openCursor() + const results: Event[] = [] + + request.onsuccess = () => { + const cursor = (request as IDBRequest).result + if (!cursor || results.length >= limit) { + transaction.commit() + resolve(results) + return + } + const item = cursor.value as TValue | undefined + if (item?.value) { + const event = item.value as Event + if (kindSet.has(event.kind)) { + const content = (event.content ?? '').toLowerCase() + const tagsStr = (event.tags ?? []).flat().join(' ').toLowerCase() + if (content.includes(q) || tagsStr.includes(q)) { + results.push(event) + } + } + } + cursor.continue() + } + + request.onerror = (event) => { + transaction.commit() + reject(event) + } + }) + } + async getPublicationStoreItems(storeName: string): Promise> { // For publication stores, only return master events with nested counts await this.initPromise diff --git a/src/services/mention-event-search.service.ts b/src/services/mention-event-search.service.ts new file mode 100644 index 00000000..6891af1b --- /dev/null +++ b/src/services/mention-event-search.service.ts @@ -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 { + const q = query.trim() + if (!q) return [] + + const kindsList = mode === 'nevent' ? [...NEVENT_KINDS] : [...NADDR_KINDS] + const seen = new Set() + 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 { + 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 { + return client.searchNpubsForMention(query, limit) +}