From ffac4cb05f5c5a8f91074c846b74fc1dd0c5faf3 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 6 Oct 2025 11:31:10 +0200 Subject: [PATCH] Emoji: Link Replacement (#36341) --- .../mastodon/components/account_bio.tsx | 40 ++++-- .../mastodon/components/emoji/html.tsx | 99 +++++++++------ .../status/handled_link.stories.tsx | 65 ++++++++++ .../components/status/handled_link.tsx | 79 ++++++++++++ .../mastodon/components/status_content.jsx | 49 ++++++-- .../mastodon/utils/__tests__/html-test.ts | 14 ++- app/javascript/mastodon/utils/html.ts | 116 ++++++++++-------- 7 files changed, 346 insertions(+), 116 deletions(-) create mode 100644 app/javascript/mastodon/components/status/handled_link.stories.tsx create mode 100644 app/javascript/mastodon/components/status/handled_link.tsx diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx index b5ff686f8..64e5cc045 100644 --- a/app/javascript/mastodon/components/account_bio.tsx +++ b/app/javascript/mastodon/components/account_bio.tsx @@ -6,9 +6,10 @@ import { useLinks } from 'mastodon/hooks/useLinks'; import { useAppSelector } from '../store'; import { isModernEmojiEnabled } from '../utils/environment'; +import type { OnElementHandler } from '../utils/html'; -import { AnimateEmojiProvider } from './emoji/context'; import { EmojiHTML } from './emoji/html'; +import { HandledLink } from './status/handled_link'; interface AccountBioProps { className: string; @@ -24,13 +25,37 @@ export const AccountBio: React.FC = ({ const handleClick = useLinks(showDropdown); const handleNodeChange = useCallback( (node: HTMLDivElement | null) => { - if (!showDropdown || !node || node.childNodes.length === 0) { + if ( + !showDropdown || + !node || + node.childNodes.length === 0 || + isModernEmojiEnabled() + ) { return; } addDropdownToHashtags(node, accountId); }, [showDropdown, accountId], ); + + const handleLink = useCallback( + (element, { key, ...props }) => { + if (element instanceof HTMLAnchorElement) { + return ( + + ); + } + return undefined; + }, + [accountId], + ); + const note = useAppSelector((state) => { const account = state.accounts.get(accountId); if (!account) { @@ -48,13 +73,14 @@ export const AccountBio: React.FC = ({ } return ( - - - + onElement={handleLink} + /> ); }; diff --git a/app/javascript/mastodon/components/emoji/html.tsx b/app/javascript/mastodon/components/emoji/html.tsx index a6ecc869c..73ad5fa23 100644 --- a/app/javascript/mastodon/components/emoji/html.tsx +++ b/app/javascript/mastodon/components/emoji/html.tsx @@ -1,60 +1,79 @@ import { useMemo } from 'react'; -import type { ComponentPropsWithoutRef, ElementType } from 'react'; import classNames from 'classnames'; import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; +import type { OnElementHandler } from '@/mastodon/utils/html'; import { htmlStringToComponents } from '@/mastodon/utils/html'; +import { polymorphicForwardRef } from '@/types/polymorphic'; import { AnimateEmojiProvider, CustomEmojiProvider } from './context'; import { textToEmojis } from './index'; -type EmojiHTMLProps = Omit< - ComponentPropsWithoutRef, - 'dangerouslySetInnerHTML' | 'className' -> & { +interface EmojiHTMLProps { htmlString: string; extraEmojis?: CustomEmojiMapArg; - as?: Element; className?: string; -}; + onElement?: OnElementHandler; +} -export const ModernEmojiHTML = ({ - extraEmojis, - htmlString, - as: asProp = 'div', // Rename for syntax highlighting - shallow, - className = '', - ...props -}: EmojiHTMLProps) => { - const contents = useMemo( - () => htmlStringToComponents(htmlString, { onText: textToEmojis }), - [htmlString], - ); +export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( + ( + { + extraEmojis, + htmlString, + as: asProp = 'div', // Rename for syntax highlighting + className = '', + onElement, + ...props + }, + ref, + ) => { + const contents = useMemo( + () => + htmlStringToComponents(htmlString, { onText: textToEmojis, onElement }), + [htmlString, onElement], + ); - return ( - - - {contents} - - - ); -}; + return ( + + + {contents} + + + ); + }, +); +ModernEmojiHTML.displayName = 'ModernEmojiHTML'; -export const LegacyEmojiHTML = ( - props: EmojiHTMLProps, -) => { - const { as: asElement, htmlString, extraEmojis, className, ...rest } = props; - const Wrapper = asElement ?? 'div'; - return ( - - ); -}; +export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( + (props, ref) => { + const { + as: asElement, + htmlString, + extraEmojis, + className, + onElement, + ...rest + } = props; + const Wrapper = asElement ?? 'div'; + return ( + + ); + }, +); +LegacyEmojiHTML.displayName = 'LegacyEmojiHTML'; export const EmojiHTML = isModernEmojiEnabled() ? ModernEmojiHTML diff --git a/app/javascript/mastodon/components/status/handled_link.stories.tsx b/app/javascript/mastodon/components/status/handled_link.stories.tsx new file mode 100644 index 000000000..a45e33626 --- /dev/null +++ b/app/javascript/mastodon/components/status/handled_link.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { HashtagMenuController } from '@/mastodon/features/ui/components/hashtag_menu_controller'; +import { accountFactoryState } from '@/testing/factories'; + +import { HoverCardController } from '../hover_card_controller'; + +import type { HandledLinkProps } from './handled_link'; +import { HandledLink } from './handled_link'; + +const meta = { + title: 'Components/Status/HandledLink', + render(args) { + return ( + <> + + + + + ); + }, + args: { + href: 'https://example.com/path/subpath?query=1#hash', + text: 'https://example.com', + }, + parameters: { + state: { + accounts: { + '1': accountFactoryState(), + }, + }, + }, +} satisfies Meta>; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Hashtag: Story = { + args: { + text: '#example', + }, +}; + +export const Mention: Story = { + args: { + text: '@user', + }, +}; + +export const InternalLink: Story = { + args: { + href: '/about', + text: 'About', + }, +}; + +export const InvalidURL: Story = { + args: { + href: 'ht!tp://invalid-url', + text: 'ht!tp://invalid-url -- invalid!', + }, +}; diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx new file mode 100644 index 000000000..ee4132128 --- /dev/null +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -0,0 +1,79 @@ +import type { ComponentProps, FC } from 'react'; + +import { Link } from 'react-router-dom'; + +export interface HandledLinkProps { + href: string; + text: string; + hashtagAccountId?: string; + mentionAccountId?: string; +} + +export const HandledLink: FC> = ({ + href, + text, + hashtagAccountId, + mentionAccountId, + ...props +}) => { + // Handle hashtags + if (text.startsWith('#')) { + const hashtag = text.slice(1).trim(); + return ( + + #{hashtag} + + ); + } else if (text.startsWith('@')) { + // Handle mentions + const mention = text.slice(1).trim(); + return ( + + @{mention} + + ); + } + + // Non-absolute paths treated as internal links. + if (href.startsWith('/')) { + return ( + + {text} + + ); + } + + try { + const url = new URL(href); + const [first, ...rest] = url.pathname.split('/').slice(1); // Start at 1 to skip the leading slash. + return ( + + {url.protocol + '//'} + {`${url.hostname}/${first ?? ''}`} + {'/' + rest.join('/')} + + ); + } catch { + return text; + } +}; diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index d766793d8..93c0e77bd 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -18,6 +18,7 @@ import { languages as preloadedLanguages } from 'mastodon/initial_state'; import { isModernEmojiEnabled } from '../utils/environment'; import { EmojiHTML } from './emoji/html'; +import { HandledLink } from './status/handled_link'; const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) @@ -99,6 +100,23 @@ class StatusContent extends PureComponent { } const { status, onCollapsedToggle } = this.props; + if (status.get('collapsed', null) === null && onCollapsedToggle) { + const { collapsible, onClick } = this.props; + + const collapsed = + collapsible + && onClick + && node.clientHeight > MAX_HEIGHT + && status.get('spoiler_text').length === 0; + + onCollapsedToggle(collapsed); + } + + // Exit if modern emoji is enabled, as it handles links using the HandledLink component. + if (isModernEmojiEnabled()) { + return; + } + const links = node.querySelectorAll('a'); let link, mention; @@ -128,18 +146,6 @@ class StatusContent extends PureComponent { link.classList.add('unhandled-link'); } } - - if (status.get('collapsed', null) === null && onCollapsedToggle) { - const { collapsible, onClick } = this.props; - - const collapsed = - collapsible - && onClick - && node.clientHeight > MAX_HEIGHT - && status.get('spoiler_text').length === 0; - - onCollapsedToggle(collapsed); - } } componentDidMount () { @@ -201,6 +207,23 @@ class StatusContent extends PureComponent { this.node = c; }; + handleElement = (element, {key, ...props}) => { + if (element instanceof HTMLAnchorElement) { + const mention = this.props.status.get('mentions').find(item => element.href === item.get('url')); + return ( + + ); + } + return undefined; + } + render () { const { status, intl, statusContent } = this.props; @@ -245,6 +268,7 @@ class StatusContent extends PureComponent { lang={language} htmlString={content} extraEmojis={status.get('emojis')} + onElement={this.handleElement.bind(this)} /> {poll} @@ -262,6 +286,7 @@ class StatusContent extends PureComponent { lang={language} htmlString={content} extraEmojis={status.get('emojis')} + onElement={this.handleElement.bind(this)} /> {poll} diff --git a/app/javascript/mastodon/utils/__tests__/html-test.ts b/app/javascript/mastodon/utils/__tests__/html-test.ts index 6aacc396d..a48a8b572 100644 --- a/app/javascript/mastodon/utils/__tests__/html-test.ts +++ b/app/javascript/mastodon/utils/__tests__/html-test.ts @@ -53,13 +53,19 @@ describe('html', () => { it('calls onElement callback', () => { const input = '

lorem ipsum

'; - const onElement = vi.fn( - (element: HTMLElement, children: React.ReactNode[]) => - React.createElement(element.tagName.toLowerCase(), {}, ...children), + const onElement = vi.fn( + (element, props, children) => + React.createElement( + element.tagName.toLowerCase(), + props, + ...children, + ), ); html.htmlStringToComponents(input, { onElement }); expect(onElement).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ tagName: 'P' }), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({ key: expect.any(String) }), expect.arrayContaining(['lorem ipsum']), {}, ); @@ -71,6 +77,8 @@ describe('html', () => { const output = html.htmlStringToComponents(input, { onElement }); expect(onElement).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ tagName: 'P' }), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({ key: expect.any(String) }), expect.arrayContaining(['lorem ipsum']), {}, ); diff --git a/app/javascript/mastodon/utils/html.ts b/app/javascript/mastodon/utils/html.ts index 971aefa6d..f37018d86 100644 --- a/app/javascript/mastodon/utils/html.ts +++ b/app/javascript/mastodon/utils/html.ts @@ -32,14 +32,21 @@ interface QueueItem { depth: number; } -export interface HTMLToStringOptions> { +export type OnElementHandler< + Arg extends Record = Record, +> = ( + element: HTMLElement, + props: Record, + children: React.ReactNode[], + extra: Arg, +) => React.ReactNode; + +export interface HTMLToStringOptions< + Arg extends Record = Record, +> { maxDepth?: number; onText?: (text: string, extra: Arg) => React.ReactNode; - onElement?: ( - element: HTMLElement, - children: React.ReactNode[], - extra: Arg, - ) => React.ReactNode; + onElement?: OnElementHandler; onAttribute?: ( name: string, value: string, @@ -125,9 +132,57 @@ export function htmlStringToComponents>( const children: React.ReactNode[] = []; let element: React.ReactNode = undefined; + // Generate props from attributes. + const key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it. + const props: Record = { key }; + for (const attr of node.attributes) { + let name = attr.name.toLowerCase(); + + // Custom attribute handler. + if (onAttribute) { + const result = onAttribute( + name, + attr.value, + node.tagName.toLowerCase(), + extraArgs, + ); + if (result) { + const [cbName, value] = result; + props[cbName] = value; + } + } else { + // 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; + } + } + // If onElement is provided, use it to create the element. if (onElement) { - const component = onElement(node, children, extraArgs); + const component = onElement(node, props, children, extraArgs); // Check for undefined to allow returning null. if (component !== undefined) { @@ -137,53 +192,6 @@ 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( - name, - attr.value, - node.tagName.toLowerCase(), - extraArgs, - ); - if (result) { - const [cbName, value] = result; - props[cbName] = value; - } - } else { - // 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( tagName, props,