[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:
Nick Funk
2019-08-22 22:47:30 +07:00
committed by Vinh
parent f3e615a610
commit 6d803b0179
39 changed files with 998 additions and 193 deletions
@@ -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,
},
},
],
+11 -3
View File
@@ -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>;
+1
View File
@@ -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;
@@ -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;
}
@@ -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;
@@ -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}
/>
);
}}
/>
);
}
}
@@ -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);
@@ -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;
@@ -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);
@@ -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;
@@ -0,0 +1,4 @@
.root {
width: 280px;
max-width: 80vw;
}
@@ -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();
});
+7
View File
@@ -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",
}
+18 -2
View File
@@ -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)
+2 -2
View File
@@ -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
>;
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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>;
+3 -3
View File
@@ -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 %}
-4
View File
@@ -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 =
+5
View File
@@ -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.
+7 -11
View File
@@ -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
+5
View File
@@ -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.
+11 -1
View File
@@ -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.