From 5df2de6afc33b40d67c5ec867bec4e7f4f51c1f7 Mon Sep 17 00:00:00 2001 From: Tessa Thornton Date: Fri, 2 Aug 2019 17:16:21 -0400 Subject: [PATCH] [CORL-156] Manage user suspension status (#2419) * wire up suspension modal * show user suspended callout on stream * prevent comment actions for suspended users * set default to 3 hour suspension * add message field to suspension * allow custom message for suspension * show suspend success modal * fix type errors * remove unused code * update styles * fix fixture for streams * add suspension ui tests * fix types * remove warnings? * remove snapshot * fix merge conflicts * allow custom email message when banning users * fix typo * correct message type * use final-form in suspend modal * refactor suspend modal to use final-form * refactor ban modal to use final-form * refactor userStatusChangeContainer to use useCallback * feat: improve translated form * fix: addressed issue caused by i18n refactor * update getMessage to accept arguments, remove format method * translate suspend info * change hour format * make message a mandatory input for suspend and ban user * fix types in user table * Revert "fix types in user table" This reverts commit d396e90b88bb1bd354c5cdbdd72b6d8f1ab72929. * fix types for user table * fix: small review tweaks --- .../UserHistoryDrawer/SuspensionAction.tsx | 37 +- .../UserHistoryDrawerQuery.tsx | 31 +- .../admin/components/UserStatus/BanModal.css | 7 + .../admin/components/UserStatus/BanModal.tsx | 164 ++++++--- .../RemoveUserSuspensionMutation.ts | 64 ++++ .../components/UserStatus/SuspendForm.tsx | 207 +++++++++++ .../components/UserStatus/SuspendModal.css | 22 ++ .../components/UserStatus/SuspendModal.tsx | 134 +++++++ .../UserStatus/SuspendUserMutation.ts | 62 ++++ .../UserStatus/UserStatusChangeContainer.tsx | 76 +++- .../client/admin/routes/Community/UserRow.tsx | 4 +- .../routes/Community/UserRowContainer.tsx | 8 + .../admin/routes/Community/UserTable.tsx | 13 +- .../routes/Community/UserTableContainer.tsx | 2 + .../__snapshots__/community.spec.tsx.snap | 115 ------ .../admin/test/community/community.spec.tsx | 337 +++++++++++++++++- src/core/client/admin/test/fixtures.ts | 18 + .../framework/components/DurationField.tsx | 37 +- src/core/client/framework/lib/i18n/format.ts | 18 + .../client/framework/lib/i18n/getMessage.ts | 14 +- src/core/client/framework/lib/i18n/index.ts | 1 + .../framework/lib/i18n/reduceSeconds.ts | 55 +++ .../framework/lib/i18n/withGetMessage.tsx | 10 +- .../Comments/Comment/CommentContainer.tsx | 15 +- .../tabs/Comments/Stream/StreamContainer.tsx | 15 +- .../Stream/SuspendedInfo/SuspendedInfo.tsx | 50 +++ .../SuspendedInfo/SuspendedInfoContainer.tsx | 48 +++ .../Comments/Stream/SuspendedInfo/index.ts | 4 + .../test/comments/stream/suspended.spec.tsx | 105 ++++++ src/core/client/stream/test/fixtures.ts | 8 + .../server/graph/tenant/mutators/Users.ts | 2 + .../tenant/resolvers/BanStatusHistory.ts | 1 + .../resolvers/SuspensionStatusHistory.ts | 1 + .../server/graph/tenant/schema/schema.graphql | 20 ++ src/core/server/locales/en-US/email.ftl | 14 +- src/core/server/models/user/user.ts | 13 + .../queue/tasks/mailer/templates/index.ts | 2 + .../queue/tasks/mailer/templates/suspend.html | 6 +- src/core/server/services/users/index.ts | 9 +- src/locales/en-US/admin.ftl | 28 ++ src/locales/en-US/framework.ftl | 3 +- src/locales/en-US/stream.ftl | 7 + 42 files changed, 1511 insertions(+), 276 deletions(-) create mode 100644 src/core/client/admin/components/UserStatus/RemoveUserSuspensionMutation.ts create mode 100644 src/core/client/admin/components/UserStatus/SuspendForm.tsx create mode 100644 src/core/client/admin/components/UserStatus/SuspendModal.css create mode 100644 src/core/client/admin/components/UserStatus/SuspendModal.tsx create mode 100644 src/core/client/admin/components/UserStatus/SuspendUserMutation.ts create mode 100644 src/core/client/framework/lib/i18n/format.ts create mode 100644 src/core/client/framework/lib/i18n/reduceSeconds.ts create mode 100644 src/core/client/stream/tabs/Comments/Stream/SuspendedInfo/SuspendedInfo.tsx create mode 100644 src/core/client/stream/tabs/Comments/Stream/SuspendedInfo/SuspendedInfoContainer.tsx create mode 100644 src/core/client/stream/tabs/Comments/Stream/SuspendedInfo/index.ts create mode 100644 src/core/client/stream/test/comments/stream/suspended.spec.tsx diff --git a/src/core/client/admin/components/UserHistoryDrawer/SuspensionAction.tsx b/src/core/client/admin/components/UserHistoryDrawer/SuspensionAction.tsx index 6da38318c..009d65eed 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/SuspensionAction.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/SuspensionAction.tsx @@ -1,7 +1,7 @@ import { Localized } from "fluent-react/compat"; import React, { FunctionComponent } from "react"; -import { DURATION_UNIT } from "coral-framework/components"; +import { reduceSeconds } from "coral-framework/lib/i18n"; interface From { start: Date; @@ -13,48 +13,23 @@ export interface SuspensionActionProps { from: From; } -const UNITS = [ - DURATION_UNIT.WEEKS, - DURATION_UNIT.DAYS, - DURATION_UNIT.HOURS, - DURATION_UNIT.MINUTES, - DURATION_UNIT.SECONDS, -]; - -const UNIT_MAP = { - [DURATION_UNIT.SECONDS]: "second", - [DURATION_UNIT.MINUTES]: "minute", - [DURATION_UNIT.HOURS]: "hour", - [DURATION_UNIT.DAYS]: "day", - [DURATION_UNIT.WEEKS]: "week", -}; - -function reduceValue(value: number) { - // Find the largest match for the smallest number. - const unit: keyof typeof UNIT_MAP = - UNITS.find(compare => value >= compare) || DURATION_UNIT.SECONDS; - - return { - value: Math.round((value / unit) * 100) / 100, - unit: UNIT_MAP[unit], - }; -} - const SuspensionAction: FunctionComponent = ({ action, from, }) => { if (action === "created") { const seconds = (from.finish.getTime() - from.start.getTime()) / 1000; - const { value, unit } = reduceValue(seconds); + const { scaled, unit } = reduceSeconds(seconds); return ( - Suspension, {seconds} seconds + + Suspension, {scaled} {unit} + ); } else if (action === "removed") { diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.tsx index 182735322..70be0314c 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.tsx @@ -2,11 +2,12 @@ import { Localized } from "fluent-react/compat"; import React, { FunctionComponent } from "react"; import { ReadyState } from "react-relay"; -import { UserHistoryDrawerQuery as QueryTypes } from "coral-admin/__generated__/UserHistoryDrawerQuery.graphql"; -import { UserStatusChangeContainer } from "coral-admin/components/UserStatus"; import { CopyButton } from "coral-framework/components"; import { useCoralContext } from "coral-framework/lib/bootstrap"; import { graphql, QueryRenderer } from "coral-framework/lib/relay"; + +import { UserHistoryDrawerQuery as QueryTypes } from "coral-admin/__generated__/UserHistoryDrawerQuery.graphql"; +import { UserStatusChangeContainer } from "coral-admin/components/UserStatus"; import { Button, CallOut, @@ -49,6 +50,12 @@ const UserHistoryDrawerQuery: FunctionComponent = ({ createdAt ...UserStatusChangeContainer_user } + settings { + organization { + name + } + ...UserStatusChangeContainer_settings + } } `} variables={{ userID }} @@ -74,7 +81,7 @@ const UserHistoryDrawerQuery: FunctionComponent = ({ ); } - const user = props.user; + const { user, settings } = props; return ( <> @@ -85,10 +92,20 @@ const UserHistoryDrawerQuery: FunctionComponent = ({ {user.username}
-
Status:
-
- -
+ +
+ + + + Status: + + + +
+
+ +
+
diff --git a/src/core/client/admin/components/UserStatus/BanModal.css b/src/core/client/admin/components/UserStatus/BanModal.css index 9b2098d45..f01b54db8 100644 --- a/src/core/client/admin/components/UserStatus/BanModal.css +++ b/src/core/client/admin/components/UserStatus/BanModal.css @@ -1,3 +1,10 @@ .card { max-width: 500px; } + +.textArea { + width: 100%; + box-sizing: border-box; + height: calc(12 * var(--mini-unit)); + padding: calc(0.5 * var(--mini-unit)); +} diff --git a/src/core/client/admin/components/UserStatus/BanModal.tsx b/src/core/client/admin/components/UserStatus/BanModal.tsx index 90c17d676..40b19bb0a 100644 --- a/src/core/client/admin/components/UserStatus/BanModal.tsx +++ b/src/core/client/admin/components/UserStatus/BanModal.tsx @@ -1,16 +1,19 @@ -import { Localized } from "fluent-react/compat"; -import React, { FunctionComponent } from "react"; - -import NotAvailable from "coral-admin/components/NotAvailable"; import { Button, Card, CardCloseButton, + CheckBox, Flex, HorizontalGutter, Modal, Typography, } from "coral-ui/components"; +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent, useCallback, useMemo } from "react"; +import { Field, Form } from "react-final-form"; + +import NotAvailable from "coral-admin/components/NotAvailable"; +import { GetMessage, withGetMessage } from "coral-framework/lib/i18n"; import styles from "./BanModal.css"; @@ -18,7 +21,8 @@ interface Props { username: string | null; open: boolean; onClose: () => void; - onConfirm: () => void; + onConfirm: (message?: string) => void; + getMessage: GetMessage; } const BanModal: FunctionComponent = ({ @@ -26,51 +30,107 @@ const BanModal: FunctionComponent = ({ onClose, onConfirm, username, -}) => ( - - {({ firstFocusableRef, lastFocusableRef }) => ( - - - - - } - $username={username || } - > - - Are you sure you want to ban{" "} - {username || }? - - - - - Once banned, this user will no longer be able to comment, use - reactions, or report comments. - - - - - - - - - - - - - - )} - -); + getMessage, +}) => { + const getDefaultMessage = useMemo((): string => { + return getMessage( + "community-banModal-emailTemplate", + "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, + } + ); + }, [getMessage, username]); -export default BanModal; + const onFormSubmit = useCallback( + ({ emailMessage }) => { + onConfirm(emailMessage); + }, + [onConfirm] + ); + + return ( + + {({ firstFocusableRef, lastFocusableRef }) => ( + + + + + } + $username={username || } + > + + Are you sure you want to ban{" "} + {username || }? + + + + + Once banned, this user will no longer be able to comment, use + reactions, or report comments. + + + +
+ {({ handleSubmit }) => ( + + + {({ input }) => ( + + + Customize ban email message + + + )} + + + {({ input: { value } }) => + value ? ( + + ) : null + } + + + + + + + + + + + + )} + +
+
+ )} +
+ ); +}; + +const enhanced = withGetMessage(BanModal); + +export default enhanced; diff --git a/src/core/client/admin/components/UserStatus/RemoveUserSuspensionMutation.ts b/src/core/client/admin/components/UserStatus/RemoveUserSuspensionMutation.ts new file mode 100644 index 000000000..c8107e1bc --- /dev/null +++ b/src/core/client/admin/components/UserStatus/RemoveUserSuspensionMutation.ts @@ -0,0 +1,64 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { RemoveUserSuspensionMutation as MutationTypes } from "coral-admin/__generated__/RemoveUserSuspensionMutation.graphql"; +import { + commitMutationPromiseNormalized, + createMutation, + lookup, + MutationInput, +} from "coral-framework/lib/relay"; +import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema"; + +let clientMutationId = 0; + +const RemoveUserSuspensionMutation = createMutation( + "removeUserSuspension", + (environment: Environment, input: MutationInput) => { + return commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation RemoveUserSuspensionMutation( + $input: RemoveUserSuspensionInput! + ) { + removeUserSuspension(input: $input) { + user { + id + status { + current + suspension { + active + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + optimisticResponse: { + removeUserSuspension: { + user: { + id: input.userID, + status: { + current: lookup( + environment, + input.userID + )!.status.current.concat(GQLUSER_STATUS.SUSPENDED), + suspension: { + active: false, + }, + }, + }, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); + } +); + +export default RemoveUserSuspensionMutation; diff --git a/src/core/client/admin/components/UserStatus/SuspendForm.tsx b/src/core/client/admin/components/UserStatus/SuspendForm.tsx new file mode 100644 index 000000000..1eabcbfa7 --- /dev/null +++ b/src/core/client/admin/components/UserStatus/SuspendForm.tsx @@ -0,0 +1,207 @@ +import { Mutator } from "final-form"; +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent, useCallback } from "react"; +import { Field, Form } from "react-final-form"; + +import { + GetMessage, + ScaledUnit, + withGetMessage, +} from "coral-framework/lib/i18n"; + +import { + Button, + CheckBox, + Flex, + HorizontalGutter, + RadioButton, +} from "coral-ui/components"; + +import styles from "./SuspendModal.css"; + +interface Props { + username: string | null; + onCancel: () => void; + getMessage: GetMessage; + organizationName: string; + onSubmit: (duration: ScaledUnit, message: string) => void; +} + +const DURATIONS: ScaledUnit[] = [ + { original: 3600, value: "3600", unit: "hour", scaled: 1 }, // 1 hour + { original: 10800, value: "10800", unit: "hour", scaled: 3 }, // 3 hours + { original: 86400, value: "86400", unit: "hour", scaled: 24 }, // 24 hours + { original: 604800, value: "604800", unit: "day", scaled: 7 }, // 7 days +]; + +const DEFAULT_DURATION = DURATIONS[0]; // 1 hour + +const SuspendForm: FunctionComponent = ({ + onCancel, + username, + getMessage, + onSubmit, + organizationName, +}) => { + const getMessageWithDuration = useCallback( + ({ scaled, unit }: Pick): string => { + return getMessage( + "community-suspendModal-emailTemplate", + `your account has been temporarily suspended. During the suspension, you will be unable to comment, flag or engage with fellow commenters. Please rejoin the conversation in ${scaled} ${unit}`, + { + username, + organizationName, + value: scaled, + unit, + } + ); + }, + [username, organizationName] + ); + + const onFormSubmit = useCallback( + ({ duration, emailMessage }) => { + const unit = DURATIONS.find(d => d.value === duration); + onSubmit(unit!, emailMessage); + }, + [onSubmit] + ); + + const setMessageValue: Mutator = useCallback( + ([name, newValue], state, { changeValue }) => { + if (state.lastFormState) { + const { duration, emailMessage } = state.lastFormState.values; + const unit = DURATIONS.find(d => d.value === duration); + const expectedEmailMessage = getMessageWithDuration(unit!); + if (expectedEmailMessage === emailMessage) { + changeValue(state, name, () => newValue); + } + } + }, + [getMessageWithDuration] + ); + + const resetMessageValue: Mutator = ( + [name, checked], + state, + { changeValue } + ) => { + if (state.lastFormState && !checked) { + const { duration, emailMessage } = state.lastFormState.values; + const unit = DURATIONS.find(d => d.value === duration); + const expectedEmailMessage = getMessageWithDuration(unit!); + if (expectedEmailMessage !== emailMessage) { + changeValue(state, name, () => expectedEmailMessage); + } + } + }; + + return ( + <> +
+ {({ handleSubmit, form }) => ( + + +
+ {DURATIONS.map(({ original, value, scaled, unit }) => ( + + {({ input }) => ( + + { + form.mutators.setMessageValue( + "emailMessage", + getMessageWithDuration({ scaled, unit }) + ); + input.onChange(event); + }} + > + + {scaled} {unit} + + + + )} + + ))} +
+ + {({ input }) => ( + + { + form.mutators.resetMessageValue( + "emailMessage", + !input.checked + ); + input.onChange(event); + }} + > + Customize suspension email message + + + )} + + + {({ input: { value } }) => + value ? ( + + ) : null + } + +
+ + + + + + + + +
+ )} + + + ); +}; + +const enhanced = withGetMessage(SuspendForm); + +export default enhanced; diff --git a/src/core/client/admin/components/UserStatus/SuspendModal.css b/src/core/client/admin/components/UserStatus/SuspendModal.css new file mode 100644 index 000000000..8f0292a1d --- /dev/null +++ b/src/core/client/admin/components/UserStatus/SuspendModal.css @@ -0,0 +1,22 @@ +.card { + max-width: 500px; +} + +.radioButton { + margin: 0 var(--spacing-1) 0 0 !important; +} + +.textArea { + width: 100%; + box-sizing: border-box; + height: calc(12 * var(--mini-unit)); + padding: calc(0.5 * var(--mini-unit)); +} + +.header { + margin: 0 0 var(--spacing-3) 0 !important; +} + +.footer { + margin-top: var(--spacing-3); +} \ No newline at end of file diff --git a/src/core/client/admin/components/UserStatus/SuspendModal.tsx b/src/core/client/admin/components/UserStatus/SuspendModal.tsx new file mode 100644 index 000000000..a50f10d0d --- /dev/null +++ b/src/core/client/admin/components/UserStatus/SuspendModal.tsx @@ -0,0 +1,134 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent, useCallback, useState } from "react"; + +import { + GetMessage, + ScaledUnit, + withGetMessage, +} from "coral-framework/lib/i18n"; + +import NotAvailable from "coral-admin/components/NotAvailable"; +import { + Button, + Card, + CardCloseButton, + Flex, + HorizontalGutter, + Modal, + Typography, +} from "coral-ui/components"; + +import SuspendForm from "./SuspendForm"; + +import styles from "./SuspendModal.css"; + +interface Props { + username: string | null; + getMessage: GetMessage; + open: boolean; + onClose: () => void; + onConfirm: (timeout: number, message: string) => void; + organizationName: string; + success: boolean; +} + +const SuspendModal: FunctionComponent = ({ + open, + onClose, + onConfirm, + getMessage, + username, + success, + organizationName, +}) => { + const [successDuration, setSuccessDuration] = useState(""); + const onFormSubmit = useCallback( + ({ original, scaled, unit }: ScaledUnit, message: string) => { + setSuccessDuration( + getMessage("framework-timeago-time", `${scaled} ${unit}`, { + value: scaled, + unit, + }) + ); + onConfirm(original, message); + }, + [onConfirm] + ); + + return ( + <> + + {({ firstFocusableRef, lastFocusableRef }) => ( + + + {success && ( + + + } + $duration={successDuration} + > + + {username} has been suspended for{" "} + {successDuration} + + + + + + + + + + + )} + {!success && ( + + } + $username={username || } + > + + Suspend {username || }? + + + + + While suspended, this user will no longer be able to + comment, use reactions, or report comments. + + + + + + Select suspension length + + + + + + )} + + )} + + + ); +}; + +const enhanced = withGetMessage(SuspendModal); + +export default enhanced; diff --git a/src/core/client/admin/components/UserStatus/SuspendUserMutation.ts b/src/core/client/admin/components/UserStatus/SuspendUserMutation.ts new file mode 100644 index 000000000..7684f8692 --- /dev/null +++ b/src/core/client/admin/components/UserStatus/SuspendUserMutation.ts @@ -0,0 +1,62 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { SuspendUserMutation as MutationTypes } from "coral-admin/__generated__/SuspendUserMutation.graphql"; +import { + commitMutationPromiseNormalized, + createMutation, + lookup, + MutationInput, +} from "coral-framework/lib/relay"; +import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema"; + +let clientMutationId = 0; + +const SuspendUserMutation = createMutation( + "suspendUser", + (environment: Environment, input: MutationInput) => { + return commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation SuspendUserMutation($input: SuspendUserInput!) { + suspendUser(input: $input) { + user { + id + status { + current + suspension { + active + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + optimisticResponse: { + suspendUser: { + user: { + id: input.userID, + status: { + current: lookup( + environment, + input.userID + )!.status.current.concat(GQLUSER_STATUS.SUSPENDED), + suspension: { + active: true, + }, + }, + }, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); + } +); + +export default SuspendUserMutation; diff --git a/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx b/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx index 3f27c323f..0b586aea5 100644 --- a/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx +++ b/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx @@ -1,5 +1,6 @@ import React, { FunctionComponent, useCallback, useState } from "react"; +import { UserStatusChangeContainer_settings as SettingsData } from "coral-admin/__generated__/UserStatusChangeContainer_settings.graphql"; import { UserStatusChangeContainer_user as UserData } from "coral-admin/__generated__/UserStatusChangeContainer_user.graphql"; import { graphql, @@ -12,21 +13,27 @@ import ButtonPadding from "../ButtonPadding"; import BanModal from "./BanModal"; import BanUserMutation from "./BanUserMutation"; import RemoveUserBanMutation from "./RemoveUserBanMutation"; +import RemoveUserSuspensionMutation from "./RemoveUserSuspensionMutation"; +import SuspendModal from "./SuspendModal"; +import SuspendUserMutation from "./SuspendUserMutation"; import UserStatusChange from "./UserStatusChange"; import UserStatusContainer from "./UserStatusContainer"; interface Props { user: UserData; fullWidth?: boolean; + settings: SettingsData; } -const UserStatusChangeContainer: FunctionComponent = ({ - user, - fullWidth = false, -}) => { +const UserStatusChangeContainer: FunctionComponent = props => { + const { user, settings, fullWidth } = props; const banUser = useMutation(BanUserMutation); + const suspendUser = useMutation(SuspendUserMutation); const removeUserBan = useMutation(RemoveUserBanMutation); + const removeUserSuspension = useMutation(RemoveUserSuspensionMutation); const [showBanned, setShowBanned] = useState(false); + const [showSuspend, setShowSuspend] = useState(false); + const [showSuspendSuccess, setShowSuspendSuccess] = useState(false); const handleBan = useCallback(() => { if (user.status.ban.active) { return; @@ -43,14 +50,43 @@ const UserStatusChangeContainer: FunctionComponent = ({ if (user.status.suspension.active) { return; } - // TODO: (cvle) - }, [user]); + setShowSuspend(true); + }, [user, setShowSuspend]); const handleRemoveSuspension = useCallback(() => { if (!user.status.suspension.active) { return; } - // TODO: (cvle) - }, [user]); + removeUserSuspension({ userID: user.id }); + }, [user, removeUserSuspension]); + + const handleSuspendModalClose = useCallback(() => { + setShowSuspend(false); + setShowSuspendSuccess(false); + }, [setShowBanned, setShowSuspendSuccess]); + + const handleBanModalClose = useCallback(() => { + setShowBanned(false); + }, [setShowBanned]); + + const handleSuspendConfirm = useCallback( + (timeout, message) => { + suspendUser({ + userID: user.id, + timeout, + message, + }); + setShowSuspendSuccess(true); + }, + [user, suspendUser, setShowSuspendSuccess] + ); + + const handleBanConfirm = useCallback( + message => { + banUser({ userID: user.id, message }); + setShowBanned(false); + }, + [user, setShowBanned] + ); if (user.role !== GQLUSER_ROLE.COMMENTER) { return ( @@ -73,14 +109,19 @@ const UserStatusChangeContainer: FunctionComponent = ({ > + setShowBanned(false)} - onConfirm={() => { - banUser({ userID: user.id }); - setShowBanned(false); - }} + onClose={handleBanModalClose} + onConfirm={handleBanConfirm} /> ); @@ -89,7 +130,6 @@ const UserStatusChangeContainer: FunctionComponent = ({ const enhanced = withFragmentContainer({ user: graphql` fragment UserStatusChangeContainer_user on User { - ...UserStatusContainer_user id role username @@ -101,6 +141,14 @@ const enhanced = withFragmentContainer({ active } } + ...UserStatusContainer_user + } + `, + settings: graphql` + fragment UserStatusChangeContainer_settings on Settings { + organization { + name + } } `, })(UserStatusChangeContainer); diff --git a/src/core/client/admin/routes/Community/UserRow.tsx b/src/core/client/admin/routes/Community/UserRow.tsx index a7c6bb3a9..5a1da791e 100644 --- a/src/core/client/admin/routes/Community/UserRow.tsx +++ b/src/core/client/admin/routes/Community/UserRow.tsx @@ -16,6 +16,7 @@ interface Props { user: PropTypesOf["user"] & PropTypesOf["user"]; viewer: PropTypesOf["viewer"]; + settings: PropTypesOf["settings"]; onUsernameClicked?: (userID: string) => void; } @@ -27,6 +28,7 @@ const UserRow: FunctionComponent = ({ user, viewer, onUsernameClicked, + settings, }) => { const usernameClicked = useCallback(() => { if (!onUsernameClicked) { @@ -53,7 +55,7 @@ const UserRow: FunctionComponent = ({ - + ); diff --git a/src/core/client/admin/routes/Community/UserRowContainer.tsx b/src/core/client/admin/routes/Community/UserRowContainer.tsx index 9138afbc5..6dfcff7b5 100644 --- a/src/core/client/admin/routes/Community/UserRowContainer.tsx +++ b/src/core/client/admin/routes/Community/UserRowContainer.tsx @@ -1,6 +1,7 @@ import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; +import { UserRowContainer_settings as SettingsData } from "coral-admin/__generated__/UserRowContainer_settings.graphql"; import { UserRowContainer_user as UserData } from "coral-admin/__generated__/UserRowContainer_user.graphql"; import { UserRowContainer_viewer as ViewerData } from "coral-admin/__generated__/UserRowContainer_viewer.graphql"; import { useCoralContext } from "coral-framework/lib/bootstrap"; @@ -11,6 +12,7 @@ import UserRow from "./UserRow"; interface Props { user: UserData; viewer: ViewerData; + settings: SettingsData; onUsernameClicked?: (userID: string) => void; } @@ -19,6 +21,7 @@ const UserRowContainer: FunctionComponent = props => { return ( ({ ...UserRoleChangeContainer_viewer } `, + settings: graphql` + fragment UserRowContainer_settings on Settings { + ...UserStatusChangeContainer_settings + } + `, user: graphql` fragment UserRowContainer_user on User { ...UserStatusChangeContainer_user diff --git a/src/core/client/admin/routes/Community/UserTable.tsx b/src/core/client/admin/routes/Community/UserTable.tsx index b9041cc72..c0749d627 100644 --- a/src/core/client/admin/routes/Community/UserTable.tsx +++ b/src/core/client/admin/routes/Community/UserTable.tsx @@ -22,6 +22,7 @@ import styles from "./UserTable.css"; interface Props { viewer: PropTypesOf["viewer"] | null; + settings: PropTypesOf["settings"] | null; users: Array<{ id: string } & PropTypesOf["user"]>; onLoadMore: () => void; hasMore: boolean; @@ -29,7 +30,11 @@ interface Props { loading: boolean; } -const UserTable: FunctionComponent = props => { +const UserTable: FunctionComponent = ({ + viewer, + settings, + ...props +}) => { const [userDrawerUserID, setUserDrawerUserID] = useState(""); const [userDrawerVisible, setUserDrawerVisible] = useState(false); @@ -45,7 +50,6 @@ const UserTable: FunctionComponent = props => { setUserDrawerVisible(false); setUserDrawerUserID(""); }, [setUserDrawerUserID, setUserDrawerVisible]); - return ( <> @@ -77,11 +81,14 @@ const UserTable: FunctionComponent = props => { {!props.loading && + settings && + viewer && props.users.map(u => ( ))} diff --git a/src/core/client/admin/routes/Community/UserTableContainer.tsx b/src/core/client/admin/routes/Community/UserTableContainer.tsx index 5bdbfd51e..d9f71e2d4 100644 --- a/src/core/client/admin/routes/Community/UserTableContainer.tsx +++ b/src/core/client/admin/routes/Community/UserTableContainer.tsx @@ -52,6 +52,7 @@ const UserTableContainer: FunctionComponent = props => { /> -
-
-
-
-
-
-
-
- -
-
-

