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 } 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 storage from '@/services/local-storage.service' import type { TDraftEvent } from '@/types' import dayjs from 'dayjs' import { AlertTriangle, 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' 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.WIKI_ARTICLE_MARKDOWN) { 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 } = 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 prevOpenRef = useRef(false) const parsedCreateKind = useMemo( () => (isCreate ? parseEventKindInput(createKindInput) : null), [isCreate, createKindInput] ) const kind = isCreate ? (parsedCreateKind ?? 0) : sourceEvent!.kind 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 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: normalizedTags, 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, normalizedTags, 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: normalizedTags, 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, normalizedTags, 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 (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: normalizedTags, 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') 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} ) : ( )}