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. + + + }> + + Enable new commenter approval + + + + + + Number of first comments sent for approval + + + {({ 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 + + + + + + + On + + + + + + + + Off + + + + + + + + Number of first comments sent for 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
+ When this is active, initial comments by a new commenter will be sent to Pending +for moderator approval before publication. +