mirror of
https://github.com/wassname/talk.git
synced 2026-07-04 04:45:36 +08:00
Merge branch 'next-respect' of github.com:coralproject/talk into next-respect
* 'next-respect' of github.com:coralproject/talk: Upgrade rte (#1906) fix: patch for sort bug feat: added dontAgree create/delete mutations Simplify getLevelClassName Refactor indent level className fix: cleanup of comments service Remove unused line feat: added flag creation and deletion mutations fix: addressed test updates feat: support deleting comment reactions Give last level a display name More comments feat: added reaction adding mutation fix: renamed, middleware fixes More comments Add some comments More tests Implement local reply list for last threading level fix: restricted action counts on Asset's Implement LocalReplyListContainer
This commit is contained in:
Generated
+3
-3
@@ -1342,9 +1342,9 @@
|
||||
}
|
||||
},
|
||||
"@coralproject/rte": {
|
||||
"version": "0.10.11",
|
||||
"resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-0.10.11.tgz",
|
||||
"integrity": "sha512-Tq8NznCOYx84QpHhZbUldxcYrztEQnnNjDC2aW5HhzltO9nBG2Hu78MzTwliML3MmaTFbLehdIb/BKGKj4eMSw==",
|
||||
"version": "0.10.12",
|
||||
"resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-0.10.12.tgz",
|
||||
"integrity": "sha512-w7UWe6u+TNoPtFcWvyjYkV7eaAE4ccTOYrhdWPsDfM34ZDRq+bM5eiiAMcUVHizvHgsUzFWoN2qjOo+jSfIjCw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"bowser": "^1.0.0",
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@
|
||||
"@babel/polyfill": "7.0.0-beta.49",
|
||||
"@babel/preset-env": "7.0.0-beta.49",
|
||||
"@babel/preset-react": "7.0.0-beta.49",
|
||||
"@coralproject/rte": "^0.10.11",
|
||||
"@coralproject/rte": "^0.10.12",
|
||||
"@types/bcryptjs": "^2.4.1",
|
||||
"@types/bull": "^3.3.16",
|
||||
"@types/bunyan": "^1.8.4",
|
||||
|
||||
@@ -20,7 +20,11 @@ type AuthPopup {
|
||||
}
|
||||
|
||||
extend type Comment {
|
||||
# pending is true during the optimistic response.
|
||||
pending: Boolean
|
||||
# localReplies contains only comments created by the user
|
||||
# on the ultimate threading level.
|
||||
localReplies: [Comment!]
|
||||
}
|
||||
|
||||
type Local {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment, RelayMutationConfig } from "relay-runtime";
|
||||
import {
|
||||
ConnectionHandler,
|
||||
Environment,
|
||||
RecordSourceSelectorProxy,
|
||||
} from "relay-runtime";
|
||||
|
||||
import { getMe } from "talk-framework/helpers";
|
||||
import { TalkContext } from "talk-framework/lib/bootstrap";
|
||||
@@ -14,7 +18,85 @@ import { CreateCommentMutation as MutationTypes } from "talk-stream/__generated_
|
||||
export type CreateCommentInput = Omit<
|
||||
MutationTypes["variables"]["input"],
|
||||
"clientMutationId"
|
||||
>;
|
||||
> & { local?: boolean };
|
||||
|
||||
function sharedUpdater(
|
||||
store: RecordSourceSelectorProxy,
|
||||
input: CreateCommentInput
|
||||
) {
|
||||
if (input.local) {
|
||||
localUpdate(store, input);
|
||||
} else {
|
||||
update(store, input);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* update integrates new comment into the CommentConnection.
|
||||
*/
|
||||
function update(store: RecordSourceSelectorProxy, input: CreateCommentInput) {
|
||||
// Get the payload returned from the server.
|
||||
const payload = store.getRootField("createComment")!;
|
||||
|
||||
// Get the edge of the newly created comment.
|
||||
const newEdge = payload.getLinkedRecord("edge")!;
|
||||
|
||||
// Get parent proxy.
|
||||
const parentProxy = input.parentID
|
||||
? store.get(input.parentID)
|
||||
: store.get(input.assetID);
|
||||
|
||||
const connectionKey = input.parentID
|
||||
? "ReplyList_replies"
|
||||
: "Stream_comments";
|
||||
|
||||
const filters = input.parentID
|
||||
? { orderBy: "CREATED_AT_ASC" }
|
||||
: { orderBy: "CREATED_AT_DESC" };
|
||||
|
||||
const where = input.parentID ? "append" : "prepend";
|
||||
|
||||
if (parentProxy) {
|
||||
const con = ConnectionHandler.getConnection(
|
||||
parentProxy,
|
||||
connectionKey,
|
||||
filters
|
||||
);
|
||||
if (con) {
|
||||
if (where === "prepend") {
|
||||
ConnectionHandler.insertEdgeBefore(con, newEdge);
|
||||
} else {
|
||||
ConnectionHandler.insertEdgeAfter(con, newEdge);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* localUpdate is like update but updates the `localReplies` endpoint.
|
||||
*/
|
||||
function localUpdate(
|
||||
store: RecordSourceSelectorProxy,
|
||||
input: CreateCommentInput
|
||||
) {
|
||||
// Get the payload returned from the server.
|
||||
const payload = store.getRootField("createComment")!;
|
||||
|
||||
// Get the edge of the newly created comment.
|
||||
const newEdge = payload.getLinkedRecord("edge")!;
|
||||
const newComment = newEdge.getLinkedRecord("node");
|
||||
|
||||
// Get parent proxy.
|
||||
const parentProxy = store.get(input.parentID!);
|
||||
|
||||
if (parentProxy) {
|
||||
const localReplies = parentProxy.getLinkedRecords("localReplies");
|
||||
const nextLocalReplies = localReplies
|
||||
? localReplies.concat(newComment)
|
||||
: [newComment];
|
||||
parentProxy.setLinkedRecords(nextLocalReplies, "localReplies");
|
||||
}
|
||||
}
|
||||
|
||||
const mutation = graphql`
|
||||
mutation CreateCommentMutation($input: CreateCommentInput!) {
|
||||
@@ -32,39 +114,6 @@ const mutation = graphql`
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
function getConfig(input: CreateCommentInput): RelayMutationConfig[] {
|
||||
if (!input.parentID) {
|
||||
return [
|
||||
{
|
||||
type: "RANGE_ADD",
|
||||
connectionInfo: [
|
||||
{
|
||||
key: "Stream_comments",
|
||||
rangeBehavior: "prepend",
|
||||
filters: { orderBy: "CREATED_AT_DESC" },
|
||||
},
|
||||
],
|
||||
parentID: input.assetID,
|
||||
edgeName: "edge",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: "RANGE_ADD",
|
||||
connectionInfo: [
|
||||
{
|
||||
key: "ReplyList_replies",
|
||||
rangeBehavior: "append",
|
||||
filters: { orderBy: "CREATED_AT_ASC" },
|
||||
},
|
||||
],
|
||||
parentID: input.parentID,
|
||||
edgeName: "edge",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function commit(
|
||||
environment: Environment,
|
||||
input: CreateCommentInput,
|
||||
@@ -77,7 +126,9 @@ function commit(
|
||||
mutation,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
assetID: input.assetID,
|
||||
parentID: input.parentID,
|
||||
body: input.body,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
@@ -102,9 +153,12 @@ function commit(
|
||||
},
|
||||
} as any, // TODO: (cvle) generated types should contain one for the optimistic response.
|
||||
optimisticUpdater: store => {
|
||||
sharedUpdater(store, input);
|
||||
store.get(id)!.setValue(true, "pending");
|
||||
},
|
||||
configs: getConfig(input),
|
||||
updater: store => {
|
||||
sharedUpdater(store, input);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
border-left: 3px solid var(--palette-grey-lighter);
|
||||
}
|
||||
|
||||
.level6 {
|
||||
padding-left: var(--spacing-unit);
|
||||
margin-left: calc(5 * var(--spacing-unit));
|
||||
border-left: 3px solid var(--palette-grey-lightest);
|
||||
}
|
||||
|
||||
.noBorder {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@@ -10,16 +10,28 @@ export interface IndentProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const levels = [
|
||||
"",
|
||||
styles.level1,
|
||||
styles.level2,
|
||||
styles.level3,
|
||||
styles.level4,
|
||||
styles.level5,
|
||||
styles.level6,
|
||||
];
|
||||
|
||||
function getLevelClassName(level: number = 0) {
|
||||
if (!(level in levels)) {
|
||||
throw new Error(`Indent level ${level} does not exist`);
|
||||
}
|
||||
return levels[level];
|
||||
}
|
||||
|
||||
const Indent: StatelessComponent<IndentProps> = props => {
|
||||
return (
|
||||
<div className={cn(props.className, styles.root)}>
|
||||
<div
|
||||
className={cn({
|
||||
[styles.level1]: props.level === 1,
|
||||
[styles.level2]: props.level === 2,
|
||||
[styles.level3]: props.level === 3,
|
||||
[styles.level4]: props.level === 4,
|
||||
[styles.level5]: props.level === 5,
|
||||
className={cn(getLevelClassName(props.level), {
|
||||
[styles.noBorder]: props.noBorder,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -20,6 +20,8 @@ it("renders correctly", () => {
|
||||
disableShowAll: false,
|
||||
indentLevel: 1,
|
||||
me: null,
|
||||
localReply: false,
|
||||
disableReplies: false,
|
||||
};
|
||||
const wrapper = shallow(<ReplyListN {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
@@ -17,11 +17,13 @@ export interface ReplyListProps {
|
||||
comments: ReadonlyArray<
|
||||
{ id: string } & PropTypesOf<typeof CommentContainer>["comment"]
|
||||
>;
|
||||
onShowAll: () => void;
|
||||
hasMore: boolean;
|
||||
disableShowAll: boolean;
|
||||
onShowAll?: () => void;
|
||||
hasMore?: boolean;
|
||||
disableShowAll?: boolean;
|
||||
indentLevel?: number;
|
||||
ReplyListComponent?: React.ComponentType<any>;
|
||||
localReply?: boolean;
|
||||
disableReplies?: boolean;
|
||||
}
|
||||
|
||||
function getReplyListElement(
|
||||
@@ -48,6 +50,8 @@ const ReplyList: StatelessComponent<ReplyListProps> = props => {
|
||||
comment={comment}
|
||||
asset={props.asset}
|
||||
indentLevel={props.indentLevel}
|
||||
localReply={props.localReply}
|
||||
disableReplies={props.disableReplies}
|
||||
/>
|
||||
{getReplyListElement(props, comment)}
|
||||
</HorizontalGutter>
|
||||
|
||||
@@ -19,8 +19,10 @@ exports[`renders correctly 1`] = `
|
||||
"id": "comment-1",
|
||||
}
|
||||
}
|
||||
disableReplies={false}
|
||||
indentLevel={1}
|
||||
key="comment-1"
|
||||
localReply={false}
|
||||
me={null}
|
||||
/>
|
||||
</withPropsOnChange(HorizontalGutter)>
|
||||
@@ -38,8 +40,10 @@ exports[`renders correctly 1`] = `
|
||||
"id": "comment-2",
|
||||
}
|
||||
}
|
||||
disableReplies={false}
|
||||
indentLevel={1}
|
||||
key="comment-2"
|
||||
localReply={false}
|
||||
me={null}
|
||||
/>
|
||||
</withPropsOnChange(HorizontalGutter)>
|
||||
|
||||
@@ -32,6 +32,8 @@ it("renders username and body", () => {
|
||||
},
|
||||
indentLevel: 1,
|
||||
showAuthPopup: noop as any,
|
||||
localReply: false,
|
||||
disableReplies: false,
|
||||
};
|
||||
|
||||
const wrapper = shallow(<CommentContainerN {...props} />);
|
||||
@@ -65,3 +67,33 @@ it("renders body only", () => {
|
||||
const wrapper = shallow(<CommentContainerN {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("hide reply button", () => {
|
||||
const props: PropTypesOf<typeof CommentContainerN> = {
|
||||
me: null,
|
||||
asset: {
|
||||
id: "asset-id",
|
||||
},
|
||||
comment: {
|
||||
id: "comment-id",
|
||||
author: {
|
||||
id: "author-id",
|
||||
username: "Marvin",
|
||||
},
|
||||
body: "Woof",
|
||||
createdAt: "1995-12-17T03:24:00.000Z",
|
||||
editing: {
|
||||
edited: false,
|
||||
editableUntil: "1995-12-17T03:24:30.000Z",
|
||||
},
|
||||
pending: false,
|
||||
},
|
||||
indentLevel: 1,
|
||||
showAuthPopup: noop as any,
|
||||
localReply: false,
|
||||
disableReplies: true,
|
||||
};
|
||||
|
||||
const wrapper = shallow(<CommentContainerN {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -27,6 +27,13 @@ interface InnerProps {
|
||||
asset: AssetData;
|
||||
indentLevel?: number;
|
||||
showAuthPopup: ShowAuthPopupMutation;
|
||||
/**
|
||||
* localReply will integrate the mutation response into
|
||||
* localReplies
|
||||
*/
|
||||
localReply?: boolean;
|
||||
/** disableReplies will remove the ReplyButton */
|
||||
disableReplies?: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -108,7 +115,13 @@ export class CommentContainer extends Component<InnerProps, State> {
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { comment, asset, indentLevel } = this.props;
|
||||
const {
|
||||
comment,
|
||||
asset,
|
||||
indentLevel,
|
||||
localReply,
|
||||
disableReplies,
|
||||
} = this.props;
|
||||
const { showReplyDialog, showEditDialog, editable } = this.state;
|
||||
if (showEditDialog) {
|
||||
return (
|
||||
@@ -145,11 +158,13 @@ export class CommentContainer extends Component<InnerProps, State> {
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<ReplyButton
|
||||
id={`comments-commentContainer-replyButton-${comment.id}`}
|
||||
onClick={this.openReplyDialog}
|
||||
active={showReplyDialog}
|
||||
/>
|
||||
{!disableReplies && (
|
||||
<ReplyButton
|
||||
id={`comments-commentContainer-replyButton-${comment.id}`}
|
||||
onClick={this.openReplyDialog}
|
||||
active={showReplyDialog}
|
||||
/>
|
||||
)}
|
||||
<PermalinkButtonContainer commentID={comment.id} />
|
||||
<RespectButtonContainer />
|
||||
</>
|
||||
@@ -160,6 +175,7 @@ export class CommentContainer extends Component<InnerProps, State> {
|
||||
comment={comment}
|
||||
asset={asset}
|
||||
onClose={this.closeReplyDialog}
|
||||
localReply={localReply}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { Component } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import withFragmentContainer from "talk-framework/lib/relay/withFragmentContainer";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { LocalReplyListContainer_asset as AssetData } from "talk-stream/__generated__/LocalReplyListContainer_asset.graphql";
|
||||
import { LocalReplyListContainer_comment as CommentData } from "talk-stream/__generated__/LocalReplyListContainer_comment.graphql";
|
||||
import { LocalReplyListContainer_me as MeData } from "talk-stream/__generated__/LocalReplyListContainer_me.graphql";
|
||||
|
||||
import ReplyList from "../components/ReplyList";
|
||||
|
||||
interface InnerProps {
|
||||
indentLevel: number;
|
||||
me: MeData;
|
||||
asset: AssetData;
|
||||
comment: CommentData;
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalReplyListContainer renders the replies from the endpoint
|
||||
* `localReplies` instead of `replies`. This is e.g. used for the
|
||||
* ultimate threading level to only display the newly created comments
|
||||
* from the current user.
|
||||
*/
|
||||
export class LocalReplyListContainer extends Component<InnerProps> {
|
||||
public render() {
|
||||
if (!this.props.comment.localReplies) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ReplyList
|
||||
me={this.props.me}
|
||||
comment={this.props.comment}
|
||||
comments={this.props.comment.localReplies}
|
||||
asset={this.props.asset}
|
||||
indentLevel={this.props.indentLevel}
|
||||
disableReplies
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withFragmentContainer<InnerProps>({
|
||||
me: graphql`
|
||||
fragment LocalReplyListContainer_me on User {
|
||||
...CommentContainer_me
|
||||
}
|
||||
`,
|
||||
asset: graphql`
|
||||
fragment LocalReplyListContainer_asset on Asset {
|
||||
...CommentContainer_asset
|
||||
}
|
||||
`,
|
||||
comment: graphql`
|
||||
fragment LocalReplyListContainer_comment on Comment {
|
||||
id
|
||||
localReplies {
|
||||
id
|
||||
...CommentContainer_comment
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(LocalReplyListContainer);
|
||||
|
||||
export type LocalReplyListContainerProps = PropTypesOf<typeof enhanced>;
|
||||
export default enhanced;
|
||||
@@ -92,7 +92,7 @@ it("save values", async () => {
|
||||
|
||||
it("creates a comment", async () => {
|
||||
const assetID = "asset-id";
|
||||
const input = { body: "Hello World!" };
|
||||
const input = { body: "Hello World!", local: false };
|
||||
const createCommentStub = sinon.stub();
|
||||
const form = { reset: noop };
|
||||
const onCloseStub = sinon.stub();
|
||||
|
||||
@@ -25,6 +25,7 @@ interface InnerProps {
|
||||
asset: AssetData;
|
||||
onClose?: () => void;
|
||||
autofocus: boolean;
|
||||
localReply?: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -76,6 +77,7 @@ export class ReplyCommentFormContainer extends Component<InnerProps, State> {
|
||||
await this.props.createComment({
|
||||
assetID: this.props.asset.id,
|
||||
parentID: this.props.comment.id,
|
||||
local: this.props.localReply,
|
||||
...input,
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ it("renders correctly", () => {
|
||||
me: null,
|
||||
indentLevel: 1,
|
||||
ReplyListComponent: () => null,
|
||||
localReply: false,
|
||||
};
|
||||
const wrapper = shallow(<ReplyListContainerN {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
@@ -50,6 +51,7 @@ it("renders correctly when replies are null", () => {
|
||||
me: null,
|
||||
indentLevel: 1,
|
||||
ReplyListComponent: undefined,
|
||||
localReply: false,
|
||||
};
|
||||
const wrapper = shallow(<ReplyListContainerN {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
@@ -75,6 +77,7 @@ describe("when has more replies", () => {
|
||||
me: null,
|
||||
indentLevel: 1,
|
||||
ReplyListComponent: undefined,
|
||||
localReply: false,
|
||||
};
|
||||
|
||||
let wrapper: ShallowWrapper;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { graphql, GraphQLTaggedNode, RelayPaginationProp } from "react-relay";
|
||||
import { withProps } from "recompose";
|
||||
|
||||
import { withPaginationContainer } from "talk-framework/lib/relay";
|
||||
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";
|
||||
@@ -11,7 +12,9 @@ import {
|
||||
ReplyListContainer1PaginationQueryVariables,
|
||||
} from "talk-stream/__generated__/ReplyListContainer1PaginationQuery.graphql";
|
||||
|
||||
import { StatelessComponent } from "enzyme";
|
||||
import ReplyList from "../components/ReplyList";
|
||||
import LocalReplyListContainer from "./LocalReplyListContainer";
|
||||
|
||||
export interface InnerProps {
|
||||
me: MeData | null;
|
||||
@@ -20,6 +23,7 @@ export interface InnerProps {
|
||||
relay: RelayPaginationProp;
|
||||
indentLevel: number;
|
||||
ReplyListComponent: React.ComponentType<any> | undefined;
|
||||
localReply: boolean | undefined;
|
||||
}
|
||||
|
||||
// TODO: (cvle) This should be autogenerated.
|
||||
@@ -52,6 +56,7 @@ export class ReplyListContainer extends React.Component<InnerProps> {
|
||||
disableShowAll={this.state.disableShowAll}
|
||||
indentLevel={this.props.indentLevel}
|
||||
ReplyListComponent={this.props.ReplyListComponent}
|
||||
localReply={this.props.localReply}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -83,9 +88,10 @@ function createReplyListContainer(
|
||||
comment: GraphQLTaggedNode;
|
||||
},
|
||||
query: GraphQLTaggedNode,
|
||||
ReplyListComponent?: React.ComponentType<any>
|
||||
ReplyListComponent?: React.ComponentType<any>,
|
||||
localReply?: boolean
|
||||
) {
|
||||
return withProps({ indentLevel, ReplyListComponent })(
|
||||
return withProps({ indentLevel, ReplyListComponent, localReply })(
|
||||
withPaginationContainer<
|
||||
InnerProps,
|
||||
ReplyListContainer1PaginationQueryVariables,
|
||||
@@ -115,17 +121,26 @@ function createReplyListContainer(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LastReplyList uses the LocalReplyListContainer.
|
||||
*/
|
||||
const LastReplyList: StatelessComponent<
|
||||
PropTypesOf<typeof LocalReplyListContainer>
|
||||
> = props => <LocalReplyListContainer {...props} indentLevel={6} />;
|
||||
|
||||
const ReplyListContainer5 = createReplyListContainer(
|
||||
5,
|
||||
{
|
||||
me: graphql`
|
||||
fragment ReplyListContainer5_me on User {
|
||||
...CommentContainer_me
|
||||
...LocalReplyListContainer_me
|
||||
}
|
||||
`,
|
||||
asset: graphql`
|
||||
fragment ReplyListContainer5_asset on Asset {
|
||||
...CommentContainer_asset
|
||||
...LocalReplyListContainer_asset
|
||||
}
|
||||
`,
|
||||
comment: graphql`
|
||||
@@ -142,6 +157,7 @@ const ReplyListContainer5 = createReplyListContainer(
|
||||
node {
|
||||
id
|
||||
...CommentContainer_comment
|
||||
...LocalReplyListContainer_comment
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,7 +178,9 @@ const ReplyListContainer5 = createReplyListContainer(
|
||||
@arguments(count: $count, cursor: $cursor, orderBy: $orderBy)
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
LastReplyList,
|
||||
true
|
||||
);
|
||||
|
||||
const ReplyListContainer4 = createReplyListContainer(
|
||||
|
||||
+26
@@ -1,5 +1,31 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`hide reply button 1`] = `
|
||||
<React.Fragment>
|
||||
<IndentedComment
|
||||
author={
|
||||
Object {
|
||||
"id": "author-id",
|
||||
"username": "Marvin",
|
||||
}
|
||||
}
|
||||
blur={false}
|
||||
body="Woof"
|
||||
createdAt="1995-12-17T03:24:00.000Z"
|
||||
footer={
|
||||
<React.Fragment>
|
||||
<withContext(withLocalStateContainer(PermalinkContainer))
|
||||
commentID="comment-id"
|
||||
/>
|
||||
</React.Fragment>
|
||||
}
|
||||
id="comment-comment-id"
|
||||
indentLevel={1}
|
||||
showEditedMarker={false}
|
||||
/>
|
||||
</React.Fragment>
|
||||
`;
|
||||
|
||||
exports[`renders body only 1`] = `
|
||||
<React.Fragment>
|
||||
<IndentedComment
|
||||
|
||||
+4
@@ -39,6 +39,7 @@ exports[`renders correctly 1`] = `
|
||||
}
|
||||
disableShowAll={false}
|
||||
indentLevel={1}
|
||||
localReply={false}
|
||||
me={null}
|
||||
onShowAll={[Function]}
|
||||
/>
|
||||
@@ -85,6 +86,7 @@ exports[`when has more replies renders hasMore 1`] = `
|
||||
disableShowAll={false}
|
||||
hasMore={true}
|
||||
indentLevel={1}
|
||||
localReply={false}
|
||||
me={null}
|
||||
onShowAll={[Function]}
|
||||
/>
|
||||
@@ -129,6 +131,7 @@ exports[`when has more replies when showing all disables show all button 1`] = `
|
||||
disableShowAll={true}
|
||||
hasMore={true}
|
||||
indentLevel={1}
|
||||
localReply={false}
|
||||
me={null}
|
||||
onShowAll={[Function]}
|
||||
/>
|
||||
@@ -173,6 +176,7 @@ exports[`when has more replies when showing all enable show all button after loa
|
||||
disableShowAll={false}
|
||||
hasMore={true}
|
||||
indentLevel={1}
|
||||
localReply={false}
|
||||
me={null}
|
||||
onShowAll={[Function]}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
||||
import { ReactTestRenderer } from "react-test-renderer";
|
||||
import timekeeper from "timekeeper";
|
||||
|
||||
import { timeout } from "talk-common/utils";
|
||||
import { createSinonStub } from "talk-framework/testHelpers";
|
||||
|
||||
import { assetWithDeepestReplies, users } from "../fixtures";
|
||||
import create from "./create";
|
||||
|
||||
let testRenderer: ReactTestRenderer;
|
||||
beforeEach(() => {
|
||||
const resolvers = {
|
||||
Query: {
|
||||
asset: createSinonStub(
|
||||
s => s.throws(),
|
||||
s => s.returns(assetWithDeepestReplies)
|
||||
),
|
||||
me: createSinonStub(s => s.throws(), s => s.returns(users[0])),
|
||||
},
|
||||
Mutation: {
|
||||
createComment: createSinonStub(
|
||||
s => s.throws(),
|
||||
s =>
|
||||
s
|
||||
.withArgs(undefined, {
|
||||
input: {
|
||||
assetID: assetWithDeepestReplies.id,
|
||||
parentID: "comment-with-deepest-replies-5",
|
||||
body: "<strong>Hello world!</strong>",
|
||||
clientMutationId: "0",
|
||||
},
|
||||
})
|
||||
.returns({
|
||||
edge: {
|
||||
cursor: null,
|
||||
node: {
|
||||
id: "comment-x",
|
||||
author: users[0],
|
||||
body: "<strong>Hello world! (from server)</strong>",
|
||||
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",
|
||||
})
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
({ testRenderer } = create({
|
||||
// Set this to true, to see graphql responses.
|
||||
logNetwork: false,
|
||||
resolvers,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(assetWithDeepestReplies.id, "assetID");
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("renders comment stream", async () => {
|
||||
// Wait for loading.
|
||||
await timeout();
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("post a reply", async () => {
|
||||
// Wait for loading.
|
||||
await timeout();
|
||||
|
||||
// Open reply form.
|
||||
testRenderer.root
|
||||
.findByProps({
|
||||
id:
|
||||
"comments-commentContainer-replyButton-comment-with-deepest-replies-5",
|
||||
})
|
||||
.props.onClick();
|
||||
|
||||
await timeout();
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot("open reply form");
|
||||
|
||||
// Write reply .
|
||||
testRenderer.root
|
||||
.findByProps({
|
||||
inputId: "comments-replyCommentForm-rte-comment-with-deepest-replies-5",
|
||||
})
|
||||
.props.onChange({ html: "<strong>Hello world!</strong>" });
|
||||
|
||||
timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z"));
|
||||
testRenderer.root
|
||||
.findByProps({
|
||||
id: "comments-replyCommentForm-form-comment-with-deepest-replies-5",
|
||||
})
|
||||
.props.onSubmit();
|
||||
// Test optimistic response.
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot("optimistic response");
|
||||
timekeeper.reset();
|
||||
|
||||
// Wait for loading.
|
||||
await timeout();
|
||||
|
||||
// Test after server response.
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot("server response");
|
||||
});
|
||||
@@ -171,3 +171,110 @@ export const assetWithDeepReplies = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const commentWithDeepestReplies = {
|
||||
...commentWithReplies,
|
||||
id: "comment-with-deepest-replies",
|
||||
body: "body 0",
|
||||
replies: {
|
||||
...commentWithReplies.replies,
|
||||
edges: [
|
||||
{
|
||||
cursor: commentWithReplies.createdAt,
|
||||
node: {
|
||||
...commentWithReplies,
|
||||
id: "comment-with-deepest-replies-1",
|
||||
body: "body 1",
|
||||
replies: {
|
||||
...commentWithReplies.replies,
|
||||
edges: [
|
||||
{
|
||||
cursor: commentWithReplies.createdAt,
|
||||
node: {
|
||||
...commentWithReplies,
|
||||
id: "comment-with-deepest-replies-2",
|
||||
body: "body 2",
|
||||
replies: {
|
||||
...commentWithReplies.replies,
|
||||
edges: [
|
||||
{
|
||||
cursor: commentWithReplies.createdAt,
|
||||
node: {
|
||||
...commentWithReplies,
|
||||
id: "comment-with-deepest-replies-3",
|
||||
body: "body 3",
|
||||
replies: {
|
||||
...commentWithReplies.replies,
|
||||
edges: [
|
||||
{
|
||||
cursor: commentWithReplies.createdAt,
|
||||
node: {
|
||||
...commentWithReplies,
|
||||
id: "comment-with-deepest-replies-4",
|
||||
body: "body 4",
|
||||
replies: {
|
||||
...commentWithReplies.replies,
|
||||
edges: [
|
||||
{
|
||||
cursor: commentWithReplies.createdAt,
|
||||
node: {
|
||||
...commentWithReplies,
|
||||
id: "comment-with-deepest-replies-5",
|
||||
body: "body 5",
|
||||
replies: {
|
||||
...commentWithReplies.replies,
|
||||
edges: [
|
||||
{
|
||||
cursor:
|
||||
commentWithReplies.createdAt,
|
||||
node: {
|
||||
...commentWithReplies,
|
||||
id:
|
||||
"comment-with-deepest-replies-6",
|
||||
body: "body 6",
|
||||
replies: {
|
||||
...commentWithReplies.replies,
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const assetWithDeepestReplies = {
|
||||
id: "asset-with-deepest-replies",
|
||||
url: "http://localhost/assets/asset-with-replies",
|
||||
isClosed: false,
|
||||
comments: {
|
||||
edges: [
|
||||
{
|
||||
node: commentWithDeepestReplies,
|
||||
cursor: commentWithDeepestReplies.createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import {
|
||||
ACTION_ITEM_TYPE,
|
||||
retrieveManyUserActionPresence,
|
||||
} from "talk-server/models/actions";
|
||||
} from "talk-server/models/action";
|
||||
import {
|
||||
retrieveCommentAssetConnection,
|
||||
retrieveCommentRepliesConnection,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GQLAssetTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { decodeActionCounts } from "talk-server/models/actions";
|
||||
import { decodeActionCounts } from "talk-server/models/action";
|
||||
import { Asset } from "talk-server/models/asset";
|
||||
|
||||
const Asset: GQLAssetTypeResolver<Asset> = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { decodeActionCounts } from "talk-server/models/actions";
|
||||
import { decodeActionCounts } from "talk-server/models/action";
|
||||
import { Comment } from "talk-server/models/comment";
|
||||
|
||||
const Comment: GQLCommentTypeResolver<Comment> = {
|
||||
|
||||
@@ -9,10 +9,10 @@ const Mutation: GQLMutationTypeResolver<void> = {
|
||||
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<void> = {
|
||||
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;
|
||||
|
||||
@@ -931,9 +931,10 @@ type Asset {
|
||||
): CommentsConnection!
|
||||
|
||||
"""
|
||||
actionCounts stores the counts of all the actions for the Comment.
|
||||
actionCounts stores the counts of all the actions against this Asset and it's
|
||||
Comments.
|
||||
"""
|
||||
actionCounts: ActionCounts!
|
||||
actionCounts: ActionCounts! @auth(roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
author is the authors listed in the meta tags for the Asset.
|
||||
@@ -1473,6 +1474,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
|
||||
##################
|
||||
@@ -1494,6 +1668,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
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
decodeActionCounts,
|
||||
encodeActionCounts,
|
||||
validateAction,
|
||||
} from "talk-server/models/actions";
|
||||
} from "talk-server/models/action";
|
||||
|
||||
describe("#encodeActionCounts", () => {
|
||||
it("generates the action counts correctly", () => {
|
||||
@@ -7,7 +7,9 @@ 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";
|
||||
@@ -45,12 +47,20 @@ 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?: GQLCOMMENT_FLAG_REASON;
|
||||
reason?: FLAG_REASON;
|
||||
user_id?: string;
|
||||
created_at: Date;
|
||||
metadata?: Record<string, any>;
|
||||
@@ -186,6 +196,7 @@ export async function createActions(
|
||||
tenantID: string,
|
||||
inputs: CreateActionInput[]
|
||||
): Promise<CreateActionResultObject[]> {
|
||||
// TODO: (wyattjoh) replace with a batch write.
|
||||
return Promise.all(inputs.map(input => createAction(mongo, tenantID, input)));
|
||||
}
|
||||
|
||||
@@ -273,10 +284,15 @@ export async function deleteAction(
|
||||
action_type: input.action_type,
|
||||
item_type: input.item_type,
|
||||
item_id: input.item_id,
|
||||
reason: input.reason,
|
||||
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 {
|
||||
@@ -314,6 +330,27 @@ export function encodeActionCounts(...actions: Action[]): EncodedActionCounts {
|
||||
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`.
|
||||
@@ -3,7 +3,7 @@ import uuid from "uuid";
|
||||
|
||||
import { Omit } from "talk-common/types";
|
||||
import { dotize } from "talk-common/utils/dotize";
|
||||
import { EncodedActionCounts } from "talk-server/models/actions";
|
||||
import { EncodedActionCounts } from "talk-server/models/action";
|
||||
import { ModerationSettings } from "talk-server/models/settings";
|
||||
import { TenantResource } from "talk-server/models/tenant";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
GQLCOMMENT_SORT,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { EncodedActionCounts } from "talk-server/models/actions";
|
||||
import { EncodedActionCounts } from "talk-server/models/action";
|
||||
import {
|
||||
Connection,
|
||||
Cursor,
|
||||
@@ -366,7 +366,7 @@ function applyInputToQuery(input: ConnectionInput, query: Query<Comment>) {
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
GQLUSER_ROLE,
|
||||
GQLUSER_USERNAME_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { EncodedActionCounts } 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";
|
||||
|
||||
|
||||
@@ -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<Comment>,
|
||||
inputs: CreateActionInput[]
|
||||
): Promise<Readonly<Comment>> {
|
||||
// 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<Readonly<Comment>> {
|
||||
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<Readonly<Comment>> {
|
||||
// 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<CreateActionInput, "item_id">;
|
||||
|
||||
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<DeleteActionInput, "item_id">;
|
||||
|
||||
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<CreateActionInput, "item_id">;
|
||||
|
||||
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<DeleteActionInput, "item_id">;
|
||||
|
||||
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<CreateActionInput, "item_id"> & {
|
||||
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<DeleteActionInput, "item_id">;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,27 +1,18 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Omit } from "talk-common/types";
|
||||
import { ACTION_ITEM_TYPE, CreateActionInput } from "talk-server/models/action";
|
||||
import { retrieveAsset } from "talk-server/models/asset";
|
||||
import {
|
||||
ACTION_ITEM_TYPE,
|
||||
CreateActionInput,
|
||||
createActions,
|
||||
encodeActionCounts,
|
||||
} from "talk-server/models/actions";
|
||||
import {
|
||||
retrieveAsset,
|
||||
updateAssetActionCounts,
|
||||
} from "talk-server/models/asset";
|
||||
import {
|
||||
Comment,
|
||||
createComment,
|
||||
CreateCommentInput,
|
||||
editComment,
|
||||
EditCommentInput,
|
||||
retrieveComment,
|
||||
updateCommentActionCounts,
|
||||
} 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";
|
||||
|
||||
@@ -172,51 +163,3 @@ export async function edit(
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
async function addCommentActions(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
comment: Readonly<Comment>,
|
||||
inputs: CreateActionInput[]
|
||||
): Promise<Readonly<Comment>> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ACTION_TYPE } from "talk-server/models/actions";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import {
|
||||
compose,
|
||||
ModerationPhaseContext,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Omit, Promiseable } from "talk-common/types";
|
||||
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { CreateActionInput } 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";
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ACTION_TYPE } from "talk-server/models/actions";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import { ModerationSettings } from "talk-server/models/settings";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ACTION_TYPE } from "talk-server/models/actions";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ACTION_TYPE } from "talk-server/models/actions";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import { ModerationSettings } from "talk-server/models/settings";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "talk-server/logger";
|
||||
import { ACTION_TYPE } from "talk-server/models/actions";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
GQLPerspectiveExternalIntegration,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "talk-server/logger";
|
||||
import { ACTION_TYPE } from "talk-server/models/actions";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ACTION_TYPE } from "talk-server/models/actions";
|
||||
import { ACTION_TYPE } from "talk-server/models/action";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
|
||||
Reference in New Issue
Block a user