diff --git a/app/javascript/config/html-tags.json b/app/javascript/config/html-tags.json
new file mode 100644
index 00000000000..c788113487c
--- /dev/null
+++ b/app/javascript/config/html-tags.json
@@ -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": {}
+ }
+}
diff --git a/app/javascript/mastodon/components/html_block/html_block.stories.tsx b/app/javascript/mastodon/components/html_block/html_block.stories.tsx
new file mode 100644
index 00000000000..9c104ba45cb
--- /dev/null
+++ b/app/javascript/mastodon/components/html_block/html_block.stories.tsx
@@ -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:
+ '
Hello, world!
\nA link
\nThis should be filtered out:
',
+ },
+ render(args) {
+ return (
+ // Just for visual clarity in Storybook.
+
+
+
+ );
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+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();
+ },
+};
diff --git a/app/javascript/mastodon/components/html_block/index.tsx b/app/javascript/mastodon/components/html_block/index.tsx
new file mode 100644
index 00000000000..51baea614d7
--- /dev/null
+++ b/app/javascript/mastodon/components/html_block/index.tsx
@@ -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({ maxSize: 1000 });
+
+interface HTMLBlockProps {
+ contents: string;
+ extraEmojis?: CustomEmojiMapArg;
+}
+
+export const HTMLBlock: FC = ({
+ 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;
+}
diff --git a/app/javascript/mastodon/features/emoji/hooks.ts b/app/javascript/mastodon/features/emoji/hooks.ts
index 7e91486780a..b3b27d274a6 100644
--- a/app/javascript/mastodon/features/emoji/hooks.ts
+++ b/app/javascript/mastodon/features/emoji/hooks.ts
@@ -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(null);
const appState = useEmojiAppState();
- const extra: ExtraCustomEmojiMap = useMemo(() => {
- if (!extraEmojis) {
- return {};
- }
- if (isList(extraEmojis)) {
- return (
- extraEmojis.toJS() as ApiCustomEmojiJSON[]
- ).reduce(
- (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);
diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts
index 6a64c3b8bfa..959732f9856 100644
--- a/app/javascript/mastodon/features/emoji/normalize.ts
+++ b/app/javascript/mastodon/features/emoji/normalize.ts
@@ -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(
+ (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
+ {},
+ );
+}
+
function hexStringToNumbers(hexString: string): number[] {
return hexString
.split('-')
diff --git a/app/javascript/mastodon/utils/__tests__/__snapshots__/html-test.ts.snap b/app/javascript/mastodon/utils/__tests__/__snapshots__/html-test.ts.snap
index a579efa406b..ea4561bc610 100644
--- a/app/javascript/mastodon/utils/__tests__/__snapshots__/html-test.ts.snap
+++ b/app/javascript/mastodon/utils/__tests__/__snapshots__/html-test.ts.snap
@@ -26,9 +26,11 @@ exports[`html > htmlStringToComponents > handles nested elements 1`] = `
exports[`html > htmlStringToComponents > ignores empty text nodes 1`] = `
[
+
lorem ipsum
+
,
]
`;
@@ -37,6 +39,7 @@ exports[`html > htmlStringToComponents > respects allowedTags option 1`] = `
[
lorem
+
dolor
diff --git a/app/javascript/mastodon/utils/__tests__/html-test.ts b/app/javascript/mastodon/utils/__tests__/html-test.ts
index 6c08cc7cbfc..6aacc396dc8 100644
--- a/app/javascript/mastodon/utils/__tests__/html-test.ts
+++ b/app/javascript/mastodon/utils/__tests__/html-test.ts
@@ -48,7 +48,7 @@ describe('html', () => {
const input = '
lorem ipsum
';
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 = 'lorem ipsum dolor
';
const output = html.htmlStringToComponents(input, {
- allowedTags: new Set(['p', 'em']),
+ allowedTags: { p: {}, em: {} },
});
expect(output).toMatchSnapshot();
});
diff --git a/app/javascript/mastodon/utils/html.ts b/app/javascript/mastodon/utils/html.ts
index 16863223007..971aefa6d16 100644
--- a/app/javascript/mastodon/utils/html.ts
+++ b/app/javascript/mastodon/utils/html.ts
@@ -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;
+ /* 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 = htmlConfig.global;
+const defaultAllowedTags: AllowedTagsType = htmlConfig.tags;
+
interface QueueItem {
node: Node;
parent: React.ReactNode[];
depth: number;
}
-interface Options {
+export interface HTMLToStringOptions> {
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;
+ allowedTags?: AllowedTagsType;
+ extraArgs?: Arg;
}
-const DEFAULT_ALLOWED_TAGS: ReadonlySet = 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>(
htmlString: string,
- options: Options = {},
+ options: HTMLToStringOptions = {},
) {
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 = {};
+ 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,
);
}