Refactor StatusContent (#39624)

This commit is contained in:
Echo 2026-06-26 14:26:18 +02:00 committed by GitHub
parent eb3c0c1176
commit b4e5b039e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 366 additions and 11 deletions

View File

@ -84,7 +84,7 @@ const preview: Preview = {
// Get the locale from the global toolbar
// and merge it with any parameters or args state.
const { locale } = globals as { locale: string };
const { state = {} } = parameters;
const { state = {}, stateFn } = parameters;
const argsState: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
@ -106,6 +106,16 @@ const preview: Preview = {
}
}
let stateFnState: Record<string, unknown> = {};
if (typeof stateFn === 'function') {
stateFnState =
(
stateFn as (
args: Record<string, unknown>,
) => Record<string, unknown> | undefined | null
)(args) ?? {};
}
const reducer = reducerWithInitialState(
{
meta: {
@ -113,6 +123,7 @@ const preview: Preview = {
},
},
state as Record<string, unknown>,
stateFnState,
argsState,
);

View File

@ -20,7 +20,7 @@ export function normalizeStatus(
) {
const normalStatus: StatusShape = {
hidden: normalOldStatus?.hidden ?? false,
collapsed: normalOldStatus?.collapsed ?? false,
collapsed: normalOldStatus?.collapsed ?? null,
content: '',
contentHtml: '',
muted: false,

View File

@ -15,7 +15,6 @@ import { fetchPoll, vote } from 'mastodon/actions/polls';
import { Icon } from 'mastodon/components/icon';
import { useIdentity } from 'mastodon/identity_context';
import type * as Model from 'mastodon/models/poll';
import type { Status } from 'mastodon/models/status';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { RelativeTimestamp } from './relative_timestamp';
@ -40,12 +39,18 @@ const isPollExpired = (expiresAt: Model.Poll['expires_at']) =>
interface PollProps {
pollId: string;
status: Status;
accountId: string;
statusUrl: string;
lang?: string;
disabled?: boolean;
}
export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
export const Poll: React.FC<PollProps> = ({
pollId,
disabled,
accountId,
statusUrl,
}) => {
// Third party hooks
const poll = useAppSelector((state) => state.polls[pollId]);
const identity = useIdentity();
@ -110,14 +115,22 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
openModal({
modalType: 'INTERACTION',
modalProps: {
accountId,
intent: 'vote',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
url: statusUrl,
},
}),
);
}
}, [voteDisabled, dispatch, identity, pollId, selected, status]);
}, [
voteDisabled,
dispatch,
identity,
pollId,
selected,
accountId,
statusUrl,
]);
const handleReveal = useCallback(() => {
setRevealed(true);

View File

@ -0,0 +1,121 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import type { StatusTranslation } from '@/mastodon/models/status';
import { statusFactoryState } from '@/testing/factories';
import { StatusContent } from './content';
interface StatusContentProps {
text: string;
collapsible: boolean;
clickable: boolean;
translatable: boolean;
translatedTo?: string;
}
const onClickFn = fn().mockName('onClick');
const onTranslateFn = fn().mockName('onTranslate');
const meta = {
title: 'Components/Status/StatusContent',
render(args) {
return (
<div style={{ width: 'min(600px, 80vw)' }}>
<StatusContent
statusId='1'
collapsible={args.collapsible}
onClick={args.clickable ? onClickFn : undefined}
onTranslate={args.translatable ? onTranslateFn : undefined}
/>
</div>
);
},
args: {
text: 'This is status text.',
collapsible: true,
clickable: true,
translatable: true,
},
argTypes: {
text: {
reduxPath: 'statuses.1.contentHtml',
},
translatedTo: {
control: 'select',
options: ['en', 'de', 'fr'],
},
},
parameters: {
state: {
server: {
translationLanguages: {
item: {
en: ['en', 'de', 'fr'],
},
},
},
},
stateFn(args: StatusContentProps) {
let status = statusFactoryState();
if (args.translatedTo) {
status = status.set('translation', {
contentHtml: `${args.text}<p><em>(in ${args.translatedTo})</em></p>`,
provider: 'Test Translation API',
spoiler_text: '',
spoilerHtml: '',
language: args.translatedTo,
detected_source_language: 'en',
} satisfies StatusTranslation);
}
return {
statuses: {
'1': status,
},
};
},
},
} satisfies Meta<StatusContentProps>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const ReadMore: Story = {
args: {
text: [
'This is a long-form piece of text that wraps multiple lines.',
'It is here to test what a longer status looks like.',
'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
'Pellentesque a ante placerat, egestas eros vitae, ornare orci.',
'Phasellus fringilla felis vel purus fermentum, nec viverra eros fringilla.',
'Mauris feugiat metus in dolor elementum, ultricies suscipit sapien tincidunt.',
'Mauris vestibulum urna vel mauris sagittis, a blandit felis cursus.',
'Sed nec sem dictum ligula hendrerit dignissim et non dolor.',
'Cras maximus lorem sit amet aliquet faucibus.',
'Donec tempus lectus vitae laoreet congue.',
'Nulla congue nibh sed eros pulvinar pharetra.',
'Fusce vel nibh quis nisi mollis volutpat quis vel erat.',
'Fusce non metus non sapien volutpat elementum.',
'Aenean elementum ipsum ut neque bibendum, eget blandit ex efficitur.',
'Morbi semper eros at ipsum pellentesque mattis.',
'Donec ultricies ante imperdiet placerat tempus.',
'Vivamus vitae ante sit amet lectus porta mollis quis dictum quam.',
'Cras dignissim ante at turpis scelerisque, non hendrerit ipsum vestibulum.',
'Quisque ac nulla ac sem auctor posuere eget id ex.',
'Phasellus cursus purus sit amet sollicitudin finibus.',
'In varius justo eu metus dapibus, non imperdiet lorem efficitur.',
'Nulla tincidunt odio eget ipsum auctor rhoncus.',
]
.map((text) => `<p>${text}</p>`)
.join('\n'),
},
};
export const Translated: Story = {
args: {
translatedTo: 'fr',
},
};

View File

@ -0,0 +1,210 @@
import type React from 'react';
import { useCallback } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import classnames from 'classnames';
import { toggleStatusCollapse } from '@/mastodon/actions/statuses';
import { useIdentity } from '@/mastodon/identity_context';
import { languages as preloadedLanguages } from '@/mastodon/initial_state';
import type { StatusTranslation } from '@/mastodon/models/status';
import { selectPlainStatus } from '@/mastodon/selectors/statuses';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import { EmojiHTML } from '../emoji/html';
import { Icon } from '../icon';
import { Poll } from '../poll';
import { useElementHandledLink } from './handled_link';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
export const StatusContent: React.FC<{
statusId: string;
onClick?: React.MouseEventHandler;
onTranslate?: React.MouseEventHandler<HTMLButtonElement>;
collapsible?: boolean;
}> = ({ statusId, onClick, onTranslate, collapsible }) => {
const status = useAppSelector((state) => selectPlainStatus(state, statusId));
const { signedIn } = useIdentity();
const targetLanguages = useAppSelector(
(state) =>
state.server.translationLanguages.item?.[status?.language ?? 'und'],
);
const intl = useIntl();
// Determines if a long post should show the read more button.
const dispatch = useAppDispatch();
const handleCollapse = useCallback(
(node: HTMLDivElement | null) => {
if (!node || status?.collapsed !== null || !collapsible) {
return;
}
const text = node.querySelector(':scope > .status__content__text');
const collapsed =
(node.clientHeight > MAX_HEIGHT ||
(text !== null && text.scrollWidth > text.clientWidth)) &&
!status.spoiler_text;
dispatch(toggleStatusCollapse(status.id, collapsed));
},
[collapsible, status, dispatch],
);
// Trigger the click event if clicking outside a link, button, or label inside a status.
const handleClick: React.MouseEventHandler<HTMLDivElement> = useCallback(
(event) => {
const { target } = event;
if (
!onClick ||
!(target instanceof Element) ||
target.closest(':is(a, button, label)')
) {
return;
}
onClick(event);
},
[onClick],
);
const hrefToMention = useCallback(
(href: string) => status?.mentions.find((item) => item.url === href),
[status?.mentions],
);
const hrefToCollectionId = useCallback(
(href: string) =>
status?.tagged_collections.find((item) => item.url === href)?.id,
[status?.tagged_collections],
);
const htmlHandlers = useElementHandledLink({
hashtagAccountId: status?.account,
hrefToCollectionId,
hrefToMention,
});
if (!status) {
return null;
}
const language = status.translation?.language ?? status.language;
const renderReadMore = !!onClick && status.collapsed;
const readMoreButton = renderReadMore && (
<button
className='status__content__read-more-button'
type='button'
onClick={onClick}
key='read-more'
>
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
<Icon id='angle-right' icon={ChevronRightIcon} />
</button>
);
const renderTranslate =
!!onTranslate &&
signedIn &&
['public', 'unlisted'].includes(status.visibility) &&
status.search_index &&
status.search_index.trim().length > 0 &&
targetLanguages?.includes(intl.locale.replace(/[_-].*/, ''));
const translateButton = renderTranslate && (
<TranslateButton onClick={onTranslate} translation={status.translation} />
);
const poll = !!status.poll && (
<Poll
pollId={status.poll}
statusUrl={status.uri}
accountId={status.account}
lang={language}
/>
);
const content = (
<EmojiHTML
className='status__content__text status__content__text--visible translate'
lang={language}
htmlString={status.translation?.contentHtml ?? status.contentHtml}
extraEmojis={status.emojis}
{...htmlHandlers}
/>
);
const classNames = classnames('status__content', {
'status__content--with-action': onClick,
'status__content--collapsed': renderReadMore,
});
if (!onClick) {
return (
<div className={classNames} ref={handleCollapse}>
{content}
{poll}
{translateButton}
</div>
);
}
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
return (
<>
<div className={classNames} ref={handleCollapse} onClick={handleClick}>
{content}
{poll}
{translateButton}
</div>
{readMoreButton}
</>
);
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
};
const TranslateButton: React.FC<{
onClick: React.MouseEventHandler<HTMLButtonElement>;
translation?: StatusTranslation;
}> = ({ translation, onClick }) => {
if (!translation) {
return (
<button
type='button'
className='status__content__translate-button'
onClick={onClick}
>
<FormattedMessage id='status.translate' defaultMessage='Translate' />
</button>
);
}
const language = preloadedLanguages?.find(
(lang) => lang[0] === translation.detected_source_language,
);
const languageName = language
? language[1]
: translation.detected_source_language;
const provider = translation.provider;
return (
<div className='translate-button'>
<button type='button' className='link-button' onClick={onClick}>
<FormattedMessage
id='status.show_original'
defaultMessage='Show original'
/>
</button>
<div className='translate-button__meta'>
<FormattedMessage
id='status.translated_from_with'
defaultMessage='Translated from {lang} using {provider}'
values={{ lang: languageName, provider }}
/>
</div>
</div>
);
};

View File

@ -208,7 +208,7 @@ class StatusContent extends PureComponent {
);
const poll = !!status.get('poll') && (
<Poll pollId={status.get('poll')} status={status} lang={language} />
<Poll pollId={status.get('poll')} statusUrl={status.get('uri')} accountId={status.getIn(['account', 'id'])} lang={language} />
);
if (this.props.onClick) {

View File

@ -73,7 +73,7 @@ export type CustomEmojiMapArg =
| ExtraCustomEmojiMap
| ImmutableList<CustomEmoji>
| CustomEmoji[]
| ApiCustomEmojiJSON[];
| Pick<ApiCustomEmojiJSON, 'shortcode' | 'static_url' | 'url'>[];
export type ExtraCustomEmojiMap = Record<
string,

View File

@ -47,7 +47,7 @@ export interface StatusShape {
pinned: boolean;
filtered: FilterResult[];
sensitive: boolean;
collapsed: boolean;
collapsed: boolean | null;
uri: string;
url: string | null;