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. 61
      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. 22
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  7. 6
      src/components/NoteOptions/useMenuActions.tsx
  8. 294
      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

61
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -53,6 +53,12 @@ import { useTranslation } from 'react-i18next' @@ -53,6 +53,12 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { AdvancedEventLabMarkupToolbar } from './AdvancedEventLabMarkupToolbar'
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 postEditorCache from '@/services/post-editor-cache.service'
import type { TEmoji } from '@/types'
@ -182,8 +188,7 @@ export type AdvancedEventLabDialogProps = { @@ -182,8 +188,7 @@ export type AdvancedEventLabDialogProps = {
formatToolbar?: ReactNode
/**
* 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)
* clears this draft so the next open is seeded from TipTap again.
* so a **reload** can restore in-progress lab work. The draft is kept on dismiss; clear happens on publish or composer Clear.
*/
draftPersistenceKey?: string | null
/** 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({ @@ -239,13 +244,12 @@ export default function AdvancedEventLabDialog({
const labPersistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const previewDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
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. */
const LAB_DRAFT_DEBOUNCE_MS = 500
const [previewDoc, setPreviewDoc] = useState('')
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. */
const labEditorMountFingerprint =
@ -358,21 +362,14 @@ export default function AdvancedEventLabDialog({ @@ -358,21 +362,14 @@ export default function AdvancedEventLabDialog({
const handleDialogOpenChange = useCallback(
(next: boolean) => {
if (!next) {
if (!skipClearLabDraftOnCloseRef.current) {
if (labPersistTimerRef.current) {
clearTimeout(labPersistTimerRef.current)
labPersistTimerRef.current = null
}
const key = draftPersistenceKeyRef.current
if (key) {
postEditorCache.clearAdvancedLabDraft(key)
}
flushLabDraftNow(key, true)
}
skipClearLabDraftOnCloseRef.current = false
}
onOpenChange(next)
},
[onOpenChange]
[onOpenChange, flushLabDraftNow]
)
const scheduleLabDraftPersist = useCallback(() => {
@ -395,6 +392,18 @@ export default function AdvancedEventLabDialog({ @@ -395,6 +392,18 @@ export default function AdvancedEventLabDialog({
}, 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(
(slice: AdvancedEventLabSlice) => {
const v = markupView.current
@ -403,7 +412,13 @@ export default function AdvancedEventLabDialog({ @@ -403,7 +412,13 @@ export default function AdvancedEventLabDialog({
changes: { from: 0, to: v.state.doc.length, insert: slice.content },
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)
scheduleLabDraftPersist()
if (isLanguageToolConfigured()) requestAdvancedLabGrammarLint(v)
@ -614,12 +629,14 @@ export default function AdvancedEventLabDialog({ @@ -614,12 +629,14 @@ export default function AdvancedEventLabDialog({
destroyEditors()
const editableTags = stripImwaldAttributionTags(initial.tags)
const baseSlice: AdvancedEventLabSlice = {
kind: initial.kind,
content: initial.content,
tags: initial.tags.map((row) => [...row])
tags: editableTags.map((row) => [...row])
}
sliceRef.current = baseSlice
setLabTagRows(labTagsToEditableRows(editableTags))
setPreviewDoc(baseSlice.content)
const markupLang: Extension =
@ -799,13 +816,14 @@ export default function AdvancedEventLabDialog({ @@ -799,13 +816,14 @@ export default function AdvancedEventLabDialog({
const payload: AdvancedEventLabSlice = {
kind,
content,
tags: s.tags.map((row) => [...row])
tags: editableRowsToLabTags(labTagRows)
}
skipClearLabDraftOnCloseRef.current = true
onApply(payload)
if (draftPersistenceKeyRef.current) {
postEditorCache.clearAdvancedLabDraft(draftPersistenceKeyRef.current)
const key = draftPersistenceKeyRef.current
if (key) {
postEditorCache.setAdvancedLabDraft(key, payload)
postEditorCache.flushPersist()
}
onApply(payload)
if (undoSessionId) {
clearLabCheckpointsSession(undoSessionId)
labCheckpointsRef.current = []
@ -947,7 +965,8 @@ export default function AdvancedEventLabDialog({ @@ -947,7 +965,8 @@ export default function AdvancedEventLabDialog({
<div className="mt-2 border-t bg-muted/20 px-2 py-2">{formatToolbar}</div>
) : 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">
<Button type="button" variant="outline" onClick={() => handleDialogOpenChange(false)}>
{t('Advanced lab cancel undo')}

143
src/components/AdvancedEventLab/AdvancedEventLabTagsEditor.tsx

@ -0,0 +1,143 @@ @@ -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 @@ -18,7 +18,7 @@ const KIND_1 = kinds.ShortTextNote
const KIND_1111 = ExtendedKind.COMMENT
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: [ExtendedKind.POLL], label: 'Polls' },
{ kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' },

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

@ -285,7 +285,7 @@ export default function Highlight({ @@ -285,7 +285,7 @@ export default function Highlight({
ExtendedKind.PICTURE, // Has PictureNotePreview
ExtendedKind.PUBLICATION, // Has PublicationCard
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_COMMENT, // Has special card
]

4
src/components/Note/index.tsx

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

22
src/components/NoteOptions/EditOrCloneEventDialog.tsx

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

6
src/components/NoteOptions/useMenuActions.tsx

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

294
src/components/PostEditor/PostContent.tsx

@ -28,15 +28,15 @@ import { @@ -28,15 +28,15 @@ import {
createVideoDraftEvent,
createLongFormArticleDraftEvent,
createWikiArticleDraftEvent,
createWikiArticleMarkdownDraftEvent,
createNostrSpecificationDraftEvent,
createPublicationContentDraftEvent,
createCitationInternalDraftEvent,
createCitationExternalDraftEvent,
createCitationHardcopyDraftEvent,
createCitationPromptDraftEvent,
applyImwaldAttributionTags,
collectUploadImetaTagsForContentUrls,
mergeUploadImetaTagsInto
mergeUploadImetaTagsInto,
stripImwaldAttributionTags
} from '@/lib/draft-event'
import {
ExtendedKind,
@ -58,6 +58,8 @@ import { @@ -58,6 +58,8 @@ import {
Check,
ChevronDown,
ListTodo,
Plus,
Trash2,
MessageCircle,
MessagesSquare,
X,
@ -108,6 +110,11 @@ import PollEditor from './PollEditor' @@ -108,6 +110,11 @@ import PollEditor from './PollEditor'
import PostOptions from './PostOptions'
import PostRelaySelector from './PostRelaySelector'
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
import {
newNostrSpecAffectedKindRow,
parseNostrSpecAffectedKinds,
type NostrSpecAffectedKindRow
} from '@/lib/nostr-spec-affected-kinds'
import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDialog'
import Uploader from './Uploader'
import HighlightEditor, { HighlightData } from './HighlightEditor'
@ -119,7 +126,7 @@ import { @@ -119,7 +126,7 @@ import {
PostEditorFormatToolbar,
type PostEditorFormatToolbarUploadHandlers
} 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'
/** 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({ @@ -249,7 +256,6 @@ export default function PostContent({
)
const [text, setText] = useState('')
const textareaRef = useRef<TPostTextareaHandle>(null)
const labTagOverrideRef = useRef<string[][] | null>(null)
const [advancedLabOpen, setAdvancedLabOpen] = useState(false)
const advancedLabOpenRef = useRef(false)
useEffect(() => {
@ -323,8 +329,11 @@ export default function PostContent({ @@ -323,8 +329,11 @@ export default function PostContent({
const [mediaUrl, setMediaUrl] = useState<string>('')
const [isLongFormArticle, setIsLongFormArticle] = useState(false)
const [isWikiArticle, setIsWikiArticle] = useState(false)
const [isWikiArticleMarkdown, setIsWikiArticleMarkdown] = useState(false)
const [isNostrSpecification, setIsNostrSpecification] = useState(false)
const [isPublicationContent, setIsPublicationContent] = useState(false)
const [nostrSpecAffectedKindRows, setNostrSpecAffectedKindRows] = useState<NostrSpecAffectedKindRow[]>(
() => [newNostrSpecAffectedKindRow()]
)
const [articleTitle, setArticleTitle] = useState('')
const [articleDTag, setArticleDTag] = useState('')
const [articleImage, setArticleImage] = useState('')
@ -385,11 +394,11 @@ export default function PostContent({ @@ -385,11 +394,11 @@ export default function PostContent({
useEffect(() => {
const isArticle =
isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent
isLongFormArticle || isWikiArticle || isNostrSpecification || isPublicationContent
if (!isArticle) {
articleDTagFallbackRef.current = null
}
}, [isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, isPublicationContent])
}, [isLongFormArticle, isWikiArticle, isNostrSpecification, isPublicationContent])
useEffect(() => {
mediaNoteKindRef.current = mediaNoteKind
@ -632,8 +641,8 @@ export default function PostContent({ @@ -632,8 +641,8 @@ export default function PostContent({
return kinds.LongFormArticle
} else if (isWikiArticle) {
return ExtendedKind.WIKI_ARTICLE
} else if (isWikiArticleMarkdown) {
return ExtendedKind.WIKI_ARTICLE_MARKDOWN
} else if (isNostrSpecification) {
return ExtendedKind.NOSTR_SPECIFICATION
} else if (isPublicationContent) {
return ExtendedKind.PUBLICATION_CONTENT
} else if (isCitationInternal) {
@ -659,7 +668,7 @@ export default function PostContent({ @@ -659,7 +668,7 @@ export default function PostContent({
isDiscussionThread,
isLongFormArticle,
isWikiArticle,
isWikiArticleMarkdown,
isNostrSpecification,
isPublicationContent,
isCitationInternal,
isCitationExternal,
@ -740,6 +749,48 @@ export default function PostContent({ @@ -740,6 +749,48 @@ export default function PostContent({
return [['i', c], ['I', c]]
}, [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
const createDraftEvent = useCallback(async (cleanedText: string): Promise<any> => {
const uploadImetaTagsOpt = mediaImetaTags.length > 0 ? mediaImetaTags : undefined
@ -896,7 +947,7 @@ export default function PostContent({ @@ -896,7 +947,7 @@ export default function PostContent({
// Articles
const isArticleDraft =
isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent
isLongFormArticle || isWikiArticle || isNostrSpecification || isPublicationContent
let effectiveArticleDTag = ''
if (isArticleDraft) {
const trimmedDTag = articleDTag.trim()
@ -907,8 +958,8 @@ export default function PostContent({ @@ -907,8 +958,8 @@ export default function PostContent({
? 'longform-article'
: isWikiArticle
? 'wiki-article'
: isWikiArticleMarkdown
? 'wiki-markdown'
: isNostrSpecification
? 'nostr-specification'
: 'publication-content'
const prev = articleDTagFallbackRef.current
if (!prev || prev.slug !== slug) {
@ -949,12 +1000,13 @@ export default function PostContent({ @@ -949,12 +1000,13 @@ export default function PostContent({
addQuietTag,
quietDays
})
} else if (isWikiArticleMarkdown) {
return await createWikiArticleMarkdownDraftEvent(cleanedText, mentions, {
} else if (isNostrSpecification) {
const affectedKinds = parseNostrSpecAffectedKinds(nostrSpecAffectedKindRows)
return await createNostrSpecificationDraftEvent(cleanedText, mentions, {
dTag: effectiveArticleDTag,
title: articleTitle.trim() || undefined,
summary: articleSummary.trim() || undefined,
image: articleImage.trim() || undefined,
affectedKinds: affectedKinds.length > 0 ? affectedKinds : undefined,
topics: topics.length > 0 ? topics : undefined,
addClientTag,
isNsfw,
@ -1125,7 +1177,7 @@ export default function PostContent({ @@ -1125,7 +1177,7 @@ export default function PostContent({
threadReadingSubject,
isLongFormArticle,
isWikiArticle,
isWikiArticleMarkdown,
isNostrSpecification,
isPublicationContent,
isCitationInternal,
isCitationExternal,
@ -1143,62 +1195,12 @@ export default function PostContent({ @@ -1143,62 +1195,12 @@ export default function PostContent({
articleTitle,
articleImage,
articleSubject,
nostrSpecAffectedKindRows,
articleSummary,
pubkey,
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(
() =>
postEditorCache.generateCacheKey({
@ -1209,6 +1211,26 @@ export default function PostContent({ @@ -1209,6 +1211,26 @@ export default function PostContent({
[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 () => {
await checkLogin(async () => {
if (!pubkey) {
@ -1220,21 +1242,20 @@ export default function PostContent({ @@ -1220,21 +1242,20 @@ export default function PostContent({
await yieldForPaintBeforeHeavyWork()
const body = textareaRef.current?.getText() ?? text
const cleanedText = rewritePlainTextHttpUrls(body)
let d = await createDraftEvent(cleanedText)
d = applyLabTagOverrideToDraft(d)
let d = await finalizeDraftEvent(cleanedText)
const labKey = advancedLabPersistenceKey
const saved = postEditorCache.getAdvancedLabDraft(labKey)
if (saved && saved.kind === d.kind) {
setAdvancedLabInitial({
kind: saved.kind,
content: saved.content,
tags: saved.tags.map((row: string[]) => [...row])
tags: stripImwaldAttributionTags(saved.tags).map((row: string[]) => [...row])
})
} else {
setAdvancedLabInitial({
kind: d.kind,
content: d.content,
tags: (d.tags ?? []).map((row: string[]) => [...row])
tags: stripImwaldAttributionTags(d.tags ?? []).map((row: string[]) => [...row])
})
}
setAdvancedLabOpen(true)
@ -1246,8 +1267,7 @@ export default function PostContent({ @@ -1246,8 +1267,7 @@ export default function PostContent({
checkLogin,
pubkey,
text,
createDraftEvent,
applyLabTagOverrideToDraft,
finalizeDraftEvent,
advancedLabPersistenceKey,
t
])
@ -1322,8 +1342,7 @@ export default function PostContent({ @@ -1322,8 +1342,7 @@ export default function PostContent({
}
// Create draft event using shared function
draftEvent = await createDraftEvent(cleanedText)
draftEvent = applyLabTagOverrideToDraft(draftEvent)
draftEvent = await finalizeDraftEvent(cleanedText)
const publishSuccessMessage = parentEvent
? t('Reply published')
@ -1463,7 +1482,7 @@ export default function PostContent({ @@ -1463,7 +1482,7 @@ export default function PostContent({
setIsHighlight(false)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
@ -1487,7 +1506,7 @@ export default function PostContent({ @@ -1487,7 +1506,7 @@ export default function PostContent({
setIsHighlight(false)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
@ -1508,7 +1527,7 @@ export default function PostContent({ @@ -1508,7 +1527,7 @@ export default function PostContent({
setIsHighlight(false)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
@ -1598,7 +1617,7 @@ export default function PostContent({ @@ -1598,7 +1617,7 @@ export default function PostContent({
setIsHighlight(false)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
@ -1631,7 +1650,7 @@ export default function PostContent({ @@ -1631,7 +1650,7 @@ export default function PostContent({
!isHighlight &&
!isLongFormArticle &&
!isWikiArticle &&
!isWikiArticleMarkdown &&
!isNostrSpecification &&
!isPublicationContent &&
!isCitationInternal &&
!isCitationExternal &&
@ -1646,7 +1665,7 @@ export default function PostContent({ @@ -1646,7 +1665,7 @@ export default function PostContent({
isHighlight,
isLongFormArticle,
isWikiArticle,
isWikiArticleMarkdown,
isNostrSpecification,
isPublicationContent,
isCitationInternal,
isCitationExternal,
@ -1667,7 +1686,7 @@ export default function PostContent({ @@ -1667,7 +1686,7 @@ export default function PostContent({
setIsPublicMessage(false)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
@ -2107,7 +2126,7 @@ export default function PostContent({ @@ -2107,7 +2126,7 @@ export default function PostContent({
setIsHighlight(false)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
@ -2138,13 +2157,19 @@ export default function PostContent({ @@ -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
setIsLongFormArticle(type === 'longform')
setIsWikiArticle(type === 'wiki')
setIsWikiArticleMarkdown(type === 'wiki-markdown')
setIsNostrSpecification(type === 'nostr-specification')
setIsPublicationContent(type === 'publication')
if (type === 'nostr-specification') {
setArticleImage('')
setNostrSpecAffectedKindRows((rows) =>
rows.length > 0 ? rows : [newNostrSpecAffectedKindRow()]
)
}
// Clear other types
setIsPoll(false)
@ -2168,7 +2193,7 @@ export default function PostContent({ @@ -2168,7 +2193,7 @@ export default function PostContent({
}
// 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
} else {
setArticleTitle('')
@ -2194,7 +2219,7 @@ export default function PostContent({ @@ -2194,7 +2219,7 @@ export default function PostContent({
setMediaNoteKind(null)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
setIsDiscussionThread(false)
@ -2224,7 +2249,7 @@ export default function PostContent({ @@ -2224,7 +2249,7 @@ export default function PostContent({
setIsHighlight(false)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
@ -2265,6 +2290,7 @@ export default function PostContent({ @@ -2265,6 +2290,7 @@ export default function PostContent({
setCitationVersion('')
setCitationSummary('')
setCitationPromptLlm('')
setNostrSpecAffectedKindRows([newNostrSpecAffectedKindRow()])
setPollCreateData({
isMultipleChoice: false,
options: ['', ''],
@ -2312,8 +2338,8 @@ export default function PostContent({ @@ -2312,8 +2338,8 @@ export default function PostContent({
return t('New Long-form Article')
} else if (determinedKind === ExtendedKind.WIKI_ARTICLE) {
return t('New Wiki Article')
} else if (determinedKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
return t('New Wiki Article (Markdown)')
} else if (determinedKind === ExtendedKind.NOSTR_SPECIFICATION) {
return t('New Nostr Specification')
} else if (determinedKind === ExtendedKind.PUBLICATION_CONTENT) {
return t('Take a note')
} else if (determinedKind === ExtendedKind.CITATION_INTERNAL) {
@ -2500,7 +2526,7 @@ export default function PostContent({ @@ -2500,7 +2526,7 @@ export default function PostContent({
)}
{/* 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-2">
<Label htmlFor="article-dtag" className="text-sm font-medium">
@ -2527,6 +2553,7 @@ export default function PostContent({ @@ -2527,6 +2553,7 @@ export default function PostContent({
/>
</div>
{!isNostrSpecification && (
<div className="space-y-2">
<Label htmlFor="article-image" className="text-sm font-medium">
{t('Image URL')}
@ -2541,6 +2568,58 @@ export default function PostContent({ @@ -2541,6 +2568,58 @@ export default function PostContent({
{t('URL of the article cover image (optional)')}
</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">
<Label htmlFor="article-subject" className="text-sm font-medium">
@ -2948,12 +3027,8 @@ export default function PostContent({ @@ -2948,12 +3027,8 @@ export default function PostContent({
kind={getDeterminedKind}
highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined}
getDraftEventJson={getDraftEventJson}
expectedDraftKind={pubkey ? getDeterminedKind : undefined}
onApplyComposerDraftJson={pubkey ? applyComposerDraftJson : undefined}
extraPreviewTags={
isDiscussionThread && !parentEvent ? discussionPreviewExtraTags : rssReplyExtraPreviewTags
}
extraPreviewTags={mergedExtraPreviewTags}
articleMetadata={articlePreviewMetadata}
addClientTag={addClientTag}
mediaImetaTags={mediaImetaTags}
mediaUrl={mediaUrl}
@ -2961,7 +3036,7 @@ export default function PostContent({ @@ -2961,7 +3036,7 @@ export default function PostContent({
const ActiveIcon =
isLongFormArticle ? FileText :
isWikiArticle ? FileText :
isWikiArticleMarkdown ? FileText :
isNostrSpecification ? FileText :
isPublicationContent ? Book :
isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt ? Quote :
isHighlight ? Highlighter :
@ -2973,7 +3048,7 @@ export default function PostContent({ @@ -2973,7 +3048,7 @@ export default function PostContent({
const activeLabel =
isLongFormArticle ? t('Long-form Article') :
isWikiArticle ? t('Wiki Article (AsciiDoc)') :
isWikiArticleMarkdown ? t('Wiki Article (Markdown)') :
isNostrSpecification ? t('Nostr Specification') :
isPublicationContent ? t('Publication Note') :
isCitationInternal ? t('Internal Citation') :
isCitationExternal ? t('External Citation') :
@ -3097,13 +3172,15 @@ export default function PostContent({ @@ -3097,13 +3172,15 @@ export default function PostContent({
</div>
{isWikiArticle && <Check className="h-4 w-4 shrink-0 text-primary" />}
</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" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Wiki Article (Markdown)')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Markdown wiki contribution')}</span>
<span className="font-medium leading-none">{t('Nostr Specification')}</span>
<span className="text-xs text-muted-foreground mt-0.5">
{t('nostrSpecificationContribution')}
</span>
</div>
{isWikiArticleMarkdown && <Check className="h-4 w-4 shrink-0 text-primary" />}
{isNostrSpecification && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
{hasPrivateRelaysAvailable && (
<DropdownMenuItem onClick={() => handleArticleToggle('publication')} className="gap-3 py-2 cursor-pointer">
@ -3570,7 +3647,12 @@ export default function PostContent({ @@ -3570,7 +3647,12 @@ export default function PostContent({
/>
}
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)
}}
/>

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

@ -47,6 +47,8 @@ export default function Preview({ @@ -47,6 +47,8 @@ export default function Preview({
image?: string
dTag?: 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). */
extraPreviewTags?: string[][]
@ -138,7 +140,7 @@ export default function Preview({ @@ -138,7 +140,7 @@ export default function Preview({
tags.push(...mediaImetaTags)
}
// 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) {
tags.push(['d', articleMetadata.dTag])
}
@ -148,9 +150,17 @@ export default function Preview({ @@ -148,9 +150,17 @@ export default function Preview({
if (articleMetadata.summary) {
tags.push(['summary', articleMetadata.summary])
}
if (articleMetadata.image) {
if (kind !== ExtendedKind.NOSTR_SPECIFICATION && 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) {
const normalizedTopics = articleMetadata.topics
.map(topic => normalizeTopic(topic.trim()))
@ -249,8 +259,8 @@ export default function Preview({ @@ -249,8 +259,8 @@ export default function Preview({
)
}
// For WikiArticleMarkdown, use MarkdownArticle
if (kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
// Nostr Specification (30817) uses MarkdownArticle
if (kind === ExtendedKind.NOSTR_SPECIFICATION) {
return withClientBadge(
<Card className={cn('p-3', className, selectableClass)}>
<MarkdownArticle event={fakeEvent} hideMetadata={true} lazyMedia={false} />

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

@ -1,4 +1,3 @@ @@ -1,4 +1,3 @@
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { parseEditorJsonToText, plainTextToTipTapDoc } from '@/lib/tiptap'
import { cn } from '@/lib/utils'
@ -18,7 +17,6 @@ import { @@ -18,7 +17,6 @@ import {
Dispatch,
forwardRef,
SetStateAction,
useEffect,
useImperativeHandle,
useMemo,
useRef,
@ -33,10 +31,6 @@ import mentionSuggestion from './Mention/suggestion' @@ -33,10 +31,6 @@ import mentionSuggestion from './Mention/suggestion'
import Preview from './Preview'
import { HighlightData } from '../HighlightEditor'
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 = {
appendText: (text: string, addNewline?: boolean) => void
@ -74,10 +68,6 @@ const PostTextarea = forwardRef< @@ -74,10 +68,6 @@ const PostTextarea = forwardRef<
highlightData?: HighlightData
pollCreateData?: import('@/types').TPollCreateData
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[][]
mediaUrl?: string
articleMetadata?: {
@ -86,6 +76,7 @@ const PostTextarea = forwardRef< @@ -86,6 +76,7 @@ const PostTextarea = forwardRef<
image?: string
dTag?: string
topics?: string[]
affectedKinds?: number[]
}
extraPreviewTags?: string[][]
addClientTag?: boolean
@ -109,9 +100,6 @@ const PostTextarea = forwardRef< @@ -109,9 +100,6 @@ const PostTextarea = forwardRef<
highlightData,
pollCreateData,
headerActions,
getDraftEventJson,
expectedDraftKind,
onApplyComposerDraftJson,
mediaImetaTags,
mediaUrl,
articleMetadata,
@ -128,72 +116,10 @@ const PostTextarea = forwardRef< @@ -128,72 +116,10 @@ const PostTextarea = forwardRef<
const onUploadCompressProgressRef = useRef(onUploadCompressProgress)
onUploadCompressProgressRef.current = onUploadCompressProgress
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 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({
// TipTap + Radix Dialog/Tabs: defer init so React 18 does not warn about flushSync in a lifecycle.
immediatelyRender: false,
@ -351,9 +277,6 @@ const PostTextarea = forwardRef< @@ -351,9 +277,6 @@ const PostTextarea = forwardRef<
<TabsTrigger value="preview" title={t('Preview')}>
{t('Preview')}
</TabsTrigger>
<TabsTrigger value="json" title={t('Json')}>
{t('Json')}
</TabsTrigger>
</TabsList>
{headerActions && (
<div className="flex gap-1 items-center flex-wrap">
@ -361,7 +284,6 @@ const PostTextarea = forwardRef< @@ -361,7 +284,6 @@ const PostTextarea = forwardRef<
</div>
)}
</div>
{/* Editor always visible (no Edit tab). Keep mounted; only Preview/Json swap panels below. */}
<EditorContent className="tiptap" editor={editor} />
<TabsContent value="preview">
<div className="space-y-2">
@ -382,78 +304,6 @@ const PostTextarea = forwardRef< @@ -382,78 +304,6 @@ const PostTextarea = forwardRef<
/>
</div>
</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>
)
}

2
src/components/ReplyNoteList/index.tsx

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

6
src/components/WebPreview/index.tsx

@ -54,8 +54,8 @@ function getEventTypeName(kind: number): string { @@ -54,8 +54,8 @@ function getEventTypeName(kind: number): string {
return 'Publication Content'
case ExtendedKind.WIKI_ARTICLE:
return 'Wiki Article'
case ExtendedKind.WIKI_ARTICLE_MARKDOWN:
return 'Wiki Article'
case ExtendedKind.NOSTR_SPECIFICATION:
return 'Nostr Specification'
case ExtendedKind.DISCUSSION:
return 'Discussion'
default:
@ -509,7 +509,7 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -509,7 +509,7 @@ export default function WebPreview({ url, className }: { url: string; className?
// Determine which article component to use based on event kind
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)
const showContentPreview = eventSummary && previewEvent && previewEvent.content && (isAsciidocEvent || isMarkdownEvent)

15
src/constants.ts

@ -547,7 +547,8 @@ export const ExtendedKind = { @@ -547,7 +547,8 @@ export const ExtendedKind = {
ZAP_RECEIPT: 9735,
PUBLICATION: 30040,
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,
CITATION_INTERNAL: 30,
CITATION_EXTERNAL: 31,
@ -702,7 +703,7 @@ export const THREAD_BACKLINK_STREAM_KINDS: readonly number[] = [ @@ -702,7 +703,7 @@ export const THREAD_BACKLINK_STREAM_KINDS: readonly number[] = [
kinds.Highlights,
kinds.LongFormArticle,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.PUBLICATION_CONTENT,
kinds.Label,
kinds.Report,
@ -803,7 +804,7 @@ export function relayFilterIncludesSocialKindBlockedKind(filter: Filter): boolea @@ -803,7 +804,7 @@ export function relayFilterIncludesSocialKindBlockedKind(filter: Filter): boolea
const DOCUMENT_RELAY_KINDS: readonly number[] = [
kinds.LongFormArticle, // 30023
ExtendedKind.WIKI_ARTICLE, // 30818
ExtendedKind.WIKI_ARTICLE_MARKDOWN, // 30817
ExtendedKind.NOSTR_SPECIFICATION, // 30817
ExtendedKind.PUBLICATION_CONTENT, // 30041
ExtendedKind.PUBLICATION // 30040
]
@ -821,7 +822,7 @@ export function isDocumentRelayKind(kind: number): boolean { @@ -821,7 +822,7 @@ export function isDocumentRelayKind(kind: number): boolean {
*/
export const NIP_SEARCH_DOCUMENT_KINDS: readonly number[] = [
kinds.LongFormArticle,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT
@ -865,7 +866,7 @@ export const READ_ALOUD_KINDS: readonly number[] = [ @@ -865,7 +866,7 @@ export const READ_ALOUD_KINDS: readonly number[] = [
kinds.LongFormArticle,
ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.WIKI_ARTICLE
]
@ -910,7 +911,7 @@ export const SUPPORTED_KINDS = [ @@ -910,7 +911,7 @@ export const SUPPORTED_KINDS = [
kinds.LiveEvent,
ExtendedKind.PUBLICATION,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
ExtendedKind.NOSTR_SPECIFICATION,
// ExtendedKind.PUBLICATION_CONTENT, // Excluded - publication content should only be embedded in publications
// NIP-89 Application Handlers
ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION,
@ -941,7 +942,7 @@ export const PROFILE_PUBLICATIONS_TAB_KINDS: readonly number[] = [ @@ -941,7 +942,7 @@ export const PROFILE_PUBLICATIONS_TAB_KINDS: readonly number[] = [
ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN
ExtendedKind.NOSTR_SPECIFICATION
]
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 { @@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown",
"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",
Author: "Author",
"Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default { @@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)",
"New Nostr Specification": "New Nostr Specification",
Newest: "Newest",
"No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default { @@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"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 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.",
@ -2020,7 +2020,7 @@ export default { @@ -2020,7 +2020,7 @@ export default {
"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).",
"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",
"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",

8
src/i18n/locales/de.ts

@ -1436,7 +1436,7 @@ export default { @@ -1436,7 +1436,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown",
"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",
Author: "Author",
"Author is required for reading groups": "Author is required for reading groups",
@ -1684,7 +1684,7 @@ export default { @@ -1684,7 +1684,7 @@ export default {
"New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)",
"New Nostr Specification": "New Nostr Specification",
Newest: "Newest",
"No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -2057,7 +2057,7 @@ export default { @@ -2057,7 +2057,7 @@ export default {
"Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"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 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.",
@ -2145,7 +2145,7 @@ export default { @@ -2145,7 +2145,7 @@ export default {
"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).",
"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",
"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",

25
src/i18n/locales/en.ts

@ -1098,6 +1098,19 @@ export default { @@ -1098,6 +1098,19 @@ export default {
"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.",
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 headings": "Headings",
"Advanced lab tb headings hint": "Inserts at the cursor; adjust spacing if needed.",
@ -1524,7 +1537,7 @@ export default { @@ -1524,7 +1537,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown",
"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",
Author: "Author",
"Author is required for reading groups": "Author is required for reading groups",
@ -1761,7 +1774,7 @@ export default { @@ -1761,7 +1774,7 @@ export default {
"New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)",
"New Nostr Specification": "New Nostr Specification",
Newest: "Newest",
"No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -2158,7 +2171,7 @@ export default { @@ -2158,7 +2171,7 @@ export default {
"Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"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 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.",
@ -2246,7 +2259,11 @@ export default { @@ -2246,7 +2259,11 @@ export default {
"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).",
"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",
"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",

8
src/i18n/locales/es.ts

@ -1348,7 +1348,7 @@ export default { @@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown",
"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",
Author: "Author",
"Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default { @@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)",
"New Nostr Specification": "New Nostr Specification",
Newest: "Newest",
"No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default { @@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"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 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.",
@ -2020,7 +2020,7 @@ export default { @@ -2020,7 +2020,7 @@ export default {
"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).",
"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",
"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",

8
src/i18n/locales/fr.ts

@ -1348,7 +1348,7 @@ export default { @@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown",
"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",
Author: "Author",
"Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default { @@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)",
"New Nostr Specification": "New Nostr Specification",
Newest: "Newest",
"No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default { @@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"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 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.",
@ -2020,7 +2020,7 @@ export default { @@ -2020,7 +2020,7 @@ export default {
"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).",
"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",
"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",

8
src/i18n/locales/nl.ts

@ -1348,7 +1348,7 @@ export default { @@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown",
"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",
Author: "Author",
"Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default { @@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)",
"New Nostr Specification": "New Nostr Specification",
Newest: "Newest",
"No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default { @@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"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 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.",
@ -2020,7 +2020,7 @@ export default { @@ -2020,7 +2020,7 @@ export default {
"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).",
"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",
"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",

8
src/i18n/locales/pl.ts

@ -1348,7 +1348,7 @@ export default { @@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown",
"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",
Author: "Author",
"Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default { @@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)",
"New Nostr Specification": "New Nostr Specification",
Newest: "Newest",
"No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default { @@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"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 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.",
@ -2020,7 +2020,7 @@ export default { @@ -2020,7 +2020,7 @@ export default {
"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).",
"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",
"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",

8
src/i18n/locales/ru.ts

@ -1348,7 +1348,7 @@ export default { @@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown",
"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",
Author: "Author",
"Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default { @@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)",
"New Nostr Specification": "New Nostr Specification",
Newest: "Newest",
"No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default { @@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"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 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.",
@ -2020,7 +2020,7 @@ export default { @@ -2020,7 +2020,7 @@ export default {
"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).",
"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",
"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",

8
src/i18n/locales/tr.ts

@ -1348,7 +1348,7 @@ export default { @@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown",
"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",
Author: "Author",
"Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default { @@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)",
"New Nostr Specification": "New Nostr Specification",
Newest: "Newest",
"No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default { @@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"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 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.",
@ -2020,7 +2020,7 @@ export default { @@ -2020,7 +2020,7 @@ export default {
"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).",
"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",
"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",

8
src/i18n/locales/zh.ts

@ -1348,7 +1348,7 @@ export default { @@ -1348,7 +1348,7 @@ export default {
"Article exported as AsciiDoc": "Article exported as AsciiDoc",
"Article exported as Markdown": "Article exported as Markdown",
"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",
Author: "Author",
"Author is required for reading groups": "Author is required for reading groups",
@ -1582,7 +1582,7 @@ export default { @@ -1582,7 +1582,7 @@ export default {
"New Prompt Citation": "New Prompt Citation",
"New Public Message": "New Public Message",
"New Wiki Article": "New Wiki Article",
"New Wiki Article (Markdown)": "New Wiki Article (Markdown)",
"New Nostr Specification": "New Nostr Specification",
Newest: "Newest",
"No JSON available": "No JSON available",
"No RSS feeds found in OPML file": "No RSS feeds found in OPML file",
@ -1932,7 +1932,7 @@ export default { @@ -1932,7 +1932,7 @@ export default {
"Vote removed": "Vote removed",
"Website where LLM was accessed (optional)": "Website where LLM was accessed (optional)",
"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 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.",
@ -2020,7 +2020,7 @@ export default { @@ -2020,7 +2020,7 @@ export default {
"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).",
"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",
"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",

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

@ -16,6 +16,10 @@ export function serializeLabSlice(slice: AdvancedEventLabSlice): string { @@ -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(
raw: string
): { ok: true; value: AdvancedEventLabSlice } | { ok: false; error: string } {

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

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

4
src/lib/kind-description.ts

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

2
src/lib/link.ts

@ -11,7 +11,7 @@ const ALEXANDRIA_PUBLICATION_NADDR_KINDS = new Set<number>([ @@ -11,7 +11,7 @@ const ALEXANDRIA_PUBLICATION_NADDR_KINDS = new Set<number>([
ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN
ExtendedKind.NOSTR_SPECIFICATION
])
/** 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 @@ -13,7 +13,7 @@ export function detectMarkupType(content: string, eventKind?: number): MarkupTyp
return 'asciidoc'
}
// Wiki articles (30817) use markdown
// Nostr specifications (30817) use markdown
if (eventKind === 30817) {
return 'advanced-markdown'
}

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

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

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

@ -0,0 +1,24 @@ @@ -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 @@ @@ -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>([ @@ -189,7 +189,7 @@ const KINDS_WITH_METADATA_TITLE = new Set<number>([
kinds.LongFormArticle,
ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.WIKI_ARTICLE
])

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

@ -83,8 +83,8 @@ function getEventTypeName(kind: number): string { @@ -83,8 +83,8 @@ function getEventTypeName(kind: number): string {
return 'Publication Content'
case ExtendedKind.WIKI_ARTICLE:
return 'Wiki Article'
case ExtendedKind.WIKI_ARTICLE_MARKDOWN:
return 'Wiki Article'
case ExtendedKind.NOSTR_SPECIFICATION:
return 'Nostr Specification'
case ExtendedKind.DISCUSSION:
return 'Discussion'
case ExtendedKind.CALENDAR_EVENT_TIME:
@ -245,8 +245,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -245,8 +245,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
return 'Note: Publication'
case 30041: // ExtendedKind.PUBLICATION_CONTENT
return 'Note: Publication Content'
case 30817: // ExtendedKind.WIKI_ARTICLE_MARKDOWN
return 'Note: Wiki Article'
case 30817: // ExtendedKind.NOSTR_SPECIFICATION
return 'Note: Nostr Specification'
case 30818: // ExtendedKind.WIKI_ARTICLE
return 'Note: Wiki Article'
case 20: // ExtendedKind.PICTURE
@ -294,7 +294,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -294,7 +294,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
kinds.LongFormArticle, // 30023
ExtendedKind.PUBLICATION, // 30040
ExtendedKind.PUBLICATION_CONTENT, // 30041
ExtendedKind.WIKI_ARTICLE_MARKDOWN, // 30817
ExtendedKind.NOSTR_SPECIFICATION, // 30817
ExtendedKind.WIKI_ARTICLE // 30818
]
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>([ @@ -111,7 +111,7 @@ const EMBEDDED_NOTE_PREFETCH_ON_INGEST_KINDS = new Set<number>([
ExtendedKind.GENERIC_REPOST,
ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.DISCUSSION

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

@ -1106,7 +1106,7 @@ class IndexedDbService { @@ -1106,7 +1106,7 @@ class IndexedDbService {
// PAYMENT_INFO (10133), RSS_FEED_LIST (10895), etc. are in the 10000-20000 range
if (
[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)
}
@ -1169,7 +1169,7 @@ class IndexedDbService { @@ -1169,7 +1169,7 @@ class IndexedDbService {
case ExtendedKind.PUBLICATION:
case ExtendedKind.PUBLICATION_CONTENT:
case ExtendedKind.WIKI_ARTICLE:
case ExtendedKind.WIKI_ARTICLE_MARKDOWN:
case ExtendedKind.NOSTR_SPECIFICATION:
case kinds.LongFormArticle:
return StoreNames.PUBLICATION_EVENTS
case ExtendedKind.BADGE_DEFINITION:

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

@ -326,13 +326,13 @@ class LocalStorageService { @@ -326,13 +326,13 @@ class LocalStorageService {
}
}
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.
if (
(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) {

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

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

Loading…
Cancel
Save