Rework the <Markup> component to do the fancy rendering of StatusContent
This commit is contained in:
parent
c27e4c6556
commit
aa6427d4ba
|
@ -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<IText, 'children' | 'dangerouslySetInnerHTML'> {
|
||||
html: { __html: string };
|
||||
mentions?: MentionEntity[];
|
||||
emojis?: CustomEmoji[];
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Text ref={ref} {...props} data-markup />
|
||||
// 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 (
|
||||
<Text ref={ref} {...props} data-markup>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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<IStatusContent> = ({
|
|||
|
||||
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 className = clsx(baseClassName, {
|
||||
'cursor-pointer': onClick,
|
||||
|
@ -160,9 +97,10 @@ const StatusContent: React.FC<IStatusContent> = ({
|
|||
direction={direction}
|
||||
lang={status.language || undefined}
|
||||
size={textSize}
|
||||
>
|
||||
{content}
|
||||
</Markup>,
|
||||
emojis={status.emojis.toJS()}
|
||||
mentions={status.mentions.toJS()}
|
||||
html={{ __html: parsedHtml }}
|
||||
/>,
|
||||
];
|
||||
|
||||
if (collapsed) {
|
||||
|
@ -187,9 +125,10 @@ const StatusContent: React.FC<IStatusContent> = ({
|
|||
direction={direction}
|
||||
lang={status.language || undefined}
|
||||
size={textSize}
|
||||
>
|
||||
{content}
|
||||
</Markup>,
|
||||
emojis={status.emojis.toJS()}
|
||||
mentions={status.mentions.toJS()}
|
||||
html={{ __html: parsedHtml }}
|
||||
/>,
|
||||
];
|
||||
|
||||
if (status.poll && typeof status.poll === 'string') {
|
||||
|
|
|
@ -11,7 +11,7 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
|
|||
const px = `${size}px`;
|
||||
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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)}>
|
||||
<AccountContainer
|
||||
{...actions}
|
||||
id={status.getIn(['account', 'id']) as string}
|
||||
id={status.account.id}
|
||||
timestamp={status.created_at}
|
||||
showProfileHoverCard={false}
|
||||
withLinkToProfile={false}
|
||||
|
@ -49,8 +49,10 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
|
|||
<Markup
|
||||
className='break-words'
|
||||
size='sm'
|
||||
dangerouslySetInnerHTML={{ __html: status.content }}
|
||||
direction={getTextDirection(status.search_index)}
|
||||
emojis={status.emojis.toJS()}
|
||||
mentions={status.mentions.toJS()}
|
||||
html={{ __html: status.content }}
|
||||
/>
|
||||
|
||||
{status.media_attachments.size > 0 && (
|
||||
|
|
|
@ -19,8 +19,8 @@ const SiteBanner: React.FC = () => {
|
|||
|
||||
<Markup
|
||||
size='lg'
|
||||
dangerouslySetInnerHTML={{ __html: description }}
|
||||
direction={getTextDirection(description)}
|
||||
html={{ __html: description }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
@ -68,7 +68,7 @@ const ProfileField: React.FC<IProfileField> = ({ field }) => {
|
|||
<Markup
|
||||
className='overflow-hidden break-words'
|
||||
tag='span'
|
||||
dangerouslySetInnerHTML={{ __html: field.value }}
|
||||
html={{ __html: field.value }}
|
||||
/>
|
||||
</HStack>
|
||||
</dd>
|
||||
|
|
|
@ -183,7 +183,7 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
|
|||
<ProfileStats account={account} />
|
||||
|
||||
{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'>
|
||||
|
|
Loading…
Reference in New Issue