From aa6427d4babf89f6168593585e349b8e7bfc1178 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 27 Nov 2024 19:09:24 -0600 Subject: [PATCH] Rework the component to do the fancy rendering of StatusContent --- src/components/markup.tsx | 77 ++++++++++++++++++- src/components/status-content.tsx | 77 ++----------------- src/components/ui/emoji.tsx | 2 +- .../compose/components/reply-indicator.tsx | 6 +- .../components/site-banner.tsx | 2 +- src/features/ui/components/profile-field.tsx | 2 +- .../ui/components/profile-info-panel.tsx | 2 +- 7 files changed, 90 insertions(+), 78 deletions(-) diff --git a/src/components/markup.tsx b/src/components/markup.tsx index 3f5647787..d43c1e35f 100644 --- a/src/components/markup.tsx +++ b/src/components/markup.tsx @@ -1,15 +1,86 @@ +import parse, { HTMLReactParserOptions, Text as DOMText, DOMNode, Element, domToReact } from 'html-react-parser'; import { forwardRef } from 'react'; +import HashtagLink from 'soapbox/components/hashtag-link.tsx'; +import Mention from 'soapbox/components/mention.tsx'; +import { CustomEmoji } from 'soapbox/schemas/custom-emoji.ts'; +import { Mention as MentionEntity } from 'soapbox/schemas/mention.ts'; +import { emojifyText } from 'soapbox/utils/emojify.tsx'; + import Text, { IText } from './ui/text.tsx'; import './markup.css'; -interface IMarkup extends IText { +interface IMarkup extends Omit { + html: { __html: string }; + mentions?: MentionEntity[]; + emojis?: CustomEmoji[]; } /** Styles HTML markup returned by the API, such as in account bios and statuses. */ -const Markup = forwardRef((props, ref) => { +const Markup = forwardRef(({ html, emojis, mentions, ...props }, ref) => { + const options: HTMLReactParserOptions = { + replace(domNode) { + if (domNode instanceof Element && ['script', 'iframe'].includes(domNode.name)) { + return null; + } + + if (domNode instanceof DOMText && emojis) { + return emojifyText(domNode.data, emojis); + } + + if (domNode instanceof Element && domNode.name === 'a') { + const classes = domNode.attribs.class?.split(' '); + + if (classes?.includes('hashtag')) { + const child = domToReact(domNode.children as DOMNode[]); + + const hashtag: string | undefined = (() => { + // Mastodon wraps the hashtag in a span, with a sibling text node containing the hashtag. + if (Array.isArray(child) && child.length) { + if (child[0]?.props?.children === '#' && typeof child[1] === 'string') { + return child[1]; + } + } + // Pleroma renders a string directly inside the hashtag link. + if (typeof child === 'string') { + return child.replace(/^#/, ''); + } + })(); + + if (hashtag) { + return ; + } + } + + if (classes?.includes('mention')) { + const mention = mentions?.find(({ url }) => domNode.attribs.href === url); + if (mention) { + return ; + } + } + + 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 as DOMNode[], options)} + + ); + } + }, + }; + + const content = parse(html.__html, options); + return ( - + + {content} + ); }); diff --git a/src/components/status-content.tsx b/src/components/status-content.tsx index 96cf532d4..e0d37e979 100644 --- a/src/components/status-content.tsx +++ b/src/components/status-content.tsx @@ -1,17 +1,13 @@ import chevronRightIcon from '@tabler/icons/outline/chevron-right.svg'; import clsx from 'clsx'; import graphemesplit from 'graphemesplit'; -import parse, { Element, type HTMLReactParserOptions, domToReact, type DOMNode, Text as DOMText } from 'html-react-parser'; import { useState, useRef, useLayoutEffect, useMemo, memo } from 'react'; import { FormattedMessage } from 'react-intl'; import Icon from 'soapbox/components/icon.tsx'; -import { emojifyText } from 'soapbox/utils/emojify.tsx'; import { getTextDirection } from 'soapbox/utils/rtl.ts'; -import HashtagLink from './hashtag-link.tsx'; import Markup from './markup.tsx'; -import Mention from './mention.tsx'; import Poll from './polls/poll.tsx'; import type { Sizes } from 'soapbox/components/ui/text.tsx'; @@ -83,65 +79,6 @@ const StatusContent: React.FC = ({ const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none'; - const options: HTMLReactParserOptions = { - replace(domNode) { - if (domNode instanceof Element && ['script', 'iframe'].includes(domNode.name)) { - return null; - } - - if (domNode instanceof DOMText) { - return emojifyText(domNode.data, status.emojis.toJS()); - } - - if (domNode instanceof Element && domNode.name === 'a') { - const classes = domNode.attribs.class?.split(' '); - - if (classes?.includes('hashtag')) { - const child = domToReact(domNode.children as DOMNode[]); - - const hashtag: string | undefined = (() => { - // Mastodon wraps the hashtag in a span, with a sibling text node containing the hashtag. - if (Array.isArray(child) && child.length) { - if (child[0]?.props?.children === '#' && typeof child[1] === 'string') { - return child[1]; - } - } - // Pleroma renders a string directly inside the hashtag link. - if (typeof child === 'string') { - return child.replace(/^#/, ''); - } - })(); - - if (hashtag) { - return ; - } - } - - if (classes?.includes('mention')) { - const mention = status.mentions.find(({ url }) => domNode.attribs.href === url); - if (mention) { - return ; - } - } - - 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 as DOMNode[], options)} - - ); - } - }, - }; - - const content = parse(parsedHtml, options); - const direction = getTextDirection(status.search_index); const className = clsx(baseClassName, { 'cursor-pointer': onClick, @@ -160,9 +97,10 @@ const StatusContent: React.FC = ({ direction={direction} lang={status.language || undefined} size={textSize} - > - {content} - , + emojis={status.emojis.toJS()} + mentions={status.mentions.toJS()} + html={{ __html: parsedHtml }} + />, ]; if (collapsed) { @@ -187,9 +125,10 @@ const StatusContent: React.FC = ({ direction={direction} lang={status.language || undefined} size={textSize} - > - {content} - , + emojis={status.emojis.toJS()} + mentions={status.mentions.toJS()} + html={{ __html: parsedHtml }} + />, ]; if (status.poll && typeof status.poll === 'string') { diff --git a/src/components/ui/emoji.tsx b/src/components/ui/emoji.tsx index 75cc4d111..e097305ab 100644 --- a/src/components/ui/emoji.tsx +++ b/src/components/ui/emoji.tsx @@ -11,7 +11,7 @@ const Emoji: React.FC = (props): JSX.Element | null => { const px = `${size}px`; return ( -
+
{emoji}
); diff --git a/src/features/compose/components/reply-indicator.tsx b/src/features/compose/components/reply-indicator.tsx index dacdcf309..52b265ab6 100644 --- a/src/features/compose/components/reply-indicator.tsx +++ b/src/features/compose/components/reply-indicator.tsx @@ -39,7 +39,7 @@ const ReplyIndicator: React.FC = ({ className, status, hideActi = ({ className, status, hideActi {status.media_attachments.size > 0 && ( diff --git a/src/features/landing-timeline/components/site-banner.tsx b/src/features/landing-timeline/components/site-banner.tsx index d4c486559..48c58eba2 100644 --- a/src/features/landing-timeline/components/site-banner.tsx +++ b/src/features/landing-timeline/components/site-banner.tsx @@ -19,8 +19,8 @@ const SiteBanner: React.FC = () => { ); diff --git a/src/features/ui/components/profile-field.tsx b/src/features/ui/components/profile-field.tsx index 42995caa4..981a123dc 100644 --- a/src/features/ui/components/profile-field.tsx +++ b/src/features/ui/components/profile-field.tsx @@ -68,7 +68,7 @@ const ProfileField: React.FC = ({ field }) => { diff --git a/src/features/ui/components/profile-info-panel.tsx b/src/features/ui/components/profile-info-panel.tsx index 26860b164..582a2023a 100644 --- a/src/features/ui/components/profile-info-panel.tsx +++ b/src/features/ui/components/profile-info-panel.tsx @@ -183,7 +183,7 @@ const ProfileInfoPanel: React.FC = ({ account, username }) => {account.note.length > 0 && ( - + )}