diff --git a/src/core/client/stream/mutations/CreateCommentMutation.ts b/src/core/client/stream/mutations/CreateCommentMutation.ts
index 079285b91..5d6d04556 100644
--- a/src/core/client/stream/mutations/CreateCommentMutation.ts
+++ b/src/core/client/stream/mutations/CreateCommentMutation.ts
@@ -19,7 +19,7 @@ export type CreateCommentInput = Omit<
const mutation = graphql`
mutation CreateCommentMutation($input: CreateCommentInput!) {
createComment(input: $input) {
- commentEdge {
+ edge {
cursor
node {
id
@@ -51,7 +51,7 @@ function commit(environment: Environment, input: CreateCommentInput) {
},
optimisticResponse: {
createComment: {
- commentEdge: {
+ edge: {
cursor: currentDate,
node: {
id: uuid(),
@@ -77,7 +77,7 @@ function commit(environment: Environment, input: CreateCommentInput) {
},
],
parentID: input.assetID,
- edgeName: "commentEdge",
+ edgeName: "edge",
},
],
});
diff --git a/src/core/client/stream/test/postComment.spec.tsx b/src/core/client/stream/test/postComment.spec.tsx
index 9ddd8b1ec..f4d97c398 100644
--- a/src/core/client/stream/test/postComment.spec.tsx
+++ b/src/core/client/stream/test/postComment.spec.tsx
@@ -42,7 +42,8 @@ beforeEach(() => {
},
})
.returns({
- commentEdge: {
+ // TODO: add a type assertion here to ensure that if the type changes, that the test will fail
+ edge: {
cursor: "2018-07-06T18:24:00.000Z",
node: {
id: "comment-x",
@@ -95,16 +96,21 @@ it("post a comment", async () => {
.findByProps({ inputId: "comments-postCommentForm-field" })
.props.onChange({ html: "Hello world!" });
- timekeeper.travel(new Date("2018-07-06T18:24:00.000Z"));
+ timekeeper.freeze(new Date("2018-07-06T18:24:00.002Z"));
testRenderer.root
.findByProps({ id: "comments-postCommentForm-form" })
.props.onSubmit();
+
// Test optimistic response.
expect(testRenderer.toJSON()).toMatchSnapshot();
- timekeeper.reset();
// Wait for loading.
await timeout();
+
+ // Travel to the time where the "timeout" has executed.
+ timekeeper.travel(new Date("2018-07-06T18:24:01.002Z"));
+
// Test after server response.
expect(testRenderer.toJSON()).toMatchSnapshot();
+ timekeeper.reset();
});
diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts
index 1560b0095..d0c26e431 100644
--- a/src/core/server/graph/tenant/mutators/comment.ts
+++ b/src/core/server/graph/tenant/mutators/comment.ts
@@ -1,11 +1,13 @@
import TenantContext from "talk-server/graph/tenant/context";
-import { GQLCreateCommentInput } from "talk-server/graph/tenant/schema/__generated__/types";
-import { Comment } from "talk-server/models/comment";
-import { create } from "talk-server/services/comments";
+import {
+ GQLCreateCommentInput,
+ GQLEditCommentInput,
+} from "talk-server/graph/tenant/schema/__generated__/types";
+import { create, edit } from "talk-server/services/comments";
export default (ctx: TenantContext) => ({
- create: (input: GQLCreateCommentInput): Promise => {
- return create(
+ create: (input: GQLCreateCommentInput) =>
+ create(
ctx.mongo,
ctx.tenant,
ctx.user!,
@@ -16,6 +18,17 @@ export default (ctx: TenantContext) => ({
parent_id: input.parentID,
},
ctx.req
- );
- },
+ ),
+ edit: (input: GQLEditCommentInput) =>
+ edit(
+ ctx.mongo,
+ ctx.tenant,
+ ctx.user!,
+ {
+ id: input.commentID,
+ asset_id: input.assetID,
+ body: input.body,
+ },
+ ctx.req
+ ),
});
diff --git a/src/core/server/graph/tenant/resolvers/comment.ts b/src/core/server/graph/tenant/resolvers/comment.ts
index 3ecf35f41..ee775ff0f 100644
--- a/src/core/server/graph/tenant/resolvers/comment.ts
+++ b/src/core/server/graph/tenant/resolvers/comment.ts
@@ -2,6 +2,16 @@ import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__genera
import { Comment } from "talk-server/models/comment";
const Comment: GQLCommentTypeResolver = {
+ editing: (comment, input, ctx) => ({
+ // When there is more than one body history, then the comment has been
+ // edited.
+ edited: comment.body_history.length > 1,
+ // The date that the comment is editable until is the tenant's edit window
+ // length added to the comment created date.
+ editableUntil: new Date(
+ comment.created_at.valueOf() + ctx.tenant.editCommentWindowLength
+ ),
+ }),
createdAt: comment => comment.created_at,
author: (comment, input, ctx) =>
ctx.loaders.Users.user.load(comment.author_id),
diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts
index 1fb331602..8f96079ff 100644
--- a/src/core/server/graph/tenant/resolvers/mutation.ts
+++ b/src/core/server/graph/tenant/resolvers/mutation.ts
@@ -1,11 +1,15 @@
import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
const Mutation: GQLMutationTypeResolver = {
+ editComment: async (source, { input }, ctx) => ({
+ comment: await ctx.mutators.Comment.edit(input),
+ clientMutationId: input.clientMutationId,
+ }),
createComment: async (source, { input }, ctx) => {
const comment = await ctx.mutators.Comment.create(input);
- // TODO: (cvle) tell wyatt to take a look at this :-)
return {
- commentEdge: {
+ edge: {
+ // FIXME: (wyattjoh) when we're using a replies/respect sort, it is index based instead of date based, needs some work!
cursor: comment.created_at,
node: comment,
},
diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql
index 9a2dbfae4..79d9a73a8 100644
--- a/src/core/server/graph/tenant/schema/schema.graphql
+++ b/src/core/server/graph/tenant/schema/schema.graphql
@@ -552,6 +552,18 @@ type User {
## Comment
################################################################################
+type EditInfo {
+ """
+ edited will be True when the Comment has been edited in the past.
+ """
+ edited: Boolean!
+
+ """
+ editableUntil is the time that the comment is editable until.
+ """
+ editableUntil: Time
+}
+
enum COMMENT_STATUS {
"""
The comment is not PREMOD, but was not applied a moderation status by a
@@ -598,7 +610,7 @@ type Comment {
body: String
"""
- createdAt is the date in which the comment was created.
+ createdAt is the date in which the Comment was created.
"""
createdAt: Time!
@@ -608,7 +620,7 @@ type Comment {
author: User
"""
- status represents the Comment's current Status.
+ status represents the Comment's current status.
"""
status: COMMENT_STATUS!
@@ -619,13 +631,18 @@ type Comment {
replyCount: Int
"""
- replies will return the replies to this comment.
+ replies will return the replies to this Comment.
"""
replies(
first: Int = 10
orderBy: COMMENT_SORT = CREATED_AT_DESC
after: Cursor
): CommentsConnection
+
+ """
+ editing returns details about the edit status of a Comment.
+ """
+ editing: EditInfo!
}
type PageInfo {
@@ -844,9 +861,54 @@ mutation.
"""
type CreateCommentPayload {
"""
- CommentEdge is the possibly created comment edge.
+ edge is the possibly created comment edge.
"""
- commentEdge: CommentEdge
+ edge: CommentEdge
+
+ """
+ clientMutationId is required for Relay support.
+ """
+ clientMutationId: String!
+}
+
+##################
+## editComment
+##################
+
+"""
+EditCommentInput provides the input for the editComment Mutation.
+"""
+input EditCommentInput {
+ """
+ assetID is the ID of the Asset where we are editing a comment on.
+ """
+ assetID: ID!
+
+ """
+ commentID is the ID of the comment being edited.
+ """
+ commentID: ID!
+
+ """
+ body is the Comment body, the content of the Comment.
+ """
+ body: String!
+
+ """
+ clientMutationId is required for Relay support.
+ """
+ clientMutationId: String!
+}
+
+"""
+EditCommentPayload contains the edited Comment after the editComment
+mutation.
+"""
+type EditCommentPayload {
+ """
+ comment is the possibly edited comment.
+ """
+ comment: Comment
"""
clientMutationId is required for Relay support.
@@ -1231,6 +1293,12 @@ type Mutation {
"""
createComment(input: CreateCommentInput!): CreateCommentPayload
+ """
+ editComment will allow the author of a comment to change the body within the
+ time allotment.
+ """
+ editComment(input: EditCommentInput!): EditCommentPayload @auth
+
"""
updateSettings will update the Settings for the given Tenant.
"""
diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts
index 9a961a17a..d2c8e03a6 100644
--- a/src/core/server/models/comment.ts
+++ b/src/core/server/models/comment.ts
@@ -101,6 +101,97 @@ export async function createComment(
return comment;
}
+export type EditCommentInput = Pick<
+ Comment,
+ "id" | "author_id" | "body" | "status"
+> & {
+ /**
+ * lastEditableCommentCreatedAt is the date that the last comment would have
+ * been editable. It is generally derived from the tenant's
+ * `editCommentWindowLength` property.
+ */
+ lastEditableCommentCreatedAt: Date;
+};
+
+export async function editComment(
+ db: Db,
+ tenantID: string,
+ input: EditCommentInput
+) {
+ const EDITABLE_STATUSES = [
+ GQLCOMMENT_STATUS.NONE,
+ GQLCOMMENT_STATUS.PREMOD,
+ GQLCOMMENT_STATUS.ACCEPTED,
+ ];
+ const createdAt = new Date();
+
+ const { id, body, lastEditableCommentCreatedAt, status, author_id } = input;
+
+ const result = await collection(db).findOneAndUpdate(
+ {
+ id,
+ tenant_id: tenantID,
+ author_id,
+ status: {
+ $in: EDITABLE_STATUSES,
+ },
+ deleted_at: null,
+ created_at: {
+ $gt: lastEditableCommentCreatedAt,
+ },
+ },
+ {
+ $set: {
+ body,
+ status,
+ },
+ $push: {
+ body_history: {
+ body,
+ created_at: createdAt,
+ },
+ status_history: {
+ type: status,
+ created_at: createdAt,
+ },
+ },
+ },
+ // False to return the updated document instead of the original
+ // document.
+ { returnOriginal: false }
+ );
+ if (!result.value) {
+ // Try to get the comment.
+ const comment = await retrieveComment(db, tenantID, id);
+ if (!comment) {
+ // TODO: (wyattjoh) return better error
+ throw new Error("comment not found");
+ }
+
+ if (comment.author_id !== author_id) {
+ // TODO: (wyattjoh) return better error
+ throw new Error("comment author mismatch");
+ }
+
+ // Check to see if the comment had a status that was editable.
+ if (!EDITABLE_STATUSES.includes(comment.status)) {
+ // TODO: (wyattjoh) return better error
+ throw new Error("comment status is not editable");
+ }
+
+ // Check to see if the edit window expired.
+ if (comment.created_at <= lastEditableCommentCreatedAt) {
+ // TODO: (wyattjoh) return better error
+ throw new Error("edit window expired");
+ }
+
+ // TODO: (wyattjoh) return better error
+ throw new Error("comment edit failed for an unexpected reason");
+ }
+
+ return result.value;
+}
+
export async function retrieveComment(db: Db, tenantID: string, id: string) {
return collection(db).findOne({ id, tenant_id: tenantID });
}
@@ -129,7 +220,7 @@ export interface ConnectionInput {
}
function cursorGetterFactory(
- input: ConnectionInput
+ input: Pick
): NodeToCursorTransformer {
switch (input.orderBy) {
case GQLCOMMENT_SORT.CREATED_AT_DESC:
diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts
index 1c4531f3e..b356b919b 100644
--- a/src/core/server/services/comments/index.ts
+++ b/src/core/server/services/comments/index.ts
@@ -5,6 +5,8 @@ import { retrieveAsset } from "talk-server/models/asset";
import {
createComment,
CreateCommentInput,
+ editComment,
+ EditCommentInput,
retrieveComment,
} from "talk-server/models/comment";
import { Tenant } from "talk-server/models/tenant";
@@ -24,13 +26,14 @@ export async function create(
input: CreateComment,
req?: Request
) {
+ // Grab the asset that we'll use to check moderation pieces with.
const asset = await retrieveAsset(mongo, tenant.id, input.asset_id);
if (!asset) {
// TODO: (wyattjoh) return better error.
throw new Error("asset referenced does not exist");
}
- // TODO: (wyattjoh) Check that the asset was visable.
+ // TODO: (wyattjoh) Check that the asset was visible.
if (input.parent_id) {
// Check to see that the reference parent ID exists.
@@ -67,3 +70,55 @@ export async function create(
return comment;
}
+
+export type EditComment = Omit<
+ EditCommentInput,
+ "status" | "author_id" | "lastEditableCommentCreatedAt"
+> & {
+ /**
+ * asset_id is the asset that the comment exists on.
+ */
+ asset_id: string;
+};
+
+export async function edit(
+ mongo: Db,
+ tenant: Tenant,
+ author: User,
+ input: EditComment,
+ req?: Request
+) {
+ // Grab the asset that we'll use to check moderation pieces with.
+ const asset = await retrieveAsset(mongo, tenant.id, input.asset_id);
+ if (!asset) {
+ // TODO: (wyattjoh) return better error.
+ throw new Error("asset referenced does not exist");
+ }
+
+ // Run the comment through the moderation phases.
+ const { status } = await processForModeration({
+ asset,
+ tenant,
+ comment: input,
+ author,
+ req,
+ });
+
+ // TODO: (wyattjoh) use the actions somehow.
+
+ const comment = await editComment(mongo, tenant.id, {
+ id: input.id,
+ author_id: author.id,
+ body: input.body,
+ status,
+ // The editable time is based on the current time, and the edit window
+ // length. By subtracting the current date from the edit window length, we
+ // get the maximum value for the `created_at` time that would be permitted
+ // for the comment edit to succeed.
+ lastEditableCommentCreatedAt: new Date(
+ Date.now() - tenant.editCommentWindowLength
+ ),
+ });
+
+ return comment;
+}
diff --git a/src/core/server/services/comments/moderation/index.ts b/src/core/server/services/comments/moderation/index.ts
index 33f286320..d46be2c59 100644
--- a/src/core/server/services/comments/moderation/index.ts
+++ b/src/core/server/services/comments/moderation/index.ts
@@ -2,9 +2,9 @@ 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 { Asset } from "talk-server/models/asset";
+import { Comment } from "talk-server/models/comment";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
-import { CreateComment } from "talk-server/services/comments";
import { Request } from "talk-server/types/express";
import { moderationPhases } from "./phases";
@@ -24,7 +24,7 @@ export interface PhaseResult {
export interface ModerationPhaseContext {
asset: Asset;
tenant: Tenant;
- comment: CreateComment;
+ comment: Partial;
author: User;
req?: Request;
}
diff --git a/src/core/server/services/comments/moderation/phases/commentLength.ts b/src/core/server/services/comments/moderation/phases/commentLength.ts
index 82a4ada04..cb51f1c56 100644
--- a/src/core/server/services/comments/moderation/phases/commentLength.ts
+++ b/src/core/server/services/comments/moderation/phases/commentLength.ts
@@ -17,7 +17,7 @@ export const commentLength: IntermediateModerationPhase = ({
tenant,
comment,
}): IntermediatePhaseResult | void => {
- const length = comment.body.length;
+ const length = comment.body ? comment.body.length : 0;
// Check to see if the body is too short, if it is, then complain about it!
if (length < 2) {
diff --git a/src/core/server/services/comments/moderation/phases/links.ts b/src/core/server/services/comments/moderation/phases/links.ts
index 18628920a..2eed3715e 100755
--- a/src/core/server/services/comments/moderation/phases/links.ts
+++ b/src/core/server/services/comments/moderation/phases/links.ts
@@ -30,8 +30,9 @@ export const links: IntermediateModerationPhase = ({
comment,
}): IntermediatePhaseResult | void => {
if (
- testPremodLinksEnable(tenant, comment.body) ||
- (asset.settings && testPremodLinksEnable(asset.settings, comment.body))
+ comment.body &&
+ (testPremodLinksEnable(tenant, comment.body) ||
+ (asset.settings && testPremodLinksEnable(asset.settings, comment.body)))
) {
// Add the flag related to Trust to the comment.
return {
diff --git a/src/core/server/services/comments/moderation/phases/spam.ts b/src/core/server/services/comments/moderation/phases/spam.ts
index 4962add1b..a15e47d9a 100644
--- a/src/core/server/services/comments/moderation/phases/spam.ts
+++ b/src/core/server/services/comments/moderation/phases/spam.ts
@@ -48,6 +48,11 @@ export const spam: IntermediateModerationPhase = async ({
return;
}
+ // If the comment doesn't have a body, it can't be spam!
+ if (!comment.body) {
+ return;
+ }
+
// Create the Akismet client.
const client = new Client({
key: integration.key,
diff --git a/src/core/server/services/comments/moderation/phases/staff.ts b/src/core/server/services/comments/moderation/phases/staff.ts
index aad2a931d..13d130df1 100755
--- a/src/core/server/services/comments/moderation/phases/staff.ts
+++ b/src/core/server/services/comments/moderation/phases/staff.ts
@@ -9,9 +9,6 @@ import {
// If a given user is a staff member, always approve their comment.
export const staff: IntermediateModerationPhase = ({
- asset,
- tenant,
- comment,
author,
}): IntermediatePhaseResult | void => {
if (author.role !== GQLUSER_ROLE.COMMENTER) {
diff --git a/src/core/server/services/comments/moderation/phases/toxic.ts b/src/core/server/services/comments/moderation/phases/toxic.ts
index 403a50f5e..8232b1767 100644
--- a/src/core/server/services/comments/moderation/phases/toxic.ts
+++ b/src/core/server/services/comments/moderation/phases/toxic.ts
@@ -19,6 +19,10 @@ export const toxic: IntermediateModerationPhase = async ({
tenant,
comment,
}): Promise => {
+ if (!comment.body) {
+ return;
+ }
+
const integration = tenant.integrations.perspective;
if (!integration.enabled) {
diff --git a/src/core/server/services/comments/moderation/phases/wordlist.ts b/src/core/server/services/comments/moderation/phases/wordlist.ts
index a817d3fea..b129093cb 100755
--- a/src/core/server/services/comments/moderation/phases/wordlist.ts
+++ b/src/core/server/services/comments/moderation/phases/wordlist.ts
@@ -14,6 +14,11 @@ export const wordlist: IntermediateModerationPhase = ({
tenant,
comment,
}): IntermediatePhaseResult | void => {
+ // If there isn't a body, there can't be a bad word!
+ if (!comment.body) {
+ return;
+ }
+
// Decide the status based on whether or not the current asset/settings
// has pre-mod enabled or not. If the comment was rejected based on the
// wordlist, then reject it, otherwise if the moderation setting is