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 { 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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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') {
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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'>
|
||||||
|
|
Loading…
Reference in New Issue