summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorClaire <claire.github-309c@sitedethib.com>2024-04-25 19:26:05 +0200
committerGitHub <noreply@github.com>2024-04-25 17:26:05 +0000
commit4ef0b48b951d65920d3a3d9cdfd099967c5e4f6d (patch)
tree87d2f1503789cb1d4f9c11df5f907fe8786a17e8
parent0ec061aa8f7383330b26b3323d2fafd9ec7663e3 (diff)
Add in-app notifications for moderation actions/warnings (#30065)
-rw-r--r--app/javascript/mastodon/features/notifications/components/moderation_warning.tsx78
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.jsx25
-rw-r--r--app/javascript/mastodon/locales/en.json9
-rw-r--r--app/javascript/mastodon/reducers/notifications.js1
-rw-r--r--app/javascript/styles/mastodon/components.scss3
-rw-r--r--app/models/admin/account_action.rb9
-rw-r--r--app/models/admin/status_batch_action.rb12
-rw-r--r--app/models/notification.rb6
-rw-r--r--app/serializers/rest/account_warning_serializer.rb16
-rw-r--r--app/serializers/rest/appeal_serializer.rb15
-rw-r--r--app/serializers/rest/notification_serializer.rb5
-rw-r--r--app/services/notify_service.rb4
-rw-r--r--spec/models/admin/account_action_spec.rb28
13 files changed, 189 insertions, 22 deletions
diff --git a/app/javascript/mastodon/features/notifications/components/moderation_warning.tsx b/app/javascript/mastodon/features/notifications/components/moderation_warning.tsx
new file mode 100644
index 00000000000..02ae9b371ef
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/moderation_warning.tsx
@@ -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>
+ );
+};
diff --git a/app/javascript/mastodon/features/notifications/components/notification.jsx b/app/javascript/mastodon/features/notifications/components/notification.jsx
index c0915546280..caf7f9bdc10 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.jsx
+++ b/app/javascript/mastodon/features/notifications/components/notification.jsx
@@ -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':
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index a1b79881c51..9d127b6b03b 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -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",
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index 7230fabcae9..64cddcb6668 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -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) => {
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index a1864a4562d..9cb03bedf18 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -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;
diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb
index 2b5560e2eba..3700ce4cd6c 100644
--- a/app/models/admin/account_action.rb
+++ b/app/models/admin/account_action.rb
@@ -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?
diff --git a/app/models/admin/status_batch_action.rb b/app/models/admin/status_batch_action.rb
index 8a8e2fa3785..4a100019351 100644
--- a/app/models/admin/status_batch_action.rb
+++ b/app/models/admin/status_batch_action.rb
@@ -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
diff --git a/app/models/notification.rb b/app/models/notification.rb
index b2376c78a3d..7e0e62683ae 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -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
diff --git a/app/serializers/rest/account_warning_serializer.rb b/app/serializers/rest/account_warning_serializer.rb
new file mode 100644
index 00000000000..a0ef341d259
--- /dev/null
+++ b/app/serializers/rest/account_warning_serializer.rb
@@ -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
diff --git a/app/serializers/rest/appeal_serializer.rb b/app/serializers/rest/appeal_serializer.rb
new file mode 100644
index 00000000000..a24cabc2728
--- /dev/null
+++ b/app/serializers/rest/appeal_serializer.rb
@@ -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
diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb
index 36a0adfec46..417245d19dd 100644
--- a/app/serializers/rest/notification_serializer.rb
+++ b/app/serializers/rest/notification_serializer.rb
@@ -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
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index c83e4c017fe..e56562c0a59 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -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)
diff --git a/spec/models/admin/account_action_spec.rb b/spec/models/admin/account_action_spec.rb
index 9bc9f8061d7..a9dcf352dcd 100644
--- a/spec/models/admin/account_action_spec.rb
+++ b/spec/models/admin/account_action_spec.rb
@@ -69,22 +69,22 @@ RSpec.describe Admin::AccountAction do
end
end
- it 'creates Admin::ActionLog' do
- expect do
- subject
- end.to change(Admin::ActionLog, :count).by 1
- end
+ it 'sends notification, log the action, and closes other reports', :aggregate_failures do
+ other_report = Fabricate(:report, target_account: target_account)
- it 'calls process_email!' do
- allow(account_action).to receive(:process_email!)
- subject
- expect(account_action).to have_received(:process_email!)
- end
+ emails = []
+ expect do
+ 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))
+
+ 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