Composer redesign: Messages (#40221)

This commit is contained in:
Echo
2026-08-20 12:59:57 +00:00
committed by GitHub
parent ceb9be7dc5
commit 60593f6a8d
12 changed files with 237 additions and 49 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -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';
}

View File

@@ -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<MenuItemRadioProps> = ({
@@ -113,7 +113,7 @@ export const MenuItemRadio: React.FC<MenuItemRadioProps> = ({
...props
}) => {
const handleChange = useCallback(() => {
onChange({ value });
onChange?.({ value });
}, [value, onChange]);
return (
@@ -132,7 +132,7 @@ export const MenuItemRadio: React.FC<MenuItemRadioProps> = ({
interface MenuItemCheckboxProps extends Omit<MenuItemRadioProps, 'onChange'> {
icon?: IconProp;
onChange: (change: { value: string; checked: boolean }) => void;
onChange?: (change: { value: string; checked: boolean }) => void;
}
export const MenuItemCheckbox: React.FC<MenuItemCheckboxProps> = ({
@@ -143,7 +143,7 @@ export const MenuItemCheckbox: React.FC<MenuItemCheckboxProps> = ({
...props
}) => {
const handleChange = useCallback(() => {
onChange({ value, checked: !checked });
onChange?.({ value, checked: !checked });
}, [onChange, value, checked]);
return (

View File

@@ -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<RedesignComposeFormProps> = ({
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<RedesignComposeFormProps> = ({
const intl = useIntl();
const titleId = useId();
return (
<form
role='dialog'
@@ -87,25 +94,19 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
aria-labelledby={titleId}
className={classNames(className, classes.root)}
>
{background && (
<div
className={classes.background}
style={{ maskImage: `url(${background})` }}
/>
)}
<ComposeFormHeader id={titleId} noMinimize={noMinimize} />
<ComposeReply />
<div className={classes.toolbar}>
<div className={classes.flexGrowWrap}>
{type !== 'message' && <ComposeVisibility />}
{type === 'message' && (
<p className={classes.toolbarMessage}>
<Icon id='lock-open' icon={LockSimpleOpenIcon} />
<FormattedMessage
id='compose.message.notice'
defaultMessage='Messages are not end-to-end encrypted'
description='Message refers to a direct message. For languages where this is confusing, "chat" or "direct message" can be used.'
/>
</p>
)}
</div>
<ComposeVisibility className={classes.flexGrowWrap} />
<ToggleField
label={intl.formatMessage(messages.sensitive)}
@@ -117,6 +118,16 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
<LanguageButton />
</div>
{type === 'message' && (
<p className={classes.toolbarMessage}>
<Icon id='lock-open' icon={LockSimpleOpenIcon} />
<FormattedMessage
id='compose.message.notice'
defaultMessage='Messages are not end-to-end encrypted'
/>
</p>
)}
{sensitive && (
<TextInputField
label={intl.formatMessage(messages.sensitiveText)}

View File

@@ -34,9 +34,7 @@ const ComposerModalCancelConfirm: React.FC<{ openNew?: boolean }> = ({
dispatch(
closeModal({ modalType: 'COMPOSER_DRAFT_DELETE', ignoreFocus: false }),
);
requestAnimationFrame(() => {
focusComposerTextarea();
});
focusComposerTextarea(true);
}, [dispatch]);
return (

View File

@@ -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 (
<ModalShell maxWidth={400}>
<ModalTitle>
<FormattedMessage
id='compose.switch_modal.title'
defaultMessage='Convert to post?'
/>
</ModalTitle>
<FormattedMessage
id='compose.switch_modal.body'
defaultMessage='Your message has limited visibility. If you convert to a post, it will switch to your default post visibility.'
/>
<ModalActions>
<Button size='sm' onClick={handleBack}>
<FormattedMessage
id='compose.switch_modal.back'
defaultMessage='Back'
/>
</Button>
<Button size='sm' color='neutral' onClick={handleContinue}>
<FormattedMessage
id='compose.switch_modal.continue'
defaultMessage='Continue'
/>
</Button>
</ModalActions>
</ModalShell>
);
};
// eslint-disable-next-line import/no-default-export
export default ComposerModalSwitch;

View File

@@ -90,7 +90,7 @@ export const selectComposeMentions = createAppSelector(
accounts.add(accountsMap[account]);
}
}
return accounts;
return [...accounts];
},
);

View File

@@ -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 {

View File

@@ -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 (
<>
<div className={className}>
<FormattedMessage
id='compose.post.to'
defaultMessage='To:'
@@ -42,28 +48,57 @@ export const ComposeVisibility: React.FC = () => {
/>
<Menu>
<MenuButton size='sm'>
{privacy !== 'private' && (
<FormattedMessage
id='privacy.public.short'
defaultMessage='Public'
/>
)}
{privacy === 'private' && (
<FormattedMessage
id='compose.post.privacy.followers'
defaultMessage='Followers {count, plural, =0 {} one {+ # other} other {+ # others}}'
description='Count is # of other people mentioned in the post. If zero, just output "Followers".'
values={{ count: mentions.size }}
/>
)}
<ComposeVisibilityButtonText privacy={privacy} />
</MenuButton>
<ComposeVisibilityMenu />
{privacy !== 'direct' ? (
<ComposeVisibilityMenu />
) : (
<ComposeDirectMenu />
)}
</Menu>
</>
</div>
);
};
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 (
<FormattedMessage id='privacy.public.short' defaultMessage='Public' />
);
} else if (privacy === 'private') {
return (
<FormattedMessage
id='compose.post.privacy.followers'
defaultMessage='Followers {count, plural, =0 {} one {+ # other} other {+ # others}}'
description='Count is # of other people mentioned in the post. If zero, just output "Followers".'
values={{ count: mentions.length }}
/>
);
} else if (mentions.length > 0) {
return (
<FormattedMessage
id='compose.message.direct.followers'
defaultMessage='{name} {count, plural, =0 {} one {+ # other} other {+ # others}}'
description='Name is the primary display name, count is # of other people mentioned in the post'
values={{
name: <DisplayNameSimple account={firstMentionedAccount} />,
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<HTMLButtonElement> =
useCallback(() => {
popover.closeMenu();
dispatch(changeComposeVisibility('direct'));
}, [dispatch]);
}, [dispatch, popover]);
return (
<MenuList placement='bottom-start' offset={4} maxWidth={280}>
@@ -231,3 +269,44 @@ const ComposeVisibilityMenu: React.FC = () => {
</MenuList>
);
};
const ComposeDirectMenu: React.FC = () => {
const dispatch = useAppDispatch();
const { popover } = useMenuContext();
const handleSwitchToPost: React.MouseEventHandler<HTMLButtonElement> =
useCallback(() => {
dispatch(
openModal({ modalType: 'COMPOSER_SWITCH_TO_POST', modalProps: {} }),
);
popover.closeMenu();
}, [dispatch, popover]);
return (
<MenuList placement='bottom-start' offset={4} maxWidth={280}>
<MenuItemGroup
label={
<FormattedMessage
id='compose.visibility.title'
defaultMessage='Visibility'
/>
}
>
<MenuItemRadio value='direct' disabled checked>
<FormattedMessage
id='compose.visibility.direct_note'
defaultMessage='Everyone mentioned'
/>
</MenuItemRadio>
</MenuItemGroup>
<MenuItemDivider />
<MenuItem icon={NewspaperIcon} onClick={handleSwitchToPost}>
<FormattedMessage
id='compose.visibility.to_post'
defaultMessage='Compose a post instead'
/>
</MenuItem>
</MenuList>
);
};

View File

@@ -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 */

View File

@@ -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.",

View File

@@ -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);
},
);