You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

540 lines
20 KiB

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) => (
<Card className={cn('p-3 select-text', className)}>{node}</Card>
)
if (k === ExtendedKind.POLL) {
return wrap(<ContentPreview event={event} />)
}
if (k === kinds.Highlights) {
return wrap(<Highlight event={event} />)
}
if (
k === kinds.ShortTextNote ||
k === ExtendedKind.COMMENT ||
k === ExtendedKind.VOICE_COMMENT
) {
return wrap(<MarkdownArticle event={event} hideMetadata />)
}
if (k === kinds.LongFormArticle) {
return wrap(<MarkdownArticle event={event} hideMetadata />)
}
if (k === ExtendedKind.WIKI_ARTICLE) {
return wrap(<AsciidocArticle event={event} hideImagesAndInfo={false} />)
}
if (k === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
return wrap(<MarkdownArticle event={event} hideMetadata />)
}
if (k === ExtendedKind.PUBLICATION_CONTENT) {
return wrap(<AsciidocArticle event={event} hideImagesAndInfo={false} />)
}
return wrap(<Content event={event} className="h-full" mustLoadMedia />)
}
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<string[][]>([['', '']])
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-[95vw] max-w-3xl flex flex-col gap-0 p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-6 pb-2 pr-14">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only">
{isCreate
? t('Set kind, content, and tags, then publish.')
: t('Edit content and tags, then publish a new signed event.')}
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 flex flex-col px-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col flex-1 min-h-0 gap-2">
<TabsList className="w-auto justify-start shrink-0">
<TabsTrigger value="edit">{t('Edit')}</TabsTrigger>
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger>
<TabsTrigger value="json">{t('Json')}</TabsTrigger>
</TabsList>
<TabsContent value="edit" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
<ScrollArea className="h-[min(50vh,420px)] pr-3">
<div className="space-y-4 pb-2">
<div className="space-y-1">
<label className="text-sm font-medium">{t('Event kind')}</label>
{isCreate ? (
<>
<Input
type="number"
min={0}
max={UNSIGNED_EXPERIMENTAL_KIND_MAX}
step={1}
value={createKindInput}
onChange={(e) => setCreateKindInput(e.target.value)}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{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
}
)}
</p>
{parsedCreateKind !== null && isUnsignedExperimentalKind(parsedCreateKind) ? (
<div
role="alert"
className="flex gap-2 rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-sm text-amber-950 dark:text-amber-100"
>
<AlertTriangle
className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400"
aria-hidden
/>
<div>
<p className="font-medium">{t('Unsigned experimental kind')}</p>
<p className="mt-1 text-xs leading-relaxed opacity-90">
{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.'
)}
</p>
</div>
</div>
) : null}
</>
) : (
<Input
type="number"
value={kind}
disabled
readOnly
className="font-mono text-sm"
aria-readonly
/>
)}
</div>
<div className="space-y-1">
<label className="text-sm font-medium">{t('Note content')}</label>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={10}
className="font-mono text-sm min-h-[160px]"
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">{t('Tags')}</div>
<div className="space-y-2">
{tagRows.map((row, i) => (
<div
key={i}
className="flex flex-wrap items-start gap-1 border rounded-md p-2 bg-muted/30"
>
{row.map((cell, j) => (
<div key={j} className="flex items-center gap-0.5 shrink-0">
<Input
value={cell}
onChange={(e) => updateRow(i, j, e.target.value)}
placeholder={j === 0 ? t('Tag name') : t('Value')}
className="h-8 w-[7rem] sm:w-32 font-mono text-xs"
/>
{row.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => removeCell(i, j)}
aria-label={t('Remove value')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => addCell(i)}
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t('Add field')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 ml-auto"
onClick={() => removeRow(i)}
aria-label={t('Remove tag')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button type="button" variant="secondary" size="sm" onClick={addRow}>
<Plus className="h-4 w-4 mr-1" />
{t('Add tag')}
</Button>
</div>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="preview" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
<ScrollArea className="h-[min(50vh,420px)] pr-3">
<div className="space-y-1.5">
{previewEvent ? (
<>
{storage.getAddClientTag() ? (
<div className="flex min-h-[1.125rem] items-center px-0.5">
<ClientTag event={previewEvent} />
</div>
) : null}
<StaticEventPreview event={previewEvent} />
</>
) : (
<p className="text-sm text-muted-foreground">
{t(
'Enter a valid event kind: 0–{{maxSigned}}, or {{unsignedMin}}–{{unsignedMax}}.',
{
maxSigned: MAX_SIGNED_CUSTOM_EVENT_KIND,
unsignedMin: UNSIGNED_EXPERIMENTAL_KIND_MIN,
unsignedMax: UNSIGNED_EXPERIMENTAL_KIND_MAX
}
)}
</p>
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="json" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
<ScrollArea className="h-[min(50vh,420px)] pr-3">
<pre className="text-xs font-mono whitespace-pre-wrap break-words border rounded-md p-3 bg-muted/40 select-text">
{draftJson}
</pre>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
<DialogFooter className="shrink-0 px-6 py-4 border-t gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button
type="button"
onClick={handlePublish}
disabled={publishing || !pubkey || (isCreate && parsedCreateKind === null)}
>
{publishing ? t('Loading...') : t('Publish')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}