From 4b81876a8f01a833332d5abbd100f9fd49da7a0f Mon Sep 17 00:00:00 2001 From: Marcus Haddon Date: Sun, 1 Oct 2023 20:33:18 -0700 Subject: [PATCH 01/30] add fields to models --- .../core/server/graph/schema/schema.graphql | 42 +++++++++++++++++++ .../models/action/moderation/comment.ts | 14 ++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/server/src/core/server/graph/schema/schema.graphql b/server/src/core/server/graph/schema/schema.graphql index 28b4db5aee..9e46030154 100644 --- a/server/src/core/server/graph/schema/schema.graphql +++ b/server/src/core/server/graph/schema/schema.graphql @@ -3274,6 +3274,43 @@ enum COMMENT_STATUS { SYSTEM_WITHHELD } +enum REJECTION_REASON { + """ + OFFENSIVE represents a rejection of a comment for being offensive. + """ + OFFENSIVE + + """ + ABUSIVE represents a rejection of a comment for being abusive. + """ + ABUSIVE + + """ + SPAM represents a rejection of a comment for being spam. + """ + SPAM + + """ + BANNED_WORD represents a rejection of a comment for containing a banned word. + """ + BANNED_WORD + + """ + AD represents a rejection of a comment for being and ad. + """ + AD + + """ + ILLEGAL_CONTENT represents a rejection of a comment for containing illegal content. + """ + ILLEGAL_CONTENT + + """ + OTHER is reserved for reasons that arent adequately described by the other options. + """ + OTHER +} + type CommentModerationAction { id: ID! @@ -3298,6 +3335,11 @@ type CommentModerationAction { """ moderator: User + """ + reason is the reason the comment was rejected, if it was rejected + """ + reason: REJECTION_REASON + """ createdAt is the time that the CommentModerationAction was created. """ diff --git a/server/src/core/server/models/action/moderation/comment.ts b/server/src/core/server/models/action/moderation/comment.ts index dde8ccea33..8590472f60 100644 --- a/server/src/core/server/models/action/moderation/comment.ts +++ b/server/src/core/server/models/action/moderation/comment.ts @@ -10,7 +10,10 @@ import { } from "coral-server/models/helpers"; import { TenantResource } from "coral-server/models/tenant"; -import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types"; +import { + GQLCOMMENT_STATUS, + GQLREJECTION_REASON, +} from "coral-server/graph/schema/__generated__/types"; /** * CommentModerationAction stores information around a moderation action that @@ -41,6 +44,15 @@ export interface CommentModerationAction extends TenantResource { */ status: GQLCOMMENT_STATUS; + /** + * reason is the GQLMODERATION_REASON_REASON for the decision, if it is + * a rejection + */ + rejectionReason?: { + reason: GQLREJECTION_REASON; + metaData?: string; + }; + /** * moderatorID is the ID of the User that created the moderation action. If * null, it indicates that it was created by the system rather than a User. From 1d3ed4ed13af07a21413122e9439324963225eab Mon Sep 17 00:00:00 2001 From: Marcus Haddon Date: Sun, 1 Oct 2023 21:11:38 -0700 Subject: [PATCH 02/30] WIP: scaffold test --- .../comments/moderation/moderate.spec.ts | 67 +++++++++++++++++++ server/src/core/server/test/mocks.ts | 4 +- 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 server/src/core/server/services/comments/moderation/moderate.spec.ts diff --git a/server/src/core/server/services/comments/moderation/moderate.spec.ts b/server/src/core/server/services/comments/moderation/moderate.spec.ts new file mode 100644 index 0000000000..d3caf279d5 --- /dev/null +++ b/server/src/core/server/services/comments/moderation/moderate.spec.ts @@ -0,0 +1,67 @@ +import { Config } from "coral-server/config"; +import { MongoContext } from "coral-server/data/context"; +import { AugmentedRedis } from "coral-server/services/redis"; +import { + createCommentFixture, + createStoryFixture, + createTenantFixture, + createUserFixture, +} from "coral-server/test/fixtures"; +import { createMockRedis } from "coral-server/test/mocks"; +import moderate, { Moderate } from "./moderate"; + +import { + GQLCOMMENT_STATUS, + GQLUSER_ROLE, +} from "coral-server/graph/schema/__generated__/types"; + +jest.mock("coral-server/models/comment"); +jest.mock("coral-server/stacks/helpers"); +jest.mock("coral-server/models/action/moderation/comment"); + +it("requires a valid rejection reason if dsaFeatures are enabled", async () => { + const tenant = createTenantFixture(); + const config = {} as Config; + const story = createStoryFixture({ tenantID: tenant.id }); + const comment = createCommentFixture({ storyID: story.id }); + const moderator = createUserFixture({ + tenantID: tenant.id, + role: GQLUSER_ROLE.MODERATOR, + }); + let mongo: MongoContext; + const redis = createMockRedis(); + + /* eslint-disable-next-line */ + require("coral-server/models/comment").retrieveComment.mockImplementation( + async () => "TODO" + ); + + /* eslint-disable-next-line */ + require("coral-server/models/comment").updateCommentStatus.mockImplementation( + async () => "TODO" + ); + + /* eslint-disable-next-line */ + require("coral-server/models/action/moderation/comment").createCommentModerationAction.mockImplementation( + async () => "TODO" + ); + + /* eslint-disable-next-line */ + require("coral-server/stacks/helpers").updateAllCommentCounts.mockImplementation( + async () => "TODO" + ); + + const input: Moderate = { + commentID: comment.id, + moderatorID: moderator.id, + commentRevisionID: comment.revisions[comment.revisions.length - 1].id, + status: GQLCOMMENT_STATUS.REJECTED, + }; + + await expect( + async () => + await moderate(mongo, redis, config, tenant, input, new Date(), false, { + actionCounts: {}, // TODO: what should this be? + }) + ).rejects.toThrow(); +}); diff --git a/server/src/core/server/test/mocks.ts b/server/src/core/server/test/mocks.ts index ca9b485202..f4369105bc 100644 --- a/server/src/core/server/test/mocks.ts +++ b/server/src/core/server/test/mocks.ts @@ -2,8 +2,8 @@ import { DataCache } from "coral-server/data/cache/dataCache"; import { MongoContext } from "coral-server/data/context"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { RejectorQueue } from "coral-server/queue/tasks/rejector"; +import { AugmentedRedis } from "coral-server/services/redis"; import { TenantCache } from "coral-server/services/tenant/cache"; -import { Redis } from "ioredis"; const createMockCollection = () => ({ findOneAndUpdate: jest.fn(), @@ -21,7 +21,7 @@ export const createMockMongoContex = () => { }; }; -export const createMockRedis = () => ({} as Redis); +export const createMockRedis = () => ({} as AugmentedRedis); export const createMockTenantCache = (): TenantCache => ({ From 392f54491af31c1255a5d74fbbcfd7e7fd69dec0 Mon Sep 17 00:00:00 2001 From: Marcus Haddon Date: Wed, 4 Oct 2023 14:20:57 -0700 Subject: [PATCH 03/30] WIP setting up tests --- .../core/server/graph/schema/schema.graphql | 21 ++++++++++++-- .../models/action/moderation/comment.ts | 2 +- .../comments/moderation/moderate.spec.ts | 29 +++++++++++++------ .../services/comments/moderation/moderate.ts | 5 +++- server/src/core/server/test/mocks.ts | 3 ++ 5 files changed, 47 insertions(+), 13 deletions(-) diff --git a/server/src/core/server/graph/schema/schema.graphql b/server/src/core/server/graph/schema/schema.graphql index 9e46030154..dd0f52095a 100644 --- a/server/src/core/server/graph/schema/schema.graphql +++ b/server/src/core/server/graph/schema/schema.graphql @@ -3274,7 +3274,7 @@ enum COMMENT_STATUS { SYSTEM_WITHHELD } -enum REJECTION_REASON { +enum REJECTION_REASON_CODE { """ OFFENSIVE represents a rejection of a comment for being offensive. """ @@ -3311,6 +3311,18 @@ enum REJECTION_REASON { OTHER } +type RejectionReason { + """ + code is the reason that the comment was rejected + """ + code: REJECTION_REASON_CODE! + + """ + metaData is any optional plaintext data added for context + """ + metaData: String +} + type CommentModerationAction { id: ID! @@ -3338,7 +3350,7 @@ type CommentModerationAction { """ reason is the reason the comment was rejected, if it was rejected """ - reason: REJECTION_REASON + reason: RejectionReason """ createdAt is the time that the CommentModerationAction was created. @@ -6558,6 +6570,11 @@ input RejectCommentInput { clientMutationId is required for Relay support. """ clientMutationId: String! + + """ + reason is the reason the comment is being rejected + """ + reason: RejectionReason! } type RejectCommentPayload { diff --git a/server/src/core/server/models/action/moderation/comment.ts b/server/src/core/server/models/action/moderation/comment.ts index 8590472f60..a3479e4594 100644 --- a/server/src/core/server/models/action/moderation/comment.ts +++ b/server/src/core/server/models/action/moderation/comment.ts @@ -49,7 +49,7 @@ export interface CommentModerationAction extends TenantResource { * a rejection */ rejectionReason?: { - reason: GQLREJECTION_REASON; + code: GQLREJECTION_REASON_CODE; metaData?: string; }; diff --git a/server/src/core/server/services/comments/moderation/moderate.spec.ts b/server/src/core/server/services/comments/moderation/moderate.spec.ts index d3caf279d5..dbdad4035a 100644 --- a/server/src/core/server/services/comments/moderation/moderate.spec.ts +++ b/server/src/core/server/services/comments/moderation/moderate.spec.ts @@ -1,13 +1,15 @@ +/* eslint-disable */ import { Config } from "coral-server/config"; -import { MongoContext } from "coral-server/data/context"; -import { AugmentedRedis } from "coral-server/services/redis"; import { createCommentFixture, createStoryFixture, createTenantFixture, createUserFixture, } from "coral-server/test/fixtures"; -import { createMockRedis } from "coral-server/test/mocks"; +import { + createMockMongoContex, + createMockRedis, +} from "coral-server/test/mocks"; import moderate, { Moderate } from "./moderate"; import { @@ -28,12 +30,12 @@ it("requires a valid rejection reason if dsaFeatures are enabled", async () => { tenantID: tenant.id, role: GQLUSER_ROLE.MODERATOR, }); - let mongo: MongoContext; + const { ctx: mongoContext } = createMockMongoContex(); const redis = createMockRedis(); /* eslint-disable-next-line */ require("coral-server/models/comment").retrieveComment.mockImplementation( - async () => "TODO" + async () => comment ); /* eslint-disable-next-line */ @@ -60,8 +62,17 @@ it("requires a valid rejection reason if dsaFeatures are enabled", async () => { await expect( async () => - await moderate(mongo, redis, config, tenant, input, new Date(), false, { - actionCounts: {}, // TODO: what should this be? - }) - ).rejects.toThrow(); + await moderate( + mongoContext, + redis, + config, + tenant, + input, + new Date(), + false, + { + actionCounts: {}, // TODO: what should this be? + } + ) + ).rejects.not.toThrow(); }); diff --git a/server/src/core/server/services/comments/moderation/moderate.ts b/server/src/core/server/services/comments/moderation/moderate.ts index 2c8cd5a102..a4692664d0 100644 --- a/server/src/core/server/services/comments/moderation/moderate.ts +++ b/server/src/core/server/services/comments/moderation/moderate.ts @@ -39,6 +39,7 @@ export default async function moderate( }; } ) { + /* eslint-disable */ // TODO: wrap these operations in a transaction? const commentsColl = isArchived && mongo.archive ? mongo.archivedComments() : mongo.comments(); @@ -54,7 +55,9 @@ export default async function moderate( } // Get the latest revision on that comment. - const revision = getLatestRevision(comment); + const revision = comment.revisions[comment.revisions.length - 1]; + const foo = getLatestRevision(comment); + console.log({ revision, foo, bar: comment.revisions[0] }); // Ensure that the latest revision is the same revision that we're moderating. if (revision.id !== input.commentRevisionID) { diff --git a/server/src/core/server/test/mocks.ts b/server/src/core/server/test/mocks.ts index f4369105bc..910ccd7a94 100644 --- a/server/src/core/server/test/mocks.ts +++ b/server/src/core/server/test/mocks.ts @@ -11,12 +11,15 @@ const createMockCollection = () => ({ }); export const createMockMongoContex = () => { + const comments = createMockCollection(); const users = createMockCollection(); return { ctx: { + comments: () => comments, users: () => users, } as unknown as MongoContext, + comments, users, }; }; From 9b635648d5ff868d5d44d98820e9d0b99a5235aa Mon Sep 17 00:00:00 2001 From: Marcus Haddon Date: Wed, 4 Oct 2023 14:25:50 -0700 Subject: [PATCH 04/30] WIP fix typo in schema --- server/src/core/server/graph/schema/schema.graphql | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/server/src/core/server/graph/schema/schema.graphql b/server/src/core/server/graph/schema/schema.graphql index dd0f52095a..7e7fabcdb6 100644 --- a/server/src/core/server/graph/schema/schema.graphql +++ b/server/src/core/server/graph/schema/schema.graphql @@ -3313,14 +3313,14 @@ enum REJECTION_REASON_CODE { type RejectionReason { """ - code is the reason that the comment was rejected + code is the enumerated reason for the rejection. """ code: REJECTION_REASON_CODE! """ - metaData is any optional plaintext data added for context + additionalDetails is any additional context in plaintext form. """ - metaData: String + additionalDetails: String } type CommentModerationAction { @@ -6570,11 +6570,6 @@ input RejectCommentInput { clientMutationId is required for Relay support. """ clientMutationId: String! - - """ - reason is the reason the comment is being rejected - """ - reason: RejectionReason! } type RejectCommentPayload { From 2c15a8f9606f34c70ff36d631292c528468d696b Mon Sep 17 00:00:00 2001 From: Marcus Haddon Date: Wed, 4 Oct 2023 16:00:09 -0700 Subject: [PATCH 05/30] get first test passing --- .../models/action/moderation/comment.ts | 4 ++-- .../comments/moderation/moderate.spec.ts | 19 +++++++++--------- .../services/comments/moderation/moderate.ts | 20 +++++++++++++++---- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/server/src/core/server/models/action/moderation/comment.ts b/server/src/core/server/models/action/moderation/comment.ts index a3479e4594..3af42edf66 100644 --- a/server/src/core/server/models/action/moderation/comment.ts +++ b/server/src/core/server/models/action/moderation/comment.ts @@ -12,7 +12,7 @@ import { TenantResource } from "coral-server/models/tenant"; import { GQLCOMMENT_STATUS, - GQLREJECTION_REASON, + GQLREJECTION_REASON_CODE, } from "coral-server/graph/schema/__generated__/types"; /** @@ -50,7 +50,7 @@ export interface CommentModerationAction extends TenantResource { */ rejectionReason?: { code: GQLREJECTION_REASON_CODE; - metaData?: string; + additionalInfo?: string; }; /** diff --git a/server/src/core/server/services/comments/moderation/moderate.spec.ts b/server/src/core/server/services/comments/moderation/moderate.spec.ts index dbdad4035a..12fca3d248 100644 --- a/server/src/core/server/services/comments/moderation/moderate.spec.ts +++ b/server/src/core/server/services/comments/moderation/moderate.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ import { Config } from "coral-server/config"; import { createCommentFixture, @@ -17,12 +16,14 @@ import { GQLUSER_ROLE, } from "coral-server/graph/schema/__generated__/types"; -jest.mock("coral-server/models/comment"); +jest.mock("coral-server/models/comment/comment"); jest.mock("coral-server/stacks/helpers"); jest.mock("coral-server/models/action/moderation/comment"); it("requires a valid rejection reason if dsaFeatures are enabled", async () => { - const tenant = createTenantFixture(); + const tenant = createTenantFixture({ + dsa: { enabled: true }, + }); const config = {} as Config; const story = createStoryFixture({ tenantID: tenant.id }); const comment = createCommentFixture({ storyID: story.id }); @@ -34,23 +35,23 @@ it("requires a valid rejection reason if dsaFeatures are enabled", async () => { const redis = createMockRedis(); /* eslint-disable-next-line */ - require("coral-server/models/comment").retrieveComment.mockImplementation( + require("coral-server/models/comment/comment").retrieveComment.mockImplementation( async () => comment ); /* eslint-disable-next-line */ - require("coral-server/models/comment").updateCommentStatus.mockImplementation( - async () => "TODO" + require("coral-server/models/comment/comment").updateCommentStatus.mockImplementation( + async () => ({}) ); /* eslint-disable-next-line */ require("coral-server/models/action/moderation/comment").createCommentModerationAction.mockImplementation( - async () => "TODO" + async () => ({}) ); /* eslint-disable-next-line */ require("coral-server/stacks/helpers").updateAllCommentCounts.mockImplementation( - async () => "TODO" + async () => ({}) ); const input: Moderate = { @@ -74,5 +75,5 @@ it("requires a valid rejection reason if dsaFeatures are enabled", async () => { actionCounts: {}, // TODO: what should this be? } ) - ).rejects.not.toThrow(); + ).rejects.toThrow(); }); diff --git a/server/src/core/server/services/comments/moderation/moderate.ts b/server/src/core/server/services/comments/moderation/moderate.ts index a4692664d0..6ad31d4ae2 100644 --- a/server/src/core/server/services/comments/moderation/moderate.ts +++ b/server/src/core/server/services/comments/moderation/moderate.ts @@ -1,9 +1,12 @@ +import { ERROR_CODES } from "coral-common/common/lib/errors"; import { Config } from "coral-server/config"; import { MongoContext } from "coral-server/data/context"; import { CommentNotFoundError, CommentRevisionNotFoundError, + OperationForbiddenError, } from "coral-server/errors"; +import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types"; import { EncodedCommentActionCounts } from "coral-server/models/action/comment"; import { createCommentModerationAction, @@ -39,7 +42,18 @@ export default async function moderate( }; } ) { - /* eslint-disable */ + if ( + tenant.dsa.enabled && + input.status === GQLCOMMENT_STATUS.REJECTED && + !input.rejectionReason + ) { + throw new OperationForbiddenError( + ERROR_CODES.VALIDATION, + "DSA features enabled, rejection reason is required", + "comment", + "moderate" + ); + } // TODO: wrap these operations in a transaction? const commentsColl = isArchived && mongo.archive ? mongo.archivedComments() : mongo.comments(); @@ -55,9 +69,7 @@ export default async function moderate( } // Get the latest revision on that comment. - const revision = comment.revisions[comment.revisions.length - 1]; - const foo = getLatestRevision(comment); - console.log({ revision, foo, bar: comment.revisions[0] }); + const revision = getLatestRevision(comment); // Ensure that the latest revision is the same revision that we're moderating. if (revision.id !== input.commentRevisionID) { From 4aa0fef1cb881dd4e200eb549a9b5be1efee31ed Mon Sep 17 00:00:00 2001 From: Marcus Haddon Date: Wed, 11 Oct 2023 14:45:19 -0700 Subject: [PATCH 06/30] scaffolding for frontend TDD --- .../ModerateCard/ModerateCardContainer.tsx | 52 +- .../ModerationReason/ModerationReason.css | 3 + .../ModerationReason/ModerationReason.tsx | 24 + .../admin/test/moderate/regularQueue.spec.tsx | 71 +- .../ModerationActionsContainer.tsx | 69 +- .../__snapshots__/permalinkView.spec.tsx.snap | 2068 +++++++++++++++++ .../test/comments/stream/moderation.spec.tsx | 49 + 7 files changed, 2330 insertions(+), 6 deletions(-) create mode 100644 client/src/core/client/admin/components/ModerationReason/ModerationReason.css create mode 100644 client/src/core/client/admin/components/ModerationReason/ModerationReason.tsx create mode 100644 client/src/core/client/stream/test/comments/permalink/__snapshots__/permalinkView.spec.tsx.snap diff --git a/client/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx b/client/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx index 4497e33b2e..7378e894d1 100644 --- a/client/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx +++ b/client/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx @@ -1,8 +1,11 @@ import { useRouter } from "found"; import React, { FunctionComponent, + Reducer, useCallback, + useEffect, useMemo, + useReducer, useState, } from "react"; import { graphql } from "react-relay"; @@ -32,6 +35,7 @@ import { ModerateCardContainer_viewer } from "coral-admin/__generated__/Moderate import { ModerateCardContainerLocal } from "coral-admin/__generated__/ModerateCardContainerLocal.graphql"; import { UserStatusChangeContainer_viewer } from "coral-admin/__generated__/UserStatusChangeContainer_viewer.graphql"; +import ModerationReason from "../ModerationReason/ModerationReason"; import FeatureCommentMutation from "./FeatureCommentMutation"; import ModerateCard from "./ModerateCard"; import ModeratedByContainer from "./ModeratedByContainer"; @@ -70,6 +74,18 @@ function isFeatured(comment: ModerateCardContainer_comment) { return comment.tags.some((t) => t.code === GQLTAG.FEATURED); } +interface RejectionState { + showModerationReason: boolean; + rejecting: boolean; +} + +type RejectionAction = + | "INDICATE_REJECT" + | "CONFIRM_REASON" + | "REJECTION_COMPLETE"; + +type RejectionReducer = Reducer; + const ModerateCardContainer: FunctionComponent = ({ comment, settings, @@ -94,10 +110,11 @@ const ModerateCardContainer: FunctionComponent = ({ const { match, router } = useRouter(); - const [{ moderationQueueSort }] = + const [{ moderationQueueSort, dsaFeaturesEnabled }] = useLocal(graphql` fragment ModerateCardContainerLocal on Local { moderationQueueSort + dsaFeaturesEnabled } `); @@ -109,6 +126,25 @@ const ModerateCardContainer: FunctionComponent = ({ ); const [showBanModal, setShowBanModal] = useState(false); + const [{ showModerationReason, rejecting }, dispatch] = + useReducer( // TODO use dispatch + (state, action) => { + /* eslint-disable */ + console.log("DISPATCHED", { action, state, dsaFeaturesEnabled }); + switch (action) { + case "INDICATE_REJECT": + return dsaFeaturesEnabled + ? { showModerationReason: true, rejecting: false } + : { showModerationReason: false, rejecting: true }; + case "CONFIRM_REASON": + return { showModerationReason: false, rejecting: true }; + case "REJECTION_COMPLETE": + return { showModerationReason: false, rejecting: false }; + } + }, + { showModerationReason: false, rejecting: false } + ); + const handleApprove = useCallback(async () => { if (!comment.revision) { return; @@ -164,6 +200,9 @@ const ModerateCardContainer: FunctionComponent = ({ section, orderBy: moderationQueueSort, }); + + dispatch("REJECTION_COMPLETE"); + if (loadNext) { loadNext(); } @@ -276,6 +315,12 @@ const ModerateCardContainer: FunctionComponent = ({ const handleBanConfirm = useCallback(() => setShowBanModal(false), []); + useEffect(() => { + if (rejecting) { + void handleReject(); + } + }, [handleReject, rejecting]); + // Only highlight comments that have been flagged for containing a banned or // suspect word. const highlight = useMemo(() => { @@ -297,6 +342,8 @@ const ModerateCardContainer: FunctionComponent = ({ const isRatingsAndReviews = comment.story.settings.mode === GQLSTORY_MODE.RATINGS_AND_REVIEWS; + console.log((dsaFeaturesEnabled && showModerationReason) ? "SHOULD SHOW MODAL" : `"UH OH"- featureEn: ${dsaFeaturesEnabled}, showMod: ${showModerationReason}`); + return ( <> @@ -319,7 +366,7 @@ const ModerateCardContainer: FunctionComponent = ({ featured={isFeatured(comment)} viewContextHref={comment.permalink} onApprove={handleApprove} - onReject={handleReject} + onReject={() => dispatch("INDICATE_REJECT")} onFeature={onFeature} onUsernameClick={onUsernameClicked} onConversationClick={ @@ -378,6 +425,7 @@ const ModerateCardContainer: FunctionComponent = ({ isMultisite={settings.multisite} /> )} + {dsaFeaturesEnabled && } ); }; diff --git a/client/src/core/client/admin/components/ModerationReason/ModerationReason.css b/client/src/core/client/admin/components/ModerationReason/ModerationReason.css new file mode 100644 index 0000000000..bedeb67b49 --- /dev/null +++ b/client/src/core/client/admin/components/ModerationReason/ModerationReason.css @@ -0,0 +1,3 @@ +.root { + +} diff --git a/client/src/core/client/admin/components/ModerationReason/ModerationReason.tsx b/client/src/core/client/admin/components/ModerationReason/ModerationReason.tsx new file mode 100644 index 0000000000..4cb1a6cdd0 --- /dev/null +++ b/client/src/core/client/admin/components/ModerationReason/ModerationReason.tsx @@ -0,0 +1,24 @@ +import React, { FunctionComponent } from "react"; + +import { Card, Modal } from "coral-ui/components/v2"; + +import styles from "./ModerationReason.css"; + +export interface Props { + open: boolean; + commentID: string; +} + +const ModerationReason: FunctionComponent = ({ open, commentID }) => { + /* eslint-disable */ + console.log("Moderation reason", { open }); + return ( + + +

Moderation Reason Modal!

+
+
+ ); +}; + +export default ModerationReason; diff --git a/client/src/core/client/admin/test/moderate/regularQueue.spec.tsx b/client/src/core/client/admin/test/moderate/regularQueue.spec.tsx index 10ec83aff1..f16dd352f3 100644 --- a/client/src/core/client/admin/test/moderate/regularQueue.spec.tsx +++ b/client/src/core/client/admin/test/moderate/regularQueue.spec.tsx @@ -850,7 +850,7 @@ it("doesnt show comments from banned users whose commens have been rejected", as }), }, Mutation: { - banUser: createMutationResolverStub( + rejectComment: createMutationResolverStub( ({ variables }) => { return {}; } @@ -878,3 +878,72 @@ it("doesnt show comments from banned users whose commens have been rejected", as ).toBeNull(); }); }); + +it.only("requires moderation reason when DSA features enabled", async () => { + await createTestRenderer({ + initLocalState(local, source, environment) { + local.setValue(true, "dsaFeaturesEnabled"); + }, + resolvers: createResolversStub({ + Query: { + moderationQueues: () => + pureMerge(emptyModerationQueues, { + reported: { + count: 2, + comments: + createQueryResolverStub( + ({ variables }) => { + expectAndFail(variables).toEqual({ + first: 5, + orderBy: "CREATED_AT_DESC", + }); + return { + edges: [ + { + node: reportedComments[0], + cursor: reportedComments[0].createdAt, + }, + { + node: reportedComments[1], + cursor: reportedComments[1].createdAt, + }, + ], + pageInfo: { + endCursor: reportedComments[1].createdAt, + hasNextPage: false, + }, + }; + } + ) as any, + }, + }), + }, + Mutation: { + rejectComment: createMutationResolverStub( + ({ variables }) => { + return {}; + } + ), + }, + }), + }); + + const modCard = await screen.findByTestId( + `moderate-comment-card-${reportedComments[0].id}` + ); + + expect(modCard).toBeInTheDocument(); + + const rejectButton = within(modCard).getByLabelText("Reject"); + expect(rejectButton).toBeVisible(); + // click it + act(() => { + userEvent.click(rejectButton); + }); + + await waitFor(() => { + expect(screen.queryByTestId("moderation-reason-modal")).toBeVisible(); + }); + + // BOOKMARK: continue here +}); diff --git a/client/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.tsx b/client/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.tsx index 956c172d3b..de0e6494c6 100644 --- a/client/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.tsx +++ b/client/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.tsx @@ -1,6 +1,14 @@ +/* eslint-disable */ import { Localized } from "@fluent/react/compat"; import cn from "classnames"; -import React, { FunctionComponent, useCallback, useMemo } from "react"; +import React, { + FunctionComponent, + Reducer, + useCallback, + useEffect, + useMemo, + useReducer, +} from "react"; import { graphql } from "react-relay"; import { useModerationLink } from "coral-framework/hooks"; @@ -21,11 +29,14 @@ import { } from "coral-ui/components/icons"; import { DropdownButton, DropdownDivider } from "coral-ui/components/v2"; +import ModerationReason from "coral-admin/components/ModerationReason/ModerationReason"; + import { ModerationActionsContainer_comment } from "coral-stream/__generated__/ModerationActionsContainer_comment.graphql"; import { ModerationActionsContainer_local } from "coral-stream/__generated__/ModerationActionsContainer_local.graphql"; import { ModerationActionsContainer_settings } from "coral-stream/__generated__/ModerationActionsContainer_settings.graphql"; import { ModerationActionsContainer_story } from "coral-stream/__generated__/ModerationActionsContainer_story.graphql"; import { ModerationActionsContainer_viewer } from "coral-stream/__generated__/ModerationActionsContainer_viewer.graphql"; +import { ModerationActionsContainerLocal } from "coral-stream/__generated__/ModerationActionsContainerLocal.graphql"; import ApproveCommentMutation from "./ApproveCommentMutation"; import CopyCommentEmbedCodeContainer from "./CopyCommentEmbedCodeContainer"; @@ -46,6 +57,18 @@ interface Props { onSiteBan: () => void; } +interface RejectState { + showModerationReason: boolean; + rejecting: boolean; +} + +type RejectAction = + | "INDICATE_REJECT" + | "CONFIRM_REJECT_REASON" + | "REJECT_COMPLETE"; + +type RejectionReducer = Reducer; + const ModerationActionsContainer: FunctionComponent = ({ comment, story, @@ -70,6 +93,34 @@ const ModerationActionsContainer: FunctionComponent = ({ const linkModerateStory = useModerationLink({ storyID: story.id }); const linkModerateComment = useModerationLink({ commentID: comment.id }); + const [{ dsaFeaturesEnabled }] = + useLocal(graphql` + fragment ModerationActionsContainerLocal on Local { + dsaFeaturesEnabled + } + `); + + const [{ showModerationReason, rejecting }, dispatch] = + useReducer( + (state, action) => { + console.log("an action was dispatched", action); + switch (action) { + case "REJECT_COMPLETE": + return { rejecting: false, showModerationReason: false }; + case "CONFIRM_REJECT_REASON": + return { rejecting: true, showModerationReason: false }; + case "INDICATE_REJECT": + return dsaFeaturesEnabled + ? { showModerationReason: true, rejecting: false } + : { showModerationReason: false, rejecting: true }; + } + }, + { + showModerationReason: false, + rejecting: false, + } + ); + const moderationLinkSuffix = !!accessToken && settings.auth.integrations.sso.enabled && @@ -116,7 +167,7 @@ const ModerationActionsContainer: FunctionComponent = ({ commentRevisionID: comment.revision.id, storyID: story.id, }); - }, [approve, comment, story]); + }, [comment, story, reject]); const onFeature = useCallback(() => { if (!comment.revision) { return; @@ -135,6 +186,13 @@ const ModerationActionsContainer: FunctionComponent = ({ }); onDismiss(); }, [unfeature, onDismiss, story, comment]); + + useEffect(() => { + if (rejecting) { + void onReject(); + } + }, [rejecting, onReject]); + const approved = comment.status === "APPROVED"; const rejected = comment.status === "REJECTED"; const featured = comment.tags.some((t) => t.code === "FEATURED"); @@ -146,6 +204,10 @@ const ModerationActionsContainer: FunctionComponent = ({ const showCopyCommentEmbed = !!comment.body; + + console.log("SHOWING MODAL?", + JSON.stringify({ dsaFeaturesEnabled, showModerationReason }, null, 2)); + return ( <> {featured ? ( @@ -245,7 +307,7 @@ const ModerationActionsContainer: FunctionComponent = ({ } - onClick={onReject} + onClick={() => dispatch("INDICATE_REJECT")} className={cn( styles.label, CLASSES.moderationDropdown.rejectButton @@ -307,6 +369,7 @@ const ModerationActionsContainer: FunctionComponent = ({ story={story} /> )} + {dsaFeaturesEnabled && } ); }; diff --git a/client/src/core/client/stream/test/comments/permalink/__snapshots__/permalinkView.spec.tsx.snap b/client/src/core/client/stream/test/comments/permalink/__snapshots__/permalinkView.spec.tsx.snap new file mode 100644 index 0000000000..01cbedc520 --- /dev/null +++ b/client/src/core/client/stream/test/comments/permalink/__snapshots__/permalinkView.spec.tsx.snap @@ -0,0 +1,2068 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders permalink view 1`] = ` +
+
+
+ + Join the conversation + +
+ + +
+
+
+
+
+ You are currently viewing a single conversation +
+ + View full discussion + +
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
+ + Ancestor: + + + + Comment from Lukas + + +
+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+ + +
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
+ + Ancestor: + + + + Comment from Isabelle + + +
+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+ + +
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
+ + Highlighted: + + + + Comment from Markus + + +
+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+ + +
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + Thread Level 1: + + + + Reply from Isabelle + + +
+
+
+
+
+ +
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+ + +
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + Thread Level 1: + + + + Reply from Site Moderator 2 + + +
+
+
+
+
+ +
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+ + +
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/client/src/core/client/stream/test/comments/stream/moderation.spec.tsx b/client/src/core/client/stream/test/comments/stream/moderation.spec.tsx index 3b375a1def..95aa9a37ca 100644 --- a/client/src/core/client/stream/test/comments/stream/moderation.spec.tsx +++ b/client/src/core/client/stream/test/comments/stream/moderation.spec.tsx @@ -639,9 +639,58 @@ it("can copy comment embed code", async () => { const embedCodeButton = within(comment).getByRole("button", { name: "Embed code", }); + fireEvent.click(embedCodeButton); expect( within(comment).getByRole("button", { name: "Code copied" }) ).toBeDefined(); window.prompt = jsdomPrompt; }); + +it.only("requires rection reason when dsaFeaturesEnabled", async () => { + await act(async () => { + await createTestRenderer({ + resolvers: createResolversStub({ + Mutation: { + rejectComment: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + commentID: firstComment.id, + commentRevisionID: firstComment.revision!.id, + }); + return { + comment: pureMerge(firstComment, { + status: GQLCOMMENT_STATUS.REJECTED, + }), + }; + }, + }, + }), + initLocalState(local, source, environment) { + local.setValue(true, "dsaFeaturesEnabled"); + }, + }); + }); + // const tabPane = await screen.findByTestId("current-tab-pane"); + const comment = screen.getByTestId(`comment-${firstComment.id}`); + + const caretButton = within(comment).getByLabelText("Moderate"); + + // THis isnt opening the thing!?!? + await act(async () => userEvent.click(caretButton)); + + const rejectButton = within(comment).getByRole("button", { name: "Reject" }); + + await act(async () => { + userEvent.click(rejectButton); + }); + + await waitFor(() => { + expect(screen.queryByTestId("moderation-reason-modal")).toBeInTheDocument(); + }); + + const reasonModal = await within(comment).findByTestId( + "moderation-reason-modal" + ); + + expect(reasonModal).toBeInTheDocument(); +}); From 769e78b37045fd30adc8872574518b4c4e2a7007 Mon Sep 17 00:00:00 2001 From: Marcus Haddon Date: Wed, 11 Oct 2023 15:27:54 -0700 Subject: [PATCH 07/30] revert to fireEvent --- .../core/client/stream/test/comments/stream/moderation.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/core/client/stream/test/comments/stream/moderation.spec.tsx b/client/src/core/client/stream/test/comments/stream/moderation.spec.tsx index 95aa9a37ca..94e15ebb59 100644 --- a/client/src/core/client/stream/test/comments/stream/moderation.spec.tsx +++ b/client/src/core/client/stream/test/comments/stream/moderation.spec.tsx @@ -681,7 +681,7 @@ it.only("requires rection reason when dsaFeaturesEnabled", async () => { const rejectButton = within(comment).getByRole("button", { name: "Reject" }); await act(async () => { - userEvent.click(rejectButton); + fireEvent.click(rejectButton); }); await waitFor(() => { From 887de272d68cebde927706893af59da4e8317713 Mon Sep 17 00:00:00 2001 From: Marcus Haddon Date: Thu, 12 Oct 2023 14:11:12 -0700 Subject: [PATCH 08/30] send reason and persist when rejecting comment --- .../ModerateCard/ModerateCardContainer.tsx | 63 +++++++++++-------- .../ModerationReason/ModerationReason.tsx | 60 +++++++++++++++--- .../admin/mutations/RejectCommentMutation.ts | 1 + .../ModerationActionsContainer.tsx | 2 +- .../src/core/server/graph/mutators/Actions.ts | 4 +- .../core/server/graph/schema/schema.graphql | 11 ++++ .../src/core/server/stacks/rejectComment.ts | 3 + 7 files changed, 107 insertions(+), 37 deletions(-) diff --git a/client/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx b/client/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx index 7378e894d1..a4f4ef02b1 100644 --- a/client/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx +++ b/client/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx @@ -35,6 +35,7 @@ import { ModerateCardContainer_viewer } from "coral-admin/__generated__/Moderate import { ModerateCardContainerLocal } from "coral-admin/__generated__/ModerateCardContainerLocal.graphql"; import { UserStatusChangeContainer_viewer } from "coral-admin/__generated__/UserStatusChangeContainer_viewer.graphql"; +import { RejectCommentInput } from "coral-stream/__generated__/RejectCommentMutation.graphql"; import ModerationReason from "../ModerationReason/ModerationReason"; import FeatureCommentMutation from "./FeatureCommentMutation"; import ModerateCard from "./ModerateCard"; @@ -77,12 +78,13 @@ function isFeatured(comment: ModerateCardContainer_comment) { interface RejectionState { showModerationReason: boolean; rejecting: boolean; + reason?: RejectCommentInput["reason"]; } type RejectionAction = - | "INDICATE_REJECT" - | "CONFIRM_REASON" - | "REJECTION_COMPLETE"; + | { action: "INDICATE_REJECT" } + | { action: "CONFIRM_REASON"; reason: RejectCommentInput["reason"] } + | { action: "REJECTION_COMPLETE" }; type RejectionReducer = Reducer; @@ -126,24 +128,28 @@ const ModerateCardContainer: FunctionComponent = ({ ); const [showBanModal, setShowBanModal] = useState(false); - const [{ showModerationReason, rejecting }, dispatch] = - useReducer( // TODO use dispatch - (state, action) => { - /* eslint-disable */ - console.log("DISPATCHED", { action, state, dsaFeaturesEnabled }); - switch (action) { - case "INDICATE_REJECT": - return dsaFeaturesEnabled - ? { showModerationReason: true, rejecting: false } - : { showModerationReason: false, rejecting: true }; - case "CONFIRM_REASON": - return { showModerationReason: false, rejecting: true }; - case "REJECTION_COMPLETE": - return { showModerationReason: false, rejecting: false }; - } - }, - { showModerationReason: false, rejecting: false } - ); + const [ + { showModerationReason, rejecting, reason: rejectionReason }, + dispatch, + ] = useReducer( // TODO use dispatch + (state, input) => { + switch (input.action) { + case "INDICATE_REJECT": + return dsaFeaturesEnabled + ? { showModerationReason: true, rejecting: false } + : { showModerationReason: false, rejecting: true }; + case "CONFIRM_REASON": + return { + showModerationReason: false, + rejecting: true, + reason: input.reason, + }; + case "REJECTION_COMPLETE": + return { showModerationReason: false, rejecting: false }; + } + }, + { showModerationReason: false, rejecting: false } + ); const handleApprove = useCallback(async () => { if (!comment.revision) { @@ -195,13 +201,14 @@ const ModerateCardContainer: FunctionComponent = ({ await rejectComment({ commentID: comment.id, commentRevisionID: comment.revision.id, + reason: rejectionReason, storyID, siteID, section, orderBy: moderationQueueSort, }); - dispatch("REJECTION_COMPLETE"); + dispatch({ action: "REJECTION_COMPLETE" }); if (loadNext) { loadNext(); @@ -218,6 +225,7 @@ const ModerateCardContainer: FunctionComponent = ({ loadNext, moderationQueueSort, onModerated, + rejectionReason, ]); const handleFeature = useCallback(() => { @@ -342,8 +350,6 @@ const ModerateCardContainer: FunctionComponent = ({ const isRatingsAndReviews = comment.story.settings.mode === GQLSTORY_MODE.RATINGS_AND_REVIEWS; - console.log((dsaFeaturesEnabled && showModerationReason) ? "SHOULD SHOW MODAL" : `"UH OH"- featureEn: ${dsaFeaturesEnabled}, showMod: ${showModerationReason}`); - return ( <> @@ -366,7 +372,7 @@ const ModerateCardContainer: FunctionComponent = ({ featured={isFeatured(comment)} viewContextHref={comment.permalink} onApprove={handleApprove} - onReject={() => dispatch("INDICATE_REJECT")} + onReject={() => dispatch({ action: "INDICATE_REJECT" })} onFeature={onFeature} onUsernameClick={onUsernameClicked} onConversationClick={ @@ -425,7 +431,12 @@ const ModerateCardContainer: FunctionComponent = ({ isMultisite={settings.multisite} /> )} - {dsaFeaturesEnabled && } + {dsaFeaturesEnabled && ( + dispatch({ action: "CONFIRM_REASON", reason })} + /> + )} ); }; diff --git a/client/src/core/client/admin/components/ModerationReason/ModerationReason.tsx b/client/src/core/client/admin/components/ModerationReason/ModerationReason.tsx index 4cb1a6cdd0..9fdbee9365 100644 --- a/client/src/core/client/admin/components/ModerationReason/ModerationReason.tsx +++ b/client/src/core/client/admin/components/ModerationReason/ModerationReason.tsx @@ -1,21 +1,65 @@ -import React, { FunctionComponent } from "react"; +import React, { FunctionComponent, useCallback, useState } from "react"; -import { Card, Modal } from "coral-ui/components/v2"; +import { Button, Card, Modal, RadioButton } from "coral-ui/components/v2"; +import { RejectCommentReasonInput } from "coral-stream/__generated__/RejectCommentMutation.graphql"; + +import { GQLREJECTION_REASON_CODE } from "coral-framework/schema"; +import { TextArea } from "coral-ui/components/v3"; import styles from "./ModerationReason.css"; +type Reason = RejectCommentReasonInput; +type ReasonCode = GQLREJECTION_REASON_CODE; + export interface Props { open: boolean; - commentID: string; + onReason: (reason: Reason) => void; } -const ModerationReason: FunctionComponent = ({ open, commentID }) => { - /* eslint-disable */ - console.log("Moderation reason", { open }); +const ModerationReason: FunctionComponent = ({ open, onReason }) => { + const [reasonCode, setReasonCode] = useState(null); + const [info, setInfo] = useState(null); + + const submitReason = useCallback(() => { + onReason({ + code: reasonCode!, + additionalInfo: + reasonCode === GQLREJECTION_REASON_CODE.OTHER ? info : undefined, + }); + }, [reasonCode, info]); + return ( - -

Moderation Reason Modal!

+ +

Temp Moderation Reason Modal

+ {Object.values(GQLREJECTION_REASON_CODE) + .filter((code) => code !== GQLREJECTION_REASON_CODE.ILLEGAL_CONTENT) + .map((code) => ( + { + if (e.target.checked) { + setReasonCode(code); + } + }} + > + {code} + + ))} + + {reasonCode === GQLREJECTION_REASON_CODE.OTHER && ( +