Add in-app notifications for moderation actions/warnings (#30065)
parent
0ec061aa8f
commit
4ef0b48b95
|
@ -0,0 +1,78 @@
|
|||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import WarningIcon from '@/material-icons/400-24px/warning-fill.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
// This needs to be kept in sync with app/models/account_warning.rb
|
||||
const messages = defineMessages({
|
||||
none: {
|
||||
id: 'notification.moderation_warning.action_none',
|
||||
defaultMessage: 'Your account has received a moderation warning.',
|
||||
},
|
||||
disable: {
|
||||
id: 'notification.moderation_warning.action_disable',
|
||||
defaultMessage: 'Your account has been disabled.',
|
||||
},
|
||||
mark_statuses_as_sensitive: {
|
||||
id: 'notification.moderation_warning.action_mark_statuses_as_sensitive',
|
||||
defaultMessage: 'Some of your posts have been marked as sensitive.',
|
||||
},
|
||||
delete_statuses: {
|
||||
id: 'notification.moderation_warning.action_delete_statuses',
|
||||
defaultMessage: 'Some of your posts have been removed.',
|
||||
},
|
||||
sensitive: {
|
||||
id: 'notification.moderation_warning.action_sensitive',
|
||||
defaultMessage: 'Your posts will be marked as sensitive from now on.',
|
||||
},
|
||||
silence: {
|
||||
id: 'notification.moderation_warning.action_silence',
|
||||
defaultMessage: 'Your account has been limited.',
|
||||
},
|
||||
suspend: {
|
||||
id: 'notification.moderation_warning.action_suspend',
|
||||
defaultMessage: 'Your account has been suspended.',
|
||||
},
|
||||
});
|
||||
|
||||
interface Props {
|
||||
action:
|
||||
| 'none'
|
||||
| 'disable'
|
||||
| 'mark_statuses_as_sensitive'
|
||||
| 'delete_statuses'
|
||||
| 'sensitive'
|
||||
| 'silence'
|
||||
| 'suspend';
|
||||
id: string;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export const ModerationWarning: React.FC<Props> = ({ action, id, hidden }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
if (hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/disputes/strikes/${id}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='notification__moderation-warning'
|
||||
>
|
||||
<Icon id='warning' icon={WarningIcon} />
|
||||
|
||||
<div className='notification__moderation-warning__content'>
|
||||
<p>{intl.formatMessage(messages[action])}</p>
|
||||
<span className='link-button'>
|
||||
<FormattedMessage
|
||||
id='notification.moderation-warning.learn_more'
|
||||
defaultMessage='Learn more'
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
|
@ -26,6 +26,7 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
|||
|
||||
import FollowRequestContainer from '../containers/follow_request_container';
|
||||
|
||||
import { ModerationWarning } from './moderation_warning';
|
||||
import { RelationshipsSeveranceEvent } from './relationships_severance_event';
|
||||
import Report from './report';
|
||||
|
||||
|
@ -40,6 +41,7 @@ const messages = defineMessages({
|
|||
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
|
||||
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
|
||||
relationshipsSevered: { id: 'notification.relationships_severance_event', defaultMessage: 'Lost connections with {name}' },
|
||||
moderationWarning: { id: 'notification.moderation_warning', defaultMessage: 'Your have received a moderation warning' },
|
||||
});
|
||||
|
||||
const notificationForScreenReader = (intl, message, timestamp) => {
|
||||
|
@ -383,6 +385,27 @@ class Notification extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
renderModerationWarning (notification) {
|
||||
const { intl, unread, hidden } = this.props;
|
||||
const warning = notification.get('moderation_warning');
|
||||
|
||||
if (!warning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-moderation-warning focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.moderationWarning), notification.get('created_at'))}>
|
||||
<ModerationWarning
|
||||
action={warning.get('action')}
|
||||
id={warning.get('id')}
|
||||
hidden={hidden}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
renderAdminSignUp (notification, account, link) {
|
||||
const { intl, unread } = this.props;
|
||||
|
||||
|
@ -456,6 +479,8 @@ class Notification extends ImmutablePureComponent {
|
|||
return this.renderPoll(notification, account);
|
||||
case 'severed_relationships':
|
||||
return this.renderRelationshipsSevered(notification);
|
||||
case 'moderation_warning':
|
||||
return this.renderModerationWarning(notification);
|
||||
case 'admin.sign_up':
|
||||
return this.renderAdminSignUp(notification, account, link);
|
||||
case 'admin.report':
|
||||
|
|
|
@ -473,6 +473,15 @@
|
|||
"notification.follow": "{name} followed you",
|
||||
"notification.follow_request": "{name} has requested to follow you",
|
||||
"notification.mention": "{name} mentioned you",
|
||||
"notification.moderation-warning.learn_more": "Learn more",
|
||||
"notification.moderation_warning": "Your have received a moderation warning",
|
||||
"notification.moderation_warning.action_delete_statuses": "Some of your posts have been removed.",
|
||||
"notification.moderation_warning.action_disable": "Your account has been disabled.",
|
||||
"notification.moderation_warning.action_mark_statuses_as_sensitive": "Some of your posts have been marked as sensitive.",
|
||||
"notification.moderation_warning.action_none": "Your account has received a moderation warning.",
|
||||
"notification.moderation_warning.action_sensitive": "Your posts will be marked as sensitive from now on.",
|
||||
"notification.moderation_warning.action_silence": "Your account has been limited.",
|
||||
"notification.moderation_warning.action_suspend": "Your account has been suspended.",
|
||||
"notification.own_poll": "Your poll has ended",
|
||||
"notification.poll": "A poll you have voted in has ended",
|
||||
"notification.reblog": "{name} boosted your post",
|
||||
|
|
|
@ -56,6 +56,7 @@ export const notificationToMap = notification => ImmutableMap({
|
|||
status: notification.status ? notification.status.id : null,
|
||||
report: notification.report ? fromJS(notification.report) : null,
|
||||
event: notification.event ? fromJS(notification.event) : null,
|
||||
moderation_warning: notification.moderation_warning ? fromJS(notification.moderation_warning) : null,
|
||||
});
|
||||
|
||||
const normalizeNotification = (state, notification, usePendingItems) => {
|
||||
|
|
|
@ -2180,7 +2180,8 @@ a.account__display-name {
|
|||
}
|
||||
}
|
||||
|
||||
.notification__relationships-severance-event {
|
||||
.notification__relationships-severance-event,
|
||||
.notification__moderation-warning {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
color: $secondary-text-color;
|
||||
|
|
|
@ -52,7 +52,7 @@ class Admin::AccountAction
|
|||
process_reports!
|
||||
end
|
||||
|
||||
process_email!
|
||||
process_notification!
|
||||
process_queue!
|
||||
end
|
||||
|
||||
|
@ -158,8 +158,11 @@ class Admin::AccountAction
|
|||
queue_suspension_worker! if type == 'suspend'
|
||||
end
|
||||
|
||||
def process_email!
|
||||
UserMailer.warning(target_account.user, warning).deliver_later! if warnable?
|
||||
def process_notification!
|
||||
return unless warnable?
|
||||
|
||||
UserMailer.warning(target_account.user, warning).deliver_later!
|
||||
LocalNotificationWorker.perform_async(target_account.id, warning.id, 'AccountWarning', 'moderation_warning')
|
||||
end
|
||||
|
||||
def warnable?
|
||||
|
|
|
@ -65,7 +65,8 @@ class Admin::StatusBatchAction
|
|||
statuses.each { |status| Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) } unless target_account.local?
|
||||
end
|
||||
|
||||
UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
|
||||
process_notification!
|
||||
|
||||
RemovalWorker.push_bulk(status_ids) { |status_id| [status_id, { 'preserve' => target_account.local?, 'immediate' => !target_account.local? }] }
|
||||
end
|
||||
|
||||
|
@ -101,7 +102,7 @@ class Admin::StatusBatchAction
|
|||
text: text
|
||||
)
|
||||
|
||||
UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
|
||||
process_notification!
|
||||
end
|
||||
|
||||
def handle_report!
|
||||
|
@ -127,6 +128,13 @@ class Admin::StatusBatchAction
|
|||
!report.nil?
|
||||
end
|
||||
|
||||
def process_notification!
|
||||
return unless warnable?
|
||||
|
||||
UserMailer.warning(target_account.user, @warning).deliver_later!
|
||||
LocalNotificationWorker.perform_async(target_account.id, @warning.id, 'AccountWarning', 'moderation_warning')
|
||||
end
|
||||
|
||||
def warnable?
|
||||
send_email_notification && target_account.local?
|
||||
end
|
||||
|
|
|
@ -57,6 +57,9 @@ class Notification < ApplicationRecord
|
|||
severed_relationships: {
|
||||
filterable: false,
|
||||
}.freeze,
|
||||
moderation_warning: {
|
||||
filterable: false,
|
||||
}.freeze,
|
||||
'admin.sign_up': {
|
||||
filterable: false,
|
||||
}.freeze,
|
||||
|
@ -90,6 +93,7 @@ class Notification < ApplicationRecord
|
|||
belongs_to :poll, inverse_of: false
|
||||
belongs_to :report, inverse_of: false
|
||||
belongs_to :account_relationship_severance_event, inverse_of: false
|
||||
belongs_to :account_warning, inverse_of: false
|
||||
end
|
||||
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
@ -180,7 +184,7 @@ class Notification < ApplicationRecord
|
|||
return unless new_record?
|
||||
|
||||
case activity_type
|
||||
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report'
|
||||
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report', 'AccountWarning'
|
||||
self.from_account_id = activity&.account_id
|
||||
when 'Mention'
|
||||
self.from_account_id = activity&.status&.account_id
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::AccountWarningSerializer < ActiveModel::Serializer
|
||||
attributes :id, :action, :text, :status_ids, :created_at
|
||||
|
||||
has_one :target_account, serializer: REST::AccountSerializer
|
||||
has_one :appeal, serializer: REST::AppealSerializer
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
|
||||
def status_ids
|
||||
object&.status_ids&.map(&:to_s)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::AppealSerializer < ActiveModel::Serializer
|
||||
attributes :text, :state
|
||||
|
||||
def state
|
||||
if object.approved?
|
||||
'approved'
|
||||
elsif object.rejected?
|
||||
'rejected'
|
||||
else
|
||||
'pending'
|
||||
end
|
||||
end
|
||||
end
|
|
@ -7,6 +7,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
|
|||
belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer
|
||||
belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer
|
||||
belongs_to :account_relationship_severance_event, key: :event, if: :relationship_severance_event?, serializer: REST::AccountRelationshipSeveranceEventSerializer
|
||||
belongs_to :account_warning, key: :moderation_warning, if: :moderation_warning_event?, serializer: REST::AccountWarningSerializer
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
|
@ -23,4 +24,8 @@ class REST::NotificationSerializer < ActiveModel::Serializer
|
|||
def relationship_severance_event?
|
||||
object.type == :severed_relationships
|
||||
end
|
||||
|
||||
def moderation_warning_event?
|
||||
object.type == :moderation_warning
|
||||
end
|
||||
end
|
||||
|
|
|
@ -9,6 +9,7 @@ class NotifyService < BaseService
|
|||
update
|
||||
poll
|
||||
status
|
||||
moderation_warning
|
||||
# TODO: this probably warrants an email notification
|
||||
severed_relationships
|
||||
).freeze
|
||||
|
@ -22,7 +23,7 @@ class NotifyService < BaseService
|
|||
|
||||
def dismiss?
|
||||
blocked = @recipient.unavailable?
|
||||
blocked ||= from_self? && @notification.type != :poll && @notification.type != :severed_relationships
|
||||
blocked ||= from_self? && %i(poll severed_relationships moderation_warning).exclude?(@notification.type)
|
||||
|
||||
return blocked if message? && from_staff?
|
||||
|
||||
|
@ -75,6 +76,7 @@ class NotifyService < BaseService
|
|||
admin.report
|
||||
poll
|
||||
update
|
||||
account_warning
|
||||
).freeze
|
||||
|
||||
def initialize(notification)
|
||||
|
|
|
@ -69,22 +69,22 @@ RSpec.describe Admin::AccountAction do
|
|||
end
|
||||
end
|
||||
|
||||
it 'creates Admin::ActionLog' do
|
||||
it 'sends notification, log the action, and closes other reports', :aggregate_failures do
|
||||
other_report = Fabricate(:report, target_account: target_account)
|
||||
|
||||
emails = []
|
||||
expect do
|
||||
subject
|
||||
end.to change(Admin::ActionLog, :count).by 1
|
||||
end
|
||||
emails = capture_emails { subject }
|
||||
end.to (change(Admin::ActionLog.where(action: type), :count).by 1)
|
||||
.and(change { other_report.reload.action_taken? }.from(false).to(true))
|
||||
|
||||
it 'calls process_email!' do
|
||||
allow(account_action).to receive(:process_email!)
|
||||
subject
|
||||
expect(account_action).to have_received(:process_email!)
|
||||
end
|
||||
expect(emails).to contain_exactly(
|
||||
have_attributes(
|
||||
to: contain_exactly(target_account.user.email)
|
||||
)
|
||||
)
|
||||
|
||||
it 'calls process_reports!' do
|
||||
allow(account_action).to receive(:process_reports!)
|
||||
subject
|
||||
expect(account_action).to have_received(:process_reports!)
|
||||
expect(LocalNotificationWorker).to have_enqueued_sidekiq_job(target_account.id, anything, 'AccountWarning', 'moderation_warning')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
Loading…
Reference in New Issue