diff --git a/package.json b/package.json index e43010029..a7812efb6 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "escape-html": "^1.0.3", "exifr": "^7.1.3", "graphemesplit": "^2.4.4", + "html-react-parser": "^4.2.2", "http-link-header": "^1.0.2", "immer": "^10.0.0", "immutable": "^4.2.1", diff --git a/src/components/status-content.tsx b/src/components/status-content.tsx index 10a11b380..8c2b2e6cd 100644 --- a/src/components/status-content.tsx +++ b/src/components/status-content.tsx @@ -1,18 +1,20 @@ import clsx from 'clsx'; +import parse, { Element, type HTMLReactParserOptions, domToReact, Text } from 'html-react-parser'; import React, { useState, useRef, useLayoutEffect, useMemo } from 'react'; import { FormattedMessage } from 'react-intl'; -import { useHistory } from 'react-router-dom'; import Icon from 'soapbox/components/icon'; import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content'; import { getTextDirection } from '../utils/rtl'; +import Link from './link'; import Markup from './markup'; +import Mention from './mention'; import Poll from './polls/poll'; import type { Sizes } from 'soapbox/components/ui/text/text'; -import type { Status, Mention } from 'soapbox/types/entities'; +import type { Status } from 'soapbox/types/entities'; const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) const BIG_EMOJI_LIMIT = 10; @@ -45,66 +47,11 @@ const StatusContent: React.FC = ({ translatable, textSize = 'md', }) => { - const history = useHistory(); - const [collapsed, setCollapsed] = useState(false); const [onlyEmoji, setOnlyEmoji] = useState(false); const node = useRef(null); - const onMentionClick = (mention: Mention, e: MouseEvent) => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - e.stopPropagation(); - history.push(`/@${mention.acct}`); - } - }; - - const onHashtagClick = (hashtag: string, e: MouseEvent) => { - hashtag = hashtag.replace(/^#/, '').toLowerCase(); - - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - e.stopPropagation(); - history.push(`/tags/${hashtag}`); - } - }; - - /** For regular links, just stop propogation */ - const onLinkClick = (e: MouseEvent) => { - e.stopPropagation(); - }; - - const updateStatusLinks = () => { - if (!node.current) return; - - const links = node.current.querySelectorAll('a'); - - links.forEach(link => { - // Skip already processed - if (link.classList.contains('status-link')) return; - - // Add attributes - link.classList.add('status-link'); - link.setAttribute('rel', 'nofollow noopener'); - link.setAttribute('target', '_blank'); - - const mention = status.mentions.find(mention => link.href === `${mention.url}`); - - // Add event listeners on mentions and hashtags - if (mention) { - link.addEventListener('click', onMentionClick.bind(link, mention), false); - link.setAttribute('title', mention.acct); - link.setAttribute('dir', 'ltr'); - } else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) { - link.addEventListener('click', onHashtagClick.bind(link, link.text), false); - } else { - link.setAttribute('title', link.href); - link.addEventListener('click', onLinkClick.bind(link), false); - } - }); - }; - const maybeSetCollapsed = (): void => { if (!node.current) return; @@ -127,7 +74,6 @@ const StatusContent: React.FC = ({ useLayoutEffect(() => { maybeSetCollapsed(); maybeSetOnlyEmoji(); - updateStatusLinks(); }); const parsedHtml = useMemo((): string => { @@ -142,7 +88,53 @@ const StatusContent: React.FC = ({ const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none'; - const content = { __html: parsedHtml }; + const options: HTMLReactParserOptions = { + replace(domNode) { + if (domNode instanceof Element && ['script', 'iframe'].includes(domNode.name)) { + return null; + } + + if (domNode instanceof Element && domNode.name === 'a') { + const classes = domNode.attribs.class?.split(' '); + + if (classes?.includes('mention')) { + const mention = status.mentions.find(({ url }) => domNode.attribs.href === url); + if (mention) { + return ; + } + } + + if (classes?.includes('hashtag')) { + const child = domNode.children[0]; + const hashtag = child instanceof Text ? child.data.replace(/^#/, '') : ''; + + if (hashtag) { + return ( + + {domToReact(domNode.children, options)} + + ); + } + } + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions + e.stopPropagation()} + rel='nofollow noopener' + target='_blank' + title={domNode.attribs.href} + > + {domToReact(domNode.children, options)} + + ); + } + }, + }; + + const content = parse(parsedHtml, options); + const direction = getTextDirection(status.search_index); const className = clsx(baseClassName, { 'cursor-pointer': onClick, @@ -159,10 +151,11 @@ const StatusContent: React.FC = ({ key='content' className={className} direction={direction} - dangerouslySetInnerHTML={content} lang={status.language || undefined} size={textSize} - />, + > + {content} + , ]; if (collapsed) { @@ -185,10 +178,11 @@ const StatusContent: React.FC = ({ 'leading-normal big-emoji': onlyEmoji, })} direction={direction} - dangerouslySetInnerHTML={content} lang={status.language || undefined} size={textSize} - />, + > + {content} + , ]; if (status.poll && typeof status.poll === 'string') { diff --git a/yarn.lock b/yarn.lock index 3c7205f8e..dcddc388f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4027,6 +4027,13 @@ domexception@^4.0.0: dependencies: webidl-conversions "^7.0.0" +domhandler@5.0.3, domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + domhandler@^4.2.0, domhandler@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" @@ -4034,13 +4041,6 @@ domhandler@^4.2.0, domhandler@^4.3.1: dependencies: domelementtype "^2.2.0" -domhandler@^5.0.2, domhandler@^5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" - integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== - dependencies: - domelementtype "^2.3.0" - domutils@^2.8.0: version "2.8.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" @@ -4050,7 +4050,7 @@ domutils@^2.8.0: domelementtype "^2.2.0" domhandler "^4.2.0" -domutils@^3.0.1: +domutils@^3.0.1, domutils@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e" integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA== @@ -4148,7 +4148,7 @@ entities@^2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== -entities@^4.2.0, entities@^4.4.0: +entities@^4.2.0, entities@^4.4.0, entities@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== @@ -5079,6 +5079,14 @@ hosted-git-info@^4.0.1: dependencies: lru-cache "^6.0.0" +html-dom-parser@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/html-dom-parser/-/html-dom-parser-4.0.0.tgz#dc382fbbc9306f8c9b5aae4e3f2822e113a48709" + integrity sha512-TUa3wIwi80f5NF8CVWzkopBVqVAtlawUzJoLwVLHns0XSJGynss4jiY0mTWpiDOsuyw+afP+ujjMgRh9CoZcXw== + dependencies: + domhandler "5.0.3" + htmlparser2 "9.0.0" + html-encoding-sniffer@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" @@ -5099,11 +5107,31 @@ html-minifier-terser@^6.1.0: relateurl "^0.2.7" terser "^5.10.0" +html-react-parser@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/html-react-parser/-/html-react-parser-4.2.2.tgz#91f1cc2138bc069d65cbd8b9d97b1e71ed423300" + integrity sha512-lh0wEGISnFZEAmvQqK4xc0duFMUh/m9YYyAhFursWxdtNv+hCZge0kj1y4wep6qPB5Zm33L+2/P6TcGWAJJbjA== + dependencies: + domhandler "5.0.3" + html-dom-parser "4.0.0" + react-property "2.0.0" + style-to-js "1.1.4" + html-tags@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ== +htmlparser2@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.0.0.tgz#e431142b7eeb1d91672742dea48af8ac7140cddb" + integrity sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.1.0" + entities "^4.5.0" + http-cache-semantics@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" @@ -5232,6 +5260,11 @@ ini@^1.3.5: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== +inline-style-parser@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1" + integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q== + internal-slot@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.5.tgz#f2a2ee21f668f8627a4667f309dc0f4fb6674986" @@ -7275,6 +7308,11 @@ react-popper@^2.2.5, react-popper@^2.3.0: react-fast-compare "^3.0.1" warning "^4.0.2" +react-property@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/react-property/-/react-property-2.0.0.tgz#2156ba9d85fa4741faf1918b38efc1eae3c6a136" + integrity sha512-kzmNjIgU32mO4mmH5+iUyrqlpFQhF8K2k7eZ4fdLSOPFrD1XgEuSBv9LDEgxRXTMBqMd8ppT0x6TIzqE5pdGdw== + react-redux@^8.0.0: version "8.0.5" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.0.5.tgz#e5fb8331993a019b8aaf2e167a93d10af469c7bd" @@ -8103,6 +8141,20 @@ style-search@^0.1.0: resolved "https://registry.yarnpkg.com/style-search/-/style-search-0.1.0.tgz#7958c793e47e32e07d2b5cafe5c0bf8e12e77902" integrity sha1-eVjHk+R+MuB9K1yv5cC/jhLneQI= +style-to-js@1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/style-to-js/-/style-to-js-1.1.4.tgz#5fa07a181ec3ca354d699edf442549e0ac61ed32" + integrity sha512-zEeU3vy9xL/hdLBFmzqjhm+2vJ1Y35V0ctDeB2sddsvN1856OdMZUCOOfKUn3nOjjEKr6uLhOnY4CrX6gLDRrA== + dependencies: + style-to-object "0.4.2" + +style-to-object@0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.4.2.tgz#a8247057111dea8bd3b8a1a66d2d0c9cf9218a54" + integrity sha512-1JGpfPB3lo42ZX8cuPrheZbfQ6kqPPnPHlKMyeRYtfKD+0jG+QsXgXN57O/dvJlzlB2elI6dGmrPnl5VPQFPaA== + dependencies: + inline-style-parser "0.1.1" + stylehacks@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-6.0.0.tgz#9fdd7c217660dae0f62e14d51c89f6c01b3cb738"