From d26e331a4f75a646b518374ee14573c8c693fba4 Mon Sep 17 00:00:00 2001 From: Nick Funk Date: Tue, 7 Jan 2020 14:38:50 -0700 Subject: [PATCH] [CORL-664] Threshold moderate new commenters (#2752) * add new commenters config * fix specs and fixtures, add translation strings * save whether a commenter is new * fix specs and snaps * add admin role to new config options * Update copy * remvoe unused ref * feat: initial impl * Create preliminary comment moderation slices CORL-688 * Move slices logic into stacks CORL-688 * Create user comment counts CORL-688 * Create naive mutation that initializes user comment counts CORL-688 * Use bulk updates in user counts migration CORL-688 * fix: review * fix: fixed issue with aggregation * Migrate creating comment into stacks CORL-688 * Migrate editing a comment to the stacks CORL-688 * Break publishing comment status out of updateAllCounts CORL-688 * review: removed variable scoping in favor of export * revert: feb8e8196cd448f5cd24f1ca2eb0b91fe9bd43c7 * review: simplification of stacks implementation This simplifies the stacks implementation to better reuse code related to count management and event publishing. This can be used to great effect with the upcomming events PR #2738. * Remove un-necessary isNew flags on users CORL-664 * review: removed variable scoping in favor of export * revert: feb8e8196cd448f5cd24f1ca2eb0b91fe9bd43c7 * review: simplification of stacks implementation This simplifies the stacks implementation to better reuse code related to count management and event publishing. This can be used to great effect with the upcomming events PR #2738. * fix: check if authorID is null before update user counts CORL-688 * fix: addressed bug in shared count retrival Co-authored-by: Tessa Thornton Co-authored-by: Wyatt Johnson --- package-lock.json | 2 +- .../ModerateCard/MarkersContainer.spec.tsx | 2 + .../ModerateCard/MarkersContainer.tsx | 9 ++ .../MarkersContainer.spec.tsx.snap | 2 + .../Moderation/ModerationConfigContainer.tsx | 3 + .../Moderation/NewCommentersConfig.css | 3 + .../Moderation/NewCommentersConfig.tsx | 100 ++++++++++++++ .../__snapshots__/moderation.spec.tsx.snap | 122 ++++++++++++++++++ src/core/client/admin/test/fixtures.ts | 5 + src/core/client/test/helpers/fixture.ts | 6 + .../server/graph/tenant/schema/schema.graphql | 46 +++++++ .../action/__snapshots__/comment.spec.ts.snap | 1 + src/core/server/models/settings.ts | 13 ++ src/core/server/models/story/counts/shared.ts | 2 +- src/core/server/models/tenant/tenant.ts | 4 + src/core/server/models/user/user.ts | 4 + .../comments/pipeline/phases/index.ts | 2 + .../pipeline/phases/premodNewCommenter.ts | 43 ++++++ ...87_add_newcommenters_settings_to_tenant.ts | 56 ++++++++ src/locales/en-US/admin.ftl | 14 ++ 20 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 src/core/client/admin/routes/Configure/sections/Moderation/NewCommentersConfig.css create mode 100644 src/core/client/admin/routes/Configure/sections/Moderation/NewCommentersConfig.tsx create mode 100644 src/core/server/services/comments/pipeline/phases/premodNewCommenter.ts create mode 100644 src/core/server/services/migrate/migrations/1570712877087_add_newcommenters_settings_to_tenant.ts diff --git a/package-lock.json b/package-lock.json index acdf9dd8e..d56e97e2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25477,7 +25477,7 @@ "dependencies": { "async": { "version": "1.5.2", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true } diff --git a/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx b/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx index 5ce58dfac..655ca1441 100644 --- a/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx +++ b/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx @@ -28,6 +28,7 @@ it("renders all markers", () => { COMMENT_DETECTED_SUSPECT_WORD: 1, COMMENT_REPORTED_OFFENSIVE: 2, COMMENT_REPORTED_SPAM: 3, + COMMENT_DETECTED_NEW_COMMENTER: 0, COMMENT_DETECTED_REPEAT_POST: 1, }, }, @@ -72,6 +73,7 @@ it("renders some markers", () => { COMMENT_DETECTED_SUSPECT_WORD: 0, COMMENT_REPORTED_OFFENSIVE: 2, COMMENT_REPORTED_SPAM: 0, + COMMENT_DETECTED_NEW_COMMENTER: 0, COMMENT_DETECTED_REPEAT_POST: 0, }, }, diff --git a/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx b/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx index c52b9604a..636979109 100644 --- a/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx +++ b/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx @@ -120,6 +120,14 @@ const markers: Array< )) || null, + c => + (c.revision && + c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_NEW_COMMENTER && ( + + New commenter + + )) || + null, ]; export class MarkersContainer extends React.Component { @@ -170,6 +178,7 @@ const enhanced = withFragmentContainer({ COMMENT_DETECTED_SUSPECT_WORD COMMENT_REPORTED_OFFENSIVE COMMENT_REPORTED_SPAM + COMMENT_DETECTED_NEW_COMMENTER COMMENT_DETECTED_REPEAT_POST } } diff --git a/src/core/client/admin/components/ModerateCard/__snapshots__/MarkersContainer.spec.tsx.snap b/src/core/client/admin/components/ModerateCard/__snapshots__/MarkersContainer.spec.tsx.snap index 40019d894..c3f571219 100644 --- a/src/core/client/admin/components/ModerateCard/__snapshots__/MarkersContainer.spec.tsx.snap +++ b/src/core/client/admin/components/ModerateCard/__snapshots__/MarkersContainer.spec.tsx.snap @@ -15,6 +15,7 @@ exports[`renders all markers 1`] = ` "reasons": Object { "COMMENT_DETECTED_BANNED_WORD": 1, "COMMENT_DETECTED_LINKS": 1, + "COMMENT_DETECTED_NEW_COMMENTER": 0, "COMMENT_DETECTED_RECENT_HISTORY": 1, "COMMENT_DETECTED_REPEAT_POST": 1, "COMMENT_DETECTED_SPAM": 1, @@ -170,6 +171,7 @@ exports[`renders some markers 1`] = ` "reasons": Object { "COMMENT_DETECTED_BANNED_WORD": 1, "COMMENT_DETECTED_LINKS": 0, + "COMMENT_DETECTED_NEW_COMMENTER": 0, "COMMENT_DETECTED_RECENT_HISTORY": 1, "COMMENT_DETECTED_REPEAT_POST": 0, "COMMENT_DETECTED_SPAM": 0, diff --git a/src/core/client/admin/routes/Configure/sections/Moderation/ModerationConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/Moderation/ModerationConfigContainer.tsx index d8d2b836f..fb5120be9 100644 --- a/src/core/client/admin/routes/Configure/sections/Moderation/ModerationConfigContainer.tsx +++ b/src/core/client/admin/routes/Configure/sections/Moderation/ModerationConfigContainer.tsx @@ -11,6 +11,7 @@ import { HorizontalGutter } from "coral-ui/components"; import { ModerationConfigContainer_settings as SettingsData } from "coral-admin/__generated__/ModerationConfigContainer_settings.graphql"; import AkismetConfig from "./AkismetConfig"; +import NewCommentersConfig from "./NewCommentersConfig"; import PerspectiveConfig from "./PerspectiveConfig"; import PreModerationConfig from "./PreModerationConfig"; import RecentCommentHistoryConfig from "./RecentCommentHistoryConfig"; @@ -29,6 +30,7 @@ export const ModerationConfigContainer: React.FunctionComponent = ({ return ( + @@ -43,6 +45,7 @@ const enhanced = withFragmentContainer({ ...PerspectiveConfig_formValues @relay(mask: false) ...PreModerationConfig_formValues @relay(mask: false) ...RecentCommentHistoryConfig_formValues @relay(mask: false) + ...NewCommentersConfigContainer_settings @relay(mask: false) } `, })(ModerationConfigContainer); diff --git a/src/core/client/admin/routes/Configure/sections/Moderation/NewCommentersConfig.css b/src/core/client/admin/routes/Configure/sections/Moderation/NewCommentersConfig.css new file mode 100644 index 000000000..3648e55cb --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Moderation/NewCommentersConfig.css @@ -0,0 +1,3 @@ +.thresholdTextField { + width: calc(6 * var(--mini-unit)); +} diff --git a/src/core/client/admin/routes/Configure/sections/Moderation/NewCommentersConfig.tsx b/src/core/client/admin/routes/Configure/sections/Moderation/NewCommentersConfig.tsx new file mode 100644 index 000000000..b35e38f86 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Moderation/NewCommentersConfig.tsx @@ -0,0 +1,100 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; +import { Field } from "react-final-form"; +import { graphql } from "react-relay"; + +import { ValidationMessage } from "coral-framework/lib/form"; +import { + composeValidators, + required, + validateWholeNumberGreaterThan, +} from "coral-framework/lib/validation"; +import { + FieldSet, + FormField, + FormFieldDescription, + Label, + TextField, +} from "coral-ui/components/v2"; + +import ConfigBox from "../../ConfigBox"; +import Header from "../../Header"; +import OnOffField from "../../OnOffField"; + +import styles from "./NewCommentersConfig.css"; + +interface Props { + disabled: boolean; +} + +// eslint-disable-next-line no-unused-expressions +graphql` + fragment NewCommentersConfigContainer_settings on Settings { + newCommenters { + premodEnabled + approvedCommentsThreshold + } + } +`; + +const NewCommentersConfig: FunctionComponent = ({ disabled }) => { + return ( + +
New commenter approval
+ + } + > + + + When this is active, initial comments by a new commenter will be sent + to Pending for moderator approval before publication. + + + }> + + + + + + + + + + + {({ input, meta }) => ( + <> + + comments + + } + {...input} + /> + + + )} + + +
+ ); +}; + +export default NewCommentersConfig; diff --git a/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap index d5133d6c6..d4a90365c 100644 --- a/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap @@ -275,6 +275,128 @@ approved by a moderator. +
+
+
+ + New commenter approval + +
+
+
+
+
+

