mirror of
https://github.com/mastodon/mastodon.git
synced 2025-10-05 00:22:42 +00:00
Adds new HTMLBlock component (#36262)
This commit is contained in:
parent
1571514e49
commit
e07b9dfdc1
61
app/javascript/config/html-tags.json
Normal file
61
app/javascript/config/html-tags.json
Normal file
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"global": {
|
||||
"class": "className",
|
||||
"id": true,
|
||||
"title": true,
|
||||
"dir": true,
|
||||
"lang": true
|
||||
},
|
||||
"tags": {
|
||||
"p": {},
|
||||
"br": {
|
||||
"children": false
|
||||
},
|
||||
"span": {
|
||||
"attributes": {
|
||||
"translate": true
|
||||
}
|
||||
},
|
||||
"a": {
|
||||
"attributes": {
|
||||
"href": true,
|
||||
"rel": true,
|
||||
"translate": true,
|
||||
"target": true
|
||||
}
|
||||
},
|
||||
"del": {},
|
||||
"s": {},
|
||||
"pre": {},
|
||||
"blockquote": {},
|
||||
"code": {},
|
||||
"b": {},
|
||||
"strong": {},
|
||||
"u": {},
|
||||
"i": {},
|
||||
"img": {
|
||||
"children": false,
|
||||
"attributes": {
|
||||
"src": true,
|
||||
"alt": true,
|
||||
"title": true
|
||||
}
|
||||
},
|
||||
"em": {},
|
||||
"ul": {},
|
||||
"ol": {
|
||||
"attributes": {
|
||||
"start": true,
|
||||
"reversed": true
|
||||
}
|
||||
},
|
||||
"li": {
|
||||
"attributes": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
"ruby": {},
|
||||
"rt": {},
|
||||
"rp": {}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
import { HTMLBlock } from './index';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/HTMLBlock',
|
||||
component: HTMLBlock,
|
||||
args: {
|
||||
contents:
|
||||
'<p>Hello, world!</p>\n<p><a href="#">A link</a></p>\n<p>This should be filtered out: <button>Bye!</button></p>',
|
||||
},
|
||||
render(args) {
|
||||
return (
|
||||
// Just for visual clarity in Storybook.
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid black',
|
||||
padding: '1rem',
|
||||
minWidth: '300px',
|
||||
}}
|
||||
>
|
||||
<HTMLBlock {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof HTMLBlock>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
async play({ canvas }) {
|
||||
const link = canvas.queryByRole('link');
|
||||
await expect(link).toBeInTheDocument();
|
||||
const button = canvas.queryByRole('button');
|
||||
await expect(button).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
50
app/javascript/mastodon/components/html_block/index.tsx
Normal file
50
app/javascript/mastodon/components/html_block/index.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import type { FC, ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
|
||||
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
|
||||
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||
|
||||
import { htmlStringToComponents } from '../../utils/html';
|
||||
|
||||
// Use a module-level cache to avoid re-rendering the same HTML multiple times.
|
||||
const cache = createLimitedCache<ReactNode>({ maxSize: 1000 });
|
||||
|
||||
interface HTMLBlockProps {
|
||||
contents: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
}
|
||||
|
||||
export const HTMLBlock: FC<HTMLBlockProps> = ({
|
||||
contents: raw,
|
||||
extraEmojis,
|
||||
}) => {
|
||||
const customEmojis = useMemo(
|
||||
() => cleanExtraEmojis(extraEmojis),
|
||||
[extraEmojis],
|
||||
);
|
||||
const contents = useMemo(() => {
|
||||
const key = JSON.stringify({ raw, customEmojis });
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
const rendered = htmlStringToComponents(raw, {
|
||||
onText,
|
||||
extraArgs: { customEmojis },
|
||||
});
|
||||
|
||||
cache.set(key, rendered);
|
||||
return rendered;
|
||||
}, [raw, customEmojis]);
|
||||
|
||||
return contents;
|
||||
};
|
||||
|
||||
function onText(
|
||||
text: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Doesn't do anything, just showing how typing would work.
|
||||
{ customEmojis }: { customEmojis: CustomEmojiMapArg | null },
|
||||
) {
|
||||
return text;
|
||||
}
|
|
@ -1,19 +1,13 @@
|
|||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { isList } from 'immutable';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
import { toSupportedLocale } from './locale';
|
||||
import { determineEmojiMode } from './mode';
|
||||
import { cleanExtraEmojis } from './normalize';
|
||||
import { emojifyElement, emojifyText } from './render';
|
||||
import type {
|
||||
CustomEmojiMapArg,
|
||||
EmojiAppState,
|
||||
ExtraCustomEmojiMap,
|
||||
} from './types';
|
||||
import type { CustomEmojiMapArg, EmojiAppState } from './types';
|
||||
import { stringHasAnyEmoji } from './utils';
|
||||
|
||||
interface UseEmojifyOptions {
|
||||
|
@ -30,20 +24,7 @@ export function useEmojify({
|
|||
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
||||
|
||||
const appState = useEmojiAppState();
|
||||
const extra: ExtraCustomEmojiMap = useMemo(() => {
|
||||
if (!extraEmojis) {
|
||||
return {};
|
||||
}
|
||||
if (isList(extraEmojis)) {
|
||||
return (
|
||||
extraEmojis.toJS() as ApiCustomEmojiJSON[]
|
||||
).reduce<ExtraCustomEmojiMap>(
|
||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
return extraEmojis;
|
||||
}, [extraEmojis]);
|
||||
const extra = useMemo(() => cleanExtraEmojis(extraEmojis), [extraEmojis]);
|
||||
|
||||
const emojify = useCallback(
|
||||
async (input: string) => {
|
||||
|
@ -51,11 +32,11 @@ export function useEmojify({
|
|||
if (deep) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = input;
|
||||
if (await emojifyElement(wrapper, appState, extra)) {
|
||||
if (await emojifyElement(wrapper, appState, extra ?? {})) {
|
||||
result = wrapper.innerHTML;
|
||||
}
|
||||
} else {
|
||||
result = await emojifyText(text, appState, extra);
|
||||
result = await emojifyText(text, appState, extra ?? {});
|
||||
}
|
||||
if (result) {
|
||||
setEmojifiedText(result);
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { isList } from 'immutable';
|
||||
|
||||
import {
|
||||
VARIATION_SELECTOR_CODE,
|
||||
KEYCAP_CODE,
|
||||
|
@ -7,7 +9,11 @@ import {
|
|||
EMOJIS_WITH_DARK_BORDER,
|
||||
EMOJIS_WITH_LIGHT_BORDER,
|
||||
} from './constants';
|
||||
import type { TwemojiBorderInfo } from './types';
|
||||
import type {
|
||||
CustomEmojiMapArg,
|
||||
ExtraCustomEmojiMap,
|
||||
TwemojiBorderInfo,
|
||||
} from './types';
|
||||
|
||||
// Misc codes that have special handling
|
||||
const SKIER_CODE = 0x26f7;
|
||||
|
@ -150,6 +156,21 @@ export function twemojiToUnicodeInfo(
|
|||
return hexNumbersToString(mappedCodes);
|
||||
}
|
||||
|
||||
export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
|
||||
if (!extraEmojis) {
|
||||
return null;
|
||||
}
|
||||
if (!isList(extraEmojis)) {
|
||||
return extraEmojis;
|
||||
}
|
||||
return extraEmojis
|
||||
.toJSON()
|
||||
.reduce<ExtraCustomEmojiMap>(
|
||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
function hexStringToNumbers(hexString: string): number[] {
|
||||
return hexString
|
||||
.split('-')
|
||||
|
|
|
@ -26,9 +26,11 @@ exports[`html > htmlStringToComponents > handles nested elements 1`] = `
|
|||
exports[`html > htmlStringToComponents > ignores empty text nodes 1`] = `
|
||||
[
|
||||
<p>
|
||||
|
||||
<span>
|
||||
lorem ipsum
|
||||
</span>
|
||||
|
||||
</p>,
|
||||
]
|
||||
`;
|
||||
|
@ -37,6 +39,7 @@ exports[`html > htmlStringToComponents > respects allowedTags option 1`] = `
|
|||
[
|
||||
<p>
|
||||
lorem
|
||||
|
||||
<em>
|
||||
dolor
|
||||
</em>
|
||||
|
|
|
@ -48,7 +48,7 @@ describe('html', () => {
|
|||
const input = '<p>lorem ipsum</p>';
|
||||
const onText = vi.fn((text: string) => text);
|
||||
html.htmlStringToComponents(input, { onText });
|
||||
expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum');
|
||||
expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum', {});
|
||||
});
|
||||
|
||||
it('calls onElement callback', () => {
|
||||
|
@ -61,6 +61,7 @@ describe('html', () => {
|
|||
expect(onElement).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ tagName: 'P' }),
|
||||
expect.arrayContaining(['lorem ipsum']),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -71,6 +72,7 @@ describe('html', () => {
|
|||
expect(onElement).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ tagName: 'P' }),
|
||||
expect.arrayContaining(['lorem ipsum']),
|
||||
{},
|
||||
);
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
@ -88,15 +90,16 @@ describe('html', () => {
|
|||
'href',
|
||||
'https://example.com',
|
||||
'a',
|
||||
{},
|
||||
);
|
||||
expect(onAttribute).toHaveBeenCalledWith('target', '_blank', 'a');
|
||||
expect(onAttribute).toHaveBeenCalledWith('rel', 'nofollow', 'a');
|
||||
expect(onAttribute).toHaveBeenCalledWith('target', '_blank', 'a', {});
|
||||
expect(onAttribute).toHaveBeenCalledWith('rel', 'nofollow', 'a', {});
|
||||
});
|
||||
|
||||
it('respects allowedTags option', () => {
|
||||
const input = '<p>lorem <strong>ipsum</strong> <em>dolor</em></p>';
|
||||
const output = html.htmlStringToComponents(input, {
|
||||
allowedTags: new Set(['p', 'em']),
|
||||
allowedTags: { p: {}, em: {} },
|
||||
});
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import htmlConfig from '../../config/html-tags.json';
|
||||
|
||||
// NB: This function can still return unsafe HTML
|
||||
export const unescapeHTML = (html: string) => {
|
||||
const wrapper = document.createElement('div');
|
||||
|
@ -10,64 +12,49 @@ export const unescapeHTML = (html: string) => {
|
|||
return wrapper.textContent;
|
||||
};
|
||||
|
||||
interface AllowedTag {
|
||||
/* True means allow, false disallows global attributes, string renames the attribute name for React. */
|
||||
attributes?: Record<string, boolean | string>;
|
||||
/* If false, the tag cannot have children. Undefined or true means allowed. */
|
||||
children?: boolean;
|
||||
}
|
||||
|
||||
type AllowedTagsType = {
|
||||
[Tag in keyof React.ReactHTML]?: AllowedTag;
|
||||
};
|
||||
|
||||
const globalAttributes: Record<string, boolean | string> = htmlConfig.global;
|
||||
const defaultAllowedTags: AllowedTagsType = htmlConfig.tags;
|
||||
|
||||
interface QueueItem {
|
||||
node: Node;
|
||||
parent: React.ReactNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
export interface HTMLToStringOptions<Arg extends Record<string, unknown>> {
|
||||
maxDepth?: number;
|
||||
onText?: (text: string) => React.ReactNode;
|
||||
onText?: (text: string, extra: Arg) => React.ReactNode;
|
||||
onElement?: (
|
||||
element: HTMLElement,
|
||||
children: React.ReactNode[],
|
||||
extra: Arg,
|
||||
) => React.ReactNode;
|
||||
onAttribute?: (
|
||||
name: string,
|
||||
value: string,
|
||||
tagName: string,
|
||||
extra: Arg,
|
||||
) => [string, unknown] | null;
|
||||
allowedTags?: Set<string>;
|
||||
allowedTags?: AllowedTagsType;
|
||||
extraArgs?: Arg;
|
||||
}
|
||||
const DEFAULT_ALLOWED_TAGS: ReadonlySet<string> = new Set([
|
||||
'a',
|
||||
'abbr',
|
||||
'b',
|
||||
'blockquote',
|
||||
'br',
|
||||
'cite',
|
||||
'code',
|
||||
'del',
|
||||
'dfn',
|
||||
'dl',
|
||||
'dt',
|
||||
'em',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'hr',
|
||||
'i',
|
||||
'li',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
'small',
|
||||
'span',
|
||||
'strong',
|
||||
'sub',
|
||||
'sup',
|
||||
'time',
|
||||
'u',
|
||||
'ul',
|
||||
]);
|
||||
|
||||
export function htmlStringToComponents(
|
||||
let uniqueIdCounter = 0;
|
||||
|
||||
export function htmlStringToComponents<Arg extends Record<string, unknown>>(
|
||||
htmlString: string,
|
||||
options: Options = {},
|
||||
options: HTMLToStringOptions<Arg> = {},
|
||||
) {
|
||||
const wrapper = document.createElement('template');
|
||||
wrapper.innerHTML = htmlString;
|
||||
|
@ -79,10 +66,11 @@ export function htmlStringToComponents(
|
|||
|
||||
const {
|
||||
maxDepth = 10,
|
||||
allowedTags = DEFAULT_ALLOWED_TAGS,
|
||||
allowedTags = defaultAllowedTags,
|
||||
onAttribute,
|
||||
onElement,
|
||||
onText,
|
||||
extraArgs = {} as Arg,
|
||||
} = options;
|
||||
|
||||
while (queue.length > 0) {
|
||||
|
@ -109,9 +97,9 @@ export function htmlStringToComponents(
|
|||
// Text can be added directly if it has any non-whitespace content.
|
||||
case Node.TEXT_NODE: {
|
||||
const text = node.textContent;
|
||||
if (text && text.trim() !== '') {
|
||||
if (text) {
|
||||
if (onText) {
|
||||
parent.push(onText(text));
|
||||
parent.push(onText(text, extraArgs));
|
||||
} else {
|
||||
parent.push(text);
|
||||
}
|
||||
|
@ -127,7 +115,9 @@ export function htmlStringToComponents(
|
|||
}
|
||||
|
||||
// If the tag is not allowed, skip it and its children.
|
||||
if (!allowedTags.has(node.tagName.toLowerCase())) {
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
const tagInfo = allowedTags[tagName as keyof typeof allowedTags];
|
||||
if (!tagInfo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
@ -137,7 +127,8 @@ export function htmlStringToComponents(
|
|||
|
||||
// If onElement is provided, use it to create the element.
|
||||
if (onElement) {
|
||||
const component = onElement(node, children);
|
||||
const component = onElement(node, children, extraArgs);
|
||||
|
||||
// Check for undefined to allow returning null.
|
||||
if (component !== undefined) {
|
||||
element = component;
|
||||
|
@ -147,25 +138,56 @@ export function htmlStringToComponents(
|
|||
// If the element wasn't created, use the default conversion.
|
||||
if (element === undefined) {
|
||||
const props: Record<string, unknown> = {};
|
||||
props.key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it.
|
||||
for (const attr of node.attributes) {
|
||||
let name = attr.name.toLowerCase();
|
||||
|
||||
// Custom attribute handler.
|
||||
if (onAttribute) {
|
||||
const result = onAttribute(
|
||||
attr.name,
|
||||
name,
|
||||
attr.value,
|
||||
node.tagName.toLowerCase(),
|
||||
extraArgs,
|
||||
);
|
||||
if (result) {
|
||||
const [name, value] = result;
|
||||
props[name] = value;
|
||||
const [cbName, value] = result;
|
||||
props[cbName] = value;
|
||||
}
|
||||
} else {
|
||||
props[attr.name] = attr.value;
|
||||
// Check global attributes first, then tag-specific ones.
|
||||
const globalAttr = globalAttributes[name];
|
||||
const tagAttr = tagInfo.attributes?.[name];
|
||||
|
||||
// Exit if neither global nor tag-specific attribute is allowed.
|
||||
if (!globalAttr && !tagAttr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rename if needed.
|
||||
if (typeof tagAttr === 'string') {
|
||||
name = tagAttr;
|
||||
} else if (typeof globalAttr === 'string') {
|
||||
name = globalAttr;
|
||||
}
|
||||
|
||||
let value: string | boolean | number = attr.value;
|
||||
|
||||
// Handle boolean attributes.
|
||||
if (value === 'true') {
|
||||
value = true;
|
||||
} else if (value === 'false') {
|
||||
value = false;
|
||||
}
|
||||
|
||||
props[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
element = React.createElement(
|
||||
node.tagName.toLowerCase(),
|
||||
tagName,
|
||||
props,
|
||||
children,
|
||||
tagInfo.children !== false ? children : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user