From e07b9dfdc12adb9c8b79d89f80049335053c3324 Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 26 Sep 2025 11:50:59 +0200 Subject: [PATCH 1/3] 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 0000000000..c788113487 --- /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 0000000000..9c104ba45c --- /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 0000000000..51baea614d --- /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 7e91486780..b3b27d274a 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 6a64c3b8bf..959732f985 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 a579efa406..ea4561bc61 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 6c08cc7cbf..6aacc396dc 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 1686322300..971aefa6d1 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, ); } From cb5bbbfb051e175afb538bf9b83a7ca2d2b2b867 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 26 Sep 2025 12:00:50 +0200 Subject: [PATCH 2/3] Update "Follow" button labels (#36264) --- .../mastodon/components/follow_button.tsx | 70 +++++++++- .../directory/components/account_card.tsx | 123 +----------------- .../components/inline_follow_suggestions.tsx | 2 - .../features/ui/hooks/useBreakpoint.tsx | 3 +- app/javascript/mastodon/locales/en.json | 7 +- 5 files changed, 78 insertions(+), 127 deletions(-) diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index 98ef3ba3f1..15a9046848 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -5,24 +5,61 @@ import { useIntl, defineMessages } from 'react-intl'; import classNames from 'classnames'; import { useIdentity } from '@/mastodon/identity_context'; -import { fetchRelationships, followAccount } from 'mastodon/actions/accounts'; +import { + fetchRelationships, + followAccount, + unblockAccount, + unmuteAccount, +} from 'mastodon/actions/accounts'; import { openModal } from 'mastodon/actions/modal'; import { Button } from 'mastodon/components/button'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { me } from 'mastodon/initial_state'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; -const messages = defineMessages({ +import { useBreakpoint } from '../features/ui/hooks/useBreakpoint'; + +const longMessages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, + unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, + followRequest: { + id: 'account.follow_request', + defaultMessage: 'Request to follow', + }, + followRequestCancel: { + id: 'account.follow_request_cancel', + defaultMessage: 'Cancel request', + }, editProfile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, }); +const shortMessages = { + ...longMessages, // Align type signature of shortMessages and longMessages + ...defineMessages({ + followBack: { + id: 'account.follow_back_short', + defaultMessage: 'Follow back', + }, + followRequest: { + id: 'account.follow_request_short', + defaultMessage: 'Request', + }, + followRequestCancel: { + id: 'account.follow_request_cancel_short', + defaultMessage: 'Cancel', + }, + editProfile: { id: 'account.edit_profile_short', defaultMessage: 'Edit' }, + }), +}; + export const FollowButton: React.FC<{ accountId?: string; compact?: boolean; -}> = ({ accountId, compact }) => { + labelLength?: 'auto' | 'short' | 'long'; +}> = ({ accountId, compact, labelLength = 'auto' }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const { signedIn } = useIdentity(); @@ -57,29 +94,48 @@ export const FollowButton: React.FC<{ if (accountId === me) { return; + } else if (relationship.muting) { + dispatch(unmuteAccount(accountId)); } else if (account && (relationship.following || relationship.requested)) { dispatch( openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }), ); + } else if (relationship.blocking) { + dispatch(unblockAccount(accountId)); } else { dispatch(followAccount(accountId)); } }, [dispatch, accountId, relationship, account, signedIn]); + const isNarrow = useBreakpoint('narrow'); + const useShortLabel = + labelLength === 'short' || (labelLength === 'auto' && isNarrow); + const messages = useShortLabel ? shortMessages : longMessages; + + const followMessage = account?.locked + ? messages.followRequest + : messages.follow; + let label; if (!signedIn) { - label = intl.formatMessage(messages.follow); + label = intl.formatMessage(followMessage); } else if (accountId === me) { label = intl.formatMessage(messages.editProfile); } else if (!relationship) { label = ; - } else if (relationship.following || relationship.requested) { + } else if (relationship.muting) { + label = intl.formatMessage(messages.unmute); + } else if (relationship.following) { label = intl.formatMessage(messages.unfollow); - } else if (relationship.followed_by) { + } else if (relationship.blocking) { + label = intl.formatMessage(messages.unblock); + } else if (relationship.requested) { + label = intl.formatMessage(messages.followRequestCancel); + } else if (relationship.followed_by && !account?.locked) { label = intl.formatMessage(messages.followBack); } else { - label = intl.formatMessage(messages.follow); + label = intl.formatMessage(followMessage); } if (accountId === me) { diff --git a/app/javascript/mastodon/features/directory/components/account_card.tsx b/app/javascript/mastodon/features/directory/components/account_card.tsx index 9d317efd43..6dc70532ab 100644 --- a/app/javascript/mastodon/features/directory/components/account_card.tsx +++ b/app/javascript/mastodon/features/directory/components/account_card.tsx @@ -1,134 +1,23 @@ -import { useCallback } from 'react'; +import { FormattedMessage } from 'react-intl'; -import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; - -import classNames from 'classnames'; import { Link } from 'react-router-dom'; -import { - followAccount, - unblockAccount, - unmuteAccount, -} from 'mastodon/actions/accounts'; -import { openModal } from 'mastodon/actions/modal'; import { Avatar } from 'mastodon/components/avatar'; -import { Button } from 'mastodon/components/button'; import { DisplayName } from 'mastodon/components/display_name'; +import { FollowButton } from 'mastodon/components/follow_button'; import { ShortNumber } from 'mastodon/components/short_number'; -import { autoPlayGif, me } from 'mastodon/initial_state'; +import { autoPlayGif } from 'mastodon/initial_state'; import type { Account } from 'mastodon/models/account'; import { makeGetAccount } from 'mastodon/selectors'; -import { useAppDispatch, useAppSelector } from 'mastodon/store'; - -const messages = defineMessages({ - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - cancel_follow_request: { - id: 'account.cancel_follow_request', - defaultMessage: 'Withdraw follow request', - }, - requested: { - id: 'account.requested', - defaultMessage: 'Awaiting approval. Click to cancel follow request', - }, - unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, - unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, - edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, -}); +import { useAppSelector } from 'mastodon/store'; const getAccount = makeGetAccount(); export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => { - const intl = useIntl(); const account = useAppSelector((s) => getAccount(s, accountId)); - const dispatch = useAppDispatch(); - - const handleFollow = useCallback(() => { - if (!account) return; - - if ( - account.getIn(['relationship', 'following']) || - account.getIn(['relationship', 'requested']) - ) { - dispatch( - openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }), - ); - } else { - dispatch(followAccount(account.get('id'))); - } - }, [account, dispatch]); - - const handleBlock = useCallback(() => { - if (account?.relationship?.blocking) { - dispatch(unblockAccount(account.get('id'))); - } - }, [account, dispatch]); - - const handleMute = useCallback(() => { - if (account?.relationship?.muting) { - dispatch(unmuteAccount(account.get('id'))); - } - }, [account, dispatch]); - - const handleEditProfile = useCallback(() => { - window.open('/settings/profile', '_blank'); - }, []); if (!account) return null; - let actionBtn; - - if (me !== account.get('id')) { - if (!account.get('relationship')) { - // Wait until the relationship is loaded - actionBtn = ''; - } else if (account.getIn(['relationship', 'requested'])) { - actionBtn = ( -