diff --git a/app/javascript/images/composer_message.svg b/app/javascript/images/composer_message.svg new file mode 100644 index 00000000000..a9c86e6d7be --- /dev/null +++ b/app/javascript/images/composer_message.svg @@ -0,0 +1 @@ + diff --git a/app/javascript/mastodon/components/display_name/index.tsx b/app/javascript/mastodon/components/display_name/index.tsx index 28bd9c17ff3..3eb38943e81 100644 --- a/app/javascript/mastodon/components/display_name/index.tsx +++ b/app/javascript/mastodon/components/display_name/index.tsx @@ -10,7 +10,7 @@ import { DisplayNameWithoutDomain } from './no-domain'; import { DisplayNameSimple } from './simple'; export interface DisplayNameProps { - account?: Account | AccountShapeFull; + account?: Account | AccountShapeFull | null; localDomain?: string; variant?: 'default' | 'simple' | 'noDomain'; } diff --git a/app/javascript/mastodon/components/menu/items.tsx b/app/javascript/mastodon/components/menu/items.tsx index a39ea4a9488..760ad1c7082 100644 --- a/app/javascript/mastodon/components/menu/items.tsx +++ b/app/javascript/mastodon/components/menu/items.tsx @@ -102,7 +102,7 @@ interface MenuItemRadioProps extends Omit< > { checked: boolean; value: string; - onChange: (change: { value: string }) => void; + onChange?: (change: { value: string }) => void; } export const MenuItemRadio: React.FC = ({ @@ -113,7 +113,7 @@ export const MenuItemRadio: React.FC = ({ ...props }) => { const handleChange = useCallback(() => { - onChange({ value }); + onChange?.({ value }); }, [value, onChange]); return ( @@ -132,7 +132,7 @@ export const MenuItemRadio: React.FC = ({ interface MenuItemCheckboxProps extends Omit { icon?: IconProp; - onChange: (change: { value: string; checked: boolean }) => void; + onChange?: (change: { value: string; checked: boolean }) => void; } export const MenuItemCheckbox: React.FC = ({ @@ -143,7 +143,7 @@ export const MenuItemCheckbox: React.FC = ({ ...props }) => { const handleChange = useCallback(() => { - onChange({ value, checked: !checked }); + onChange?.({ value, checked: !checked }); }, [onChange, value, checked]); return ( diff --git a/app/javascript/mastodon/features/compose/redesign/index.tsx b/app/javascript/mastodon/features/compose/redesign/index.tsx index 8095836a841..d76ea6bc835 100644 --- a/app/javascript/mastodon/features/compose/redesign/index.tsx +++ b/app/javascript/mastodon/features/compose/redesign/index.tsx @@ -8,6 +8,7 @@ import classNames from 'classnames'; import { LockSimpleOpenIcon } from '@phosphor-icons/react'; import { useDebouncedCallback } from 'use-debounce'; +import messageBackground from '@/images/composer_message.svg?url'; import { changeComposeSpoilerness, changeComposeSpoilerText, @@ -69,6 +70,11 @@ export const RedesignComposeForm: React.FC = ({ const type = useAppSelector(selectComposeType); const { sensitive, sensitiveText } = useAppSelector(selectComposeSensitive); + let background: string | null = null; + if (type === 'message') { + background = messageBackground; + } + const { onSensitiveChange, onSensitiveTextChange, @@ -80,6 +86,7 @@ export const RedesignComposeForm: React.FC = ({ const intl = useIntl(); const titleId = useId(); + return ( = ({ aria-labelledby={titleId} className={classNames(className, classes.root)} > + {background && ( + + )} + - - {type !== 'message' && } - - {type === 'message' && ( - - - - - )} - + = ({ + {type === 'message' && ( + + + + + )} + {sensitive && ( = ({ dispatch( closeModal({ modalType: 'COMPOSER_DRAFT_DELETE', ignoreFocus: false }), ); - requestAnimationFrame(() => { - focusComposerTextarea(); - }); + focusComposerTextarea(true); }, [dispatch]); return ( diff --git a/app/javascript/mastodon/features/compose/redesign/modal_switch.tsx b/app/javascript/mastodon/features/compose/redesign/modal_switch.tsx new file mode 100644 index 00000000000..77d71e7ca67 --- /dev/null +++ b/app/javascript/mastodon/features/compose/redesign/modal_switch.tsx @@ -0,0 +1,70 @@ +import { useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { changeComposeVisibility } from '@/mastodon/actions/compose_typed'; +import { closeModal } from '@/mastodon/actions/modal'; +import type { StatusVisibility } from '@/mastodon/api_types/statuses'; +import { Button } from '@/mastodon/components/button/redesign'; +import { + ModalActions, + ModalShell, + ModalTitle, +} from '@/mastodon/components/modal_shell/redesign'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; + +const ComposerModalSwitch: React.FC = () => { + const dispatch = useAppDispatch(); + const handleBack = useCallback(() => { + dispatch( + closeModal({ modalType: 'COMPOSER_SWITCH_TO_POST', ignoreFocus: false }), + ); + }, [dispatch]); + + const defaultPrivacy = useAppSelector( + (state) => + (state.compose.get('default_privacy') as StatusVisibility | undefined) ?? + 'public', + ); + const handleContinue = useCallback(() => { + dispatch(changeComposeVisibility(defaultPrivacy)); + dispatch( + closeModal({ modalType: 'COMPOSER_SWITCH_TO_POST', ignoreFocus: false }), + ); + }, [defaultPrivacy, dispatch]); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default ComposerModalSwitch; diff --git a/app/javascript/mastodon/features/compose/redesign/selectors.ts b/app/javascript/mastodon/features/compose/redesign/selectors.ts index 1d5952ec98d..8fe1f23e4d3 100644 --- a/app/javascript/mastodon/features/compose/redesign/selectors.ts +++ b/app/javascript/mastodon/features/compose/redesign/selectors.ts @@ -90,7 +90,7 @@ export const selectComposeMentions = createAppSelector( accounts.add(accountsMap[account]); } } - return accounts; + return [...accounts]; }, ); diff --git a/app/javascript/mastodon/features/compose/redesign/styles.module.scss b/app/javascript/mastodon/features/compose/redesign/styles.module.scss index e79b2accfd0..b1af1042951 100644 --- a/app/javascript/mastodon/features/compose/redesign/styles.module.scss +++ b/app/javascript/mastodon/features/compose/redesign/styles.module.scss @@ -11,6 +11,20 @@ gap: var(--space-md); padding: var(--space-md); min-width: 300px; + position: relative; + overflow: hidden; +} + +.background { + mask-repeat: repeat; + background-color: var(--color-bg-brand-base); + opacity: 0.15; + position: absolute; + bottom: 0; + right: 0; + width: 800px; + height: 800px; + pointer-events: none; } .header { diff --git a/app/javascript/mastodon/features/compose/redesign/visibility.tsx b/app/javascript/mastodon/features/compose/redesign/visibility.tsx index 224bf888e81..6884acf85d8 100644 --- a/app/javascript/mastodon/features/compose/redesign/visibility.tsx +++ b/app/javascript/mastodon/features/compose/redesign/visibility.tsx @@ -6,6 +6,7 @@ import { FormattedMessage } from 'react-intl'; import { ChatCircleIcon, MagnifyingGlassIcon, + NewspaperIcon, QuotesIcon, } from '@phosphor-icons/react'; @@ -13,8 +14,10 @@ import { changeComposeVisibility, setComposeQuotePolicy, } from '@/mastodon/actions/compose_typed'; +import { openModal } from '@/mastodon/actions/modal'; import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes'; import type { StatusVisibility } from '@/mastodon/api_types/statuses'; +import { DisplayNameSimple } from '@/mastodon/components/display_name/simple'; import { Menu, MenuList, @@ -24,17 +27,20 @@ import { MenuItem, MenuItemRadio, MenuItemCheckbox, + useMenuContext, } from '@/mastodon/components/menu'; +import { selectPlainAccount } from '@/mastodon/selectors/accounts'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { selectComposeMentions, selectComposePrivacy } from './selectors'; -export const ComposeVisibility: React.FC = () => { +export const ComposeVisibility: React.FC<{ className?: string }> = ({ + className, +}) => { const privacy = useAppSelector(selectComposePrivacy); - const mentions = useAppSelector(selectComposeMentions); return ( - <> + { /> - {privacy !== 'private' && ( - - )} - {privacy === 'private' && ( - - )} + - + {privacy !== 'direct' ? ( + + ) : ( + + )} - > + ); }; +const ComposeVisibilityButtonText: React.FC<{ + privacy: StatusVisibility; +}> = ({ privacy }) => { + const mentions = useAppSelector(selectComposeMentions); + const firstMentionedAccount = useAppSelector((state) => + selectPlainAccount(state, mentions.at(0)), + ); + + if (privacy === 'public' || privacy === 'unlisted') { + return ( + + ); + } else if (privacy === 'private') { + return ( + + ); + } else if (mentions.length > 0) { + return ( + , + count: mentions.length - 1, + }} + /> + ); + } + + return '-'; +}; + const ComposeVisibilityMenu: React.FC = () => { const privacy = useAppSelector(selectComposePrivacy); const defaultPrivacy = useAppSelector( @@ -119,10 +154,13 @@ const ComposeVisibilityMenu: React.FC = () => { }, [defaultQuotePolicy, dispatch], ); + + const { popover } = useMenuContext(); const handleSwitchToMessage: React.MouseEventHandler = useCallback(() => { + popover.closeMenu(); dispatch(changeComposeVisibility('direct')); - }, [dispatch]); + }, [dispatch, popover]); return ( @@ -231,3 +269,44 @@ const ComposeVisibilityMenu: React.FC = () => { ); }; + +const ComposeDirectMenu: React.FC = () => { + const dispatch = useAppDispatch(); + const { popover } = useMenuContext(); + const handleSwitchToPost: React.MouseEventHandler = + useCallback(() => { + dispatch( + openModal({ modalType: 'COMPOSER_SWITCH_TO_POST', modalProps: {} }), + ); + popover.closeMenu(); + }, [dispatch, popover]); + + return ( + + + } + > + + + + + + + + + + + + ); +}; diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index fc6dd33f009..868b255daa0 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -108,6 +108,7 @@ export const MODAL_COMPONENTS = { 'ACCOUNT_HIDE_FEATURED_TAB': () => import('@/mastodon/features/ui/components/confirmation_modals/hide_featured_tab').then(module => ({ default: module.ConfirmHideFeaturedTabModal })), 'COMPOSER_DRAFT_DELETE': () => import('@/mastodon/features/compose/redesign/modal_cancel'), 'COMPOSER_REARRANGE': () => import('@/mastodon/features/compose/redesign/modal_rearrange'), + 'COMPOSER_SWITCH_TO_POST': () => import('@/mastodon/features/compose/redesign/modal_switch'), }; /** @arg {keyof import('@/mastodon/features/account_edit/modals')} type */ diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index a02f47b2888..7251586083d 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -502,6 +502,7 @@ "compose.hints.missing-alt-many": "One or more of your attachments are missing alt text.", "compose.language.change": "Change language", "compose.language.search": "Search languages...", + "compose.message.direct.followers": "{name} {count, plural, =0 {} one {+ # other} other {+ # others}}", "compose.message.notice": "Messages are not end-to-end encrypted", "compose.message.placeholder": "Add your recipients and your message.", "compose.message.publish": "Send", @@ -537,6 +538,10 @@ "compose.saved.body": "Post saved.", "compose.sensitive": "Sensitive", "compose.sensitive.text": "Sensitive content description", + "compose.switch_modal.back": "Back", + "compose.switch_modal.body": "Your message has limited visibility. If you convert to a post, it will switch to your default post visibility.", + "compose.switch_modal.continue": "Continue", + "compose.switch_modal.title": "Convert to post?", "compose.upload.alt": "Alt", "compose.upload.audio.delete": "Remove audio", "compose.upload.menu": "Add alt text or remove the image", @@ -544,10 +549,12 @@ "compose.upload.menu.delete": "Remove image", "compose.upload.menu.edit_alt": "Edit alt text", "compose.upload.menu.rearrange": "Rearrangeā¦", + "compose.visibility.direct_note": "Everyone mentioned", "compose.visibility.quote_policy": "Who can quote", "compose.visibility.quote_policy.anyone": "Anyone", "compose.visibility.quote_policy.followers": "Followers", "compose.visibility.title": "Visibility", + "compose.visibility.to_post": "Compose a post instead", "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.", diff --git a/app/javascript/mastodon/reducers/slices/composer.ts b/app/javascript/mastodon/reducers/slices/composer.ts index ce2ea8a0609..a5c1588d29b 100644 --- a/app/javascript/mastodon/reducers/slices/composer.ts +++ b/app/javascript/mastodon/reducers/slices/composer.ts @@ -29,8 +29,18 @@ export function getComposerTextarea() { } return null; } -export function focusComposerTextarea() { - getComposerTextarea()?.focus(); +/** + * Focuses on the composer textarea. + * @param defer Waits before focusing. Useful if the composer may not be focusable immediately. + */ +export function focusComposerTextarea(defer = false) { + if (defer) { + requestAnimationFrame(() => { + getComposerTextarea()?.focus(); + }); + } else { + getComposerTextarea()?.focus(); + } } type DisplayState = 'hidden' | 'showing' | 'minimized'; @@ -131,10 +141,7 @@ export const openNewComposer = createAppThunk( } dispatch(composerSlice.actions.showComposer()); - // Delay for a frame to ensure the DOM has updated. - requestAnimationFrame(() => { - focusComposerTextarea(); - }); + focusComposerTextarea(true); }, );
- - -
+ + +