diff --git a/app/javascript/mastodon/features/compose/redesign/attachments.tsx b/app/javascript/mastodon/features/compose/redesign/attachments.tsx
new file mode 100644
index 00000000000..d7e76509bcf
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/redesign/attachments.tsx
@@ -0,0 +1,61 @@
+import type React from 'react';
+
+import { useAppSelector } from '@/mastodon/store';
+
+import { ComposePoll } from './poll';
+import {
+ selectComposeAttachments,
+ selectComposeHasAttachments,
+} from './selectors';
+import classes from './styles.module.scss';
+import { ComposeUpload } from './upload';
+
+export const ComposeAttachments: React.FC = () => {
+ const { hasPoll, hasAttachments, quotedStatusId } = useAppSelector(
+ selectComposeHasAttachments,
+ );
+
+ if (!hasPoll && !hasAttachments && !quotedStatusId) {
+ return null;
+ }
+
+ return (
+ <>
+ {hasPoll && }
+ {hasAttachments && }
+ {quotedStatusId && }
+ >
+ );
+};
+
+const ComposeMediaAttachments: React.FC = () => {
+ const attachments = useAppSelector(selectComposeAttachments);
+ const pendingAttachments = useAppSelector((state) =>
+ Number(state.compose.get('pending_media_attachments')),
+ );
+ const totalAttachments = attachments.length + pendingAttachments;
+
+ if (totalAttachments === 1) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {attachments.map(({ id }) => (
+
+ ))}
+ {[...Array(pendingAttachments).keys()].map((_, index) => (
+
+ ))}
+
+ );
+};
+
+const ComposeQuotedStatus: React.FC<{ id: string }> = ({ id }) => {
+ return Quoting status {id}
;
+};
diff --git a/app/javascript/mastodon/features/compose/redesign/footer.tsx b/app/javascript/mastodon/features/compose/redesign/footer.tsx
new file mode 100644
index 00000000000..57723a3b3fb
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/redesign/footer.tsx
@@ -0,0 +1,182 @@
+import type React from 'react';
+import { useCallback, useRef } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import {
+ ImageSquareIcon,
+ SmileyIcon,
+ ChartBarHorizontalIcon,
+} from '@phosphor-icons/react';
+
+import { addPoll, uploadCompose } from '@/mastodon/actions/compose';
+import { Button, IconButton } from '@/mastodon/components/button/redesign';
+import {
+ createAppSelector,
+ useAppDispatch,
+ useAppSelector,
+} from '@/mastodon/store';
+
+import {
+ selectComposeAttachments,
+ selectComposeCanSubmit,
+ selectComposeCharsCount,
+ selectComposeHasAttachments,
+ selectComposeType,
+} from './selectors';
+import classes from './styles.module.scss';
+
+export const ComposeFooter: React.FC = () => {
+ const type = useAppSelector(selectComposeType);
+ const { current, max } = useAppSelector(selectComposeCharsCount);
+ const { hasPoll, quotedStatusId } = useAppSelector(
+ selectComposeHasAttachments,
+ );
+ const hasQuote = !!quotedStatusId;
+ const isSubmitting = useAppSelector(
+ (state) => !!state.compose.get('is_submitting'),
+ );
+ const canSubmit = useAppSelector(selectComposeCanSubmit);
+
+ const dispatch = useAppDispatch();
+ const handlePoll = useCallback(() => {
+ dispatch(addPoll());
+ }, [dispatch]);
+
+ return (
+
+ );
+};
+
+const selectUpload = createAppSelector(
+ [
+ (state) =>
+ state.media_attachments.get('accept_content_types') as
+ | Immutable.List
+ | undefined,
+ (state) => !!state.compose.get('is_uploading'),
+ selectComposeAttachments,
+ (state) => state.compose.get('pending_media_attachments') as number,
+ (state) =>
+ state.server.server.item?.configuration.statuses.max_media_attachments ??
+ 4,
+ (state) => state.compose.get('resetFileKey') as number,
+ ],
+ (
+ fileTypes,
+ isUploading,
+ attachments,
+ pendingAttachments,
+ maxAttachments,
+ resetFileKey,
+ ) => {
+ const hasVideoOrAudio = attachments.some(
+ (attachment) =>
+ attachment.type === 'audio' || attachment.type === 'video',
+ );
+ return {
+ accepted: (fileTypes?.toArray() ?? []).join(','),
+ loading: isUploading || pendingAttachments > 0,
+ disabled:
+ attachments.length + pendingAttachments >= maxAttachments ||
+ hasVideoOrAudio,
+ resetFileKey,
+ };
+ },
+);
+
+const ComposeUploadButton: React.FC<{ disabled?: boolean }> = ({
+ disabled: disabledProp,
+}) => {
+ const { accepted, disabled, loading, resetFileKey } =
+ useAppSelector(selectUpload);
+
+ const ref = useRef(null);
+ const handleClick = useCallback(() => {
+ ref.current?.click();
+ }, []);
+
+ const dispatch = useAppDispatch();
+ const handleChange: React.ChangeEventHandler = useCallback(
+ (event) => {
+ const files = event.target.files;
+ if (files?.length) {
+ dispatch(uploadCompose(files));
+ }
+ },
+ [dispatch],
+ );
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
diff --git a/app/javascript/mastodon/features/compose/redesign/header.tsx b/app/javascript/mastodon/features/compose/redesign/header.tsx
new file mode 100644
index 00000000000..10a7e095819
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/redesign/header.tsx
@@ -0,0 +1,57 @@
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+
+import { XIcon } from '@phosphor-icons/react';
+
+import { IconButton } from '@/mastodon/components/button/redesign';
+import { createAppSelector, useAppSelector } from '@/mastodon/store';
+
+import { selectComposeType } from './selectors';
+import classes from './styles.module.scss';
+
+const messages = defineMessages({
+ postNew: {
+ id: 'compose.post.title.new',
+ defaultMessage: 'New post',
+ },
+ postEdit: {
+ id: 'compose.post.title.edit',
+ defaultMessage: 'Edit post',
+ },
+ replyNew: {
+ id: 'compose.reply.title.new',
+ defaultMessage: 'New reply',
+ },
+ replyEdit: {
+ id: 'compose_form.reply.title.edit',
+ defaultMessage: 'Edit reply',
+ },
+ messageNew: {
+ id: 'compose_form.message.title.new',
+ defaultMessage: 'New message',
+ },
+ messageEdit: {
+ id: 'compose_form.message.title.edit',
+ defaultMessage: 'Edit message',
+ },
+});
+
+const selectComposeFormTitle = createAppSelector(
+ [selectComposeType, (state) => state.compose.get('id') as null | string],
+ (type, id) => {
+ return messages[`${type}${id ? 'Edit' : 'New'}`];
+ },
+);
+
+export const ComposeFormHeader: React.FC<{ id?: string }> = ({ id }) => {
+ const intl = useIntl();
+ const titleMessage = useAppSelector(selectComposeFormTitle);
+
+ return (
+
+ {intl.formatMessage(titleMessage)}
+
+
+
+
+ );
+};
diff --git a/app/javascript/mastodon/features/compose/redesign/index.stories.tsx b/app/javascript/mastodon/features/compose/redesign/index.stories.tsx
new file mode 100644
index 00000000000..77548483c4a
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/redesign/index.stories.tsx
@@ -0,0 +1,71 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { RedesignComposeForm } from '.';
+
+const meta = {
+ title: 'Redesign/Compose',
+ component: RedesignComposeForm,
+ render() {
+ return (
+
+
+
+ );
+ },
+ parameters: {
+ redesign: true,
+ state: {
+ media_attachments: {
+ accept_content_types: [
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ 'image/webp',
+ 'image/avif',
+ 'video/mp4',
+ 'video/quicktime',
+ 'video/ogg',
+ 'audio/wave',
+ 'audio/ogg',
+ 'audio/mp3',
+ ],
+ },
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Post: Story = {};
+
+export const PostPending: Story = {
+ parameters: {
+ state: {
+ compose: {
+ pending_media_attachments: 2,
+ },
+ },
+ },
+};
+
+export const Reply: Story = {
+ parameters: {
+ state: {
+ compose: {
+ in_reply_to: '1',
+ },
+ },
+ },
+};
+
+export const Message: Story = {
+ parameters: {
+ state: {
+ compose: {
+ privacy: 'direct',
+ },
+ },
+ },
+};
diff --git a/app/javascript/mastodon/features/compose/redesign/index.tsx b/app/javascript/mastodon/features/compose/redesign/index.tsx
new file mode 100644
index 00000000000..229a91b5fab
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/redesign/index.tsx
@@ -0,0 +1,294 @@
+import type React from 'react';
+import { useCallback, useEffect, useId, useRef } from 'react';
+
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+
+import { LockSimpleOpenIcon } from '@phosphor-icons/react';
+import type { TextareaAutosizeProps } from 'react-textarea-autosize';
+
+import {
+ changeCompose,
+ changeComposeSpoilerness,
+ changeComposeSpoilerText,
+ clearComposeSuggestions,
+ fetchComposeSuggestions,
+ selectComposeSuggestion,
+} from '@/mastodon/actions/compose';
+import {
+ processPasteOrDrop,
+ submitCompose,
+} from '@/mastodon/actions/compose_typed';
+import AutosuggestTextarea from '@/mastodon/components/autosuggest_textarea';
+import {
+ ToggleField,
+ TextInputField,
+} from '@/mastodon/components/form_fields/redesign';
+import { Icon } from '@/mastodon/components/icon';
+import { useAppDispatch, useAppSelector } from '@/mastodon/store';
+
+import { ComposeAttachments } from './attachments';
+import { ComposeFooter } from './footer';
+import { ComposeFormHeader } from './header';
+import { LanguageButton } from './language';
+import { selectComposeCanSubmit, selectComposeState } from './selectors';
+import classes from './styles.module.scss';
+import { ComposeVisibility } from './visibility';
+
+const messages = defineMessages({
+ sensitive: {
+ id: 'compose.sensitive',
+ defaultMessage: 'Sensitive',
+ },
+ sensitiveText: {
+ id: 'compose.sensitive.text',
+ defaultMessage: 'Sensitive content description',
+ },
+ placeholder: {
+ id: 'compose.post.placeholder',
+ defaultMessage: 'What would you like to say?',
+ },
+ messagePlaceholder: {
+ id: 'compose.message.placeholder',
+ defaultMessage: 'Add your recipients and your message.',
+ },
+});
+
+interface RedesignComposeFormProps {
+ autoFocus?: boolean;
+ redirectOnSuccess?: boolean;
+}
+
+export const RedesignComposeForm: React.FC = ({
+ autoFocus,
+ redirectOnSuccess,
+}) => {
+ const {
+ type,
+ sensitive,
+ sensitiveText,
+ suggestions,
+ text,
+ lang,
+ isSubmitting,
+ } = useAppSelector(selectComposeState);
+
+ const {
+ textAreaRef,
+ onSensitiveChange,
+ onSensitiveTextChange,
+ onSubmit,
+ ...handlers
+ } = useHandlers(redirectOnSuccess);
+
+ const intl = useIntl();
+ const titleId = useId();
+ return (
+
+ );
+};
+
+type SuggestSelectedHandler = (
+ position: number,
+ token: string,
+ suggestion: unknown,
+) => void;
+
+const ComposeTextarea = AutosuggestTextarea as React.ForwardRefExoticComponent<
+ {
+ suggestions: Immutable.List;
+ onSuggestionSelected: SuggestSelectedHandler;
+ onSuggestionsClearRequested: () => void;
+ onSuggestionsFetchRequested: (token: string) => void;
+ } & TextareaAutosizeProps &
+ React.RefAttributes
+>;
+
+function useHandlers(redirectOnSuccess?: boolean) {
+ const textAreaRef = useRef(null);
+
+ const dispatch = useAppDispatch();
+
+ // Focus the sensitive
+ const isSensitive = useAppSelector((state) => !!state.compose.get('spoiler'));
+ useEffect(() => {
+ if (!isSensitive) {
+ textAreaRef.current?.focus();
+ }
+ }, [isSensitive]);
+
+ // Sensitive toggles
+ const onSensitiveChange = useCallback(() => {
+ dispatch(changeComposeSpoilerness());
+ }, [dispatch]);
+ const onSensitiveTextChange: React.ChangeEventHandler =
+ useCallback(
+ (event) => {
+ dispatch(changeComposeSpoilerText(event.target.value));
+ },
+ [dispatch],
+ );
+
+ // Submit status
+
+ const canSubmit = useAppSelector(selectComposeCanSubmit);
+ const onSubmit = useCallback(
+ (event?: React.SubmitEvent) => {
+ if (!canSubmit) {
+ return;
+ }
+ dispatch(
+ submitCompose({
+ textareaValue: textAreaRef.current?.value,
+ redirectOnSuccess,
+ }),
+ );
+
+ if (event) {
+ event.preventDefault();
+ }
+ },
+ [canSubmit, dispatch, redirectOnSuccess],
+ );
+
+ // Text changes
+
+ const onChange: React.ChangeEventHandler = useCallback(
+ (event) => {
+ dispatch(changeCompose(event.target.value));
+ },
+ [dispatch],
+ );
+ const onKeyDown: React.KeyboardEventHandler =
+ useCallback(
+ (event) => {
+ if (
+ event.key.toLowerCase() === 'enter' &&
+ (event.ctrlKey || event.metaKey)
+ ) {
+ onSubmit();
+ event.preventDefault();
+ }
+ blurOnEscape(event);
+ },
+ [onSubmit],
+ );
+ const onPaste: React.ClipboardEventHandler = useCallback(
+ (event) => {
+ if (event.clipboardData.files.length === 1) {
+ event.preventDefault();
+ }
+ dispatch(processPasteOrDrop(event.clipboardData));
+ },
+ [dispatch],
+ );
+ const onDrop: React.DragEventHandler = useCallback(
+ (event) => {
+ if (event.dataTransfer.files.length === 1) {
+ event.preventDefault();
+ }
+ dispatch(processPasteOrDrop(event.dataTransfer));
+ },
+ [dispatch],
+ );
+
+ // Suggestions
+
+ const onSuggestionsFetchRequested = useCallback(
+ (token: string) => {
+ dispatch(fetchComposeSuggestions(token));
+ },
+ [dispatch],
+ );
+ const onSuggestionsClearRequested = useCallback(() => {
+ dispatch(clearComposeSuggestions());
+ }, [dispatch]);
+ const onSuggestionSelected: SuggestSelectedHandler = useCallback(
+ (position, token, suggestion) => {
+ dispatch(selectComposeSuggestion(position, token, suggestion));
+ },
+ [dispatch],
+ );
+
+ return {
+ textAreaRef,
+ onSubmit,
+ onChange,
+ onKeyDown,
+ onPaste,
+ onDrop,
+ onSensitiveChange,
+ onSensitiveTextChange,
+ onSuggestionsFetchRequested,
+ onSuggestionsClearRequested,
+ onSuggestionSelected,
+ };
+}
+
+function blurOnEscape(event: React.KeyboardEvent) {
+ if (
+ ['esc', 'escape'].includes(event.key.toLowerCase()) &&
+ event.target instanceof HTMLTextAreaElement
+ ) {
+ event.target.blur();
+ }
+}
diff --git a/app/javascript/mastodon/features/compose/redesign/language.tsx b/app/javascript/mastodon/features/compose/redesign/language.tsx
new file mode 100644
index 00000000000..919eff2f780
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/redesign/language.tsx
@@ -0,0 +1,130 @@
+import type React from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import { TranslateIcon } from '@phosphor-icons/react';
+
+import { changeComposeLanguage } from '@/mastodon/actions/compose';
+import { IconButton } from '@/mastodon/components/button/redesign';
+import { Dropdown } from '@/mastodon/components/dropdown/redesign';
+import type { PopoverChildProps } from '@/mastodon/components/popover';
+import { Popover } from '@/mastodon/components/popover';
+import { useAppDispatch, useAppSelector } from '@/mastodon/store';
+
+import { LanguageDropdownMenu } from '../components/language_dropdown';
+
+import classes from './styles.module.scss';
+
+export const LanguageButton: React.FC = () => {
+ const [open, setOpen] = useState(false);
+ const [trigger, setTrigger] = useState(null);
+ const activeElementRef = useRef(null);
+
+ const handleMouseDown = useCallback(() => {
+ if (!open && document.activeElement instanceof HTMLElement) {
+ activeElementRef.current = document.activeElement;
+ }
+ }, [open]);
+
+ const handleToggle = useCallback(() => {
+ if (open && activeElementRef.current)
+ activeElementRef.current.focus({ preventScroll: true });
+
+ setOpen(!open);
+ }, [open]);
+
+ const handleClose = useCallback(() => {
+ if (open && activeElementRef.current)
+ activeElementRef.current.focus({ preventScroll: true });
+
+ setOpen(false);
+ }, [open]);
+
+ return (
+ <>
+
+
+
+
+
+ {({ props }) => }
+
+ >
+ );
+};
+
+export const LanguageDropdown: React.FC<
+ PopoverChildProps & { onClose: () => void }
+> = ({ onClose, ...props }) => {
+ const language = useAppSelector(
+ (state) => state.compose.get('language') as string,
+ );
+ const guess = useLanguageGuess();
+
+ const dispatch = useAppDispatch();
+ const handleChange = useCallback(
+ (newLanguage: string) => {
+ dispatch(changeComposeLanguage(newLanguage));
+ onClose();
+ },
+ [dispatch, onClose],
+ );
+
+ return (
+
+
+
+ );
+};
+
+function useLanguageGuess() {
+ const text = useAppSelector((state) => state.compose.get('text') as string);
+ const [guess, setGuess] = useState('');
+
+ useEffect(() => {
+ void import('../util/language_detection').then(({ debouncedGuess }) => {
+ if (text.length > 20) {
+ debouncedGuess(text, setGuess);
+ } else {
+ debouncedGuess.cancel();
+ }
+ });
+ }, [text]);
+
+ // Keeping track of the previous render's text length here
+ // to be able to reset the guess when the text length drops
+ // below the threshold needed to make a guess
+ const isLongText = text.length > 20;
+ const [wasLongText, setWasLongText] = useState(() => isLongText);
+ if (wasLongText !== isLongText) {
+ setWasLongText(isLongText);
+
+ if (wasLongText) {
+ setGuess('');
+ }
+ }
+
+ return guess;
+}
diff --git a/app/javascript/mastodon/features/compose/redesign/poll.tsx b/app/javascript/mastodon/features/compose/redesign/poll.tsx
new file mode 100644
index 00000000000..038cdd04a7f
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/redesign/poll.tsx
@@ -0,0 +1,145 @@
+import { useCallback } from 'react';
+
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+
+import {
+ changePollSettings,
+ removePoll,
+ changePollOption,
+} from '@/mastodon/actions/compose';
+import { Button } from '@/mastodon/components/button/redesign';
+import {
+ ToggleField,
+ TextInput,
+} from '@/mastodon/components/form_fields/redesign';
+import { useAppSelector, useAppDispatch } from '@/mastodon/store';
+
+import { selectComposePoll } from './selectors';
+import classes from './styles.module.scss';
+
+const messages = defineMessages({
+ option_placeholder: {
+ id: 'compose_form.poll.option_placeholder',
+ defaultMessage: 'Option {number}',
+ },
+ minutes: {
+ id: 'intervals.full.minutes',
+ defaultMessage: '{number, plural, one {# minute} other {# minutes}}',
+ },
+ hours: {
+ id: 'intervals.full.hours',
+ defaultMessage: '{number, plural, one {# hour} other {# hours}}',
+ },
+ days: {
+ id: 'intervals.full.days',
+ defaultMessage: '{number, plural, one {# day} other {# days}}',
+ },
+});
+
+export const ComposePoll: React.FC = () => {
+ const poll = useAppSelector(selectComposePoll);
+
+ const dispatch = useAppDispatch();
+ const handlePollChangeMultiple: React.ChangeEventHandler =
+ useCallback(
+ (event) => {
+ dispatch(changePollSettings(poll?.expiresIn, event.target.checked));
+ },
+ [dispatch, poll?.expiresIn],
+ );
+ const handleDelete = useCallback(() => {
+ dispatch(removePoll());
+ }, [dispatch]);
+
+ if (!poll) {
+ return null;
+ }
+
+ return (
+
+
+ {poll.options.map((option, index) => (
+
+ ))}
+
+
+
+ }
+ />
+
+
+
+
+
+ ),
+ }}
+ tagName='span'
+ />
+
+
+
+
+ );
+};
+
+const ComposePollOption: React.FC<{ index: number; value: string }> = ({
+ index,
+ value,
+}) => {
+ const intl = useIntl();
+ const maxOptions = useAppSelector(
+ (state) => state.server.server.item?.configuration.polls.max_options ?? 4,
+ );
+
+ const dispatch = useAppDispatch();
+ const handleChange: React.ChangeEventHandler = useCallback(
+ (event) => {
+ dispatch(changePollOption(index, event.target.value, maxOptions));
+ },
+ [dispatch, index, maxOptions],
+ );
+
+ return (
+
+
+
+ );
+};
diff --git a/app/javascript/mastodon/features/compose/redesign/selectors.ts b/app/javascript/mastodon/features/compose/redesign/selectors.ts
new file mode 100644
index 00000000000..8b4a8773ad8
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/redesign/selectors.ts
@@ -0,0 +1,138 @@
+import { length } from 'stringz';
+
+import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments';
+import type { StatusVisibility } from '@/mastodon/models/status';
+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,
+ (state) => state.compose.get('default_privacy') as StatusVisibility,
+ ],
+ (privacy, defaultPrivacy) => privacy ?? defaultPrivacy,
+);
+
+export const selectComposeType = createAppSelector(
+ [
+ (state) => state.compose.get('in_reply_to') as string | null,
+ selectComposePrivacy,
+ ],
+ (inReplyToId, privacy) => {
+ let type: ComposeType = 'post';
+ if (inReplyToId) {
+ type = 'reply';
+ } else if (privacy === 'direct') {
+ type = 'message';
+ }
+
+ return type;
+ },
+);
+
+export const selectComposeCharsCount = createAppSelector(
+ [
+ (state) => state.server.server.item?.configuration.statuses.max_characters,
+ (state) => state.compose.get('text') as string,
+ (state) =>
+ state.compose.get('spoiler')
+ ? (state.compose.get('spoiler_text') as string)
+ : '',
+ ],
+ (maxChars, text, spoilerText) => {
+ const allText = (countableText(text) as string) + spoilerText;
+ return {
+ text: allText,
+ current: length(allText),
+ max: maxChars ?? 500,
+ };
+ },
+);
+
+export const selectComposeCanSubmit = createAppSelector(
+ [
+ (state) => !!state.compose.get('is_submitting'),
+ (state) => !!state.compose.get('is_uploading'),
+ (state) => !!state.compose.get('is_changing_upload'),
+ selectComposeCharsCount,
+ ],
+ (isSubmitting, isUploading, isChangingUpload, { current, max }) =>
+ !isSubmitting && !isUploading && !isChangingUpload && current <= max,
+);
+
+export const selectComposeState = createAppSelector(
+ [(state) => state.compose, selectComposeType, selectComposeCanSubmit],
+ (compose, type, canSubmit) => ({
+ type,
+ text: compose.get('text') as string,
+ sensitive: !!compose.get('spoiler'),
+ sensitiveText: compose.get('spoiler_text') as string,
+ lang: compose.get('language') as string,
+ suggestions: compose.get(
+ 'suggestions',
+ ) as unknown as Immutable.List,
+ canSubmit,
+ isSubmitting: !!compose.get('is_submitting'),
+ }),
+);
+
+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
+ | 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 = ApiMediaAttachmentJSON & {
+ file?: File;
+ unattached: boolean;
+};
+
+export const selectComposeAttachments = createAppSelector(
+ [
+ (state) =>
+ state.compose.get('media_attachments') as
+ | Immutable.List
+ | undefined,
+ ],
+ (attachments) => {
+ if (!attachments) {
+ return [];
+ }
+ return attachments.toJS() as ComposeAttachment[];
+ },
+);
+
+export const selectComposePoll = createAppSelector(
+ [
+ (state) =>
+ state.compose.get('poll') as Immutable.Map | null,
+ ],
+ (rawPoll) => {
+ if (rawPoll === null) {
+ return null;
+ }
+
+ return {
+ options: (rawPoll.get('options') as Immutable.List).toArray(),
+ expiresIn: Number(rawPoll.get('expires_in')),
+ multiple: !!rawPoll.get('multiple'),
+ };
+ },
+);
diff --git a/app/javascript/mastodon/features/compose/redesign/styles.module.scss b/app/javascript/mastodon/features/compose/redesign/styles.module.scss
new file mode 100644
index 00000000000..b35cb80f20c
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/redesign/styles.module.scss
@@ -0,0 +1,228 @@
+@use '@/styles/mastodon/mixins';
+
+.root {
+ @include mixins.elevation-2;
+
+ border-radius: var(--radius-xl);
+ background: var(--color-bg-primary);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+ padding: var(--space-md);
+}
+
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ h2 {
+ @include mixins.type-heading-sm;
+ }
+}
+
+.toolbar {
+ display: flex;
+ gap: var(--space-xs);
+ align-items: center;
+
+ label {
+ font-weight: inherit;
+ }
+}
+
+.toolbarGrow {
+ margin-right: auto;
+}
+
+.toolbarMessage {
+ display: flex;
+ gap: var(--space-2xs);
+ align-items: center;
+ flex-grow: 1;
+ color: var(--color-text-secondary);
+
+ svg {
+ width: var(--space-lg);
+ height: var(--space-lg);
+ }
+}
+
+.textarea > textarea {
+ @include mixins.type-body-lg;
+
+ border: none;
+ width: 100%;
+ outline: none;
+}
+
+.footer {
+ display: flex;
+ align-items: center;
+ gap: var(--space-xs);
+}
+
+.counter {
+ margin-left: auto;
+ font-family: var(--font-monospace);
+ color: var(--color-text-tertiary);
+}
+
+.visibilityFieldset {
+ display: block;
+
+ [data-label] {
+ @include mixins.type-label-sm;
+
+ color: var(--color-text-tertiary);
+ padding: var(--space-xs) var(--space-sm) 0;
+ }
+
+ [data-fields] {
+ display: block;
+ }
+
+ [role='status'] {
+ margin-top: 0;
+ }
+}
+
+.languageMenu {
+ padding: var(--space-xs) var(--space-sm);
+
+ :global(.emoji-mart-search) {
+ padding: 0;
+ padding-inline-end: 0;
+ }
+
+ :global(.emoji-mart-search-icon) {
+ top: 0;
+ inset-inline-end: 0;
+ }
+
+ :global(.emoji-mart-scroll) {
+ padding: 0;
+ margin-top: var(--space-xs);
+ }
+}
+
+// Attachments
+
+.mediaSingle {
+ min-width: 120px;
+ max-height: var(--max-media-height-large);
+ width: var(--width);
+ height: var(--height);
+}
+
+.mediaGrid {
+ display: grid;
+ gap: var(--space-3xs);
+ grid-template-columns: repeat(2, 1fr);
+ grid-template-rows: repeat(2, 1fr);
+ aspect-ratio: 3/2;
+
+ &[data-number='2'] {
+ grid-template-rows: 1fr;
+ }
+
+ &[data-number='3'] .mediaUpload:first-child {
+ grid-row: span 2;
+ }
+}
+
+.mediaUpload {
+ border-radius: var(--radius-md);
+ position: relative;
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+}
+
+.mediaUploadPending {
+ animation: pulse 1s ease-in-out infinite;
+ background-color: var(--color-bg-disabled);
+}
+
+@keyframes pulse {
+ 0% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.8;
+ }
+}
+
+.mediaMenuButton {
+ position: absolute;
+ top: var(--space-xs);
+ right: var(--space-xs);
+}
+
+.mediaAlt {
+ @include mixins.type-micro;
+
+ display: block;
+ padding: var(--space-2xs);
+ border-radius: var(--radius-xs);
+ background: var(--color-bg-inverted);
+ color: var(--color-text-inverted);
+ position: absolute;
+ bottom: var(--space-xs);
+ right: var(--space-xs);
+ text-transform: uppercase;
+}
+
+.mediaMenuDelete {
+ color: var(--color-text-error);
+}
+
+// Polls
+
+.poll {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-sm);
+ border: 0.5px solid var(--color-text-tertiary);
+ border-radius: var(--radius-md);
+ padding: var(--space-xs);
+
+ ol {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ counter-reset: poll;
+ width: 100%;
+ }
+
+ label {
+ @include mixins.type-label-sm;
+ }
+}
+
+.pollOption {
+ display: flex;
+ gap: var(--space-sm);
+ align-items: center;
+ counter-increment: poll;
+
+ &::before {
+ @include mixins.type-micro;
+
+ content: counter(poll, alpha);
+ width: 8px;
+ padding: var(--space-2xs);
+ border-radius: var(--radius-xs);
+ background: var(--color-bg-highlight);
+ }
+}
+
+.pollMultipleToggle {
+ align-self: flex-start;
+}
+
+.pollControls {
+ display: flex;
+ justify-content: space-between;
+}
diff --git a/app/javascript/mastodon/features/compose/redesign/upload.tsx b/app/javascript/mastodon/features/compose/redesign/upload.tsx
new file mode 100644
index 00000000000..2fc743f107b
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/redesign/upload.tsx
@@ -0,0 +1,142 @@
+import { useCallback, useState } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import classNames from 'classnames';
+
+import { DotsThreeIcon, PlusIcon, TrashIcon } from '@phosphor-icons/react';
+
+import { undoUploadCompose } from '@/mastodon/actions/compose';
+import { openModal } from '@/mastodon/actions/modal';
+import { IconButton } from '@/mastodon/components/button/redesign';
+import {
+ DropdownItemButton,
+ DropdownPopover,
+} from '@/mastodon/components/dropdown/redesign';
+import { useToggle } from '@/mastodon/hooks/useToggle';
+import {
+ createAppSelector,
+ useAppDispatch,
+ useAppSelector,
+} from '@/mastodon/store';
+
+import { selectComposeAttachments } from './selectors';
+import classes from './styles.module.scss';
+
+const selectAttachment = createAppSelector(
+ [selectComposeAttachments, (_, id?: string) => id],
+ (attachments, id) => {
+ if (!id) {
+ return null;
+ }
+ return attachments.find((attachment) => attachment.id === id) ?? null;
+ },
+);
+
+export const ComposeUpload: React.FC<{ id?: string; className?: string }> = ({
+ id,
+ className,
+}) => {
+ const attachment = useAppSelector((state) => selectAttachment(state, id));
+ const [open, { onToggle, onFalse }] = useToggle();
+ const [target, setTarget] = useState(null);
+
+ const dispatch = useAppDispatch();
+ const handleEdit = useCallback(() => {
+ if (id) {
+ dispatch(
+ openModal({ modalType: 'FOCAL_POINT', modalProps: { mediaId: id } }),
+ );
+ }
+ }, [dispatch, id]);
+ const handleDelete = useCallback(() => {
+ if (id) {
+ dispatch(undoUploadCompose(id));
+ }
+ }, [dispatch, id]);
+
+ if (!attachment || attachment.type === 'unknown') {
+ return ;
+ }
+
+ let x = 50;
+ let y = 50;
+ if (
+ attachment.type === 'image' ||
+ attachment.type === 'gifv' ||
+ attachment.type === 'video'
+ ) {
+ const focusX = attachment.meta.focus?.x;
+ const focusY = attachment.meta.focus?.y;
+ if (focusX && focusY) {
+ x = (focusX / 2 + 0.5) * 100;
+ y = (focusY / -2 + 0.5) * 100;
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {attachment.description && (
+
+
+
+ )}
+
+ );
+};
diff --git a/app/javascript/mastodon/features/compose/redesign/visibility.tsx b/app/javascript/mastodon/features/compose/redesign/visibility.tsx
new file mode 100644
index 00000000000..1937873a627
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/redesign/visibility.tsx
@@ -0,0 +1,209 @@
+import type React from 'react';
+import { useCallback, useState } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import { ChatCircleIcon } from '@phosphor-icons/react';
+
+import {
+ changeComposeVisibility,
+ setComposeQuotePolicy,
+} from '@/mastodon/actions/compose_typed';
+import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
+import type { StatusVisibility } from '@/mastodon/api_types/statuses';
+import { Button } from '@/mastodon/components/button/redesign';
+import {
+ Dropdown,
+ DropdownItem,
+ DropdownItemButton,
+} from '@/mastodon/components/dropdown/redesign';
+import { Fieldset, RadioButtonField } from '@/mastodon/components/form_fields';
+import { ToggleField } from '@/mastodon/components/form_fields/redesign';
+import { Popover } from '@/mastodon/components/popover';
+import { useToggle } from '@/mastodon/hooks/useToggle';
+import { useAppDispatch, useAppSelector } from '@/mastodon/store';
+
+import { selectComposePrivacy } from './selectors';
+import classes from './styles.module.scss';
+
+export const ComposeVisibility: React.FC = () => {
+ const privacy = useAppSelector(selectComposePrivacy);
+ const [trigger, setTrigger] = useState(null);
+ const [showMenu, { onToggle, onFalse }] = useToggle();
+
+ return (
+ <>
+
+ {privacy !== 'private' && (
+
+ )}
+ {privacy === 'private' && (
+
+ )}
+
+ ),
+ }}
+ />
+
+ {({ props }) => }
+
+ >
+ );
+};
+
+const ComposeVisibilityMenu: React.FC> = (
+ wrapperProps,
+) => {
+ const privacy = useAppSelector(selectComposePrivacy);
+ const defaultPrivacy = useAppSelector(
+ (state) => state.compose.get('default_privacy') as StatusVisibility,
+ );
+ const quotePolicy = useAppSelector(
+ (state) =>
+ (state.compose.get('quote_policy') as ApiQuotePolicy | undefined) ??
+ (state.compose.get('default_quote_policy') as ApiQuotePolicy),
+ );
+
+ const dispatch = useAppDispatch();
+ const handlePrivacyChange: React.ChangeEventHandler =
+ useCallback(
+ (event) => {
+ const { name } = event.target;
+ if (name === 'private' && privacy !== 'private') {
+ dispatch(changeComposeVisibility(name));
+ } else if (name === 'public' && privacy === 'private') {
+ dispatch(
+ changeComposeVisibility(
+ defaultPrivacy === 'unlisted' ? 'unlisted' : 'public',
+ ),
+ );
+ } else if (name === 'unlisted' && privacy !== 'private') {
+ dispatch(
+ changeComposeVisibility(
+ privacy === 'public' ? 'unlisted' : 'public',
+ ),
+ );
+ }
+ },
+ [defaultPrivacy, dispatch, privacy],
+ );
+ const handleQuotePolicyChange: React.ChangeEventHandler =
+ useCallback(
+ (event) => {
+ const checked = event.target.checked;
+ dispatch(setComposeQuotePolicy(checked ? 'public' : 'nobody'));
+ },
+ [dispatch],
+ );
+ const handleSwitchToMessage: React.MouseEventHandler =
+ useCallback(() => {
+ dispatch(changeComposeVisibility('direct'));
+ }, [dispatch]);
+
+ return (
+
+
+ }
+ className={classes.visibilityFieldset}
+ >
+
+
+ }
+ checked={privacy === 'public' || privacy === 'unlisted'}
+ onChange={handlePrivacyChange}
+ />
+
+
+
+
+ }
+ checked={privacy === 'private'}
+ onChange={handlePrivacyChange}
+ />
+
+
+
+
+
+
+
+ }
+ disabled={privacy === 'private'}
+ checked={privacy === 'public'}
+ onChange={handlePrivacyChange}
+ size='sm'
+ />
+
+
+
+
+ }
+ disabled={privacy === 'private'}
+ checked={quotePolicy === 'public' && privacy !== 'private'}
+ onChange={handleQuotePolicyChange}
+ size='sm'
+ />
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index b7fb4c74fc3..526671a61a1 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -490,17 +490,42 @@
"community.column_settings.local_only": "Local only",
"community.column_settings.media_only": "Media Only",
"community.column_settings.remote_only": "Remote only",
+ "compose.counter": "{current, number}/{max, number}",
+ "compose.discoverable": "Discoverable in public feeds & search results",
"compose.error.blank_post": "Post can't be blank.",
"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.poll.delete": "Delete poll",
+ "compose.poll.duration": "Duration: {button}",
+ "compose.poll.multiple": "Allow multiple selections",
+ "compose.post.placeholder": "What would you like to say?",
+ "compose.post.title.edit": "Edit post",
+ "compose.post.title.new": "New post",
+ "compose.post.to": "To: {button}",
+ "compose.post.to_message": "Compose a message instead",
+ "compose.publish": "Publish",
"compose.published.body": "Post published.",
"compose.published.open": "Open",
+ "compose.quotable": "Allow others to quote",
+ "compose.reply.title.new": "New reply",
"compose.saved.body": "Post saved.",
+ "compose.sensitive": "Sensitive",
+ "compose.sensitive.text": "Sensitive content description",
+ "compose.upload.alt": "Alt",
+ "compose.upload.menu": "Add alt text or remove the image",
+ "compose.upload.menu.add_alt": "Add alt text",
+ "compose.upload.menu.delete": "Remove image",
+ "compose.visibility.title": "Visibility",
"compose_form.direct_message_warning_learn_more": "Learn more",
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.",
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
"compose_form.lock_disclaimer.lock": "locked",
+ "compose_form.message.title.edit": "Edit message",
+ "compose_form.message.title.new": "New message",
"compose_form.placeholder": "What's on your mind?",
"compose_form.poll.duration": "Poll duration",
"compose_form.poll.multiple": "Multiple choice",
@@ -511,6 +536,7 @@
"compose_form.poll.type": "Style",
"compose_form.publish": "Post",
"compose_form.reply": "Reply",
+ "compose_form.reply.title.edit": "Edit reply",
"compose_form.save_changes": "Update",
"compose_form.spoiler.marked": "Remove content warning",
"compose_form.spoiler.unmarked": "Add content warning",