diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index cb33af5334..4f7e6e4843 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -6,7 +6,7 @@ on: merge_group: env: - IMAGE_REPOSITORY: 'gcr.io/the-coral-project/coral' + IMAGE_REPOSITORY: 'us-east1-docker.pkg.dev/the-coral-project/coral/talk' IMAGE_CACHE_REPOSITORY: 'coralproject/ci' DOCKERHUB_USERNAME: 'coralproject' @@ -27,12 +27,12 @@ jobs: with: ssh-private-key: ${{ secrets.REPO_PATCHED_DEPLOY_KEY }} - - name: Login to GCR + name: Login to GAR uses: docker/login-action@v2 with: - registry: gcr.io + registry: us-east1-docker.pkg.dev username: _json_key - password: ${{ secrets.GCR_JSON_KEY }} + password: ${{ secrets.GAR_JSON_KEY }} - name: Login to Docker Hub uses: docker/login-action@v2 diff --git a/.github/workflows/build-test-deploy.yml b/.github/workflows/build-test-deploy.yml index 092dbbcd67..a73c408c05 100644 --- a/.github/workflows/build-test-deploy.yml +++ b/.github/workflows/build-test-deploy.yml @@ -34,7 +34,7 @@ jobs: id: 'auth' uses: 'google-github-actions/auth@v1' with: - credentials_json: '${{ secrets.GCR_JSON_KEY }}' + credentials_json: '${{ secrets.GAR_JSON_KEY }}' - name: Set up Cloud SDK uses: 'google-github-actions/setup-gcloud@v1' diff --git a/docs/docs/sso.md b/docs/docs/sso.md index de67c12205..7862054d15 100644 --- a/docs/docs/sso.md +++ b/docs/docs/sso.md @@ -35,9 +35,10 @@ You will then have to generate a JWT with the following claims: about status changes on a user account such as bans or suspensions. - `user.username` **(required)** - the username that should be used when being presented inside Coral to moderators and other users. There are no username validations or restrictions enforced by Coral when you're using SSO. -- `user.badges` _(optional)_ - array of strings to be displayed as badges beside - username inside Coral, visible to other users and moderators. For example, to indicate - a user's subscription status. If you include the claim, but you are not passing a badge value, then use an empty array instead of null. +- `user.badges` _(optional)_ - array of strings to be displayed as badges and custom flair badges beside username inside Coral, + visible to other users and moderators. Badges are configured by passing through strings and can be used to indicate a user's subscription status. + - Custom flair badges are configured by passing through names that link to the desired flair badge image. + - To use custom flair badges, they must also be enabled in the admin, and each custom flair badge name, image URL must be added in the admin as well. If you include the badges claim, but you are not passing a badge value, then use an empty array instead of null. - `user.role` _(optional)_ - one of "COMMENTER", "STAFF", "MODERATOR", "ADMIN". Will create/update Coral user with this permission level. When users have both an assigned role greather than COMMENTER and a badge, both will be displayed. - `user.url` _(optional)_ - url for user account management, where a user will @@ -55,7 +56,8 @@ An example of the claims for this token would be: "user": { "id": "628bdc61-6616-4add-bfec-dd79156715d4", "email": "bob@example.com", - "username": "bob" + "username": "bob", + "badges": ["subscriber", "https://www.example/com/image.jpg"] } } ``` diff --git a/package-lock.json b/package-lock.json index a1cc0cb83e..701c60b896 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@coralproject/talk", - "version": "8.4.2", + "version": "8.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@coralproject/talk", - "version": "8.4.2", + "version": "8.5.0", "license": "Apache-2.0", "dependencies": { "@ampproject/toolbox-cache-url": "^2.9.0", diff --git a/package.json b/package.json index f74b7a2237..d22c55d20b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@coralproject/talk", - "version": "8.4.2", + "version": "8.5.0", "author": "The Coral Project", "homepage": "https://coralproject.net/", "sideEffects": [ diff --git a/src/core/client/admin/components/BanModal.tsx b/src/core/client/admin/components/BanModal.tsx index b603a81c17..1461f1b50c 100644 --- a/src/core/client/admin/components/BanModal.tsx +++ b/src/core/client/admin/components/BanModal.tsx @@ -1,4 +1,5 @@ import { Localized } from "@fluent/react/compat"; +import { FORM_ERROR } from "final-form"; import React, { FunctionComponent, useCallback, @@ -221,27 +222,40 @@ const BanModal: FunctionComponent = ({ const onFormSubmit = useCallback(async () => { switch (updateType) { case UpdateType.ALL_SITES: - await banUser({ - userID, // Should be defined because the modal shouldn't open if author is null - message: customizeMessage ? emailMessage : getDefaultMessage, - rejectExistingComments, - siteIDs: viewerIsScoped - ? viewer?.moderationScopes?.sites?.map(({ id }) => id) - : [], - }); + try { + await banUser({ + userID, // Should be defined because the modal shouldn't open if author is null + message: customizeMessage ? emailMessage : getDefaultMessage, + rejectExistingComments, + siteIDs: viewerIsScoped + ? viewer?.moderationScopes?.sites?.map(({ id }) => id) + : [], + }); + } catch (err) { + return { [FORM_ERROR]: err.message }; + } break; case UpdateType.SPECIFIC_SITES: - await updateUserBan({ - userID, - message: customizeMessage ? emailMessage : getDefaultMessage, - banSiteIDs, - unbanSiteIDs, - }); + try { + await updateUserBan({ + userID, + message: customizeMessage ? emailMessage : getDefaultMessage, + banSiteIDs, + unbanSiteIDs, + rejectExistingComments, + }); + } catch (err) { + return { [FORM_ERROR]: err.message }; + } break; case UpdateType.NO_SITES: - await removeUserBan({ - userID, - }); + try { + await removeUserBan({ + userID, + }); + } catch (err) { + return { [FORM_ERROR]: err.message }; + } } if (banDomain) { void createDomainBan({ @@ -326,59 +340,66 @@ const BanModal: FunctionComponent = ({ > {/* BAN FROM/REJECT COMMENTS */} - {/* ban from header */} - - - - - {/* sites options */} - {showAllSitesOption && ( - - - - setUpdateType(UpdateType.ALL_SITES) - } - disabled={userBanStatus?.active} - > - All sites - - - - )} - - - - setUpdateType(UpdateType.SPECIFIC_SITES) - } - > - Specific Sites - + {isMultisite && ( + <> + + - - {!viewerIsScoped && userHasAnyBan && ( - - - - setUpdateType(UpdateType.NO_SITES) - } - > - No Sites - - - - )} - + + {/* sites options */} + {showAllSitesOption && ( + + + + setUpdateType(UpdateType.ALL_SITES) + } + disabled={userBanStatus?.active} + > + All sites + + + + )} + + + + setUpdateType(UpdateType.SPECIFIC_SITES) + } + > + Specific Sites + + + + {!viewerIsScoped && userHasAnyBan && ( + + + + setUpdateType(UpdateType.NO_SITES) + } + > + No Sites + + + + )} + + + )} {/* reject comments option */} {updateType !== UpdateType.NO_SITES && ( ) => { + async (environment: Environment, input: MutationInput) => { const viewer = getViewer(environment)!; - return commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation BanUserMutation($input: BanUserInput!) { - banUser(input: $input) { - user { - id - status { - current - warning { - active - } - premod { - active - } - suspension { - active - } - ban { - active - history { + + const res = await commitMutationPromiseNormalized( + environment, + { + mutation: graphql` + mutation BanUserMutation($input: BanUserInput!) { + banUser(input: $input) { + user { + id + status { + current + warning { + active + } + premod { + active + } + suspension { + active + } + ban { active - createdAt - createdBy { + history { + active + createdAt + createdBy { + id + username + } + } + sites { id - username } } - sites { - id - } } } + clientMutationId } - clientMutationId } - } - `, - variables: { - input: { - ...input, - clientMutationId: clientMutationId.toString(), + `, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, }, - }, - optimisticResponse: { - banUser: { - user: { - id: input.userID, - status: { - current: lookup( - environment, - input.userID - )!.status.current.concat(GQLUSER_STATUS.BANNED), - ban: { - active: true, - history: [ - { - active: true, - createdAt: new Date().toISOString(), - createdBy: { - id: viewer.id, - username: viewer.username || null, + optimisticResponse: { + banUser: { + user: { + id: input.userID, + status: { + current: lookup( + environment, + input.userID + )!.status.current.concat(GQLUSER_STATUS.BANNED), + ban: { + active: true, + history: [ + { + active: true, + createdAt: new Date().toISOString(), + createdBy: { + id: viewer.id, + username: viewer.username || null, + }, }, - }, - ], - sites: - input.siteIDs?.map((id) => { - return { id }; - }) || [], - }, - warning: { - active: false, - }, - suspension: { - active: false, - }, - premod: { - active: false, + ], + sites: + input.siteIDs?.map((id) => { + return { id }; + }) || [], + }, + warning: { + active: false, + }, + suspension: { + active: false, + }, + premod: { + active: false, + }, }, }, + clientMutationId: (clientMutationId++).toString(), }, - clientMutationId: (clientMutationId++).toString(), }, - }, - }); + } + ); + + if (input.rejectExistingComments) { + commitLocalUpdate(environment, (store) => { + const record = store.get(input.userID); + return record?.setValue(true, "allCommentsRejected"); + }); + } + + return res; } ); diff --git a/src/core/client/admin/components/ConversationModal/ConversationModalCommentContainer.css b/src/core/client/admin/components/ConversationModal/ConversationModalCommentContainer.css index f0a55205df..ab1c832711 100644 --- a/src/core/client/admin/components/ConversationModal/ConversationModalCommentContainer.css +++ b/src/core/client/admin/components/ConversationModal/ConversationModalCommentContainer.css @@ -2,6 +2,7 @@ $conversationModalHighlightBackground: $colors-teal-100; $conversationModalCommentText: var(--palette-text-500); .root { flex: 1; + padding: 0 var(--spacing-2); } .line { @@ -35,3 +36,16 @@ $conversationModalCommentText: var(--palette-text-500); .showReplies { padding-left: var(--spacing-2); } + +.commentWrapper { + flex: 1; +} + +.rejectButton { + height: fit-content; + margin-top: var(--spacing-1); + &:disabled { + background-color: var(--palette-error-500) !important; + opacity: 1 !important; + } +} diff --git a/src/core/client/admin/components/ConversationModal/ConversationModalCommentContainer.tsx b/src/core/client/admin/components/ConversationModal/ConversationModalCommentContainer.tsx index 8965bee3a4..29005be9dc 100644 --- a/src/core/client/admin/components/ConversationModal/ConversationModalCommentContainer.tsx +++ b/src/core/client/admin/components/ConversationModal/ConversationModalCommentContainer.tsx @@ -1,10 +1,23 @@ import { Localized } from "@fluent/react/compat"; import cn from "classnames"; -import React, { FunctionComponent, useCallback, useState } from "react"; +import { useRouter } from "found"; +import React, { + FunctionComponent, + useCallback, + useMemo, + useState, +} from "react"; import { graphql } from "react-relay"; import { MediaContainer } from "coral-admin/components/MediaContainer"; -import { withFragmentContainer } from "coral-framework/lib/relay"; +import { RejectCommentMutation } from "coral-admin/mutations"; +import { parseModerationOptions } from "coral-framework/helpers"; +import { + useLocal, + useMutation, + withFragmentContainer, +} from "coral-framework/lib/relay"; +import { RemoveIcon, SvgIcon } from "coral-ui/components/icons"; import { Button, Flex, @@ -13,7 +26,7 @@ import { } from "coral-ui/components/v2"; import { ConversationModalCommentContainer_comment } from "coral-admin/__generated__/ConversationModalCommentContainer_comment.graphql"; -import { ConversationModalCommentContainer_settings } from "coral-admin/__generated__/ConversationModalCommentContainer_settings.graphql"; +import { ConversationModalCommentContainerLocal } from "coral-admin/__generated__/ConversationModalCommentContainerLocal.graphql"; import { CommentContent, InReplyTo, UsernameButton } from "../Comment"; import { Circle, Line } from "../Timeline"; @@ -27,7 +40,6 @@ interface Props { onUsernameClick: (id?: string) => void; isParent?: boolean; isReply?: boolean; - settings: ConversationModalCommentContainer_settings; } const ConversationModalCommentContainer: FunctionComponent = ({ @@ -36,8 +48,16 @@ const ConversationModalCommentContainer: FunctionComponent = ({ isParent, isReply, onUsernameClick, - settings, }) => { + const rejectComment = useMutation(RejectCommentMutation); + const { match } = useRouter(); + const { storyID, siteID, section } = parseModerationOptions(match); + const [{ moderationQueueSort }] = + useLocal(graphql` + fragment ConversationModalCommentContainerLocal on Local { + moderationQueueSort + } + `); const commentAuthorClick = useCallback(() => { if (comment.author) { onUsernameClick(comment.author.id); @@ -52,8 +72,54 @@ const ConversationModalCommentContainer: FunctionComponent = ({ const onShowReplies = useCallback(() => { setShowReplies(true); }, []); + const onRejectComment = useCallback(async () => { + if (!comment.revision) { + return; + } + await rejectComment({ + commentID: comment.id, + commentRevisionID: comment.revision.id, + storyID, + siteID, + section, + orderBy: moderationQueueSort, + }); + }, [ + comment.id, + comment.revision, + match, + moderationQueueSort, + rejectComment, + storyID, + siteID, + section, + ]); + const rejectButtonOptions = useMemo((): { + localization: string; + variant: "regular" | "outlined"; + ariaLabel: string; + text: string; + disabled: boolean; + } => { + if (comment.status === "REJECTED") { + return { + localization: "conversation-modal-rejectButton-rejected", + variant: "regular", + ariaLabel: "Rejected", + text: "Rejected", + disabled: true, + }; + } + return { + localization: "conversation-modal-rejectButton", + variant: "outlined", + ariaLabel: "Reject", + text: "Reject", + disabled: false, + }; + }, [comment.status]); return ( - + = ({ [styles.highlighted]: isHighlighted, })} > -
- - {comment.author && comment.author.username && ( - - )} - {comment.createdAt} - - {comment.parent && - comment.parent.author && - comment.parent.author.username && ( - - {comment.parent.author.username} - - )} -
+ + + +
+ + {comment.author && comment.author.username && ( + + )} + {comment.createdAt} + + {comment.parent && + comment.parent.author && + comment.parent.author.username && ( + + {comment.parent.author.username} + + )} +
-
- {comment.body && ( - - {comment.body} - - )} - -
+
+ {comment.body && ( + + {comment.body} + + )} + +
+
+
+ + }} + > + + + +
{isReply && comment.replyCount > 0 && ( @@ -127,13 +219,6 @@ const ConversationModalCommentContainer: FunctionComponent = ({ }; const enhanced = withFragmentContainer({ - settings: graphql` - fragment ConversationModalCommentContainer_settings on Settings { - multisite - featureFlags - ...MarkersContainer_settings - } - `, comment: graphql` fragment ConversationModalCommentContainer_comment on Comment { id @@ -143,6 +228,10 @@ const enhanced = withFragmentContainer({ username id } + revision { + id + } + status replyCount parent { author { diff --git a/src/core/client/admin/components/ConversationModal/ConversationModalContainer.tsx b/src/core/client/admin/components/ConversationModal/ConversationModalContainer.tsx index 59b3cc8d82..b229e0a093 100644 --- a/src/core/client/admin/components/ConversationModal/ConversationModalContainer.tsx +++ b/src/core/client/admin/components/ConversationModal/ConversationModalContainer.tsx @@ -14,7 +14,6 @@ import { } from "coral-ui/components/v2"; import { ConversationModalContainer_comment } from "coral-admin/__generated__/ConversationModalContainer_comment.graphql"; -import { ConversationModalContainer_settings } from "coral-admin/__generated__/ConversationModalContainer_settings.graphql"; import { ConversationModalContainerPaginationQueryVariables } from "coral-admin/__generated__/ConversationModalContainerPaginationQuery.graphql"; import { Circle } from "../Timeline"; @@ -25,7 +24,6 @@ import styles from "./ConversationModalContainer.css"; interface Props { relay: RelayPaginationProp; comment: ConversationModalContainer_comment; - settings: ConversationModalContainer_settings; onClose: () => void; onUsernameClicked: (id?: string) => void; } @@ -33,7 +31,6 @@ interface Props { const ConversationModalContainer: FunctionComponent = ({ comment, relay, - settings, onUsernameClicked, }) => { const [loadMore] = useLoadMore(relay, 5); @@ -66,13 +63,11 @@ const ConversationModalContainer: FunctionComponent = ({ isParent={true} comment={parent} onUsernameClick={onUsernameClicked} - settings={settings} isHighlighted={false} /> ))} @@ -89,11 +84,6 @@ const enhanced = withPaginationContainer< FragmentVariables >( { - settings: graphql` - fragment ConversationModalContainer_settings on Settings { - ...ConversationModalCommentContainer_settings - } - `, comment: graphql` fragment ConversationModalContainer_comment on Comment @argumentDefinitions( diff --git a/src/core/client/admin/components/ConversationModal/ConversationModalQuery.tsx b/src/core/client/admin/components/ConversationModal/ConversationModalQuery.tsx index 6090956c20..31341c10f1 100644 --- a/src/core/client/admin/components/ConversationModal/ConversationModalQuery.tsx +++ b/src/core/client/admin/components/ConversationModal/ConversationModalQuery.tsx @@ -32,10 +32,6 @@ const ConversationModalQuery: FunctionComponent = ({ query={graphql` query ConversationModalQuery($commentID: ID!) { - settings { - ...ConversationModalContainer_settings - ...ConversationModalRepliesContainer_settings - } comment(id: $commentID) { ...ConversationModalContainer_comment ...ConversationModalRepliesContainer_comment @@ -71,7 +67,7 @@ const ConversationModalQuery: FunctionComponent = ({ } return ( - + = ({
diff --git a/src/core/client/admin/components/ConversationModal/ConversationModalRepliesContainer.tsx b/src/core/client/admin/components/ConversationModal/ConversationModalRepliesContainer.tsx index 76691abafc..a775d330a2 100644 --- a/src/core/client/admin/components/ConversationModal/ConversationModalRepliesContainer.tsx +++ b/src/core/client/admin/components/ConversationModal/ConversationModalRepliesContainer.tsx @@ -14,7 +14,6 @@ import { import { Button, HorizontalGutter } from "coral-ui/components/v2"; import { ConversationModalRepliesContainer_comment } from "coral-admin/__generated__/ConversationModalRepliesContainer_comment.graphql"; -import { ConversationModalRepliesContainer_settings } from "coral-admin/__generated__/ConversationModalRepliesContainer_settings.graphql"; import { ConversationModalRepliesContainerPaginationQueryVariables } from "coral-admin/__generated__/ConversationModalRepliesContainerPaginationQuery.graphql"; import ConversationModalCommentContainer from "./ConversationModalCommentContainer"; @@ -24,7 +23,6 @@ import styles from "./ConversationModalRepliesContainer.css"; interface Props { relay: RelayPaginationProp; comment: ConversationModalRepliesContainer_comment; - settings: ConversationModalRepliesContainer_settings; onClose: () => void; onUsernameClicked: (id?: string) => void; } @@ -32,7 +30,6 @@ interface Props { const ConversationModalRepliesContainer: FunctionComponent = ({ comment, relay, - settings, onUsernameClicked, }) => { const [loadMore] = useLoadMore(relay, 5); @@ -51,7 +48,6 @@ const ConversationModalRepliesContainer: FunctionComponent = ({
( { - settings: graphql` - fragment ConversationModalRepliesContainer_settings on Settings { - ...ConversationModalCommentContainer_settings - } - `, comment: graphql` fragment ConversationModalRepliesContainer_comment on Comment @argumentDefinitions( diff --git a/src/core/client/admin/components/ConversationModal/ConversationModalRepliesQuery.tsx b/src/core/client/admin/components/ConversationModal/ConversationModalRepliesQuery.tsx index 89789b4ed5..6306b5db7a 100644 --- a/src/core/client/admin/components/ConversationModal/ConversationModalRepliesQuery.tsx +++ b/src/core/client/admin/components/ConversationModal/ConversationModalRepliesQuery.tsx @@ -23,9 +23,6 @@ const ConversationModalRepliesQuery: FunctionComponent = ({ query={graphql` query ConversationModalRepliesQuery($commentID: ID!) { - settings { - ...ConversationModalCommentContainer_settings - } comment(id: $commentID) { replies { edges { @@ -72,7 +69,6 @@ const ConversationModalRepliesQuery: FunctionComponent = ({ ({ comment: graphql` fragment ModerateCardContainer_comment on Comment { id + site { + id + } author { id email username + allCommentsRejected + commentsRejectedOnSites status { current ban { diff --git a/src/core/client/admin/components/UpdateUserBanMutation.ts b/src/core/client/admin/components/UpdateUserBanMutation.ts index 717730f3f4..e7b33d089b 100644 --- a/src/core/client/admin/components/UpdateUserBanMutation.ts +++ b/src/core/client/admin/components/UpdateUserBanMutation.ts @@ -1,5 +1,5 @@ import { graphql } from "react-relay"; -import { Environment } from "relay-runtime"; +import { commitLocalUpdate, Environment } from "relay-runtime"; import { commitMutationPromiseNormalized, @@ -12,7 +12,7 @@ let clientMutationId = 0; const UpdateUserBanMutation = createMutation( "updateUserBan", - (environment: Environment, input: MutationInput) => { + async (environment: Environment, input: MutationInput) => { const { userID, banSiteIDs, @@ -20,7 +20,8 @@ const UpdateUserBanMutation = createMutation( message, rejectExistingComments, } = input; - return commitMutationPromiseNormalized(environment, { + + const res = await commitMutationPromiseNormalized(environment, { mutation: graphql` mutation UpdateUserBanMutation($input: UpdateUserBanInput!) { updateUserBan(input: $input) { @@ -50,6 +51,15 @@ const UpdateUserBanMutation = createMutation( }, }, }); + + if (input.rejectExistingComments) { + commitLocalUpdate(environment, (store) => { + const record = store.get(input.userID); + return record!.setValue(banSiteIDs, "commentsRejectedOnSites"); + }); + } + + return res; } ); diff --git a/src/core/client/admin/local/local.graphql b/src/core/client/admin/local/local.graphql index 27053aead7..3194c33bd9 100644 --- a/src/core/client/admin/local/local.graphql +++ b/src/core/client/admin/local/local.graphql @@ -25,6 +25,17 @@ extend type CommentsConnection { viewNewEdges: [CommentEdge!] } +extend type User { + # Set to true when this user has had all of their comments + # rejected as part of the ban process, since it may take + # some time to take effect on the backend + allCommentsRejected: Boolean + # Contains ids of sites on which a user has had all of their + # comments rejected in the course of being banned on specific + # sites + commentsRejectedOnSites: [ID!] +} + extend type Local { redirectPath: String authView: View diff --git a/src/core/client/admin/routes/Configure/sections/General/CreateFlairBadgeMutation.tsx b/src/core/client/admin/routes/Configure/sections/General/CreateFlairBadgeMutation.tsx new file mode 100644 index 0000000000..d365146cfa --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/General/CreateFlairBadgeMutation.tsx @@ -0,0 +1,44 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { CreateFlairBadgeMutation as MutationTypes } from "coral-admin/__generated__/CreateFlairBadgeMutation.graphql"; + +let clientMutationId = 0; + +const CreateFlairBadgeMutation = createMutation( + "createFlairBadge", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation CreateFlairBadgeMutation($input: CreateFlairBadgeInput!) { + createFlairBadge(input: $input) { + settings { + flairBadges { + flairBadgesEnabled + badges { + name + url + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + name: input.name, + url: input.url, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default CreateFlairBadgeMutation; diff --git a/src/core/client/admin/routes/Configure/sections/General/DeleteFlairBadgeMutation.tsx b/src/core/client/admin/routes/Configure/sections/General/DeleteFlairBadgeMutation.tsx new file mode 100644 index 0000000000..ce84defc0d --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/General/DeleteFlairBadgeMutation.tsx @@ -0,0 +1,43 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { DeleteFlairBadgeMutation as MutationTypes } from "coral-admin/__generated__/DeleteFlairBadgeMutation.graphql"; + +let clientMutationId = 0; + +const DeleteFlairBadgeMutation = createMutation( + "deleteFlairBadge", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation DeleteFlairBadgeMutation($input: DeleteFlairBadgeInput!) { + deleteFlairBadge(input: $input) { + settings { + flairBadges { + flairBadgesEnabled + badges { + name + url + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + name: input.name, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default DeleteFlairBadgeMutation; diff --git a/src/core/client/admin/routes/Configure/sections/General/FlairBadgeConfigContainer.css b/src/core/client/admin/routes/Configure/sections/General/FlairBadgeConfigContainer.css new file mode 100644 index 0000000000..700963e88d --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/General/FlairBadgeConfigContainer.css @@ -0,0 +1,24 @@ +.deleteButton { + margin-left: auto; +} + +.addButton { + height: 34px; +} + +.flairBadgeURLInput { + margin-right: var(--spacing-3); +} + +.flairBadgeNameInput { + margin-right: var(--spacing-3); +} + +.urlTableCell { + max-width: 250px; + overflow-wrap: anywhere; +} + +.emptyCustomFlairText { + padding-left: var(--spacing-3); +} diff --git a/src/core/client/admin/routes/Configure/sections/General/FlairBadgeConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/General/FlairBadgeConfigContainer.tsx new file mode 100644 index 0000000000..d0cf0a5240 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/General/FlairBadgeConfigContainer.tsx @@ -0,0 +1,339 @@ +import { Localized } from "@fluent/react/compat"; +import React, { + ChangeEvent, + FunctionComponent, + useCallback, + useState, +} from "react"; +import { Field } from "react-final-form"; +import { graphql } from "react-relay"; + +import { FLAIR_BADGE_NAME_REGEX } from "coral-common/constants"; +import { ExternalLink } from "coral-framework/lib/i18n/components"; +import { useMutation, withFragmentContainer } from "coral-framework/lib/relay"; +import { validateImageURLFunc } from "coral-framework/lib/validation"; +import { AddIcon, BinIcon, ButtonSvgIcon } from "coral-ui/components/icons"; +import { + Button, + CallOut, + FieldSet, + Flex, + FormField, + FormFieldDescription, + HelperText, + HorizontalGutter, + Label, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + ValidationMessage, +} from "coral-ui/components/v2"; + +import { FlairBadgeConfigContainer_settings as SettingsData } from "coral-admin/__generated__/FlairBadgeConfigContainer_settings.graphql"; + +import ConfigBox from "../../ConfigBox"; +import Header from "../../Header"; +import OnOffField from "../../OnOffField"; +import CreateFlairBadgeMutation from "./CreateFlairBadgeMutation"; +import DeleteFlairBadgeMutation from "./DeleteFlairBadgeMutation"; +import { FlairBadgeImagePreview } from "./FlairBadgeImagePreview"; + +import styles from "./FlairBadgeConfigContainer.css"; + +// eslint-disable-next-line no-unused-expressions +graphql` + fragment FlairBadgeConfigContainer_formValues on Settings { + flairBadges { + flairBadgesEnabled + } + } +`; + +interface Props { + disabled: boolean; + settings: SettingsData; +} + +const isValidName = (value: string) => { + const regex = new RegExp(FLAIR_BADGE_NAME_REGEX); + const nameResult = regex.exec(value); + if (nameResult === null || nameResult === undefined) { + return false; + } + + return true; +}; + +const isValidURL = (value: string) => { + const urlValidationResult = validateImageURLFunc(value); + return urlValidationResult; +}; + +const FlairBadgeConfigContainer: FunctionComponent = ({ + disabled, + settings, +}) => { + const addFlairBadge = useMutation(CreateFlairBadgeMutation); + const deleteFlairBadge = useMutation(DeleteFlairBadgeMutation); + + const [flairBadgeNameInput, setFlairBadgeNameInput] = useState(""); + const [flairBadgeURLInput, setFlairBadgeURLInput] = useState(""); + const [submitError, setSubmitError] = useState(null); + const [nameError, setNameError] = useState(false); + const [urlError, setURLError] = useState(false); + + const onSubmit = useCallback(async () => { + try { + setSubmitError(null); + setNameError(false); + setURLError(false); + + let haveValidationError = false; + + // Check the name + if (!isValidName(flairBadgeNameInput)) { + setNameError(true); + haveValidationError = true; + } + + // Check the URL + if (!isValidURL(flairBadgeURLInput)) { + setURLError(true); + haveValidationError = true; + } + + if (haveValidationError) { + return; + } + + await addFlairBadge({ + url: flairBadgeURLInput, + name: flairBadgeNameInput, + }); + + setFlairBadgeNameInput(""); + setFlairBadgeURLInput(""); + } catch (e) { + setSubmitError(e.message); + } + }, [addFlairBadge, flairBadgeURLInput, flairBadgeNameInput]); + + const onDelete = useCallback( + async (name: string) => { + await deleteFlairBadge({ name }); + }, + [deleteFlairBadge] + ); + + const onChangeName = useCallback((e: ChangeEvent) => { + setNameError(false); + setFlairBadgeNameInput(e.target.value); + }, []); + + const onChangeURL = useCallback((e: ChangeEvent) => { + setURLError(false); + setFlairBadgeURLInput(e.target.value); + }, []); + + return ( + +
}>Custom flair badges
+ + } + container={
} + data-testid="custom-flair-badge-configuration" + > + + ), + }} + > + + Encourage user engagement and participation by adding custom flair + badges for your site. Badges can be allocated as part of your{" "} + + JWT claim + + . + + + }> + + + + + + + + + + + + + Name the flair with a descriptive identifier + + + + {nameError && ( + + + Only letters, numbers, and the special characters - . are + permitted. + + + )} + + + + + + + + Paste the web address for your custom flair badge. Supported file + types: png, jpeg, jpg, and gif + + + + {urlError && ( + + + The URL is invalid or has an unsupported file type. + + + )} + + + {({ input }) => ( + + + + + + + {submitError && ( + + {submitError} + + )} + + )} + + + + + + + + Name + + + URL + + + Preview + + + + {settings.flairBadges?.badges && + settings.flairBadges.badges.length > 0 && ( + + {settings.flairBadges.badges.map((badge) => { + return ( + + + {badge.name} + + + {badge.url} + + + + + + , + }} + > + + + + + + + ); + })} + + )} +
+ {(!settings.flairBadges?.badges || + settings.flairBadges.badges.length === 0) && ( + + + No custom flair added for this site + + + )} + + ); +}; + +const enhanced = withFragmentContainer({ + settings: graphql` + fragment FlairBadgeConfigContainer_settings on Settings { + flairBadges { + flairBadgesEnabled + badges { + name + url + } + } + } + `, +})(FlairBadgeConfigContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/General/FlairBadgeImagePreview.css b/src/core/client/admin/routes/Configure/sections/General/FlairBadgeImagePreview.css new file mode 100644 index 0000000000..5eeafb529f --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/General/FlairBadgeImagePreview.css @@ -0,0 +1,5 @@ +.imagePreview { + max-height: 25px; + min-height: 8px; + min-width: 8px; +} diff --git a/src/core/client/admin/routes/Configure/sections/General/FlairBadgeImagePreview.tsx b/src/core/client/admin/routes/Configure/sections/General/FlairBadgeImagePreview.tsx new file mode 100644 index 0000000000..305581dda6 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/General/FlairBadgeImagePreview.tsx @@ -0,0 +1,31 @@ +import React, { FunctionComponent, useCallback, useState } from "react"; + +import { ImageFileWarningIcon, SvgIcon } from "coral-ui/components/icons"; + +import styles from "./FlairBadgeImagePreview.css"; + +interface Props { + url: string; + alt: string; +} + +export const FlairBadgeImagePreview: FunctionComponent = ({ + url, + alt, +}) => { + const [error, setError] = useState(false); + const onError = useCallback(() => { + setError(true); + }, [setError]); + + return error ? ( + + ) : ( + {alt} + ); +}; diff --git a/src/core/client/admin/routes/Configure/sections/General/GeneralConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/General/GeneralConfigContainer.tsx index 1b93abb415..5156a72a91 100644 --- a/src/core/client/admin/routes/Configure/sections/General/GeneralConfigContainer.tsx +++ b/src/core/client/admin/routes/Configure/sections/General/GeneralConfigContainer.tsx @@ -17,6 +17,7 @@ import ClosingCommentStreamsConfig from "./ClosingCommentStreamsConfig"; import CommentEditingConfig from "./CommentEditingConfig"; import CommentLengthConfig from "./CommentLengthConfig"; import FeaturedByConfig from "./FeaturedByConfig"; +import FlairBadgeConfigContainer from "./FlairBadgeConfigContainer"; import FlattenRepliesConfig from "./FlattenRepliesConfig"; import GuidelinesConfig from "./GuidelinesConfig"; import LocaleConfig from "./LocaleConfig"; @@ -58,6 +59,7 @@ const GeneralConfigContainer: React.FunctionComponent = ({ + @@ -79,6 +81,8 @@ const enhanced = withFragmentContainer({ ...FeaturedByConfig_formValues @relay(mask: false) ...ReactionConfig_formValues @relay(mask: false) ...BadgeConfig_formValues @relay(mask: false) + ...FlairBadgeConfigContainer_formValues @relay(mask: false) + ...FlairBadgeConfigContainer_settings ...RTEConfig_formValues @relay(mask: false) ...MediaLinksConfig_formValues @relay(mask: false) ...MemberBioConfig_formValues @relay(mask: false) diff --git a/src/core/client/admin/routes/Moderate/Queue/ApprovedQueueRoute.tsx b/src/core/client/admin/routes/Moderate/Queue/ApprovedQueueRoute.tsx index 9943a48c7a..6a1e492c4e 100644 --- a/src/core/client/admin/routes/Moderate/Queue/ApprovedQueueRoute.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/ApprovedQueueRoute.tsx @@ -130,6 +130,13 @@ const enhanced = withPaginationContainer< edges { node { id + site { + id + } + author { + commentsRejectedOnSites + allCommentsRejected + } ...ModerateCardContainer_comment } } diff --git a/src/core/client/admin/routes/Moderate/Queue/ForReviewQueueRoute/ForReviewQueueRowContainer.tsx b/src/core/client/admin/routes/Moderate/Queue/ForReviewQueueRoute/ForReviewQueueRowContainer.tsx index fc4a94d876..197a8c28cb 100644 --- a/src/core/client/admin/routes/Moderate/Queue/ForReviewQueueRoute/ForReviewQueueRowContainer.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/ForReviewQueueRoute/ForReviewQueueRowContainer.tsx @@ -184,6 +184,10 @@ const enhanced = withFragmentContainer({ body } comment { + author { + allCommentsRejected + commentsRejectedOnSites + } id } revision { diff --git a/src/core/client/admin/routes/Moderate/Queue/Queue.tsx b/src/core/client/admin/routes/Moderate/Queue/Queue.tsx index 86b4c28ca5..0479f3e6b3 100644 --- a/src/core/client/admin/routes/Moderate/Queue/Queue.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/Queue.tsx @@ -25,7 +25,14 @@ import styles from "./Queue.css"; interface Props { comments: Array< - { id: string } & PropTypesOf["comment"] + { + id: string; + site: { id: string }; + author: { + allCommentsRejected: boolean | null; + commentsRejectedOnSites: ReadonlyArray | null; + } | null; + } & PropTypesOf["comment"] >; settings: PropTypesOf["settings"]; viewer: PropTypesOf["viewer"]; @@ -41,7 +48,7 @@ interface Props { const Queue: FunctionComponent = ({ settings, - comments, + comments: unfilteredComments, viewer, hasLoadMore: hasMore, disableLoadMore, @@ -61,8 +68,18 @@ const Queue: FunctionComponent = ({ useState(false); const [conversationCommentID, setConversationCommentID] = useState(""); const [hasModerated, setHasModerated] = useState(false); + const [comments, setComments] = useState([]); const memoize = useMemoizer(); + useEffect(() => { + const filteredComments = unfilteredComments.filter( + (comment) => + !comment.author?.allCommentsRejected && + !comment.author?.commentsRejectedOnSites?.includes(comment.site.id) + ); + setComments(filteredComments); + }, [unfilteredComments]); + // So we can register hotkeys for the first comment without immediately pulling focus useEffect(() => { if (comments.length > 0) { diff --git a/src/core/client/admin/routes/Moderate/Queue/QueueRoute.tsx b/src/core/client/admin/routes/Moderate/Queue/QueueRoute.tsx index 299d6069e7..52f26ed1d4 100644 --- a/src/core/client/admin/routes/Moderate/Queue/QueueRoute.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/QueueRoute.tsx @@ -281,12 +281,26 @@ const createQueueRoute = ( cursor node { id + site { + id + } + author { + commentsRejectedOnSites + allCommentsRejected + } ...ModerateCardContainer_comment } } edges { node { id + site { + id + } + author { + commentsRejectedOnSites + allCommentsRejected + } ...ModerateCardContainer_comment } } diff --git a/src/core/client/admin/routes/Moderate/SingleModerate/SingleModerateRoute.tsx b/src/core/client/admin/routes/Moderate/SingleModerate/SingleModerateRoute.tsx index 2353c7fe5a..8c9b1b1377 100644 --- a/src/core/client/admin/routes/Moderate/SingleModerate/SingleModerateRoute.tsx +++ b/src/core/client/admin/routes/Moderate/SingleModerate/SingleModerateRoute.tsx @@ -59,6 +59,13 @@ const enhanced = withRouteConfig({ query SingleModerateRouteQuery($commentID: ID!) { comment(id: $commentID) { id + site { + id + } + author { + commentsRejectedOnSites + allCommentsRejected + } ...ModerateCardContainer_comment } settings { diff --git a/src/core/client/admin/test/community/banUser.spec.tsx b/src/core/client/admin/test/community/banUser.spec.tsx index cd87d1068e..9afbc18e74 100644 --- a/src/core/client/admin/test/community/banUser.spec.tsx +++ b/src/core/client/admin/test/community/banUser.spec.tsx @@ -314,6 +314,35 @@ it("ban user across specific sites", async () => { expect(resolvers.Mutation!.updateUserBan!.called).toBe(true); }); +it("displays limited options for single site tenants", async () => { + const resolvers = createResolversStub({ + Query: { + settings: () => settings, // base settings has multisite: false + }, + }); + + const { container } = await createTestRenderer({ + resolvers, + }); + + const userRow = within(container).getByRole("row", { + name: "Isabelle isabelle@test.com 07/06/18, 06:24 PM Commenter Active", + }); + userEvent.click( + within(userRow).getByRole("button", { name: "Change user status" }) + ); + + const dropdown = within(userRow).getByLabelText( + "A dropdown to change the user status" + ); + fireEvent.click(within(dropdown).getByRole("button", { name: "Manage Ban" })); + + const modal = screen.getByLabelText("Are you sure you want to ban Isabelle?"); + expect(modal).toBeInTheDocument(); + expect(screen.queryByText("All sites")).not.toBeInTheDocument(); + expect(screen.queryByText("Specific sites")).not.toBeInTheDocument(); +}); + it("site moderators can unban users on their sites but not sites out of their scope", async () => { const user = users.siteBannedCommenter; const resolvers = createResolversStub({ diff --git a/src/core/client/admin/test/configure/general.spec.tsx b/src/core/client/admin/test/configure/general.spec.tsx index 817fa8c576..a8a0dc47d9 100644 --- a/src/core/client/admin/test/configure/general.spec.tsx +++ b/src/core/client/admin/test/configure/general.spec.tsx @@ -635,3 +635,150 @@ it("add announcement", async () => { expect(resolvers.Mutation!.createAnnouncement!.called).toBe(true); }); + +it("enable, add, and delete custom flair badges", async () => { + const resolvers = createResolversStub({ + Mutation: { + createFlairBadge: ({ variables }) => { + expectAndFail(variables.name).toEqual("subscriber"); + expectAndFail(variables.url).toEqual( + "https://www.example.com/image.jpg" + ); + return { + settings: pureMerge(settings, { + flairBadges: { + flairBadgesEnabled: true, + badges: [ + { + name: "subscriber", + url: "https://www.example.com/image.jpg", + }, + ], + }, + }), + }; + }, + deleteFlairBadge: ({ variables }) => { + expectAndFail(variables.name).toEqual("subscriber"); + return { + settings: pureMerge(settings, { + flairBadges: { + flairBadgesEnabled: true, + flairBadgeURLs: [], + }, + }), + }; + }, + }, + }); + await createTestRenderer({ + resolvers, + }); + const customFlairBadgeConfig = screen.getByTestId( + "custom-flair-badge-configuration" + ); + + // check for correct text and link to docs + expect( + within(customFlairBadgeConfig).getByText( + "Encourage user engagement and participation by adding custom flair badges for your site. Badges can be allocated as part of your", + { exact: false } + ) + ); + expect( + within(customFlairBadgeConfig).getByRole("link", { name: "JWT claim" }) + ).toHaveAttribute("href", "https://docs.coralproject.net/sso"); + + // custom flair badges off by default + expect( + within(customFlairBadgeConfig).getByRole("radio", { name: "Off" }) + ).toBeChecked(); + + // enable custom flair badges + const onButton = within(customFlairBadgeConfig).getByRole("radio", { + name: "On", + }); + userEvent.click(onButton); + expect(onButton).toBeChecked(); + + // no custom flair added text displayed + expect( + within(customFlairBadgeConfig).getByText( + "No custom flair added for this site" + ) + ).toBeVisible(); + + // set the flair name to an invalid name + userEvent.type( + within(customFlairBadgeConfig).getByTestId("flairBadgeNameInput"), + "!nval!d(name)" + ); + + // set the url to an invalid url + userEvent.type( + within(customFlairBadgeConfig).getByTestId("flairBadgeURLInput"), + "not a url" + ); + + // try to submit + userEvent.click( + within(customFlairBadgeConfig).getByRole("button", { + name: "Add", + }) + ); + + expect(resolvers.Mutation!.createFlairBadge!.called).toBe(false); + + // Expect to see the validation errors + expect( + within(customFlairBadgeConfig).getByText( + "Only letters, numbers, and the special characters - . are permitted.", + { + exact: false, + } + ) + ).toBeVisible(); + + expect( + within(customFlairBadgeConfig).getByText( + "The URL is invalid or has an unsupported file type.", + { + exact: false, + } + ) + ).toBeVisible(); + + // set the flair name and URL to valid values + const nameField = within(customFlairBadgeConfig).getByTestId( + "flairBadgeNameInput" + ); + userEvent.clear(nameField); + userEvent.type(nameField, "subscriber"); + + const urlField = within(customFlairBadgeConfig).getByTestId( + "flairBadgeURLInput" + ); + userEvent.clear(urlField); + userEvent.type(urlField, "https://www.example.com/image.jpg"); + + // submit the valid values + userEvent.click( + within(customFlairBadgeConfig).getByRole("button", { + name: "Add", + }) + ); + + // image URL should be displayed within table after it's added + expect(resolvers.Mutation!.createFlairBadge!.called).toBe(true); + const imageURL = await within(customFlairBadgeConfig).findByRole("cell", { + name: "https://www.example.com/image.jpg", + }); + expect(imageURL).toBeVisible(); + + // image URL can be deleted and delete mutation is then called + const deleteButton = within(customFlairBadgeConfig).getByRole("button", { + name: "Delete", + }); + userEvent.click(deleteButton); + expect(resolvers.Mutation!.deleteFlairBadge!.called).toBe(true); +}); diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index 95635e3f7a..7102c1f35a 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -225,6 +225,10 @@ export const settings = createFixture({ embeddedComments: { allowReplies: true, }, + flairBadges: { + flairBadgesEnabled: false, + badges: [], + }, }); export const settingsWithMultisite = createFixture( @@ -787,8 +791,40 @@ export const baseComment = createFixture({ site: sites[0], parent: NULL_VALUE, deleted: NULL_VALUE, + replyCount: 0, }); +export const comments = createFixtures( + [ + { + id: "comment-regular-0", + author: users.commenters[0], + body: "Joining Too", + revision: { + id: "comment-0-revision-0", + }, + permalink: "permalink", + }, + { + id: "comment-regular-1", + author: users.commenters[1], + body: "What's up?", + revision: { + id: "comment-1-revision-1", + }, + }, + { + id: "comment-regular-2", + author: users.commenters[2], + body: "Hey!", + revision: { + id: "comment-2-revision-2", + }, + }, + ], + baseComment +); + export const unmoderatedComments = createFixtures( [ { @@ -864,6 +900,17 @@ export const reportedComments = createFixtures( }, permalink: "http://localhost/comment/0", body: "This is the last random sentence I will be writing and I am going to stop mid-sent", + replies: { + edges: [ + { node: comments[0], cursor: comments[0].createdAt }, + { node: comments[1], cursor: comments[1].createdAt }, + { node: comments[2], cursor: comments[2].createdAt }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + replyCount: 2, flags: { edges: [ { @@ -916,6 +963,14 @@ export const reportedComments = createFixtures( ], pageInfo: { endCursor: "2021-06-01T14:21:21.890Z", hasNextPage: true }, }, + parents: { + edges: [], + pageInfo: { + hasPreviousPage: false, + startCursor: null, + }, + }, + parentCount: 0, }, { id: "comment-1", diff --git a/src/core/client/admin/test/moderate/conversationModal.spec.tsx b/src/core/client/admin/test/moderate/conversationModal.spec.tsx new file mode 100644 index 0000000000..aa514f1b0f --- /dev/null +++ b/src/core/client/admin/test/moderate/conversationModal.spec.tsx @@ -0,0 +1,178 @@ +import { act, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { pureMerge } from "coral-common/utils"; +import { + GQLCOMMENT_STATUS, + GQLResolver, + ModerationQueueToCommentsResolver, + MutationToRejectCommentResolver, +} from "coral-framework/schema"; +import { + createMutationResolverStub, + createQueryResolverStub, + createResolversStub, + CreateTestRendererParams, + replaceHistoryLocation, +} from "coral-framework/testHelpers"; + +import { createContext } from "../create"; +import customRenderAppWithContext from "../customRenderAppWithContext"; +import { + comments, + emptyModerationQueues, + reportedComments, + settings, + site, + siteConnection, + users, +} from "../fixtures"; + +const viewer = users.admins[0]; + +beforeEach(async () => { + replaceHistoryLocation("http://localhost/admin/moderate"); +}); + +const rejectCommentStub = + createMutationResolverStub( + ({ variables }) => { + expectAndFail(variables).toMatchObject({ + commentID: comments[0].id, + commentRevisionID: comments[0].revision!.id, + }); + return { + comment: { + ...comments[0], + status: GQLCOMMENT_STATUS.REJECTED, + statusHistory: { + edges: [ + { + node: { + id: "mod-action", + status: GQLCOMMENT_STATUS.REJECTED, + author: { + id: viewer.id, + username: viewer.username, + }, + }, + }, + ], + }, + }, + moderationQueues: emptyModerationQueues, + }; + } + ); + +const reportedCommentsEdges = { + edges: [ + { + node: reportedComments[0], + cursor: reportedComments[0].createdAt, + }, + ], + pageInfo: { + endCursor: reportedComments[0].createdAt, + hasNextPage: false, + }, +}; + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { context } = createContext({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + site: () => site, + viewer: () => viewer, + moderationQueues: () => emptyModerationQueues, + comments: () => { + return reportedCommentsEdges; + }, + sites: () => siteConnection, + comment: () => reportedComments[0], + }, + Mutation: { + rejectComment: rejectCommentStub, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + customRenderAppWithContext(context); + + return { context }; +} + +it("renders view conversation thread and allows comments to be rejected there", async () => { + await act(async () => { + await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + moderationQueues: () => + pureMerge(emptyModerationQueues, { + reported: { + count: 1, + comments: + createQueryResolverStub( + ({ variables }) => { + expectAndFail(variables).toEqual({ + first: 5, + orderBy: "CREATED_AT_DESC", + }); + return reportedCommentsEdges; + } + ) as any, + }, + }), + }, + }), + }); + }); + const firstComment = screen.getByTestId( + `moderate-comment-card-${reportedComments[0].id}` + ); + const viewConversationButton = within(firstComment).getByRole("button", { + name: "conversation-chat-text View Conversation", + }); + await act(async () => { + userEvent.click(viewConversationButton); + }); + const modal = await screen.findByTestId("conversation-modal"); + + const showRepliesButton = screen.getByRole("button", { + name: "Show replies", + }); + userEvent.click(showRepliesButton); + + // find first reply to comment and its reject button + const firstReply = within(modal).getByTestId( + `conversation-modal-comment-${comments[0].id}` + ); + const rejectButtonReportedComment0 = within(firstReply).getByRole("button", { + name: "Reject", + }); + expect(rejectButtonReportedComment0).toHaveTextContent("Reject"); + + // click reject + userEvent.click(rejectButtonReportedComment0); + + // check that comment is rejected and the button text updates as expected + expect(rejectCommentStub.called).toBe(true); + expect(rejectButtonReportedComment0).toHaveTextContent("Rejected"); + + // parent comment should still have button with Reject text + const parentComment = within(modal).getByTestId( + `conversation-modal-comment-${reportedComments[0].id}` + ); + expect(parentComment).toHaveTextContent("Reject"); +}); diff --git a/src/core/client/admin/test/moderate/regularQueue.spec.tsx b/src/core/client/admin/test/moderate/regularQueue.spec.tsx index 918495f47b..97a41d8fb0 100644 --- a/src/core/client/admin/test/moderate/regularQueue.spec.tsx +++ b/src/core/client/admin/test/moderate/regularQueue.spec.tsx @@ -1,5 +1,6 @@ import { act, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { commitLocalUpdate } from "relay-runtime"; import { pureMerge } from "coral-common/utils"; import { @@ -8,6 +9,7 @@ import { GQLResolver, ModerationQueueToCommentsResolver, MutationToApproveCommentResolver, + MutationToBanUserResolver, MutationToRejectCommentResolver, } from "coral-framework/schema"; import { @@ -578,6 +580,12 @@ it("renders reported queue with comments and load more", async () => { expect(screen.queryByRole("button", { name: "Load More" })).toBeNull(); }); + await waitFor(() => { + expect( + screen.queryByTestId(`moderate-comment-card-${reportedComments[2].id}`) + ).toBeInTheDocument(); + }); + // Verify we have one more item now. const comments = screen.getAllByTestId(/^moderate-comment-card-.*$/); expect(comments.length).toBe(previousCount + 1); @@ -804,3 +812,69 @@ it("rejects comment in reported queue", async () => { ); expect(within(reportedCount).getByText("1")).toBeVisible(); }); + +it("doesnt show comments from banned users whose commens have been rejected", async () => { + const { context } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + moderationQueues: () => + pureMerge(emptyModerationQueues, { + reported: { + count: 2, + comments: + createQueryResolverStub( + ({ variables }) => { + expectAndFail(variables).toEqual({ + first: 5, + orderBy: "CREATED_AT_DESC", + }); + return { + edges: [ + { + node: reportedComments[0], + cursor: reportedComments[0].createdAt, + }, + { + node: reportedComments[1], + cursor: reportedComments[1].createdAt, + }, + ], + pageInfo: { + endCursor: reportedComments[1].createdAt, + hasNextPage: false, + }, + }; + } + ) as any, + }, + }), + }, + Mutation: { + banUser: createMutationResolverStub( + ({ variables }) => { + return {}; + } + ), + }, + }), + }); + + const modCard = await screen.findByTestId( + `moderate-comment-card-${reportedComments[0].id}` + ); + + expect(modCard).toBeInTheDocument(); + + act(() => { + commitLocalUpdate(context.relayEnvironment, (store) => { + const user = store.get(reportedComments[0].author!.id); + return user?.setValue(true, "allCommentsRejected"); + }); + }); + + await waitFor(() => { + expect( + screen.queryByTestId(`moderate-comment-card-${reportedComments[0].id}`) + ).toBeNull(); + }); +}); diff --git a/src/core/client/admin/test/moderate/singleComment.spec.tsx b/src/core/client/admin/test/moderate/singleComment.spec.tsx index 967ae49b18..dc3c0e524c 100644 --- a/src/core/client/admin/test/moderate/singleComment.spec.tsx +++ b/src/core/client/admin/test/moderate/singleComment.spec.tsx @@ -1,4 +1,4 @@ -import { screen, within } from "@testing-library/react"; +import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { pureMerge } from "coral-common/utils"; @@ -82,6 +82,13 @@ it("renders single comment view with story info", async () => { const singleModerateContainer = await screen.findByTestId( "single-moderate-container" ); + + await waitFor(() => { + expect( + screen.getByTestId(`moderate-comment-card-${reportedComments[0].id}`) + ).toBeInTheDocument(); + }); + expect( within(singleModerateContainer).queryByText("Comment On") ).toBeInTheDocument(); diff --git a/src/core/client/count/index.ts b/src/core/client/count/index.ts index c694728ae6..33d05faa65 100644 --- a/src/core/client/count/index.ts +++ b/src/core/client/count/index.ts @@ -10,12 +10,11 @@ import injectJSONPCallback from "./injectJSONPCallback"; interface CountQueryArgs { id?: string; url?: string; - notext: boolean; } /** createCountQueryRef creates a unique reference from the query args */ function createCountQueryRef(args: CountQueryArgs) { - return btoa(`${args.notext ? "true" : "false"};${args.id || args.url}`); + return btoa(`${args.url}`); } interface DetectAndInjectArgs { @@ -34,18 +33,17 @@ function detectAndInject(opts: DetectAndInjectArgs = {}) { const elements = document.querySelectorAll(COUNT_SELECTOR); Array.prototype.forEach.call(elements, (element: HTMLElement) => { const id = element.dataset.coralId; - const notext = element.dataset.coralNotext === "true"; // If there is no URL or ID on the element, add one based on the story url // that we detected. let url = element.dataset.coralUrl; - if (!url && !id) { + if (!url) { url = STORY_URL; element.dataset.coralUrl = STORY_URL; } // Construct the args for generating the ref. - const args = { id, url, notext }; + const args = { id, url }; // Get or create a ref. let ref = element.dataset.coralRef; @@ -68,13 +66,12 @@ function detectAndInject(opts: DetectAndInjectArgs = {}) { // Call server using JSONP. Object.keys(queryMap).forEach((ref) => { - const { url, id, notext } = queryMap[ref]; + const { url, id } = queryMap[ref]; // Compile the arguments used to generate the const args: Record = { id, url, - notext: notext ? "true" : "false", ref, }; diff --git a/src/core/client/count/injectJSONPCallback.ts b/src/core/client/count/injectJSONPCallback.ts index 452409aaa1..7e30ddd0bc 100644 --- a/src/core/client/count/injectJSONPCallback.ts +++ b/src/core/client/count/injectJSONPCallback.ts @@ -2,8 +2,6 @@ import { CountJSONPData } from "coral-common/types/count"; import { COUNT_SELECTOR } from "coral-framework/constants"; import getPreviousCountStorageKey from "coral-framework/helpers/getPreviousCountStorageKey"; -const TEXT_CLASS_NAME = "coral-count-text"; - type GetCountFunction = (opts?: { reset?: boolean }) => void; /** @@ -44,57 +42,68 @@ interface CountElementDataset { } function createCountElementEnhancer({ - html, + countHtml, + textHtml, count: currentCount, id: storyID, }: CountJSONPData) { - // Get the dataset together for setting properties on the enhancer. - const dataset: CountElementDataset = { - coralCount: currentCount.toString(), - }; - - // Create the root element we're using for this. - const element = document.createElement("span"); - - const showText = html.includes(TEXT_CLASS_NAME); - - // Update the innerHTML which contains the count and new value.. - element.innerHTML = html; + return (target: HTMLElement) => { + // Get the dataset together for setting properties on the enhancer. + const dataset: CountElementDataset = { + coralCount: currentCount.toString(), + }; - if (storyID) { - const previousCount = getPreviousCount(storyID) ?? 0; + // Create the root element we're using for this. + const element = document.createElement("span"); - // The new count is the current count subtracted from the previous - // count if the counts are different, otherwise, zero. - const newCount = - previousCount < currentCount ? currentCount - previousCount : 0; + const showText = !(target.dataset.coralNotext === "true"); - // Add the counts to the dataset so it can be targeted by CSS if you want. - dataset.coralPreviousCount = previousCount.toString(); - dataset.coralNewCount = newCount.toString(); + // replace the placeholder COMMENT_COUNT with current count + const updatedCountHtml = countHtml.replace( + "COMMENT_COUNT", + currentCount.toString() + ); + // Update the innerHTML which contains the count and new value.. if (showText) { - // Insert the divider " / " - const dividerElement = document.createElement("span"); - dividerElement.className = "coral-new-count-divider"; - dividerElement.innerText = " / "; - element.appendChild(dividerElement); - - // Add the number of new comments to that. - const newCountNumber = document.createElement("span"); - newCountNumber.className = "coral-new-count-number"; - newCountNumber.innerText = newCount.toString(); - element.appendChild(newCountNumber); - - // Add the number of new comments to that. - const newCountText = document.createElement("span"); - newCountText.className = "coral-new-count-text"; - newCountText.innerText = " New"; - element.appendChild(newCountText); + element.innerHTML = `${updatedCountHtml} ${textHtml}`; + } else { + element.innerHTML = updatedCountHtml; + } + + if (storyID) { + const previousCount = getPreviousCount(storyID) ?? 0; + + // The new count is the current count subtracted from the previous + // count if the counts are different, otherwise, zero. + const newCount = + previousCount < currentCount ? currentCount - previousCount : 0; + + // Add the counts to the dataset so it can be targeted by CSS if you want. + dataset.coralPreviousCount = previousCount.toString(); + dataset.coralNewCount = newCount.toString(); + + if (showText) { + // Insert the divider " / " + const dividerElement = document.createElement("span"); + dividerElement.className = "coral-new-count-divider"; + dividerElement.innerText = " / "; + element.appendChild(dividerElement); + + // Add the number of new comments to that. + const newCountNumber = document.createElement("span"); + newCountNumber.className = "coral-new-count-number"; + newCountNumber.innerText = newCount.toString(); + element.appendChild(newCountNumber); + + // Add the number of new comments to that. + const newCountText = document.createElement("span"); + newCountText.className = "coral-new-count-text"; + newCountText.innerText = " New"; + element.appendChild(newCountText); + } } - } - return (target: HTMLElement) => { // Add the innerHTML from the element to the target element. This will // include any optional children that were appended related to new comment // counts. diff --git a/src/core/client/embed/index.html b/src/core/client/embed/index.html index 215fadbfe9..3e1dc16cc3 100644 --- a/src/core/client/embed/index.html +++ b/src/core/client/embed/index.html @@ -14,12 +14,15 @@ -

+

Admin | Story | Story With Button | AMP

Coral – Embed Stream

+
+ +