Add in-app notifications for moderation actions/warnings (#30065)
This commit is contained in:
		
							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