Redesign: Status base (#40378)

Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
Echo
2026-09-04 10:53:25 +00:00
committed by GitHub
parent 8e9a176054
commit f68f36c495
11 changed files with 434 additions and 327 deletions

View File

@@ -17,7 +17,7 @@ const messages = defineMessages({
export function useAccountHandle(
account: DisplayNameProps['account'],
localDomain: DisplayNameProps['localDomain'],
localDomain?: DisplayNameProps['localDomain'],
) {
const intl = useIntl();

View File

@@ -294,8 +294,8 @@ class Status extends ImmutablePureComponent {
};
_openStatus = (newTab = false) => {
if (this.props.onClick) {
this.props.onClick();
if (this.props.onOpen) {
this.props.onOpen();
return;
}

View File

@@ -1,7 +1,16 @@
import type React from 'react';
import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import {
ArrowsClockwiseIcon,
BookmarkSimpleIcon,
ChatCircleTextIcon,
DotsThreeIcon,
HeartIcon,
ShareFatIcon,
} from '@phosphor-icons/react';
import {
muteAccount,
@@ -40,20 +49,13 @@ import {
} from '@/mastodon/selectors/statuses';
import type { AppDispatch } from '@/mastodon/store';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
import { Button, IconButton } from '../button/redesign';
import { Dropdown } from '../dropdown_menu';
import { IconButton } from '../icon_button';
import { RemoveQuoteHint } from '../status_action_bar/remove_quote_hint';
import { BoostButton } from './boost_button';
import { quoteItemState } from './boost_button_utils';
import classes from './styles.module.scss';
import type { StatusContextType } from './types';
interface StatusActionBarProps {
@@ -74,18 +76,12 @@ const messages = defineMessages({
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
share: { id: 'status.share', defaultMessage: 'Share' },
more: { id: 'status.more', defaultMessage: 'More' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
removeFavourite: {
id: 'status.remove_favourite',
defaultMessage: 'Remove from favorites',
},
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: {
id: 'status.remove_bookmark',
defaultMessage: 'Remove bookmark',
},
open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation: {
@@ -150,11 +146,32 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
state.statuses.getIn([status?.quote?.quoted_status, 'account']) ?? null,
);
const currentAccountId = useCurrentAccountId();
const statusUrl = status?.url ?? status?.uri;
// Actions
const dispatch = useAppDispatch();
const handleReplyClick = useCallback(() => {
dispatch(statusInteraction({ statusId, intent: 'reply' }));
}, [dispatch, statusId]);
const handleBoostClick = useCallback(() => {
dispatch(statusInteraction({ statusId, intent: 'reblog' }));
}, [dispatch, statusId]);
const handleShareClick = useCallback(() => {
if (!statusUrl) {
return;
}
// We need to make this partial as by default share always is set, despite not being supported in FF.
const nav = navigator as Partial<Pick<Navigator, 'share'>> &
Pick<Navigator, 'clipboard'>;
if (nav.share) {
void nav.share({
url: statusUrl,
});
} else {
void nav.clipboard.writeText(statusUrl);
}
}, [statusUrl]);
const handleFavouriteClick = useCallback(() => {
dispatch(statusInteraction({ statusId, intent: 'favourite' }));
}, [dispatch, statusId]);
@@ -168,17 +185,14 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
return null;
}
const isPublic =
status.visibility === 'public' || status.visibility === 'unlisted';
const isReply =
!status.in_reply_to_id || status.in_reply_to_account_id === status.account;
const replyTitle = isReply
? intl.formatMessage(messages.reply)
: intl.formatMessage(messages.replyAll);
const replyIcon = isReply ? 'reply' : 'reply-all';
const replyIconComponent = isReply ? ReplyIcon : ReplyAllIcon;
const bookmarkTitle = intl.formatMessage(
status.bookmarked ? messages.removeBookmark : messages.bookmark,
);
const favouriteTitle = intl.formatMessage(
status.favourited ? messages.removeFavourite : messages.favourite,
);
@@ -188,43 +202,64 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
isQuotingMe && contextType === 'notifications';
return (
<div className='status__action-bar'>
<div className='status__action-bar__button-wrapper'>
<div className={classes.actions}>
<Button
size='sm'
variant='ghost'
title={replyTitle}
leadingIcon={ChatCircleTextIcon}
onClick={handleReplyClick}
>
{withCounters && status.replies_count}
</Button>
<Button
size='sm'
variant='ghost'
leadingIcon={ArrowsClockwiseIcon}
onClick={handleBoostClick}
>
{withCounters && status.reblogs_count}
</Button>
<Button
size='sm'
variant='ghost'
title={favouriteTitle}
leadingIcon={HeartIcon}
onClick={handleFavouriteClick}
className={classes.actionsButtonGap}
>
{withCounters && status.favourites_count}
</Button>
{isPublic && (
<IconButton
className='status__action-bar__button'
title={replyTitle}
icon={replyIcon}
iconComponent={replyIconComponent}
onClick={handleReplyClick}
counter={status.replies_count}
/>
</div>
<div className='status__action-bar__button-wrapper'>
<BoostButton statusId={statusId} counters={withCounters} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton
className='status__action-bar__button star-icon'
animate
active={status.favourited}
title={favouriteTitle}
icon='star'
iconComponent={status.favourited ? StarIcon : StarBorderIcon}
onClick={handleFavouriteClick}
counter={withCounters ? status.favourites_count : undefined}
/>
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton
className='status__action-bar__button bookmark-icon'
disabled={!currentAccountId}
active={status.bookmarked}
title={bookmarkTitle}
icon='bookmark'
iconComponent={status.bookmarked ? BookmarkIcon : BookmarkBorderIcon}
onClick={handleBookmarkClick}
/>
</div>
size='sm'
variant='ghost'
icon={ShareFatIcon}
onClick={handleShareClick}
>
<FormattedMessage id='status.share' defaultMessage='Share' />
</IconButton>
)}
<IconButton
size='sm'
variant='ghost'
icon={BookmarkSimpleIcon}
onClick={handleBookmarkClick}
>
{!status.bookmarked ? (
<FormattedMessage id='status.bookmark' defaultMessage='Bookmark' />
) : (
<FormattedMessage
id='status.remove_bookmark'
defaultMessage='Remove bookmark'
/>
)}
</IconButton>
<RemoveQuoteHint
className='status__action-bar__button-wrapper'
canShowHint={shouldShowQuoteRemovalHint}
@@ -314,12 +349,9 @@ const StatusActionMenu: React.FC<{
return (
<Dropdown scrollKey={scrollKey} items={menu} onOpen={handleOpen}>
<IconButton
className='status__action-bar__button'
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
title={intl.formatMessage(messages.more)}
/>
<IconButton size='sm' variant='ghost' icon={DotsThreeIcon}>
<FormattedMessage id='status.more' defaultMessage='More' />
</IconButton>
</Dropdown>
);
};

View File

@@ -39,11 +39,11 @@ const messages = defineMessages({
export function useStatusHandlers({
status,
contextType,
onClick,
onOpen,
}: {
status?: ExpandedStatusShape;
contextType?: StatusContextType;
onClick?: () => void;
onOpen?: () => void;
}) {
const matchedFilters = useAppSelector((state) =>
selectStatusFilters(state, { contextType, statusId: status?.id }),
@@ -95,10 +95,10 @@ export function useStatusHandlers({
// Navigation handlers
const history = useHistory();
const onOpen = useCallback(
const onOpenCallback = useCallback(
(newTab = false) => {
if (onClick || !status) {
onClick?.();
if (onOpen || !status) {
onOpen?.();
return;
}
@@ -112,7 +112,7 @@ export function useStatusHandlers({
history.push(path, { focusTarget: FOCUS_TARGET.POST });
}
},
[history, onClick, status],
[history, onOpen, status],
);
const onOpenClick: React.MouseEventHandler = useCallback(
@@ -120,15 +120,15 @@ export function useStatusHandlers({
event.preventDefault();
if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
onOpen();
onOpenCallback();
} else if (
event.button === 1 ||
(event.button === 0 && (event.ctrlKey || event.metaKey))
) {
onOpen(true);
onOpenCallback(true);
}
},
[onOpen],
[onOpenCallback],
);
const onHeaderClick: React.MouseEventHandler = useCallback(
@@ -192,7 +192,9 @@ export function useStatusHandlers({
onFilterToggle,
onHeaderClick,
onMention,
onOpen,
onOpen: () => {
onOpenCallback();
},
onOpenMedia,
onOpenProfile,
onToggleHidden,
@@ -208,7 +210,7 @@ export function useStatusHandlers({
onFilterToggle,
onHeaderClick,
onMention,
onOpen,
onOpenCallback,
onOpenClick,
onOpenMedia,
onOpenProfile,

View File

@@ -0,0 +1,86 @@
import { useId } from 'react';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { AccountStatusShape } from '@/mastodon/models/status';
import { Avatar } from '../../avatar';
import { DisplayName } from '../../display_name';
import { useAccountHandle } from '../../display_name/default';
import { RelativeTimestamp } from '../../relative_timestamp';
import { Skeleton } from '../../skeleton';
import { statusLink } from '../utils';
import classes from './styles.module.scss';
interface StatusRedesignHeaderProps {
status: Pick<AccountStatusShape, 'id' | 'account' | 'created_at'>;
children?: React.ReactNode;
className?: string;
avatarSize?: number;
}
export const StatusRedesignHeader: React.FC<StatusRedesignHeaderProps> = ({
status,
children,
className,
avatarSize = 40,
}) => {
const account = status.account;
const handle = useAccountHandle(account);
const handleId = useId();
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 (
<header className={classNames(className, classes.header)}>
<Link {...accountLinkProps} role='presentation' tabIndex={-1}>
<Avatar account={account} size={avatarSize} />
</Link>
<div className={classes.headerNameWrapper}>
<p className={classes.headerName}>
<Link
{...accountLinkProps}
className={classes.headerNameLink}
aria-describedby={handleId}
>
<DisplayName account={account} variant='noDomain' />
</Link>
&bull;
<Link
to={{
pathname: statusLink(status),
state: { reference: 'status' },
}}
>
<RelativeTimestamp timestamp={status.created_at} />
</Link>
</p>
<p className={classes.headerHandle}>
<Link
{...accountLinkProps}
role='presentation'
tabIndex={-1}
id={handleId}
>
{handle ?? <Skeleton width='7ch' />}
</Link>
</p>
</div>
{children}
</header>
);
};

View File

@@ -0,0 +1,50 @@
@use '@/styles/mastodon/mixins';
// Header
.header {
display: flex;
align-items: center;
gap: var(--space-xs);
color: var(--color-text-secondary);
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;
}
}
.headerNameWrapper {
flex-grow: 1;
}
.headerName {
@include mixins.type-body;
display: flex;
gap: var(--space-2xs);
> .headerNameLink {
@include mixins.type-body-strong;
color: var(--color-text-primary);
// Overrides .display-name__html
strong {
font-weight: inherit;
}
}
}
.headerHandle {
@include mixins.type-label-md;
}

View File

@@ -6,7 +6,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { useAppSelector } from '@/mastodon/store';
import {
accountFactoryImmutable,
pollFactoryImmutable,
@@ -14,10 +13,10 @@ import {
statusFactoryImmutable,
} from '@/testing/factories';
import { StatusRedesign } from './status';
import type { AttachmentArgs } from './testing';
import { attachmentArgTypes, attachmentFactory } from './testing';
import type { StatusContextType } from './types';
import { TypedStatus } from './types';
interface StatusStoryProps extends AttachmentArgs {
// Contents
@@ -60,12 +59,10 @@ const StatusStoryComponent: FC<StatusStoryProps> = (props) => {
const {
isReblog,
isReply,
isPoll,
isQuote,
contentWarning,
hasFilter,
hasVoted,
disableActions = false,
contextType,
@@ -75,53 +72,25 @@ const StatusStoryComponent: FC<StatusStoryProps> = (props) => {
muted,
showPrepend = true,
} = props;
const account = useAppSelector((state) => state.accounts.get('1'));
const status = useAppSelector((state) =>
state.statuses.get('1')?.withMutations((status) => {
status.set('account', account);
status.set('matched_filters', hasFilter ? ['test'] : false);
status.set('matched_media_filters', hasFilter ? ['test'] : false);
status.set('hidden', hidden);
// StatusActionBar checks specifically for null so undefined doesn't work.
if (!status.get('in_reply_to_id')) {
status.set('in_reply_to_id', null);
}
if (isReblog) {
status.set(
'reblog',
statusFactoryImmutable({ id: '2' }).set('account', otherAccount),
);
}
if (isPoll) {
status.set('poll', hasVoted ? '2' : '1');
}
}),
);
return (
<div style={{ width: 'min(600px, 80vw)' }}>
<TypedStatus
{...staticProps}
key={JSON.stringify(props)} // Update on any props change. Required because Status has updateOnProps set.
status={status}
account={isReblog ? account : undefined}
isQuotedPost={isQuote}
showActions={!disableActions}
contextType={contextType}
withCounters={showCounters}
// Either we are showing a thread (in a timeline) or it's a full reply chain view.
showThread={isReply && showThread}
previousId={isReply && !showThread ? '2' : undefined}
rootId={isReply && !showThread ? '2' : undefined}
nextInReplyToId={isReply && !showThread ? '1' : undefined}
muted={muted}
hidden={hidden && !contentWarning && !hasFilter}
skipPrepend={!showPrepend}
withDismiss={contextType === 'notifications'}
/>
</div>
<StatusRedesign
{...staticProps}
id='1'
accountId={isReblog ? '1' : undefined}
isQuotedPost={isQuote}
showActions={!disableActions}
contextType={contextType}
withCounters={showCounters}
// Either we are showing a thread (in a timeline) or it's a full reply chain view.
showThread={isReply && showThread}
previousId={isReply && !showThread ? '2' : undefined}
rootId={isReply && !showThread ? '2' : undefined}
nextInReplyToId={isReply && !showThread ? '1' : undefined}
muted={muted}
hidden={hidden && !contentWarning && !hasFilter}
skipPrepend={!showPrepend}
withDismiss={contextType === 'notifications'}
/>
);
};
@@ -181,7 +150,7 @@ const categoryDisplay = {
} as const;
const meta = {
title: 'Components/Status/Status',
title: 'Redesign/Status',
component: StatusStoryComponent,
argTypes: {
// Contents
@@ -370,7 +339,15 @@ const meta = {
controls: {
disableSaveFromUI: true,
},
redesign: true,
},
decorators: [
(Story) => (
<div style={{ width: 'min(600px, 80vw)' }}>
<Story />
</div>
),
],
} satisfies Meta<typeof StatusStoryComponent>;
export default meta;
@@ -392,6 +369,21 @@ export const LongText: Story = {
'It is here to test what a longer status looks like.',
'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
'Curabitur pretium tincidunt lacus, nulla gravida orci a odio.',
'Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris.',
'Integer in mauris eu nibh euismod gravida, duis ac tellus et risus vulputate vehicula.',
'Donec lobortis risus a elit, etiam tempor.',
'Vestibulum commodo volutpat a, convallis ac, laoreet enim.',
'Phasellus fermentum in, dolor pellentesque facilisis.',
'Integer rutrum, orci vestibulum ullamcorper ultricies, lacus quam ultricies odio, vitae placerat pede sem sit amet enim.',
'Morbi purus libero, faucibus adipiscing, commodo quis, gravida id, est.',
'Sed lectus, suspendisse varius enim in eros elementum tristique.',
'Duis cursus, mi quis viverra ornare, eros dolor interdum nulla, ut commodo diam libero vitae erat.',
'Aenean faucibus nibh et justo cursus id rutrum lorem imperdiet.',
'Nunc ut sem vitae risus tristique posuere.',
].join('\n'),
},
};

View File

@@ -1,11 +1,9 @@
import type React from 'react';
import { useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import classNames from 'classnames';
import type { Merge } from 'type-fest';
import { selectPlainAccount } from '@/mastodon/selectors/accounts';
import { selectStatusFilters } from '@/mastodon/selectors/filters';
import { selectExpandedStatus } from '@/mastodon/selectors/statuses';
import { createAppSelector, useAppSelector } from '@/mastodon/store';
@@ -18,10 +16,11 @@ import { Hotkeys } from '../hotkeys';
import { StatusActionBar } from './action_bar';
import { StatusAttachments } from './attachments';
import { StatusContent } from './content';
import { StatusHeader } from './header';
import type { StatusHandlers } from './hooks';
import { useStatusHandlers, useTextForScreenReader } from './hooks';
import { StatusPrepend } from './prepend';
import { StatusRedesignHeader } from './redesign/header';
import classes from './styles.module.scss';
import type { StatusContainerProps, StatusContextType } from './types';
type StatusRedesignProps = Merge<
@@ -29,7 +28,7 @@ type StatusRedesignProps = Merge<
{
accountId?: string;
contextType?: StatusContextType;
onClick?: () => void;
headerContents?: React.ReactNode;
}
>;
@@ -55,35 +54,27 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
id,
muted,
rootId,
previousId,
nextId,
unread,
skipPrepend,
unfocusable,
contextType,
featured,
isQuotedPost,
accountId,
hidden,
shouldHighlightOnMount,
showActions,
showActions = true,
scrollKey,
children,
headerRenderFn,
avatarSize,
avatarSize = 40,
withCounters,
withDismiss,
onClick,
onOpen,
showThread,
headerContents,
}) => {
// Select data from store
const { status, parent } = useAppSelector((state) =>
selectStatusReblog(state, id),
);
const account = useAppSelector(
(state) =>
parent?.account ?? selectPlainAccount(state, accountId) ?? undefined,
);
const matchedFilters = useAppSelector((state) =>
selectStatusFilters(state, { contextType, statusId: parent?.id ?? id }),
);
@@ -110,7 +101,7 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
onOpenClick,
onTranslate,
...handlers
} = useStatusHandlers({ status, contextType, onClick });
} = useStatusHandlers({ status, contextType, onOpen });
if (!status) {
return null; // loading state
@@ -123,172 +114,146 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
(!status.hidden || !status.spoiler_text);
const hotkeysProps = {
...handlers,
onTranslate,
handlers: {
...handlers,
onTranslate,
},
muted,
unfocusable,
} satisfies Omit<React.ComponentProps<typeof StatusHotkeys>, 'children'>;
'data-id': id,
};
if (hidden) {
return (
<StatusHotkeys {...hotkeysProps}>
<div
className={classNames('status__wrapper', { focusable: !muted })}
tabIndex={unfocusable ? undefined : 0}
>
<span>{status.account.display_name || status.account.username}</span>
{status.spoiler_text && <span>{status.spoiler_text}</span>}
{expanded && <span>{status.content}</span>}
</div>
<StatusHotkeys
{...hotkeysProps}
className={classNames('status__wrapper', { focusable: !muted })}
>
<span>{status.account.display_name || status.account.username}</span>
{status.spoiler_text && <span>{status.spoiler_text}</span>}
{expanded && <span>{status.content}</span>}
</StatusHotkeys>
);
}
const header = headerRenderFn ? (
headerRenderFn({
statusId: status.id,
account,
avatarSize,
onHeaderClick,
featured,
})
) : (
<StatusHeader
statusId={status.id}
account={account}
avatarSize={avatarSize}
onHeaderClick={onHeaderClick}
/>
);
return (
<StatusHotkeys {...hotkeysProps}>
<div
className={classNames(
'status__wrapper',
`status__wrapper-${status.visibility}`,
{
'status__wrapper-reply': !!status.in_reply_to_id,
'status__wrapper--in-thread': !!rootId,
unread,
focusable: !muted,
},
)}
tabIndex={muted || unfocusable ? undefined : 0}
data-featured={featured ? 'true' : null}
aria-label={screenReaderText}
data-nosnippet={status.account.noindex || undefined}
>
{!skipPrepend && (
<StatusPrepend
status={actualStatus}
isReblog={!!parent}
showThread={showThread}
/>
)}
<StatusContentWrapper
<StatusHotkeys
{...hotkeysProps}
className={classNames(
classes.root,
'status__wrapper',
`status__wrapper-${status.visibility}`,
{
'status__wrapper-reply': !!status.in_reply_to_id,
'status__wrapper--in-thread': !!rootId,
unread,
focusable: !muted,
},
)}
data-featured={featured ? 'true' : null}
aria-label={screenReaderText}
data-nosnippet={status.account.noindex || undefined}
>
{!skipPrepend && (
<StatusPrepend
status={actualStatus}
isReblog={!!parent}
showThread={showThread}
/>
)}
<StatusRedesignHeader status={status} avatarSize={avatarSize}>
{headerContents}
</StatusRedesignHeader>
{matchedFilters.length > 0 && (
<FilterWarning
title={matchedFilters.map((filter) => filter.title).join(', ')}
expanded={showDespiteFilter}
onClick={onFilterToggle}
/>
)}
{(matchedFilters.length === 0 || showDespiteFilter) && (
<ContentWarning
statusId={status.id}
inReplyToId={actualStatus.in_reply_to_id}
rootId={rootId}
nextId={nextId}
previousId={previousId}
className={classNames(`status-${status.visibility}`, {
muted,
'status--is-quote': isQuotedPost,
'status--has-quote': !!status.quote,
'status--highlighted-entry': shouldHighlightOnMount,
})}
>
{header}
expanded={expanded}
onClick={onExpandedToggle}
/>
)}
{matchedFilters.length > 0 && (
<FilterWarning
title={matchedFilters.map((filter) => filter.title).join(', ')}
expanded={showDespiteFilter}
onClick={onFilterToggle}
{expanded && (
<>
<StatusContent
statusId={status.id}
statusContent={statusContent}
onClick={onOpenClick}
onTranslate={onTranslate}
collapsible
/>
<StatusAttachments statusId={status.id} contextType={contextType} />
{hashtagsInBar && (
<HashtagBar
hashtags={hashtagsInBar}
accountId={status.account.id}
/>
)}
{(matchedFilters.length === 0 || showDespiteFilter) && (
<ContentWarning
statusId={status.id}
expanded={expanded}
onClick={onExpandedToggle}
/>
)}
{children}
</>
)}
{expanded && (
<>
<StatusContent
statusId={status.id}
statusContent={statusContent}
onClick={onOpenClick}
onTranslate={onTranslate}
collapsible
/>
<StatusAttachments
statusId={status.id}
contextType={contextType}
/>
{hashtagsInBar && (
<HashtagBar
hashtags={hashtagsInBar}
accountId={status.account.id}
/>
)}
{children}
</>
)}
{showActions && !isQuotedPost && (
<StatusActionBar
scrollKey={scrollKey}
statusId={status.id}
contextType={contextType}
withDismiss={withDismiss}
withCounters={withCounters}
/>
)}
</StatusContentWrapper>
</div>
{showActions && !isQuotedPost && (
<StatusActionBar
scrollKey={scrollKey}
statusId={status.id}
contextType={contextType}
withDismiss={withDismiss}
withCounters={withCounters}
/>
)}
</StatusHotkeys>
);
};
const StatusHotkeys: React.FC<
{
muted?: boolean;
unfocusable?: boolean;
children: React.ReactNode;
} & Omit<
interface StatusHotkeysProps {
muted?: boolean;
unfocusable?: boolean;
children: React.ReactNode;
handlers: Omit<
StatusHandlers,
| 'showDespiteFilter'
| 'onOpenClick'
| 'onHeaderClick'
| 'onExpandedToggle'
| 'onFilterToggle'
>
> = ({ muted, unfocusable, children, ...handlers }) => {
const onOpen = useCallback(() => {
handlers.onOpen();
}, [handlers]);
>;
}
const StatusHotkeys = ({
muted,
unfocusable,
children,
handlers,
...props
}: StatusHotkeysProps & React.ComponentPropsWithoutRef<'article'>) => {
if (muted) {
return children;
return <article {...props}>{children}</article>;
}
return (
<Hotkeys
{...props}
as='article'
handlers={{
reply: handlers.onReply,
favourite: handlers.onFavourite,
boost: handlers.onBoost,
quote: handlers.onQuote,
mention: handlers.onMention,
open: onOpen,
open: handlers.onOpen,
openProfile: handlers.onOpenProfile,
toggleHidden: handlers.onToggleHidden,
// TODO: This is handled in a child component, so needs to be fixed.
@@ -302,52 +267,3 @@ const StatusHotkeys: React.FC<
</Hotkeys>
);
};
const StatusContentWrapper: React.FC<
Pick<StatusRedesignProps, 'rootId' | 'previousId' | 'nextId' | 'children'> & {
statusId: string;
inReplyToId?: string;
className?: string;
}
> = ({
statusId,
inReplyToId,
rootId,
previousId,
nextId,
className,
children,
}) => {
const nextInReplyToId = useAppSelector((state) =>
nextId ? state.statuses.getIn([nextId, 'in_reply_to_id']) : null,
);
const connectUp = !!previousId && previousId === inReplyToId;
const connectToRoot = !!rootId && rootId === inReplyToId;
const connectReply = !!nextInReplyToId && nextInReplyToId === statusId;
return (
<div
className={classNames(
'status',
{
'status-reply': !!inReplyToId,
'status--in-thread': !!rootId,
'status--first-in-thread':
previousId && (!connectUp || connectToRoot),
},
className,
)}
data-id={statusId}
>
{(connectReply || connectUp || connectToRoot) && (
<div
className={classNames('status__line', {
'status__line--full': connectReply,
'status__line--first': !inReplyToId && !connectToRoot,
})}
/>
)}
{children}
</div>
);
};

View File

@@ -0,0 +1,18 @@
.root {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.actions {
display: flex;
margin-inline-start: calc(-1 * var(--space-sm));
button:not(:hover, :active) {
opacity: 0.7;
}
}
.actionsButtonGap {
margin-inline-end: auto;
}

View File

@@ -1,12 +1,14 @@
import type { ComponentType, MouseEventHandler, ReactNode } from 'react';
import type { ComponentType, ReactNode } from 'react';
import StatusContainer from '@/mastodon/containers/status_container';
import type { Account as TAccount } from '@/mastodon/models/account';
import type { Status as TStatus } from '@/mastodon/models/status';
import { isRedesignEnabled } from '@/mastodon/utils/environment';
import Status from '../status';
import type { StatusHeaderRenderFn } from './header';
import { StatusRedesign } from './status';
export type StatusContextType =
| 'account'
@@ -28,7 +30,7 @@ export interface StatusContainerProps {
rootId?: string;
previousId?: string;
nextId?: string;
onClick?: MouseEventHandler<HTMLDivElement>;
onOpen?: () => void;
muted?: boolean;
hidden?: boolean;
unread?: boolean;
@@ -51,8 +53,9 @@ export interface StatusContainerProps {
withDismiss?: boolean;
}
export const TypedStatusContainer =
StatusContainer as ComponentType<StatusContainerProps>;
export const TypedStatusContainer = isRedesignEnabled()
? StatusRedesign
: (StatusContainer as ComponentType<StatusContainerProps>);
// Taken from the Status component.
export interface StatusProps extends Omit<StatusContainerProps, 'nextId'> {

View File

@@ -0,0 +1,8 @@
import type { AccountStatusShape, StatusShape } from '@/mastodon/models/status';
export function statusLink({
account,
id,
}: Pick<StatusShape | AccountStatusShape, 'account' | 'id'>) {
return `/@${typeof account === 'string' ? account : account.acct}/${id}`;
}