Browse Source

bug-fixes

imwald
Silberengel 2 weeks ago
parent
commit
59da76683a
  1. 223
      src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
  2. 5
      src/i18n/locales/de.ts
  3. 5
      src/i18n/locales/en.ts
  4. 71
      src/lib/citation-picker-relays.ts
  5. 65
      src/lib/citation-picker-search.ts
  6. 28
      src/services/client-events.service.ts
  7. 98
      src/services/indexed-db.service.ts
  8. 92
      src/services/mention-event-search.service.ts

223
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -21,7 +21,7 @@ import { @@ -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 { @@ -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' @@ -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({ @@ -149,6 +225,9 @@ export default function AdvancedEventLabDialog({
const markupView = useRef<EditorView | null>(null)
const sliceRef = useRef<AdvancedEventLabSlice | null>(null)
const draftPersistenceKeyRef = useRef<string | null>(null)
const labUndoAnonIdRef = useRef<string | null>(null)
const labCheckpointsRef = useRef<AdvancedEventLabSlice[]>([])
const [undoUiTick, setUndoUiTick] = useState(0)
const labPersistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const previewDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const schedulePreviewUpdateRef = useRef<(text: string) => void>(() => {})
@ -191,6 +270,21 @@ export default function AdvancedEventLabDialog({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -676,6 +884,17 @@ export default function AdvancedEventLabDialog({
{t('Advanced lab use translation read aloud')}
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
disabled={!canUndoCheckpoint}
title={t('Advanced lab undo checkpoint hint')}
onClick={handleUndoCheckpoint}
>
<Undo2 className="h-4 w-4 mr-1 inline" />
{t('Advanced lab undo checkpoint')}
</Button>
</div>
</div>

5
src/i18n/locales/de.ts

@ -957,6 +957,11 @@ export default { @@ -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',

5
src/i18n/locales/en.ts

@ -958,6 +958,11 @@ export default { @@ -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',

71
src/lib/citation-picker-relays.ts

@ -0,0 +1,71 @@ @@ -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 3033) 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<string[]> {
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)
}

65
src/lib/citation-picker-search.ts

@ -0,0 +1,65 @@ @@ -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
}

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

@ -26,6 +26,7 @@ import { @@ -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 { @@ -512,6 +513,33 @@ export class EventService {
return results
}
/**
* Session cache: NIP-32 citation kinds (3033) 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<number>([
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.

98
src/services/indexed-db.service.ts

@ -8,6 +8,7 @@ import { TNip66RelayDiscovery, TRelayInfo } from '@/types' @@ -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 { @@ -1407,6 +1408,103 @@ class IndexedDbService {
return [...fromPub, ...rest].slice(0, limit)
}
/**
* Publication store + {@link StoreNames.EVENT_ARCHIVE}: citation kinds (3033) 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<Event[]> {
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<void>((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<IDBCursorWithValue>).result
if (!cursor || fromPub.length >= limit) {
transaction.commit()
resolve()
return
}
const item = cursor.value as TValue<Event> | 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<void>((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<IDBCursorWithValue>).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<Array<{ key: string; value: any; addedAt: number; nestedCount?: number }>> {
// For publication stores, only return master events with nested counts
await this.initPromise

92
src/services/mention-event-search.service.ts

@ -3,10 +3,14 @@ @@ -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 = [ @@ -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<NEvent[]> {
const seen = new Set<string>()
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( @@ -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<string>()
const out: NEvent[] = []

Loading…
Cancel
Save