+ When this is active, initial comments by a new commenter will be sent to Pending +for moderator approval before publication. +

+
+ + Enable new commenter approval + +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+ comments +
+
+
+
+
+
diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index 8f2c1a71b..200317f50 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -161,6 +161,10 @@ export const settings = createFixture({ changeUsername: true, deleteAccount: true, }, + newCommenters: { + premodEnabled: false, + approvedCommentsThreshold: 2, + }, slack: { channels: [], }, @@ -532,6 +536,7 @@ export const baseComment = createFixture({ COMMENT_DETECTED_SUSPECT_WORD: 0, COMMENT_REPORTED_OFFENSIVE: 0, COMMENT_REPORTED_SPAM: 0, + COMMENT_DETECTED_NEW_COMMENTER: 0, COMMENT_DETECTED_REPEAT_POST: 0, }, }, diff --git a/src/core/client/test/helpers/fixture.ts b/src/core/client/test/helpers/fixture.ts index 7853896fe..95990a0e8 100644 --- a/src/core/client/test/helpers/fixture.ts +++ b/src/core/client/test/helpers/fixture.ts @@ -100,6 +100,7 @@ export function createComment(author?: GQLUser) { COMMENT_DETECTED_SUSPECT_WORD: 0, COMMENT_REPORTED_OFFENSIVE: 0, COMMENT_REPORTED_SPAM: 0, + COMMENT_DETECTED_NEW_COMMENTER: 0, COMMENT_DETECTED_REPEAT_POST: 0, }, }, @@ -148,6 +149,7 @@ export function createComment(author?: GQLUser) { COMMENT_DETECTED_BANNED_WORD: 0, COMMENT_DETECTED_SUSPECT_WORD: 0, COMMENT_DETECTED_PREMOD_USER: 0, + COMMENT_DETECTED_NEW_COMMENTER: 0, COMMENT_DETECTED_REPEAT_POST: 0, }, }, @@ -286,6 +288,10 @@ export function createSettings() { enabled: false, }, }, + newCommenters: { + premodEnabled: false, + approvedCommentsThreshold: 2, + }, auth: { integrations: { local: { diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index ccefe4ada..bec03d482 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -179,6 +179,7 @@ enum COMMENT_FLAG_REASON { COMMENT_DETECTED_SUSPECT_WORD COMMENT_DETECTED_RECENT_HISTORY COMMENT_DETECTED_PREMOD_USER + COMMENT_DETECTED_NEW_COMMENTER COMMENT_DETECTED_REPEAT_POST } @@ -215,6 +216,7 @@ type FlagReasonActionCounts { COMMENT_DETECTED_SUSPECT_WORD: Int! COMMENT_DETECTED_RECENT_HISTORY: Int! COMMENT_DETECTED_PREMOD_USER: Int! + COMMENT_DETECTED_NEW_COMMENTER: Int! COMMENT_DETECTED_REPEAT_POST: Int! } @@ -1194,6 +1196,23 @@ type StaffConfiguration { label: String! } +""" +NewCommenterConfiguration specifies the features that apply to new commenters +""" +type NewCommentersConfiguration { + """ + premodEnabled ensures that new commenters' comments are pre-moderated until they have + enough approved comments + """ + premodEnabled: Boolean! + + """ + approvedCommentsThreshold is the number of comments a user must have approved before their + comments do not require premoderation + """ + approvedCommentsThreshold: Int! +} + """ Settings stores the global settings for a given Tenant. """ @@ -1339,6 +1358,11 @@ type Settings { createdAt is the time that the Settings was created at. """ createdAt: Time! @auth(roles: [ADMIN]) + + """ + newCommenters is the configuration for how new commenters comments are treated. + """ + newCommenters: NewCommentersConfiguration! @auth(roles: [ADMIN]) } ################################################################################ @@ -3525,6 +3549,23 @@ input SlackConfigurationInput { channels: [SlackChannelConfigurationInput!] } +""" +NewCommenterConfigurationInput specifies the features that apply to new commenters +""" +input NewCommentersConfigurationInput { + """ + premodEnabled ensures that new commenters' comments are pre-moderated until they have + enough approved comments + """ + premodEnabled: Boolean + + """ + approvedCommentsThreshold is the number of comments a user must have approved before their + comments do not require premoderation + """ + approvedCommentsThreshold: Int +} + """ SettingsInput is the partial type of the Settings type for performing mutations. """ @@ -3644,6 +3685,11 @@ input SettingsInput { locale specifies the locale for this Tenant. """ locale: Locale + + """ + newCommenters is the configuration for how new commenters comments are treated. + """ + newCommenters: NewCommentersConfigurationInput } """ diff --git a/src/core/server/models/action/__snapshots__/comment.spec.ts.snap b/src/core/server/models/action/__snapshots__/comment.spec.ts.snap index 231b48750..02d0dd225 100644 --- a/src/core/server/models/action/__snapshots__/comment.spec.ts.snap +++ b/src/core/server/models/action/__snapshots__/comment.spec.ts.snap @@ -19,6 +19,7 @@ Object { "reasons": Object { "COMMENT_DETECTED_BANNED_WORD": 1, "COMMENT_DETECTED_LINKS": 0, + "COMMENT_DETECTED_NEW_COMMENTER": 0, "COMMENT_DETECTED_PREMOD_USER": 0, "COMMENT_DETECTED_RECENT_HISTORY": 0, "COMMENT_DETECTED_REPEAT_POST": 0, diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts index c85880b6b..3b96c4aad 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/settings.ts @@ -95,6 +95,14 @@ export interface AccountFeatures { downloadComments: boolean; } +/** + * NewCommentersConfiguration is the configuration for how new commenters comments are treated. + */ +export interface NewCommentersConfiguration { + premodEnabled: boolean; + approvedCommentsThreshold: number; +} + /** * Auth is the set of configured authentication integrations. */ @@ -165,4 +173,9 @@ export type Settings = GlobalModerationSettings & * AccountFeatures are features enabled for commenter accounts */ accountFeatures: AccountFeatures; + + /** + * newCommenters is the configuration for how new commenters comments are treated. + */ + newCommenters: NewCommentersConfiguration; }; diff --git a/src/core/server/models/story/counts/shared.ts b/src/core/server/models/story/counts/shared.ts index 02916fab0..f20ebadf6 100644 --- a/src/core/server/models/story/counts/shared.ts +++ b/src/core/server/models/story/counts/shared.ts @@ -157,8 +157,8 @@ export async function retrieveSharedModerationQueueQueuesCounts( const [[, fresh], [, queues]] = await redis .pipeline() - .hgetall(key) .get(freshKey) + .hgetall(key) .exec(); if (!fresh || !queues) { logger.debug({ tenantID }, "comment moderation counts were not cached"); diff --git a/src/core/server/models/tenant/tenant.ts b/src/core/server/models/tenant/tenant.ts index ce370a732..573d3ce47 100644 --- a/src/core/server/models/tenant/tenant.ts +++ b/src/core/server/models/tenant/tenant.ts @@ -197,6 +197,10 @@ export async function createTenant( deleteAccount: false, downloadComments: false, }, + newCommenters: { + premodEnabled: false, + approvedCommentsThreshold: 2, + }, createdAt: now, slack: { channels: [], diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 777869dfd..c146a252c 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -472,6 +472,10 @@ export interface User extends TenantResource { */ deletedAt?: Date; + /** + * commentCounts are the tallies of all comment statuses for this + * user. + */ commentCounts: UserCommentCounts; } diff --git a/src/core/server/services/comments/pipeline/phases/index.ts b/src/core/server/services/comments/pipeline/phases/index.ts index baf14c50f..70506a164 100644 --- a/src/core/server/services/comments/pipeline/phases/index.ts +++ b/src/core/server/services/comments/pipeline/phases/index.ts @@ -6,6 +6,7 @@ import { detectLinks } from "./detectLinks"; import { linkify } from "./linkify"; import { preModerate } from "./preModerate"; import { premodUser } from "./preModerateUser"; +import { premodNewCommenter } from "./premodNewCommenter"; import { purify } from "./purify"; import { recentCommentHistory } from "./recentCommentHistory"; import { repeatPost } from "./repeatPost"; @@ -33,4 +34,5 @@ export const moderationPhases: IntermediateModerationPhase[] = [ detectLinks, preModerate, premodUser, + premodNewCommenter, ]; diff --git a/src/core/server/services/comments/pipeline/phases/premodNewCommenter.ts b/src/core/server/services/comments/pipeline/phases/premodNewCommenter.ts new file mode 100644 index 000000000..57eae4224 --- /dev/null +++ b/src/core/server/services/comments/pipeline/phases/premodNewCommenter.ts @@ -0,0 +1,43 @@ +import { + GQLCOMMENT_FLAG_REASON, + GQLCOMMENT_STATUS, +} from "coral-server/graph/tenant/schema/__generated__/types"; +import { ACTION_TYPE } from "coral-server/models/action/comment"; +import { + IntermediatePhaseResult, + ModerationPhaseContext, +} from "coral-server/services/comments/pipeline"; + +export const premodNewCommenter = async ({ + tenant, + author, + mongo, + now, +}: Pick< + ModerationPhaseContext, + "author" | "tenant" | "now" | "mongo" +>): Promise => { + // Ensure this mode is enabled. + if (!tenant.newCommenters.premodEnabled) { + return; + } + + if ( + author.commentCounts.status.APPROVED < + tenant.newCommenters.approvedCommentsThreshold + ) { + return { + status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, + actions: [ + { + userID: null, + actionType: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_NEW_COMMENTER, + metadata: { + count: author.commentCounts.status.APPROVED, + }, + }, + ], + }; + } +}; diff --git a/src/core/server/services/migrate/migrations/1570712877087_add_newcommenters_settings_to_tenant.ts b/src/core/server/services/migrate/migrations/1570712877087_add_newcommenters_settings_to_tenant.ts new file mode 100644 index 000000000..1bea1db63 --- /dev/null +++ b/src/core/server/services/migrate/migrations/1570712877087_add_newcommenters_settings_to_tenant.ts @@ -0,0 +1,56 @@ +import { Db } from "mongodb"; + +// Use the following collections reference to interact with specific +// collections. +import collections from "coral-server/services/mongodb/collections"; + +import Migration from "coral-server/services/migrate/migration"; + +export default class extends Migration { + public async up(mongo: Db, tenantID: string) { + const result = await collections.tenants(mongo).updateOne( + { + id: tenantID, + newCommenters: null, + }, + { + $set: { + newCommenters: { + premodEnabled: false, + approvedCommentsThreshold: 2, + }, + }, + } + ); + this.log(tenantID).warn( + { + matchedCount: result.matchedCount, + modifiedCount: result.matchedCount, + }, + "added new commenters config" + ); + } + + public async down(mongo: Db, tenantID: string) { + const result = await collections.tenants(mongo).updateOne( + { + id: tenantID, + newCommenters: { + $exists: true, + }, + }, + { + $unset: { + newCommenters: "", + }, + } + ); + this.log(tenantID).warn( + { + matchedCount: result.matchedCount, + modifiedCount: result.matchedCount, + }, + "removed new commenters config" + ); + } +} diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 74b2bbb3c..e717808b9 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -323,6 +323,19 @@ configure-moderation-perspective-accountNote = For additional information on how to set up the Perspective Toxic Comment Filter please visit: https://github.com/conversationai/perspectiveapi#readme +configure-moderation-newCommenters-title = New commenter approval +configure-moderation-newCommenters-enable = Enable new commenter approval +configure-moderation-newCommenters-description = + When this is active, initial comments by a new commenter will be sent to Pending + for moderator approval before publication. +configure-moderation-newCommenters-enable-description = Enable pre-moderation for new commenters +configure-moderation-newCommenters-approvedCommentsThreshold = Number of first comments sent for approval +configure-moderation-newCommenters-approvedCommentsThreshold-description = + The number of comments a user must have approved before they do + not have to be premoderated +configure-moderation-newCommenters-comments = comments + + #### Banned Words Configuration configure-wordList-banned-bannedWordsAndPhrases = Banned words and phrases configure-wordList-banned-explanation = @@ -426,6 +439,7 @@ moderate-marker-toxic = Toxic moderate-marker-recentHistory = Recent history moderate-marker-bodyCount = Body count moderate-marker-offensive = Offensive +moderate-marker-newCommenter = New commenter moderate-marker-repeatPost = Repeat comment moderate-markers-details = Details