Composer redesign: Add compose buttons (#40012)

This commit is contained in:
Echo
2026-08-10 19:48:06 +02:00
committed by GitHub
parent eca7a8c09d
commit 7d9b24bba2
16 changed files with 412 additions and 64 deletions

View File

@@ -81,3 +81,7 @@
border: none;
width: calc(100% - (2 * var(--space-sm)));
}
.popoverMenu {
width: 100%;
}

View File

@@ -38,6 +38,7 @@ export const DropdownPopover = <As extends React.ElementType>({
matchReferenceWidth,
closeOnClickOutside,
children,
className,
...props
}: DropdownProps<As> & Omit<PopoverProps, 'children'>) => {
const popoverProps = {
@@ -56,7 +57,14 @@ export const DropdownPopover = <As extends React.ElementType>({
return (
<Popover {...popoverProps}>
{({ props: popoverChildProps }) => (
<Dropdown {...props} {...popoverChildProps}>
<Dropdown
{...props}
{...popoverChildProps}
className={classNames(
className,
props.maxWidth && classes.popoverMenu,
)}
>
{children}
</Dropdown>
)}

View File

@@ -37,10 +37,9 @@ const ComposeMediaAttachments: React.FC = () => {
if (totalAttachments === 1) {
return (
<ComposeUpload
id={attachments.at(0)?.id}
className={classes.mediaSingle}
/>
<div className={classes.mediaSingle}>
<ComposeUpload id={attachments.at(0)?.id} />
</div>
);
}

View File

@@ -61,30 +61,32 @@ export const ComposeFooter: React.FC<{ onEmojiPick: OnEmojiPick }> = ({
/>
</IconButton>
<span className={classes.counter}>
<FormattedMessage
id='compose.counter'
defaultMessage='{current, number}/{max, number}'
values={{ current, max }}
/>
</span>
<Button
color='neutral'
type='submit'
disabled={!canSubmit}
loading={isSubmitting}
>
{type !== 'message' && (
<FormattedMessage id='compose.publish' defaultMessage='Publish' />
)}
{type === 'message' && (
<div className={classes.flexGrowWrap}>
<span className={classes.counter}>
<FormattedMessage
id='compose.message.publish'
defaultMessage='Send'
id='compose.counter'
defaultMessage='{current, number}/{max, number}'
values={{ current, max }}
/>
)}
</Button>
</span>
<Button
color='neutral'
type='submit'
disabled={!canSubmit}
loading={isSubmitting}
>
{type !== 'message' && (
<FormattedMessage id='compose.publish' defaultMessage='Publish' />
)}
{type === 'message' && (
<FormattedMessage
id='compose.message.publish'
defaultMessage='Send'
/>
)}
</Button>
</div>
</footer>
);
};

View File

@@ -1,9 +1,20 @@
import { useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { XIcon } from '@phosphor-icons/react';
import { ArrowsOutSimpleIcon, MinusIcon, XIcon } from '@phosphor-icons/react';
import { IconButton } from '@/mastodon/components/button/redesign';
import { createAppSelector, useAppSelector } from '@/mastodon/store';
import {
hideComposer,
minimizeComposerToggle,
selectIsMinimized,
} from '@/mastodon/reducers/slices/composer';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import { selectComposeType } from './selectors';
import classes from './styles.module.scss';
@@ -42,14 +53,48 @@ const selectComposeFormTitle = createAppSelector(
},
);
export const ComposeFormHeader: React.FC<{ id?: string }> = ({ id }) => {
export const ComposeFormHeader: React.FC<{
id?: string;
noMinimize?: boolean;
}> = ({ id, noMinimize }) => {
const intl = useIntl();
const titleMessage = useAppSelector(selectComposeFormTitle);
const isMinimized = useAppSelector(selectIsMinimized);
const dispatch = useAppDispatch();
const onClose = useCallback(() => {
dispatch(hideComposer());
}, [dispatch]);
const onMinimize = useCallback(() => {
dispatch(minimizeComposerToggle());
}, [dispatch]);
return (
<header className={classes.header}>
<h2 id={id}>{intl.formatMessage(titleMessage)}</h2>
<IconButton icon={XIcon} variant='ghost' size='sm'>
{!noMinimize && (
<IconButton
size='sm'
variant='ghost'
icon={isMinimized ? ArrowsOutSimpleIcon : MinusIcon}
onClick={onMinimize}
>
{isMinimized ? (
<FormattedMessage
id='compose.expand'
defaultMessage='Show composer'
/>
) : (
<FormattedMessage
id='compose.minimize'
defaultMessage='Minimize composer'
/>
)}
</IconButton>
)}
<IconButton icon={XIcon} variant='ghost' size='sm' onClick={onClose}>
<FormattedMessage id='lightbox.close' defaultMessage='Close' />
</IconButton>
</header>

View File

@@ -3,6 +3,8 @@ import { useCallback, useEffect, useId, useRef } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import { LockSimpleOpenIcon } from '@phosphor-icons/react';
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
@@ -57,11 +59,15 @@ const messages = defineMessages({
interface RedesignComposeFormProps {
autoFocus?: boolean;
className?: string;
noMinimize?: boolean;
redirectOnSuccess?: boolean;
}
export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
autoFocus,
className,
noMinimize,
redirectOnSuccess,
}) => {
const {
@@ -90,21 +96,24 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
role='dialog'
onSubmit={onSubmit}
aria-labelledby={titleId}
className={classes.root}
className={classNames(className, classes.root)}
>
<ComposeFormHeader id={titleId} />
<div className={classes.toolbar}>
{type !== 'message' && <ComposeVisibility />}
<ComposeFormHeader id={titleId} noMinimize={noMinimize} />
{type === 'message' && (
<p className={classes.toolbarMessage}>
<Icon id='lock-open' icon={LockSimpleOpenIcon} />
<FormattedMessage
id='compose.message.notice'
defaultMessage='Messages are not end-to-end encrypted'
/>
</p>
)}
<div className={classes.toolbar}>
<div className={classes.flexGrowWrap}>
{type !== 'message' && <ComposeVisibility />}
{type === 'message' && (
<p className={classes.toolbarMessage}>
<Icon id='lock-open' icon={LockSimpleOpenIcon} />
<FormattedMessage
id='compose.message.notice'
defaultMessage='Messages are not end-to-end encrypted'
/>
</p>
)}
</div>
<ToggleField
label={intl.formatMessage(messages.sensitive)}

View File

@@ -2,12 +2,11 @@ import { length } from 'stringz';
import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments';
import type { StatusVisibility } from '@/mastodon/models/status';
import type { ComposeType } from '@/mastodon/reducers/slices/composer';
import { createAppSelector } from '@/mastodon/store';
import { countableText } from '../util/counter';
export type ComposeType = 'post' | 'message' | 'reply';
export const selectComposePrivacy = createAppSelector(
[
(state) => state.compose.get('privacy') as StatusVisibility | null,

View File

@@ -14,11 +14,12 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
@include mixins.type-heading-sm;
flex-grow: 1;
}
}
@@ -26,21 +27,32 @@
display: flex;
gap: var(--space-xs);
align-items: center;
flex-wrap: wrap;
label {
font-weight: inherit;
}
}
.toolbarGrow {
margin-right: auto;
.flexGrowWrap {
display: flex;
align-items: center;
gap: var(--space-xs);
flex-grow: 1;
@media (width < 310px) {
flex-basis: 100%;
}
.footer & {
justify-content: space-between;
}
}
.toolbarMessage {
display: flex;
gap: var(--space-2xs);
align-items: center;
flex-grow: 1;
color: var(--color-text-secondary);
svg {
@@ -60,17 +72,29 @@
border: none;
width: 100%;
outline: none;
user-select: none;
}
.textarea {
flex-grow: 1;
> textarea {
@include mixins.type-body-lg;
border: none;
width: 100%;
outline: none;
}
}
.footer {
display: flex;
align-items: center;
gap: var(--space-xs);
flex-wrap: wrap;
}
.counter {
margin-left: auto;
font-family: var(--font-monospace);
color: var(--color-text-tertiary);
}
@@ -116,10 +140,16 @@
// Attachments
.mediaSingle {
min-width: 120px;
max-height: var(--max-media-height-large);
width: var(--width);
height: var(--height);
overflow: auto;
.mediaUpload {
aspect-ratio: var(--width) / var(--height);
width: var(--width);
min-width: 120px;
max-width: 100%;
height: var(--height);
max-height: var(--max-media-height-large);
}
}
.mediaGrid {

View File

@@ -0,0 +1,34 @@
@use '@/styles/mastodon/variables';
.button {
position: fixed;
bottom: var(--space-md);
right: var(--space-md);
}
.composer,
.composerMinimized {
position: fixed;
bottom: 0;
right: 0;
margin: var(--space-md);
box-sizing: border-box;
}
.composer {
width: min(560px, calc(100vw - var(--space-md) * 2));
min-height: 520px;
max-height: 80vh;
}
.composerMinimized {
width: min(320px, calc(100vw - var(--space-md) * 2));
padding: var(--space-md);
}
@media (width < #{variables.$mobile-menu-breakpoint}) {
.composer,
.composerMinimized {
bottom: var(--mobile-bottom-nav-height);
}
}

View File

@@ -0,0 +1,116 @@
/* eslint-disable jsx-a11y/no-autofocus */
import type React from 'react';
import { lazy, Suspense, useCallback, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import {
ChatCircleIcon,
NewspaperIcon,
PenNibIcon,
} from '@phosphor-icons/react';
import { IconButton } from '@/mastodon/components/button/redesign';
import { CircularProgress } from '@/mastodon/components/circular_progress';
import {
Dropdown,
DropdownItemButton,
DropdownPopover,
} from '@/mastodon/components/dropdown/redesign';
import { useToggle } from '@/mastodon/hooks/useToggle';
import { openNewComposer } from '@/mastodon/reducers/slices/composer';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { isRedesignEnabled } from '@/mastodon/utils/environment';
import { ComposeFormHeader } from './header';
import classes from './trigger.module.scss';
const ComposeLazyForm = lazy(() =>
import('./index').then(({ RedesignComposeForm }) => ({
default: RedesignComposeForm,
})),
);
export const ComposeRedesignButton: React.FC = () => {
const [ref, setRef] = useState<HTMLButtonElement | null>(null);
const [menuOpen, { onFalse: onMenuClose, onToggle: onMenuToggle }] =
useToggle();
const displayState = useAppSelector((state) => state.composer.displayState);
const dispatch = useAppDispatch();
const handleComposerOpen: React.MouseEventHandler<HTMLButtonElement> =
useCallback(
(event) => {
const {
currentTarget: { name },
} = event;
if (name === 'post' || name === 'message') {
dispatch(openNewComposer({ type: name }));
onMenuClose();
}
},
[dispatch, onMenuClose],
);
if (!isRedesignEnabled()) {
return null;
}
if (displayState === 'minimized') {
return (
<Dropdown className={classes.composerMinimized} elevation={2}>
<ComposeFormHeader />
</Dropdown>
);
}
if (displayState === 'showing') {
return (
<Suspense fallback={<CircularProgress strokeWidth={2} size={50} />}>
<ComposeLazyForm autoFocus className={classes.composer} />
</Suspense>
);
}
return (
<>
<IconButton
icon={PenNibIcon}
color='neutral'
ref={setRef}
onClick={onMenuToggle}
className={classes.button}
size='lg'
>
<FormattedMessage
id='compose.new'
defaultMessage='Write a new post or messsage'
/>
</IconButton>
<DropdownPopover
isOpen={menuOpen}
maxWidth={180}
reference={ref}
onClose={onMenuClose}
placement='top-end'
>
<DropdownItemButton
name='post'
onClick={handleComposerOpen}
leadingIcon={NewspaperIcon}
>
<FormattedMessage id='compose.new.post' defaultMessage='Post' />
</DropdownItemButton>
<DropdownItemButton
name='message'
onClick={handleComposerOpen}
leadingIcon={ChatCircleIcon}
>
<FormattedMessage id='compose.new.message' defaultMessage='Message' />
</DropdownItemButton>
</DropdownPopover>
</>
);
};

View File

@@ -47,12 +47,7 @@ export const ComposeVisibility: React.FC = () => {
defaultMessage='To: {button}'
values={{
button: (
<Button
className={classes.toolbarGrow}
size='sm'
onClick={onToggle}
ref={setTrigger}
>
<Button size='sm' onClick={onToggle} ref={setTrigger}>
{privacy !== 'private' && (
<FormattedMessage
id='privacy.public.short'

View File

@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { lazy, PureComponent, Suspense } from 'react';
import { defineMessages } from 'react-intl';
@@ -23,6 +23,7 @@ import { PictureInPicture } from 'mastodon/features/picture_in_picture';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { layoutFromWindow } from 'mastodon/is_mobile';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { isRedesignEnabled } from '@/mastodon/utils/environment';
import { checkAnnualReport } from '@/mastodon/reducers/slices/annual_report';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
@@ -269,9 +270,13 @@ class SwitchingColumnsArea extends PureComponent {
</ColumnsContextProvider>
);
}
}
const LazyRedesignComposeButton = lazy(
() => import('@/mastodon/features/compose/redesign/trigger')
.then(({ ComposeRedesignButton }) => ({ default: ComposeRedesignButton }))
);
class UI extends PureComponent {
static propTypes = {
identity: identityContextPropShape,
@@ -665,6 +670,12 @@ class UI extends PureComponent {
<LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
{isRedesignEnabled() && (
<Suspense>
<LazyRedesignComposeButton />
</Suspense>
)}
</div>
</Hotkeys>
);

View File

@@ -493,11 +493,16 @@
"compose.counter": "{current, number}/{max, number}",
"compose.discoverable": "Discoverable in public feeds & search results",
"compose.error.blank_post": "Post can't be blank.",
"compose.expand": "Show composer",
"compose.language.change": "Change language",
"compose.language.search": "Search languages...",
"compose.message.notice": "Messages are not end-to-end encrypted",
"compose.message.placeholder": "Add your recipients and your message.",
"compose.message.publish": "Send",
"compose.minimize": "Minimize composer",
"compose.new": "Write a new post or messsage",
"compose.new.message": "Message",
"compose.new.post": "Post",
"compose.poll.delete": "Delete poll",
"compose.poll.duration": "Duration: {button}",
"compose.poll.multiple": "Allow multiple selections",

View File

@@ -116,6 +116,10 @@ function statusToTextMentions(state, status) {
return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
}
/**
* @param {typeof initialState} state
* @returns {typeof initialState}
*/
function clearAll(state) {
return state.withMutations(map => {
map.set('id', null);

View File

@@ -0,0 +1,85 @@
import { createSlice } from '@reduxjs/toolkit';
import {
directCompose,
replyComposeById,
resetCompose,
} from '@/mastodon/actions/compose';
import { changeComposeVisibility } from '@/mastodon/actions/compose_typed';
import {
createAppSelector,
createAppThunk,
} from '@/mastodon/store/typed_functions';
type DisplayState = 'hidden' | 'showing' | 'minimized';
export type ComposeType = 'post' | 'message' | 'reply';
interface ComposerState {
displayState: DisplayState;
}
const initialState: ComposerState = {
displayState: 'hidden',
};
const composerSlice = createSlice({
name: 'composer',
initialState,
reducers: {
showComposer(state) {
state.displayState = 'showing';
},
minimizeComposerToggle(state) {
state.displayState =
state.displayState === 'showing' ? 'minimized' : 'showing';
},
hideComposer(state) {
state.displayState = 'hidden';
},
},
});
export const composer = composerSlice.reducer;
export const { minimizeComposerToggle } = composerSlice.actions;
interface ComposeNewPost {
type?: 'post';
}
interface ComposeNewReply {
type: 'reply';
toStatusId: string;
}
interface ComposeNewMessage {
type: 'message';
toAccountId?: string;
}
type ComposeNewPayload = ComposeNewPost | ComposeNewReply | ComposeNewMessage;
export const openNewComposer = createAppThunk(
(payload: ComposeNewPayload, { dispatch, getState }) => {
dispatch(resetCompose());
if (payload.type === 'message') {
const account =
!!payload.toAccountId && getState().accounts.get(payload.toAccountId);
if (account) {
dispatch(directCompose(account));
} else {
dispatch(changeComposeVisibility('direct'));
}
} else if (payload.type === 'reply') {
dispatch(replyComposeById(payload.toStatusId));
}
dispatch(composerSlice.actions.showComposer());
},
);
export const hideComposer = createAppThunk((_arg, { dispatch }) => {
dispatch(composerSlice.actions.hideComposer());
dispatch(resetCompose());
});
export const selectIsMinimized = createAppSelector(
[(state) => state.composer.displayState],
(displayState) => displayState === 'minimized',
);

View File

@@ -1,11 +1,13 @@
import { annualReport } from './annual_report';
import { collections } from './collections';
import { composer } from './composer';
import { emojis } from './emojis';
import { profileEdit } from './profile_edit';
export const sliceReducers = {
annualReport,
collections,
composer,
emojis,
profileEdit,
};