From 62b24a53e86b48c87391e17116199d4ff6cc3749 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 16 Apr 2026 09:44:06 +0200 Subject: [PATCH] add citation helper --- .../AdvancedEventLabMarkupToolbar.tsx | 65 ++++++- .../AdvancedLabCitationPickerDialog.tsx | 161 ++++++++++++++++++ .../Mention/NeventNaddrPickerDialog.tsx | 2 +- src/i18n/locales/de.ts | 15 ++ src/i18n/locales/en.ts | 15 ++ src/services/mention-event-search.service.ts | 45 +++-- 6 files changed, 288 insertions(+), 15 deletions(-) create mode 100644 src/components/AdvancedEventLab/AdvancedLabCitationPickerDialog.tsx diff --git a/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx b/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx index c5526fef..c727ae2a 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx @@ -1,4 +1,8 @@ import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' +import { + AdvancedLabCitationPickerDialog, + type LabCitationDisplayType +} from '@/components/AdvancedEventLab/AdvancedLabCitationPickerDialog' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -14,6 +18,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' import type { EditorView } from '@codemirror/view' import { Anchor, + BookMarked, Braces, ChevronDown, Code2, @@ -34,7 +39,7 @@ import { Volume2 } from 'lucide-react' import type { MutableRefObject } from 'react' -import { useMemo, useState } from 'react' +import { Fragment, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { labInsertRaw, @@ -94,6 +99,16 @@ const CODE_LANGUAGES = [ 'protobuf' ] as const +const LAB_CITATION_MENU_ITEMS: { type: LabCitationDisplayType; labelKey: string }[] = [ + { type: 'inline', labelKey: 'Advanced lab citation type inline' }, + { type: 'quote', labelKey: 'Advanced lab citation type quote' }, + { type: 'end', labelKey: 'Advanced lab citation type end' }, + { type: 'foot', labelKey: 'Advanced lab citation type foot' }, + { type: 'foot-end', labelKey: 'Advanced lab citation type footEnd' }, + { type: 'prompt-inline', labelKey: 'Advanced lab citation type promptInline' }, + { type: 'prompt-end', labelKey: 'Advanced lab citation type promptEnd' } +] + export type AdvancedEventLabMarkupToolbarProps = { markupMode: 'markdown' | 'asciidoc' viewRef: MutableRefObject @@ -108,6 +123,13 @@ export function AdvancedEventLabMarkupToolbar({ const { t } = useTranslation() const [codeFilter, setCodeFilter] = useState('') const [langFilter, setLangFilter] = useState('') + const [citationPickerOpen, setCitationPickerOpen] = useState(false) + const [citationDisplayType, setCitationDisplayType] = useState('inline') + + const openCitationPicker = (displayType: LabCitationDisplayType) => { + setCitationDisplayType(displayType) + setCitationPickerOpen(true) + } const filteredLangs = useMemo(() => { const q = codeFilter.trim().toLowerCase() @@ -121,6 +143,37 @@ export function AdvancedEventLabMarkupToolbar({ fn(v) } + const citationPicker = ( + + run((v) => labInsertRawWithOptionalBlockLeadNl(v, sliceRef, `${macro}\n`)) + } + /> + ) + + const citationDropdown = ( + + + + + + {t('Advanced lab tb citationsHint')} + {LAB_CITATION_MENU_ITEMS.map(({ type, labelKey }) => ( + openCitationPicker(type)}> + {t(labelKey)} + + ))} + + + ) + /** Contiguous document header per https://docs.asciidoctor.org/asciidoc/latest/document/header/ (no blank lines until after the last header line). */ const adocInsertFullHeader = (titleLine: string) => { run((v) => @@ -152,6 +205,7 @@ export function AdvancedEventLabMarkupToolbar({ if (markupMode === 'markdown') { return ( +
{t('Advanced lab tb markup tools')} @@ -314,6 +368,8 @@ export function AdvancedEventLabMarkupToolbar({ + {citationDropdown} +
+ {citationPicker} +
) } /* AsciiDoc */ return ( +
{t('Advanced lab tb markup tools')} @@ -713,6 +772,8 @@ export function AdvancedEventLabMarkupToolbar({ + {citationDropdown} +
+ {citationPicker} +
) } diff --git a/src/components/AdvancedEventLab/AdvancedLabCitationPickerDialog.tsx b/src/components/AdvancedEventLab/AdvancedLabCitationPickerDialog.tsx new file mode 100644 index 00000000..42d4dcbb --- /dev/null +++ b/src/components/AdvancedEventLab/AdvancedLabCitationPickerDialog.tsx @@ -0,0 +1,161 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { SimpleUsername } from '@/components/Username' +import { getNoteBech32Id } from '@/lib/event' +import { CITATION_PICKER_KINDS, searchCitationEventsForPicker } from '@/services/mention-event-search.service' +import client from '@/services/client.service' +import { Search } from 'lucide-react' +import { nip19, type Event as NostrEvent } from 'nostr-tools' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +/** Matches {@link MarkdownArticle} / AsciiDoc citation token `[[citation::type::…]]`. */ +export type LabCitationDisplayType = + | 'end' + | 'foot' + | 'foot-end' + | 'inline' + | 'quote' + | 'prompt-end' + | 'prompt-inline' + +export function buildCitationWikiMacro(displayType: LabCitationDisplayType, nostrLink: string): string { + let id = nostrLink.trim() + if (id.toLowerCase().startsWith('nostr:')) id = id.slice(6) + return `[[citation::${displayType}::${id}]]` +} + +function isCitationKind(kind: number): boolean { + return (CITATION_PICKER_KINDS as readonly number[]).includes(kind) +} + +export function AdvancedLabCitationPickerDialog({ + open, + onOpenChange, + displayType, + onInsertMacro +}: { + open: boolean + onOpenChange: (open: boolean) => void + displayType: LabCitationDisplayType + onInsertMacro: (macro: string) => void +}) { + const { t } = useTranslation() + const [query, setQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!open) return + setQuery('') + setDebouncedQuery('') + setEvents([]) + }, [open]) + + useEffect(() => { + if (!open) return + const timer = setTimeout(() => setDebouncedQuery(query.trim()), 300) + return () => clearTimeout(timer) + }, [open, query]) + + useEffect(() => { + if (!open || !debouncedQuery) { + setEvents([]) + setLoading(false) + return + } + let cancelled = false + setLoading(true) + void searchCitationEventsForPicker(debouncedQuery, 20) + .then((list) => { + if (cancelled) return + setEvents(list.filter((ev) => isCitationKind(ev.kind)).slice(0, 15)) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [open, debouncedQuery]) + + const handleSelect = useCallback( + (event: NostrEvent) => { + if (!isCitationKind(event.kind)) return + client.addEventToCache(event) + try { + const bech32 = getNoteBech32Id(event) + const link = `nostr:${bech32}` + onInsertMacro(buildCitationWikiMacro(displayType, link)) + onOpenChange(false) + } catch { + onInsertMacro(buildCitationWikiMacro(displayType, `nostr:${nip19.noteEncode(event.id)}`)) + onOpenChange(false) + } + }, + [displayType, onInsertMacro, onOpenChange] + ) + + return ( + + + + {t('Advanced lab citation dialog title')} +

{t('Advanced lab citation dialog hint')}

+
+
+ + setQuery(e.target.value)} + className="pl-9" + autoFocus + /> +
+
+
+ {loading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + {!loading && debouncedQuery && events.length === 0 && ( +

{t('Advanced lab citation none')}

+ )} + {!loading && + events.map((ev) => ( + + ))} +
+
+
+
+ ) +} diff --git a/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx b/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx index 301ea75a..4673ae73 100644 --- a/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx @@ -65,7 +65,7 @@ function NeventNaddrPickerDialog({ } let cancelled = false setLoading(true) - searchEventsForPicker(debouncedQuery, 20, mode) + searchEventsForPicker(debouncedQuery, 20, mode, undefined) .then((list) => { if (cancelled) return setEvents(list.slice(0, 15) as NEvent[]) diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 8d5b72ed..f10dcbf2 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1014,6 +1014,21 @@ export default { 'Advanced lab tb image': 'Bild ![alt](URL)', 'Advanced lab tb imageTitled': 'Bild mit Titel ![alt](URL "Titel")', 'Advanced lab tb hardBreak': 'Harter Zeilenumbruch (zwei Leerzeichen + Zeilenumbruch)', + 'Advanced lab tb citations': 'Zitate (NIP-32)', + 'Advanced lab tb citationsHint': 'Kinds 30–33. Fügt `[[citation::typ::nevent…]]` ein (Markdown & AsciiDoc).', + 'Advanced lab citation dialog title': 'Zitation einfügen', + 'Advanced lab citation dialog hint': + 'NIP-32-Zitations-Events suchen (intern, extern, Hardcopy, Prompt) und auswählen.', + 'Advanced lab citation search placeholder': 'Kinds 30–33 durchsuchen…', + 'Advanced lab citation none': 'Keine Zitations-Events gefunden', + 'Advanced lab citation kindLabel': 'Kind {{kind}}', + 'Advanced lab citation type inline': 'Inline-Zitation', + 'Advanced lab citation type quote': 'Zitat (Block)', + 'Advanced lab citation type end': 'Endnoten-Zitation', + 'Advanced lab citation type foot': 'Fußnotenanker', + 'Advanced lab citation type footEnd': 'Fußnote + Ende', + 'Advanced lab citation type promptInline': 'Prompt (inline)', + 'Advanced lab citation type promptEnd': 'Prompt (Ende)', 'Advanced lab tb lists': 'Listen', 'Advanced lab tb bulletList': 'Aufzählung (-)', 'Advanced lab tb bulletListStar': 'Aufzählung (*)', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 91f57c1a..58102fe3 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1015,6 +1015,21 @@ export default { 'Advanced lab tb image': 'Image ![alt](url)', 'Advanced lab tb imageTitled': 'Image with title ![alt](url "title")', 'Advanced lab tb hardBreak': 'Hard line break (two spaces + newline)', + 'Advanced lab tb citations': 'Citations (NIP-32)', + 'Advanced lab tb citationsHint': 'Kinds 30–33. Inserts `[[citation::type::nevent…]]` (Markdown & AsciiDoc).', + 'Advanced lab citation dialog title': 'Insert citation', + 'Advanced lab citation dialog hint': + 'Search for NIP-32 citation events (internal, external, hardcopy, prompt), then pick one.', + 'Advanced lab citation search placeholder': 'Search kinds 30–33…', + 'Advanced lab citation none': 'No citation events found', + 'Advanced lab citation kindLabel': 'Kind {{kind}}', + 'Advanced lab citation type inline': 'Inline citation', + 'Advanced lab citation type quote': 'Block quote citation', + 'Advanced lab citation type end': 'Endnote citation', + 'Advanced lab citation type foot': 'Footnote call', + 'Advanced lab citation type footEnd': 'Footnote + end', + 'Advanced lab citation type promptInline': 'Prompt (inline)', + 'Advanced lab citation type promptEnd': 'Prompt (end)', 'Advanced lab tb lists': 'Lists', 'Advanced lab tb bulletList': 'Bullet list (-)', 'Advanced lab tb bulletListStar': 'Bullet list (*)', diff --git a/src/services/mention-event-search.service.ts b/src/services/mention-event-search.service.ts index f0711cb6..0f3521ec 100644 --- a/src/services/mention-event-search.service.ts +++ b/src/services/mention-event-search.service.ts @@ -17,20 +17,28 @@ export const MENTION_NPUB_DROPDOWN_LIMIT = 50 /** Kinds for nevent search: notes, threads, long-form, etc. */ export const NEVENT_KINDS = [ kinds.ShortTextNote, - ExtendedKind.PICTURE, - ExtendedKind.VIDEO, - ExtendedKind.SHORT_VIDEO, + ExtendedKind.PICTURE, + ExtendedKind.VIDEO, + ExtendedKind.SHORT_VIDEO, ExtendedKind.POLL, ExtendedKind.ZAP_POLL, - ExtendedKind.COMMENT, - ExtendedKind.VOICE, - ExtendedKind.VOICE_COMMENT, - ExtendedKind.PUBLIC_MESSAGE, + ExtendedKind.COMMENT, + ExtendedKind.VOICE, + ExtendedKind.VOICE_COMMENT, + ExtendedKind.PUBLIC_MESSAGE, ExtendedKind.DISCUSSION, - ExtendedKind.CITATION_INTERNAL, - ExtendedKind.CITATION_EXTERNAL, - ExtendedKind.CITATION_HARDCOPY, - ExtendedKind.CITATION_PROMPT, + ExtendedKind.CITATION_INTERNAL, + ExtendedKind.CITATION_EXTERNAL, + ExtendedKind.CITATION_HARDCOPY, + ExtendedKind.CITATION_PROMPT +] as const + +/** NIP-32 citation events only (Advanced lab citation picker, etc.). */ +export const CITATION_PICKER_KINDS = [ + ExtendedKind.CITATION_INTERNAL, + ExtendedKind.CITATION_EXTERNAL, + ExtendedKind.CITATION_HARDCOPY, + ExtendedKind.CITATION_PROMPT ] as const /** Kinds for naddr search: calendar, publications, wiki, etc. */ @@ -49,16 +57,19 @@ 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). + * @param kindFilter - When set, only these kinds are searched (overrides `mode` for the kinds list). */ export async function searchEventsForPicker( query: string, limit: number = DEFAULT_NOTES_LIMIT, - mode: PickerSearchMode = 'nevent' + mode: PickerSearchMode = 'nevent', + kindFilter?: readonly number[] ): Promise { const q = query.trim() if (!q) return [] - const kindsList = mode === 'nevent' ? [...NEVENT_KINDS] : [...NADDR_KINDS] + const kindsList = + kindFilter && kindFilter.length > 0 ? [...kindFilter] : mode === 'nevent' ? [...NEVENT_KINDS] : [...NADDR_KINDS] const seen = new Set() const out: NEvent[] = [] @@ -85,6 +96,14 @@ export async function searchEventsForPicker( return out.slice(0, limit) } +/** Search only NIP-32 citation events (kinds 30–33). */ +export async function searchCitationEventsForPicker( + query: string, + limit: number = DEFAULT_NOTES_LIMIT +): Promise { + return searchEventsForPicker(query, limit, 'nevent', CITATION_PICKER_KINDS) +} + /** * @deprecated Use searchEventsForPicker(query, limit, 'nevent') instead. */