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.
285 lines
10 KiB
285 lines
10 KiB
import ClientTag from '@/components/ClientTag' |
|
import { Card } from '@/components/ui/card' |
|
import { ExtendedKind, POLL_TYPE } from '@/constants' |
|
import { |
|
buildClientTag, |
|
stripImwaldAttributionTags, |
|
transformCustomEmojisInContent |
|
} from '@/lib/draft-event' |
|
import { normalizeTopic } from '@/lib/discussion-topics' |
|
import { createFakeEvent } from '@/lib/event' |
|
import { randomString } from '@/lib/random' |
|
import { cleanUrl, rewritePlainTextHttpUrls } from '@/lib/url' |
|
import { cn } from '@/lib/utils' |
|
import { TPollCreateData } from '@/types' |
|
import { kinds, nip19 } from 'nostr-tools' |
|
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' |
|
import { useMemo, type ReactNode } from 'react' |
|
import ContentPreview from '../../ContentPreview' |
|
import Content from '../../Content' |
|
import Highlight from '../../Note/Highlight' |
|
import MarkdownArticle from '../../Note/MarkdownArticle/MarkdownArticle' |
|
import AsciidocArticle from '../../Note/AsciidocArticle/AsciidocArticle' |
|
import { HighlightData } from '../HighlightEditor' |
|
|
|
export default function Preview({ |
|
content, |
|
className, |
|
kind = 1, |
|
highlightData, |
|
pollCreateData, |
|
mediaImetaTags, |
|
mediaUrl, |
|
articleMetadata, |
|
extraPreviewTags, |
|
addClientTag = true |
|
}: { |
|
content: string |
|
className?: string |
|
kind?: number |
|
highlightData?: HighlightData |
|
pollCreateData?: TPollCreateData |
|
mediaImetaTags?: string[][] |
|
mediaUrl?: string |
|
articleMetadata?: { |
|
title?: string |
|
summary?: string |
|
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[][] |
|
/** When true (default), preview matches publish: Imwald `client` + attribution `alt` tags and badge. */ |
|
addClientTag?: boolean |
|
}) { |
|
const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo( |
|
() => { |
|
// Clean tracking parameters from URLs in the preview |
|
const cleanedContent = rewritePlainTextHttpUrls(content) |
|
const { content: processed, emojiTags: tags } = transformCustomEmojisInContent(cleanedContent) |
|
const customShortcodes = tags.map((t) => t[1]).filter(Boolean) |
|
const withNativeEmojis = replaceStandardEmojiShortcodesInContent(processed, customShortcodes) |
|
|
|
// Build highlight tags if this is a highlight |
|
let highlightTags: string[][] = [] |
|
if (kind === kinds.Highlights && highlightData) { |
|
// Add source tag |
|
if (highlightData.sourceType === 'url') { |
|
try { |
|
highlightTags.push([ |
|
'r', |
|
cleanUrl(highlightData.sourceValue) || highlightData.sourceValue, |
|
'source' |
|
]) |
|
} catch { |
|
highlightTags.push(['r', highlightData.sourceValue, 'source']) |
|
} |
|
} else if (highlightData.sourceType === 'nostr') { |
|
// For preview, we'll use a simple e-tag with the source value |
|
// The actual tag building happens in createHighlightDraftEvent |
|
if (highlightData.sourceHexId) { |
|
highlightTags.push(['e', highlightData.sourceHexId]) |
|
} else if (highlightData.sourceValue) { |
|
// Try to extract hex ID from bech32 if possible |
|
try { |
|
const decoded = nip19.decode(highlightData.sourceValue) |
|
if (decoded.type === 'note' || decoded.type === 'nevent') { |
|
const hexId = decoded.type === 'note' ? decoded.data : decoded.data.id |
|
highlightTags.push(['e', hexId]) |
|
} else if (decoded.type === 'naddr') { |
|
const { kind, pubkey, identifier } = decoded.data |
|
highlightTags.push(['a', `${kind}:${pubkey}:${identifier}`]) |
|
} |
|
} catch { |
|
// If decoding fails, just use the source value as-is for preview |
|
highlightTags.push(['r', highlightData.sourceValue]) |
|
} |
|
} |
|
} |
|
|
|
// Add context tag if provided |
|
if (highlightData.context) { |
|
highlightTags.push(['context', highlightData.context]) |
|
} |
|
} |
|
|
|
// Build poll tags if this is a poll |
|
let pollTags: string[][] = [] |
|
if (kind === ExtendedKind.POLL && pollCreateData) { |
|
const validOptions = pollCreateData.options.filter((opt) => opt.trim()) |
|
pollTags.push(...validOptions.map((option) => ['option', randomString(9), option.trim()])) |
|
pollTags.push(['polltype', pollCreateData.isMultipleChoice ? POLL_TYPE.MULTIPLE_CHOICE : POLL_TYPE.SINGLE_CHOICE]) |
|
if (pollCreateData.endsAt) { |
|
pollTags.push(['endsAt', pollCreateData.endsAt.toString()]) |
|
} |
|
if (pollCreateData.relays.length > 0) { |
|
pollCreateData.relays.forEach((relay) => { |
|
pollTags.push(['relay', relay]) |
|
}) |
|
} |
|
} |
|
|
|
return { |
|
content: withNativeEmojis, |
|
emojiTags: tags, |
|
highlightTags, |
|
pollTags |
|
} |
|
}, |
|
[content, kind, highlightData, pollCreateData] |
|
) |
|
|
|
// Combine emoji tags, highlight tags, poll tags, media imeta tags, and article metadata tags |
|
const allTags = useMemo(() => { |
|
const tags = [...emojiTags, ...highlightTags, ...pollTags] |
|
// Add imeta tags for media (voice comments, etc.) |
|
if (mediaImetaTags && mediaImetaTags.length > 0) { |
|
tags.push(...mediaImetaTags) |
|
} |
|
// Add article metadata tags for article kinds |
|
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]) |
|
} |
|
if (articleMetadata.title) { |
|
tags.push(['title', articleMetadata.title]) |
|
} |
|
if (articleMetadata.summary) { |
|
tags.push(['summary', articleMetadata.summary]) |
|
} |
|
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())) |
|
.filter(topic => topic.length > 0) |
|
tags.push(...normalizedTopics.map((topic) => ['t', topic])) |
|
} |
|
} |
|
if (extraPreviewTags?.length) { |
|
tags.push(...extraPreviewTags) |
|
} |
|
const stripped = stripImwaldAttributionTags(tags) |
|
if (addClientTag) { |
|
stripped.push(buildClientTag()) |
|
} |
|
return stripped |
|
}, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, kind, extraPreviewTags, addClientTag]) |
|
|
|
const fakeEvent = useMemo(() => { |
|
// For voice comments, include the media URL in content if not already there |
|
let eventContent = processedContent |
|
if ((kind === ExtendedKind.VOICE_COMMENT || kind === ExtendedKind.VOICE) && mediaUrl && !processedContent.includes(mediaUrl)) { |
|
eventContent = mediaUrl + (processedContent ? '\n\n' + processedContent : '') |
|
} |
|
|
|
return createFakeEvent({ |
|
content: eventContent, |
|
tags: allTags, |
|
kind |
|
}) |
|
}, [processedContent, allTags, kind, mediaUrl]) |
|
|
|
const selectableClass = 'select-text' |
|
const withClientBadge = (node: ReactNode) => |
|
addClientTag ? ( |
|
<div className="space-y-1.5"> |
|
<div className="flex min-h-[1.125rem] items-center px-0.5"> |
|
<ClientTag event={fakeEvent} /> |
|
</div> |
|
{node} |
|
</div> |
|
) : ( |
|
node |
|
) |
|
|
|
// For polls, use ContentPreview to show poll properly |
|
if (kind === ExtendedKind.POLL) { |
|
return withClientBadge( |
|
<Card className={cn('p-3', className, selectableClass)}> |
|
<ContentPreview event={fakeEvent} /> |
|
</Card> |
|
) |
|
} |
|
|
|
// For highlights, use the Highlight component for proper formatting |
|
if (kind === kinds.Highlights) { |
|
return withClientBadge( |
|
<Card className={cn('p-3', className, selectableClass)}> |
|
<Highlight event={fakeEvent} /> |
|
</Card> |
|
) |
|
} |
|
|
|
// For kind 1 notes, use MarkdownArticle to match actual rendering |
|
// This ensures preview matches the final result (no Links section, correct image placement, proper line breaks) |
|
if (kind === kinds.ShortTextNote || kind === ExtendedKind.COMMENT || kind === ExtendedKind.VOICE_COMMENT) { |
|
return withClientBadge( |
|
<Card className={cn('p-3', className, selectableClass)}> |
|
<MarkdownArticle event={fakeEvent} hideMetadata={true} lazyMedia={false} /> |
|
</Card> |
|
) |
|
} |
|
|
|
if (kind === ExtendedKind.DISCUSSION) { |
|
return withClientBadge( |
|
<Card className={cn('p-3', className, selectableClass)}> |
|
<MarkdownArticle event={fakeEvent} hideMetadata={true} lazyMedia={false} /> |
|
</Card> |
|
) |
|
} |
|
|
|
// For LongFormArticle, use MarkdownArticle |
|
if (kind === kinds.LongFormArticle) { |
|
return withClientBadge( |
|
<Card className={cn('p-3', className, selectableClass)}> |
|
<MarkdownArticle event={fakeEvent} hideMetadata={true} lazyMedia={false} /> |
|
</Card> |
|
) |
|
} |
|
|
|
// For WikiArticle (AsciiDoc), use AsciidocArticle |
|
if (kind === ExtendedKind.WIKI_ARTICLE) { |
|
return withClientBadge( |
|
<Card className={cn('p-3', className, selectableClass)}> |
|
<AsciidocArticle event={fakeEvent} hideImagesAndInfo={false} /> |
|
</Card> |
|
) |
|
} |
|
|
|
// 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} /> |
|
</Card> |
|
) |
|
} |
|
|
|
// For PublicationContent, use AsciidocArticle |
|
if (kind === ExtendedKind.PUBLICATION_CONTENT) { |
|
return withClientBadge( |
|
<Card className={cn('p-3', className, selectableClass)}> |
|
<AsciidocArticle event={fakeEvent} hideImagesAndInfo={false} /> |
|
</Card> |
|
) |
|
} |
|
|
|
return withClientBadge( |
|
<Card className={cn('p-3', className, selectableClass)}> |
|
<Content event={fakeEvent} className="h-full" mustLoadMedia /> |
|
</Card> |
|
) |
|
}
|
|
|