Status redesign: Boosts and replies (#40555)

This commit is contained in:
Echo
2026-09-16 15:58:23 +00:00
committed by GitHub
parent be358fd79d
commit 07dc097f42
5 changed files with 269 additions and 62 deletions

View File

@@ -0,0 +1,62 @@
@use '@/styles/mastodon/mixins';
.root {
@include mixins.type-label-md;
display: grid;
grid-template-columns: subgrid;
grid-template-rows: 1fr;
align-items: center;
color: var(--color-text-secondary);
grid-column: span 2;
a {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
// When hovering over any link with the account ID, underline them all.
&:has(a[data-id]:hover) a[data-id] {
text-decoration: underline;
}
}
.reblogIcon {
padding-inline-end: var(--col-gap);
justify-self: end;
color: var(--color-text-brand);
}
.replyIcon {
border-block-start: 2px solid var(--color-border-primary);
border-inline-start: 2px solid var(--color-border-primary);
border-top-left-radius: var(--radius-md);
box-sizing: border-box;
margin: var(--space-xs) var(--col-gap) 0 var(--space-2xs);
width: calc(var(--avatar-size) / 2 + 1px);
height: calc(100% - var(--space-xs));
justify-self: end;
}
.contents {
display: flex;
align-items: center;
gap: var(--space-2xs);
}
// Add a tag to override .root a
a.account {
font-weight: bold;
color: var(--color-text-primary);
}
.text {
@include mixins.line-clamp(1);
display: block;
margin-block-start: var(--space-2xs);
}

View File

@@ -1,67 +1,135 @@
import type React from 'react';
import { FormattedMessage } from 'react-intl';
import type { ExpandedStatusShape } from '@/mastodon/models/status';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import { Link } from 'react-router-dom';
import { LinkedDisplayName } from '../display_name';
import { ArrowsClockwiseIcon } from '@phosphor-icons/react';
import { useAccountStatus } from '@/mastodon/hooks/useStatus';
import type { ExpandedStatusShape } from '@/mastodon/models/status';
import { Avatar } from '../avatar';
import { DisplayName } from '../display_name';
import { EmojiHTML } from '../emoji/html';
import { Icon } from '../icon';
import { StatusThreadLabel } from '../status_thread_label';
import { RelativeTimestamp } from '../relative_timestamp';
import { onStatusLinksDisabled } from './hooks';
import classes from './prepend.module.scss';
import { statusLink } from './utils';
export const StatusPrepend: React.FC<{
status: ExpandedStatusShape;
reblogId?: string;
showThread?: boolean;
isReblog?: boolean;
}> = ({ status, showThread, isReblog }) => {
if (isReblog) {
return (
<div className='status__prepend'>
<div className='status__prepend__icon'>
<Icon id='retweet' icon={RepeatIcon} />
</div>
}> = ({ status, showThread, reblogId }) => {
if (!reblogId && (!showThread || !status.in_reply_to_id)) {
return null;
}
return (
<>
{!!reblogId && <StatusPrependReblog reblogId={reblogId} />}
{showThread && !!status.in_reply_to_id && (
<StatusPrependReply replyId={status.in_reply_to_id} />
)}
</>
);
};
const StatusPrependReblog: React.FC<{ reblogId: string }> = ({ reblogId }) => {
const status = useAccountStatus(reblogId, true);
if (!status) {
return null;
}
const account = status.account;
const accountLinkProps = {
to: {
pathname: `/@${account.acct}`,
state: { reference: 'status' },
},
title: `@${account.acct}`,
'data-id': account.id,
'data-hover-card-account': account.id,
'data-hover-card-reference': 'status',
};
return (
<div className={classes.root}>
<Icon icon={ArrowsClockwiseIcon} className={classes.reblogIcon} />
<span className={classes.contents}>
<Link {...accountLinkProps} role='presentation' tabIndex={-1}>
<Avatar account={account} />
</Link>
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} boosted'
values={{
name: (
<LinkedDisplayName
displayProps={{
account: status.account,
variant: 'simple',
}}
className='status__display-name muted'
/>
<Link {...accountLinkProps} className={classes.account}>
<DisplayName variant='simple' account={account} />
</Link>
),
}}
tagName='span'
/>
</div>
);
}
if (status.visibility === 'direct') {
return (
<div className='status__prepend'>
<div className='status__prepend__icon'>
<Icon id='at' icon={AlternateEmailIcon} />
</div>
<FormattedMessage
id='status.direct_indicator'
defaultMessage='Private mention'
tagName='span'
/>
</div>
);
}
if (showThread && status.in_reply_to_account_id) {
return (
<StatusThreadLabel
accountId={status.account.id}
inReplyToAccountId={status.in_reply_to_account_id}
/>
);
}
return null;
&nbsp;&bull;
<RelativeTimestamp timestamp={status.created_at} />
</span>
</div>
);
};
const StatusPrependReply: React.FC<{ replyId: string }> = ({ replyId }) => {
const status = useAccountStatus(replyId, true);
if (!status) {
return null;
}
const account = status.account;
const accountLinkProps = {
to: {
pathname: `/@${account.acct}`,
state: { reference: 'status' },
},
title: `@${account.acct}`,
'data-id': account.id,
'data-hover-card-account': account.id,
'data-hover-card-reference': 'status',
};
const language = status.translation?.language ?? status.language;
return (
<div className={classes.root}>
<div className={classes.replyIcon} />
<div>
<span className={classes.contents}>
<Link {...accountLinkProps} role='presentation' tabIndex={-1}>
<Avatar account={account} />
</Link>
<Link {...accountLinkProps} className={classes.account}>
<DisplayName variant='simple' account={account} />
</Link>
&bull;
<Link to={statusLink(status)}>
<RelativeTimestamp timestamp={status.created_at} />
</Link>
</span>
<Link to={statusLink(status)} className={classes.text}>
<EmojiHTML
as='blockquote'
lang={language}
htmlString={status.translation?.contentHtml ?? status.contentHtml}
extraEmojis={status.emojis}
onElement={onStatusLinksDisabled}
/>
</Link>
</div>
</div>
);
};

View File

@@ -113,8 +113,6 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
return null; // loading state
}
const actualStatus = parent ?? status;
const hotkeysProps = {
handlers: {
...handlers,
@@ -148,6 +146,7 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
variant === 'thread' && classes.variantThread,
variant === 'page' && classes.variantPage,
isQuotedPost && classes.isQuote,
status.visibility === 'direct' && classes.isMessage,
)}
data-featured={featured ? 'true' : null}
aria-label={screenReaderText}
@@ -155,8 +154,8 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
>
{!skipPrepend && (
<StatusPrepend
status={actualStatus}
isReblog={!!parent}
status={status}
reblogId={parent?.id}
showThread={showThread}
/>
)}
@@ -168,7 +167,7 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
<TranslateButton status={status} onTranslate={onTranslate} />
<StatusWarning
statusId={actualStatus.id}
statusId={status.id}
dismissedFilter={showDespiteFilter}
onFilterToggle={onFilterToggle}
wrapperId={contentWrapperId}

