Emoji: Link Replacement (#36341)
This commit is contained in:
@@ -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<AccountBioProps> = ({
|
||||
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<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 account = state.accounts.get(accountId);
|
||||
if (!account) {
|
||||
@@ -48,13 +73,14 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimateEmojiProvider
|
||||
<EmojiHTML
|
||||
htmlString={note}
|
||||
extraEmojis={extraEmojis}
|
||||
className={classNames(className, 'translate')}
|
||||
onClickCapture={handleClick}
|
||||
onClickCapture={isModernEmojiEnabled() ? undefined : handleClick}
|
||||
ref={handleNodeChange}
|
||||
>
|
||||
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
||||
</AnimateEmojiProvider>
|
||||
onElement={handleLink}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Element extends ElementType = 'div'> = Omit<
|
||||
ComponentPropsWithoutRef<Element>,
|
||||
'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<ElementType>) => {
|
||||
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 (
|
||||
<CustomEmojiProvider emojis={extraEmojis}>
|
||||
<AnimateEmojiProvider {...props} as={asProp} className={className}>
|
||||
{contents}
|
||||
</AnimateEmojiProvider>
|
||||
</CustomEmojiProvider>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<CustomEmojiProvider emojis={extraEmojis}>
|
||||
<AnimateEmojiProvider
|
||||
{...props}
|
||||
as={asProp}
|
||||
className={className}
|
||||
ref={ref}
|
||||
>
|
||||
{contents}
|
||||
</AnimateEmojiProvider>
|
||||
</CustomEmojiProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
ModernEmojiHTML.displayName = 'ModernEmojiHTML';
|
||||
|
||||
export const LegacyEmojiHTML = <Element extends ElementType>(
|
||||
props: EmojiHTMLProps<Element>,
|
||||
) => {
|
||||
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
|
||||
const Wrapper = asElement ?? 'div';
|
||||
return (
|
||||
<Wrapper
|
||||
{...rest}
|
||||
dangerouslySetInnerHTML={{ __html: htmlString }}
|
||||
className={classNames(className, 'animate-parent')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
as: asElement,
|
||||
htmlString,
|
||||
extraEmojis,
|
||||
className,
|
||||
onElement,
|
||||
...rest
|
||||
} = props;
|
||||
const Wrapper = asElement ?? 'div';
|
||||
return (
|
||||
<Wrapper
|
||||
{...rest}
|
||||
ref={ref}
|
||||
dangerouslySetInnerHTML={{ __html: htmlString }}
|
||||
className={classNames(className, 'animate-parent')}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
LegacyEmojiHTML.displayName = 'LegacyEmojiHTML';
|
||||
|
||||
export const EmojiHTML = isModernEmojiEnabled()
|
||||
? ModernEmojiHTML
|
||||
|
||||
@@ -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!',
|
||||
},
|
||||
};
|
||||
79
app/javascript/mastodon/components/status/handled_link.tsx
Normal file
79
app/javascript/mastodon/components/status/handled_link.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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 (
|
||||
<HandledLink
|
||||
{...props}
|
||||
href={element.href}
|
||||
text={element.innerText}
|
||||
hashtagAccountId={this.props.status.getIn(['account', 'id'])}
|
||||
mentionAccountId={mention?.get('id')}
|
||||
key={key}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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}
|
||||
|
||||
@@ -53,13 +53,19 @@ describe('html', () => {
|
||||
|
||||
it('calls onElement callback', () => {
|
||||
const input = '<p>lorem ipsum</p>';
|
||||
const onElement = vi.fn(
|
||||
(element: HTMLElement, children: React.ReactNode[]) =>
|
||||
React.createElement(element.tagName.toLowerCase(), {}, ...children),
|
||||
const onElement = vi.fn<html.OnElementHandler>(
|
||||
(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']),
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -32,14 +32,21 @@ interface QueueItem {
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface HTMLToStringOptions<Arg extends Record<string, unknown>> {
|
||||
export type OnElementHandler<
|
||||
Arg extends Record<string, unknown> = Record<string, unknown>,
|
||||
> = (
|
||||
element: HTMLElement,
|
||||
props: Record<string, unknown>,
|
||||
children: React.ReactNode[],
|
||||
extra: Arg,
|
||||
) => React.ReactNode;
|
||||
|
||||
export interface HTMLToStringOptions<
|
||||
Arg extends Record<string, unknown> = Record<string, unknown>,
|
||||
> {
|
||||
maxDepth?: number;
|
||||
onText?: (text: string, extra: Arg) => React.ReactNode;
|
||||
onElement?: (
|
||||
element: HTMLElement,
|
||||
children: React.ReactNode[],
|
||||
extra: Arg,
|
||||
) => React.ReactNode;
|
||||
onElement?: OnElementHandler<Arg>;
|
||||
onAttribute?: (
|
||||
name: string,
|
||||
value: string,
|
||||
@@ -125,9 +132,57 @@ export function htmlStringToComponents<Arg extends Record<string, unknown>>(
|
||||
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<string, unknown> = { 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<Arg extends Record<string, unknown>>(
|
||||
|
||||
// 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) {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user