Rework the <Markup> component to do the fancy rendering of StatusContent

This commit is contained in:
Alex Gleason 2024-11-27 19:09:24 -06:00
parent c27e4c6556
commit aa6427d4ba
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
7 changed files with 90 additions and 78 deletions

View File

@ -1,15 +1,86 @@
import parse, { HTMLReactParserOptions, Text as DOMText, DOMNode, Element, domToReact } from 'html-react-parser';
import { forwardRef } from 'react'; 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 Text, { IText } from './ui/text.tsx';
import './markup.css'; import './markup.css';
interface IMarkup extends IText { interface IMarkup extends Omit<IText, 'children' | 'dangerouslySetInnerHTML'> {
html: { __html: string };
mentions?: MentionEntity[];
emojis?: CustomEmoji[];
} }
/** Styles HTML markup returned by the API, such as in account bios and statuses. */ /** Styles HTML markup returned by the API, such as in account bios and statuses. */
const Markup = forwardRef<any, IMarkup>((props, ref) => { const Markup = forwardRef<any, IMarkup>(({ 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 <HashtagLink hashtag={hashtag} />;
}
}
if (classes?.includes('mention')) {
const mention = mentions?.find(({ url }) => domNode.attribs.href === url);
if (mention) {
return <Mention mention={mention} />;
}
}
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<a
{...domNode.attribs}
onClick={(e) => e.stopPropagation()}
rel='nofollow noopener'
target='_blank'
title={domNode.attribs.href}
>
{domToReact(domNode.children as DOMNode[], options)}
</a>
);
}
},
};
const content = parse(html.__html, options);
return ( return (
<Text ref={ref} {...props} data-markup /> <Text ref={ref} {...props} data-markup>
{content}
</Text>
); );
}); });

View File

@ -1,17 +1,13 @@
import chevronRightIcon from '@tabler/icons/outline/chevron-right.svg'; import chevronRightIcon from '@tabler/icons/outline/chevron-right.svg';
import clsx from 'clsx'; import clsx from 'clsx';
import graphemesplit from 'graphemesplit'; 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 { useState, useRef, useLayoutEffect, useMemo, memo } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Icon from 'soapbox/components/icon.tsx'; import Icon from 'soapbox/components/icon.tsx';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import { getTextDirection } from 'soapbox/utils/rtl.ts'; import { getTextDirection } from 'soapbox/utils/rtl.ts';
import HashtagLink from './hashtag-link.tsx';
import Markup from './markup.tsx'; import Markup from './markup.tsx';
import Mention from './mention.tsx';
import Poll from './polls/poll.tsx'; import Poll from './polls/poll.tsx';
import type { Sizes } from 'soapbox/components/ui/text.tsx'; import type { Sizes } from 'soapbox/components/ui/text.tsx';
@ -83,65 +79,6 @@ const StatusContent: React.FC<IStatusContent> = ({
const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none'; 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 <HashtagLink hashtag={hashtag} />;
}
}
if (classes?.includes('mention')) {
const mention = status.mentions.find(({ url }) => domNode.attribs.href === url);
if (mention) {
return <Mention mention={mention} />;
}
}
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<a
{...domNode.attribs}
onClick={(e) => e.stopPropagation()}
rel='nofollow noopener'
target='_blank'
title={domNode.attribs.href}
>
{domToReact(domNode.children as DOMNode[], options)}
</a>
);
}
},
};
const content = parse(parsedHtml, options);
const direction = getTextDirection(status.search_index); const direction = getTextDirection(status.search_index);
const className = clsx(baseClassName, { const className = clsx(baseClassName, {
'cursor-pointer': onClick, 'cursor-pointer': onClick,
@ -160,9 +97,10 @@ const StatusContent: React.FC<IStatusContent> = ({
direction={direction} direction={direction}
lang={status.language || undefined} lang={status.language || undefined}
size={textSize} size={textSize}
> emojis={status.emojis.toJS()}
{content} mentions={status.mentions.toJS()}
</Markup>, html={{ __html: parsedHtml }}
/>,
]; ];
if (collapsed) { if (collapsed) {
@ -187,9 +125,10 @@ const StatusContent: React.FC<IStatusContent> = ({
direction={direction} direction={direction}
lang={status.language || undefined} lang={status.language || undefined}
size={textSize} size={textSize}
> emojis={status.emojis.toJS()}
{content} mentions={status.mentions.toJS()}
</Markup>, html={{ __html: parsedHtml }}
/>,
]; ];
if (status.poll && typeof status.poll === 'string') { if (status.poll && typeof status.poll === 'string') {

View File

@ -11,7 +11,7 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
const px = `${size}px`; const px = `${size}px`;
return ( return (
<div className='inline-flex items-center justify-center font-emoji leading-[0]' style={{ width: px, height: px, fontSize: px }}> <div className='inline-flex select-none items-center justify-center font-emoji leading-[0]' style={{ width: px, height: px, fontSize: px }}>
{emoji} {emoji}
</div> </div>
); );

View File

@ -39,7 +39,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
<Stack space={2} className={clsx('max-h-72 overflow-y-auto rounded-lg bg-gray-100 p-4 black:bg-gray-900 dark:bg-gray-800', className)}> <Stack space={2} className={clsx('max-h-72 overflow-y-auto rounded-lg bg-gray-100 p-4 black:bg-gray-900 dark:bg-gray-800', className)}>
<AccountContainer <AccountContainer
{...actions} {...actions}
id={status.getIn(['account', 'id']) as string} id={status.account.id}
timestamp={status.created_at} timestamp={status.created_at}
showProfileHoverCard={false} showProfileHoverCard={false}
withLinkToProfile={false} withLinkToProfile={false}
@ -49,8 +49,10 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
<Markup <Markup
className='break-words' className='break-words'
size='sm' size='sm'
dangerouslySetInnerHTML={{ __html: status.content }}
direction={getTextDirection(status.search_index)} direction={getTextDirection(status.search_index)}
emojis={status.emojis.toJS()}
mentions={status.mentions.toJS()}
html={{ __html: status.content }}
/> />
{status.media_attachments.size > 0 && ( {status.media_attachments.size > 0 && (

View File

@ -19,8 +19,8 @@ const SiteBanner: React.FC = () => {
<Markup <Markup
size='lg' size='lg'
dangerouslySetInnerHTML={{ __html: description }}
direction={getTextDirection(description)} direction={getTextDirection(description)}
html={{ __html: description }}
/> />
</Stack> </Stack>
); );

View File

@ -68,7 +68,7 @@ const ProfileField: React.FC<IProfileField> = ({ field }) => {
<Markup <Markup
className='overflow-hidden break-words' className='overflow-hidden break-words'
tag='span' tag='span'
dangerouslySetInnerHTML={{ __html: field.value }} html={{ __html: field.value }}
/> />
</HStack> </HStack>
</dd> </dd>

View File

@ -183,7 +183,7 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
<ProfileStats account={account} /> <ProfileStats account={account} />
{account.note.length > 0 && ( {account.note.length > 0 && (
<Markup size='sm' dangerouslySetInnerHTML={{ __html: account.note }} truncate /> <Markup size='sm' html={{ __html: account.note }} emojis={account.emojis} truncate />
)} )}
<div className='flex flex-col items-start gap-2 md:flex-row md:flex-wrap md:items-center'> <div className='flex flex-col items-start gap-2 md:flex-row md:flex-wrap md:items-center'>