[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
This commit is contained in:
Tessa Thornton
2019-08-02 17:16:21 -04:00
committed by Wyatt Johnson
parent 4e548e8fbf
commit 5df2de6afc
42 changed files with 1511 additions and 276 deletions
@@ -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<SuspensionActionProps> = ({
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 (
<Localized
id="moderate-user-drawer-suspension"
$unit={unit}
$value={value}
$value={scaled}
>
<span>Suspension, {seconds} seconds</span>
<span>
Suspension, {scaled} {unit}
</span>
</Localized>
);
} else if (action === "removed") {
@@ -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<Props> = ({
createdAt
...UserStatusChangeContainer_user
}
settings {
organization {
name
}
...UserStatusChangeContainer_settings
}
}
`}
variables={{ userID }}
@@ -74,7 +81,7 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
);
}
const user = props.user;
const { user, settings } = props;
return (
<>
@@ -85,10 +92,20 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
<span>{user.username}</span>
</Flex>
<div className={styles.userStatus}>
<div className={styles.userStatusLabel}>Status:</div>
<div className={styles.userStatusChange}>
<UserStatusChangeContainer user={user} />
</div>
<Flex alignItems="center">
<div className={styles.userStatusLabel}>
<Typography variant="bodyCopyBold" container="div">
<Flex alignItems="center" itemGutter="half">
<Localized id="moderate-user-drawer-status-label">
Status:
</Localized>
</Flex>
</Typography>
</div>
<div className={styles.userStatusChange}>
<UserStatusChangeContainer settings={settings} user={user} />
</div>
</Flex>
</div>
<div className={styles.userDetails}>
<Flex alignItems="center" className={styles.userDetail}>
@@ -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));
}
@@ -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<Props> = ({
@@ -26,51 +30,107 @@ const BanModal: FunctionComponent<Props> = ({
onClose,
onConfirm,
username,
}) => (
<Modal open={open} onClose={onClose} aria-labelledby="banModal-title">
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.card}>
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
<HorizontalGutter size="double">
<HorizontalGutter>
<Localized
id="community-banModal-areYouSure"
strong={<strong />}
$username={username || <NotAvailable />}
>
<Typography variant="header2" id="banModal-title">
Are you sure you want to ban{" "}
<strong>{username || <NotAvailable />}</strong>?
</Typography>
</Localized>
<Localized id="community-banModal-consequence">
<Typography>
Once banned, this user will no longer be able to comment, use
reactions, or report comments.
</Typography>
</Localized>
</HorizontalGutter>
<Flex justifyContent="flex-end" itemGutter="half">
<Localized id="community-banModal-cancel">
<Button variant="outlined" onClick={onClose}>
Cancel
</Button>
</Localized>
<Localized id="community-banModal-banUser">
<Button
variant="filled"
color="primary"
onClick={onConfirm}
ref={lastFocusableRef}
>
Ban User
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</Card>
)}
</Modal>
);
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 (
<Modal open={open} onClose={onClose} aria-labelledby="banModal-title">
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.card}>
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
<HorizontalGutter size="double">
<HorizontalGutter>
<Localized
id="community-banModal-areYouSure"
strong={<strong />}
$username={username || <NotAvailable />}
>
<Typography variant="header2" id="banModal-title">
Are you sure you want to ban{" "}
<strong>{username || <NotAvailable />}</strong>?
</Typography>
</Localized>
<Localized id="community-banModal-consequence">
<Typography>
Once banned, this user will no longer be able to comment, use
reactions, or report comments.
</Typography>
</Localized>
</HorizontalGutter>
<Form
onSubmit={onFormSubmit}
initialValues={{
showMessage: false,
emailMessage: getDefaultMessage,
}}
>
{({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<Field name="showMessage">
{({ input }) => (
<Localized id="community-banModal-customize">
<CheckBox {...input} id="banModal-showMessage">
Customize ban email message
</CheckBox>
</Localized>
)}
</Field>
<Field name="showMessage" subscription={{ value: true }}>
{({ input: { value } }) =>
value ? (
<Field
className={styles.textArea}
id="banModal-message"
component="textarea"
name="emailMessage"
/>
) : null
}
</Field>
<Flex justifyContent="flex-end" itemGutter="half">
<Localized id="community-banModal-cancel">
<Button variant="outlined" onClick={onClose}>
Cancel
</Button>
</Localized>
<Localized id="community-banModal-banUser">
<Button
variant="filled"
color="primary"
type="submit"
ref={lastFocusableRef}
>
Ban User
</Button>
</Localized>
</Flex>
</form>
)}
</Form>
</HorizontalGutter>
</Card>
)}
</Modal>
);
};
const enhanced = withGetMessage(BanModal);
export default enhanced;
@@ -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<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(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<GQLUser>(
environment,
input.userID
)!.status.current.concat(GQLUSER_STATUS.SUSPENDED),
suspension: {
active: false,
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default RemoveUserSuspensionMutation;
@@ -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<Props> = ({
onCancel,
username,
getMessage,
onSubmit,
organizationName,
}) => {
const getMessageWithDuration = useCallback(
({ scaled, unit }: Pick<ScaledUnit, "scaled" | "unit">): 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 (
<>
<Form
onSubmit={onFormSubmit}
mutators={{
setMessageValue,
resetMessageValue,
}}
initialValues={{
duration: DEFAULT_DURATION.value,
emailMessage: getMessageWithDuration(DEFAULT_DURATION),
}}
>
{({ handleSubmit, form }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter spacing={2}>
<div>
{DURATIONS.map(({ original, value, scaled, unit }) => (
<Field
key={value}
name="duration"
type="radio"
component="input"
value={value}
>
{({ input }) => (
<Localized
id="framework-timeago-time"
$value={scaled}
$unit={unit}
>
<RadioButton
id={`duration-${value}`}
{...input}
onChange={event => {
form.mutators.setMessageValue(
"emailMessage",
getMessageWithDuration({ scaled, unit })
);
input.onChange(event);
}}
>
<span>
{scaled} {unit}
</span>
</RadioButton>
</Localized>
)}
</Field>
))}
</div>
<Field type="checkbox" name="editMessage">
{({ input }) => (
<Localized id="community-suspendModal-customize">
<CheckBox
id="suspendModal-editMessage"
{...input}
onChange={event => {
form.mutators.resetMessageValue(
"emailMessage",
!input.checked
);
input.onChange(event);
}}
>
Customize suspension email message
</CheckBox>
</Localized>
)}
</Field>
<Field name="editMessage" subscription={{ value: true }}>
{({ input: { value } }) =>
value ? (
<Field
className={styles.textArea}
id="suspendModal-message"
component="textarea"
name="emailMessage"
/>
) : null
}
</Field>
</HorizontalGutter>
<Flex
className={styles.footer}
justifyContent="flex-end"
itemGutter="half"
>
<Localized id="community-suspendModal-cancel">
<Button variant="outlined" onClick={onCancel}>
Cancel
</Button>
</Localized>
<Localized id="community-suspendModal-suspendUser">
<Button variant="filled" color="primary" type="submit">
Suspend User
</Button>
</Localized>
</Flex>
</form>
)}
</Form>
</>
);
};
const enhanced = withGetMessage(SuspendForm);
export default enhanced;
@@ -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);
}
@@ -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<Props> = ({
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 (
<>
<Modal open={open} onClose={onClose} aria-labelledby="suspendModal-title">
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.card}>
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
{success && (
<HorizontalGutter spacing={2}>
<HorizontalGutter>
<Localized
id="community-suspendModal-success"
$username={username}
strong={<strong />}
$duration={successDuration}
>
<Typography>
<strong>{username}</strong> has been suspended for{" "}
<strong>{successDuration}</strong>
</Typography>
</Localized>
</HorizontalGutter>
<Flex justifyContent="flex-end" itemGutter="half">
<Localized id="community-suspendModal-success-close">
<Button variant="filled" color="primary" onClick={onClose}>
Ok
</Button>
</Localized>
</Flex>
</HorizontalGutter>
)}
{!success && (
<HorizontalGutter spacing={2}>
<Localized
id="community-suspendModal-areYouSure"
strong={<strong />}
$username={username || <NotAvailable />}
>
<Typography
className={styles.header}
variant="header2"
id="suspendModal-title"
>
Suspend <strong>{username || <NotAvailable />}</strong>?
</Typography>
</Localized>
<Localized id="community-suspendModal-consequence">
<Typography>
While suspended, this user will no longer be able to
comment, use reactions, or report comments.
</Typography>
</Localized>
<Localized id="community-suspendModal-selectDuration">
<Typography variant="header3">
Select suspension length
</Typography>
</Localized>
<SuspendForm
username={username}
onCancel={onClose}
organizationName={organizationName}
onSubmit={onFormSubmit}
/>
</HorizontalGutter>
)}
</Card>
)}
</Modal>
</>
);
};
const enhanced = withGetMessage(SuspendModal);
export default enhanced;
@@ -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<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(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<GQLUser>(
environment,
input.userID
)!.status.current.concat(GQLUSER_STATUS.SUSPENDED),
suspension: {
active: true,
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default SuspendUserMutation;
@@ -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<Props> = ({
user,
fullWidth = false,
}) => {
const UserStatusChangeContainer: FunctionComponent<Props> = 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<boolean>(false);
const [showSuspend, setShowSuspend] = useState<boolean>(false);
const [showSuspendSuccess, setShowSuspendSuccess] = useState<boolean>(false);
const handleBan = useCallback(() => {
if (user.status.ban.active) {
return;
@@ -43,14 +50,43 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
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<Props> = ({
>
<UserStatusContainer user={user} />
</UserStatusChange>
<SuspendModal
username={user.username}
open={showSuspend || showSuspendSuccess}
success={showSuspendSuccess}
onClose={handleSuspendModalClose}
organizationName={settings.organization.name}
onConfirm={handleSuspendConfirm}
/>
<BanModal
username={user.username}
open={showBanned}
onClose={() => setShowBanned(false)}
onConfirm={() => {
banUser({ userID: user.id });
setShowBanned(false);
}}
onClose={handleBanModalClose}
onConfirm={handleBanConfirm}
/>
</>
);
@@ -89,7 +130,6 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment UserStatusChangeContainer_user on User {
...UserStatusContainer_user
id
role
username
@@ -101,6 +141,14 @@ const enhanced = withFragmentContainer<Props>({
active
}
}
...UserStatusContainer_user
}
`,
settings: graphql`
fragment UserStatusChangeContainer_settings on Settings {
organization {
name
}
}
`,
})(UserStatusChangeContainer);
@@ -16,6 +16,7 @@ interface Props {
user: PropTypesOf<typeof UserRole>["user"] &
PropTypesOf<typeof UserStatus>["user"];
viewer: PropTypesOf<typeof UserRole>["viewer"];
settings: PropTypesOf<typeof UserStatus>["settings"];
onUsernameClicked?: (userID: string) => void;
}
@@ -27,6 +28,7 @@ const UserRow: FunctionComponent<Props> = ({
user,
viewer,
onUsernameClicked,
settings,
}) => {
const usernameClicked = useCallback(() => {
if (!onUsernameClicked) {
@@ -53,7 +55,7 @@ const UserRow: FunctionComponent<Props> = ({
<UserRole user={user} viewer={viewer} />
</TableCell>
<TableCell className={styles.statusColumn}>
<UserStatus user={user} fullWidth />
<UserStatus user={user} settings={settings} fullWidth />
</TableCell>
</TableRow>
);
@@ -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> = props => {
return (
<UserRow
user={props.user}
settings={props.settings}
viewer={props.viewer}
userID={props.user.id}
username={props.user.username!}
@@ -39,6 +42,11 @@ const enhanced = withFragmentContainer<Props>({
...UserRoleChangeContainer_viewer
}
`,
settings: graphql`
fragment UserRowContainer_settings on Settings {
...UserStatusChangeContainer_settings
}
`,
user: graphql`
fragment UserRowContainer_user on User {
...UserStatusChangeContainer_user
@@ -22,6 +22,7 @@ import styles from "./UserTable.css";
interface Props {
viewer: PropTypesOf<typeof UserRowContainer>["viewer"] | null;
settings: PropTypesOf<typeof UserRowContainer>["settings"] | null;
users: Array<{ id: string } & PropTypesOf<typeof UserRowContainer>["user"]>;
onLoadMore: () => void;
hasMore: boolean;
@@ -29,7 +30,11 @@ interface Props {
loading: boolean;
}
const UserTable: FunctionComponent<Props> = props => {
const UserTable: FunctionComponent<Props> = ({
viewer,
settings,
...props
}) => {
const [userDrawerUserID, setUserDrawerUserID] = useState("");
const [userDrawerVisible, setUserDrawerVisible] = useState(false);
@@ -45,7 +50,6 @@ const UserTable: FunctionComponent<Props> = props => {
setUserDrawerVisible(false);
setUserDrawerUserID("");
}, [setUserDrawerUserID, setUserDrawerVisible]);
return (
<>
<HorizontalGutter size="double">
@@ -77,11 +81,14 @@ const UserTable: FunctionComponent<Props> = props => {
</TableHead>
<TableBody>
{!props.loading &&
settings &&
viewer &&
props.users.map(u => (
<UserRowContainer
key={u.id}
user={u}
viewer={props.viewer!}
settings={settings}
viewer={viewer}
onUsernameClicked={onShowUserDrawer}
/>
))}
@@ -52,6 +52,7 @@ const UserTableContainer: FunctionComponent<Props> = props => {
/>
<UserTable
viewer={props.query && props.query.viewer}
settings={props.query && props.query.settings}
loading={!props.query || isRefetching}
users={users}
onLoadMore={loadMore}
@@ -87,6 +88,7 @@ const enhanced = withPaginationContainer<
}
settings {
...InviteUsersContainer_settings
...UserRowContainer_settings
}
users(
first: $count
@@ -1,120 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ban user 1`] = `
<div
aria-labelledby="banModal-title"
className="Modal-root"
onKeyDown={[Function]}
role="modal"
>
<div
className="Backdrop-root Backdrop-active"
data-testid="backdrop"
onClick={[Function]}
/>
<div
className="Modal-scroll"
>
<div
className="Modal-alignContainer1"
>
<div
className="Modal-alignContainer2"
>
<div
className="Modal-wrapper"
>
<div
onFocus={[Function]}
tabIndex={0}
/>
<div
tabIndex={-1}
/>
<div
className="Card-root BanModal-card"
>
<button
className="BaseButton-root CloseButton-root"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span
aria-hidden="true"
className="Icon-root Icon-md CloseButton-icon"
>
close
</span>
</button>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<h1
className="Box-root Typography-root Typography-header2 Typography-colorTextPrimary"
id="banModal-title"
>
Are you sure you want to ban
<strong>
Isabelle
</strong>
?
</h1>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Once banned, this user will no longer be able to comment, use
reactions, or report comments.
</p>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-halfItemGutter Flex-justifyFlexEnd gutter"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantOutlined"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Cancel
</button>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Ban User
</button>
</div>
</div>
</div>
<div
onFocus={[Function]}
tabIndex={0}
/>
</div>
</div>
</div>
</div>
</div>
`;
exports[`renders community 1`] = `
<div
className="MainLayout-root Community-root"
@@ -404,6 +404,264 @@ it("can't change staff, moderator and admin status", async () => {
});
});
it("suspend user", async () => {
const user = users.commenters[0];
const resolvers = createResolversStub<GQLResolver>({
Mutation: {
suspendUser: ({ variables }) => {
expectAndFail(variables).toMatchObject({
userID: user.id,
});
const userRecord = pureMerge<typeof user>(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<GQLResolver>({
Mutation: {
removeUserSuspension: ({ variables }) => {
expectAndFail(variables).toMatchObject({
userID: user.id,
});
const userRecord = pureMerge<typeof user>(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<GQLResolver>({
Mutation: {
suspendUser: ({ variables }) => {
expectAndFail(variables).toMatchObject({
userID: user.id,
timeout: 604800,
});
const userRecord = pureMerge<typeof user>(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<GQLResolver>({
Mutation: {
suspendUser: ({ variables }) => {
expectAndFail(variables).toMatchObject({
userID: user.id,
message: "YOU WERE SUSPENDED FOR BEHAVING BADLY",
});
const userRecord = pureMerge<typeof user>(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<GQLResolver>({
Mutation: {
banUser: ({ variables }) => {
expectAndFail(variables).toMatchObject({
userID: user.id,
message: "YOU WERE BANNED FOR BREAKING THE RULES",
});
const userRecord = pureMerge<typeof user>(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);
+18
View File
@@ -355,6 +355,24 @@ export const users = {
],
baseUser
),
suspendedCommenter: createFixture<GQLUser>(
{
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<GQLUser>(
{
id: "user-banned-0",
@@ -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<any>;
// This is used to render the Option elements to inlcude in the select field.
const unitElementMap: Record<DURATION_UNIT, UnitElementCallback> = {
[DURATION_UNIT.SECONDS]: (currentValue, unitValue) => (
const unitElementMap: Record<UNIT, UnitElementCallback> = {
[UNIT.SECONDS]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-seconds"
$value={currentValue}
@@ -33,7 +28,7 @@ const unitElementMap: Record<DURATION_UNIT, UnitElementCallback> = {
<Option value={unitValue}>Seconds</Option>
</Localized>
),
[DURATION_UNIT.MINUTES]: (currentValue, unitValue) => (
[UNIT.MINUTES]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-minutes"
$value={currentValue}
@@ -42,7 +37,7 @@ const unitElementMap: Record<DURATION_UNIT, UnitElementCallback> = {
<Option value={unitValue}>Minutes</Option>
</Localized>
),
[DURATION_UNIT.HOURS]: (currentValue, unitValue) => (
[UNIT.HOURS]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-hours"
$value={currentValue}
@@ -51,7 +46,7 @@ const unitElementMap: Record<DURATION_UNIT, UnitElementCallback> = {
<Option value={unitValue}>Hours</Option>
</Localized>
),
[DURATION_UNIT.DAYS]: (currentValue, unitValue) => (
[UNIT.DAYS]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-days"
$value={currentValue}
@@ -60,7 +55,7 @@ const unitElementMap: Record<DURATION_UNIT, UnitElementCallback> = {
<Option value={unitValue}>Days</Option>
</Localized>
),
[DURATION_UNIT.WEEKS]: (currentValue, unitValue) => (
[UNIT.WEEKS]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-weeks"
$value={currentValue}
@@ -77,16 +72,16 @@ interface Props {
disabled: boolean;
onChange: (v: string) => void;
/** Specifiy units to include */
units?: ReadonlyArray<DURATION_UNIT>;
units?: ReadonlyArray<UNIT>;
}
interface State {
/** Current value */
value: string;
/** Current unit */
unit?: DURATION_UNIT;
unit?: UNIT;
/** All available units */
units: ReadonlyArray<DURATION_UNIT>;
units: ReadonlyArray<UNIT>;
/**
* 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<DURATION_UNIT>,
unit?: DURATION_UNIT
) {
function valueToState(value: string, units: ReadonlyArray<UNIT>, 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<Props, State> {
public static defaultProps: Partial<Props> = {
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!);
@@ -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 || "";
}
@@ -1,17 +1,21 @@
import { FluentBundle } from "fluent/compat";
export default function getMessage(
export default function getMessage<T extends {}>(
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("");
@@ -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";
@@ -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> = T[keyof T];
export interface ScaledUnit {
original: number;
value: string;
unit: ValueOf<typeof UNIT_MAP>;
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],
};
}
@@ -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 = <T>(id: string, defaultTo: string, args?: T) => string;
interface InjectedProps {
getMessage: GetMessage;
@@ -35,8 +35,12 @@ const withGetMessage: DefaultingInferableComponentEnhancer<
const Workaround = BaseComponent as React.ComponentType<InjectedProps>;
class WithGetMessage extends React.Component<Props> {
private getMessage = (id: string, defaultTo?: string) => {
return getMessage(this.props.localeBundles, id, defaultTo);
private getMessage = <U extends {}>(
id: string,
defaultTo: string,
args?: U
): string => {
return getMessage(this.props.localeBundles, id, defaultTo, args);
};
public render() {
const { localeBundles: _, ...rest } = this.props;
@@ -92,8 +92,13 @@ export class CommentContainer extends Component<Props, State> {
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<Props, State> {
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<Props, State> {
comment={comment}
settings={settings}
viewer={viewer}
readOnly={banned}
readOnly={banned || suspended}
/>
{!disableReplies && !banned && (
{!disableReplies && !banned && !suspended && (
<ReplyButton
id={`comments-commentContainer-replyButton-${
comment.id
@@ -283,7 +292,7 @@ export class CommentContainer extends Component<Props, State> {
/>
</ButtonsBar>
<ButtonsBar>
{!banned && (
{!banned && !suspended && (
<ReportButtonContainer
comment={comment}
viewer={viewer}
@@ -32,6 +32,7 @@ import { PostCommentFormContainer } from "./PostCommentForm";
import SortMenu from "./SortMenu";
import StoryClosedTimeoutContainer from "./StoryClosedTimeout";
import styles from "./StreamContainer.css";
import { SuspendedInfoContainer } from "./SuspendedInfo/index";
interface Props {
story: StoryData;
@@ -75,6 +76,10 @@ export const StreamContainer: FunctionComponent<Props> = 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> = props => {
<HorizontalGutter className={styles.root} size="double">
<UserBoxContainer viewer={props.viewer} settings={props.settings} />
<CommunityGuidelinesContainer settings={props.settings} />
{!banned && (
{!banned && !suspended && (
<PostCommentFormContainer
settings={props.settings}
story={props.story}
@@ -110,6 +115,12 @@ export const StreamContainer: FunctionComponent<Props> = props => {
/>
)}
{banned && <BannedInfo />}
{suspended && (
<SuspendedInfoContainer
viewer={props.viewer}
settings={props.settings}
/>
)}
<HorizontalGutter spacing={4} className={styles.tabBarContainer}>
<SortMenu
className={styles.sortMenu}
@@ -193,6 +204,7 @@ const enhanced = withFragmentContainer<Props>({
...CreateCommentReplyMutation_viewer
...CreateCommentMutation_viewer
...PostCommentFormContainer_viewer
...SuspendedInfoContainer_viewer
status {
current
}
@@ -206,6 +218,7 @@ const enhanced = withFragmentContainer<Props>({
...PostCommentFormContainer_settings
...UserBoxContainer_settings
...CommunityGuidelinesContainer_settings
...SuspendedInfoContainer_settings
}
`,
})(StreamContainer);
@@ -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<Props> = ({ 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 (
<CallOut fullWidth>
<HorizontalGutter>
<Localized id="suspendInfo-heading">
<Typography variant="heading3">
Your account has been temporarily suspended from commenting.
</Typography>
</Localized>
<Localized
$organization={organization}
$until={untilDate}
id="suspendInfo-info"
>
<Typography variant="bodyCopy">
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}.
</Typography>
</Localized>
</HorizontalGutter>
</CallOut>
);
};
export default SuspendedInfo;
@@ -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<Props> = ({
settings,
viewer,
}) => {
if (!viewer) {
return null;
}
return (
<SuspendedInfo
until={viewer.status.suspension.until}
organization={settings.organization.name}
/>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment SuspendedInfoContainer_viewer on User {
status {
suspension {
until
}
}
}
`,
settings: graphql`
fragment SuspendedInfoContainer_settings on Settings {
organization {
name
}
}
`,
})(SuspendedInfoContainer);
export default enhanced;
@@ -0,0 +1,4 @@
export {
default,
default as SuspendedInfoContainer,
} from "./SuspendedInfoContainer";
@@ -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<GQLResolver> = {}
) {
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
viewer: () =>
pureMerge<typeof viewer>(viewer, {
status: {
current: [GQLUSER_STATUS.SUSPENDED],
suspension: {
until: new Date(Date.now() + 600000).toISOString(),
},
},
}),
story: () =>
pureMerge<typeof story>(story, {
comments: {
edges: [
...story.comments.edges,
{
node: pureMerge<typeof comments[2]>(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);
});
+8
View File
@@ -40,6 +40,11 @@ export const settings = createFixture<GQLSettings>({
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<GQLUser>({
createdAt: "2018-02-06T18:24:00.000Z",
status: {
current: [GQLUSER_STATUS.ACTIVE],
suspension: {
active: false,
},
},
ignoredUsers: [],
comments: {
@@ -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) =>
@@ -13,4 +13,5 @@ export const BanStatusHistory: Required<
return null;
},
createdAt: ({ createdAt }) => createdAt,
message: ({ message }) => message,
};
@@ -23,4 +23,5 @@ export const SuspensionStatusHistory: Required<
return null;
},
modifiedAt: ({ modifiedAt }) => modifiedAt,
message: ({ message }) => message,
};
@@ -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 {
+4 -10
View File
@@ -10,11 +10,9 @@ email-notification-template-forgotPassword =
email-subject-forgotPassword = Password Reset Request
email-notification-template-ban =
Hello { $username },<br/><br/>
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 <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
{ $customMessage }<br /><br />
if you think this has been done in error, please contact our community team
at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
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 },<br/><br/>
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 }.<br/><br/>
{ $customMessage }<br/><br/>
If you think this has been done in error, please contact our community team
at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
+13
View File
@@ -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.
@@ -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;
}
>;
@@ -2,10 +2,12 @@
{% block content %}
Hello {{ context.username }},<br/><br/>
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 }}.<br/><br/>
If you think this has been done in error, please contact our community team
at <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
If you think this has been done in error, please contact our community team at
<a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
{% endblock %}
+8 -1
View File
@@ -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, "<br />"),
},
},
});
@@ -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, "<br />"),
},
},
});
+28
View File
@@ -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 <strong>{ $username }</strong>?
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 =
<strong>{ $username }</strong> has been suspended for <strong>{ $duration }</strong>
community-suspendModal-success-close = Close
community-suspendModal-selectDuration = Select suspension length
community-invite-inviteMember = Invite members to your organization
community-invite-emailAddressLabel = Email address:
+1 -2
View File
@@ -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
+7
View File
@@ -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 }