import storage from '@/services/local-storage.service' import { Card } from '@/components/ui/card' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ScrollArea } from '@/components/ui/scroll-area' import Content from '@/components/Content' import ContentPreview from '@/components/ContentPreview' import Highlight from '@/components/Note/Highlight' import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle' import ClientTag from '@/components/ClientTag' import { ExtendedKind, MAX_SIGNED_CUSTOM_EVENT_KIND, UNSIGNED_EXPERIMENTAL_KIND_MAX, UNSIGNED_EXPERIMENTAL_KIND_MIN, isUnsignedExperimentalKind } from '@/constants' import { applyImwaldAttributionTags, collectUploadImetaTagsForContentUrls, mergeUploadImetaTagsInto } from '@/lib/draft-event' import { createFakeEvent } from '@/lib/event' import logger from '@/lib/logger' import { showPublishingError, showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import postEditorCache from '@/services/post-editor-cache.service' import type { TDraftEvent } from '@/types' import dayjs from 'dayjs' import { AlertTriangle, Code2, Plus, Trash2 } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import AdvancedEventLabDialog from '@/components/AdvancedEventLab/AdvancedEventLabDialog' import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' import { isAsciidocMarkupKind } from '@/lib/advanced-event-lab-kinds' import { canPublishWithContent } from '@/lib/publish-content-required' function normalizeTagRow(row: string[]): string[] | null { const trimmed = row.map((c) => c.trim()) if (!trimmed[0]) return null let end = trimmed.length while (end > 1 && trimmed[end - 1] === '') end-- return trimmed.slice(0, end) } function tagsFromRows(rows: string[][]): string[][] { const out: string[][] = [] for (const row of rows) { const n = normalizeTagRow(row) if (n) out.push(n) } return out } /** Integer kind in [0, MAX_SIGNED_CUSTOM_EVENT_KIND] or unsigned experimental range; null if invalid / empty. */ function parseEventKindInput(s: string): number | null { const trimmed = s.trim() if (trimmed === '') return null const n = Number(trimmed) if (!Number.isInteger(n) || n < 0) return null if (n <= MAX_SIGNED_CUSTOM_EVENT_KIND) return n if (isUnsignedExperimentalKind(n)) return n return null } function StaticEventPreview({ event, className }: { event: Event; className?: string }) { const k = event.kind const wrap = (node: ReactNode) => ( {node} ) if (k === ExtendedKind.POLL) { return wrap() } if (k === kinds.Highlights) { return wrap() } if ( k === kinds.ShortTextNote || k === ExtendedKind.COMMENT || k === ExtendedKind.VOICE_COMMENT ) { return wrap() } if (k === kinds.LongFormArticle) { return wrap() } if (k === ExtendedKind.WIKI_ARTICLE) { return wrap() } if (k === ExtendedKind.NOSTR_SPECIFICATION) { return wrap() } if (k === ExtendedKind.PUBLICATION_CONTENT) { return wrap() } return wrap() } export type TEditOrCloneMode = 'edit' | 'clone' export type EditOrCloneEventDialogProps = | { open: boolean onOpenChange: (open: boolean) => void mode: 'create' } | { open: boolean onOpenChange: (open: boolean) => void mode: TEditOrCloneMode sourceEvent: Event } export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProps) { const { open, onOpenChange, mode } = props const isCreate = mode === 'create' const sourceEvent = !isCreate ? props.sourceEvent : null const { t, i18n } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() const [content, setContent] = useState(() => sourceEvent?.content ?? '') const [createKindInput, setCreateKindInput] = useState('1') const [tagRows, setTagRows] = useState([['', '']]) const [activeTab, setActiveTab] = useState('edit') const [publishing, setPublishing] = useState(false) const [advancedLabOpen, setAdvancedLabOpen] = useState(false) const [advancedLabInitial, setAdvancedLabInitial] = useState(null) const prevOpenRef = useRef(false) const parsedCreateKind = useMemo( () => (isCreate ? parseEventKindInput(createKindInput) : null), [isCreate, createKindInput] ) /** Stable lab draft bucket (separate from composer {@link postEditorCache.generateCacheKey}). */ const advancedLabDraftPersistenceKey = useMemo(() => { if (isCreate) { if (parsedCreateKind === null) return null return `event-lab:ecc:create:${parsedCreateKind}` } const id = sourceEvent!.id.trim() const normalized = /^[0-9a-f]{64}$/i.test(id) ? id.toLowerCase() : id return mode === 'edit' ? `event-lab:ecc:edit:${normalized}` : `event-lab:ecc:clone:${normalized}` }, [isCreate, parsedCreateKind, sourceEvent, mode]) const kind = isCreate ? (parsedCreateKind ?? 0) : sourceEvent!.kind const canPublishEvent = useMemo( () => canPublishWithContent(kind, content), [kind, content] ) useEffect(() => { if (open && !prevOpenRef.current) { if (isCreate) { setCreateKindInput('1') setContent('') setTagRows([['', '']]) } else if (sourceEvent) { setContent(sourceEvent.content) setTagRows( sourceEvent.tags?.length ? sourceEvent.tags.map((row) => [...row]) : [['', '']] ) } setActiveTab('edit') } prevOpenRef.current = open }, [open, isCreate, sourceEvent]) const normalizedTags = useMemo(() => tagsFromRows(tagRows), [tagRows]) const tagsWithContentUploadImeta = useMemo(() => { const next = [...normalizedTags] mergeUploadImetaTagsInto(next, collectUploadImetaTagsForContentUrls(content)) return next }, [normalizedTags, content]) const previewEvent = useMemo(() => { if (isCreate && parsedCreateKind === null) return null const k = isCreate ? parsedCreateKind! : sourceEvent!.kind const now = Math.floor(Date.now() / 1000) const base: TDraftEvent = { kind: k, content, tags: tagsWithContentUploadImeta, created_at: now } const withAttribution = applyImwaldAttributionTags(base, { addClientTag: storage.getAddClientTag() }) return createFakeEvent({ kind: k, content, tags: withAttribution.tags, pubkey: pubkey ?? '', created_at: now }) }, [isCreate, parsedCreateKind, sourceEvent, content, tagsWithContentUploadImeta, pubkey]) const buildDraftJson = useCallback(() => { if (isCreate && parsedCreateKind === null) { return t( 'Enter a valid event kind: integer 0–{{maxSigned}}, or {{unsignedMin}}–{{unsignedMax}} (unsigned experiment).', { maxSigned: MAX_SIGNED_CUSTOM_EVENT_KIND, unsignedMin: UNSIGNED_EXPERIMENTAL_KIND_MIN, unsignedMax: UNSIGNED_EXPERIMENTAL_KIND_MAX } ) } const k = isCreate ? parsedCreateKind! : sourceEvent!.kind const base: TDraftEvent = { kind: k, content, tags: tagsWithContentUploadImeta, created_at: dayjs().unix() } const withAttribution = applyImwaldAttributionTags(base, { addClientTag: storage.getAddClientTag() }) const unsignedNote = isUnsignedExperimentalKind(withAttribution.kind) ? t( 'Unsigned experimental kind: `sig` will be empty at publish; `id` is still the standard event hash. Not accepted by normal relays. Relays that allow this should authenticate you (e.g. NIP-42 AUTH) before writes.' ) : t('id and sig are assigned when you publish') const draft = { pubkey: pubkey ?? t('Log in to publish'), kind: withAttribution.kind, content: withAttribution.content, tags: withAttribution.tags, created_at: t('Set when you publish'), _note: unsignedNote } return JSON.stringify(draft, null, 2) }, [isCreate, parsedCreateKind, sourceEvent, pubkey, content, tagsWithContentUploadImeta, t]) const draftJson = activeTab === 'json' ? buildDraftJson() : '' const updateRow = (i: number, j: number, value: string) => { setTagRows((rows) => { const next = rows.map((r) => [...r]) if (!next[i]) return rows next[i][j] = value return next }) } const addRow = () => setTagRows((rows) => [...rows, ['', '']]) const removeRow = (i: number) => { setTagRows((rows) => (rows.length <= 1 ? [['', '']] : rows.filter((_, idx) => idx !== i))) } const addCell = (i: number) => { setTagRows((rows) => { const next = rows.map((r) => [...r]) next[i] = [...next[i], ''] return next }) } const removeCell = (i: number, j: number) => { setTagRows((rows) => { const next = rows.map((r) => [...r]) if (next[i].length <= 1) return rows next[i] = next[i].filter((_, idx) => idx !== j) return next }) } const handlePublish = async () => { await checkLogin(async () => { if (!pubkey) return if (!canPublishWithContent(isCreate ? parseEventKindInput(createKindInput) ?? 0 : sourceEvent!.kind, content)) { return } if (isCreate) { const k = parseEventKindInput(createKindInput) if (k === null) { showPublishingError( t( 'Kind must be an integer from 0 to {{maxSigned}}, or from {{unsignedMin}} to {{unsignedMax}} (unsigned experiment).', { maxSigned: MAX_SIGNED_CUSTOM_EVENT_KIND, unsignedMin: UNSIGNED_EXPERIMENTAL_KIND_MIN, unsignedMax: UNSIGNED_EXPERIMENTAL_KIND_MAX } ) ) return } } setPublishing(true) try { const publishKind = isCreate ? parseEventKindInput(createKindInput)! : sourceEvent!.kind const draft = { kind: publishKind, content, tags: tagsWithContentUploadImeta, created_at: dayjs().unix() } const newEvent = await publish(draft, { addClientTag: storage.getAddClientTag() }) if ((newEvent as any)?.relayStatuses) { const rs = (newEvent as any).relayStatuses showPublishingFeedback( { success: true, relayStatuses: rs, successCount: rs.filter((s: any) => s.success).length, totalCount: rs.length }, { message: t('Post published'), duration: 6000 } ) } else { showSimplePublishSuccess(t('Post published')) } onOpenChange(false) } catch (e) { if (e instanceof AggregateError && (e as any).relayStatuses) { const relayStatuses = (e as any).relayStatuses const successCount = relayStatuses.filter((s: any) => s.success).length const totalCount = relayStatuses.length showPublishingFeedback( { success: successCount > 0, relayStatuses, successCount, totalCount }, { message: successCount > 0 ? t('Published to some relays only') : t('Failed to post'), duration: 6000 } ) if (successCount > 0) onOpenChange(false) } else { logger.error('Edit/clone publish failed', { error: e }) showPublishingError(e instanceof Error ? e : String(e)) } } finally { setPublishing(false) } }) } const title = mode === 'edit' ? t('Edit this event') : mode === 'clone' ? t('Clone or fork this event') : t('Create custom event') 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, advancedLabDraftPersistenceKey]) const labKind = isCreate ? (parsedCreateKind ?? 0) : sourceEvent?.kind ?? 0 const labPreviewEmojiTags = useMemo( () => !isCreate && sourceEvent?.tags?.length ? sourceEvent.tags.filter(([n]) => n === 'emoji').map((row) => [...row]) : [], [isCreate, sourceEvent] ) return ( <> {title} {isCreate ? t('Set kind, content, and tags, then publish.') : t('Edit content and tags, then publish a new signed event.')}
{t('Edit')} {t('Preview')} {t('Json')}
{isCreate ? ( <> setCreateKindInput(e.target.value)} className="font-mono text-sm" />

{t( 'Signed: 0–{{maxSigned}}. Unsigned experiment (empty sig): {{unsignedMin}}–{{unsignedMax}}.', { maxSigned: MAX_SIGNED_CUSTOM_EVENT_KIND, unsignedMin: UNSIGNED_EXPERIMENTAL_KIND_MIN, unsignedMax: UNSIGNED_EXPERIMENTAL_KIND_MAX } )}

{parsedCreateKind !== null && isUnsignedExperimentalKind(parsedCreateKind) ? (

{t('Unsigned experimental kind')}

{t( 'This kind is published with an empty signature. Normal Nostr relays will reject it, and these events are not portable on the open network. Only use relays that explicitly support this experiment and authenticate you (for example with NIP-42 AUTH) before accepting writes.' )}

) : null} ) : ( )}