diff --git a/app/javascript/mastodon/actions/interactions_typed.ts b/app/javascript/mastodon/actions/interactions_typed.ts index f58faffa86d..832ea189104 100644 --- a/app/javascript/mastodon/actions/interactions_typed.ts +++ b/app/javascript/mastodon/actions/interactions_typed.ts @@ -1,4 +1,8 @@ -import { apiReblog, apiUnreblog } from 'mastodon/api/interactions'; +import { + apiReblog, + apiUnreblog, + apiRevokeQuote, +} from 'mastodon/api/interactions'; import type { StatusVisibility } from 'mastodon/models/status'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; @@ -33,3 +37,19 @@ export const unreblog = createDataLoadingThunk( return discardLoadData; }, ); + +export const revokeQuote = createDataLoadingThunk( + 'status/revoke_quote', + ({ + statusId, + quotedStatusId, + }: { + statusId: string; + quotedStatusId: string; + }) => apiRevokeQuote(quotedStatusId, statusId), + (data, { dispatch, discardLoadData }) => { + dispatch(importFetchedStatus(data)); + + return discardLoadData; + }, +); diff --git a/app/javascript/mastodon/api/interactions.ts b/app/javascript/mastodon/api/interactions.ts index 118b5f06d20..5ffa5d15076 100644 --- a/app/javascript/mastodon/api/interactions.ts +++ b/app/javascript/mastodon/api/interactions.ts @@ -8,3 +8,8 @@ export const apiReblog = (statusId: string, visibility: StatusVisibility) => export const apiUnreblog = (statusId: string) => apiRequestPost(`v1/statuses/${statusId}/unreblog`); + +export const apiRevokeQuote = (quotedStatusId: string, statusId: string) => + apiRequestPost( + `v1/statuses/${quotedStatusId}/quotes/${statusId}/revoke`, + ); diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index ec5a9780cb7..663fc53407c 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -67,21 +67,28 @@ const messages = defineMessages({ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, filter: { id: 'status.filter', defaultMessage: 'Filter this post' }, openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, + revokeQuote: { id: 'status.revoke_quote', defaultMessage: 'Remove my post from @{name}’s post' }, }); -const mapStateToProps = (state, { status }) => ({ - relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), -}); +const mapStateToProps = (state, { status }) => { + const quotedStatusId = status.getIn(['quote', 'quoted_status']); + return ({ + relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), + quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null, + }); +}; class StatusActionBar extends ImmutablePureComponent { static propTypes = { identity: identityContextPropShape, status: ImmutablePropTypes.map.isRequired, relationship: ImmutablePropTypes.record, + quotedAccountId: ImmutablePropTypes.string, onReply: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, onDelete: PropTypes.func, + onRevokeQuote: PropTypes.func, onDirect: PropTypes.func, onMention: PropTypes.func, onMute: PropTypes.func, @@ -110,6 +117,7 @@ class StatusActionBar extends ImmutablePureComponent { updateOnProps = [ 'status', 'relationship', + 'quotedAccountId', 'withDismiss', ]; @@ -190,6 +198,10 @@ class StatusActionBar extends ImmutablePureComponent { } }; + handleRevokeQuoteClick = () => { + this.props.onRevokeQuote(this.props.status); + } + handleBlockClick = () => { const { status, relationship, onBlock, onUnblock } = this.props; const account = status.get('account'); @@ -241,7 +253,7 @@ class StatusActionBar extends ImmutablePureComponent { }; render () { - const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; + const { status, relationship, quotedAccountId, intl, withDismiss, withCounters, scrollKey } = this.props; const { signedIn, permissions } = this.props.identity; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -291,6 +303,10 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick }); menu.push(null); + if (quotedAccountId === me) { + menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true }); + } + if (relationship && relationship.get('muting')) { menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick }); } else { diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index 0fb5f255607..f3bc8fe5ff3 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -111,6 +111,10 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({ } }, + onRevokeQuote (status) { + dispatch(openModal({ modalType: 'CONFIRM_REVOKE_QUOTE', modalProps: { statusId: status.get('id'), quotedStatusId: status.getIn(['quote', 'quoted_status']) }})); + }, + onEdit (status) { dispatch((_, getState) => { let state = getState(); diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index 81f8163fffe..5d6625fc103 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -61,22 +61,29 @@ const messages = defineMessages({ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, + revokeQuote: { id: 'status.revoke_quote', defaultMessage: 'Remove my post from @{name}’s post' }, }); -const mapStateToProps = (state, { status }) => ({ - relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), -}); +const mapStateToProps = (state, { status }) => { + const quotedStatusId = status.getIn(['quote', 'quoted_status']); + return ({ + relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), + quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null, + }); +}; class ActionBar extends PureComponent { static propTypes = { identity: identityContextPropShape, status: ImmutablePropTypes.map.isRequired, relationship: ImmutablePropTypes.record, + quotedAccountId: ImmutablePropTypes.string, onReply: PropTypes.func.isRequired, onReblog: PropTypes.func.isRequired, onFavourite: PropTypes.func.isRequired, onBookmark: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, + onRevokeQuote: PropTypes.func, onEdit: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, @@ -113,6 +120,10 @@ class ActionBar extends PureComponent { this.props.onDelete(this.props.status); }; + handleRevokeQuoteClick = () => { + this.props.onRevokeQuote(this.props.status); + } + handleRedraftClick = () => { this.props.onDelete(this.props.status, true); }; @@ -193,7 +204,7 @@ class ActionBar extends PureComponent { }; render () { - const { status, relationship, intl } = this.props; + const { status, relationship, quotedAccountId, intl } = this.props; const { signedIn, permissions } = this.props.identity; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -237,6 +248,10 @@ class ActionBar extends PureComponent { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); menu.push(null); + if (quotedAccountId === me) { + menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true }); + } + if (relationship && relationship.get('muting')) { menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick }); } else { diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 4a714fa2c59..7bdcdb89711 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -259,6 +259,12 @@ class Status extends ImmutablePureComponent { } }; + handleRevokeQuoteClick = (status) => { + const { dispatch } = this.props; + + dispatch(openModal({ modalType: 'CONFIRM_REVOKE_QUOTE', modalProps: { statusId: status.get('id'), quotedStatusId: status.getIn(['quote', 'quoted_status']) }})); + }; + handleEditClick = (status) => { const { dispatch, askReplyConfirmation } = this.props; @@ -635,6 +641,7 @@ class Status extends ImmutablePureComponent { onReblog={this.handleReblogClick} onBookmark={this.handleBookmarkClick} onDelete={this.handleDeleteClick} + onRevokeQuote={this.handleRevokeQuoteClick} onEdit={this.handleEditClick} onDirect={this.handleDirectClick} onMention={this.handleMentionClick} diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/index.ts b/app/javascript/mastodon/features/ui/components/confirmation_modals/index.ts index 25ffb3b6291..139b6f8ba25 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/index.ts +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/index.ts @@ -10,3 +10,4 @@ export { ConfirmClearNotificationsModal } from './clear_notifications'; export { ConfirmLogOutModal } from './log_out'; export { ConfirmFollowToListModal } from './follow_to_list'; export { ConfirmMissingAltTextModal } from './missing_alt_text'; +export { ConfirmRevokeQuoteModal } from './revoke_quote'; diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/revoke_quote.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/revoke_quote.tsx new file mode 100644 index 00000000000..83964aa5fe8 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/revoke_quote.tsx @@ -0,0 +1,48 @@ +import { useCallback } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { revokeQuote } from 'mastodon/actions/interactions_typed'; +import { useAppDispatch } from 'mastodon/store'; + +import type { BaseConfirmationModalProps } from './confirmation_modal'; +import { ConfirmationModal } from './confirmation_modal'; + +const messages = defineMessages({ + revokeQuoteTitle: { + id: 'confirmations.revoke_quote.title', + defaultMessage: 'Remove post?', + }, + revokeQuoteMessage: { + id: 'confirmations.revoke_quote.message', + defaultMessage: 'This action cannot be undone.', + }, + revokeQuoteConfirm: { + id: 'confirmations.revoke_quote.confirm', + defaultMessage: 'Remove post', + }, +}); + +export const ConfirmRevokeQuoteModal: React.FC< + { + statusId: string; + quotedStatusId: string; + } & BaseConfirmationModalProps +> = ({ statusId, quotedStatusId, onClose }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const onConfirm = useCallback(() => { + void dispatch(revokeQuote({ quotedStatusId, statusId })); + }, [dispatch, statusId, quotedStatusId]); + + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 4a98de0a31a..3b7a24faaf4 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -37,6 +37,7 @@ import { ConfirmLogOutModal, ConfirmFollowToListModal, ConfirmMissingAltTextModal, + ConfirmRevokeQuoteModal, } from './confirmation_modals'; import { ImageModal } from './image_modal'; import MediaModal from './media_modal'; @@ -59,6 +60,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_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }), 'MUTE': MuteModal, 'BLOCK': BlockModal, 'DOMAIN_BLOCK': DomainBlockModal, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index aa18fc39ce1..6b796f9f81d 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -245,6 +245,9 @@ "confirmations.remove_from_followers.confirm": "Remove follower", "confirmations.remove_from_followers.message": "{name} will stop following you. Are you sure you want to proceed?", "confirmations.remove_from_followers.title": "Remove follower?", + "confirmations.revoke_quote.confirm": "Remove post", + "confirmations.revoke_quote.message": "This action cannot be undone.", + "confirmations.revoke_quote.title": "Remove post?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", "confirmations.unfollow.title": "Unfollow user?", @@ -896,6 +899,7 @@ "status.reply": "Reply", "status.replyAll": "Reply to thread", "status.report": "Report @{name}", + "status.revoke_quote": "Remove my post from @{name}’s post", "status.sensitive_warning": "Sensitive content", "status.share": "Share", "status.show_less_all": "Show less for all",