Status redesign: Single page and threading (#40625)

This commit is contained in:
Echo
2026-09-21 13:06:42 +00:00
committed by GitHub
parent 152de5e8e8
commit af2a49d309
16 changed files with 497 additions and 90 deletions

View File

@@ -2,12 +2,13 @@ import type { ComponentProps } from 'react';
import { FormattedDate } from 'react-intl';
export const FormattedDateWrapper = (
props: ComponentProps<typeof FormattedDate> & { className?: string },
) => (
export const FormattedDateWrapper = ({
className,
...props
}: ComponentProps<typeof FormattedDate> & { className?: string }) => (
<FormattedDate {...props}>
{(date) => (
<time dateTime={tryIsoString(props.value)} className={props.className}>
<time dateTime={tryIsoString(props.value)} className={className}>
{date}
</time>
)}

View File

@@ -3,6 +3,8 @@ import { useCallback, useMemo } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import {
ArrowsClockwiseIcon,
BookmarkSimpleIcon,
@@ -77,6 +79,7 @@ interface StatusActionBarProps {
statusId: string;
withDismiss?: boolean;
withCounters?: boolean;
onlyInteractions?: boolean;
}
const messages = defineMessages({
@@ -150,6 +153,7 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
statusId,
withDismiss,
withCounters,
onlyInteractions,
}) => {
const status = useStatus(statusId);
const quotedAccountId = useAppSelector(
@@ -211,8 +215,8 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
const shouldShowQuoteRemovalHint =
isQuotingMe && contextType === 'notifications';
return (
<div className={classes.actions}>
const interactionButtons = (
<>
<Button
size='sm'
clipPadding
@@ -235,10 +239,20 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
title={favouriteTitle}
leadingIcon={favouriteIcon}
onClick={handleFavouriteClick}
className={classes.actionsButtonGap}
className={classNames(!onlyInteractions && classes.actionsButtonGap)}
>
{withCounters && status.favourites_count}
</ToggleButton>
</>
);
if (onlyInteractions) {
return <div className={classes.actions}>{interactionButtons}</div>;
}
return (
<div className={classes.actions}>
{interactionButtons}
{isPublic && (
<IconButton

View File

@@ -1,7 +1,7 @@
import type React from 'react';
import { useMemo } from 'react';
import { FormattedDate, FormattedMessage } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
@@ -10,16 +10,15 @@ import type {
StatusVisibility,
} from '@/mastodon/models/status';
import { AnimatedNumber } from '../animated_number';
import { FormattedDateWrapper } from '../formatted_date';
import classes from './styles.module.scss';
import { statusLink } from './utils';
export const StatusMeta: React.FC<
{
status: Pick<
AnyStatusShape,
'account' | 'application' | 'created_at' | 'id' | 'visibility'
>;
} & React.ComponentPropsWithRef<'span'>
> = ({ status, ...props }) => {
export const StatusMeta: React.FC<{
status: AnyStatusShape;
}> = ({ status }) => {
const { created_at, application } = status;
const createdAt = useMemo(() => {
@@ -38,9 +37,67 @@ export const StatusMeta: React.FC<
return null;
}
const createdLink = (
<Link to={statusLink(status)}>
<FormattedDate
let applicationDisplay: React.ReactNode = null;
if (application?.website) {
applicationDisplay = (
<a href={application.website} target='_blank' rel='noopener noreferrer'>
{application.name}
</a>
);
} else if (application?.name) {
applicationDisplay = <span>{application.name}</span>;
}
const baseStatusLink = statusLink(status);
return (
<div className={classes.meta}>
<FormattedMessage
id='status.replies_count'
defaultMessage='{count, plural, one {{counter} reply} other {{counter} replies}}'
values={{
count: status.replies_count,
counter: <AnimatedNumber value={status.replies_count} />,
}}
tagName='span'
/>
&bull;
<Link to={`${baseStatusLink}/quotes`}>
<FormattedMessage
id='status.quotes_count'
defaultMessage='{count, plural, one {{counter} quote} other {{counter} quotes}}'
values={{
count: status.quotes_count,
counter: <AnimatedNumber value={status.quotes_count} />,
}}
/>
</Link>
&bull;
<Link to={`${baseStatusLink}/reblogs`}>
<FormattedMessage
id='status.reblogs_count'
defaultMessage='{count, plural, one {{counter} boost} other {{counter} boosts}}'
values={{
count: status.reblogs_count,
counter: <AnimatedNumber value={status.reblogs_count} />,
}}
/>
</Link>
&bull;
<Link
to={`${baseStatusLink}/favourites`}
className={classes.actionsButtonGap}
>
<FormattedMessage
id='status.likes_count'
defaultMessage='{count, plural, one {{counter} like} other {{counter} likes}}'
values={{
count: status.favourites_count,
counter: <AnimatedNumber value={status.favourites_count} />,
}}
/>
</Link>
<FormattedDateWrapper
value={createdAt}
year='numeric'
month='short'
@@ -48,43 +105,15 @@ export const StatusMeta: React.FC<
hour='2-digit'
minute='2-digit'
/>
</Link>
);
let applicationLink: React.ReactNode = application?.name;
if (application?.website) {
applicationLink = (
<a href={application.website} target='_blank' rel='noopener noreferrer'>
{application.name}
</a>
);
}
return (
<span {...props}>
{applicationLink ? (
<FormattedMessage
id='status.meta'
defaultMessage='{createdAt} on {source} {sep} {visibility}'
values={{
createdAt: createdLink,
source: applicationLink,
visibility,
sep: <>&bull;</>,
}}
/>
) : (
<FormattedMessage
id='status.meta.no_application'
defaultMessage='{createdAt} {sep} {visibility}'
values={{
createdAt: createdLink,
visibility,
sep: <>&bull;</>,
}}
/>
{applicationDisplay && (
<>
&bull;
{applicationDisplay}
</>
)}
</span>
&bull;
<span>{visibility}</span>
</div>
);
};

View File

@@ -31,12 +31,11 @@
}
.replyIcon {
border-block-start: 2px solid var(--color-border-primary);
border-inline-start: 2px solid var(--color-border-primary);
border-block-start: var(--bw-thick) solid var(--color-border-primary);
border-inline-start: var(--bw-thick) solid var(--color-border-primary);
border-top-left-radius: var(--radius-4);
box-sizing: border-box;
margin: var(--space-2) var(--col-gap) 0 var(--space-1);
width: calc(var(--avatar-size) / 2 + 1px);
width: calc(var(--avatar-size) / 2 - 1px);
height: calc(100% - var(--space-2));
justify-self: end;
}

View File

@@ -76,6 +76,7 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
showThread,
headerContents,
variant = contextToVariant(contextType),
nextId,
}) => {
// Select data from store
const { status, parent } = useAppSelector((state) =>
@@ -96,6 +97,10 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
);
const contentWrapperId = useId();
const isNextReplyingToMe = useAppSelector(
(state) => state.statuses.getIn([nextId, 'in_reply_to_id']) === statusId,
);
// Handlers
const {
isFiltered,
@@ -147,10 +152,15 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
variant === 'page' && classes.variantPage,
isQuotedPost && classes.isQuote,
status.visibility === 'direct' && classes.isMessage,
variant === 'thread' &&
isNextReplyingToMe &&
!showThread &&
classes.connectNextReply,
)}
data-featured={featured ? 'true' : null}
aria-label={screenReaderText}
data-nosnippet={status.account.noindex || undefined}
data-connect-next={nextId ? isNextReplyingToMe : undefined}
>
{!skipPrepend && (
<StatusPrepend
@@ -210,17 +220,16 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
{(variant === 'page' || (showActions && !isQuotedPost)) && (
<footer className={classes.footer}>
{variant === 'page' && (
<StatusMeta status={status} className={classes.meta} />
)}
{showActions && !isQuotedPost && (
<StatusActionBar
statusId={status.id}
withDismiss={withDismiss}
withCounters={withCounters}
onlyInteractions={variant === 'page'}
/>
)}
{variant === 'page' && <StatusMeta status={status} />}
</footer>
)}
</StatusHotkeys>

View File

@@ -16,13 +16,13 @@
padding: var(--space-4);
}
.variantPage {
padding: var(--space-2) var(--space-4);
}
.variantThread {
--avatar-size: 32px;
padding: var(--space-2) 0;
}
.variantPage {
padding: 0;
}
@@ -70,6 +70,18 @@
grid-column: var(--content-col);
}
.connectNextReply {
&::after {
content: '';
grid-column: 1;
grid-row: 2 / span 2;
border-inline-start: var(--bw-thick) solid var(--color-border-primary);
width: calc(var(--avatar-size) / 2 - 1px);
margin-inline-end: var(--col-gap);
justify-self: end;
}
}
.translate {
display: flex;
align-items: center;
@@ -219,13 +231,21 @@
.meta {
@include mixins.type-micro;
display: flex;
gap: var(--space-1);
color: var(--color-text-secondary);
padding-block-start: var(--space-2);
margin-block-start: var(--space-2);
border-block-start: var(--border-default);
margin-inline: calc(-1 * var(--space-4));
padding-inline: var(--space-4);
a {
color: inherit;
text-decoration: none;
&:hover {
&:hover,
&:hover * {
text-decoration: underline;
}
}

View File

@@ -1,9 +1,8 @@
import type { ShouldUpdateScroll } from 'scroll-behavior';
import type { MastodonLocation } from 'mastodon/components/router';
export type ShouldUpdateScrollFn = (
prevLocationContext: MastodonLocation | null,
locationContext: MastodonLocation,
) => boolean;
export type ShouldUpdateScrollFn = ShouldUpdateScroll<MastodonLocation>;
/**
* ScrollBehavior will automatically scroll to the top on navigations

View File

@@ -23,10 +23,7 @@ export interface ScrollBehaviorContextType {
registerElement: (
key: string,
element: HTMLElement,
shouldUpdateScroll: (
prevLocationContext: MastodonLocation | null,
locationContext: MastodonLocation,
) => boolean,
shouldUpdateScroll: ShouldUpdateScrollFn,
) => void;
unregisterElement: (key: string) => void;
scrollBehavior?: ScrollBehaviorInstance;

View File

@@ -0,0 +1,18 @@
import { lazy, Suspense } from 'react';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { isRedesignStatusEnabled } from '@/mastodon/utils/environment';
const LazyStatusRedesign = lazy(() =>
import('./redesign').then(({ StatusPage }) => ({ default: StatusPage })),
);
const LazyStatusLegacy = lazy(() => import('./legacy'));
const StatusPage = () => (
<Suspense fallback={<LoadingIndicator />}>
{isRedesignStatusEnabled() ? <LazyStatusRedesign /> : <LazyStatusLegacy />}
</Suspense>
);
// eslint-disable-next-line import/no-default-export
export default StatusPage;

View File

@@ -0,0 +1,38 @@
@use '@/styles/mastodon/mixins';
.mainStatus {
border-bottom: var(--border-default);
}
.thread {
display: flex;
flex-direction: column;
border-block-end: var(--border-default);
> article {
padding-inline: var(--space-4);
padding-block-start: var(--space-2);
&:first-child,
&[data-connect-next='false'] + article {
padding-block-start: var(--space-4);
}
&[data-connect-next='false'],
&:last-child {
padding-block-end: var(--space-4);
}
&[data-connect-next='false'] {
border-block-end: var(--border-default);
}
}
}
.threadEnd {
@include mixins.type-meta;
color: var(--color-text-tertiary);
padding: var(--space-10) var(--space-4);
text-align: center;
}

View File

@@ -0,0 +1,263 @@
import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import { useParams } from 'react-router';
import { Helmet } from '@unhead/react/helmet';
import { Column } from '@/mastodon/components/column';
import {
ColumnHeader,
ColumnSettingsMenu,
} from '@/mastodon/components/column_header';
import { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import {
FOCUS_TARGET,
NavigationFocusTarget,
} from '@/mastodon/components/navigation_focus_target';
import { StatusRedesign as Status } from '@/mastodon/components/status/status';
import { ScrollContainer } from '@/mastodon/containers/scroll_container';
import type { ShouldUpdateScrollFn } from '@/mastodon/containers/scroll_container/default_should_update_scroll';
import { useExpandedStatus } from '@/mastodon/hooks/useStatus';
import {
getAncestorsIds,
getDescendantsIds,
} from '@/mastodon/selectors/contexts';
import { useAppSelector } from '@/mastodon/store';
import { BundleColumnError } from '../ui/components/bundle_column_error';
import { useColumnsContext } from '../ui/util/columns_context';
import {
attachFullscreenListener,
detachFullscreenListener,
isFullscreen,
} from '../ui/util/fullscreen';
import { RefreshController } from './components/refresh_controller';
import classes from './redesign.module.scss';
const messages = defineMessages({
revealAll: {
id: 'status.show_more_all',
defaultMessage: 'Show more for all',
},
hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
statusTitleWithAttachments: {
id: 'status.title.with_attachments',
defaultMessage:
'{user} posted {attachmentCount, plural, one {an attachment} other {# attachments}}',
},
detailedStatus: {
id: 'status.detailed_status',
defaultMessage: 'Detailed conversation view',
},
});
export const StatusPage: React.FC = () => {
const { statusId } = useParams<{ acct: string; statusId: string }>();
const { multiColumn } = useColumnsContext();
const intl = useIntl();
const [fullscreen, setFullscreen] = useState(isFullscreen);
useEffect(() => {
const handler = () => {
setFullscreen(isFullscreen());
};
attachFullscreenListener(handler);
return () => {
detachFullscreenListener(handler);
};
});
const status = useExpandedStatus(statusId, 'force');
const isLoading = useAppSelector(
(state) => !!state.statuses.getIn([statusId, 'isLoading']),
);
const ancestorIds = useAppSelector((state) =>
getAncestorsIds(state, statusId),
);
const descendantIds = useAppSelector((state) =>
getDescendantsIds(state, statusId),
);
const statusFocusRef = useRef<HTMLDivElement>(null);
const shouldUpdateScroll: ShouldUpdateScrollFn = useCallback(
(prevLocation, location) => {
// Do not change scroll when opening a modal
if (
location.state?.mastodonModalKey !==
prevLocation?.state?.mastodonModalKey
) {
return false;
}
// Scroll to focused post if it is loaded
if (statusFocusRef.current) {
return [0, statusFocusRef.current.offsetTop];
}
// Do not scroll otherwise, `componentDidUpdate` will take care of that
return false;
},
[],
);
if (isLoading) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
if (!status) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
const { account } = status;
const isLocal = !account.acct.includes('@');
const isIndexable = !account.noindex;
const columnTitle =
status.visibility === 'direct' ? (
<FormattedMessage
id='status.title.message'
defaultMessage='Message by {name}'
values={{
name: <DisplayNameSimple account={account} />,
}}
/>
) : (
<FormattedMessage
id='status.title'
defaultMessage='Post by {name}'
values={{
name: <DisplayNameSimple account={account} />,
}}
/>
);
const accountName = account.display_name || account.username;
const titleText =
!!status.search_index &&
(status.search_index.length > 30
? `${status.search_index.slice(0, 30)}…`
: status.search_index);
const pageTitle = titleText
? `${accountName}: "${titleText}"`
: intl.formatMessage(messages.statusTitleWithAttachments, {
user: accountName,
attachmentCount: status.media_attachments.length,
});
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.detailedStatus)}
>
<ColumnHeader
withBackButton
title={columnTitle}
extraButtons={
<ColumnSettingsMenu
label={
<FormattedMessage
id='status.options'
defaultMessage='Post options'
/>
}
>
WIP: This menu will contain post actions from the new Status
component
</ColumnSettingsMenu>
}
/>
<ScrollContainer
scrollKey='thread'
shouldUpdateScroll={shouldUpdateScroll}
>
<div
className={classNames({
fullscreen,
})}
>
{
// Length needs to be greater than 1, as ancestorIds includes the current root ID.
ancestorIds.length > 1 && (
<div className={classes.thread}>
<StatusRelativeList statusIds={ancestorIds} rootId={statusId} />
</div>
)
}
<NavigationFocusTarget
as='div'
focusTargetName={FOCUS_TARGET.POST}
className={classes.mainStatus}
tabIndex={0}
ref={statusFocusRef}
>
<Status id={statusId} contextType='detailed' />
</NavigationFocusTarget>
{descendantIds.length > 0 && (
<div className={classes.thread}>
<StatusRelativeList statusIds={descendantIds} rootId={statusId} />
</div>
)}
<div className={classes.threadEnd}>
<FormattedMessage
id='status.thread_end'
defaultMessage='You’ve reached the end of the conversation.'
/>
</div>
<RefreshController
isLocal={isLocal}
statusId={status.id}
statusCreatedAt={status.created_at}
/>
</div>
</ScrollContainer>
<Helmet>
<title>{pageTitle}</title>
<meta
name='robots'
content={isLocal && isIndexable ? 'all' : 'noindex'}
/>
<link rel='canonical' href={status.url ?? status.uri} />
</Helmet>
</Column>
);
};
const StatusRelativeList: React.FC<{
statusIds: string[];
rootId: string;
}> = ({ statusIds, rootId }) => {
return (
statusIds
// Omits the current post ID, but it still is in statusIds so nextId can link correctly.
.filter((statusId) => statusId !== rootId)
.map((statusId, index) => (
<Status
key={statusId}
id={statusId}
rootId={rootId}
contextType='thread'
previousId={statusIds[index - 1]}
nextId={statusIds[index + 1]}
/>
))
);
};

View File

@@ -11,31 +11,36 @@ import {
import { useAppDispatch, useAppSelector } from '../store';
type IdType = string | null | undefined;
type FetchArg = boolean | 'force';
export function useStatus(id: IdType, fetch = false) {
export function useStatus(id: IdType, fetch: FetchArg = false) {
const status = useAppSelector((state) => selectPlainStatus(state, id));
useStatusFetch(fetch && id);
useStatusFetch(fetch && id, { force: fetch === 'force' });
return status;
}
/** Gets status with full account information, fetching missing data if enabled. */
export function useAccountStatus(id: IdType, fetch = false) {
export function useAccountStatus(id: IdType, fetch: FetchArg = false) {
const status = useAppSelector((state) => selectAccountStatus(state, id));
useStatusFetch(fetch && id, { withAccount: true });
useStatusFetch(fetch && id, { withAccount: true, force: fetch === 'force' });
return status;
}
/** Adds reblog status and account information to standard Status */
export function useExpandedStatus(id: IdType, fetch = false) {
export function useExpandedStatus(id: IdType, fetch: FetchArg = false) {
const status = useAppSelector((state) =>
selectExpandedStatus(state, id ?? undefined),
);
useStatusFetch(fetch && id, { withAccount: true, withReblog: true });
useStatusFetch(fetch && id, {
withAccount: true,
withReblog: true,
force: fetch === 'force',
});
return status;
}
@@ -45,7 +50,8 @@ export function useStatusFetch(
{
withAccount,
withReblog,
}: { withAccount?: boolean; withReblog?: boolean } = {},
force: forceFetch,
}: { withAccount?: boolean; withReblog?: boolean; force?: boolean } = {},
) {
const status = useAppSelector((state) =>
selectPlainStatus(state, id || null),
@@ -63,11 +69,20 @@ export function useStatusFetch(
return;
}
if (!status) {
dispatch(fetchStatus(id));
dispatch(fetchStatus(id, { forceFetch }));
} else if (withAccount && status.account && !account) {
dispatch(fetchAccount(status.account));
} else if (withReblog && status.reblog && !reblog) {
dispatch(fetchStatus(status.reblog));
dispatch(fetchStatus(status.reblog, { forceFetch }));
}
}, [account, dispatch, id, reblog, status, withAccount, withReblog]);
}, [
account,
dispatch,
forceFetch,
id,
reblog,
status,
withAccount,
withReblog,
]);
}

View File

@@ -1391,6 +1391,7 @@
"status.header.to_followers": "{displayName} to Followers",
"status.history.created": "{name} created {date}",
"status.history.edited": "{name} edited {date}",
"status.likes_count": "{count, plural, one {{counter} like} other {{counter} likes}}",
"status.likes_title": "Post Likes",
"status.link_preview.authors": "{count, plural, one {Find the author in the Fediverse:} other {Find the authors in the Fediverse:}}",
"status.load_more": "Load more",
@@ -1398,8 +1399,6 @@
"status.media.show": "Click to show",
"status.media_hidden": "Media hidden",
"status.mention": "Mention @{name}",
"status.meta": "{createdAt} on {source} {sep} {visibility}",
"status.meta.no_application": "{createdAt} {sep} {visibility}",
"status.more": "More",
"status.mute": "Mute @{name}",
"status.mute_conversation": "Mute conversation",
@@ -1444,6 +1443,7 @@
"status.remove_quote": "Remove",
"status.replied_in_thread": "Replied in thread",
"status.replied_to": "Replied to {name}",
"status.replies_count": "{count, plural, one {{counter} reply} other {{counter} replies}}",
"status.reply": "Reply",
"status.replyAll": "Reply to thread",
"status.report": "Report @{name}",
@@ -1454,6 +1454,7 @@
"status.show_less_all": "Show less for all",
"status.show_more_all": "Show more for all",
"status.show_original": "Show original",
"status.thread_end": "You’ve reached the end of the conversation.",
"status.title": "Post by {name}",
"status.title.message": "Message by {name}",
"status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}",

View File

@@ -1,8 +1,11 @@
import { createAppSelector } from 'mastodon/store';
export const getAncestorsIds = createAppSelector(
[(_, id: string) => id, (state) => state.contexts.inReplyTos],
[(_, id?: string) => id, (state) => state.contexts.inReplyTos],
(statusId, inReplyTos) => {
if (!statusId) {
return [];
}
const ancestorsIds: string[] = [];
let currentId: string | undefined = statusId;

View File

@@ -21,7 +21,8 @@ export const getStatusList = createAppSelector(
export const selectPlainStatus = createAppSelector(
[(state, statusId?: string | null) => state.statuses.get(statusId ?? '')],
(status) => {
if (!status) {
// Check for statuses that are just `{isLoading: true}`.
if (!status?.get('id')) {
return null;
}
return status.toJS() as unknown as StatusShape;