From 8b6f5b22be015cb96dcfff3f297027c22d32ba10 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 9 Sep 2026 18:18:26 +0000 Subject: [PATCH] Status redesign: Actions menu and interactions (#40442) --- .../mastodon/actions/interactions_typed.ts | 16 +- .../components/button/redesign.module.scss | 46 ++-- .../components/button/redesign.stories.tsx | 16 +- .../mastodon/components/button/redesign.tsx | 12 + .../mastodon/components/menu/index.tsx | 2 +- .../mastodon/components/status/action_bar.tsx | 257 ++++++++++++++---- .../components/status/attachments.stories.tsx | 14 +- .../components/status/attachments.tsx | 8 +- .../mastodon/components/status/content.tsx | 1 - .../mastodon/components/status/hooks.ts | 11 +- .../mastodon/components/status/status.tsx | 167 ++++++------ .../components/status/styles.module.scss | 17 +- app/javascript/mastodon/locales/en.json | 1 + app/javascript/mastodon/selectors/statuses.ts | 1 + 14 files changed, 399 insertions(+), 170 deletions(-) diff --git a/app/javascript/mastodon/actions/interactions_typed.ts b/app/javascript/mastodon/actions/interactions_typed.ts index 30e89207755..1b34d18749f 100644 --- a/app/javascript/mastodon/actions/interactions_typed.ts +++ b/app/javascript/mastodon/actions/interactions_typed.ts @@ -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( diff --git a/app/javascript/mastodon/components/button/redesign.module.scss b/app/javascript/mastodon/components/button/redesign.module.scss index 5c6f6408222..278b24c6fe6 100644 --- a/app/javascript/mastodon/components/button/redesign.module.scss +++ b/app/javascript/mastodon/components/button/redesign.module.scss @@ -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); - } - } -} diff --git a/app/javascript/mastodon/components/button/redesign.stories.tsx b/app/javascript/mastodon/components/button/redesign.stories.tsx index 747d4b55722..7f947222ae6 100644 --- a/app/javascript/mastodon/components/button/redesign.stories.tsx +++ b/app/javascript/mastodon/components/button/redesign.stories.tsx @@ -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 ; }, }; + +export const ToggleIcon: Story = { + render(args) { + const [active, { onToggle }] = useToggle(); + return ( + + ); + }, +}; diff --git a/app/javascript/mastodon/components/button/redesign.tsx b/app/javascript/mastodon/components/button/redesign.tsx index b1edd0a32af..412b82789e3 100644 --- a/app/javascript/mastodon/components/button/redesign.tsx +++ b/app/javascript/mastodon/components/button/redesign.tsx @@ -176,3 +176,15 @@ export const ToggleButton: React.FC = ({ className={classNames(className, classes.toggle)} /> ); + +export const ToggleIconButton: React.FC< + IconButtonProps & { active?: boolean } +> = ({ active, className, ...props }) => ( + +); diff --git a/app/javascript/mastodon/components/menu/index.tsx b/app/javascript/mastodon/components/menu/index.tsx index e83df85b598..34d70f41652 100644 --- a/app/javascript/mastodon/components/menu/index.tsx +++ b/app/javascript/mastodon/components/menu/index.tsx @@ -266,7 +266,7 @@ export type MenuListProps = Omit< 'isOpen' | 'onClose' | 'reference' | 'popoverElement' >; -export const MenuList = ({ +export const MenuList = ({ children, ...props }: MenuListProps) => { diff --git a/app/javascript/mastodon/components/status/action_bar.tsx b/app/javascript/mastodon/components/status/action_bar.tsx index 42654e03a7b..43a78ca52f1 100644 --- a/app/javascript/mastodon/components/status/action_bar.tsx +++ b/app/javascript/mastodon/components/status/action_bar.tsx @@ -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 = ({ statusId, - contextType, withDismiss, withCounters, - scrollKey, }) => { const status = useStatus(statusId); const quotedAccountId = useAppSelector( @@ -148,16 +157,17 @@ export const StatusActionBar: React.FC = ({ 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 = ({ 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) => + status?.favourited ? ( + + ) : ( + + ), + [status?.favourited], + ); + const bookmarkIcon = useCallback( + (props: React.SVGProps) => + status?.bookmarked ? ( + + ) : ( + + ), + [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 = ({ - + - + {isPublic && ( = ({ )} - {!status.bookmarked ? ( @@ -260,7 +279,7 @@ export const StatusActionBar: React.FC = ({ defaultMessage='Remove bookmark' /> )} - + = ({ )} @@ -280,14 +297,99 @@ export const StatusActionBar: React.FC = ({ ); }; +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 ( + + {children} + + ); + } + + return ( + + + {children} + + + + +

+ {intl.formatMessage(boostState.title)} + {boostState.meta && ( + + {intl.formatMessage(boostState.meta)} + + )} +

+
+ +

+ {intl.formatMessage(quoteState.title)} + {quoteState.meta && ( + + {intl.formatMessage(quoteState.meta)} + + )} +

+
+
+
+ ); +}; + +const QuotesFilledIcon = (props: React.SVGProps) => ( + +); + 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 ( - - + + - - + + + + {menu.map((item, index) => ( + + ))} + + + ); }; +const StatusActionItem: React.FC<{ item: DropdownItem }> = ({ item }) => { + if (!item) { + return ; + } + + const commonProps = { + icon: item.icon, + disabled: item.disabled, + className: classNames(item.dangerous && classes.actionDangerous), + children: item.description ? ( +

+ {item.text} + {item.description} +

+ ) : ( + item.text + ), + } as const; + + if ('to' in item) { + return ; + } else if ('href' in item) { + return ; + } + + return ; +}; + +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) { diff --git a/app/javascript/mastodon/components/status/attachments.stories.tsx b/app/javascript/mastodon/components/status/attachments.stories.tsx index 78a2bf01d14..f969afba57b 100644 --- a/app/javascript/mastodon/components/status/attachments.stories.tsx +++ b/app/javascript/mastodon/components/status/attachments.stories.tsx @@ -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 ( -
- -
+ + + ); }, args: { @@ -138,6 +139,13 @@ const meta = { }; }, }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], } as Meta; export default meta; diff --git a/app/javascript/mastodon/components/status/attachments.tsx b/app/javascript/mastodon/components/status/attachments.tsx index 02f2f178075..eb32928ed3e 100644 --- a/app/javascript/mastodon/components/status/attachments.tsx +++ b/app/javascript/mastodon/components/status/attachments.tsx @@ -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<{ 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; }); + const { contextType } = useStatusContext(); const mediaFilters = useAppSelector((state) => selectMediaFilters(state, { statusId, contextType }), ); diff --git a/app/javascript/mastodon/components/status/content.tsx b/app/javascript/mastodon/components/status/content.tsx index 32f7c6080e2..199416cff0c 100644 --- a/app/javascript/mastodon/components/status/content.tsx +++ b/app/javascript/mastodon/components/status/content.tsx @@ -90,7 +90,6 @@ export const StatusContent: React.FC< ref={onRef} > ({}); + +export function useStatusContext() { + return use(StatusContext); +} + const messages = defineMessages({ quote_noun: { id: 'status.quote_noun', diff --git a/app/javascript/mastodon/components/status/status.tsx b/app/javascript/mastodon/components/status/status.tsx index 1f2a4a2b9c7..d3de820b8ec 100644 --- a/app/javascript/mastodon/components/status/status.tsx +++ b/app/javascript/mastodon/components/status/status.tsx @@ -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 = ({ isQuotedPost, hidden, showActions = true, - scrollKey, children, - withCounters, + withCounters = true, withDismiss, onOpen, showThread, @@ -140,94 +143,94 @@ export const StatusRedesign: React.FC = ({ (showActions && !isQuotedPost); return ( - - {!skipPrepend && ( - - )} + + + {!skipPrepend && ( + + )} - - {headerContents} - + + {headerContents} + - {matchedFilters.length > 0 && ( - filter.title).join(', ')} - expanded={showDespiteFilter} - onClick={onFilterToggle} - /> - )} + {matchedFilters.length > 0 && ( + filter.title).join(', ')} + expanded={showDespiteFilter} + onClick={onFilterToggle} + /> + )} - {(matchedFilters.length === 0 || showDespiteFilter) && ( - - )} + {(matchedFilters.length === 0 || showDespiteFilter) && ( + + )} - {expanded && ( - - {!!status.poll && ( - - )} + {expanded && ( + + {!!status.poll && ( + + )} - + - {children} - - )} + {children} + + )} - {showFooter && ( -
- {expanded && hashtagsInBar.length > 0 && ( - - )} + {showFooter && ( +
+ {expanded && hashtagsInBar.length > 0 && ( + + )} - {variant === 'page' && ( - - )} + {variant === 'page' && ( + + )} - {showActions && !isQuotedPost && ( - - )} -
- )} - + {showActions && !isQuotedPost && ( + + )} +
+ )} +
+
); }; diff --git a/app/javascript/mastodon/components/status/styles.module.scss b/app/javascript/mastodon/components/status/styles.module.scss index bd28b4f2dde..c3397d365bf 100644 --- a/app/javascript/mastodon/components/status/styles.module.scss +++ b/app/javascript/mastodon/components/status/styles.module.scss @@ -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)); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 72e8ebef7b3..28a8f4b7116 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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", diff --git a/app/javascript/mastodon/selectors/statuses.ts b/app/javascript/mastodon/selectors/statuses.ts index 3e4f4a8787c..cafc3404e61 100644 --- a/app/javascript/mastodon/selectors/statuses.ts +++ b/app/javascript/mastodon/selectors/statuses.ts @@ -172,6 +172,7 @@ export const selectStatusInteractions = createAppSelector( Partial & { allowed: boolean } > = { bookmark: addAllowed({ isLoggedIn }), + copy: addAllowed({}), delete: addAllowed({ isMine }), edit: addAllowed({ isMine }), editQuotePolicy: addAllowed({ isMine, isPublic }),