View File

@@ -27,6 +27,25 @@
padding: var(--space-sm);
}
.isMessage {
position: relative;
&::before {
content: '';
mask-repeat: repeat;
mask-image: url('@/images/composer_message.svg');
mask-size: 311.36px auto;
background-color: var(--color-border-brand);
opacity: 0.15;
position: absolute;
bottom: 0;
right: 0;
top: 0;
left: 0;
pointer-events: none;
}
}
.variantPage,
.isQuote {
--content-col: span 2;
@@ -40,7 +59,6 @@
grid-column: span 2;
}
.root :global(.status__prepend),
.spoiler,
.translate,
.contentWrapper,

View File

@@ -1,13 +1,73 @@
import { selectExpandedStatus, selectPlainStatus } from '../selectors/statuses';
import { useAppSelector } from '../store';
import { useEffect } from 'react';
export function useStatus(id?: string | null) {
return useAppSelector((state) => selectPlainStatus(state, id));
import { fetchAccount } from '../actions/accounts';
import { fetchStatus } from '../actions/statuses';
import { selectPlainAccount } from '../selectors/accounts';
import {
selectAccountStatus,
selectExpandedStatus,
selectPlainStatus,
} from '../selectors/statuses';
import { useAppDispatch, useAppSelector } from '../store';
type IdType = string | null | undefined;
export function useStatus(id: IdType, fetch = false) {
const status = useAppSelector((state) => selectPlainStatus(state, id));
useStatusFetch(fetch && id);
return status;
}
/** Gets status with full account information, fetching missing data if enabled. */
export function useAccountStatus(id: IdType, fetch = false) {
const status = useAppSelector((state) => selectAccountStatus(state, id));
useStatusFetch(fetch && id, { withAccount: true });
return status;
}
/** Adds reblog status and account information to standard Status */
export function useExpandedStatus(id?: string | null) {
return useAppSelector((state) =>
export function useExpandedStatus(id: IdType, fetch = false) {
const status = useAppSelector((state) =>
selectExpandedStatus(state, id ?? undefined),
);
useStatusFetch(fetch && id, { withAccount: true, withReblog: true });
return status;
}
export function useStatusFetch(
id: IdType | false,
{
withAccount,
withReblog,
}: { withAccount?: boolean; withReblog?: boolean } = {},
) {
const status = useAppSelector((state) =>
selectPlainStatus(state, id || null),
);
const account = useAppSelector((state) =>
selectPlainAccount(state, status?.account),
);
const reblog = useAppSelector((state) =>
selectPlainStatus(state, status?.reblog),
);
const dispatch = useAppDispatch();
useEffect(() => {
if (!id) {
return;
}
if (!status) {
dispatch(fetchStatus(id));
} else if (withAccount && !account) {
dispatch(fetchAccount(status.account));
} else if (withReblog && status.reblog && !reblog) {
dispatch(fetchStatus(status.reblog));
}
}, [account, dispatch, id, reblog, status, withAccount, withReblog]);
}