mirror of
https://github.com/wassname/talk.git
synced 2026-08-14 12:50:17 +08:00
[CORL-382] Allow moderators to ban a commenter from the comment stream (#2385)
* Add a ban user action to the stream moderation drop down CORL-382 * Show banned stated for user in stream moderation drop down If the user is actively banned, the ban user option will be disabled and show a status of "banned". CORL-382 * Create utility for generating random stories, comments, and users CORL-382 * Add ban and suspension values to baseUser in fixtures CORL-382 * Updated banned.spec.tsx to use new test utilities for generating fixture data CORL-382 * Prevent users from being able to ban themselves in the moderation dropdown CORL-382 * Kill optimistic response errors for comment mutations Set the author.status.current to an empty array so it stops complaining about it being unused. CORL-382 * Rename util in tests to helpers/fixture.ts CORL-382 * Remove unused import from CreateCommentReplyMutation.ts CORL-382 * Put back the optimistic ban responses into comment mutations The warnings spewing out during tests are false, for further detail please see: https://github.com/facebook/relay/pull/2760 CORL-382 * Denormalize generated stories and comments CORL-382 * Clean up import ordering in ModerationActionsContainer.tsx CORL-382 * Inject appropriate scoped items into callbacks for moderation dropdown CORL-382 * Set optimistic response author status from known viewer status CORL-382 * fix: Make in stream banning work * feat: Send translated email + reject comment + fix tests * test: add feature test * feat: add copy mentioning comment rejection * chore: improve loading state * chore: add tiny comment
This commit is contained in:
@@ -34,7 +34,7 @@ const BanModal: FunctionComponent<Props> = ({
|
||||
}) => {
|
||||
const getDefaultMessage = useMemo((): string => {
|
||||
return getMessage(
|
||||
"community-banModal-emailTemplate",
|
||||
"common-banEmailTemplate",
|
||||
"Someone with access to your account has violated our community guidelines. As a result, your account has been banned. You will no longer be able to comment, react or report comments",
|
||||
{
|
||||
username,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { BanUserMutation as MutationTypes } from "coral-admin/__generated__/BanUserMutation.graphql";
|
||||
import { getViewer } from "coral-framework/helpers";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
@@ -15,6 +16,7 @@ let clientMutationId = 0;
|
||||
const BanUserMutation = createMutation(
|
||||
"banUser",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
const viewer = getViewer(environment)!;
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation BanUserMutation($input: BanUserInput!) {
|
||||
@@ -29,6 +31,7 @@ const BanUserMutation = createMutation(
|
||||
active
|
||||
createdAt
|
||||
createdBy {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
@@ -60,6 +63,10 @@ const BanUserMutation = createMutation(
|
||||
{
|
||||
active: true,
|
||||
createdAt: new Date(),
|
||||
createdBy: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { pick } from "lodash";
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { RemoveUserSuspensionMutation as MutationTypes } from "coral-admin/__generated__/RemoveUserSuspensionMutation.graphql";
|
||||
import { DeepWritable } from "coral-common/types";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
@@ -15,6 +17,24 @@ let clientMutationId = 0;
|
||||
const RemoveUserSuspensionMutation = createMutation(
|
||||
"removeUserSuspension",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
const user = lookup<GQLUser>(environment, input.userID)!;
|
||||
let newHistory: DeepWritable<
|
||||
MutationTypes["response"]["removeUserSuspension"]["user"]["status"]["suspension"]["history"]
|
||||
> = [];
|
||||
if (user.status.suspension.history) {
|
||||
newHistory = user.status.suspension.history.map(h =>
|
||||
pick(h, [
|
||||
"active",
|
||||
"from.start",
|
||||
"from.finish",
|
||||
"createdBy.id",
|
||||
"createdBy.username",
|
||||
])
|
||||
) as any;
|
||||
newHistory[newHistory.length - 1].active = false;
|
||||
newHistory[newHistory.length - 1].from.finish = new Date().toISOString();
|
||||
}
|
||||
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation RemoveUserSuspensionMutation(
|
||||
@@ -34,6 +54,7 @@ const RemoveUserSuspensionMutation = createMutation(
|
||||
finish
|
||||
}
|
||||
createdBy {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
@@ -55,17 +76,10 @@ const RemoveUserSuspensionMutation = createMutation(
|
||||
user: {
|
||||
id: input.userID,
|
||||
status: {
|
||||
current: lookup<GQLUser>(
|
||||
environment,
|
||||
input.userID
|
||||
)!.status.current.concat(GQLUSER_STATUS.SUSPENDED),
|
||||
current: user.status.current.concat(GQLUSER_STATUS.SUSPENDED),
|
||||
suspension: {
|
||||
active: false,
|
||||
history: [
|
||||
{
|
||||
active: false,
|
||||
},
|
||||
],
|
||||
history: newHistory,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { SuspendUserMutation as MutationTypes } from "coral-admin/__generated__/SuspendUserMutation.graphql";
|
||||
import { getViewer } from "coral-framework/helpers";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
@@ -16,6 +17,7 @@ let clientMutationId = 0;
|
||||
const SuspendUserMutation = createMutation(
|
||||
"suspendUser",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
const viewer = getViewer(environment)!;
|
||||
const now = new Date();
|
||||
const finish = DateTime.fromJSDate(now).plus({
|
||||
seconds: input.timeout,
|
||||
@@ -37,6 +39,7 @@ const SuspendUserMutation = createMutation(
|
||||
finish
|
||||
}
|
||||
createdBy {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
@@ -68,8 +71,12 @@ const SuspendUserMutation = createMutation(
|
||||
{
|
||||
active: true,
|
||||
from: {
|
||||
start: now,
|
||||
finish,
|
||||
start: now.toISOString(),
|
||||
finish: finish.toISODate(),
|
||||
},
|
||||
createdBy: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -36,10 +36,18 @@ const createProxy = <T = any>(
|
||||
return prop in recordSource;
|
||||
},
|
||||
get(_, prop) {
|
||||
if ((recordSource as any)[prop] && (recordSource as any)[prop].__ref) {
|
||||
return lookup(environment, (recordSource as any)[prop].__ref);
|
||||
const rsrc = recordSource as any;
|
||||
if (rsrc[prop]) {
|
||||
// Resolve references.
|
||||
if (rsrc[prop].__ref) {
|
||||
return lookup(environment, rsrc[prop].__ref);
|
||||
}
|
||||
// Resolve array references.
|
||||
if (rsrc[prop].__refs) {
|
||||
return rsrc[prop].__refs.map((v: string) => lookup(environment, v));
|
||||
}
|
||||
}
|
||||
return (recordSource as any)[prop];
|
||||
return rsrc[prop];
|
||||
},
|
||||
};
|
||||
return new Proxy({}, proxy) as RecordSourceProxy<T>;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { mapValues } from "lodash";
|
||||
const permissionMap = {
|
||||
// Mutation.updateStorySettings
|
||||
CHANGE_STORY_CONFIGURATION: [GQLUSER_ROLE.ADMIN, GQLUSER_ROLE.MODERATOR],
|
||||
MODERATE: [GQLUSER_ROLE.ADMIN, GQLUSER_ROLE.MODERATOR],
|
||||
};
|
||||
|
||||
export type AbilityType = keyof typeof permissionMap;
|
||||
|
||||
@@ -3,9 +3,9 @@ import React, { Component, MouseEvent } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { isBeforeDate } from "coral-common/utils";
|
||||
import { getURLWithCommentID, roleIsAtLeast } from "coral-framework/helpers";
|
||||
import { getURLWithCommentID } from "coral-framework/helpers";
|
||||
import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer";
|
||||
import { GQLTAG, GQLUSER_ROLE, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
import { GQLTAG, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
import { PropTypesOf } from "coral-framework/types";
|
||||
import { CommentContainer_comment as CommentData } from "coral-stream/__generated__/CommentContainer_comment.graphql";
|
||||
import { CommentContainer_settings as SettingsData } from "coral-stream/__generated__/CommentContainer_settings.graphql";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
withSetCommentIDMutation,
|
||||
withShowAuthPopupMutation,
|
||||
} from "coral-stream/mutations";
|
||||
import { Ability, can } from "coral-stream/permissions";
|
||||
import { Button, Flex, HorizontalGutter, Tag } from "coral-ui/components";
|
||||
|
||||
import { isPublished } from "../helpers";
|
||||
@@ -192,8 +193,7 @@ export class CommentContainer extends Component<Props, State> {
|
||||
this.props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED)
|
||||
);
|
||||
const showCaret =
|
||||
this.props.viewer &&
|
||||
roleIsAtLeast(this.props.viewer.role, GQLUSER_ROLE.MODERATOR);
|
||||
this.props.viewer && can(this.props.viewer, Ability.MODERATE);
|
||||
if (showEditDialog) {
|
||||
return (
|
||||
<div data-testid={`comment-${comment.id}`}>
|
||||
@@ -262,7 +262,11 @@ export class CommentContainer extends Component<Props, State> {
|
||||
</Localized>
|
||||
)}
|
||||
{showCaret && (
|
||||
<CaretContainer comment={comment} story={story} />
|
||||
<CaretContainer
|
||||
comment={comment}
|
||||
story={story}
|
||||
viewer={viewer!}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
@@ -349,6 +353,7 @@ const enhanced = withSetCommentIDMutation(
|
||||
...UsernameWithPopoverContainer_viewer
|
||||
...ReactionButtonContainer_viewer
|
||||
...ReportButtonContainer_viewer
|
||||
...CaretContainer_viewer
|
||||
}
|
||||
`,
|
||||
story: graphql`
|
||||
|
||||
@@ -5,6 +5,7 @@ import { graphql } from "react-relay";
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { CaretContainer_comment } from "coral-stream/__generated__/CaretContainer_comment.graphql";
|
||||
import { CaretContainer_story } from "coral-stream/__generated__/CaretContainer_story.graphql";
|
||||
import { CaretContainer_viewer } from "coral-stream/__generated__/CaretContainer_viewer.graphql";
|
||||
import { Button, ClickOutside, Icon, Popover } from "coral-ui/components";
|
||||
|
||||
import ModerationDropdownContainer from "./ModerationDropdownContainer";
|
||||
@@ -14,6 +15,7 @@ import styles from "./CaretContainer.css";
|
||||
interface Props {
|
||||
comment: CaretContainer_comment;
|
||||
story: CaretContainer_story;
|
||||
viewer: CaretContainer_viewer;
|
||||
}
|
||||
|
||||
const CaretContainer: FunctionComponent<Props> = props => {
|
||||
@@ -27,12 +29,14 @@ const CaretContainer: FunctionComponent<Props> = props => {
|
||||
id={popoverID}
|
||||
placement="bottom-end"
|
||||
description="A popover menu to moderate the comment"
|
||||
body={({ toggleVisibility }) => (
|
||||
body={({ toggleVisibility, scheduleUpdate }) => (
|
||||
<ClickOutside onClickOutside={toggleVisibility}>
|
||||
<ModerationDropdownContainer
|
||||
comment={props.comment}
|
||||
story={props.story}
|
||||
viewer={props.viewer}
|
||||
onDismiss={toggleVisibility}
|
||||
scheduleUpdate={scheduleUpdate}
|
||||
/>
|
||||
</ClickOutside>
|
||||
)}
|
||||
@@ -72,6 +76,11 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...ModerationDropdownContainer_story
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment CaretContainer_viewer on User {
|
||||
...ModerationDropdownContainer_viewer
|
||||
}
|
||||
`,
|
||||
})(CaretContainer);
|
||||
|
||||
export default enhanced;
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
.banned {
|
||||
&:disabled {
|
||||
opacity: 1.0;
|
||||
}
|
||||
color: var(--palette-error-dark);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
.banIcon {
|
||||
width: 18px;
|
||||
height: 14px;
|
||||
text-align: center;
|
||||
line-height: 12px;
|
||||
}
|
||||
.spinner {
|
||||
height: 14px;
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { ModerationActionBanContainer_user } from "coral-stream/__generated__/ModerationActionBanContainer_user.graphql";
|
||||
import { DropdownButton, Icon, Spinner } from "coral-ui/components";
|
||||
|
||||
import styles from "./ModerationActionBanContainer.css";
|
||||
|
||||
interface Props {
|
||||
/** user in question or null if still loading */
|
||||
user: ModerationActionBanContainer_user | null;
|
||||
onBan: () => void;
|
||||
}
|
||||
|
||||
const ModerationActionBanContainer: FunctionComponent<Props> = ({
|
||||
user,
|
||||
onBan,
|
||||
}) => {
|
||||
if (!user) {
|
||||
return (
|
||||
<Localized id="comments-moderationDropdown-ban">
|
||||
<DropdownButton
|
||||
icon={
|
||||
<div className={styles.banIcon}>
|
||||
<Icon size="sm">block</Icon>
|
||||
</div>
|
||||
}
|
||||
adornment={<Spinner size="xs" className={styles.spinner} />}
|
||||
disabled
|
||||
>
|
||||
Ban User
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
const banned = user.status.ban.active;
|
||||
if (banned) {
|
||||
return (
|
||||
<Localized id="comments-moderationDropdown-banned">
|
||||
<DropdownButton
|
||||
icon={
|
||||
<div className={cn(styles.banIcon, styles.banned)}>
|
||||
<Icon size="sm">block</Icon>
|
||||
</div>
|
||||
}
|
||||
className={styles.banned}
|
||||
disabled
|
||||
>
|
||||
Banned
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Localized id="comments-moderationDropdown-ban">
|
||||
<DropdownButton
|
||||
icon={
|
||||
<div className={styles.banIcon}>
|
||||
<Icon size="sm">block</Icon>
|
||||
</div>
|
||||
}
|
||||
onClick={onBan}
|
||||
>
|
||||
Ban User
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
user: graphql`
|
||||
fragment ModerationActionBanContainer_user on User {
|
||||
id
|
||||
status {
|
||||
ban {
|
||||
active
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(ModerationActionBanContainer);
|
||||
|
||||
export default enhanced;
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { ModerationActionBanQuery as QueryTypes } from "coral-stream/__generated__/ModerationActionBanQuery.graphql";
|
||||
|
||||
import ModerationActionBanContainer from "./ModerationActionBanContainer";
|
||||
|
||||
interface Props {
|
||||
onBan: () => void;
|
||||
userID: string;
|
||||
}
|
||||
|
||||
export default class ModerationActionBanQuery extends Component<Props> {
|
||||
public render() {
|
||||
return (
|
||||
<QueryRenderer<QueryTypes>
|
||||
query={graphql`
|
||||
query ModerationActionBanQuery($userID: ID!) {
|
||||
user(id: $userID) {
|
||||
...ModerationActionBanContainer_user
|
||||
}
|
||||
}
|
||||
`}
|
||||
dataFrom="STORE_THEN_NETWORK"
|
||||
variables={{
|
||||
userID: this.props.userID,
|
||||
}}
|
||||
render={({ error, props }) => {
|
||||
if (error) {
|
||||
return <div>{error.message}</div>;
|
||||
}
|
||||
if (props && !props.user) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ModerationActionBanContainer
|
||||
onBan={this.props.onBan}
|
||||
user={props ? props.user : null}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
@@ -1,10 +1,24 @@
|
||||
.approved {
|
||||
color: var(--palette-success-dark);
|
||||
font-weight: var(--font-weight-bold);
|
||||
&:disabled {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
.approveIcon {
|
||||
padding-top: 1px;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.rejectIcon {
|
||||
padding-top: 1px;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.rejected {
|
||||
color: var(--palette-error-dark);
|
||||
font-weight: var(--font-weight-bold);
|
||||
&:disabled {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
.featured {
|
||||
color: var(--palette-primary-dark);
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { ModerationActionsContainer_comment } from "coral-stream/__generated__/ModerationActionsContainer_comment.graphql";
|
||||
import { ModerationActionsContainer_story } from "coral-stream/__generated__/ModerationActionsContainer_story.graphql";
|
||||
import { ModerationActionsContainer_viewer } from "coral-stream/__generated__/ModerationActionsContainer_viewer.graphql";
|
||||
import { DropdownButton, DropdownDivider, Icon } from "coral-ui/components";
|
||||
|
||||
import ApproveCommentMutation from "./ApproveCommentMutation";
|
||||
import FeatureCommentMutation from "./FeatureCommentMutation";
|
||||
import ModerationActionBanQuery from "./ModerationActionBanQuery";
|
||||
import RejectCommentMutation from "./RejectCommentMutation";
|
||||
import UnfeatureCommentMutation from "./UnfeatureCommentMutation";
|
||||
|
||||
import styles from "./ModerationActionsContainer.css";
|
||||
|
||||
interface Props {
|
||||
comment: ModerationActionsContainer_comment;
|
||||
story: ModerationActionsContainer_story;
|
||||
viewer: ModerationActionsContainer_viewer;
|
||||
onDismiss: () => void;
|
||||
onBan: () => void;
|
||||
}
|
||||
|
||||
const ModerationActionsContainer: FunctionComponent<Props> = ({
|
||||
comment,
|
||||
story,
|
||||
viewer,
|
||||
onDismiss,
|
||||
onBan,
|
||||
}) => {
|
||||
const approve = useMutation(ApproveCommentMutation);
|
||||
const feature = useMutation(FeatureCommentMutation);
|
||||
const unfeature = useMutation(UnfeatureCommentMutation);
|
||||
const reject = useMutation(RejectCommentMutation);
|
||||
|
||||
const onApprove = useCallback(() => {
|
||||
approve({ commentID: comment.id, commentRevisionID: comment.revision.id });
|
||||
}, [approve, comment]);
|
||||
const onReject = useCallback(
|
||||
() =>
|
||||
reject({ commentID: comment.id, commentRevisionID: comment.revision.id }),
|
||||
[approve, comment]
|
||||
);
|
||||
const onFeature = useCallback(() => {
|
||||
feature({
|
||||
storyID: story.id,
|
||||
commentID: comment.id,
|
||||
commentRevisionID: comment.revision.id,
|
||||
});
|
||||
onDismiss();
|
||||
}, [feature, onDismiss, story, comment]);
|
||||
const onUnfeature = useCallback(() => {
|
||||
unfeature({
|
||||
commentID: comment.id,
|
||||
storyID: story.id,
|
||||
});
|
||||
onDismiss();
|
||||
}, [unfeature, onDismiss, story, comment]);
|
||||
const approved = comment.status === "APPROVED";
|
||||
const rejected = comment.status === "REJECTED";
|
||||
const featured = comment.tags.some(t => t.code === "FEATURED");
|
||||
const showBanOption =
|
||||
!comment.author || !comment.author.id || viewer === null
|
||||
? false
|
||||
: comment.author!.id !== viewer.id;
|
||||
|
||||
return (
|
||||
<>
|
||||
{featured ? (
|
||||
<Localized id="comments-moderationDropdown-unfeature">
|
||||
<DropdownButton
|
||||
icon={
|
||||
<Icon className={styles.featured} size="md">
|
||||
star
|
||||
</Icon>
|
||||
}
|
||||
className={styles.featured}
|
||||
onClick={onUnfeature}
|
||||
>
|
||||
Un-Feature
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
) : (
|
||||
<Localized id="comments-moderationDropdown-feature">
|
||||
<DropdownButton
|
||||
icon={<Icon size="md">star_border</Icon>}
|
||||
onClick={onFeature}
|
||||
>
|
||||
Feature
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
)}
|
||||
{approved ? (
|
||||
<Localized id="comments-moderationDropdown-approved">
|
||||
<DropdownButton
|
||||
icon={
|
||||
<Icon
|
||||
className={cn(styles.approveIcon, styles.approved)}
|
||||
size="md"
|
||||
>
|
||||
check
|
||||
</Icon>
|
||||
}
|
||||
className={styles.approved}
|
||||
disabled
|
||||
>
|
||||
Approved
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
) : (
|
||||
<Localized id="comments-moderationDropdown-approve">
|
||||
<DropdownButton
|
||||
icon={
|
||||
<Icon size="md" className={styles.approveIcon}>
|
||||
check
|
||||
</Icon>
|
||||
}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
)}
|
||||
{rejected ? (
|
||||
<Localized id="comments-moderationDropdown-rejected">
|
||||
<DropdownButton
|
||||
icon={
|
||||
<Icon
|
||||
className={cn(styles.rejectIcon, styles.rejected)}
|
||||
size="md"
|
||||
>
|
||||
close
|
||||
</Icon>
|
||||
}
|
||||
className={styles.rejected}
|
||||
disabled
|
||||
>
|
||||
Rejected
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
) : (
|
||||
<Localized id="comments-moderationDropdown-reject">
|
||||
<DropdownButton
|
||||
icon={
|
||||
<Icon size="md" className={styles.rejectIcon}>
|
||||
close
|
||||
</Icon>
|
||||
}
|
||||
onClick={onReject}
|
||||
>
|
||||
Reject
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
)}
|
||||
{showBanOption && (
|
||||
<>
|
||||
<DropdownDivider />
|
||||
<ModerationActionBanQuery onBan={onBan} userID={comment.author!.id} />
|
||||
</>
|
||||
)}
|
||||
<DropdownDivider />
|
||||
<Localized id="comments-moderationDropdown-goToModerate">
|
||||
<DropdownButton
|
||||
href={`/admin/moderate/comment/${comment.id}`}
|
||||
target="_blank"
|
||||
anchor
|
||||
>
|
||||
Go to Moderate
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
comment: graphql`
|
||||
fragment ModerationActionsContainer_comment on Comment {
|
||||
id
|
||||
author {
|
||||
id
|
||||
}
|
||||
revision {
|
||||
id
|
||||
}
|
||||
status
|
||||
tags {
|
||||
code
|
||||
}
|
||||
}
|
||||
`,
|
||||
story: graphql`
|
||||
fragment ModerationActionsContainer_story on Story {
|
||||
id
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment ModerationActionsContainer_viewer on User {
|
||||
id
|
||||
}
|
||||
`,
|
||||
})(ModerationActionsContainer);
|
||||
|
||||
export default enhanced;
|
||||
+41
-128
@@ -1,153 +1,54 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { ModerationDropdownContainer_comment } from "coral-stream/__generated__/ModerationDropdownContainer_comment.graphql";
|
||||
import { ModerationDropdownContainer_story } from "coral-stream/__generated__/ModerationDropdownContainer_story.graphql";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownButton,
|
||||
DropdownDivider,
|
||||
Icon,
|
||||
} from "coral-ui/components";
|
||||
import { ModerationDropdownContainer_viewer } from "coral-stream/__generated__/ModerationDropdownContainer_viewer.graphql";
|
||||
import { Dropdown } from "coral-ui/components";
|
||||
|
||||
import ApproveCommentMutation from "./ApproveCommentMutation";
|
||||
import FeatureCommentMutation from "./FeatureCommentMutation";
|
||||
import RejectCommentMutation from "./RejectCommentMutation";
|
||||
import UnfeatureCommentMutation from "./UnfeatureCommentMutation";
|
||||
import UserBanPopoverContainer from "../UserBanPopover/UserBanPopoverContainer";
|
||||
import ModerationActionsContainer from "./ModerationActionsContainer";
|
||||
|
||||
import styles from "./ModerationDropdownContainer.css";
|
||||
type View = "MODERATE" | "BAN";
|
||||
|
||||
interface Props {
|
||||
comment: ModerationDropdownContainer_comment;
|
||||
story: ModerationDropdownContainer_story;
|
||||
viewer: ModerationDropdownContainer_viewer;
|
||||
onDismiss: () => void;
|
||||
scheduleUpdate: () => void;
|
||||
}
|
||||
|
||||
const ModerationDropdownContainer: FunctionComponent<Props> = ({
|
||||
comment,
|
||||
story,
|
||||
viewer,
|
||||
onDismiss,
|
||||
scheduleUpdate,
|
||||
}) => {
|
||||
const approve = useMutation(ApproveCommentMutation);
|
||||
const feature = useMutation(FeatureCommentMutation);
|
||||
const unfeature = useMutation(UnfeatureCommentMutation);
|
||||
const reject = useMutation(RejectCommentMutation);
|
||||
|
||||
const onApprove = useCallback(() => {
|
||||
approve({ commentID: comment.id, commentRevisionID: comment.revision.id });
|
||||
}, [approve, comment]);
|
||||
const onReject = useCallback(
|
||||
() =>
|
||||
reject({ commentID: comment.id, commentRevisionID: comment.revision.id }),
|
||||
[approve, comment]
|
||||
);
|
||||
const onFeature = useCallback(() => {
|
||||
feature({
|
||||
storyID: story.id,
|
||||
commentID: comment.id,
|
||||
commentRevisionID: comment.revision.id,
|
||||
});
|
||||
onDismiss();
|
||||
}, [feature, comment]);
|
||||
const onUnfeature = useCallback(() => {
|
||||
unfeature({
|
||||
commentID: comment.id,
|
||||
storyID: story.id,
|
||||
});
|
||||
onDismiss();
|
||||
}, [unfeature, comment]);
|
||||
|
||||
const approved = comment.status === "APPROVED";
|
||||
const rejected = comment.status === "REJECTED";
|
||||
const featured = comment.tags.some(t => t.code === "FEATURED");
|
||||
const [view, setView] = useState<View>("MODERATE");
|
||||
const onBan = useCallback(() => {
|
||||
setView("BAN");
|
||||
scheduleUpdate();
|
||||
}, [setView, scheduleUpdate]);
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
{featured ? (
|
||||
<Localized id="comments-moderationDropdown-unfeature">
|
||||
<DropdownButton
|
||||
icon={
|
||||
<Icon className={styles.featured} size="md">
|
||||
star
|
||||
</Icon>
|
||||
}
|
||||
className={styles.featured}
|
||||
onClick={onUnfeature}
|
||||
>
|
||||
Un-Feature
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
<div>
|
||||
{view === "MODERATE" ? (
|
||||
<Dropdown>
|
||||
<ModerationActionsContainer
|
||||
comment={comment}
|
||||
story={story}
|
||||
viewer={viewer}
|
||||
onDismiss={onDismiss}
|
||||
onBan={onBan}
|
||||
/>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Localized id="comments-moderationDropdown-feature">
|
||||
<DropdownButton
|
||||
icon={<Icon size="md">star_border</Icon>}
|
||||
onClick={onFeature}
|
||||
>
|
||||
Feature
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
<UserBanPopoverContainer comment={comment} onDismiss={onDismiss} />
|
||||
)}
|
||||
{approved ? (
|
||||
<Localized id="comments-moderationDropdown-approved">
|
||||
<DropdownButton
|
||||
icon={
|
||||
<Icon className={styles.approved} size="md">
|
||||
check
|
||||
</Icon>
|
||||
}
|
||||
className={styles.approved}
|
||||
disabled
|
||||
>
|
||||
Approved
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
) : (
|
||||
<Localized id="comments-moderationDropdown-approve">
|
||||
<DropdownButton
|
||||
icon={<Icon size="md">check</Icon>}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
)}
|
||||
{rejected ? (
|
||||
<Localized id="comments-moderationDropdown-rejected">
|
||||
<DropdownButton
|
||||
icon={
|
||||
<Icon className={styles.rejected} size="md">
|
||||
close
|
||||
</Icon>
|
||||
}
|
||||
className={styles.rejected}
|
||||
disabled
|
||||
>
|
||||
Rejected
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
) : (
|
||||
<Localized id="comments-moderationDropdown-reject">
|
||||
<DropdownButton
|
||||
icon={<Icon size="md">close</Icon>}
|
||||
onClick={onReject}
|
||||
>
|
||||
Reject
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
)}
|
||||
<DropdownDivider />
|
||||
<Localized id="comments-moderationDropdown-goToModerate">
|
||||
<DropdownButton
|
||||
href={`/admin/moderate/comment/${comment.id}`}
|
||||
target="_blank"
|
||||
anchor
|
||||
>
|
||||
Go to Moderate
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -155,6 +56,10 @@ const enhanced = withFragmentContainer<Props>({
|
||||
comment: graphql`
|
||||
fragment ModerationDropdownContainer_comment on Comment {
|
||||
id
|
||||
author {
|
||||
id
|
||||
username
|
||||
}
|
||||
revision {
|
||||
id
|
||||
}
|
||||
@@ -162,11 +67,19 @@ const enhanced = withFragmentContainer<Props>({
|
||||
tags {
|
||||
code
|
||||
}
|
||||
...ModerationActionsContainer_comment
|
||||
...UserBanPopoverContainer_comment
|
||||
}
|
||||
`,
|
||||
story: graphql`
|
||||
fragment ModerationDropdownContainer_story on Story {
|
||||
id
|
||||
...ModerationActionsContainer_story
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment ModerationDropdownContainer_viewer on User {
|
||||
...ModerationActionsContainer_viewer
|
||||
}
|
||||
`,
|
||||
})(ModerationDropdownContainer);
|
||||
|
||||
+7
@@ -114,7 +114,14 @@ graphql`
|
||||
graphql`
|
||||
fragment CreateCommentReplyMutation_viewer on User {
|
||||
role
|
||||
badges
|
||||
createdAt
|
||||
status {
|
||||
current
|
||||
ban {
|
||||
active
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
/** end */
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { BanUserMutation } from "coral-stream/__generated__/BanUserMutation.graphql";
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
const BanUserMutation = createMutation(
|
||||
"banUser",
|
||||
(environment: Environment, input: MutationInput<BanUserMutation>) => {
|
||||
return commitMutationPromiseNormalized<BanUserMutation>(environment, {
|
||||
mutation: graphql`
|
||||
mutation BanUserMutation($input: BanUserInput!) {
|
||||
banUser(input: $input) {
|
||||
user {
|
||||
id
|
||||
status {
|
||||
ban {
|
||||
active
|
||||
}
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
optimisticResponse: {
|
||||
banUser: {
|
||||
user: {
|
||||
id: input.userID,
|
||||
status: {
|
||||
ban: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default BanUserMutation;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
.root {
|
||||
width: 280px;
|
||||
max-width: 80vw;
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
import { getMessage } from "coral-framework/lib/i18n";
|
||||
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { UserBanPopoverContainer_comment } from "coral-stream/__generated__/UserBanPopoverContainer_comment.graphql";
|
||||
import { Box, Button, Flex, Typography } from "coral-ui/components";
|
||||
|
||||
import RejectCommentMutation from "../ModerationDropdown/RejectCommentMutation";
|
||||
import BanUserMutation from "./BanUserMutation";
|
||||
|
||||
import styles from "./UserBanPopoverContainer.css";
|
||||
|
||||
interface Props {
|
||||
onDismiss: () => void;
|
||||
comment: UserBanPopoverContainer_comment;
|
||||
}
|
||||
|
||||
const UserBanPopoverContainer: FunctionComponent<Props> = ({
|
||||
comment,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const user = comment.author!;
|
||||
const rejected = comment.status === "REJECTED";
|
||||
const reject = useMutation(RejectCommentMutation);
|
||||
const banUser = useMutation(BanUserMutation);
|
||||
const { localeBundles } = useCoralContext();
|
||||
|
||||
const onBan = useCallback(() => {
|
||||
banUser({
|
||||
userID: user.id,
|
||||
message: getMessage(
|
||||
localeBundles,
|
||||
"common-banEmailTemplate",
|
||||
"Someone with access to your account has violated our community guidelines. As a result, your account has been banned. You will no longer be able to comment, react or report comments",
|
||||
{ username: user.username }
|
||||
),
|
||||
});
|
||||
if (!rejected) {
|
||||
reject({ commentID: comment.id, commentRevisionID: comment.revision.id });
|
||||
}
|
||||
onDismiss();
|
||||
}, [user, banUser, onDismiss, localeBundles]);
|
||||
return (
|
||||
<Box className={styles.root} p={3}>
|
||||
<Localized id="comments-userBanPopover-title" $username={user.username}>
|
||||
<Typography variant="heading3" mb={2}>
|
||||
Ban {user.username}?
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized id="comments-userBanPopover-description">
|
||||
<Typography variant="detail" mb={3}>
|
||||
Once banned, this user will no longer be able to comment, use
|
||||
reactions, or report comments.
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Flex justifyContent="flex-end" itemGutter="half">
|
||||
<Localized id="comments-userBanPopover-cancel">
|
||||
<Button variant="outlined" size="small" onClick={onDismiss}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Localized>
|
||||
<Localized id="comments-userBanPopover-ban">
|
||||
<Button variant="filled" size="small" onClick={onBan}>
|
||||
Ban
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
comment: graphql`
|
||||
fragment UserBanPopoverContainer_comment on Comment {
|
||||
id
|
||||
revision {
|
||||
id
|
||||
}
|
||||
status
|
||||
author {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(UserBanPopoverContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -79,6 +79,12 @@ graphql`
|
||||
role
|
||||
createdAt
|
||||
badges
|
||||
status {
|
||||
current
|
||||
ban {
|
||||
active
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
// tslint:disable-next-line:no-unused-expression
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { getViewer } from "coral-framework/helpers";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
@@ -19,6 +20,7 @@ const UpdateEmailMutation = createMutation(
|
||||
updateEmail(input: $input) {
|
||||
clientMutationId
|
||||
user {
|
||||
id
|
||||
email
|
||||
emailVerified
|
||||
}
|
||||
@@ -35,6 +37,10 @@ const UpdateEmailMutation = createMutation(
|
||||
updateEmail: {
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
user: {
|
||||
// Only a logged in user will be able to change its email
|
||||
// and access this mutation, so the viewer is always available
|
||||
// in the cache when calling this mutation.
|
||||
id: getViewer(environment)!.id,
|
||||
email: input.email,
|
||||
emailVerified: false,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { getViewer } from "coral-framework/helpers";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
@@ -42,6 +43,7 @@ const UpdateUsernameMutation = createMutation(
|
||||
updateUsername: {
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
user: {
|
||||
id: getViewer(environment)!.id,
|
||||
username: input.username,
|
||||
status: {
|
||||
username: {
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import timekeeper from "timekeeper";
|
||||
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { GQLResolver, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
waitForElement,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
import {
|
||||
createComment,
|
||||
createStory,
|
||||
createUser,
|
||||
createUserStatus,
|
||||
} from "coral-stream/test/helpers/fixture";
|
||||
|
||||
import { comments, settings, stories } from "../../fixtures";
|
||||
import { settings } from "../../fixtures";
|
||||
import create from "./create";
|
||||
|
||||
const story = stories[0];
|
||||
const bannedUser = createUser();
|
||||
bannedUser.status = createUserStatus(true);
|
||||
|
||||
const story = createStory();
|
||||
const firstComment = story.comments.edges[0].node;
|
||||
const viewer = firstComment.author!;
|
||||
const reactedComment = createComment();
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
@@ -25,22 +34,17 @@ async function createTestRenderer(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () =>
|
||||
pureMerge<typeof viewer>(viewer, {
|
||||
status: {
|
||||
current: [GQLUSER_STATUS.BANNED],
|
||||
},
|
||||
}),
|
||||
viewer: () => bannedUser,
|
||||
story: () =>
|
||||
pureMerge<typeof story>(story, {
|
||||
comments: {
|
||||
edges: [
|
||||
...story.comments.edges,
|
||||
{
|
||||
node: pureMerge<typeof comments[2]>(comments[2], {
|
||||
node: pureMerge<typeof reactedComment>(reactedComment, {
|
||||
actionCounts: { reaction: { total: 1 } },
|
||||
}),
|
||||
cursor: comments[2].createdAt,
|
||||
cursor: reactedComment.createdAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -203,3 +203,114 @@ it("reject comment", async () => {
|
||||
);
|
||||
expect(link.props.href).toBe(`/admin/moderate/comment/${firstComment.id}`);
|
||||
});
|
||||
|
||||
it("ban user", async () => {
|
||||
const { testRenderer, tabPane } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
user: ({ variables }) => {
|
||||
expectAndFail(variables.id).toBe(firstComment.author!.id);
|
||||
return firstComment.author!;
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
banUser: ({ variables }) => {
|
||||
expectAndFail(variables).toMatchObject({
|
||||
userID: firstComment.author!.id,
|
||||
});
|
||||
return {
|
||||
user: pureMerge<typeof firstComment.author>(firstComment.author!, {
|
||||
status: {
|
||||
ban: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
rejectComment: ({ variables }) => {
|
||||
expectAndFail(variables).toMatchObject({
|
||||
commentID: firstComment.id,
|
||||
commentRevisionID: firstComment.revision.id,
|
||||
});
|
||||
return {
|
||||
comment: pureMerge<typeof firstComment>(firstComment, {
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const comment = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID(`comment-${firstComment.id}`)
|
||||
);
|
||||
const caretButton = within(comment).getByLabelText("Moderate");
|
||||
caretButton.props.onClick();
|
||||
|
||||
await act(async () => {
|
||||
const banButton = await waitForElement(() => {
|
||||
const el = within(comment).getByText("Ban User", {
|
||||
selector: "button",
|
||||
});
|
||||
expect(el.props.disabled).toBeFalsy();
|
||||
return el;
|
||||
});
|
||||
banButton.props.onClick();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const banButtonDialog = within(comment).getByText("Ban", {
|
||||
selector: "button",
|
||||
});
|
||||
banButtonDialog.props.onClick();
|
||||
});
|
||||
|
||||
await waitForElement(() =>
|
||||
within(tabPane).getByText("You have rejected this comment", {
|
||||
exact: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("cancel ban user", async () => {
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
user: ({ variables }) => {
|
||||
expectAndFail(variables.id).toBe(firstComment.author!.id);
|
||||
return firstComment.author!;
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const comment = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID(`comment-${firstComment.id}`)
|
||||
);
|
||||
const caretButton = within(comment).getByLabelText("Moderate");
|
||||
caretButton.props.onClick();
|
||||
|
||||
await act(async () => {
|
||||
const banButton = await waitForElement(() => {
|
||||
const el = within(comment).getByText("Ban User", {
|
||||
selector: "button",
|
||||
});
|
||||
expect(el.props.disabled).toBeFalsy();
|
||||
return el;
|
||||
});
|
||||
banButton.props.onClick();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const cancelButtonDialog = within(comment).getByText("Cancel", {
|
||||
selector: "button",
|
||||
});
|
||||
cancelButtonDialog.props.onClick();
|
||||
});
|
||||
|
||||
expect(
|
||||
within(comment).queryByText("Ban", {
|
||||
selector: "button",
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -115,13 +115,20 @@ export const baseUser = createFixture<GQLUser>({
|
||||
createdAt: "2018-02-06T18:24:00.000Z",
|
||||
id: "base-user",
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
badges: [],
|
||||
status: {
|
||||
current: [GQLUSER_STATUS.ACTIVE],
|
||||
ban: {
|
||||
active: false,
|
||||
history: [],
|
||||
},
|
||||
username: {
|
||||
history: [],
|
||||
},
|
||||
suspension: {
|
||||
active: false,
|
||||
until: null,
|
||||
history: [],
|
||||
},
|
||||
},
|
||||
ignoredUsers: [],
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
GQLComment,
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLMODERATION_MODE,
|
||||
GQLStory,
|
||||
GQLUser,
|
||||
GQLUSER_ROLE,
|
||||
GQLUSER_STATUS,
|
||||
GQLUserStatus,
|
||||
} from "coral-framework/schema";
|
||||
import {
|
||||
createFixture,
|
||||
denormalizeComment,
|
||||
denormalizeStory,
|
||||
} from "coral-framework/testHelpers";
|
||||
import uuid from "uuid/v4";
|
||||
|
||||
export function createDateInRange(start: Date, end: Date) {
|
||||
return new Date(
|
||||
start.getTime() + Math.random() * (end.getTime() - start.getTime())
|
||||
);
|
||||
}
|
||||
|
||||
export function randomDate() {
|
||||
return createDateInRange(new Date(2000, 0, 1), new Date());
|
||||
}
|
||||
|
||||
export function createUserStatus(banned: boolean = false): GQLUserStatus {
|
||||
return {
|
||||
current: [banned ? GQLUSER_STATUS.BANNED : GQLUSER_STATUS.ACTIVE],
|
||||
ban: {
|
||||
active: banned,
|
||||
history: [],
|
||||
},
|
||||
suspension: {
|
||||
active: false,
|
||||
until: null,
|
||||
history: [],
|
||||
},
|
||||
username: {
|
||||
history: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createUser() {
|
||||
return createFixture<GQLUser>({
|
||||
id: uuid(),
|
||||
username: uuid(),
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
createdAt: randomDate().toISOString(),
|
||||
status: createUserStatus(),
|
||||
ignoredUsers: [],
|
||||
comments: {
|
||||
edges: [],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
},
|
||||
},
|
||||
ignoreable: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function createComment() {
|
||||
const revision = uuid();
|
||||
const createdAt = randomDate();
|
||||
const editableUntil = new Date(createdAt.getTime() + 30 * 60000);
|
||||
const author = createUser();
|
||||
author.createdAt = new Date(createdAt.getTime() - 60 * 60000).toISOString();
|
||||
|
||||
return denormalizeComment(
|
||||
createFixture<GQLComment>({
|
||||
id: uuid(),
|
||||
author,
|
||||
body: uuid(),
|
||||
revision: {
|
||||
id: revision,
|
||||
},
|
||||
status: GQLCOMMENT_STATUS.NONE,
|
||||
createdAt: createdAt.toISOString(),
|
||||
replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
|
||||
replyCount: 0,
|
||||
editing: {
|
||||
edited: false,
|
||||
editableUntil: editableUntil.toISOString(),
|
||||
},
|
||||
actionCounts: {
|
||||
reaction: {
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
tags: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function createStory(createComments: boolean = true) {
|
||||
const id = uuid();
|
||||
const comments = [createComment(), createComment()];
|
||||
|
||||
return denormalizeStory(
|
||||
createFixture<GQLStory>({
|
||||
id,
|
||||
url: `http://localhost/stories/story-${id}`,
|
||||
comments: {
|
||||
edges: [
|
||||
{ node: comments[0], cursor: comments[0].createdAt },
|
||||
{ node: comments[1], cursor: comments[1].createdAt },
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
title: uuid(),
|
||||
},
|
||||
isClosed: false,
|
||||
commentCounts: {
|
||||
totalPublished: 0,
|
||||
tags: {
|
||||
FEATURED: 0,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
moderation: GQLMODERATION_MODE.POST,
|
||||
premodLinksEnable: false,
|
||||
messageBox: {
|
||||
enabled: false,
|
||||
},
|
||||
live: {
|
||||
enabled: true,
|
||||
configurable: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.root:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.root:not(:disabled):active {
|
||||
background-color: var(--palette-primary-lightest);
|
||||
}
|
||||
@@ -31,16 +35,17 @@
|
||||
align-items: center;
|
||||
color: var(--palette-grey-main);
|
||||
margin-right: var(--spacing-2);
|
||||
padding-top: 1px;
|
||||
}
|
||||
.iconAfter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: baseline;
|
||||
margin-left: var(--spacing-2);
|
||||
text-decoration: unset;
|
||||
}
|
||||
.iconOpenInNew {
|
||||
padding-top: 1px;
|
||||
color: var(--palette-primary-main);
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
.blankAdornment {
|
||||
|
||||
@@ -17,6 +17,10 @@ interface Props extends Omit<BaseButtonProps, "ref"> {
|
||||
className?: string;
|
||||
onClick?: React.EventHandler<React.MouseEvent>;
|
||||
classes: typeof styles;
|
||||
/**
|
||||
* adornment if set is rendered at the end of the button.
|
||||
*/
|
||||
adornment?: React.ReactNode;
|
||||
/**
|
||||
* blankAdornment if true will leave some blank space after the text, so
|
||||
* that it looks nice, if mixed with other buttons which have an external link
|
||||
@@ -34,6 +38,7 @@ const Button: FunctionComponent<Props> = ({
|
||||
classes,
|
||||
icon,
|
||||
disabled,
|
||||
adornment,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
@@ -60,8 +65,11 @@ const Button: FunctionComponent<Props> = ({
|
||||
{children}
|
||||
</div>
|
||||
</Flex>
|
||||
{rest.target === "_blank" && (
|
||||
<Icon className={classes.iconAfter}>open_in_new</Icon>
|
||||
{adornment && <div className={classes.iconAfter}>{adornment}</div>}
|
||||
{!adornment && rest.target === "_blank" && (
|
||||
<div className={classes.iconAfter}>
|
||||
<Icon className={classes.iconOpenInNew}>open_in_new</Icon>
|
||||
</div>
|
||||
)}
|
||||
</BaseButton>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ exports[`renders anchor button 1`] = `
|
||||
"blankAdornment": "Button-blankAdornment",
|
||||
"iconAfter": "Button-iconAfter",
|
||||
"iconBefore": "Button-iconBefore",
|
||||
"iconOpenInNew": "Button-iconOpenInNew",
|
||||
"mouseHover": "Button-mouseHover",
|
||||
"root": "Button-root",
|
||||
}
|
||||
@@ -31,6 +32,7 @@ exports[`renders button 1`] = `
|
||||
"blankAdornment": "Button-blankAdornment",
|
||||
"iconAfter": "Button-iconAfter",
|
||||
"iconBefore": "Button-iconBefore",
|
||||
"iconOpenInNew": "Button-iconOpenInNew",
|
||||
"mouseHover": "Button-mouseHover",
|
||||
"root": "Button-root",
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ export type RequireProperty<T, P extends keyof T> = Omit<T, P> &
|
||||
Required<Pick<T, P>>;
|
||||
|
||||
/**
|
||||
* Make all properties in T writeable
|
||||
* Make all properties in T Writable
|
||||
*/
|
||||
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
|
||||
export type Writable<T> = { -readonly [P in keyof T]: T[P] };
|
||||
|
||||
/**
|
||||
* Defines a type that may be a promise or a simple value return.
|
||||
@@ -22,6 +22,22 @@ export type Promiseable<T> = Promise<T> | T;
|
||||
|
||||
export type Nullable<T> = { [P in keyof T]: T[P] | null };
|
||||
|
||||
export type DeepWritableObject<T> = T extends object
|
||||
? {
|
||||
-readonly [P in keyof T]: T[P] extends (Array<infer U> | undefined)
|
||||
? Array<DeepWritableObject<U>>
|
||||
: T[P] extends (ReadonlyArray<infer V> | undefined)
|
||||
? ReadonlyArray<DeepWritableObject<V>>
|
||||
: DeepWritableObject<T[P]>
|
||||
}
|
||||
: T;
|
||||
|
||||
export type DeepWritable<T> = T extends (
|
||||
| Array<infer U>
|
||||
| ReadonlyArray<infer U>)
|
||||
? Array<DeepWritableObject<U>>
|
||||
: DeepWritableObject<T>;
|
||||
|
||||
export type DeepNullable<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]: T[P] extends (Array<infer U> | undefined)
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ERROR_CODES, ERROR_TYPES } from "coral-common/errors";
|
||||
import { reduceSeconds, UNIT } from "coral-common/helpers/i18n";
|
||||
import { translate } from "coral-server/services/i18n";
|
||||
|
||||
import { Writeable } from "coral-common/types";
|
||||
import { Writable } from "coral-common/types";
|
||||
import { GQLUSER_AUTH_CONDITIONS } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import { ERROR_TRANSLATIONS } from "./translations";
|
||||
|
||||
@@ -482,7 +482,7 @@ export class InternalDevelopmentError extends CoralError {
|
||||
bundle: FluentBundle | null
|
||||
): CoralErrorExtensions {
|
||||
// Serialize the extensions from the public source.
|
||||
const extensions = super.serializeExtensions(bundle) as Writeable<
|
||||
const extensions = super.serializeExtensions(bundle) as Writable<
|
||||
CoralErrorExtensions
|
||||
>;
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ email-subject-forgotPassword = Password Reset Request
|
||||
|
||||
email-notification-template-ban =
|
||||
{ $customMessage }<br /><br />
|
||||
if you think this has been done in error, please contact our community team
|
||||
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
|
||||
@@ -56,4 +56,4 @@ email-notification-template-invite =
|
||||
email-subject-downloadComments = Your comments are ready for download
|
||||
email-notification-template-downloadComments =
|
||||
Your comments from { $organizationName } as of { $date } are now available for download.<br /><br />
|
||||
<a data-l10n-name="downloadUrl">Download my comment archive</a>
|
||||
<a data-l10n-name="downloadUrl">Download my comment archive</a>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { merge } from "lodash";
|
||||
import { Collection, IndexOptions } from "mongodb";
|
||||
|
||||
import { Writeable } from "coral-common/types";
|
||||
import { Writable } from "coral-common/types";
|
||||
import logger from "coral-server/logger";
|
||||
|
||||
type IndexType = 1 | -1 | "text";
|
||||
|
||||
export type IndexSpecification<T> = {
|
||||
[P in keyof Writeable<Partial<T>>]: IndexType
|
||||
[P in keyof Writable<Partial<T>>]: IndexType
|
||||
} &
|
||||
Record<string, IndexType>;
|
||||
|
||||
|
||||
@@ -2,15 +2,15 @@ import { isUndefined, omitBy } from "lodash";
|
||||
|
||||
import { Collection, Cursor, FilterQuery as MongoFilterQuery } from "mongodb";
|
||||
|
||||
import { Writeable } from "coral-common/types";
|
||||
import { Writable } from "coral-common/types";
|
||||
import logger from "coral-server/logger";
|
||||
|
||||
/**
|
||||
* FilterQuery<T> ensures that given the type T, that the FilterQuery will be a
|
||||
* writeable, partial set of properties while also including MongoDB specific
|
||||
* Writable, partial set of properties while also including MongoDB specific
|
||||
* properties (like $lt, or $gte).
|
||||
*/
|
||||
export type FilterQuery<T> = MongoFilterQuery<Writeable<Partial<T>>>;
|
||||
export type FilterQuery<T> = MongoFilterQuery<Writable<Partial<T>>>;
|
||||
|
||||
/**
|
||||
* Query is a convenience class used to wrap the existing MongoDB driver to
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
Hello {{ context.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,
|
||||
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" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
|
||||
{% endblock %}
|
||||
|
||||
@@ -570,10 +570,6 @@ community-banModal-consequence =
|
||||
community-banModal-cancel = Afbestille
|
||||
community-banModal-banUser = Forbud bruger
|
||||
community-banModal-customize = Tilpas forbud e-mail-besked
|
||||
community-banModal-emailTemplate =
|
||||
Hej { $username },
|
||||
|
||||
En person med adgang til din konto har overtrådt vores fællesskabsretningslinjer. Som et resultat er din konto forbudt. Du vil ikke længere være i stand til at kommentere, reagere eller rapportere kommentarer.
|
||||
|
||||
community-suspendModal-areYouSure = Suspenderer <strong>{ $username }</strong>?
|
||||
community-suspendModal-consequence =
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
-brand-name = The Coral Project
|
||||
-product-name = Coral
|
||||
|
||||
common-banEmailTemplate =
|
||||
Hej { $username },
|
||||
|
||||
En person med adgang til din konto har overtrådt vores fællesskabsretningslinjer. Som et resultat er din konto forbudt. Du vil ikke længere være i stand til at kommentere, reagere eller rapportere kommentarer.
|
||||
|
||||
@@ -472,8 +472,8 @@ moderate-user-drawer-account-history-banned = Banned
|
||||
moderate-user-drawer-account-history-ban-removed = Ban removed
|
||||
moderate-user-drawer-account-history-no-history = No actions have been taken on this account
|
||||
moderate-user-drawer-username-change = Username change
|
||||
moderate-user-drawer-username-change-new = New:
|
||||
moderate-user-drawer-username-change-old = Old:
|
||||
moderate-user-drawer-username-change-new = New:
|
||||
moderate-user-drawer-username-change-old = Old:
|
||||
|
||||
moderate-user-drawer-suspension =
|
||||
Suspension, { $value } { $unit ->
|
||||
@@ -617,10 +617,6 @@ community-banModal-consequence =
|
||||
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 =
|
||||
@@ -729,19 +725,19 @@ userDetails-suspension-start = <strong>Start:</strong> { $timestamp }
|
||||
userDetails-suspension-end = <strong>End:</strong> { $timestamp }
|
||||
|
||||
configure-general-reactions-title = Reactions
|
||||
configure-general-reactions-explanation =
|
||||
configure-general-reactions-explanation =
|
||||
Allow your community to engage with one another and express themselves
|
||||
with one-click reactions. By default, Coral allows commenters to
|
||||
"Respect" each other's comments, but you may customize reaction text
|
||||
based on the needs of your community.
|
||||
configure-general-reactions-label = Reaction label
|
||||
configure-general-reactions-input =
|
||||
configure-general-reactions-input =
|
||||
.placehodlder = E.g. Respect
|
||||
configure-general-reactions-active-label = Active reaction label
|
||||
configure-general-reactions-active-input =
|
||||
configure-general-reactions-active-input =
|
||||
.placehodlder = E.g. Respected
|
||||
configure-general-reactions-sort-label = Sort label
|
||||
configure-general-reactions-sort-input =
|
||||
configure-general-reactions-sort-input =
|
||||
.placehodlder = E.g. Most Respected
|
||||
configure-general-reactions-preview = Preview
|
||||
configure-general-reaction-sortMenu-sortBy = Sort by
|
||||
configure-general-reaction-sortMenu-sortBy = Sort by
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
-brand-name = The Coral Project
|
||||
-product-name = Coral
|
||||
|
||||
common-banEmailTemplate =
|
||||
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.
|
||||
|
||||
@@ -119,6 +119,14 @@ comments-userIgnorePopover-description =
|
||||
comments-userIgnorePopover-ignore = Ignore
|
||||
comments-userIgnorePopover-cancel = Cancel
|
||||
|
||||
comments-userBanPopover-title = Ban {$username}?
|
||||
comments-userBanPopover-description =
|
||||
Once banned, this user will no longer be able
|
||||
to comment, use reactions, or report comments.
|
||||
This comment will also be rejected.
|
||||
comments-userBanPopover-cancel = Cancel
|
||||
comments-userBanPopover-ban = Ban
|
||||
|
||||
comments-moderationDropdown-popover =
|
||||
.description = A popover menu to moderate the comment
|
||||
comments-moderationDropdown-feature = Feature
|
||||
@@ -127,6 +135,8 @@ comments-moderationDropdown-approve = Approve
|
||||
comments-moderationDropdown-approved = Approved
|
||||
comments-moderationDropdown-reject = Reject
|
||||
comments-moderationDropdown-rejected = Rejected
|
||||
comments-moderationDropdown-ban = Ban User
|
||||
comments-moderationDropdown-banned = Banned
|
||||
comments-moderationDropdown-goToModerate = Go to Moderate
|
||||
comments-moderationDropdown-caretButton =
|
||||
.aria-label = Moderate
|
||||
@@ -282,7 +292,7 @@ suspendInfo-info =
|
||||
profile-changeEmail-unverified = (Unverified)
|
||||
profile-changeEmail-edit = Edit
|
||||
profile-changeEmail-please-verify = Verify your email address
|
||||
profile-changeEmail-please-verify-details =
|
||||
profile-changeEmail-please-verify-details =
|
||||
An email has been sent to { $email } to verify your account.
|
||||
You must verify your new email address before it can be used for
|
||||
signing into your account or for email notifications.
|
||||
|
||||
Reference in New Issue
Block a user