2
0

Emoji: Link Replacement (#36341)

This commit is contained in:
Echo
2025-10-06 11:31:10 +02:00
committed by GitHub
parent 4dc21d7afd
commit ffac4cb05f
7 changed files with 346 additions and 116 deletions

View File

@@ -6,9 +6,10 @@ import { useLinks } from 'mastodon/hooks/useLinks';
import { useAppSelector } from '../store'; import { useAppSelector } from '../store';
import { isModernEmojiEnabled } from '../utils/environment'; import { isModernEmojiEnabled } from '../utils/environment';
import type { OnElementHandler } from '../utils/html';
import { AnimateEmojiProvider } from './emoji/context';
import { EmojiHTML } from './emoji/html'; import { EmojiHTML } from './emoji/html';
import { HandledLink } from './status/handled_link';
interface AccountBioProps { interface AccountBioProps {
className: string; className: string;
@@ -24,13 +25,37 @@ export const AccountBio: React.FC<AccountBioProps> = ({
const handleClick = useLinks(showDropdown); const handleClick = useLinks(showDropdown);
const handleNodeChange = useCallback( const handleNodeChange = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => {
if (!showDropdown || !node || node.childNodes.length === 0) { if (
!showDropdown ||
!node ||
node.childNodes.length === 0 ||
isModernEmojiEnabled()
) {
return; return;
} }
addDropdownToHashtags(node, accountId); addDropdownToHashtags(node, accountId);
}, },
[showDropdown, accountId], [showDropdown, accountId],
); );
const handleLink = useCallback<OnElementHandler>(
(element, { key, ...props }) => {
if (element instanceof HTMLAnchorElement) {
return (
<HandledLink
{...props}
key={key as string} // React requires keys to not be part of spread props.
href={element.href}
text={element.innerText}
hashtagAccountId={accountId}
/>
);
}
return undefined;
},
[accountId],
);
const note = useAppSelector((state) => { const note = useAppSelector((state) => {
const account = state.accounts.get(accountId); const account = state.accounts.get(accountId);
if (!account) { if (!account) {
@@ -48,13 +73,14 @@ export const AccountBio: React.FC<AccountBioProps> = ({
} }
return ( return (
<AnimateEmojiProvider <EmojiHTML
htmlString={note}
extraEmojis={extraEmojis}
className={classNames(className, 'translate')} className={classNames(className, 'translate')}
onClickCapture={handleClick} onClickCapture={isModernEmojiEnabled() ? undefined : handleClick}
ref={handleNodeChange} ref={handleNodeChange}
> onElement={handleLink}
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} /> />
</AnimateEmojiProvider>
); );
}; };

View File

@@ -1,60 +1,79 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { ComponentPropsWithoutRef, ElementType } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types'; import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import type { OnElementHandler } from '@/mastodon/utils/html';
import { htmlStringToComponents } from '@/mastodon/utils/html'; import { htmlStringToComponents } from '@/mastodon/utils/html';
import { polymorphicForwardRef } from '@/types/polymorphic';
import { AnimateEmojiProvider, CustomEmojiProvider } from './context'; import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
import { textToEmojis } from './index'; import { textToEmojis } from './index';
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit< interface EmojiHTMLProps {
ComponentPropsWithoutRef<Element>,
'dangerouslySetInnerHTML' | 'className'
> & {
htmlString: string; htmlString: string;
extraEmojis?: CustomEmojiMapArg; extraEmojis?: CustomEmojiMapArg;
as?: Element;
className?: string; className?: string;
}; onElement?: OnElementHandler;
}
export const ModernEmojiHTML = ({ export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
(
{
extraEmojis, extraEmojis,
htmlString, htmlString,
as: asProp = 'div', // Rename for syntax highlighting as: asProp = 'div', // Rename for syntax highlighting
shallow,
className = '', className = '',
onElement,
...props ...props
}: EmojiHTMLProps<ElementType>) => { },
ref,
) => {
const contents = useMemo( const contents = useMemo(
() => htmlStringToComponents(htmlString, { onText: textToEmojis }), () =>
[htmlString], htmlStringToComponents(htmlString, { onText: textToEmojis, onElement }),
[htmlString, onElement],
); );
return ( return (
<CustomEmojiProvider emojis={extraEmojis}> <CustomEmojiProvider emojis={extraEmojis}>
<AnimateEmojiProvider {...props} as={asProp} className={className}> <AnimateEmojiProvider
{...props}
as={asProp}
className={className}
ref={ref}
>
{contents} {contents}
</AnimateEmojiProvider> </AnimateEmojiProvider>
</CustomEmojiProvider> </CustomEmojiProvider>
); );
}; },
);
ModernEmojiHTML.displayName = 'ModernEmojiHTML';
export const LegacyEmojiHTML = <Element extends ElementType>( export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
props: EmojiHTMLProps<Element>, (props, ref) => {
) => { const {
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props; as: asElement,
htmlString,
extraEmojis,
className,
onElement,
...rest
} = props;
const Wrapper = asElement ?? 'div'; const Wrapper = asElement ?? 'div';
return ( return (
<Wrapper <Wrapper
{...rest} {...rest}
ref={ref}
dangerouslySetInnerHTML={{ __html: htmlString }} dangerouslySetInnerHTML={{ __html: htmlString }}
className={classNames(className, 'animate-parent')} className={classNames(className, 'animate-parent')}
/> />
); );
}; },
);
LegacyEmojiHTML.displayName = 'LegacyEmojiHTML';
export const EmojiHTML = isModernEmojiEnabled() export const EmojiHTML = isModernEmojiEnabled()
? ModernEmojiHTML ? ModernEmojiHTML

View File

@@ -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 (
<>
<HandledLink {...args} mentionAccountId='1' hashtagAccountId='1' />
<HashtagMenuController />
<HoverCardController />
</>
);
},
args: {
href: 'https://example.com/path/subpath?query=1#hash',
text: 'https://example.com',
},
parameters: {
state: {
accounts: {
'1': accountFactoryState(),
},
},
},
} satisfies Meta<Pick<HandledLinkProps, 'href' | 'text'>>;
export default meta;
type Story = StoryObj<typeof meta>;
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!',
},
};

