2
0

Emoji: Account page (#36385)

This commit is contained in:
Echo
2025-10-08 13:11:25 +02:00
committed by GitHub
parent 3867f3bc61
commit 6abda76d13
10 changed files with 195 additions and 137 deletions

View File

@@ -5,6 +5,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { import {
blockAccount, blockAccount,
@@ -331,9 +332,10 @@ export const Account: React.FC<AccountProps> = ({
{account && {account &&
withBio && withBio &&
(account.note.length > 0 ? ( (account.note.length > 0 ? (
<div <EmojiHTML
className='account__note translate' className='account__note translate'
dangerouslySetInnerHTML={{ __html: account.note_emojified }} htmlString={account.note_emojified}
extraEmojis={account.emojis}
/> />
) : ( ) : (
<div className='account__note account__note--missing'> <div className='account__note account__note--missing'>

View File

@@ -6,10 +6,9 @@ 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 { EmojiHTML } from './emoji/html'; import { EmojiHTML } from './emoji/html';
import { HandledLink } from './status/handled_link'; import { useElementHandledLink } from './status/handled_link';
interface AccountBioProps { interface AccountBioProps {
className: string; className: string;
@@ -38,23 +37,9 @@ export const AccountBio: React.FC<AccountBioProps> = ({
[showDropdown, accountId], [showDropdown, accountId],
); );
const handleLink = useCallback<OnElementHandler>( const htmlHandlers = useElementHandledLink({
(element, { key, ...props }) => { hashtagAccountId: showDropdown ? accountId : undefined,
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);
@@ -77,9 +62,9 @@ export const AccountBio: React.FC<AccountBioProps> = ({
htmlString={note} htmlString={note}
extraEmojis={extraEmojis} extraEmojis={extraEmojis}
className={classNames(className, 'translate')} className={classNames(className, 'translate')}
onClickCapture={isModernEmojiEnabled() ? undefined : handleClick} onClickCapture={handleClick}
ref={handleNodeChange} ref={handleNodeChange}
onElement={handleLink} {...htmlHandlers}
/> />
); );
}; };

View File

@@ -1,42 +1,70 @@
import { useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { useLinks } from 'mastodon/hooks/useLinks';
import type { Account } from 'mastodon/models/account'; import type { Account } from 'mastodon/models/account';
export const AccountFields: React.FC<{ import { CustomEmojiProvider } from './emoji/context';
fields: Account['fields']; import { EmojiHTML } from './emoji/html';
limit: number; import { useElementHandledLink } from './status/handled_link';
}> = ({ fields, limit = -1 }) => {
const handleClick = useLinks(); export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
fields,
emojis,
}) => {
const intl = useIntl();
const htmlHandlers = useElementHandledLink();
if (fields.size === 0) { if (fields.size === 0) {
return null; return null;
} }
return ( return (
<div className='account-fields' onClickCapture={handleClick}> <CustomEmojiProvider emojis={emojis}>
{fields.take(limit).map((pair, i) => ( {fields.map((pair, i) => (
<dl <dl key={i} className={classNames({ verified: pair.verified_at })}>
key={i} <EmojiHTML
className={classNames({ verified: pair.get('verified_at') })} as='dt'
> htmlString={pair.name_emojified}
<dt
dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
className='translate' className='translate'
{...htmlHandlers}
/> />
<dd className='translate' title={pair.get('value_plain') ?? ''}> <dd className='translate' title={pair.value_plain ?? ''}>
{pair.get('verified_at') && ( {pair.verified_at && (
<Icon id='check' icon={CheckIcon} className='verified__mark' />
)}
<span <span
dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} title={intl.formatMessage(
{
id: 'account.link_verified_on',
defaultMessage:
'Ownership of this link was checked on {date}',
},
{
date: intl.formatDate(pair.verified_at, dateFormatOptions),
},
)}
>
<Icon id='check' icon={CheckIcon} className='verified__mark' />
</span>
)}{' '}
<EmojiHTML
as='span'
htmlString={pair.value_emojified}
{...htmlHandlers}
/> />
</dd> </dd>
</dl> </dl>
))} ))}
</div> </CustomEmojiProvider>
); );
}; };
const dateFormatOptions: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};

View File

@@ -4,7 +4,10 @@ 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 type {
OnAttributeHandler,
OnElementHandler,
} from '@/mastodon/utils/html';
import { htmlStringToComponents } from '@/mastodon/utils/html'; import { htmlStringToComponents } from '@/mastodon/utils/html';
import { polymorphicForwardRef } from '@/types/polymorphic'; import { polymorphicForwardRef } from '@/types/polymorphic';
@@ -16,6 +19,7 @@ interface EmojiHTMLProps {
extraEmojis?: CustomEmojiMapArg; extraEmojis?: CustomEmojiMapArg;
className?: string; className?: string;
onElement?: OnElementHandler; onElement?: OnElementHandler;
onAttribute?: OnAttributeHandler;
} }
export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
@@ -26,14 +30,19 @@ export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
as: asProp = 'div', // Rename for syntax highlighting as: asProp = 'div', // Rename for syntax highlighting
className = '', className = '',
onElement, onElement,
onAttribute,
...props ...props
}, },
ref, ref,
) => { ) => {
const contents = useMemo( const contents = useMemo(
() => () =>
htmlStringToComponents(htmlString, { onText: textToEmojis, onElement }), htmlStringToComponents(htmlString, {
[htmlString, onElement], onText: textToEmojis,
onElement,
onAttribute,
}),
[htmlString, onAttribute, onElement],
); );
return ( return (
@@ -60,6 +69,7 @@ export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
extraEmojis, extraEmojis,
className, className,
onElement, onElement,
onAttribute,
...rest ...rest
} = props; } = props;
const Wrapper = asElement ?? 'div'; const Wrapper = asElement ?? 'div';

View File

@@ -23,6 +23,8 @@ import { domain } from 'mastodon/initial_state';
import { getAccountHidden } from 'mastodon/selectors/accounts'; import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { useLinks } from '../hooks/useLinks';
export const HoverCardAccount = forwardRef< export const HoverCardAccount = forwardRef<
HTMLDivElement, HTMLDivElement,
{ accountId?: string } { accountId?: string }
@@ -64,6 +66,8 @@ export const HoverCardAccount = forwardRef<
!isMutual && !isMutual &&
!isFollower; !isFollower;
const handleClick = useLinks();
return ( return (
<div <div
ref={ref} ref={ref}
@@ -105,7 +109,14 @@ export const HoverCardAccount = forwardRef<
accountId={account.id} accountId={account.id}
className='hover-card__bio' className='hover-card__bio'
/> />
<AccountFields fields={account.fields} limit={2} />
<div className='account-fields' onClickCapture={handleClick}>
<AccountFields
fields={account.fields.take(2)}
emojis={account.emojis}
/>
</div>
{note && note.length > 0 && ( {note && note.length > 0 && (
<dl className='hover-card__note'> <dl className='hover-card__note'>
<dt className='hover-card__note-label'> <dt className='hover-card__note-label'>

View File

@@ -1,7 +1,10 @@
import { useCallback } from 'react';
import type { ComponentProps, FC } from 'react'; import type { ComponentProps, FC } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { OnElementHandler } from '@/mastodon/utils/html';
export interface HandledLinkProps { export interface HandledLinkProps {
href: string; href: string;
text: string; text: string;
@@ -77,3 +80,31 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
return text; return text;
} }
}; };
export const useElementHandledLink = ({
hashtagAccountId,
mentionAccountId,
}: {
hashtagAccountId?: string;
mentionAccountId?: string;
} = {}) => {
const onElement = 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={hashtagAccountId}
mentionAccountId={mentionAccountId}
/>
);
}
return undefined;
},
[hashtagAccountId, mentionAccountId],
);
return { onElement };
};

View File

@@ -1,10 +1,17 @@
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { isModernEmojiEnabled } from '../utils/environment';
import type { OnAttributeHandler } from '../utils/html';
import { Icon } from './icon'; import { Icon } from './icon';
const domParser = new DOMParser(); const domParser = new DOMParser();
const stripRelMe = (html: string) => { const stripRelMe = (html: string) => {
if (isModernEmojiEnabled()) {
return html;
}
const document = domParser.parseFromString(html, 'text/html').documentElement; const document = domParser.parseFromString(html, 'text/html').documentElement;
document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => { document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
@@ -15,7 +22,23 @@ const stripRelMe = (html: string) => {
}); });
const body = document.querySelector('body'); const body = document.querySelector('body');
return body ? { __html: body.innerHTML } : undefined; return body?.innerHTML ?? '';
};
const onAttribute: OnAttributeHandler = (name, value, tagName) => {
if (name === 'rel' && tagName === 'a') {
if (value === 'me') {
return null;
}
return [
name,
value
.split(' ')
.filter((x) => x !== 'me')
.join(' '),
];
}
return undefined;
}; };
interface Props { interface Props {
@@ -24,6 +47,10 @@ interface Props {
export const VerifiedBadge: React.FC<Props> = ({ link }) => ( export const VerifiedBadge: React.FC<Props> = ({ link }) => (
<span className='verified-badge'> <span className='verified-badge'>
<Icon id='check' icon={CheckIcon} className='verified-badge__mark' /> <Icon id='check' icon={CheckIcon} className='verified-badge__mark' />
<span dangerouslySetInnerHTML={stripRelMe(link)} /> <EmojiHTML
as='span'
htmlString={stripRelMe(link)}
onAttribute={onAttribute}
/>
</span> </span>
); );

View File

@@ -7,9 +7,9 @@ import { Helmet } from 'react-helmet';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/mastodon/components/account_bio'; import { AccountBio } from '@/mastodon/components/account_bio';
import { AccountFields } from '@/mastodon/components/account_fields';
import { DisplayName } from '@/mastodon/components/display_name'; import { DisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context'; import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react'; import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
@@ -186,14 +186,6 @@ const titleFromAccount = (account: Account) => {
return `${prefix} (@${acct})`; return `${prefix} (@${acct})`;
}; };
const dateFormatOptions: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
export const AccountHeader: React.FC<{ export const AccountHeader: React.FC<{
accountId: string; accountId: string;
hideTabs?: boolean; hideTabs?: boolean;
@@ -891,46 +883,7 @@ export const AccountHeader: React.FC<{
</dd> </dd>
</dl> </dl>
{fields.map((pair, i) => ( <AccountFields fields={fields} emojis={account.emojis} />
<dl
key={i}
className={classNames({
verified: pair.verified_at,
})}
>
<dt
dangerouslySetInnerHTML={{
__html: pair.name_emojified,
}}
title={pair.name}
className='translate'
/>
<dd className='translate' title={pair.value_plain ?? ''}>
{pair.verified_at && (
<span
title={intl.formatMessage(messages.linkVerifiedOn, {
date: intl.formatDate(
pair.verified_at,
dateFormatOptions,
),
})}
>
<Icon
id='check'
icon={CheckIcon}
className='verified__mark'
/>
</span>
)}{' '}
<span
dangerouslySetInnerHTML={{
__html: pair.value_emojified,
}}
/>
</dd>
</dl>
))}
</div> </div>
</div> </div>

View File

@@ -7,6 +7,8 @@ import { isFulfilled, isRejected } from '@reduxjs/toolkit';
import { openURL } from 'mastodon/actions/search'; import { openURL } from 'mastodon/actions/search';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
import { isModernEmojiEnabled } from '../utils/environment';
const isMentionClick = (element: HTMLAnchorElement) => const isMentionClick = (element: HTMLAnchorElement) =>
element.classList.contains('mention') && element.classList.contains('mention') &&
!element.classList.contains('hashtag'); !element.classList.contains('hashtag');
@@ -53,6 +55,11 @@ export const useLinks = (skipHashtags?: boolean) => {
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
// Exit early if modern emoji is enabled, as this is handled by HandledLink.
if (isModernEmojiEnabled()) {
return;
}
const target = (e.target as HTMLElement).closest('a'); const target = (e.target as HTMLElement).closest('a');
if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) { if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {

View File

@@ -41,18 +41,22 @@ export type OnElementHandler<
extra: Arg, extra: Arg,
) => React.ReactNode; ) => React.ReactNode;
export type OnAttributeHandler<
Arg extends Record<string, unknown> = Record<string, unknown>,
> = (
name: string,
value: string,
tagName: string,
extra: Arg,
) => [string, unknown] | undefined | null;
export interface HTMLToStringOptions< export interface HTMLToStringOptions<
Arg extends Record<string, unknown> = Record<string, unknown>, Arg extends Record<string, unknown> = Record<string, unknown>,
> { > {
maxDepth?: number; maxDepth?: number;
onText?: (text: string, extra: Arg) => React.ReactNode; onText?: (text: string, extra: Arg) => React.ReactNode;
onElement?: OnElementHandler<Arg>; onElement?: OnElementHandler<Arg>;
onAttribute?: ( onAttribute?: OnAttributeHandler<Arg>;
name: string,
value: string,
tagName: string,
extra: Arg,
) => [string, unknown] | null;
allowedTags?: AllowedTagsType; allowedTags?: AllowedTagsType;
extraArgs?: Arg; extraArgs?: Arg;
} }
@@ -140,17 +144,18 @@ export function htmlStringToComponents<Arg extends Record<string, unknown>>(
// Custom attribute handler. // Custom attribute handler.
if (onAttribute) { if (onAttribute) {
const result = onAttribute( const result = onAttribute(name, attr.value, tagName, extraArgs);
name, // Rewrite this attribute.
attr.value,
node.tagName.toLowerCase(),
extraArgs,
);
if (result) { if (result) {
const [cbName, value] = result; const [cbName, value] = result;
props[cbName] = value; props[cbName] = value;
continue;
} else if (result === null) {
// Explicitly remove this attribute.
continue;
} }
} else { }
// Check global attributes first, then tag-specific ones. // Check global attributes first, then tag-specific ones.
const globalAttr = globalAttributes[name]; const globalAttr = globalAttributes[name];
const tagAttr = tagInfo.attributes?.[name]; const tagAttr = tagInfo.attributes?.[name];
@@ -178,7 +183,6 @@ export function htmlStringToComponents<Arg extends Record<string, unknown>>(
props[name] = value; props[name] = value;
} }
}
// If onElement is provided, use it to create the element. // If onElement is provided, use it to create the element.
if (onElement) { if (onElement) {