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.
 
 
 
 

401 lines
14 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 } from '@/constants'
import { applyImwaldAttributionTags, stripImwaldAttributionTags } 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 { 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
}
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 default function EditOrCloneEventDialog({
open,
onOpenChange,
sourceEvent,
mode
}: {
open: boolean
onOpenChange: (open: boolean) => void
sourceEvent: Event
mode: TEditOrCloneMode
}) {
const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
const [content, setContent] = useState(sourceEvent.content)
const [tagRows, setTagRows] = useState<string[][]>([['', '']])
const [activeTab, setActiveTab] = useState('edit')
const [publishing, setPublishing] = useState(false)
const prevOpenRef = useRef(false)
const kind = sourceEvent.kind
useEffect(() => {
if (open && !prevOpenRef.current) {
setContent(sourceEvent.content)
setTagRows(
sourceEvent.tags?.length
? sourceEvent.tags.map((row) => [...row])
: [['', '']]
)
setActiveTab('edit')
}
prevOpenRef.current = open
}, [open, sourceEvent])
const normalizedTags = useMemo(() => tagsFromRows(tagRows), [tagRows])
const previewEvent = useMemo(() => {
const now = Math.floor(Date.now() / 1000)
const base: TDraftEvent = {
kind,
content,
tags: normalizedTags,
created_at: now
}
const withAttribution = applyImwaldAttributionTags(base, {
addClientTag: storage.getAddClientTag()
})
return createFakeEvent({
kind,
content,
tags: withAttribution.tags,
pubkey: pubkey ?? '',
created_at: now
})
}, [kind, content, normalizedTags, pubkey])
const buildDraftJson = useCallback(() => {
const base: TDraftEvent = {
kind,
content,
tags: normalizedTags,
created_at: dayjs().unix()
}
const withAttribution = applyImwaldAttributionTags(base, {
addClientTag: storage.getAddClientTag()
})
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: t('id and sig are assigned when you publish')
}
return JSON.stringify(draft, null, 2)
}, [pubkey, kind, 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
setPublishing(true)
try {
const draft = {
kind,
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') : t('Clone or fork this 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">
{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>
<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">
{storage.getAddClientTag() ? (
<div className="flex min-h-[1.125rem] items-center px-0.5">
<ClientTag event={previewEvent} />
</div>
) : null}
<StaticEventPreview event={previewEvent} />
</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}>
{publishing ? t('Loading...') : t('Publish')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}