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