mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-12 16:35:57 -05:00
Redesign: Status base (#40378)
Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
@@ -17,7 +17,7 @@ const messages = defineMessages({
|
||||
|
||||
export function useAccountHandle(
|
||||
account: DisplayNameProps['account'],
|
||||
localDomain: DisplayNameProps['localDomain'],
|
||||
localDomain?: DisplayNameProps['localDomain'],
|
||||
) {
|
||||
const intl = useIntl();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
•
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
18
app/javascript/mastodon/components/status/styles.module.scss
Normal file
18
app/javascript/mastodon/components/status/styles.module.scss
Normal 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;
|
||||
}
|
||||
@@ -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'> {
|
||||
|
||||
8
app/javascript/mastodon/components/status/utils.ts
Normal file
8
app/javascript/mastodon/components/status/utils.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user