diff --git a/src/core/client/admin/components/UserStatus/PremodModal.css b/src/core/client/admin/components/ModalBodyText.css similarity index 68% rename from src/core/client/admin/components/UserStatus/PremodModal.css rename to src/core/client/admin/components/ModalBodyText.css index 9617ee7a5..62ae84c8d 100644 --- a/src/core/client/admin/components/UserStatus/PremodModal.css +++ b/src/core/client/admin/components/ModalBodyText.css @@ -1,9 +1,9 @@ -$premod-modal-text: var(--v2-colors-mono-500); +$modal-body-text: var(--v2-colors-mono-500); -.bodyText { +.root { font-size: var(--v2-font-size-3); font-family: var(--v2-font-family-primary); font-weight: var(--v2-font-weight-primary-regular); line-height: var(--v2-line-height-body-short); - color: $premod-modal-text; + color: $modal-body-text; } diff --git a/src/core/client/admin/components/ModalBodyText.tsx b/src/core/client/admin/components/ModalBodyText.tsx new file mode 100644 index 000000000..69e069139 --- /dev/null +++ b/src/core/client/admin/components/ModalBodyText.tsx @@ -0,0 +1,9 @@ +import React, { FunctionComponent } from "react"; + +import styles from "./ModalBodyText.css"; + +const ModalBodyText: FunctionComponent = ({ children }) => ( +

{children}

+); + +export default ModalBodyText; diff --git a/src/core/client/admin/components/UserStatus/ChangeStatusModalHeader.css b/src/core/client/admin/components/ModalHeader.css similarity index 73% rename from src/core/client/admin/components/UserStatus/ChangeStatusModalHeader.css rename to src/core/client/admin/components/ModalHeader.css index 8222b9bb7..b5e9408ca 100644 --- a/src/core/client/admin/components/UserStatus/ChangeStatusModalHeader.css +++ b/src/core/client/admin/components/ModalHeader.css @@ -1,10 +1,10 @@ -$status-modal-text: var(--v2-colors-mono-500); +$modal-text: var(--v2-colors-mono-500); .root { font-size: var(--v2-font-size-5); font-family: var(--v2-font-family-primary); font-weight: var(--v2-font-weight-primary-semi-bold); line-height: var(--v2-line-height-title); - color: $status-modal-text; + color: $modal-text; margin: 0; } diff --git a/src/core/client/admin/components/ModalHeader.tsx b/src/core/client/admin/components/ModalHeader.tsx new file mode 100644 index 000000000..b94ca0266 --- /dev/null +++ b/src/core/client/admin/components/ModalHeader.tsx @@ -0,0 +1,16 @@ +import React, { FunctionComponent, HTMLAttributes } from "react"; + +import styles from "./ModalHeader.css"; + +const ModalHeader: FunctionComponent> = ({ + children, + ...rest +}) => { + return ( +

+ {children} +

+ ); +}; + +export default ModalHeader; diff --git a/src/core/client/admin/components/UserStatus/ModalHeaderUsername.css b/src/core/client/admin/components/ModalHeaderUsername.css similarity index 100% rename from src/core/client/admin/components/UserStatus/ModalHeaderUsername.css rename to src/core/client/admin/components/ModalHeaderUsername.css diff --git a/src/core/client/admin/components/UserStatus/ModalHeaderUsername.tsx b/src/core/client/admin/components/ModalHeaderUsername.tsx similarity index 73% rename from src/core/client/admin/components/UserStatus/ModalHeaderUsername.tsx rename to src/core/client/admin/components/ModalHeaderUsername.tsx index 0e31e17df..cc7dd5404 100644 --- a/src/core/client/admin/components/UserStatus/ModalHeaderUsername.tsx +++ b/src/core/client/admin/components/ModalHeaderUsername.tsx @@ -2,7 +2,7 @@ import React, { FunctionComponent } from "react"; import styles from "./ModalHeaderUsername.css"; -const ModalHeaderUsername: FunctionComponent<{}> = ({ children }) => { +const ModalHeaderUsername: FunctionComponent = ({ children }) => { return {children}; }; diff --git a/src/core/client/admin/components/ModerateCard/ApproveButton.css b/src/core/client/admin/components/ModerateCard/ApproveButton.css index e1de1e004..052169cce 100644 --- a/src/core/client/admin/components/ModerateCard/ApproveButton.css +++ b/src/core/client/admin/components/ModerateCard/ApproveButton.css @@ -16,8 +16,19 @@ $moderateCardButtonOutlineApproveColor: var(--v2-colors-green-500); } } +.readOnly { + background-color: transparent; + border-color: var(--v2-colors-grey-300); + color: var(--v2-colors-grey-300); + + &:hover { + cursor: not-allowed; + } +} + .invert { background-color: $moderateCardButtonOutlineApproveColor; + border-color: $moderateCardButtonOutlineApproveColor; color: var(--v2-colors-pure-white); } diff --git a/src/core/client/admin/components/ModerateCard/ApproveButton.tsx b/src/core/client/admin/components/ModerateCard/ApproveButton.tsx index 6d077c52d..cdd1b103e 100644 --- a/src/core/client/admin/components/ModerateCard/ApproveButton.tsx +++ b/src/core/client/admin/components/ModerateCard/ApproveButton.tsx @@ -9,10 +9,12 @@ import styles from "./ApproveButton.css"; interface Props extends Omit, "ref"> { invert?: boolean; + readOnly?: boolean; } const ApproveButton: FunctionComponent = ({ invert, + readOnly, className, ...rest }) => ( @@ -21,6 +23,7 @@ const ApproveButton: FunctionComponent = ({ {...rest} className={cn(className, styles.root, { [styles.invert]: invert, + [styles.readOnly]: readOnly, })} aria-label="Approve" > diff --git a/src/core/client/admin/components/ModerateCard/FeatureButton.css b/src/core/client/admin/components/ModerateCard/FeatureButton.css index f496436d9..34cb3e9c5 100644 --- a/src/core/client/admin/components/ModerateCard/FeatureButton.css +++ b/src/core/client/admin/components/ModerateCard/FeatureButton.css @@ -24,8 +24,7 @@ } &:disabled { - cursor: default; - + cursor: not-allowed; background-color: transparent; border-color: var(--v2-colors-grey-300); color: var(--v2-colors-grey-300); diff --git a/src/core/client/admin/components/ModerateCard/ModerateCard.css b/src/core/client/admin/components/ModerateCard/ModerateCard.css index 22e18525b..02506bb9b 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCard.css +++ b/src/core/client/admin/components/ModerateCard/ModerateCard.css @@ -120,10 +120,6 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700); text-transform: uppercase; } -.storyTitle { - color: $moderateCardStoryTitleColor; -} - .commentOn { font-size: var(--v2-font-size-2); font-family: var(--v2-font-family-primary); @@ -136,6 +132,7 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700); } .storyTitle { + color: $moderateCardStoryTitleColor; font-weight: var(--v2-font-weight-primary-semi-bold); } @@ -150,10 +147,6 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700); height: 40px; } -.deleted { - background: var(--v2-palette-grey-lightest); -} - .timestamp { color: $moderateCardTimestampColor; font-family: var(--v2-font-family-primary); @@ -186,6 +179,7 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700); } .deleted { + background: var(--v2-palette-grey-lightest); font-family: var(--v2-font-family-primary); font-weight: var(--v2-font-weight-primary-regular); font-size: var(--v2-font-size-2); diff --git a/src/core/client/admin/components/ModerateCard/ModerateCard.tsx b/src/core/client/admin/components/ModerateCard/ModerateCard.tsx index 117e7bf24..7a54986a0 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCard.tsx +++ b/src/core/client/admin/components/ModerateCard/ModerateCard.tsx @@ -6,6 +6,7 @@ import React, { FunctionComponent, useCallback, useEffect, + useMemo, useRef, } from "react"; @@ -71,6 +72,12 @@ interface Props { */ dangling?: boolean; deleted?: boolean; + + /** + * If set to true, it means that this comment cannot be moderated by the + * current user. + */ + readOnly?: boolean; edited: boolean; selectPrev?: () => void; selectNext?: () => void; @@ -108,6 +115,7 @@ const ModerateCard: FunctionComponent = ({ mini = false, hideUsername = false, deleted = false, + readOnly = false, edited, selectNext, selectPrev, @@ -115,6 +123,7 @@ const ModerateCard: FunctionComponent = ({ isQA, }) => { const div = useRef(null); + useEffect(() => { if (selected) { if (selectNext) { @@ -143,31 +152,39 @@ const ModerateCard: FunctionComponent = ({ } return noop; - }, [selected, id]); + }, [selected, id, selectNext, selectPrev, onBan, onApprove, onReject]); useEffect(() => { if (selected && div && div.current) { div.current.focus(); } - }, [selected]); - const commentBody = deleted ? ( - -
- This comment is no longer available. The commenter has deleted their - account. -
-
- ) : ( - body + }, [selected, div]); + + const commentBody = useMemo( + () => + deleted ? ( + +
+ This comment is no longer available. The commenter has deleted their + account. +
+
+ ) : ( + body + ), + [deleted, body] ); + const commentAuthorClick = useCallback(() => { onUsernameClick(); }, [onUsernameClick]); + const commentParentAuthorClick = useCallback(() => { if (inReplyTo) { onUsernameClick(inReplyTo.id); } }, [onUsernameClick, inReplyTo]); + return ( = ({ {inReplyTo && inReplyTo.username && ( @@ -304,14 +321,24 @@ const ModerateCard: FunctionComponent = ({ {moderatedBy} diff --git a/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx b/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx index 1d306e177..9c7fc7f02 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx +++ b/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx @@ -1,5 +1,10 @@ import { Match, Router, withRouter } from "found"; -import React, { FunctionComponent, useCallback, useState } from "react"; +import React, { + FunctionComponent, + useCallback, + useMemo, + useState, +} from "react"; import { graphql } from "react-relay"; import NotAvailable from "coral-admin/components/NotAvailable"; @@ -11,18 +16,20 @@ import { import FadeInTransition from "coral-framework/components/FadeInTransition"; import { getModerationLink } from "coral-framework/helpers"; import parseModerationOptions from "coral-framework/helpers/parseModerationOptions"; +import { useMutation, withFragmentContainer } from "coral-framework/lib/relay"; import { - MutationProp, - withFragmentContainer, - withMutation, -} from "coral-framework/lib/relay"; -import { GQLSTORY_MODE, GQLTAG, GQLUSER_STATUS } from "coral-framework/schema"; + GQLFEATURE_FLAG, + GQLSTORY_MODE, + GQLTAG, + GQLUSER_STATUS, +} from "coral-framework/schema"; import { COMMENT_STATUS, ModerateCardContainer_comment, } from "coral-admin/__generated__/ModerateCardContainer_comment.graphql"; import { ModerateCardContainer_settings } from "coral-admin/__generated__/ModerateCardContainer_settings.graphql"; +import { ModerateCardContainer_viewer } from "coral-admin/__generated__/ModerateCardContainer_viewer.graphql"; import BanCommentUserMutation from "./BanCommentUserMutation"; import FeatureCommentMutation from "./FeatureCommentMutation"; @@ -31,13 +38,9 @@ import ModeratedByContainer from "./ModeratedByContainer"; import UnfeatureCommentMutation from "./UnfeatureCommentMutation"; interface Props { + viewer: ModerateCardContainer_viewer; comment: ModerateCardContainer_comment; settings: ModerateCardContainer_settings; - approveComment: MutationProp; - rejectComment: MutationProp; - featureComment: MutationProp; - unfeatureComment: MutationProp; - banUser: MutationProp; danglingLogic: (status: COMMENT_STATUS) => boolean; match: Match; router: Router; @@ -71,14 +74,11 @@ function isFeatured(comment: ModerateCardContainer_comment) { const ModerateCardContainer: FunctionComponent = ({ comment, settings, + viewer, danglingLogic, showStoryInfo, match, router, - approveComment, - rejectComment, - featureComment, - unfeatureComment, mini, hideUsername, selected, @@ -87,15 +87,36 @@ const ModerateCardContainer: FunctionComponent = ({ onUsernameClicked: usernameClicked, onConversationClicked: conversationClicked, onSetSelected: setSelected, - banUser, loadNext, }) => { + const approveComment = useMutation(ApproveCommentMutation); + const rejectComment = useMutation(RejectCommentMutation); + const featureComment = useMutation(FeatureCommentMutation); + const unfeatureComment = useMutation(UnfeatureCommentMutation); + const banUser = useMutation(BanCommentUserMutation); + + const scoped = useMemo( + () => + settings.featureFlags.includes(GQLFEATURE_FLAG.SITE_MODERATOR) && + !!viewer.moderationScopes?.scoped, + [settings, viewer] + ); + + const readOnly = useMemo(() => scoped && !comment.canModerate, [ + scoped, + comment, + ]); + const [showBanModal, setShowBanModal] = useState(false); const handleApprove = useCallback(async () => { if (!comment.revision) { return; } + if (readOnly) { + return; + } + const { storyID, siteID, section } = parseModerationOptions(match); await approveComment({ @@ -108,13 +129,17 @@ const ModerateCardContainer: FunctionComponent = ({ if (loadNext) { loadNext(); } - }, [approveComment, comment, match]); + }, [approveComment, comment, match, readOnly]); const handleReject = useCallback(async () => { if (!comment.revision) { return; } + if (readOnly) { + return; + } + const { storyID, siteID, section } = parseModerationOptions(match); await rejectComment({ @@ -127,13 +152,17 @@ const ModerateCardContainer: FunctionComponent = ({ if (loadNext) { loadNext(); } - }, [rejectComment, comment, match]); + }, [rejectComment, comment, match, readOnly]); const handleFeature = useCallback(() => { if (!comment.revision) { return; } + if (readOnly) { + return; + } + const { storyID, siteID, section } = parseModerationOptions(match); void featureComment({ @@ -143,24 +172,31 @@ const ModerateCardContainer: FunctionComponent = ({ siteID, section, }); - }, [featureComment, comment, match]); + }, [featureComment, comment, match, readOnly]); const handleUnfeature = useCallback(() => { + if (readOnly) { + return; + } + void unfeatureComment({ commentID: comment.id, storyID: match.params.storyID, }); - }, [unfeatureComment, comment, match]); + }, [unfeatureComment, comment, match, readOnly]); const onFeature = useCallback(() => { - const featured = isFeatured(comment); + if (readOnly) { + return; + } + const featured = isFeatured(comment); if (featured) { handleUnfeature(); } else { handleFeature(); } - }, [comment]); + }, [comment, readOnly, handleFeature, handleUnfeature]); const onUsernameClicked = useCallback( (id?: string) => { @@ -197,7 +233,7 @@ const ModerateCardContainer: FunctionComponent = ({ const handleBanModalClose = useCallback(() => { setShowBanModal(false); - }, []); + }, [setShowBanModal]); const openBanModal = useCallback(() => { if ( @@ -206,8 +242,9 @@ const ModerateCardContainer: FunctionComponent = ({ ) { return; } + setShowBanModal(true); - }, [comment]); + }, [comment, setShowBanModal]); const handleBanConfirm = useCallback( async (rejectExistingComments: boolean, message: string) => { @@ -220,17 +257,22 @@ const ModerateCardContainer: FunctionComponent = ({ } setShowBanModal(false); }, - [comment] + [comment, banUser, setShowBanModal] ); // Only highlight comments that have been flagged for containing a banned or // suspect word. - const highlight = comment.revision - ? comment.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD + - comment.revision.actionCounts.flag.reasons - .COMMENT_DETECTED_SUSPECT_WORD > - 0 - : false; + const highlight = useMemo( + () => + comment.revision + ? comment.revision.actionCounts.flag.reasons + .COMMENT_DETECTED_BANNED_WORD + + comment.revision.actionCounts.flag.reasons + .COMMENT_DETECTED_SUSPECT_WORD > + 0 + : false, + [comment] + ); return ( <> @@ -284,6 +326,7 @@ const ModerateCardContainer: FunctionComponent = ({ hideUsername={hideUsername} deleted={comment.deleted ? comment.deleted : false} edited={comment.editing.edited} + readOnly={readOnly} isQA={comment.story.settings.mode === GQLSTORY_MODE.QA} /> @@ -341,6 +384,7 @@ const enhanced = withFragmentContainer({ username } } + canModerate story { id metadata { @@ -374,18 +418,13 @@ const enhanced = withFragmentContainer({ ...MarkersContainer_settings } `, -})( - withRouter( - withMutation(BanCommentUserMutation)( - withMutation(ApproveCommentMutation)( - withMutation(RejectCommentMutation)( - withMutation(FeatureCommentMutation)( - withMutation(UnfeatureCommentMutation)(ModerateCardContainer) - ) - ) - ) - ) - ) -); + viewer: graphql` + fragment ModerateCardContainer_viewer on User { + moderationScopes { + scoped + } + } + `, +})(withRouter(ModerateCardContainer)); export default enhanced; diff --git a/src/core/client/admin/components/ModerateCard/RejectButton.css b/src/core/client/admin/components/ModerateCard/RejectButton.css index a4b826914..aa247e4a7 100644 --- a/src/core/client/admin/components/ModerateCard/RejectButton.css +++ b/src/core/client/admin/components/ModerateCard/RejectButton.css @@ -16,8 +16,19 @@ $moderateCardButtonOutlineRejectColor: var(--v2-colors-red-500); } } +.readOnly { + background-color: transparent; + border-color: var(--v2-colors-grey-300); + color: var(--v2-colors-grey-300); + + &:hover { + cursor: not-allowed; + } +} + .invert { background-color: $moderateCardButtonOutlineRejectColor; + border-color: $moderateCardButtonOutlineRejectColor; color: var(--v2-colors-pure-white); } diff --git a/src/core/client/admin/components/ModerateCard/RejectButton.tsx b/src/core/client/admin/components/ModerateCard/RejectButton.tsx index 1b4f74f55..0007af6a8 100644 --- a/src/core/client/admin/components/ModerateCard/RejectButton.tsx +++ b/src/core/client/admin/components/ModerateCard/RejectButton.tsx @@ -9,10 +9,12 @@ import styles from "./RejectButton.css"; interface Props extends Omit, "ref"> { invert?: boolean; + readOnly?: boolean; } const RejectButton: FunctionComponent = ({ invert, + readOnly, className, ...rest }) => ( @@ -21,6 +23,7 @@ const RejectButton: FunctionComponent = ({ {...rest} className={cn(className, styles.root, { [styles.invert]: invert, + [styles.readOnly]: readOnly, })} aria-label="Reject" > diff --git a/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap b/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap index 3a4107f45..c10c20628 100644 --- a/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap +++ b/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap @@ -122,12 +122,14 @@ exports[`renders approved correctly 1`] = ` disabled={false} invert={false} onClick={[Function]} + readOnly={false} /> @@ -257,12 +259,14 @@ exports[`renders correctly 1`] = ` disabled={false} invert={false} onClick={[Function]} + readOnly={false} /> @@ -392,12 +396,14 @@ exports[`renders dangling correctly 1`] = ` disabled={true} invert={false} onClick={[Function]} + readOnly={false} /> @@ -527,12 +533,14 @@ exports[`renders rejected correctly 1`] = ` disabled={true} invert={true} onClick={[Function]} + readOnly={false} /> @@ -671,12 +679,14 @@ exports[`renders reply correctly 1`] = ` disabled={false} invert={false} onClick={[Function]} + readOnly={false} /> @@ -844,12 +854,14 @@ exports[`renders story info 1`] = ` disabled={false} invert={false} onClick={[Function]} + readOnly={false} /> @@ -987,12 +999,14 @@ exports[`renders tombstoned when comment is deleted 1`] = ` disabled={true} invert={false} onClick={[Function]} + readOnly={false} /> diff --git a/src/core/client/admin/components/TranslatedRole.tsx b/src/core/client/admin/components/TranslatedRole.tsx index 83ffca619..b7c1b491e 100644 --- a/src/core/client/admin/components/TranslatedRole.tsx +++ b/src/core/client/admin/components/TranslatedRole.tsx @@ -5,22 +5,24 @@ import { GQLUSER_ROLE, GQLUSER_ROLE_RL } from "coral-framework/schema"; interface Props { container?: React.ReactElement | React.ComponentType | string; - children: GQLUSER_ROLE_RL; + role: GQLUSER_ROLE_RL; + scoped?: boolean; + moderationScopesEnabled: boolean; } function createElement( Container: React.ReactElement | React.ComponentType | string, - children: React.ReactNode + text: string ) { if (React.isValidElement(Container)) { - return React.cloneElement(Container, { children }); + return React.cloneElement(Container, { children: text }); } else { - return {children}; + return {text}; } } const TranslatedRole: React.FunctionComponent = (props) => { - switch (props.children) { + switch (props.role) { case GQLUSER_ROLE.COMMENTER: return ( @@ -34,9 +36,25 @@ const TranslatedRole: React.FunctionComponent = (props) => { ); case GQLUSER_ROLE.MODERATOR: + if (!props.moderationScopesEnabled) { + return ( + + {createElement(props.container!, "Moderator")} + + ); + } + + if (props.scoped) { + return ( + + {createElement(props.container!, "Site Moderator")} + + ); + } + return ( - - {createElement(props.container!, "Moderator")} + + {createElement(props.container!, "Organization Moderator")} ); case GQLUSER_ROLE.STAFF: @@ -47,7 +65,7 @@ const TranslatedRole: React.FunctionComponent = (props) => { ); default: // Unknown role, just use untranslated string. - return createElement(props.container!, props.children); + return createElement(props.container!, props.role); } }; diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx index ae91bcd33..7dcaf18d7 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx @@ -2,7 +2,7 @@ import { Localized } from "@fluent/react/compat"; import React, { FunctionComponent, useMemo } from "react"; import { graphql } from "react-relay"; -import { useCoralContext } from "coral-framework/lib/bootstrap"; +import { useDateTimeFormatter } from "coral-framework/hooks"; import { withFragmentContainer } from "coral-framework/lib/relay"; import { CallOut, @@ -48,7 +48,6 @@ const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { ); - const { locales } = useCoralContext(); const combinedHistory = useMemo(() => { // Collect all the different types of history items. const history: HistoryRecord[] = []; @@ -137,7 +136,7 @@ const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { // Sort the history so that it's in the right order. return history.sort((a, b) => b.date.getTime() - a.date.getTime()); }, [user]); - const formatter = new Intl.DateTimeFormat(locales, { + const formatter = useDateTimeFormatter({ year: "numeric", month: "long", day: "numeric", @@ -167,7 +166,7 @@ const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { {combinedHistory.map((history, index) => ( - {formatter.format(history.date)} + {formatter(history.date)} diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerAllComments.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerAllComments.tsx index 0e9985bfb..266ec21e2 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerAllComments.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerAllComments.tsx @@ -11,6 +11,7 @@ import { Button, CallOut, Divider } from "coral-ui/components/v2"; import { UserHistoryDrawerAllComments_settings } from "coral-admin/__generated__/UserHistoryDrawerAllComments_settings.graphql"; import { UserHistoryDrawerAllComments_user } from "coral-admin/__generated__/UserHistoryDrawerAllComments_user.graphql"; +import { UserHistoryDrawerAllComments_viewer } from "coral-admin/__generated__/UserHistoryDrawerAllComments_viewer.graphql"; import { UserHistoryDrawerAllCommentsPaginationQueryVariables } from "coral-admin/__generated__/UserHistoryDrawerAllCommentsPaginationQuery.graphql"; import styles from "./UserHistoryDrawerAllComments.css"; @@ -18,12 +19,14 @@ import styles from "./UserHistoryDrawerAllComments.css"; interface Props { user: UserHistoryDrawerAllComments_user; settings: UserHistoryDrawerAllComments_settings; + viewer: UserHistoryDrawerAllComments_viewer; relay: RelayPaginationProp; } const UserHistoryDrawerAllComments: FunctionComponent = ({ user, settings, + viewer, relay, }) => { const [loadMore, isLoadingMore] = useLoadMore(relay, 5); @@ -59,6 +62,7 @@ const UserHistoryDrawerAllComments: FunctionComponent = ({ false} hideUsername showStoryInfo @@ -92,6 +96,11 @@ const enhanced = withPaginationContainer< ...ModerateCardContainer_settings } `, + viewer: graphql` + fragment UserHistoryDrawerAllComments_viewer on User { + ...ModerateCardContainer_viewer + } + `, user: graphql` fragment UserHistoryDrawerAllComments_user on User @argumentDefinitions( diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerAllCommentsQuery.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerAllCommentsQuery.tsx index a7e52f2dd..782a8517f 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerAllCommentsQuery.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerAllCommentsQuery.tsx @@ -28,12 +28,15 @@ const UserHistoryDrawerAllCommentsQuery: FunctionComponent = ({ settings { ...UserHistoryDrawerAllComments_settings } + viewer { + ...UserHistoryDrawerAllComments_viewer + } } `} variables={{ userID }} cacheConfig={{ force: true }} render={({ error, props }) => { - if (!props) { + if (!props || !props.viewer) { return (
@@ -56,6 +59,7 @@ const UserHistoryDrawerAllCommentsQuery: FunctionComponent = ({ return ( ); diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx index 216d79b7a..06d0fec6a 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx @@ -4,7 +4,7 @@ import { graphql } from "react-relay"; import { UserStatusChangeContainer } from "coral-admin/components/UserStatus"; import { CopyButton } from "coral-framework/components"; -import { useCoralContext } from "coral-framework/lib/bootstrap"; +import { useDateTimeFormatter } from "coral-framework/hooks"; import { withFragmentContainer } from "coral-framework/lib/relay"; import { Button, @@ -16,6 +16,7 @@ import { import { UserHistoryDrawerContainer_settings } from "coral-admin/__generated__/UserHistoryDrawerContainer_settings.graphql"; import { UserHistoryDrawerContainer_user } from "coral-admin/__generated__/UserHistoryDrawerContainer_user.graphql"; +import { UserHistoryDrawerContainer_viewer } from "coral-admin/__generated__/UserHistoryDrawerContainer_viewer.graphql"; import RecentHistoryContainer from "./RecentHistoryContainer"; import Tabs from "./Tabs"; @@ -27,16 +28,17 @@ import styles from "./UserHistoryDrawerContainer.css"; interface Props { user: UserHistoryDrawerContainer_user; settings: UserHistoryDrawerContainer_settings; + viewer: UserHistoryDrawerContainer_viewer; onClose: () => void; } const UserHistoryDrawerContainer: FunctionComponent = ({ settings, user, + viewer, onClose, }) => { - const { locales } = useCoralContext(); - const formatter = new Intl.DateTimeFormat(locales, { + const formatter = useDateTimeFormatter({ month: "long", day: "numeric", year: "numeric", @@ -69,6 +71,7 @@ const UserHistoryDrawerContainer: FunctionComponent = ({ bordered={true} settings={settings} user={user} + viewer={viewer} />
@@ -101,7 +104,7 @@ const UserHistoryDrawerContainer: FunctionComponent = ({ - {formatter.format(new Date(user.createdAt))} + {formatter(user.createdAt)} @@ -153,6 +156,11 @@ const enhanced = withFragmentContainer({ } } `, + viewer: graphql` + fragment UserHistoryDrawerContainer_viewer on User { + ...UserStatusChangeContainer_viewer + } + `, })(UserHistoryDrawerContainer); export default enhanced; diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.tsx index 4aafa5c79..a6999d6c6 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.tsx @@ -32,12 +32,15 @@ const UserHistoryDrawerQuery: FunctionComponent = ({ settings { ...UserHistoryDrawerContainer_settings } + viewer { + ...UserHistoryDrawerContainer_viewer + } } `} variables={{ userID }} cacheConfig={{ force: true }} render={({ props }: QueryRenderData) => { - if (!props) { + if (!props || !props.viewer) { return (
@@ -62,6 +65,7 @@ const UserHistoryDrawerQuery: FunctionComponent = ({ onClose={onClose} user={props.user} settings={props.settings} + viewer={props.viewer} /> ); }} diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerRejectedComments.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerRejectedComments.tsx index c47c2eda9..8421af8b7 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerRejectedComments.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerRejectedComments.tsx @@ -11,6 +11,7 @@ import { Button, CallOut, Divider } from "coral-ui/components/v2"; import { UserHistoryDrawerRejectedComments_settings } from "coral-admin/__generated__/UserHistoryDrawerRejectedComments_settings.graphql"; import { UserHistoryDrawerRejectedComments_user } from "coral-admin/__generated__/UserHistoryDrawerRejectedComments_user.graphql"; +import { UserHistoryDrawerRejectedComments_viewer } from "coral-admin/__generated__/UserHistoryDrawerRejectedComments_viewer.graphql"; import { UserHistoryDrawerRejectedCommentsPaginationQueryVariables } from "coral-admin/__generated__/UserHistoryDrawerRejectedCommentsPaginationQuery.graphql"; import styles from "./UserHistoryDrawerRejectedComments.css"; @@ -18,6 +19,7 @@ import styles from "./UserHistoryDrawerRejectedComments.css"; const danglingLogic = () => false; interface Props { + viewer: UserHistoryDrawerRejectedComments_viewer; user: UserHistoryDrawerRejectedComments_user; settings: UserHistoryDrawerRejectedComments_settings; relay: RelayPaginationProp; @@ -26,6 +28,7 @@ interface Props { const UserHistoryDrawerRejectedComments: FunctionComponent = ({ user, settings, + viewer, relay, }) => { const [loadMore, isLoadingMore] = useLoadMore(relay, 5); @@ -63,6 +66,7 @@ const UserHistoryDrawerRejectedComments: FunctionComponent = ({ = ({ settings { ...UserHistoryDrawerRejectedComments_settings } + viewer { + ...UserHistoryDrawerRejectedComments_viewer + } } `} variables={{ userID }} cacheConfig={{ force: true }} render={({ error, props }) => { - if (!props) { + if (!props || !props.viewer) { return (
@@ -54,6 +57,7 @@ const UserHistoryDrawerRejectedCommentsQuery: FunctionComponent = ({ return ( ); diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserStatusDetailsContainer.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserStatusDetailsContainer.tsx index fd195ed6c..63ca92cb3 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserStatusDetailsContainer.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserStatusDetailsContainer.tsx @@ -2,7 +2,7 @@ import { Localized } from "@fluent/react/compat"; import React, { FunctionComponent, useMemo } from "react"; import { graphql } from "react-relay"; -import { useCoralContext } from "coral-framework/lib/bootstrap"; +import { useDateTimeFormatter } from "coral-framework/hooks"; import { withFragmentContainer } from "coral-framework/lib/relay"; import { BaseButton, @@ -33,9 +33,7 @@ const UserStatusDetailsContainer: FunctionComponent = ({ user }) => { return user.status.suspension.history.find((item) => item.active); }, [user]); - const { locales } = useCoralContext(); - - const formatter = new Intl.DateTimeFormat(locales, { + const formatter = useDateTimeFormatter({ day: "2-digit", month: "2-digit", year: "numeric", @@ -55,12 +53,12 @@ const UserStatusDetailsContainer: FunctionComponent = ({ user }) => {
} >

Banned on {" "} - {formatter.format(new Date(activeBan.createdAt))} + {formatter(activeBan.createdAt)}

{activeBan.createdBy && ( @@ -94,25 +92,21 @@ const UserStatusDetailsContainer: FunctionComponent = ({ user }) => { } - $timestamp={formatter.format( - new Date(activeSuspension.from.start) - )} + $timestamp={formatter(activeSuspension.from.start)} >

Start: - {formatter.format(new Date(activeSuspension.from.start))} + {formatter(activeSuspension.from.start)}

} - $timestamp={formatter.format( - new Date(activeSuspension.from.finish) - )} + $timestamp={formatter(activeSuspension.from.finish)} id="userDetails-suspension-finish" >

End: - {formatter.format(new Date(activeSuspension.from.finish))} + {formatter(activeSuspension.from.finish)}

diff --git a/src/core/client/admin/components/UserRole/SiteModeratorModal.css b/src/core/client/admin/components/UserRole/SiteModeratorModal.css new file mode 100644 index 000000000..91838626b --- /dev/null +++ b/src/core/client/admin/components/UserRole/SiteModeratorModal.css @@ -0,0 +1,5 @@ +.root { + max-width: 500px; + padding: var(--v2-spacing-2) var(--v2-spacing-3) var(--v2-spacing-3) + var(--v2-spacing-3); +} diff --git a/src/core/client/admin/components/UserRole/SiteModeratorModal.tsx b/src/core/client/admin/components/UserRole/SiteModeratorModal.tsx new file mode 100644 index 000000000..761bb770e --- /dev/null +++ b/src/core/client/admin/components/UserRole/SiteModeratorModal.tsx @@ -0,0 +1,120 @@ +import { Localized } from "@fluent/react/compat"; +import { FORM_ERROR } from "final-form"; +import React, { FunctionComponent, useCallback } from "react"; +import { Form } from "react-final-form"; + +import { InvalidRequestError } from "coral-framework/lib/errors"; +import { + Button, + CallOut, + Card, + CardCloseButton, + Flex, + HorizontalGutter, + Modal, +} from "coral-ui/components/v2"; +import { PropTypesOf } from "coral-ui/types"; + +import ModalBodyText from "../ModalBodyText"; +import ModalHeader from "../ModalHeader"; +import ModalHeaderUsername from "../ModalHeaderUsername"; +import NotAvailable from "../NotAvailable"; +import SiteModeratorModalSiteFieldContainer from "./SiteModeratorModalSiteFieldContainer"; + +import styles from "./SiteModeratorModal.css"; + +interface Props { + username: string | null; + open: boolean; + onCancel: () => void; + onFinish: (siteIDs: string[]) => Promise; + selectedSiteIDs?: string[]; + query: PropTypesOf["query"]; +} + +const SiteModeratorModal: FunctionComponent = ({ + username, + open, + onFinish, + onCancel, + selectedSiteIDs = [], + query, +}) => { + const onSubmit = useCallback( + async (values: { siteIDs: string[] }) => { + try { + await onFinish(values.siteIDs); + return; + } catch (err) { + if (err instanceof InvalidRequestError) { + return err.invalidArgs; + } + return { [FORM_ERROR]: err.message }; + } + }, + [onFinish] + ); + + return ( + + {({ firstFocusableRef, lastFocusableRef }) => ( + + + + +
+ {({ handleSubmit, submitError, submitting }) => ( + + + } + $username={username || } + > + + Assign sites for{" "} + {username} + + + {submitError && ( + + {submitError} + + )} + + + Site moderators are permitted to make moderation decisions + and issue suspensions on the sites they are assigned. + + + + + + + + + + + + +
+ )} + +
+ )} +
+ ); +}; + +export default SiteModeratorModal; diff --git a/src/core/client/admin/components/UserRole/SiteModeratorModalSiteField.css b/src/core/client/admin/components/UserRole/SiteModeratorModalSiteField.css new file mode 100644 index 000000000..b793b2d9e --- /dev/null +++ b/src/core/client/admin/components/UserRole/SiteModeratorModalSiteField.css @@ -0,0 +1,3 @@ +.listGroup { + max-height: 250px; +} diff --git a/src/core/client/admin/components/UserRole/SiteModeratorModalSiteField.tsx b/src/core/client/admin/components/UserRole/SiteModeratorModalSiteField.tsx new file mode 100644 index 000000000..44b286267 --- /dev/null +++ b/src/core/client/admin/components/UserRole/SiteModeratorModalSiteField.tsx @@ -0,0 +1,93 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback } from "react"; +import { useField } from "react-final-form"; + +import AutoLoadMore from "coral-admin/components/AutoLoadMore"; +import { + CheckBox, + FieldSet, + Flex, + FormField, + Label, + ListGroup, + ListGroupRow, + Spinner, +} from "coral-ui/components/v2"; + +import styles from "./SiteModeratorModalSiteField.css"; + +interface Props { + sites: Array<{ id: string; name: string }>; + onLoadMore: () => void; + hasMore: boolean; + disableLoadMore: boolean; + loading: boolean; +} + +const SiteModeratorModalSiteField: FunctionComponent = ({ + sites, + onLoadMore, + hasMore, + disableLoadMore, + loading, +}) => { + const { input } = useField("siteIDs"); + const onChange = useCallback( + (siteID: string, selectedIndex: number) => () => { + const changed = [...input.value]; + if (selectedIndex >= 0) { + changed.splice(selectedIndex, 1); + } else { + changed.push(siteID); + } + + input.onChange(changed); + }, + [input] + ); + + return ( +
+ + + + + + {sites.map((site) => { + const selectedIndex = input.value.indexOf(site.id); + return ( + + = 0} + onChange={onChange(site.id, selectedIndex)} + > + {site.name} + + + ); + })} + {!loading && sites.length === 0 && ( + + No sites + + )} + {loading && ( + + + + )} + {hasMore && ( + + + + )} + + +
+ ); +}; + +export default SiteModeratorModalSiteField; diff --git a/src/core/client/admin/components/UserRole/SiteModeratorModalSiteFieldContainer.tsx b/src/core/client/admin/components/UserRole/SiteModeratorModalSiteFieldContainer.tsx new file mode 100644 index 000000000..3e4e06964 --- /dev/null +++ b/src/core/client/admin/components/UserRole/SiteModeratorModalSiteFieldContainer.tsx @@ -0,0 +1,105 @@ +import React, { FunctionComponent, useMemo } from "react"; +import { graphql, RelayPaginationProp } from "react-relay"; + +import { IntersectionProvider } from "coral-framework/lib/intersection"; +import { + useLoadMore, + useRefetch, + withPaginationContainer, +} from "coral-framework/lib/relay"; + +import { SiteModeratorModalSiteFieldContainer_query } from "coral-admin/__generated__/SiteModeratorModalSiteFieldContainer_query.graphql"; +import { SiteModeratorModalSiteFieldContainerPaginationQueryVariables } from "coral-admin/__generated__/SiteModeratorModalSiteFieldContainerPaginationQuery.graphql"; + +import SiteModeratorModalSiteField from "./SiteModeratorModalSiteField"; + +interface Props { + query: SiteModeratorModalSiteFieldContainer_query; + relay: RelayPaginationProp; +} + +const SiteModeratorModalSiteFieldContainer: FunctionComponent = ({ + query, + relay, +}) => { + const sites = useMemo( + () => query?.sites.edges.map((edge) => edge.node) || [], + [query?.sites.edges] + ); + const [loadMore, isLoadingMore] = useLoadMore(relay, 1); + const [, isRefetching] = useRefetch< + SiteModeratorModalSiteFieldContainerPaginationQueryVariables + >(relay); + + return ( + + + + ); +}; + +type FragmentVariables = SiteModeratorModalSiteFieldContainerPaginationQueryVariables; + +const enhanced = withPaginationContainer< + Props, + SiteModeratorModalSiteFieldContainerPaginationQueryVariables, + FragmentVariables +>( + { + query: graphql` + fragment SiteModeratorModalSiteFieldContainer_query on Query + @argumentDefinitions( + count: { type: "Int!", defaultValue: 20 } + cursor: { type: "Cursor" } + ) { + sites(first: $count, after: $cursor) + @connection(key: "SiteModeratorModalSiteField_sites") { + edges { + node { + id + name + } + } + } + } + `, + }, + { + direction: "forward", + getConnectionFromProps(props) { + return props.query && props.query.sites; + }, + // This is also the default implementation of `getFragmentVariables` if it isn't provided. + getFragmentVariables(prevVars, totalCount) { + return { + ...prevVars, + count: totalCount, + }; + }, + getVariables(props, { count, cursor }, fragmentVariables) { + return { + count, + cursor, + }; + }, + query: graphql` + # Pagination query to be fetched upon calling 'loadMore'. + # Notice that we re-use our fragment, and the shape of this query matches our fragment spec. + query SiteModeratorModalSiteFieldContainerPaginationQuery( + $count: Int! + $cursor: Cursor + ) { + ...SiteModeratorModalSiteFieldContainer_query + @arguments(count: $count, cursor: $cursor) + } + `, + } +)(SiteModeratorModalSiteFieldContainer); + +export default enhanced; diff --git a/src/core/client/admin/components/UserRole/UpdateUserModerationScopesMutation.ts b/src/core/client/admin/components/UserRole/UpdateUserModerationScopesMutation.ts new file mode 100644 index 000000000..1cb046243 --- /dev/null +++ b/src/core/client/admin/components/UserRole/UpdateUserModerationScopesMutation.ts @@ -0,0 +1,47 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { UpdateUserModerationScopesMutation as MutationTypes } from "coral-admin/__generated__/UpdateUserModerationScopesMutation.graphql"; + +let clientMutationId = 0; + +const UpdateUserModerationScopesMutation = createMutation( + "updateUserModerationScopes", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation UpdateUserModerationScopesMutation( + $input: UpdateUserModerationScopesInput! + ) { + updateUserModerationScopes(input: $input) { + user { + id + role + moderationScopes { + scoped + sites { + id + name + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default UpdateUserModerationScopesMutation; diff --git a/src/core/client/admin/components/UserRole/UserRoleChange.tsx b/src/core/client/admin/components/UserRole/UserRoleChange.tsx index 676e61f63..c8b871c3e 100644 --- a/src/core/client/admin/components/UserRole/UserRoleChange.tsx +++ b/src/core/client/admin/components/UserRole/UserRoleChange.tsx @@ -1,82 +1,192 @@ import { Localized } from "@fluent/react/compat"; -import React, { FunctionComponent } from "react"; +import React, { FunctionComponent, useCallback, useMemo } from "react"; -import TranslatedRole from "coral-admin/components/TranslatedRole"; +import { useToggleState } from "coral-framework/hooks"; import { GQLUSER_ROLE, GQLUSER_ROLE_RL } from "coral-framework/schema"; import { Button, ButtonIcon, ClickOutside, Dropdown, - DropdownButton, Popover, } from "coral-ui/components/v2"; +import { PropTypesOf } from "coral-ui/types"; +import { UserRoleChangeContainer_user } from "coral-admin/__generated__/UserRoleChangeContainer_user.graphql"; + +import SiteModeratorModal from "./SiteModeratorModal"; +import UserRoleChangeButton from "./UserRoleChangeButton"; import UserRoleText from "./UserRoleText"; import styles from "./UserRoleChange.css"; interface Props { - onChangeRole: (role: GQLUSER_ROLE_RL) => void; + username: string | null; + onChangeRole: (role: GQLUSER_ROLE_RL) => Promise; + onChangeModerationScopes: (siteIDs: string[]) => Promise; role: GQLUSER_ROLE_RL; + scoped?: boolean; + moderationScopes: UserRoleChangeContainer_user["moderationScopes"]; + moderationScopesEnabled?: boolean; + query: PropTypesOf["query"]; } -const UserRoleChange: FunctionComponent = (props) => ( - - ( - - - {Object.keys(GQLUSER_ROLE).map((r: GQLUSER_ROLE_RL) => ( - = ({ + username, + role, + scoped, + onChangeRole, + onChangeModerationScopes, + moderationScopes, + moderationScopesEnabled = false, + query, +}) => { + // Setup state and callbacks for the popover. + const [ + isPopoverVisible, + setPopoverVisibility, + togglePopoverVisibility, + ] = useToggleState(); + + /** + * handleChangeRole combines the change role function with the change + * moderation scopes. + */ + const handleChangeRole = useCallback( + async (r: GQLUSER_ROLE_RL, siteIDs: string[] = []) => { + await onChangeRole(r); + + if (moderationScopesEnabled) { + await onChangeModerationScopes(siteIDs); + } + }, + [onChangeRole, onChangeModerationScopes, moderationScopesEnabled] + ); + const onClick = useCallback( + (r: GQLUSER_ROLE_RL, siteIDs: string[] = []) => async () => { + await handleChangeRole(r, siteIDs); + togglePopoverVisibility(); + }, + [handleChangeRole, togglePopoverVisibility] + ); + + // Setup state and callbacks for the site moderator modal. + const [ + isModalVisible, + setModalVisibility, + toggleModalVisibility, + ] = useToggleState(); + const onFinishModal = useCallback( + async (siteIDs: string[]) => { + // Set the user as a moderator and then update the siteIDs. + await handleChangeRole(GQLUSER_ROLE.MODERATOR, siteIDs); + + // Close the modal. + setModalVisibility(false); + }, + [setModalVisibility, handleChangeRole] + ); + + const selectedSiteIDs = useMemo( + () => moderationScopes?.sites?.map((site) => site.id), + [moderationScopes] + ); + + return ( + <> + {moderationScopesEnabled && ( + + )} + + + + + + {moderationScopesEnabled && ( + { - props.onChangeRole(r); - toggleVisibility(); + setModalVisibility(true); + setPopoverVisibility(false); }} - > - dummy - - } - > - {r} - - ))} - - - )} - > - {({ toggleVisibility, ref, visible }) => ( - + )} + + + + + } > - - - )} - - -); + {({ ref }) => ( + + + + )} + + + + ); +}; export default UserRoleChange; diff --git a/src/core/client/admin/components/UserRole/UserRoleChangeButton.css b/src/core/client/admin/components/UserRole/UserRoleChangeButton.css new file mode 100644 index 000000000..e45df6ea3 --- /dev/null +++ b/src/core/client/admin/components/UserRole/UserRoleChangeButton.css @@ -0,0 +1,3 @@ +.active { + font-weight: bold; +} diff --git a/src/core/client/admin/components/UserRole/UserRoleChangeButton.tsx b/src/core/client/admin/components/UserRole/UserRoleChangeButton.tsx new file mode 100644 index 000000000..39bcb6291 --- /dev/null +++ b/src/core/client/admin/components/UserRole/UserRoleChangeButton.tsx @@ -0,0 +1,38 @@ +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import TranslatedRole from "coral-admin/components/TranslatedRole"; +import { PropTypesOf } from "coral-framework/types"; +import { ButtonIcon, DropdownButton } from "coral-ui/components/v2"; + +import styles from "./UserRoleChangeButton.css"; + +interface Props extends Omit, "container"> { + active?: boolean; + onClick: () => void; +} + +const UserRoleChangeButton: FunctionComponent = ({ + active, + onClick, + ...props +}) => { + return ( + settings + } + > + dummy + + } + {...props} + /> + ); +}; + +export default UserRoleChangeButton; diff --git a/src/core/client/admin/components/UserRole/UserRoleChangeContainer.tsx b/src/core/client/admin/components/UserRole/UserRoleChangeContainer.tsx index a50267b4c..0ff9bdada 100644 --- a/src/core/client/admin/components/UserRole/UserRoleChangeContainer.tsx +++ b/src/core/client/admin/components/UserRole/UserRoleChangeContainer.tsx @@ -1,14 +1,17 @@ -import React, { FunctionComponent, useCallback } from "react"; +import React, { FunctionComponent, useCallback, useMemo } from "react"; import { graphql } from "react-relay"; import { Ability, can } from "coral-admin/permissions"; import { useMutation, withFragmentContainer } from "coral-framework/lib/relay"; -import { GQLUSER_ROLE_RL } from "coral-framework/schema"; +import { GQLFEATURE_FLAG, GQLUSER_ROLE_RL } from "coral-framework/schema"; +import { UserRoleChangeContainer_query } from "coral-admin/__generated__/UserRoleChangeContainer_query.graphql"; +import { UserRoleChangeContainer_settings } from "coral-admin/__generated__/UserRoleChangeContainer_settings.graphql"; import { UserRoleChangeContainer_user } from "coral-admin/__generated__/UserRoleChangeContainer_user.graphql"; import { UserRoleChangeContainer_viewer } from "coral-admin/__generated__/UserRoleChangeContainer_viewer.graphql"; import ButtonPadding from "../ButtonPadding"; +import UpdateUserModerationScopesMutation from "./UpdateUserModerationScopesMutation"; import UpdateUserRoleMutation from "./UpdateUserRoleMutation"; import UserRoleChange from "./UserRoleChange"; import UserRoleText from "./UserRoleText"; @@ -16,35 +19,75 @@ import UserRoleText from "./UserRoleText"; interface Props { viewer: UserRoleChangeContainer_viewer; user: UserRoleChangeContainer_user; + settings: UserRoleChangeContainer_settings; + query: UserRoleChangeContainer_query; } -const UserRoleChangeContainer: FunctionComponent = (props) => { +const UserRoleChangeContainer: FunctionComponent = ({ + user, + viewer, + settings, + query, +}) => { const updateUserRole = useMutation(UpdateUserRoleMutation); + const updateUserModerationScopes = useMutation( + UpdateUserModerationScopesMutation + ); const handleOnChangeRole = useCallback( - (role: GQLUSER_ROLE_RL) => { - if (role === props.user.role) { + async (role: GQLUSER_ROLE_RL) => { + if (role === user.role) { + // No role change is needed! User already has the selected role. return; } - void updateUserRole({ userID: props.user.id, role }); + + await updateUserRole({ userID: user.id, role }); }, - [props.user.id, props.user.role, updateUserRole] + [user, updateUserRole] + ); + const handleOnChangeModerationScopes = useCallback( + async (siteIDs: string[]) => { + await updateUserModerationScopes({ + userID: user.id, + moderationScopes: { siteIDs }, + }); + }, + [user] + ); + const canChangeRole = useMemo( + () => viewer.id !== user.id && can(viewer, Ability.CHANGE_ROLE), + [viewer, user] ); - const canChangeRole = - props.viewer.id !== props.user.id && can(props.viewer, Ability.CHANGE_ROLE); + const moderationScopesEnabled = useMemo( + () => + settings.featureFlags.includes(GQLFEATURE_FLAG.SITE_MODERATOR) && + settings.multisite, + [settings] + ); - if (canChangeRole) { + if (!canChangeRole) { return ( - + + + ); } + return ( - - {props.user.role} - + ); }; @@ -58,7 +101,26 @@ const enhanced = withFragmentContainer({ user: graphql` fragment UserRoleChangeContainer_user on User { id + username role + moderationScopes { + scoped + sites { + id + name + } + } + } + `, + settings: graphql` + fragment UserRoleChangeContainer_settings on Settings { + multisite + featureFlags + } + `, + query: graphql` + fragment UserRoleChangeContainer_query on Query { + ...SiteModeratorModalSiteFieldContainer_query } `, })(UserRoleChangeContainer); diff --git a/src/core/client/admin/components/UserRole/UserRoleText.tsx b/src/core/client/admin/components/UserRole/UserRoleText.tsx index 9208bfa82..0f06eedcc 100644 --- a/src/core/client/admin/components/UserRole/UserRoleText.tsx +++ b/src/core/client/admin/components/UserRole/UserRoleText.tsx @@ -7,9 +7,7 @@ import { PropTypesOf } from "coral-ui/types"; import styles from "./UserRoleText.css"; -interface Props { - children: PropTypesOf["children"]; -} +type Props = Omit, "container">; const UserRoleText: FunctionComponent = (props) => ( = (props) => ( })} /> } - > - {props.children} - + {...props} + /> ); export default UserRoleText; diff --git a/src/core/client/admin/components/UserStatus/BanModal.tsx b/src/core/client/admin/components/UserStatus/BanModal.tsx index c3905fbad..e130a69e5 100644 --- a/src/core/client/admin/components/UserStatus/BanModal.tsx +++ b/src/core/client/admin/components/UserStatus/BanModal.tsx @@ -12,9 +12,9 @@ import { Textarea, } from "coral-ui/components/v2"; +import ModalHeader from "../ModalHeader"; +import ModalHeaderUsername from "../ModalHeaderUsername"; import ChangeStatusModal from "./ChangeStatusModal"; -import ChangeStatusModalHeader from "./ChangeStatusModalHeader"; -import ModalHeaderUsername from "./ModalHeaderUsername"; import styles from "./BanModal.css"; @@ -66,13 +66,13 @@ const BanModal: FunctionComponent = ({ {username || } ))} > - + Are you sure you want to ban{" "} {username || } ? - +

diff --git a/src/core/client/admin/components/UserStatus/ChangeStatusModalHeader.tsx b/src/core/client/admin/components/UserStatus/ChangeStatusModalHeader.tsx deleted file mode 100644 index e94b65aa1..000000000 --- a/src/core/client/admin/components/UserStatus/ChangeStatusModalHeader.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React, { FunctionComponent, HTMLAttributes } from "react"; - -import styles from "./ChangeStatusModalHeader.css"; - -const ChangeStatusModalHeader: FunctionComponent> = ({ children, ...rest }) => { - return ( -

- {children} -

- ); -}; - -export default ChangeStatusModalHeader; diff --git a/src/core/client/admin/components/UserStatus/PremodModal.tsx b/src/core/client/admin/components/UserStatus/PremodModal.tsx index b9f6272f9..0ba9938cd 100644 --- a/src/core/client/admin/components/UserStatus/PremodModal.tsx +++ b/src/core/client/admin/components/UserStatus/PremodModal.tsx @@ -4,11 +4,10 @@ import React, { FunctionComponent } from "react"; import NotAvailable from "coral-admin/components/NotAvailable"; import { Button, Flex, HorizontalGutter } from "coral-ui/components/v2"; +import ModalBodyText from "../ModalBodyText"; +import ModalHeader from "../ModalHeader"; +import ModalHeaderUsername from "../ModalHeaderUsername"; import ChangeStatusModal from "./ChangeStatusModal"; -import ChangeStatusModalHeader from "./ChangeStatusModalHeader"; -import ModalHeaderUsername from "./ModalHeaderUsername"; - -import styles from "./PremodModal.css"; interface Props { username: string | null; @@ -36,19 +35,19 @@ const PremodModal: FunctionComponent = ({ strong={} $username={username || } > - + Are you sure you want to always premoderate{" "} {username || } ? - +
-
+ Note: Always premoderating this user will place all of their comments in the Pre-Moderate queue. -
+
diff --git a/src/core/client/admin/components/UserStatus/SuspendForm.css b/src/core/client/admin/components/UserStatus/SuspendForm.css index 296f41a4a..95549ad7e 100644 --- a/src/core/client/admin/components/UserStatus/SuspendForm.css +++ b/src/core/client/admin/components/UserStatus/SuspendForm.css @@ -1,12 +1,18 @@ -.textArea { - height: calc(12 * var(--mini-unit)); +.radioButton { + margin: 0 var(--spacing-1) 0 0 !important; } -.subTitle { - font-size: var(--v2-font-size-4); - font-family: var(--v2-font-family-primary); - font-weight: var(--v2-font-weight-primary-semi-bold); - line-height: var(--v2-line-height-title); - color: $suspend-modal-text; - margin: 0; +.textArea { + width: 100%; + box-sizing: border-box; + height: calc(12 * var(--mini-unit)); + padding: calc(0.5 * var(--mini-unit)); +} + +.header { + margin: 0 0 var(--spacing-3) 0 !important; +} + +.footer { + margin-top: var(--spacing-3); } diff --git a/src/core/client/admin/components/UserStatus/SuspendForm.tsx b/src/core/client/admin/components/UserStatus/SuspendForm.tsx index cbb39fca1..8f2d4c355 100644 --- a/src/core/client/admin/components/UserStatus/SuspendForm.tsx +++ b/src/core/client/admin/components/UserStatus/SuspendForm.tsx @@ -10,11 +10,12 @@ import { CheckBox, Flex, HorizontalGutter, + Label, RadioButton, Textarea, } from "coral-ui/components/v2"; -import styles from "./SuspendModal.css"; +import styles from "./SuspendForm.css"; interface Props { username: string | null; @@ -120,7 +121,7 @@ const SuspendForm: FunctionComponent = ({ -

Select suspension length

+
diff --git a/src/core/client/admin/components/UserStatus/SuspendModal.css b/src/core/client/admin/components/UserStatus/SuspendModal.css deleted file mode 100644 index 350b1036c..000000000 --- a/src/core/client/admin/components/UserStatus/SuspendModal.css +++ /dev/null @@ -1,37 +0,0 @@ -$suspend-modal-text: var(--v2-colors-mono-500); - -.bodyText { - font-size: var(--v2-font-size-3); - font-family: var(--v2-font-family-primary); - font-weight: var(--v2-font-weight-primary-regular); - line-height: var(--v2-line-height-body-short); - color: $suspend-modal-text; -} - -.subTitle { - font-size: var(--v2-font-size-4); - font-family: var(--v2-font-family-primary); - font-weight: var(--v2-font-weight-primary-semi-bold); - line-height: var(--v2-line-height-title); - color: $suspend-modal-text; - margin: 0; -} - -.radioButton { - margin: 0 var(--spacing-1) 0 0 !important; -} - -.textArea { - width: 100%; - box-sizing: border-box; - height: calc(12 * var(--mini-unit)); - padding: calc(0.5 * var(--mini-unit)); -} - -.header { - margin: 0 0 var(--spacing-3) 0 !important; -} - -.footer { - margin-top: var(--spacing-3); -} diff --git a/src/core/client/admin/components/UserStatus/SuspendModal.tsx b/src/core/client/admin/components/UserStatus/SuspendModal.tsx index 06a52b656..681c4cfb3 100644 --- a/src/core/client/admin/components/UserStatus/SuspendModal.tsx +++ b/src/core/client/admin/components/UserStatus/SuspendModal.tsx @@ -6,14 +6,12 @@ import { ScaledUnit } from "coral-common/helpers/i18n"; import { GetMessage, withGetMessage } from "coral-framework/lib/i18n"; import { Button, Flex, HorizontalGutter } from "coral-ui/components/v2"; +import ModalBodyText from "../ModalBodyText"; +import ModalHeader from "../ModalHeader"; +import ModalHeaderUsername from "../ModalHeaderUsername"; import ChangeStatusModal from "./ChangeStatusModal"; -import ChangeStatusModalHeader from "./ChangeStatusModalHeader"; -import ModalHeaderUsername from "./ModalHeaderUsername"; - import SuspendForm from "./SuspendForm"; -import styles from "./SuspendModal.css"; - interface Props { username: string | null; getMessage: GetMessage; @@ -63,10 +61,10 @@ const SuspendModal: FunctionComponent = ({ strong={} $duration={successDuration} > - + {username} has been suspended for {successDuration} - + @@ -85,19 +83,19 @@ const SuspendModal: FunctionComponent = ({ strong={} $username={username || } > - + Suspend{" "} {username || } ? - + -
+ While suspended, this user will no longer be able to comment, use reactions, or report comments. -
+
void; - onRemoveBan: () => void; + /** + * onBan when set to false disables the controls associated with banning a + * user. Otherwise the provided function is called when the control is + * clicked. + */ + onBan: false | (() => void); + + /** + * onRemoveBan when set to false disables the controls associated with + * banning a user. Otherwise the provided function is called when the control + * is clicked. + */ + onRemoveBan: false | (() => void); + onSuspend: () => void; onRemoveSuspension: () => void; onPremod: () => void; @@ -54,9 +66,12 @@ const UserStatusChange: FunctionComponent = ({ { - onRemoveBan(); - toggleVisibility(); + if (onRemoveBan) { + onRemoveBan(); + toggleVisibility(); + } }} > Remove ban @@ -66,9 +81,12 @@ const UserStatusChange: FunctionComponent = ({ { - onBan(); - toggleVisibility(); + if (onBan) { + onBan(); + toggleVisibility(); + } }} > Ban diff --git a/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx b/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx index 1997b5ce0..e7a4a4a00 100644 --- a/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx +++ b/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx @@ -4,8 +4,9 @@ import { graphql } from "react-relay"; import { useMutation, withFragmentContainer } from "coral-framework/lib/relay"; import { GQLUSER_ROLE } from "coral-framework/schema"; -import { UserStatusChangeContainer_settings as SettingsData } from "coral-admin/__generated__/UserStatusChangeContainer_settings.graphql"; -import { UserStatusChangeContainer_user as UserData } from "coral-admin/__generated__/UserStatusChangeContainer_user.graphql"; +import { UserStatusChangeContainer_settings } from "coral-admin/__generated__/UserStatusChangeContainer_settings.graphql"; +import { UserStatusChangeContainer_user } from "coral-admin/__generated__/UserStatusChangeContainer_user.graphql"; +import { UserStatusChangeContainer_viewer } from "coral-admin/__generated__/UserStatusChangeContainer_viewer.graphql"; import BanModal from "./BanModal"; import BanUserMutation from "./BanUserMutation"; @@ -20,14 +21,20 @@ import UserStatusChange from "./UserStatusChange"; import UserStatusContainer from "./UserStatusContainer"; interface Props { - user: UserData; + settings: UserStatusChangeContainer_settings; + user: UserStatusChangeContainer_user; + viewer: UserStatusChangeContainer_viewer; fullWidth?: boolean; - settings: SettingsData; bordered?: boolean; } -const UserStatusChangeContainer: FunctionComponent = (props) => { - const { user, settings, fullWidth, bordered } = props; +const UserStatusChangeContainer: FunctionComponent = ({ + user, + settings, + fullWidth, + bordered, + viewer, +}) => { const banUser = useMutation(BanUserMutation); const suspendUser = useMutation(SuspendUserMutation); const removeUserBan = useMutation(RemoveUserBanMutation); @@ -119,11 +126,13 @@ const UserStatusChangeContainer: FunctionComponent = (props) => { return ; } + const scoped = !!viewer.moderationScopes?.scoped; + return ( <> = (props) => { onClose={hidePremod} onConfirm={handlePremodConfirm} /> - + {!scoped && ( + + )} ); }; @@ -187,6 +198,13 @@ const enhanced = withFragmentContainer({ } } `, + viewer: graphql` + fragment UserStatusChangeContainer_viewer on User { + moderationScopes { + scoped + } + } + `, })(UserStatusChangeContainer); export default enhanced; diff --git a/src/core/client/admin/routes/Community/InviteUsers/InviteUsersModal.tsx b/src/core/client/admin/routes/Community/InviteUsers/InviteUsersModal.tsx index 49202c148..b22faa407 100644 --- a/src/core/client/admin/routes/Community/InviteUsers/InviteUsersModal.tsx +++ b/src/core/client/admin/routes/Community/InviteUsers/InviteUsersModal.tsx @@ -11,7 +11,7 @@ import { import InviteForm from "./InviteUsersForm"; import Success from "./Success"; -import * as styles from "./InviteUsersModal.css"; +import styles from "./InviteUsersModal.css"; interface Props { onHide: () => void; diff --git a/src/core/client/admin/routes/Community/InviteUsers/RoleField.css b/src/core/client/admin/routes/Community/InviteUsers/RoleField.css deleted file mode 100644 index b2f5bdbbc..000000000 --- a/src/core/client/admin/routes/Community/InviteUsers/RoleField.css +++ /dev/null @@ -1,9 +0,0 @@ -$role-field-text: var(--v2-colors-mono-500); - -.legend { - font-size: var(--v2-font-size-3); - font-family: var(--v2-font-family-primary); - font-weight: var(--v2-font-weight-primary-semi-bold); - line-height: var(--v2-line-height-body-short); - color: $role-field-text; -} diff --git a/src/core/client/admin/routes/Community/InviteUsers/RoleField.tsx b/src/core/client/admin/routes/Community/InviteUsers/RoleField.tsx index 9e1658e68..14fd3e16a 100644 --- a/src/core/client/admin/routes/Community/InviteUsers/RoleField.tsx +++ b/src/core/client/admin/routes/Community/InviteUsers/RoleField.tsx @@ -3,9 +3,7 @@ import React, { FunctionComponent } from "react"; import { Field } from "react-final-form"; import { GQLUSER_ROLE } from "coral-framework/schema"; -import { FieldSet, RadioButton } from "coral-ui/components/v2"; - -import styles from "./RoleField.css"; +import { FieldSet, Label, RadioButton } from "coral-ui/components/v2"; interface Props { disabled: boolean; @@ -14,7 +12,7 @@ interface Props { const RoleField: FunctionComponent = ({ disabled }) => (
- Invite as: +
diff --git a/src/core/client/admin/routes/Community/UserRow.tsx b/src/core/client/admin/routes/Community/UserRow.tsx index 70a047712..0dc285794 100644 --- a/src/core/client/admin/routes/Community/UserRow.tsx +++ b/src/core/client/admin/routes/Community/UserRow.tsx @@ -16,8 +16,11 @@ interface Props { memberSince: string; user: PropTypesOf["user"] & PropTypesOf["user"]; - viewer: PropTypesOf["viewer"]; - settings: PropTypesOf["settings"]; + viewer: PropTypesOf["viewer"] & + PropTypesOf["viewer"]; + query: PropTypesOf["query"]; + settings: PropTypesOf["settings"] & + PropTypesOf["settings"]; onUsernameClicked?: (userID: string) => void; deletedAt?: string | null; } @@ -32,6 +35,7 @@ const UserRow: FunctionComponent = ({ onUsernameClicked, settings, deletedAt, + query, }) => { const usernameClicked = useCallback(() => { if (!onUsernameClicked) { @@ -75,10 +79,15 @@ const UserRow: FunctionComponent = ({ {memberSince} - + - + ); diff --git a/src/core/client/admin/routes/Community/UserRowContainer.tsx b/src/core/client/admin/routes/Community/UserRowContainer.tsx index 65db615ac..9587f7b4d 100644 --- a/src/core/client/admin/routes/Community/UserRowContainer.tsx +++ b/src/core/client/admin/routes/Community/UserRowContainer.tsx @@ -1,9 +1,10 @@ import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; -import { useCoralContext } from "coral-framework/lib/bootstrap"; +import { useDateTimeFormatter } from "coral-framework/hooks"; import { withFragmentContainer } from "coral-framework/lib/relay"; +import { UserRowContainer_query as QueryData } from "coral-admin/__generated__/UserRowContainer_query.graphql"; import { UserRowContainer_settings as SettingsData } from "coral-admin/__generated__/UserRowContainer_settings.graphql"; import { UserRowContainer_user as UserData } from "coral-admin/__generated__/UserRowContainer_user.graphql"; import { UserRowContainer_viewer as ViewerData } from "coral-admin/__generated__/UserRowContainer_viewer.graphql"; @@ -14,24 +15,29 @@ interface Props { user: UserData; viewer: ViewerData; settings: SettingsData; + query: QueryData; onUsernameClicked?: (userID: string) => void; } const UserRowContainer: FunctionComponent = (props) => { - const { locales } = useCoralContext(); + const formatter = useDateTimeFormatter({ + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); + return ( @@ -41,12 +47,14 @@ const UserRowContainer: FunctionComponent = (props) => { const enhanced = withFragmentContainer({ viewer: graphql` fragment UserRowContainer_viewer on User { + ...UserStatusChangeContainer_viewer ...UserRoleChangeContainer_viewer } `, settings: graphql` fragment UserRowContainer_settings on Settings { ...UserStatusChangeContainer_settings + ...UserRoleChangeContainer_settings } `, user: graphql` @@ -60,6 +68,11 @@ const enhanced = withFragmentContainer({ deletedAt } `, + query: graphql` + fragment UserRowContainer_query on Query { + ...UserRoleChangeContainer_query + } + `, })(UserRowContainer); export default enhanced; diff --git a/src/core/client/admin/routes/Community/UserTable.tsx b/src/core/client/admin/routes/Community/UserTable.tsx index 06120eb8e..1cc4a45b5 100644 --- a/src/core/client/admin/routes/Community/UserTable.tsx +++ b/src/core/client/admin/routes/Community/UserTable.tsx @@ -23,6 +23,7 @@ import styles from "./UserTable.css"; interface Props { viewer: PropTypesOf["viewer"] | null; settings: PropTypesOf["settings"] | null; + query: PropTypesOf["query"] | null; users: Array<{ id: string } & PropTypesOf["user"]>; onLoadMore: () => void; hasMore: boolean; @@ -33,9 +34,12 @@ interface Props { const UserTable: FunctionComponent = ({ viewer, settings, + query, ...props }) => { - const [userDrawerUserID, setUserDrawerUserID] = useState(""); + const [userDrawerUserID, setUserDrawerUserID] = useState( + undefined + ); const [userDrawerVisible, setUserDrawerVisible] = useState(false); const onShowUserDrawer = useCallback( @@ -48,73 +52,72 @@ const UserTable: FunctionComponent = ({ const onHideUserDrawer = useCallback(() => { setUserDrawerVisible(false); - setUserDrawerUserID(""); + setUserDrawerUserID(undefined); }, [setUserDrawerUserID, setUserDrawerVisible]); + return ( - <> - - - - - - - Username - - - - - Email Address - - - - - Member Since - - - - Role - - - Status - - - - - {!props.loading && - settings && - viewer && - props.users.map((u) => ( - - ))} - -
- {!props.loading && props.users.length === 0 && } - {props.loading && ( - - - - )} - {props.hasMore && ( - - - - )} - -
- + + + + + + Username + + + + Email Address + + + + + Member Since + + + + Role + + + Status + + + + + {!props.loading && + settings && + viewer && + query && + props.users.map((user) => ( + + ))} + +
+ {!props.loading && props.users.length === 0 && } + {props.loading && ( + + + + )} + {props.hasMore && ( + + + + )} + +
); }; diff --git a/src/core/client/admin/routes/Community/UserTableContainer.tsx b/src/core/client/admin/routes/Community/UserTableContainer.tsx index 2f2b2fd64..fcd87c756 100644 --- a/src/core/client/admin/routes/Community/UserTableContainer.tsx +++ b/src/core/client/admin/routes/Community/UserTableContainer.tsx @@ -54,6 +54,7 @@ const UserTableContainer: FunctionComponent = (props) => { = ({ createdAt, duration, }) => { - const { locales } = useCoralContext(); + const formatter = useDateTimeFormatter({ + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); const formattedDate = useMemo(() => { const disableAt = DateTime.fromISO(createdAt) .plus({ seconds: duration }) .toJSDate(); - return new Intl.DateTimeFormat(locales, { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }).format(new Date(disableAt)); - }, [createdAt, duration]); + + return formatter(disableAt); + }, [createdAt, duration, formatter]); return ( diff --git a/src/core/client/admin/routes/Configure/sections/Organization/SitesConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/Organization/SitesConfigContainer.tsx index 42ac5e795..fec55e6ad 100644 --- a/src/core/client/admin/routes/Configure/sections/Organization/SitesConfigContainer.tsx +++ b/src/core/client/admin/routes/Configure/sections/Organization/SitesConfigContainer.tsx @@ -22,7 +22,7 @@ const SitesConfigContainer: React.FunctionComponent = (props) => { () => (props.query ? props.query.sites.edges.map((edge) => edge.node) : []), [props?.query?.sites.edges] ); - const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10); + const [loadMore, isLoadingMore] = useLoadMore(props.relay, 20); const [, isRefetching] = useRefetch< SitesConfigContainerPaginationQueryVariables >(props.relay); @@ -92,4 +92,5 @@ const enhanced = withPaginationContainer< `, } )(SitesConfigContainer); + export default enhanced; diff --git a/src/core/client/admin/routes/Dashboard/sections/CommentActivity.tsx b/src/core/client/admin/routes/Dashboard/sections/CommentActivity.tsx index 6c3965d45..9570dfdb6 100644 --- a/src/core/client/admin/routes/Dashboard/sections/CommentActivity.tsx +++ b/src/core/client/admin/routes/Dashboard/sections/CommentActivity.tsx @@ -12,11 +12,10 @@ import { YAxis, } from "recharts"; -import { Flex } from "coral-ui/components/v2"; - import { TimeSeriesMetricsJSON } from "coral-common/rest/dashboard/types"; +import { useDateTimeFormatter } from "coral-framework/hooks"; import { useImmediateFetch } from "coral-framework/lib/relay/fetch"; -import { useUIContext } from "coral-ui/components"; +import { Flex } from "coral-ui/components/v2"; import { DashboardBox, DashboardComponentHeading, Loader } from "../components"; import createDashboardFetch from "../createDashboardFetch"; @@ -31,7 +30,6 @@ import CommentActivityTooltip from "./CommentActivityTooltip"; import styles from "./CommentActivity.css"; interface Props { - locales?: string[]; siteID: string; lastUpdated: string; } @@ -41,18 +39,16 @@ const HourlyCommentsMetricsFetch = createDashboardFetch( "/dashboard/hourly/comments" ); -const CommentActivity: FunctionComponent = ({ - locales: localesFromProps, - siteID, - lastUpdated, -}) => { +const CommentActivity: FunctionComponent = ({ siteID, lastUpdated }) => { const [hourly, loading] = useImmediateFetch( HourlyCommentsMetricsFetch, { siteID }, lastUpdated ); - const { locales: localesFromContext } = useUIContext(); - const locales = localesFromProps || localesFromContext || ["en-US"]; + const formatter = useDateTimeFormatter({ + hour: "numeric", + hour12: true, + }); return ( @@ -81,16 +77,9 @@ const CommentActivity: FunctionComponent = ({ tick={{ fontSize: 12, fontWeight: 600 }} tickLine={false} dy={6} - tickFormatter={(unixTime: number) => { - const formatter = new Intl.DateTimeFormat(locales, { - hour: "numeric", - hour12: true, - }); - return formatter - .format(new Date(unixTime)) - .toLowerCase() - .replace(" ", ""); - }} + tickFormatter={(unixTime: number) => + formatter(unixTime).toLowerCase().replace(" ", "") + } /> = ({ /> ( - + )} /> diff --git a/src/core/client/admin/routes/Dashboard/sections/CommentActivityTooltip.tsx b/src/core/client/admin/routes/Dashboard/sections/CommentActivityTooltip.tsx index eb8142bca..a5929d73b 100644 --- a/src/core/client/admin/routes/Dashboard/sections/CommentActivityTooltip.tsx +++ b/src/core/client/admin/routes/Dashboard/sections/CommentActivityTooltip.tsx @@ -2,28 +2,27 @@ import { Localized } from "@fluent/react/compat"; import React, { FunctionComponent, useMemo } from "react"; import { TooltipProps } from "recharts"; +import { useDateTimeFormatter } from "coral-framework/hooks"; + import styles from "./CommentActivityTooltip.css"; -type Props = TooltipProps & { - locales: string[]; -}; +type Props = TooltipProps; const CommentActivityTooltip: FunctionComponent = ({ active, payload, label, - locales, }) => { + const formatter = useDateTimeFormatter({ + hour: "2-digit", + minute: "2-digit", + }); const formattedLabel = useMemo(() => { if (label) { - const formatter = new Intl.DateTimeFormat(locales, { - hour: "2-digit", - minute: "2-digit", - }); - return formatter.format(new Date(label)).toLowerCase(); + return formatter(label).toLowerCase(); } return ""; - }, [label]); + }, [label, formatter]); if (active) { return (
diff --git a/src/core/client/admin/routes/Dashboard/sections/SignupActivity.tsx b/src/core/client/admin/routes/Dashboard/sections/SignupActivity.tsx index 6935e71cd..d60361bb5 100644 --- a/src/core/client/admin/routes/Dashboard/sections/SignupActivity.tsx +++ b/src/core/client/admin/routes/Dashboard/sections/SignupActivity.tsx @@ -12,7 +12,6 @@ import { import { TimeSeriesMetricsJSON } from "coral-common/rest/dashboard/types"; import { useImmediateFetch } from "coral-framework/lib/relay/fetch"; -import { useUIContext } from "coral-ui/components"; import { DashboardBox, DashboardComponentHeading, Loader } from "../components"; import createDashboardFetch from "../createDashboardFetch"; @@ -27,7 +26,6 @@ import SignupActivityTick from "./SignupActivityTick"; import styles from "./SignupActivity.css"; interface Props { - locales?: string[]; siteID: string; lastUpdated: string; } @@ -37,7 +35,6 @@ const DailySignupMetrics = createDashboardFetch( ); const CommenterActivity: FunctionComponent = ({ - locales: localesFromProps, siteID, lastUpdated, }) => { @@ -46,8 +43,7 @@ const CommenterActivity: FunctionComponent = ({ { siteID }, lastUpdated ); - const { locales: localesFromContext } = useUIContext(); - const locales = localesFromProps || localesFromContext || ["en-US"]; + return ( @@ -77,7 +73,6 @@ const CommenterActivity: FunctionComponent = ({ daily.series && daily.series.length - 1 === props.index } - locales={locales} {...props} /> )} diff --git a/src/core/client/admin/routes/Dashboard/sections/SignupActivityTick.tsx b/src/core/client/admin/routes/Dashboard/sections/SignupActivityTick.tsx index aa0f36b9f..1bec08bdb 100644 --- a/src/core/client/admin/routes/Dashboard/sections/SignupActivityTick.tsx +++ b/src/core/client/admin/routes/Dashboard/sections/SignupActivityTick.tsx @@ -1,5 +1,7 @@ import React, { FunctionComponent, useMemo } from "react"; +import { useDateTimeFormatter } from "coral-framework/hooks"; + import { CHART_COLOR_MONO_100, CHART_COLOR_MONO_500, @@ -12,7 +14,6 @@ interface TickPayload { interface Props { x: number; - locales: string[]; y: number; payload: TickPayload; isToday: boolean; @@ -22,22 +23,22 @@ const SignupActivityTick: FunctionComponent = ({ x, y, payload, - locales, isToday, }) => { - const date = useMemo(() => { - const formatter = new Intl.DateTimeFormat(locales, { - day: "numeric", - month: "numeric", - }); - return formatter.format(new Date(payload.value)); - }, [payload.value]); - const dayOfWeek = useMemo(() => { - const formatter = new Intl.DateTimeFormat(locales, { - weekday: "short", - }); - return formatter.format(new Date(payload.value)); - }, [payload.value]); + const dateFormatter = useDateTimeFormatter({ + day: "numeric", + month: "numeric", + }); + const date = useMemo(() => dateFormatter(payload.value), [ + payload.value, + dateFormatter, + ]); + const dayOfWeekFormatter = useDateTimeFormatter({ + weekday: "short", + }); + const dayOfWeek = useMemo(() => dayOfWeekFormatter(payload.value), [ + payload.value, + ]); return ( ; - setAuthView: MutationProp; - local: Local; - auth: AuthData; - viewer: UserData | null; - setRedirectPath: MutationProp; -} & RouterState; +interface Props extends RouterState { + local: AccountCompletionContainerLocal; + auth: AccountCompletionContainer_auth; + viewer: AccountCompletionContainer_viewer | null; +} -function handleAccountCompletion(props: Props) { - const { - local: { authView, authDuplicateEmail }, - viewer, - auth, - setAuthView, - } = props; - if (viewer) { +const AccountCompletionContainer: FunctionComponent = ({ + local, + auth, + viewer, + children, + router, +}) => { + const completeAccount = useMutation(CompleteAccountMutation); + const [checked, setChecked] = useState(false); + const [completed, setCompleted] = useState(false); + const setAuthView = useMutation(SetAuthViewMutation); + const redirect = useCallback( + (view: SetAuthViewInput["view"]) => { + if (local.authView !== view) { + setAuthView({ view }); + } + }, + [local, setAuthView] + ); + + useEffect(() => { + // If we've already been marked as completed, stop now. + if (completed) { + return; + } + + // Mark that we checked the completion status. + setChecked(true); + + // If there is no viewer, exit now! + if (!viewer) { + return; + } + + // Check if the user has an email address. if (!viewer.email) { - // email not set yet. + // The email is not set on the viewer, check to see if the duplicate email + // was detected via the `ADD_EMAIL_ADDRESS` process (indicated via + // `authDuplicateEmail`) or from social login (indicated via + // `viewer.duplicateEmail`). If they're already at the `ADD_EMAIL_ADDRESS` + // view we don't need to change that. if ( - // duplicate email detected during the `ADD_EMAIL_ADDRESS` process. - authDuplicateEmail || - // detected duplicate email usually coming from a social login. - viewer.duplicateEmail + (local.authDuplicateEmail || viewer.duplicateEmail) && + local.authView !== "ADD_EMAIL_ADDRESS" ) { - // Duplicate email detected. - if (authView !== "ADD_EMAIL_ADDRESS" && authView !== "LINK_ACCOUNT") { - // `ADD_EMAIL_ADDRESS` view is allowed in case the viewer wants to change the email address. - // otherwise direct to the link account view. - setAuthView({ view: "LINK_ACCOUNT" }); - } - } else if (authView !== "ADD_EMAIL_ADDRESS") { - setAuthView({ view: "ADD_EMAIL_ADDRESS" }); + redirect("LINK_ACCOUNT"); + return; } - } else if (!viewer.username) { - // username not set yet. - if (authView !== "CREATE_USERNAME") { - // direct to create username view. - setAuthView({ view: "CREATE_USERNAME" }); - } - } else if ( - // password not set when local auth is enabled. + + redirect("ADD_EMAIL_ADDRESS"); + return; + } + + // Check if the user has a username. + if (!viewer.username) { + // The username is not set on the viewer, ensure we're on the create + // username view. + redirect("CREATE_USERNAME"); + return; + } + + // Check if the user has a password (and if they need one). + if ( !viewer.profiles.some((p) => p.__typename === "LocalProfile") && auth.integrations.local.enabled && - (auth.integrations.local.targetFilter.admin || - auth.integrations.local.targetFilter.stream) + auth.integrations.local.targetFilter.admin ) { - if (authView !== "CREATE_PASSWORD") { - // direct to create password view. - setAuthView({ view: "CREATE_PASSWORD" }); - } - } else { - // all set, complete account. - void props - .completeAccount({ accessToken: props.local.accessToken! }) - .then(() => { - props.router.replace(props.local.redirectPath || "/admin"); - }); - // account completed. - return true; + // The password is not set on the viewer and the password is configured on + // the admin area. + redirect("CREATE_PASSWORD"); + return; } - } - // account not completed yet. - return false; -} -interface State { - checkedCompletionStatus: boolean; - completed: boolean; -} - -class AccountCompletionContainer extends Component { - public state = { - checkedCompletionStatus: false, - completed: false, - }; - - public componentDidMount() { - const completed = handleAccountCompletion(this.props); - this.setState({ checkedCompletionStatus: true, completed }); - } - public componentDidUpdate() { - if (!this.state.completed) { - const completed = handleAccountCompletion(this.props); - if (completed) { - this.setState({ completed }); + // Create a callback finish function that will redirect the user once the + // access token has been set in the store. + async function finish() { + try { + await completeAccount({ accessToken: local.accessToken! }); + router.replace(local.redirectPath || "/admin"); + } catch (err) { + window.console.error(err); } } + + // Looks like the account is complete! Finish the account setup. + void finish(); + + // Mark the account as completed. + setCompleted(true); + }, [ + completed, + setChecked, + viewer, + auth, + redirect, + local, + router, + completeAccount, + setCompleted, + ]); + + // If we haven't checked or we haven't completed the account, then render + // nothing. + if (!checked || completed) { + return null; } - public render() { - const { children } = this.props; - // We skip first frame to check for completion status. - if (!this.state.checkedCompletionStatus || this.state.completed) { - return null; - } - return <>{children}; - } -} + return <>{children}; +}; const enhanced = withLocalStateContainer( graphql` @@ -151,15 +170,7 @@ const enhanced = withLocalStateContainer( } } `, - })( - withMutation(SetAuthViewMutation)( - withMutation(SetRedirectPathMutation)( - withMutation(CompleteAccountMutation)( - withRouter(AccountCompletionContainer) - ) - ) - ) - ) + })(withRouter(AccountCompletionContainer)) ); export default enhanced; diff --git a/src/core/client/admin/routes/Login/LoginRoute.tsx b/src/core/client/admin/routes/Login/LoginRoute.tsx index 5d211e50b..3f9b15933 100644 --- a/src/core/client/admin/routes/Login/LoginRoute.tsx +++ b/src/core/client/admin/routes/Login/LoginRoute.tsx @@ -64,4 +64,5 @@ const enhanced = withRouteConfig({ ` )(LoginRoute) ); + export default enhanced; diff --git a/src/core/client/admin/routes/Moderate/Moderate.spec.tsx b/src/core/client/admin/routes/Moderate/Moderate.spec.tsx index d85a50a0e..b3abc7ff9 100644 --- a/src/core/client/admin/routes/Moderate/Moderate.spec.tsx +++ b/src/core/client/admin/routes/Moderate/Moderate.spec.tsx @@ -13,6 +13,7 @@ it("renders correctly", () => { allStories: true, moderationQueues: {}, story: {}, + viewer: null, siteID: null, query: "", routeParams: {}, diff --git a/src/core/client/admin/routes/Moderate/Moderate.tsx b/src/core/client/admin/routes/Moderate/Moderate.tsx index 1bf671044..7db5f2a74 100644 --- a/src/core/client/admin/routes/Moderate/Moderate.tsx +++ b/src/core/client/admin/routes/Moderate/Moderate.tsx @@ -30,13 +30,17 @@ interface Props { PropTypesOf["story"]; query: PropTypesOf["query"] & PropTypesOf["query"]; + viewer: PropTypesOf["viewer"]; moderationQueues: PropTypesOf< typeof ModerateNavigationContainer >["moderationQueues"]; allStories: boolean; siteID: string | null; section?: SectionFilter | null; - settings: PropTypesOf["settings"] | null; + settings: + | (PropTypesOf["settings"] & + PropTypesOf["settings"]) + | null; children?: React.ReactNode; queueName: string; routeParams: RouteParams; @@ -46,6 +50,7 @@ const Moderate: FunctionComponent = ({ moderationQueues, story, query, + viewer, allStories, children, queueName, @@ -71,6 +76,7 @@ const Moderate: FunctionComponent = ({ key.unbind(HOTKEYS.GUIDE); }; }, []); + return (
= ({ } diff --git a/src/core/client/admin/routes/Moderate/ModerateContainer.tsx b/src/core/client/admin/routes/Moderate/ModerateContainer.tsx index c228f79bf..7a651dabf 100644 --- a/src/core/client/admin/routes/Moderate/ModerateContainer.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateContainer.tsx @@ -1,9 +1,11 @@ -import { Match, RouteProps, Router, withRouter } from "found"; -import React from "react"; +import { Match, Router, withRouter } from "found"; +import React, { FunctionComponent, useEffect, useMemo } from "react"; import { graphql } from "react-relay"; +import { getModerationLink, QUEUE_NAME } from "coral-framework/helpers"; import parseModerationOptions from "coral-framework/helpers/parseModerationOptions"; import { withRouteConfig } from "coral-framework/lib/router"; +import { GQLFEATURE_FLAG } from "coral-framework/schema"; import { Spinner } from "coral-ui/components/v2"; import { ModerateContainerQueryResponse } from "coral-admin/__generated__/ModerateContainerQuery.graphql"; @@ -16,64 +18,134 @@ interface RouteParams { } interface Props { - data: ModerateContainerQueryResponse; + data: ModerateContainerQueryResponse | null; router: Router; match: Match & { params: RouteParams }; } -class ModerateContainer extends React.Component { - public static routeConfig: RouteProps; +const queueNames: QUEUE_NAME[] = [ + "reported", + "pending", + "unmoderated", + "approved", + "rejected", +]; - public render() { - const allStories = !this.props.match.params.storyID; - // TODO: (tessalt) get active route in a better way - const queueName = [ +const ModerateContainer: FunctionComponent = ({ + data, + match, + router, + children, +}) => { + const allStories = !match.params.storyID; + const queueName = useMemo( + () => + // TODO: (tessalt) get active route in a better way + queueNames.find((name) => match.location.pathname.includes(name)) || "default", - "reported", - "pending", - "unmoderated", - "approved", - "rejected", - ].find((name) => { - return this.props.match.location.pathname.includes(name); - }); - const { section } = parseModerationOptions(this.props.match); + [match.location.pathname] + ); - if (!this.props.data) { - return ( - - - - ); + // This guard is used to ensure that the current viewer has permission to + // moderate on this site. It's injected here because we get the result from + // relay here for the site that is referenced if this is supposed to moderate + // a story. + useEffect(() => { + // Wait for the data and viewer to become available. + if (!data || !data.viewer) { + return; } + // If the feature flag isn't enabled, we don't need to do anything! + if (!data.settings.featureFlags.includes(GQLFEATURE_FLAG.SITE_MODERATOR)) { + return; + } + + // If the viewer isn't moderation scoped, nothing we need to do! + if ( + !data.viewer.moderationScopes?.scoped || + !data.viewer.moderationScopes.sites + ) { + return; + } + + // Grab a reference for the following function to make the type checker + // happy. + const sites = data.viewer.moderationScopes.sites; + const redirect = () => + router.push( + getModerationLink({ + queue: queueName as QUEUE_NAME, + // We'll grab the first site in the moderation scopes (a user can only + // be scoped if there is at least one site). + siteID: sites[0].id, + }) + ); + + // If we've loaded a specific story, we'll have the site on that story too, + // so check if we're allowed to moderate it. + if (data.story && !data.story.site.canModerate) { + redirect(); + return; + } + + // Get some options from the router. + const { siteID } = parseModerationOptions(match); + + // If the viewer is moderation scoped, they cannot moderate under all sites. + if (!siteID) { + redirect(); + return; + } + + // Check to see if the user is allowed to moderate on this site given that + // they are already scoped. If the current site ID is not found, redirect + // them! + if (!sites.some(({ id }) => id === siteID)) { + redirect(); + return; + } + }, [router, match, data]); + + // Get some options for the moderate cards. + const { section } = parseModerationOptions(match); + + if (!data) { return ( - {this.props.children} + ); } -} + + return ( + + {children} + + ); +}; const enhanced = withRouteConfig({ query: graphql` @@ -83,21 +155,42 @@ const enhanced = withRouteConfig({ $siteID: ID $section: SectionFilter ) { + ...SiteSelectorContainer_query + ...SectionSelectorContainer_query + settings { ...ModerateSearchBarContainer_settings + ...SiteSelectorContainer_settings + + featureFlags } + story(id: $storyID) @include(if: $includeStory) { ...ModerateNavigationContainer_story ...ModerateSearchBarContainer_story + site { id + canModerate } } + moderationQueues(storyID: $storyID, siteID: $siteID, section: $section) { ...ModerateNavigationContainer_moderationQueues } - ...SiteSelectorContainer_query - ...SectionSelectorContainer_query + + viewer { + ...SiteSelectorContainer_viewer + + id + role + moderationScopes { + scoped + sites { + id + } + } + } } `, cacheConfig: { force: true }, @@ -108,8 +201,7 @@ const enhanced = withRouteConfig({ storyID, siteID, section, - includeStory: Boolean(storyID), - includeSite: Boolean(siteID), + includeStory: !!storyID, }; }, })(withRouter(ModerateContainer)); diff --git a/src/core/client/admin/routes/Moderate/ModerateSearchBar/ModerateSearchBarContainer.tsx b/src/core/client/admin/routes/Moderate/ModerateSearchBar/ModerateSearchBarContainer.tsx index b4339ced9..0bc3c337f 100644 --- a/src/core/client/admin/routes/Moderate/ModerateSearchBar/ModerateSearchBarContainer.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateSearchBar/ModerateSearchBarContainer.tsx @@ -104,12 +104,13 @@ function getStoryDetails( } function getContextOptionsWhenModeratingAll( - onClickOrEnter: ListBoxOptionClickOrEnterHandler + onClickOrEnter: ListBoxOptionClickOrEnterHandler, + siteID: string | null ): SearchBarOptions { return [ { element: ( -