View File

@@ -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<HandledLinkProps & ComponentProps<'a'>> = ({
href,
text,
hashtagAccountId,
mentionAccountId,
...props
}) => {
// Handle hashtags
if (text.startsWith('#')) {
const hashtag = text.slice(1).trim();
return (
<Link
{...props}
className='mention hashtag'
to={`/tags/${hashtag}`}
rel='tag'
data-menu-hashtag={hashtagAccountId}
>
#<span>{hashtag}</span>
</Link>
);
} else if (text.startsWith('@')) {
// Handle mentions
const mention = text.slice(1).trim();
return (
<Link
{...props}
className='mention'
to={`/@${mention}`}
title={`@${mention}`}
data-hover-card-account={mentionAccountId}
>
@<span>{mention}</span>
</Link>
);
}
// Non-absolute paths treated as internal links.
if (href.startsWith('/')) {
return (
<Link {...props} className='unhandled-link' to={href}>
{text}
</Link>
);
}
try {
const url = new URL(href);
const [first, ...rest] = url.pathname.split('/').slice(1); // Start at 1 to skip the leading slash.
return (
<a
{...props}
href={href}
title={href}
className='unhandled-link'
target='_blank'
rel='noreferrer noopener'
translate='no'
>
<span className='invisible'>{url.protocol + '//'}</span>
<span className='ellipsis'>{`${url.hostname}/${first ?? ''}`}</span>
<span className='invisible'>{'/' + rest.join('/')}</span>
</a>
);
} catch {
return text;
}
};

View File

@@ -18,6 +18,7 @@ import { languages as preloadedLanguages } from 'mastodon/initial_state';
import { isModernEmojiEnabled } from '../utils/environment'; import { isModernEmojiEnabled } from '../utils/environment';
import { EmojiHTML } from './emoji/html'; import { EmojiHTML } from './emoji/html';
import { HandledLink } from './status/handled_link';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
@@ -99,6 +100,23 @@ class StatusContent extends PureComponent {
} }
const { status, onCollapsedToggle } = this.props; 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'); const links = node.querySelectorAll('a');
let link, mention; let link, mention;
@@ -128,18 +146,6 @@ class StatusContent extends PureComponent {
link.classList.add('unhandled-link'); 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 () { componentDidMount () {
@@ -201,6 +207,23 @@ class StatusContent extends PureComponent {
this.node = c; 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 (
<HandledLink
{...props}
href={element.href}
text={element.innerText}
hashtagAccountId={this.props.status.getIn(['account', 'id'])}
mentionAccountId={mention?.get('id')}
key={key}
/>
);
}
return undefined;
}
render () { render () {
const { status, intl, statusContent } = this.props; const { status, intl, statusContent } = this.props;
@@ -245,6 +268,7 @@ class StatusContent extends PureComponent {
lang={language} lang={language}
htmlString={content} htmlString={content}
extraEmojis={status.get('emojis')} extraEmojis={status.get('emojis')}
onElement={this.handleElement.bind(this)}
/> />
{poll} {poll}
@@ -262,6 +286,7 @@ class StatusContent extends PureComponent {
lang={language} lang={language}
htmlString={content} htmlString={content}
extraEmojis={status.get('emojis')} extraEmojis={status.get('emojis')}
onElement={this.handleElement.bind(this)}
/> />
{poll} {poll}

View File

@@ -53,13 +53,19 @@ describe('html', () => {
it('calls onElement callback', () => { it('calls onElement callback', () => {
const input = '<p>lorem ipsum</p>'; const input = '<p>lorem ipsum</p>';
const onElement = vi.fn( const onElement = vi.fn<html.OnElementHandler>(
(element: HTMLElement, children: React.ReactNode[]) => (element, props, children) =>
React.createElement(element.tagName.toLowerCase(), {}, ...children), React.createElement(
element.tagName.toLowerCase(),
props,
...children,
),
); );
html.htmlStringToComponents(input, { onElement }); html.htmlStringToComponents(input, { onElement });
expect(onElement).toHaveBeenCalledExactlyOnceWith( expect(onElement).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ tagName: 'P' }), expect.objectContaining({ tagName: 'P' }),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ key: expect.any(String) }),
expect.arrayContaining(['lorem ipsum']), expect.arrayContaining(['lorem ipsum']),
{}, {},
); );
@@ -71,6 +77,8 @@ describe('html', () => {
const output = html.htmlStringToComponents(input, { onElement }); const output = html.htmlStringToComponents(input, { onElement });
expect(onElement).toHaveBeenCalledExactlyOnceWith( expect(onElement).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ tagName: 'P' }), expect.objectContaining({ tagName: 'P' }),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ key: expect.any(String) }),
expect.arrayContaining(['lorem ipsum']), expect.arrayContaining(['lorem ipsum']),
{}, {},
); );

View File

@@ -32,14 +32,21 @@ interface QueueItem {
depth: number; depth: number;
} }
export interface HTMLToStringOptions<Arg extends Record<string, unknown>> { export type OnElementHandler<
maxDepth?: number; Arg extends Record<string, unknown> = Record<string, unknown>,
onText?: (text: string, extra: Arg) => React.ReactNode; > = (
onElement?: (
element: HTMLElement, element: HTMLElement,
props: Record<string, unknown>,
children: React.ReactNode[], children: React.ReactNode[],
extra: Arg, extra: Arg,
) => React.ReactNode; ) => React.ReactNode;
export interface HTMLToStringOptions<
Arg extends Record<string, unknown> = Record<string, unknown>,
> {
maxDepth?: number;
onText?: (text: string, extra: Arg) => React.ReactNode;
onElement?: OnElementHandler<Arg>;
onAttribute?: ( onAttribute?: (
name: string, name: string,
value: string, value: string,
@@ -125,20 +132,9 @@ export function htmlStringToComponents<Arg extends Record<string, unknown>>(
const children: React.ReactNode[] = []; const children: React.ReactNode[] = [];
let element: React.ReactNode = undefined; let element: React.ReactNode = undefined;
// If onElement is provided, use it to create the element. // Generate props from attributes.
if (onElement) { const key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it.
const component = onElement(node, children, extraArgs); const props: Record<string, unknown> = { key };
// Check for undefined to allow returning null.
if (component !== undefined) {
element = component;
}
}
// 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) { for (const attr of node.attributes) {
let name = attr.name.toLowerCase(); let name = attr.name.toLowerCase();
@@ -184,6 +180,18 @@ export function htmlStringToComponents<Arg extends Record<string, unknown>>(
} }
} }
// If onElement is provided, use it to create the element.
if (onElement) {
const component = onElement(node, props, children, extraArgs);
// Check for undefined to allow returning null.
if (component !== undefined) {
element = component;
}
}
// If the element wasn't created, use the default conversion.
if (element === undefined) {
element = React.createElement( element = React.createElement(
tagName, tagName,
props, props,