Add in-app notifications for moderation actions/warnings (#30065)

This commit is contained in:
Claire 2024-04-25 19:26:05 +02:00 committed by GitHub
parent 0ec061aa8f
commit 4ef0b48b95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 188 additions and 21 deletions

View file

@ -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>
);
};

View file

@ -26,6 +26,7 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import FollowRequestContainer from '../containers/follow_request_container'; import FollowRequestContainer from '../containers/follow_request_container';
import { ModerationWarning } from './moderation_warning';
import { RelationshipsSeveranceEvent } from './relationships_severance_event'; import { RelationshipsSeveranceEvent } from './relationships_severance_event';
import Report from './report'; import Report from './report';
@ -40,6 +41,7 @@ const messages = defineMessages({
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' }, adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' }, adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
relationshipsSevered: { id: 'notification.relationships_severance_event', defaultMessage: 'Lost connections with {name}' }, 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) => { 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) { renderAdminSignUp (notification, account, link) {
const { intl, unread } = this.props; const { intl, unread } = this.props;
@ -456,6 +479,8 @@ class Notification extends ImmutablePureComponent {
return this.renderPoll(notification, account); return this.renderPoll(notification, account);
case 'severed_relationships': case 'severed_relationships':
return this.renderRelationshipsSevered(notification); return this.renderRelationshipsSevered(notification);
case 'moderation_warning':
return this.renderModerationWarning(notification);
case 'admin.sign_up': case 'admin.sign_up':
return this.renderAdminSignUp(notification, account, link); return this.renderAdminSignUp(notification, account, link);
case 'admin.report': case 'admin.report':

View file

@ -473,6 +473,15 @@
"notification.follow": "{name} followed you", "notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you", "notification.follow_request": "{name} has requested to follow you",
"notification.mention": "{name} mentioned 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.own_poll": "Your poll has ended",
"notification.poll": "A poll you have voted in has ended", "notification.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} boosted your post", "notification.reblog": "{name} boosted your post",

View file

@ -56,6 +56,7 @@ export const notificationToMap = notification => ImmutableMap({
status: notification.status ? notification.status.id : null, status: notification.status ? notification.status.id : null,
report: notification.report ? fromJS(notification.report) : null, report: notification.report ? fromJS(notification.report) : null,
event: notification.event ? fromJS(notification.event) : null, event: notification.event ? fromJS(notification.event) : null,
moderation_warning: notification.moderation_warning ? fromJS(notification.moderation_warning) : null,
}); });
const normalizeNotification = (state, notification, usePendingItems) => { const normalizeNotification = (state, notification, usePendingItems) => {

View file

@ -2180,7 +2180,8 @@ a.account__display-name {
} }
} }
.notification__relationships-severance-event { .notification__relationships-severance-event,
.notification__moderation-warning {
display: flex; display: flex;
gap: 16px; gap: 16px;
color: $secondary-text-color; color: $secondary-text-color;

View file

@ -52,7 +52,7 @@ class Admin::AccountAction
process_reports! process_reports!
end end
process_email! process_notification!
process_queue! process_queue!
end end
@ -158,8 +158,11 @@ class Admin::AccountAction
queue_suspension_worker! if type == 'suspend' queue_suspension_worker! if type == 'suspend'
end end
def process_email! def process_notification!
UserMailer.warning(target_account.user, warning).deliver_later! if warnable? return unless warnable?
UserMailer.warning(target_account.user, warning).deliver_later!
LocalNotificationWorker.perform_async(target_account.id, warning.id, 'AccountWarning', 'moderation_warning')
end end
def warnable? def warnable?

View file

@ -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? statuses.each { |status| Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) } unless target_account.local?
end 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? }] } RemovalWorker.push_bulk(status_ids) { |status_id| [status_id, { 'preserve' => target_account.local?, 'immediate' => !target_account.local? }] }
end end
@ -101,7 +102,7 @@ class Admin::StatusBatchAction
text: text text: text
) )
UserMailer.warning(target_account.user, @warning).deliver_later! if warnable? process_notification!
end end
def handle_report! def handle_report!
@ -127,6 +128,13 @@ class Admin::StatusBatchAction
!report.nil? !report.nil?
end 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? def warnable?
send_email_notification && target_account.local? send_email_notification && target_account.local?
end end

View file

@ -57,6 +57,9 @@ class Notification < ApplicationRecord
severed_relationships: { severed_relationships: {
filterable: false, filterable: false,
}.freeze, }.freeze,
moderation_warning: {
filterable: false,
}.freeze,
'admin.sign_up': { 'admin.sign_up': {
filterable: false, filterable: false,
}.freeze, }.freeze,
@ -90,6 +93,7 @@ class Notification < ApplicationRecord
belongs_to :poll, inverse_of: false belongs_to :poll, inverse_of: false
belongs_to :report, inverse_of: false belongs_to :report, inverse_of: false
belongs_to :account_relationship_severance_event, inverse_of: false belongs_to :account_relationship_severance_event, inverse_of: false
belongs_to :account_warning, inverse_of: false
end end
validates :type, inclusion: { in: TYPES } validates :type, inclusion: { in: TYPES }
@ -180,7 +184,7 @@ class Notification < ApplicationRecord
return unless new_record? return unless new_record?
case activity_type 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 self.from_account_id = activity&.account_id
when 'Mention' when 'Mention'
self.from_account_id = activity&.status&.account_id self.from_account_id = activity&.status&.account_id

View file

@ -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

View file

@ -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

View file

@ -7,6 +7,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer
belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer 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_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 def id
object.id.to_s object.id.to_s
@ -23,4 +24,8 @@ class REST::NotificationSerializer < ActiveModel::Serializer
def relationship_severance_event? def relationship_severance_event?
object.type == :severed_relationships object.type == :severed_relationships
end end
def moderation_warning_event?
object.type == :moderation_warning
end
end end

View file

@ -9,6 +9,7 @@ class NotifyService < BaseService
update update
poll poll
status status
moderation_warning
# TODO: this probably warrants an email notification # TODO: this probably warrants an email notification
severed_relationships severed_relationships
).freeze ).freeze
@ -22,7 +23,7 @@ class NotifyService < BaseService
def dismiss? def dismiss?
blocked = @recipient.unavailable? 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? return blocked if message? && from_staff?
@ -75,6 +76,7 @@ class NotifyService < BaseService
admin.report admin.report
poll poll
update update
account_warning
).freeze ).freeze
def initialize(notification) def initialize(notification)

View file

@ -69,22 +69,22 @@ RSpec.describe Admin::AccountAction do
end end
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 expect do
subject emails = capture_emails { subject }
end.to change(Admin::ActionLog, :count).by 1 end.to (change(Admin::ActionLog.where(action: type), :count).by 1)
end .and(change { other_report.reload.action_taken? }.from(false).to(true))
it 'calls process_email!' do expect(emails).to contain_exactly(
allow(account_action).to receive(:process_email!) have_attributes(
subject to: contain_exactly(target_account.user.email)
expect(account_action).to have_received(:process_email!) )
end )
it 'calls process_reports!' do expect(LocalNotificationWorker).to have_enqueued_sidekiq_job(target_account.id, anything, 'AccountWarning', 'moderation_warning')
allow(account_action).to receive(:process_reports!)
subject
expect(account_action).to have_received(:process_reports!)
end end
end end