Emoji: Account page (#36385)
This commit is contained in:
@@ -5,6 +5,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
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 {
|
||||
blockAccount,
|
||||
@@ -331,9 +332,10 @@ export const Account: React.FC<AccountProps> = ({
|
||||
{account &&
|
||||
withBio &&
|
||||
(account.note.length > 0 ? (
|
||||
<div
|
||||
<EmojiHTML
|
||||
className='account__note translate'
|
||||
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
||||
htmlString={account.note_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
/>
|
||||
) : (
|
||||
<div className='account__note account__note--missing'>
|
||||
|
||||
@@ -6,10 +6,9 @@ import { useLinks } from 'mastodon/hooks/useLinks';
|
||||
|
||||
import { useAppSelector } from '../store';
|
||||
import { isModernEmojiEnabled } from '../utils/environment';
|
||||
import type { OnElementHandler } from '../utils/html';
|
||||
|
||||
import { EmojiHTML } from './emoji/html';
|
||||
import { HandledLink } from './status/handled_link';
|
||||
import { useElementHandledLink } from './status/handled_link';
|
||||
|
||||
interface AccountBioProps {
|
||||
className: string;
|
||||
@@ -38,23 +37,9 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
||||
[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 htmlHandlers = useElementHandledLink({
|
||||
hashtagAccountId: showDropdown ? accountId : undefined,
|
||||
});
|
||||
|
||||
const note = useAppSelector((state) => {
|
||||
const account = state.accounts.get(accountId);
|
||||
@@ -77,9 +62,9 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
||||
htmlString={note}
|
||||
extraEmojis={extraEmojis}
|
||||
className={classNames(className, 'translate')}
|
||||
onClickCapture={isModernEmojiEnabled() ? undefined : handleClick}
|
||||
onClickCapture={handleClick}
|
||||
ref={handleNodeChange}
|
||||
onElement={handleLink}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,42 +1,70 @@
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
|
||||
export const AccountFields: React.FC<{
|
||||
fields: Account['fields'];
|
||||
limit: number;
|
||||
}> = ({ fields, limit = -1 }) => {
|
||||
const handleClick = useLinks();
|
||||
import { CustomEmojiProvider } from './emoji/context';
|
||||
import { EmojiHTML } from './emoji/html';
|
||||
import { useElementHandledLink } from './status/handled_link';
|
||||
|
||||
export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
|
||||
fields,
|
||||
emojis,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const htmlHandlers = useElementHandledLink();
|
||||
|
||||
if (fields.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account-fields' onClickCapture={handleClick}>
|
||||
{fields.take(limit).map((pair, i) => (
|
||||
<dl
|
||||
key={i}
|
||||
className={classNames({ verified: pair.get('verified_at') })}
|
||||
>
|
||||
<dt
|
||||
dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
|
||||
<CustomEmojiProvider emojis={emojis}>
|
||||
{fields.map((pair, i) => (
|
||||
<dl key={i} className={classNames({ verified: pair.verified_at })}>
|
||||
<EmojiHTML
|
||||
as='dt'
|
||||
htmlString={pair.name_emojified}
|
||||
className='translate'
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
|
||||
<dd className='translate' title={pair.get('value_plain') ?? ''}>
|
||||
{pair.get('verified_at') && (
|
||||
<Icon id='check' icon={CheckIcon} className='verified__mark' />
|
||||
)}
|
||||
<dd className='translate' title={pair.value_plain ?? ''}>
|
||||
{pair.verified_at && (
|
||||
<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>
|
||||
</dl>
|
||||
))}
|
||||
</div>
|
||||
</CustomEmojiProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
@@ -4,7 +4,10 @@ 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 type {
|
||||
OnAttributeHandler,
|
||||
OnElementHandler,
|
||||
} from '@/mastodon/utils/html';
|
||||
import { htmlStringToComponents } from '@/mastodon/utils/html';
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
@@ -16,6 +19,7 @@ interface EmojiHTMLProps {
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
className?: string;
|
||||
onElement?: OnElementHandler;
|
||||
onAttribute?: OnAttributeHandler;
|
||||
}
|
||||
|
||||
export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
||||
@@ -26,14 +30,19 @@ export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
||||
as: asProp = 'div', // Rename for syntax highlighting
|
||||
className = '',
|
||||
onElement,
|
||||
onAttribute,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const contents = useMemo(
|
||||
() =>
|
||||
htmlStringToComponents(htmlString, { onText: textToEmojis, onElement }),
|
||||
[htmlString, onElement],
|
||||
htmlStringToComponents(htmlString, {
|
||||
onText: textToEmojis,
|
||||
onElement,
|
||||
onAttribute,
|
||||
}),
|
||||
[htmlString, onAttribute, onElement],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -60,6 +69,7 @@ export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
||||
extraEmojis,
|
||||
className,
|
||||
onElement,
|
||||
onAttribute,
|
||||
...rest
|
||||
} = props;
|
||||
const Wrapper = asElement ?? 'div';
|
||||
|
||||
@@ -23,6 +23,8 @@ import { domain } from 'mastodon/initial_state';
|
||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { useLinks } from '../hooks/useLinks';
|
||||
|
||||
export const HoverCardAccount = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ accountId?: string }
|
||||
@@ -64,6 +66,8 @@ export const HoverCardAccount = forwardRef<
|
||||
!isMutual &&
|
||||
!isFollower;
|
||||
|
||||
const handleClick = useLinks();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -105,7 +109,14 @@ export const HoverCardAccount = forwardRef<
|
||||
accountId={account.id}
|
||||
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 && (
|
||||
<dl className='hover-card__note'>
|
||||
<dt className='hover-card__note-label'>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { ComponentProps, FC } from 'react';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { OnElementHandler } from '@/mastodon/utils/html';
|
||||
|
||||
export interface HandledLinkProps {
|
||||
href: string;
|
||||
text: string;
|
||||
@@ -77,3 +80,31 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
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';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
const stripRelMe = (html: string) => {
|
||||
if (isModernEmojiEnabled()) {
|
||||
return html;
|
||||
}
|
||||
const document = domParser.parseFromString(html, 'text/html').documentElement;
|
||||
|
||||
document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
|
||||
@@ -15,7 +22,23 @@ const stripRelMe = (html: string) => {
|
||||
});
|
||||
|
||||
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 {
|
||||
@@ -24,6 +47,10 @@ interface Props {
|
||||
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
|
||||
<span className='verified-badge'>
|
||||
<Icon id='check' icon={CheckIcon} className='verified-badge__mark' />
|
||||
<span dangerouslySetInnerHTML={stripRelMe(link)} />
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={stripRelMe(link)}
|
||||
onAttribute={onAttribute}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -7,9 +7,9 @@ import { Helmet } from 'react-helmet';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||
import { AccountFields } from '@/mastodon/components/account_fields';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
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 MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
@@ -186,14 +186,6 @@ const titleFromAccount = (account: Account) => {
|
||||
return `${prefix} (@${acct})`;
|
||||
};
|
||||
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
export const AccountHeader: React.FC<{
|
||||
accountId: string;
|
||||
hideTabs?: boolean;
|
||||
@@ -891,46 +883,7 @@ export const AccountHeader: React.FC<{
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{fields.map((pair, i) => (
|
||||
<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>
|
||||
))}
|
||||
<AccountFields fields={fields} emojis={account.emojis} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { isFulfilled, isRejected } from '@reduxjs/toolkit';
|
||||
import { openURL } from 'mastodon/actions/search';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { isModernEmojiEnabled } from '../utils/environment';
|
||||
|
||||
const isMentionClick = (element: HTMLAnchorElement) =>
|
||||
element.classList.contains('mention') &&
|
||||
!element.classList.contains('hashtag');
|
||||
@@ -53,6 +55,11 @@ export const useLinks = (skipHashtags?: boolean) => {
|
||||
|
||||
const handleClick = useCallback(
|
||||
(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');
|
||||
|
||||
if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||
|
||||
@@ -41,18 +41,22 @@ export type OnElementHandler<
|
||||
extra: Arg,
|
||||
) => 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<
|
||||
Arg extends Record<string, unknown> = Record<string, unknown>,
|
||||
> {
|
||||
maxDepth?: number;
|
||||
onText?: (text: string, extra: Arg) => React.ReactNode;
|
||||
onElement?: OnElementHandler<Arg>;
|
||||
onAttribute?: (
|
||||
name: string,
|
||||
value: string,
|
||||
tagName: string,
|
||||
extra: Arg,
|
||||
) => [string, unknown] | null;
|
||||
onAttribute?: OnAttributeHandler<Arg>;
|
||||
allowedTags?: AllowedTagsType;
|
||||
extraArgs?: Arg;
|
||||
}
|
||||
@@ -140,17 +144,18 @@ export function htmlStringToComponents<Arg extends Record<string, unknown>>(
|
||||
|
||||
// Custom attribute handler.
|
||||
if (onAttribute) {
|
||||
const result = onAttribute(
|
||||
name,
|
||||
attr.value,
|
||||
node.tagName.toLowerCase(),
|
||||
extraArgs,
|
||||
);
|
||||
const result = onAttribute(name, attr.value, tagName, extraArgs);
|
||||
// Rewrite this attribute.
|
||||
if (result) {
|
||||
const [cbName, value] = result;
|
||||
props[cbName] = value;
|
||||
continue;
|
||||
} else if (result === null) {
|
||||
// Explicitly remove this attribute.
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// Check global attributes first, then tag-specific ones.
|
||||
const globalAttr = globalAttributes[name];
|
||||
const tagAttr = tagInfo.attributes?.[name];
|
||||
@@ -178,7 +183,6 @@ export function htmlStringToComponents<Arg extends Record<string, unknown>>(
|
||||
|
||||
props[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// If onElement is provided, use it to create the element.
|
||||
if (onElement) {
|
||||
|
||||
Reference in New Issue
Block a user