diff --git a/app/soapbox/actions/accounts.js b/app/soapbox/actions/accounts.js index 1005af838..4df56417e 100644 --- a/app/soapbox/actions/accounts.js +++ b/app/soapbox/actions/accounts.js @@ -57,6 +57,10 @@ export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST'; export const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS'; export const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL'; +export const ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST'; +export const ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS'; +export const ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL'; + export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST'; export const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS'; export const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL'; @@ -520,6 +524,42 @@ export function unsubscribeAccountFail(error) { }; } + +export function removeFromFollowers(id) { + return (dispatch, getState) => { + if (!isLoggedIn(getState)) return; + + dispatch(muteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/remove_from_followers`).then(response => { + dispatch(removeFromFollowersSuccess(response.data)); + }).catch(error => { + dispatch(removeFromFollowersFail(id, error)); + }); + }; +} + +export function removeFromFollowersRequest(id) { + return { + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST, + id, + }; +} + +export function removeFromFollowersSuccess(relationship) { + return { + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS, + relationship, + }; +} + +export function removeFromFollowersFail(error) { + return { + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL, + error, + }; +} + export function fetchFollowers(id) { return (dispatch, getState) => { dispatch(fetchFollowersRequest(id)); diff --git a/app/soapbox/features/account/components/header.js b/app/soapbox/features/account/components/header.js index 47c1172c7..7a65b0bef 100644 --- a/app/soapbox/features/account/components/header.js +++ b/app/soapbox/features/account/components/header.js @@ -48,6 +48,7 @@ const messages = defineMessages({ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' }, unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, + removeFromFollowers: { id: 'account.remove_from_followers', defaultMessage: 'Remove this follower' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' }, deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' }, @@ -283,6 +284,14 @@ class Header extends ImmutablePureComponent { }); } + if (features.removeFromFollowers && account.getIn(['relationship', 'followed_by'])) { + menu.push({ + text: intl.formatMessage(messages.removeFromFollowers), + action: this.props.onRemoveFromFollowers, + icon: require('@tabler/icons/icons/user-x.svg'), + }); + } + if (account.getIn(['relationship', 'muting'])) { menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), diff --git a/app/soapbox/features/account_timeline/components/header.js b/app/soapbox/features/account_timeline/components/header.js index b969e0b61..bba6bbcd5 100644 --- a/app/soapbox/features/account_timeline/components/header.js +++ b/app/soapbox/features/account_timeline/components/header.js @@ -25,6 +25,7 @@ class Header extends ImmutablePureComponent { onUnblockDomain: PropTypes.func.isRequired, onEndorseToggle: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired, + onRemoveFromFollowers: PropTypes.func.isRequired, username: PropTypes.string, history: PropTypes.object, }; @@ -141,6 +142,10 @@ class Header extends ImmutablePureComponent { this.props.onShowNote(this.props.account); } + handleRemoveFromFollowers = () => { + this.props.onRemoveFromFollowers(this.props.account); + } + render() { const { account } = this.props; const moved = (account) ? account.get('moved') : false; @@ -177,6 +182,7 @@ class Header extends ImmutablePureComponent { onSuggestUser={this.handleSuggestUser} onUnsuggestUser={this.handleUnsuggestUser} onShowNote={this.handleShowNote} + onRemoveFromFollowers={this.handleRemoveFromFollowers} username={this.props.username} /> diff --git a/app/soapbox/features/account_timeline/containers/header_container.js b/app/soapbox/features/account_timeline/containers/header_container.js index 43ceceb2f..baf0ccb17 100644 --- a/app/soapbox/features/account_timeline/containers/header_container.js +++ b/app/soapbox/features/account_timeline/containers/header_container.js @@ -13,6 +13,7 @@ import { unpinAccount, subscribeAccount, unsubscribeAccount, + removeFromFollowers, } from 'soapbox/actions/accounts'; import { verifyUser, @@ -56,6 +57,7 @@ const messages = defineMessages({ demotedToUser: { id: 'admin.users.actions.demote_to_user_message', defaultMessage: '@{acct} was demoted to a regular user' }, userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' }, userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' }, + removeFromFollowersConfirm: { id: 'confirmations.remove_from_followers.confirm', defaultMessage: 'Remove' }, }); const makeMapStateToProps = () => { @@ -269,6 +271,21 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onShowNote(account) { dispatch(initAccountNoteModal(account)); }, + + onRemoveFromFollowers(account) { + dispatch((_, getState) => { + const unfollowModal = getSettings(getState()).get('unfollowModal'); + if (unfollowModal) { + dispatch(openModal('CONFIRM', { + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.removeFromFollowersConfirm), + onConfirm: () => dispatch(removeFromFollowers(account.get('id'))), + })); + } else { + dispatch(removeFromFollowers(account.get('id'))); + } + }); + }, }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/soapbox/reducers/relationships.js b/app/soapbox/reducers/relationships.js index c63ee978e..80754842c 100644 --- a/app/soapbox/reducers/relationships.js +++ b/app/soapbox/reducers/relationships.js @@ -19,6 +19,7 @@ import { ACCOUNT_UNSUBSCRIBE_SUCCESS, ACCOUNT_PIN_SUCCESS, ACCOUNT_UNPIN_SUCCESS, + ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS, RELATIONSHIPS_FETCH_SUCCESS, } from '../actions/accounts'; import { @@ -108,6 +109,7 @@ export default function relationships(state = initialState, action) { case ACCOUNT_PIN_SUCCESS: case ACCOUNT_UNPIN_SUCCESS: case ACCOUNT_NOTE_SUBMIT_SUCCESS: + case ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS: return normalizeRelationship(state, action.relationship); case RELATIONSHIPS_FETCH_SUCCESS: return normalizeRelationships(state, action.relationships); diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 1e4d76266..7819b2554 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -420,6 +420,15 @@ const getInstanceFeatures = (instance: Instance) => { */ remoteInteractionsAPI: v.software === PLEROMA && gte(v.version, '2.4.50'), + /** + * Ability to remove an account from your followers. + * @see POST /api/v1/accounts/:id/remove_from_followers + */ + removeFromFollowers: any([ + v.software === MASTODON && gte(v.compatVersion, '3.5.0'), + v.software === PLEROMA && v.build === SOAPBOX && gte(v.version, '2.4.50'), + ]), + reportMultipleStatuses: any([ v.software === MASTODON, v.software === PLEROMA,