From 3a8231647517f027e352afde3d39480227383207 Mon Sep 17 00:00:00 2001 From: Kathryn Beaty Date: Wed, 5 Jul 2023 15:49:27 -0400 Subject: [PATCH 01/18] initial spam ban changes --- .../ModerationActionBanButton.tsx | 4 +- .../ModerationActionBanContainer.tsx | 15 +- .../ModerationDropdownContainer.tsx | 3 + .../UserBanPopoverContainer.css | 30 +++- .../UserBanPopoverContainer.tsx | 136 +++++++++++++++--- src/locales/en-US/stream.ftl | 13 +- 6 files changed, 173 insertions(+), 28 deletions(-) diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanButton.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanButton.tsx index 9a6e098478..becde7287e 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanButton.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanButton.tsx @@ -20,9 +20,9 @@ const ModerationActionBanButton: FunctionComponent = ({ showSpinner = false, }) => { const localizationId = allSiteBan - ? "comments-moderationDropdown-ban" + ? "comments-moderationDropdown-spam-ban" : "comments-moderationDropdown-siteBan"; - const defaultText = allSiteBan ? "Ban User" : "Site Ban"; + const defaultText = allSiteBan ? "Spam ban" : "Site ban"; return ( = ({ {settings?.multisite ? ( <> - - {!viewerScoped && ( + {viewerScoped ? ( + + ) : ( = ({ }) => { const emitShowEvent = useViewerEvent(ShowModerationPopoverEvent); const [view, setView] = useState("MODERATE"); + const onBan = useCallback(() => { setView("BAN"); scheduleUpdate(); @@ -72,6 +73,7 @@ const ModerationDropdownContainer: FunctionComponent = ({ ) : ( ({ settings: graphql` fragment ModerationDropdownContainer_settings on Settings { ...ModerationActionsContainer_settings + ...UserBanPopoverContainer_settings } `, viewer: graphql` diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css index 673140bf86..b5400ecf1b 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css @@ -10,8 +10,36 @@ line-height: 1; color: var(--palette-text-500); +} + +.header { + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-bold); + font-size: var(--font-size-2); + color: var(--palette-text-500); + margin-bottom: var(--spacing-1); + margin-top: var(--spacing-3); +} - margin-bottom: var(--spacing-3); +.orderedList { + margin: 0; + padding-left: 16px; +} + +.callOut { + padding: var(--spacing-1); + font-weight: var(--font-weight-primary-semi-bold); + margin-top: var(--spacing-3); + font-size: var(--font-size-1); +} + +.confirmationInput { + box-sizing: border-box; + font-family: var(--font-family-primary); + font-size: var(--font-size-2); + line-height: 2.25; + padding-left: var(--spacing-2); + width: 100%; } .description { diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx index 6c356c0c68..84d3449954 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx @@ -1,15 +1,27 @@ import { Localized } from "@fluent/react/compat"; import cn from "classnames"; -import React, { FunctionComponent, useCallback } from "react"; +import React, { + FunctionComponent, + useCallback, + useMemo, + useState, +} from "react"; import { graphql } from "react-relay"; +import { useModerationLink } from "coral-framework/hooks"; import { useCoralContext } from "coral-framework/lib/bootstrap"; import { getMessage } from "coral-framework/lib/i18n"; -import { useMutation, withFragmentContainer } from "coral-framework/lib/relay"; +import { + useLocal, + useMutation, + withFragmentContainer, +} from "coral-framework/lib/relay"; import CLASSES from "coral-stream/classes"; -import { Box, Button, Flex } from "coral-ui/components/v2"; +import { Box, Button, CallOut, Divider, Flex } from "coral-ui/components/v2"; import { UserBanPopoverContainer_comment } from "coral-stream/__generated__/UserBanPopoverContainer_comment.graphql"; +import { UserBanPopoverContainer_local } from "coral-stream/__generated__/UserBanPopoverContainer_local.graphql"; +import { UserBanPopoverContainer_settings } from "coral-stream/__generated__/UserBanPopoverContainer_settings.graphql"; import { UserBanPopoverContainer_story } from "coral-stream/__generated__/UserBanPopoverContainer_story.graphql"; import RejectCommentMutation from "../ModerationDropdown/RejectCommentMutation"; @@ -20,27 +32,52 @@ import styles from "./UserBanPopoverContainer.css"; interface Props { onDismiss: () => void; comment: UserBanPopoverContainer_comment; + settings: UserBanPopoverContainer_settings; story: UserBanPopoverContainer_story; siteBan: boolean; } const UserBanPopoverContainer: FunctionComponent = ({ comment, + settings, story, onDismiss, siteBan, }) => { + const [{ accessToken }] = useLocal(graphql` + fragment UserBanPopoverContainer_local on Local { + accessToken + } + `); + const user = comment.author!; const rejected = comment.status === "REJECTED"; const reject = useMutation(RejectCommentMutation); const banUser = useMutation(BanUserMutation); const { localeBundles } = useCoralContext(); + const [spamBanConfirmation, setSpamBanConfirmation] = useState(""); + + const linkModerateComment = useModerationLink({ commentID: comment.id }); + const moderationLinkSuffix = + !!accessToken && + settings.auth.integrations.sso.enabled && + settings.auth.integrations.sso.targetFilter.admin && + `#accessToken=${accessToken}`; + + const gotoModerateCommentHref = useMemo(() => { + let ret = linkModerateComment; + if (moderationLinkSuffix) { + ret += moderationLinkSuffix; + } + + return ret; + }, [linkModerateComment, moderationLinkSuffix]); const onBan = useCallback(() => { void banUser({ userID: user.id, commentID: comment.id, - rejectExistingComments: false, + rejectExistingComments: siteBan ? false : true, message: getMessage( localeBundles, "common-banEmailTemplate", @@ -85,19 +122,49 @@ const UserBanPopoverContainer: FunctionComponent = ({ ) : ( - -
Ban {user.username}?
-
+ <> + +
Spam ban
+
+ +
Username
+
+
{user.username}
+ +
Spam ban will
+
+
+
    + +
  1. Ban this account from the comments
  2. +
    + +
  3. Reject all comments written by this account
  4. +
    +
+
+ + + {/* TODO: Add icon */} + Only for use on obvious spam accounts + + + +
Type in "spam ban" to confirm
+
+ setSpamBanConfirmation(e.target.value)} + > + )} - - - Once banned, this user will no longer be able to comment, use - reactions, or report comments. - - = ({ + {!siteBan && ( + <> + + + + For more context, go to{" "} + + + + + )} ); }; @@ -153,6 +243,20 @@ const enhanced = withFragmentContainer({ } } `, + settings: graphql` + fragment UserBanPopoverContainer_settings on Settings { + auth { + integrations { + sso { + enabled + targetFilter { + admin + } + } + } + } + } + `, })(UserBanPopoverContainer); export default enhanced; diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 1ad3213f22..ebccb1895c 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -255,6 +255,14 @@ comments-userIgnorePopover-description = comments-userIgnorePopover-ignore = Ignore comments-userIgnorePopover-cancel = Cancel +comments-userSpamBanPopover-title = Spam ban +comments-userSpamBanPopover-header-username = Username +comments-userSpamBanPopover-header-description = Spam ban will +comments-userSpamBanPopover-callout = Only for use on obvious spam accounts +comments-userSpamBanPopover-description-list-banFromComments = Ban this account from the comments +comments-userSpamBanPopover-description-list-rejectAllComments = Reject all comments written by this account +comments-userSpamBanPopover-confirmation = Type in "spam ban" to confirm + comments-userBanPopover-title = Ban {$username}? comments-userSiteBanPopover-title = Ban {$username} from this site? comments-userBanPopover-description = @@ -272,8 +280,9 @@ comments-moderationDropdown-approve = Approve comments-moderationDropdown-approved = Approved comments-moderationDropdown-reject = Reject comments-moderationDropdown-rejected = Rejected -comments-moderationDropdown-ban = Ban User -comments-moderationDropdown-siteBan = Site Ban +comments-moderationDropdown-spam-ban = Spam ban +comments-moderationDropdown-ban = Ban user +comments-moderationDropdown-siteBan = Site ban comments-moderationDropdown-banned = Banned comments-moderationDropdown-goToModerate = comments-moderationDropdown-moderationView = Moderation view From 13b056dd23a7d1e1c515f9b1865621bc9a7ceab2 Mon Sep 17 00:00:00 2001 From: Kathryn Beaty Date: Mon, 10 Jul 2023 11:54:01 -0400 Subject: [PATCH 02/18] add spam ban confirm view --- src/core/client/stream/local/local.graphql | 4 + .../Comments/Comment/CommentContainer.tsx | 4 + .../ModerationDropdown/CaretContainer.tsx | 4 + .../ModerationActionBanContainer.tsx | 18 +- .../ModerationActionsContainer.tsx | 1 + .../ModerationDropdownContainer.tsx | 9 +- .../ModerationRejectedTombstoneContainer.css | 5 + .../ModerationRejectedTombstoneContainer.tsx | 74 +++- .../RejectCommentMutation.ts | 9 +- .../UserBanPopoverContainer.css | 11 + .../UserBanPopoverContainer.tsx | 325 ++++++++++++------ .../tabs/Comments/Comment/setSpamBanned.ts | 18 + src/core/server/services/users/users.ts | 12 +- 13 files changed, 350 insertions(+), 144 deletions(-) create mode 100644 src/core/client/stream/tabs/Comments/Comment/setSpamBanned.ts diff --git a/src/core/client/stream/local/local.graphql b/src/core/client/stream/local/local.graphql index 8bf34e1830..b734e14b93 100644 --- a/src/core/client/stream/local/local.graphql +++ b/src/core/client/stream/local/local.graphql @@ -64,6 +64,10 @@ extend type Comment { # Remember last viewer action that could have caused a status change. lastViewerAction: COMMENT_VIEWER_ACTION + # If the comment was spam banned and rejected. Used for showing spam banned + # confirmation in moderation rejected tombstone container. + spamBanned: Boolean + # If true then Comment came in live. enteredLive: Boolean diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx index acbaa157ec..93e9c24a78 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx @@ -399,6 +399,8 @@ export const CommentContainer: FunctionComponent = ({ ); } @@ -805,6 +807,7 @@ const enhanced = withShowAuthPopupMutation( ...ReportFlowContainer_viewer ...ReportButton_viewer ...CaretContainer_viewer + ...ModerationRejectedTombstoneContainer_viewer } `, story: graphql` @@ -826,6 +829,7 @@ const enhanced = withShowAuthPopupMutation( ...PermalinkButtonContainer_story ...ReplyCommentFormContainer_story ...UserTagsContainer_story + ...ModerationRejectedTombstoneContainer_story } `, comment: graphql` diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx index 02c6b490e3..7deea26ad9 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx @@ -26,6 +26,8 @@ interface Props { story: CaretContainer_story; viewer: CaretContainer_viewer; settings: CaretContainer_settings; + view?: "MODERATE" | "BAN" | "SITE_BAN" | "CONFIRM_BAN"; + open?: boolean; } const CaretContainer: FunctionComponent = (props) => { @@ -37,6 +39,7 @@ const CaretContainer: FunctionComponent = (props) => { > ( @@ -48,6 +51,7 @@ const CaretContainer: FunctionComponent = (props) => { settings={props.settings} onDismiss={toggleVisibility} scheduleUpdate={scheduleUpdate} + view={props.view} /> )} diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.tsx index 9bfdc362e4..550c8528e7 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.tsx @@ -59,26 +59,26 @@ const ModerationActionBanContainer: FunctionComponent = ({ {settings?.multisite ? ( <> - {viewerScoped ? ( + {viewerScoped && ( - ) : ( - )} + + ) : ( <> = ({ commentID: comment.id, commentRevisionID: comment.revision.id, storyID: story.id, + spamBan: false, }); }, [approve, comment, story]); const onFeature = useCallback(() => { diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx index c03465459a..08ea9127da 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx @@ -20,7 +20,7 @@ import { ModerationDropdownContainer_viewer } from "coral-stream/__generated__/M import UserBanPopoverContainer from "../UserBanPopover/UserBanPopoverContainer"; import ModerationActionsContainer from "./ModerationActionsContainer"; -type View = "MODERATE" | "BAN" | "SITE_BAN"; +type View = "MODERATE" | "BAN" | "SITE_BAN" | "CONFIRM_BAN"; interface Props { comment: ModerationDropdownContainer_comment; @@ -29,6 +29,7 @@ interface Props { settings: ModerationDropdownContainer_settings; onDismiss: () => void; scheduleUpdate: () => void; + view?: View; } const ModerationDropdownContainer: FunctionComponent = ({ @@ -38,9 +39,10 @@ const ModerationDropdownContainer: FunctionComponent = ({ settings, onDismiss, scheduleUpdate, + view: viewProp, }) => { const emitShowEvent = useViewerEvent(ShowModerationPopoverEvent); - const [view, setView] = useState("MODERATE"); + const [view, setView] = useState(viewProp ?? "MODERATE"); const onBan = useCallback(() => { setView("BAN"); @@ -75,8 +77,10 @@ const ModerationDropdownContainer: FunctionComponent = ({ comment={comment} settings={settings} story={story} + viewer={viewer} onDismiss={onDismiss} siteBan={view === "SITE_BAN"} + view={view} /> )} @@ -118,6 +122,7 @@ const enhanced = withFragmentContainer({ viewer: graphql` fragment ModerationDropdownContainer_viewer on User { ...ModerationActionsContainer_viewer + ...UserBanPopoverContainer_viewer } `, })(ModerationDropdownContainer); diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.css b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.css index c6d47a18f8..5baeae9985 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.css +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.css @@ -1,3 +1,8 @@ .icon { margin-left: var(--spacing-1); } + +.rejectedContainer { + margin-right: auto; + width: 100%; +} diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.tsx index 498183763d..fca67cd515 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.tsx @@ -11,19 +11,26 @@ import { Button, Tombstone } from "coral-ui/components/v3"; import { ModerationRejectedTombstoneContainer_comment as CommentData } from "coral-stream/__generated__/ModerationRejectedTombstoneContainer_comment.graphql"; import { ModerationRejectedTombstoneContainer_local } from "coral-stream/__generated__/ModerationRejectedTombstoneContainer_local.graphql"; import { ModerationRejectedTombstoneContainer_settings as SettingsData } from "coral-stream/__generated__/ModerationRejectedTombstoneContainer_settings.graphql"; +import { ModerationRejectedTombstoneContainer_story as StoryData } from "coral-stream/__generated__/ModerationRejectedTombstoneContainer_story.graphql"; +import { ModerationRejectedTombstoneContainer_viewer as ViewerData } from "coral-stream/__generated__/ModerationRejectedTombstoneContainer_viewer.graphql"; import computeCommentElementID from "../computeCommentElementID"; +import CaretContainer from "./CaretContainer"; import styles from "./ModerationRejectedTombstoneContainer.css"; interface Props { comment: CommentData; settings: SettingsData; + story: StoryData; + viewer: ViewerData; } const ModerationRejectedTombstoneContainer: FunctionComponent = ({ comment, settings, + story, + viewer, }) => { const [{ accessToken }] = useLocal(graphql` @@ -55,26 +62,44 @@ const ModerationRejectedTombstoneContainer: FunctionComponent = ({ fullWidth noBottomBorder > - -
You have rejected this comment.
-
- - + {comment.spamBanned && ( + + + + )} + ); }; @@ -83,6 +108,8 @@ const enhanced = withFragmentContainer({ comment: graphql` fragment ModerationRejectedTombstoneContainer_comment on Comment { id + spamBanned + ...CaretContainer_comment } `, settings: graphql` @@ -97,6 +124,17 @@ const enhanced = withFragmentContainer({ } } } + ...CaretContainer_settings + } + `, + viewer: graphql` + fragment ModerationRejectedTombstoneContainer_viewer on User { + ...CaretContainer_viewer + } + `, + story: graphql` + fragment ModerationRejectedTombstoneContainer_story on Story { + ...CaretContainer_story } `, })(ModerationRejectedTombstoneContainer); diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/RejectCommentMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/RejectCommentMutation.ts index d71d6f09be..fcaed222c3 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/RejectCommentMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/RejectCommentMutation.ts @@ -19,7 +19,11 @@ const RejectCommentMutation = createMutation( "rejectComment", async ( environment: Environment, - input: MutationInput & { storyID: string; noEmit?: boolean }, + input: MutationInput & { + storyID: string; + noEmit?: boolean; + spamBan: boolean; + }, { eventEmitter }: CoralContext ) => { let rejectCommentEvent: ReturnType | null = @@ -92,6 +96,9 @@ const RejectCommentMutation = createMutation( .getRootField("rejectComment")! .getLinkedRecord("comment")!; comment.setValue("REJECT", "lastViewerAction"); + if (input.spamBan) { + comment.setValue(true, "spamBanned"); + } }, } ); diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css index b5400ecf1b..b5fdbca2f6 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css @@ -1,6 +1,7 @@ .root { width: 280px; max-width: 80vw; + text-align: left; } .title { @@ -54,3 +55,13 @@ .actions { padding-top: var(--spacing-3); } + +.link { + display: inline; + vertical-align: baseline; + white-space: break-spaces; +} + +.container { + margin-top: var(--spacing-2); +} diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx index 84d3449954..684aa481bb 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx @@ -23,8 +23,10 @@ import { UserBanPopoverContainer_comment } from "coral-stream/__generated__/User import { UserBanPopoverContainer_local } from "coral-stream/__generated__/UserBanPopoverContainer_local.graphql"; import { UserBanPopoverContainer_settings } from "coral-stream/__generated__/UserBanPopoverContainer_settings.graphql"; import { UserBanPopoverContainer_story } from "coral-stream/__generated__/UserBanPopoverContainer_story.graphql"; +import { UserBanPopoverContainer_viewer } from "coral-stream/__generated__/UserBanPopoverContainer_viewer.graphql"; import RejectCommentMutation from "../ModerationDropdown/RejectCommentMutation"; +import { SetSpamBanned } from "../setSpamBanned"; import BanUserMutation from "./BanUserMutation"; import styles from "./UserBanPopoverContainer.css"; @@ -34,31 +36,40 @@ interface Props { comment: UserBanPopoverContainer_comment; settings: UserBanPopoverContainer_settings; story: UserBanPopoverContainer_story; + viewer: UserBanPopoverContainer_viewer; siteBan: boolean; + view: "MODERATE" | "BAN" | "SITE_BAN" | "CONFIRM_BAN"; } const UserBanPopoverContainer: FunctionComponent = ({ comment, settings, story, + viewer, onDismiss, siteBan, + view, }) => { const [{ accessToken }] = useLocal(graphql` fragment UserBanPopoverContainer_local on Local { accessToken } `); + const setSpamBanned = useMutation(SetSpamBanned); const user = comment.author!; + const viewerScoped = + viewer?.moderationScopes && viewer.moderationScopes.scoped; const rejected = comment.status === "REJECTED"; const reject = useMutation(RejectCommentMutation); const banUser = useMutation(BanUserMutation); - const { localeBundles } = useCoralContext(); + const { localeBundles, rootURL } = useCoralContext(); const [spamBanConfirmation, setSpamBanConfirmation] = useState(""); const linkModerateComment = useModerationLink({ commentID: comment.id }); - const moderationLinkSuffix = + const linkCommunitySection = rootURL + "/admin/community"; + + const adminLinkSuffix = !!accessToken && settings.auth.integrations.sso.enabled && settings.auth.integrations.sso.targetFilter.admin && @@ -66,36 +77,51 @@ const UserBanPopoverContainer: FunctionComponent = ({ const gotoModerateCommentHref = useMemo(() => { let ret = linkModerateComment; - if (moderationLinkSuffix) { - ret += moderationLinkSuffix; + if (adminLinkSuffix) { + ret += adminLinkSuffix; } return ret; - }, [linkModerateComment, moderationLinkSuffix]); + }, [linkModerateComment, adminLinkSuffix]); + + const gotoCommunitySectionHref = useMemo(() => { + let ret = linkCommunitySection; + if (adminLinkSuffix) { + ret += adminLinkSuffix; + } - const onBan = useCallback(() => { - void banUser({ - userID: user.id, - commentID: comment.id, - rejectExistingComments: siteBan ? false : true, - message: getMessage( - localeBundles, - "common-banEmailTemplate", - "Someone with access to your account has violated our community guidelines. As a result, your account has been banned. You will no longer be able to comment, react or report comments", - { username: user.username } - ), - siteIDs: siteBan ? [story.site.id] : [], - }); + return ret; + }, [linkCommunitySection, adminLinkSuffix]); - if (!rejected && comment.revision) { - void reject({ + const onBan = useCallback(async () => { + try { + await banUser({ + userID: user.id, commentID: comment.id, - commentRevisionID: comment.revision.id, - storyID: story.id, - noEmit: true, + rejectExistingComments: !siteBan, + message: getMessage( + localeBundles, + "common-banEmailTemplate", + "Someone with access to your account has violated our community guidelines. As a result, your account has been banned. You will no longer be able to comment, react or report comments", + { username: user.username } + ), + siteIDs: siteBan || viewerScoped ? [story.site.id] : [], }); + if (!rejected && comment.revision) { + await reject({ + commentID: comment.id, + commentRevisionID: comment.revision.id, + storyID: story.id, + noEmit: true, + spamBan: !siteBan, + }); + } + } catch (e) { + // error + } + if (siteBan) { + onDismiss(); } - onDismiss(); }, [ user.id, user.username, @@ -108,100 +134,51 @@ const UserBanPopoverContainer: FunctionComponent = ({ story.site.id, story.id, reject, + viewerScoped, ]); - return ( - - {siteBan ? ( - -
- Ban {user.username} from this site? -
+ if (view === "CONFIRM_BAN") { + return ( + + +
{user.username} is now banned
- ) : ( - <> - -
Spam ban
-
- -
Username
+ + +
+ This account can no longer comment, use reactions, or report + comments +
-
{user.username}
- -
Spam ban will
+ +
All comments by this account have been rejected
-
-
    - -
  1. Ban this account from the comments
  2. -
    - -
  3. Reject all comments written by this account
  4. -
    -
-
- - + + + - setSpamBanConfirmation(e.target.value)} - > - - )} - - - - - - - - - {!siteBan && ( - <> - + + + - - For more context, go to{" "} +
+ You can still review this account's history by searching in + Coral's{" "} - +
+
+
+ ); + } + + return ( + + { + <> + {siteBan ? ( + +
+ Ban {user.username} from this site? +
+
+ ) : ( + <> + +
Spam ban
+
+ +
Username
+
+
{user.username}
+ +
Spam ban will
+
+
+
    + +
  1. Ban this account from the comments
  2. +
    + +
  3. Reject all comments written by this account
  4. +
    +
+
+ + + {/* TODO: Add icon */} + Only for use on obvious spam accounts + + + +
+ Type in "spam ban" to confirm +
+
+ setSpamBanConfirmation(e.target.value)} + > + + )} + + + + + + + + + {!siteBan && ( + <> + + + + For more context, go to{" "} + + + + + )} - )} + }
); }; @@ -257,6 +351,13 @@ const enhanced = withFragmentContainer({ } } `, + viewer: graphql` + fragment UserBanPopoverContainer_viewer on User { + moderationScopes { + scoped + } + } + `, })(UserBanPopoverContainer); export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Comment/setSpamBanned.ts b/src/core/client/stream/tabs/Comments/Comment/setSpamBanned.ts new file mode 100644 index 0000000000..4c7c786e22 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Comment/setSpamBanned.ts @@ -0,0 +1,18 @@ +import { commitLocalUpdate, Environment } from "relay-runtime"; + +import { createMutation } from "coral-framework/lib/relay"; + +export interface Input { + commentID: string; +} + +export async function commit(environment: Environment, input: Input) { + return commitLocalUpdate(environment, (store) => { + const comment = store.get(input.commentID); + if (comment) { + comment.setValue(false, "spamBanned"); + } + }); +} + +export const SetSpamBanned = createMutation("SetSpamBanned", commit); diff --git a/src/core/server/services/users/users.ts b/src/core/server/services/users/users.ts index c2932db8cf..76cb623510 100644 --- a/src/core/server/services/users/users.ts +++ b/src/core/server/services/users/users.ts @@ -28,7 +28,7 @@ import { ModeratorCannotBeBannedOnSiteError, PasswordIncorrect, TokenNotFoundError, - UserAlreadyBannedError, + // UserAlreadyBannedError, UserAlreadyPremoderated, UserAlreadySuspendedError, UserBioTooLongError, @@ -1441,7 +1441,15 @@ export async function ban( // Check to see if the User is currently banned. const banStatus = consolidateUserBanStatus(targetUser.status.ban); if (banStatus.active) { - throw new UserAlreadyBannedError(); + if (rejectExistingComments) { + await rejector.add({ + tenantID: tenant.id, + authorID: userID, + moderatorID: banner.id, + }); + } + return targetUser; + // throw new UserAlreadyBannedError(); } // Ban the user. From 3e36dd930c8f8922b7ac38fa5fd28059b5ee0632 Mon Sep 17 00:00:00 2001 From: Kathryn Beaty Date: Mon, 10 Jul 2023 12:59:17 -0400 Subject: [PATCH 03/18] update moderationdropdown view logic --- .../ModerationDropdown/CaretContainer.tsx | 21 +++++++++++++++---- .../ModerationDropdownContainer.tsx | 13 ++++++++---- .../ModerationRejectedTombstoneContainer.tsx | 2 +- .../UserBanPopoverContainer.tsx | 6 +++--- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx index 7deea26ad9..f0f3cf82ed 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx @@ -3,7 +3,7 @@ import cn from "classnames"; import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; -import { withFragmentContainer } from "coral-framework/lib/relay"; +import { useMutation, withFragmentContainer } from "coral-framework/lib/relay"; import CLASSES from "coral-stream/classes"; import { BaseButton, @@ -17,7 +17,10 @@ import { CaretContainer_settings } from "coral-stream/__generated__/CaretContain import { CaretContainer_story } from "coral-stream/__generated__/CaretContainer_story.graphql"; import { CaretContainer_viewer } from "coral-stream/__generated__/CaretContainer_viewer.graphql"; -import ModerationDropdownContainer from "./ModerationDropdownContainer"; +import { SetSpamBanned } from "../setSpamBanned"; +import ModerationDropdownContainer, { + ModerationDropdownView, +} from "./ModerationDropdownContainer"; import styles from "./CaretContainer.css"; @@ -26,12 +29,14 @@ interface Props { story: CaretContainer_story; viewer: CaretContainer_viewer; settings: CaretContainer_settings; - view?: "MODERATE" | "BAN" | "SITE_BAN" | "CONFIRM_BAN"; + view?: ModerationDropdownView; open?: boolean; } const CaretContainer: FunctionComponent = (props) => { const popoverID = `comments-moderationMenu-${props.comment.id}`; + const setSpamBanned = useMutation(SetSpamBanned); + return ( = (props) => { placement="bottom-end" description="A popover menu to moderate the comment" body={({ toggleVisibility, scheduleUpdate }) => ( - + { + if (props.comment.spamBanned) { + void setSpamBanned({ commentID: props.comment.id }); + } + toggleVisibility(); + }} + > ({ comment: graphql` fragment CaretContainer_comment on Comment { id + spamBanned ...ModerationDropdownContainer_comment } `, diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx index 08ea9127da..3508655484 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx @@ -20,7 +20,11 @@ import { ModerationDropdownContainer_viewer } from "coral-stream/__generated__/M import UserBanPopoverContainer from "../UserBanPopover/UserBanPopoverContainer"; import ModerationActionsContainer from "./ModerationActionsContainer"; -type View = "MODERATE" | "BAN" | "SITE_BAN" | "CONFIRM_BAN"; +export type ModerationDropdownView = + | "MODERATE" + | "BAN" + | "SITE_BAN" + | "CONFIRM_BAN"; interface Props { comment: ModerationDropdownContainer_comment; @@ -29,7 +33,7 @@ interface Props { settings: ModerationDropdownContainer_settings; onDismiss: () => void; scheduleUpdate: () => void; - view?: View; + view?: ModerationDropdownView; } const ModerationDropdownContainer: FunctionComponent = ({ @@ -42,7 +46,9 @@ const ModerationDropdownContainer: FunctionComponent = ({ view: viewProp, }) => { const emitShowEvent = useViewerEvent(ShowModerationPopoverEvent); - const [view, setView] = useState(viewProp ?? "MODERATE"); + const [view, setView] = useState( + viewProp ?? "MODERATE" + ); const onBan = useCallback(() => { setView("BAN"); @@ -79,7 +85,6 @@ const ModerationDropdownContainer: FunctionComponent = ({ story={story} viewer={viewer} onDismiss={onDismiss} - siteBan={view === "SITE_BAN"} view={view} /> )} diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.tsx index fca67cd515..713eca6a2b 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.tsx @@ -92,7 +92,7 @@ const ModerationRejectedTombstoneContainer: FunctionComponent = ({ = ({ @@ -47,7 +47,6 @@ const UserBanPopoverContainer: FunctionComponent = ({ story, viewer, onDismiss, - siteBan, view, }) => { const [{ accessToken }] = useLocal(graphql` @@ -56,6 +55,7 @@ const UserBanPopoverContainer: FunctionComponent = ({ } `); const setSpamBanned = useMutation(SetSpamBanned); + const siteBan = view === "SITE_BAN"; const user = comment.author!; const viewerScoped = From 4b4cbdc55f99be4aef1507318d8911cba351d6f0 Mon Sep 17 00:00:00 2001 From: Kathryn Beaty Date: Mon, 10 Jul 2023 14:51:00 -0400 Subject: [PATCH 04/18] add more localization; fix tests --- .../CreateCommentReplyMutation.ts | 1 + .../UserBanPopoverContainer.tsx | 50 +++++++++++-------- .../PostCommentForm/CreateCommentMutation.ts | 1 + .../test/comments/stream/moderation.spec.tsx | 41 +++++++++------ src/locales/en-US/stream.ftl | 9 ++++ 5 files changed, 66 insertions(+), 36 deletions(-) diff --git a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts index c297cdc051..42d2ab7b0f 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts @@ -353,6 +353,7 @@ async function commit( pending: false, lastViewerAction: "CREATE", hasTraversalFocus: false, + spamBanned: false, author: { id: viewer.id, username: viewer.username || null, diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx index 064d77b1d5..2987801577 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx @@ -140,17 +140,20 @@ const UserBanPopoverContainer: FunctionComponent = ({ if (view === "CONFIRM_BAN") { return ( - +
{user.username} is now banned
- +
This account can no longer comment, use reactions, or report comments
- +
All comments by this account have been rejected
@@ -159,7 +162,7 @@ const UserBanPopoverContainer: FunctionComponent = ({ itemGutter="half" className={styles.actions} > - + - - + +
); @@ -243,16 +250,15 @@ const UserBanPopoverContainer: FunctionComponent = ({
-
- Type in "spam ban" to confirm -
+
Type in "spam" to confirm
setSpamBanConfirmation(e.target.value)} - > + /> )} = ({
- - + + )} diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts index 380b24569f..e256e4de05 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts @@ -303,6 +303,7 @@ export const CreateCommentMutation = createMutation( pending: false, lastViewerAction: "CREATE", hasTraversalFocus: false, + spamBanned: false, author: { id: viewer.id, username: viewer.username || null, diff --git a/src/core/client/stream/test/comments/stream/moderation.spec.tsx b/src/core/client/stream/test/comments/stream/moderation.spec.tsx index fd81da1103..c3e154dc6e 100644 --- a/src/core/client/stream/test/comments/stream/moderation.spec.tsx +++ b/src/core/client/stream/test/comments/stream/moderation.spec.tsx @@ -230,7 +230,7 @@ it("reject comment", async () => { ); }); -it("ban user", async () => { +it("spam ban user", async () => { await act(async () => { await createTestRenderer({ resolvers: createResolversStub({ @@ -244,7 +244,7 @@ it("ban user", async () => { banUser: ({ variables }) => { expectAndFail(variables).toMatchObject({ userID: firstComment.author!.id, - rejectExistingComments: false, + rejectExistingComments: true, siteIDs: [], }); return { @@ -280,15 +280,25 @@ it("ban user", async () => { }); await waitFor(() => { expect( - within(comment).getByRole("button", { name: "Ban User" }) + within(comment).getByRole("button", { name: "Spam ban" }) ).not.toBeDisabled(); }); // this is not multisite, so there should be no Site Ban option expect( - within(comment).queryByRole("button", { name: "Site Ban" }) + within(comment).queryByRole("button", { name: "Site ban" }) ).not.toBeInTheDocument(); - fireEvent.click(within(comment).getByRole("button", { name: "Ban User" })); + fireEvent.click(within(comment).getByRole("button", { name: "Spam ban" })); + const banButtonDialog = await screen.findByRole("button", { name: "Ban" }); + expect(banButtonDialog).toBeDisabled(); + + const input = screen.getByTestId("userSpamBanConfirmation"); + fireEvent.change(input, { target: { value: "spam" } }); + + await waitFor(() => { + expect(banButtonDialog).toBeEnabled(); + }); + fireEvent.click(banButtonDialog); expect( await within(tabPane).findByText("You have rejected this comment.") @@ -315,10 +325,10 @@ it("cancel ban user", async () => { }); await waitFor(() => { expect( - within(comment).getByRole("button", { name: "Ban User" }) + within(comment).getByRole("button", { name: "Spam ban" }) ).not.toBeDisabled(); }); - fireEvent.click(within(comment).getByRole("button", { name: "Ban User" })); + fireEvent.click(within(comment).getByRole("button", { name: "Spam ban" })); const cancelButtonDialog = await screen.findByRole("button", { name: "Cancel", }); @@ -382,14 +392,14 @@ it("site moderator can site ban commenter", async () => { }); // Site moderator has Site Ban option but not Ban User option const siteBanButton = await within(comment).findByRole("button", { - name: "Site Ban", + name: "Site ban", }); await waitFor(() => { expect(siteBanButton).not.toBeDisabled(); }); expect( - within(comment).queryByRole("button", { name: "Ban User" }) - ).not.toBeInTheDocument(); + within(comment).queryByRole("button", { name: "Spam ban" }) + ).toBeInTheDocument(); fireEvent.click(siteBanButton); expect( await screen.findByText("Ban Markus from this site?") @@ -397,10 +407,13 @@ it("site moderator can site ban commenter", async () => { const banButtonDialog = screen.getByRole("button", { name: "Ban" }); fireEvent.click(banButtonDialog); - expect( - within(tabPane).getByText("You have rejected this comment.") - ).toBeVisible(); - const link = within(tabPane).getByRole("link", { + await waitFor(() => { + expect( + within(tabPane).getByText("You have rejected this comment.") + ).toBeVisible(); + }); + + const link = await within(tabPane).findByRole("link", { name: "Go to moderate to review this decision", }); expect(link).toHaveAttribute( diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index ebccb1895c..a466c93b73 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -271,6 +271,15 @@ comments-userBanPopover-description = This comment will also be rejected. comments-userBanPopover-cancel = Cancel comments-userBanPopover-ban = Ban +comments-userBanPopover-moreContext = For more context, go to +comments-userBanPopover-moderationView = Moderation view + +comments-userSiteBanPopover-confirm-title = {$username} is now banned +comments-userSiteBanPopover-confirm-spam-banned = This account can no longer comment, use reactions, or report comments +comments-userSiteBanPopover-confirm-comments-rejected = All comments by this account have been rejected +comments-userSiteBanPopover-confirm-closeButton = Close +comments-userSiteBanPopover-confirm-reviewAccountHistory = You can still review this account's history by searching in Coral's +comments-userSiteBanPopover-confirm-communitySection = Community section comments-moderationDropdown-popover = .description = A popover menu to moderate the comment From 0ba89d69c9d03d9f363d9f993745e60d5f96be57 Mon Sep 17 00:00:00 2001 From: Kathryn Beaty Date: Tue, 11 Jul 2023 09:11:25 -0400 Subject: [PATCH 05/18] add error for cannot ban mod account with privileges --- .../UserBanPopoverContainer.css | 6 +++ .../UserBanPopoverContainer.tsx | 13 ++++- src/core/common/errors.ts | 6 +++ src/core/server/errors/index.ts | 8 ++++ src/core/server/services/users/users.ts | 47 ++++++++++++++----- src/locales/en-US/stream.ftl | 3 +- 6 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css index b5fdbca2f6..e111b71fbb 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css @@ -65,3 +65,9 @@ .container { margin-top: var(--spacing-2); } + +.error { + color: var(--palette-error-500); + margin-top: var(--spacing-2); + font-weight: var(--font-weight-primary-semi-bold); +} diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx index 2987801577..ca793d0aad 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx @@ -65,6 +65,7 @@ const UserBanPopoverContainer: FunctionComponent = ({ const banUser = useMutation(BanUserMutation); const { localeBundles, rootURL } = useCoralContext(); const [spamBanConfirmation, setSpamBanConfirmation] = useState(""); + const [banError, setBanError] = useState(null); const linkModerateComment = useModerationLink({ commentID: comment.id }); const linkCommunitySection = rootURL + "/admin/community"; @@ -117,7 +118,14 @@ const UserBanPopoverContainer: FunctionComponent = ({ }); } } catch (e) { - // error + if (e.message === "CANNOT_BAN_ACCOUNT_WITH_MOD_PRIVILEGES") { + const errorMessage = getMessage( + localeBundles, + "comments-userBanPopover-moderator-ban-error", + "Cannot ban accounts with moderator privileges" + ); + setBanError(errorMessage); + } } if (siteBan) { onDismiss(); @@ -135,6 +143,7 @@ const UserBanPopoverContainer: FunctionComponent = ({ story.id, reject, viewerScoped, + setBanError, ]); if (view === "CONFIRM_BAN") { @@ -259,6 +268,8 @@ const UserBanPopoverContainer: FunctionComponent = ({ placeholder="" onChange={(e) => setSpamBanConfirmation(e.target.value)} /> + {/* TODO: Add icon */} + {banError &&
{banError}
} )} { + return siteIDs.includes(site); + } + ); + if (siteIDInModScopes) { + throw new CannotBanAccountWithModPrivilegesError(); + } + } } let user: Readonly; diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index a466c93b73..360ca81169 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -261,7 +261,7 @@ comments-userSpamBanPopover-header-description = Spam ban will comments-userSpamBanPopover-callout = Only for use on obvious spam accounts comments-userSpamBanPopover-description-list-banFromComments = Ban this account from the comments comments-userSpamBanPopover-description-list-rejectAllComments = Reject all comments written by this account -comments-userSpamBanPopover-confirmation = Type in "spam ban" to confirm +comments-userSpamBanPopover-confirmation = Type in "spam" to confirm comments-userBanPopover-title = Ban {$username}? comments-userSiteBanPopover-title = Ban {$username} from this site? @@ -271,6 +271,7 @@ comments-userBanPopover-description = This comment will also be rejected. comments-userBanPopover-cancel = Cancel comments-userBanPopover-ban = Ban +comments-userBanPopover-moderator-ban-error = Cannot ban accounts with moderator privileges comments-userBanPopover-moreContext = For more context, go to comments-userBanPopover-moderationView = Moderation view From 36cda701ef68ed6807a0dbc23e240f9a023130c7 Mon Sep 17 00:00:00 2001 From: Kathryn Beaty Date: Tue, 11 Jul 2023 09:50:53 -0400 Subject: [PATCH 06/18] test spam ban details and confirmation view --- .../test/comments/stream/moderation.spec.tsx | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/core/client/stream/test/comments/stream/moderation.spec.tsx b/src/core/client/stream/test/comments/stream/moderation.spec.tsx index c3e154dc6e..5b41824e80 100644 --- a/src/core/client/stream/test/comments/stream/moderation.spec.tsx +++ b/src/core/client/stream/test/comments/stream/moderation.spec.tsx @@ -290,11 +290,34 @@ it("spam ban user", async () => { fireEvent.click(within(comment).getByRole("button", { name: "Spam ban" })); const banButtonDialog = await screen.findByRole("button", { name: "Ban" }); + + // Ban button should be disabled at first expect(banButtonDialog).toBeDisabled(); + // Spam ban option includes all details + expect( + screen.getByText("Ban this account from the comments") + ).toBeInTheDocument(); + expect( + screen.getByText("Reject all comments written by this account") + ).toBeInTheDocument(); + expect( + screen.getByText("Only for use on obvious spam accounts") + ).toBeInTheDocument(); + expect(screen.getByText("For more context, go to")).toBeInTheDocument(); + const moderationViewLink = screen.getByRole("link", { + name: "Moderation view", + }); + expect(moderationViewLink).toBeInTheDocument(); + expect(moderationViewLink).toHaveAttribute( + "href", + `/admin/moderate/comment/${firstComment.id}` + ); + const input = screen.getByTestId("userSpamBanConfirmation"); fireEvent.change(input, { target: { value: "spam" } }); + // After "spam" is typed in, Ban button should be enabled await waitFor(() => { expect(banButtonDialog).toBeEnabled(); }); @@ -303,6 +326,31 @@ it("spam ban user", async () => { expect( await within(tabPane).findByText("You have rejected this comment.") ).toBeVisible(); + + // spam ban confirmation should be shown + expect(screen.getByText("Markus is now banned")).toBeInTheDocument(); + expect( + screen.getByText( + "This account can no longer comment, use reactions, or report comments" + ) + ).toBeInTheDocument(); + expect( + screen.getByText("All comments by this account have been rejected") + ).toBeInTheDocument(); + expect( + screen.getByText( + "You can still review this account's history by searching in Coral's" + ) + ).toBeInTheDocument(); + const communityLink = screen.getByRole("link", { name: "Community section" }); + expect(communityLink).toBeInTheDocument(); + expect(communityLink).toHaveAttribute("href", "/admin/community"); + const closeButton = screen.getByRole("button", { name: "Close" }); + fireEvent.click(closeButton); + + // spam ban comfirmation should no longer be shown after Close button clicked + expect(screen.queryByText("Markus is now banned")).toBeNull(); + expect(screen.queryByText("You have rejected this comment.")).toBeDefined(); }); it("cancel ban user", async () => { @@ -390,13 +438,14 @@ it("site moderator can site ban commenter", async () => { await act(async () => { userEvent.click(caretButton); }); - // Site moderator has Site Ban option but not Ban User option + // Site moderator has Site Ban option const siteBanButton = await within(comment).findByRole("button", { name: "Site ban", }); await waitFor(() => { expect(siteBanButton).not.toBeDisabled(); }); + // Site moderator also has Spam ban option expect( within(comment).queryByRole("button", { name: "Spam ban" }) ).toBeInTheDocument(); From e62f46ee3383e717c12e32bb1e57c52f83a322fe Mon Sep 17 00:00:00 2001 From: Kathryn Beaty Date: Tue, 11 Jul 2023 09:58:27 -0400 Subject: [PATCH 07/18] add test for site mod can spam ban commenter --- .../test/comments/stream/moderation.spec.tsx | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/core/client/stream/test/comments/stream/moderation.spec.tsx b/src/core/client/stream/test/comments/stream/moderation.spec.tsx index 5b41824e80..a94ad1356e 100644 --- a/src/core/client/stream/test/comments/stream/moderation.spec.tsx +++ b/src/core/client/stream/test/comments/stream/moderation.spec.tsx @@ -471,6 +471,85 @@ it("site moderator can site ban commenter", async () => { ); }); +it("site moderator can spam ban commenter", async () => { + await act(async () => { + await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + user: ({ variables }) => { + expectAndFail(variables.id).toBe(firstComment.author!.id); + return firstComment.author!; + }, + settings: () => settingsWithMultisite, + viewer: () => moderators[1], + }, + Mutation: { + banUser: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + userID: firstComment.author!.id, + rejectExistingComments: true, + siteIDs: ["site-id"], + }); + return { + user: pureMerge(firstComment.author, { + status: { + ban: { + active: true, + }, + }, + }), + }; + }, + rejectComment: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + commentID: firstComment.id, + commentRevisionID: firstComment.revision!.id, + }); + return { + comment: pureMerge(firstComment, { + status: GQLCOMMENT_STATUS.REJECTED, + }), + }; + }, + }, + }), + }); + }); + const tabPane = await screen.findByTestId("current-tab-pane"); + + const comment = screen.getByTestId(`comment-${firstComment.id}`); + const caretButton = within(comment).getByLabelText("Moderate"); + await act(async () => { + userEvent.click(caretButton); + }); + // Site moderator has Spam ban option + const spamBanButton = await within(comment).findByRole("button", { + name: "Spam ban", + }); + fireEvent.click(spamBanButton); + + const input = screen.getByTestId("userSpamBanConfirmation"); + fireEvent.change(input, { target: { value: "spam" } }); + + const banButtonDialog = screen.getByRole("button", { name: "Ban" }); + fireEvent.click(banButtonDialog); + await waitFor(() => { + expect( + within(tabPane).getByText("You have rejected this comment.") + ).toBeVisible(); + }); + + expect(screen.getByText("Markus is now banned")).toBeInTheDocument(); + + const link = await within(tabPane).findByRole("link", { + name: "Go to moderate to review this decision", + }); + expect(link).toHaveAttribute( + "href", + `/admin/moderate/comment/${firstComment.id}` + ); +}); + it("can copy comment embed code", async () => { const jsdomPrompt = window.prompt; window.prompt = jest.fn(() => null); From d155a82c6f56df88184e3afad1b150b070a33df6 Mon Sep 17 00:00:00 2001 From: Kathryn Beaty Date: Tue, 11 Jul 2023 11:18:16 -0400 Subject: [PATCH 08/18] add test for mod cannot ban mod with site privileges error --- .../test/comments/stream/moderation.spec.tsx | 71 ++++++++++++++++++- src/core/client/stream/test/fixtures.ts | 57 +++++++++------ 2 files changed, 104 insertions(+), 24 deletions(-) diff --git a/src/core/client/stream/test/comments/stream/moderation.spec.tsx b/src/core/client/stream/test/comments/stream/moderation.spec.tsx index a94ad1356e..b5bca39bf9 100644 --- a/src/core/client/stream/test/comments/stream/moderation.spec.tsx +++ b/src/core/client/stream/test/comments/stream/moderation.spec.tsx @@ -7,7 +7,9 @@ import { } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { ERROR_CODES } from "coral-common/errors"; import { pureMerge } from "coral-common/utils"; +import { InvalidRequestError } from "coral-framework/lib/errors"; import { GQLCOMMENT_STATUS, GQLResolver } from "coral-framework/schema"; import { createResolversStub, @@ -37,10 +39,12 @@ function createStory() { const story = createStory(); const firstComment = story.comments.edges[0].node; +const thirdComment = story.comments.edges[2].node; const viewer = moderators[0]; async function createTestRenderer( - params: CreateTestRendererParams = {} + params: CreateTestRendererParams = {}, + options: { muteNetworkErrors?: boolean } = {} ) { const { context } = createContext({ ...params, @@ -54,6 +58,7 @@ async function createTestRenderer( }), params.resolvers ), + muteNetworkErrors: options.muteNetworkErrors, initLocalState: (localRecord, source, environment) => { localRecord.setValue(story.id, "storyID"); @@ -550,6 +555,70 @@ it("site moderator can spam ban commenter", async () => { ); }); +it("site moderator cannot ban another moderator with site privileges", async () => { + await act(async () => { + await createTestRenderer( + { + resolvers: createResolversStub({ + Query: { + user: () => { + return moderators[2]; + }, + settings: () => settingsWithMultisite, + viewer: () => moderators[1], + }, + Mutation: { + banUser: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + userID: thirdComment.author?.id, + rejectExistingComments: true, + siteIDs: ["site-id"], + }); + throw new InvalidRequestError({ + code: ERROR_CODES.CANNOT_BAN_ACCOUNT_WITH_MOD_PRIVILEGES, + param: "input.body", + traceID: "traceID", + }); + }, + rejectComment: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + commentID: thirdComment.id, + commentRevisionID: thirdComment.revision?.id, + }); + return { + comment: pureMerge(thirdComment, { + status: GQLCOMMENT_STATUS.REJECTED, + }), + }; + }, + }, + }), + }, + { muteNetworkErrors: true } + ); + }); + + const comment = screen.getByTestId(`comment-${thirdComment.id}`); + const caretButton = within(comment).getByLabelText("Moderate"); + await act(async () => { + userEvent.click(caretButton); + }); + // Site moderator has Spam ban option + const spamBanButton = await within(comment).findByRole("button", { + name: "Spam ban", + }); + fireEvent.click(spamBanButton); + + const input = screen.getByTestId("userSpamBanConfirmation"); + fireEvent.change(input, { target: { value: "spam" } }); + + const banButtonDialog = screen.getByRole("button", { name: "Ban" }); + fireEvent.click(banButtonDialog); + expect( + await screen.findByText("Cannot ban accounts with moderator privileges") + ).toBeInTheDocument(); +}); + it("can copy comment embed code", async () => { const jsdomPrompt = window.prompt; window.prompt = jest.fn(() => null); diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index a354840b1b..99ca684aff 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -442,6 +442,38 @@ export const baseComment = createFixture({ }, }); +export const moderators = createFixtures( + [ + { + id: "me-as-moderator", + username: "Moderator", + role: GQLUSER_ROLE.MODERATOR, + ignoreable: false, + }, + { + id: "site-moderator", + username: "Site Moderator", + role: GQLUSER_ROLE.MODERATOR, + ignoreable: false, + moderationScopes: { + scoped: true, + sites: [site[0]], + }, + }, + { + id: "site-moderator-2", + username: "Site Moderator 2", + role: GQLUSER_ROLE.MODERATOR, + ignoreable: false, + moderationScopes: { + scoped: true, + sites: [site[0]], + }, + }, + ], + baseUser +); + export const comments = denormalizeComments( createFixtures( [ @@ -472,7 +504,7 @@ export const comments = denormalizeComments( }, { id: "comment-5", - author: commenters[2], + author: moderators[2], body: "Comment Body 5", }, { @@ -735,28 +767,6 @@ export const commentWithDeepestReplies = denormalizeComment( }) ); -export const moderators = createFixtures( - [ - { - id: "me-as-moderator", - username: "Moderator", - role: GQLUSER_ROLE.MODERATOR, - ignoreable: false, - }, - { - id: "site-moderator", - username: "Site Moderator", - role: GQLUSER_ROLE.MODERATOR, - ignoreable: false, - moderationScopes: { - scoped: true, - sites: [site[0]], - }, - }, - ], - baseUser -); - export const commentFromModerator = denormalizeComment( createFixture( { @@ -797,6 +807,7 @@ export const stories = denormalizeStories( edges: [ { node: comments[0], cursor: comments[0].createdAt }, { node: comments[1], cursor: comments[1].createdAt }, + { node: comments[5], cursor: comments[5].createdAt }, ], }, }, From 68884da501de5749550b2e58d729814bebc9d522 Mon Sep 17 00:00:00 2001 From: Kathryn Beaty Date: Tue, 11 Jul 2023 11:23:47 -0400 Subject: [PATCH 09/18] use variable for spam confirm text --- .../UserBanPopover/UserBanPopoverContainer.tsx | 16 +++++++++++++--- src/locales/en-US/stream.ftl | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx index ca793d0aad..2a3f771e01 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx @@ -56,6 +56,7 @@ const UserBanPopoverContainer: FunctionComponent = ({ `); const setSpamBanned = useMutation(SetSpamBanned); const siteBan = view === "SITE_BAN"; + const spamConfirmationText = "spam"; const user = comment.author!; const viewerScoped = @@ -258,8 +259,13 @@ const UserBanPopoverContainer: FunctionComponent = ({ Only for use on obvious spam accounts - -
Type in "spam" to confirm
+ +
+ Type in "{spamConfirmationText}" to confirm +
= ({
@@ -265,10 +286,10 @@ const UserBanPopoverContainer: FunctionComponent = ({
- Type in "{spamConfirmationText}" to confirm + Type in "{spamBanConfirmationText}" to confirm
= ({ className={styles.confirmationInput} type="text" placeholder="" - onChange={(e) => setSpamBanConfirmation(e.target.value)} + onChange={onSpamBanConfirmationTextInputChange} /> {banError && (
@@ -304,14 +325,7 @@ const UserBanPopoverContainer: FunctionComponent = ({