index.tsx raw
1 import {
2 EmbeddedEmojiParser,
3 EmbeddedEventParser,
4 EmbeddedHashtagParser,
5 EmbeddedLNInvoiceParser,
6 EmbeddedMentionParser,
7 EmbeddedUrlParser,
8 EmbeddedWebsocketUrlParser,
9 parseContent
10 } from '@/lib/content-parser'
11 import { getImetaInfosFromEvent } from '@/lib/event'
12 import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag'
13 import { cn } from '@/lib/utils'
14 import { useContentPolicy } from '@/providers/ContentPolicyProvider'
15 import mediaUpload from '@/services/media-upload.service'
16 import { TImetaInfo } from '@/types'
17 import { Event } from 'nostr-tools'
18 import { useMemo, useRef, useState } from 'react'
19 import {
20 EmbeddedHashtag,
21 EmbeddedLNInvoice,
22 EmbeddedMention,
23 EmbeddedNote,
24 EmbeddedWebsocketUrl
25 } from '../Embedded'
26 import Emoji from '../Emoji'
27 import ExternalLink from '../ExternalLink'
28 import HighlightButton from '../HighlightButton'
29 import ResponsiveImageGallery from '../ResponsiveImageGallery'
30 import MediaPlayer from '../MediaPlayer'
31 import PostEditor from '../PostEditor'
32 import WebPreview from '../WebPreview'
33 import XEmbeddedPost from '../XEmbeddedPost'
34 import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
35 import MarkdownText from './MarkdownText'
36
37 export default function Content({
38 event,
39 content,
40 className,
41 mustLoadMedia,
42 enableHighlight = false,
43 enableMarkdown: enableMarkdownProp
44 }: {
45 event?: Event
46 content?: string
47 className?: string
48 mustLoadMedia?: boolean
49 enableHighlight?: boolean
50 enableMarkdown?: boolean
51 }) {
52 const { enableMarkdown: globalEnableMarkdown } = useContentPolicy()
53 const markdown = enableMarkdownProp ?? globalEnableMarkdown
54 const contentRef = useRef<HTMLDivElement>(null)
55 const [showHighlightEditor, setShowHighlightEditor] = useState(false)
56 const [selectedText, setSelectedText] = useState('')
57 const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => {
58 const _content = event?.content ?? content
59 if (!_content) return {}
60
61 const nodes = parseContent(_content, [
62 EmbeddedEventParser,
63 EmbeddedMentionParser,
64 EmbeddedUrlParser,
65 EmbeddedLNInvoiceParser,
66 EmbeddedWebsocketUrlParser,
67 EmbeddedHashtagParser,
68 EmbeddedEmojiParser
69 ])
70
71 const imetaInfos = event ? getImetaInfosFromEvent(event) : []
72 const allImages = nodes
73 .map((node) => {
74 if (node.type === 'image') {
75 const imageInfo = imetaInfos.find((image) => image.url === node.data)
76 if (imageInfo) {
77 return imageInfo
78 }
79 const tag = mediaUpload.getImetaTagByUrl(node.data)
80 return tag
81 ? getImetaInfoFromImetaTag(tag, event?.pubkey)
82 : { url: node.data, pubkey: event?.pubkey }
83 }
84 if (node.type === 'images') {
85 const urls = Array.isArray(node.data) ? node.data : [node.data]
86 return urls.map((url) => {
87 const imageInfo = imetaInfos.find((image) => image.url === url)
88 return imageInfo ?? { url, pubkey: event?.pubkey }
89 })
90 }
91 return null
92 })
93 .filter(Boolean)
94 .flat() as TImetaInfo[]
95
96 const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
97
98 const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
99 const lastNormalUrl =
100 typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
101
102 return { nodes, allImages, emojiInfos, lastNormalUrl }
103 }, [event, content])
104
105 if (!nodes || nodes.length === 0) {
106 return null
107 }
108
109 const handleHighlight = (text: string) => {
110 setSelectedText(text)
111 setShowHighlightEditor(true)
112 }
113
114 let imageIndex = 0
115 return (
116 <>
117 <div
118 ref={contentRef}
119 className={cn('text-wrap break-words prose prose-zinc dark:prose-invert max-w-none prose-p:mt-0 prose-p:mb-2 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-pre:my-2 prose-blockquote:my-2', className)}
120 >
121 {nodes.map((node, index) => {
122 if (node.type === 'text') {
123 if (!markdown) {
124 return <span key={index} className="whitespace-pre-wrap">{node.data}</span>
125 }
126 // Split on paragraph breaks so each fragment renders inline (flowing
127 // with adjacent hashtags/links) while preserving visual paragraph gaps.
128 const paragraphs = node.data.split(/\n\s*\n/)
129 return (
130 <span key={index}>
131 {paragraphs.map((para, i) => {
132 const leading = para.match(/^(\s+)/)?.[1] ?? ''
133 const trailing = para.match(/(\s+)$/)?.[1] ?? ''
134 const trimmed = para.slice(leading.length, para.length - trailing.length)
135 return (
136 <span key={i}>
137 {i > 0 && <span className="block mb-2" />}
138 {leading}
139 {trimmed ? <MarkdownText text={trimmed} /> : null}
140 {trailing}
141 </span>
142 )
143 })}
144 </span>
145 )
146 }
147 if (node.type === 'image' || node.type === 'images') {
148 const start = imageIndex
149 const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
150 imageIndex = end
151 return (
152 <ResponsiveImageGallery
153 className="mt-2"
154 key={index}
155 images={allImages}
156 start={start}
157 end={end}
158 mustLoad={mustLoadMedia}
159 />
160 )
161 }
162 if (node.type === 'media') {
163 return (
164 <MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} />
165 )
166 }
167 if (node.type === 'url') {
168 return <ExternalLink url={node.data} key={index} />
169 }
170 if (node.type === 'invoice') {
171 return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
172 }
173 if (node.type === 'websocket-url') {
174 return <EmbeddedWebsocketUrl url={node.data} key={index} />
175 }
176 if (node.type === 'event') {
177 const id = node.data.split(':')[1]
178 if (!id) return <span key={index}>{node.data}</span>
179 return <EmbeddedNote key={index} noteId={id} className="mt-2" />
180 }
181 if (node.type === 'mention') {
182 const userId = node.data.split(':')[1]
183 if (!userId) return <span key={index}>{node.data}</span>
184 return <EmbeddedMention key={index} userId={userId} />
185 }
186 if (node.type === 'hashtag') {
187 return <EmbeddedHashtag hashtag={node.data} key={index} />
188 }
189 if (node.type === 'emoji') {
190 const shortcode = node.data.split(':')[1]
191 const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
192 if (!emoji) return node.data
193 return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
194 }
195 if (node.type === 'youtube') {
196 return (
197 <YoutubeEmbeddedPlayer
198 key={index}
199 url={node.data}
200 className="mt-2"
201 mustLoad={mustLoadMedia}
202 />
203 )
204 }
205 if (node.type === 'x-post') {
206 return (
207 <XEmbeddedPost
208 key={index}
209 url={node.data}
210 className="mt-2"
211 mustLoad={mustLoadMedia}
212 />
213 )
214 }
215 return null
216 })}
217 {lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
218 </div>
219 {enableHighlight && (
220 <HighlightButton onHighlight={handleHighlight} containerRef={contentRef} />
221 )}
222 {enableHighlight && (
223 <PostEditor
224 highlightedText={selectedText}
225 parentStuff={event}
226 open={showHighlightEditor}
227 setOpen={setShowHighlightEditor}
228 />
229 )}
230 </>
231 )
232 }
233