Merge branch 'custom-render' into 'main'

StatusContent: replace custom emojis on render

See merge request soapbox-pub/soapbox!3260
This commit is contained in:
Alex Gleason 2024-11-18 05:48:05 +00:00
commit 19aeb7ebc6
8 changed files with 40 additions and 15 deletions

View File

@ -1,10 +1,11 @@
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 } from 'html-react-parser';
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 { useCustomEmojis } from 'soapbox/api/hooks/useCustomEmojis.ts';
import Icon from 'soapbox/components/icon.tsx';
import { getTextDirection } from '../utils/rtl.ts';
@ -51,11 +52,12 @@ const StatusContent: React.FC<IStatusContent> = ({
const [collapsed, setCollapsed] = useState(false);
const node = useRef<HTMLDivElement>(null);
const { customEmojis } = useCustomEmojis();
const isOnlyEmoji = useMemo(() => {
const textContent = new DOMParser().parseFromString(status.contentHtml, 'text/html').body.firstChild?.textContent;
return Boolean(textContent && /^\p{Extended_Pictographic}+$/u.test(textContent) && (graphemesplit(textContent).length <= BIG_EMOJI_LIMIT));
}, [status.contentHtml]);
const textContent = new DOMParser().parseFromString(status.content, 'text/html').body.firstChild?.textContent ?? '';
return Boolean(/^\p{Extended_Pictographic}+$/u.test(textContent) && (graphemesplit(textContent).length <= BIG_EMOJI_LIMIT));
}, [status.content]);
const maybeSetCollapsed = (): void => {
if (!node.current) return;
@ -72,8 +74,8 @@ const StatusContent: React.FC<IStatusContent> = ({
});
const parsedHtml = useMemo((): string => {
return translatable && status.translation ? status.translation.get('content')! : status.contentHtml;
}, [status.contentHtml, status.translation]);
return translatable && status.translation ? status.translation.get('content')! : status.content;
}, [status.content, status.translation]);
if (status.content.length === 0) {
return null;
@ -89,6 +91,30 @@ const StatusContent: React.FC<IStatusContent> = ({
return null;
}
if (domNode instanceof DOMText) {
const parts: Array<string | JSX.Element> = [];
const textNodes = domNode.data.split(/:\w+:/);
const shortcodes = [...domNode.data.matchAll(/:(\w+):/g)];
for (let i = 0; i < textNodes.length; i++) {
parts.push(textNodes[i]);
if (shortcodes[i]) {
const [text, shortcode] = shortcodes[i];
const customEmoji = customEmojis.find((e) => e.shortcode === shortcode);
if (customEmoji) {
parts.push(<img key={i} src={customEmoji.url} alt={shortcode} className='inline-block h-[1em]' />);
} else {
parts.push(text);
}
}
}
return <>{parts}</>;
}
if (domNode instanceof Element && domNode.name === 'a') {
const classes = domNode.attribs.class?.split(' ');

View File

@ -31,7 +31,7 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
target_languages: targetLanguages,
} = instance.pleroma.metadata.translation;
const renderTranslate = (me || allowUnauthenticated) && (allowRemote || status.account.local) && ['public', 'unlisted'].includes(status.visibility) && status.contentHtml.length > 0 && status.language !== null && intl.locale !== status.language;
const renderTranslate = (me || allowUnauthenticated) && (allowRemote || status.account.local) && ['public', 'unlisted'].includes(status.visibility) && status.content.length > 0 && status.language !== null && intl.locale !== status.language;
const supportsLanguages = (!sourceLanguages || sourceLanguages.includes(status.language!)) && (!targetLanguages || targetLanguages.includes(intl.locale));

View File

@ -49,7 +49,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
<Markup
className='break-words'
size='sm'
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
dangerouslySetInnerHTML={{ __html: status.content }}
direction={getTextDirection(status.search_index)}
/>

View File

@ -195,7 +195,7 @@ const EventInformation: React.FC<IEventInformation> = ({ params }) => {
return (
<Stack className='mt-4 sm:p-2' space={2}>
{!!status.contentHtml.trim() && (
{!!status.content.trim() && (
<Stack space={1}>
<Text size='xl' weight='bold'>
<FormattedMessage id='event.description' defaultMessage='Description' />

View File

@ -43,7 +43,7 @@ const CompareHistoryModal: React.FC<ICompareHistoryModal> = ({ onClose, statusId
body = (
<div className='divide-y divide-solid divide-gray-200 dark:divide-gray-800'>
{versions?.map((version) => {
const content = { __html: version.contentHtml };
const content = { __html: version.content };
const spoilerContent = { __html: version.spoilerHtml };
const poll = typeof version.poll !== 'string' && version.poll;

View File

@ -88,7 +88,6 @@ export const StatusRecord = ImmutableRecord({
event: null as ReturnType<typeof EventRecord> | null,
// Internal fields
contentHtml: '',
expectsCard: false,
hidden: false,
search_index: '',

View File

@ -109,7 +109,6 @@ export const calculateStatus = (
if (oldStatus && oldStatus.content === status.content && oldStatus.spoiler_text === status.spoiler_text) {
return status.merge({
search_index: oldStatus.search_index,
contentHtml: oldStatus.contentHtml,
spoilerHtml: oldStatus.spoilerHtml,
hidden: oldStatus.hidden,
});
@ -120,7 +119,7 @@ export const calculateStatus = (
return status.merge({
search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent || '',
contentHtml: DOMPurify.sanitize(stripCompatibilityFeatures(emojify(status.content, emojiMap)), { USE_PROFILES: { html: true } }),
content: DOMPurify.sanitize(stripCompatibilityFeatures(status.content), { USE_PROFILES: { html: true } }),
spoilerHtml: DOMPurify.sanitize(emojify(escapeTextContentForBrowser(spoilerText), emojiMap), { USE_PROFILES: { html: true } }),
hidden: expandSpoilers ? false : spoilerText.length > 0 || status.sensitive,
});

View File

@ -1,4 +1,5 @@
import escapeTextContentForBrowser from 'escape-html';
import DOMPurify from 'isomorphic-dompurify';
import { z } from 'zod';
import emojify from 'soapbox/features/emoji/index.ts';
@ -106,13 +107,13 @@ type Translation = {
const transformStatus = <T extends TransformableStatus>({ pleroma, ...status }: T) => {
const emojiMap = makeCustomEmojiMap(status.emojis);
const contentHtml = stripCompatibilityFeatures(emojify(status.content, emojiMap));
const content = DOMPurify.sanitize(stripCompatibilityFeatures(status.content), { USE_PROFILES: { html: true } });
const spoilerHtml = emojify(escapeTextContentForBrowser(status.spoiler_text), emojiMap);
return {
...status,
approval_status: 'approval' as const,
contentHtml,
content,
expectsCard: false,
event: pleroma?.event,
filtered: [],