From e07b9dfdc12adb9c8b79d89f80049335053c3324 Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 26 Sep 2025 11:50:59 +0200 Subject: [PATCH] Adds new HTMLBlock component (#36262) --- app/javascript/config/html-tags.json | 61 +++++++++ .../html_block/html_block.stories.tsx | 40 ++++++ .../mastodon/components/html_block/index.tsx | 50 +++++++ .../mastodon/features/emoji/hooks.ts | 29 +---- .../mastodon/features/emoji/normalize.ts | 23 +++- .../__tests__/__snapshots__/html-test.ts.snap | 3 + .../mastodon/utils/__tests__/html-test.ts | 11 +- app/javascript/mastodon/utils/html.ts | 122 +++++++++++------- 8 files changed, 260 insertions(+), 79 deletions(-) create mode 100644 app/javascript/config/html-tags.json create mode 100644 app/javascript/mastodon/components/html_block/html_block.stories.tsx create mode 100644 app/javascript/mastodon/components/html_block/index.tsx 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!

\n

A link

\n

This 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, ); }