diff --git a/package-lock.json b/package-lock.json index e63cb085e..c08064188 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1647,9 +1647,9 @@ } }, "@types/bson": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@types/bson/-/bson-1.0.10.tgz", - "integrity": "sha512-gRf+Qy5Qiyjz28ZkPRP37bDHtGG67op/lV2qcIMhWUq4vIMJ6/j13ajeYH7LFhJ5RNflyLHmdANPGDXZ5a8EzQ==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/bson/-/bson-1.0.11.tgz", + "integrity": "sha512-j+UcCWI+FsbI5/FQP/Kj2CXyplWAz39ktHFkXk84h7dNblKRSoNJs95PZFRd96NQGqsPEPgeclqnznWZr14ZDA==", "dev": true, "requires": { "@types/node": "*" @@ -1989,9 +1989,9 @@ } }, "@types/mongodb": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.1.1.tgz", - "integrity": "sha512-lHEH+OwYNeuC28jlmdPT/wBAVMuB6M1sHjZKAtaho/LeJf78ILJPYUq2OD7mWVj9QR7uYiKVt4ExGkXegHFCJQ==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.1.8.tgz", + "integrity": "sha512-5higsHdPx63XKIh5hjr5GGrCCErBqEbpZZiNsUcqk97mMDpCBH9R4dRi/T8bcMrQItCdL+wecagdAj3JPKkuVg==", "dev": true, "requires": { "@types/bson": "*", diff --git a/package.json b/package.json index 98bbdd298..9f72204d7 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "@types/lodash": "^4.14.111", "@types/luxon": "^0.5.3", "@types/mini-css-extract-plugin": "^0.2.0", - "@types/mongodb": "^3.1.1", + "@types/mongodb": "^3.1.8", "@types/ms": "^0.7.30", "@types/node": "^10.5.2", "@types/node-fetch": "^2.1.2", diff --git a/src/core/client/stream/components/App.spec.tsx b/src/core/client/stream/components/App.spec.tsx new file mode 100644 index 000000000..01c0bec0b --- /dev/null +++ b/src/core/client/stream/components/App.spec.tsx @@ -0,0 +1,16 @@ +import { shallow } from "enzyme"; +import noop from "lodash"; +import React from "react"; + +import { PropTypesOf } from "talk-framework/types"; + +import App from "./App"; + +it("renders comments", () => { + const props: PropTypesOf = { + activeTab: "COMMENTS", + onTabClick: noop, + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src/core/client/stream/components/__snapshots__/App.spec.tsx.snap b/src/core/client/stream/components/__snapshots__/App.spec.tsx.snap new file mode 100644 index 000000000..f99caa827 --- /dev/null +++ b/src/core/client/stream/components/__snapshots__/App.spec.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders comments 1`] = ` + + + + + + + + + + + + + + +`; diff --git a/src/core/client/stream/mutations/CreateCommentMutation.ts b/src/core/client/stream/mutations/CreateCommentMutation.ts index d2a23a660..813563185 100644 --- a/src/core/client/stream/mutations/CreateCommentMutation.ts +++ b/src/core/client/stream/mutations/CreateCommentMutation.ts @@ -158,6 +158,11 @@ function commit( editing: { editableUntil: new Date(Date.now() + 10000), }, + actionCounts: { + reaction: { + total: 0, + }, + }, }, }, clientMutationId: (clientMutationId++).toString(), diff --git a/src/core/client/stream/mutations/CreateCommentReactionMutation.ts b/src/core/client/stream/mutations/CreateCommentReactionMutation.ts new file mode 100644 index 000000000..ef0b7a61a --- /dev/null +++ b/src/core/client/stream/mutations/CreateCommentReactionMutation.ts @@ -0,0 +1,70 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutationContainer, +} from "talk-framework/lib/relay"; +import { Omit } from "talk-framework/types"; + +import { CreateCommentReactionMutation as MutationTypes } from "talk-stream/__generated__/CreateCommentReactionMutation.graphql"; + +export type CreateCommentReactionInput = Omit< + MutationTypes["variables"]["input"], + "clientMutationId" +>; + +const mutation = graphql` + mutation CreateCommentReactionMutation($input: CreateCommentReactionInput!) { + createCommentReaction(input: $input) { + comment { + ...ReactionButtonContainer_comment + } + clientMutationId + } + } +`; + +let clientMutationId = 0; + +function commit(environment: Environment, input: CreateCommentReactionInput) { + const source = environment.getStore().getSource(); + const currentCount = source.get( + source.get(source.get(input.commentID)!.actionCounts.__ref)!.reaction.__ref + )!.total; + + return commitMutationPromiseNormalized(environment, { + mutation, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + optimisticResponse: { + createCommentReaction: { + comment: { + id: input.commentID, + myActionPresence: { + reaction: true, + }, + actionCounts: { + reaction: { + total: currentCount + 1, + }, + }, + }, + clientMutationId: (clientMutationId++).toString(), + }, + } as any, // TODO: (cvle) generated types should contain one for the optimistic response. + }); +} + +export const withCreateCommentReactionMutation = createMutationContainer( + "createCommentReaction", + commit +); + +export type CreateCommentReactionMutation = ( + input: CreateCommentReactionInput +) => Promise; diff --git a/src/core/client/stream/mutations/DeleteCommentReactionMutation.ts b/src/core/client/stream/mutations/DeleteCommentReactionMutation.ts new file mode 100644 index 000000000..027723544 --- /dev/null +++ b/src/core/client/stream/mutations/DeleteCommentReactionMutation.ts @@ -0,0 +1,64 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutationContainer, +} from "talk-framework/lib/relay"; + +import { DeleteCommentReactionMutation as MutationTypes } from "talk-stream/__generated__/DeleteCommentReactionMutation.graphql"; +import { CreateCommentReactionInput } from "./CreateCommentReactionMutation"; + +const mutation = graphql` + mutation DeleteCommentReactionMutation($input: CreateCommentReactionInput!) { + deleteCommentReaction(input: $input) { + comment { + ...ReactionButtonContainer_comment + } + clientMutationId + } + } +`; + +const clientMutationId = 0; + +function commit(environment: Environment, input: CreateCommentReactionInput) { + const source = environment.getStore().getSource(); + const currentCount = source.get( + source.get(source.get(input.commentID)!.actionCounts.__ref)!.reaction.__ref + )!.total; + return commitMutationPromiseNormalized(environment, { + mutation, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + optimisticResponse: { + deleteCommentReaction: { + comment: { + id: input.commentID, + myActionPresence: { + reaction: false, + }, + actionCounts: { + reaction: { + total: currentCount - 1, + }, + }, + }, + clientMutationId: clientMutationId.toString(), + }, + } as any, // TODO: (cvle) generated types should contain one for the optimistic response. + }); +} + +export const withDeleteCommentReactionMutation = createMutationContainer( + "deleteCommentReaction", + commit +); + +export type DeleteCommentReactionMutation = ( + input: CreateCommentReactionInput +) => Promise; diff --git a/src/core/client/stream/mutations/index.ts b/src/core/client/stream/mutations/index.ts index 3f83af22d..ef2364995 100644 --- a/src/core/client/stream/mutations/index.ts +++ b/src/core/client/stream/mutations/index.ts @@ -31,3 +31,12 @@ export { withSetActiveTabMutation, SetActiveTabMutation, } from "./SetActiveTabMutation"; +export { + withCreateCommentReactionMutation, + CreateCommentReactionMutation, + CreateCommentReactionInput, +} from "./CreateCommentReactionMutation"; +export { + withDeleteCommentReactionMutation, + DeleteCommentReactionMutation, +} from "./DeleteCommentReactionMutation"; diff --git a/src/core/client/stream/tabs/comments/components/PermalinkButton/PermalinkButton.tsx b/src/core/client/stream/tabs/comments/components/PermalinkButton/PermalinkButton.tsx index d93fb46e8..1b34a1e6c 100644 --- a/src/core/client/stream/tabs/comments/components/PermalinkButton/PermalinkButton.tsx +++ b/src/core/client/stream/tabs/comments/components/PermalinkButton/PermalinkButton.tsx @@ -35,7 +35,7 @@ class Permalink extends React.Component { id={popoverID} placement="top-start" description="A dialog showing a permalink to the comment" - className={styles.popover} + classes={{ popover: styles.popover }} body={({ toggleVisibility }) => ( diff --git a/src/core/client/stream/tabs/comments/components/PermalinkView.tsx b/src/core/client/stream/tabs/comments/components/PermalinkView.tsx index ceb58f0d1..475483bb1 100644 --- a/src/core/client/stream/tabs/comments/components/PermalinkView.tsx +++ b/src/core/client/stream/tabs/comments/components/PermalinkView.tsx @@ -10,6 +10,7 @@ import * as styles from "./PermalinkView.css"; export interface PermalinkViewProps { me: PropTypesOf["me"]; asset: PropTypesOf["asset"]; + settings: PropTypesOf["settings"]; comment: PropTypesOf["comment"] | null; showAllCommentsHref: string | null; onShowAllComments: (e: MouseEvent) => void; @@ -18,6 +19,7 @@ export interface PermalinkViewProps { const PermalinkView: StatelessComponent = ({ showAllCommentsHref, comment, + settings, asset, onShowAllComments, me, @@ -46,7 +48,14 @@ const PermalinkView: StatelessComponent = ({ Comment not found )} - {comment && } + {comment && ( + + )} ); }; diff --git a/src/core/client/stream/tabs/comments/components/ReactionButton.tsx b/src/core/client/stream/tabs/comments/components/ReactionButton.tsx new file mode 100644 index 000000000..559ba34f5 --- /dev/null +++ b/src/core/client/stream/tabs/comments/components/ReactionButton.tsx @@ -0,0 +1,43 @@ +import React from "react"; + +import { Button, ButtonIcon, MatchMedia } from "talk-ui/components"; + +interface ReactionButtonProps { + onClick: () => void; + totalReactions: number; + reacted: boolean | null; + label: string; + labelActive: string | null; + icon: string; + iconActive: string | null; + // color: string; +} + +class ReactionButton extends React.Component { + public render() { + const { totalReactions, reacted } = this.props; + return ( + + ); + } +} + +export default ReactionButton; diff --git a/src/core/client/stream/tabs/comments/components/ReplyList.spec.tsx b/src/core/client/stream/tabs/comments/components/ReplyList.spec.tsx index 76707a974..9b503ac0e 100644 --- a/src/core/client/stream/tabs/comments/components/ReplyList.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/ReplyList.spec.tsx @@ -25,6 +25,12 @@ it("renders correctly", () => { me: null, localReply: false, disableReplies: false, + settings: { + reaction: { + icon: "thumb_up_alt", + label: "Respect", + }, + }, }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); @@ -40,6 +46,12 @@ describe("when there is more", () => { disableShowAll: false, indentLevel: 1, me: null, + settings: { + reaction: { + icon: "thumb_up_alt", + label: "Respect", + }, + }, }; const wrapper = shallow(); diff --git a/src/core/client/stream/tabs/comments/components/ReplyList.tsx b/src/core/client/stream/tabs/comments/components/ReplyList.tsx index 87f9eb610..9d9ab0b39 100644 --- a/src/core/client/stream/tabs/comments/components/ReplyList.tsx +++ b/src/core/client/stream/tabs/comments/components/ReplyList.tsx @@ -15,29 +15,21 @@ export interface ReplyListProps { id: string; }; comments: ReadonlyArray< - { id: string; showConversationLink?: boolean } & PropTypesOf< - typeof CommentContainer - >["comment"] + { + id: string; + replyListElement?: React.ReactElement; + showConversationLink?: boolean; + } & PropTypesOf["comment"] >; + settings: PropTypesOf["settings"]; onShowAll?: () => void; hasMore?: boolean; disableShowAll?: boolean; indentLevel?: number; - ReplyListComponent?: React.ComponentType; localReply?: boolean; disableReplies?: boolean; } -function getReplyListElement( - { ReplyListComponent, me, asset }: ReplyListProps, - comment: PropTypesOf["comment"] -) { - if (!ReplyListComponent) { - return null; - } - return ; -} - const ReplyList: StatelessComponent = props => { return ( = props => { me={props.me} comment={comment} asset={props.asset} + settings={props.settings} indentLevel={props.indentLevel} localReply={props.localReply} disableReplies={props.disableReplies} showConversationLink={!!comment.showConversationLink} /> - {getReplyListElement(props, comment)} + {comment.replyListElement} ))} {props.hasMore && ( diff --git a/src/core/client/stream/tabs/comments/components/Stream.spec.tsx b/src/core/client/stream/tabs/comments/components/Stream.spec.tsx index 6a717731c..b0043b2fe 100644 --- a/src/core/client/stream/tabs/comments/components/Stream.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/Stream.spec.tsx @@ -17,6 +17,12 @@ it("renders correctly", () => { isClosed: false, }, comments: [{ id: "comment-1" }, { id: "comment-2" }], + settings: { + reaction: { + icon: "thumb_up_alt", + label: "Respect", + }, + }, onLoadMore: noop, disableLoadMore: false, hasMore: false, @@ -38,6 +44,12 @@ describe("when use is logged in", () => { disableLoadMore: false, hasMore: false, me: {}, + settings: { + reaction: { + icon: "thumb_up_alt", + label: "Respect", + }, + }, }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); @@ -51,6 +63,12 @@ describe("when there is more", () => { isClosed: false, }, comments: [{ id: "comment-1" }, { id: "comment-2" }], + settings: { + reaction: { + icon: "thumb_up_alt", + label: "Respect", + }, + }, onLoadMore: sinon.spy(), disableLoadMore: false, hasMore: true, diff --git a/src/core/client/stream/tabs/comments/components/Stream.tsx b/src/core/client/stream/tabs/comments/components/Stream.tsx index 52c08e645..fcc9c474e 100644 --- a/src/core/client/stream/tabs/comments/components/Stream.tsx +++ b/src/core/client/stream/tabs/comments/components/Stream.tsx @@ -18,6 +18,8 @@ export interface StreamProps { isClosed?: boolean; } & PropTypesOf["asset"] & PropTypesOf["asset"]; + settings: PropTypesOf["settings"] & + PropTypesOf["settings"]; comments: ReadonlyArray< { id: string } & PropTypesOf["comment"] & PropTypesOf["comment"] @@ -52,10 +54,12 @@ const Stream: StatelessComponent = props => { @@ -47,6 +55,14 @@ exports[`renders correctly 1`] = ` key="comment-2" localReply={false} me={null} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } showConversationLink={true} /> @@ -75,6 +91,14 @@ exports[`when there is more disables load more button 1`] = ` indentLevel={1} key="comment-1" me={null} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } showConversationLink={false} /> @@ -95,6 +119,14 @@ exports[`when there is more disables load more button 1`] = ` indentLevel={1} key="comment-2" me={null} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } showConversationLink={false} /> @@ -142,6 +174,14 @@ exports[`when there is more renders a load more button 1`] = ` indentLevel={1} key="comment-1" me={null} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } showConversationLink={false} /> @@ -162,6 +202,14 @@ exports[`when there is more renders a load more button 1`] = ` indentLevel={1} key="comment-2" me={null} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } showConversationLink={false} /> diff --git a/src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap b/src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap index d43a8ebe0..7706c0207 100644 --- a/src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap +++ b/src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap @@ -34,6 +34,14 @@ exports[`renders correctly 1`] = ` } } me={null} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } /> @@ -120,6 +152,14 @@ exports[`when there is more disables load more button 1`] = ` } } me={null} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } /> diff --git a/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx index 714e5e584..44a71647d 100644 --- a/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx +++ b/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx @@ -30,6 +30,12 @@ it("renders username and body", () => { }, pending: false, }, + settings: { + reaction: { + icon: "thumb_up_alt", + label: "Respect", + }, + }, indentLevel: 1, showAuthPopup: noop as any, setCommentID: noop as any, @@ -61,6 +67,12 @@ it("renders body only", () => { }, pending: false, }, + settings: { + reaction: { + icon: "thumb_up_alt", + label: "Respect", + }, + }, indentLevel: 1, showAuthPopup: noop as any, setCommentID: noop as any, @@ -90,6 +102,12 @@ it("hide reply button", () => { }, pending: false, }, + settings: { + reaction: { + icon: "thumb_up_alt", + label: "Respect", + }, + }, indentLevel: 1, showAuthPopup: noop as any, setCommentID: noop as any, @@ -107,6 +125,13 @@ it("shows conversation link", () => { asset: { url: "http://localhost/asset", }, + settings: { + reaction: { + icon: "thumb_up", + label: "Respect", + labelActive: "Respected", + }, + }, comment: { id: "comment-id", author: { diff --git a/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx b/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx index dc1b2b3e1..cca9cd3cd 100644 --- a/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx @@ -9,6 +9,7 @@ import { PropTypesOf } from "talk-framework/types"; import { CommentContainer_asset as AssetData } from "talk-stream/__generated__/CommentContainer_asset.graphql"; import { CommentContainer_comment as CommentData } from "talk-stream/__generated__/CommentContainer_comment.graphql"; import { CommentContainer_me as MeData } from "talk-stream/__generated__/CommentContainer_me.graphql"; +import { CommentContainer_settings as SettingsData } from "talk-stream/__generated__/CommentContainer_settings.graphql"; import { SetCommentIDMutation, ShowAuthPopupMutation, @@ -16,6 +17,7 @@ import { withShowAuthPopupMutation, } from "talk-stream/mutations"; +import ReactionButtonContainer from "talk-stream/tabs/comments/containers/ReactionButtonContainer"; import { Button } from "talk-ui/components"; import Comment, { ButtonsBar, @@ -30,6 +32,7 @@ interface InnerProps { me: MeData | null; comment: CommentData; asset: AssetData; + settings: SettingsData; indentLevel?: number; showAuthPopup: ShowAuthPopupMutation; setCommentID: SetCommentIDMutation; @@ -131,6 +134,7 @@ export class CommentContainer extends Component { public render() { const { comment, + settings, asset, indentLevel, localReply, @@ -182,6 +186,12 @@ export class CommentContainer extends Component { /> )} + {this.props.me && ( + + )} {showConversationLink && ( { return ( ({ } } `, + settings: graphql` + fragment LocalReplyListContainer_settings on Settings { + ...CommentContainer_settings + } + `, })(LocalReplyListContainer); export type LocalReplyListContainerProps = PropTypesOf; diff --git a/src/core/client/stream/tabs/comments/containers/PermalinkViewContainer.tsx b/src/core/client/stream/tabs/comments/containers/PermalinkViewContainer.tsx index cdc5c2f65..4354012cd 100644 --- a/src/core/client/stream/tabs/comments/containers/PermalinkViewContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/PermalinkViewContainer.tsx @@ -8,6 +8,7 @@ import { withFragmentContainer } from "talk-framework/lib/relay"; import { PermalinkViewContainer_asset as AssetData } from "talk-stream/__generated__/PermalinkViewContainer_asset.graphql"; import { PermalinkViewContainer_comment as CommentData } from "talk-stream/__generated__/PermalinkViewContainer_comment.graphql"; import { PermalinkViewContainer_me as MeData } from "talk-stream/__generated__/PermalinkViewContainer_me.graphql"; +import { PermalinkViewContainer_settings as SettingsData } from "talk-stream/__generated__/PermalinkViewContainer_settings.graphql"; import { SetCommentIDMutation, withSetCommentIDMutation, @@ -18,6 +19,7 @@ import PermalinkView from "../components/PermalinkView"; interface PermalinkViewContainerProps { comment: CommentData | null; asset: AssetData; + settings: SettingsData; me: MeData | null; setCommentID: SetCommentIDMutation; pym: PymChild | undefined; @@ -48,12 +50,13 @@ class PermalinkViewContainer extends React.Component< } public render() { - const { comment, asset, me } = this.props; + const { comment, asset, me, settings } = this.props; return ( @@ -82,6 +85,11 @@ const enhanced = withContext(ctx => ({ ...CommentContainer_me } `, + settings: graphql` + fragment PermalinkViewContainer_settings on Settings { + ...CommentContainer_settings + } + `, })(PermalinkViewContainer) ) ); diff --git a/src/core/client/stream/tabs/comments/containers/ReactionButtonContainer.tsx b/src/core/client/stream/tabs/comments/containers/ReactionButtonContainer.tsx new file mode 100644 index 000000000..686988a1e --- /dev/null +++ b/src/core/client/stream/tabs/comments/containers/ReactionButtonContainer.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { graphql } from "react-relay"; +import { withFragmentContainer } from "talk-framework/lib/relay"; +import { ReactionButtonContainer_comment as CommentData } from "talk-stream/__generated__/ReactionButtonContainer_comment.graphql"; +import { ReactionButtonContainer_settings as SettingsData } from "talk-stream/__generated__/ReactionButtonContainer_settings.graphql"; + +import { + CreateCommentReactionMutation, + DeleteCommentReactionMutation, + withCreateCommentReactionMutation, + withDeleteCommentReactionMutation, +} from "talk-stream/mutations"; +import ReactionButton from "talk-stream/tabs/comments/components/ReactionButton"; + +interface ReactionButtonContainerProps { + createCommentReaction: CreateCommentReactionMutation; + deleteCommentReaction: DeleteCommentReactionMutation; + comment: CommentData; + settings: SettingsData; +} + +class ReactionButtonContainer extends React.Component< + ReactionButtonContainerProps +> { + private handleClick = () => { + const input = { + commentID: this.props.comment.id, + }; + + const { createCommentReaction, deleteCommentReaction } = this.props; + const reacted = + this.props.comment.myActionPresence && + this.props.comment.myActionPresence.reaction; + + reacted ? deleteCommentReaction(input) : createCommentReaction(input); + }; + public render() { + const { + actionCounts: { + reaction: { total: totalReactions }, + }, + } = this.props.comment; + const { + reaction: { label, labelActive, icon, iconActive }, + } = this.props.settings; + + const reacted = + this.props.comment.myActionPresence && + this.props.comment.myActionPresence.reaction; + + return ( + + ); + } +} + +export default withDeleteCommentReactionMutation( + withCreateCommentReactionMutation( + withFragmentContainer({ + comment: graphql` + fragment ReactionButtonContainer_comment on Comment { + id + myActionPresence { + reaction + } + actionCounts { + reaction { + total + } + } + } + `, + settings: graphql` + fragment ReactionButtonContainer_settings on Settings { + reaction { + label + labelActive + icon + iconActive + } + } + `, + })(ReactionButtonContainer) + ) +); diff --git a/src/core/client/stream/tabs/comments/containers/ReplyListContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/ReplyListContainer.spec.tsx index dd9ac740a..6d440b197 100644 --- a/src/core/client/stream/tabs/comments/containers/ReplyListContainer.spec.tsx +++ b/src/core/client/stream/tabs/comments/containers/ReplyListContainer.spec.tsx @@ -22,6 +22,12 @@ it("renders correctly", () => { edges: [{ node: { id: "comment-1" } }, { node: { id: "comment-2" } }], }, }, + settings: { + reaction: { + icon: "thumb_up_alt", + label: "Respect", + }, + }, relay: { hasMore: noop, isLoading: noop, @@ -49,6 +55,12 @@ it("renders correctly when replies are empty", () => { isLoading: noop, } as any, me: null, + settings: { + reaction: { + icon: "thumb_up_alt", + label: "Respect", + }, + }, indentLevel: 1, ReplyListComponent: undefined, localReply: false, @@ -69,6 +81,12 @@ describe("when has more replies", () => { edges: [{ node: { id: "comment-1" } }, { node: { id: "comment-2" } }], }, }, + settings: { + reaction: { + icon: "thumb_up_alt", + label: "Respect", + }, + }, relay: { hasMore: () => true, isLoading: () => false, diff --git a/src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx b/src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx index cb64d4ade..d9f26cab1 100644 --- a/src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx @@ -7,6 +7,7 @@ import { PropTypesOf } from "talk-framework/types"; import { ReplyListContainer1_asset as AssetData } from "talk-stream/__generated__/ReplyListContainer1_asset.graphql"; import { ReplyListContainer1_comment as CommentData } from "talk-stream/__generated__/ReplyListContainer1_comment.graphql"; import { ReplyListContainer1_me as MeData } from "talk-stream/__generated__/ReplyListContainer1_me.graphql"; +import { ReplyListContainer1_settings as SettingsData } from "talk-stream/__generated__/ReplyListContainer1_settings.graphql"; import { COMMENT_SORT, ReplyListContainer1PaginationQueryVariables, @@ -14,22 +15,29 @@ import { import { ReplyListContainer5_comment as Comment5Data } from "talk-stream/__generated__/ReplyListContainer5_comment.graphql"; import { StatelessComponent } from "enzyme"; +import { FragmentKeys } from "talk-framework/lib/relay/types"; import ReplyList from "../components/ReplyList"; import LocalReplyListContainer from "./LocalReplyListContainer"; type UnpackArray = T extends ReadonlyArray ? U : any; type ReplyNode5 = UnpackArray["node"]; -export interface InnerProps { +export interface BaseProps { me: MeData | null; asset: AssetData; comment: CommentData; + settings: SettingsData; relay: RelayPaginationProp; indentLevel: number; - ReplyListComponent: React.ComponentType | undefined; localReply: boolean | undefined; } +export type InnerProps = BaseProps & { + ReplyListComponent: + | React.ComponentType<{ [P in FragmentKeys]: any }> + | undefined; +}; + // TODO: (cvle) This should be autogenerated. interface FragmentVariables { count: number; @@ -50,6 +58,14 @@ export class ReplyListContainer extends React.Component { } const comments = this.props.comment.replies.edges.map(edge => ({ ...edge.node, + replyListElement: this.props.ReplyListComponent && ( + + ), // ReplyListContainer5 contains replyCount. showConversationLink: ((edge.node as any) as ReplyNode5).replyCount > 0, })); @@ -59,11 +75,11 @@ export class ReplyListContainer extends React.Component { comment={this.props.comment} comments={comments} asset={this.props.asset} + settings={this.props.settings} onShowAll={this.showAll} hasMore={this.props.relay.hasMore()} disableShowAll={this.state.disableShowAll} indentLevel={this.props.indentLevel} - ReplyListComponent={this.props.ReplyListComponent} localReply={this.props.localReply} /> ); @@ -94,9 +110,10 @@ function createReplyListContainer( me: GraphQLTaggedNode; asset: GraphQLTaggedNode; comment: GraphQLTaggedNode; + settings: GraphQLTaggedNode; }, query: GraphQLTaggedNode, - ReplyListComponent?: React.ComponentType, + ReplyListComponent?: InnerProps["ReplyListComponent"], localReply?: boolean ) { return withProps({ indentLevel, ReplyListComponent, localReply })( @@ -145,6 +162,12 @@ const ReplyListContainer5 = createReplyListContainer( ...LocalReplyListContainer_me } `, + settings: graphql` + fragment ReplyListContainer5_settings on Settings { + ...LocalReplyListContainer_settings + ...CommentContainer_settings + } + `, asset: graphql` fragment ReplyListContainer5_asset on Asset { ...CommentContainer_asset @@ -201,6 +224,12 @@ const ReplyListContainer4 = createReplyListContainer( ...CommentContainer_me } `, + settings: graphql` + fragment ReplyListContainer4_settings on Settings { + ...ReplyListContainer5_settings + ...CommentContainer_settings + } + `, asset: graphql` fragment ReplyListContainer4_asset on Asset { ...ReplyListContainer5_asset @@ -255,6 +284,12 @@ const ReplyListContainer3 = createReplyListContainer( ...CommentContainer_me } `, + settings: graphql` + fragment ReplyListContainer3_settings on Settings { + ...ReplyListContainer4_settings + ...CommentContainer_settings + } + `, asset: graphql` fragment ReplyListContainer3_asset on Asset { ...ReplyListContainer4_asset @@ -309,6 +344,12 @@ const ReplyListContainer2 = createReplyListContainer( ...CommentContainer_me } `, + settings: graphql` + fragment ReplyListContainer2_settings on Settings { + ...ReplyListContainer3_settings + ...CommentContainer_settings + } + `, asset: graphql` fragment ReplyListContainer2_asset on Asset { ...ReplyListContainer3_asset @@ -363,6 +404,12 @@ const ReplyListContainer1 = createReplyListContainer( ...CommentContainer_me } `, + settings: graphql` + fragment ReplyListContainer1_settings on Settings { + ...ReplyListContainer2_settings + ...CommentContainer_settings + } + `, asset: graphql` fragment ReplyListContainer1_asset on Asset { ...ReplyListContainer2_asset diff --git a/src/core/client/stream/tabs/comments/containers/StreamContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/StreamContainer.spec.tsx index 3d3a51841..8ba69289f 100644 --- a/src/core/client/stream/tabs/comments/containers/StreamContainer.spec.tsx +++ b/src/core/client/stream/tabs/comments/containers/StreamContainer.spec.tsx @@ -21,6 +21,12 @@ it("renders correctly", () => { }, }, me: null, + settings: { + reaction: { + icon: "thumb_up_alt", + label: "Respect", + }, + }, relay: { hasMore: noop, isLoading: noop, @@ -41,6 +47,12 @@ describe("when has more comments", () => { }, }, me: null, + settings: { + reaction: { + icon: "thumb_up_alt", + label: "Respect", + }, + }, relay: { hasMore: () => true, isLoading: () => false, diff --git a/src/core/client/stream/tabs/comments/containers/StreamContainer.tsx b/src/core/client/stream/tabs/comments/containers/StreamContainer.tsx index 5ca2286bc..e9127c913 100644 --- a/src/core/client/stream/tabs/comments/containers/StreamContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/StreamContainer.tsx @@ -5,6 +5,7 @@ import { withPaginationContainer } from "talk-framework/lib/relay"; import { PropTypesOf } from "talk-framework/types"; import { StreamContainer_asset as AssetData } from "talk-stream/__generated__/StreamContainer_asset.graphql"; import { StreamContainer_me as MeData } from "talk-stream/__generated__/StreamContainer_me.graphql"; +import { StreamContainer_settings as SettingsData } from "talk-stream/__generated__/StreamContainer_settings.graphql"; import { COMMENT_SORT, StreamContainerPaginationQueryVariables, @@ -14,6 +15,7 @@ import Stream from "../components/Stream"; interface InnerProps { asset: AssetData; + settings: SettingsData; me: MeData | null; relay: RelayPaginationProp; } @@ -38,6 +40,7 @@ export class StreamContainer extends React.Component { , "showConversationLink": false, }, Object { "id": "comment-2", + "replyListElement": , "showConversationLink": false, }, ] @@ -44,6 +85,14 @@ exports[`renders correctly 1`] = ` localReply={false} me={null} onShowAll={[Function]} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } /> `; @@ -79,10 +128,12 @@ exports[`when has more replies renders hasMore 1`] = ` Array [ Object { "id": "comment-1", + "replyListElement": undefined, "showConversationLink": false, }, Object { "id": "comment-2", + "replyListElement": undefined, "showConversationLink": false, }, ] @@ -93,6 +144,14 @@ exports[`when has more replies renders hasMore 1`] = ` localReply={false} me={null} onShowAll={[Function]} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } /> `; @@ -126,10 +185,12 @@ exports[`when has more replies when showing all disables show all button 1`] = ` Array [ Object { "id": "comment-1", + "replyListElement": undefined, "showConversationLink": false, }, Object { "id": "comment-2", + "replyListElement": undefined, "showConversationLink": false, }, ] @@ -140,6 +201,14 @@ exports[`when has more replies when showing all disables show all button 1`] = ` localReply={false} me={null} onShowAll={[Function]} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } /> `; @@ -173,10 +242,12 @@ exports[`when has more replies when showing all enable show all button after loa Array [ Object { "id": "comment-1", + "replyListElement": undefined, "showConversationLink": false, }, Object { "id": "comment-2", + "replyListElement": undefined, "showConversationLink": false, }, ] @@ -187,5 +258,13 @@ exports[`when has more replies when showing all enable show all button after loa localReply={false} me={null} onShowAll={[Function]} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } /> `; diff --git a/src/core/client/stream/tabs/comments/containers/__snapshots__/StreamContainer.spec.tsx.snap b/src/core/client/stream/tabs/comments/containers/__snapshots__/StreamContainer.spec.tsx.snap index d29f00fdf..990b9c2fe 100644 --- a/src/core/client/stream/tabs/comments/containers/__snapshots__/StreamContainer.spec.tsx.snap +++ b/src/core/client/stream/tabs/comments/containers/__snapshots__/StreamContainer.spec.tsx.snap @@ -35,6 +35,14 @@ exports[`renders correctly 1`] = ` disableLoadMore={false} me={null} onLoadMore={[Function]} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } /> `; @@ -74,6 +82,14 @@ exports[`when has more comments renders hasMore 1`] = ` hasMore={true} me={null} onLoadMore={[Function]} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } /> `; @@ -113,6 +129,14 @@ exports[`when has more comments when loading more disables load more button 1`] hasMore={true} me={null} onLoadMore={[Function]} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } /> `; @@ -152,5 +176,13 @@ exports[`when has more comments when loading more enable load more button after hasMore={true} me={null} onLoadMore={[Function]} + settings={ + Object { + "reaction": Object { + "icon": "thumb_up_alt", + "label": "Respect", + }, + } + } /> `; diff --git a/src/core/client/stream/tabs/comments/queries/PermalinkViewQuery.tsx b/src/core/client/stream/tabs/comments/queries/PermalinkViewQuery.tsx index 9a3bad91f..2f108f994 100644 --- a/src/core/client/stream/tabs/comments/queries/PermalinkViewQuery.tsx +++ b/src/core/client/stream/tabs/comments/queries/PermalinkViewQuery.tsx @@ -36,6 +36,7 @@ export const render = ({ return ( @@ -63,6 +64,9 @@ const PermalinkViewQuery: StatelessComponent = ({ comment(id: $commentID) { ...PermalinkViewContainer_comment } + settings { + ...PermalinkViewContainer_settings + } } `} variables={{ diff --git a/src/core/client/stream/tabs/comments/queries/StreamQuery.tsx b/src/core/client/stream/tabs/comments/queries/StreamQuery.tsx index da7879f8e..4a7f2c2ed 100644 --- a/src/core/client/stream/tabs/comments/queries/StreamQuery.tsx +++ b/src/core/client/stream/tabs/comments/queries/StreamQuery.tsx @@ -31,7 +31,13 @@ export const render = ({ ); } - return ; + return ( + + ); } return ; @@ -49,6 +55,9 @@ const StreamQuery: StatelessComponent = ({ asset(id: $assetID, url: $assetURL) { ...StreamContainer_asset } + settings { + ...StreamContainer_settings + } } `} variables={{ diff --git a/src/core/client/stream/test/comments/__snapshots__/editComment.spec.tsx.snap b/src/core/client/stream/test/comments/__snapshots__/editComment.spec.tsx.snap index d2a122808..d08ad4599 100644 --- a/src/core/client/stream/test/comments/__snapshots__/editComment.spec.tsx.snap +++ b/src/core/client/stream/test/comments/__snapshots__/editComment.spec.tsx.snap @@ -323,6 +323,21 @@ exports[`cancel edit: edit canceled 1`] = ` Reply + @@ -359,10 +374,10 @@ exports[`cancel edit: edit canceled 1`] = ` > @@ -397,6 +412,21 @@ exports[`cancel edit: edit canceled 1`] = ` Reply + @@ -843,10 +873,10 @@ exports[`edit a comment: edit form 1`] = ` > @@ -881,6 +911,21 @@ exports[`edit a comment: edit form 1`] = ` Reply + @@ -1327,10 +1372,10 @@ exports[`edit a comment: optimistic response 1`] = ` > @@ -1365,6 +1410,21 @@ exports[`edit a comment: optimistic response 1`] = ` Reply + @@ -1700,6 +1760,21 @@ exports[`edit a comment: render stream 1`] = ` Reply + @@ -1736,10 +1811,10 @@ exports[`edit a comment: render stream 1`] = ` > @@ -1774,6 +1849,21 @@ exports[`edit a comment: render stream 1`] = ` Reply + @@ -2118,6 +2208,21 @@ exports[`edit a comment: server response 1`] = ` Reply + @@ -2154,10 +2259,10 @@ exports[`edit a comment: server response 1`] = ` > @@ -2192,6 +2297,21 @@ exports[`edit a comment: server response 1`] = ` Reply + @@ -2527,6 +2647,21 @@ exports[`shows expiry message: edit form closed 1`] = ` Reply + @@ -2563,10 +2698,10 @@ exports[`shows expiry message: edit form closed 1`] = ` > @@ -2601,6 +2736,21 @@ exports[`shows expiry message: edit form closed 1`] = ` Reply + @@ -3024,10 +3174,10 @@ exports[`shows expiry message: edit time expired 1`] = ` > @@ -3062,6 +3212,21 @@ exports[`shows expiry message: edit time expired 1`] = ` Reply + diff --git a/src/core/client/stream/test/comments/__snapshots__/loadMore.spec.tsx.snap b/src/core/client/stream/test/comments/__snapshots__/loadMore.spec.tsx.snap index 1bf5a0a05..ff45a60d2 100644 --- a/src/core/client/stream/test/comments/__snapshots__/loadMore.spec.tsx.snap +++ b/src/core/client/stream/test/comments/__snapshots__/loadMore.spec.tsx.snap @@ -282,10 +282,10 @@ exports[`loads more comments 1`] = ` > @@ -356,10 +356,10 @@ exports[`loads more comments 1`] = ` > @@ -688,10 +688,10 @@ exports[`renders comment stream 1`] = ` > diff --git a/src/core/client/stream/test/comments/__snapshots__/postComment.spec.tsx.snap b/src/core/client/stream/test/comments/__snapshots__/postComment.spec.tsx.snap index 843b01875..b9c12183b 100644 --- a/src/core/client/stream/test/comments/__snapshots__/postComment.spec.tsx.snap +++ b/src/core/client/stream/test/comments/__snapshots__/postComment.spec.tsx.snap @@ -317,6 +317,21 @@ exports[`post a comment: optimistic response 1`] = ` Reply + @@ -391,6 +406,21 @@ exports[`post a comment: optimistic response 1`] = ` Reply + @@ -427,10 +457,10 @@ exports[`post a comment: optimistic response 1`] = ` > @@ -465,6 +495,21 @@ exports[`post a comment: optimistic response 1`] = ` Reply + @@ -784,6 +829,21 @@ exports[`post a comment: server response 1`] = ` Reply + @@ -858,6 +918,21 @@ exports[`post a comment: server response 1`] = ` Reply + @@ -894,10 +969,10 @@ exports[`post a comment: server response 1`] = ` > @@ -932,6 +1007,21 @@ exports[`post a comment: server response 1`] = ` Reply + @@ -1251,6 +1341,21 @@ exports[`renders comment stream 1`] = ` Reply + @@ -1287,10 +1392,10 @@ exports[`renders comment stream 1`] = ` > @@ -1325,6 +1430,21 @@ exports[`renders comment stream 1`] = ` Reply + diff --git a/src/core/client/stream/test/comments/__snapshots__/postLocalReply.spec.tsx.snap b/src/core/client/stream/test/comments/__snapshots__/postLocalReply.spec.tsx.snap index d1e6c2e92..2ee9aa210 100644 --- a/src/core/client/stream/test/comments/__snapshots__/postLocalReply.spec.tsx.snap +++ b/src/core/client/stream/test/comments/__snapshots__/postLocalReply.spec.tsx.snap @@ -307,6 +307,21 @@ exports[`post a reply: open reply form 1`] = ` Reply + @@ -385,6 +400,21 @@ exports[`post a reply: open reply form 1`] = ` Reply + @@ -463,6 +493,21 @@ exports[`post a reply: open reply form 1`] = ` Reply + @@ -541,6 +586,21 @@ exports[`post a reply: open reply form 1`] = ` Reply + @@ -619,6 +679,21 @@ exports[`post a reply: open reply form 1`] = ` Reply + @@ -697,6 +772,21 @@ exports[`post a reply: open reply form 1`] = ` Reply + + @@ -1247,6 +1352,21 @@ exports[`post a reply: optimistic response 1`] = ` Reply + @@ -1325,6 +1445,21 @@ exports[`post a reply: optimistic response 1`] = ` Reply + @@ -1403,6 +1538,21 @@ exports[`post a reply: optimistic response 1`] = ` Reply + @@ -1481,6 +1631,21 @@ exports[`post a reply: optimistic response 1`] = ` Reply + @@ -1559,6 +1724,21 @@ exports[`post a reply: optimistic response 1`] = ` Reply +
+ > + +
@@ -2104,6 +2300,21 @@ exports[`post a reply: server response 1`] = ` Reply + @@ -2182,6 +2393,21 @@ exports[`post a reply: server response 1`] = ` Reply + @@ -2260,6 +2486,21 @@ exports[`post a reply: server response 1`] = ` Reply + @@ -2338,6 +2579,21 @@ exports[`post a reply: server response 1`] = ` Reply + @@ -2416,6 +2672,21 @@ exports[`post a reply: server response 1`] = ` Reply + @@ -2494,6 +2765,21 @@ exports[`post a reply: server response 1`] = ` Reply +
+ > + +
@@ -2902,6 +3204,21 @@ exports[`renders comment stream 1`] = ` Reply + @@ -2980,6 +3297,21 @@ exports[`renders comment stream 1`] = ` Reply + @@ -3058,6 +3390,21 @@ exports[`renders comment stream 1`] = ` Reply + @@ -3136,6 +3483,21 @@ exports[`renders comment stream 1`] = ` Reply + @@ -3214,6 +3576,21 @@ exports[`renders comment stream 1`] = ` Reply + @@ -3292,6 +3669,21 @@ exports[`renders comment stream 1`] = ` Reply +
+ @@ -470,10 +485,10 @@ exports[`post a reply: open reply form 1`] = ` > @@ -508,6 +523,21 @@ exports[`post a reply: open reply form 1`] = ` Reply + @@ -827,6 +857,21 @@ exports[`post a reply: optimistic response 1`] = ` Reply + @@ -1042,6 +1087,21 @@ exports[`post a reply: optimistic response 1`] = ` Reply + @@ -1080,10 +1140,10 @@ exports[`post a reply: optimistic response 1`] = ` > @@ -1118,6 +1178,21 @@ exports[`post a reply: optimistic response 1`] = ` Reply + @@ -1437,6 +1512,21 @@ exports[`post a reply: server response 1`] = ` Reply + @@ -1515,6 +1605,21 @@ exports[`post a reply: server response 1`] = ` Reply + @@ -1553,10 +1658,10 @@ exports[`post a reply: server response 1`] = ` > @@ -1591,6 +1696,21 @@ exports[`post a reply: server response 1`] = ` Reply + @@ -1910,6 +2030,21 @@ exports[`renders comment stream 1`] = ` Reply + @@ -1946,10 +2081,10 @@ exports[`renders comment stream 1`] = ` > @@ -1984,6 +2119,21 @@ exports[`renders comment stream 1`] = ` Reply + diff --git a/src/core/client/stream/test/comments/__snapshots__/renderReplies.spec.tsx.snap b/src/core/client/stream/test/comments/__snapshots__/renderReplies.spec.tsx.snap index cc4ef9116..e527a8b9d 100644 --- a/src/core/client/stream/test/comments/__snapshots__/renderReplies.spec.tsx.snap +++ b/src/core/client/stream/test/comments/__snapshots__/renderReplies.spec.tsx.snap @@ -27,7 +27,7 @@ exports[`renders comment stream 1`] = ` role="tab" type="button" > - ⁨1⁩ Comments + ⁨2⁩ Comments @@ -438,10 +438,10 @@ exports[`renders comment stream 1`] = ` > @@ -512,10 +512,10 @@ exports[`renders comment stream 1`] = ` > @@ -588,10 +588,10 @@ exports[`renders comment stream 1`] = ` > diff --git a/src/core/client/stream/test/comments/__snapshots__/renderStream.spec.tsx.snap b/src/core/client/stream/test/comments/__snapshots__/renderStream.spec.tsx.snap index 714bba9c7..dd39c45e0 100644 --- a/src/core/client/stream/test/comments/__snapshots__/renderStream.spec.tsx.snap +++ b/src/core/client/stream/test/comments/__snapshots__/renderStream.spec.tsx.snap @@ -282,10 +282,10 @@ exports[`renders comment stream 1`] = ` > diff --git a/src/core/client/stream/test/comments/__snapshots__/showAllReplies.spec.tsx.snap b/src/core/client/stream/test/comments/__snapshots__/showAllReplies.spec.tsx.snap index 1043bdc40..233b7810d 100644 --- a/src/core/client/stream/test/comments/__snapshots__/showAllReplies.spec.tsx.snap +++ b/src/core/client/stream/test/comments/__snapshots__/showAllReplies.spec.tsx.snap @@ -286,10 +286,10 @@ exports[`renders comment stream 1`] = ` > @@ -648,10 +648,10 @@ exports[`show all replies 1`] = ` > @@ -722,10 +722,10 @@ exports[`show all replies 1`] = ` > diff --git a/src/core/client/stream/test/comments/editComment.spec.tsx b/src/core/client/stream/test/comments/editComment.spec.tsx index 67684bc6f..c033133b1 100644 --- a/src/core/client/stream/test/comments/editComment.spec.tsx +++ b/src/core/client/stream/test/comments/editComment.spec.tsx @@ -1,9 +1,10 @@ +import sinon from "sinon"; import timekeeper from "timekeeper"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; -import { assets, users } from "../fixtures"; +import { assets, settings, users } from "../fixtures"; import create from "./create"; function createTestRenderer() { @@ -16,10 +17,8 @@ function createTestRenderer() { .withArgs(undefined, { id: assets[0].id, url: null }) .returns(assets[0]) ), - me: createSinonStub( - s => s.throws(), - s => s.withArgs(undefined).returns(users[0]) - ), + me: sinon.stub().returns(users[0]), + settings: sinon.stub().returns(settings), }, Mutation: { editComment: createSinonStub( diff --git a/src/core/client/stream/test/comments/loadMore.spec.tsx b/src/core/client/stream/test/comments/loadMore.spec.tsx index d51ea60fa..bcbde1021 100644 --- a/src/core/client/stream/test/comments/loadMore.spec.tsx +++ b/src/core/client/stream/test/comments/loadMore.spec.tsx @@ -4,7 +4,7 @@ import sinon from "sinon"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; -import { assets, comments } from "../fixtures"; +import { assets, comments, settings } from "../fixtures"; import create from "./create"; let testRenderer: ReactTestRenderer; @@ -66,6 +66,7 @@ beforeEach(() => { ) .returns(assetStub) ), + settings: sinon.stub().returns(settings), }, }; diff --git a/src/core/client/stream/test/comments/permalinkView.spec.tsx b/src/core/client/stream/test/comments/permalinkView.spec.tsx index 180abbe0b..2e0ee1225 100644 --- a/src/core/client/stream/test/comments/permalinkView.spec.tsx +++ b/src/core/client/stream/test/comments/permalinkView.spec.tsx @@ -4,7 +4,7 @@ import sinon from "sinon"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; -import { assets, comments } from "../fixtures"; +import { assets, comments, settings } from "../fixtures"; import create from "./create"; let testRenderer: ReactTestRenderer; @@ -41,6 +41,7 @@ beforeEach(() => { .withArgs(undefined, { id: assetStub.id, url: null }) .returns(assetStub) ), + settings: sinon.stub().returns(settings), }, }; diff --git a/src/core/client/stream/test/comments/permalinkViewAssetNotFound.spec.tsx b/src/core/client/stream/test/comments/permalinkViewAssetNotFound.spec.tsx index 9d2df883e..84d161158 100644 --- a/src/core/client/stream/test/comments/permalinkViewAssetNotFound.spec.tsx +++ b/src/core/client/stream/test/comments/permalinkViewAssetNotFound.spec.tsx @@ -1,7 +1,9 @@ import { ReactTestRenderer } from "react-test-renderer"; +import sinon from "sinon"; import { timeout } from "talk-common/utils"; +import { settings } from "../fixtures"; import create from "./create"; let testRenderer: ReactTestRenderer; @@ -10,6 +12,7 @@ beforeEach(() => { Query: { comment: () => null, asset: () => null, + settings: sinon.stub().returns(settings), }, }; diff --git a/src/core/client/stream/test/comments/permalinkViewCommentNotFound.spec.tsx b/src/core/client/stream/test/comments/permalinkViewCommentNotFound.spec.tsx index 38a4fe066..8340ab39e 100644 --- a/src/core/client/stream/test/comments/permalinkViewCommentNotFound.spec.tsx +++ b/src/core/client/stream/test/comments/permalinkViewCommentNotFound.spec.tsx @@ -4,7 +4,7 @@ import sinon from "sinon"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; -import { assets, comments } from "../fixtures"; +import { assets, comments, settings } from "../fixtures"; import create from "./create"; let testRenderer: ReactTestRenderer; @@ -38,6 +38,7 @@ beforeEach(() => { .withArgs(undefined, { id: assetStub.id, url: null }) .returns(assetStub) ), + settings: sinon.stub().returns(settings), }, }; diff --git a/src/core/client/stream/test/comments/postComment.spec.tsx b/src/core/client/stream/test/comments/postComment.spec.tsx index 133f0976e..35ce4c4f2 100644 --- a/src/core/client/stream/test/comments/postComment.spec.tsx +++ b/src/core/client/stream/test/comments/postComment.spec.tsx @@ -1,18 +1,26 @@ import { ReactTestRenderer } from "react-test-renderer"; +import sinon from "sinon"; import timekeeper from "timekeeper"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; -import { assets, users } from "../fixtures"; +import { assets, baseComment, settings, users } from "../fixtures"; import create from "./create"; let testRenderer: ReactTestRenderer; beforeEach(() => { const resolvers = { Query: { - asset: createSinonStub(s => s.throws(), s => s.returns(assets[0])), - me: createSinonStub(s => s.throws(), s => s.returns(users[0])), + settings: sinon.stub().returns(settings), + me: sinon.stub().returns(users[0]), + asset: createSinonStub( + s => s.throws(), + s => + s + .withArgs(undefined, { id: assets[0].id, url: null }) + .returns(assets[0]) + ), }, Mutation: { createComment: createSinonStub( @@ -31,15 +39,10 @@ beforeEach(() => { edge: { cursor: null, node: { + ...baseComment, id: "comment-x", author: users[0], body: "Hello world! (from server)", - createdAt: "2018-07-06T18:24:00.000Z", - editing: { - edited: false, - editableUntil: "2018-07-06T18:24:30.000Z", - }, - replies: { edges: [], pageInfo: {} }, }, }, clientMutationId: "0", @@ -71,7 +74,7 @@ it("post a comment", async () => { .findByProps({ inputId: "comments-postCommentForm-field" }) .props.onChange({ html: "Hello world!" }); - timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z")); + timekeeper.freeze(new Date(baseComment.createdAt)); testRenderer.root .findByProps({ id: "comments-postCommentForm-form" }) diff --git a/src/core/client/stream/test/comments/postLocalReply.spec.tsx b/src/core/client/stream/test/comments/postLocalReply.spec.tsx index f0e5c8949..3e665cf80 100644 --- a/src/core/client/stream/test/comments/postLocalReply.spec.tsx +++ b/src/core/client/stream/test/comments/postLocalReply.spec.tsx @@ -1,21 +1,31 @@ import { ReactTestRenderer } from "react-test-renderer"; +import sinon from "sinon"; import timekeeper from "timekeeper"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; -import { assetWithDeepestReplies, users } from "../fixtures"; +import { + assetWithDeepestReplies, + baseComment, + settings, + users, +} from "../fixtures"; import create from "./create"; let testRenderer: ReactTestRenderer; beforeEach(() => { const resolvers = { Query: { + settings: sinon.stub().returns(settings), + me: sinon.stub().returns(users[0]), asset: createSinonStub( s => s.throws(), - s => s.returns(assetWithDeepestReplies) + s => + s + .withArgs(undefined, { id: assetWithDeepestReplies.id, url: null }) + .returns(assetWithDeepestReplies) ), - me: createSinonStub(s => s.throws(), s => s.returns(users[0])), }, Mutation: { createComment: createSinonStub( @@ -34,18 +44,10 @@ beforeEach(() => { edge: { cursor: null, node: { + ...baseComment, id: "comment-x", author: users[0], body: "Hello world! (from server)", - createdAt: "2018-07-06T18:24:00.000Z", - replies: { - edges: [], - pageInfo: { endCursor: null, hasNextPage: false }, - }, - editing: { - edited: false, - editableUntil: "2018-07-06T18:24:30.000Z", - }, }, }, clientMutationId: "0", @@ -92,7 +94,7 @@ it("post a reply", async () => { }) .props.onChange({ html: "Hello world!" }); - timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z")); + timekeeper.freeze(new Date(baseComment.createdAt)); testRenderer.root .findByProps({ id: "comments-replyCommentForm-form-comment-with-deepest-replies-5", diff --git a/src/core/client/stream/test/comments/postReply.spec.tsx b/src/core/client/stream/test/comments/postReply.spec.tsx index 6f75b57a8..ee7b34566 100644 --- a/src/core/client/stream/test/comments/postReply.spec.tsx +++ b/src/core/client/stream/test/comments/postReply.spec.tsx @@ -1,18 +1,26 @@ import { ReactTestRenderer } from "react-test-renderer"; +import sinon from "sinon"; import timekeeper from "timekeeper"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; -import { assets, users } from "../fixtures"; +import { assets, baseComment, settings, users } from "../fixtures"; import create from "./create"; let testRenderer: ReactTestRenderer; beforeEach(() => { const resolvers = { Query: { - asset: createSinonStub(s => s.throws(), s => s.returns(assets[0])), - me: createSinonStub(s => s.throws(), s => s.returns(users[0])), + settings: sinon.stub().returns(settings), + me: sinon.stub().returns(users[0]), + asset: createSinonStub( + s => s.throws(), + s => + s + .withArgs(undefined, { id: assets[0].id, url: null }) + .returns(assets[0]) + ), }, Mutation: { createComment: createSinonStub( @@ -31,18 +39,10 @@ beforeEach(() => { edge: { cursor: null, node: { + ...baseComment, id: "comment-x", author: users[0], body: "Hello world! (from server)", - createdAt: "2018-07-06T18:24:00.000Z", - replies: { - edges: [], - pageInfo: { endCursor: null, hasNextPage: false }, - }, - editing: { - edited: false, - editableUntil: "2018-07-06T18:24:30.000Z", - }, }, }, clientMutationId: "0", @@ -84,7 +84,7 @@ it("post a reply", async () => { .findByProps({ inputId: "comments-replyCommentForm-rte-comment-0" }) .props.onChange({ html: "Hello world!" }); - timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z")); + timekeeper.freeze(new Date(baseComment.createdAt)); testRenderer.root .findByProps({ id: "comments-replyCommentForm-form-comment-0" }) .props.onSubmit(); diff --git a/src/core/client/stream/test/comments/renderReplies.spec.tsx b/src/core/client/stream/test/comments/renderReplies.spec.tsx index 11ffcf000..abf029955 100644 --- a/src/core/client/stream/test/comments/renderReplies.spec.tsx +++ b/src/core/client/stream/test/comments/renderReplies.spec.tsx @@ -1,9 +1,10 @@ import { ReactTestRenderer } from "react-test-renderer"; +import sinon from "sinon"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; -import { assetWithDeepReplies } from "../fixtures"; +import { assetWithDeepReplies, settings } from "../fixtures"; import create from "./create"; let testRenderer: ReactTestRenderer; @@ -17,6 +18,7 @@ beforeEach(() => { .withArgs(undefined, { id: assetWithDeepReplies.id, url: null }) .returns(assetWithDeepReplies) ), + settings: sinon.stub().returns(settings), }, }; diff --git a/src/core/client/stream/test/comments/renderStream.spec.tsx b/src/core/client/stream/test/comments/renderStream.spec.tsx index 6a104ea6e..301fc57dd 100644 --- a/src/core/client/stream/test/comments/renderStream.spec.tsx +++ b/src/core/client/stream/test/comments/renderStream.spec.tsx @@ -1,9 +1,10 @@ import { ReactTestRenderer } from "react-test-renderer"; +import sinon from "sinon"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; -import { assets } from "../fixtures"; +import { assets, settings } from "../fixtures"; import create from "./create"; let testRenderer: ReactTestRenderer; @@ -17,6 +18,7 @@ beforeEach(() => { .withArgs(undefined, { id: assets[0].id, url: null }) .returns(assets[0]) ), + settings: sinon.stub().returns(settings), }, }; diff --git a/src/core/client/stream/test/comments/showAllReplies.spec.tsx b/src/core/client/stream/test/comments/showAllReplies.spec.tsx index ac1c699e2..b5122028d 100644 --- a/src/core/client/stream/test/comments/showAllReplies.spec.tsx +++ b/src/core/client/stream/test/comments/showAllReplies.spec.tsx @@ -4,7 +4,7 @@ import sinon from "sinon"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; -import { assets, comments } from "../fixtures"; +import { assets, comments, settings } from "../fixtures"; import create from "./create"; let testRenderer: ReactTestRenderer; @@ -65,6 +65,7 @@ beforeEach(() => { const resolvers = { Query: { + settings: sinon.stub().returns(settings), comment: createSinonStub( s => s.throws(), s => s.withArgs(undefined, { id: commentStub.id }).returns(commentStub) diff --git a/src/core/client/stream/test/comments/showConversation.spec.tsx b/src/core/client/stream/test/comments/showConversation.spec.tsx index ec2752228..f9fb2f13e 100644 --- a/src/core/client/stream/test/comments/showConversation.spec.tsx +++ b/src/core/client/stream/test/comments/showConversation.spec.tsx @@ -4,7 +4,7 @@ import sinon from "sinon"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; -import { assetWithDeepestReplies, comments } from "../fixtures"; +import { assetWithDeepestReplies, comments, settings } from "../fixtures"; import create from "./create"; let testRenderer: ReactTestRenderer; @@ -25,6 +25,7 @@ beforeEach(() => { id: "comment-with-deepest-replies-5", }) ), + settings: sinon.stub().returns(settings), }, }; diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index dc58ac4b8..720b28c25 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -1,3 +1,11 @@ +export const settings = { + reaction: { + icon: "thumb_up", + label: "Respect", + labelActive: "Respected", + }, +}; + export const users = [ { id: "user-0", @@ -13,106 +21,67 @@ export const users = [ }, ]; +export const baseComment = { + author: users[0], + body: "Comment Body", + createdAt: "2018-07-06T18:24:00.000Z", + replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, + replyCount: 0, + editing: { + edited: false, + editableUntil: "2018-07-06T18:24:30.000Z", + }, + actionCounts: { + reaction: { + total: 0, + }, + }, +}; + export const comments = [ { + ...baseComment, id: "comment-0", author: users[0], body: "Joining Too", - createdAt: "2018-07-06T18:24:00.000Z", - replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, - replyCount: 0, - editing: { - edited: false, - editableUntil: "2018-07-06T18:24:30.000Z", - }, }, { + ...baseComment, id: "comment-1", author: users[1], body: "What's up?", - createdAt: "2018-07-06T18:20:00.000Z", - replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, - replyCount: 0, - editing: { - edited: false, - editableUntil: "2018-07-06T18:20:30.000Z", - }, }, { + ...baseComment, id: "comment-2", author: users[2], body: "Hey!", - createdAt: "2018-07-06T18:14:00.000Z", - replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, - replyCount: 0, - editing: { - edited: false, - editableUntil: "2018-07-06T18:14:30.000Z", - }, }, { + ...baseComment, id: "comment-3", author: users[2], body: "Comment Body 3", - createdAt: "2018-07-06T18:14:00.000Z", - replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, - replyCount: 0, - editing: { - edited: false, - editableUntil: "2018-07-06T18:14:30.000Z", - }, }, { + ...baseComment, id: "comment-4", author: users[2], body: "Comment Body 4", - createdAt: "2018-07-06T18:14:00.000Z", - replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, - replyCount: 0, - editing: { - edited: false, - editableUntil: "2018-07-06T18:14:30.000Z", - }, }, { + ...baseComment, id: "comment-5", author: users[2], body: "Comment Body 5", - createdAt: "2018-07-06T18:14:00.000Z", - replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, - replyCount: 0, - editing: { - edited: false, - editableUntil: "2018-07-06T18:14:30.000Z", - }, - }, -]; - -export const assets = [ - { - id: "asset-1", - url: "http://localhost/assets/asset-1", - isClosed: false, - commentCounts: { - totalVisible: 2, - }, - comments: { - edges: [ - { node: comments[0], cursor: comments[0].createdAt }, - { node: comments[1], cursor: comments[1].createdAt }, - ], - pageInfo: { - hasNextPage: false, - }, - }, }, ]; export const commentWithReplies = { + ...baseComment, id: "comment-with-replies", author: users[0], body: "I like yoghurt", - createdAt: "2018-07-06T18:24:00.000Z", replies: { edges: [ { node: comments[3], cursor: comments[3].createdAt }, @@ -123,17 +92,13 @@ export const commentWithReplies = { }, }, replyCount: 2, - editing: { - edited: false, - editableUntil: "2018-07-06T18:24:30.000Z", - }, }; export const commentWithDeepReplies = { + ...baseComment, id: "comment-with-deep-replies", author: users[0], body: "I like yoghurt", - createdAt: "2018-07-06T18:24:00.000Z", replies: { edges: [ { node: commentWithReplies, cursor: commentWithReplies.createdAt }, @@ -144,120 +109,76 @@ export const commentWithDeepReplies = { }, }, replyCount: 2, - editing: { - edited: false, - editableUntil: "2018-07-06T18:24:30.000Z", - }, -}; - -export const assetWithReplies = { - id: "asset-with-replies", - url: "http://localhost/assets/asset-with-replies", - isClosed: false, - comments: { - edges: [ - { node: comments[0], cursor: comments[0].createdAt }, - { node: commentWithReplies, cursor: commentWithReplies.createdAt }, - ], - pageInfo: { - hasNextPage: false, - }, - }, - commentCounts: { - totalVisible: 1, - }, -}; - -export const assetWithDeepReplies = { - id: "asset-with-deep-replies", - url: "http://localhost/assets/asset-with-replies", - isClosed: false, - comments: { - edges: [ - { node: comments[0], cursor: comments[0].createdAt }, - { - node: commentWithDeepReplies, - cursor: commentWithDeepReplies.createdAt, - }, - ], - pageInfo: { - hasNextPage: false, - }, - }, - commentCounts: { - totalVisible: 1, - }, }; export const commentWithDeepestReplies = { - ...commentWithReplies, + ...baseComment, id: "comment-with-deepest-replies", body: "body 0", replyCount: 1, replies: { - ...commentWithReplies.replies, + ...baseComment.replies, edges: [ { - cursor: commentWithReplies.createdAt, + cursor: baseComment.createdAt, node: { - ...commentWithReplies, + ...baseComment, id: "comment-with-deepest-replies-1", body: "body 1", replyCount: 1, replies: { - ...commentWithReplies.replies, + ...baseComment.replies, edges: [ { - cursor: commentWithReplies.createdAt, + cursor: baseComment.createdAt, node: { - ...commentWithReplies, + ...baseComment, id: "comment-with-deepest-replies-2", body: "body 2", replyCount: 1, replies: { - ...commentWithReplies.replies, + ...baseComment.replies, edges: [ { - cursor: commentWithReplies.createdAt, + cursor: baseComment.createdAt, node: { - ...commentWithReplies, + ...baseComment, id: "comment-with-deepest-replies-3", body: "body 3", replyCount: 1, replies: { - ...commentWithReplies.replies, + ...baseComment.replies, edges: [ { - cursor: commentWithReplies.createdAt, + cursor: baseComment.createdAt, node: { - ...commentWithReplies, + ...baseComment, id: "comment-with-deepest-replies-4", body: "body 4", replyCount: 1, replies: { - ...commentWithReplies.replies, + ...baseComment.replies, edges: [ { - cursor: commentWithReplies.createdAt, + cursor: baseComment.createdAt, node: { - ...commentWithReplies, + ...baseComment, id: "comment-with-deepest-replies-5", body: "body 5", replyCount: 1, replies: { - ...commentWithReplies.replies, + ...baseComment.replies, edges: [ { - cursor: - commentWithReplies.createdAt, + cursor: baseComment.createdAt, node: { - ...commentWithReplies, + ...baseComment, id: "comment-with-deepest-replies-6", body: "body 6", replyCount: 1, replies: { - ...commentWithReplies.replies, + ...baseComment.replies, edges: [], }, }, @@ -286,10 +207,82 @@ export const commentWithDeepestReplies = { }, }; +export const baseAsset = { + isClosed: false, + comments: { + edges: [], + pageInfo: { + hasNextPage: false, + }, + }, + commentCounts: { + totalVisible: 0, + }, +}; + +export const assets = [ + { + ...baseAsset, + id: "asset-1", + url: "http://localhost/assets/asset-1", + comments: { + edges: [ + { node: comments[0], cursor: comments[0].createdAt }, + { node: comments[1], cursor: comments[1].createdAt }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + commentCounts: { + totalVisible: 2, + }, + }, +]; + +export const assetWithReplies = { + ...baseAsset, + id: "asset-with-replies", + url: "http://localhost/assets/asset-with-replies", + comments: { + edges: [ + { node: comments[0], cursor: comments[0].createdAt }, + { node: commentWithReplies, cursor: commentWithReplies.createdAt }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + commentCounts: { + totalVisible: 2, + }, +}; + +export const assetWithDeepReplies = { + ...baseAsset, + id: "asset-with-deep-replies", + url: "http://localhost/assets/asset-with-replies", + comments: { + edges: [ + { node: comments[0], cursor: comments[0].createdAt }, + { + node: commentWithDeepReplies, + cursor: commentWithDeepReplies.createdAt, + }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + commentCounts: { + totalVisible: 2, + }, +}; + export const assetWithDeepestReplies = { + ...baseAsset, id: "asset-with-deepest-replies", url: "http://localhost/assets/asset-with-replies", - isClosed: false, comments: { edges: [ { diff --git a/src/core/client/ui/components/Popover/Popover.css b/src/core/client/ui/components/Popover/Popover.css index 82879b01b..3c7488ce6 100644 --- a/src/core/client/ui/components/Popover/Popover.css +++ b/src/core/client/ui/components/Popover/Popover.css @@ -1,4 +1,7 @@ .root { +} + +.popover { background: var(--palette-common-white); border: 1px solid var(--palette-grey-lighter); box-sizing: border-box; diff --git a/src/core/client/ui/components/Popover/Popover.tsx b/src/core/client/ui/components/Popover/Popover.tsx index 7708b86aa..a1ad1122e 100644 --- a/src/core/client/ui/components/Popover/Popover.tsx +++ b/src/core/client/ui/components/Popover/Popover.tsx @@ -7,6 +7,9 @@ import { Reference, RefHandler, } from "react-popper"; + +import { withStyles } from "talk-ui/hocs"; + import AriaInfo from "../AriaInfo"; import * as styles from "./Popover.css"; @@ -43,6 +46,7 @@ interface PopoverProps { onClose?: () => void; className?: string; placement?: Placement; + classes: typeof styles; } interface State { @@ -50,7 +54,7 @@ interface State { } class Popover extends React.Component { - public static defaultProps = { + public static defaultProps: Partial = { placement: "top", }; public state: State = { @@ -92,56 +96,61 @@ class Popover extends React.Component { description, className, placement, + classes, } = this.props; const { visible } = this.state; - const popoverClassName = cn(styles.root, className, { - [styles.top]: placement!.startsWith("top"), - [styles.left]: placement!.startsWith("left"), - [styles.right]: placement!.startsWith("right"), - [styles.bottom]: placement!.startsWith("bottom"), + const popoverClassName = cn(classes.popover, { + [classes.top]: placement!.startsWith("top"), + [classes.left]: placement!.startsWith("left"), + [classes.right]: placement!.startsWith("right"), + [classes.bottom]: placement!.startsWith("bottom"), }); return ( - - - {(props: PopperArrowProps) => - children({ - forwardRef: props.ref, - toggleVisibility: this.toggleVisibility, - visible: this.state.visible, - }) - } - - - {(props: PopperArrowProps) => ( -
- {description} - {visible && ( -
- {typeof body === "function" - ? body({ - toggleVisibility: this.toggleVisibility, - visible: this.state.visible, - }) - : body} -
- )} -
- )} -
-
+
+ + + {(props: PopperArrowProps) => + children({ + forwardRef: props.ref, + toggleVisibility: this.toggleVisibility, + visible: this.state.visible, + }) + } + + + {(props: PopperArrowProps) => ( +
+ {description} + {visible && ( +
+ {typeof body === "function" + ? body({ + toggleVisibility: this.toggleVisibility, + visible: this.state.visible, + }) + : body} +
+ )} +
+ )} +
+
+
); } } -export default Popover; +const enhanced = withStyles(styles)(Popover); + +export default enhanced; diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 034f6bbd6..baaae0a9c 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -97,7 +97,7 @@ function setupViews(options: AppOptions) { const { parent } = options; // configure the default views directory. - const views = path.join(__dirname, "..", "..", "..", "static"); + const views = path.join(__dirname, "..", "..", "..", "..", "dist", "static"); parent.set("views", views); // Reconfigure nunjucks. diff --git a/src/core/server/app/middleware/error.ts b/src/core/server/app/middleware/error.ts index b7be782cb..8eadfc187 100644 --- a/src/core/server/app/middleware/error.ts +++ b/src/core/server/app/middleware/error.ts @@ -9,15 +9,9 @@ export const errorHandler: ErrorRequestHandler = (err, req, res, next) => { // TODO: handle better when we improve errors. if (err.message === "not found") { // TODO: handle better when we improve errors. - res - .status(404) - .send(err.message) - .end(); + res.status(404).send(err.message); } else { // TODO: handle better when we improve errors. - res - .status(500) - .send(err.message) - .end(); + res.status(500).send(err.message); } }; diff --git a/src/core/server/app/middleware/playground.ts b/src/core/server/app/middleware/playground.ts index 1873a0537..0364d10aa 100644 --- a/src/core/server/app/middleware/playground.ts +++ b/src/core/server/app/middleware/playground.ts @@ -1,4 +1,17 @@ +import { RequestHandler } from "express"; import { MiddlewareOptions } from "graphql-playground-html"; import playground from "graphql-playground-middleware-express"; -export default (options: MiddlewareOptions) => playground(options); +export default (options: MiddlewareOptions): RequestHandler => ( + req, + res, + next +) => { + try { + playground(options)(req, res, () => { + // The playground calls next() when it's not supposed to. + }); + } catch (err) { + return next(err); + } +}; diff --git a/src/core/server/app/middleware/serveStatic.ts b/src/core/server/app/middleware/serveStatic.ts index ed21e62a2..418dede24 100644 --- a/src/core/server/app/middleware/serveStatic.ts +++ b/src/core/server/app/middleware/serveStatic.ts @@ -2,7 +2,7 @@ import serveStatic from "express-static-gzip"; import path from "path"; const staticPath = path.resolve( - path.join(__dirname, "..", "..", "..", "..", "static", "assets") + path.join(__dirname, "..", "..", "..", "..", "..", "dist", "static", "assets") ); export default serveStatic(staticPath, { index: false }); diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index f696eb552..cef69f6ac 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -5,8 +5,13 @@ import { AssetToCommentsArgs, CommentToParentsArgs, CommentToRepliesArgs, + GQLActionPresence, GQLCOMMENT_SORT, } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + ACTION_ITEM_TYPE, + retrieveManyUserActionPresence, +} from "talk-server/models/action"; import { Comment, retrieveCommentAssetConnection, @@ -38,6 +43,17 @@ export default (ctx: Context) => ({ comment: new DataLoader((ids: string[]) => retrieveManyComments(ctx.mongo, ctx.tenant.id, ids) ), + retrieveMyActionPresence: new DataLoader( + (itemIDs: string[]) => + retrieveManyUserActionPresence( + ctx.mongo, + ctx.tenant.id, + // This should only ever be accessed when a user is logged in. + ctx.user!.id, + ACTION_ITEM_TYPE.COMMENTS, + itemIDs + ) + ), forUser: ( userID: string, // Apply the graph schema defaults at the loader. diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts index aeaac70ee..01c734185 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/comment.ts @@ -1,9 +1,23 @@ import TenantContext from "talk-server/graph/tenant/context"; import { + GQLCreateCommentDontAgreeInput, + GQLCreateCommentFlagInput, GQLCreateCommentInput, + GQLCreateCommentReactionInput, + GQLDeleteCommentDontAgreeInput, + GQLDeleteCommentFlagInput, + GQLDeleteCommentReactionInput, GQLEditCommentInput, } from "talk-server/graph/tenant/schema/__generated__/types"; import { create, edit } from "talk-server/services/comments"; +import { + createDontAgree, + createFlag, + createReaction, + deleteDontAgree, + deleteFlag, + deleteReaction, +} from "talk-server/services/comments/actions"; export default (ctx: TenantContext) => ({ create: (input: GQLCreateCommentInput) => @@ -30,4 +44,29 @@ export default (ctx: TenantContext) => ({ }, ctx.req ), + createReaction: (input: GQLCreateCommentReactionInput) => + createReaction(ctx.mongo, ctx.tenant, ctx.user!, { + item_id: input.commentID, + }), + deleteReaction: (input: GQLDeleteCommentReactionInput) => + deleteReaction(ctx.mongo, ctx.tenant, ctx.user!, { + item_id: input.commentID, + }), + createDontAgree: (input: GQLCreateCommentDontAgreeInput) => + createDontAgree(ctx.mongo, ctx.tenant, ctx.user!, { + item_id: input.commentID, + }), + deleteDontAgree: (input: GQLDeleteCommentDontAgreeInput) => + deleteDontAgree(ctx.mongo, ctx.tenant, ctx.user!, { + item_id: input.commentID, + }), + createFlag: (input: GQLCreateCommentFlagInput) => + createFlag(ctx.mongo, ctx.tenant, ctx.user!, { + item_id: input.commentID, + reason: input.reason, + }), + deleteFlag: (input: GQLDeleteCommentFlagInput) => + deleteFlag(ctx.mongo, ctx.tenant, ctx.user!, { + item_id: input.commentID, + }), }); diff --git a/src/core/server/graph/tenant/resolvers/asset.ts b/src/core/server/graph/tenant/resolvers/asset.ts index 95f05daa3..b017c992e 100644 --- a/src/core/server/graph/tenant/resolvers/asset.ts +++ b/src/core/server/graph/tenant/resolvers/asset.ts @@ -1,4 +1,5 @@ import { GQLAssetTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { decodeActionCounts } from "talk-server/models/action"; import { Asset } from "talk-server/models/asset"; const Asset: GQLAssetTypeResolver = { @@ -6,6 +7,7 @@ const Asset: GQLAssetTypeResolver = { ctx.loaders.Comments.forAsset(asset.id, input), // TODO: implement this. isClosed: () => false, + actionCounts: asset => decodeActionCounts(asset.action_counts), commentCounts: asset => asset.comment_counts, }; diff --git a/src/core/server/graph/tenant/resolvers/comment.ts b/src/core/server/graph/tenant/resolvers/comment.ts index b9126b6ad..c7179ac4e 100644 --- a/src/core/server/graph/tenant/resolvers/comment.ts +++ b/src/core/server/graph/tenant/resolvers/comment.ts @@ -3,6 +3,7 @@ import { GQLComment, GQLCommentTypeResolver, } from "talk-server/graph/tenant/schema/__generated__/types"; +import { decodeActionCounts } from "talk-server/models/action"; import { Comment } from "talk-server/models/comment"; import { createConnection } from "talk-server/models/connection"; @@ -24,6 +25,11 @@ const Comment: GQLCommentTypeResolver = { comment.reply_count > 0 ? ctx.loaders.Comments.forParent(comment.asset_id, comment.id, input) : createConnection(), + actionCounts: comment => decodeActionCounts(comment.action_counts), + myActionPresence: (comment, input, ctx) => + ctx.user + ? ctx.loaders.Comments.retrieveMyActionPresence.load(comment.id) + : null, parentCount: comment => comment.parent_id ? comment.grandparent_ids.length + 1 : 0, depth: comment => diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts index 5b7c65dc4..77f0ca109 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/mutation.ts @@ -9,10 +9,10 @@ const Mutation: GQLMutationTypeResolver = { const comment = await ctx.mutators.Comment.create(input); return { edge: { - // (cvle) - // Depending on the sort we can't determine the accurate cursor - // in a performant way, so we return null instead. - // It seems that Relay does not directly use this value... + // NOTE: (cvle) + // Depending on the sort we can't determine the accurate cursor in a + // performant way, so we return null instead. It seems that Relay does + // not directly use this value. cursor: null, node: comment, }, @@ -23,6 +23,30 @@ const Mutation: GQLMutationTypeResolver = { settings: await ctx.mutators.Settings.update(input.settings), clientMutationId: input.clientMutationId, }), + createCommentReaction: async (source, { input }, ctx) => ({ + comment: await ctx.mutators.Comment.createReaction(input), + clientMutationId: input.clientMutationId, + }), + deleteCommentReaction: async (source, { input }, ctx) => ({ + comment: await ctx.mutators.Comment.deleteReaction(input), + clientMutationId: input.clientMutationId, + }), + createCommentDontAgree: async (source, { input }, ctx) => ({ + comment: await ctx.mutators.Comment.createDontAgree(input), + clientMutationId: input.clientMutationId, + }), + deleteCommentDontAgree: async (source, { input }, ctx) => ({ + comment: await ctx.mutators.Comment.deleteDontAgree(input), + clientMutationId: input.clientMutationId, + }), + createCommentFlag: async (source, { input }, ctx) => ({ + comment: await ctx.mutators.Comment.createFlag(input), + clientMutationId: input.clientMutationId, + }), + deleteCommentFlag: async (source, { input }, ctx) => ({ + comment: await ctx.mutators.Comment.deleteFlag(input), + clientMutationId: input.clientMutationId, + }), }; export default Mutation; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index d720468f2..09a6fe6f7 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -30,23 +30,181 @@ scalar Cursor ## Actions ################################################################################ -enum ACTION_TYPE { - FLAG - DONTAGREE +""" +COMMENT_FLAG_REPORTED_REASON is a reason that is reported by a User on a +Comment. +""" +enum COMMENT_FLAG_REPORTED_REASON { + """ + COMMENT_REPORTED_OFFENSIVE is used when a User reported a Comment as being + offensive. + """ + COMMENT_REPORTED_OFFENSIVE + + """ + COMMENT_REPORTED_SPAM is used when a User reported a Comment as appearing like + spam. + """ + COMMENT_REPORTED_SPAM } -enum ACTION_ITEM_TYPE { - COMMENTS +""" +COMMENT_FLAG_DETECTED_REASON is a reason that is detected by the system on a +Comment. +""" +enum COMMENT_FLAG_DETECTED_REASON { + """ + COMMENT_DETECTED_TOXIC is used when the Comment was detected as being toxic by + the system. + """ + COMMENT_DETECTED_TOXIC + + """ + COMMENT_DETECTED_SPAM is used when the Comment was detected as having spam by + the system. + """ + COMMENT_DETECTED_SPAM + + """ + COMMENT_DETECTED_BODY_COUNT is used when the Comment was detected as exceeding + the body length by the system. + """ + COMMENT_DETECTED_BODY_COUNT + + """ + COMMENT_DETECTED_TRUST is used when the Comment being left was done by a User + that has a low karma/trust score. + """ + COMMENT_DETECTED_TRUST + + """ + COMMENT_DETECTED_LINKS is used when the Comment was detected as containing + links. + """ + COMMENT_DETECTED_LINKS + + """ + COMMENT_DETECTED_BANNED_WORD is used when the Comment was detected as + containing a banned word. + """ + COMMENT_DETECTED_BANNED_WORD + + """ + COMMENT_DETECTED_SUSPECT_WORD is used when the Comment was detected as + containing a suspect word. + """ + COMMENT_DETECTED_SUSPECT_WORD } -enum ACTION_GROUP { - SPAM_COMMENT - TOXIC_COMMENT - BODY_COUNT - TRUST - LINKS - BANNED_WORD - SUSPECT_WORD +""" +COMMENT_FLAG_REASON is the union of the COMMENT_FLAG_REPORTED_REASON +and COMMENT_FLAG_DETECTED_REASON types. +""" +enum COMMENT_FLAG_REASON { + COMMENT_REPORTED_OFFENSIVE + COMMENT_REPORTED_SPAM + COMMENT_DETECTED_TOXIC + COMMENT_DETECTED_SPAM + COMMENT_DETECTED_BODY_COUNT + COMMENT_DETECTED_TRUST + COMMENT_DETECTED_LINKS + COMMENT_DETECTED_BANNED_WORD + COMMENT_DETECTED_SUSPECT_WORD +} + +""" +ReactionActionCounts stores all the counts for the counts for the reaction +action on a given item. +""" +type ReactionActionCounts { + """ + total is the total number of reactions against a given item. + """ + total: Int! +} + +""" +DontAgreeActionCounts stores all the counts for the counts for the dontAgree +action on a given item. +""" +type DontAgreeActionCounts { + """ + total is the total number of dontAgree actions against a given item. + """ + total: Int! +} + +type FlagReasonActionCounts { + COMMENT_REPORTED_OFFENSIVE: Int! + COMMENT_REPORTED_SPAM: Int! + COMMENT_DETECTED_TOXIC: Int! + COMMENT_DETECTED_SPAM: Int! + COMMENT_DETECTED_BODY_COUNT: Int! + COMMENT_DETECTED_TRUST: Int! + COMMENT_DETECTED_LINKS: Int! + COMMENT_DETECTED_BANNED_WORD: Int! + COMMENT_DETECTED_SUSPECT_WORD: Int! +} + +""" +FlagActionCounts stores all the counts for the counts for the flag action on a +given item and the reason counts. +""" +type FlagActionCounts { + """ + total is the total number of flags against a given item. + """ + total: Int! + + """ + reasons stores the counts for the various reasons that an item could be + flagged for. + """ + reasons: FlagReasonActionCounts! +} + +""" +ActionCounts returns the counts of each action for an item. +""" +type ActionCounts { + """ + reaction returns the counts for the reaction action on an item. + """ + reaction: ReactionActionCounts! + + """ + dontAgree returns the counts for the dontAgree action on an item. This edge is + restricted to administrators and moderators. + """ + dontAgree: DontAgreeActionCounts! @auth(roles: [ADMIN, MODERATOR]) + + """ + flag returns the counts for the flag action on an item. This edge is + restricted to administrators and moderators. + """ + flag: FlagActionCounts! @auth(roles: [ADMIN, MODERATOR]) +} + +""" +ActionPresence returns whether or not a given item has one of the following +actions on it. This is typically used to determine if a given user has left +one of the following actions. +""" +type ActionPresence { + """ + reaction is true when a reaction action was left on an item. + """ + reaction: Boolean! + + """ + dontAgree is true when a dontAgree action was left on an item. + """ + dontAgree: Boolean! + + """ + flag is true when a flag action was left on an item. + """ + flag: Boolean! } ################################################################################ @@ -331,6 +489,42 @@ type Email { fromAddress: String } +################################################################################ +## ReactionConfiguration +################################################################################ + +""" +ReactionConfiguration stores the configuration for reactions used across this +Tenant. +""" +type ReactionConfiguration { + """ + icon is the string representing the icon to be used for the reactions. + """ + icon: String! + + """ + + """ + iconActive: String + + """ + label is the string placed beside the reaction icon to provide better context. + """ + label: String! + + """ + labelActive is the string placed beside the reaction icon to provide better + context when it has been selected. + """ + labelActive: String + + """ + color is the hex color code that can be used to change the color of the button. + """ + color: String +} + ################################################################################ ## Settings ################################################################################ @@ -439,22 +633,22 @@ type Settings { """ organizationName is the name of the organization. """ - organizationName: String + organizationName: String! """ organizationContactEmail is the email of the organization. """ - organizationContactEmail: String + organizationContactEmail: String! """ email is the set of credentials and settings associated with the organization. """ - email: Email @auth(roles: [ADMIN]) + email: Email! @auth(roles: [ADMIN]) """ wordlist will return a given list of words. """ - wordlist: Wordlist @auth(roles: [ADMIN, MODERATOR]) + wordlist: Wordlist! @auth(roles: [ADMIN, MODERATOR]) """ auth contains all the settings related to authentication and authorization. @@ -464,13 +658,18 @@ type Settings { """ integrations contains all the external integrations that can be enabled. """ - integrations: ExternalIntegrations @auth(roles: [ADMIN]) + integrations: ExternalIntegrations! @auth(roles: [ADMIN]) """ karma is the set of settings related to how user Trust and Karma are handled. """ - karma: Karma @auth(roles: [ADMIN, MODERATOR]) + karma: Karma! @auth(roles: [ADMIN, MODERATOR]) + + """ + reaction specifies the configuration for reactions. + """ + reaction: ReactionConfiguration! } ################################################################################ @@ -697,6 +896,17 @@ type Comment { """ editing: EditInfo! + """ + actionCounts stores the counts of all the actions for the Comment. + """ + actionCounts: ActionCounts! + + """ + myActionPresence stores the presense information for all the actions + left by the current User on this Comment. + """ + myActionPresence: ActionPresence + """ asset is the Asset that the Comment was written on. """ @@ -838,6 +1048,12 @@ type Asset { after: Cursor ): CommentsConnection! + """ + actionCounts stores the counts of all the actions against this Asset and it's + Comments. + """ + actionCounts: ActionCounts! @auth(roles: [ADMIN, MODERATOR]) + """ author is the authors listed in the meta tags for the Asset. """ @@ -1388,6 +1604,179 @@ type UpdateSettingsPayload { clientMutationId: String! } +################## +## createCommentReaction +################## + +input CreateCommentReactionInput { + """ + commentID is the Comment's ID that we want to create a Reaction on. + """ + commentID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type CreateCommentReactionPayload { + """ + comment is the Comment that the Reaction was created on. + """ + comment: Comment + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +## deleteCommentReaction +################## + +input DeleteCommentReactionInput { + """ + commentID is the Comment's ID that we want to delete a Reaction on. + """ + commentID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type DeleteCommentReactionPayload { + """ + comment is the Comment that the Reaction was deleted on. + """ + comment: Comment + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +## createCommentDontAgree +################## + +input CreateCommentDontAgreeInput { + """ + commentID is the Comment's ID that we want to create a DontAgree on. + """ + commentID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type CreateCommentDontAgreePayload { + """ + comment is the Comment that the DontAgree was created on. + """ + comment: Comment + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +## deleteCommentDontAgree +################## + +input DeleteCommentDontAgreeInput { + """ + commentID is the Comment's ID that we want to delete a DontAgree on. + """ + commentID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type DeleteCommentDontAgreePayload { + """ + comment is the Comment that the DontAgree was deleted on. + """ + comment: Comment + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +## createCommentFlag +################## + +input CreateCommentFlagInput { + """ + commentID is the Comment's ID that we want to create a Flag on. + """ + commentID: ID! + + """ + reason is the selected reason why the Flag is being created. + """ + reason: COMMENT_FLAG_REPORTED_REASON! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type CreateCommentFlagPayload { + """ + comment is the Comment that the Flag was created on. + """ + comment: Comment + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +## deleteCommentFlag +################## + +input DeleteCommentFlagInput { + """ + commentID is the Comment's ID that we want to delete a Flag on. + """ + commentID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type DeleteCommentFlagPayload { + """ + comment is the Comment that the Flag was deleted on. + """ + comment: Comment + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## ## Mutation ################## @@ -1409,6 +1798,52 @@ type Mutation { """ updateSettings(input: UpdateSettingsInput!): UpdateSettingsPayload @auth(roles: [ADMIN, MODERATOR]) + + """ + createCommentReaction will create a Reaction authored by the current logged in + User on a Comment. + """ + createCommentReaction( + input: CreateCommentReactionInput! + ): CreateCommentReactionPayload @auth + + """ + deleteCommentReaction will delete a Reaction authored by the current logged in + User on a Comment if it exists. + """ + deleteCommentReaction( + input: CreateCommentReactionInput! + ): CreateCommentReactionPayload @auth + + """ + createCommentDontAgree will create a DontAgree authored by the current logged in + User on a Comment. + """ + createCommentDontAgree( + input: CreateCommentDontAgreeInput! + ): CreateCommentDontAgreePayload @auth + + """ + deleteCommentDontAgree will delete a DontAgree authored by the current logged in + User on a Comment if it exists. + """ + deleteCommentDontAgree( + input: CreateCommentDontAgreeInput! + ): CreateCommentDontAgreePayload @auth + + """ + createCommentFlag will create a Flag authored by the current logged in User on + a given Comment. + """ + createCommentFlag(input: CreateCommentFlagInput!): CreateCommentFlagPayload + @auth + + """ + deleteCommentFlag will create a Flag authored by the current logged in User on + a given Comment. + """ + deleteCommentFlag(input: DeleteCommentFlagInput!): DeleteCommentFlagPayload + @auth } ################################################################################ diff --git a/src/core/server/graph/tenant/schema/schema.spec.ts b/src/core/server/graph/tenant/schema/schema.spec.ts new file mode 100644 index 000000000..f3aea0007 --- /dev/null +++ b/src/core/server/graph/tenant/schema/schema.spec.ts @@ -0,0 +1,24 @@ +import { + GQLCOMMENT_FLAG_REASON, + GQLFlagReasonActionCounts, +} from "talk-server/graph/tenant/schema/__generated__/types"; + +type ExtractKeys = { [P in keyof T]: P }[keyof T]; +type A = ExtractKeys; +type B = ExtractKeys; + +// These tests ensure that the enums contained in GQLCOMMENT_FLAG_REASON are +// also defined on the GQLFlagReasonActionCounts type. +describe("GQLFlagReasonActionCounts", () => { + it("contains all the flag enum types", () => { + const a: A = "" as any; + let b: B = "" as any; + b = a; + expect(b).toBe(""); + + let c: A = "" as any; + const d: B = "" as any; + c = d; + expect(c).toBe(""); + }); +}); diff --git a/src/core/server/models/__snapshots__/action.spec.ts.snap b/src/core/server/models/__snapshots__/action.spec.ts.snap new file mode 100644 index 000000000..df5d294c3 --- /dev/null +++ b/src/core/server/models/__snapshots__/action.spec.ts.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#decodeActionCounts parses the action counts correctly 1`] = ` +Object { + "DONT_AGREE": 1, + "FLAG": 2, + "FLAG__COMMENT_DETECTED_BANNED_WORD": 1, + "FLAG__COMMENT_DETECTED_BODY_COUNT": 1, + "REACTION": 3, +} +`; + +exports[`#decodeActionCounts parses the action counts correctly 2`] = ` +Object { + "dontAgree": Object { + "total": 1, + }, + "flag": Object { + "reasons": Object { + "COMMENT_DETECTED_BANNED_WORD": 1, + "COMMENT_DETECTED_BODY_COUNT": 1, + "COMMENT_DETECTED_LINKS": 0, + "COMMENT_DETECTED_SPAM": 0, + "COMMENT_DETECTED_SUSPECT_WORD": 0, + "COMMENT_DETECTED_TOXIC": 0, + "COMMENT_DETECTED_TRUST": 0, + "COMMENT_REPORTED_OFFENSIVE": 0, + "COMMENT_REPORTED_SPAM": 0, + }, + "total": 2, + }, + "reaction": Object { + "total": 3, + }, +} +`; + +exports[`#encodeActionCounts generates the action counts correctly 1`] = ` +Object { + "DONT_AGREE": 1, + "FLAG": 2, + "FLAG__COMMENT_DETECTED_BANNED_WORD": 1, + "FLAG__COMMENT_DETECTED_BODY_COUNT": 1, +} +`; diff --git a/src/core/server/models/action.spec.ts b/src/core/server/models/action.spec.ts new file mode 100644 index 000000000..f89f587eb --- /dev/null +++ b/src/core/server/models/action.spec.ts @@ -0,0 +1,132 @@ +import { GQLCOMMENT_FLAG_REASON } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + Action, + ACTION_ITEM_TYPE, + ACTION_TYPE, + decodeActionCounts, + encodeActionCounts, + validateAction, +} from "talk-server/models/action"; + +describe("#encodeActionCounts", () => { + it("generates the action counts correctly", () => { + const actions = [ + { action_type: ACTION_TYPE.DONT_AGREE }, + { + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD, + }, + { + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT, + }, + ]; + const actionCounts = encodeActionCounts(...(actions as Action[])); + + expect(actionCounts).toMatchSnapshot(); + }); +}); + +describe("#decodeActionCounts", () => { + it("parses the action counts correctly", () => { + const actions = [ + { action_type: ACTION_TYPE.REACTION }, + { action_type: ACTION_TYPE.REACTION }, + { action_type: ACTION_TYPE.REACTION }, + { action_type: ACTION_TYPE.DONT_AGREE }, + { + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD, + }, + { + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT, + }, + ]; + + const modelActionCounts = encodeActionCounts(...(actions as Action[])); + + expect(modelActionCounts).toMatchSnapshot(); + + const actionCounts = decodeActionCounts(modelActionCounts); + + expect(actionCounts).toMatchSnapshot(); + }); +}); + +describe("#validateAction", () => { + it("allows a valid action", () => { + const actions = [ + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.REACTION, + }, + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.DONT_AGREE, + }, + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM, + }, + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC, + }, + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT, + }, + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TRUST, + }, + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS, + }, + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD, + }, + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD, + }, + ]; + + for (const action of actions) { + validateAction(action as Action); + } + }); + + it("does not allow an invalid action", () => { + const actions = [ + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.DONT_AGREE, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM, + }, + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.DONT_AGREE, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT, + }, + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.FLAG, + }, + ]; + + for (const action of actions) { + expect(() => validateAction(action as Action)).toThrow(); + } + }); +}); diff --git a/src/core/server/models/action.ts b/src/core/server/models/action.ts new file mode 100644 index 000000000..bcc1c4fd3 --- /dev/null +++ b/src/core/server/models/action.ts @@ -0,0 +1,511 @@ +import Joi from "joi"; +import { camelCase, pick } from "lodash"; +import { Db } from "mongodb"; +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, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { FilterQuery } from "talk-server/models/query"; +import { TenantResource } from "talk-server/models/tenant"; + +function collection(db: Db) { + return db.collection>("actions"); +} + +export enum ACTION_TYPE { + /** + * REACTION corresponds to a reaction to a comment from a user. + */ + REACTION = "REACTION", + + /** + * DONT_AGREE corresponds to when a user marks a given comment that they don't + * agree with. + */ + DONT_AGREE = "DONT_AGREE", + + /** + * FLAG corresponds to a flag action that indicates that the given resource needs + * moderator attention. + */ + FLAG = "FLAG", +} + +export type EncodedActionCounts = Record; + +export interface ActionCountGroup { + total: number; +} + +export enum ACTION_ITEM_TYPE { + COMMENTS = "COMMENTS", +} + +/** + * FLAG_REASON is the reason that a given Flag has been created. + */ +export type FLAG_REASON = + | GQLCOMMENT_FLAG_DETECTED_REASON + | GQLCOMMENT_FLAG_REPORTED_REASON + | GQLCOMMENT_FLAG_REASON; + +export interface Action extends TenantResource { + readonly id: string; + action_type: ACTION_TYPE; + item_type: ACTION_ITEM_TYPE; + item_id: string; + reason?: FLAG_REASON; + user_id?: string; + created_at: Date; + metadata?: Record; +} + +const ActionSchema = [ + // Flags + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.FLAG, + // Only reasons for the flag action will be allowed here, and it must be + // specified. + reason: Object.keys(GQLCOMMENT_FLAG_REASON), + }, + // Don't Agree + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.DONT_AGREE, + }, + // Reaction + { + item_type: ACTION_ITEM_TYPE.COMMENTS, + action_type: ACTION_TYPE.REACTION, + }, +]; + +/** + * validateAction is used to validate that a specific action conforms to the + * expected schema, `ActionSchema`. + */ +export function validateAction( + action: Pick +) { + const { error } = Joi.validate( + // In typescript, this isn't an issue, but when this is transpiled to + // javascript, it will contain additional elements. + pick(action, ["item_type", "action_type", "reason"]), + ActionSchema, + { + presence: "required", + abortEarly: false, + } + ); + if (error) { + // TODO: wrap error? + throw error; + } +} + +export type CreateActionInput = Omit; + +export interface CreateActionResultObject { + /** + * action contains the resultant action that was created. + */ + action: Action; + + /** + * wasUpserted when true, indicates that this action was just newly created. + * When false, it indicates that this action was just looked up, and had + * existed prior to the `createAction` call. + */ + wasUpserted: boolean; +} + +export async function createAction( + mongo: Db, + tenantID: string, + input: CreateActionInput +): Promise { + // Create a new ID for the action. + const id = uuid.v4(); + + // defaults are the properties set by the application when a new action is + // created. + const defaults: Sub = { + id, + tenant_id: tenantID, + created_at: new Date(), + }; + + // Merge the defaults with the input. + const action: Readonly = { + ...defaults, + ...input, + }; + + // This filter ensures that a given user can't flag/respect a given user more + // than once. + const filter: FilterQuery = { + action_type: input.action_type, + item_type: input.item_type, + item_id: input.item_id, + reason: input.reason, + user_id: input.user_id, + }; + + // Create the upsert/update operation. + const update: { $setOnInsert: Readonly } = { + $setOnInsert: action, + }; + + // Insert the action into the database using an upsert operation. + const result = await collection(mongo).findOneAndUpdate(filter, update, { + // We are using this to create a action, so we need to upsert it. + upsert: true, + + // False to return the updated document instead of the original document. + // This lets us detect if the document was updated or not. + returnOriginal: false, + }); + + // Check to see if this was a new action that was upserted, or one was found + // that matched existing records. We are sure here that the record exists + // because we're returning the updated document and performing an upsert + // operation. + + // Because it's relevant that we know that the action was just created, or + // was just looked up, we need to return the action with an object that + // indicates if it was upserted. + const wasUpserted = result.value!.id === id; + + // Return the action that was created/found with a boolean indicating if this + // action was just upserted (and therefore was newly created). + return { + action: result.value!, + wasUpserted, + }; +} + +export async function createActions( + mongo: Db, + tenantID: string, + inputs: CreateActionInput[] +): Promise { + // TODO: (wyattjoh) replace with a batch write. + return Promise.all(inputs.map(input => createAction(mongo, tenantID, input))); +} + +/** + * retrieveManyUserActionPresence returns the action presence for a specific + * user. + */ +export async function retrieveManyUserActionPresence( + mongo: Db, + tenantID: string, + userID: string | null, + itemType: ACTION_ITEM_TYPE, + itemIDs: string[] +): Promise { + const cursor = await collection(mongo).find( + { + tenant_id: tenantID, + user_id: userID, + item_type: itemType, + item_id: { $in: itemIDs }, + }, + { + // We only need the item_id and action_type from the database. + projection: { + item_id: 1, + action_type: 1, + }, + } + ); + + const actions = await cursor.toArray(); + + // For each of the actions returned by the query, group the actions by the + // item id. Then compute the action presence for each of the actions. + return itemIDs + .map(itemID => actions.filter(action => action.item_id === itemID)) + .map(itemActions => + itemActions.reduce( + (actionPresence, { action_type }) => ({ + ...actionPresence, + [camelCase(action_type)]: true, + }), + { + reaction: false, + dontAgree: false, + flag: false, + } + ) + ); +} + +export type DeleteActionInput = Pick< + Action, + "action_type" | "item_type" | "item_id" | "reason" | "user_id" +>; + +/** + * The result returned by `deleteAction`. + */ +export interface DeletedActionResultObject { + /** + * action is the action that was deleted. + */ + action?: Action; + + /** + * wasDeleted is true when the action that was supposed to be deleted was + * actually deleted. + */ + wasDeleted: boolean; +} + +/** + * deleteAction will delete the action based on the form of the action rather + * than a specific action by ID. + */ +export async function deleteAction( + mongo: Db, + tenantID: string, + input: DeleteActionInput +): Promise { + // Extract the filter parameters. + const filter: FilterQuery = { + tenant_id: tenantID, + action_type: input.action_type, + item_type: input.item_type, + item_id: input.item_id, + user_id: input.user_id, + }; + + // Only add the reason to the filter if it's been specified, otherwise we'll + // never match a Flag that has an unspecified reason. + if (input.reason) { + filter.reason = input.reason; + } + + // Remove the action from the database, returning the action that was deleted. + const result = await collection(mongo).findOneAndDelete(filter); + return { + action: result.value, + wasDeleted: Boolean(result.ok && result.value), + }; +} + +/** + * ACTION_COUNT_JOIN_CHAR is the character that is used to separate the reason + * from the action type when storing the action counts in the models. + */ +export const ACTION_COUNT_JOIN_CHAR = "__"; + +/** + * encodeActionCounts will take a list of actions, and generate action counts + * from it. + * + * @param actions list of actions to generate the action counts from + */ +export function encodeActionCounts(...actions: Action[]): EncodedActionCounts { + const actionCounts: EncodedActionCounts = {}; + + // Loop over the actions, and increment them. + for (const action of actions) { + for (const key of encodeActionCountKeys(action)) { + if (key in actionCounts) { + actionCounts[key]++; + } else { + actionCounts[key] = 1; + } + } + } + + return actionCounts; +} + +/** + * invertEncodedActionCounts will allow inverting of the action count object. + * + * @param actionCounts the encoded action counts to invert + */ +export function invertEncodedActionCounts( + actionCounts: EncodedActionCounts +): EncodedActionCounts { + for (const key in actionCounts) { + if (!actionCounts.hasOwnProperty(key)) { + continue; + } + + if (actionCounts[key] > 0) { + actionCounts[key] = -actionCounts[key]; + } + } + + return actionCounts; +} + +/** + * encodeActionCountKeys encodes the action into string keys which represents + * the groupings as seen in `EncodedActionCounts`. + */ +function encodeActionCountKeys(action: Action): string[] { + const keys = [action.action_type as string]; + if (action.reason) { + keys.push( + [action.action_type as string, action.reason as string].join( + ACTION_COUNT_JOIN_CHAR + ) + ); + } + return keys; +} + +interface DecodedActionCountKey { + /** + * actionType stores the action type referenced by the key. + */ + actionType: ACTION_TYPE; + + /** + * reason stores the reason referenced by the key if the actionType is FLAG. + */ + reason?: GQLCOMMENT_FLAG_REASON; +} + +/** + * decodeActionCountGroup will unpack the key as it is encoded into the separate + * actionType and reason. + */ +function decodeActionCountKey(key: string): DecodedActionCountKey { + let actionType: string = ""; + let reason: string = ""; + + if (key.indexOf(ACTION_COUNT_JOIN_CHAR) >= 0) { + const keys = key.split(ACTION_COUNT_JOIN_CHAR); + if (keys.length !== 2) { + throw new Error( + "invalid action count contained more than two components" + ); + } + + actionType = keys[0]; + reason = keys[1]; + + // Validate that the action type is flag. + if (actionType !== ACTION_TYPE.FLAG) { + throw new Error("invalid action type, expected only flag to have reason"); + } + + // Validate that the reason is valid. + if (!reason || !(reason in GQLCOMMENT_FLAG_REASON)) { + throw new Error("expected flag to have a reason that was valid"); + } + } else { + actionType = key; + } + + // Validate that the action type is valid. + if (!actionType || !(actionType in ACTION_TYPE)) { + throw new Error("expected action to have an action type that was valid"); + } + + const result: DecodedActionCountKey = { + actionType: actionType as ACTION_TYPE, + }; + + // Merge in the reason if it's provided. If we got here, we know that the + // reason is a GQLCOMMENT_FLAG_REASON. + if (reason) { + result.reason = reason as GQLCOMMENT_FLAG_REASON; + } + + return result; +} + +/** + * createEmptyActionCounts creates a default/empty set of action counts. + */ +function createEmptyActionCounts(): GQLActionCounts { + return { + reaction: { + total: 0, + }, + dontAgree: { + total: 0, + }, + flag: { + total: 0, + // Note that this isn't type checked.. We force it because TS can't + // understand the reduce. + reasons: Object.keys(GQLCOMMENT_FLAG_REASON).reduce( + (reasons, reason) => ({ + ...reasons, + [reason]: 0, + }), + {} + ) as Record, + }, + }; +} + +/** + * decodeActionCounts will take the encoded action counts and decode them into + * a useable format. + * + * @param encodedActionCounts the action counts to decode + */ +export function decodeActionCounts( + encodedActionCounts: EncodedActionCounts +): GQLActionCounts { + // Default all the action counts to zero. + const actionCounts: GQLActionCounts = createEmptyActionCounts(); + + // Loop over all the encoded action counts to extract each of the action + // counts as they are encoded. + Object.entries(encodedActionCounts).map(([key, count]) => { + // Pull out the action type and the reason from the key. + const { actionType, reason } = decodeActionCountKey(key); + + // Handle the different types and reasons. + incrementActionCounts(actionCounts, actionType, reason, count); + }); + + return actionCounts; +} + +function incrementActionCounts( + actionCounts: GQLActionCounts, + actionType: ACTION_TYPE, + reason: GQLCOMMENT_FLAG_REASON | undefined, + count: number = 1 +) { + switch (actionType) { + case ACTION_TYPE.REACTION: + actionCounts.reaction.total += count; + break; + case ACTION_TYPE.DONT_AGREE: + actionCounts.dontAgree.total += count; + break; + case ACTION_TYPE.FLAG: + // When we have a reason, we are incrementing for that particular reason + // rather than incrementing for the total. If we don't have a reason, we + // just got the updated reason. + if (reason) { + actionCounts.flag.reasons[reason] += count; + } else { + actionCounts.flag.total += count; + } + break; + default: + throw new Error("unexpected action type"); + } + + return actionCounts; +} diff --git a/src/core/server/models/actions.ts b/src/core/server/models/actions.ts deleted file mode 100644 index d162b1300..000000000 --- a/src/core/server/models/actions.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - GQLACTION_GROUP, - GQLACTION_ITEM_TYPE, - GQLACTION_TYPE, -} from "talk-server/graph/tenant/schema/__generated__/types"; - -export type ActionCounts = Record; - -export interface Action { - readonly id: string; - action_type: GQLACTION_TYPE; - item_type: GQLACTION_ITEM_TYPE; - item_id: string; - group_id?: GQLACTION_GROUP; - user_id?: string; - created_at: Date; - metadata?: Record; -} diff --git a/src/core/server/models/asset.ts b/src/core/server/models/asset.ts index b2d5bd866..8c5dc4856 100644 --- a/src/core/server/models/asset.ts +++ b/src/core/server/models/asset.ts @@ -4,6 +4,7 @@ import uuid from "uuid"; import { Omit } from "talk-common/types"; import { dotize } from "talk-common/utils/dotize"; import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; +import { EncodedActionCounts } from "talk-server/models/action"; import { ModerationSettings } from "talk-server/models/settings"; import { TenantResource } from "talk-server/models/tenant"; @@ -37,6 +38,11 @@ export interface Asset extends TenantResource { author?: string; publication_date?: Date; + /** + * action_counts stores all the action counts for all Comment's on this Asset. + */ + action_counts: EncodedActionCounts; + /** * comment_counts stores the different counts for each comment on the Asset * according to their statuses. @@ -72,6 +78,7 @@ export async function upsertAsset( url, tenant_id: tenantID, created_at: now, + action_counts: {}, comment_counts: createEmptyCommentCounts(), }, }; @@ -250,3 +257,31 @@ export async function updateAsset( return result.value || null; } + +/** + * updateAssetActionCounts will update the given comment's action counts on + * the Asset. + * + * @param mongo the database handle + * @param tenantID the id of the Tenant + * @param id the id of the Asset being updated + * @param actionCounts the action counts to merge into the Asset + */ +export async function updateAssetActionCounts( + mongo: Db, + tenantID: string, + id: string, + actionCounts: EncodedActionCounts +) { + const result = await collection(mongo).findOneAndUpdate( + { id, tenant_id: tenantID }, + // Update all the specific action counts that are associated with each of + // the counts. + { $inc: dotize({ action_counts: actionCounts }) }, + // False to return the updated document instead of the original + // document. + { returnOriginal: false } + ); + + return result.value; +} diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index bc47f6c96..25840d96c 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -7,7 +7,7 @@ import { GQLCOMMENT_SORT, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { ActionCounts } from "talk-server/models/actions"; +import { EncodedActionCounts } from "talk-server/models/action"; import { Connection, createConnection, @@ -43,7 +43,7 @@ export interface Comment extends TenantResource { body_history: BodyHistoryItem[]; status: GQLCOMMENT_STATUS; status_history: StatusHistoryItem[]; - action_counts: ActionCounts; + action_counts: EncodedActionCounts; grandparent_ids: string[]; reply_ids: string[]; reply_count: number; @@ -514,10 +514,37 @@ function applyInputToQuery(input: ConnectionInput, query: Query) { } break; case GQLCOMMENT_SORT.RESPECT_DESC: - query.orderBy({ "action_counts.respect": -1, created_at: -1 }); + query.orderBy({ "action_counts.REACTION": -1, created_at: -1 }); if (input.after) { query.after(input.after as number); } break; } } + +/** + * updateCommentActionCounts will update the given comment's action counts. + * + * @param mongo the database handle + * @param tenantID the id of the Tenant + * @param id the id of the Comment being updated + * @param actionCounts the action counts to merge into the Comment + */ +export async function updateCommentActionCounts( + mongo: Db, + tenantID: string, + id: string, + actionCounts: EncodedActionCounts +) { + const result = await collection(mongo).findOneAndUpdate( + { id, tenant_id: tenantID }, + // Update all the specific action counts that are associated with each of + // the counts. + { $inc: dotize({ action_counts: actionCounts }) }, + // False to return the updated document instead of the original + // document. + { returnOriginal: false } + ); + + return result.value; +} diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts index cf5a3e2c7..6434f30c8 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/settings.ts @@ -5,6 +5,7 @@ import { GQLExternalIntegrations, GQLKarma, GQLMODERATION_MODE, + GQLReactionConfiguration, GQLWordlist, } from "talk-server/graph/tenant/schema/__generated__/types"; @@ -101,4 +102,9 @@ export interface Settings extends ModerationSettings { * Various integrations with external services. */ integrations: GQLExternalIntegrations; + + /** + * reaction specifies the configuration for reactions. + */ + reaction: GQLReactionConfiguration; } diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 7aebe644b..325a49d1b 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -49,10 +49,10 @@ export type CreateTenantInput = Pick< /** * create will create a new Tenant. * - * @param db the MongoDB connection used to create the tenant. + * @param mongo the MongoDB connection used to create the tenant. * @param input the customizable parts of the Tenant available during creation */ -export async function createTenant(db: Db, input: CreateTenantInput) { +export async function createTenant(mongo: Db, input: CreateTenantInput) { const defaults: Sub = { // Create a new ID. id: uuid.v4(), @@ -117,6 +117,13 @@ export async function createTenant(db: Db, input: CreateTenantInput) { enabled: false, }, }, + reaction: { + // By default, the standard reaction style will use the Respect with the + // handshake. + label: "Respect", + labelActive: "Respected", + icon: "thumb_up", + }, }; // Create the new Tenant by merging it together with the defaults. @@ -126,7 +133,7 @@ export async function createTenant(db: Db, input: CreateTenantInput) { }; // Insert the Tenant into the database. - await collection(db).insert(tenant); + await collection(mongo).insert(tenant); return tenant; } diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 9b1d04343..a89d3b42a 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -7,7 +7,7 @@ import { GQLUSER_ROLE, GQLUSER_USERNAME_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { ActionCounts } from "talk-server/models/actions"; +import { EncodedActionCounts } from "talk-server/models/action"; import { FilterQuery } from "talk-server/models/query"; import { TenantResource } from "talk-server/models/tenant"; @@ -70,7 +70,7 @@ export interface User extends TenantResource { tokens: Token[]; role: GQLUSER_ROLE; status: UserStatus; - action_counts: ActionCounts; + action_counts: EncodedActionCounts; ignored_users: string[]; created_at: Date; } diff --git a/src/core/server/services/comments/actions.ts b/src/core/server/services/comments/actions.ts new file mode 100644 index 000000000..e788757c9 --- /dev/null +++ b/src/core/server/services/comments/actions.ts @@ -0,0 +1,229 @@ +import { Db } from "mongodb"; + +import { GQLCOMMENT_FLAG_REPORTED_REASON } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + ACTION_ITEM_TYPE, + ACTION_TYPE, + CreateActionInput, + createActions, + deleteAction, + DeleteActionInput, + encodeActionCounts, + invertEncodedActionCounts, +} from "talk-server/models/action"; +import { updateAssetActionCounts } from "talk-server/models/asset"; +import { + retrieveComment, + updateCommentActionCounts, +} from "talk-server/models/comment"; +import { Comment } from "talk-server/models/comment"; +import { Tenant } from "talk-server/models/tenant"; +import { User } from "talk-server/models/user"; + +export async function addCommentActions( + mongo: Db, + tenant: Tenant, + comment: Readonly, + inputs: CreateActionInput[] +): Promise> { + // Create each of the actions, returning each of the action results. + const results = await createActions(mongo, tenant.id, inputs); + + // Get the actions that were upserted, we only want to increment the action + // counts of actions that were just created. + const upsertedActions = results + .filter(({ wasUpserted }) => wasUpserted) + .map(({ action }) => action); + + if (upsertedActions.length > 0) { + // Compute the action counts. + const actionCounts = encodeActionCounts(...upsertedActions); + + // Update the comment action counts here. + const updatedComment = await updateCommentActionCounts( + mongo, + tenant.id, + comment.id, + actionCounts + ); + + // Update the Asset with the updated action counts. + await updateAssetActionCounts( + mongo, + tenant.id, + comment.asset_id, + actionCounts + ); + + // Check to see if there was an actual comment returned (there should + // have been, we just created it!). + if (!updatedComment) { + // TODO: (wyattjoh) return a better error. + throw new Error("could not update comment action counts"); + } + + return updatedComment; + } + + return comment; +} + +async function addCommentAction( + mongo: Db, + tenant: Tenant, + input: CreateActionInput +): Promise> { + const comment = await retrieveComment(mongo, tenant.id, input.item_id); + if (!comment) { + // TODO: replace to match error returned by the models/comments.ts + throw new Error("comment not found"); + } + + return addCommentActions(mongo, tenant, comment, [input]); +} + +export async function removeCommentAction( + mongo: Db, + tenant: Tenant, + input: DeleteActionInput +): Promise> { + // Get the Comment that we are leaving the Action on. + const comment = await retrieveComment(mongo, tenant.id, input.item_id); + if (!comment) { + // TODO: replace to match error returned by the models/comments.ts + throw new Error("comment not found"); + } + + // Create each of the actions, returning each of the action results. + const { wasDeleted, action } = await deleteAction(mongo, tenant.id, input); + if (wasDeleted) { + // Compute the action counts, and invert them (because we're deleting an + // action). + const actionCounts = invertEncodedActionCounts(encodeActionCounts(action!)); + + // Update the comment action counts here. + const updatedComment = await updateCommentActionCounts( + mongo, + tenant.id, + comment.id, + actionCounts + ); + + // Update the Asset with the updated action counts. + await updateAssetActionCounts( + mongo, + tenant.id, + comment.asset_id, + actionCounts + ); + + // Check to see if there was an actual comment returned. + if (!updatedComment) { + // TODO: (wyattjoh) return a better error. + throw new Error("could not update comment action counts"); + } + + return updatedComment; + } + + return comment; +} + +export type CreateCommentReaction = Pick; + +export async function createReaction( + mongo: Db, + tenant: Tenant, + author: User, + input: CreateCommentReaction +) { + return addCommentAction(mongo, tenant, { + action_type: ACTION_TYPE.REACTION, + item_type: ACTION_ITEM_TYPE.COMMENTS, + item_id: input.item_id, + user_id: author.id, + }); +} + +export type DeleteCommentReaction = Pick; + +export async function deleteReaction( + mongo: Db, + tenant: Tenant, + author: User, + input: DeleteCommentReaction +) { + return removeCommentAction(mongo, tenant, { + action_type: ACTION_TYPE.REACTION, + item_type: ACTION_ITEM_TYPE.COMMENTS, + item_id: input.item_id, + user_id: author.id, + }); +} + +export type CreateCommentDontAgree = Pick; + +export async function createDontAgree( + mongo: Db, + tenant: Tenant, + author: User, + input: CreateCommentDontAgree +) { + return addCommentAction(mongo, tenant, { + action_type: ACTION_TYPE.DONT_AGREE, + item_type: ACTION_ITEM_TYPE.COMMENTS, + item_id: input.item_id, + user_id: author.id, + }); +} + +export type DeleteCommentDontAgree = Pick; + +export async function deleteDontAgree( + mongo: Db, + tenant: Tenant, + author: User, + input: DeleteCommentDontAgree +) { + return removeCommentAction(mongo, tenant, { + action_type: ACTION_TYPE.DONT_AGREE, + item_type: ACTION_ITEM_TYPE.COMMENTS, + item_id: input.item_id, + user_id: author.id, + }); +} + +export type CreateCommentFlag = Pick & { + reason: GQLCOMMENT_FLAG_REPORTED_REASON; +}; + +export async function createFlag( + mongo: Db, + tenant: Tenant, + author: User, + input: CreateCommentFlag +) { + return addCommentAction(mongo, tenant, { + action_type: ACTION_TYPE.FLAG, + reason: input.reason, + item_type: ACTION_ITEM_TYPE.COMMENTS, + item_id: input.item_id, + user_id: author.id, + }); +} + +export type DeleteCommentFlag = Pick; + +export async function deleteFlag( + mongo: Db, + tenant: Tenant, + author: User, + input: DeleteCommentFlag +) { + return removeCommentAction(mongo, tenant, { + action_type: ACTION_TYPE.FLAG, + item_type: ACTION_ITEM_TYPE.COMMENTS, + item_id: input.item_id, + user_id: author.id, + }); +} diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 80b3b0408..82e10c97c 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -1,6 +1,7 @@ import { Db } from "mongodb"; import { Omit } from "talk-common/types"; +import { ACTION_ITEM_TYPE, CreateActionInput } from "talk-server/models/action"; import { retrieveAsset, updateCommentStatusCount, @@ -15,6 +16,7 @@ import { } from "talk-server/models/comment"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; +import { addCommentActions } from "talk-server/services/comments/actions"; import { processForModeration } from "talk-server/services/comments/moderation"; import { Request } from "talk-server/types/express"; @@ -59,7 +61,7 @@ export async function create( } // Run the comment through the moderation phases. - const { status, metadata } = await processForModeration({ + const { actions, status, metadata } = await processForModeration({ asset, tenant, comment: input, @@ -67,9 +69,8 @@ export async function create( req, }); - // TODO: (wyattjoh) use the actions somehow. - - const comment = await createComment(mongo, tenant.id, { + // Create the comment! + let comment = await createComment(mongo, tenant.id, { ...input, status, action_counts: {}, @@ -77,6 +78,23 @@ export async function create( metadata, }); + if (actions.length > 0) { + // The actions coming from the moderation phases didn't know the item_id + // at the time, and we didn't want the repetitive nature of adding the + // item_type each time, so this mapping function adds them! + const inputs = actions.map( + (action): CreateActionInput => ({ + ...action, + item_id: comment.id, + item_type: ACTION_ITEM_TYPE.COMMENTS, + }) + ); + + // Insert and handle creating the actions. + comment = await addCommentActions(mongo, tenant, comment, inputs); + // asse + } + if (input.parent_id) { // Push the child's ID onto the parent. await pushChildCommentIDOntoParent( @@ -122,7 +140,7 @@ export async function edit( } // Run the comment through the moderation phases. - const { status, metadata } = await processForModeration({ + const { status, metadata, actions } = await processForModeration({ asset, tenant, comment: input, @@ -130,9 +148,7 @@ export async function edit( req, }); - // TODO: (wyattjoh) use the actions somehow. - - const editedComment = await editComment(mongo, tenant.id, { + let editedComment = await editComment(mongo, tenant.id, { id: input.id, author_id: author.id, body: input.body, @@ -146,6 +162,28 @@ export async function edit( Date.now() - tenant.editCommentWindowLength ), }); + if (!comment) { + // TODO: replace to match error returned by the models/comments.ts + throw new Error("comment not found"); + } + + if (actions.length > 0) { + // The actions coming from the moderation phases didn't know the item_id + // at the time, and we didn't want the repetitive nature of adding the + // item_type each time, so this mapping function adds them! + const inputs = actions.map( + (action): CreateActionInput => ({ + ...action, + // Strict null check seems to have failed here... Null checking was done + // above where we errored if the comment was falsely. + item_id: comment!.id, + item_type: ACTION_ITEM_TYPE.COMMENTS, + }) + ); + + // Insert and handle creating the actions. + editedComment = await addCommentActions(mongo, tenant, comment, inputs); + } if (comment.status !== editedComment.status) { // Increment the status count for the particular status on the Asset, and diff --git a/src/core/server/services/comments/moderation/index.spec.ts b/src/core/server/services/comments/moderation/index.spec.ts index 4b0ce2ebf..3adcccfd2 100644 --- a/src/core/server/services/comments/moderation/index.spec.ts +++ b/src/core/server/services/comments/moderation/index.spec.ts @@ -1,8 +1,8 @@ import { - GQLACTION_GROUP, - GQLACTION_TYPE, + GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; +import { ACTION_TYPE } from "talk-server/models/action"; import { compose, ModerationPhaseContext, @@ -51,12 +51,12 @@ describe("compose", () => { const flags = [ { - action_type: GQLACTION_TYPE.FLAG, - group_id: GQLACTION_GROUP.TOXIC_COMMENT, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC, }, { - action_type: GQLACTION_TYPE.FLAG, - group_id: GQLACTION_GROUP.SPAM_COMMENT, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM, }, ]; @@ -71,8 +71,8 @@ describe("compose", () => { () => ({ actions: [ { - action_type: GQLACTION_TYPE.FLAG, - group_id: GQLACTION_GROUP.LINKS, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS, }, ], }), @@ -85,8 +85,8 @@ describe("compose", () => { } expect(final.actions).not.toContainEqual({ - action_type: GQLACTION_TYPE.FLAG, - group_id: GQLACTION_GROUP.LINKS, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS, }); }); diff --git a/src/core/server/services/comments/moderation/index.ts b/src/core/server/services/comments/moderation/index.ts index d46be2c59..7bf497f75 100644 --- a/src/core/server/services/comments/moderation/index.ts +++ b/src/core/server/services/comments/moderation/index.ts @@ -1,6 +1,6 @@ import { Omit, Promiseable } from "talk-common/types"; import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; -import { Action } from "talk-server/models/actions"; +import { CreateActionInput } from "talk-server/models/action"; import { Asset } from "talk-server/models/asset"; import { Comment } from "talk-server/models/comment"; import { Tenant } from "talk-server/models/tenant"; @@ -9,14 +9,10 @@ import { Request } from "talk-server/types/express"; import { moderationPhases } from "./phases"; -// TODO: (wyattjoh) move into actions module once we have action methods. -export type CreateAction = Omit< - Action, - "id" | "item_type" | "item_id" | "created_at" ->; +export type ModerationAction = Omit; export interface PhaseResult { - actions: CreateAction[]; + actions: ModerationAction[]; status: GQLCOMMENT_STATUS; metadata: Record; } diff --git a/src/core/server/services/comments/moderation/phases/commentLength.ts b/src/core/server/services/comments/moderation/phases/commentLength.ts index c48db49fd..0015c813f 100644 --- a/src/core/server/services/comments/moderation/phases/commentLength.ts +++ b/src/core/server/services/comments/moderation/phases/commentLength.ts @@ -2,10 +2,10 @@ import striptags from "striptags"; import { isNil } from "lodash"; import { - GQLACTION_GROUP, - GQLACTION_TYPE, + GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; +import { ACTION_TYPE } from "talk-server/models/action"; import { ModerationSettings } from "talk-server/models/settings"; import { IntermediateModerationPhase, @@ -50,8 +50,8 @@ export const commentLength: IntermediateModerationPhase = ({ status: GQLCOMMENT_STATUS.REJECTED, actions: [ { - action_type: GQLACTION_TYPE.FLAG, - group_id: GQLACTION_GROUP.BODY_COUNT, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT, metadata: { count: length, }, diff --git a/src/core/server/services/comments/moderation/phases/karma.ts b/src/core/server/services/comments/moderation/phases/karma.ts index f5f4d9e37..91ffb069f 100755 --- a/src/core/server/services/comments/moderation/phases/karma.ts +++ b/src/core/server/services/comments/moderation/phases/karma.ts @@ -1,8 +1,8 @@ import { - GQLACTION_GROUP, - GQLACTION_TYPE, + GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; +import { ACTION_TYPE } from "talk-server/models/action"; import { IntermediateModerationPhase, IntermediatePhaseResult, @@ -33,8 +33,8 @@ export const karma: IntermediateModerationPhase = ({ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - action_type: GQLACTION_TYPE.FLAG, - group_id: GQLACTION_GROUP.TRUST, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC, metadata: { trust: getCommentTrustScore(author), }, diff --git a/src/core/server/services/comments/moderation/phases/links.ts b/src/core/server/services/comments/moderation/phases/links.ts index 2eed3715e..ae2c0e723 100755 --- a/src/core/server/services/comments/moderation/phases/links.ts +++ b/src/core/server/services/comments/moderation/phases/links.ts @@ -2,10 +2,10 @@ import linkify from "linkify-it"; import tlds from "tlds"; import { - GQLACTION_GROUP, - GQLACTION_TYPE, + GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; +import { ACTION_TYPE } from "talk-server/models/action"; import { ModerationSettings } from "talk-server/models/settings"; import { IntermediateModerationPhase, @@ -39,8 +39,8 @@ export const links: IntermediateModerationPhase = ({ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - action_type: GQLACTION_TYPE.FLAG, - group_id: GQLACTION_GROUP.LINKS, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS, metadata: { links: comment.body, }, diff --git a/src/core/server/services/comments/moderation/phases/spam.ts b/src/core/server/services/comments/moderation/phases/spam.ts index a15e47d9a..5e3c495a7 100644 --- a/src/core/server/services/comments/moderation/phases/spam.ts +++ b/src/core/server/services/comments/moderation/phases/spam.ts @@ -1,11 +1,11 @@ import { Client } from "akismet-api"; import { - GQLACTION_GROUP, - GQLACTION_TYPE, + GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; import logger from "talk-server/logger"; +import { ACTION_TYPE } from "talk-server/models/action"; import { IntermediateModerationPhase, IntermediatePhaseResult, @@ -110,8 +110,8 @@ export const spam: IntermediateModerationPhase = async ({ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - action_type: GQLACTION_TYPE.FLAG, - group_id: GQLACTION_GROUP.SPAM_COMMENT, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM, }, ], metadata: { diff --git a/src/core/server/services/comments/moderation/phases/toxic.ts b/src/core/server/services/comments/moderation/phases/toxic.ts index 8232b1767..ec3a9f6ae 100644 --- a/src/core/server/services/comments/moderation/phases/toxic.ts +++ b/src/core/server/services/comments/moderation/phases/toxic.ts @@ -4,12 +4,12 @@ import fetch from "node-fetch"; import { Omit } from "talk-common/types"; import { - GQLACTION_GROUP, - GQLACTION_TYPE, + GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, GQLPerspectiveExternalIntegration, } from "talk-server/graph/tenant/schema/__generated__/types"; import logger from "talk-server/logger"; +import { ACTION_TYPE } from "talk-server/models/action"; import { IntermediateModerationPhase, IntermediatePhaseResult, @@ -103,8 +103,8 @@ export const toxic: IntermediateModerationPhase = async ({ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actions: [ { - action_type: GQLACTION_TYPE.FLAG, - group_id: GQLACTION_GROUP.TOXIC_COMMENT, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC, }, ], metadata: { diff --git a/src/core/server/services/comments/moderation/phases/wordlist.ts b/src/core/server/services/comments/moderation/phases/wordlist.ts index b129093cb..05f6431b9 100755 --- a/src/core/server/services/comments/moderation/phases/wordlist.ts +++ b/src/core/server/services/comments/moderation/phases/wordlist.ts @@ -1,8 +1,8 @@ import { - GQLACTION_GROUP, - GQLACTION_TYPE, + GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; +import { ACTION_TYPE } from "talk-server/models/action"; import { IntermediateModerationPhase, IntermediatePhaseResult, @@ -29,8 +29,8 @@ export const wordlist: IntermediateModerationPhase = ({ status: GQLCOMMENT_STATUS.REJECTED, actions: [ { - action_type: GQLACTION_TYPE.FLAG, - group_id: GQLACTION_GROUP.BANNED_WORD, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD, }, ], }; @@ -46,8 +46,8 @@ export const wordlist: IntermediateModerationPhase = ({ return { actions: [ { - action_type: GQLACTION_TYPE.FLAG, - group_id: GQLACTION_GROUP.SUSPECT_WORD, + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD, }, ], };