Composer quote improvements (#35835)

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
Echo 2025-08-21 16:07:31 +02:00 committed by GitHub
parent e770303968
commit f85f0eee1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 371 additions and 96 deletions

View File

@ -84,6 +84,7 @@ export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
const messages = defineMessages({ const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
uploadQuote: { id: 'upload_error.quote', defaultMessage: 'File upload not allowed with quotes.' },
open: { id: 'compose.published.open', defaultMessage: 'Open' }, open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' }, published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' }, saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
@ -146,7 +147,7 @@ export function resetCompose() {
}; };
} }
export const focusCompose = (defaultText) => (dispatch, getState) => { export const focusCompose = (defaultText = '') => (dispatch, getState) => {
dispatch({ dispatch({
type: COMPOSE_FOCUS, type: COMPOSE_FOCUS,
defaultText, defaultText,
@ -303,6 +304,11 @@ export function submitComposeFail(error) {
export function uploadCompose(files) { export function uploadCompose(files) {
return function (dispatch, getState) { return function (dispatch, getState) {
// Exit if there's a quote.
if (getState().compose.get('quoted_status_id')) {
dispatch(showAlert({ message: messages.uploadQuote }));
return;
}
const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']); const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']);
const media = getState().getIn(['compose', 'media_attachments']); const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']); const pending = getState().getIn(['compose', 'pending_media_attachments']);

View File

@ -1,3 +1,5 @@
import { defineMessages } from 'react-intl';
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
@ -12,7 +14,27 @@ import {
import type { ApiQuotePolicy } from '../api_types/quotes'; import type { ApiQuotePolicy } from '../api_types/quotes';
import type { Status } from '../models/status'; import type { Status } from '../models/status';
import { ensureComposeIsVisible } from './compose'; import { showAlert } from './alerts';
import { focusCompose } from './compose';
const messages = defineMessages({
quoteErrorUpload: {
id: 'quote_error.upload',
defaultMessage: 'Quoting is not allowed with media attachments.',
},
quoteErrorPoll: {
id: 'quote_error.poll',
defaultMessage: 'Quoting is not allowed with polls.',
},
quoteErrorQuote: {
id: 'quote_error.quote',
defaultMessage: 'Only one quote at a time is allowed.',
},
quoteErrorUnauthorized: {
id: 'quote_error.unauthorized',
defaultMessage: 'You are not authorized to quote this post.',
},
});
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & { type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
unattached?: boolean; unattached?: boolean;
@ -78,14 +100,43 @@ export const changeUploadCompose = createDataLoadingThunk(
}, },
); );
export const quoteComposeByStatus = createAppThunk( export const quoteCompose = createAppThunk(
'compose/quoteComposeStatus', 'compose/quoteComposeStatus',
(status: Status, { getState }) => { (status: Status, { dispatch }) => {
ensureComposeIsVisible(getState); dispatch(focusCompose());
return status; return status;
}, },
); );
export const quoteComposeByStatus = createAppThunk(
(status: Status, { dispatch, getState }) => {
const composeState = getState().compose;
const mediaAttachments = composeState.get('media_attachments');
if (composeState.get('poll')) {
dispatch(showAlert({ message: messages.quoteErrorPoll }));
} else if (
composeState.get('is_uploading') ||
(mediaAttachments &&
typeof mediaAttachments !== 'string' &&
typeof mediaAttachments !== 'number' &&
typeof mediaAttachments !== 'boolean' &&
mediaAttachments.size !== 0)
) {
dispatch(showAlert({ message: messages.quoteErrorUpload }));
} else if (composeState.get('quoted_status_id')) {
dispatch(showAlert({ message: messages.quoteErrorQuote }));
} else if (
status.getIn(['quote_approval', 'current_user']) !== 'automatic' &&
status.getIn(['quote_approval', 'current_user']) !== 'manual'
) {
dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
} else {
dispatch(quoteCompose(status));
}
},
);
export const quoteComposeById = createAppThunk( export const quoteComposeById = createAppThunk(
(statusId: string, { dispatch, getState }) => { (statusId: string, { dispatch, getState }) => {
const status = getState().statuses.get(statusId); const status = getState().statuses.get(statusId);
@ -97,6 +148,6 @@ export const quoteComposeById = createAppThunk(
export const quoteComposeCancel = createAction('compose/quoteComposeCancel'); export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
export const setQuotePolicy = createAction<ApiQuotePolicy>( export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
'compose/setQuotePolicy', 'compose/setQuotePolicy',
); );

View File

@ -133,3 +133,9 @@ export interface ApiStatusSourceJSON {
text: string; text: string;
spoiler_text: string; spoiler_text: string;
} }
export function isStatusVisibility(
visibility: string,
): visibility is StatusVisibility {
return ['public', 'unlisted', 'private', 'direct'].includes(visibility);
}

View File

@ -110,6 +110,7 @@ class Status extends ImmutablePureComponent {
onToggleCollapsed: PropTypes.func, onToggleCollapsed: PropTypes.func,
onTranslate: PropTypes.func, onTranslate: PropTypes.func,
onInteractionModal: PropTypes.func, onInteractionModal: PropTypes.func,
onQuoteCancel: PropTypes.func,
muted: PropTypes.bool, muted: PropTypes.bool,
hidden: PropTypes.bool, hidden: PropTypes.bool,
unread: PropTypes.bool, unread: PropTypes.bool,
@ -583,7 +584,7 @@ class Status extends ImmutablePureComponent {
<DisplayName account={status.get('account')} /> <DisplayName account={status.get('account')} />
</Link> </Link>
{this.props.contextType === 'compose' && isQuotedPost && ( {isQuotedPost && !!this.props.onQuoteCancel && (
<IconButton <IconButton
onClick={this.handleQuoteCancel} onClick={this.handleQuoteCancel}
className='status__quote-cancel' className='status__quote-cancel'

View File

@ -63,12 +63,21 @@ type GetStatusSelector = (
props: { id?: string | null; contextType?: string }, props: { id?: string | null; contextType?: string },
) => Status | null; ) => Status | null;
export const QuotedStatus: React.FC<{ interface QuotedStatusProps {
quote: QuoteMap; quote: QuoteMap;
contextType?: string; contextType?: string;
variant?: 'full' | 'link'; variant?: 'full' | 'link';
nestingLevel?: number; nestingLevel?: number;
}> = ({ quote, contextType, nestingLevel = 1, variant = 'full' }) => { onQuoteCancel?: () => void; // Used for composer.
}
export const QuotedStatus: React.FC<QuotedStatusProps> = ({
quote,
contextType,
nestingLevel = 1,
variant = 'full',
onQuoteCancel,
}) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const quotedStatusId = quote.get('quoted_status'); const quotedStatusId = quote.get('quoted_status');
const quoteState = quote.get('state'); const quoteState = quote.get('state');
@ -160,6 +169,7 @@ export const QuotedStatus: React.FC<{
id={quotedStatusId} id={quotedStatusId}
contextType={contextType} contextType={contextType}
avatarSize={32} avatarSize={32}
onQuoteCancel={onQuoteCancel}
> >
{canRenderChildQuote && ( {canRenderChildQuote && (
<QuotedStatus <QuotedStatus

View File

@ -41,10 +41,10 @@ import {
translateStatus, translateStatus,
undoStatusTranslation, undoStatusTranslation,
} from '../actions/statuses'; } from '../actions/statuses';
import { setStatusQuotePolicy } from '../actions/statuses_typed';
import Status from '../components/status'; import Status from '../components/status';
import { deleteModal } from '../initial_state'; import { deleteModal } from '../initial_state';
import { makeGetStatus, makeGetPictureInPicture } from '../selectors'; import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
import { quoteComposeCancel } from '../actions/compose_typed';
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
@ -112,18 +112,18 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
} }
}, },
onQuoteCancel() {
if (contextType === 'compose') {
dispatch(quoteComposeCancel());
}
},
onRevokeQuote (status) { onRevokeQuote (status) {
dispatch(openModal({ modalType: 'CONFIRM_REVOKE_QUOTE', modalProps: { statusId: status.get('id'), quotedStatusId: status.getIn(['quote', 'quoted_status']) }})); dispatch(openModal({ modalType: 'CONFIRM_REVOKE_QUOTE', modalProps: { statusId: status.get('id'), quotedStatusId: status.getIn(['quote', 'quoted_status']) }}));
}, },
onQuotePolicyChange(status) { onQuotePolicyChange(status) {
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId: status.get('id') } })); const statusId = status.get('id');
const handleChange = (_, quotePolicy) => {
dispatch(
setStatusQuotePolicy({ policy: quotePolicy, statusId }),
);
}
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } }));
}, },
onEdit (status) { onEdit (status) {

View File

@ -15,10 +15,8 @@ import { missingAltTextModal } from 'mastodon/initial_state';
import AutosuggestInput from 'mastodon/components/autosuggest_input'; import AutosuggestInput from 'mastodon/components/autosuggest_input';
import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea'; import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea';
import { Button } from 'mastodon/components/button'; import { Button } from 'mastodon/components/button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollButtonContainer from '../containers/poll_button_container'; import PollButtonContainer from '../containers/poll_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import SpoilerButtonContainer from '../containers/spoiler_button_container'; import SpoilerButtonContainer from '../containers/spoiler_button_container';
import UploadButtonContainer from '../containers/upload_button_container'; import UploadButtonContainer from '../containers/upload_button_container';
import { countableText } from '../util/counter'; import { countableText } from '../util/counter';
@ -32,6 +30,7 @@ import { ReplyIndicator } from './reply_indicator';
import { UploadForm } from './upload_form'; import { UploadForm } from './upload_form';
import { Warning } from './warning'; import { Warning } from './warning';
import { ComposeQuotedStatus } from './quoted_post'; import { ComposeQuotedStatus } from './quoted_post';
import { VisibilityButton } from './visibility_button';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
@ -260,7 +259,7 @@ class ComposeForm extends ImmutablePureComponent {
<EditIndicator /> <EditIndicator />
<div className='compose-form__dropdowns'> <div className='compose-form__dropdowns'>
<PrivacyDropdownContainer disabled={this.props.isEditing} /> <VisibilityButton disabled={this.props.isEditing} />
<LanguageDropdown /> <LanguageDropdown />
</div> </div>

View File

@ -1,15 +1,17 @@
import { useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { Map } from 'immutable'; import { Map } from 'immutable';
import { quoteComposeCancel } from '@/mastodon/actions/compose_typed';
import { QuotedStatus } from '@/mastodon/components/status_quoted'; import { QuotedStatus } from '@/mastodon/components/status_quoted';
import { useAppSelector } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store';
export const ComposeQuotedStatus: FC = () => { export const ComposeQuotedStatus: FC = () => {
const quotedStatusId = useAppSelector( const quotedStatusId = useAppSelector(
(state) => state.compose.get('quoted_status_id') as string | null, (state) => state.compose.get('quoted_status_id') as string | null,
); );
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
const quote = useMemo( const quote = useMemo(
() => () =>
quotedStatusId quotedStatusId
@ -20,8 +22,17 @@ export const ComposeQuotedStatus: FC = () => {
: null, : null,
[quotedStatusId], [quotedStatusId],
); );
const dispatch = useAppDispatch();
const handleQuoteCancel = useCallback(() => {
dispatch(quoteComposeCancel());
}, [dispatch]);
if (!quote) { if (!quote) {
return null; return null;
} }
return <QuotedStatus quote={quote} contextType='compose' />; return (
<QuotedStatus
quote={quote}
onQuoteCancel={!isEditing ? handleQuoteCancel : undefined}
/>
);
}; };

View File

@ -0,0 +1,148 @@
import { useCallback, useMemo } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { changeComposeVisibility } from '@/mastodon/actions/compose';
import { 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 { Icon } from '@/mastodon/components/icon';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
import { isFeatureEnabled } from '@/mastodon/utils/environment';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import type { VisibilityModalCallback } from '../../ui/components/visibility_modal';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import { messages as privacyMessages } from './privacy_dropdown';
const messages = defineMessages({
anyone_quote: {
id: 'privacy.quote.anyone',
defaultMessage: '{visibility}, anyone can quote',
},
limited_quote: {
id: 'privacy.quote.limited',
defaultMessage: '{visibility}, quotes limited',
},
disabled_quote: {
id: 'privacy.quote.disabled',
defaultMessage: '{visibility}, quotes disabled',
},
});
interface PrivacyDropdownProps {
disabled?: boolean;
}
export const VisibilityButton: FC<PrivacyDropdownProps> = (props) => {
if (!isFeatureEnabled('outgoing_quotes')) {
return <PrivacyDropdownContainer {...props} />;
}
return <PrivacyModalButton {...props} />;
};
const visibilityOptions = {
public: {
icon: 'globe',
iconComponent: PublicIcon,
value: 'public',
text: privacyMessages.public_short,
},
unlisted: {
icon: 'unlock',
iconComponent: QuietTimeIcon,
value: 'unlisted',
text: privacyMessages.unlisted_short,
},
private: {
icon: 'lock',
iconComponent: LockIcon,
value: 'private',
text: privacyMessages.private_short,
},
direct: {
icon: 'at',
iconComponent: AlternateEmailIcon,
value: 'direct',
text: privacyMessages.direct_short,
},
};
const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
const intl = useIntl();
const { visibility, quotePolicy } = useAppSelector((state) => ({
visibility: state.compose.get('privacy') as StatusVisibility,
quotePolicy: state.compose.get('quote_policy') as ApiQuotePolicy,
}));
const { icon, iconComponent } = useMemo(() => {
const option = visibilityOptions[visibility];
return { icon: option.icon, iconComponent: option.iconComponent };
}, [visibility]);
const text = useMemo(() => {
const visibilityText = intl.formatMessage(
visibilityOptions[visibility].text,
);
if (visibility === 'private' || visibility === 'direct') {
return visibilityText;
}
if (quotePolicy === 'nobody') {
return intl.formatMessage(messages.disabled_quote, {
visibility: visibilityText,
});
}
if (quotePolicy !== 'public') {
return intl.formatMessage(messages.limited_quote, {
visibility: visibilityText,
});
}
return intl.formatMessage(messages.anyone_quote, {
visibility: visibilityText,
});
}, [quotePolicy, visibility, intl]);
const dispatch = useAppDispatch();
const handleChange: VisibilityModalCallback = useCallback(
(newVisibility, newQuotePolicy) => {
if (newVisibility !== visibility) {
dispatch(changeComposeVisibility(newVisibility));
}
if (newQuotePolicy !== quotePolicy) {
dispatch(setComposeQuotePolicy(newQuotePolicy));
}
},
[dispatch, quotePolicy, visibility],
);
const handleOpen = useCallback(() => {
dispatch(
openModal({
modalType: 'COMPOSE_PRIVACY',
modalProps: { onChange: handleChange },
}),
);
}, [dispatch, handleChange]);
return (
<button
type='button'
title={intl.formatMessage(privacyMessages.change_privacy)}
onClick={handleOpen}
disabled={disabled}
className={classNames('dropdown-button')}
>
<Icon id={icon} icon={iconComponent} />
<span className='dropdown-button__label'>{text}</span>
</button>
);
};

View File

@ -1,6 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
@ -15,7 +15,6 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac
import { Hotkeys } from 'mastodon/components/hotkeys'; import { Hotkeys } from 'mastodon/components/hotkeys';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { TimelineHint } from 'mastodon/components/timeline_hint';
import ScrollContainer from 'mastodon/containers/scroll_container'; import ScrollContainer from 'mastodon/containers/scroll_container';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
@ -57,6 +56,7 @@ import {
translateStatus, translateStatus,
undoStatusTranslation, undoStatusTranslation,
} from '../../actions/statuses'; } from '../../actions/statuses';
import { setStatusQuotePolicy } from '../../actions/statuses_typed';
import ColumnHeader from '../../components/column_header'; import ColumnHeader from '../../components/column_header';
import { textForScreenReader, defaultMediaVisibility } from '../../components/status'; import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
import { StatusQuoteManager } from '../../components/status_quoted'; import { StatusQuoteManager } from '../../components/status_quoted';
@ -266,8 +266,14 @@ class Status extends ImmutablePureComponent {
}; };
handleQuotePolicyChange = (status) => { handleQuotePolicyChange = (status) => {
const statusId = status.get('id');
const { dispatch } = this.props; const { dispatch } = this.props;
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId: status.get('id') } })); const handleChange = (_, quotePolicy) => {
dispatch(
setStatusQuotePolicy({ policy: quotePolicy, statusId }),
);
}
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } }));
}; };
handleEditClick = (status) => { handleEditClick = (status) => {

View File

@ -1,25 +1,31 @@
import { forwardRef, useCallback, useId, useMemo } from 'react'; import {
forwardRef,
useCallback,
useId,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { changeComposeVisibility } from '@/mastodon/actions/compose';
import { setStatusQuotePolicy } from '@/mastodon/actions/statuses_typed';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes'; import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import { isQuotePolicy } from '@/mastodon/api_types/quotes'; import { isQuotePolicy } from '@/mastodon/api_types/quotes';
import { isStatusVisibility } from '@/mastodon/api_types/statuses';
import type { StatusVisibility } from '@/mastodon/api_types/statuses'; import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { Dropdown } from '@/mastodon/components/dropdown'; import { Dropdown } from '@/mastodon/components/dropdown';
import type { SelectItem } from '@/mastodon/components/dropdown_selector'; import type { SelectItem } from '@/mastodon/components/dropdown_selector';
import { IconButton } from '@/mastodon/components/icon_button'; import { IconButton } from '@/mastodon/components/icon_button';
import { messages as privacyMessages } from '@/mastodon/features/compose/components/privacy_dropdown'; import { messages as privacyMessages } from '@/mastodon/features/compose/components/privacy_dropdown';
import { import { createAppSelector, useAppSelector } from '@/mastodon/store';
createAppSelector, import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import type { BaseConfirmationModalProps } from './confirmation_modals/confirmation_modal'; import type { BaseConfirmationModalProps } from './confirmation_modals/confirmation_modal';
@ -43,13 +49,26 @@ const messages = defineMessages({
}, },
}); });
export type VisibilityModalCallback = (
visibility: StatusVisibility,
quotePolicy: ApiQuotePolicy,
) => void;
interface VisibilityModalProps extends BaseConfirmationModalProps { interface VisibilityModalProps extends BaseConfirmationModalProps {
statusId: string; statusId?: string;
onChange: VisibilityModalCallback;
} }
const selectStatusPolicy = createAppSelector( const selectStatusPolicy = createAppSelector(
[(state) => state.statuses, (_state, statusId: string) => statusId], [
(statuses, statusId) => { (state) => state.statuses,
(_state, statusId?: string) => statusId,
(state) => state.compose.get('quote_policy') as ApiQuotePolicy,
],
(statuses, statusId, composeQuotePolicy) => {
if (!statusId) {
return composeQuotePolicy;
}
const status = statuses.get(statusId); const status = statuses.get(statusId);
if (!status) { if (!status) {
return 'public'; return 'public';
@ -77,24 +96,25 @@ const selectStatusPolicy = createAppSelector(
); );
export const VisibilityModal: FC<VisibilityModalProps> = forwardRef( export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
// eslint-disable-next-line @typescript-eslint/no-unused-vars ({ onClose, onChange, statusId }, ref) => {
({ onClose, statusId }, ref) => {
const intl = useIntl(); const intl = useIntl();
const currentVisibility = useAppSelector( const currentVisibility = useAppSelector((state) =>
(state) => statusId
(state.statuses.getIn([statusId, 'visibility'], 'public') as ? ((state.statuses.getIn([statusId, 'visibility'], 'public') as
| StatusVisibility | StatusVisibility
| undefined) ?? 'public', | undefined) ?? 'public')
: (state.compose.get('privacy') as StatusVisibility),
); );
const currentQuotePolicy = useAppSelector((state) => const currentQuotePolicy = useAppSelector((state) =>
selectStatusPolicy(state, statusId), selectStatusPolicy(state, statusId),
); );
const [visibility, setVisibility] = useState(currentVisibility);
const [quotePolicy, setQuotePolicy] = useState(currentQuotePolicy);
const disableVisibility = !!statusId;
const disableQuotePolicy = const disableQuotePolicy =
currentVisibility === 'private' || currentVisibility === 'direct'; visibility === 'private' || visibility === 'direct';
const isSaving = useAppSelector(
(state) =>
state.statuses.getIn([statusId, 'isSavingQuotePolicy']) === true,
);
const visibilityItems = useMemo<SelectItem<StatusVisibility>[]>( const visibilityItems = useMemo<SelectItem<StatusVisibility>[]>(
() => [ () => [
@ -102,21 +122,30 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
value: 'public', value: 'public',
text: intl.formatMessage(privacyMessages.public_short), text: intl.formatMessage(privacyMessages.public_short),
meta: intl.formatMessage(privacyMessages.public_long), meta: intl.formatMessage(privacyMessages.public_long),
icon: 'globe',
iconComponent: PublicIcon,
}, },
{ {
value: 'unlisted', value: 'unlisted',
text: intl.formatMessage(privacyMessages.unlisted_short), text: intl.formatMessage(privacyMessages.unlisted_short),
meta: intl.formatMessage(privacyMessages.unlisted_long), meta: intl.formatMessage(privacyMessages.unlisted_long),
extra: intl.formatMessage(privacyMessages.unlisted_extra),
icon: 'unlock',
iconComponent: QuietTimeIcon,
}, },
{ {
value: 'private', value: 'private',
text: intl.formatMessage(privacyMessages.private_short), text: intl.formatMessage(privacyMessages.private_short),
meta: intl.formatMessage(privacyMessages.private_long), meta: intl.formatMessage(privacyMessages.private_long),
icon: 'lock',
iconComponent: LockIcon,
}, },
{ {
value: 'direct', value: 'direct',
text: intl.formatMessage(privacyMessages.direct_short), text: intl.formatMessage(privacyMessages.direct_short),
meta: intl.formatMessage(privacyMessages.direct_long), meta: intl.formatMessage(privacyMessages.direct_long),
icon: 'at',
iconComponent: AlternateEmailIcon,
}, },
], ],
[intl], [intl],
@ -133,24 +162,27 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
[intl], [intl],
); );
const dispatch = useAppDispatch(); const handleVisibilityChange = useCallback((value: string) => {
const handleVisibilityChange = useCallback( if (isStatusVisibility(value)) {
(value: string) => { setVisibility(value);
// Published statuses cannot change visibility.
if (statusId) {
return;
} }
dispatch(changeComposeVisibility(value)); }, []);
}, const handleQuotePolicyChange = useCallback((value: string) => {
[dispatch, statusId], if (isQuotePolicy(value)) {
); setQuotePolicy(value);
const handleQuotePolicyChange = useCallback(
(value: string) => {
if (isQuotePolicy(value) && !disableQuotePolicy) {
void dispatch(setStatusQuotePolicy({ policy: value, statusId }));
} }
}, []);
// Save on close
useImperativeHandle(
ref,
() => ({
getCloseConfirmationMessage() {
onChange(visibility, quotePolicy);
return null;
}, },
[disableQuotePolicy, dispatch, statusId], }),
[onChange, quotePolicy, visibility],
); );
const privacyDropdownId = useId(); const privacyDropdownId = useId();
@ -192,7 +224,7 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
<label <label
htmlFor={privacyDropdownId} htmlFor={privacyDropdownId}
className={classNames('visibility-dropdown__label', { className={classNames('visibility-dropdown__label', {
disabled: isSaving || !!statusId, disabled: disableVisibility,
})} })}
> >
<FormattedMessage <FormattedMessage
@ -203,10 +235,10 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
<Dropdown <Dropdown
items={visibilityItems} items={visibilityItems}
classPrefix='visibility-dropdown' classPrefix='visibility-dropdown'
current={currentVisibility} current={visibility}
onChange={handleVisibilityChange} onChange={handleVisibilityChange}
title={intl.formatMessage(privacyMessages.change_privacy)} title={intl.formatMessage(privacyMessages.change_privacy)}
disabled={isSaving || !!statusId} disabled={disableVisibility}
id={privacyDropdownId} id={privacyDropdownId}
/> />
{!!statusId && ( {!!statusId && (
@ -222,7 +254,7 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
<label <label
htmlFor={quoteDropdownId} htmlFor={quoteDropdownId}
className={classNames('visibility-dropdown__label', { className={classNames('visibility-dropdown__label', {
disabled: disableQuotePolicy || isSaving, disabled: disableQuotePolicy,
})} })}
> >
<FormattedMessage <FormattedMessage
@ -234,15 +266,12 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
items={quoteItems} items={quoteItems}
onChange={handleQuotePolicyChange} onChange={handleQuotePolicyChange}
classPrefix='visibility-dropdown' classPrefix='visibility-dropdown'
current={currentQuotePolicy} current={quotePolicy}
title={intl.formatMessage(messages.buttonTitle)} title={intl.formatMessage(messages.buttonTitle)}
disabled={disableQuotePolicy || isSaving} disabled={disableQuotePolicy}
id={quoteDropdownId} id={quoteDropdownId}
/> />
<QuotePolicyHelper <QuotePolicyHelper policy={quotePolicy} visibility={visibility} />
policy={currentQuotePolicy}
visibility={currentVisibility}
/>
</label> </label>
</div> </div>
</div> </div>

View File

@ -738,11 +738,18 @@
"privacy.private.short": "Followers", "privacy.private.short": "Followers",
"privacy.public.long": "Anyone on and off Mastodon", "privacy.public.long": "Anyone on and off Mastodon",
"privacy.public.short": "Public", "privacy.public.short": "Public",
"privacy.quote.anyone": "{visibility}, anyone can quote",
"privacy.quote.disabled": "{visibility}, quotes disabled",
"privacy.quote.limited": "{visibility}, quotes limited",
"privacy.unlisted.additional": "This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.", "privacy.unlisted.additional": "This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.",
"privacy.unlisted.long": "Fewer algorithmic fanfares", "privacy.unlisted.long": "Fewer algorithmic fanfares",
"privacy.unlisted.short": "Quiet public", "privacy.unlisted.short": "Quiet public",
"privacy_policy.last_updated": "Last updated {date}", "privacy_policy.last_updated": "Last updated {date}",
"privacy_policy.title": "Privacy Policy", "privacy_policy.title": "Privacy Policy",
"quote_error.poll": "Quoting is not allowed with polls.",
"quote_error.quote": "Only one quote at a time is allowed.",
"quote_error.unauthorized": "You are not authorized to quote this post.",
"quote_error.upload": "Quoting is not allowed with media attachments.",
"recommended": "Recommended", "recommended": "Recommended",
"refresh": "Refresh", "refresh": "Refresh",
"regeneration_indicator.please_stand_by": "Please stand by.", "regeneration_indicator.please_stand_by": "Please stand by.",
@ -944,6 +951,7 @@
"upload_button.label": "Add images, a video or an audio file", "upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.", "upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.", "upload_error.poll": "File upload not allowed with polls.",
"upload_error.quote": "File upload not allowed with quotes.",
"upload_form.drag_and_drop.instructions": "To pick up a media attachment, press space or enter. While dragging, use the arrow keys to move the media attachment in any given direction. Press space or enter again to drop the media attachment in its new position, or press escape to cancel.", "upload_form.drag_and_drop.instructions": "To pick up a media attachment, press space or enter. While dragging, use the arrow keys to move the media attachment in any given direction. Press space or enter again to drop the media attachment in its new position, or press escape to cancel.",
"upload_form.drag_and_drop.on_drag_cancel": "Dragging was cancelled. Media attachment {item} was dropped.", "upload_form.drag_and_drop.on_drag_cancel": "Dragging was cancelled. Media attachment {item} was dropped.",
"upload_form.drag_and_drop.on_drag_end": "Media attachment {item} was dropped.", "upload_form.drag_and_drop.on_drag_end": "Media attachment {item} was dropped.",

View File

@ -2,9 +2,9 @@ import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrde
import { import {
changeUploadCompose, changeUploadCompose,
quoteComposeByStatus, quoteCompose,
quoteComposeCancel, quoteComposeCancel,
setQuotePolicy, setComposeQuotePolicy,
} from 'mastodon/actions/compose_typed'; } from 'mastodon/actions/compose_typed';
import { timelineDelete } from 'mastodon/actions/timelines_typed'; import { timelineDelete } from 'mastodon/actions/timelines_typed';
@ -329,22 +329,15 @@ export const composeReducer = (state = initialState, action) => {
return state.set('is_changing_upload', true); return state.set('is_changing_upload', true);
} else if (changeUploadCompose.rejected.match(action)) { } else if (changeUploadCompose.rejected.match(action)) {
return state.set('is_changing_upload', false); return state.set('is_changing_upload', false);
} else if (quoteComposeByStatus.match(action)) { } else if (quoteCompose.match(action)) {
const status = action.payload; const status = action.payload;
if (
status.getIn(['quote_approval', 'current_user']) === 'automatic' &&
state.get('media_attachments').size === 0 &&
!state.get('is_uploading') &&
!state.get('poll')
) {
return state return state
.set('quoted_status_id', status.get('id')) .set('quoted_status_id', status.get('id'))
.set('spoiler', status.get('sensitive')) .set('spoiler', status.get('sensitive'))
.set('spoiler_text', status.get('spoiler_text')); .set('spoiler_text', status.get('spoiler_text'));
}
} else if (quoteComposeCancel.match(action)) { } else if (quoteComposeCancel.match(action)) {
return state.set('quoted_status_id', null); return state.set('quoted_status_id', null);
} else if (setQuotePolicy.match(action)) { } else if (setComposeQuotePolicy.match(action)) {
return state.set('quote_policy', action.payload); return state.set('quote_policy', action.payload);
} }
@ -520,6 +513,7 @@ export const composeReducer = (state = initialState, action) => {
map.set('sensitive', action.status.get('sensitive')); map.set('sensitive', action.status.get('sensitive'));
map.set('language', action.status.get('language')); map.set('language', action.status.get('language'));
map.set('id', null); map.set('id', null);
map.set('quoted_status_id', action.status.getIn(['quote', 'quoted_status']));
// Mastodon-authored posts can be expected to have at most one automatic approval policy // Mastodon-authored posts can be expected to have at most one automatic approval policy
map.set('quote_policy', action.status.getIn(['quote_approval', 'automatic', 0]) || 'nobody'); map.set('quote_policy', action.status.getIn(['quote_approval', 'automatic', 0]) || 'nobody');
@ -551,6 +545,7 @@ export const composeReducer = (state = initialState, action) => {
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
map.set('sensitive', action.status.get('sensitive')); map.set('sensitive', action.status.get('sensitive'));
map.set('language', action.status.get('language')); map.set('language', action.status.get('language'));
map.set('quoted_status_id', action.status.getIn(['quote', 'quoted_status']));
// Mastodon-authored posts can be expected to have at most one automatic approval policy // Mastodon-authored posts can be expected to have at most one automatic approval policy
map.set('quote_policy', action.status.getIn(['quote_approval', 'automatic', 0]) || 'nobody'); map.set('quote_policy', action.status.getIn(['quote_approval', 'automatic', 0]) || 'nobody');

View File

@ -15,7 +15,7 @@ export const getFilters = createSelector(
(_, { contextType }: { contextType: string }) => contextType, (_, { contextType }: { contextType: string }) => contextType,
], ],
(filters, contextType) => { (filters, contextType) => {
if (!contextType || contextType === 'compose') { if (!contextType) {
return null; return null;
} }

View File

@ -5512,7 +5512,10 @@ a.status-card {
.privacy-dropdown__option__content, .privacy-dropdown__option__content,
.privacy-dropdown__option__content strong, .privacy-dropdown__option__content strong,
.privacy-dropdown__option__additional { .privacy-dropdown__option__additional,
.visibility-dropdown__option__content,
.visibility-dropdown__option__content strong,
.visibility-dropdown__option__additional {
color: $primary-text-color; color: $primary-text-color;
} }
} }
@ -5526,13 +5529,15 @@ a.status-card {
} }
} }
.privacy-dropdown__option__icon { .privacy-dropdown__option__icon,
.visibility-dropdown__option__icon {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.privacy-dropdown__option__content { .privacy-dropdown__option__content,
.visibility-dropdown__option__content {
flex: 1 1 auto; flex: 1 1 auto;
color: $darker-text-color; color: $darker-text-color;