FEATURE: add penalty options for take action (#10926)

* FEATURE: add penalty options for take action

Add the ability to silence or suspend users from the "take action"
button when moderators are flagging posts. This allows for a more streamlined
active moderation workflow, when moderating against a topic directly.
This commit is contained in:
Jeff Wong 2020-10-15 10:48:52 -07:00 committed by GitHub
parent e062b94e7f
commit d68ad82a9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 245 additions and 15 deletions

View File

@ -7,6 +7,9 @@ import ActionSummary from "discourse/models/action-summary";
import { MAX_MESSAGE_LENGTH } from "discourse/models/post-action-type"; import { MAX_MESSAGE_LENGTH } from "discourse/models/post-action-type";
import optionalService from "discourse/lib/optional-service"; import optionalService from "discourse/lib/optional-service";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "I18n";
import User from "discourse/models/user";
import { Promise } from "rsvp";
export default Controller.extend(ModalFunctionality, { export default Controller.extend(ModalFunctionality, {
adminTools: optionalService(), adminTools: optionalService(),
@ -17,6 +20,58 @@ export default Controller.extend(ModalFunctionality, {
isWarning: false, isWarning: false,
topicActionByName: null, topicActionByName: null,
spammerDetails: null, spammerDetails: null,
flagActions: null,
init() {
this._super(...arguments);
this.flagActions = {
icon: "gavel",
label: I18n.t("flagging.take_action"),
actions: [
{
id: "agree_and_keep",
icon: "thumbs-up",
label: I18n.t("flagging.take_action_options.default.title"),
description: I18n.t("flagging.take_action_options.default.details"),
},
{
id: "agree_and_suspend",
icon: "ban",
label: I18n.t("flagging.take_action_options.suspend.title"),
description: I18n.t("flagging.take_action_options.suspend.details"),
client_action: "suspend",
},
{
id: "agree_and_silence",
icon: "microphone-slash",
label: I18n.t("flagging.take_action_options.silence.title"),
description: I18n.t("flagging.take_action_options.silence.details"),
client_action: "silence",
},
],
};
},
clientSuspend(performAction) {
this._penalize("showSuspendModal", performAction);
},
clientSilence(performAction) {
this._penalize("showSilenceModal", performAction);
},
async _penalize(adminToolMethod, performAction) {
if (this.adminTools) {
let createdBy = await User.findByUsername(this.model.username);
let postId = this.model.id;
let postEdit = this.model.cooked;
return this.adminTools[adminToolMethod](createdBy, {
postId,
postEdit,
before: performAction,
});
}
},
onShow() { onShow() {
this.setProperties({ this.setProperties({
@ -24,9 +79,8 @@ export default Controller.extend(ModalFunctionality, {
spammerDetails: null, spammerDetails: null,
}); });
let adminTools = this.adminTools; if (this.adminTools) {
if (adminTools) { this.adminTools.checkSpammer(this.get("model.user_id")).then((result) => {
adminTools.checkSpammer(this.get("model.user_id")).then((result) => {
this.set("spammerDetails", result); this.set("spammerDetails", result);
}); });
} }
@ -133,9 +187,28 @@ export default Controller.extend(ModalFunctionality, {
} }
}, },
takeAction() { takeAction(action) {
this.send("createFlag", { takeAction: true }); let performAction = (o = {}) => {
this.set("model.hidden", true); o.takeAction = true;
this.send("createFlag", o);
return Promise.resolve();
};
if (action.client_action) {
let actionMethod = this[`client${action.client_action.classify()}`];
if (actionMethod) {
return actionMethod.call(this, () =>
performAction({ skipClose: true })
);
} else {
// eslint-disable-next-line no-console
console.error(`No handler for ${action.client_action} found`);
return;
}
} else {
this.set("model.hidden", true);
return performAction();
}
}, },
createFlag(opts) { createFlag(opts) {
@ -171,7 +244,9 @@ export default Controller.extend(ModalFunctionality, {
postAction postAction
.act(this.model, params) .act(this.model, params)
.then(() => { .then(() => {
this.send("closeModal"); if (!opts.skipClose) {
this.send("closeModal");
}
if (params.message) { if (params.message) {
this.set("message", ""); this.set("message", "");
} }

View File

@ -35,13 +35,10 @@
{{/if}} {{/if}}
{{#if canTakeAction}} {{#if canTakeAction}}
{{d-button {{reviewable-bundled-action
class="btn-danger" bundle=flagActions
action=(action "takeAction") performAction=(action "takeAction")
disabled=submitDisabled reviewableUpdating=submitDisabled
title="flagging.take_action_tooltip"
icon="gavel"
label="flagging.take_action"
}} }}
{{/if}} {{/if}}

View File

@ -0,0 +1,144 @@
import { test } from "qunit";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import userFixtures from "discourse/tests/fixtures/user-fixtures";
acceptance("flagging", {
loggedIn: true,
afterEach() {
sandbox.restore();
},
pretend(pretenderServer, helper) {
const userResponse = Object.assign({}, userFixtures["/u/charlie.json"]);
pretenderServer.get("/u/uwe_keim.json", () => {
return helper.response(userResponse);
});
pretenderServer.get("/admin/users/255.json", () => {
return helper.response({
id: 255,
automatic: false,
name: "admin",
username: "admin",
user_count: 0,
alias_level: 99,
visible: true,
automatic_membership_email_domains: "",
primary_group: false,
title: null,
grant_trust_level: null,
has_messages: false,
flair_url: null,
flair_bg_color: null,
flair_color: null,
bio_raw: null,
bio_cooked: null,
public_admission: false,
allow_membership_requests: true,
membership_request_template: "Please add me",
full_name: null,
});
});
pretenderServer.get("/admin/users/5.json", () => {
return helper.response({
id: 5,
automatic: false,
name: "user",
username: "user",
user_count: 0,
alias_level: 99,
visible: true,
automatic_membership_email_domains: "",
primary_group: false,
title: null,
grant_trust_level: null,
has_messages: false,
flair_url: null,
flair_bg_color: null,
flair_color: null,
bio_raw: null,
bio_cooked: null,
public_admission: false,
allow_membership_requests: true,
membership_request_template: "Please add me",
full_name: null,
});
});
pretenderServer.put("admin/users/5/silence", () => {
return helper.response({
silenced: true,
});
});
pretenderServer.post("post_actions", () => {
return helper.response({
response: true,
});
});
},
});
async function openFlagModal() {
if (exists(".topic-post:first-child button.show-more-actions")) {
await click(".topic-post:first-child button.show-more-actions");
}
await click(".topic-post:first-child button.create-flag");
}
test("Flag modal opening", async (assert) => {
await visit("/t/internationalization-localization/280");
await openFlagModal();
assert.ok(exists(".flag-modal-body"), "it shows the flag modal");
});
test("Flag take action dropdown exists", async (assert) => {
await visit("/t/internationalization-localization/280");
await openFlagModal();
await click("#radio_inappropriate");
await selectKit(".reviewable-action-dropdown").expand();
assert.ok(
exists("[data-value='agree_and_silence']"),
"it shows the silence action option"
);
await click("[data-value='agree_and_silence']");
assert.ok(exists(".silence-user-modal"), "it shows the silence modal");
});
test("Can silence from take action", async (assert) => {
await visit("/t/internationalization-localization/280");
await openFlagModal();
await click("#radio_inappropriate");
await selectKit(".reviewable-action-dropdown").expand();
await click("[data-value='agree_and_silence']");
const silenceUntilCombobox = selectKit(".silence-until .combobox");
await silenceUntilCombobox.expand();
await silenceUntilCombobox.selectRowByValue("tomorrow");
await fillIn(".silence-reason", "for breaking the rules");
await click(".perform-silence");
assert.equal(find(".bootbox.modal:visible").length, 0);
});
test("Gets dismissable warning from canceling incomplete silence from take action", async (assert) => {
await visit("/t/internationalization-localization/280");
await openFlagModal();
await click("#radio_inappropriate");
await selectKit(".reviewable-action-dropdown").expand();
await click("[data-value='agree_and_silence']");
const silenceUntilCombobox = selectKit(".silence-until .combobox");
await silenceUntilCombobox.expand();
await silenceUntilCombobox.selectRowByValue("tomorrow");
await fillIn(".silence-reason", "for breaking the rules");
await click(".d-modal-cancel");
assert.equal(find(".bootbox.modal:visible").length, 1);
await click(".modal-footer .btn-default");
assert.equal(find(".bootbox.modal:visible").length, 0);
assert.ok(exists(".silence-user-modal"), "it shows the silence modal");
await click(".d-modal-cancel");
assert.equal(find(".bootbox.modal:visible").length, 1);
await click(".modal-footer .btn-primary");
assert.equal(find(".bootbox.modal:visible").length, 0);
});

View File

@ -490,6 +490,10 @@
} }
} }
.flag-modal .modal-inner-container .select-kit.reviewable-action-dropdown {
width: initial;
}
@media screen and (max-width: 1000px) { @media screen and (max-width: 1000px) {
table.reviewable-scores { table.reviewable-scores {
width: 100%; width: 100%;

View File

@ -2997,7 +2997,17 @@ en:
flagging: flagging:
title: "Thanks for helping to keep our community civil!" title: "Thanks for helping to keep our community civil!"
action: "Flag Post" action: "Flag Post"
take_action: "Take Action" take_action: "Take Action..."
take_action_options:
default:
title: "Take Action"
details: "Reach the flag threshold immediately, rather than waiting for more community flags"
suspend:
title: "Suspend User"
details: "Reach the flag threshold, and suspend the user"
silence:
title: "Silence User"
details: "Reach the flag threshold, and silence the user"
notify_action: "Message" notify_action: "Message"
official_warning: "Official Warning" official_warning: "Official Warning"
delete_spammer: "Delete Spammer" delete_spammer: "Delete Spammer"