diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 2d4f48c20..232c4b1c1 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -56,7 +56,6 @@ export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; -export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE'; @@ -794,13 +793,6 @@ export function changeComposeSpoilerText(text) { }; } -export function changeComposeVisibility(value) { - return { - type: COMPOSE_VISIBILITY_CHANGE, - value, - }; -} - export function insertEmojiCompose(position, emoji, needsSpace) { return { type: COMPOSE_EMOJI_INSERT, diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts index 808830602..03a973ec0 100644 --- a/app/javascript/mastodon/actions/compose_typed.ts +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -13,10 +13,10 @@ import { } from 'mastodon/store/typed_functions'; import type { ApiQuotePolicy } from '../api_types/quotes'; -import type { Status } from '../models/status'; +import type { Status, StatusVisibility } from '../models/status'; import { showAlert } from './alerts'; -import { focusCompose } from './compose'; +import { changeCompose, focusCompose } from './compose'; import { importFetchedStatuses } from './importer'; import { openModal } from './modal'; @@ -67,6 +67,39 @@ const simulateModifiedApiResponse = ( return data; }; +export const changeComposeVisibility = createAppThunk( + 'compose/visibility_change', + (visibility: StatusVisibility, { dispatch, getState }) => { + if (visibility !== 'direct') { + return visibility; + } + + const state = getState(); + const quotedStatusId = state.compose.get('quoted_status_id') as + | string + | null; + if (!quotedStatusId) { + return visibility; + } + + // Remove the quoted status + dispatch(quoteComposeCancel()); + const quotedStatus = state.statuses.get(quotedStatusId) as Status | null; + if (!quotedStatus) { + return visibility; + } + + // Append the quoted status URL to the compose text + const url = quotedStatus.get('url') as string; + const text = state.compose.get('text') as string; + if (!text.includes(url)) { + const newText = text.trim() ? `${text}\n\n${url}` : url; + dispatch(changeCompose(newText)); + } + return visibility; + }, +); + export const changeUploadCompose = createDataLoadingThunk( 'compose/changeUpload', async ( diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 299de12e7..770f77604 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -140,7 +140,10 @@ class ComposeForm extends ImmutablePureComponent { return; } - this.props.onSubmit(missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct'); + this.props.onSubmit({ + missingAltText: missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct', + quoteToPrivate: this.props.quoteToPrivate, + }); if (e) { e.preventDefault(); diff --git a/app/javascript/mastodon/features/compose/components/visibility_button.tsx b/app/javascript/mastodon/features/compose/components/visibility_button.tsx index 1ea504ab1..d93940502 100644 --- a/app/javascript/mastodon/features/compose/components/visibility_button.tsx +++ b/app/javascript/mastodon/features/compose/components/visibility_button.tsx @@ -5,8 +5,10 @@ import { defineMessages, useIntl } from 'react-intl'; import classNames from 'classnames'; -import { changeComposeVisibility } from '@/mastodon/actions/compose'; -import { setComposeQuotePolicy } from '@/mastodon/actions/compose_typed'; +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'; diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index 3dad46bc5..15b1c7cc4 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -12,6 +12,7 @@ import { } from 'mastodon/actions/compose'; import { pasteLinkCompose } from 'mastodon/actions/compose_typed'; import { openModal } from 'mastodon/actions/modal'; +import { PRIVATE_QUOTE_MODAL_ID } from 'mastodon/features/ui/components/confirmation_modals/private_quote_notify'; import ComposeForm from '../components/compose_form'; @@ -32,6 +33,10 @@ const mapStateToProps = state => ({ isUploading: state.getIn(['compose', 'is_uploading']), anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, missingAltText: state.getIn(['compose', 'media_attachments']).some(media => ['image', 'gifv'].includes(media.get('type')) && (media.get('description') ?? '').length === 0), + quoteToPrivate: + !!state.getIn(['compose', 'quoted_status_id']) + && state.getIn(['compose', 'privacy']) === 'private' + && !state.getIn(['settings', 'dismissed_banners', PRIVATE_QUOTE_MODAL_ID]), isInReply: state.getIn(['compose', 'in_reply_to']) !== null, lang: state.getIn(['compose', 'language']), maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500), @@ -43,12 +48,17 @@ const mapDispatchToProps = (dispatch, props) => ({ dispatch(changeCompose(text)); }, - onSubmit (missingAltText) { + onSubmit ({ missingAltText, quoteToPrivate }) { if (missingAltText) { dispatch(openModal({ modalType: 'CONFIRM_MISSING_ALT_TEXT', modalProps: {}, })); + } else if (quoteToPrivate) { + dispatch(openModal({ + modalType: 'CONFIRM_PRIVATE_QUOTE_NOTIFY', + modalProps: {}, + })); } else { dispatch(submitCompose((status) => { if (props.redirectOnSuccess) { diff --git a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js index 6d3eef13a..803dcb1a4 100644 --- a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js +++ b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js @@ -1,8 +1,7 @@ import { connect } from 'react-redux'; -import { changeComposeVisibility } from '../../../actions/compose'; -import { openModal, closeModal } from '../../../actions/modal'; -import { isUserTouching } from '../../../is_mobile'; +import { changeComposeVisibility } from '@/mastodon/actions/compose_typed'; + import PrivacyDropdown from '../components/privacy_dropdown'; const mapStateToProps = state => ({ diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx index 47f9fca89..cfa50855a 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx @@ -18,6 +18,7 @@ export const ConfirmationModal: React.FC< onSecondary?: () => void; onConfirm: () => void; closeWhenConfirm?: boolean; + extraContent?: React.ReactNode; } & BaseConfirmationModalProps > = ({ title, @@ -29,6 +30,7 @@ export const ConfirmationModal: React.FC< secondary, onSecondary, closeWhenConfirm = true, + extraContent, }) => { const handleClick = useCallback(() => { if (closeWhenConfirm) { @@ -49,6 +51,8 @@ export const ConfirmationModal: React.FC<

{title}

{message &&

{message}

} + + {extraContent}
diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/private_quote_notify.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/private_quote_notify.tsx new file mode 100644 index 000000000..ef917a102 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/private_quote_notify.tsx @@ -0,0 +1,88 @@ +import { forwardRef, useCallback, useState } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { submitCompose } from '@/mastodon/actions/compose'; +import { changeSetting } from '@/mastodon/actions/settings'; +import { CheckBox } from '@/mastodon/components/check_box'; +import { useAppDispatch } from '@/mastodon/store'; + +import { ConfirmationModal } from './confirmation_modal'; +import type { BaseConfirmationModalProps } from './confirmation_modal'; +import classes from './styles.module.css'; + +export const PRIVATE_QUOTE_MODAL_ID = 'quote/private_notify'; + +const messages = defineMessages({ + title: { + id: 'confirmations.private_quote_notify.title', + defaultMessage: 'Share with followers and mentioned users?', + }, + message: { + id: 'confirmations.private_quote_notify.message', + defaultMessage: + 'The person you are quoting and other mentions ' + + "will be notified and will be able to view your post, even if they're not following you.", + }, + confirm: { + id: 'confirmations.private_quote_notify.confirm', + defaultMessage: 'Publish post', + }, + cancel: { + id: 'confirmations.private_quote_notify.cancel', + defaultMessage: 'Back to editing', + }, +}); + +export const PrivateQuoteNotify = forwardRef< + HTMLDivElement, + BaseConfirmationModalProps +>( + ( + { onClose }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _ref, + ) => { + const intl = useIntl(); + + const [dismiss, setDismissed] = useState(false); + const handleDismissToggle = useCallback(() => { + setDismissed((prev) => !prev); + }, []); + + const dispatch = useAppDispatch(); + const handleConfirm = useCallback(() => { + dispatch(submitCompose()); + if (dismiss) { + dispatch( + changeSetting(['dismissed_banners', PRIVATE_QUOTE_MODAL_ID], true), + ); + } + }, [dismiss, dispatch]); + + return ( + + {' '} + + + } + /> + ); + }, +); +PrivateQuoteNotify.displayName = 'PrivateQuoteNotify'; diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/styles.module.css b/app/javascript/mastodon/features/ui/components/confirmation_modals/styles.module.css new file mode 100644 index 000000000..f685c4525 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/styles.module.css @@ -0,0 +1,7 @@ +.checkbox_wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 1rem 0; + cursor: pointer; +} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 944feb325..ad5f16d94 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -47,6 +47,7 @@ import MediaModal from './media_modal'; import { ModalPlaceholder } from './modal_placeholder'; import VideoModal from './video_modal'; import { VisibilityModal } from './visibility_modal'; +import { PrivateQuoteNotify } from './confirmation_modals/private_quote_notify'; export const MODAL_COMPONENTS = { 'MEDIA': () => Promise.resolve({ default: MediaModal }), @@ -66,6 +67,7 @@ export const MODAL_COMPONENTS = { 'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }), 'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }), 'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }), + 'CONFIRM_PRIVATE_QUOTE_NOTIFY': () => Promise.resolve({ default: PrivateQuoteNotify }), 'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }), 'CONFIRM_QUIET_QUOTE': () => Promise.resolve({ default: QuietPostQuoteInfoModal }), 'MUTE': MuteModal, diff --git a/app/javascript/mastodon/features/ui/components/visibility_modal.tsx b/app/javascript/mastodon/features/ui/components/visibility_modal.tsx index afd9ee7ed..7bc7e0ab9 100644 --- a/app/javascript/mastodon/features/ui/components/visibility_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/visibility_modal.tsx @@ -128,9 +128,12 @@ export const VisibilityModal: FC = forwardRef( const disableVisibility = !!statusId; const disableQuotePolicy = visibility === 'private' || visibility === 'direct'; - const disablePublicVisibilities: boolean = useAppSelector( + const disablePublicVisibilities = useAppSelector( selectDisablePublicVisibilities, ); + const isQuotePost = useAppSelector( + (state) => state.compose.get('quoted_status_id') !== null, + ); const visibilityItems = useMemo[]>(() => { const items: SelectItem[] = [ @@ -315,6 +318,21 @@ export const VisibilityModal: FC = forwardRef( id={quoteDescriptionId} /> + + {isQuotePost && visibility === 'direct' && ( +
+ + +
+ )}