From f85f0eee1bb79322f40694101dbd4b9bf2b601eb Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 21 Aug 2025 16:07:31 +0200 Subject: [PATCH] Composer quote improvements (#35835) Co-authored-by: Claire Co-authored-by: diondiondion --- app/javascript/mastodon/actions/compose.js | 8 +- .../mastodon/actions/compose_typed.ts | 61 +++++++- app/javascript/mastodon/api_types/statuses.ts | 6 + app/javascript/mastodon/components/status.jsx | 3 +- .../mastodon/components/status_quoted.tsx | 14 +- .../mastodon/containers/status_container.jsx | 16 +- .../compose/components/compose_form.jsx | 5 +- .../compose/components/quoted_post.tsx | 17 +- .../compose/components/visibility_button.tsx | 148 ++++++++++++++++++ .../mastodon/features/status/index.jsx | 12 +- .../ui/components/visibility_modal.tsx | 131 ++++++++++------ app/javascript/mastodon/locales/en.json | 8 + app/javascript/mastodon/reducers/compose.js | 25 ++- app/javascript/mastodon/selectors/filters.ts | 2 +- .../styles/mastodon/components.scss | 11 +- 15 files changed, 371 insertions(+), 96 deletions(-) create mode 100644 app/javascript/mastodon/features/compose/components/visibility_button.tsx diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 9dfa4041bdc..41b2336bc22 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -84,6 +84,7 @@ export const COMPOSE_FOCUS = 'COMPOSE_FOCUS'; const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, 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' }, published: { id: 'compose.published.body', defaultMessage: 'Post published.' }, 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({ type: COMPOSE_FOCUS, defaultText, @@ -303,6 +304,11 @@ export function submitComposeFail(error) { export function uploadCompose(files) { 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 media = getState().getIn(['compose', 'media_attachments']); const pending = getState().getIn(['compose', 'pending_media_attachments']); diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts index 7b1f5e688c6..3c6a2649936 100644 --- a/app/javascript/mastodon/actions/compose_typed.ts +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -1,3 +1,5 @@ +import { defineMessages } from 'react-intl'; + import { createAction } from '@reduxjs/toolkit'; import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; @@ -12,7 +14,27 @@ import { import type { ApiQuotePolicy } from '../api_types/quotes'; 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 & { unattached?: boolean; @@ -78,14 +100,43 @@ export const changeUploadCompose = createDataLoadingThunk( }, ); -export const quoteComposeByStatus = createAppThunk( +export const quoteCompose = createAppThunk( 'compose/quoteComposeStatus', - (status: Status, { getState }) => { - ensureComposeIsVisible(getState); + (status: Status, { dispatch }) => { + dispatch(focusCompose()); 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( (statusId: string, { dispatch, getState }) => { const status = getState().statuses.get(statusId); @@ -97,6 +148,6 @@ export const quoteComposeById = createAppThunk( export const quoteComposeCancel = createAction('compose/quoteComposeCancel'); -export const setQuotePolicy = createAction( +export const setComposeQuotePolicy = createAction( 'compose/setQuotePolicy', ); diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index 0127f6334bf..d5889501189 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -133,3 +133,9 @@ export interface ApiStatusSourceJSON { text: string; spoiler_text: string; } + +export function isStatusVisibility( + visibility: string, +): visibility is StatusVisibility { + return ['public', 'unlisted', 'private', 'direct'].includes(visibility); +} diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index fe66de9d163..7271e1d6266 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -110,6 +110,7 @@ class Status extends ImmutablePureComponent { onToggleCollapsed: PropTypes.func, onTranslate: PropTypes.func, onInteractionModal: PropTypes.func, + onQuoteCancel: PropTypes.func, muted: PropTypes.bool, hidden: PropTypes.bool, unread: PropTypes.bool, @@ -583,7 +584,7 @@ class Status extends ImmutablePureComponent { - {this.props.contextType === 'compose' && isQuotedPost && ( + {isQuotedPost && !!this.props.onQuoteCancel && ( Status | null; -export const QuotedStatus: React.FC<{ +interface QuotedStatusProps { quote: QuoteMap; contextType?: string; variant?: 'full' | 'link'; nestingLevel?: number; -}> = ({ quote, contextType, nestingLevel = 1, variant = 'full' }) => { + onQuoteCancel?: () => void; // Used for composer. +} + +export const QuotedStatus: React.FC = ({ + quote, + contextType, + nestingLevel = 1, + variant = 'full', + onQuoteCancel, +}) => { const dispatch = useAppDispatch(); const quotedStatusId = quote.get('quoted_status'); const quoteState = quote.get('state'); @@ -160,6 +169,7 @@ export const QuotedStatus: React.FC<{ id={quotedStatusId} contextType={contextType} avatarSize={32} + onQuoteCancel={onQuoteCancel} > {canRenderChildQuote && ( { const getStatus = makeGetStatus(); @@ -112,18 +112,18 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({ } }, - onQuoteCancel() { - if (contextType === 'compose') { - dispatch(quoteComposeCancel()); - } - }, - onRevokeQuote (status) { dispatch(openModal({ modalType: 'CONFIRM_REVOKE_QUOTE', modalProps: { statusId: status.get('id'), quotedStatusId: status.getIn(['quote', 'quoted_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) { diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 08d111b064e..ac0a4969391 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -15,10 +15,8 @@ import { missingAltTextModal } from 'mastodon/initial_state'; import AutosuggestInput from 'mastodon/components/autosuggest_input'; import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea'; import { Button } from 'mastodon/components/button'; -import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import PollButtonContainer from '../containers/poll_button_container'; -import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import UploadButtonContainer from '../containers/upload_button_container'; import { countableText } from '../util/counter'; @@ -32,6 +30,7 @@ import { ReplyIndicator } from './reply_indicator'; import { UploadForm } from './upload_form'; import { Warning } from './warning'; 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'; @@ -260,7 +259,7 @@ class ComposeForm extends ImmutablePureComponent {
- +
diff --git a/app/javascript/mastodon/features/compose/components/quoted_post.tsx b/app/javascript/mastodon/features/compose/components/quoted_post.tsx index ee12e4ae75f..335e7ce610d 100644 --- a/app/javascript/mastodon/features/compose/components/quoted_post.tsx +++ b/app/javascript/mastodon/features/compose/components/quoted_post.tsx @@ -1,15 +1,17 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import type { FC } from 'react'; import { Map } from 'immutable'; +import { quoteComposeCancel } from '@/mastodon/actions/compose_typed'; import { QuotedStatus } from '@/mastodon/components/status_quoted'; -import { useAppSelector } from '@/mastodon/store'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; export const ComposeQuotedStatus: FC = () => { const quotedStatusId = useAppSelector( (state) => state.compose.get('quoted_status_id') as string | null, ); + const isEditing = useAppSelector((state) => !!state.compose.get('id')); const quote = useMemo( () => quotedStatusId @@ -20,8 +22,17 @@ export const ComposeQuotedStatus: FC = () => { : null, [quotedStatusId], ); + const dispatch = useAppDispatch(); + const handleQuoteCancel = useCallback(() => { + dispatch(quoteComposeCancel()); + }, [dispatch]); if (!quote) { return null; } - return ; + return ( + + ); }; diff --git a/app/javascript/mastodon/features/compose/components/visibility_button.tsx b/app/javascript/mastodon/features/compose/components/visibility_button.tsx new file mode 100644 index 00000000000..203a569bcc9 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/visibility_button.tsx @@ -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 = (props) => { + if (!isFeatureEnabled('outgoing_quotes')) { + return ; + } + return ; +}; + +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 = ({ 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 ( + + ); +}; diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 3cfda6e837f..36a67226f86 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; import classNames from 'classnames'; 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 { Icon } from 'mastodon/components/icon'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -import { TimelineHint } from 'mastodon/components/timeline_hint'; import ScrollContainer from 'mastodon/containers/scroll_container'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; @@ -57,6 +56,7 @@ import { translateStatus, undoStatusTranslation, } from '../../actions/statuses'; +import { setStatusQuotePolicy } from '../../actions/statuses_typed'; import ColumnHeader from '../../components/column_header'; import { textForScreenReader, defaultMediaVisibility } from '../../components/status'; import { StatusQuoteManager } from '../../components/status_quoted'; @@ -266,8 +266,14 @@ class Status extends ImmutablePureComponent { }; handleQuotePolicyChange = (status) => { + const statusId = status.get('id'); 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) => { diff --git a/app/javascript/mastodon/features/ui/components/visibility_modal.tsx b/app/javascript/mastodon/features/ui/components/visibility_modal.tsx index 82a1a482a3f..8e681ea5c52 100644 --- a/app/javascript/mastodon/features/ui/components/visibility_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/visibility_modal.tsx @@ -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 { defineMessages, FormattedMessage, useIntl } from 'react-intl'; 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 { isQuotePolicy } from '@/mastodon/api_types/quotes'; +import { isStatusVisibility } from '@/mastodon/api_types/statuses'; import type { StatusVisibility } from '@/mastodon/api_types/statuses'; import { Dropdown } from '@/mastodon/components/dropdown'; import type { SelectItem } from '@/mastodon/components/dropdown_selector'; import { IconButton } from '@/mastodon/components/icon_button'; import { messages as privacyMessages } from '@/mastodon/features/compose/components/privacy_dropdown'; -import { - createAppSelector, - useAppDispatch, - useAppSelector, -} from '@/mastodon/store'; +import { createAppSelector, useAppSelector } from '@/mastodon/store'; +import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.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'; @@ -43,13 +49,26 @@ const messages = defineMessages({ }, }); +export type VisibilityModalCallback = ( + visibility: StatusVisibility, + quotePolicy: ApiQuotePolicy, +) => void; + interface VisibilityModalProps extends BaseConfirmationModalProps { - statusId: string; + statusId?: string; + onChange: VisibilityModalCallback; } 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); if (!status) { return 'public'; @@ -77,24 +96,25 @@ const selectStatusPolicy = createAppSelector( ); export const VisibilityModal: FC = forwardRef( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ({ onClose, statusId }, ref) => { + ({ onClose, onChange, statusId }, ref) => { const intl = useIntl(); - const currentVisibility = useAppSelector( - (state) => - (state.statuses.getIn([statusId, 'visibility'], 'public') as - | StatusVisibility - | undefined) ?? 'public', + const currentVisibility = useAppSelector((state) => + statusId + ? ((state.statuses.getIn([statusId, 'visibility'], 'public') as + | StatusVisibility + | undefined) ?? 'public') + : (state.compose.get('privacy') as StatusVisibility), ); const currentQuotePolicy = useAppSelector((state) => selectStatusPolicy(state, statusId), ); + + const [visibility, setVisibility] = useState(currentVisibility); + const [quotePolicy, setQuotePolicy] = useState(currentQuotePolicy); + + const disableVisibility = !!statusId; const disableQuotePolicy = - currentVisibility === 'private' || currentVisibility === 'direct'; - const isSaving = useAppSelector( - (state) => - state.statuses.getIn([statusId, 'isSavingQuotePolicy']) === true, - ); + visibility === 'private' || visibility === 'direct'; const visibilityItems = useMemo[]>( () => [ @@ -102,21 +122,30 @@ export const VisibilityModal: FC = forwardRef( value: 'public', text: intl.formatMessage(privacyMessages.public_short), meta: intl.formatMessage(privacyMessages.public_long), + icon: 'globe', + iconComponent: PublicIcon, }, { value: 'unlisted', text: intl.formatMessage(privacyMessages.unlisted_short), meta: intl.formatMessage(privacyMessages.unlisted_long), + extra: intl.formatMessage(privacyMessages.unlisted_extra), + icon: 'unlock', + iconComponent: QuietTimeIcon, }, { value: 'private', text: intl.formatMessage(privacyMessages.private_short), meta: intl.formatMessage(privacyMessages.private_long), + icon: 'lock', + iconComponent: LockIcon, }, { value: 'direct', text: intl.formatMessage(privacyMessages.direct_short), meta: intl.formatMessage(privacyMessages.direct_long), + icon: 'at', + iconComponent: AlternateEmailIcon, }, ], [intl], @@ -133,24 +162,27 @@ export const VisibilityModal: FC = forwardRef( [intl], ); - const dispatch = useAppDispatch(); - const handleVisibilityChange = useCallback( - (value: string) => { - // Published statuses cannot change visibility. - if (statusId) { - return; - } - dispatch(changeComposeVisibility(value)); - }, - [dispatch, statusId], - ); - const handleQuotePolicyChange = useCallback( - (value: string) => { - if (isQuotePolicy(value) && !disableQuotePolicy) { - void dispatch(setStatusQuotePolicy({ policy: value, statusId })); - } - }, - [disableQuotePolicy, dispatch, statusId], + const handleVisibilityChange = useCallback((value: string) => { + if (isStatusVisibility(value)) { + setVisibility(value); + } + }, []); + const handleQuotePolicyChange = useCallback((value: string) => { + if (isQuotePolicy(value)) { + setQuotePolicy(value); + } + }, []); + + // Save on close + useImperativeHandle( + ref, + () => ({ + getCloseConfirmationMessage() { + onChange(visibility, quotePolicy); + return null; + }, + }), + [onChange, quotePolicy, visibility], ); const privacyDropdownId = useId(); @@ -192,7 +224,7 @@ export const VisibilityModal: FC = forwardRef(