From 59da76683a959a67928019687330d1553a507d1c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 16 Apr 2026 10:17:11 +0200 Subject: [PATCH] bug-fixes --- .../AdvancedEventLabDialog.tsx | 223 +++++++++++++++++- src/i18n/locales/de.ts | 5 + src/i18n/locales/en.ts | 5 + src/lib/citation-picker-relays.ts | 71 ++++++ src/lib/citation-picker-search.ts | 65 +++++ src/services/client-events.service.ts | 28 +++ src/services/indexed-db.service.ts | 98 ++++++++ src/services/mention-event-search.service.ts | 92 +++++++- 8 files changed, 583 insertions(+), 4 deletions(-) create mode 100644 src/lib/citation-picker-relays.ts create mode 100644 src/lib/citation-picker-search.ts diff --git a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx index beaefaff..e262cfaa 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx @@ -21,7 +21,7 @@ import { buildLanguageToolPreferenceList, pickLanguageToolCodeForTranslateTarget } from '@/lib/languagetool-language-order' -import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' +import { parseLabSlice, type AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect' import { fetchTranslateLanguages, @@ -41,6 +41,7 @@ import { lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view' +import { Undo2 } from 'lucide-react' import type { MutableRefObject, ReactNode } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -53,6 +54,81 @@ import type { TEmoji } from '@/types' const PREVIEW_DEBOUNCE_MS = 200 +const LAB_UNDO_STORAGE_V = 1 as const +const LAB_UNDO_INTERVAL_MS = 30_000 +const LAB_UNDO_MAX_CHECKPOINTS = 10 + +function labUndoSessionStorageKey(storageId: string): string { + return `jumble:advLabUndo:${storageId}` +} + +function cloneLabSlice(s: AdvancedEventLabSlice): AdvancedEventLabSlice { + return { kind: s.kind, content: s.content, tags: s.tags.map((row) => [...row]) } +} + +function labSlicesEqual(a: AdvancedEventLabSlice, b: AdvancedEventLabSlice): boolean { + if (a.kind !== b.kind || a.content !== b.content) return false + return JSON.stringify(a.tags) === JSON.stringify(b.tags) +} + +function parseCheckpointEntry(raw: unknown): AdvancedEventLabSlice | null { + let json: string + try { + json = JSON.stringify(raw) + } catch { + return null + } + const parsed = parseLabSlice(json) + return parsed.ok ? parsed.value : null +} + +function loadLabCheckpointsFromSession( + storageId: string, + base: AdvancedEventLabSlice +): AdvancedEventLabSlice[] | null { + if (!storageId || typeof sessionStorage === 'undefined') return null + try { + const key = labUndoSessionStorageKey(storageId) + const raw = sessionStorage.getItem(key) + if (!raw) return null + const o = JSON.parse(raw) as { v?: number; checkpoints?: unknown } + if (!o || o.v !== LAB_UNDO_STORAGE_V || !Array.isArray(o.checkpoints)) return null + const out: AdvancedEventLabSlice[] = [] + for (const row of o.checkpoints) { + const slice = parseCheckpointEntry(row) + if (!slice) return null + out.push(slice) + } + if (out.length === 0) return null + const last = out[out.length - 1]! + if (!labSlicesEqual(last, base)) return null + return out.slice(0, LAB_UNDO_MAX_CHECKPOINTS).map(cloneLabSlice) + } catch { + return null + } +} + +function persistLabCheckpointsToSession(storageId: string, checkpoints: AdvancedEventLabSlice[]): void { + if (!storageId || typeof sessionStorage === 'undefined') return + try { + sessionStorage.setItem( + labUndoSessionStorageKey(storageId), + JSON.stringify({ v: LAB_UNDO_STORAGE_V, checkpoints }) + ) + } catch { + // quota / private mode + } +} + +function clearLabCheckpointsSession(storageId: string): void { + if (!storageId || typeof sessionStorage === 'undefined') return + try { + sessionStorage.removeItem(labUndoSessionStorageKey(storageId)) + } catch { + /* ignore */ + } +} + /** Subset of {@link TPostTextareaHandle} so media upload + toolbar can target the lab surface. */ export type AdvancedLabBodyHandle = { getText: () => string @@ -149,6 +225,9 @@ export default function AdvancedEventLabDialog({ const markupView = useRef(null) const sliceRef = useRef(null) const draftPersistenceKeyRef = useRef(null) + const labUndoAnonIdRef = useRef(null) + const labCheckpointsRef = useRef([]) + const [undoUiTick, setUndoUiTick] = useState(0) const labPersistTimerRef = useRef | null>(null) const previewDebounceTimerRef = useRef | null>(null) const schedulePreviewUpdateRef = useRef<(text: string) => void>(() => {}) @@ -191,6 +270,21 @@ export default function AdvancedEventLabDialog({ draftPersistenceKeyRef.current = draftPersistenceKey ?? null + useEffect(() => { + if (!open) labUndoAnonIdRef.current = null + }, [open]) + + const undoSessionId = useMemo(() => { + if (!open) return '' + if (draftPersistenceKey) return draftPersistenceKey + if (!labUndoAnonIdRef.current) labUndoAnonIdRef.current = crypto.randomUUID() + return labUndoAnonIdRef.current + }, [open, draftPersistenceKey]) + + const bumpUndoUi = useCallback(() => { + setUndoUiTick((n) => n + 1) + }, []) + const flushLabDraftNow = useCallback((key: string) => { const v = markupView.current const s = sliceRef.current @@ -276,6 +370,103 @@ export default function AdvancedEventLabDialog({ }, LAB_DRAFT_DEBOUNCE_MS) }, []) + const restoreSliceInEditor = useCallback( + (slice: AdvancedEventLabSlice) => { + const v = markupView.current + if (!v) return + v.dispatch({ + changes: { from: 0, to: v.state.doc.length, insert: slice.content }, + selection: EditorSelection.cursor(0) + }) + sliceRef.current = cloneLabSlice(slice) + setPreviewDoc(slice.content) + scheduleLabDraftPersist() + if (isLanguageToolConfigured()) requestAdvancedLabGrammarLint(v) + bumpUndoUi() + }, + [bumpUndoUi, scheduleLabDraftPersist] + ) + + const pushLabCheckpoint = useCallback(() => { + const v = markupView.current + const s = sliceRef.current + if (!v || !s || !undoSessionId) return + const snap = cloneLabSlice({ + kind: s.kind, + content: v.state.doc.toString(), + tags: s.tags.map((row) => [...row]) + }) + const cp = labCheckpointsRef.current + const last = cp[cp.length - 1] + if (last && labSlicesEqual(last, snap)) return + cp.push(snap) + while (cp.length > LAB_UNDO_MAX_CHECKPOINTS) cp.shift() + persistLabCheckpointsToSession(undoSessionId, cp) + bumpUndoUi() + }, [undoSessionId, bumpUndoUi]) + + const handleUndoCheckpoint = useCallback(() => { + const v = markupView.current + const s = sliceRef.current + if (!v || !s || !undoSessionId) return + const cp = labCheckpointsRef.current + if (cp.length === 0) { + toast.message(t('Advanced lab undo checkpoint none')) + return + } + const live = cloneLabSlice({ + kind: s.kind, + content: v.state.doc.toString(), + tags: s.tags.map((row) => [...row]) + }) + const last = cp[cp.length - 1]! + if (!labSlicesEqual(live, last)) { + restoreSliceInEditor(last) + persistLabCheckpointsToSession(undoSessionId, cp) + toast.success(t('Advanced lab undo checkpoint restored')) + return + } + if (cp.length < 2) { + toast.message(t('Advanced lab undo checkpoint none')) + return + } + cp.pop() + const target = cp[cp.length - 1]! + restoreSliceInEditor(target) + persistLabCheckpointsToSession(undoSessionId, cp) + toast.success(t('Advanced lab undo checkpoint restored')) + }, [undoSessionId, restoreSliceInEditor, t]) + + const canUndoCheckpoint = useMemo(() => { + if (!open) return false + const v = markupView.current + const s = sliceRef.current + const cp = labCheckpointsRef.current + if (!v || !s || cp.length === 0) return false + const live: AdvancedEventLabSlice = { + kind: s.kind, + content: v.state.doc.toString(), + tags: s.tags.map((row) => [...row]) + } + const last = cp[cp.length - 1]! + if (!labSlicesEqual(live, last)) return true + return cp.length >= 2 + }, [undoUiTick, open, previewDoc]) + + useEffect(() => { + if (!open || !initial) return + const pushId = window.setInterval(() => { + pushLabCheckpoint() + }, LAB_UNDO_INTERVAL_MS) + const uiId = window.setInterval(() => { + bumpUndoUi() + }, 2500) + return () => { + clearInterval(pushId) + clearInterval(uiId) + } + }, [open, initial, pushLabCheckpoint, bumpUndoUi]) + const ltList = useMemo( () => buildLanguageToolPreferenceList(i18nLanguage ?? i18n.language), [i18nLanguage, i18n.language] @@ -416,6 +607,17 @@ export default function AdvancedEventLabDialog({ markupView.current = new EditorView({ state: mkState, parent: mkEl }) + const loaded = + undoSessionId && undoSessionId.length > 0 + ? loadLabCheckpointsFromSession(undoSessionId, baseSlice) + : null + labCheckpointsRef.current = + loaded && loaded.length > 0 ? loaded : [cloneLabSlice(baseSlice)] + if (undoSessionId) { + persistLabCheckpointsToSession(undoSessionId, labCheckpointsRef.current) + } + bumpUndoUi() + if (bodyApiRef) { bodyApiRef.current = { getText: () => markupView.current?.state.doc.toString() ?? '', @@ -485,7 +687,9 @@ export default function AdvancedEventLabDialog({ t, bodyApiRef, scheduleLabDraftPersist, - flushLabDraftNow + flushLabDraftNow, + undoSessionId, + bumpUndoUi ]) const handleApply = () => { @@ -506,6 +710,10 @@ export default function AdvancedEventLabDialog({ if (draftPersistenceKeyRef.current) { postEditorCache.clearAdvancedLabDraft(draftPersistenceKeyRef.current) } + if (undoSessionId) { + clearLabCheckpointsSession(undoSessionId) + labCheckpointsRef.current = [] + } handleDialogOpenChange(false) } @@ -676,6 +884,17 @@ export default function AdvancedEventLabDialog({ {t('Advanced lab use translation read aloud')} ) : null} + diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index f10dcbf2..d745ad51 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -957,6 +957,11 @@ export default { 'Advanced event lab': 'Erweiterter Editor', 'Advanced lab applyError': 'Editor ist nicht bereit. Bitte erneut versuchen.', 'Advanced lab cancel undo': 'Abbrechen und Änderungen verwerfen', + 'Advanced lab undo checkpoint': 'Checkpoint wiederherstellen', + 'Advanced lab undo checkpoint hint': + 'Etwa alle 30 Sekunden speichert dieser Tab den Editor (Kind, Text, Tags) in der Sitzung, bis zu 10 Versionen. Nutzen Sie das nach einer Übersetzung oder großen Änderung, die Sie rückgängig machen möchten.', + 'Advanced lab undo checkpoint none': 'Kein älterer Checkpoint zum Wiederherstellen.', + 'Advanced lab undo checkpoint restored': 'Editor auf einen gespeicherten Checkpoint zurückgesetzt.', 'Advanced lab markup label markdown': 'Markdown', 'Advanced lab markup label asciidoc': 'AsciiDoc', 'Advanced lab preview': 'Vorschau', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 58102fe3..3e305589 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -958,6 +958,11 @@ export default { 'Advanced event lab': 'Advanced editor', 'Advanced lab applyError': 'Editor is not ready. Try again.', 'Advanced lab cancel undo': 'Cancel and Undo Changes', + 'Advanced lab undo checkpoint': 'Restore checkpoint', + 'Advanced lab undo checkpoint hint': + 'About every 30 seconds this tab saves the editor (kind, body, tags) in session storage, up to 10 versions. Use this after a translation or bulk edit you want to roll back.', + 'Advanced lab undo checkpoint none': 'No older checkpoint to restore.', + 'Advanced lab undo checkpoint restored': 'Editor restored to a saved checkpoint.', 'Advanced lab markup label markdown': 'Markdown', 'Advanced lab markup label asciidoc': 'AsciiDoc', 'Advanced lab preview': 'Preview', diff --git a/src/lib/citation-picker-relays.ts b/src/lib/citation-picker-relays.ts new file mode 100644 index 00000000..e103875c --- /dev/null +++ b/src/lib/citation-picker-relays.ts @@ -0,0 +1,71 @@ +import { + BOOKSTR_RELAY_URLS, + DOCUMENT_RELAY_URLS, + FAST_READ_RELAY_URLS, + NIP66_DISCOVERY_RELAY_URLS, + SEARCHABLE_RELAY_URLS +} from '@/constants' +import { mergeRelayUrlLayers } from '@/lib/favorites-feed-relays' +import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' +import { normalizeUrl } from '@/lib/url' +import client from '@/services/client.service' +import nip66Service from '@/services/nip66.service' + +/** Broad NIP-50 / index relays not always present in {@link SEARCHABLE_RELAY_URLS}. */ +const CITATION_SEARCH_EXTRA_INDEX_RELAYS = ['wss://relay.nostr.band'] as const + +/** Cap NIP-66 “supports search” relays so we do not open hundreds of sockets. */ +const CITATION_SEARCH_NIP66_NIP50_CAP = 42 + +/** Final cap after merge (priority = earlier layers in {@link mergeRelayUrlLayers}). */ +const CITATION_SEARCH_MAX_RELAYS = 56 + +function normList(urls: readonly string[]): string[] { + return urls.map((u) => normalizeUrl(u) || u.trim()).filter(Boolean) +} + +/** + * Relay stack for NIP-32 citation (kinds 30–33) NIP-50 search: user NIP-65 / favorites / local / profile, + * static searchable + document + discovery pools, NIP-66 search-capable relays, then fast read. + */ +export async function buildCitationPickerSearchRelayUrls(): Promise { + const viewer = client.pubkey?.trim() || undefined + + let userCentric: string[] = [] + try { + userCentric = await buildComprehensiveRelayList({ + userPubkey: viewer, + includeUserOwnRelays: !!viewer, + includeProfileFetchRelays: true, + includeFastReadRelays: true, + includeFastWriteRelays: true, + includeSearchableRelays: true, + includeLocalRelays: !!viewer, + includeFavoriteRelays: !!viewer + }) + } catch { + /* continue with static layers */ + } + + const nip66Search = nip66Service + .getSearchableRelayUrls() + .map((u) => normalizeUrl(u) || u.trim()) + .filter(Boolean) + .slice(0, CITATION_SEARCH_NIP66_NIP50_CAP) + + const merged = mergeRelayUrlLayers( + [ + userCentric, + normList(SEARCHABLE_RELAY_URLS), + normList(DOCUMENT_RELAY_URLS), + normList(NIP66_DISCOVERY_RELAY_URLS), + normList(BOOKSTR_RELAY_URLS), + normList([...CITATION_SEARCH_EXTRA_INDEX_RELAYS]), + nip66Search, + normList(FAST_READ_RELAY_URLS) + ], + [] + ) + + return merged.slice(0, CITATION_SEARCH_MAX_RELAYS) +} diff --git a/src/lib/citation-picker-search.ts b/src/lib/citation-picker-search.ts new file mode 100644 index 00000000..fdbb8ec9 --- /dev/null +++ b/src/lib/citation-picker-search.ts @@ -0,0 +1,65 @@ +import type { Event } from 'nostr-tools' +import { nip19 } from 'nostr-tools' + +/** Tag values joined for tags whose first letter is `name` (e.g. `title`, `summary`). */ +function citationTagLine(ev: Event, name: string): string { + const parts: string[] = [] + for (const row of ev.tags ?? []) { + if (row[0] !== name) continue + const rest = row.slice(1).filter(Boolean) + if (rest.length) parts.push(rest.join(' ')) + } + return parts.join(' ') +} + +/** + * Lowercased haystack for NIP-32 citation kinds: body plus common metadata tags + * (title, summary, author, identifiers, etc.). Used for client-side matching when + * relays do not index these fields for NIP-50. + */ +export function citationPickerHaystack(ev: Event): string { + const chunks = [ + ev.content ?? '', + citationTagLine(ev, 'title'), + citationTagLine(ev, 'summary'), + citationTagLine(ev, 'author'), + citationTagLine(ev, 'chapter_title'), + citationTagLine(ev, 'published_in'), + citationTagLine(ev, 'published_by'), + citationTagLine(ev, 'published_on'), + citationTagLine(ev, 'accessed_on'), + citationTagLine(ev, 'location'), + citationTagLine(ev, 'u'), + citationTagLine(ev, 'doi'), + citationTagLine(ev, 'c'), + citationTagLine(ev, 'llm'), + citationTagLine(ev, 'page_range'), + citationTagLine(ev, 'editor'), + citationTagLine(ev, 'version') + ] + return chunks.join('\n').toLowerCase() +} + +export function citationPickerMatchesQuery(ev: Event, query: string): boolean { + const q = query.trim().toLowerCase() + if (!q) return true + const h = citationPickerHaystack(ev) + if (h.includes(q)) return true + const words = q.split(/\s+/).filter((w) => w.length > 1) + if (words.length >= 2 && words.every((w) => h.includes(w))) return true + return false +} + +/** Hex id, `note1…`, or `nevent1…` for direct citation lookup. */ +export function tryParseCitationEventIdFromQuery(query: string): string | null { + const t = query.trim() + if (/^[0-9a-f]{64}$/i.test(t)) return t.toLowerCase() + try { + const d = nip19.decode(t) + if (d.type === 'note') return d.data as string + if (d.type === 'nevent') return d.data.id + } catch { + /* ignore */ + } + return null +} diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 4f8783c5..1bb09136 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -26,6 +26,7 @@ import { queuePersistSeenEvent } from './event-archive.service' import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config' +import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { normalizeUrl } from '@/lib/url' @@ -512,6 +513,33 @@ export class EventService { return results } + /** + * Session cache: NIP-32 citation kinds (30–33) matched on title/summary/content and related tags + * (not NIP-50 relay semantics). + */ + getSessionCitationFieldSearch(query: string, limit: number): NEvent[] { + const results: NEvent[] = [] + const q = query.trim() + if (!q || limit <= 0) return results + + const kindSet = new Set([ + ExtendedKind.CITATION_INTERNAL, + ExtendedKind.CITATION_EXTERNAL, + ExtendedKind.CITATION_HARDCOPY, + ExtendedKind.CITATION_PROMPT + ]) + + for (const [, event] of this.sessionEventCache.entries()) { + if (shouldDropEventOnIngest(event)) continue + if (!kindSet.has(event.kind)) continue + if (!citationPickerMatchesQuery(event, q)) continue + results.push(event) + if (results.length >= limit) break + } + + return results + } + /** * Kind 9735 in session LRU whose top-level `e` references the given hex event id (e.g. zap poll / note). * Used to show tally immediately when opening the note drawer after the feed already saw these receipts. diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 20a8ae1a..c9139a00 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -8,6 +8,7 @@ import { TNip66RelayDiscovery, TRelayInfo } from '@/types' import type { Event } from 'nostr-tools' import { kinds } from 'nostr-tools' import { isReplaceableEvent, getReplaceableCoordinateFromEvent } from '@/lib/event' +import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import logger from '@/lib/logger' /** Hot archive row in {@link StoreNames.EVENT_ARCHIVE}. */ @@ -1407,6 +1408,103 @@ class IndexedDbService { return [...fromPub, ...rest].slice(0, limit) } + /** + * Publication store + {@link StoreNames.EVENT_ARCHIVE}: citation kinds (30–33) where the query matches + * body/title/summary/author and other citation tags via {@link citationPickerMatchesQuery} (not relay NIP-50). + */ + async getCachedAndArchivedCitationFieldSearch( + query: string, + limit: number, + allowedKinds: number[], + options?: { archiveScanMaxMs?: number } + ): Promise { + await this.initPromise + const qRaw = query.trim() + if (!qRaw || allowedKinds.length === 0 || limit <= 0) return [] + + const kindSet = new Set(allowedKinds) + const fromPub: Event[] = [] + + if (this.db?.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) { + await new Promise((resolve, reject) => { + const transaction = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly') + const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS) + const request = store.openCursor() + + request.onsuccess = () => { + const cursor = (request as IDBRequest).result + if (!cursor || fromPub.length >= limit) { + transaction.commit() + resolve() + return + } + const item = cursor.value as TValue | undefined + if (item?.value) { + const event = item.value as Event + if (kindSet.has(event.kind) && citationPickerMatchesQuery(event, qRaw)) { + fromPub.push(event) + } + } + cursor.continue() + } + + request.onerror = (event) => { + transaction.commit() + reject(event) + } + }).catch((e: unknown) => { + logger.warn('[indexedDb] getCachedAndArchivedCitationFieldSearch publication scan failed', { e }) + }) + } + + if (fromPub.length >= limit) return fromPub.slice(0, limit) + + const seen = new Set(fromPub.map((e) => e.id)) + const rest: Event[] = [] + const scanStart = Date.now() + const archiveScanMaxMs = options?.archiveScanMaxMs + + if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) { + return fromPub + } + + await new Promise((resolve, reject) => { + const transaction = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly') + const store = transaction.objectStore(StoreNames.EVENT_ARCHIVE) + const request = store.openCursor() + + request.onsuccess = () => { + if (archiveScanMaxMs !== undefined && Date.now() - scanStart >= archiveScanMaxMs) { + transaction.commit() + resolve() + return + } + const cursor = (request as IDBRequest).result + if (!cursor || fromPub.length + rest.length >= limit) { + transaction.commit() + resolve() + return + } + const row = cursor.value as TArchivedEventRow + const ev = row?.value + if (ev && kindSet.has(ev.kind) && !seen.has(ev.id) && citationPickerMatchesQuery(ev, qRaw)) { + seen.add(ev.id) + rest.push(ev) + } + cursor.continue() + } + + request.onerror = (e) => { + transaction.commit() + reject(idbEventToError(e)) + } + }).catch((e: unknown) => { + logger.warn('[indexedDb] getCachedAndArchivedCitationFieldSearch archive scan failed', { e }) + }) + + return [...fromPub, ...rest].slice(0, limit) + } + 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 index 0f3521ec..c268635a 100644 --- a/src/services/mention-event-search.service.ts +++ b/src/services/mention-event-search.service.ts @@ -3,10 +3,14 @@ * Both use the same pattern: cache first, then IndexedDB, then relays, up to limit. */ +import { buildCitationPickerSearchRelayUrls } from '@/lib/citation-picker-relays' +import { + citationPickerMatchesQuery, + tryParseCitationEventIdFromQuery +} from '@/lib/citation-picker-search' import { ExtendedKind, SEARCHABLE_RELAY_URLS } from '@/constants' import { kinds, type Event as NEvent } from 'nostr-tools' -import { eventService, queryService } from './client.service' -import client from './client.service' +import client, { eventService, queryService } from './client.service' import indexedDb from './indexed-db.service' const DEFAULT_NOTES_LIMIT = 20 @@ -54,6 +58,85 @@ export const NADDR_KINDS = [ export type PickerSearchMode = 'nevent' | 'naddr' +/** True when `kindFilter` is exactly the four NIP-32 citation kinds (any order, each once). */ +function isCitationOnlyKindFilter(kindFilter: readonly number[] | undefined): boolean { + if (!kindFilter?.length) return false + const a = [...CITATION_PICKER_KINDS].sort((x, y) => x - y) + const b = [...kindFilter].sort((x, y) => x - y) + if (a.length !== b.length) return false + return a.every((k, i) => k === b[i]) +} + +async function searchCitationEventsForPickerInternal( + q: string, + limit: number, + kindsList: number[] +): Promise { + const seen = new Set() + const out: NEvent[] = [] + + const push = (evt: NEvent, requireFieldMatch: boolean) => { + if (seen.has(evt.id)) return + if (requireFieldMatch && !citationPickerMatchesQuery(evt, q)) return + seen.add(evt.id) + out.push(evt) + } + + const idHex = tryParseCitationEventIdFromQuery(q) + if (idHex) { + const ev = await client.fetchEvent(idHex) + if (ev && kindsList.includes(ev.kind)) push(ev, false) + if (out.length >= limit) return out.slice(0, limit) + } + + for (const ev of eventService.getSessionCitationFieldSearch(q, limit)) { + push(ev, false) + if (out.length >= limit) return out.slice(0, limit) + } + + const fromArch = await indexedDb.getCachedAndArchivedCitationFieldSearch( + q, + limit - out.length, + kindsList, + { archiveScanMaxMs: 14_000 } + ) + for (const ev of fromArch) { + push(ev, false) + if (out.length >= limit) return out.slice(0, limit) + } + + const relayUrls = await buildCitationPickerSearchRelayUrls() + const need = limit - out.length + if (need <= 0) return out.slice(0, limit) + + const nip50Limit = Math.max(need, 8) + const broadLimit = Math.min(160, Math.max(need * 8, 48)) + + const [fromNip50, fromBroad] = await Promise.all([ + queryService.fetchEvents( + relayUrls, + { kinds: kindsList, search: q, limit: nip50Limit }, + { eoseTimeout: 8500, globalTimeout: 14_000 } + ), + queryService.fetchEvents( + SEARCHABLE_RELAY_URLS, + { kinds: kindsList, limit: broadLimit }, + { eoseTimeout: 5000, globalTimeout: 9000 } + ) + ]) + + for (const ev of fromNip50) { + push(ev, true) + if (out.length >= limit) return out.slice(0, limit) + } + for (const ev of fromBroad) { + push(ev, true) + if (out.length >= limit) break + } + + return out.slice(0, limit) +} + /** * 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). @@ -70,6 +153,11 @@ export async function searchEventsForPicker( const kindsList = kindFilter && kindFilter.length > 0 ? [...kindFilter] : mode === 'nevent' ? [...NEVENT_KINDS] : [...NADDR_KINDS] + + if (isCitationOnlyKindFilter(kindFilter)) { + return searchCitationEventsForPickerInternal(q, limit, kindsList) + } + const seen = new Set() const out: NEvent[] = []