Optional notification muting (#5087)
* Add a hide_notifications column to mutes * Add muting_notifications? and a notifications argument to mute! * block notifications in notify_service from hard muted accounts * Add specs for how mute! interacts with muting_notifications? * specs testing that hide_notifications in mutes actually hides notifications * Add support for muting notifications in MuteService * API support for muting notifications (and specs) * Less gross passing of notifications flag * Break out a separate mute modal with a hide-notifications checkbox. * Convert profile header mute to use mute modal * Satisfy eslint. * specs for MuteService notifications params * add trailing newlines to files for Pork :) * Put the label for the hide notifications checkbox in a label element. * Add a /api/v1/mutes/details route that just returns the array of mutes. * Define a serializer for /api/v1/mutes/details * Add more specs for the /api/v1/mutes/details endpoint * Expose whether a mute hides notifications in the api/v1/relationships endpoint * Show whether muted users' notifications are muted in account lists * Allow modifying the hide_notifications of a mute with the /api/v1/accounts/:id/mute endpoint * make the hide/unhide notifications buttons work * satisfy eslint * In probably dead code, replace a dispatch of muteAccount that was skipping the modal with launching the mute modal. * fix a missing import * add an explanatory comment to AccountInteractions * Refactor handling of default params for muting to make code cleaner * minor code style fixes oops * Fixed a typo that was breaking the account mute API endpoint * Apply white-space: nowrap to account relationships icons * Fix code style issues * Remove superfluous blank line * Rename /api/v1/mutes/details -> /api/v2/mutes * Don't serialize "account" in MuteSerializer Doing so is somewhat unnecessary since it's always the current user's account. * Fix wrong variable name in api/v2/mutes * Use Toggle in place of checkbox in the mute modal. * Make the Toggle in the mute modal look better * Code style changes in specs and removed an extra space * Code review suggestions from akihikodaki Also fixed a syntax error in tests for AccountInteractions. * Make AddHideNotificationsToMute Concurrent It's not clear how much this will benefit instances in practice, as the number of mutes tends to be pretty small, but this should prevent any blocking migrations nonetheless. * Fix up migration things * Remove /api/v2/mutes
This commit is contained in:
parent
6d7e05ec1f
commit
031a5a8f92
|
@ -26,7 +26,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def mute
|
def mute
|
||||||
MuteService.new.call(current_user.account, @account)
|
MuteService.new.call(current_user.account, @account, notifications: params[:notifications])
|
||||||
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
|
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -241,11 +241,11 @@ export function unblockAccountFail(error) {
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export function muteAccount(id) {
|
export function muteAccount(id, notifications) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(muteAccountRequest(id));
|
dispatch(muteAccountRequest(id));
|
||||||
|
|
||||||
api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => {
|
api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
|
||||||
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
|
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
|
||||||
dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
|
dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import api, { getLinks } from '../api';
|
import api, { getLinks } from '../api';
|
||||||
import { fetchRelationships } from './accounts';
|
import { fetchRelationships } from './accounts';
|
||||||
|
import { openModal } from '../../mastodon/actions/modal';
|
||||||
|
|
||||||
export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
|
export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
|
||||||
export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
|
export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
|
||||||
|
@ -9,6 +10,9 @@ export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST';
|
||||||
export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
|
export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
|
||||||
export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
|
export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
|
||||||
|
|
||||||
|
export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
|
||||||
|
export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
|
||||||
|
|
||||||
export function fetchMutes() {
|
export function fetchMutes() {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(fetchMutesRequest());
|
dispatch(fetchMutesRequest());
|
||||||
|
@ -80,3 +84,20 @@ export function expandMutesFail(error) {
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function initMuteModal(account) {
|
||||||
|
return dispatch => {
|
||||||
|
dispatch({
|
||||||
|
type: MUTES_INIT_MODAL,
|
||||||
|
account,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(openModal('MUTE'));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleHideNotifications() {
|
||||||
|
return dispatch => {
|
||||||
|
dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
|
||||||
|
};
|
||||||
|
}
|
|
@ -15,6 +15,8 @@ const messages = defineMessages({
|
||||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
||||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||||
|
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
|
||||||
|
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@injectIntl
|
@injectIntl
|
||||||
|
@ -41,6 +43,14 @@ export default class Account extends ImmutablePureComponent {
|
||||||
this.props.onMute(this.props.account);
|
this.props.onMute(this.props.account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleMuteNotifications = () => {
|
||||||
|
this.props.onMuteNotifications(this.props.account, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUnmuteNotifications = () => {
|
||||||
|
this.props.onMuteNotifications(this.props.account, false);
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { account, intl, hidden } = this.props;
|
const { account, intl, hidden } = this.props;
|
||||||
|
|
||||||
|
@ -70,7 +80,18 @@ export default class Account extends ImmutablePureComponent {
|
||||||
} else if (blocking) {
|
} else if (blocking) {
|
||||||
buttons = <IconButton active icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
|
buttons = <IconButton active icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
|
||||||
} else if (muting) {
|
} else if (muting) {
|
||||||
buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
|
let hidingNotificationsButton;
|
||||||
|
if (muting.get('notifications')) {
|
||||||
|
hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />;
|
||||||
|
} else {
|
||||||
|
hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />;
|
||||||
|
}
|
||||||
|
buttons = (
|
||||||
|
<div>
|
||||||
|
<IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />
|
||||||
|
{hidingNotificationsButton}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
|
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
unmuteAccount,
|
unmuteAccount,
|
||||||
} from '../actions/accounts';
|
} from '../actions/accounts';
|
||||||
import { openModal } from '../actions/modal';
|
import { openModal } from '../actions/modal';
|
||||||
|
import { initMuteModal } from '../actions/mutes';
|
||||||
import { unfollowModal } from '../initial_state';
|
import { unfollowModal } from '../initial_state';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -58,10 +59,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
if (account.getIn(['relationship', 'muting'])) {
|
if (account.getIn(['relationship', 'muting'])) {
|
||||||
dispatch(unmuteAccount(account.get('id')));
|
dispatch(unmuteAccount(account.get('id')));
|
||||||
} else {
|
} else {
|
||||||
dispatch(muteAccount(account.get('id')));
|
dispatch(initMuteModal(account));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
onMuteNotifications (account, notifications) {
|
||||||
|
dispatch(muteAccount(account.get('id'), notifications));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
|
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
|
||||||
|
|
|
@ -14,11 +14,9 @@ import {
|
||||||
pin,
|
pin,
|
||||||
unpin,
|
unpin,
|
||||||
} from '../actions/interactions';
|
} from '../actions/interactions';
|
||||||
import {
|
import { blockAccount } from '../actions/accounts';
|
||||||
blockAccount,
|
|
||||||
muteAccount,
|
|
||||||
} from '../actions/accounts';
|
|
||||||
import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses';
|
import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses';
|
||||||
|
import { initMuteModal } from '../actions/mutes';
|
||||||
import { initReport } from '../actions/reports';
|
import { initReport } from '../actions/reports';
|
||||||
import { openModal } from '../actions/modal';
|
import { openModal } from '../actions/modal';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
@ -28,7 +26,6 @@ const messages = defineMessages({
|
||||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
|
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
|
||||||
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
|
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
|
||||||
muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
|
@ -120,11 +117,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
onMute (account) {
|
onMute (account) {
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(initMuteModal(account));
|
||||||
message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
|
||||||
confirm: intl.formatMessage(messages.muteConfirm),
|
|
||||||
onConfirm: () => dispatch(muteAccount(account.get('id'))),
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onMuteConversation (status) {
|
onMuteConversation (status) {
|
||||||
|
|
|
@ -7,10 +7,10 @@ import {
|
||||||
unfollowAccount,
|
unfollowAccount,
|
||||||
blockAccount,
|
blockAccount,
|
||||||
unblockAccount,
|
unblockAccount,
|
||||||
muteAccount,
|
|
||||||
unmuteAccount,
|
unmuteAccount,
|
||||||
} from '../../../actions/accounts';
|
} from '../../../actions/accounts';
|
||||||
import { mentionCompose } from '../../../actions/compose';
|
import { mentionCompose } from '../../../actions/compose';
|
||||||
|
import { initMuteModal } from '../../../actions/mutes';
|
||||||
import { initReport } from '../../../actions/reports';
|
import { initReport } from '../../../actions/reports';
|
||||||
import { openModal } from '../../../actions/modal';
|
import { openModal } from '../../../actions/modal';
|
||||||
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
|
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
|
||||||
|
@ -20,7 +20,6 @@ import { unfollowModal } from '../../../initial_state';
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
||||||
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
|
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
|
||||||
muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' },
|
|
||||||
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
|
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -76,11 +75,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
if (account.getIn(['relationship', 'muting'])) {
|
if (account.getIn(['relationship', 'muting'])) {
|
||||||
dispatch(unmuteAccount(account.get('id')));
|
dispatch(unmuteAccount(account.get('id')));
|
||||||
} else {
|
} else {
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(initMuteModal(account));
|
||||||
message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
|
||||||
confirm: intl.formatMessage(messages.muteConfirm),
|
|
||||||
onConfirm: () => dispatch(muteAccount(account.get('id'))),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ import BoostModal from './boost_modal';
|
||||||
import ConfirmationModal from './confirmation_modal';
|
import ConfirmationModal from './confirmation_modal';
|
||||||
import {
|
import {
|
||||||
OnboardingModal,
|
OnboardingModal,
|
||||||
|
MuteModal,
|
||||||
ReportModal,
|
ReportModal,
|
||||||
EmbedModal,
|
EmbedModal,
|
||||||
} from '../../../features/ui/util/async-components';
|
} from '../../../features/ui/util/async-components';
|
||||||
|
@ -20,6 +21,7 @@ const MODAL_COMPONENTS = {
|
||||||
'VIDEO': () => Promise.resolve({ default: VideoModal }),
|
'VIDEO': () => Promise.resolve({ default: VideoModal }),
|
||||||
'BOOST': () => Promise.resolve({ default: BoostModal }),
|
'BOOST': () => Promise.resolve({ default: BoostModal }),
|
||||||
'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
|
'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
|
||||||
|
'MUTE': MuteModal,
|
||||||
'REPORT': ReportModal,
|
'REPORT': ReportModal,
|
||||||
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
||||||
'EMBED': EmbedModal,
|
'EMBED': EmbedModal,
|
||||||
|
|
105
app/javascript/mastodon/features/ui/components/mute_modal.js
Normal file
105
app/javascript/mastodon/features/ui/components/mute_modal.js
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
import Toggle from 'react-toggle';
|
||||||
|
import Button from '../../../components/button';
|
||||||
|
import { closeModal } from '../../../actions/modal';
|
||||||
|
import { muteAccount } from '../../../actions/accounts';
|
||||||
|
import { toggleHideNotifications } from '../../../actions/mutes';
|
||||||
|
|
||||||
|
|
||||||
|
const mapStateToProps = state => {
|
||||||
|
return {
|
||||||
|
isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
|
||||||
|
account: state.getIn(['mutes', 'new', 'account']),
|
||||||
|
notifications: state.getIn(['mutes', 'new', 'notifications']),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDispatchToProps = dispatch => {
|
||||||
|
return {
|
||||||
|
onConfirm(account, notifications) {
|
||||||
|
dispatch(muteAccount(account.get('id'), notifications));
|
||||||
|
},
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
dispatch(closeModal());
|
||||||
|
},
|
||||||
|
|
||||||
|
onToggleNotifications() {
|
||||||
|
dispatch(toggleHideNotifications());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
@connect(mapStateToProps, mapDispatchToProps)
|
||||||
|
@injectIntl
|
||||||
|
export default class MuteModal extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
isSubmitting: PropTypes.bool.isRequired,
|
||||||
|
account: PropTypes.object.isRequired,
|
||||||
|
notifications: PropTypes.bool.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
onConfirm: PropTypes.func.isRequired,
|
||||||
|
onToggleNotifications: PropTypes.func.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.button.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick = () => {
|
||||||
|
this.props.onClose();
|
||||||
|
this.props.onConfirm(this.props.account, this.props.notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancel = () => {
|
||||||
|
this.props.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = (c) => {
|
||||||
|
this.button = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleNotifications = () => {
|
||||||
|
this.props.onToggleNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { account, notifications } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='modal-root__modal mute-modal'>
|
||||||
|
<div className='mute-modal__container'>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.mute.message'
|
||||||
|
defaultMessage='Are you sure you want to mute {name}?'
|
||||||
|
values={{ name: <strong>@{account.get('acct')}</strong> }}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label htmlFor='mute-modal__hide-notifications-checkbox'>
|
||||||
|
<FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
|
||||||
|
{' '}
|
||||||
|
<Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mute-modal__action-bar'>
|
||||||
|
<Button onClick={this.handleCancel} className='mute-modal__cancel-button'>
|
||||||
|
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={this.handleClick} ref={this.setRef}>
|
||||||
|
<FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -86,6 +86,10 @@ export function OnboardingModal () {
|
||||||
return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal');
|
return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MuteModal () {
|
||||||
|
return import(/* webpackChunkName: "modals/mute_modal" */'../components/mute_modal');
|
||||||
|
}
|
||||||
|
|
||||||
export function ReportModal () {
|
export function ReportModal () {
|
||||||
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
|
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import settings from './settings';
|
||||||
import push_notifications from './push_notifications';
|
import push_notifications from './push_notifications';
|
||||||
import status_lists from './status_lists';
|
import status_lists from './status_lists';
|
||||||
import cards from './cards';
|
import cards from './cards';
|
||||||
|
import mutes from './mutes';
|
||||||
import reports from './reports';
|
import reports from './reports';
|
||||||
import contexts from './contexts';
|
import contexts from './contexts';
|
||||||
import compose from './compose';
|
import compose from './compose';
|
||||||
|
@ -37,6 +38,7 @@ const reducers = {
|
||||||
settings,
|
settings,
|
||||||
push_notifications,
|
push_notifications,
|
||||||
cards,
|
cards,
|
||||||
|
mutes,
|
||||||
reports,
|
reports,
|
||||||
contexts,
|
contexts,
|
||||||
compose,
|
compose,
|
||||||
|
|
29
app/javascript/mastodon/reducers/mutes.js
Normal file
29
app/javascript/mastodon/reducers/mutes.js
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MUTES_INIT_MODAL,
|
||||||
|
MUTES_TOGGLE_HIDE_NOTIFICATIONS,
|
||||||
|
} from '../actions/mutes';
|
||||||
|
|
||||||
|
const initialState = Immutable.Map({
|
||||||
|
new: Immutable.Map({
|
||||||
|
isSubmitting: false,
|
||||||
|
account: null,
|
||||||
|
notifications: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function mutes(state = initialState, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case MUTES_INIT_MODAL:
|
||||||
|
return state.withMutations((state) => {
|
||||||
|
state.setIn(['new', 'isSubmitting'], false);
|
||||||
|
state.setIn(['new', 'account'], action.account);
|
||||||
|
state.setIn(['new', 'notifications'], true);
|
||||||
|
});
|
||||||
|
case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
|
||||||
|
return state.updateIn(['new', 'notifications'], (old) => !old);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
|
@ -906,6 +906,7 @@
|
||||||
.account__relationship {
|
.account__relationship {
|
||||||
height: 18px;
|
height: 18px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account__header {
|
.account__header {
|
||||||
|
@ -3515,7 +3516,8 @@ button.icon-button.active i.fa-retweet {
|
||||||
.boost-modal,
|
.boost-modal,
|
||||||
.confirmation-modal,
|
.confirmation-modal,
|
||||||
.report-modal,
|
.report-modal,
|
||||||
.actions-modal {
|
.actions-modal,
|
||||||
|
.mute-modal {
|
||||||
background: lighten($ui-secondary-color, 8%);
|
background: lighten($ui-secondary-color, 8%);
|
||||||
color: $ui-base-color;
|
color: $ui-base-color;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
@ -3565,6 +3567,7 @@ button.icon-button.active i.fa-retweet {
|
||||||
|
|
||||||
.boost-modal__action-bar,
|
.boost-modal__action-bar,
|
||||||
.confirmation-modal__action-bar,
|
.confirmation-modal__action-bar,
|
||||||
|
.mute-modal__action-bar,
|
||||||
.report-modal__action-bar {
|
.report-modal__action-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
@ -3601,6 +3604,14 @@ button.icon-button.active i.fa-retweet {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mute-modal {
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mute-modal .react-toggle {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
.report-modal__statuses,
|
.report-modal__statuses,
|
||||||
.report-modal__comment {
|
.report-modal__comment {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
@ -3673,8 +3684,10 @@ button.icon-button.active i.fa-retweet {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirmation-modal__action-bar {
|
.confirmation-modal__action-bar,
|
||||||
.confirmation-modal__cancel-button {
|
.mute-modal__action-bar {
|
||||||
|
.confirmation-modal__cancel-button,
|
||||||
|
.mute-modal__cancel-button {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: darken($ui-secondary-color, 34%);
|
color: darken($ui-secondary-color, 34%);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
@ -3689,6 +3702,7 @@ button.icon-button.active i.fa-retweet {
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirmation-modal__container,
|
.confirmation-modal__container,
|
||||||
|
.mute-modal__container,
|
||||||
.report-modal__target {
|
.report-modal__target {
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|
|
@ -17,7 +17,11 @@ module AccountInteractions
|
||||||
end
|
end
|
||||||
|
|
||||||
def muting_map(target_account_ids, account_id)
|
def muting_map(target_account_ids, account_id)
|
||||||
follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
|
Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping|
|
||||||
|
mapping[mute.target_account_id] = {
|
||||||
|
notifications: mute.hide_notifications?,
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def requested_map(target_account_ids, account_id)
|
def requested_map(target_account_ids, account_id)
|
||||||
|
@ -70,8 +74,13 @@ module AccountInteractions
|
||||||
block_relationships.find_or_create_by!(target_account: other_account)
|
block_relationships.find_or_create_by!(target_account: other_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def mute!(other_account)
|
def mute!(other_account, notifications: nil)
|
||||||
mute_relationships.find_or_create_by!(target_account: other_account)
|
notifications = true if notifications.nil?
|
||||||
|
mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account)
|
||||||
|
# When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
|
||||||
|
if mute.hide_notifications? != notifications
|
||||||
|
mute.update!(hide_notifications: notifications)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def mute_conversation!(conversation)
|
def mute_conversation!(conversation)
|
||||||
|
@ -127,6 +136,10 @@ module AccountInteractions
|
||||||
conversation_mutes.where(conversation: conversation).exists?
|
conversation_mutes.where(conversation: conversation).exists?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def muting_notifications?(other_account)
|
||||||
|
mute_relationships.where(target_account: other_account, hide_notifications: true).exists?
|
||||||
|
end
|
||||||
|
|
||||||
def requested?(other_account)
|
def requested?(other_account)
|
||||||
follow_requests.where(target_account: other_account).exists?
|
follow_requests.where(target_account: other_account).exists?
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,11 +3,12 @@
|
||||||
#
|
#
|
||||||
# Table name: mutes
|
# Table name: mutes
|
||||||
#
|
#
|
||||||
# created_at :datetime not null
|
# id :integer not null, primary key
|
||||||
# updated_at :datetime not null
|
# created_at :datetime not null
|
||||||
# account_id :bigint not null
|
# updated_at :datetime not null
|
||||||
# id :bigint not null, primary key
|
# account_id :integer not null
|
||||||
# target_account_id :bigint not null
|
# target_account_id :integer not null
|
||||||
|
# hide_notifications :boolean default(TRUE), not null
|
||||||
#
|
#
|
||||||
|
|
||||||
class Mute < ApplicationRecord
|
class Mute < ApplicationRecord
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class MuteService < BaseService
|
class MuteService < BaseService
|
||||||
def call(account, target_account)
|
def call(account, target_account, notifications: nil)
|
||||||
return if account.id == target_account.id
|
return if account.id == target_account.id
|
||||||
mute = account.mute!(target_account)
|
FeedManager.instance.clear_from_timeline(account, target_account)
|
||||||
|
mute = account.mute!(target_account, notifications: notifications)
|
||||||
BlockWorker.perform_async(account.id, target_account.id)
|
BlockWorker.perform_async(account.id, target_account.id)
|
||||||
mute
|
mute
|
||||||
end
|
end
|
||||||
|
|
|
@ -81,7 +81,7 @@ class NotifyService < BaseService
|
||||||
blocked ||= from_self? # Skip for interactions with self
|
blocked ||= from_self? # Skip for interactions with self
|
||||||
blocked ||= domain_blocking? # Skip for domain blocked accounts
|
blocked ||= domain_blocking? # Skip for domain blocked accounts
|
||||||
blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts
|
blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts
|
||||||
blocked ||= @recipient.muting?(@notification.from_account) # Skip for muted accounts
|
blocked ||= @recipient.muting_notifications?(@notification.from_account)
|
||||||
blocked ||= hellbanned? # Hellban
|
blocked ||= hellbanned? # Hellban
|
||||||
blocked ||= optional_non_follower? # Options
|
blocked ||= optional_non_follower? # Options
|
||||||
blocked ||= optional_non_following? # Options
|
blocked ||= optional_non_following? # Options
|
||||||
|
|
15
db/migrate/20170716191202_add_hide_notifications_to_mute.rb
Normal file
15
db/migrate/20170716191202_add_hide_notifications_to_mute.rb
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
|
||||||
|
|
||||||
|
class AddHideNotificationsToMute < ActiveRecord::Migration[5.1]
|
||||||
|
include Mastodon::MigrationHelpers
|
||||||
|
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def up
|
||||||
|
add_column_with_default :mutes, :hide_notifications, :boolean, default: true, allow_null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_column :mutes, :hide_notifications
|
||||||
|
end
|
||||||
|
end
|
|
@ -203,6 +203,7 @@ ActiveRecord::Schema.define(version: 20171114080328) do
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.bigint "account_id", null: false
|
t.bigint "account_id", null: false
|
||||||
t.bigint "target_account_id", null: false
|
t.bigint "target_account_id", null: false
|
||||||
|
t.boolean "hide_notifications", default: true, null: false
|
||||||
t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true
|
t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -137,6 +137,35 @@ RSpec.describe Api::V1::AccountsController, type: :controller do
|
||||||
it 'creates a muting relation' do
|
it 'creates a muting relation' do
|
||||||
expect(user.account.muting?(other_account)).to be true
|
expect(user.account.muting?(other_account)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'mutes notifications' do
|
||||||
|
expect(user.account.muting_notifications?(other_account)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST #mute with notifications set to false' do
|
||||||
|
let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
|
||||||
|
|
||||||
|
before do
|
||||||
|
user.account.follow!(other_account)
|
||||||
|
post :mute, params: {id: other_account.id, notifications: false }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success' do
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not remove the following relation between user and target user' do
|
||||||
|
expect(user.account.following?(other_account)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a muting relation' do
|
||||||
|
expect(user.account.muting?(other_account)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not mute notifications' do
|
||||||
|
expect(user.account.muting_notifications?(other_account)).to be false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST #unmute' do
|
describe 'POST #unmute' do
|
||||||
|
|
|
@ -7,7 +7,7 @@ RSpec.describe Api::V1::MutesController, type: :controller do
|
||||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'follow') }
|
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'follow') }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
Fabricate(:mute, account: user.account)
|
Fabricate(:mute, account: user.account, hide_notifications: false)
|
||||||
allow(controller).to receive(:doorkeeper_token) { token }
|
allow(controller).to receive(:doorkeeper_token) { token }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
38
spec/models/concerns/account_interactions_spec.rb
Normal file
38
spec/models/concerns/account_interactions_spec.rb
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe AccountInteractions do
|
||||||
|
describe 'muting an account' do
|
||||||
|
let(:me) { Fabricate(:account, username: 'Me') }
|
||||||
|
let(:you) { Fabricate(:account, username: 'You') }
|
||||||
|
|
||||||
|
context 'with the notifications option unspecified' do
|
||||||
|
before do
|
||||||
|
me.mute!(you)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'defaults to muting notifications' do
|
||||||
|
expect(me.muting_notifications?(you)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with the notifications option set to false' do
|
||||||
|
before do
|
||||||
|
me.mute!(you, notifications: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not mute notifications' do
|
||||||
|
expect(me.muting_notifications?(you)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with the notifications option set to true' do
|
||||||
|
before do
|
||||||
|
me.mute!(you, notifications: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does mute notifications' do
|
||||||
|
expect(me.muting_notifications?(you)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -32,4 +32,36 @@ RSpec.describe MuteService do
|
||||||
account.muting?(target_account)
|
account.muting?(target_account)
|
||||||
}.from(false).to(true)
|
}.from(false).to(true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'without specifying a notifications parameter' do
|
||||||
|
it 'mutes notifications from the account' do
|
||||||
|
is_expected.to change {
|
||||||
|
account.muting_notifications?(target_account)
|
||||||
|
}.from(false).to(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a true notifications parameter' do
|
||||||
|
subject do
|
||||||
|
-> { described_class.new.call(account, target_account, notifications: true) }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'mutes notifications from the account' do
|
||||||
|
is_expected.to change {
|
||||||
|
account.muting_notifications?(target_account)
|
||||||
|
}.from(false).to(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a false notifications parameter' do
|
||||||
|
subject do
|
||||||
|
-> { described_class.new.call(account, target_account, notifications: false) }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not mute notifications from the account' do
|
||||||
|
is_expected.to_not change {
|
||||||
|
account.muting_notifications?(target_account)
|
||||||
|
}.from(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,6 +17,16 @@ RSpec.describe NotifyService do
|
||||||
is_expected.to_not change(Notification, :count)
|
is_expected.to_not change(Notification, :count)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'does not notify when sender is muted with hide_notifications' do
|
||||||
|
recipient.mute!(sender, notifications: true)
|
||||||
|
is_expected.to_not change(Notification, :count)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does notify when sender is muted without hide_notifications' do
|
||||||
|
recipient.mute!(sender, notifications: false)
|
||||||
|
is_expected.to change(Notification, :count)
|
||||||
|
end
|
||||||
|
|
||||||
it 'does not notify when sender\'s domain is blocked' do
|
it 'does not notify when sender\'s domain is blocked' do
|
||||||
recipient.block_domain!(sender.domain)
|
recipient.block_domain!(sender.domain)
|
||||||
is_expected.to_not change(Notification, :count)
|
is_expected.to_not change(Notification, :count)
|
||||||
|
|
Loading…
Reference in a new issue