index.tsx raw

   1  import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
   2  import ImageWithLightbox from '@/components/ImageWithLightbox'
   3  import HighlightButton from '@/components/HighlightButton'
   4  import PostEditor from '@/components/PostEditor'
   5  import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
   6  import { toNote, toNoteList, toProfile } from '@/lib/link'
   7  import { ExternalLink } from 'lucide-react'
   8  import { Event, kinds } from 'nostr-tools'
   9  import { useMemo, useRef, useState } from 'react'
  10  import Markdown from 'react-markdown'
  11  import remarkGfm from 'remark-gfm'
  12  import NostrNode from './NostrNode'
  13  import { remarkNostr } from './remarkNostr'
  14  import { Components } from './types'
  15  
  16  export default function LongFormArticle({
  17    event,
  18    className
  19  }: {
  20    event: Event
  21    className?: string
  22  }) {
  23    const { push } = useSecondaryPage()
  24    const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
  25    const contentRef = useRef<HTMLDivElement>(null)
  26    const [showHighlightEditor, setShowHighlightEditor] = useState(false)
  27    const [selectedText, setSelectedText] = useState('')
  28  
  29    const handleHighlight = (text: string) => {
  30      setSelectedText(text)
  31      setShowHighlightEditor(true)
  32    }
  33  
  34    const components = useMemo(
  35      () =>
  36        ({
  37          nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />,
  38          a: ({ href, children, ...props }) => {
  39            if (!href) {
  40              return <span {...props} className="break-words" />
  41            }
  42            if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) {
  43              return (
  44                <SecondaryPageLink
  45                  to={toNote(href)}
  46                  className="break-words underline text-foreground"
  47                >
  48                  {children}
  49                </SecondaryPageLink>
  50              )
  51            }
  52            if (href.startsWith('npub1') || href.startsWith('nprofile1')) {
  53              return (
  54                <SecondaryPageLink
  55                  to={toProfile(href)}
  56                  className="break-words underline text-foreground"
  57                >
  58                  {children}
  59                </SecondaryPageLink>
  60              )
  61            }
  62            return (
  63              <a
  64                {...props}
  65                href={href}
  66                target="_blank"
  67                rel="noreferrer noopener"
  68                className="break-words inline-flex items-baseline gap-1"
  69              >
  70                {children} <ExternalLink className="size-3" />
  71              </a>
  72            )
  73          },
  74          p: (props) => <p {...props} className="break-words" />,
  75          div: (props) => <div {...props} className="break-words" />,
  76          code: (props) => <code {...props} className="break-words whitespace-pre-wrap" />,
  77          img: (props) => (
  78            <ImageWithLightbox
  79              image={{ url: props.src || '', pubkey: event.pubkey }}
  80              className="max-h-[80vh] sm:max-h-[50vh] object-contain my-0"
  81              classNames={{
  82                wrapper: 'w-fit max-w-full'
  83              }}
  84            />
  85          )
  86        }) as Components,
  87      [event.pubkey]
  88    )
  89  
  90    return (
  91      <>
  92        <div
  93          ref={contentRef}
  94          className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}
  95        >
  96          <h1 className="break-words">{metadata.title}</h1>
  97          {metadata.summary && (
  98            <blockquote>
  99              <p className="break-words">{metadata.summary}</p>
 100            </blockquote>
 101          )}
 102          {metadata.image && (
 103            <ImageWithLightbox
 104              image={{ url: metadata.image, pubkey: event.pubkey }}
 105              className="w-full aspect-[3/1] object-cover my-0"
 106            />
 107          )}
 108          <Markdown
 109            remarkPlugins={[remarkGfm, remarkNostr]}
 110            urlTransform={(url) => {
 111              if (url.startsWith('nostr:')) {
 112                return url.slice(6) // Remove 'nostr:' prefix for rendering
 113              }
 114              return url
 115            }}
 116            components={components}
 117          >
 118            {event.content}
 119          </Markdown>
 120          {metadata.tags.length > 0 && (
 121            <div className="flex gap-2 flex-wrap pb-2">
 122              {metadata.tags.map((tag) => (
 123                <div
 124                  key={tag}
 125                  title={tag}
 126                  className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground"
 127                  onClick={(e) => {
 128                    e.stopPropagation()
 129                    push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
 130                  }}
 131                >
 132                  #<span className="truncate">{tag}</span>
 133                </div>
 134              ))}
 135            </div>
 136          )}
 137        </div>
 138        <HighlightButton onHighlight={handleHighlight} containerRef={contentRef} />
 139        <PostEditor
 140          highlightedText={selectedText}
 141          parentStuff={event}
 142          open={showHighlightEditor}
 143          setOpen={setShowHighlightEditor}
 144        />
 145      </>
 146    )
 147  }
 148