Browse Source

fix 30817 and allow full kind edits on new pots

Make advanced editor to include tag editos
Get rid of json editing
imwald
Silberengel 3 weeks ago
parent
commit
1ba19ef3de
  1. 65
      src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
  2. 143
      src/components/AdvancedEventLab/AdvancedEventLabTagsEditor.tsx
  3. 2
      src/components/KindFilter/index.tsx
  4. 2
      src/components/Note/Highlight/index.tsx
  5. 4
      src/components/Note/index.tsx
  6. 32
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  7. 6
      src/components/NoteOptions/useMenuActions.tsx
  8. 322
      src/components/PostEditor/PostContent.tsx
  9. 18
      src/components/PostEditor/PostTextarea/Preview.tsx
  10. 152
      src/components/PostEditor/PostTextarea/index.tsx
  11. 2
      src/components/ReplyNoteList/index.tsx
  12. 6
      src/components/WebPreview/index.tsx
  13. 15
      src/constants.ts
  14. 8
      src/i18n/locales/cs.ts
  15. 8
      src/i18n/locales/de.ts
  16. 25
      src/i18n/locales/en.ts
  17. 8
      src/i18n/locales/es.ts
  18. 8
      src/i18n/locales/fr.ts
  19. 8
      src/i18n/locales/nl.ts
  20. 8
      src/i18n/locales/pl.ts
  21. 8
      src/i18n/locales/ru.ts
  22. 8
      src/i18n/locales/tr.ts
  23. 8
      src/i18n/locales/zh.ts
  24. 4
      src/lib/advanced-event-lab-slice.ts
  25. 26
      src/lib/composer-extra-tags.test.ts
  26. 25
      src/lib/composer-extra-tags.ts
  27. 13
      src/lib/draft-event.ts
  28. 4
      src/lib/kind-description.ts
  29. 2
      src/lib/link.ts
  30. 2
      src/lib/markup-detection.ts
  31. 2
      src/lib/merged-search-note-preview.ts
  32. 24
      src/lib/nostr-spec-affected-kinds.test.ts
  33. 20
      src/lib/nostr-spec-affected-kinds.ts
  34. 2
      src/lib/read-aloud.ts
  35. 10
      src/pages/secondary/NotePage/index.tsx
  36. 2
      src/services/client-events.service.ts
  37. 4
      src/services/indexed-db.service.ts
  38. 6
      src/services/local-storage.service.ts
  39. 2
      src/services/mention-event-search.service.ts

