diff --git a/src/core/client/admin/routes/moderate/components/FlagDetailsCategory.css b/src/core/client/admin/routes/moderate/components/FlagDetailsCategory.css new file mode 100644 index 000000000..3ce9c004c --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/FlagDetailsCategory.css @@ -0,0 +1,3 @@ +.category { + color: var(--palette-error-darkest); +} diff --git a/src/core/client/admin/routes/moderate/components/FlagDetailsCategory.tsx b/src/core/client/admin/routes/moderate/components/FlagDetailsCategory.tsx new file mode 100644 index 000000000..7dfb4570d --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/FlagDetailsCategory.tsx @@ -0,0 +1,26 @@ +import React, { FunctionComponent } from "react"; + +import { HorizontalGutter, Typography } from "talk-ui/components"; + +import styles from "./FlagDetailsCategory.css"; + +interface Props { + category: React.ReactNode; + children?: React.ReactNode; +} + +const FlagDetailsCategory: FunctionComponent = ({ + category, + children, +}) => { + return ( + + + {category} + + {children} + + ); +}; + +export default FlagDetailsCategory; diff --git a/src/core/client/admin/routes/moderate/components/FlagDetailsEntry.css b/src/core/client/admin/routes/moderate/components/FlagDetailsEntry.css new file mode 100644 index 000000000..0e4ec663a --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/FlagDetailsEntry.css @@ -0,0 +1,18 @@ +.user { + font-size: calc(14rem / var(--rem-base)); + font-weight: var(--font-weight-medium); + font-family: var(--font-family-serif); + line-height: calc(16em / 14); + letter-spacing: calc(0.2em / 14); + color: var(--palette-text-primary); + padding-right: calc(0.5 * var(--mini-unit)); +} +.details { + font-size: calc(14rem / var(--rem-base)); + font-weight: var(--font-weight-regular); + font-family: var(--font-family-sans-serif); + line-height: calc(16em / 14); + letter-spacing: calc(0.2em / 14); + color: var(--palette-text-primary); + font-style: italic; +} diff --git a/src/core/client/admin/routes/moderate/components/FlagDetailsEntry.tsx b/src/core/client/admin/routes/moderate/components/FlagDetailsEntry.tsx new file mode 100644 index 000000000..d2e114151 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/FlagDetailsEntry.tsx @@ -0,0 +1,22 @@ +import React, { FunctionComponent } from "react"; + +import styles from "./FlagDetailsEntry.css"; + +interface Props { + user: React.ReactNode; + details?: React.ReactNode; +} + +const FlagDetailsEntry: FunctionComponent = ({ user, details }) => { + return ( +
+ + {user} + {details && ":"} + + {details && {details}} +
+ ); +}; + +export default FlagDetailsEntry; diff --git a/src/core/client/admin/routes/moderate/components/Markers.css b/src/core/client/admin/routes/moderate/components/Markers.css new file mode 100644 index 000000000..6d2f74e66 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Markers.css @@ -0,0 +1,16 @@ +.detailsButton { + height: 100%; + padding: 2px 0px; + margin-left: var(--mini-unit); + font-size: calc(12rem / var(--rem-base)); + letter-spacing: calc(-0.1em / 12); + align-self: flex-end; + text-transform: uppercase; + &.detailsButtonColorRegular { + color: var(--palette-text-primary); + } +} + +.detailsText { + margin: 0; +} diff --git a/src/core/client/admin/routes/moderate/components/Markers.tsx b/src/core/client/admin/routes/moderate/components/Markers.tsx new file mode 100644 index 000000000..f6ebf33b1 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Markers.tsx @@ -0,0 +1,47 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent, useCallback, useState } from "react"; + +import { useUUID } from "talk-framework/hooks"; +import { Button, Flex, HorizontalGutter, Icon } from "talk-ui/components"; + +import styles from "./Markers.css"; + +interface Props { + children: React.ReactNode; + details: React.ReactElement | null; +} + +const Markers: FunctionComponent = ({ children, details }) => { + const uuid = useUUID(); + const [showDetails, setShowDetails] = useState(false); + const toggleDetails = useCallback(() => setShowDetails(!showDetails), [ + showDetails, + ]); + return ( + + + {children} + {details && ( + + )} + + {showDetails &&
{details}
} +
+ ); +}; + +export default Markers; diff --git a/src/core/client/admin/routes/moderate/containers/FlagDetailsContainer.tsx b/src/core/client/admin/routes/moderate/containers/FlagDetailsContainer.tsx new file mode 100644 index 000000000..217feb7ba --- /dev/null +++ b/src/core/client/admin/routes/moderate/containers/FlagDetailsContainer.tsx @@ -0,0 +1,93 @@ +import { Localized } from "fluent-react/compat"; +import React from "react"; +import { graphql } from "react-relay"; + +import { FlagDetailsContainer_comment as CommentData } from "talk-admin/__generated__/FlagDetailsContainer_comment.graphql"; +import NotAvailable from "talk-admin/components/NotAvailable"; +import { withFragmentContainer } from "talk-framework/lib/relay"; +import { GQLCOMMENT_FLAG_REASON } from "talk-framework/schema"; +import { HorizontalGutter } from "talk-ui/components"; + +import FlagDetailsCategory from "../components/FlagDetailsCategory"; +import FlagDetailsEntry from "../components/FlagDetailsEntry"; + +interface Props { + comment: CommentData; +} + +export class FlagDetailsContainer extends React.Component { + public render() { + const nodes = this.props.comment.flags.nodes; + const offensiveList: React.ReactElement[] = []; + const spamList: React.ReactElement[] = []; + nodes.forEach(n => { + switch (n.reason) { + case GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_OFFENSIVE: + offensiveList.push( + } + details={n.additionalDetails} + /> + ); + return; + case GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_SPAM: + spamList.push( + } + details={n.additionalDetails} + /> + ); + return; + default: + return; + } + }); + if (offensiveList.length + spamList.length === 0) { + return null; + } + return ( + + {offensiveList.length > 0 && ( + + Offensive + + } + > + {offensiveList} + + )} + {spamList.length > 0 && ( + + Spam + + } + > + {spamList} + + )} + + ); + } +} + +const enhanced = withFragmentContainer({ + comment: graphql` + fragment FlagDetailsContainer_comment on Comment { + flags { + nodes { + flagger { + username + } + reason + additionalDetails + } + } + } + `, +})(FlagDetailsContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/moderate/containers/MarkersContainer.tsx b/src/core/client/admin/routes/moderate/containers/MarkersContainer.tsx index 6e337bb40..715f87526 100644 --- a/src/core/client/admin/routes/moderate/containers/MarkersContainer.tsx +++ b/src/core/client/admin/routes/moderate/containers/MarkersContainer.tsx @@ -4,12 +4,22 @@ import { graphql } from "react-relay"; import { MarkersContainer_comment as CommentData } from "talk-admin/__generated__/MarkersContainer_comment.graphql"; import { withFragmentContainer } from "talk-framework/lib/relay"; -import { Flex, Marker, MarkerCount } from "talk-ui/components"; +import { Marker, MarkerCount } from "talk-ui/components"; +import Markers from "../components/Markers"; +import FlagDetailsContainer from "./FlagDetailsContainer"; interface MarkersContainerProps { comment: CommentData; } +function hasDetails(c: CommentData) { + return ( + c.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE + + c.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM > + 0 + ); +} + let keyCounter = 0; const markers: Array<(c: CommentData) => React.ReactElement | null> = [ c => @@ -93,7 +103,17 @@ export class MarkersContainer extends React.Component { public render() { const elements = markers.map(cb => cb(this.props.comment)).filter(m => m); if (elements.length) { - return {elements}; + return ( + + ) : null + } + > + {elements} + + ); } return null; } @@ -102,6 +122,7 @@ export class MarkersContainer extends React.Component { const enhanced = withFragmentContainer({ comment: graphql` fragment MarkersContainer_comment on Comment { + ...FlagDetailsContainer_comment status actionCounts { flag { diff --git a/src/core/client/admin/routes/moderate/containers/__snapshots__/MarkersContainer.spec.tsx.snap b/src/core/client/admin/routes/moderate/containers/__snapshots__/MarkersContainer.spec.tsx.snap index 3ed66f6a8..8e37b9289 100644 --- a/src/core/client/admin/routes/moderate/containers/__snapshots__/MarkersContainer.spec.tsx.snap +++ b/src/core/client/admin/routes/moderate/containers/__snapshots__/MarkersContainer.spec.tsx.snap @@ -1,8 +1,30 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders all markers 1`] = ` - + } > - + `; exports[`renders some markers 1`] = ` - + } > - + `; diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index 05daf8235..b74b00b70 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -1,5 +1,6 @@ import { GQLComment, + GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, GQLCommentModerationAction, GQLCommentsConnection, @@ -424,6 +425,9 @@ export const baseComment = createFixture({ }, }, }, + flags: { + nodes: [], + }, story: stories[0], }); @@ -445,6 +449,20 @@ export const reportedComments = createFixtures( }, }, }, + flags: { + nodes: [ + { + reason: GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_SPAM, + flagger: users.commenters[0], + additionalDetails: "This looks like an ad", + }, + { + reason: GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_SPAM, + flagger: users.commenters[1], + additionalDetails: "", + }, + ], + }, }, { id: "comment-1", @@ -461,6 +479,25 @@ export const reportedComments = createFixtures( }, }, }, + flags: { + nodes: [ + { + reason: GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_OFFENSIVE, + flagger: users.commenters[0], + additionalDetails: "I find this offensive", + }, + { + reason: GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_OFFENSIVE, + flagger: users.commenters[1], + additionalDetails: "Not like that", + }, + { + reason: GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_OFFENSIVE, + flagger: users.commenters[2], + additionalDetails: "", + }, + ], + }, }, { id: "comment-2", @@ -479,6 +516,20 @@ export const reportedComments = createFixtures( }, }, }, + flags: { + nodes: [ + { + reason: GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_OFFENSIVE, + flagger: users.commenters[0], + additionalDetails: "I find this offensive", + }, + { + reason: GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_SPAM, + flagger: users.commenters[2], + additionalDetails: "", + }, + ], + }, }, ], baseComment diff --git a/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap b/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap index 98dfb1e5e..ea281a6c3 100644 --- a/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap +++ b/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap @@ -94,21 +94,53 @@ exports[`rejected queue accepts comment in rejected queue: dangling 1`] = `
- - - Spam - - - - 2 - - + + + Spam + + + + 2 + + +
+ + @@ -262,21 +294,53 @@ exports[`rejected queue renders rejected queue with comments 1`] = `
- - - Spam - - - - 2 - - + + + Spam + + + + 2 + + +
+ + @@ -417,21 +481,53 @@ exports[`rejected queue renders rejected queue with comments 1`] = `
- - - Offensive - - - - 3 - - + + + Offensive + + + + 3 + + +
+ + @@ -578,34 +674,66 @@ exports[`rejected queue renders rejected queue with comments and load more 1`] =
- - - Offensive - - - - 1 - - - - - Spam - - - + + Offensive + + + + 1 + + + + + Spam + + + + 1 + + +
+ + @@ -762,21 +890,53 @@ exports[`reported queue accepts comment in reported queue: dangling 1`] = `
- - - Spam - - - - 2 - - + + + Spam + + + + 2 + + +
+ + @@ -933,21 +1093,53 @@ exports[`reported queue rejects comment in reported queue: dangling 1`] = `
- - - Spam - - - - 2 - - + + + Spam + + + + 2 + + +
+ + @@ -1122,21 +1314,53 @@ exports[`reported queue renders reported queue with comments 1`] = `
- - - Spam - - - - 2 - - + + + Spam + + + + 2 + + +
+ + @@ -1277,21 +1501,53 @@ exports[`reported queue renders reported queue with comments 1`] = `
- - - Offensive - - - - 3 - - + + + Offensive + + + + 3 + + +
+ + @@ -1448,21 +1704,53 @@ exports[`reported queue renders reported queue with comments 2`] = `
- - - Spam - - - - 2 - - + + + Spam + + + + 2 + + +
+ + @@ -1603,21 +1891,53 @@ exports[`reported queue renders reported queue with comments 2`] = `
- - - Offensive - - - - 3 - - + + + Offensive + + + + 3 + + +
+ + @@ -1764,39 +2084,71 @@ exports[`reported queue renders reported queue with comments and load more 1`] =
- - Pre-Mod - - - - Offensive - - - - 1 - - - - - Spam - - - + Pre-Mod + + + + Offensive + + + + 1 + + + + + Spam + + + + 1 + + +
+ + @@ -2172,21 +2524,53 @@ exports[`single comment view accepts single comment 1`] = `
- - - Spam - - - - 2 - - + + + Spam + + + + 2 + + +
+ + @@ -2308,21 +2692,53 @@ exports[`single comment view rejects single comment 1`] = `
- - - Spam - - - - 2 - - + + + Spam + + + + 2 + + +
+ + @@ -2479,21 +2895,53 @@ exports[`single comment view renders single comment view 1`] = `
- - - Spam - - - - 2 - - + + + Spam + + + + 2 + + +
+ + diff --git a/src/core/client/admin/test/moderate/moderate.spec.tsx b/src/core/client/admin/test/moderate/moderate.spec.tsx index 0963f6fbf..70d5dcb33 100644 --- a/src/core/client/admin/test/moderate/moderate.spec.tsx +++ b/src/core/client/admin/test/moderate/moderate.spec.tsx @@ -400,7 +400,7 @@ describe("reported queue", () => { hasNextPage: false, }, }; - }) as any, + }), }, }), }, @@ -410,6 +410,52 @@ describe("reported queue", () => { await waitForElement(() => getByTestID("moderate-container")); expect(toJSON(getByTestID("moderate-main-container"))).toMatchSnapshot(); }); + it("show details of comment with flags", async () => { + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + moderationQueues: () => + pureMerge(emptyModerationQueues, { + reported: { + count: 1, + comments: createQueryResolverStub< + ModerationQueueToCommentsResolver + >(({ variables }) => { + expectAndFail(variables).toEqual({ first: 5 }); + return { + edges: [ + { + node: reportedComments[0], + cursor: reportedComments[0].createdAt, + }, + ], + pageInfo: { + endCursor: reportedComments[0].createdAt, + hasNextPage: false, + }, + }; + }), + }, + }), + }, + }), + }); + const { getByTestID } = within(testRenderer.root); + const reported = await waitForElement(() => + getByTestID(`moderate-comment-${reportedComments[0].id}`) + ); + expect( + within(reported).queryByText( + reportedComments[0].flags.nodes[0].additionalDetails! + ) + ).toBeNull(); + within(reported) + .getByText("Details", { selector: "button" }) + .props.onClick(); + within(reported).getByText( + reportedComments[0].flags.nodes[0].additionalDetails! + ); + }); it("shows a moderate story", async () => { const { testRenderer, diff --git a/src/core/client/framework/hooks/index.ts b/src/core/client/framework/hooks/index.ts index d99853f1d..4c6c57a6e 100644 --- a/src/core/client/framework/hooks/index.ts +++ b/src/core/client/framework/hooks/index.ts @@ -1,3 +1,4 @@ export { default as useEffectAfterMount } from "./useEffectAfterMount"; export { default as usePrevious } from "./usePrevious"; export { default as useEffectWhenChanged } from "./useEffectWhenChanged"; +export { default as useUUID } from "./useUUID"; diff --git a/src/core/client/framework/hooks/useUUID.ts b/src/core/client/framework/hooks/useUUID.ts new file mode 100644 index 000000000..2cf7dfb40 --- /dev/null +++ b/src/core/client/framework/hooks/useUUID.ts @@ -0,0 +1,11 @@ +import { useMemo } from "react"; + +import { useTalkContext } from "talk-framework/lib/bootstrap"; + +/** + * useUUID returns a unique identifier. + */ +export default function useUUID(): string { + const { uuidGenerator } = useTalkContext(); + return useMemo(() => uuidGenerator(), []); +} diff --git a/src/core/client/ui/shared/typography.css b/src/core/client/ui/shared/typography.css index d46179db1..79a4c0a37 100644 --- a/src/core/client/ui/shared/typography.css +++ b/src/core/client/ui/shared/typography.css @@ -9,6 +9,17 @@ url("typeface-source-sans-pro/files/source-sans-pro-latin-300.woff") format("woff"); } +@font-face { + font-family: "Source Sans Pro"; + font-style: italic; + font-display: block; + font-weight: 300; + src: local("Source Sans Pro Light "), local("Source Sans Pro-Light"), + url("typeface-source-sans-pro/files/source-sans-pro-latin-300italic.woff2") + format("woff2"), + url("typeface-source-sans-pro/files/source-sans-pro-latin-300italic.woff") + format("woff"); +} @font-face { font-family: "Source Sans Pro"; @@ -22,6 +33,17 @@ url("typeface-source-sans-pro/files/source-sans-pro-latin-400.woff") format("woff"); } +@font-face { + font-family: "Source Sans Pro"; + font-style: italic; + font-display: block; + font-weight: 400; + src: local("Source Sans Pro Light "), local("Source Sans Pro-Light"), + url("typeface-source-sans-pro/files/source-sans-pro-latin-400italic.woff2") + format("woff2"), + url("typeface-source-sans-pro/files/source-sans-pro-latin-400italic.woff") + format("woff"); +} @font-face { font-family: "Source Sans Pro"; diff --git a/src/core/server/graph/tenant/loaders/CommentActions.ts b/src/core/server/graph/tenant/loaders/CommentActions.ts new file mode 100644 index 000000000..0bf5f4612 --- /dev/null +++ b/src/core/server/graph/tenant/loaders/CommentActions.ts @@ -0,0 +1,14 @@ +import Context from "talk-server/graph/tenant/context"; +import { + CommentActionConnectionInput, + retrieveCommentActionConnection, +} from "talk-server/models/action/comment"; + +export default (ctx: Context) => ({ + connection: ({ first = 10, after, filter }: CommentActionConnectionInput) => + retrieveCommentActionConnection(ctx.mongo, ctx.tenant.id, { + first, + after, + filter, + }), +}); diff --git a/src/core/server/graph/tenant/loaders/index.ts b/src/core/server/graph/tenant/loaders/index.ts index 97450305d..ff3f2c1e8 100644 --- a/src/core/server/graph/tenant/loaders/index.ts +++ b/src/core/server/graph/tenant/loaders/index.ts @@ -1,6 +1,7 @@ import Context from "talk-server/graph/tenant/context"; import Auth from "./Auth"; +import CommentActions from "./CommentActions"; import CommentModerationActions from "./CommentModerationActions"; import Comments from "./Comments"; import Stories from "./Stories"; @@ -8,6 +9,7 @@ import Users from "./Users"; export default (ctx: Context) => ({ Auth: Auth(ctx), + CommentActions: CommentActions(ctx), CommentModerationActions: CommentModerationActions(ctx), Stories: Stories(ctx), Comments: Comments(ctx), diff --git a/src/core/server/graph/tenant/resolvers/Comment.ts b/src/core/server/graph/tenant/resolvers/Comment.ts index 6d2384274..b4fbdefc1 100644 --- a/src/core/server/graph/tenant/resolvers/Comment.ts +++ b/src/core/server/graph/tenant/resolvers/Comment.ts @@ -5,7 +5,10 @@ import { GQLComment, GQLCommentTypeResolver, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { decodeActionCounts } from "talk-server/models/action/comment"; +import { + ACTION_TYPE, + decodeActionCounts, +} from "talk-server/models/action/comment"; import * as comment from "talk-server/models/comment"; import { getLatestRevision } from "talk-server/models/comment"; import { createConnection } from "talk-server/models/helpers/connection"; @@ -64,6 +67,15 @@ export const Comment: GQLCommentTypeResolver = { : createConnection(), // Action Counts are encoded, decode them for use with the GraphQL system. actionCounts: c => decodeActionCounts(c.actionCounts), + flags: ({ id }, { first = 10, after }, ctx) => + ctx.loaders.CommentActions.connection({ + first, + after, + filter: { + actionType: ACTION_TYPE.FLAG, + commentID: id, + }, + }), viewerActionPresence: (c, input, ctx) => ctx.user ? ctx.loaders.Comments.retrieveMyActionPresence.load(c.id) : null, parentCount: c => (c.parentID ? c.grandparentIDs.length + 1 : 0), diff --git a/src/core/server/graph/tenant/resolvers/Flag.ts b/src/core/server/graph/tenant/resolvers/Flag.ts new file mode 100644 index 000000000..531b04460 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/Flag.ts @@ -0,0 +1,12 @@ +import { GQLFlagTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import * as actions from "talk-server/models/action/comment"; + +export const Flag: GQLFlagTypeResolver = { + flagger: ({ userID }, args, ctx) => { + if (userID) { + return ctx.loaders.Users.user.load(userID); + } + + return null; + }, +}; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index 7eb7c606c..4835bfdfc 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -13,6 +13,7 @@ import { CommentModerationAction } from "./CommentModerationAction"; import { CommentRevision } from "./CommentRevision"; import { DisableCommenting } from "./DisableCommenting"; import { FacebookAuthIntegration } from "./FacebookAuthIntegration"; +import { Flag } from "./Flag"; import { GoogleAuthIntegration } from "./GoogleAuthIntegration"; import { ModerationQueue } from "./ModerationQueue"; import { ModerationQueues } from "./ModerationQueues"; @@ -42,6 +43,7 @@ const Resolvers: GQLResolver = { Cursor, DisableCommenting, FacebookAuthIntegration, + Flag, GoogleAuthIntegration, ModerationQueue, ModerationQueues, diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index ee8dbdfaa..1a8c4f03b 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -182,6 +182,54 @@ type FlagReasonActionCounts { COMMENT_DETECTED_SUSPECT_WORD: Int! } +type Flag { + """ + flagger is the User that created the Flag. If this is null, then the system + created the Flag. + """ + flagger: User + + """ + reason is the selected reason why the Flag is being created. + """ + reason: COMMENT_FLAG_REASON! + + """ + additionalDetails stores information from the User as to why the Flag was + created or is relevant. + """ + additionalDetails: String +} + +type FlagEdge { + """ + node is the Flag for this edge. + """ + node: Flag! + + """ + cursor is used in pagination. + """ + cursor: Cursor! +} + +type FlagsConnection { + """ + edges are a subset of FlagEdge's. + """ + edges: [FlagEdge!]! + + """ + nodes is a list of Flags. + """ + nodes: [Flag!]! + + """ + pageInfo is information to aid in pagination. + """ + pageInfo: PageInfo! +} + """ FlagActionCounts stores all the counts for the counts for the flag action on a given item and the reason counts. @@ -1690,6 +1738,12 @@ type Comment { """ actionCounts: ActionCounts! + """ + flags is the actual Flags that were left by the Users or the system. + """ + flags(first: Int = 10, after: Cursor): FlagsConnection! + @auth(roles: [ADMIN, MODERATOR]) + """ viewerActionPresence stores the presence information for all the actions left by the current User on this Comment. diff --git a/src/core/server/models/action/comment.ts b/src/core/server/models/action/comment.ts index a074b849b..e0c06c8fe 100644 --- a/src/core/server/models/action/comment.ts +++ b/src/core/server/models/action/comment.ts @@ -5,14 +5,24 @@ import uuid from "uuid"; import { Omit, Sub } from "talk-common/types"; import { - GQLActionCounts, GQLActionPresence, GQLCOMMENT_FLAG_DETECTED_REASON, GQLCOMMENT_FLAG_REASON, GQLCOMMENT_FLAG_REPORTED_REASON, + GQLDontAgreeActionCounts, + GQLFlagActionCounts, + GQLReactionActionCounts, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { createIndexFactory } from "talk-server/models/helpers/indexing"; -import { FilterQuery } from "talk-server/models/helpers/query"; +import { + Connection, + ConnectionInput, + resolveConnection, +} from "talk-server/models/helpers/connection"; +import { + createConnectionOrderVariants, + createIndexFactory, +} from "talk-server/models/helpers/indexing"; +import Query, { FilterQuery } from "talk-server/models/helpers/query"; import { TenantResource } from "talk-server/models/tenant"; function collection(mongo: Db) { @@ -40,6 +50,27 @@ export enum ACTION_TYPE { export type EncodedCommentActionCounts = Record; +export type FlagActionCounts = GQLFlagActionCounts; + +export interface ActionCounts { + /** + * reaction returns the counts for the reaction action on an item. + */ + reaction: GQLReactionActionCounts; + + /** + * dontAgree returns the counts for the dontAgree action on an item. This edge is + * restricted to administrators and moderators. + */ + dontAgree: GQLDontAgreeActionCounts; + + /** + * flag returns the counts for the flag action on an item. This edge is + * restricted to administrators and moderators. + */ + flag: FlagActionCounts; +} + /** * FLAG_REASON is the reason that a given Flag has been created. */ @@ -115,6 +146,19 @@ export async function createCommentActionIndexes(mongo: Db) { { tenantID: 1, actionType: 1, commentID: 1, userID: 1 }, { background: true } ); + + const variants = createConnectionOrderVariants>( + [{ createdAt: -1 }], + { background: true } + ); + + // Connection pagination. + // { ...connectionParams } + await variants(createIndex, { + tenantID: 1, + actionType: 1, + commentID: 1, + }); } const ActionSchema = [ @@ -254,6 +298,38 @@ export async function createActions( ); } +export type CommentActionConnectionInput = ConnectionInput; + +export async function retrieveCommentActionConnection( + mongo: Db, + tenantID: string, + input: CommentActionConnectionInput +): Promise>>> { + // Create the query. + const query = new Query(collection(mongo)).where({ tenantID }); + + // If a filter is being applied, filter it as well. + if (input.filter) { + query.where(input.filter); + } + + return retrieveConnection(input, query); +} + +async function retrieveConnection( + input: CommentActionConnectionInput, + query: Query +): Promise>>> { + // Apply the pagination arguments to the query. + query.orderBy({ createdAt: -1 }); + if (input.after) { + query.where({ createdAt: { $lt: input.after as Date } }); + } + + // Return a connection. + return resolveConnection(query, input, action => action.createdAt); +} + export async function retrieveUserAction( mongo: Db, tenantID: string, @@ -502,7 +578,7 @@ function decodeActionCountKey(key: string): DecodedActionCountKey { /** * createEmptyActionCounts creates a default/empty set of action counts. */ -function createEmptyActionCounts(): GQLActionCounts { +function createEmptyActionCounts(): ActionCounts { return { reaction: { total: 0, @@ -557,9 +633,9 @@ export function countTotalActionCounts( */ export function decodeActionCounts( encodedActionCounts: EncodedCommentActionCounts -): GQLActionCounts { +): ActionCounts { // Default all the action counts to zero. - const actionCounts: GQLActionCounts = createEmptyActionCounts(); + const actionCounts: ActionCounts = createEmptyActionCounts(); // Loop over all the encoded action counts to extract each of the action // counts as they are encoded. @@ -575,7 +651,7 @@ export function decodeActionCounts( } function incrementActionCounts( - actionCounts: GQLActionCounts, + actionCounts: ActionCounts, actionType: ACTION_TYPE, reason: GQLCOMMENT_FLAG_REASON | undefined, count: number = 1 diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 9c09dc43f..e15dfa7af 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -317,6 +317,10 @@ moderate-marker-karma = Karma moderate-marker-bodyCount = Body Count moderate-marker-offensive = Offensive +moderate-markers-details = Details +moderate-flagDetails-offensive = Offensive +moderate-flagDetails-spam = Spam + moderate-emptyQueue-pending = Nicely done! There are no more pending comments to moderate. moderate-emptyQueue-reported = Nicely done! There are no more reported comments to moderate. moderate-emptyQueue-unmoderated = Nicely done! All comments have been moderated.