Composer redesign: Settings menu (#40798)

This commit is contained in:
Echo
2026-10-01 13:22:39 +00:00
committed by GitHub
parent 48112fb7a1
commit 7c8e89807f
13 changed files with 391 additions and 262 deletions

View File

@@ -28,6 +28,7 @@
&:hover:not(
:active,
:disabled,
[aria-disabled='true'],
[aria-pressed='true'],
[aria-expanded='true']
) {
@@ -40,7 +41,8 @@
outline-offset: 2px;
}
&:disabled {
&:disabled,
&[aria-disabled='true'] {
cursor: not-allowed;
opacity: 0.5;
}

View File

@@ -22,8 +22,8 @@ interface ForwardedReferenceProps {
onClick: React.MouseEventHandler;
onMouseEnter: React.MouseEventHandler;
onMouseLeave: React.MouseEventHandler;
onFocus: React.KeyboardEventHandler;
onBlur: React.KeyboardEventHandler;
onFocus: React.FocusEventHandler;
onBlur: React.FocusEventHandler;
}
type GetTooltipProps = <T extends Record<string, unknown>>(
@@ -96,7 +96,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
setIsOpen(false);
}, [clearOpenTimeout, isOpen]);
const handleFocus = useCallback<React.KeyboardEventHandler>(
const handleFocus: React.FocusEventHandler = useCallback(
(e) => {
if (
e.target instanceof HTMLElement &&

View File

@@ -9,8 +9,8 @@ import { IconButton } from 'mastodon/components/icon_button';
import { changeComposeSpoilerness } from '../../../actions/compose';
const messages = defineMessages({
marked: { id: 'compose_form.spoiler.marked', defaultMessage: 'Text is hidden behind warning' },
unmarked: { id: 'compose_form.spoiler.unmarked', defaultMessage: 'Text is not hidden' },
marked: { id: 'compose_form.spoiler.marked', defaultMessage: 'Remove content warning' },
unmarked: { id: 'compose_form.spoiler.unmarked', defaultMessage: 'Add content warning' },
});
const mapStateToProps = (state, { intl }) => ({

View File

@@ -1,19 +1,10 @@
import type React from 'react';
import { useCallback, useEffect, useId } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { FlagIcon, LockSimpleOpenIcon } from '@phosphor-icons/react';
import {
changeComposeSpoilerness,
insertEmojiCompose,
} from '@/mastodon/actions/compose';
import { ToggleButton } from '@/mastodon/components/button/redesign';
import { insertEmojiCompose } from '@/mastodon/actions/compose';
import { normalizeKey } from '@/mastodon/components/hotkeys/utils';
import { Icon, useIconWeight } from '@/mastodon/components/icon';
import {
closeComposer,
getComposerTextarea,
@@ -28,8 +19,9 @@ import { ComposeFormHeader } from './header';
import { ComposeHints } from './hints';
import { LanguageButton } from './language';
import { ComposeReply } from './reply';
import { selectComposeSensitive, selectComposeType } from './selectors';
import { selectComposeType } from './selectors';
import { ComposeSensitiveField } from './sensitive';
import { ComposeSettingsMenu } from './settings';
import classes from './styles.module.scss';
import { ComposeTextarea } from './textarea';
import { ComposeVisibility } from './visibility';
@@ -45,15 +37,11 @@ export const RedesignComposeForm: React.FC<
RedesignComposeFormProps & React.ComponentPropsWithRef<'form'>
> = ({ autoFocus, className, noMinimize, redirectOnSuccess, ...props }) => {
const type = useAppSelector(selectComposeType);
const { sensitive } = useAppSelector(selectComposeSensitive);
const { onSensitiveChange, onEmojiPick, onSubmit } =
useComposeHandlers(redirectOnSuccess);
const { onEmojiPick, onSubmit } = useComposeHandlers(redirectOnSuccess);
const titleId = useId();
const sensitiveIcon = useIconWeight(FlagIcon, sensitive && 'fill');
return (
<form
{...props}
@@ -75,27 +63,9 @@ export const RedesignComposeForm: React.FC<
<LanguageButton />
<ToggleButton
size='sm'
active={sensitive}
onClick={onSensitiveChange}
leadingIcon={sensitiveIcon}
>
<FormattedMessage id='compose.sensitive' defaultMessage='Sensitive' />
</ToggleButton>
<ComposeSettingsMenu />
</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'
description='Message refers to a direct message. For languages where this is confusing, "chat" or "direct message" can be used.'
/>
</p>
)}
<ComposeSensitiveField />
<ComposeTextarea
@@ -140,10 +110,6 @@ function useComposeHandlers(redirectOnSuccess?: boolean) {
};
}, [dispatch, isModalOpen]);
const onSensitiveChange = useCallback(() => {
dispatch(changeComposeSpoilerness());
}, [dispatch]);
const onEmojiPick: OnEmojiPick = useCallback(
(emoji) => {
const position = getComposerTextarea()?.selectionStart ?? 0;
@@ -179,6 +145,5 @@ function useComposeHandlers(redirectOnSuccess?: boolean) {
return {
onSubmit,
onEmojiPick,
onSensitiveChange,
};
}

View File

@@ -3,10 +3,9 @@ import { useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { MagnifyingGlassIcon } from '@phosphor-icons/react';
import { MagnifyingGlassIcon, TranslateIcon } from '@phosphor-icons/react';
import { changeComposeLanguage } from '@/mastodon/actions/compose';
import { CaretIcon } from '@/mastodon/components/button/redesign';
import { TextInput } from '@/mastodon/components/form_fields/redesign';
import {
Menu,
@@ -33,7 +32,7 @@ export const LanguageButton: React.FC = () => {
return (
<Menu>
<MenuTrigger size='sm' trailingIcon={CaretIcon}>
<MenuTrigger size='sm' leadingIcon={TranslateIcon}>
{langCode.toLocaleUpperCase()}
</MenuTrigger>

View File

@@ -0,0 +1,75 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { changeComposeSpoilerness } from '@/mastodon/actions/compose';
import { closeModal } from '@/mastodon/actions/modal';
import { Button } from '@/mastodon/components/button/redesign';
import {
ModalShell,
ModalActions,
ModalTitle,
} from '@/mastodon/components/modal_shell/redesign';
import {
requestComposerFocus,
submitComposer,
} from '@/mastodon/reducers/slices/composer';
import { useAppDispatch } from '@/mastodon/store';
const ComposerModalSensitive: React.FC<{ redirectOnSuccess?: boolean }> = ({
redirectOnSuccess,
}) => {
const dispatch = useAppDispatch();
const onCancel = useCallback(() => {
dispatch(requestComposerFocus());
dispatch(
closeModal({
modalType: 'COMPOSER_ADD_CONTENT_WARNING',
ignoreFocus: false,
}),
);
}, [dispatch]);
const onContinue = useCallback(() => {
dispatch(changeComposeSpoilerness());
dispatch(submitComposer({ redirectOnSuccess }));
dispatch(
closeModal({
modalType: 'COMPOSER_ADD_CONTENT_WARNING',
ignoreFocus: false,
}),
);
}, [dispatch, redirectOnSuccess]);
return (
<ModalShell>
<ModalTitle>
<FormattedMessage
id='compose.sensitive_modal.title'
defaultMessage='Publish without content warning?'
/>
</ModalTitle>
<FormattedMessage
id='compose.sensitive_modal.body'
defaultMessage="You haven't added content warning text. If you publish now, your post will not be hidden behind a content warning."
/>
<ModalActions>
<Button variant='solid' color='destructive' onClick={onCancel}>
<FormattedMessage
id='compose.sensitive_modal.cancel'
defaultMessage='Continue editing'
/>
</Button>
<Button variant='solid' onClick={onContinue}>
<FormattedMessage
id='compose.sensitive_modal.continue'
defaultMessage='Publish without warning'
/>
</Button>
</ModalActions>
</ModalShell>
);
};
export default ComposerModalSensitive;

View File

@@ -1,6 +1,7 @@
import { length } from 'stringz';
import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import { immutableListToSuggestions } from '@/mastodon/components/autosuggest/utils';
import type { StatusVisibility } from '@/mastodon/models/status';
import type { ComposeType } from '@/mastodon/reducers/slices/composer';
@@ -17,6 +18,20 @@ export const selectComposePrivacy = createAppSelector(
(privacy, defaultPrivacy) => privacy ?? defaultPrivacy,
);
export const selectComposeQuotePolicy = createAppSelector(
[
(state) => state.compose.get('quote_policy') as ApiQuotePolicy | undefined,
(state) => state.compose.get('default_quote_policy') as ApiQuotePolicy,
selectComposePrivacy,
],
(quotePolicy, defaultQuotePolicy, privacy) => {
if (privacy === 'private' || privacy === 'direct') {
return 'nobody';
}
return quotePolicy ?? defaultQuotePolicy;
},
);
export const selectComposeType = createAppSelector(
[
(state) => state.compose.get('in_reply_to') as string | null,
@@ -131,10 +146,12 @@ export const selectComposeSensitive = createAppSelector(
[
(state) => !!state.compose.get('spoiler'),
(state) => state.compose.get('spoiler_text'),
(state) => !!state.compose.get('sensitive'),
],
(sensitive, text) => ({
sensitive,
(spoiler, text, sensitive) => ({
sensitive: spoiler,
sensitiveText: typeof text === 'string' ? text : '',
mediaSensitive: sensitive || spoiler,
}),
);

View File

@@ -0,0 +1,183 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { EyeSlashIcon, GearIcon, WarningIcon } from '@phosphor-icons/react';
import {
changeComposeSensitivity,
changeComposeSpoilerness,
} from '@/mastodon/actions/compose';
import { setComposeQuotePolicy } from '@/mastodon/actions/compose_typed';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import { IconButton } from '@/mastodon/components/button/redesign';
import {
Menu,
MenuItemCheckbox,
MenuItemDivider,
MenuItemGroup,
MenuItemRadio,
MenuList,
MenuTrigger,
} from '@/mastodon/components/menu';
import {
useAppDispatch,
useAppSelector,
} from '@/mastodon/store/typed_functions';
import {
selectComposePrivacy,
selectComposeQuotePolicy,
selectComposeSensitive,
} from './selectors';
export const ComposeSettingsMenu: React.FC = () => {
return (
<Menu>
<MenuTrigger as={IconButton} icon={GearIcon} size='sm'>
<FormattedMessage id='compose.settings' defaultMessage='Settings' />
</MenuTrigger>
<MenuList maxWidth={280} placement='top-end'>
<ComposeSettingsInnerMenu />
</MenuList>
</Menu>
);
};
const ComposeSettingsInnerMenu: React.FC = () => {
// Quote policy
const quotePolicy = useAppSelector(selectComposeQuotePolicy);
const privacy = useAppSelector(selectComposePrivacy);
const disablePublicQuotes = privacy === 'private';
const dispatch = useAppDispatch();
const onQuotePolicyChange = useCallback(
({ value }: { value: string }) => {
let newQuotePolicy: ApiQuotePolicy = 'nobody';
switch (value) {
case 'public':
newQuotePolicy = 'public';
break;
case 'followers':
newQuotePolicy = 'followers';
break;
}
dispatch(setComposeQuotePolicy(newQuotePolicy));
},
[dispatch],
);
// Sensitive content
const { sensitive, mediaSensitive } = useAppSelector(selectComposeSensitive);
const onSensitiveChange = useCallback(() => {
dispatch(changeComposeSpoilerness());
}, [dispatch]);
const onMediaSensitiveChange = useCallback(() => {
dispatch(changeComposeSensitivity());
}, [dispatch]);
return (
<>
{privacy !== 'direct' && (
<MenuItemGroup
label={
<FormattedMessage
id='compose.visibility.quote_policy'
defaultMessage='Who can quote'
/>
}
>
<MenuItemRadio
name='quote_policy'
value='public'
checked={quotePolicy === 'public'}
onChange={onQuotePolicyChange}
disabled={disablePublicQuotes}
keepMenuOpenOnClick
>
<FormattedMessage
id='visibility_modal.quote_public'
defaultMessage='Anyone'
/>
</MenuItemRadio>
<MenuItemRadio
name='quote_policy'
value='followers'
checked={quotePolicy === 'followers'}
onChange={onQuotePolicyChange}
disabled={disablePublicQuotes}
keepMenuOpenOnClick
>
<FormattedMessage
id='compose.visibility.quote_policy.followers'
defaultMessage='Followers'
/>
</MenuItemRadio>
<MenuItemRadio
name='quote_policy'
value='nobody'
checked={quotePolicy === 'nobody'}
onChange={onQuotePolicyChange}
description={
disablePublicQuotes && (
<FormattedMessage
id='compose.visibility.quote_policy.only_me_hint'
defaultMessage="When your post's visibility is set to Followers, it can only be quoted by you."
/>
)
}
keepMenuOpenOnClick
>
<FormattedMessage
id='visibility_modal.quote_nobody'
defaultMessage='Just me'
/>
</MenuItemRadio>
</MenuItemGroup>
)}
{privacy !== 'direct' && <MenuItemDivider />}
<MenuItemGroup
label={
<FormattedMessage
id='compose.sensitive.label'
defaultMessage='Sensitive content'
/>
}
>
<MenuItemCheckbox
name='content_warning'
value='on'
checked={sensitive}
onChange={onSensitiveChange}
keepMenuOpenOnClick
icon={WarningIcon}
>
<FormattedMessage
id='compose_form.spoiler.unmarked'
defaultMessage='Add content warning'
/>
</MenuItemCheckbox>
<MenuItemCheckbox
name='media_spoiler'
value='on'
checked={mediaSensitive}
disabled={sensitive}
onChange={onMediaSensitiveChange}
keepMenuOpenOnClick
icon={EyeSlashIcon}
>
<FormattedMessage
id='compose.blur_media'
defaultMessage='Blur media'
/>
</MenuItemCheckbox>
</MenuItemGroup>
</>
);
};

View File

@@ -119,7 +119,7 @@
.textareaMirror {
@include mixins.type-body;
padding: var(--space-2);
padding: var(--space-1);
// Prevent automatic page zoom in Safari when focusing
// text fields with font-sizes below 16px

View File

@@ -1,23 +1,15 @@
import { useCallback, useState } from 'react';
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import {
ChatCircleDotsIcon,
MagnifyingGlassIcon,
NewspaperIcon,
QuotesIcon,
} from '@phosphor-icons/react';
import classNames from 'classnames';
import {
changeComposeVisibility,
setComposeQuotePolicy,
} from '@/mastodon/actions/compose_typed';
import { openModal } from '@/mastodon/actions/modal';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import { ChatCircleDotsIcon, EyeIcon } from '@phosphor-icons/react';
import { changeComposeVisibility } from '@/mastodon/actions/compose_typed';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { Button, CaretIcon } from '@/mastodon/components/button/redesign';
import { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
import { Icon } from '@/mastodon/components/icon';
import {
Menu,
MenuList,
@@ -28,10 +20,11 @@ import {
MenuItemRadio,
MenuItemCheckbox,
} from '@/mastodon/components/menu';
import { selectPlainAccount } from '@/mastodon/selectors/accounts';
import { Tooltip } from '@/mastodon/components/tooltip';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { selectComposeMentions, selectComposePrivacy } from './selectors';
import { selectComposePrivacy } from './selectors';
import classes from './styles.module.scss';
export const ComposeVisibility: React.FC<{ className?: string }> = ({
className,
@@ -39,28 +32,53 @@ export const ComposeVisibility: React.FC<{ className?: string }> = ({
const privacy = useAppSelector(selectComposePrivacy);
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
if (privacy === 'direct') {
return (
<div className={classNames(className, classes.toolbarMessage)}>
<Icon icon={EyeIcon} />
<FormattedMessage
id='compose.privacy.direct.hint'
defaultMessage='Visible to everyone mentioned. Not encrypted.'
/>
</div>
);
}
if (isEditing) {
return (
<div className={className}>
<Tooltip
renderTextWhenClosed
text={
<FormattedMessage
id='compose.privacy.editing'
defaultMessage='Visibility can’t be edited after a post has been published.'
/>
}
>
{({ getTooltipProps, tooltipId }) => (
<Button
{...getTooltipProps()}
size='sm'
aria-disabled
aria-describedby={tooltipId}
>
<ComposeVisibilityButtonText privacy={privacy} />
</Button>
)}
</Tooltip>
</div>
);
}
return (
<div className={className}>
<FormattedMessage
id='compose.post.to'
defaultMessage='To:'
description='Before button that indicates who a post is for (Public, Followers, mentioned people)'
/>
<Menu>
<MenuTrigger
as={Button}
size='sm'
trailingIcon={CaretIcon}
disabled={isEditing}
>
<MenuTrigger as={Button} size='sm' trailingIcon={CaretIcon}>
<ComposeVisibilityButtonText privacy={privacy} />
</MenuTrigger>
{privacy !== 'direct' ? (
<ComposeVisibilityMenu />
) : (
<ComposeDirectMenu />
)}
<ComposeVisibilityMenu />
</Menu>
</div>
);
@@ -69,34 +87,22 @@ export const ComposeVisibility: React.FC<{ className?: string }> = ({
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') {
if (privacy === 'public') {
return (
<FormattedMessage id='privacy.public.short' defaultMessage='Public' />
);
} else if (privacy === 'unlisted') {
return (
<FormattedMessage
id='compose.privacy.unlisted'
defaultMessage='Public, hidden from search'
/>
);
} 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,
}}
id='compose.privacy.followers'
defaultMessage='Followers (+ mentions)'
/>
);
}
@@ -109,18 +115,6 @@ const ComposeVisibilityMenu: React.FC = () => {
const defaultPrivacy = useAppSelector(
(state) => state.compose.get('default_privacy') as StatusVisibility,
);
const currentQuotePolicy = useAppSelector(
(state) => state.compose.get('quote_policy') as ApiQuotePolicy | undefined,
);
const defaultQuotePolicy = useAppSelector(
(state) => state.compose.get('default_quote_policy') as ApiQuotePolicy,
);
// Track the last public quote policy, so the picker remembers what was last used before quoting was disabled.
const [lastQuotePolicy, setLastQuotePolicy] = useState(
defaultQuotePolicy !== 'nobody' ? defaultQuotePolicy : 'public',
);
const quotePolicy = currentQuotePolicy ?? defaultQuotePolicy;
const isReply = useAppSelector((state) => !!state.compose.get('in_reply_to'));
@@ -144,31 +138,6 @@ const ComposeVisibilityMenu: React.FC = () => {
[defaultPrivacy, dispatch, privacy],
);
const handleQuotePolicyChange = useCallback(
({ value, checked }: { value: string; checked?: boolean }) => {
let newQuotePolicy: ApiQuotePolicy = 'nobody';
switch (value) {
case 'public':
newQuotePolicy = 'public';
setLastQuotePolicy(newQuotePolicy);
break;
case 'followers':
newQuotePolicy = 'followers';
setLastQuotePolicy(newQuotePolicy);
break;
case 'others':
// If it's not checked, then it's nobody.
if (checked) {
// Only use the default if it's not nobody, as then it'll never be enabled.
newQuotePolicy = lastQuotePolicy;
}
break;
}
dispatch(setComposeQuotePolicy(newQuotePolicy));
},
[dispatch, lastQuotePolicy],
);
const handleSwitchToMessage: React.MouseEventHandler<HTMLButtonElement> =
useCallback(() => {
dispatch(changeComposeVisibility('direct'));
@@ -202,8 +171,8 @@ const ComposeVisibilityMenu: React.FC = () => {
keepMenuOpenOnClick
>
<FormattedMessage
id='privacy.private.short'
defaultMessage='Followers'
id='compose.privacy.followers'
defaultMessage='Followers (+ mentions)'
/>
</MenuItemRadio>
@@ -212,68 +181,22 @@ const ComposeVisibilityMenu: React.FC = () => {
<MenuItemCheckbox
value='unlisted'
disabled={privacy === 'private'}
checked={privacy === 'public'}
checked={privacy === 'unlisted' || privacy === 'private'}
onChange={handlePrivacyChange}
icon={MagnifyingGlassIcon}
keepMenuOpenOnClick
>
<FormattedMessage
id='compose.discoverable'
defaultMessage='Discoverable in public feeds & search results'
/>
</MenuItemCheckbox>
<MenuItemCheckbox
value='others'
disabled={privacy === 'private'}
checked={quotePolicy !== 'nobody' && privacy !== 'private'}
onChange={handleQuotePolicyChange}
icon={QuotesIcon}
keepMenuOpenOnClick
>
<FormattedMessage
id='compose.quotable'
defaultMessage='Allow others to quote'
/>
</MenuItemCheckbox>
</MenuItemGroup>
{quotePolicy !== 'nobody' && privacy !== 'private' && (
<MenuItemGroup
label={
description={
<FormattedMessage
id='compose.visibility.quote_policy'
defaultMessage='Who can quote'
id='compose.discoverable.hint'
defaultMessage='Also applies to discovery feeds'
/>
}
>
<MenuItemRadio
name='quote_policy'
value='public'
checked={quotePolicy === 'public'}
onChange={handleQuotePolicyChange}
keepMenuOpenOnClick
>
<FormattedMessage
id='compose.visibility.quote_policy.anyone'
defaultMessage='Anyone'
/>
</MenuItemRadio>
<MenuItemRadio
name='quote_policy'
value='followers'
checked={quotePolicy === 'followers'}
onChange={handleQuotePolicyChange}
keepMenuOpenOnClick
>
<FormattedMessage
id='compose.visibility.quote_policy.followers'
defaultMessage='Followers'
/>
</MenuItemRadio>
</MenuItemGroup>
)}
<FormattedMessage
id='compose.discoverable'
defaultMessage='Hide from search results'
/>
</MenuItemCheckbox>
</MenuItemGroup>
<MenuItemDivider />
@@ -286,7 +209,7 @@ const ComposeVisibilityMenu: React.FC = () => {
) : (
<FormattedMessage
id='compose.post.to_message'
defaultMessage='Compose a message instead'
defaultMessage='Convert to private message'
description='Message refers to a direct message. For languages where this is confusing, "chat" or "direct message" can be used.'
/>
)}
@@ -294,51 +217,3 @@ const ComposeVisibilityMenu: React.FC = () => {
</MenuList>
);
};
const ComposeDirectMenu: React.FC = () => {
const dispatch = useAppDispatch();
const handleSwitchToPost: React.MouseEventHandler<HTMLButtonElement> =
useCallback(() => {
dispatch(
openModal({ modalType: 'COMPOSER_SWITCH_TO_POST', modalProps: {} }),
);
}, [dispatch]);
const isReply = useAppSelector((state) => !!state.compose.get('in_reply_to'));
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}>
{isReply ? (
<FormattedMessage
id='compose.visibility.to_reply'
defaultMessage='Reply publicly instead'
/>
) : (
<FormattedMessage
id='compose.visibility.to_post'
defaultMessage='Compose a post instead'
/>
)}
</MenuItem>
</MenuList>
);
};

View File

@@ -110,6 +110,7 @@ export const MODAL_COMPONENTS = {
'COMPOSER_REARRANGE': () => import('@/mastodon/features/compose/redesign/modal_rearrange'),
'COMPOSER_SWITCH_TO_POST': () => import('@/mastodon/features/compose/redesign/modal_switch'),
'COMPOSER_ADD_QUOTE': () => import('@/mastodon/features/compose/redesign/modal_quote'),
'COMPOSER_ADD_CONTENT_WARNING': () => import('@/mastodon/features/compose/redesign/modal_sensitive'),
'NOTIFICATION_SETTINGS': () => import('@/mastodon/features/notifications_v2/components/notification_settings_modal'),
'NOTIFICATION_REQUESTS_SETTINGS': () => import('@/mastodon/features/notifications/components/notification_requests_settings').then(module => ({default: module.NotificationRequestsSettingsModal})),
'HASHTAG_SETTINGS': () => import('@/mastodon/features/hashtag_timeline/components/column_settings_modal'),

View File

@@ -507,6 +507,7 @@
"community.column_settings.local_only": "Local only",
"community.column_settings.media_only": "Media Only",
"community.column_settings.remote_only": "Remote only",
"compose.blur_media": "Blur media",
"compose.cancel_modal.body": "You have a draft already in progress. What would you like to do?",
"compose.cancel_modal.continue": "Continue draft",
"compose.cancel_modal.delete": "Delete draft",
@@ -516,7 +517,8 @@
"compose.cancel_modal.edit.title": "Unsaved changes",
"compose.cancel_modal.title": "Discard draft",
"compose.counter": "{current, number}/{max, number}",
"compose.discoverable": "Discoverable in public feeds & search results",
"compose.discoverable": "Hide from search results",
"compose.discoverable.hint": "Also applies to discovery feeds",
"compose.error.blank_post": "Post can't be blank.",
"compose.expand": "Show composer",
"compose.hints.empty": "Post can't be blank",
@@ -530,8 +532,6 @@
"compose.language.change": "Change language",
"compose.language.not-found": "No language found",
"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",
"compose.message.published.body": "Message sent",
@@ -542,16 +542,17 @@
"compose.poll.duration": "Duration:",
"compose.poll.multiple": "Allow multiple selections",
"compose.post.placeholder": "What would you like to say?",
"compose.post.privacy.followers": "Followers {count, plural, =0 {} one {+ # other} other {+ # others}}",
"compose.post.title.edit": "Edit post",
"compose.post.title.new": "New post",
"compose.post.to": "To:",
"compose.post.to_message": "Compose a message instead",
"compose.post.to_message": "Convert to private message",
"compose.post.to_private_reply": "Reply privately instead",
"compose.privacy.direct.hint": "Visible to everyone mentioned. Not encrypted.",
"compose.privacy.editing": "Visibility can’t be edited after a post has been published.",
"compose.privacy.followers": "Followers (+ mentions)",
"compose.privacy.unlisted": "Public, hidden from search",
"compose.publish": "Publish",
"compose.published.body": "Post published.",
"compose.published.open": "Open",
"compose.quotable": "Allow others to quote",
"compose.quote.audio": "Audio file {duration}",
"compose.quote.poll": "Poll {sep} {isOpen, select, open {Accepting responses} other {Closed}}",
"compose.quote.spoiler": "Content:",
@@ -574,8 +575,13 @@
"compose.reply_private.title.edit": "Edit private reply",
"compose.reply_private.title.new": "New private reply",
"compose.saved.body": "Post saved.",
"compose.sensitive": "Sensitive",
"compose.sensitive.label": "Sensitive content",
"compose.sensitive.text": "Content warning",
"compose.sensitive_modal.body": "You haven't added content warning text. If you publish now, your post will not be hidden behind a content warning.",
"compose.sensitive_modal.cancel": "Continue editing",
"compose.sensitive_modal.continue": "Publish without warning",
"compose.sensitive_modal.title": "Publish without content warning?",
"compose.settings": "Settings",
"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",
@@ -587,13 +593,10 @@
"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.quote_policy.only_me_hint": "When your post's visibility is set to Followers, it can only be quoted by you.",
"compose.visibility.title": "Visibility",
"compose.visibility.to_post": "Compose a post instead",
"compose.visibility.to_reply": "Reply publicly 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.encryption_warning_messages": "Messages on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.",

View File

@@ -314,6 +314,15 @@ export const submitComposer = createAppThunk(
modalProps: {},
}),
);
} else if (!!compose.get('spoiler') && !compose.get('spoiler_text')) {
dispatch(
openModal({
modalType: 'COMPOSER_ADD_CONTENT_WARNING',
modalProps: {
redirectOnSuccess,
},
}),
);
} else {
dispatch(
submitCompose((status: ApiStatusJSON) => {