65
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -53,6 +53,12 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { AdvancedEventLabMarkupToolbar } from './AdvancedEventLabMarkupToolbar' import { AdvancedEventLabMarkupToolbar } from './AdvancedEventLabMarkupToolbar'
import { AdvancedEventLabPreviewPane } from './AdvancedEventLabPreviewPane' import { AdvancedEventLabPreviewPane } from './AdvancedEventLabPreviewPane'
import AdvancedEventLabTagsEditor, {
editableRowsToLabTags,
labTagsToEditableRows
} from './AdvancedEventLabTagsEditor'
import { newComposerTagRow, type ComposerExtraTagRow } from '@/lib/composer-extra-tags'
import { stripImwaldAttributionTags } from '@/lib/draft-event'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
import postEditorCache from '@/services/post-editor-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
import type { TEmoji } from '@/types' import type { TEmoji } from '@/types'
@ -182,8 +188,7 @@ export type AdvancedEventLabDialogProps = {
formatToolbar?: ReactNode formatToolbar?: ReactNode
/** /**
* When set, lab markup/tags are debounced to the post-editor draft store (same persistence as TipTap) * When set, lab markup/tags are debounced to the post-editor draft store (same persistence as TipTap)
* so a **reload** can restore in-progress lab work. Closing without Apply (dismiss actions, including the cancel button, Escape, overlay) * so a **reload** can restore in-progress lab work. The draft is kept on dismiss; clear happens on publish or composer Clear.
* clears this draft so the next open is seeded from TipTap again.
*/ */
draftPersistenceKey?: string | null draftPersistenceKey?: string | null
/** Lab preview: resolve custom `:shortcode:` from this author's NIP-30 inventory when tags do not define them. */ /** Lab preview: resolve custom `:shortcode:` from this author's NIP-30 inventory when tags do not define them. */
@ -239,13 +244,12 @@ export default function AdvancedEventLabDialog({
const labPersistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const labPersistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const previewDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const previewDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const schedulePreviewUpdateRef = useRef<(text: string) => void>(() => {}) const schedulePreviewUpdateRef = useRef<(text: string) => void>(() => {})
/** When true, closing is from Apply (draft already cleared); skip discard cleanup. */
const skipClearLabDraftOnCloseRef = useRef(false)
/** Debounce writes to the draft map; pagehide/beforeunload flush immediately to disk. */ /** Debounce writes to the draft map; pagehide/beforeunload flush immediately to disk. */
const LAB_DRAFT_DEBOUNCE_MS = 500 const LAB_DRAFT_DEBOUNCE_MS = 500
const [previewDoc, setPreviewDoc] = useState('') const [previewDoc, setPreviewDoc] = useState('')
const [labBodyTab, setLabBodyTab] = useState<'edit' | 'preview'>('edit') const [labBodyTab, setLabBodyTab] = useState<'edit' | 'preview'>('edit')
const [labTagRows, setLabTagRows] = useState<ComposerExtraTagRow[]>(() => [newComposerTagRow()])
/** Stable while payload matches; avoids remounting the editor when the parent passes a new `initial` object reference. */ /** Stable while payload matches; avoids remounting the editor when the parent passes a new `initial` object reference. */
const labEditorMountFingerprint = const labEditorMountFingerprint =
@ -358,21 +362,14 @@ export default function AdvancedEventLabDialog({
const handleDialogOpenChange = useCallback( const handleDialogOpenChange = useCallback(
(next: boolean) => { (next: boolean) => {
if (!next) { if (!next) {
if (!skipClearLabDraftOnCloseRef.current) { const key = draftPersistenceKeyRef.current
if (labPersistTimerRef.current) { if (key) {
clearTimeout(labPersistTimerRef.current) flushLabDraftNow(key, true)
labPersistTimerRef.current = null
}
const key = draftPersistenceKeyRef.current
if (key) {
postEditorCache.clearAdvancedLabDraft(key)
}
} }
skipClearLabDraftOnCloseRef.current = false
} }
onOpenChange(next) onOpenChange(next)
}, },
[onOpenChange] [onOpenChange, flushLabDraftNow]
) )
const scheduleLabDraftPersist = useCallback(() => { const scheduleLabDraftPersist = useCallback(() => {
@ -395,6 +392,18 @@ export default function AdvancedEventLabDialog({
}, LAB_DRAFT_DEBOUNCE_MS) }, LAB_DRAFT_DEBOUNCE_MS)
}, []) }, [])
const syncLabTagsFromRows = useCallback(
(rows: ComposerExtraTagRow[]) => {
setLabTagRows(rows)
const s = sliceRef.current
if (!s) return
s.tags = editableRowsToLabTags(rows)
scheduleLabDraftPersist()
bumpUndoUi()
},
[scheduleLabDraftPersist, bumpUndoUi]
)
const restoreSliceInEditor = useCallback( const restoreSliceInEditor = useCallback(
(slice: AdvancedEventLabSlice) => { (slice: AdvancedEventLabSlice) => {
const v = markupView.current const v = markupView.current
@ -403,7 +412,13 @@ export default function AdvancedEventLabDialog({
changes: { from: 0, to: v.state.doc.length, insert: slice.content }, changes: { from: 0, to: v.state.doc.length, insert: slice.content },
selection: EditorSelection.cursor(0) selection: EditorSelection.cursor(0)
}) })
sliceRef.current = cloneLabSlice(slice) const editable = stripImwaldAttributionTags(slice.tags)
sliceRef.current = {
kind: slice.kind,
content: slice.content,
tags: editable.map((row) => [...row])
}
setLabTagRows(labTagsToEditableRows(editable))
setPreviewDoc(slice.content) setPreviewDoc(slice.content)
scheduleLabDraftPersist() scheduleLabDraftPersist()
if (isLanguageToolConfigured()) requestAdvancedLabGrammarLint(v) if (isLanguageToolConfigured()) requestAdvancedLabGrammarLint(v)
@ -614,12 +629,14 @@ export default function AdvancedEventLabDialog({
destroyEditors() destroyEditors()
const editableTags = stripImwaldAttributionTags(initial.tags)
const baseSlice: AdvancedEventLabSlice = { const baseSlice: AdvancedEventLabSlice = {
kind: initial.kind, kind: initial.kind,
content: initial.content, content: initial.content,
tags: initial.tags.map((row) => [...row]) tags: editableTags.map((row) => [...row])
} }
sliceRef.current = baseSlice sliceRef.current = baseSlice
setLabTagRows(labTagsToEditableRows(editableTags))
setPreviewDoc(baseSlice.content) setPreviewDoc(baseSlice.content)
const markupLang: Extension = const markupLang: Extension =
@ -799,13 +816,14 @@ export default function AdvancedEventLabDialog({
const payload: AdvancedEventLabSlice = { const payload: AdvancedEventLabSlice = {
kind, kind,
content, content,
tags: s.tags.map((row) => [...row]) tags: editableRowsToLabTags(labTagRows)
} }
skipClearLabDraftOnCloseRef.current = true const key = draftPersistenceKeyRef.current
onApply(payload) if (key) {
if (draftPersistenceKeyRef.current) { postEditorCache.setAdvancedLabDraft(key, payload)
postEditorCache.clearAdvancedLabDraft(draftPersistenceKeyRef.current) postEditorCache.flushPersist()
} }
onApply(payload)
if (undoSessionId) { if (undoSessionId) {
clearLabCheckpointsSession(undoSessionId) clearLabCheckpointsSession(undoSessionId)
labCheckpointsRef.current = [] labCheckpointsRef.current = []
@ -947,7 +965,8 @@ export default function AdvancedEventLabDialog({
<div className="mt-2 border-t bg-muted/20 px-2 py-2">{formatToolbar}</div> <div className="mt-2 border-t bg-muted/20 px-2 py-2">{formatToolbar}</div>
) : null} ) : null}
<div className="mt-2 border-t bg-background px-4 py-3"> <div className="mt-2 border-t bg-background px-4 py-3 space-y-3">
<AdvancedEventLabTagsEditor rows={labTagRows} onChange={syncLabTagsFromRows} />
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={() => handleDialogOpenChange(false)}> <Button type="button" variant="outline" onClick={() => handleDialogOpenChange(false)}>
{t('Advanced lab cancel undo')} {t('Advanced lab cancel undo')}

143
src/components/AdvancedEventLab/AdvancedEventLabTagsEditor.tsx

@ -0,0 +1,143 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@/components/ui/collapsible'
import {
formatComposerTagValuesInput,
newComposerTagRow,
normalizeComposerExtraTags,
parseComposerTagValuesInput,
type ComposerExtraTagRow
} from '@/lib/composer-extra-tags'
import { cn } from '@/lib/utils'
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export function labTagsToEditableRows(tags: string[][]): ComposerExtraTagRow[] {
const normalized = tags.filter((t) => Array.isArray(t) && String(t[0] ?? '').trim())
if (!normalized.length) return [newComposerTagRow()]
return normalized.map((tag) => newComposerTagRow([...tag]))
}
export function editableRowsToLabTags(rows: ComposerExtraTagRow[]): string[][] {
return normalizeComposerExtraTags(rows)
}
export default function AdvancedEventLabTagsEditor({
rows,
onChange,
className
}: {
rows: ComposerExtraTagRow[]
onChange: (rows: ComposerExtraTagRow[]) => void
className?: string
}) {
const { t } = useTranslation()
const updateRow = (id: string, patch: Partial<{ name: string; valuesRaw: string }>) => {
onChange(
rows.map((row) => {
if (row.id !== id) return row
const name = patch.name !== undefined ? patch.name : (row.tag[0] ?? '')
const valuesRaw =
patch.valuesRaw !== undefined ? patch.valuesRaw : formatComposerTagValuesInput(row.tag)
const vals = parseComposerTagValuesInput(valuesRaw)
return {
...row,
tag: name.trim() ? [name.trim(), ...vals] : vals.length ? ['', ...vals] : ['', '']
}
})
)
}
const removeRow = (id: string) => {
const next = rows.filter((row) => row.id !== id)
onChange(next.length > 0 ? next : [newComposerTagRow()])
}
const addRow = () => {
onChange([...rows, newComposerTagRow()])
}
const filledCount = rows.filter((r) => (r.tag[0] ?? '').trim()).length
return (
<Collapsible
defaultOpen={filledCount > 0}
className={cn('rounded-lg border bg-muted/30', className)}
>
<CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm font-medium hover:bg-muted/50 rounded-lg">
<ChevronDown className="h-4 w-4 shrink-0 transition-transform [[data-state=open]_&]:rotate-180" />
<span className="flex-1">{t('advancedLabTagsTitle', { defaultValue: 'Event tags' })}</span>
<span className="text-xs font-normal text-muted-foreground">
{t('advancedLabTagsCount', {
defaultValue: '{{count}} tags',
count: filledCount
})}
</span>
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-3 pt-0 space-y-3">
<p className="text-xs text-muted-foreground leading-snug">
{t('advancedLabTagsHint', {
defaultValue:
'All tags except the client tag. One value per line for multi-value tags. Changes are saved automatically.'
})}
</p>
<div className="space-y-2 max-h-[min(40vh,20rem)] overflow-y-auto pr-1">
{rows.map((row) => {
const name = row.tag[0] ?? ''
const valuesRaw = formatComposerTagValuesInput(row.tag)
return (
<div
key={row.id}
className="flex flex-wrap gap-2 items-start min-w-0 rounded-md border border-border/60 bg-background/50 p-2"
>
<div className="w-full min-w-[5rem] sm:w-28 shrink-0">
<Label className="sr-only">{t('Tag name')}</Label>
<Input
value={name}
placeholder={t('Tag name')}
className="font-mono text-xs h-8"
onChange={(e) => updateRow(row.id, { name: e.target.value })}
/>
</div>
<div className="flex-1 min-w-[8rem]">
<Label className="sr-only">{t('Values')}</Label>
<Textarea
value={valuesRaw}
placeholder={t('advancedLabTagValuesPlaceholder', {
defaultValue: 'One value per line'
})}
className="font-mono text-xs min-h-[2.25rem] resize-y"
rows={2}
onChange={(e) => updateRow(row.id, { valuesRaw: e.target.value })}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive mt-0.5"
disabled={rows.length <= 1}
onClick={() => removeRow(row.id)}
aria-label={t('Remove')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})}
</div>
<Button type="button" variant="outline" size="sm" className="gap-1" onClick={addRow}>
<Plus className="h-3.5 w-3.5" />
{t('advancedLabTagsAdd', { defaultValue: 'Add tag' })}
</Button>
</CollapsibleContent>
</Collapsible>
)
}

2
src/components/KindFilter/index.tsx

@ -18,7 +18,7 @@ const KIND_1 = kinds.ShortTextNote
const KIND_1111 = ExtendedKind.COMMENT const KIND_1111 = ExtendedKind.COMMENT
const KIND_FILTER_OPTIONS = [ const KIND_FILTER_OPTIONS = [
{ kindGroup: [kinds.LongFormArticle, ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE_MARKDOWN], label: 'Articles' }, { kindGroup: [kinds.LongFormArticle, ExtendedKind.WIKI_ARTICLE, ExtendedKind.NOSTR_SPECIFICATION], label: 'Articles' },
{ kindGroup: [kinds.Highlights], label: 'Highlights' }, { kindGroup: [kinds.Highlights], label: 'Highlights' },
{ kindGroup: [ExtendedKind.POLL], label: 'Polls' }, { kindGroup: [ExtendedKind.POLL], label: 'Polls' },
{ kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' }, { kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' },

2
src/components/Note/Highlight/index.tsx

@ -285,7 +285,7 @@ export default function Highlight({
ExtendedKind.PICTURE, // Has PictureNotePreview ExtendedKind.PICTURE, // Has PictureNotePreview
ExtendedKind.PUBLICATION, // Has PublicationCard ExtendedKind.PUBLICATION, // Has PublicationCard
ExtendedKind.WIKI_ARTICLE, // Has special card ExtendedKind.WIKI_ARTICLE, // Has special card
ExtendedKind.WIKI_ARTICLE_MARKDOWN, // Has special card ExtendedKind.NOSTR_SPECIFICATION, // Has special card
ExtendedKind.VOICE, // Has special card ExtendedKind.VOICE, // Has special card
ExtendedKind.VOICE_COMMENT, // Has special card ExtendedKind.VOICE_COMMENT, // Has special card
] ]

4
src/components/Note/index.tsx

@ -305,7 +305,7 @@ export default function Note({
event.kind === kinds.ShortTextNote || event.kind === kinds.ShortTextNote ||
event.kind === kinds.LongFormArticle || event.kind === kinds.LongFormArticle ||
event.kind === ExtendedKind.WIKI_ARTICLE || event.kind === ExtendedKind.WIKI_ARTICLE ||
event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || event.kind === ExtendedKind.NOSTR_SPECIFICATION ||
event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION ||
event.kind === ExtendedKind.PUBLICATION_CONTENT || event.kind === ExtendedKind.PUBLICATION_CONTENT ||
event.kind === ExtendedKind.DISCUSSION || event.kind === ExtendedKind.DISCUSSION ||
@ -458,7 +458,7 @@ export default function Note({
) : ( ) : (
<WikiCard className="mt-2" event={displayEvent} /> <WikiCard className="mt-2" event={displayEvent} />
) )
} else if (event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { } else if (event.kind === ExtendedKind.NOSTR_SPECIFICATION) {
content = showFull ? ( content = showFull ? (
renderEventContent() renderEventContent()
) : ( ) : (

32
src/components/NoteOptions/EditOrCloneEventDialog.tsx

@ -103,7 +103,7 @@ function StaticEventPreview({ event, className }: { event: Event; className?: st
if (k === ExtendedKind.WIKI_ARTICLE) { if (k === ExtendedKind.WIKI_ARTICLE) {
return wrap(<AsciidocArticle event={event} hideImagesAndInfo={false} />) return wrap(<AsciidocArticle event={event} hideImagesAndInfo={false} />)
} }
if (k === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { if (k === ExtendedKind.NOSTR_SPECIFICATION) {
return wrap(<MarkdownArticle event={event} hideMetadata />) return wrap(<MarkdownArticle event={event} hideMetadata />)
} }
if (k === ExtendedKind.PUBLICATION_CONTENT) { if (k === ExtendedKind.PUBLICATION_CONTENT) {
@ -368,13 +368,23 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
const openAdvancedLab = useCallback(() => { const openAdvancedLab = useCallback(() => {
if (isCreate && parsedCreateKind === null) return if (isCreate && parsedCreateKind === null) return
const k = isCreate ? parsedCreateKind! : sourceEvent!.kind const k = isCreate ? parsedCreateKind! : sourceEvent!.kind
setAdvancedLabInitial({ const key = advancedLabDraftPersistenceKey
kind: k, const saved = key ? postEditorCache.getAdvancedLabDraft(key) : undefined
content, if (saved && saved.kind === k) {
tags: normalizedTags.map((row) => [...row]) setAdvancedLabInitial({
}) kind: saved.kind,
content: saved.content,
tags: saved.tags.map((row) => [...row])
})
} else {
setAdvancedLabInitial({
kind: k,
content,
tags: normalizedTags.map((row) => [...row])
})
}
setAdvancedLabOpen(true) setAdvancedLabOpen(true)
}, [isCreate, parsedCreateKind, sourceEvent, content, normalizedTags]) }, [isCreate, parsedCreateKind, sourceEvent, content, normalizedTags, advancedLabDraftPersistenceKey])
const labKind = isCreate ? (parsedCreateKind ?? 0) : sourceEvent?.kind ?? 0 const labKind = isCreate ? (parsedCreateKind ?? 0) : sourceEvent?.kind ?? 0
@ -630,6 +640,14 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
advancedLabOpen && advancedLabDraftPersistenceKey ? advancedLabDraftPersistenceKey : null advancedLabOpen && advancedLabDraftPersistenceKey ? advancedLabDraftPersistenceKey : null
} }
onApply={(payload) => { onApply={(payload) => {
if (advancedLabDraftPersistenceKey) {
postEditorCache.setAdvancedLabDraft(advancedLabDraftPersistenceKey, {
kind: payload.kind,
content: payload.content,
tags: payload.tags.map((r) => [...r])
})
postEditorCache.flushPersist()
}
setContent(payload.content) setContent(payload.content)
setTagRows(payload.tags.length > 0 ? payload.tags.map((r) => [...r]) : [['', '']]) setTagRows(payload.tags.length > 0 ? payload.tags.map((r) => [...r]) : [['', '']])
if (isCreate) { if (isCreate) {

6
src/components/NoteOptions/useMenuActions.tsx

@ -493,7 +493,7 @@ export function useMenuActions({
const isArticleType = useMemo(() => { const isArticleType = useMemo(() => {
return event.kind === kinds.LongFormArticle || return event.kind === kinds.LongFormArticle ||
event.kind === ExtendedKind.WIKI_ARTICLE || event.kind === ExtendedKind.WIKI_ARTICLE ||
event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || event.kind === ExtendedKind.NOSTR_SPECIFICATION ||
event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION ||
event.kind === ExtendedKind.PUBLICATION_CONTENT event.kind === ExtendedKind.PUBLICATION_CONTENT
}, [event.kind]) }, [event.kind])
@ -1067,7 +1067,7 @@ export function useMenuActions({
// Add export options for article-type events // Add export options for article-type events
if (isArticleType) { if (isArticleType) {
const isMarkdownFormat = event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN const isMarkdownFormat = event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.NOSTR_SPECIFICATION
const isAsciidocFormat = event.kind === ExtendedKind.WIKI_ARTICLE || event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT const isAsciidocFormat = event.kind === ExtendedKind.WIKI_ARTICLE || event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT
if (isMarkdownFormat) { if (isMarkdownFormat) {
@ -1115,7 +1115,7 @@ export function useMenuActions({
event.kind === ExtendedKind.PUBLICATION_CONTENT || event.kind === ExtendedKind.PUBLICATION_CONTENT ||
event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION ||
event.kind === ExtendedKind.WIKI_ARTICLE || event.kind === ExtendedKind.WIKI_ARTICLE ||
event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN event.kind === ExtendedKind.NOSTR_SPECIFICATION
) { ) {
// For 30041, 30040, 30818, 30817: Alexandria // For 30041, 30040, 30818, 30817: Alexandria
if (naddr) { if (naddr) {

322
src/components/PostEditor/PostContent.tsx

@ -28,15 +28,15 @@ import {
createVideoDraftEvent, createVideoDraftEvent,
createLongFormArticleDraftEvent, createLongFormArticleDraftEvent,
createWikiArticleDraftEvent, createWikiArticleDraftEvent,
createWikiArticleMarkdownDraftEvent, createNostrSpecificationDraftEvent,
createPublicationContentDraftEvent, createPublicationContentDraftEvent,
createCitationInternalDraftEvent, createCitationInternalDraftEvent,
createCitationExternalDraftEvent, createCitationExternalDraftEvent,
createCitationHardcopyDraftEvent, createCitationHardcopyDraftEvent,
createCitationPromptDraftEvent, createCitationPromptDraftEvent,
applyImwaldAttributionTags,
collectUploadImetaTagsForContentUrls, collectUploadImetaTagsForContentUrls,
mergeUploadImetaTagsInto mergeUploadImetaTagsInto,
stripImwaldAttributionTags
} from '@/lib/draft-event' } from '@/lib/draft-event'
import { import {
ExtendedKind, ExtendedKind,
@ -58,6 +58,8 @@ import {
Check, Check,
ChevronDown, ChevronDown,
ListTodo, ListTodo,
Plus,
Trash2,
MessageCircle, MessageCircle,
MessagesSquare, MessagesSquare,
X, X,
@ -108,6 +110,11 @@ import PollEditor from './PollEditor'
import PostOptions from './PostOptions' import PostOptions from './PostOptions'
import PostRelaySelector from './PostRelaySelector' import PostRelaySelector from './PostRelaySelector'
import PostTextarea, { TPostTextareaHandle } from './PostTextarea' import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
import {
newNostrSpecAffectedKindRow,
parseNostrSpecAffectedKinds,
type NostrSpecAffectedKindRow
} from '@/lib/nostr-spec-affected-kinds'
import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDialog' import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDialog'
import Uploader from './Uploader' import Uploader from './Uploader'
import HighlightEditor, { HighlightData } from './HighlightEditor' import HighlightEditor, { HighlightData } from './HighlightEditor'
@ -119,7 +126,7 @@ import {
PostEditorFormatToolbar, PostEditorFormatToolbar,
type PostEditorFormatToolbarUploadHandlers type PostEditorFormatToolbarUploadHandlers
} from './PostEditorFormatToolbar' } from './PostEditorFormatToolbar'
import { parseLabSlice, type AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice'
import { isAsciidocMarkupKind } from '@/lib/advanced-event-lab-kinds' import { isAsciidocMarkupKind } from '@/lib/advanced-event-lab-kinds'
/** Let the UI paint before heavy work. `requestAnimationFrame` alone can stall indefinitely in hidden or throttled documents. */ /** Let the UI paint before heavy work. `requestAnimationFrame` alone can stall indefinitely in hidden or throttled documents. */
@ -249,7 +256,6 @@ export default function PostContent({
) )
const [text, setText] = useState('') const [text, setText] = useState('')
const textareaRef = useRef<TPostTextareaHandle>(null) const textareaRef = useRef<TPostTextareaHandle>(null)
const labTagOverrideRef = useRef<string[][] | null>(null)
const [advancedLabOpen, setAdvancedLabOpen] = useState(false) const [advancedLabOpen, setAdvancedLabOpen] = useState(false)
const advancedLabOpenRef = useRef(false) const advancedLabOpenRef = useRef(false)
useEffect(() => { useEffect(() => {
@ -323,8 +329,11 @@ export default function PostContent({
const [mediaUrl, setMediaUrl] = useState<string>('') const [mediaUrl, setMediaUrl] = useState<string>('')
const [isLongFormArticle, setIsLongFormArticle] = useState(false) const [isLongFormArticle, setIsLongFormArticle] = useState(false)
const [isWikiArticle, setIsWikiArticle] = useState(false) const [isWikiArticle, setIsWikiArticle] = useState(false)
const [isWikiArticleMarkdown, setIsWikiArticleMarkdown] = useState(false) const [isNostrSpecification, setIsNostrSpecification] = useState(false)
const [isPublicationContent, setIsPublicationContent] = useState(false) const [isPublicationContent, setIsPublicationContent] = useState(false)
const [nostrSpecAffectedKindRows, setNostrSpecAffectedKindRows] = useState<NostrSpecAffectedKindRow[]>(
() => [newNostrSpecAffectedKindRow()]
)
const [articleTitle, setArticleTitle] = useState('') const [articleTitle, setArticleTitle] = useState('')
const [articleDTag, setArticleDTag] = useState('') const [articleDTag, setArticleDTag] = useState('')
const [articleImage, setArticleImage] = useState('') const [articleImage, setArticleImage] = useState('')
@ -385,11 +394,11 @@ export default function PostContent({
useEffect(() => { useEffect(() => {
const isArticle = const isArticle =
isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent isLongFormArticle || isWikiArticle || isNostrSpecification || isPublicationContent
if (!isArticle) { if (!isArticle) {
articleDTagFallbackRef.current = null articleDTagFallbackRef.current = null
} }
}, [isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, isPublicationContent]) }, [isLongFormArticle, isWikiArticle, isNostrSpecification, isPublicationContent])
useEffect(() => { useEffect(() => {
mediaNoteKindRef.current = mediaNoteKind mediaNoteKindRef.current = mediaNoteKind
@ -632,8 +641,8 @@ export default function PostContent({
return kinds.LongFormArticle return kinds.LongFormArticle
} else if (isWikiArticle) { } else if (isWikiArticle) {
return ExtendedKind.WIKI_ARTICLE return ExtendedKind.WIKI_ARTICLE
} else if (isWikiArticleMarkdown) { } else if (isNostrSpecification) {
return ExtendedKind.WIKI_ARTICLE_MARKDOWN return ExtendedKind.NOSTR_SPECIFICATION
} else if (isPublicationContent) { } else if (isPublicationContent) {
return ExtendedKind.PUBLICATION_CONTENT return ExtendedKind.PUBLICATION_CONTENT
} else if (isCitationInternal) { } else if (isCitationInternal) {
@ -659,7 +668,7 @@ export default function PostContent({
isDiscussionThread, isDiscussionThread,
isLongFormArticle, isLongFormArticle,
isWikiArticle, isWikiArticle,
isWikiArticleMarkdown, isNostrSpecification,
isPublicationContent, isPublicationContent,
isCitationInternal, isCitationInternal,
isCitationExternal, isCitationExternal,
@ -740,6 +749,48 @@ export default function PostContent({
return [['i', c], ['I', c]] return [['i', c], ['I', c]]
}, [parentEvent]) }, [parentEvent])
const articlePreviewMetadata = useMemo(() => {
const isArticle =
isLongFormArticle || isWikiArticle || isNostrSpecification || isPublicationContent
if (!isArticle) return undefined
const topics = articleSubject.trim()
? articleSubject.split(/[,\s]+/).filter((s) => s.trim())
: []
const base = {
dTag: articleDTag.trim() || undefined,
title: articleTitle.trim() || undefined,
summary: articleSummary.trim() || undefined,
topics: topics.length > 0 ? topics : undefined
}
if (isNostrSpecification) {
const affectedKinds = parseNostrSpecAffectedKinds(nostrSpecAffectedKindRows)
return {
...base,
affectedKinds: affectedKinds.length > 0 ? affectedKinds : undefined
}
}
return { ...base, image: articleImage.trim() || undefined }
}, [
isLongFormArticle,
isWikiArticle,
isNostrSpecification,
isPublicationContent,
articleDTag,
articleTitle,
articleSummary,
articleImage,
articleSubject,
nostrSpecAffectedKindRows
])
const mergedExtraPreviewTags = useMemo((): string[][] | undefined => {
const contextual =
isDiscussionThread && !parentEvent
? discussionPreviewExtraTags ?? []
: rssReplyExtraPreviewTags ?? []
return contextual.length ? contextual : undefined
}, [isDiscussionThread, parentEvent, discussionPreviewExtraTags, rssReplyExtraPreviewTags])
// Shared function to create draft event - used by both preview and posting // Shared function to create draft event - used by both preview and posting
const createDraftEvent = useCallback(async (cleanedText: string): Promise<any> => { const createDraftEvent = useCallback(async (cleanedText: string): Promise<any> => {
const uploadImetaTagsOpt = mediaImetaTags.length > 0 ? mediaImetaTags : undefined const uploadImetaTagsOpt = mediaImetaTags.length > 0 ? mediaImetaTags : undefined
@ -896,7 +947,7 @@ export default function PostContent({
// Articles // Articles
const isArticleDraft = const isArticleDraft =
isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent isLongFormArticle || isWikiArticle || isNostrSpecification || isPublicationContent
let effectiveArticleDTag = '' let effectiveArticleDTag = ''
if (isArticleDraft) { if (isArticleDraft) {
const trimmedDTag = articleDTag.trim() const trimmedDTag = articleDTag.trim()
@ -907,8 +958,8 @@ export default function PostContent({
? 'longform-article' ? 'longform-article'
: isWikiArticle : isWikiArticle
? 'wiki-article' ? 'wiki-article'
: isWikiArticleMarkdown : isNostrSpecification
? 'wiki-markdown' ? 'nostr-specification'
: 'publication-content' : 'publication-content'
const prev = articleDTagFallbackRef.current const prev = articleDTagFallbackRef.current
if (!prev || prev.slug !== slug) { if (!prev || prev.slug !== slug) {
@ -949,12 +1000,13 @@ export default function PostContent({
addQuietTag, addQuietTag,
quietDays quietDays
}) })
} else if (isWikiArticleMarkdown) { } else if (isNostrSpecification) {
return await createWikiArticleMarkdownDraftEvent(cleanedText, mentions, { const affectedKinds = parseNostrSpecAffectedKinds(nostrSpecAffectedKindRows)
return await createNostrSpecificationDraftEvent(cleanedText, mentions, {
dTag: effectiveArticleDTag, dTag: effectiveArticleDTag,
title: articleTitle.trim() || undefined, title: articleTitle.trim() || undefined,
summary: articleSummary.trim() || undefined, summary: articleSummary.trim() || undefined,
image: articleImage.trim() || undefined, affectedKinds: affectedKinds.length > 0 ? affectedKinds : undefined,
topics: topics.length > 0 ? topics : undefined, topics: topics.length > 0 ? topics : undefined,
addClientTag, addClientTag,
isNsfw, isNsfw,
@ -1125,7 +1177,7 @@ export default function PostContent({
threadReadingSubject, threadReadingSubject,
isLongFormArticle, isLongFormArticle,
isWikiArticle, isWikiArticle,
isWikiArticleMarkdown, isNostrSpecification,
isPublicationContent, isPublicationContent,
isCitationInternal, isCitationInternal,
isCitationExternal, isCitationExternal,
@ -1143,62 +1195,12 @@ export default function PostContent({
articleTitle, articleTitle,
articleImage, articleImage,
articleSubject, articleSubject,
nostrSpecAffectedKindRows,
articleSummary, articleSummary,
pubkey, pubkey,
t t
]) ])
const applyLabTagOverrideToDraft = useCallback((draft: TDraftEvent): TDraftEvent => {
if (!labTagOverrideRef.current) return draft
const tags = labTagOverrideRef.current.map((r) => [...r])
labTagOverrideRef.current = null
mergeUploadImetaTagsInto(tags, collectUploadImetaTagsForContentUrls(draft.content))
return { ...draft, tags }
}, [])
// Function to generate draft event JSON for preview
const getDraftEventJson = useCallback(async (): Promise<string> => {
if (!pubkey) {
return JSON.stringify({ error: 'Not logged in' }, null, 2)
}
try {
// Clean tracking parameters from URLs in the post content
const body = textareaRef.current?.getText() ?? text
const cleanedText = rewritePlainTextHttpUrls(body)
let draftEvent = await createDraftEvent(cleanedText)
draftEvent = applyLabTagOverrideToDraft(draftEvent)
return JSON.stringify(applyImwaldAttributionTags(draftEvent, { addClientTag }), null, 2)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2)
}
}, [text, pubkey, isDiscussionThread, createDraftEvent, addClientTag, applyLabTagOverrideToDraft])
const applyComposerDraftJson = useCallback(
(raw: string) => {
const parsed = parseLabSlice(raw.trim())
if (!parsed.ok) {
toast.error(parsed.error)
return false
}
if (parsed.value.kind !== getDeterminedKind) {
toast.error(
t('composerJsonKindMismatch', {
expected: String(getDeterminedKind),
got: String(parsed.value.kind)
})
)
return false
}
labTagOverrideRef.current = parsed.value.tags.map((r) => [...r])
textareaRef.current?.setDocumentFromPlainText(parsed.value.content)
toast.success(t('composerJsonApplySuccess'))
return true
},
[getDeterminedKind, t]
)
const advancedLabPersistenceKey = useMemo( const advancedLabPersistenceKey = useMemo(
() => () =>
postEditorCache.generateCacheKey({ postEditorCache.generateCacheKey({
@ -1209,6 +1211,26 @@ export default function PostContent({
[getDeterminedKind, defaultContent, parentEvent] [getDeterminedKind, defaultContent, parentEvent]
) )
const applyPersistedLabTagsToDraft = useCallback(
(draft: TDraftEvent, labKey: string): TDraftEvent => {
const saved = postEditorCache.getAdvancedLabDraft(labKey)
if (!saved || saved.kind !== draft.kind) return draft
const tags = saved.tags.map((r) => [...r])
mergeUploadImetaTagsInto(tags, collectUploadImetaTagsForContentUrls(draft.content))
return { ...draft, tags }
},
[]
)
const finalizeDraftEvent = useCallback(
async (cleanedText: string): Promise<TDraftEvent> => {
let draft = await createDraftEvent(cleanedText)
draft = applyPersistedLabTagsToDraft(draft, advancedLabPersistenceKey)
return draft
},
[createDraftEvent, applyPersistedLabTagsToDraft, advancedLabPersistenceKey]
)
const handleOpenAdvancedLab = useCallback(async () => { const handleOpenAdvancedLab = useCallback(async () => {
await checkLogin(async () => { await checkLogin(async () => {
if (!pubkey) { if (!pubkey) {
@ -1220,21 +1242,20 @@ export default function PostContent({
await yieldForPaintBeforeHeavyWork() await yieldForPaintBeforeHeavyWork()
const body = textareaRef.current?.getText() ?? text const body = textareaRef.current?.getText() ?? text
const cleanedText = rewritePlainTextHttpUrls(body) const cleanedText = rewritePlainTextHttpUrls(body)
let d = await createDraftEvent(cleanedText) let d = await finalizeDraftEvent(cleanedText)
d = applyLabTagOverrideToDraft(d)
const labKey = advancedLabPersistenceKey const labKey = advancedLabPersistenceKey
const saved = postEditorCache.getAdvancedLabDraft(labKey) const saved = postEditorCache.getAdvancedLabDraft(labKey)
if (saved && saved.kind === d.kind) { if (saved && saved.kind === d.kind) {
setAdvancedLabInitial({ setAdvancedLabInitial({
kind: saved.kind, kind: saved.kind,
content: saved.content, content: saved.content,
tags: saved.tags.map((row: string[]) => [...row]) tags: stripImwaldAttributionTags(saved.tags).map((row: string[]) => [...row])
}) })
} else { } else {
setAdvancedLabInitial({ setAdvancedLabInitial({
kind: d.kind, kind: d.kind,
content: d.content, content: d.content,
tags: (d.tags ?? []).map((row: string[]) => [...row]) tags: stripImwaldAttributionTags(d.tags ?? []).map((row: string[]) => [...row])
}) })
} }
setAdvancedLabOpen(true) setAdvancedLabOpen(true)
@ -1246,8 +1267,7 @@ export default function PostContent({
checkLogin, checkLogin,
pubkey, pubkey,
text, text,
createDraftEvent, finalizeDraftEvent,
applyLabTagOverrideToDraft,
advancedLabPersistenceKey, advancedLabPersistenceKey,
t t
]) ])
@ -1322,8 +1342,7 @@ export default function PostContent({
} }
// Create draft event using shared function // Create draft event using shared function
draftEvent = await createDraftEvent(cleanedText) draftEvent = await finalizeDraftEvent(cleanedText)
draftEvent = applyLabTagOverrideToDraft(draftEvent)
const publishSuccessMessage = parentEvent const publishSuccessMessage = parentEvent
? t('Reply published') ? t('Reply published')
@ -1463,7 +1482,7 @@ export default function PostContent({
setIsHighlight(false) setIsHighlight(false)
setIsLongFormArticle(false) setIsLongFormArticle(false)
setIsWikiArticle(false) setIsWikiArticle(false)
setIsWikiArticleMarkdown(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
@ -1487,7 +1506,7 @@ export default function PostContent({
setIsHighlight(false) setIsHighlight(false)
setIsLongFormArticle(false) setIsLongFormArticle(false)
setIsWikiArticle(false) setIsWikiArticle(false)
setIsWikiArticleMarkdown(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
@ -1508,7 +1527,7 @@ export default function PostContent({
setIsHighlight(false) setIsHighlight(false)
setIsLongFormArticle(false) setIsLongFormArticle(false)
setIsWikiArticle(false) setIsWikiArticle(false)
setIsWikiArticleMarkdown(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
@ -1598,7 +1617,7 @@ export default function PostContent({
setIsHighlight(false) setIsHighlight(false)
setIsLongFormArticle(false) setIsLongFormArticle(false)
setIsWikiArticle(false) setIsWikiArticle(false)
setIsWikiArticleMarkdown(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
@ -1631,7 +1650,7 @@ export default function PostContent({
!isHighlight && !isHighlight &&
!isLongFormArticle && !isLongFormArticle &&
!isWikiArticle && !isWikiArticle &&
!isWikiArticleMarkdown && !isNostrSpecification &&
!isPublicationContent && !isPublicationContent &&
!isCitationInternal && !isCitationInternal &&
!isCitationExternal && !isCitationExternal &&
@ -1646,7 +1665,7 @@ export default function PostContent({
isHighlight, isHighlight,
isLongFormArticle, isLongFormArticle,
isWikiArticle, isWikiArticle,
isWikiArticleMarkdown, isNostrSpecification,
isPublicationContent, isPublicationContent,
isCitationInternal, isCitationInternal,
isCitationExternal, isCitationExternal,
@ -1667,7 +1686,7 @@ export default function PostContent({
setIsPublicMessage(false) setIsPublicMessage(false)
setIsLongFormArticle(false) setIsLongFormArticle(false)
setIsWikiArticle(false) setIsWikiArticle(false)
setIsWikiArticleMarkdown(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
@ -2107,7 +2126,7 @@ export default function PostContent({
setIsHighlight(false) setIsHighlight(false)
setIsLongFormArticle(false) setIsLongFormArticle(false)
setIsWikiArticle(false) setIsWikiArticle(false)
setIsWikiArticleMarkdown(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
@ -2138,13 +2157,19 @@ export default function PostContent({
] ]
) )
const handleArticleToggle = (type: 'longform' | 'wiki' | 'wiki-markdown' | 'publication') => { const handleArticleToggle = (type: 'longform' | 'wiki' | 'nostr-specification' | 'publication') => {
if (parentEvent) return // Can't create articles as replies if (parentEvent) return // Can't create articles as replies
setIsLongFormArticle(type === 'longform') setIsLongFormArticle(type === 'longform')
setIsWikiArticle(type === 'wiki') setIsWikiArticle(type === 'wiki')
setIsWikiArticleMarkdown(type === 'wiki-markdown') setIsNostrSpecification(type === 'nostr-specification')
setIsPublicationContent(type === 'publication') setIsPublicationContent(type === 'publication')
if (type === 'nostr-specification') {
setArticleImage('')
setNostrSpecAffectedKindRows((rows) =>
rows.length > 0 ? rows : [newNostrSpecAffectedKindRow()]
)
}
// Clear other types // Clear other types
setIsPoll(false) setIsPoll(false)
@ -2168,7 +2193,7 @@ export default function PostContent({
} }
// Clear article fields when toggling off // Clear article fields when toggling off
if (type === 'longform' || type === 'wiki' || type === 'wiki-markdown' || type === 'publication') { if (type === 'longform' || type === 'wiki' || type === 'nostr-specification' || type === 'publication') {
// Keep fields when switching between article types // Keep fields when switching between article types
} else { } else {
setArticleTitle('') setArticleTitle('')
@ -2194,7 +2219,7 @@ export default function PostContent({
setMediaNoteKind(null) setMediaNoteKind(null)
setIsLongFormArticle(false) setIsLongFormArticle(false)
setIsWikiArticle(false) setIsWikiArticle(false)
setIsWikiArticleMarkdown(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsDiscussionThread(false) setIsDiscussionThread(false)
@ -2224,7 +2249,7 @@ export default function PostContent({
setIsHighlight(false) setIsHighlight(false)
setIsLongFormArticle(false) setIsLongFormArticle(false)
setIsWikiArticle(false) setIsWikiArticle(false)
setIsWikiArticleMarkdown(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
@ -2265,6 +2290,7 @@ export default function PostContent({
setCitationVersion('') setCitationVersion('')
setCitationSummary('') setCitationSummary('')
setCitationPromptLlm('') setCitationPromptLlm('')
setNostrSpecAffectedKindRows([newNostrSpecAffectedKindRow()])
setPollCreateData({ setPollCreateData({
isMultipleChoice: false, isMultipleChoice: false,
options: ['', ''], options: ['', ''],
@ -2312,8 +2338,8 @@ export default function PostContent({
return t('New Long-form Article') return t('New Long-form Article')
} else if (determinedKind === ExtendedKind.WIKI_ARTICLE) { } else if (determinedKind === ExtendedKind.WIKI_ARTICLE) {
return t('New Wiki Article') return t('New Wiki Article')
} else if (determinedKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { } else if (determinedKind === ExtendedKind.NOSTR_SPECIFICATION) {
return t('New Wiki Article (Markdown)') return t('New Nostr Specification')
} else if (determinedKind === ExtendedKind.PUBLICATION_CONTENT) { } else if (determinedKind === ExtendedKind.PUBLICATION_CONTENT) {
return t('Take a note') return t('Take a note')
} else if (determinedKind === ExtendedKind.CITATION_INTERNAL) { } else if (determinedKind === ExtendedKind.CITATION_INTERNAL) {
@ -2500,7 +2526,7 @@ export default function PostContent({
)} )}
{/* Article metadata fields */} {/* Article metadata fields */}
{(isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent) && ( {(isLongFormArticle || isWikiArticle || isNostrSpecification || isPublicationContent) && (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30"> <div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="article-dtag" className="text-sm font-medium"> <Label htmlFor="article-dtag" className="text-sm font-medium">
@ -2527,20 +2553,73 @@ export default function PostContent({
/> />
</div> </div>
<div className="space-y-2"> {!isNostrSpecification && (
<Label htmlFor="article-image" className="text-sm font-medium"> <div className="space-y-2">
{t('Image URL')} <Label htmlFor="article-image" className="text-sm font-medium">
</Label> {t('Image URL')}
<Input </Label>
id="article-image" <Input
value={articleImage} id="article-image"
onChange={(e) => setArticleImage(e.target.value)} value={articleImage}
placeholder={t('https://example.com/image.jpg')} onChange={(e) => setArticleImage(e.target.value)}
/> placeholder={t('https://example.com/image.jpg')}
<p className="text-xs text-muted-foreground"> />
{t('URL of the article cover image (optional)')} <p className="text-xs text-muted-foreground">
</p> {t('URL of the article cover image (optional)')}
</div> </p>
</div>
)}
{isNostrSpecification && (
<div className="space-y-2">
<Label className="text-sm font-medium">{t('nostrSpecAffectedKindLabel')}</Label>
<p className="text-xs text-muted-foreground">{t('nostrSpecAffectedKindHint')}</p>
<div className="space-y-2">
{nostrSpecAffectedKindRows.map((row, index) => (
<div key={row.id} className="flex gap-2 items-center">
<Input
id={index === 0 ? 'nostr-spec-k-0' : undefined}
value={row.value}
inputMode="numeric"
className="font-mono text-sm"
placeholder={t('nostrSpecAffectedKindPlaceholder')}
onChange={(e) => {
const v = e.target.value
setNostrSpecAffectedKindRows((rows) =>
rows.map((r) => (r.id === row.id ? { ...r, value: v } : r))
)
}}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
disabled={nostrSpecAffectedKindRows.length <= 1}
onClick={() =>
setNostrSpecAffectedKindRows((rows) => rows.filter((r) => r.id !== row.id))
}
aria-label={t('Remove')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1"
onClick={() =>
setNostrSpecAffectedKindRows((rows) => [...rows, newNostrSpecAffectedKindRow()])
}
>
<Plus className="h-3.5 w-3.5" />
{t('nostrSpecAffectedKindAdd')}
</Button>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="article-subject" className="text-sm font-medium"> <Label htmlFor="article-subject" className="text-sm font-medium">
@ -2948,12 +3027,8 @@ export default function PostContent({
kind={getDeterminedKind} kind={getDeterminedKind}
highlightData={isHighlight ? highlightData : undefined} highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined} pollCreateData={isPoll ? pollCreateData : undefined}
getDraftEventJson={getDraftEventJson} extraPreviewTags={mergedExtraPreviewTags}
expectedDraftKind={pubkey ? getDeterminedKind : undefined} articleMetadata={articlePreviewMetadata}
onApplyComposerDraftJson={pubkey ? applyComposerDraftJson : undefined}
extraPreviewTags={
isDiscussionThread && !parentEvent ? discussionPreviewExtraTags : rssReplyExtraPreviewTags
}
addClientTag={addClientTag} addClientTag={addClientTag}
mediaImetaTags={mediaImetaTags} mediaImetaTags={mediaImetaTags}
mediaUrl={mediaUrl} mediaUrl={mediaUrl}
@ -2961,7 +3036,7 @@ export default function PostContent({
const ActiveIcon = const ActiveIcon =
isLongFormArticle ? FileText : isLongFormArticle ? FileText :
isWikiArticle ? FileText : isWikiArticle ? FileText :
isWikiArticleMarkdown ? FileText : isNostrSpecification ? FileText :
isPublicationContent ? Book : isPublicationContent ? Book :
isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt ? Quote : isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt ? Quote :
isHighlight ? Highlighter : isHighlight ? Highlighter :
@ -2973,7 +3048,7 @@ export default function PostContent({
const activeLabel = const activeLabel =
isLongFormArticle ? t('Long-form Article') : isLongFormArticle ? t('Long-form Article') :
isWikiArticle ? t('Wiki Article (AsciiDoc)') : isWikiArticle ? t('Wiki Article (AsciiDoc)') :
isWikiArticleMarkdown ? t('Wiki Article (Markdown)') : isNostrSpecification ? t('Nostr Specification') :
isPublicationContent ? t('Publication Note') : isPublicationContent ? t('Publication Note') :
isCitationInternal ? t('Internal Citation') : isCitationInternal ? t('Internal Citation') :
isCitationExternal ? t('External Citation') : isCitationExternal ? t('External Citation') :
@ -3097,13 +3172,15 @@ export default function PostContent({
</div> </div>
{isWikiArticle && <Check className="h-4 w-4 shrink-0 text-primary" />} {isWikiArticle && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleArticleToggle('wiki-markdown')} className="gap-3 py-2 cursor-pointer"> <DropdownMenuItem onClick={() => handleArticleToggle('nostr-specification')} className="gap-3 py-2 cursor-pointer">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" /> <FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Wiki Article (Markdown)')}</span> <span className="font-medium leading-none">{t('Nostr Specification')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Markdown wiki contribution')}</span> <span className="text-xs text-muted-foreground mt-0.5">
{t('nostrSpecificationContribution')}
</span>
</div> </div>
{isWikiArticleMarkdown && <Check className="h-4 w-4 shrink-0 text-primary" />} {isNostrSpecification && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem> </DropdownMenuItem>
{hasPrivateRelaysAvailable && ( {hasPrivateRelaysAvailable && (
<DropdownMenuItem onClick={() => handleArticleToggle('publication')} className="gap-3 py-2 cursor-pointer"> <DropdownMenuItem onClick={() => handleArticleToggle('publication')} className="gap-3 py-2 cursor-pointer">
@ -3570,7 +3647,12 @@ export default function PostContent({
/> />
} }
onApply={(payload) => { onApply={(payload) => {
labTagOverrideRef.current = payload.tags.map((r) => [...r]) postEditorCache.setAdvancedLabDraft(advancedLabPersistenceKey, {
kind: payload.kind,
content: payload.content,
tags: payload.tags.map((r) => [...r])
})
postEditorCache.flushPersist()
textareaRef.current?.setDocumentFromPlainText(payload.content) textareaRef.current?.setDocumentFromPlainText(payload.content)
}} }}
/> />

18
src/components/PostEditor/PostTextarea/Preview.tsx

@ -47,6 +47,8 @@ export default function Preview({
image?: string image?: string
dTag?: string dTag?: string
topics?: string[] topics?: string[]
/** Kind 30817: each number becomes a `k` tag. */
affectedKinds?: number[]
} }
/** Merged into the fake event (e.g. kind 11 discussion title / topic tags). */ /** Merged into the fake event (e.g. kind 11 discussion title / topic tags). */
extraPreviewTags?: string[][] extraPreviewTags?: string[][]
@ -138,7 +140,7 @@ export default function Preview({
tags.push(...mediaImetaTags) tags.push(...mediaImetaTags)
} }
// Add article metadata tags for article kinds // Add article metadata tags for article kinds
if (articleMetadata && (kind === kinds.LongFormArticle || kind === ExtendedKind.WIKI_ARTICLE || kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || kind === ExtendedKind.PUBLICATION_CONTENT)) { if (articleMetadata && (kind === kinds.LongFormArticle || kind === ExtendedKind.WIKI_ARTICLE || kind === ExtendedKind.NOSTR_SPECIFICATION || kind === ExtendedKind.PUBLICATION_CONTENT)) {
if (articleMetadata.dTag) { if (articleMetadata.dTag) {
tags.push(['d', articleMetadata.dTag]) tags.push(['d', articleMetadata.dTag])
} }
@ -148,9 +150,17 @@ export default function Preview({
if (articleMetadata.summary) { if (articleMetadata.summary) {
tags.push(['summary', articleMetadata.summary]) tags.push(['summary', articleMetadata.summary])
} }
if (articleMetadata.image) { if (kind !== ExtendedKind.NOSTR_SPECIFICATION && articleMetadata.image) {
tags.push(['image', articleMetadata.image]) tags.push(['image', articleMetadata.image])
} }
if (
kind === ExtendedKind.NOSTR_SPECIFICATION &&
articleMetadata.affectedKinds?.length
) {
for (const k of articleMetadata.affectedKinds) {
tags.push(['k', String(k)])
}
}
if (articleMetadata.topics && articleMetadata.topics.length > 0) { if (articleMetadata.topics && articleMetadata.topics.length > 0) {
const normalizedTopics = articleMetadata.topics const normalizedTopics = articleMetadata.topics
.map(topic => normalizeTopic(topic.trim())) .map(topic => normalizeTopic(topic.trim()))
@ -249,8 +259,8 @@ export default function Preview({
) )
} }
// For WikiArticleMarkdown, use MarkdownArticle // Nostr Specification (30817) uses MarkdownArticle
if (kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { if (kind === ExtendedKind.NOSTR_SPECIFICATION) {
return withClientBadge( return withClientBadge(
<Card className={cn('p-3', className, selectableClass)}> <Card className={cn('p-3', className, selectableClass)}>
<MarkdownArticle event={fakeEvent} hideMetadata={true} lazyMedia={false} /> <MarkdownArticle event={fakeEvent} hideMetadata={true} lazyMedia={false} />

152
src/components/PostEditor/PostTextarea/index.tsx

@ -1,4 +1,3 @@
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { parseEditorJsonToText, plainTextToTipTapDoc } from '@/lib/tiptap' import { parseEditorJsonToText, plainTextToTipTapDoc } from '@/lib/tiptap'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -18,7 +17,6 @@ import {
Dispatch, Dispatch,
forwardRef, forwardRef,
SetStateAction, SetStateAction,
useEffect,
useImperativeHandle, useImperativeHandle,
useMemo, useMemo,
useRef, useRef,
@ -33,10 +31,6 @@ import mentionSuggestion from './Mention/suggestion'
import Preview from './Preview' import Preview from './Preview'
import { HighlightData } from '../HighlightEditor' import { HighlightData } from '../HighlightEditor'
import { getKindDescription } from '@/lib/kind-description' import { getKindDescription } from '@/lib/kind-description'
import { parseLabSlice } from '@/lib/advanced-event-lab-slice'
/** Draft JSON uses relay fetches (e.g. thread root); cap wait so the Json tab cannot spin forever. */
const DRAFT_JSON_PREVIEW_TIMEOUT_MS = 25_000
export type TPostTextareaHandle = { export type TPostTextareaHandle = {
appendText: (text: string, addNewline?: boolean) => void appendText: (text: string, addNewline?: boolean) => void
@ -74,10 +68,6 @@ const PostTextarea = forwardRef<
highlightData?: HighlightData highlightData?: HighlightData
pollCreateData?: import('@/types').TPollCreateData pollCreateData?: import('@/types').TPollCreateData
headerActions?: React.ReactNode headerActions?: React.ReactNode
getDraftEventJson?: () => Promise<string>
/** When set with `onApplyComposerDraftJson`, the Json tab becomes editable with Apply. */
expectedDraftKind?: number
onApplyComposerDraftJson?: (rawJson: string) => boolean
mediaImetaTags?: string[][] mediaImetaTags?: string[][]
mediaUrl?: string mediaUrl?: string
articleMetadata?: { articleMetadata?: {
@ -86,6 +76,7 @@ const PostTextarea = forwardRef<
image?: string image?: string
dTag?: string dTag?: string
topics?: string[] topics?: string[]
affectedKinds?: number[]
} }
extraPreviewTags?: string[][] extraPreviewTags?: string[][]
addClientTag?: boolean addClientTag?: boolean
@ -109,9 +100,6 @@ const PostTextarea = forwardRef<
highlightData, highlightData,
pollCreateData, pollCreateData,
headerActions, headerActions,
getDraftEventJson,
expectedDraftKind,
onApplyComposerDraftJson,
mediaImetaTags, mediaImetaTags,
mediaUrl, mediaUrl,
articleMetadata, articleMetadata,
@ -128,72 +116,10 @@ const PostTextarea = forwardRef<
const onUploadCompressProgressRef = useRef(onUploadCompressProgress) const onUploadCompressProgressRef = useRef(onUploadCompressProgress)
onUploadCompressProgressRef.current = onUploadCompressProgress onUploadCompressProgressRef.current = onUploadCompressProgress
const [activeTab, setActiveTab] = useState('preview') const [activeTab, setActiveTab] = useState('preview')
const [draftEventJson, setDraftEventJson] = useState<string>('')
const [isLoadingJson, setIsLoadingJson] = useState(false)
const [jsonFieldValue, setJsonFieldValue] = useState('')
const [jsonReloadToken, setJsonReloadToken] = useState(0)
/** Bumps when preview tab is shown or a new JSON fetch starts; completions only apply if seq still matches. */
const jsonPanelFetchSeq = useRef(0)
const editorRef = useRef<Editor | null>(null) const editorRef = useRef<Editor | null>(null)
const kindDescription = useMemo(() => getKindDescription(kind), [kind]) const kindDescription = useMemo(() => getKindDescription(kind), [kind])
useEffect(() => {
if (activeTab === 'preview') {
jsonPanelFetchSeq.current += 1
setDraftEventJson('')
setIsLoadingJson(false)
return
}
if (activeTab !== 'json' || !getDraftEventJson) {
return
}
const seq = ++jsonPanelFetchSeq.current
setIsLoadingJson(true)
let timeoutId: number | undefined = window.setTimeout(() => {
timeoutId = undefined
if (seq !== jsonPanelFetchSeq.current) return
setDraftEventJson(
`Error generating JSON: Timed out after ${Math.round(DRAFT_JSON_PREVIEW_TIMEOUT_MS / 1000)}s (relays or network slow)`
)
setIsLoadingJson(false)
}, DRAFT_JSON_PREVIEW_TIMEOUT_MS)
const clearJsonTimeout = () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
timeoutId = undefined
}
}
void Promise.resolve(getDraftEventJson())
.then((json) => {
clearJsonTimeout()
if (seq !== jsonPanelFetchSeq.current) return
setDraftEventJson(json)
setIsLoadingJson(false)
})
.catch((error: unknown) => {
clearJsonTimeout()
if (seq !== jsonPanelFetchSeq.current) return
const msg = error instanceof Error ? error.message : String(error)
setDraftEventJson(`Error generating JSON: ${msg}`)
setIsLoadingJson(false)
})
// `text` is included so JSON refreshes when the parent memoizes `getDraftEventJson` too narrowly;
// `kind` catches compose-mode switches even if callback identity were ever stable across them.
// Use `jsonPanelFetchSeq` instead of an effect cleanup `cancelled` flag so a superseded fetch
// does not skip `setIsLoadingJson(false)` and leave the Json tab stuck on "Loading...".
}, [activeTab, getDraftEventJson, kind, text, jsonReloadToken])
useEffect(() => {
if (activeTab !== 'json' || isLoadingJson) return
setJsonFieldValue(draftEventJson)
}, [activeTab, isLoadingJson, draftEventJson])
const editor = useEditor({ const editor = useEditor({
// TipTap + Radix Dialog/Tabs: defer init so React 18 does not warn about flushSync in a lifecycle. // TipTap + Radix Dialog/Tabs: defer init so React 18 does not warn about flushSync in a lifecycle.
immediatelyRender: false, immediatelyRender: false,
@ -351,9 +277,6 @@ const PostTextarea = forwardRef<
<TabsTrigger value="preview" title={t('Preview')}> <TabsTrigger value="preview" title={t('Preview')}>
{t('Preview')} {t('Preview')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="json" title={t('Json')}>
{t('Json')}
</TabsTrigger>
</TabsList> </TabsList>
{headerActions && ( {headerActions && (
<div className="flex gap-1 items-center flex-wrap"> <div className="flex gap-1 items-center flex-wrap">
@ -361,7 +284,6 @@ const PostTextarea = forwardRef<
</div> </div>
)} )}
</div> </div>
{/* Editor always visible (no Edit tab). Keep mounted; only Preview/Json swap panels below. */}
<EditorContent className="tiptap" editor={editor} /> <EditorContent className="tiptap" editor={editor} />
<TabsContent value="preview"> <TabsContent value="preview">
<div className="space-y-2"> <div className="space-y-2">
@ -382,78 +304,6 @@ const PostTextarea = forwardRef<
/> />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="json" className="mt-2 flex flex-col gap-2 min-h-0">
{isLoadingJson ? (
<div className="text-muted-foreground text-sm">{t('Loading...')}</div>
) : expectedDraftKind !== undefined && onApplyComposerDraftJson ? (
<>
<p className="text-xs text-muted-foreground">{t('Composer JSON tab hint')}</p>
<textarea
className="w-full min-h-[min(55dvh,32rem)] max-h-[min(70vh,40rem)] resize-y rounded-lg border bg-background p-3 font-mono text-xs leading-relaxed text-foreground shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
spellCheck={false}
value={jsonFieldValue}
onChange={(e) => setJsonFieldValue(e.target.value)}
aria-label={t('Json')}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => {
const parsed = parseLabSlice(jsonFieldValue.trim())
if (!parsed.ok) {
return
}
if (parsed.value.kind !== expectedDraftKind) {
return
}
const ok = onApplyComposerDraftJson(jsonFieldValue)
if (ok) setJsonReloadToken((n) => n + 1)
}}
disabled={
!jsonFieldValue.trim() ||
(() => {
const p = parseLabSlice(jsonFieldValue.trim())
if (!p.ok) return true
if (p.value.kind !== expectedDraftKind) return true
return false
})()
}
>
{t('Composer JSON apply')}
</Button>
{(() => {
const p = parseLabSlice(jsonFieldValue.trim())
if (!jsonFieldValue.trim()) return null
if (!p.ok) {
return (
<span className="text-xs text-destructive" role="alert">
{p.error}
</span>
)
}
if (p.value.kind !== expectedDraftKind) {
return (
<span className="text-xs text-destructive" role="alert">
{t('composerJsonKindMismatch', {
expected: String(expectedDraftKind),
got: String(p.value.kind)
})}
</span>
)
}
return null
})()}
</div>
</>
) : (
<div className="border rounded-lg p-3 bg-muted/40 max-h-[min(70vh,40rem)] overflow-auto select-text">
<pre className="text-xs whitespace-pre-wrap break-words font-mono select-text">
{draftEventJson || t('No JSON available')}
</pre>
</div>
)}
</TabsContent>
</Tabs> </Tabs>
) )
} }

2
src/components/ReplyNoteList/index.tsx

@ -309,7 +309,7 @@ function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string {
if ( if (
item.kind === kinds.LongFormArticle || item.kind === kinds.LongFormArticle ||
item.kind === ExtendedKind.WIKI_ARTICLE || item.kind === ExtendedKind.WIKI_ARTICLE ||
item.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || item.kind === ExtendedKind.NOSTR_SPECIFICATION ||
item.kind === ExtendedKind.PUBLICATION_CONTENT item.kind === ExtendedKind.PUBLICATION_CONTENT
) { ) {
return t('cited in article') return t('cited in article')

6
src/components/WebPreview/index.tsx

@ -54,8 +54,8 @@ function getEventTypeName(kind: number): string {
return 'Publication Content' return 'Publication Content'
case ExtendedKind.WIKI_ARTICLE: case ExtendedKind.WIKI_ARTICLE:
return 'Wiki Article' return 'Wiki Article'
case ExtendedKind.WIKI_ARTICLE_MARKDOWN: case ExtendedKind.NOSTR_SPECIFICATION:
return 'Wiki Article' return 'Nostr Specification'
case ExtendedKind.DISCUSSION: case ExtendedKind.DISCUSSION:
return 'Discussion' return 'Discussion'
default: default:
@ -509,7 +509,7 @@ export default function WebPreview({ url, className }: { url: string; className?
// Determine which article component to use based on event kind // Determine which article component to use based on event kind
const isAsciidocEvent = fetchedEvent && (fetchedEvent.kind === ExtendedKind.WIKI_ARTICLE || fetchedEvent.kind === ExtendedKind.PUBLICATION_CONTENT) const isAsciidocEvent = fetchedEvent && (fetchedEvent.kind === ExtendedKind.WIKI_ARTICLE || fetchedEvent.kind === ExtendedKind.PUBLICATION_CONTENT)
const isMarkdownEvent = fetchedEvent && (fetchedEvent.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) const isMarkdownEvent = fetchedEvent && (fetchedEvent.kind === ExtendedKind.NOSTR_SPECIFICATION)
// Only show content preview if summary exists (exclude LongFormArticle - they should show summary instead) // Only show content preview if summary exists (exclude LongFormArticle - they should show summary instead)
const showContentPreview = eventSummary && previewEvent && previewEvent.content && (isAsciidocEvent || isMarkdownEvent) const showContentPreview = eventSummary && previewEvent && previewEvent.content && (isAsciidocEvent || isMarkdownEvent)

15
src/constants.ts

@ -547,7 +547,8 @@ export const ExtendedKind = {
ZAP_RECEIPT: 9735, ZAP_RECEIPT: 9735,
PUBLICATION: 30040, PUBLICATION: 30040,
WIKI_ARTICLE: 30818, WIKI_ARTICLE: 30818,
WIKI_ARTICLE_MARKDOWN: 30817, /** NIP/spec document (Markdown) for relay publication instead of GitHub; kind 30817. */
NOSTR_SPECIFICATION: 30817,
PUBLICATION_CONTENT: 30041, PUBLICATION_CONTENT: 30041,
CITATION_INTERNAL: 30, CITATION_INTERNAL: 30,
CITATION_EXTERNAL: 31, CITATION_EXTERNAL: 31,
@ -702,7 +703,7 @@ export const THREAD_BACKLINK_STREAM_KINDS: readonly number[] = [
kinds.Highlights, kinds.Highlights,
kinds.LongFormArticle, kinds.LongFormArticle,
ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN, ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.PUBLICATION_CONTENT, ExtendedKind.PUBLICATION_CONTENT,
kinds.Label, kinds.Label,
kinds.Report, kinds.Report,
@ -803,7 +804,7 @@ export function relayFilterIncludesSocialKindBlockedKind(filter: Filter): boolea
const DOCUMENT_RELAY_KINDS: readonly number[] = [ const DOCUMENT_RELAY_KINDS: readonly number[] = [
kinds.LongFormArticle, // 30023 kinds.LongFormArticle, // 30023
ExtendedKind.WIKI_ARTICLE, // 30818 ExtendedKind.WIKI_ARTICLE, // 30818
ExtendedKind.WIKI_ARTICLE_MARKDOWN, // 30817 ExtendedKind.NOSTR_SPECIFICATION, // 30817
ExtendedKind.PUBLICATION_CONTENT, // 30041 ExtendedKind.PUBLICATION_CONTENT, // 30041
ExtendedKind.PUBLICATION // 30040 ExtendedKind.PUBLICATION // 30040
] ]
@ -821,7 +822,7 @@ export function isDocumentRelayKind(kind: number): boolean {
*/ */
export const NIP_SEARCH_DOCUMENT_KINDS: readonly number[] = [ export const NIP_SEARCH_DOCUMENT_KINDS: readonly number[] = [
kinds.LongFormArticle, kinds.LongFormArticle,
ExtendedKind.WIKI_ARTICLE_MARKDOWN, ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE,
ExtendedKind.PUBLICATION, ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT ExtendedKind.PUBLICATION_CONTENT
@ -865,7 +866,7 @@ export const READ_ALOUD_KINDS: readonly number[] = [
kinds.LongFormArticle, kinds.LongFormArticle,
ExtendedKind.PUBLICATION, ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT, ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE_MARKDOWN, ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.WIKI_ARTICLE ExtendedKind.WIKI_ARTICLE
] ]
@ -910,7 +911,7 @@ export const SUPPORTED_KINDS = [
kinds.LiveEvent, kinds.LiveEvent,
ExtendedKind.PUBLICATION, ExtendedKind.PUBLICATION,
ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN, ExtendedKind.NOSTR_SPECIFICATION,
// ExtendedKind.PUBLICATION_CONTENT, // Excluded - publication content should only be embedded in publications // ExtendedKind.PUBLICATION_CONTENT, // Excluded - publication content should only be embedded in publications
// NIP-89 Application Handlers // NIP-89 Application Handlers
ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION, ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION,
@ -941,7 +942,7 @@ export const PROFILE_PUBLICATIONS_TAB_KINDS: readonly number[] = [
ExtendedKind.PUBLICATION, ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT, ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN ExtendedKind.NOSTR_SPECIFICATION
] ]
const PROFILE_PUBLICATIONS_TAB_KIND_SET = new Set<number>(PROFILE_PUBLICATIONS_TAB_KINDS) const PROFILE_PUBLICATIONS_TAB_KIND_SET = new Set<number>(PROFILE_PUBLICATIONS_TAB_KINDS)

8
src/i18n/locales/cs.ts

@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc", "Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown", "Article exported as Markdown": "Article exported as Markdown",
"Article title (optional)": "Article title (optional)", "Article title (optional)": "Article title (optional)",
articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….", articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, nostr-specification-…, publication-content-….",
Audio: "Audio", Audio: "Audio",
Author: "Author", Author: "Author",
"Author is required for reading groups": "Author is required for reading groups", "Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation", "New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message", "New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article", "New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)", "New Nostr Specification": "New Nostr Specification",
Newest: "Newest", Newest: "Newest",
"No JSON available": "No JSON available", "No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file", "No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed", "Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)", "Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)", "Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)",
"Wiki Article (Markdown)": "Wiki Article (Markdown)", "Nostr Specification": "Nostr Specification",
"You can only delete your own notes": "You can only delete your own notes", "You can only delete your own notes": "You can only delete your own notes",
"You must be logged in to create a thread": "You must be logged in to create a thread", "You must be logged in to create a thread": "You must be logged in to create a thread",
"You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.", "You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.",
@ -2020,7 +2020,7 @@ export default {
"Invalid event fields": "Invalid event fields", "Invalid event fields": "Invalid event fields",
"Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).", "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).",
"Markdown article (NIP-23)": "Markdown article (NIP-23)", "Markdown article (NIP-23)": "Markdown article (NIP-23)",
"Markdown wiki contribution": "Markdown wiki contribution", nostrSpecificationContribution: "NIP or spec document for relay publication (Markdown)",
"Media Note": "Media Note", "Media Note": "Media Note",
"No publication events found for rebroadcast": "No publication events found for rebroadcast", "No publication events found for rebroadcast": "No publication events found for rebroadcast",
"No publication events were accepted by any relay": "No publication events were accepted by any relay", "No publication events were accepted by any relay": "No publication events were accepted by any relay",

8
src/i18n/locales/de.ts

@ -1436,7 +1436,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc", "Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown", "Article exported as Markdown": "Article exported as Markdown",
"Article title (optional)": "Article title (optional)", "Article title (optional)": "Article title (optional)",
articleDTagDefaultHint: "Optional. Wenn leer: automatischer d-Tag mit typischem Präfix und Unix-Zeitstempel (Sekunden), z. B. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….", articleDTagDefaultHint: "Optional. Wenn leer: automatischer d-Tag mit typischem Präfix und Unix-Zeitstempel (Sekunden), z. B. longform-article-…, wiki-article-…, nostr-specification-…, publication-content-….",
Audio: "Audio", Audio: "Audio",
Author: "Author", Author: "Author",
"Author is required for reading groups": "Author is required for reading groups", "Author is required for reading groups": "Author is required for reading groups",
@ -1684,7 +1684,7 @@ export default {
"New Prompt Citation": "New Prompt Citation", "New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message", "New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article", "New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)", "New Nostr Specification": "New Nostr Specification",
Newest: "Newest", Newest: "Newest",
"No JSON available": "No JSON available", "No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file", "No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -2057,7 +2057,7 @@ export default {
"Vote removed": "Vote removed", "Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)", "Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)", "Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)",
"Wiki Article (Markdown)": "Wiki Article (Markdown)", "Nostr Specification": "Nostr Specification",
"You can only delete your own notes": "You can only delete your own notes", "You can only delete your own notes": "You can only delete your own notes",
"You must be logged in to create a thread": "You must be logged in to create a thread", "You must be logged in to create a thread": "You must be logged in to create a thread",
"You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.", "You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.",
@ -2145,7 +2145,7 @@ export default {
"Invalid event fields": "Invalid event fields", "Invalid event fields": "Invalid event fields",
"Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).", "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).",
"Markdown article (NIP-23)": "Markdown article (NIP-23)", "Markdown article (NIP-23)": "Markdown article (NIP-23)",
"Markdown wiki contribution": "Markdown wiki contribution", nostrSpecificationContribution: "NIP or spec document for relay publication (Markdown)",
"Media Note": "Media Note", "Media Note": "Media Note",
"No publication events found for rebroadcast": "No publication events found for rebroadcast", "No publication events found for rebroadcast": "No publication events found for rebroadcast",
"No publication events were accepted by any relay": "No publication events were accepted by any relay", "No publication events were accepted by any relay": "No publication events were accepted by any relay",

25
src/i18n/locales/en.ts

@ -1098,6 +1098,19 @@ export default {
"Composer JSON apply": "Apply JSON", "Composer JSON apply": "Apply JSON",
composerJsonKindMismatch: "JSON kind {{got}} does not match the composer (expected {{expected}}). Change the note type or fix `kind` in JSON.", composerJsonKindMismatch: "JSON kind {{got}} does not match the composer (expected {{expected}}). Change the note type or fix `kind` in JSON.",
composerJsonApplySuccess: "Draft updated from JSON.", composerJsonApplySuccess: "Draft updated from JSON.",
composerExtraTagsTitle: "Additional tags",
composerExtraTagsHint:
"Optional Nostr tags appended when you publish (after the tags this composer adds automatically). Use one value per line for multi-value tags.",
composerExtraTagsEmpty: "No extra tags yet.",
composerExtraTagValues: "Values",
composerExtraTagValuesPlaceholder: "One value per line",
composerExtraTagsAdd: "Add tag",
advancedLabTagsTitle: "Event tags",
advancedLabTagsCount: "{{count}} tags",
advancedLabTagsHint:
"All tags except the client tag. One value per line for multi-value tags. Changes are saved automatically.",
advancedLabTagValuesPlaceholder: "One value per line",
advancedLabTagsAdd: "Add tag",
"Advanced lab tb markup tools": "Markup helpers", "Advanced lab tb markup tools": "Markup helpers",
"Advanced lab tb headings": "Headings", "Advanced lab tb headings": "Headings",
"Advanced lab tb headings hint": "Inserts at the cursor; adjust spacing if needed.", "Advanced lab tb headings hint": "Inserts at the cursor; adjust spacing if needed.",
@ -1524,7 +1537,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc", "Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown", "Article exported as Markdown": "Article exported as Markdown",
"Article title (optional)": "Article title (optional)", "Article title (optional)": "Article title (optional)",
articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….", articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, nostr-specification-…, publication-content-….",
Audio: "Audio", Audio: "Audio",
Author: "Author", Author: "Author",
"Author is required for reading groups": "Author is required for reading groups", "Author is required for reading groups": "Author is required for reading groups",
@ -1761,7 +1774,7 @@ export default {
"New Prompt Citation": "New Prompt Citation", "New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message", "New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article", "New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)", "New Nostr Specification": "New Nostr Specification",
Newest: "Newest", Newest: "Newest",
"No JSON available": "No JSON available", "No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file", "No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -2158,7 +2171,7 @@ export default {
"Vote removed": "Vote removed", "Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)", "Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)", "Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)",
"Wiki Article (Markdown)": "Wiki Article (Markdown)", "Nostr Specification": "Nostr Specification",
"You can only delete your own notes": "You can only delete your own notes", "You can only delete your own notes": "You can only delete your own notes",
"You must be logged in to create a thread": "You must be logged in to create a thread", "You must be logged in to create a thread": "You must be logged in to create a thread",
"You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.", "You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.",
@ -2246,7 +2259,11 @@ export default {
"Invalid event fields": "Invalid event fields", "Invalid event fields": "Invalid event fields",
"Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).", "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).",
"Markdown article (NIP-23)": "Markdown article (NIP-23)", "Markdown article (NIP-23)": "Markdown article (NIP-23)",
"Markdown wiki contribution": "Markdown wiki contribution", nostrSpecificationContribution: "NIP or spec document for relay publication (Markdown)",
nostrSpecAffectedKindLabel: "Affected kinds (k tags)",
nostrSpecAffectedKindHint: "One Nostr kind number per line. Each line becomes its own k tag.",
nostrSpecAffectedKindPlaceholder: "e.g. 1",
nostrSpecAffectedKindAdd: "Add kind",
"Media Note": "Media Note", "Media Note": "Media Note",
"No publication events found for rebroadcast": "No publication events found for rebroadcast", "No publication events found for rebroadcast": "No publication events found for rebroadcast",
"No publication events were accepted by any relay": "No publication events were accepted by any relay", "No publication events were accepted by any relay": "No publication events were accepted by any relay",

8
src/i18n/locales/es.ts

@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc", "Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown", "Article exported as Markdown": "Article exported as Markdown",
"Article title (optional)": "Article title (optional)", "Article title (optional)": "Article title (optional)",
articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….", articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, nostr-specification-…, publication-content-….",
Audio: "Audio", Audio: "Audio",
Author: "Author", Author: "Author",
"Author is required for reading groups": "Author is required for reading groups", "Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation", "New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message", "New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article", "New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)", "New Nostr Specification": "New Nostr Specification",
Newest: "Newest", Newest: "Newest",
"No JSON available": "No JSON available", "No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file", "No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed", "Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)", "Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)", "Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)",
"Wiki Article (Markdown)": "Wiki Article (Markdown)", "Nostr Specification": "Nostr Specification",
"You can only delete your own notes": "You can only delete your own notes", "You can only delete your own notes": "You can only delete your own notes",
"You must be logged in to create a thread": "You must be logged in to create a thread", "You must be logged in to create a thread": "You must be logged in to create a thread",
"You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.", "You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.",
@ -2020,7 +2020,7 @@ export default {
"Invalid event fields": "Invalid event fields", "Invalid event fields": "Invalid event fields",
"Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).", "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).",
"Markdown article (NIP-23)": "Markdown article (NIP-23)", "Markdown article (NIP-23)": "Markdown article (NIP-23)",
"Markdown wiki contribution": "Markdown wiki contribution", nostrSpecificationContribution: "NIP or spec document for relay publication (Markdown)",
"Media Note": "Media Note", "Media Note": "Media Note",
"No publication events found for rebroadcast": "No publication events found for rebroadcast", "No publication events found for rebroadcast": "No publication events found for rebroadcast",
"No publication events were accepted by any relay": "No publication events were accepted by any relay", "No publication events were accepted by any relay": "No publication events were accepted by any relay",

8
src/i18n/locales/fr.ts

@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc", "Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown", "Article exported as Markdown": "Article exported as Markdown",
"Article title (optional)": "Article title (optional)", "Article title (optional)": "Article title (optional)",
articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….", articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, nostr-specification-…, publication-content-….",
Audio: "Audio", Audio: "Audio",
Author: "Author", Author: "Author",
"Author is required for reading groups": "Author is required for reading groups", "Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation", "New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message", "New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article", "New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)", "New Nostr Specification": "New Nostr Specification",
Newest: "Newest", Newest: "Newest",
"No JSON available": "No JSON available", "No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file", "No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed", "Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)", "Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)", "Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)",
"Wiki Article (Markdown)": "Wiki Article (Markdown)", "Nostr Specification": "Nostr Specification",
"You can only delete your own notes": "You can only delete your own notes", "You can only delete your own notes": "You can only delete your own notes",
"You must be logged in to create a thread": "You must be logged in to create a thread", "You must be logged in to create a thread": "You must be logged in to create a thread",
"You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.", "You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.",
@ -2020,7 +2020,7 @@ export default {
"Invalid event fields": "Invalid event fields", "Invalid event fields": "Invalid event fields",
"Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).", "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).",
"Markdown article (NIP-23)": "Markdown article (NIP-23)", "Markdown article (NIP-23)": "Markdown article (NIP-23)",
"Markdown wiki contribution": "Markdown wiki contribution", nostrSpecificationContribution: "NIP or spec document for relay publication (Markdown)",
"Media Note": "Media Note", "Media Note": "Media Note",
"No publication events found for rebroadcast": "No publication events found for rebroadcast", "No publication events found for rebroadcast": "No publication events found for rebroadcast",
"No publication events were accepted by any relay": "No publication events were accepted by any relay", "No publication events were accepted by any relay": "No publication events were accepted by any relay",

8
src/i18n/locales/nl.ts

@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc", "Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown", "Article exported as Markdown": "Article exported as Markdown",
"Article title (optional)": "Article title (optional)", "Article title (optional)": "Article title (optional)",
articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….", articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, nostr-specification-…, publication-content-….",
Audio: "Audio", Audio: "Audio",
Author: "Author", Author: "Author",
"Author is required for reading groups": "Author is required for reading groups", "Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation", "New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message", "New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article", "New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)", "New Nostr Specification": "New Nostr Specification",
Newest: "Newest", Newest: "Newest",
"No JSON available": "No JSON available", "No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file", "No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed", "Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)", "Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)", "Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)",
"Wiki Article (Markdown)": "Wiki Article (Markdown)", "Nostr Specification": "Nostr Specification",
"You can only delete your own notes": "You can only delete your own notes", "You can only delete your own notes": "You can only delete your own notes",
"You must be logged in to create a thread": "You must be logged in to create a thread", "You must be logged in to create a thread": "You must be logged in to create a thread",
"You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.", "You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.",
@ -2020,7 +2020,7 @@ export default {
"Invalid event fields": "Invalid event fields", "Invalid event fields": "Invalid event fields",
"Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).", "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).",
"Markdown article (NIP-23)": "Markdown article (NIP-23)", "Markdown article (NIP-23)": "Markdown article (NIP-23)",
"Markdown wiki contribution": "Markdown wiki contribution", nostrSpecificationContribution: "NIP or spec document for relay publication (Markdown)",
"Media Note": "Media Note", "Media Note": "Media Note",
"No publication events found for rebroadcast": "No publication events found for rebroadcast", "No publication events found for rebroadcast": "No publication events found for rebroadcast",
"No publication events were accepted by any relay": "No publication events were accepted by any relay", "No publication events were accepted by any relay": "No publication events were accepted by any relay",

8
src/i18n/locales/pl.ts

@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc", "Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown", "Article exported as Markdown": "Article exported as Markdown",
"Article title (optional)": "Article title (optional)", "Article title (optional)": "Article title (optional)",
articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….", articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, nostr-specification-…, publication-content-….",
Audio: "Audio", Audio: "Audio",
Author: "Author", Author: "Author",
"Author is required for reading groups": "Author is required for reading groups", "Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation", "New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message", "New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article", "New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)", "New Nostr Specification": "New Nostr Specification",
Newest: "Newest", Newest: "Newest",
"No JSON available": "No JSON available", "No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file", "No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed", "Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)", "Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)", "Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)",
"Wiki Article (Markdown)": "Wiki Article (Markdown)", "Nostr Specification": "Nostr Specification",
"You can only delete your own notes": "You can only delete your own notes", "You can only delete your own notes": "You can only delete your own notes",
"You must be logged in to create a thread": "You must be logged in to create a thread", "You must be logged in to create a thread": "You must be logged in to create a thread",
"You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.", "You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.",
@ -2020,7 +2020,7 @@ export default {
"Invalid event fields": "Invalid event fields", "Invalid event fields": "Invalid event fields",
"Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).", "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).",
"Markdown article (NIP-23)": "Markdown article (NIP-23)", "Markdown article (NIP-23)": "Markdown article (NIP-23)",
"Markdown wiki contribution": "Markdown wiki contribution", nostrSpecificationContribution: "NIP or spec document for relay publication (Markdown)",
"Media Note": "Media Note", "Media Note": "Media Note",
"No publication events found for rebroadcast": "No publication events found for rebroadcast", "No publication events found for rebroadcast": "No publication events found for rebroadcast",
"No publication events were accepted by any relay": "No publication events were accepted by any relay", "No publication events were accepted by any relay": "No publication events were accepted by any relay",

8
src/i18n/locales/ru.ts

@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc", "Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown", "Article exported as Markdown": "Article exported as Markdown",
"Article title (optional)": "Article title (optional)", "Article title (optional)": "Article title (optional)",
articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….", articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, nostr-specification-…, publication-content-….",
Audio: "Audio", Audio: "Audio",
Author: "Author", Author: "Author",
"Author is required for reading groups": "Author is required for reading groups", "Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation", "New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message", "New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article", "New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)", "New Nostr Specification": "New Nostr Specification",
Newest: "Newest", Newest: "Newest",
"No JSON available": "No JSON available", "No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file", "No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed", "Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)", "Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)", "Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)",
"Wiki Article (Markdown)": "Wiki Article (Markdown)", "Nostr Specification": "Nostr Specification",
"You can only delete your own notes": "You can only delete your own notes", "You can only delete your own notes": "You can only delete your own notes",
"You must be logged in to create a thread": "You must be logged in to create a thread", "You must be logged in to create a thread": "You must be logged in to create a thread",
"You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.", "You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.",
@ -2020,7 +2020,7 @@ export default {
"Invalid event fields": "Invalid event fields", "Invalid event fields": "Invalid event fields",
"Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).", "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).",
"Markdown article (NIP-23)": "Markdown article (NIP-23)", "Markdown article (NIP-23)": "Markdown article (NIP-23)",
"Markdown wiki contribution": "Markdown wiki contribution", nostrSpecificationContribution: "NIP or spec document for relay publication (Markdown)",
"Media Note": "Media Note", "Media Note": "Media Note",
"No publication events found for rebroadcast": "No publication events found for rebroadcast", "No publication events found for rebroadcast": "No publication events found for rebroadcast",
"No publication events were accepted by any relay": "No publication events were accepted by any relay", "No publication events were accepted by any relay": "No publication events were accepted by any relay",

8
src/i18n/locales/tr.ts

@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc", "Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown", "Article exported as Markdown": "Article exported as Markdown",
"Article title (optional)": "Article title (optional)", "Article title (optional)": "Article title (optional)",
articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….", articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, nostr-specification-…, publication-content-….",
Audio: "Audio", Audio: "Audio",
Author: "Author", Author: "Author",
"Author is required for reading groups": "Author is required for reading groups", "Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation", "New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message", "New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article", "New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)", "New Nostr Specification": "New Nostr Specification",
Newest: "Newest", Newest: "Newest",
"No JSON available": "No JSON available", "No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file", "No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed", "Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)", "Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)", "Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)",
"Wiki Article (Markdown)": "Wiki Article (Markdown)", "Nostr Specification": "Nostr Specification",
"You can only delete your own notes": "You can only delete your own notes", "You can only delete your own notes": "You can only delete your own notes",
"You must be logged in to create a thread": "You must be logged in to create a thread", "You must be logged in to create a thread": "You must be logged in to create a thread",
"You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.", "You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.",
@ -2020,7 +2020,7 @@ export default {
"Invalid event fields": "Invalid event fields", "Invalid event fields": "Invalid event fields",
"Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).", "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).",
"Markdown article (NIP-23)": "Markdown article (NIP-23)", "Markdown article (NIP-23)": "Markdown article (NIP-23)",
"Markdown wiki contribution": "Markdown wiki contribution", nostrSpecificationContribution: "NIP or spec document for relay publication (Markdown)",
"Media Note": "Media Note", "Media Note": "Media Note",
"No publication events found for rebroadcast": "No publication events found for rebroadcast", "No publication events found for rebroadcast": "No publication events found for rebroadcast",
"No publication events were accepted by any relay": "No publication events were accepted by any relay", "No publication events were accepted by any relay": "No publication events were accepted by any relay",

8
src/i18n/locales/zh.ts

@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc", "Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown", "Article exported as Markdown": "Article exported as Markdown",
"Article title (optional)": "Article title (optional)", "Article title (optional)": "Article title (optional)",
articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….", articleDTagDefaultHint: "Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, nostr-specification-…, publication-content-….",
Audio: "Audio", Audio: "Audio",
Author: "Author", Author: "Author",
"Author is required for reading groups": "Author is required for reading groups", "Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation", "New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message", "New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article", "New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)", "New Nostr Specification": "New Nostr Specification",
Newest: "Newest", Newest: "Newest",
"No JSON available": "No JSON available", "No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file", "No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed", "Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)", "Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)", "Wiki Article (AsciiDoc)": "Wiki Article (AsciiDoc)",
"Wiki Article (Markdown)": "Wiki Article (Markdown)", "Nostr Specification": "Nostr Specification",
"You can only delete your own notes": "You can only delete your own notes", "You can only delete your own notes": "You can only delete your own notes",
"You must be logged in to create a thread": "You must be logged in to create a thread", "You must be logged in to create a thread": "You must be logged in to create a thread",
"You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.", "You need to add at least one media server in order to upload media files.": "You need to add at least one media server in order to upload media files.",
@ -2020,7 +2020,7 @@ export default {
"Invalid event fields": "Invalid event fields", "Invalid event fields": "Invalid event fields",
"Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).", "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).": "Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).",
"Markdown article (NIP-23)": "Markdown article (NIP-23)", "Markdown article (NIP-23)": "Markdown article (NIP-23)",
"Markdown wiki contribution": "Markdown wiki contribution", nostrSpecificationContribution: "NIP or spec document for relay publication (Markdown)",
"Media Note": "Media Note", "Media Note": "Media Note",
"No publication events found for rebroadcast": "No publication events found for rebroadcast", "No publication events found for rebroadcast": "No publication events found for rebroadcast",
"No publication events were accepted by any relay": "No publication events were accepted by any relay", "No publication events were accepted by any relay": "No publication events were accepted by any relay",

4
src/lib/advanced-event-lab-slice.ts

@ -16,6 +16,10 @@ export function serializeLabSlice(slice: AdvancedEventLabSlice): string {
) )
} }
/**
* Accepts lab slice JSON or a fuller draft/event object; ignores unknown top-level fields
* (`created_at`, `pubkey`, `id`, etc.).
*/
export function parseLabSlice( export function parseLabSlice(
raw: string raw: string
): { ok: true; value: AdvancedEventLabSlice } | { ok: false; error: string } { ): { ok: true; value: AdvancedEventLabSlice } | { ok: false; error: string } {

26
src/lib/composer-extra-tags.test.ts

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import {
normalizeComposerExtraTags,
parseComposerTagValuesInput,
type ComposerExtraTagRow
} from './composer-extra-tags'
function row(tag: string[]): ComposerExtraTagRow {
return { id: '1', tag }
}
describe('normalizeComposerExtraTags', () => {
it('drops rows without a tag name', () => {
expect(normalizeComposerExtraTags([row(['', 'x']), row(['t', 'a'])])).toEqual([['t', 'a']])
})
it('trims tag name and values', () => {
expect(normalizeComposerExtraTags([row([' k ', ' 1 ', '2 '])])).toEqual([['k', '1', '2']])
})
})
describe('parseComposerTagValuesInput', () => {
it('splits on newlines and drops empty lines', () => {
expect(parseComposerTagValuesInput('a\n\n b \n')).toEqual(['a', 'b'])
})
})

25
src/lib/composer-extra-tags.ts

@ -0,0 +1,25 @@
export type ComposerExtraTagRow = { id: string; tag: string[] }
export function newComposerTagRow(tag: string[] = ['', '']): ComposerExtraTagRow {
return { id: crypto.randomUUID(), tag: [...tag] }
}
/** Normalize user rows into valid Nostr tag arrays (non-empty tag name). */
export function normalizeComposerExtraTags(rows: ComposerExtraTagRow[]): string[][] {
return rows
.map((row) => row.tag)
.filter((tag) => Array.isArray(tag) && String(tag[0] ?? '').trim())
.map((tag) => [String(tag[0]).trim(), ...tag.slice(1).map((v) => String(v ?? '').trim())])
}
/** Join tag[1..] for a single-line editor (commas in values are preserved via multiline instead). */
export function formatComposerTagValuesInput(tag: string[]): string {
return tag.slice(1).join('\n')
}
export function parseComposerTagValuesInput(raw: string): string[] {
return raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}

13
src/lib/draft-event.ts

@ -2167,14 +2167,15 @@ export async function createWikiArticleDraftEvent(
}) })
} }
export async function createWikiArticleMarkdownDraftEvent( export async function createNostrSpecificationDraftEvent(
content: string, content: string,
mentions: string[], mentions: string[],
options: { options: {
dTag: string dTag: string
title?: string title?: string
summary?: string summary?: string
image?: string /** NIP/kind numbers this specification applies to (each becomes a `k` tag). */
affectedKinds?: number[]
topics?: string[] topics?: string[]
addClientTag?: boolean addClientTag?: boolean
isNsfw?: boolean isNsfw?: boolean
@ -2195,8 +2196,10 @@ export async function createWikiArticleMarkdownDraftEvent(
if (options.summary) { if (options.summary) {
tags.push(['summary', options.summary]) tags.push(['summary', options.summary])
} }
if (options.image) { if (options.affectedKinds?.length) {
tags.push(['image', options.image]) for (const kindNum of options.affectedKinds) {
tags.push(['k', String(kindNum)])
}
} }
tags.push(...emojiTags) tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag))) tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
@ -2222,7 +2225,7 @@ export async function createWikiArticleMarkdownDraftEvent(
} }
return setDraftEventCache({ return setDraftEventCache({
kind: ExtendedKind.WIKI_ARTICLE_MARKDOWN, kind: ExtendedKind.NOSTR_SPECIFICATION,
content: transformedEmojisContent, content: transformedEmojisContent,
tags tags
}) })

4
src/lib/kind-description.ts

@ -42,8 +42,8 @@ export function getKindDescription(
return { number: 30023, description: 'Long-form Article' } return { number: 30023, description: 'Long-form Article' }
case ExtendedKind.WIKI_ARTICLE: case ExtendedKind.WIKI_ARTICLE:
return { number: 30818, description: 'Wiki Article (AsciiDoc)' } return { number: 30818, description: 'Wiki Article (AsciiDoc)' }
case ExtendedKind.WIKI_ARTICLE_MARKDOWN: case ExtendedKind.NOSTR_SPECIFICATION:
return { number: 30817, description: 'Wiki Article (Markdown)' } return { number: 30817, description: 'Nostr Specification' }
case ExtendedKind.PUBLICATION_CONTENT: case ExtendedKind.PUBLICATION_CONTENT:
return { number: 30041, description: 'Publication Content' } return { number: 30041, description: 'Publication Content' }
case ExtendedKind.CITATION_INTERNAL: case ExtendedKind.CITATION_INTERNAL:

2
src/lib/link.ts

@ -11,7 +11,7 @@ const ALEXANDRIA_PUBLICATION_NADDR_KINDS = new Set<number>([
ExtendedKind.PUBLICATION, ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT, ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN ExtendedKind.NOSTR_SPECIFICATION
]) ])
/** NIP-19 `naddr` for article-like replaceable events (`d` tag required). */ /** NIP-19 `naddr` for article-like replaceable events (`d` tag required). */

2
src/lib/markup-detection.ts

@ -13,7 +13,7 @@ export function detectMarkupType(content: string, eventKind?: number): MarkupTyp
return 'asciidoc' return 'asciidoc'
} }
// Wiki articles (30817) use markdown // Nostr specifications (30817) use markdown
if (eventKind === 30817) { if (eventKind === 30817) {
return 'advanced-markdown' return 'advanced-markdown'
} }

2
src/lib/merged-search-note-preview.ts

@ -7,7 +7,7 @@ import { kinds } from 'nostr-tools'
const DOC_KINDS = new Set<number>([ const DOC_KINDS = new Set<number>([
kinds.LongFormArticle, kinds.LongFormArticle,
ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN, ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.PUBLICATION, ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT ExtendedKind.PUBLICATION_CONTENT
]) ])

24
src/lib/nostr-spec-affected-kinds.test.ts

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { parseNostrSpecAffectedKinds } from './nostr-spec-affected-kinds'
describe('parseNostrSpecAffectedKinds', () => {
it('parses one kind per row and dedupes', () => {
expect(
parseNostrSpecAffectedKinds([
{ id: '1', value: '1' },
{ id: '2', value: ' 42 ' },
{ id: '3', value: '1' }
])
).toEqual([1, 42])
})
it('skips empty and invalid rows', () => {
expect(
parseNostrSpecAffectedKinds([
{ id: '1', value: '' },
{ id: '2', value: 'abc' },
{ id: '3', value: '-1' }
])
).toEqual([])
})
})

20
src/lib/nostr-spec-affected-kinds.ts

@ -0,0 +1,20 @@
export type NostrSpecAffectedKindRow = { id: string; value: string }
export function newNostrSpecAffectedKindRow(value = ''): NostrSpecAffectedKindRow {
return { id: crypto.randomUUID(), value }
}
/** Parse kind numbers from composer rows (one kind per line). */
export function parseNostrSpecAffectedKinds(rows: NostrSpecAffectedKindRow[]): number[] {
const seen = new Set<number>()
const out: number[] = []
for (const row of rows) {
const raw = row.value.trim()
if (!raw) continue
const n = Number.parseInt(raw, 10)
if (!Number.isInteger(n) || n < 0 || seen.has(n)) continue
seen.add(n)
out.push(n)
}
return out
}

2
src/lib/read-aloud.ts

@ -189,7 +189,7 @@ const KINDS_WITH_METADATA_TITLE = new Set<number>([
kinds.LongFormArticle, kinds.LongFormArticle,
ExtendedKind.PUBLICATION, ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT, ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE_MARKDOWN, ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.WIKI_ARTICLE ExtendedKind.WIKI_ARTICLE
]) ])

10
src/pages/secondary/NotePage/index.tsx

@ -83,8 +83,8 @@ function getEventTypeName(kind: number): string {
return 'Publication Content' return 'Publication Content'
case ExtendedKind.WIKI_ARTICLE: case ExtendedKind.WIKI_ARTICLE:
return 'Wiki Article' return 'Wiki Article'
case ExtendedKind.WIKI_ARTICLE_MARKDOWN: case ExtendedKind.NOSTR_SPECIFICATION:
return 'Wiki Article' return 'Nostr Specification'
case ExtendedKind.DISCUSSION: case ExtendedKind.DISCUSSION:
return 'Discussion' return 'Discussion'
case ExtendedKind.CALENDAR_EVENT_TIME: case ExtendedKind.CALENDAR_EVENT_TIME:
@ -245,8 +245,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
return 'Note: Publication' return 'Note: Publication'
case 30041: // ExtendedKind.PUBLICATION_CONTENT case 30041: // ExtendedKind.PUBLICATION_CONTENT
return 'Note: Publication Content' return 'Note: Publication Content'
case 30817: // ExtendedKind.WIKI_ARTICLE_MARKDOWN case 30817: // ExtendedKind.NOSTR_SPECIFICATION
return 'Note: Wiki Article' return 'Note: Nostr Specification'
case 30818: // ExtendedKind.WIKI_ARTICLE case 30818: // ExtendedKind.WIKI_ARTICLE
return 'Note: Wiki Article' return 'Note: Wiki Article'
case 20: // ExtendedKind.PICTURE case 20: // ExtendedKind.PICTURE
@ -294,7 +294,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
kinds.LongFormArticle, // 30023 kinds.LongFormArticle, // 30023
ExtendedKind.PUBLICATION, // 30040 ExtendedKind.PUBLICATION, // 30040
ExtendedKind.PUBLICATION_CONTENT, // 30041 ExtendedKind.PUBLICATION_CONTENT, // 30041
ExtendedKind.WIKI_ARTICLE_MARKDOWN, // 30817 ExtendedKind.NOSTR_SPECIFICATION, // 30817
ExtendedKind.WIKI_ARTICLE // 30818 ExtendedKind.WIKI_ARTICLE // 30818
] ]
if (articleKinds.includes(finalEvent.kind)) { if (articleKinds.includes(finalEvent.kind)) {

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

@ -111,7 +111,7 @@ const EMBEDDED_NOTE_PREFETCH_ON_INGEST_KINDS = new Set<number>([
ExtendedKind.GENERIC_REPOST, ExtendedKind.GENERIC_REPOST,
ExtendedKind.PUBLICATION_CONTENT, ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN, ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,
ExtendedKind.DISCUSSION ExtendedKind.DISCUSSION

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

@ -1106,7 +1106,7 @@ class IndexedDbService {
// PAYMENT_INFO (10133), RSS_FEED_LIST (10895), etc. are in the 10000-20000 range // PAYMENT_INFO (10133), RSS_FEED_LIST (10895), etc. are in the 10000-20000 range
if ( if (
[kinds.Metadata, kinds.Contacts, ExtendedKind.PAYMENT_INFO].includes(event.kind) || [kinds.Metadata, kinds.Contacts, ExtendedKind.PAYMENT_INFO].includes(event.kind) ||
(event.kind >= 10000 && event.kind < 20000 && event.kind !== ExtendedKind.PUBLICATION && event.kind !== ExtendedKind.PUBLICATION_CONTENT && event.kind !== ExtendedKind.WIKI_ARTICLE && event.kind !== ExtendedKind.WIKI_ARTICLE_MARKDOWN && event.kind !== kinds.LongFormArticle) (event.kind >= 10000 && event.kind < 20000 && event.kind !== ExtendedKind.PUBLICATION && event.kind !== ExtendedKind.PUBLICATION_CONTENT && event.kind !== ExtendedKind.WIKI_ARTICLE && event.kind !== ExtendedKind.NOSTR_SPECIFICATION && event.kind !== kinds.LongFormArticle)
) { ) {
return this.getReplaceableEventKey(event.pubkey) return this.getReplaceableEventKey(event.pubkey)
} }
@ -1169,7 +1169,7 @@ class IndexedDbService {
case ExtendedKind.PUBLICATION: case ExtendedKind.PUBLICATION:
case ExtendedKind.PUBLICATION_CONTENT: case ExtendedKind.PUBLICATION_CONTENT:
case ExtendedKind.WIKI_ARTICLE: case ExtendedKind.WIKI_ARTICLE:
case ExtendedKind.WIKI_ARTICLE_MARKDOWN: case ExtendedKind.NOSTR_SPECIFICATION:
case kinds.LongFormArticle: case kinds.LongFormArticle:
return StoreNames.PUBLICATION_EVENTS return StoreNames.PUBLICATION_EVENTS
case ExtendedKind.BADGE_DEFINITION: case ExtendedKind.BADGE_DEFINITION:

6
src/services/local-storage.service.ts

@ -326,13 +326,13 @@ class LocalStorageService {
} }
} }
if (showKindsVersion < 12) { if (showKindsVersion < 12) {
// Add WIKI_ARTICLE_MARKDOWN (30817) for users who already have long-form articles (30023) or // Add NOSTR_SPECIFICATION (30817) for users who already have long-form articles (30023) or
// wiki articles (30818) enabled — it was omitted from the earlier v4 migration. // wiki articles (30818) enabled — it was omitted from the earlier v4 migration.
if ( if (
(showKinds.includes(kinds.LongFormArticle) || showKinds.includes(ExtendedKind.WIKI_ARTICLE)) && (showKinds.includes(kinds.LongFormArticle) || showKinds.includes(ExtendedKind.WIKI_ARTICLE)) &&
!showKinds.includes(ExtendedKind.WIKI_ARTICLE_MARKDOWN) !showKinds.includes(ExtendedKind.NOSTR_SPECIFICATION)
) { ) {
showKinds.push(ExtendedKind.WIKI_ARTICLE_MARKDOWN) showKinds.push(ExtendedKind.NOSTR_SPECIFICATION)
} }
} }
if (showKindsVersion < 13) { if (showKindsVersion < 13) {

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

@ -51,7 +51,7 @@ export const NADDR_KINDS = [
ExtendedKind.CALENDAR_EVENT_TIME, ExtendedKind.CALENDAR_EVENT_TIME,
ExtendedKind.PUBLICATION, ExtendedKind.PUBLICATION,
ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN, ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.PUBLICATION_CONTENT, ExtendedKind.PUBLICATION_CONTENT,
kinds.LongFormArticle, kinds.LongFormArticle,
] as const ] as const

Loading…
Cancel
Save