From df213ecb1d3ea810063dbb85f9249d6e14b63b44 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 10 Sep 2026 15:33:34 +0000 Subject: [PATCH] Composer redesign: Q/A pass (#40452) --- app/javascript/mastodon/actions/compose.js | 31 ++++++++++- .../mastodon/components/menu/card.tsx | 1 + .../mastodon/components/menu/index.tsx | 6 ++- .../components/menu/styles.module.scss | 3 ++ .../mastodon/components/status/action_bar.tsx | 3 +- .../mastodon/components/status/hooks.ts | 28 +++++++++- .../mastodon/components/status/quote.tsx | 27 +--------- .../features/compose/redesign/footer.tsx | 5 +- .../features/compose/redesign/index.tsx | 6 +-- .../compose/redesign/modal_cancel.tsx | 4 +- .../compose/redesign/modal_switch.tsx | 4 +- .../features/compose/redesign/reply.tsx | 30 ++++++----- .../features/compose/redesign/selectors.ts | 53 +++++++++++-------- .../compose/redesign/styles.module.scss | 31 ++++++----- .../features/compose/redesign/textarea.tsx | 21 ++++++-- .../compose/redesign/trigger.module.scss | 4 +- .../features/compose/redesign/trigger.tsx | 5 +- .../features/compose/redesign/visibility.tsx | 10 +++- .../mastodon/reducers/slices/composer.ts | 41 +++++++++----- .../styles/mastodon/tokens/theme/_light.scss | 2 +- app/javascript/types/polymorphic.ts | 7 ++- 21 files changed, 208 insertions(+), 114 deletions(-) diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 57d215688d7..e9c9af02b46 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -16,6 +16,7 @@ import { openModal } from './modal'; import { updateTimeline } from './timelines'; import { insertStatusIntoAccountTimelines } from './timelines_typed'; import { isRedesignEnabled } from '../utils/environment'; +import { requestComposerFocus } from '../reducers/slices/composer'; /** @type {AbortController | undefined} */ let fetchComposeSuggestionsAccountsController; @@ -126,6 +127,12 @@ export function replyCompose(status) { }); ensureComposeIsVisible(getState); + + if (isRedesignEnabled()) { + const text = getState().getIn(['compose', 'text'], ''); + // Preselect any mentions past the first, mirroring the reply text's leading `@user `. + dispatch(requestComposerFocus({ start: text.search(/\s/) + 1, end: text.length })); + } }; } @@ -161,6 +168,11 @@ export const focusCompose = (defaultText = '', caretStart = false) => (dispatch, }); ensureComposeIsVisible(getState); + + if (isRedesignEnabled()) { + const position = caretStart ? 0 : getState().getIn(['compose', 'text'], '').length; + dispatch(requestComposerFocus({ start: position, end: position })); + } }; export function mentionCompose(account) { @@ -171,6 +183,11 @@ export function mentionCompose(account) { }); ensureComposeIsVisible(getState); + + if (isRedesignEnabled()) { + const position = getState().getIn(['compose', 'text'], '').length; + dispatch(requestComposerFocus({ start: position, end: position })); + } }; } @@ -188,6 +205,11 @@ export function directCompose(account) { }); ensureComposeIsVisible(getState); + + if (isRedesignEnabled()) { + const position = getState().getIn(['compose', 'text'], '').length; + dispatch(requestComposerFocus({ start: position, end: position })); + } }; } @@ -654,7 +676,9 @@ export function selectComposeSuggestion(position, token, suggestion, path) { // We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that // the suggestions are dismissed and the cursor moves forward. - if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) { + const inserted = suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0; + + if (inserted) { dispatch({ type: COMPOSE_SUGGESTION_SELECT, position: startPosition, @@ -671,6 +695,11 @@ export function selectComposeSuggestion(position, token, suggestion, path) { path, }); } + + if (isRedesignEnabled() && path.length === 1 && path[0] === 'text') { + const caretPosition = startPosition + (inserted ? completion.length : token.length) + 1; + dispatch(requestComposerFocus({ start: caretPosition, end: caretPosition })); + } }; } diff --git a/app/javascript/mastodon/components/menu/card.tsx b/app/javascript/mastodon/components/menu/card.tsx index 2b340bc8dfd..47411b80599 100644 --- a/app/javascript/mastodon/components/menu/card.tsx +++ b/app/javascript/mastodon/components/menu/card.tsx @@ -19,6 +19,7 @@ export type MenuCardProps = PolymorphicProps< elevation?: 1 | 2; maxWidth?: number | string; style?: React.CSSProperties; + popover?: React.HTMLAttributes['popover']; }, As >; diff --git a/app/javascript/mastodon/components/menu/index.tsx b/app/javascript/mastodon/components/menu/index.tsx index 0c9ebb33be4..d32870ea521 100644 --- a/app/javascript/mastodon/components/menu/index.tsx +++ b/app/javascript/mastodon/components/menu/index.tsx @@ -150,8 +150,10 @@ export const Menu: React.FC = ({ if (shouldClose === false) return; setIsMenuOpen(false); - triggerElement?.focus(); - }, [triggerElement, onClose]); + if (listElement?.contains(document.activeElement)) { + triggerElement?.focus(); + } + }, [listElement, triggerElement, onClose]); const toggleMenu = isMenuOpen ? closeMenu : openMenu; diff --git a/app/javascript/mastodon/components/menu/styles.module.scss b/app/javascript/mastodon/components/menu/styles.module.scss index 3040f26666b..f43abfb4062 100644 --- a/app/javascript/mastodon/components/menu/styles.module.scss +++ b/app/javascript/mastodon/components/menu/styles.module.scss @@ -130,4 +130,7 @@ .itemTrailingContent { margin-inline-start: auto; + + // Add additional space after the text. + padding-inline-start: var(--space-xs); } diff --git a/app/javascript/mastodon/components/status/action_bar.tsx b/app/javascript/mastodon/components/status/action_bar.tsx index e46f538b188..74de8ee5ba8 100644 --- a/app/javascript/mastodon/components/status/action_bar.tsx +++ b/app/javascript/mastodon/components/status/action_bar.tsx @@ -52,6 +52,7 @@ import { } from '@/mastodon/selectors/statuses'; import type { AppDispatch } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +import { isRedesignEnabled } from '@/mastodon/utils/environment'; import { Button, @@ -580,7 +581,7 @@ function getMenuItems({ ), action: onStatusInteraction('mute'), }); - if (interactions.editQuotePolicy) { + if (interactions.editQuotePolicy && !isRedesignEnabled()) { menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: onStatusInteraction('editQuotePolicy'), diff --git a/app/javascript/mastodon/components/status/hooks.ts b/app/javascript/mastodon/components/status/hooks.ts index 90a72fc6424..6d372ecc3b2 100644 --- a/app/javascript/mastodon/components/status/hooks.ts +++ b/app/javascript/mastodon/components/status/hooks.ts @@ -1,4 +1,4 @@ -import { createContext, use, useCallback, useMemo } from 'react'; +import { createContext, createElement, use, useCallback, useMemo } from 'react'; import { defineMessages, useIntl } from 'react-intl'; @@ -12,11 +12,13 @@ import { toggleStatusSpoilers } from '@/mastodon/actions/statuses'; import { useExpandedStatus } from '@/mastodon/hooks/useStatus'; import { useToggle } from '@/mastodon/hooks/useToggle'; import type { + AccountStatusShape, ExpandedStatusShape, StatusShape, } from '@/mastodon/models/status'; import { selectStatusFilters } from '@/mastodon/selectors/filters'; import { useAppSelector, useAppDispatch } from '@/mastodon/store'; +import type { OnElementHandler } from '@/mastodon/utils/html'; import { FOCUS_TARGET } from '../navigation_focus_target'; @@ -284,3 +286,27 @@ export function useHandlersForStatus( hrefToMention, }); } + +export const onStatusLinksDisabled: OnElementHandler = ( + element, + { key, href }, + children, + status, +) => { + // If this is a paragraph with just a link and it matches the card, don't add it. + if ( + element instanceof HTMLParagraphElement && + element.children.length === 1 && + element.firstChild instanceof HTMLAnchorElement && + element.firstChild.href === status.card?.url + ) { + return null; + } else if (element instanceof HTMLAnchorElement) { + if (href === status.card?.url) { + return null; + } + // Just use createElement instead of making the whole file JSX. + return createElement('strong', { key: key as string }, children); + } + return undefined; +}; diff --git a/app/javascript/mastodon/components/status/quote.tsx b/app/javascript/mastodon/components/status/quote.tsx index f2b4d0d7571..4e35c66d9a7 100644 --- a/app/javascript/mastodon/components/status/quote.tsx +++ b/app/javascript/mastodon/components/status/quote.tsx @@ -32,7 +32,6 @@ import { selectStatusLoadingState, } from '@/mastodon/selectors/statuses'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; -import type { OnElementHandler } from '@/mastodon/utils/html'; import { Avatar } from '../avatar'; import { Button } from '../button/redesign'; @@ -44,6 +43,7 @@ import { Icon } from '../icon'; import { PopoverMenuCard } from '../menu/card'; import { RelativeTimestamp } from '../relative_timestamp'; +import { onStatusLinksDisabled } from './hooks'; import { StatusImage } from './image'; import classes from './quote.module.scss'; @@ -212,7 +212,7 @@ const QuotedStatusBody: React.FC<{ htmlString={status.translation?.contentHtml ?? status.contentHtml} extraEmojis={status.emojis} lang={status.translation?.language ?? status.language} - onElement={onStatusLinks} + onElement={onStatusLinksDisabled} extraArgs={status} /> @@ -326,29 +326,6 @@ const QuotedStatusLink: React.FC<{ status: AccountStatusShape }> = ({ return {link}; }; -const onStatusLinks: OnElementHandler = ( - element, - { key, href }, - children, - status, -) => { - // If this is a paragraph with just a link and it matches the card, don't add it. - if ( - element instanceof HTMLParagraphElement && - element.children.length === 1 && - element.firstChild instanceof HTMLAnchorElement && - element.firstChild.href === status.card?.url - ) { - return null; - } else if (element instanceof HTMLAnchorElement) { - if (href === status.card?.url) { - return null; - } - return {children}; - } - return undefined; -}; - function useQuoteError({ quoted_status: quoteId, state: quoteState, diff --git a/app/javascript/mastodon/features/compose/redesign/footer.tsx b/app/javascript/mastodon/features/compose/redesign/footer.tsx index 035bec8ce56..ef17a630f45 100644 --- a/app/javascript/mastodon/features/compose/redesign/footer.tsx +++ b/app/javascript/mastodon/features/compose/redesign/footer.tsx @@ -88,10 +88,9 @@ export const ComposeFooter: React.FC<{ onEmojiPick: OnEmojiPick }> = ({ disabled={!canSubmit} loading={isSubmitting} > - {type !== 'message' && ( + {type !== 'message' && type !== 'replyPrivate' ? ( - )} - {type === 'message' && ( + ) : ( !!state.compose.get('spoiler')); useEffect(() => { if (!isSensitive) { - focusComposerTextarea(); + dispatch(requestComposerFocus()); } - }, [isSensitive]); + }, [isSensitive, dispatch]); const onSensitiveChange = useCallback(() => { dispatch(changeComposeSpoilerness()); diff --git a/app/javascript/mastodon/features/compose/redesign/modal_cancel.tsx b/app/javascript/mastodon/features/compose/redesign/modal_cancel.tsx index 21f010a712e..0fa0eb7b0a1 100644 --- a/app/javascript/mastodon/features/compose/redesign/modal_cancel.tsx +++ b/app/javascript/mastodon/features/compose/redesign/modal_cancel.tsx @@ -10,8 +10,8 @@ import { ModalTitle, } from '@/mastodon/components/modal_shell/redesign'; import { - focusComposerTextarea, openNewComposer, + requestComposerFocus, resetComposer, } from '@/mastodon/reducers/slices/composer'; import { useAppDispatch } from '@/mastodon/store'; @@ -34,7 +34,7 @@ const ComposerModalCancelConfirm: React.FC<{ openNew?: boolean }> = ({ dispatch( closeModal({ modalType: 'COMPOSER_DRAFT_DELETE', ignoreFocus: false }), ); - focusComposerTextarea(true); + dispatch(requestComposerFocus()); }, [dispatch]); return ( diff --git a/app/javascript/mastodon/features/compose/redesign/modal_switch.tsx b/app/javascript/mastodon/features/compose/redesign/modal_switch.tsx index 9eb9e9f07ef..431826767c4 100644 --- a/app/javascript/mastodon/features/compose/redesign/modal_switch.tsx +++ b/app/javascript/mastodon/features/compose/redesign/modal_switch.tsx @@ -49,14 +49,14 @@ const ComposerModalSwitch: React.FC = () => { /> - -