diff --git a/app/javascript/mastodon/components/dropdown/redesign.module.scss b/app/javascript/mastodon/components/dropdown/redesign.module.scss index 54f8eddc2e1..84a9d4cda2f 100644 --- a/app/javascript/mastodon/components/dropdown/redesign.module.scss +++ b/app/javascript/mastodon/components/dropdown/redesign.module.scss @@ -81,3 +81,7 @@ border: none; width: calc(100% - (2 * var(--space-sm))); } + +.popoverMenu { + width: 100%; +} diff --git a/app/javascript/mastodon/components/dropdown/redesign.tsx b/app/javascript/mastodon/components/dropdown/redesign.tsx index 9037a71c6ac..215878f34e2 100644 --- a/app/javascript/mastodon/components/dropdown/redesign.tsx +++ b/app/javascript/mastodon/components/dropdown/redesign.tsx @@ -38,6 +38,7 @@ export const DropdownPopover = ({ matchReferenceWidth, closeOnClickOutside, children, + className, ...props }: DropdownProps & Omit) => { const popoverProps = { @@ -56,7 +57,14 @@ export const DropdownPopover = ({ return ( {({ props: popoverChildProps }) => ( - + {children} )} diff --git a/app/javascript/mastodon/features/compose/redesign/attachments.tsx b/app/javascript/mastodon/features/compose/redesign/attachments.tsx index d7e76509bcf..94d289fc1b1 100644 --- a/app/javascript/mastodon/features/compose/redesign/attachments.tsx +++ b/app/javascript/mastodon/features/compose/redesign/attachments.tsx @@ -37,10 +37,9 @@ const ComposeMediaAttachments: React.FC = () => { if (totalAttachments === 1) { return ( - +
+ +
); } diff --git a/app/javascript/mastodon/features/compose/redesign/footer.tsx b/app/javascript/mastodon/features/compose/redesign/footer.tsx index faa17ef56d5..897d6730600 100644 --- a/app/javascript/mastodon/features/compose/redesign/footer.tsx +++ b/app/javascript/mastodon/features/compose/redesign/footer.tsx @@ -61,30 +61,32 @@ export const ComposeFooter: React.FC<{ onEmojiPick: OnEmojiPick }> = ({ /> - - - - - + + + + ); }; diff --git a/app/javascript/mastodon/features/compose/redesign/header.tsx b/app/javascript/mastodon/features/compose/redesign/header.tsx index 10a7e095819..029169c29dd 100644 --- a/app/javascript/mastodon/features/compose/redesign/header.tsx +++ b/app/javascript/mastodon/features/compose/redesign/header.tsx @@ -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 (

{intl.formatMessage(titleMessage)}

- + + {!noMinimize && ( + + {isMinimized ? ( + + ) : ( + + )} + + )} + +
diff --git a/app/javascript/mastodon/features/compose/redesign/index.tsx b/app/javascript/mastodon/features/compose/redesign/index.tsx index b5c759d60d5..6aa17255f5d 100644 --- a/app/javascript/mastodon/features/compose/redesign/index.tsx +++ b/app/javascript/mastodon/features/compose/redesign/index.tsx @@ -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 = ({ autoFocus, + className, + noMinimize, redirectOnSuccess, }) => { const { @@ -90,21 +96,24 @@ export const RedesignComposeForm: React.FC = ({ role='dialog' onSubmit={onSubmit} aria-labelledby={titleId} - className={classes.root} + className={classNames(className, classes.root)} > - -
- {type !== 'message' && } + - {type === 'message' && ( -

- - -

- )} +
+
+ {type !== 'message' && } + + {type === 'message' && ( +

+ + +

+ )} +
state.compose.get('privacy') as StatusVisibility | null, diff --git a/app/javascript/mastodon/features/compose/redesign/styles.module.scss b/app/javascript/mastodon/features/compose/redesign/styles.module.scss index b4d66f8921e..fe4429a6e21 100644 --- a/app/javascript/mastodon/features/compose/redesign/styles.module.scss +++ b/app/javascript/mastodon/features/compose/redesign/styles.module.scss @@ -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 { diff --git a/app/javascript/mastodon/features/compose/redesign/trigger.module.scss b/app/javascript/mastodon/features/compose/redesign/trigger.module.scss new file mode 100644 index 00000000000..5ac85ab8c0a --- /dev/null +++ b/app/javascript/mastodon/features/compose/redesign/trigger.module.scss @@ -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); + } +} diff --git a/app/javascript/mastodon/features/compose/redesign/trigger.tsx b/app/javascript/mastodon/features/compose/redesign/trigger.tsx new file mode 100644 index 00000000000..91f5253c616 --- /dev/null +++ b/app/javascript/mastodon/features/compose/redesign/trigger.tsx @@ -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(null); + const [menuOpen, { onFalse: onMenuClose, onToggle: onMenuToggle }] = + useToggle(); + const displayState = useAppSelector((state) => state.composer.displayState); + + const dispatch = useAppDispatch(); + const handleComposerOpen: React.MouseEventHandler = + 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 ( + + + + ); + } + + if (displayState === 'showing') { + return ( + }> + + + ); + } + + return ( + <> + + + + + + + + + + + + + + + ); +}; diff --git a/app/javascript/mastodon/features/compose/redesign/visibility.tsx b/app/javascript/mastodon/features/compose/redesign/visibility.tsx index 7c8ae5c7a93..2afe134a892 100644 --- a/app/javascript/mastodon/features/compose/redesign/visibility.tsx +++ b/app/javascript/mastodon/features/compose/redesign/visibility.tsx @@ -47,12 +47,7 @@ export const ComposeVisibility: React.FC = () => { defaultMessage='To: {button}' values={{ button: ( -
); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index f30c8b074da..24e62e4ef0f 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index ce9ef9cbdaa..ffd04c46663 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -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); diff --git a/app/javascript/mastodon/reducers/slices/composer.ts b/app/javascript/mastodon/reducers/slices/composer.ts new file mode 100644 index 00000000000..8be7f2c1faa --- /dev/null +++ b/app/javascript/mastodon/reducers/slices/composer.ts @@ -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', +); diff --git a/app/javascript/mastodon/reducers/slices/index.ts b/app/javascript/mastodon/reducers/slices/index.ts index 1b94687cc6d..a0f1b9cfb19 100644 --- a/app/javascript/mastodon/reducers/slices/index.ts +++ b/app/javascript/mastodon/reducers/slices/index.ts @@ -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, };