pl-fe: Move parsed html to a separate component

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2024-10-03 12:09:35 +02:00
parent de8623935e
commit 63924bcc50
3 changed files with 91 additions and 73 deletions

View File

@ -0,0 +1,80 @@
import parse, { Element, type HTMLReactParserOptions, domToReact, type DOMNode } from 'html-react-parser';
import React, { useMemo } from 'react';
import { Link } from 'react-router-dom';
import HashtagLink from './hashtag-link';
import HoverRefWrapper from './hover-ref-wrapper';
import type { Mention } from 'pl-api';
const nodesToText = (nodes: Array<DOMNode>): string =>
nodes.map(node => node.type === 'text' ? node.data : node.type === 'tag' ? nodesToText(node.children as Array<DOMNode>) : '').join('');
interface IParsedContent {
html: string;
mentions?: Array<Mention>;
}
const ParsedContent: React.FC<IParsedContent> = (({ html, mentions }) => {
return useMemo(() => {
if (html.length === 0) {
return null;
}
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(' ');
const fallback = (
// 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>
);
if (classes?.includes('mention') && mentions) {
const mention = mentions.find(({ url }) => domNode.attribs.href === url);
if (mention) {
return (
<HoverRefWrapper accountId={mention.id} inline>
<Link
to={`/@${mention.acct}`}
className='text-primary-600 hover:underline dark:text-accent-blue'
dir='ltr'
onClick={(e) => e.stopPropagation()}
>
@{mention.username}
</Link>
</HoverRefWrapper>
);
}
}
if (classes?.includes('hashtag')) {
const hashtag = nodesToText(domNode.children as Array<DOMNode>);
if (hashtag) {
return <HashtagLink hashtag={hashtag.replace(/^#/, '')} />;
}
}
return fallback;
}
},
};
return parse(html, options);
}, [html]);
});
export { ParsedContent };

View File

@ -1,8 +1,6 @@
import clsx from 'clsx';
import parse, { Element, type HTMLReactParserOptions, domToReact, type DOMNode } from 'html-react-parser';
import React, { useState, useRef, useLayoutEffect, useMemo, useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { collapseStatusSpoiler, expandStatusSpoiler } from 'pl-fe/actions/statuses';
import Icon from 'pl-fe/components/icon';
@ -12,9 +10,8 @@ import { onlyEmoji as isOnlyEmoji } from 'pl-fe/utils/rich-content';
import { getTextDirection } from '../utils/rtl';
import HashtagLink from './hashtag-link';
import HoverRefWrapper from './hover-ref-wrapper';
import Markup from './markup';
import { ParsedContent } from './parsed-content';
import Poll from './polls/poll';
import type { Sizes } from 'pl-fe/components/ui/text/text';
@ -22,9 +19,6 @@ import type { MinifiedStatus } from 'pl-fe/reducers/statuses';
const BIG_EMOJI_LIMIT = 10;
const nodesToText = (nodes: Array<DOMNode>): string =>
nodes.map(node => node.type === 'text' ? node.data : node.type === 'tag' ? nodesToText(node.children as Array<DOMNode>) : '').join('');
interface IReadMoreButton {
onClick: React.MouseEventHandler;
quote?: boolean;
@ -118,64 +112,6 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
[status.contentHtml, status.translation, status.currentLanguage],
);
const content = useMemo(() => {
if (status.content.length === 0) {
return null;
}
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 (
<HoverRefWrapper accountId={mention.id} inline>
<Link
to={`/@${mention.acct}`}
className='text-primary-600 hover:underline dark:text-accent-blue'
dir='ltr'
onClick={(e) => e.stopPropagation()}
>
@{mention.username}
</Link>
</HoverRefWrapper>
);
}
}
if (classes?.includes('hashtag')) {
const hashtag = nodesToText(domNode.children as Array<DOMNode>);
if (hashtag) {
return <HashtagLink hashtag={hashtag.replace(/^#/, '')} />;
}
}
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>
);
}
},
};
return parse(parsedHtml, options);
}, [parsedHtml]);
useEffect(() => {
setLineClamp(!spoilerNode.current || spoilerNode.current.clientHeight >= 96);
}, [spoilerNode.current]);
@ -208,7 +144,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
dangerouslySetInnerHTML={{ __html: spoilerText }}
ref={spoilerNode}
/>
{content && expandable && (
{status.content && expandable && (
<Button
className='ml-2 align-middle'
type='button'
@ -229,7 +165,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
if (expandable && !expanded) return <>{output}</>;
if (onClick) {
if (content) {
if (status.content) {
output.push(
<Markup
ref={node}
@ -240,7 +176,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
lang={status.language || undefined}
size={textSize}
>
{content}
<ParsedContent html={parsedHtml} mentions={status.mentions} />
</Markup>,
);
}
@ -257,7 +193,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
return <Stack space={4} className={clsx({ 'bg-gray-100 dark:bg-primary-800 rounded-md p-4': hasPoll })}>{output}</Stack>;
} else {
if (content) {
if (status.content) {
output.push(
<Markup
ref={node}
@ -268,7 +204,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
lang={status.language || undefined}
size={textSize}
>
{content}
<ParsedContent html={parsedHtml} mentions={status.mentions} />
</Markup>,
);
}

View File

@ -3,6 +3,7 @@ import React from 'react';
import AttachmentThumbs from 'pl-fe/components/attachment-thumbs';
import Markup from 'pl-fe/components/markup';
import { ParsedContent } from 'pl-fe/components/parsed-content';
import { Stack } from 'pl-fe/components/ui';
import AccountContainer from 'pl-fe/containers/account-container';
import { getTextDirection } from 'pl-fe/utils/rtl';
@ -11,7 +12,7 @@ import type { Account, Status } from 'pl-fe/normalizers';
interface IReplyIndicator {
className?: string;
status?: Pick<Status, | 'contentHtml' | 'created_at' | 'hidden' | 'media_attachments' | 'search_index' | 'sensitive' | 'spoiler_text'> & { account: Pick<Account, 'id'> };
status?: Pick<Status, | 'contentHtml' | 'created_at' | 'hidden' | 'media_attachments' | 'mentions' | 'search_index' | 'sensitive' | 'spoiler_text'> & { account: Pick<Account, 'id'> };
onCancel?: () => void;
hideActions: boolean;
}
@ -49,9 +50,10 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
<Markup
className='break-words'
size='sm'
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
direction={getTextDirection(status.search_index)}
/>
>
<ParsedContent html={status.contentHtml} mentions={status.mentions} />
</Markup>
{status.media_attachments.length > 0 && (
<AttachmentThumbs