diff --git a/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx b/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx index 65f137d58..ed5546cc4 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx @@ -6,14 +6,16 @@ import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction"; import UsernameChangeAction, { UsernameChangeActionProps, } from "./UsernameChangeAction"; +import WarningAction, { WarningActionProps } from "./WarningAction"; export interface HistoryActionProps { - kind: "username" | "suspension" | "ban" | "premod"; + kind: "username" | "suspension" | "ban" | "premod" | "warning"; action: | UsernameChangeActionProps | SuspensionActionProps | BanActionProps - | PremodActionProps; + | PremodActionProps + | WarningActionProps; } const AccountHistoryAction: FunctionComponent = ({ @@ -31,6 +33,8 @@ const AccountHistoryAction: FunctionComponent = ({ return ; case "premod": return ; + case "warning": + return ; default: return null; } diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx index 7dcaf18d7..a2a2ee0e3 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx @@ -133,6 +133,30 @@ const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { }); }); + user.status.warning.history.forEach((record, i) => { + let action: "created" | "acknowledged" | "removed"; + if (record.active) { + action = "created"; + } else { + if (record.acknowledgedAt) { + action = "acknowledged"; + } else { + action = "removed"; + } + } + history.push({ + kind: "warning", + date: new Date(record.createdAt), + takenBy: record.createdBy.username, + action: { + action, + acknowledgedAt: record.acknowledgedAt + ? new Date(record.acknowledgedAt) + : null, + }, + }); + }); + // Sort the history so that it's in the right order. return history.sort((a, b) => b.date.getTime() - a.date.getTime()); }, [user]); @@ -193,6 +217,16 @@ const enhanced = withFragmentContainer({ } } } + warning { + history { + active + createdBy { + username + } + acknowledgedAt + createdAt + } + } ban { history { active diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserStatusDetailsContainer.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserStatusDetailsContainer.tsx index e14e2fa82..03aef61c9 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserStatusDetailsContainer.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserStatusDetailsContainer.tsx @@ -1,4 +1,5 @@ import { Localized } from "@fluent/react/compat"; +import { last } from "lodash"; import React, { FunctionComponent, useMemo } from "react"; import { graphql } from "react-relay"; @@ -22,13 +23,23 @@ interface Props { const UserStatusDetailsContainer: FunctionComponent = ({ user }) => { const activeBan = useMemo(() => { - return user.status.ban.history.find((item) => item.active); + if (user.status.ban.active) { + return last(user.status.ban.history); + } + return null; }, [user]); const activeSuspension = useMemo(() => { return user.status.suspension.history.find((item) => item.active); }, [user]); + const activeWarning = useMemo(() => { + if (user.status.warning.active) { + return last(user.status.warning.history); + } + return null; + }, [user]); + const formatter = useDateTimeFormatter({ day: "2-digit", month: "2-digit", @@ -37,7 +48,11 @@ const UserStatusDetailsContainer: FunctionComponent = ({ user }) => { minute: "2-digit", }); - if (!user.status.ban.active && !user.status.suspension.active) { + if ( + !user.status.ban.active && + !user.status.suspension.active && + !user.status.warning.active + ) { return null; } @@ -49,6 +64,37 @@ const UserStatusDetailsContainer: FunctionComponent = ({ user }) => { body={({ toggleVisibility }) => ( + {activeWarning && ( +
+ } + > +

+ Warned on {" "} + {formatter(activeWarning.createdAt)} +

+
+ {activeWarning.createdBy && ( + } + $username={activeWarning.createdBy.username} + > +

+ by + {activeWarning.createdBy.username} +

+
+ )} + +

+ User has not acknowledged the warning. +