- Are you sure you want to ban - - Isabelle - - ? -

-

- Once banned, this user will no longer be able to comment, use -reactions, or report comments. -

-
-
- - -
-
-
-
-
-
-
-
-
-`; - exports[`renders community 1`] = `
{ }); }); +it("suspend user", async () => { + const user = users.commenters[0]; + + const resolvers = createResolversStub({ + Mutation: { + suspendUser: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + userID: user.id, + }); + const userRecord = pureMerge(user, { + status: { + current: user.status.current.concat(GQLUSER_STATUS.SUSPENDED), + suspension: { active: true }, + }, + }); + return { + user: userRecord, + }; + }, + }, + }); + + const { container, testRenderer } = await createTestRenderer({ + resolvers, + }); + + const userRow = within(container).getByText(user.username!, { + selector: "tr", + }); + + TestRenderer.act(() => { + within(userRow) + .getByLabelText("Change user status") + .props.onClick(); + }); + + const popup = within(userRow).getByLabelText( + "A dropdown to change the user status" + ); + + TestRenderer.act(() => { + within(popup) + .getByText("Suspend User", { selector: "button" }) + .props.onClick(); + }); + + const modal = within(testRenderer.root).getByLabelText("Suspend", { + exact: false, + }); + + TestRenderer.act(() => { + within(modal) + .getByType("form") + .props.onSubmit(); + }); + within(userRow).getByText("Suspended"); + expect(resolvers.Mutation!.suspendUser!.called).toBe(true); +}); + +it("remove user suspension", async () => { + const user = users.suspendedCommenter; + const resolvers = createResolversStub({ + Mutation: { + removeUserSuspension: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + userID: user.id, + }); + const userRecord = pureMerge(user, { + status: { + current: [GQLUSER_STATUS.ACTIVE], + suspension: { active: false }, + }, + }); + return { + user: userRecord, + }; + }, + }, + Query: { + users: () => ({ + edges: [ + { + node: user, + cursor: user.createdAt, + }, + ], + pageInfo: { endCursor: null, hasNextPage: false }, + }), + }, + }); + + const { container } = await createTestRenderer({ + resolvers, + }); + + const userRow = within(container).getByText(user.username!, { + selector: "tr", + }); + + TestRenderer.act(() => { + within(userRow) + .getByLabelText("Change user status") + .props.onClick(); + }); + + const popup = within(userRow).getByLabelText( + "A dropdown to change the user status" + ); + + TestRenderer.act(() => { + within(popup) + .getByText("Remove Suspension", { selector: "button" }) + .props.onClick(); + }); + expect(resolvers.Mutation!.removeUserSuspension!.called).toBe(true); + + await waitForElement(() => within(userRow).getByText("Active")); +}); + +it("suspend user with custom timeout", async () => { + const user = users.commenters[0]; + + const resolvers = createResolversStub({ + Mutation: { + suspendUser: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + userID: user.id, + timeout: 604800, + }); + const userRecord = pureMerge(user, { + status: { + current: user.status.current.concat(GQLUSER_STATUS.SUSPENDED), + suspension: { active: true }, + }, + }); + return { + user: userRecord, + }; + }, + }, + }); + + const { container, testRenderer } = await createTestRenderer({ + resolvers, + }); + + const userRow = within(container).getByText(user.username!, { + selector: "tr", + }); + + TestRenderer.act(() => { + within(userRow) + .getByLabelText("Change user status") + .props.onClick(); + }); + + const popup = within(userRow).getByLabelText( + "A dropdown to change the user status" + ); + + TestRenderer.act(() => { + within(popup) + .getByText("Suspend User", { selector: "button" }) + .props.onClick(); + }); + + const modal = within(testRenderer.root).getByLabelText("Suspend", { + exact: false, + }); + + const changedDuration = within(modal).getByID("duration-604800"); + + TestRenderer.act(() => { + changedDuration.props.onChange(changedDuration.props.value.toString()); + }); + + TestRenderer.act(() => { + within(modal) + .getByType("form") + .props.onSubmit(); + }); + within(userRow).getByText("Suspended"); + expect(resolvers.Mutation!.suspendUser!.called).toBe(true); +}); + +it("suspend user with custom message", async () => { + const user = users.commenters[0]; + + const resolvers = createResolversStub({ + Mutation: { + suspendUser: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + userID: user.id, + message: "YOU WERE SUSPENDED FOR BEHAVING BADLY", + }); + const userRecord = pureMerge(user, { + status: { + current: user.status.current.concat(GQLUSER_STATUS.SUSPENDED), + suspension: { active: true }, + }, + }); + return { + user: userRecord, + }; + }, + }, + }); + + const { container, testRenderer } = await createTestRenderer({ + resolvers, + }); + + const userRow = within(container).getByText(user.username!, { + selector: "tr", + }); + + TestRenderer.act(() => { + within(userRow) + .getByLabelText("Change user status") + .props.onClick(); + }); + + const popup = within(userRow).getByLabelText( + "A dropdown to change the user status" + ); + + TestRenderer.act(() => { + within(popup) + .getByText("Suspend User", { selector: "button" }) + .props.onClick(); + }); + + const modal = within(testRenderer.root).getByLabelText("Suspend", { + exact: false, + }); + + const toggleEdit = within(modal).getByID("suspendModal-editMessage"); + + TestRenderer.act(() => { + toggleEdit.props.onChange(true); + }); + + TestRenderer.act(() => { + within(modal) + .getByID("suspendModal-message") + .props.onChange("YOU WERE SUSPENDED FOR BEHAVING BADLY"); + }); + + TestRenderer.act(() => { + within(modal) + .getByType("form") + .props.onSubmit(); + }); + + within(userRow).getByText("Suspended"); + expect(resolvers.Mutation!.suspendUser!.called).toBe(true); +}); + it("ban user", async () => { const user = users.commenters[0]; @@ -457,12 +715,85 @@ it("ban user", async () => { } ); - expect(within(modal).toJSON()).toMatchSnapshot(); + TestRenderer.act(() => { + within(modal) + .getByType("form") + .props.onSubmit(); + }); + within(userRow).getByText("Banned"); + expect(resolvers.Mutation!.banUser!.called).toBe(true); +}); + +it("ban user with custom message", async () => { + const user = users.commenters[0]; + + const resolvers = createResolversStub({ + Mutation: { + banUser: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + userID: user.id, + message: "YOU WERE BANNED FOR BREAKING THE RULES", + }); + const userRecord = pureMerge(user, { + status: { + current: user.status.current.concat(GQLUSER_STATUS.BANNED), + ban: { active: true }, + }, + }); + return { + user: userRecord, + }; + }, + }, + }); + + const { container, testRenderer } = await createTestRenderer({ + resolvers, + }); + + const userRow = within(container).getByText(user.username!, { + selector: "tr", + }); + + TestRenderer.act(() => { + within(userRow) + .getByLabelText("Change user status") + .props.onClick(); + }); + + const popup = within(userRow).getByLabelText( + "A dropdown to change the user status" + ); + + TestRenderer.act(() => { + within(popup) + .getByText("Ban User", { selector: "button" }) + .props.onClick(); + }); + + const modal = within(testRenderer.root).getByLabelText( + "Are you sure you want to ban", + { + exact: false, + } + ); + + const toggleMessage = within(modal).getByID("banModal-showMessage"); + + TestRenderer.act(() => { + toggleMessage.props.onChange(true); + }); TestRenderer.act(() => { within(modal) - .getByText("Ban User") - .props.onClick(); + .getByID("banModal-message") + .props.onChange("YOU WERE BANNED FOR BREAKING THE RULES"); + }); + + TestRenderer.act(() => { + within(modal) + .getByType("form") + .props.onSubmit(); }); within(userRow).getByText("Banned"); expect(resolvers.Mutation!.banUser!.called).toBe(true); diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index 2666eeda5..b5e5a0d91 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -355,6 +355,24 @@ export const users = { ], baseUser ), + suspendedCommenter: createFixture( + { + id: "user-suspended-0", + username: "lol1111", + email: "lol1111@test.com", + role: GQLUSER_ROLE.COMMENTER, + ignoreable: true, + status: { + current: [GQLUSER_STATUS.SUSPENDED], + ban: { active: false }, + suspension: { + active: true, + until: new Date(Date.now() + 600000).toISOString(), + }, + }, + }, + baseUser + ), bannedCommenter: createFixture( { id: "user-banned-0", diff --git a/src/core/client/framework/components/DurationField.tsx b/src/core/client/framework/components/DurationField.tsx index 63b609f35..046e03ecb 100644 --- a/src/core/client/framework/components/DurationField.tsx +++ b/src/core/client/framework/components/DurationField.tsx @@ -1,6 +1,7 @@ import { Localized } from "fluent-react/compat"; import React, { ChangeEvent, Component } from "react"; +import { UNIT } from "coral-framework/lib/i18n"; import { Flex, Option, SelectField, TextField } from "coral-ui/components"; import styles from "./DurationField.css"; @@ -9,22 +10,16 @@ import styles from "./DurationField.css"; * DURATION_UNIT are units that can be used in the * DurationField components. */ -export enum DURATION_UNIT { - SECONDS = 1, - MINUTES = 60, - HOURS = 3600, - DAYS = 86400, - WEEKS = 604800, -} +export const DURATION_UNIT = UNIT; type UnitElementCallback = ( - currentValue: DURATION_UNIT, + currentValue: UNIT, unitValue: string ) => React.ReactElement; // This is used to render the Option elements to inlcude in the select field. -const unitElementMap: Record = { - [DURATION_UNIT.SECONDS]: (currentValue, unitValue) => ( +const unitElementMap: Record = { + [UNIT.SECONDS]: (currentValue, unitValue) => ( = { ), - [DURATION_UNIT.MINUTES]: (currentValue, unitValue) => ( + [UNIT.MINUTES]: (currentValue, unitValue) => ( = { ), - [DURATION_UNIT.HOURS]: (currentValue, unitValue) => ( + [UNIT.HOURS]: (currentValue, unitValue) => ( = { ), - [DURATION_UNIT.DAYS]: (currentValue, unitValue) => ( + [UNIT.DAYS]: (currentValue, unitValue) => ( = { ), - [DURATION_UNIT.WEEKS]: (currentValue, unitValue) => ( + [UNIT.WEEKS]: (currentValue, unitValue) => ( void; /** Specifiy units to include */ - units?: ReadonlyArray; + units?: ReadonlyArray; } interface State { /** Current value */ value: string; /** Current unit */ - unit?: DURATION_UNIT; + unit?: UNIT; /** All available units */ - units: ReadonlyArray; + units: ReadonlyArray; /** * Element callbacks to generate the rendered * Option element for the select field @@ -100,11 +95,7 @@ interface State { * @param units The units that we use. * @param unit The current value if any otherwise the best matching unit will be used. */ -function valueToState( - value: string, - units: ReadonlyArray, - unit?: DURATION_UNIT -) { +function valueToState(value: string, units: ReadonlyArray, unit?: UNIT) { const parsed = parseInt(value, 10); // If value was a valid number.. @@ -147,7 +138,7 @@ function stateToValue(state: State) { */ class DurationField extends Component { public static defaultProps: Partial = { - units: [DURATION_UNIT.HOURS, DURATION_UNIT.DAYS, DURATION_UNIT.WEEKS], + units: [UNIT.HOURS, UNIT.DAYS, UNIT.WEEKS], }; public state: State = valueToState(this.props.value, this.props.units!); diff --git a/src/core/client/framework/lib/i18n/format.ts b/src/core/client/framework/lib/i18n/format.ts new file mode 100644 index 000000000..47c79e011 --- /dev/null +++ b/src/core/client/framework/lib/i18n/format.ts @@ -0,0 +1,18 @@ +import { FluentBundle } from "fluent/compat"; + +export default function format( + bundles: FluentBundle[], + key: string, + args?: object +): string { + const res = bundles.reduce((val, bundle) => { + const message = bundle.getMessage(key); + const got = bundle.format(message, args); + return val || got; + }, ""); + if (res && Array.isArray(res)) { + return res.join(""); + } + + return res || ""; +} diff --git a/src/core/client/framework/lib/i18n/getMessage.ts b/src/core/client/framework/lib/i18n/getMessage.ts index 9a6fad517..03b3dcd2c 100644 --- a/src/core/client/framework/lib/i18n/getMessage.ts +++ b/src/core/client/framework/lib/i18n/getMessage.ts @@ -1,17 +1,21 @@ import { FluentBundle } from "fluent/compat"; -export default function getMessage( +export default function getMessage( bundles: FluentBundle[], key: string, - defaultTo = "" + defaultTo: string, + args?: T ): string { const res = bundles.reduce((val, bundle) => { - const got = bundle.getMessage(key); - if (!got && process.env.NODE_ENV !== "production") { + const message = bundle.getMessage(key); + if (!message && process.env.NODE_ENV !== "production") { // tslint:disable-next-line:no-console console.warn(`Translation ${key} was not found for ${bundle.locales}`); } - return val || got; + if (!args) { + return val || message; + } + return val || bundle.format(message, args); }, ""); if (res && Array.isArray(res)) { return res.join(""); diff --git a/src/core/client/framework/lib/i18n/index.ts b/src/core/client/framework/lib/i18n/index.ts index c5d064651..ccc154ea6 100644 --- a/src/core/client/framework/lib/i18n/index.ts +++ b/src/core/client/framework/lib/i18n/index.ts @@ -3,3 +3,4 @@ export { default as negotiateLanguages } from "./negotiateLanguages"; export { BundledLocales, LoadableLocales, LocalesData } from "./locales"; export { default as getMessage } from "./getMessage"; export { default as withGetMessage, GetMessage } from "./withGetMessage"; +export { default as reduceSeconds, UNIT, ScaledUnit } from "./reduceSeconds"; diff --git a/src/core/client/framework/lib/i18n/reduceSeconds.ts b/src/core/client/framework/lib/i18n/reduceSeconds.ts new file mode 100644 index 000000000..c11761039 --- /dev/null +++ b/src/core/client/framework/lib/i18n/reduceSeconds.ts @@ -0,0 +1,55 @@ +/** + * UNIT are units that can be used in the + * DurationField components. + */ +export enum UNIT { + SECONDS = 1, + MINUTES = 60, + HOURS = 3600, + DAYS = 86400, + WEEKS = 604800, +} + +export const UNIT_MAP = { + [UNIT.SECONDS]: "second", + [UNIT.MINUTES]: "minute", + [UNIT.HOURS]: "hour", + [UNIT.DAYS]: "day", + [UNIT.WEEKS]: "week", +}; + +export const DEFAULT_UNITS = [ + UNIT.WEEKS, + UNIT.DAYS, + UNIT.HOURS, + UNIT.MINUTES, + UNIT.SECONDS, +]; + +type ValueOf = T[keyof T]; + +export interface ScaledUnit { + original: number; + value: string; + unit: ValueOf; + scaled: number; +} + +export default function reduceSeconds( + value: number, + units: UNIT[] = DEFAULT_UNITS +): ScaledUnit { + // Find the largest match for the smallest number. + const unit: keyof typeof UNIT_MAP = + units.find(compare => value >= compare) || UNIT.SECONDS; + + // Scale the value to the unit. + const scaled = Math.round((value / unit) * 100) / 100; + + return { + original: value, + value: value.toString(), + scaled, + unit: UNIT_MAP[unit], + }; +} diff --git a/src/core/client/framework/lib/i18n/withGetMessage.tsx b/src/core/client/framework/lib/i18n/withGetMessage.tsx index f077cc645..deb6ce8a2 100644 --- a/src/core/client/framework/lib/i18n/withGetMessage.tsx +++ b/src/core/client/framework/lib/i18n/withGetMessage.tsx @@ -4,7 +4,7 @@ import { DefaultingInferableComponentEnhancer, hoistStatics } from "recompose"; import { CoralContext, withContext } from "../bootstrap"; import getMessage from "./getMessage"; -export type GetMessage = (id: string, defaultTo?: string) => string; +export type GetMessage = (id: string, defaultTo: string, args?: T) => string; interface InjectedProps { getMessage: GetMessage; @@ -35,8 +35,12 @@ const withGetMessage: DefaultingInferableComponentEnhancer< const Workaround = BaseComponent as React.ComponentType; class WithGetMessage extends React.Component { - private getMessage = (id: string, defaultTo?: string) => { - return getMessage(this.props.localeBundles, id, defaultTo); + private getMessage = ( + id: string, + defaultTo: string, + args?: U + ): string => { + return getMessage(this.props.localeBundles, id, defaultTo, args); }; public render() { const { localeBundles: _, ...rest } = this.props; diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx index b13272ba7..a847a08fe 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx @@ -92,8 +92,13 @@ export class CommentContainer extends Component { this.props.viewer && this.props.viewer.status.current.includes(GQLUSER_STATUS.BANNED) ); + const suspended = Boolean( + this.props.viewer && + this.props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED) + ); return ( !banned && + !suspended && isMyComment && isBeforeDate(this.props.comment.editing.editableUntil) ); @@ -181,6 +186,10 @@ export class CommentContainer extends Component { this.props.viewer && this.props.viewer.status.current.includes(GQLUSER_STATUS.BANNED) ); + const suspended = Boolean( + this.props.viewer && + this.props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED) + ); const showCaret = this.props.viewer && roleIsAtLeast(this.props.viewer.role, GQLUSER_ROLE.MODERATOR); @@ -263,9 +272,9 @@ export class CommentContainer extends Component { comment={comment} settings={settings} viewer={viewer} - readOnly={banned} + readOnly={banned || suspended} /> - {!disableReplies && !banned && ( + {!disableReplies && !banned && !suspended && ( { /> - {!banned && ( + {!banned && !suspended && ( = props => { const banned = Boolean( props.viewer && props.viewer.status.current.includes(GQLUSER_STATUS.BANNED) ); + const suspended = Boolean( + props.viewer && + props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED) + ); const allCommentsCount = props.story.commentCounts.totalVisible; const featuredCommentsCount = props.story.commentCounts.tags.FEATURED; @@ -100,7 +105,7 @@ export const StreamContainer: FunctionComponent = props => { - {!banned && ( + {!banned && !suspended && ( = props => { /> )} {banned && } + {suspended && ( + + )} ({ ...CreateCommentReplyMutation_viewer ...CreateCommentMutation_viewer ...PostCommentFormContainer_viewer + ...SuspendedInfoContainer_viewer status { current } @@ -206,6 +218,7 @@ const enhanced = withFragmentContainer({ ...PostCommentFormContainer_settings ...UserBoxContainer_settings ...CommunityGuidelinesContainer_settings + ...SuspendedInfoContainer_settings } `, })(StreamContainer); diff --git a/src/core/client/stream/tabs/Comments/Stream/SuspendedInfo/SuspendedInfo.tsx b/src/core/client/stream/tabs/Comments/Stream/SuspendedInfo/SuspendedInfo.tsx new file mode 100644 index 000000000..b4a28818d --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/SuspendedInfo/SuspendedInfo.tsx @@ -0,0 +1,50 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent, useMemo } from "react"; + +import { useCoralContext } from "coral-framework/lib/bootstrap"; + +import { CallOut, HorizontalGutter, Typography } from "coral-ui/components"; + +interface Props { + organization: string; + until: string; +} + +const SuspendedInfo: FunctionComponent = ({ until, organization }) => { + const { locales } = useCoralContext(); + const untilDate = useMemo(() => { + const formatter = new Intl.DateTimeFormat(locales, { + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + return formatter.format(new Date(until)); + }, [locales, until]); + return ( + + + + + Your account has been temporarily suspended from commenting. + + + + + In accordance with {organization}'s community guidelines your + account has been temporarily suspended. While suspended you will not + be able to comment, respect or report comments. Please rejoin the + conversation on {untilDate}. + + + + + ); +}; + +export default SuspendedInfo; diff --git a/src/core/client/stream/tabs/Comments/Stream/SuspendedInfo/SuspendedInfoContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/SuspendedInfo/SuspendedInfoContainer.tsx new file mode 100644 index 000000000..2500c80e4 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/SuspendedInfo/SuspendedInfoContainer.tsx @@ -0,0 +1,48 @@ +import { withFragmentContainer } from "coral-framework/lib/relay"; +import { SuspendedInfoContainer_settings as SettingsData } from "coral-stream/__generated__/SuspendedInfoContainer_settings.graphql"; +import { SuspendedInfoContainer_viewer as ViewerData } from "coral-stream/__generated__/SuspendedInfoContainer_viewer.graphql"; +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import SuspendedInfo from "./SuspendedInfo"; + +interface Props { + settings: SettingsData; + viewer: ViewerData | null; +} + +export const SuspendedInfoContainer: FunctionComponent = ({ + settings, + viewer, +}) => { + if (!viewer) { + return null; + } + return ( + + ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment SuspendedInfoContainer_viewer on User { + status { + suspension { + until + } + } + } + `, + settings: graphql` + fragment SuspendedInfoContainer_settings on Settings { + organization { + name + } + } + `, +})(SuspendedInfoContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Stream/SuspendedInfo/index.ts b/src/core/client/stream/tabs/Comments/Stream/SuspendedInfo/index.ts new file mode 100644 index 000000000..1cf0add23 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/SuspendedInfo/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as SuspendedInfoContainer, +} from "./SuspendedInfoContainer"; diff --git a/src/core/client/stream/test/comments/stream/suspended.spec.tsx b/src/core/client/stream/test/comments/stream/suspended.spec.tsx new file mode 100644 index 000000000..b31bf4fab --- /dev/null +++ b/src/core/client/stream/test/comments/stream/suspended.spec.tsx @@ -0,0 +1,105 @@ +import timekeeper from "timekeeper"; + +import { pureMerge } from "coral-common/utils"; +import { GQLResolver, GQLUSER_STATUS } from "coral-framework/schema"; +import { + createResolversStub, + CreateTestRendererParams, + waitForElement, + within, +} from "coral-framework/testHelpers"; + +import { comments, settings, stories } from "../../fixtures"; +import create from "./create"; + +const story = stories[0]; +const firstComment = story.comments.edges[0].node; +const viewer = firstComment.author!; + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => + pureMerge(viewer, { + status: { + current: [GQLUSER_STATUS.SUSPENDED], + suspension: { + until: new Date(Date.now() + 600000).toISOString(), + }, + }, + }), + story: () => + pureMerge(story, { + comments: { + edges: [ + ...story.comments.edges, + { + node: pureMerge(comments[2], { + actionCounts: { reaction: { total: 1 } }, + }), + cursor: comments[2].createdAt, + }, + ], + }, + }), + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(story.id, "storyID"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + + const tabPane = await waitForElement(() => + within(testRenderer.root).getByTestID("current-tab-pane") + ); + + return { + testRenderer, + context, + tabPane, + }; +} + +afterAll(() => { + timekeeper.reset(); +}); + +it("disables comment stream", async () => { + timekeeper.freeze(firstComment.createdAt); + const { testRenderer, tabPane } = await createTestRenderer(); + await waitForElement(() => + within(testRenderer.root).getByTestID("comments-allComments-log") + ); + within(tabPane).getAllByText( + "Your account has been temporarily suspended from commenting.", + { + exact: false, + } + ); + within(tabPane).getAllByText("In accordance with Acme Co", { + exact: false, + }); + expect( + within(tabPane).queryByText("Reply", { selector: "button" }) + ).toBeNull(); + expect( + within(tabPane).queryByText("Report", { selector: "button" }) + ).toBeNull(); + expect( + within(tabPane).queryByText("Edit", { selector: "button" }) + ).toBeNull(); + expect( + within(tabPane).getByText("Respect", { selector: "button" }).props.disabled + ).toBe(true); +}); diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index 7a7d4f9e5..55c58248f 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -40,6 +40,11 @@ export const settings = createFixture({ message: "Story is closed", timeout: undefined, }, + organization: { + name: "Acme Co", + contactEmail: "acme@acme.co", + url: "https://acme.co", + }, auth: { integrations: { facebook: { @@ -110,6 +115,9 @@ export const baseUser = createFixture({ createdAt: "2018-02-06T18:24:00.000Z", status: { current: [GQLUSER_STATUS.ACTIVE], + suspension: { + active: false, + }, }, ignoredUsers: [], comments: { diff --git a/src/core/server/graph/tenant/mutators/Users.ts b/src/core/server/graph/tenant/mutators/Users.ts index 009477fae..70e18df13 100644 --- a/src/core/server/graph/tenant/mutators/Users.ts +++ b/src/core/server/graph/tenant/mutators/Users.ts @@ -133,6 +133,7 @@ export const Users = (ctx: TenantContext) => ({ ctx.tenant, ctx.user!, input.userID, + input.message, ctx.now ), suspend: async (input: GQLSuspendUserInput) => @@ -143,6 +144,7 @@ export const Users = (ctx: TenantContext) => ({ ctx.user!, input.userID, input.timeout, + input.message, ctx.now ), removeBan: async (input: GQLRemoveUserBanInput) => diff --git a/src/core/server/graph/tenant/resolvers/BanStatusHistory.ts b/src/core/server/graph/tenant/resolvers/BanStatusHistory.ts index eabe6b207..5e99f5173 100644 --- a/src/core/server/graph/tenant/resolvers/BanStatusHistory.ts +++ b/src/core/server/graph/tenant/resolvers/BanStatusHistory.ts @@ -13,4 +13,5 @@ export const BanStatusHistory: Required< return null; }, createdAt: ({ createdAt }) => createdAt, + message: ({ message }) => message, }; diff --git a/src/core/server/graph/tenant/resolvers/SuspensionStatusHistory.ts b/src/core/server/graph/tenant/resolvers/SuspensionStatusHistory.ts index 2657dea13..a48d4c6f3 100644 --- a/src/core/server/graph/tenant/resolvers/SuspensionStatusHistory.ts +++ b/src/core/server/graph/tenant/resolvers/SuspensionStatusHistory.ts @@ -23,4 +23,5 @@ export const SuspensionStatusHistory: Required< return null; }, modifiedAt: ({ modifiedAt }) => modifiedAt, + message: ({ message }) => message, }; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index c54c83375..d736b62a4 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -1301,6 +1301,11 @@ type BanStatusHistory { createdAt is the time that the given User was banned. """ createdAt: Time! + + """ + message is sent to banned user via email. + """ + message: String! } """ @@ -1375,6 +1380,11 @@ type SuspensionStatusHistory { then the suspension has not been cancelled/edited. """ modifiedAt: Time + + """ + message is sent to suspended user via email. + """ + message: String! } """ @@ -4225,6 +4235,11 @@ input BanUserInput { clientMutationId is required for Relay support. """ clientMutationId: String! + + """ + message is sent to banned user via email. + """ + message: String! } type BanUserPayload { @@ -4259,6 +4274,11 @@ input SuspendUserInput { clientMutationId is required for Relay support. """ clientMutationId: String! + + """ + message is sent to suspended user via email. + """ + message: String! } type SuspendUserPayload { diff --git a/src/core/server/locales/en-US/email.ftl b/src/core/server/locales/en-US/email.ftl index 9aa835461..a0c10054b 100644 --- a/src/core/server/locales/en-US/email.ftl +++ b/src/core/server/locales/en-US/email.ftl @@ -10,11 +10,9 @@ email-notification-template-forgotPassword = email-subject-forgotPassword = Password Reset Request email-notification-template-ban = - Hello { $username },

- 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. if you think this has been done in error, - please contact our community team at { $organizationContactEmail }. + { $customMessage }

+ if you think this has been done in error, please contact our community team + at { $organizationContactEmail }. email-subject-ban = Your account has been banned @@ -27,11 +25,7 @@ email-notification-template-passwordChange = email-subject-passwordChange = Your password has been changed email-notification-template-suspend = - Hello { $username },

- In accordance with { $organizationName }'s community guidelines, your - account has been temporarily suspended. During the suspension, you will be - unable to comment, flag or engage with fellow commenters. Please rejoin the - conversation { $until }.

+ { $customMessage }

If you think this has been done in error, please contact our community team at { $organizationContactEmail }. diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 8a06ff5fb..359ed8029 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -142,6 +142,11 @@ export interface SuspensionStatusHistory { * was edited at. */ modifiedAt?: Date; + + /** + * message is the email message content sent to the user. + */ + message: string; } /** @@ -180,6 +185,8 @@ export interface BanStatusHistory { * createdAt is the time that the given ban was added. */ createdAt: Date; + + message?: string; } /** @@ -1128,6 +1135,7 @@ async function retrieveConnection( * @param tenantID the Tenant's ID where the User exists * @param id the ID of the user being banned * @param createdBy the ID of the user banning the above mentioned user + * @param message message to banned user * @param now the current date */ export async function banUser( @@ -1135,6 +1143,7 @@ export async function banUser( tenantID: string, id: string, createdBy: string, + message?: string, now = new Date() ) { // Create the new ban. @@ -1143,6 +1152,7 @@ export async function banUser( active: true, createdBy, createdAt: now, + message, }; // Try to update the user if the user isn't already banned. @@ -1267,6 +1277,7 @@ export async function removeUserBan( * @param id the ID of the user being suspended * @param createdBy the ID of the user banning the above mentioned user * @param from the range of time that the user is being banned for + * @param message the message sent to suspended user in email * @param now the current date */ export async function suspendUser( @@ -1275,6 +1286,7 @@ export async function suspendUser( id: string, createdBy: string, finish: Date, + message: string, now = new Date() ) { // Create the new suspension. @@ -1286,6 +1298,7 @@ export async function suspendUser( }, createdBy, createdAt: now, + message, }; // Try to update the user if the user isn't already suspended. diff --git a/src/core/server/queue/tasks/mailer/templates/index.ts b/src/core/server/queue/tasks/mailer/templates/index.ts index aba4c7dd0..ad3941ef3 100644 --- a/src/core/server/queue/tasks/mailer/templates/index.ts +++ b/src/core/server/queue/tasks/mailer/templates/index.ts @@ -24,6 +24,7 @@ export type BanTemplate = UserNotificationContext< { username: string; organizationContactEmail: string; + customMessage?: string; } >; @@ -33,6 +34,7 @@ export type SuspendTemplate = UserNotificationContext< username: string; until: string; organizationContactEmail: string; + customMessage?: string; } >; diff --git a/src/core/server/queue/tasks/mailer/templates/suspend.html b/src/core/server/queue/tasks/mailer/templates/suspend.html index 46ed0ed7b..217e8041f 100644 --- a/src/core/server/queue/tasks/mailer/templates/suspend.html +++ b/src/core/server/queue/tasks/mailer/templates/suspend.html @@ -2,10 +2,12 @@ {% block content %} Hello {{ context.username }},

+ In accordance with {{ context.organizationName }}'s community guidelines, your account has been temporarily suspended. During the suspension, you will be unable to comment, flag or engage with fellow commenters. Please rejoin the conversation {{ context.until }}.

- If you think this has been done in error, please contact our community team - at {{ context.organizationContactEmail }}. + + If you think this has been done in error, please contact our community team at + {{ context.organizationContactEmail }}. {% endblock %} diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 8594614f1..d855d2c63 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -447,6 +447,7 @@ export async function updateAvatar( * @param tenant Tenant where the User will be banned on * @param user the User that is banning the User * @param userID the ID of the User being banned + * @param message message to banned user * @param now the current time that the ban took effect */ export async function ban( @@ -455,6 +456,7 @@ export async function ban( tenant: Tenant, banner: User, userID: string, + message: string, now = new Date() ) { // Get the user being banned to check to see if the user already has an @@ -471,7 +473,7 @@ export async function ban( } // Ban the user. - const user = await banUser(mongo, tenant.id, userID, banner.id, now); + const user = await banUser(mongo, tenant.id, userID, banner.id, message, now); // If the user has an email address associated with their account, send them // a ban notification email. @@ -490,6 +492,7 @@ export async function ban( organizationName: tenant.organization.name, organizationURL: tenant.organization.url, organizationContactEmail: tenant.organization.contactEmail, + customMessage: (message || "").replace(/\n/g, "
"), }, }, }); @@ -506,6 +509,7 @@ export async function ban( * @param user the User that is suspending the User * @param userID the ID of the user being suspended * @param timeout the duration in seconds that the user will suspended for + * @param message message to suspended user * @param now the current time that the suspension will take effect */ export async function suspend( @@ -515,6 +519,7 @@ export async function suspend( user: User, userID: string, timeout: number, + message: string, now = new Date() ) { // Convert the timeout to the until time. @@ -542,6 +547,7 @@ export async function suspend( userID, user.id, finishDateTime.toJSDate(), + message, now ); @@ -563,6 +569,7 @@ export async function suspend( organizationName: tenant.organization.name, organizationURL: tenant.organization.url, organizationContactEmail: tenant.organization.contactEmail, + customMessage: (message || "").replace(/\n/g, "
"), }, }, }); diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index d33c535cd..8e2f3a681 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -577,6 +577,34 @@ community-banModal-consequence = reactions, or report comments. community-banModal-cancel = Cancel community-banModal-banUser = Ban User +community-banModal-customize = Customize ban email message +community-banModal-emailTemplate = + Hello { $username }, + + 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 + +community-suspendModal-areYouSure = Suspend { $username }? +community-suspendModal-consequence = + Once suspended, this user will no longer be able to comment, use + reactions, or report comments. +community-suspendModal-duration-3600 = 1 hour +community-suspendModal-duration-10800 = 3 hours +community-suspendModal-duration-86400 = 24 hours +community-suspendModal-duration-604800 = 7 days +community-suspendModal-cancel = Cancel +community-suspendModal-suspendUser = Suspend User +community-suspendModal-emailTemplate = + Hello { $username }, + + In accordance with { $organizationName }'s community guidelines, your account has been temporarily suspended. During the suspension, you will be unable to comment, flag or engage with fellow commenters. Please rejoin the conversation in { framework-timeago-time }. + +community-suspendModal-customize = Customize suspension email message + +community-suspendModal-success = + { $username } has been suspended for { $duration } + +community-suspendModal-success-close = Close +community-suspendModal-selectDuration = Select suspension length community-invite-inviteMember = Invite members to your organization community-invite-emailAddressLabel = Email address: diff --git a/src/locales/en-US/framework.ftl b/src/locales/en-US/framework.ftl index ffc6f4151..f282cee11 100644 --- a/src/locales/en-US/framework.ftl +++ b/src/locales/en-US/framework.ftl @@ -36,8 +36,7 @@ framework-validation-notAWholeNumberGreaterThanOrEqual = Please enter a whole nu framework-timeago-just-now = Just now framework-timeago-time = - { $value } - { $unit -> + { $value } { $unit -> [second] { $value -> [1] second *[other] seconds diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index c889ad202..57de81652 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -245,3 +245,10 @@ configure-openStream-description = configure-openStream-openStream = Open Stream comments-tombstone-ignore = This comment is hidden because you ignored {$username} + +suspendInfo-heading = Your account has been temporarily suspended from commenting. +suspendInfo-info = + In accordance with { $organization }'s community guidelines your + account has been temporarily suspended. While suspended you will not + be able to comment, respect or report comments. Please rejoin the + conversation on { $until } \ No newline at end of file