Add rendering of quote posts in web UI (#34738)

This commit is contained in:
diondiondion 2025-05-21 17:50:45 +02:00 committed by GitHub
commit 97b9e8849d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 219 additions and 43 deletions

View file

@ -5,14 +5,12 @@ 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 { HotKeys } from 'react-hotkeys';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import { ContentWarning } from 'mastodon/components/content_warning';
import { FilterWarning } from 'mastodon/components/filter_warning';
@ -88,6 +86,7 @@ class Status extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.record,
children: PropTypes.node,
previousId: PropTypes.string,
nextInReplyToId: PropTypes.string,
rootId: PropTypes.string,
@ -115,6 +114,7 @@ class Status extends ImmutablePureComponent {
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
showThread: PropTypes.bool,
isQuotedPost: PropTypes.bool,
getScrollPosition: PropTypes.func,
updateScrollBottom: PropTypes.func,
cacheMediaWidth: PropTypes.func,
@ -372,7 +372,7 @@ class Status extends ImmutablePureComponent {
};
render () {
const { intl, hidden, featured, unfocusable, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46 } = this.props;
const { intl, hidden, featured, unfocusable, unread, showThread, isQuotedPost = false, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46, children } = this.props;
let { status, account, ...other } = this.props;
@ -543,7 +543,7 @@ class Status extends ImmutablePureComponent {
<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)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
{!skipPrepend && prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted, 'status--is-quote': isQuotedPost })} data-id={status.get('id')}>
{(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'>
@ -576,12 +576,16 @@ class Status extends ImmutablePureComponent {
{...statusContentProps}
/>
{children}
{media}
{hashtagBar}
</>
)}
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
{!isQuotedPost &&
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
}
</div>
</div>
</HotKeys>

View file

@ -9,7 +9,7 @@ import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
import StatusContainer from '../containers/status_container';
import { StatusQuoteManager } from '../components/status_quoted';
import { LoadGap } from './load_gap';
import ScrollableList from './scrollable_list';
@ -113,7 +113,7 @@ export default class StatusList extends ImmutablePureComponent {
);
default:
return (
<StatusContainer
<StatusQuoteManager
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
@ -130,7 +130,7 @@ export default class StatusList extends ImmutablePureComponent {
if (scrollableContent && featuredStatusIds) {
scrollableContent = featuredStatusIds.map(statusId => (
<StatusContainer
<StatusQuoteManager
key={`f-${statusId}`}
id={statusId}
featured

View file

@ -0,0 +1,117 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import type { Map as ImmutableMap } from 'immutable';
import { Icon } from 'mastodon/components/icon';
import StatusContainer from 'mastodon/containers/status_container';
import { useAppSelector } from 'mastodon/store';
import QuoteIcon from '../../images/quote.svg?react';
const QuoteWrapper: React.FC<{
isError?: boolean;
children: React.ReactNode;
}> = ({ isError, children }) => {
return (
<div
className={classNames('status__quote', {
'status__quote--error': isError,
})}
>
<Icon id='quote' icon={QuoteIcon} className='status__quote-icon' />
{children}
</div>
);
};
type QuoteMap = ImmutableMap<'state' | 'quoted_status', string | null>;
export const QuotedStatus: React.FC<{ quote: QuoteMap }> = ({ quote }) => {
const quotedStatusId = quote.get('quoted_status');
const state = quote.get('state');
const status = useAppSelector((state) =>
quotedStatusId ? state.statuses.get(quotedStatusId) : undefined,
);
let quoteError: React.ReactNode | null = null;
if (state === 'deleted') {
quoteError = (
<FormattedMessage
id='status.quote_error.removed'
defaultMessage='This post was removed by its author.'
/>
);
} else if (state === 'unauthorized') {
quoteError = (
<FormattedMessage
id='status.quote_error.unauthorized'
defaultMessage='This post cannot be displayed as you are not authorized to view it.'
/>
);
} else if (state === 'pending') {
quoteError = (
<FormattedMessage
id='status.quote_error.pending_approval'
defaultMessage='This post is pending approval from the original author.'
/>
);
} else if (state === 'rejected' || state === 'revoked') {
quoteError = (
<FormattedMessage
id='status.quote_error.rejected'
defaultMessage='This post cannot be displayed as the original author does not allow it to be quoted.'
/>
);
} else if (!status || !quotedStatusId) {
quoteError = (
<FormattedMessage
id='status.quote_error.not_found'
defaultMessage='This post cannot be displayed.'
/>
);
}
if (quoteError) {
return <QuoteWrapper isError>{quoteError}</QuoteWrapper>;
}
return (
<QuoteWrapper>
<StatusContainer
// @ts-expect-error Status isn't typed yet
isQuotedPost
id={quotedStatusId}
avatarSize={40}
/>
</QuoteWrapper>
);
};
interface StatusQuoteManagerProps {
id: string;
[key: string]: unknown;
}
/**
* This wrapper component takes a status ID and, if the associated status
* is a quote post, it renders the quote into `StatusContainer` as a child.
* It passes all other props through to `StatusContainer`.
*/
export const StatusQuoteManager = (props: StatusQuoteManagerProps) => {
const status = useAppSelector((state) => state.statuses.get(props.id));
const quote = status?.get('quote') as QuoteMap | undefined;
if (quote) {
return (
<StatusContainer {...props}>
<QuotedStatus quote={quote} />
</StatusContainer>
);
}
return <StatusContainer {...props} />;
};