diff --git a/src/core/client/admin/components/UserStatus/BanModal.tsx b/src/core/client/admin/components/UserStatus/BanModal.tsx index 40b19bb0a..5cdd2e63f 100644 --- a/src/core/client/admin/components/UserStatus/BanModal.tsx +++ b/src/core/client/admin/components/UserStatus/BanModal.tsx @@ -34,7 +34,7 @@ const BanModal: FunctionComponent = ({ }) => { 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, diff --git a/src/core/client/admin/components/UserStatus/BanUserMutation.ts b/src/core/client/admin/components/UserStatus/BanUserMutation.ts index ffbeaa2b7..7a497d3d1 100644 --- a/src/core/client/admin/components/UserStatus/BanUserMutation.ts +++ b/src/core/client/admin/components/UserStatus/BanUserMutation.ts @@ -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) => { + const viewer = getViewer(environment)!; return commitMutationPromiseNormalized(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, + }, }, ], }, diff --git a/src/core/client/admin/components/UserStatus/RemoveUserSuspensionMutation.ts b/src/core/client/admin/components/UserStatus/RemoveUserSuspensionMutation.ts index 585c7c762..e0abf84aa 100644 --- a/src/core/client/admin/components/UserStatus/RemoveUserSuspensionMutation.ts +++ b/src/core/client/admin/components/UserStatus/RemoveUserSuspensionMutation.ts @@ -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) => { + const user = lookup(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(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( - 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, }, }, }, diff --git a/src/core/client/admin/components/UserStatus/SuspendUserMutation.ts b/src/core/client/admin/components/UserStatus/SuspendUserMutation.ts index c79e820c2..0625e7c94 100644 --- a/src/core/client/admin/components/UserStatus/SuspendUserMutation.ts +++ b/src/core/client/admin/components/UserStatus/SuspendUserMutation.ts @@ -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) => { + 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, }, }, ], diff --git a/src/core/client/framework/lib/relay/lookup.ts b/src/core/client/framework/lib/relay/lookup.ts index 718dadcf9..c7882daff 100644 --- a/src/core/client/framework/lib/relay/lookup.ts +++ b/src/core/client/framework/lib/relay/lookup.ts @@ -36,10 +36,18 @@ const createProxy = ( 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; diff --git a/src/core/client/stream/permissions.tsx b/src/core/client/stream/permissions.tsx index fb2f9e3cc..0ddee2f40 100644 --- a/src/core/client/stream/permissions.tsx +++ b/src/core/client/stream/permissions.tsx @@ -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; diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx index 2be0fc8f8..47023f8be 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx @@ -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 { 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 (
@@ -262,7 +262,11 @@ export class CommentContainer extends Component { )} {showCaret && ( - + )} } @@ -349,6 +353,7 @@ const enhanced = withSetCommentIDMutation( ...UsernameWithPopoverContainer_viewer ...ReactionButtonContainer_viewer ...ReportButtonContainer_viewer + ...CaretContainer_viewer } `, story: graphql` diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx index b244f5310..c5991ef4b 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx @@ -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 => { @@ -27,12 +29,14 @@ const CaretContainer: FunctionComponent = props => { id={popoverID} placement="bottom-end" description="A popover menu to moderate the comment" - body={({ toggleVisibility }) => ( + body={({ toggleVisibility, scheduleUpdate }) => ( )} @@ -72,6 +76,11 @@ const enhanced = withFragmentContainer({ ...ModerationDropdownContainer_story } `, + viewer: graphql` + fragment CaretContainer_viewer on User { + ...ModerationDropdownContainer_viewer + } + `, })(CaretContainer); export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.css b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.css new file mode 100644 index 000000000..d79dbcb41 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.css @@ -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; +} diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.tsx new file mode 100644 index 000000000..3f15e91a2 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.tsx @@ -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 = ({ + user, + onBan, +}) => { + if (!user) { + return ( + + + block +
+ } + adornment={} + disabled + > + Ban User + + + ); + } + const banned = user.status.ban.active; + if (banned) { + return ( + + + block + + } + className={styles.banned} + disabled + > + Banned + + + ); + } + return ( + + + block + + } + onClick={onBan} + > + Ban User + + + ); +}; + +const enhanced = withFragmentContainer({ + user: graphql` + fragment ModerationActionBanContainer_user on User { + id + status { + ban { + active + } + } + } + `, +})(ModerationActionBanContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanQuery.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanQuery.tsx new file mode 100644 index 000000000..13e33614a --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanQuery.tsx @@ -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 { + public render() { + return ( + + 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
{error.message}
; + } + if (props && !props.user) { + return null; + } + return ( + + ); + }} + /> + ); + } +} diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.css b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.css similarity index 58% rename from src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.css rename to src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.css index 7662868a2..f446e503f 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.css +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.css @@ -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); diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.tsx new file mode 100644 index 000000000..cfebe9b6b --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.tsx @@ -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 = ({ + 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 ? ( + + + star + + } + className={styles.featured} + onClick={onUnfeature} + > + Un-Feature + + + ) : ( + + star_border} + onClick={onFeature} + > + Feature + + + )} + {approved ? ( + + + check + + } + className={styles.approved} + disabled + > + Approved + + + ) : ( + + + check + + } + onClick={onApprove} + > + Approve + + + )} + {rejected ? ( + + + close + + } + className={styles.rejected} + disabled + > + Rejected + + + ) : ( + + + close + + } + onClick={onReject} + > + Reject + + + )} + {showBanOption && ( + <> + + + + )} + + + + Go to Moderate + + + + ); +}; + +const enhanced = withFragmentContainer({ + 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; diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx index f83850f6e..d166d580b 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx @@ -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 = ({ 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("MODERATE"); + const onBan = useCallback(() => { + setView("BAN"); + scheduleUpdate(); + }, [setView, scheduleUpdate]); return ( - - {featured ? ( - - - star - - } - className={styles.featured} - onClick={onUnfeature} - > - Un-Feature - - +
+ {view === "MODERATE" ? ( + + + ) : ( - - star_border} - onClick={onFeature} - > - Feature - - + )} - {approved ? ( - - - check - - } - className={styles.approved} - disabled - > - Approved - - - ) : ( - - check} - onClick={onApprove} - > - Approve - - - )} - {rejected ? ( - - - close - - } - className={styles.rejected} - disabled - > - Rejected - - - ) : ( - - close} - onClick={onReject} - > - Reject - - - )} - - - - Go to Moderate - - - +
); }; @@ -155,6 +56,10 @@ const enhanced = withFragmentContainer({ comment: graphql` fragment ModerationDropdownContainer_comment on Comment { id + author { + id + username + } revision { id } @@ -162,11 +67,19 @@ const enhanced = withFragmentContainer({ 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); diff --git a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts index 7ceed6b4c..341eff9a9 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts @@ -114,7 +114,14 @@ graphql` graphql` fragment CreateCommentReplyMutation_viewer on User { role + badges createdAt + status { + current + ban { + active + } + } } `; /** end */ diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/BanUserMutation.ts b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/BanUserMutation.ts new file mode 100644 index 000000000..d66535191 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/BanUserMutation.ts @@ -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) => { + return commitMutationPromiseNormalized(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; diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css new file mode 100644 index 000000000..362844dbd --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css @@ -0,0 +1,4 @@ +.root { + width: 280px; + max-width: 80vw; +} \ No newline at end of file diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx new file mode 100644 index 000000000..e8fbcd7e1 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx @@ -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 = ({ + 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 ( + + + + Ban {user.username}? + + + + + Once banned, this user will no longer be able to comment, use + reactions, or report comments. + + + + + + + + + + + + ); +}; + +const enhanced = withFragmentContainer({ + comment: graphql` + fragment UserBanPopoverContainer_comment on Comment { + id + revision { + id + } + status + author { + id + username + } + } + `, +})(UserBanPopoverContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts index b4d8cef5a..19cf518e2 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts @@ -79,6 +79,12 @@ graphql` role createdAt badges + status { + current + ban { + active + } + } } `; // tslint:disable-next-line:no-unused-expression diff --git a/src/core/client/stream/tabs/Profile/ChangeEmail/UpdateEmailMutation.ts b/src/core/client/stream/tabs/Profile/ChangeEmail/UpdateEmailMutation.ts index 5cc5fe106..156ba5d05 100644 --- a/src/core/client/stream/tabs/Profile/ChangeEmail/UpdateEmailMutation.ts +++ b/src/core/client/stream/tabs/Profile/ChangeEmail/UpdateEmailMutation.ts @@ -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, }, diff --git a/src/core/client/stream/tabs/Profile/ChangeUsername/UpdateUsernameMutation.tsx b/src/core/client/stream/tabs/Profile/ChangeUsername/UpdateUsernameMutation.tsx index 3538b1241..964384865 100644 --- a/src/core/client/stream/tabs/Profile/ChangeUsername/UpdateUsernameMutation.tsx +++ b/src/core/client/stream/tabs/Profile/ChangeUsername/UpdateUsernameMutation.tsx @@ -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: { diff --git a/src/core/client/stream/test/comments/stream/banned.spec.tsx b/src/core/client/stream/test/comments/stream/banned.spec.tsx index 04acf6621..d3f26f0fb 100644 --- a/src/core/client/stream/test/comments/stream/banned.spec.tsx +++ b/src/core/client/stream/test/comments/stream/banned.spec.tsx @@ -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 = {} @@ -25,22 +34,17 @@ async function createTestRenderer( createResolversStub({ Query: { settings: () => settings, - viewer: () => - pureMerge(viewer, { - status: { - current: [GQLUSER_STATUS.BANNED], - }, - }), + viewer: () => bannedUser, story: () => pureMerge(story, { comments: { edges: [ ...story.comments.edges, { - node: pureMerge(comments[2], { + node: pureMerge(reactedComment, { actionCounts: { reaction: { total: 1 } }, }), - cursor: comments[2].createdAt, + cursor: reactedComment.createdAt, }, ], }, diff --git a/src/core/client/stream/test/comments/stream/moderation.spec.tsx b/src/core/client/stream/test/comments/stream/moderation.spec.tsx index af3382690..ad20bec6a 100644 --- a/src/core/client/stream/test/comments/stream/moderation.spec.tsx +++ b/src/core/client/stream/test/comments/stream/moderation.spec.tsx @@ -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({ + 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(firstComment.author!, { + status: { + ban: { + active: true, + }, + }, + }), + }; + }, + rejectComment: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + commentID: firstComment.id, + commentRevisionID: firstComment.revision.id, + }); + return { + comment: pureMerge(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({ + 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(); +}); diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index 6500c00e5..93bf939ea 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -115,13 +115,20 @@ export const baseUser = createFixture({ 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: [], diff --git a/src/core/client/stream/test/helpers/fixture.ts b/src/core/client/stream/test/helpers/fixture.ts new file mode 100644 index 000000000..18a42a9ae --- /dev/null +++ b/src/core/client/stream/test/helpers/fixture.ts @@ -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({ + 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({ + 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({ + 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, + }, + }, + }) + ); +} diff --git a/src/core/client/ui/components/Dropdown/Button.css b/src/core/client/ui/components/Dropdown/Button.css index 958e18502..c4128bfc0 100644 --- a/src/core/client/ui/components/Dropdown/Button.css +++ b/src/core/client/ui/components/Dropdown/Button.css @@ -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 { diff --git a/src/core/client/ui/components/Dropdown/Button.tsx b/src/core/client/ui/components/Dropdown/Button.tsx index 41080cc4d..50df59083 100644 --- a/src/core/client/ui/components/Dropdown/Button.tsx +++ b/src/core/client/ui/components/Dropdown/Button.tsx @@ -17,6 +17,10 @@ interface Props extends Omit { className?: string; onClick?: React.EventHandler; 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 = ({ classes, icon, disabled, + adornment, ...rest }) => { return ( @@ -60,8 +65,11 @@ const Button: FunctionComponent = ({ {children} - {rest.target === "_blank" && ( - open_in_new + {adornment &&
{adornment}
} + {!adornment && rest.target === "_blank" && ( +
+ open_in_new +
)} ); diff --git a/src/core/client/ui/components/Dropdown/__snapshots__/Button.spec.tsx.snap b/src/core/client/ui/components/Dropdown/__snapshots__/Button.spec.tsx.snap index 0a03e2078..6c34fec47 100644 --- a/src/core/client/ui/components/Dropdown/__snapshots__/Button.spec.tsx.snap +++ b/src/core/client/ui/components/Dropdown/__snapshots__/Button.spec.tsx.snap @@ -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", } diff --git a/src/core/common/types.ts b/src/core/common/types.ts index 1ccecacc5..e930b7241 100644 --- a/src/core/common/types.ts +++ b/src/core/common/types.ts @@ -11,9 +11,9 @@ export type RequireProperty = Omit & Required>; /** - * Make all properties in T writeable + * Make all properties in T Writable */ -export type Writeable = { -readonly [P in keyof T]: T[P] }; +export type Writable = { -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 = Promise | T; export type Nullable = { [P in keyof T]: T[P] | null }; +export type DeepWritableObject = T extends object + ? { + -readonly [P in keyof T]: T[P] extends (Array | undefined) + ? Array> + : T[P] extends (ReadonlyArray | undefined) + ? ReadonlyArray> + : DeepWritableObject + } + : T; + +export type DeepWritable = T extends ( + | Array + | ReadonlyArray) + ? Array> + : DeepWritableObject; + export type DeepNullable = T extends object ? { [P in keyof T]: T[P] extends (Array | undefined) diff --git a/src/core/server/errors/index.ts b/src/core/server/errors/index.ts index 2ad268272..33ca90f7f 100644 --- a/src/core/server/errors/index.ts +++ b/src/core/server/errors/index.ts @@ -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 >; diff --git a/src/core/server/locales/en-US/email.ftl b/src/core/server/locales/en-US/email.ftl index bb7b9db24..5e45e1c32 100644 --- a/src/core/server/locales/en-US/email.ftl +++ b/src/core/server/locales/en-US/email.ftl @@ -11,7 +11,7 @@ email-subject-forgotPassword = Password Reset Request email-notification-template-ban = { $customMessage }

- 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 { $organizationContactEmail }. 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.

- Download my comment archive \ No newline at end of file + Download my comment archive diff --git a/src/core/server/models/helpers/indexing.ts b/src/core/server/models/helpers/indexing.ts index 26743245e..55b3dc5d3 100644 --- a/src/core/server/models/helpers/indexing.ts +++ b/src/core/server/models/helpers/indexing.ts @@ -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 = { - [P in keyof Writeable>]: IndexType + [P in keyof Writable>]: IndexType } & Record; diff --git a/src/core/server/models/helpers/query.ts b/src/core/server/models/helpers/query.ts index 7c3afafeb..5b66cb2dc 100644 --- a/src/core/server/models/helpers/query.ts +++ b/src/core/server/models/helpers/query.ts @@ -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 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 = MongoFilterQuery>>; +export type FilterQuery = MongoFilterQuery>>; /** * Query is a convenience class used to wrap the existing MongoDB driver to diff --git a/src/core/server/queue/tasks/mailer/templates/ban.html b/src/core/server/queue/tasks/mailer/templates/ban.html index 9dfdca434..a5af08274 100644 --- a/src/core/server/queue/tasks/mailer/templates/ban.html +++ b/src/core/server/queue/tasks/mailer/templates/ban.html @@ -4,6 +4,6 @@ Hello {{ context.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. 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 {{ context.organizationContactEmail }}. {% endblock %} diff --git a/src/locales/da/admin.ftl b/src/locales/da/admin.ftl index 8c3ef157d..4cb55fa16 100644 --- a/src/locales/da/admin.ftl +++ b/src/locales/da/admin.ftl @@ -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 { $username }? community-suspendModal-consequence = diff --git a/src/locales/da/common.ftl b/src/locales/da/common.ftl index a7e1aa16a..019ce0fba 100644 --- a/src/locales/da/common.ftl +++ b/src/locales/da/common.ftl @@ -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. diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 8509a3058..8f4c619cb 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -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 { $username }? community-suspendModal-consequence = @@ -729,19 +725,19 @@ userDetails-suspension-start = Start: { $timestamp } userDetails-suspension-end = End: { $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 \ No newline at end of file +configure-general-reaction-sortMenu-sortBy = Sort by diff --git a/src/locales/en-US/common.ftl b/src/locales/en-US/common.ftl index a7e1aa16a..6a28eda38 100644 --- a/src/locales/en-US/common.ftl +++ b/src/locales/en-US/common.ftl @@ -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. diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 46ff3f1fa..ea551b972 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -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.