Add in-app notifications for moderation actions/warnings (#30065)
This commit is contained in:
parent
0ec061aa8f
commit
4ef0b48b95
13 changed files with 188 additions and 21 deletions
|
@ -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 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':
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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?
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
16
app/serializers/rest/account_warning_serializer.rb
Normal file
16
app/serializers/rest/account_warning_serializer.rb
Normal 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
|
15
app/serializers/rest/appeal_serializer.rb
Normal file
15
app/serializers/rest/appeal_serializer.rb
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue