Refactors header from Status component (#37732)

This commit is contained in:
Echo 2026-02-04 14:12:21 +01:00 committed by GitHub
parent ee631bf826
commit 7f53a77fa3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 189 additions and 45 deletions

View File

@ -3,14 +3,12 @@ import PropTypes from 'prop-types';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import { Hotkeys } from 'mastodon/components/hotkeys';
import { ContentWarning } from 'mastodon/components/content_warning';
import { FilterWarning } from 'mastodon/components/filter_warning';
@ -26,16 +24,12 @@ import { MediaGallery, Video, Audio } from '../features/ui/util/async-components
import { SensitiveMediaContext } from '../features/ui/util/sensitive_media_context';
import { displayMedia } from '../initial_state';
import { Avatar } from './avatar';
import { AvatarOverlay } from './avatar_overlay';
import { StatusHeader } from './status/header'
import { LinkedDisplayName } from './display_name';
import { getHashtagBarForStatus } from './hashtag_bar';
import { RelativeTimestamp } from './relative_timestamp';
import StatusActionBar from './status_action_bar';
import StatusContent from './status_content';
import { StatusThreadLabel } from './status_thread_label';
import { VisibilityIcon } from './visibility_icon';
import { IconButton } from './icon_button';
const domParser = new DOMParser();
@ -112,7 +106,6 @@ class Status extends ImmutablePureComponent {
onToggleCollapsed: PropTypes.func,
onTranslate: PropTypes.func,
onInteractionModal: PropTypes.func,
onQuoteCancel: PropTypes.func,
muted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
@ -129,6 +122,7 @@ class Status extends ImmutablePureComponent {
avatarSize: PropTypes.number,
deployPictureInPicture: PropTypes.func,
unfocusable: PropTypes.bool,
headerRenderFn: PropTypes.func,
pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool,
available: PropTypes.bool,
@ -146,7 +140,6 @@ class Status extends ImmutablePureComponent {
'hidden',
'unread',
'pictureInPicture',
'onQuoteCancel',
];
state = {
@ -364,10 +357,6 @@ class Status extends ImmutablePureComponent {
this.setState(state => ({ ...state, showDespiteFilter: !state.showDespiteFilter }));
};
handleQuoteCancel = () => {
this.props.onQuoteCancel?.();
}
_properStatus () {
const { status } = this.props;
@ -383,7 +372,24 @@ class Status extends ImmutablePureComponent {
};
render () {
const { intl, hidden, featured, unfocusable, unread, showThread, showActions = true, isQuotedPost = false, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46, children } = this.props;
const {
intl,
hidden,
featured,
unfocusable,
unread,
showThread,
showActions = true,
isQuotedPost = false,
scrollKey,
pictureInPicture,
previousId,
nextInReplyToId,
rootId,
skipPrepend,
avatarSize = 46,
children,
} = this.props;
let { status, account, ...other } = this.props;
@ -405,7 +411,7 @@ class Status extends ImmutablePureComponent {
onTranslate: this.handleTranslate,
};
let media, statusAvatar, prepend, rebloggedByText;
let media, prepend, rebloggedByText;
const connectUp = previousId && previousId === status.get('in_reply_to_id');
const connectToRoot = rootId && rootId === status.get('in_reply_to_id');
@ -547,13 +553,19 @@ class Status extends ImmutablePureComponent {
);
}
if (account === undefined || account === null) {
statusAvatar = <Avatar account={status.get('account')} size={avatarSize} />;
} else {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
}
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
const header = this.props.headerRenderFn
? this.props.headerRenderFn({ status, account, avatarSize, messages, onHeaderClick: this.handleHeaderClick })
: (
<StatusHeader
status={status}
account={account}
avatarSize={avatarSize}
onHeaderClick={this.handleHeaderClick}
/>
);
return (
<Hotkeys handlers={handlers} focusable={!unfocusable}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader({intl, status, rebloggedByText, isQuote: isQuotedPost})} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
@ -575,28 +587,7 @@ class Status extends ImmutablePureComponent {
>
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
<div onClick={this.handleHeaderClick} onAuxClick={this.handleHeaderClick} className='status__info'>
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time'>
<span className='status__visibility-icon'><VisibilityIcon visibility={status.get('visibility')} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</Link>
<LinkedDisplayName displayProps={{account: status.get('account')}} className='status__display-name'>
<div className='status__avatar'>
{statusAvatar}
</div>
</LinkedDisplayName>
{isQuotedPost && !!this.props.onQuoteCancel && (
<IconButton
onClick={this.handleQuoteCancel}
className='status__quote-cancel'
title={intl.formatMessage(messages.quote_cancel)}
icon="cancel-fill"
iconComponent={CancelFillIcon}
/>
)}
</div>
{header}
{matchedFilters && <FilterWarning title={matchedFilters.join(', ')} expanded={this.state.showDespiteFilter} onClick={this.handleFilterToggle} />}

View File

@ -0,0 +1,128 @@
import type { FC, HTMLAttributes, MouseEventHandler, ReactNode } from 'react';
import { defineMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { isStatusVisibility } from '@/mastodon/api_types/statuses';
import type { Account } from '@/mastodon/models/account';
import type { Status } from '@/mastodon/models/status';
import { Avatar } from '../avatar';
import { AvatarOverlay } from '../avatar_overlay';
import type { DisplayNameProps } from '../display_name';
import { LinkedDisplayName } from '../display_name';
import { RelativeTimestamp } from '../relative_timestamp';
import { VisibilityIcon } from '../visibility_icon';
export interface StatusHeaderProps {
status: Status;
account?: Account;
avatarSize?: number;
children?: ReactNode;
wrapperProps?: HTMLAttributes<HTMLDivElement>;
displayNameProps?: DisplayNameProps;
onHeaderClick?: MouseEventHandler<HTMLDivElement>;
}
export type StatusHeaderRenderFn = (args: StatusHeaderProps) => ReactNode;
export const StatusHeader: FC<StatusHeaderProps> = ({
status,
account,
children,
avatarSize = 48,
wrapperProps,
onHeaderClick,
}) => {
const statusAccount = status.get('account') as Account | undefined;
const editedAt = status.get('edited_at') as string;
return (
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div
onClick={onHeaderClick}
onAuxClick={onHeaderClick}
{...wrapperProps}
className='status__info'
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
>
<Link
to={`/@${statusAccount?.acct}/${status.get('id') as string}`}
className='status__relative-time'
>
<StatusVisibility visibility={status.get('visibility')} />
<RelativeTimestamp timestamp={status.get('created_at') as string} />
{editedAt && <StatusEditedAt editedAt={editedAt} />}
</Link>
<StatusDisplayName
statusAccount={statusAccount}
friendAccount={account}
avatarSize={avatarSize}
/>
{children}
</div>
);
};
export const StatusVisibility: FC<{ visibility: unknown }> = ({
visibility,
}) => {
if (typeof visibility !== 'string' || !isStatusVisibility(visibility)) {
return null;
}
return (
<span className='status__visibility-icon'>
<VisibilityIcon visibility={visibility} />
</span>
);
};
const editMessage = defineMessage({
id: 'status.edited',
defaultMessage: 'Edited {date}',
});
export const StatusEditedAt: FC<{ editedAt: string }> = ({ editedAt }) => {
const intl = useIntl();
return (
<abbr
title={intl.formatMessage(editMessage, {
date: intl.formatDate(editedAt, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}),
})}
>
{' '}
*
</abbr>
);
};
export const StatusDisplayName: FC<{
statusAccount?: Account;
friendAccount?: Account;
avatarSize: number;
}> = ({ statusAccount, friendAccount, avatarSize }) => {
const AccountComponent = friendAccount ? AvatarOverlay : Avatar;
return (
<LinkedDisplayName
displayProps={{ account: statusAccount }}
className='status__display-name'
>
<div className='status__avatar'>
<AccountComponent
account={statusAccount}
friend={friendAccount}
size={avatarSize}
/>
</div>
</LinkedDisplayName>
);
};

View File

@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import type { Map as ImmutableMap } from 'immutable';
import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import { fetchRelationships } from 'mastodon/actions/accounts';
import { revealAccount } from 'mastodon/actions/accounts_typed';
import { fetchStatus } from 'mastodon/actions/statuses';
@ -18,6 +19,9 @@ import type { RootState } from 'mastodon/store';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { Button } from './button';
import { IconButton } from './icon_button';
import type { StatusHeaderRenderFn } from './status/header';
import { StatusHeader } from './status/header';
const MAX_QUOTE_POSTS_NESTING_LEVEL = 1;
@ -147,6 +151,11 @@ interface QuotedStatusProps {
onQuoteCancel?: () => void; // Used for composer.
}
const quoteCancelMessage = defineMessage({
id: 'status.quote.cancel',
defaultMessage: 'Cancel quote',
});
export const QuotedStatus: React.FC<QuotedStatusProps> = ({
quote,
contextType,
@ -213,6 +222,22 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
if (accountId && hiddenAccount) dispatch(fetchRelationships([accountId]));
}, [accountId, hiddenAccount, dispatch]);
const intl = useIntl();
const headerRenderFn: StatusHeaderRenderFn = useCallback(
(props) => (
<StatusHeader {...props}>
<IconButton
onClick={onQuoteCancel}
className='status__quote-cancel'
title={intl.formatMessage(quoteCancelMessage)}
icon='cancel-fill'
iconComponent={CancelFillIcon}
/>
</StatusHeader>
),
[intl, onQuoteCancel],
);
const isFilteredAndHidden = loadingState === 'filtered';
let quoteError: React.ReactNode = null;
@ -314,7 +339,7 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
id={quotedStatusId}
contextType={contextType}
avatarSize={32}
onQuoteCancel={onQuoteCancel}
headerRenderFn={headerRenderFn}
>
{canRenderChildQuote && (
<QuotedStatus