+
+
+ )} {activeBan && (
({ user: graphql` fragment UserStatusDetailsContainer_user on User { status { + warning { + active + history { + active + createdBy { + username + } + createdAt + } + } ban { active history { diff --git a/src/core/client/admin/components/UserHistoryDrawer/WarningAction.tsx b/src/core/client/admin/components/UserHistoryDrawer/WarningAction.tsx new file mode 100644 index 000000000..b86714df4 --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/WarningAction.tsx @@ -0,0 +1,43 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +export interface WarningActionProps { + action: "created" | "removed" | "acknowledged"; + acknowledgedAt: Date | null; +} + +import { useDateTimeFormatter } from "coral-framework/hooks"; + +const WarningAction: FunctionComponent = ({ + action, + acknowledgedAt, +}) => { + const formatter = useDateTimeFormatter({ + hour: "numeric", + minute: "2-digit", + }); + if (action === "created") { + return ( + + User warned + + ); + } else if (action === "removed") { + return ( + + Warning removed + + ); + } else if (action === "acknowledged") { + return ( + + + Warning acknowledged at{" "} + {acknowledgedAt ? formatter(acknowledgedAt) : ""} + + + ); + } + return null; +}; +export default WarningAction; diff --git a/src/core/client/admin/components/UserStatus/RemoveUserWarningMutation.ts b/src/core/client/admin/components/UserStatus/RemoveUserWarningMutation.ts new file mode 100644 index 000000000..1774cbccd --- /dev/null +++ b/src/core/client/admin/components/UserStatus/RemoveUserWarningMutation.ts @@ -0,0 +1,84 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { getViewer } from "coral-framework/helpers"; +import { + commitMutationPromiseNormalized, + createMutation, + lookup, + MutationInput, +} from "coral-framework/lib/relay"; +import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema"; + +import { RemoveUserWarningMutation as MutationTypes } from "coral-admin/__generated__/RemoveUserWarningMutation.graphql"; + +let clientMutationId = 0; + +const RemoveUserWarningMutation = createMutation( + "removeUserWarning", + (environment: Environment, input: MutationInput) => { + const viewer = getViewer(environment)!; + const now = new Date(); + return commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation RemoveUserWarningMutation($input: RemoveUserWarningInput!) { + removeUserWarning(input: $input) { + user { + id + status { + current + warning { + active + history { + active + createdAt + createdBy { + id + username + } + } + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + optimisticResponse: { + removeUserWarning: { + user: { + id: input.userID, + status: { + current: lookup( + environment, + input.userID + )!.status.current.filter((s) => s !== GQLUSER_STATUS.WARNED), + warning: { + active: false, + history: [ + { + active: false, + createdBy: { + id: viewer.id, + username: viewer.username, + }, + createdAt: now.toISOString(), + }, + ], + }, + }, + }, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); + } +); + +export default RemoveUserWarningMutation; diff --git a/src/core/client/admin/components/UserStatus/UserStatus.tsx b/src/core/client/admin/components/UserStatus/UserStatus.tsx index 9f347ac6e..f2daf3f95 100644 --- a/src/core/client/admin/components/UserStatus/UserStatus.tsx +++ b/src/core/client/admin/components/UserStatus/UserStatus.tsx @@ -10,6 +10,7 @@ interface Props { banned: boolean; suspended: boolean; premod: boolean; + warned: boolean; } const render = (className: string, content: React.ReactNode) => ( @@ -45,6 +46,14 @@ const UserStatus: FunctionComponent = (props) => { ); } + if (props.warned) { + return render( + styles.warning, + +
Warned
+
+ ); + } return render( styles.success, diff --git a/src/core/client/admin/components/UserStatus/UserStatusChange.tsx b/src/core/client/admin/components/UserStatus/UserStatusChange.tsx index 52f452485..9a93c23ec 100644 --- a/src/core/client/admin/components/UserStatus/UserStatusChange.tsx +++ b/src/core/client/admin/components/UserStatus/UserStatusChange.tsx @@ -32,9 +32,12 @@ interface Props { onRemoveSuspension: () => void; onPremod: () => void; onRemovePremod: () => void; + onWarn: () => void; + onRemoveWarning: () => void; banned: boolean; suspended: boolean; premod: boolean; + warned: boolean; children: React.ReactNode; fullWidth?: boolean; bordered?: boolean; @@ -47,6 +50,9 @@ const UserStatusChange: FunctionComponent = ({ onRemoveSuspension, onPremod, onRemovePremod, + onWarn, + onRemoveWarning, + warned, banned, suspended, premod, @@ -143,6 +149,37 @@ const UserStatusChange: FunctionComponent = ({ )} + {warned ? ( + + { + if (onRemoveWarning) { + onRemoveWarning(); + toggleVisibility(); + } + }} + > + Remove warning + + + ) : ( + + { + if (onWarn) { + onWarn(); + toggleVisibility(); + } + }} + > + Warn + + + )} )} diff --git a/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx b/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx index e7a4a4a00..2f4bcdbec 100644 --- a/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx +++ b/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx @@ -15,10 +15,13 @@ import PremodUserMutation from "./PremodUserMutation"; import RemoveUserBanMutation from "./RemoveUserBanMutation"; import RemoveUserPremodMudtaion from "./RemoveUserPremodMutation"; import RemoveUserSuspensionMutation from "./RemoveUserSuspensionMutation"; +import RemoveUserWarningMutation from "./RemoveUserWarningMutation"; import SuspendModal from "./SuspendModal"; import SuspendUserMutation from "./SuspendUserMutation"; import UserStatusChange from "./UserStatusChange"; import UserStatusContainer from "./UserStatusContainer"; +import WarnModal from "./WarnModal"; +import WarnUserMutation from "./WarnUserMutation"; interface Props { settings: UserStatusChangeContainer_settings; @@ -41,10 +44,37 @@ const UserStatusChangeContainer: FunctionComponent = ({ const removeUserSuspension = useMutation(RemoveUserSuspensionMutation); const premodUser = useMutation(PremodUserMutation); const removeUserPremod = useMutation(RemoveUserPremodMudtaion); + const warnUser = useMutation(WarnUserMutation); + const removeUserWarning = useMutation(RemoveUserWarningMutation); const [showPremod, setShowPremod] = useState(false); const [showBanned, setShowBanned] = useState(false); const [showSuspend, setShowSuspend] = useState(false); + const [showWarn, setShowWarn] = useState(false); const [showSuspendSuccess, setShowSuspendSuccess] = useState(false); + const [showWarnSuccess, setShowWarnSuccess] = useState(false); + const handleWarn = useCallback(() => { + if (user.status.warning.active) { + return; + } + setShowWarn(true); + }, [user, setShowWarn]); + const handleRemoveWarning = useCallback(() => { + if (!user.status.warning.active) { + return; + } + void removeUserWarning({ userID: user.id }); + }, [user, removeUserWarning]); + const hideWarn = useCallback(() => { + setShowWarn(false); + setShowWarnSuccess(false); + }, [setShowWarn]); + const handleWarnConfirm = useCallback( + (message: string) => { + void warnUser({ userID: user.id, message }); + setShowWarnSuccess(true); + }, + [warnUser, user, setShowWarnSuccess] + ); const handleBan = useCallback(() => { if (user.status.ban.active) { return; @@ -91,12 +121,12 @@ const UserStatusChangeContainer: FunctionComponent = ({ return; } void removeUserPremod({ userID: user.id }); - }, [user, premodUser]); + }, [user, removeUserPremod]); const handleSuspendModalClose = useCallback(() => { setShowSuspend(false); setShowSuspendSuccess(false); - }, [setShowBanned, setShowSuspendSuccess]); + }, [setShowSuspendSuccess]); const handleBanModalClose = useCallback(() => { setShowBanned(false); @@ -119,7 +149,7 @@ const UserStatusChangeContainer: FunctionComponent = ({ void banUser({ userID: user.id, message, rejectExistingComments }); setShowBanned(false); }, - [user, setShowBanned] + [user, setShowBanned, banUser] ); if (user.role !== GQLUSER_ROLE.COMMENTER) { @@ -140,6 +170,9 @@ const UserStatusChangeContainer: FunctionComponent = ({ banned={user.status.ban.active} suspended={user.status.suspension.active} premod={user.status.premod.active} + warned={user.status.warning.active} + onWarn={handleWarn} + onRemoveWarning={handleRemoveWarning} fullWidth={fullWidth} bordered={bordered} > @@ -159,6 +192,14 @@ const UserStatusChangeContainer: FunctionComponent = ({ onClose={hidePremod} onConfirm={handlePremodConfirm} /> + {!scoped && ( ({ premod { active } + warning { + active + } } ...UserStatusContainer_user } diff --git a/src/core/client/admin/components/UserStatus/UserStatusContainer.tsx b/src/core/client/admin/components/UserStatus/UserStatusContainer.tsx index 54c5661af..ce386c81d 100644 --- a/src/core/client/admin/components/UserStatus/UserStatusContainer.tsx +++ b/src/core/client/admin/components/UserStatus/UserStatusContainer.tsx @@ -18,6 +18,7 @@ const UserStatusContainer: FunctionComponent = (props) => { banned={props.user.status.current.includes(GQLUSER_STATUS.BANNED)} suspended={props.user.status.current.includes(GQLUSER_STATUS.SUSPENDED)} premod={props.user.status.current.includes(GQLUSER_STATUS.PREMOD)} + warned={props.user.status.current.includes(GQLUSER_STATUS.WARNED)} /> ); }; diff --git a/src/core/client/admin/components/UserStatus/WarnForm.css b/src/core/client/admin/components/UserStatus/WarnForm.css new file mode 100644 index 000000000..41cc0b1b5 --- /dev/null +++ b/src/core/client/admin/components/UserStatus/WarnForm.css @@ -0,0 +1,10 @@ +.label { + margin-bottom: 0; +} + +.required { + color: var(--palette-text-100); + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-regular); + font-size: var(--font-size-2); +} diff --git a/src/core/client/admin/components/UserStatus/WarnForm.tsx b/src/core/client/admin/components/UserStatus/WarnForm.tsx new file mode 100644 index 000000000..aeb4f94ea --- /dev/null +++ b/src/core/client/admin/components/UserStatus/WarnForm.tsx @@ -0,0 +1,86 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, RefObject, useCallback } from "react"; +import { Field, Form } from "react-final-form"; + +import { required } from "coral-framework/lib/validation"; +import { + Button, + Flex, + HelperText, + HorizontalGutter, + Label, + Textarea, +} from "coral-ui/components/v2"; + +import styles from "./WarnForm.css"; + +interface Props { + onCancel: () => void; + onSubmit: (message: string) => void; + lastFocusableRef: RefObject; +} + +const WarnForm: FunctionComponent = ({ + onCancel, + onSubmit, + lastFocusableRef, +}) => { + const onFormSubmit = useCallback( + ({ message }) => { + onSubmit(message); + }, + [onSubmit] + ); + + return ( + <> +
+ {({ handleSubmit, invalid, form }) => ( + + + + + + + + + Required + + + + + Explain to this user how they should change their behavior + on your site. + + + + + {({ input }) => ( +