Add notifications for statuses deleted by moderators (#17204)
parent
d5c9feb7b7
commit
14f436c457
59 changed files with 1213 additions and 591 deletions
@ -1,44 +0,0 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
module Admin |
||||
class ReportedStatusesController < BaseController |
||||
before_action :set_report |
||||
|
||||
def create |
||||
authorize :status, :update? |
||||
|
||||
@form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button)) |
||||
flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save |
||||
|
||||
redirect_to admin_report_path(@report) |
||||
rescue ActionController::ParameterMissing |
||||
flash[:alert] = I18n.t('admin.statuses.no_status_selected') |
||||
|
||||
redirect_to admin_report_path(@report) |
||||
end |
||||
|
||||
private |
||||
|
||||
def status_params |
||||
params.require(:status).permit(:sensitive) |
||||
end |
||||
|
||||
def form_status_batch_params |
||||
params.require(:form_status_batch).permit(status_ids: []) |
||||
end |
||||
|
||||
def action_from_button |
||||
if params[:nsfw_on] |
||||
'nsfw_on' |
||||
elsif params[:nsfw_off] |
||||
'nsfw_off' |
||||
elsif params[:delete] |
||||
'delete' |
||||
end |
||||
end |
||||
|
||||
def set_report |
||||
@report = Report.find(params[:report_id]) |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,159 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import api from 'mastodon/api'; |
||||
import { injectIntl, defineMessages } from 'react-intl'; |
||||
import classNames from 'classnames'; |
||||
|
||||
const messages = defineMessages({ |
||||
other: { id: 'report.categories.other', defaultMessage: 'Other' }, |
||||
spam: { id: 'report.categories.spam', defaultMessage: 'Spam' }, |
||||
violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' }, |
||||
}); |
||||
|
||||
class Category extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
id: PropTypes.string.isRequired, |
||||
text: PropTypes.string.isRequired, |
||||
selected: PropTypes.bool, |
||||
disabled: PropTypes.bool, |
||||
onSelect: PropTypes.func, |
||||
children: PropTypes.node, |
||||
}; |
||||
|
||||
handleClick = () => { |
||||
const { id, disabled, onSelect } = this.props; |
||||
|
||||
if (!disabled) { |
||||
onSelect(id); |
||||
} |
||||
}; |
||||
|
||||
render () { |
||||
const { id, text, disabled, selected, children } = this.props; |
||||
|
||||
return ( |
||||
<div tabIndex='0' role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}> |
||||
{selected && <input type='hidden' name='report[category]' value={id} />} |
||||
|
||||
<div className='report-reason-selector__category__label'> |
||||
<span className={classNames('poll__input', { active: selected, disabled })} /> |
||||
{text} |
||||
</div> |
||||
|
||||
{(selected && children) && ( |
||||
<div className='report-reason-selector__category__rules'> |
||||
{children} |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
class Rule extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
id: PropTypes.string.isRequired, |
||||
text: PropTypes.string.isRequired, |
||||
selected: PropTypes.bool, |
||||
disabled: PropTypes.bool, |
||||
onToggle: PropTypes.func, |
||||
}; |
||||
|
||||
handleClick = () => { |
||||
const { id, disabled, onToggle } = this.props; |
||||
|
||||
if (!disabled) { |
||||
onToggle(id); |
||||
} |
||||
}; |
||||
|
||||
render () { |
||||
const { id, text, disabled, selected } = this.props; |
||||
|
||||
return ( |
||||
<div tabIndex='0' role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}> |
||||
<span className={classNames('poll__input', { checkbox: true, active: selected, disabled })} /> |
||||
{selected && <input type='hidden' name='report[rule_ids][]' value={id} />} |
||||
{text} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
export default @injectIntl |
||||
class ReportReasonSelector extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
id: PropTypes.string.isRequired, |
||||
category: PropTypes.string.isRequired, |
||||
rule_ids: PropTypes.arrayOf(PropTypes.string), |
||||
disabled: PropTypes.bool, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
state = { |
||||
category: this.props.category, |
||||
rule_ids: this.props.rule_ids || [], |
||||
rules: [], |
||||
}; |
||||
|
||||
componentDidMount() { |
||||
api().get('/api/v1/instance').then(res => { |
||||
this.setState({ |
||||
rules: res.data.rules, |
||||
}); |
||||
}).catch(err => { |
||||
console.error(err); |
||||
}); |
||||
} |
||||
|
||||
_save = () => { |
||||
const { id, disabled } = this.props; |
||||
const { category, rule_ids } = this.state; |
||||
|
||||
if (disabled) { |
||||
return; |
||||
} |
||||
|
||||
api().put(`/api/v1/admin/reports/${id}`, { |
||||
category, |
||||
rule_ids, |
||||
}).catch(err => { |
||||
console.error(err); |
||||
}); |
||||
}; |
||||
|
||||
handleSelect = id => { |
||||
this.setState({ category: id }, () => this._save()); |
||||
}; |
||||
|
||||
handleToggle = id => { |
||||
const { rule_ids } = this.state; |
||||
|
||||
if (rule_ids.includes(id)) { |
||||
this.setState({ rule_ids: rule_ids.filter(x => x !== id ) }, () => this._save()); |
||||
} else { |
||||
this.setState({ rule_ids: [...rule_ids, id] }, () => this._save()); |
||||
} |
||||
}; |
||||
|
||||
render () { |
||||
const { disabled, intl } = this.props; |
||||
const { rules, category, rule_ids } = this.state; |
||||
|
||||
return ( |
||||
<div className='report-reason-selector'> |
||||
<Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} /> |
||||
<Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} /> |
||||
<Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}> |
||||
{rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)} |
||||
</Category> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,92 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class Admin::StatusBatchAction |
||||
include ActiveModel::Model |
||||
include AccountableConcern |
||||
include Authorization |
||||
|
||||
attr_accessor :current_account, :type, |
||||
:status_ids, :report_id |
||||
|
||||
def save! |
||||
process_action! |
||||
end |
||||
|
||||
private |
||||
|
||||
def statuses |
||||
Status.with_discarded.where(id: status_ids) |
||||
end |
||||
|
||||
def process_action! |
||||
return if status_ids.empty? |
||||
|
||||
case type |
||||
when 'delete' |
||||
handle_delete! |
||||
when 'report' |
||||
handle_report! |
||||
when 'remove_from_report' |
||||
handle_remove_from_report! |
||||
end |
||||
end |
||||
|
||||
def handle_delete! |
||||
statuses.each { |status| authorize(status, :destroy?) } |
||||
|
||||
ApplicationRecord.transaction do |
||||
statuses.each do |status| |
||||
status.discard |
||||
log_action(:destroy, status) |
||||
end |
||||
|
||||
if with_report? |
||||
report.resolve!(current_account) |
||||
log_action(:resolve, report) |
||||
end |
||||
|
||||
@warning = target_account.strikes.create!( |
||||
action: :delete_statuses, |
||||
account: current_account, |
||||
report: report, |
||||
status_ids: status_ids |
||||
) |
||||
|
||||
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 target_account.local? |
||||
RemovalWorker.push_bulk(status_ids) { |status_id| [status_id, preserve: target_account.local?, immediate: !target_account.local?] } |
||||
end |
||||
|
||||
def handle_report! |
||||
@report = Report.new(report_params) unless with_report? |
||||
@report.status_ids = (@report.status_ids + status_ids.map(&:to_i)).uniq |
||||
@report.save! |
||||
|
||||
@report_id = @report.id |
||||
end |
||||
|
||||
def handle_remove_from_report! |
||||
return unless with_report? |
||||
|
||||
report.status_ids -= status_ids.map(&:to_i) |
||||
report.save! |
||||
end |
||||
|
||||
def report |
||||
@report ||= Report.find(report_id) if report_id.present? |
||||
end |
||||
|
||||
def with_report? |
||||
!report.nil? |
||||
end |
||||
|
||||
def target_account |
||||
@target_account ||= statuses.first.account |
||||
end |
||||
|
||||
def report_params |
||||
{ account: current_account, target_account: target_account } |
||||
end |
||||
end |
@ -0,0 +1,41 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class Admin::StatusFilter |
||||
KEYS = %i( |
||||
media |
||||
id |
||||
report_id |
||||
).freeze |
||||
|
||||
attr_reader :params |
||||
|
||||
def initialize(account, params) |
||||
@account = account |
||||
@params = params |
||||
end |
||||
|
||||
def results |
||||
scope = @account.statuses.where(visibility: [:public, :unlisted]) |
||||
|
||||
params.each do |key, value| |
||||
next if %w(page report_id).include?(key.to_s) |
||||
|
||||
scope.merge!(scope_for(key, value.to_s.strip)) if value.present? |
||||
end |
||||
|
||||
scope |
||||
end |
||||
|
||||
private |
||||
|
||||
def scope_for(key, value) |
||||
case key.to_s |
||||
when 'media' |
||||
Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id) |
||||
when 'id' |
||||
Status.where(id: value) |
||||
else |
||||
raise "Unknown filter: #{key}" |
||||
end |
||||
end |
||||
end |
@ -1,45 +0,0 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class Form::StatusBatch |
||||
include ActiveModel::Model |
||||
include AccountableConcern |
||||
|
||||
attr_accessor :status_ids, :action, :current_account |
||||
|
||||
def save |
||||
case action |
||||
when 'nsfw_on', 'nsfw_off' |
||||
change_sensitive(action == 'nsfw_on') |
||||
when 'delete' |
||||
delete_statuses |
||||
end |
||||
end |
||||
|
||||
private |
||||
|
||||
def change_sensitive(sensitive) |
||||
media_attached_status_ids = MediaAttachment.where(status_id: status_ids).pluck(:status_id) |
||||
|
||||
ApplicationRecord.transaction do |
||||
Status.where(id: media_attached_status_ids).reorder(nil).find_each do |status| |
||||
status.update!(sensitive: sensitive) |
||||
log_action :update, status |
||||
end |
||||
end |
||||
|
||||
true |
||||
rescue ActiveRecord::RecordInvalid |
||||
false |
||||
end |
||||
|
||||
def delete_statuses |
||||
Status.where(id: status_ids).reorder(nil).find_each do |status| |
||||
status.discard |
||||
RemovalWorker.perform_async(status.id, immediate: true) |
||||
Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) |
||||
log_action :destroy, status |
||||
end |
||||
|
||||
true |
||||
end |
||||
end |
@ -1,7 +1,18 @@ |
||||
.speech-bubble |
||||
.speech-bubble__bubble |
||||
.report-notes__item |
||||
= image_tag report_note.account.avatar.url, class: 'report-notes__item__avatar' |
||||
|
||||
.report-notes__item__header |
||||
%span.username |
||||
= link_to display_name(report_note.account), admin_account_path(report_note.account_id) |
||||
%time{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) } |
||||
- if report_note.created_at.today? |
||||
= t('admin.report_notes.today_at', time: l(report_note.created_at, format: :time)) |
||||
- else |
||||
= l report_note.created_at.to_date |
||||
|
||||
.report-notes__item__content |
||||
= simple_format(h(report_note.content)) |
||||
.speech-bubble__owner |
||||
= admin_account_link_to report_note.account |
||||
%time.formatted{ datetime: report_note.created_at.iso8601 }= l report_note.created_at |
||||
= table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete if can?(:destroy, report_note) |
||||
|
||||
- if can?(:destroy, report_note) |
||||
.report-notes__item__actions |
||||
= table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete |
||||
|
@ -1,6 +0,0 @@ |
||||
.speech-bubble.positive |
||||
.speech-bubble__bubble |
||||
= t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}_html", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target')) |
||||
.speech-bubble__owner |
||||
= admin_account_link_to(action_log.account) |
||||
%time.formatted{ datetime: action_log.created_at.iso8601 }= l action_log.created_at |
@ -1,27 +0,0 @@ |
||||
- content_for :page_title do |
||||
= t('admin.statuses.title') |
||||
\- |
||||
= "@#{@account.acct}" |
||||
|
||||
.filters |
||||
.back-link |
||||
= link_to admin_account_path(@account.id) do |
||||
%i.fa.fa-chevron-left.fa-fw |
||||
= t('admin.statuses.back_to_account') |
||||
|
||||
%hr.spacer/ |
||||
|
||||
= form_for(@form, url: admin_account_statuses_path(@account.id)) do |f| |
||||
= hidden_field_tag :page, params[:page] |
||||
= hidden_field_tag :media, params[:media] |
||||
|
||||
.batch-table |
||||
.batch-table__toolbar |
||||
%label.batch-table__toolbar__select.batch-checkbox-all |
||||
= check_box_tag :batch_checkbox_all, nil, false |
||||
.batch-table__toolbar__actions |
||||
= f.button safe_join([fa_icon('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } |
||||
= f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } |
||||
= f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } |
||||
.batch-table__body |
||||
= render partial: 'admin/reports/status', collection: @statuses, locals: { f: f } |
@ -1,8 +1,8 @@ |
||||
<% if status.spoiler_text? %> |
||||
<%= raw status.spoiler_text %> |
||||
---- |
||||
|
||||
> <%= raw word_wrap(status.spoiler_text, break_sequence: "\n> ") %> |
||||
> ---- |
||||
> |
||||
<% end %> |
||||
<%= raw Formatter.instance.plaintext(status) %> |
||||
> <%= raw word_wrap(Formatter.instance.plaintext(status), break_sequence: "\n> ") %> |
||||
|
||||
<%= raw t('application_mailer.view')%> <%= web_url("statuses/#{status.id}") %> |
||||
|
@ -0,0 +1,21 @@ |
||||
require Rails.root.join('lib', 'mastodon', 'migration_helpers') |
||||
|
||||
class AddCategoryToReports < ActiveRecord::Migration[6.1] |
||||
include Mastodon::MigrationHelpers |
||||
|
||||
disable_ddl_transaction! |
||||
|
||||
def up |
||||
safety_assured { add_column_with_default :reports, :category, :int, default: 0, allow_null: false } |
||||
add_column :reports, :action_taken_at, :datetime |
||||
add_column :reports, :rule_ids, :bigint, array: true |
||||
safety_assured { execute 'UPDATE reports SET action_taken_at = updated_at WHERE action_taken = TRUE' } |
||||
end |
||||
|
||||
def down |
||||
safety_assured { execute 'UPDATE reports SET action_taken = TRUE WHERE action_taken_at IS NOT NULL' } |
||||
remove_column :reports, :category |
||||
remove_column :reports, :action_taken_at |
||||
remove_column :reports, :rule_ids |
||||
end |
||||
end |
@ -0,0 +1,6 @@ |
||||
class AddReportIdToAccountWarnings < ActiveRecord::Migration[6.1] |
||||
def change |
||||
safety_assured { add_reference :account_warnings, :report, foreign_key: { on_delete: :cascade }, index: false } |
||||
add_column :account_warnings, :status_ids, :string, array: true |
||||
end |
||||
end |
@ -0,0 +1,21 @@ |
||||
class FixAccountWarningActions < ActiveRecord::Migration[6.1] |
||||
disable_ddl_transaction! |
||||
|
||||
def up |
||||
safety_assured do |
||||
execute 'UPDATE account_warnings SET action = 1000 WHERE action = 1' |
||||
execute 'UPDATE account_warnings SET action = 2000 WHERE action = 2' |
||||
execute 'UPDATE account_warnings SET action = 3000 WHERE action = 3' |
||||
execute 'UPDATE account_warnings SET action = 4000 WHERE action = 4' |
||||
end |
||||
end |
||||
|
||||
def down |
||||
safety_assured do |
||||
execute 'UPDATE account_warnings SET action = 1 WHERE action = 1000' |
||||
execute 'UPDATE account_warnings SET action = 2 WHERE action = 2000' |
||||
execute 'UPDATE account_warnings SET action = 3 WHERE action = 3000' |
||||
execute 'UPDATE account_warnings SET action = 4 WHERE action = 4000' |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,7 @@ |
||||
class AddDeletedAtIndexOnStatuses < ActiveRecord::Migration[6.1] |
||||
disable_ddl_transaction! |
||||
|
||||
def change |
||||
add_index :statuses, :deleted_at, where: 'deleted_at IS NOT NULL', algorithm: :concurrently |
||||
end |
||||
end |
@ -0,0 +1,9 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class RemoveActionTakenFromReports < ActiveRecord::Migration[5.2] |
||||
disable_ddl_transaction! |
||||
|
||||
def change |
||||
safety_assured { remove_column :reports, :action_taken, :boolean, default: false, null: false } |
||||
end |
||||
end |
@ -1,59 +0,0 @@ |
||||
require 'rails_helper' |
||||
|
||||
describe Admin::ReportedStatusesController do |
||||
render_views |
||||
|
||||
let(:user) { Fabricate(:user, admin: true) } |
||||
let(:report) { Fabricate(:report, status_ids: [status.id]) } |
||||
let(:status) { Fabricate(:status) } |
||||
|
||||
before do |
||||
sign_in user, scope: :user |
||||
end |
||||
|
||||
describe 'POST #create' do |
||||
subject do |
||||
-> { post :create, params: { :report_id => report, action => '', :form_status_batch => { status_ids: status_ids } } } |
||||
end |
||||
|
||||
let(:action) { 'nsfw_on' } |
||||
let(:status_ids) { [status.id] } |
||||
let(:status) { Fabricate(:status, sensitive: !sensitive) } |
||||
let(:sensitive) { true } |
||||
let!(:media_attachment) { Fabricate(:media_attachment, status: status) } |
||||
|
||||
context 'when action is nsfw_on' do |
||||
it 'updates sensitive column' do |
||||
is_expected.to change { |
||||
status.reload.sensitive |
||||
}.from(false).to(true) |
||||
end |
||||
end |
||||
|
||||
context 'when action is nsfw_off' do |
||||
let(:action) { 'nsfw_off' } |
||||
let(:sensitive) { false } |
||||
|
||||
it 'updates sensitive column' do |
||||
is_expected.to change { |
||||
status.reload.sensitive |
||||
}.from(true).to(false) |
||||
end |
||||
end |
||||
|
||||
context 'when action is delete' do |
||||
let(:action) { 'delete' } |
||||
|
||||
it 'removes a status' do |
||||
allow(RemovalWorker).to receive(:perform_async) |
||||
subject.call |
||||
expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, immediate: true) |
||||
end |
||||
end |
||||
|
||||
it 'redirects to report page' do |
||||
subject.call |
||||
expect(response).to redirect_to(admin_report_path(report)) |
||||
end |
||||
end |
||||
end |
@ -1,6 +1,6 @@ |
||||
Fabricator(:report) do |
||||
account |
||||
target_account { Fabricate(:account) } |
||||
comment "You nasty" |
||||
action_taken false |
||||
target_account { Fabricate(:account) } |
||||
comment "You nasty" |
||||
action_taken_at nil |
||||
end |
||||
|
@ -1,52 +0,0 @@ |
||||
require 'rails_helper' |
||||
|
||||
describe Form::StatusBatch do |
||||
let(:form) { Form::StatusBatch.new(action: action, status_ids: status_ids) } |
||||
let(:status) { Fabricate(:status) } |
||||
|
||||
describe 'with nsfw action' do |
||||
let(:status_ids) { [status.id, nonsensitive_status.id, sensitive_status.id] } |
||||
let(:nonsensitive_status) { Fabricate(:status, sensitive: false) } |
||||
let(:sensitive_status) { Fabricate(:status, sensitive: true) } |
||||
let!(:shown_media_attachment) { Fabricate(:media_attachment, status: nonsensitive_status) } |
||||
let!(:hidden_media_attachment) { Fabricate(:media_attachment, status: sensitive_status) } |
||||
|
||||
context 'nsfw_on' do |
||||
let(:action) { 'nsfw_on' } |
||||
|
||||
it { expect(form.save).to be true } |
||||
it { expect { form.save }.to change { nonsensitive_status.reload.sensitive }.from(false).to(true) } |
||||
it { expect { form.save }.not_to change { sensitive_status.reload.sensitive } } |
||||
it { expect { form.save }.not_to change { status.reload.sensitive } } |
||||
end |
||||
|
||||
context 'nsfw_off' do |
||||
let(:action) { 'nsfw_off' } |
||||
|
||||
it { expect(form.save).to be true } |
||||
it { expect { form.save }.to change { sensitive_status.reload.sensitive }.from(true).to(false) } |
||||
it { expect { form.save }.not_to change { nonsensitive_status.reload.sensitive } } |
||||
it { expect { form.save }.not_to change { status.reload.sensitive } } |
||||
end |
||||
end |
||||
|
||||
describe 'with delete action' do |
||||
let(:status_ids) { [status.id] } |
||||
let(:action) { 'delete' } |
||||
let!(:another_status) { Fabricate(:status) } |
||||
|
||||
before do |
||||
allow(RemovalWorker).to receive(:perform_async) |
||||
end |
||||
|
||||
it 'call RemovalWorker' do |
||||
form.save |
||||
expect(RemovalWorker).to have_received(:perform_async).with(status.id, immediate: true) |
||||
end |
||||
|
||||
it 'do not call RemovalWorker' do |
||||
form.save |
||||
expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id, immediate: true) |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue