2
0

Emoji: Statuses (#36393)

This commit is contained in:
Echo
2025-10-08 16:18:11 +02:00
committed by GitHub
parent 2b213e9b1b
commit 0c1ca6c969
14 changed files with 121 additions and 78 deletions

View File

@@ -1,15 +1,38 @@
import type { List } from 'immutable';
import type { CustomEmoji } from '../models/custom_emoji';
import type { Status } from '../models/status';
import { EmojiHTML } from './emoji/html';
import { StatusBanner, BannerVariant } from './status_banner'; import { StatusBanner, BannerVariant } from './status_banner';
export const ContentWarning: React.FC<{ export const ContentWarning: React.FC<{
text: string; status: Status;
expanded?: boolean; expanded?: boolean;
onClick?: () => void; onClick?: () => void;
}> = ({ text, expanded, onClick }) => ( }> = ({ status, expanded, onClick }) => {
<StatusBanner const hasSpoiler = !!status.get('spoiler_text');
expanded={expanded} if (!hasSpoiler) {
onClick={onClick} return null;
variant={BannerVariant.Warning} }
>
<span dangerouslySetInnerHTML={{ __html: text }} /> const text =
</StatusBanner> status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml');
); if (typeof text !== 'string' || text.length === 0) {
return null;
}
return (
<StatusBanner
expanded={expanded}
onClick={onClick}
variant={BannerVariant.Warning}
>
<EmojiHTML
as='span'
htmlString={text}
extraEmojis={status.get('emoji') as List<CustomEmoji>}
/>
</StatusBanner>
);
};

View File

@@ -8,6 +8,7 @@ import classNames from 'classnames';
import { animated, useSpring } from '@react-spring/web'; import { animated, useSpring } from '@react-spring/web';
import escapeTextContentForBrowser from 'escape-html'; import escapeTextContentForBrowser from 'escape-html';
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 { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { fetchPoll, vote } from 'mastodon/actions/polls'; import { fetchPoll, vote } from 'mastodon/actions/polls';
@@ -305,10 +306,11 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
</span> </span>
)} )}
<span <EmojiHTML
className='poll__option__text translate' className='poll__option__text translate'
lang={lang} lang={lang}
dangerouslySetInnerHTML={{ __html: titleHtml }} htmlString={titleHtml}
extraEmojis={poll.emojis}
/> />
{!!voted && ( {!!voted && (

View File

@@ -600,7 +600,7 @@ class Status extends ImmutablePureComponent {
{matchedFilters && <FilterWarning title={matchedFilters.join(', ')} expanded={this.state.showDespiteFilter} onClick={this.handleFilterToggle} />} {matchedFilters && <FilterWarning title={matchedFilters.join(', ')} expanded={this.state.showDespiteFilter} onClick={this.handleFilterToggle} />}
{(status.get('spoiler_text').length > 0 && (!matchedFilters || this.state.showDespiteFilter)) && <ContentWarning text={status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml')} expanded={expanded} onClick={this.handleExpandedToggle} />} {(!matchedFilters || this.state.showDespiteFilter) && <ContentWarning status={status} expanded={expanded} onClick={this.handleExpandedToggle} />}
{expanded && ( {expanded && (
<> <>

View File

@@ -83,14 +83,15 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
export const useElementHandledLink = ({ export const useElementHandledLink = ({
hashtagAccountId, hashtagAccountId,
mentionAccountId, hrefToMentionAccountId,
}: { }: {
hashtagAccountId?: string; hashtagAccountId?: string;
mentionAccountId?: string; hrefToMentionAccountId?: (href: string) => string | undefined;
} = {}) => { } = {}) => {
const onElement = useCallback<OnElementHandler>( const onElement = useCallback<OnElementHandler>(
(element, { key, ...props }) => { (element, { key, ...props }) => {
if (element instanceof HTMLAnchorElement) { if (element instanceof HTMLAnchorElement) {
const mentionId = hrefToMentionAccountId?.(element.href);
return ( return (
<HandledLink <HandledLink
{...props} {...props}
@@ -98,13 +99,13 @@ export const useElementHandledLink = ({
href={element.href} href={element.href}
text={element.innerText} text={element.innerText}
hashtagAccountId={hashtagAccountId} hashtagAccountId={hashtagAccountId}
mentionAccountId={mentionAccountId} mentionAccountId={mentionId}
/> />
); );
} }
return undefined; return undefined;
}, },
[hashtagAccountId, mentionAccountId], [hashtagAccountId, hrefToMentionAccountId],
); );
return { onElement }; return { onElement };
}; };

View File

@@ -3,6 +3,8 @@ import { useCallback, useRef, useId } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { AnimateEmojiProvider } from './emoji/context';
export enum BannerVariant { export enum BannerVariant {
Warning = 'warning', Warning = 'warning',
Filter = 'filter', Filter = 'filter',
@@ -34,8 +36,7 @@ export const StatusBanner: React.FC<{
return ( return (
// Element clicks are passed on to button // Element clicks are passed on to button
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions <AnimateEmojiProvider
<div
className={ className={
variant === BannerVariant.Warning variant === BannerVariant.Warning
? 'content-warning' ? 'content-warning'
@@ -69,6 +70,6 @@ export const StatusBanner: React.FC<{
/> />
)} )}
</button> </button>
</div> </AnimateEmojiProvider>
); );
}; };

View File

@@ -50,9 +50,7 @@ export const EditIndicator = () => {
<EmbeddedStatusContent <EmbeddedStatusContent
className='edit-indicator__content translate' className='edit-indicator__content translate'
content={status.get('contentHtml')} status={status}
language={status.get('language')}
mentions={status.get('mentions')}
/> />
{(status.get('poll') || status.get('media_attachments').size > 0) && ( {(status.get('poll') || status.get('media_attachments').size > 0) && (

View File

@@ -35,9 +35,7 @@ export const ReplyIndicator = () => {
<EmbeddedStatusContent <EmbeddedStatusContent
className='reply-indicator__content translate' className='reply-indicator__content translate'
content={status.get('contentHtml')} status={status}
language={status.get('language')}
mentions={status.get('mentions')}
/> />
{(status.get('poll') || status.get('media_attachments').size > 0) && ( {(status.get('poll') || status.get('media_attachments').size > 0) && (

View File

@@ -2,6 +2,7 @@ import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { Avatar } from 'mastodon/components/avatar'; import { Avatar } from 'mastodon/components/avatar';
import { DisplayName } from 'mastodon/components/display_name'; import { DisplayName } from 'mastodon/components/display_name';
import { FollowButton } from 'mastodon/components/follow_button'; import { FollowButton } from 'mastodon/components/follow_button';
@@ -39,9 +40,10 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
</Link> </Link>
{account.get('note').length > 0 && ( {account.get('note').length > 0 && (
<div <EmojiHTML
className='account-card__bio translate animate-parent' className='account-card__bio translate'
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} htmlString={account.get('note_emojified')}
extraEmojis={account.get('emojis')}
/> />
)} )}

View File

@@ -154,6 +154,12 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
if (!extraEmojis) { if (!extraEmojis) {
return null; return null;
} }
if (Array.isArray(extraEmojis)) {
return extraEmojis.reduce<ExtraCustomEmojiMap>(
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
{},
);
}
if (!isList(extraEmojis)) { if (!isList(extraEmojis)) {
return extraEmojis; return extraEmojis;
} }

View File

@@ -56,7 +56,8 @@ export type EmojiStateMap = LimitedCache<string, EmojiState>;
export type CustomEmojiMapArg = export type CustomEmojiMapArg =
| ExtraCustomEmojiMap | ExtraCustomEmojiMap
| ImmutableList<CustomEmoji>; | ImmutableList<CustomEmoji>
| CustomEmoji[];
export type ExtraCustomEmojiMap = Record< export type ExtraCustomEmojiMap = Record<
string, string,

View File

@@ -10,9 +10,10 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { Avatar } from '../../../components/avatar'; import { Avatar } from '@/mastodon/components/avatar';
import { DisplayName } from '../../../components/display_name'; import { DisplayName } from '@/mastodon/components/display_name';
import { IconButton } from '../../../components/icon_button'; import { IconButton } from '@/mastodon/components/icon_button';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
const messages = defineMessages({ const messages = defineMessages({
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
@@ -30,7 +31,6 @@ class AccountAuthorize extends ImmutablePureComponent {
render () { render () {
const { intl, account, onAuthorize, onReject } = this.props; const { intl, account, onAuthorize, onReject } = this.props;
const content = { __html: account.get('note_emojified') };
return ( return (
<div className='account-authorize__wrapper'> <div className='account-authorize__wrapper'>
@@ -40,7 +40,11 @@ class AccountAuthorize extends ImmutablePureComponent {
<DisplayName account={account} /> <DisplayName account={account} />
</Link> </Link>
<div className='account__header__content translate' dangerouslySetInnerHTML={content} /> <EmojiHTML
className='account__header__content translate'
htmlString={account.get('note_emojified')}
extraEmojis={account.get('emojis')}
/>
</div> </div>
<div className='account--panel'> <div className='account--panel'>

View File

@@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom';
import type { List as ImmutableList, RecordOf } from 'immutable'; import type { List as ImmutableList, RecordOf } from 'immutable';
import type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context'; import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react'; import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'; import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
@@ -18,7 +19,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { EmbeddedStatusContent } from './embedded_status_content'; import { EmbeddedStatusContent } from './embedded_status_content';
export type Mention = RecordOf<{ url: string; acct: string }>; export type Mention = RecordOf<ApiMentionJSON>;
export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
statusId, statusId,
@@ -86,12 +87,9 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
} }
// Assign status attributes to variables with a forced type, as status is not yet properly typed // Assign status attributes to variables with a forced type, as status is not yet properly typed
const contentHtml = status.get('contentHtml') as string; const hasContentWarning = !!status.get('spoiler_text');
const contentWarning = status.get('spoilerHtml') as string;
const poll = status.get('poll'); const poll = status.get('poll');
const language = status.get('language') as string; const expanded = !status.get('hidden') || !hasContentWarning;
const mentions = status.get('mentions') as ImmutableList<Mention>;
const expanded = !status.get('hidden') || !contentWarning;
const mediaAttachmentsSize = ( const mediaAttachmentsSize = (
status.get('media_attachments') as ImmutableList<unknown> status.get('media_attachments') as ImmutableList<unknown>
).size; ).size;
@@ -109,20 +107,16 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
<DisplayName account={account} /> <DisplayName account={account} />
</div> </div>
{contentWarning && ( <ContentWarning
<ContentWarning status={status}
text={contentWarning} onClick={handleContentWarningClick}
onClick={handleContentWarningClick} expanded={expanded}
expanded={expanded} />
/>
)}
{(!contentWarning || expanded) && ( {(!hasContentWarning || expanded) && (
<EmbeddedStatusContent <EmbeddedStatusContent
className='notification-group__embedded-status__content reply-indicator__content translate' className='notification-group__embedded-status__content reply-indicator__content translate'
content={contentHtml} status={status}
language={language}
mentions={mentions}
/> />
)} )}

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
@@ -6,16 +6,22 @@ import type { List } from 'immutable';
import type { History } from 'history'; import type { History } from 'history';
import type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
import type { Status } from '@/mastodon/models/status';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import type { Mention } from './embedded_status'; import type { Mention } from './embedded_status';
const handleMentionClick = ( const handleMentionClick = (
history: History, history: History,
mention: Mention, mention: ApiMentionJSON,
e: MouseEvent, e: MouseEvent,
) => { ) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
history.push(`/@${mention.get('acct')}`); history.push(`/@${mention.acct}`);
} }
}; };
@@ -31,16 +37,26 @@ const handleHashtagClick = (
}; };
export const EmbeddedStatusContent: React.FC<{ export const EmbeddedStatusContent: React.FC<{
content: string; status: Status;
mentions: List<Mention>;
language: string;
className?: string; className?: string;
}> = ({ content, mentions, language, className }) => { }> = ({ status, className }) => {
const history = useHistory(); const history = useHistory();
const mentions = useMemo(
() => (status.get('mentions') as List<Mention>).toJS(),
[status],
);
const htmlHandlers = useElementHandledLink({
hashtagAccountId: status.get('account') as string | undefined,
hrefToMentionAccountId(href) {
const mention = mentions.find((item) => item.url === href);
return mention?.id;
},
});
const handleContentRef = useCallback( const handleContentRef = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => {
if (!node) { if (!node || isModernEmojiEnabled()) {
return; return;
} }
@@ -53,7 +69,7 @@ export const EmbeddedStatusContent: React.FC<{
link.classList.add('status-link'); link.classList.add('status-link');
const mention = mentions.find((item) => link.href === item.get('url')); const mention = mentions.find((item) => link.href === item.url);
if (mention) { if (mention) {
link.addEventListener( link.addEventListener(
@@ -61,8 +77,8 @@ export const EmbeddedStatusContent: React.FC<{
handleMentionClick.bind(null, history, mention), handleMentionClick.bind(null, history, mention),
false, false,
); );
link.setAttribute('title', `@${mention.get('acct')}`); link.setAttribute('title', `@${mention.acct}`);
link.setAttribute('href', `/@${mention.get('acct')}`); link.setAttribute('href', `/@${mention.acct}`);
} else if ( } else if (
link.textContent.startsWith('#') || link.textContent.startsWith('#') ||
link.previousSibling?.textContent?.endsWith('#') link.previousSibling?.textContent?.endsWith('#')
@@ -83,11 +99,12 @@ export const EmbeddedStatusContent: React.FC<{
); );
return ( return (
<div <EmojiHTML
{...htmlHandlers}
className={className} className={className}
ref={handleContentRef} ref={handleContentRef}
lang={language} lang={status.get('language') as string}
dangerouslySetInnerHTML={{ __html: content }} htmlString={status.get('contentHtml') as string}
/> />
); );
}; };

View File

@@ -394,17 +394,13 @@ export const DetailedStatus: React.FC<{
/> />
)} )}
{status.get('spoiler_text').length > 0 && {(!matchedFilters || showDespiteFilter) && (
(!matchedFilters || showDespiteFilter) && ( <ContentWarning
<ContentWarning status={status}
text={ expanded={expanded}
status.getIn(['translation', 'spoilerHtml']) || onClick={handleExpandedToggle}
status.get('spoilerHtml') />
} )}
expanded={expanded}
onClick={handleExpandedToggle}
/>
)}
{expanded && ( {expanded && (
<> <>