mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-12 02:46:45 -05:00
Status redesign: Actions menu and interactions (#40442)
This commit is contained in:
@@ -18,7 +18,7 @@ import { deleteModal } from '../initial_state';
|
||||
import { selectStatusInteractions } from '../selectors/statuses';
|
||||
|
||||
import { showAlert, showGenericAlert } from './alerts';
|
||||
import { replyCompose } from './compose';
|
||||
import { replyComposeById } from './compose';
|
||||
import { quoteComposeById } from './compose_typed';
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
import {
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
|
||||
export type StatusInteractionIntent =
|
||||
| 'bookmark'
|
||||
| 'copy'
|
||||
| 'delete'
|
||||
| 'editQuotePolicy'
|
||||
| 'edit'
|
||||
@@ -59,6 +60,10 @@ export type StatusInteractionIntent =
|
||||
| 'translate';
|
||||
|
||||
const messages = defineMessages({
|
||||
copied: {
|
||||
id: 'status.copied',
|
||||
defaultMessage: 'Copied post link to clipboard',
|
||||
},
|
||||
noEdits: {
|
||||
id: 'status.cannot_edit',
|
||||
defaultMessage: 'You are not allowed to edit this post',
|
||||
@@ -136,6 +141,13 @@ export const statusInteraction = createAppThunk(
|
||||
dispatch(bookmark(statusImmutable));
|
||||
}
|
||||
return;
|
||||
case 'copy':
|
||||
void navigator.clipboard
|
||||
.writeText(status.url ?? status.uri)
|
||||
.then(() => {
|
||||
dispatch(showAlert({ message: messages.copied }));
|
||||
});
|
||||
return;
|
||||
case 'delete':
|
||||
if (!deleteModal) {
|
||||
void dispatch(deleteStatus(statusId));
|
||||
@@ -238,7 +250,7 @@ export const statusInteraction = createAppThunk(
|
||||
}
|
||||
return;
|
||||
case 'reply':
|
||||
dispatch(replyCompose(statusImmutable));
|
||||
dispatch(replyComposeById(statusId));
|
||||
return;
|
||||
case 'report':
|
||||
dispatch(
|
||||
|
||||
@@ -176,6 +176,34 @@ a.base {
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle buttons
|
||||
.toggle {
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.solid {
|
||||
@include active {
|
||||
--bg: var(--color-bg-brand-base);
|
||||
--fg: var(--color-text-on-brand-base);
|
||||
}
|
||||
}
|
||||
|
||||
&.tonal {
|
||||
@include active {
|
||||
--bg: rgb(from var(--color-bg-brand-base) r g b / 10%);
|
||||
--fg: var(--color-text-brand);
|
||||
}
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
@include active {
|
||||
--bg: transparent;
|
||||
--fg: var(--color-text-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper classes
|
||||
|
||||
.icon,
|
||||
@@ -197,21 +225,3 @@ a.base {
|
||||
.loading {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
// Toggle tonal buttons by default show the neutral color, but when pressed show the brand color.
|
||||
.toggle {
|
||||
&.tonal {
|
||||
@include active {
|
||||
--bg: rgb(from var(--color-bg-brand-base) r g b / 10%);
|
||||
--fg: var(--color-text-brand);
|
||||
}
|
||||
}
|
||||
|
||||
&.solid,
|
||||
&.ghost {
|
||||
@include active {
|
||||
--bg: var(--color-bg-brand-base);
|
||||
--fg: var(--color-text-on-brand-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import ChatIcon from '@/material-icons/400-24px/chat.svg?react';
|
||||
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
|
||||
import HeadphonesIcon from '@/material-icons/400-24px/headphones.svg?react';
|
||||
|
||||
import { Button, IconButton, ToggleButton } from './redesign';
|
||||
import { Button, IconButton, ToggleButton, ToggleIconButton } from './redesign';
|
||||
|
||||
const iconArgType = {
|
||||
control: 'select',
|
||||
@@ -109,3 +109,17 @@ export const Toggle: Story = {
|
||||
return <ToggleButton {...args} active={active} onClick={onToggle} />;
|
||||
},
|
||||
};
|
||||
|
||||
export const ToggleIcon: Story = {
|
||||
render(args) {
|
||||
const [active, { onToggle }] = useToggle();
|
||||
return (
|
||||
<ToggleIconButton
|
||||
{...args}
|
||||
active={active}
|
||||
onClick={onToggle}
|
||||
icon={args.leadingIcon ?? ChatIcon}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -176,3 +176,15 @@ export const ToggleButton: React.FC<ButtonProps & { active?: boolean }> = ({
|
||||
className={classNames(className, classes.toggle)}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ToggleIconButton: React.FC<
|
||||
IconButtonProps & { active?: boolean }
|
||||
> = ({ active, className, ...props }) => (
|
||||
<IconButton
|
||||
aria-pressed={active}
|
||||
{...props}
|
||||
// Toggle buttons always have neutral until pressed.
|
||||
color='neutral'
|
||||
className={classNames(className, classes.toggle)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -266,7 +266,7 @@ export type MenuListProps<As extends React.ElementType> = Omit<
|
||||
'isOpen' | 'onClose' | 'reference' | 'popoverElement'
|
||||
>;
|
||||
|
||||
export const MenuList = <As extends React.ElementType>({
|
||||
export const MenuList = <As extends React.ElementType = 'div'>({
|
||||
children,
|
||||
...props
|
||||
}: MenuListProps<As>) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ChatCircleTextIcon,
|
||||
DotsThreeIcon,
|
||||
HeartIcon,
|
||||
QuotesIcon,
|
||||
ShareFatIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
@@ -34,7 +35,7 @@ import { useStatus } from '@/mastodon/hooks/useStatus';
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import { quickBoosting } from '@/mastodon/initial_state';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import type { MenuItem } from '@/mastodon/models/dropdown_menu';
|
||||
import type { MenuItem as DropdownItem } from '@/mastodon/models/dropdown_menu';
|
||||
import type { Relationship } from '@/mastodon/models/relationship';
|
||||
import type { StatusShape } from '@/mastodon/models/status';
|
||||
import {
|
||||
@@ -52,20 +53,30 @@ import {
|
||||
import type { AppDispatch } from '@/mastodon/store';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { Button, IconButton } from '../button/redesign';
|
||||
import { Dropdown } from '../dropdown_menu';
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
ToggleButton,
|
||||
ToggleIconButton,
|
||||
} from '../button/redesign';
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuItemDivider,
|
||||
MenuItemLink,
|
||||
MenuList,
|
||||
MenuTrigger,
|
||||
} from '../menu';
|
||||
import { RemoveQuoteHint } from '../status_action_bar/remove_quote_hint';
|
||||
|
||||
import { quoteItemState } from './boost_button_utils';
|
||||
import { boostItemState, quoteItemState } from './boost_button_utils';
|
||||
import { useStatusContext } from './hooks';
|
||||
import classes from './styles.module.scss';
|
||||
import type { StatusContextType } from './types';
|
||||
|
||||
interface StatusActionBarProps {
|
||||
statusId: string;
|
||||
contextType?: StatusContextType;
|
||||
withDismiss?: boolean;
|
||||
withCounters?: boolean;
|
||||
scrollKey?: string;
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -137,10 +148,8 @@ const messages = defineMessages({
|
||||
|
||||
export const StatusActionBar: React.FC<StatusActionBarProps> = ({
|
||||
statusId,
|
||||
contextType,
|
||||
withDismiss,
|
||||
withCounters,
|
||||
scrollKey,
|
||||
}) => {
|
||||
const status = useStatus(statusId);
|
||||
const quotedAccountId = useAppSelector(
|
||||
@@ -148,16 +157,17 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
|
||||
state.statuses.getIn([status?.quote?.quoted_status, 'account']) ?? null,
|
||||
);
|
||||
const currentAccountId = useCurrentAccountId();
|
||||
const { contextType } = useStatusContext();
|
||||
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]);
|
||||
dispatch(statusInteraction({ statusId, intent: 'reply', contextType }));
|
||||
}, [contextType, dispatch, statusId]);
|
||||
const handleFavouriteClick = useCallback(() => {
|
||||
dispatch(statusInteraction({ statusId, intent: 'favourite', contextType }));
|
||||
}, [contextType, dispatch, statusId]);
|
||||
const handleShareClick = useCallback(() => {
|
||||
if (!statusUrl) {
|
||||
return;
|
||||
@@ -171,29 +181,40 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
|
||||
url: statusUrl,
|
||||
});
|
||||
} else {
|
||||
void nav.clipboard.writeText(statusUrl);
|
||||
dispatch(statusInteraction({ statusId, intent: 'copy', contextType }));
|
||||
}
|
||||
}, [statusUrl]);
|
||||
const handleFavouriteClick = useCallback(() => {
|
||||
dispatch(statusInteraction({ statusId, intent: 'favourite' }));
|
||||
}, [dispatch, statusId]);
|
||||
}, [contextType, dispatch, statusId, statusUrl]);
|
||||
const handleBookmarkClick = useCallback(() => {
|
||||
dispatch(statusInteraction({ statusId, intent: 'bookmark' }));
|
||||
}, [dispatch, statusId]);
|
||||
dispatch(statusInteraction({ statusId, intent: 'bookmark', contextType }));
|
||||
}, [contextType, dispatch, statusId]);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
const favouriteIcon = useCallback(
|
||||
(props: React.SVGProps<SVGSVGElement>) =>
|
||||
status?.favourited ? (
|
||||
<HeartIcon {...props} weight='fill' />
|
||||
) : (
|
||||
<HeartIcon {...props} />
|
||||
),
|
||||
[status?.favourited],
|
||||
);
|
||||
const bookmarkIcon = useCallback(
|
||||
(props: React.SVGProps<SVGSVGElement>) =>
|
||||
status?.bookmarked ? (
|
||||
<BookmarkSimpleIcon {...props} weight='fill' />
|
||||
) : (
|
||||
<BookmarkSimpleIcon {...props} />
|
||||
),
|
||||
[status?.bookmarked],
|
||||
);
|
||||
|
||||
if (!status) {
|
||||
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 favouriteTitle = intl.formatMessage(
|
||||
status.favourited ? messages.removeFavourite : messages.favourite,
|
||||
@@ -208,32 +229,28 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
title={replyTitle}
|
||||
title={intl.formatMessage(messages.replyAll)}
|
||||
leadingIcon={ChatCircleTextIcon}
|
||||
onClick={handleReplyClick}
|
||||
>
|
||||
{withCounters && status.replies_count}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
leadingIcon={ArrowsClockwiseIcon}
|
||||
onClick={handleBoostClick}
|
||||
>
|
||||
<StatusReblogButton statusId={statusId}>
|
||||
{withCounters && status.reblogs_count}
|
||||
</Button>
|
||||
</StatusReblogButton>
|
||||
|
||||
<Button
|
||||
<ToggleButton
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
active={status.favourited}
|
||||
title={favouriteTitle}
|
||||
leadingIcon={HeartIcon}
|
||||
leadingIcon={favouriteIcon}
|
||||
onClick={handleFavouriteClick}
|
||||
className={classes.actionsButtonGap}
|
||||
>
|
||||
{withCounters && status.favourites_count}
|
||||
</Button>
|
||||
</ToggleButton>
|
||||
|
||||
{isPublic && (
|
||||
<IconButton
|
||||
@@ -246,11 +263,13 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
<ToggleIconButton
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
icon={BookmarkSimpleIcon}
|
||||
active={status.bookmarked}
|
||||
icon={bookmarkIcon}
|
||||
onClick={handleBookmarkClick}
|
||||
aria-pressed={status.bookmarked}
|
||||
>
|
||||
{!status.bookmarked ? (
|
||||
<FormattedMessage id='status.bookmark' defaultMessage='Bookmark' />
|
||||
@@ -260,7 +279,7 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
|
||||
defaultMessage='Remove bookmark'
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
</ToggleIconButton>
|
||||
|
||||
<RemoveQuoteHint
|
||||
className='status__action-bar__button-wrapper'
|
||||
@@ -270,9 +289,7 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
|
||||
<StatusActionMenu
|
||||
dismissQuoteHint={dismissQuoteHint}
|
||||
status={status}
|
||||
contextType={contextType}
|
||||
withDismiss={withDismiss}
|
||||
scrollKey={scrollKey}
|
||||
/>
|
||||
)}
|
||||
</RemoveQuoteHint>
|
||||
@@ -280,14 +297,99 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const StatusReblogButton: React.FC<{
|
||||
statusId: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ statusId, children }) => {
|
||||
const conditions = useAppSelector((state) =>
|
||||
selectStatusConditions(state, statusId),
|
||||
);
|
||||
const { isBoosted } = conditions;
|
||||
|
||||
const boostState = boostItemState(conditions);
|
||||
const quoteState = quoteItemState(conditions);
|
||||
const intl = useIntl();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onReblog = useCallback(() => {
|
||||
dispatch(statusInteraction({ statusId, intent: 'reblog' }));
|
||||
}, [dispatch, statusId]);
|
||||
const onQuote = useCallback(() => {
|
||||
dispatch(statusInteraction({ statusId, intent: 'quote' }));
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
if (quickBoosting) {
|
||||
return (
|
||||
<ToggleButton
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
active={isBoosted}
|
||||
leadingIcon={ArrowsClockwiseIcon}
|
||||
onClick={onReblog}
|
||||
disabled={boostState.disabled}
|
||||
>
|
||||
{children}
|
||||
</ToggleButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
as={ToggleButton}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
active={isBoosted}
|
||||
leadingIcon={ArrowsClockwiseIcon}
|
||||
>
|
||||
{children}
|
||||
</MenuTrigger>
|
||||
|
||||
<MenuList placement='bottom' maxWidth={180} container={document.body}>
|
||||
<MenuItem
|
||||
onClick={onReblog}
|
||||
icon={ArrowsClockwiseIcon}
|
||||
disabled={boostState.disabled}
|
||||
>
|
||||
<p>
|
||||
{intl.formatMessage(boostState.title)}
|
||||
{boostState.meta && (
|
||||
<span className={classes.actionDescription}>
|
||||
{intl.formatMessage(boostState.meta)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={onQuote}
|
||||
icon={QuotesFilledIcon}
|
||||
disabled={quoteState.disabled}
|
||||
>
|
||||
<p>
|
||||
{intl.formatMessage(quoteState.title)}
|
||||
{quoteState.meta && (
|
||||
<span className={classes.actionDescription}>
|
||||
{intl.formatMessage(quoteState.meta)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const QuotesFilledIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<QuotesIcon {...props} weight='fill' />
|
||||
);
|
||||
|
||||
const StatusActionMenu: React.FC<{
|
||||
dismissQuoteHint: () => void;
|
||||
status: StatusShape;
|
||||
contextType?: StatusContextType;
|
||||
scrollKey?: string;
|
||||
withDismiss?: boolean;
|
||||
}> = ({ status, dismissQuoteHint, contextType, scrollKey, withDismiss }) => {
|
||||
}> = ({ status, dismissQuoteHint, withDismiss }) => {
|
||||
const account = useAppSelector((state) => state.accounts.get(status.account));
|
||||
const { contextType } = useStatusContext();
|
||||
const { permissions } = useIdentity();
|
||||
const relationship = useRelationship(account?.id);
|
||||
const intl = useIntl();
|
||||
@@ -337,7 +439,7 @@ const StatusActionMenu: React.FC<{
|
||||
dispatch,
|
||||
],
|
||||
);
|
||||
const handleOpen = useCallback(() => {
|
||||
const onOpen = useCallback(() => {
|
||||
// Replicates needsStatusRefresh of the Dropdown component.
|
||||
if (quickBoosting && !status.quote_approval) {
|
||||
dispatch(
|
||||
@@ -350,14 +452,61 @@ const StatusActionMenu: React.FC<{
|
||||
}, [dismissQuoteHint, dispatch, status.id, status.quote_approval]);
|
||||
|
||||
return (
|
||||
<Dropdown scrollKey={scrollKey} items={menu} onOpen={handleOpen}>
|
||||
<IconButton size='sm' variant='ghost' icon={DotsThreeIcon}>
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
as={IconButton}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
icon={DotsThreeIcon}
|
||||
>
|
||||
<FormattedMessage id='status.more' defaultMessage='More' />
|
||||
</IconButton>
|
||||
</Dropdown>
|
||||
</MenuTrigger>
|
||||
|
||||
<MenuList placement='top-end' container={document.body}>
|
||||
{menu.map((item, index) => (
|
||||
<StatusActionItem key={index} item={item} />
|
||||
))}
|
||||
<StatusActionLoader onMount={onOpen} />
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusActionItem: React.FC<{ item: DropdownItem }> = ({ item }) => {
|
||||
if (!item) {
|
||||
return <MenuItemDivider />;
|
||||
}
|
||||
|
||||
const commonProps = {
|
||||
icon: item.icon,
|
||||
disabled: item.disabled,
|
||||
className: classNames(item.dangerous && classes.actionDangerous),
|
||||
children: item.description ? (
|
||||
<p>
|
||||
{item.text}
|
||||
<span className={classes.actionDescription}>{item.description}</span>
|
||||
</p>
|
||||
) : (
|
||||
item.text
|
||||
),
|
||||
} as const;
|
||||
|
||||
if ('to' in item) {
|
||||
return <MenuItemLink {...commonProps} to={item.to} as='link' />;
|
||||
} else if ('href' in item) {
|
||||
return <MenuItemLink {...commonProps} href={item.href} as='a' />;
|
||||
}
|
||||
|
||||
return <MenuItem {...commonProps} onClick={item.action} />;
|
||||
};
|
||||
|
||||
const StatusActionLoader = ({ onMount }: { onMount: () => void }) => {
|
||||
useEffect(() => {
|
||||
onMount();
|
||||
}, [onMount]);
|
||||
return null;
|
||||
};
|
||||
|
||||
interface MenuItemsParams {
|
||||
status: StatusShape;
|
||||
account?: Account;
|
||||
@@ -383,7 +532,7 @@ function getMenuItems({
|
||||
relationship,
|
||||
dispatch,
|
||||
}: MenuItemsParams) {
|
||||
const menu: MenuItem[] = [];
|
||||
const menu: DropdownItem[] = [];
|
||||
|
||||
const statusId = status.id;
|
||||
const statusUrl = status.url ?? status.uri;
|
||||
@@ -403,9 +552,7 @@ function getMenuItems({
|
||||
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.copy),
|
||||
action: () => {
|
||||
void navigator.clipboard.writeText(statusUrl);
|
||||
},
|
||||
action: onStatusInteraction('copy'),
|
||||
});
|
||||
|
||||
if (isPublic && 'share' in navigator) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/testing/factories';
|
||||
|
||||
import { StatusAttachments } from './attachments';
|
||||
import { StatusContext } from './hooks';
|
||||
import type { AttachmentArgs } from './testing';
|
||||
import { attachmentArgTypes, attachmentFactory } from './testing';
|
||||
|
||||
@@ -26,9 +27,9 @@ const meta = {
|
||||
title: 'Components/Status/StatusAttachments',
|
||||
render() {
|
||||
return (
|
||||
<div style={{ width: 'min(600px, 80vw)' }}>
|
||||
<StatusAttachments statusId='1' contextType='home' />
|
||||
</div>
|
||||
<StatusContext.Provider value={{}}>
|
||||
<StatusAttachments statusId='1' />
|
||||
</StatusContext.Provider>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
@@ -138,6 +139,13 @@ const meta = {
|
||||
};
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 'min(600px, 80vw)' }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} as Meta<StatusAttachmentsStoryProps>;
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -24,12 +24,12 @@ import { Card, CardBody, CardTitle } from '../card';
|
||||
import { PictureInPicturePlaceholder } from '../picture_in_picture_placeholder';
|
||||
import { RelativeTimestamp } from '../relative_timestamp';
|
||||
|
||||
import { useStatusContext } from './hooks';
|
||||
import { StatusQuote } from './quote';
|
||||
|
||||
export const StatusAttachments: React.FC<{
|
||||
statusId: string;
|
||||
contextType?: string;
|
||||
}> = ({ statusId, contextType }) => {
|
||||
}> = ({ statusId }) => {
|
||||
const status = useExpandedStatus(statusId);
|
||||
|
||||
if (!status) {
|
||||
@@ -42,7 +42,6 @@ export const StatusAttachments: React.FC<{
|
||||
<MediaAttachments
|
||||
statusId={statusId}
|
||||
accountId={status.account.id}
|
||||
contextType={contextType}
|
||||
sensitive={status.sensitive}
|
||||
language={status.translation?.language ?? status.language}
|
||||
attachment={attachment}
|
||||
@@ -98,7 +97,6 @@ const Video = lazy(() => import('@/mastodon/features/video'));
|
||||
const MediaAttachments: React.FC<{
|
||||
statusId: string;
|
||||
accountId: string;
|
||||
contextType?: string;
|
||||
sensitive: boolean;
|
||||
language: string;
|
||||
attachment: MediaAttachmentShape;
|
||||
@@ -107,7 +105,6 @@ const MediaAttachments: React.FC<{
|
||||
}> = ({
|
||||
statusId,
|
||||
accountId,
|
||||
contextType,
|
||||
sensitive,
|
||||
language,
|
||||
attachment,
|
||||
@@ -122,6 +119,7 @@ const MediaAttachments: React.FC<{
|
||||
'media_attachments',
|
||||
]) as Immutable.List<MediaAttachment>;
|
||||
});
|
||||
const { contextType } = useStatusContext();
|
||||
const mediaFilters = useAppSelector((state) =>
|
||||
selectMediaFilters(state, { statusId, contextType }),
|
||||
);
|
||||
|
||||
@@ -90,7 +90,6 @@ export const StatusContent: React.FC<
|
||||
ref={onRef}
|
||||
>
|
||||
<EmojiHTML
|
||||
{...props}
|
||||
className={classes.contentText}
|
||||
ref={onRef}
|
||||
lang={language}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { createContext, use, useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
@@ -23,6 +23,15 @@ import { FOCUS_TARGET } from '../navigation_focus_target';
|
||||
import { useElementHandledLink } from './handled_link';
|
||||
import type { StatusContextType } from './types';
|
||||
|
||||
export const StatusContext = createContext<{
|
||||
id?: string | null;
|
||||
contextType?: StatusContextType;
|
||||
}>({});
|
||||
|
||||
export function useStatusContext() {
|
||||
return use(StatusContext);
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
quote_noun: {
|
||||
id: 'status.quote_noun',
|
||||
|
||||
@@ -18,7 +18,11 @@ import { StatusActionBar } from './action_bar';
|
||||
import { StatusAttachments } from './attachments';
|
||||
import { StatusContent } from './content';
|
||||
import type { StatusHandlers } from './hooks';
|
||||
import { useStatusHandlers, useTextForScreenReader } from './hooks';
|
||||
import {
|
||||
StatusContext,
|
||||
useStatusHandlers,
|
||||
useTextForScreenReader,
|
||||
} from './hooks';
|
||||
import { StatusMeta } from './meta';
|
||||
import { StatusPrepend } from './prepend';
|
||||
import { StatusRedesignHeader } from './redesign/header';
|
||||
@@ -65,9 +69,8 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
|
||||
isQuotedPost,
|
||||
hidden,
|
||||
showActions = true,
|
||||
scrollKey,
|
||||
children,
|
||||
withCounters,
|
||||
withCounters = true,
|
||||
withDismiss,
|
||||
onOpen,
|
||||
showThread,
|
||||
@@ -140,94 +143,94 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
|
||||
(showActions && !isQuotedPost);
|
||||
|
||||
return (
|
||||
<StatusHotkeys
|
||||
{...hotkeysProps}
|
||||
className={classNames(
|
||||
classes.root,
|
||||
variant === 'thread' && classes.variantThread,
|
||||
variant === 'page' && classes.variantPage,
|
||||
isQuotedPost && classes.isQuote,
|
||||
)}
|
||||
data-featured={featured ? 'true' : null}
|
||||
aria-label={screenReaderText}
|
||||
data-nosnippet={status.account.noindex || undefined}
|
||||
>
|
||||
{!skipPrepend && (
|
||||
<StatusPrepend
|
||||
status={actualStatus}
|
||||
isReblog={!!parent}
|
||||
showThread={showThread}
|
||||
/>
|
||||
)}
|
||||
<StatusContext.Provider value={{ id, contextType }}>
|
||||
<StatusHotkeys
|
||||
{...hotkeysProps}
|
||||
className={classNames(
|
||||
classes.root,
|
||||
variant === 'thread' && classes.variantThread,
|
||||
variant === 'page' && classes.variantPage,
|
||||
isQuotedPost && classes.isQuote,
|
||||
)}
|
||||
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} className={classes.header}>
|
||||
{headerContents}
|
||||
</StatusRedesignHeader>
|
||||
<StatusRedesignHeader status={status} className={classes.header}>
|
||||
{headerContents}
|
||||
</StatusRedesignHeader>
|
||||
|
||||
{matchedFilters.length > 0 && (
|
||||
<FilterWarning
|
||||
title={matchedFilters.map((filter) => filter.title).join(', ')}
|
||||
expanded={showDespiteFilter}
|
||||
onClick={onFilterToggle}
|
||||
/>
|
||||
)}
|
||||
{matchedFilters.length > 0 && (
|
||||
<FilterWarning
|
||||
title={matchedFilters.map((filter) => filter.title).join(', ')}
|
||||
expanded={showDespiteFilter}
|
||||
onClick={onFilterToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(matchedFilters.length === 0 || showDespiteFilter) && (
|
||||
<ContentWarning
|
||||
statusId={status.id}
|
||||
expanded={expanded}
|
||||
onClick={onExpandedToggle}
|
||||
/>
|
||||
)}
|
||||
{(matchedFilters.length === 0 || showDespiteFilter) && (
|
||||
<ContentWarning
|
||||
statusId={status.id}
|
||||
expanded={expanded}
|
||||
onClick={onExpandedToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<StatusContent
|
||||
status={status}
|
||||
statusContent={statusContent}
|
||||
onReadMore={handlers.onOpen}
|
||||
onTranslate={onTranslate}
|
||||
collapsible
|
||||
>
|
||||
{!!status.poll && (
|
||||
<Poll
|
||||
pollId={status.poll}
|
||||
statusUrl={status.uri}
|
||||
accountId={status.account.id}
|
||||
lang={status.translation?.language ?? status.language}
|
||||
/>
|
||||
)}
|
||||
{expanded && (
|
||||
<StatusContent
|
||||
status={status}
|
||||
statusContent={statusContent}
|
||||
onReadMore={handlers.onOpen}
|
||||
onTranslate={onTranslate}
|
||||
collapsible
|
||||
>
|
||||
{!!status.poll && (
|
||||
<Poll
|
||||
pollId={status.poll}
|
||||
statusUrl={status.uri}
|
||||
accountId={status.account.id}
|
||||
lang={status.translation?.language ?? status.language}
|
||||
/>
|
||||
)}
|
||||
|
||||
<StatusAttachments statusId={status.id} contextType={contextType} />
|
||||
<StatusAttachments statusId={status.id} />
|
||||
|
||||
{children}
|
||||
</StatusContent>
|
||||
)}
|
||||
{children}
|
||||
</StatusContent>
|
||||
)}
|
||||
|
||||
{showFooter && (
|
||||
<footer className={classes.footer}>
|
||||
{expanded && hashtagsInBar.length > 0 && (
|
||||
<HashtagBar
|
||||
hashtags={hashtagsInBar}
|
||||
accountId={status.account.id}
|
||||
/>
|
||||
)}
|
||||
{showFooter && (
|
||||
<footer className={classes.footer}>
|
||||
{expanded && hashtagsInBar.length > 0 && (
|
||||
<HashtagBar
|
||||
hashtags={hashtagsInBar}
|
||||
accountId={status.account.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{variant === 'page' && (
|
||||
<StatusMeta status={status} className={classes.meta} />
|
||||
)}
|
||||
{variant === 'page' && (
|
||||
<StatusMeta status={status} className={classes.meta} />
|
||||
)}
|
||||
|
||||
{showActions && !isQuotedPost && (
|
||||
<StatusActionBar
|
||||
scrollKey={scrollKey}
|
||||
statusId={status.id}
|
||||
contextType={contextType}
|
||||
withDismiss={withDismiss}
|
||||
withCounters={withCounters}
|
||||
/>
|
||||
)}
|
||||
</footer>
|
||||
)}
|
||||
</StatusHotkeys>
|
||||
{showActions && !isQuotedPost && (
|
||||
<StatusActionBar
|
||||
statusId={status.id}
|
||||
withDismiss={withDismiss}
|
||||
withCounters={withCounters}
|
||||
/>
|
||||
)}
|
||||
</footer>
|
||||
)}
|
||||
</StatusHotkeys>
|
||||
</StatusContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
|
||||
button:not(:hover, :active) {
|
||||
> button:not(:hover, :active, [aria-pressed='true'], [aria-expanded='true']) {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,21 @@
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.actionDescription {
|
||||
@include mixins.type-label-sm;
|
||||
|
||||
display: block;
|
||||
color: var(--color-text-tertiary);
|
||||
|
||||
[data-menu-item='true'][aria-disabled='true'] & {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.actionDangerous {
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
|
||||
.buttonAlign {
|
||||
margin-inline-start: calc(-1 * var(--space-sm));
|
||||
}
|
||||
|
||||
@@ -1367,6 +1367,7 @@
|
||||
"status.context.retry": "Retry",
|
||||
"status.context.show": "Show",
|
||||
"status.continued_thread": "Continued thread",
|
||||
"status.copied": "Copied post link to clipboard",
|
||||
"status.copy": "Copy link to post",
|
||||
"status.delete": "Delete",
|
||||
"status.delete.success": "Post deleted",
|
||||
|
||||
@@ -172,6 +172,7 @@ export const selectStatusInteractions = createAppSelector(
|
||||
Partial<typeof conditionals> & { allowed: boolean }
|
||||
> = {
|
||||
bookmark: addAllowed({ isLoggedIn }),
|
||||
copy: addAllowed({}),
|
||||
delete: addAllowed({ isMine }),
|
||||
edit: addAllowed({ isMine }),
|
||||
editQuotePolicy: addAllowed({ isMine, isPublic }),
|
||||
|
||||
Reference in New Issue
Block a user