Composer redesign: Q/A pass (#40452)

This commit is contained in:
Echo
2026-09-10 15:33:34 +00:00
committed by GitHub
parent 629583d449
commit df213ecb1d
21 changed files with 208 additions and 114 deletions

View File

@@ -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 }));
}
};
}

View File

@@ -19,6 +19,7 @@ export type MenuCardProps<As extends React.ElementType> = PolymorphicProps<
elevation?: 1 | 2;
maxWidth?: number | string;
style?: React.CSSProperties;
popover?: React.HTMLAttributes<As>['popover'];
},
As
>;

View File

@@ -150,8 +150,10 @@ export const Menu: React.FC<MenuProps> = ({
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;

View File

@@ -130,4 +130,7 @@
.itemTrailingContent {
margin-inline-start: auto;
// Add additional space after the text.
padding-inline-start: var(--space-xs);
}

View File

@@ -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'),

View File

@@ -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<AccountStatusShape> = (
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;
};

View File

@@ -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 <CardBody>{link}</CardBody>;
};
const onStatusLinks: OnElementHandler<AccountStatusShape> = (
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 <strong key={key as string}>{children}</strong>;
}
return undefined;
};
function useQuoteError({
quoted_status: quoteId,
state: quoteState,

View File

@@ -88,10 +88,9 @@ export const ComposeFooter: React.FC<{ onEmojiPick: OnEmojiPick }> = ({
disabled={!canSubmit}
loading={isSubmitting}
>
{type !== 'message' && (
{type !== 'message' && type !== 'replyPrivate' ? (
<FormattedMessage id='compose.publish' defaultMessage='Publish' />
)}
{type === 'message' && (
) : (
<FormattedMessage
id='compose.message.publish'
defaultMessage='Send'

View File

@@ -16,8 +16,8 @@ import { ToggleButton } from '@/mastodon/components/button/redesign';
import { TextInputField } from '@/mastodon/components/form_fields/redesign';
import { Icon } from '@/mastodon/components/icon';
import {
focusComposerTextarea,
getComposerTextarea,
requestComposerFocus,
submitComposer,
} from '@/mastodon/reducers/slices/composer';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
@@ -143,9 +143,9 @@ function useComposeHandlers(redirectOnSuccess?: boolean) {
const isSensitive = useAppSelector((state) => !!state.compose.get('spoiler'));
useEffect(() => {
if (!isSensitive) {
focusComposerTextarea();
dispatch(requestComposerFocus());
}
}, [isSensitive]);
}, [isSensitive, dispatch]);
const onSensitiveChange = useCallback(() => {
dispatch(changeComposeSpoilerness());

View File

@@ -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 (

View File

@@ -49,14 +49,14 @@ const ComposerModalSwitch: React.FC = () => {
/>
<ModalActions>
<Button size='sm' onClick={handleBack}>
<Button onClick={handleBack}>
<FormattedMessage
id='compose.switch_modal.back'
defaultMessage='Back'
/>
</Button>
<Button size='sm' variant='solid' onClick={handleContinue}>
<Button variant='solid' onClick={handleContinue}>
<FormattedMessage
id='compose.switch_modal.continue'
defaultMessage='Continue'

View File

@@ -4,7 +4,8 @@ import { Avatar } from '@/mastodon/components/avatar';
import { LinkedDisplayName } from '@/mastodon/components/display_name';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { RelativeTimestamp } from '@/mastodon/components/relative_timestamp';
import { useHandlersForStatus } from '@/mastodon/components/status/hooks';
import { onStatusLinksDisabled } from '@/mastodon/components/status/hooks';
import { statusLink } from '@/mastodon/components/status/utils';
import { selectAccountStatus } from '@/mastodon/selectors/statuses';
import { useAppSelector } from '@/mastodon/store';
@@ -16,8 +17,6 @@ export const ComposeReply: React.FC = () => {
);
const status = useAppSelector((state) => selectAccountStatus(state, replyId));
const htmlHandlers = useHandlersForStatus(status);
if (!status) {
return;
}
@@ -30,26 +29,29 @@ export const ComposeReply: React.FC = () => {
className={classes.replyAvatar}
withLink
/>
<LinkedDisplayName
displayProps={{ account: status.account, variant: 'simple' }}
/>
<span className={classes.replyTime}>
&middot;&nbsp;
<Link to={`/@${status.account.acct}/${status.id}`}>
&nbsp;&bull;&nbsp;
<Link to={statusLink(status)}>
<RelativeTimestamp timestamp={status.created_at} />
</Link>
</span>
</figcaption>
<EmojiHTML
as='blockquote'
cite={status.uri}
htmlString={status.translation?.contentHtml ?? status.contentHtml}
extraEmojis={status.emojis}
className={classes.replyText}
lang={status.translation?.language ?? status.language}
{...htmlHandlers}
/>
<Link to={statusLink(status)} className={classes.replyText}>
<EmojiHTML
as='blockquote'
cite={status.uri}
htmlString={status.translation?.contentHtml ?? status.contentHtml}
extraEmojis={status.emojis}
lang={status.translation?.language ?? status.language}
onElement={onStatusLinksDisabled}
/>
</Link>
</figure>
);
};

View File

@@ -59,19 +59,46 @@ export const selectComposeCharsCount = createAppSelector(
},
);
export const selectComposeHasAttachments = createAppSelector(
[
(state) => !!state.compose.get('poll'),
(state) => state.compose.get('quoted_status_id') as string | null,
(state) =>
state.compose.get('media_attachments') as
| Immutable.List<unknown>
| undefined,
(state) => Number(state.compose.get('pending_media_attachments')),
],
(hasPoll, quotedStatusId, attachments, pendingAttachments) => {
return {
hasPoll,
hasAttachments:
(attachments && attachments.size > 0) || pendingAttachments > 0,
quotedStatusId,
};
},
);
export const selectComposeCanSubmit = createAppSelector(
[
(state) => !!state.compose.get('is_submitting'),
(state) => !!state.compose.get('is_uploading'),
(state) => !!state.compose.get('is_changing_upload'),
selectComposeHasAttachments,
selectComposeCharsCount,
],
(isSubmitting, isUploading, isChangingUpload, { text, max }) =>
(
isSubmitting,
isUploading,
isChangingUpload,
{ hasAttachments, hasPoll, quotedStatusId },
{ text, max },
) =>
!isSubmitting &&
!isUploading &&
!isChangingUpload &&
text.trim().length <= max &&
text.trim().length > 0,
(hasAttachments || hasPoll || quotedStatusId || text.trim().length > 0),
);
export const selectComposeMentions = createAppSelector(
@@ -83,7 +110,7 @@ export const selectComposeMentions = createAppSelector(
(accountsMap, text, localDomain) => {
const accounts = new Set<string>();
const potentialAccounts = text.matchAll(
/@(?<username>[a-zA-Z0-9_.-]+)(?<domain>@[a-zA-Z0-9_.-]+)?/g,
/(?<!:\/\/[^\s]+)@(?<username>[a-zA-Z0-9_.-]+)(?<domain>@[a-zA-Z0-9_.-]+)?/g,
);
for (const match of potentialAccounts) {
const { username, domain } = match.groups ?? {};
@@ -163,26 +190,6 @@ export const selectFrequentlyUsedEmoji = createAppSelector(
},
);
export const selectComposeHasAttachments = createAppSelector(
[
(state) => !!state.compose.get('poll'),
(state) => state.compose.get('quoted_status_id') as string | null,
(state) =>
state.compose.get('media_attachments') as
| Immutable.List<unknown>
| undefined,
(state) => Number(state.compose.get('pending_media_attachments')),
],
(hasPoll, quotedStatusId, attachments, pendingAttachments) => {
return {
hasPoll,
hasAttachments:
(attachments && attachments.size > 0) || pendingAttachments > 0,
quotedStatusId,
};
},
);
export type ComposeAttachment<
TAttachment extends ApiMediaAttachmentJSON = ApiMediaAttachmentJSON,
> = TAttachment & {

View File

@@ -6,7 +6,7 @@
background: var(--color-bg-primary);
display: flex;
flex-direction: column;
gap: var(--space-md);
gap: var(--space-xs);
padding: var(--space-md);
z-index: 1; // To cover the sidebar
@@ -44,6 +44,7 @@
}
.toolbar {
margin-top: var(--space-xs);
display: flex;
gap: var(--space-xs);
align-items: center;
@@ -215,11 +216,6 @@ textarea.textarea {
.reply {
border-inline-start: 0.5px solid var(--color-border-primary);
padding: 0 var(--space-md);
}
.replyAccount {
display: flex;
margin-bottom: var(--space-2xs);
a {
color: inherit;
@@ -227,6 +223,17 @@ textarea.textarea {
}
}
.replyAccount {
display: flex;
align-items: center;
margin-bottom: var(--space-2xs);
> a,
> span {
display: block;
}
}
.replyAvatar {
margin-inline-end: var(--space-xs);
border-radius: var(--radius-round);
@@ -241,17 +248,13 @@ textarea.textarea {
@include mixins.line-clamp(2);
@include mixins.type-body-compact;
display: block;
// Round two lines to the nearest 5px.
max-height: round(2lh, 5px);
a {
color: var(--color-text-brand);
text-decoration: none;
&:hover,
&:focus {
text-decoration: underline;
}
strong {
font-weight: bold;
}
}

View File

@@ -1,5 +1,5 @@
import type React from 'react';
import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@@ -21,8 +21,8 @@ import { TextArea } from '@/mastodon/components/form_fields';
import { normalizeKey } from '@/mastodon/components/hotkeys/utils';
import { useScrollSensor } from '@/mastodon/hooks/useScrollSensor';
import {
clearComposerFocusRequest,
COMPOSER_TEXTAREA_ID,
focusComposerTextarea,
} from '@/mastodon/reducers/slices/composer';
import {
createAppSelector,
@@ -93,7 +93,6 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
dispatch(
selectComposeSuggestion(tokenStart, token, suggestion, ['text']),
);
focusComposerTextarea(true);
},
[dispatch],
);
@@ -105,6 +104,22 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
const suggestions = useAppSelector(selectSuggestions);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
// Applies a focus/selection requested from elsewhere (e.g. reply, mention) once this textarea exists,
// which also covers it not being mounted yet when the request was made (it's lazy-loaded).
const pendingFocus = useAppSelector((state) => state.composer.pendingFocus);
useEffect(() => {
if (!pendingFocus) {
return;
}
const { selection } = pendingFocus;
if (selection) {
textAreaRef.current?.setSelectionRange(selection.start, selection.end);
}
textAreaRef.current?.focus({ preventScroll: true });
dispatch(clearComposerFocusRequest());
}, [pendingFocus, dispatch]);
const {
onTextChange,
focus,

View File

@@ -27,8 +27,9 @@
@media (width >= #{variables.$mobile-menu-breakpoint}) {
top: auto;
bottom: 0;
min-height: 520px;
min-height: 270px;
height: auto;
max-height: calc(100vh - (var(--space-md) * 2));
min-width: 300px;
width: calc(100vw - var(--space-md) * 2);
max-width: 500px;
@@ -49,6 +50,7 @@
width: min(320px, calc(100vw - var(--space-md) * 2));
padding: var(--space-md);
margin: var(--space-md);
border-radius: var(--radius-xl);
@media (width < #{variables.$mobile-menu-breakpoint}) {
bottom: var(--mobile-bottom-nav-height);

View File

@@ -20,6 +20,7 @@ import {
MenuItem,
} from '@/mastodon/components/menu';
import { MenuCard } from '@/mastodon/components/menu/card';
import { useIdentity } from '@/mastodon/identity_context';
import { openNewComposer } from '@/mastodon/reducers/slices/composer';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { isRedesignEnabled } from '@/mastodon/utils/environment';
@@ -69,7 +70,9 @@ export const ComposeRedesignButton: React.FC<{
[dispatch],
);
if (!isRedesignEnabled()) {
const { signedIn } = useIdentity();
if (!isRedesignEnabled() || !signedIn) {
return null;
}

View File

@@ -17,7 +17,7 @@ import {
import { openModal } from '@/mastodon/actions/modal';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { CaretIcon } from '@/mastodon/components/button/redesign';
import { Button, CaretIcon } from '@/mastodon/components/button/redesign';
import { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
import {
Menu,
@@ -38,6 +38,7 @@ export const ComposeVisibility: React.FC<{ className?: string }> = ({
className,
}) => {
const privacy = useAppSelector(selectComposePrivacy);
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
return (
<div className={className}>
@@ -47,7 +48,12 @@ export const ComposeVisibility: React.FC<{ className?: string }> = ({
description='Before button that indicates who a post is for (Public, Followers, mentioned people)'
/>
<Menu>
<MenuTrigger size='sm' trailingIcon={CaretIcon}>
<MenuTrigger
as={Button}
size='sm'
trailingIcon={CaretIcon}
disabled={isEditing}
>
<ComposeVisibilityButtonText privacy={privacy} />
</MenuTrigger>

View File

@@ -1,4 +1,5 @@
import { createSlice, isAction } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import {
changeCompose,
@@ -36,18 +37,13 @@ export function getComposerTextarea() {
}
return null;
}
/**
* Focuses on the composer textarea.
* @param defer Waits before focusing. Useful if the composer may not be focusable immediately.
*/
export function focusComposerTextarea(defer = false) {
if (defer) {
requestAnimationFrame(() => {
getComposerTextarea()?.focus();
});
} else {
getComposerTextarea()?.focus();
}
export interface ComposerTextareaSelection {
start: number;
end: number;
}
interface PendingFocus {
selection: ComposerTextareaSelection | null;
}
type DisplayState = 'hidden' | 'showing' | 'minimized';
@@ -56,10 +52,12 @@ export type ComposeType = 'post' | 'message' | 'reply' | 'replyPrivate';
interface ComposerState {
displayState: DisplayState;
pendingFocus: PendingFocus | null;
}
const initialState: ComposerState = {
displayState: 'hidden',
pendingFocus: null,
};
const composerSlice = createSlice({
@@ -76,6 +74,15 @@ const composerSlice = createSlice({
hideComposer(state) {
state.displayState = 'hidden';
},
requestFocus(
state,
action: PayloadAction<ComposerTextareaSelection | undefined>,
) {
state.pendingFocus = { selection: action.payload ?? null };
},
clearPendingFocus(state) {
state.pendingFocus = null;
},
},
extraReducers(builder) {
builder.addMatcher(
@@ -98,6 +105,11 @@ const composerSlice = createSlice({
export const composer = composerSlice.reducer;
export const {
requestFocus: requestComposerFocus,
clearPendingFocus: clearComposerFocusRequest,
} = composerSlice.actions;
export const minimizeComposerToggle = createAppThunk(
(_arg, { dispatch, getState }) => {
dispatch(composerSlice.actions.minimizeComposerToggle());
@@ -172,12 +184,13 @@ export const openNewComposer = createAppThunk(
dispatch(directCompose(account));
} else {
dispatch(changeComposeVisibility('direct'));
dispatch(requestComposerFocus());
}
} else if (payload.type === 'reply') {
dispatch(replyComposeById(payload.toStatusId));
} else {
dispatch(requestComposerFocus());
}
focusComposerTextarea(true);
},
);

View File

@@ -46,7 +46,7 @@
// Utility
--color-bg-inverted: var(--color-grey-950);
--color-bg-overlay-highlight: var(--color-bg-highlight); // legacy
--color-bg-overlay: var(--color-bg-primary); // legacy
--color-bg-overlay: var(--color-black); // legacy
--color-bg-media-base: var(--color-black); // legacy
--color-bg-media: #{utils.css-alpha(var(--color-bg-media-base), 65%)}; // legacy
--color-bg-disabled: var(--color-grey-400); // legacy

View File

@@ -8,6 +8,8 @@ import type {
ForwardRefExoticComponent,
} from 'react';
import type { DistributedOmit } from 'type-fest';
// This complicated type file is based on the following posts:
// - https://www.tsteele.dev/posts/react-polymorphic-forwardref
// - https://www.kripod.dev/blog/behind-the-as-prop-polymorphism-done-well/
@@ -83,5 +85,8 @@ export type PolymorphicProps<
As extends React.ElementType,
> = {
as?: As;
} & Omit<React.ComponentPropsWithRef<As>, keyof AdditionalProps | 'as'> &
} & DistributedOmit<
React.ComponentPropsWithRef<As>,
keyof AdditionalProps | 'as'
> &
AdditionalProps;