mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-13 02:36:36 -05:00
Composer redesign: Messages (#40221)
This commit is contained in:
1
app/javascript/images/composer_message.svg
Normal file
1
app/javascript/images/composer_message.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.7 KiB |
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -34,9 +34,7 @@ const ComposerModalCancelConfirm: React.FC<{ openNew?: boolean }> = ({
|
||||
dispatch(
|
||||
closeModal({ modalType: 'COMPOSER_DRAFT_DELETE', ignoreFocus: false }),
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
focusComposerTextarea();
|
||||
});
|
||||
focusComposerTextarea(true);
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
@@ -90,7 +90,7 @@ export const selectComposeMentions = createAppSelector(
|
||||
accounts.add(accountsMap[account]);
|
||||
}
|
||||
}
|
||||
return accounts;
|
||||
return [...accounts];
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user