From 6abda76d13b46c82741de8618e2c141b29fe5355 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 8 Oct 2025 13:11:25 +0200 Subject: [PATCH] Emoji: Account page (#36385) --- .../mastodon/components/account/index.tsx | 6 +- .../mastodon/components/account_bio.tsx | 27 ++----- .../mastodon/components/account_fields.tsx | 70 +++++++++++----- .../mastodon/components/emoji/html.tsx | 16 +++- .../components/hover_card_account.tsx | 13 ++- .../components/status/handled_link.tsx | 31 +++++++ .../mastodon/components/verified_badge.tsx | 31 ++++++- .../components/account_header.tsx | 51 +----------- app/javascript/mastodon/hooks/useLinks.ts | 7 ++ app/javascript/mastodon/utils/html.ts | 80 ++++++++++--------- 10 files changed, 195 insertions(+), 137 deletions(-) diff --git a/app/javascript/mastodon/components/account/index.tsx b/app/javascript/mastodon/components/account/index.tsx index 8397695a4..3aebedc94 100644 --- a/app/javascript/mastodon/components/account/index.tsx +++ b/app/javascript/mastodon/components/account/index.tsx @@ -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 = ({ {account && withBio && (account.note.length > 0 ? ( -
) : (
diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx index 6c9ea43a4..e87ae654f 100644 --- a/app/javascript/mastodon/components/account_bio.tsx +++ b/app/javascript/mastodon/components/account_bio.tsx @@ -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 = ({ [showDropdown, accountId], ); - const handleLink = useCallback( - (element, { key, ...props }) => { - if (element instanceof HTMLAnchorElement) { - return ( - - ); - } - 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 = ({ htmlString={note} extraEmojis={extraEmojis} className={classNames(className, 'translate')} - onClickCapture={isModernEmojiEnabled() ? undefined : handleClick} + onClickCapture={handleClick} ref={handleNodeChange} - onElement={handleLink} + {...htmlHandlers} /> ); }; diff --git a/app/javascript/mastodon/components/account_fields.tsx b/app/javascript/mastodon/components/account_fields.tsx index 4ce55f789..dd17b89d8 100644 --- a/app/javascript/mastodon/components/account_fields.tsx +++ b/app/javascript/mastodon/components/account_fields.tsx @@ -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> = ({ + fields, + emojis, +}) => { + const intl = useIntl(); + const htmlHandlers = useElementHandledLink(); if (fields.size === 0) { return null; } return ( -
- {fields.take(limit).map((pair, i) => ( -
-
+ {fields.map((pair, i) => ( +
+ -
- {pair.get('verified_at') && ( - - )} - + {pair.verified_at && ( + + + + )}{' '} +
))} -
+ ); }; + +const dateFormatOptions: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}; diff --git a/app/javascript/mastodon/components/emoji/html.tsx b/app/javascript/mastodon/components/emoji/html.tsx index 73ad5fa23..b462a2ee6 100644 --- a/app/javascript/mastodon/components/emoji/html.tsx +++ b/app/javascript/mastodon/components/emoji/html.tsx @@ -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'; diff --git a/app/javascript/mastodon/components/hover_card_account.tsx b/app/javascript/mastodon/components/hover_card_account.tsx index a5a5e4c95..471d48841 100644 --- a/app/javascript/mastodon/components/hover_card_account.tsx +++ b/app/javascript/mastodon/components/hover_card_account.tsx @@ -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 (
- + +
+ +
+ {note && note.length > 0 && (
diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx index ee4132128..d40303818 100644 --- a/app/javascript/mastodon/components/status/handled_link.tsx +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -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> = ({ return text; } }; + +export const useElementHandledLink = ({ + hashtagAccountId, + mentionAccountId, +}: { + hashtagAccountId?: string; + mentionAccountId?: string; +} = {}) => { + const onElement = useCallback( + (element, { key, ...props }) => { + if (element instanceof HTMLAnchorElement) { + return ( + + ); + } + return undefined; + }, + [hashtagAccountId, mentionAccountId], + ); + return { onElement }; +}; diff --git a/app/javascript/mastodon/components/verified_badge.tsx b/app/javascript/mastodon/components/verified_badge.tsx index 626cc500d..43edbc795 100644 --- a/app/javascript/mastodon/components/verified_badge.tsx +++ b/app/javascript/mastodon/components/verified_badge.tsx @@ -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('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 = ({ link }) => ( - + ); diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index 776157ccf..2bf636d06 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -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<{
- {fields.map((pair, i) => ( -
-
- -
- {pair.verified_at && ( - - - - )}{' '} - -
-
- ))} +
diff --git a/app/javascript/mastodon/hooks/useLinks.ts b/app/javascript/mastodon/hooks/useLinks.ts index 00e1dd9bb..77609181b 100644 --- a/app/javascript/mastodon/hooks/useLinks.ts +++ b/app/javascript/mastodon/hooks/useLinks.ts @@ -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) { diff --git a/app/javascript/mastodon/utils/html.ts b/app/javascript/mastodon/utils/html.ts index f37018d86..c87b5a34c 100644 --- a/app/javascript/mastodon/utils/html.ts +++ b/app/javascript/mastodon/utils/html.ts @@ -41,18 +41,22 @@ export type OnElementHandler< extra: Arg, ) => React.ReactNode; +export type OnAttributeHandler< + Arg extends Record = Record, +> = ( + name: string, + value: string, + tagName: string, + extra: Arg, +) => [string, unknown] | undefined | null; + export interface HTMLToStringOptions< Arg extends Record = Record, > { maxDepth?: number; onText?: (text: string, extra: Arg) => React.ReactNode; onElement?: OnElementHandler; - onAttribute?: ( - name: string, - value: string, - tagName: string, - extra: Arg, - ) => [string, unknown] | null; + onAttribute?: OnAttributeHandler; allowedTags?: AllowedTagsType; extraArgs?: Arg; } @@ -140,44 +144,44 @@ export function htmlStringToComponents>( // 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; - } - } 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; + } else if (result === null) { + // Explicitly remove this attribute. 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; } + + // 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.