* Parse internal refs to links * Item mention notifications * Also parse item mentions as URLs * Fix subType determined by referrer item instead of referee item * Ignore subType Considering if the item that was referred to was a post or comment made the code more complex than initially necessary. For example, notifications for /notifications are deduplicated based on item id and the same item could refer to posts and comments, so to include "one of your posts" or "one of your comments" in the title would require splitting notifications based on the type of referred item. I didn't want to do this but also wanted to have consistent notification titles between push and /notifications, so I use "items" in both places now, even though I think using "items" isn't ideal from a user perspective. I think it might be confusing. * Fix rootText * Replace full links to #<id> syntax in push notifications * Refactor mention code into separate functions
314 lines
10 KiB
JavaScript
314 lines
10 KiB
JavaScript
import styles from './text.module.css'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import YouTube from 'react-youtube'
|
|
import gfm from 'remark-gfm'
|
|
import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter'
|
|
import atomDark from 'react-syntax-highlighter/dist/cjs/styles/prism/atom-dark'
|
|
import mention from '@/lib/remark-mention'
|
|
import sub from '@/lib/remark-sub'
|
|
import React, { useState, memo, useRef, useCallback, useMemo, useEffect } from 'react'
|
|
import GithubSlugger from 'github-slugger'
|
|
import LinkIcon from '@/svgs/link.svg'
|
|
import Thumb from '@/svgs/thumb-up-fill.svg'
|
|
import { toString } from 'mdast-util-to-string'
|
|
import copy from 'clipboard-copy'
|
|
import ZoomableImage, { decodeOriginalUrl } from './image'
|
|
import { IMGPROXY_URL_REGEXP, parseInternalLinks, parseEmbedUrl } from '@/lib/url'
|
|
import reactStringReplace from 'react-string-replace'
|
|
import { rehypeInlineCodeProperty } from '@/lib/md'
|
|
import { Button } from 'react-bootstrap'
|
|
import { useRouter } from 'next/router'
|
|
import Link from 'next/link'
|
|
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
|
import isEqual from 'lodash/isEqual'
|
|
import UserPopover from './user-popover'
|
|
import ItemPopover from './item-popover'
|
|
import ref from '@/lib/remark-ref2link'
|
|
|
|
export function SearchText ({ text }) {
|
|
return (
|
|
<div className={styles.text}>
|
|
<p className={styles.p}>
|
|
{reactStringReplace(text, /\*\*\*([^*]+)\*\*\*/g, (match, i) => {
|
|
return <mark key={`strong-${match}-${i}`}>{match}</mark>
|
|
})}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// this is one of the slowest components to render
|
|
export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, outlawed, topLevel, noFragments }) {
|
|
const [overflowing, setOverflowing] = useState(false)
|
|
const router = useRouter()
|
|
const [show, setShow] = useState(false)
|
|
const containerRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
setShow(router.asPath.includes('#'))
|
|
const handleRouteChange = (url, { shallow }) => {
|
|
setShow(url.includes('#'))
|
|
}
|
|
|
|
router.events.on('hashChangeStart', handleRouteChange)
|
|
|
|
return () => {
|
|
router.events.off('hashChangeStart', handleRouteChange)
|
|
}
|
|
}, [router])
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current
|
|
if (!container || overflowing) return
|
|
|
|
function checkOverflow () {
|
|
setOverflowing(container.scrollHeight > window.innerHeight * 2)
|
|
}
|
|
|
|
let resizeObserver
|
|
if (!overflowing && 'ResizeObserver' in window) {
|
|
resizeObserver = new window.ResizeObserver(checkOverflow).observe(container)
|
|
}
|
|
|
|
window.addEventListener('resize', checkOverflow)
|
|
checkOverflow()
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', checkOverflow)
|
|
resizeObserver?.disconnect()
|
|
}
|
|
}, [containerRef.current, setOverflowing])
|
|
|
|
const slugger = new GithubSlugger()
|
|
|
|
const Heading = useCallback(({ children, node, ...props }) => {
|
|
const [copied, setCopied] = useState(false)
|
|
const nodeText = toString(node)
|
|
const id = useMemo(() => noFragments ? undefined : slugger?.slug(nodeText.replace(/[^\w\-\s]+/gi, '')), [nodeText, noFragments, slugger])
|
|
const h = useMemo(() => {
|
|
if (topLevel) {
|
|
return node?.TagName
|
|
}
|
|
|
|
const h = parseInt(node?.tagName?.replace('h', '') || 0)
|
|
if (h < 4) return `h${h + 3}`
|
|
|
|
return 'h6'
|
|
}, [node, topLevel])
|
|
const Icon = copied ? Thumb : LinkIcon
|
|
|
|
return (
|
|
<span className={styles.heading}>
|
|
{React.createElement(h || node?.tagName, { id, ...props }, children)}
|
|
{!noFragments && topLevel &&
|
|
<a className={`${styles.headingLink} ${copied ? styles.copied : ''}`} href={`#${id}`}>
|
|
<Icon
|
|
onClick={() => {
|
|
const location = new URL(window.location)
|
|
location.hash = `${id}`
|
|
copy(location.href)
|
|
setTimeout(() => setCopied(false), 1500)
|
|
setCopied(true)
|
|
}}
|
|
width={18}
|
|
height={18}
|
|
className='fill-grey'
|
|
/>
|
|
</a>}
|
|
</span>
|
|
)
|
|
}, [topLevel, noFragments, slugger.current])
|
|
|
|
const Table = useCallback(({ node, ...props }) =>
|
|
<span className='table-responsive'>
|
|
<table className='table table-bordered table-sm' {...props} />
|
|
</span>, [])
|
|
|
|
const Code = useCallback(({ node, inline, className, children, style, ...props }) => {
|
|
return inline
|
|
? (
|
|
<code className={className} {...props}>
|
|
{children}
|
|
</code>
|
|
)
|
|
: (
|
|
<SyntaxHighlighter style={atomDark} language='text' PreTag='div' {...props}>
|
|
{children}
|
|
</SyntaxHighlighter>
|
|
)
|
|
}, [])
|
|
|
|
const P = useCallback(({ children, node, ...props }) => <div className={styles.p} {...props}>{children}</div>, [])
|
|
|
|
const Img = useCallback(({ node, src, ...props }) => {
|
|
const url = IMGPROXY_URL_REGEXP.test(src) ? decodeOriginalUrl(src) : src
|
|
// if outlawed, render the image link as text
|
|
if (outlawed) {
|
|
return url
|
|
}
|
|
const srcSet = imgproxyUrls?.[url]
|
|
return <ZoomableImage srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel />
|
|
}, [imgproxyUrls, topLevel, tab])
|
|
|
|
return (
|
|
<div className={`${styles.text} ${show ? styles.textUncontained : overflowing ? styles.textContained : ''}`} ref={containerRef}>
|
|
<ReactMarkdown
|
|
components={{
|
|
h1: Heading,
|
|
h2: Heading,
|
|
h3: Heading,
|
|
h4: Heading,
|
|
h5: Heading,
|
|
h6: Heading,
|
|
table: Table,
|
|
p: P,
|
|
li: props => {
|
|
return <li {...props} id={props.id && itemId ? `${props.id}-${itemId}` : props.id} />
|
|
},
|
|
code: Code,
|
|
a: ({ node, href, children, ...props }) => {
|
|
children = children ? Array.isArray(children) ? children : [children] : []
|
|
// don't allow zoomable images to be wrapped in links
|
|
if (children.some(e => e?.props?.node?.tagName === 'img')) {
|
|
return <>{children}</>
|
|
}
|
|
|
|
// if outlawed, render the link as text
|
|
if (outlawed) {
|
|
return href
|
|
}
|
|
|
|
// If [text](url) was parsed as <a> and text is not empty and not a link itself,
|
|
// we don't render it as an image since it was probably a conscious choice to include text.
|
|
const text = children[0]
|
|
let url
|
|
try {
|
|
url = !href.startsWith('/') && new URL(href)
|
|
} catch {
|
|
// ignore invalid URLs
|
|
}
|
|
|
|
const internalURL = process.env.NEXT_PUBLIC_URL
|
|
if (!!text && !/^https?:\/\//.test(text)) {
|
|
if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') {
|
|
return (
|
|
<Link
|
|
{...props}
|
|
id={props.id && itemId ? `${props.id}-${itemId}` : props.id}
|
|
href={itemId ? `${href}-${itemId}` : href}
|
|
>{text}
|
|
</Link>
|
|
)
|
|
}
|
|
if (text.startsWith?.('@')) {
|
|
return (
|
|
<UserPopover name={text.replace('@', '')}>
|
|
<Link
|
|
id={props.id}
|
|
href={href}
|
|
>
|
|
{text}
|
|
</Link>
|
|
</UserPopover>
|
|
)
|
|
} else if (href.startsWith('/') || url?.origin === internalURL) {
|
|
try {
|
|
const linkText = parseInternalLinks(href)
|
|
if (linkText) {
|
|
return (
|
|
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
|
<Link href={href}>{text}</Link>
|
|
</ItemPopover>
|
|
)
|
|
}
|
|
} catch {
|
|
// ignore errors like invalid URLs
|
|
}
|
|
|
|
return (
|
|
<Link
|
|
id={props.id}
|
|
href={href}
|
|
>
|
|
{text}
|
|
</Link>
|
|
)
|
|
}
|
|
return (
|
|
// eslint-disable-next-line
|
|
<a id={props.id} target='_blank' rel={rel ?? UNKNOWN_LINK_REL} href={href}>{text}</a>
|
|
)
|
|
}
|
|
|
|
try {
|
|
const linkText = parseInternalLinks(href)
|
|
if (linkText) {
|
|
return (
|
|
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
|
<Link href={href}>{linkText}</Link>
|
|
</ItemPopover>
|
|
)
|
|
}
|
|
} catch {
|
|
// ignore errors like invalid URLs
|
|
}
|
|
|
|
const videoWrapperStyles = {
|
|
maxWidth: topLevel ? '640px' : '320px',
|
|
margin: '0.5rem 0',
|
|
paddingRight: '15px'
|
|
}
|
|
|
|
try {
|
|
const { provider, id, meta } = parseEmbedUrl(href)
|
|
// Youtube video embed
|
|
if (provider === 'youtube') {
|
|
return (
|
|
<div style={videoWrapperStyles}>
|
|
<YouTube
|
|
videoId={id} className={styles.videoContainer} opts={{
|
|
playerVars: {
|
|
start: meta?.start || 0
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Rumble video embed
|
|
if (provider === 'rumble') {
|
|
return (
|
|
<div style={videoWrapperStyles}>
|
|
<div className={styles.videoContainer}>
|
|
<iframe
|
|
title='Rumble Video'
|
|
allowFullScreen=''
|
|
src={meta?.href}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
} catch {
|
|
// ignore invalid URLs
|
|
}
|
|
|
|
// assume the link is an image which will fallback to link if it's not
|
|
return <Img src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</Img>
|
|
},
|
|
img: Img
|
|
}}
|
|
remarkPlugins={[gfm, mention, sub, ref]}
|
|
rehypePlugins={[rehypeInlineCodeProperty]}
|
|
>
|
|
{children}
|
|
</ReactMarkdown>
|
|
{overflowing && !show &&
|
|
<Button size='lg' variant='info' className={styles.textShowFull} onClick={() => setShow(true)}>
|
|
show full text
|
|
</Button>}
|
|
</div>
|
|
)
|
|
}, isEqual)
|