From a95a375681086b2ffa206dc0996b0c0423accbf8 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 10 Aug 2026 17:29:01 +0200 Subject: [PATCH] Redesign composer (#40001) --- .../features/compose/redesign/attachments.tsx | 61 ++++ .../features/compose/redesign/footer.tsx | 182 +++++++++++ .../features/compose/redesign/header.tsx | 57 ++++ .../compose/redesign/index.stories.tsx | 71 +++++ .../features/compose/redesign/index.tsx | 294 ++++++++++++++++++ .../features/compose/redesign/language.tsx | 130 ++++++++ .../features/compose/redesign/poll.tsx | 145 +++++++++ .../features/compose/redesign/selectors.ts | 138 ++++++++ .../compose/redesign/styles.module.scss | 228 ++++++++++++++ .../features/compose/redesign/upload.tsx | 142 +++++++++ .../features/compose/redesign/visibility.tsx | 209 +++++++++++++ app/javascript/mastodon/locales/en.json | 26 ++ 12 files changed, 1683 insertions(+) create mode 100644 app/javascript/mastodon/features/compose/redesign/attachments.tsx create mode 100644 app/javascript/mastodon/features/compose/redesign/footer.tsx create mode 100644 app/javascript/mastodon/features/compose/redesign/header.tsx create mode 100644 app/javascript/mastodon/features/compose/redesign/index.stories.tsx create mode 100644 app/javascript/mastodon/features/compose/redesign/index.tsx create mode 100644 app/javascript/mastodon/features/compose/redesign/language.tsx create mode 100644 app/javascript/mastodon/features/compose/redesign/poll.tsx create mode 100644 app/javascript/mastodon/features/compose/redesign/selectors.ts create mode 100644 app/javascript/mastodon/features/compose/redesign/styles.module.scss create mode 100644 app/javascript/mastodon/features/compose/redesign/upload.tsx create mode 100644 app/javascript/mastodon/features/compose/redesign/visibility.tsx 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 !== 'message' && } + + {type === 'message' && ( +

+ + +

+ )} + + + + +
+ + {sensitive && ( + + )} + + + + + + + + ); +}; + +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",