[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 21:16:21 +00:00
committed by Wyatt Johnson
parent 4e548e8fbf
commit 5df2de6afc
42 changed files with 1511 additions and 276 deletions
@@ -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: {