diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 09d6e7bbb3..3553378b44 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -58,7 +58,6 @@ export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE' 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_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE'; export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE'; @@ -825,13 +824,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/flavours/glitch/actions/compose_typed.ts b/app/javascript/flavours/glitch/actions/compose_typed.ts index 0b6b0f50de..257c867034 100644 --- a/app/javascript/flavours/glitch/actions/compose_typed.ts +++ b/app/javascript/flavours/glitch/actions/compose_typed.ts @@ -13,10 +13,11 @@ import { } from 'flavours/glitch/store/typed_functions'; import type { ApiQuotePolicy } from '../api_types/quotes'; -import type { Status } from '../models/status'; +import type { Status, StatusVisibility } from '../models/status'; +import type { RootState } from '../store'; import { showAlert } from './alerts'; -import { focusCompose } from './compose'; +import { changeCompose, focusCompose } from './compose'; import { importFetchedStatuses } from './importer'; import { openModal } from './modal'; @@ -41,6 +42,10 @@ const messages = defineMessages({ id: 'quote_error.unauthorized', defaultMessage: 'You are not authorized to quote this post.', }, + quoteErrorPrivateMention: { + id: 'quote_error.private_mentions', + defaultMessage: 'Quoting is not allowed with direct mentions.', + }, }); type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & { @@ -67,6 +72,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 ( @@ -130,6 +168,8 @@ export const quoteComposeByStatus = createAppThunk( if (composeState.get('id')) { dispatch(showAlert({ message: messages.quoteErrorEdit })); + } else if (composeState.get('privacy') === 'direct') { + dispatch(showAlert({ message: messages.quoteErrorPrivateMention })); } else if (composeState.get('poll')) { dispatch(showAlert({ message: messages.quoteErrorPoll })); } else if ( @@ -173,6 +213,17 @@ export const quoteComposeById = createAppThunk( }, ); +const composeStateForbidsLink = (composeState: RootState['compose']) => { + return ( + composeState.get('quoted_status_id') || + composeState.get('is_submitting') || + composeState.get('poll') || + composeState.get('is_uploading') || + composeState.get('id') || + composeState.get('privacy') === 'direct' + ); +}; + export const pasteLinkCompose = createDataLoadingThunk( 'compose/pasteLink', async ({ url }: { url: string }) => { @@ -183,15 +234,12 @@ export const pasteLinkCompose = createDataLoadingThunk( limit: 2, }); }, - (data, { dispatch, getState }) => { + (data, { dispatch, getState, requestId }) => { const composeState = getState().compose; if ( - composeState.get('quoted_status_id') || - composeState.get('is_submitting') || - composeState.get('poll') || - composeState.get('is_uploading') || - composeState.get('id') + composeStateForbidsLink(composeState) || + composeState.get('fetching_link') !== requestId // Request has been cancelled ) return; @@ -207,6 +255,17 @@ export const pasteLinkCompose = createDataLoadingThunk( dispatch(quoteComposeById(data.statuses[0].id)); } }, + { + useLoadingBar: false, + condition: (_, { getState }) => + !getState().compose.get('fetching_link') && + !composeStateForbidsLink(getState().compose), + }, +); + +// Ideally this would cancel the action and the HTTP request, but this is good enough +export const cancelPasteLinkCompose = createAction( + 'compose/cancelPasteLinkCompose', ); export const quoteComposeCancel = createAction('compose/quoteComposeCancel'); diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx index 42fbeb3a33..60ff881604 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx @@ -155,7 +155,11 @@ class ComposeForm extends ImmutablePureComponent { return; } - this.props.onSubmit(missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct', overridePrivacy); + this.props.onSubmit({ + missingAltTextModal: missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct', + quoteToPrivate: this.props.quoteToPrivate, + overridePrivacy, + }); if (e) { e.preventDefault(); diff --git a/app/javascript/flavours/glitch/features/compose/components/quote_placeholder.tsx b/app/javascript/flavours/glitch/features/compose/components/quote_placeholder.tsx new file mode 100644 index 0000000000..18e131ec5e --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/quote_placeholder.tsx @@ -0,0 +1,48 @@ +import { useCallback } from 'react'; +import type { FC } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { cancelPasteLinkCompose } from '@/flavours/glitch/actions/compose_typed'; +import { useAppDispatch } from '@/flavours/glitch/store'; +import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react'; +import { DisplayName } from 'flavours/glitch/components/display_name'; +import { IconButton } from 'flavours/glitch/components/icon_button'; +import { Skeleton } from 'flavours/glitch/components/skeleton'; + +const messages = defineMessages({ + quote_cancel: { id: 'status.quote.cancel', defaultMessage: 'Cancel quote' }, +}); + +export const QuotePlaceholder: FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const handleQuoteCancel = useCallback(() => { + dispatch(cancelPasteLinkCompose()); + }, [dispatch]); + + return ( +
+
+
+
+ +
+
+ +
+ +
+
+ +
+
+
+ ); +}; diff --git a/app/javascript/flavours/glitch/features/compose/components/quoted_post.tsx b/app/javascript/flavours/glitch/features/compose/components/quoted_post.tsx index db95c58cb9..a59ec923bb 100644 --- a/app/javascript/flavours/glitch/features/compose/components/quoted_post.tsx +++ b/app/javascript/flavours/glitch/features/compose/components/quoted_post.tsx @@ -7,11 +7,17 @@ import { quoteComposeCancel } from '@/flavours/glitch/actions/compose_typed'; import { QuotedStatus } from '@/flavours/glitch/components/status_quoted'; import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; +import { QuotePlaceholder } from './quote_placeholder'; + export const ComposeQuotedStatus: FC = () => { const quotedStatusId = useAppSelector( (state) => state.compose.get('quoted_status_id') as string | null, ); + const isFetchingLink = useAppSelector( + (state) => !!state.compose.get('fetching_link'), + ); + const isEditing = useAppSelector((state) => !!state.compose.get('id')); const quote = useMemo( @@ -30,7 +36,9 @@ export const ComposeQuotedStatus: FC = () => { dispatch(quoteComposeCancel()); }, [dispatch]); - if (!quote) { + if (isFetchingLink && !quote) { + return ; + } else if (!quote) { return null; } diff --git a/app/javascript/flavours/glitch/features/compose/components/visibility_button.tsx b/app/javascript/flavours/glitch/features/compose/components/visibility_button.tsx index 1e6462ecd3..07f4815e02 100644 --- a/app/javascript/flavours/glitch/features/compose/components/visibility_button.tsx +++ b/app/javascript/flavours/glitch/features/compose/components/visibility_button.tsx @@ -5,8 +5,10 @@ import { defineMessages, useIntl } from 'react-intl'; import classNames from 'classnames'; -import { changeComposeVisibility } from '@/flavours/glitch/actions/compose'; -import { setComposeQuotePolicy } from '@/flavours/glitch/actions/compose_typed'; +import { + changeComposeVisibility, + setComposeQuotePolicy, +} from '@/flavours/glitch/actions/compose_typed'; import { openModal } from '@/flavours/glitch/actions/modal'; import type { ApiQuotePolicy } from '@/flavours/glitch/api_types/quotes'; import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses'; diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js index fd7186d71a..eb9efb2e32 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js @@ -12,6 +12,7 @@ import { } from 'flavours/glitch/actions/compose'; import { pasteLinkCompose } from 'flavours/glitch/actions/compose_typed'; import { openModal } from 'flavours/glitch/actions/modal'; +import { PRIVATE_QUOTE_MODAL_ID } from 'flavours/glitch/features/ui/components/confirmation_modals/private_quote_notify'; import { privacyPreference } from 'flavours/glitch/utils/privacy_preference'; import ComposeForm from '../components/compose_form'; @@ -52,6 +53,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']), sideArm: sideArmPrivacy(state), @@ -65,12 +70,17 @@ const mapDispatchToProps = (dispatch, props) => ({ dispatch(changeCompose(text)); }, - onSubmit (missingAltText, overridePrivacy = null) { + onSubmit ({ missingAltText, quoteToPrivate, overridePrivacy = null }) { if (missingAltText) { dispatch(openModal({ modalType: 'CONFIRM_MISSING_ALT_TEXT', modalProps: { overridePrivacy }, })); + } else if (quoteToPrivate) { + dispatch(openModal({ + modalType: 'CONFIRM_PRIVATE_QUOTE_NOTIFY', + modalProps: {}, + })); } else { dispatch(submitCompose(overridePrivacy, (status) => { if (props.redirectOnSuccess) { diff --git a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js index 6d3eef13aa..a44b5c0d97 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js +++ b/app/javascript/flavours/glitch/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 '@/flavours/glitch/actions/compose_typed'; + import PrivacyDropdown from '../components/privacy_dropdown'; const mapStateToProps = state => ({ diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index 072842ed3a..35299d8eed 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -329,6 +329,12 @@ class Status extends ImmutablePureComponent { dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } })); }; + handleQuote = (status) => { + const { dispatch } = this.props; + + dispatch(quoteComposeById(status.get('id'))); + }; + handleEditClick = (status) => { const { dispatch, askReplyConfirmation } = this.props; @@ -659,6 +665,7 @@ class Status extends ImmutablePureComponent { onDelete={this.handleDeleteClick} onRevokeQuote={this.handleRevokeQuoteClick} onQuotePolicyChange={this.handleQuotePolicyChange} + onQuote={this.handleQuote} onEdit={this.handleEditClick} onDirect={this.handleDirectClick} onMention={this.handleMentionClick} diff --git a/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx index 065ffc9d7c..cf22159073 100644 --- a/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx +++ b/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx @@ -26,6 +26,7 @@ export const ConfirmationModal: React.FC< onSecondary?: () => void; onConfirm: () => void; closeWhenConfirm?: boolean; + extraContent?: React.ReactNode; } & BaseConfirmationModalProps > = ({ title, @@ -37,6 +38,7 @@ export const ConfirmationModal: React.FC< secondary, onSecondary, closeWhenConfirm = true, + extraContent, }) => { const handleClick = useCallback(() => { if (closeWhenConfirm) { @@ -57,6 +59,8 @@ export const ConfirmationModal: React.FC<

{title}

{message &&

{message}

} + + {extraContent}
diff --git a/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/private_quote_notify.tsx b/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/private_quote_notify.tsx new file mode 100644 index 0000000000..c461e1e43e --- /dev/null +++ b/app/javascript/flavours/glitch/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 '@/flavours/glitch/actions/compose'; +import { changeSetting } from '@/flavours/glitch/actions/settings'; +import { CheckBox } from '@/flavours/glitch/components/check_box'; +import { useAppDispatch } from '@/flavours/glitch/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/flavours/glitch/features/ui/components/confirmation_modals/styles.module.css b/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/styles.module.css new file mode 100644 index 0000000000..f685c4525f --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/ui/components/modal_root.jsx b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx index 4f2e63fb0d..9ba2970f93 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx @@ -51,6 +51,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 }), @@ -72,6 +73,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/flavours/glitch/features/ui/components/visibility_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/visibility_modal.tsx index 9edd061e9f..4184c84c4b 100644 --- a/app/javascript/flavours/glitch/features/ui/components/visibility_modal.tsx +++ b/app/javascript/flavours/glitch/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' && ( +
+ + +
+ )}