From 414a4c2a4238d1dd4c27a136a90723f882719ab2 Mon Sep 17 00:00:00 2001 From: Vinh Date: Sat, 29 Jun 2019 01:20:10 +0200 Subject: [PATCH] =?UTF-8?q?[CORL-421,=20CORL-415]=C2=A0Live=20Comments=20o?= =?UTF-8?q?n=20Stream=20(#2379)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support comment replies * feat: comment created * feat: live update top level comments * feat: live updates on the stream embed * fix: tests * chore: refactor FadeInTransition * fix: add missing translation and a live update bug * fix: graqphql * feat: improve loading experiene :-) * fix: live comment bugs * chore: adapt translation * feat: stop live updates when story is closed or commenting is disabled * test: add tests for stream live updates * fix: remove forgotten piece of code * fix: tests --- .../ModerateCard/ModerateCardContainer.tsx | 2 +- .../components}/FadeInTransition.css | 0 .../components}/FadeInTransition.tsx | 0 src/core/client/framework/components/index.ts | 1 + .../framework/testHelpers/denormalize.ts | 12 +- src/core/client/stream/local/local.graphql | 8 + .../CreateCommentReplyMutation.ts | 1 + .../Comments/PermalinkView/PermalinkView.tsx | 1 + .../__snapshots__/PermalinkView.spec.tsx.snap | 1 + .../CommentReplyCreatedSubscription.tsx | 117 +++++ .../tabs/Comments/ReplyList/ReplyList.tsx | 60 ++- .../ReplyList/ReplyListContainer.spec.tsx | 35 +- .../Comments/ReplyList/ReplyListContainer.tsx | 198 +++++--- .../ReplyList/ReplyListViewNewMutation.tsx | 44 ++ .../__snapshots__/ReplyList.spec.tsx.snap | 432 +++++++++--------- .../ReplyListContainer.spec.tsx.snap | 72 ++- .../AllCommentsTabContainer.tsx | 141 ++++-- .../AllCommentsTab/AllCommentsTabQuery.tsx | 31 +- .../AllCommentsTabViewNewMutation.tsx | 37 ++ .../CommentCreatedSubscription.tsx | 91 ++++ .../AllCommentsTab/SpinnerWhileRendering.tsx | 62 +++ .../FeaturedCommentsContainer.tsx | 9 +- .../PostCommentForm/CreateCommentMutation.ts | 1 + .../tabs/Comments/Stream/StreamContainer.tsx | 2 +- .../tabs/Comments/Stream/StreamQuery.tsx | 42 +- .../renderFeaturedStream.spec.tsx.snap | 6 +- .../test/comments/featured/loadMore.spec.tsx | 2 +- .../featured/renderFeaturedStream.spec.tsx | 2 +- .../__snapshots__/permalinkView.spec.tsx.snap | 54 +++ .../permalink/liveDirectReplies.spec.tsx | 77 ++++ .../comments/permalink/permalinkView.spec.tsx | 2 +- .../permalinkViewCommentNotFound.spec.tsx | 2 +- .../__snapshots__/loadMore.spec.tsx.snap | 7 +- .../postLocalReply.spec.tsx.snap | 166 ++++++- .../renderCommunityGuidelines.spec.tsx.snap | 6 +- .../renderMessageBox.spec.tsx.snap | 24 +- .../__snapshots__/renderReplies.spec.tsx.snap | 108 +++++ .../__snapshots__/renderStream.spec.tsx.snap | 6 +- .../showConversation.spec.tsx.snap | 27 ++ .../__snapshots__/sortStream.spec.tsx.snap | 4 +- .../test/comments/stream/banned.spec.tsx | 2 +- .../closedOrDisabledCommentStream.spec.tsx | 2 +- .../test/comments/stream/ignoreUser.spec.tsx | 6 +- .../stream/liveCommentReplies.spec.tsx | 253 ++++++++++ .../stream/liveTopLevelComments.spec.tsx | 153 +++++++ .../test/comments/stream/loadMore.spec.tsx | 4 +- .../comments/stream/postLocalReply.spec.tsx | 4 +- .../comments/stream/renderMessageBox.spec.tsx | 2 +- .../comments/stream/renderStream.spec.tsx | 2 +- .../comments/stream/showAllReplies.spec.tsx | 17 +- .../comments/stream/showConversation.spec.tsx | 4 +- .../test/comments/stream/sortStream.spec.tsx | 4 +- src/core/client/stream/test/fixtures.ts | 11 +- .../server/graph/tenant/resolvers/Comment.ts | 2 +- .../tenant/resolvers/CommentCreatedPayload.ts | 11 + .../CommentEnteredModerationQueuePayload.ts | 11 + .../CommentLeftModerationQueuePayload.ts | 11 + .../resolvers/CommentReplyCreatedPayload.ts | 11 + .../resolvers/CommentStatusUpdatedPayload.ts | 13 + .../resolvers/Subscription/commentCreated.ts | 21 + .../commentEnteredModerationQueue.ts | 4 - .../commentLeftModerationQueue.ts | 4 - .../Subscription/commentReplyCreated.ts | 21 + .../Subscription/commentStatusUpdated.ts | 12 - .../tenant/resolvers/Subscription/helpers.ts | 7 +- .../tenant/resolvers/Subscription/index.ts | 4 + .../tenant/resolvers/Subscription/types.ts | 12 + .../server/graph/tenant/resolvers/index.ts | 10 + .../server/graph/tenant/schema/schema.graphql | 34 ++ src/core/server/services/comments/index.ts | 14 +- src/core/server/services/events/comments.ts | 32 +- src/locales/en-US/stream.ftl | 10 +- 72 files changed, 2105 insertions(+), 496 deletions(-) rename src/core/client/{admin/routes/Moderate/ModerateCard => framework/components}/FadeInTransition.css (100%) rename src/core/client/{admin/routes/Moderate/ModerateCard => framework/components}/FadeInTransition.tsx (100%) create mode 100644 src/core/client/stream/tabs/Comments/ReplyList/CommentReplyCreatedSubscription.tsx create mode 100644 src/core/client/stream/tabs/Comments/ReplyList/ReplyListViewNewMutation.tsx create mode 100644 src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabViewNewMutation.tsx create mode 100644 src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/CommentCreatedSubscription.tsx create mode 100644 src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/SpinnerWhileRendering.tsx create mode 100644 src/core/client/stream/test/comments/permalink/liveDirectReplies.spec.tsx create mode 100644 src/core/client/stream/test/comments/stream/liveCommentReplies.spec.tsx create mode 100644 src/core/client/stream/test/comments/stream/liveTopLevelComments.spec.tsx create mode 100644 src/core/server/graph/tenant/resolvers/CommentCreatedPayload.ts create mode 100644 src/core/server/graph/tenant/resolvers/CommentEnteredModerationQueuePayload.ts create mode 100644 src/core/server/graph/tenant/resolvers/CommentLeftModerationQueuePayload.ts create mode 100644 src/core/server/graph/tenant/resolvers/CommentReplyCreatedPayload.ts create mode 100644 src/core/server/graph/tenant/resolvers/CommentStatusUpdatedPayload.ts create mode 100644 src/core/server/graph/tenant/resolvers/Subscription/commentCreated.ts create mode 100644 src/core/server/graph/tenant/resolvers/Subscription/commentReplyCreated.ts diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCardContainer.tsx b/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCardContainer.tsx index 301de1ba9..2debfa6f3 100644 --- a/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCardContainer.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateCard/ModerateCardContainer.tsx @@ -12,6 +12,7 @@ import NotAvailable from "coral-admin/components/NotAvailable"; import { getModerationLink } from "coral-admin/helpers"; import { ApproveCommentMutation } from "coral-admin/mutations"; import { RejectCommentMutation } from "coral-admin/mutations"; +import FadeInTransition from "coral-framework/components/FadeInTransition"; import { MutationProp, withFragmentContainer, @@ -19,7 +20,6 @@ import { } from "coral-framework/lib/relay"; import { GQLTAG } from "coral-framework/schema"; -import FadeInTransition from "./FadeInTransition"; import FeatureCommentMutation from "./FeatureCommentMutation"; import ModerateCard from "./ModerateCard"; import ModeratedByContainer from "./ModeratedByContainer"; diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/FadeInTransition.css b/src/core/client/framework/components/FadeInTransition.css similarity index 100% rename from src/core/client/admin/routes/Moderate/ModerateCard/FadeInTransition.css rename to src/core/client/framework/components/FadeInTransition.css diff --git a/src/core/client/admin/routes/Moderate/ModerateCard/FadeInTransition.tsx b/src/core/client/framework/components/FadeInTransition.tsx similarity index 100% rename from src/core/client/admin/routes/Moderate/ModerateCard/FadeInTransition.tsx rename to src/core/client/framework/components/FadeInTransition.tsx diff --git a/src/core/client/framework/components/index.ts b/src/core/client/framework/components/index.ts index f5777498d..5e878a7c3 100644 --- a/src/core/client/framework/components/index.ts +++ b/src/core/client/framework/components/index.ts @@ -4,4 +4,5 @@ export { default as FacebookButton } from "./FacebookButton"; export { default as GoogleButton } from "./GoogleButton"; export { default as OIDCButton } from "./OIDCButton"; export { default as Markdown } from "./Markdown"; +export { default as FadeInTransition } from "./FadeInTransition"; export { default as DurationField, DURATION_UNIT } from "./DurationField"; diff --git a/src/core/client/framework/testHelpers/denormalize.ts b/src/core/client/framework/testHelpers/denormalize.ts index b442a1bf8..2e4e87595 100644 --- a/src/core/client/framework/testHelpers/denormalize.ts +++ b/src/core/client/framework/testHelpers/denormalize.ts @@ -32,8 +32,10 @@ export function denormalizeComment( return createFixture({ ...comment, replies: { edges: replyEdges, pageInfo: repliesPageInfo }, - replyCount: replyEdges.length, + replyCount: + comment.replyCount !== undefined ? comment.replyCount : replyEdges.length, parentCount: parents.length, + parent: parents.length > 0 ? parents[parents.length - 1].node : undefined, parents: { edges: parents, pageInfo: { startCursor: null, hasPreviousPage: false }, @@ -55,9 +57,15 @@ export function denormalizeStory(story: Fixture) { }))) || []; const commentsPageInfo = (story.comments && story.comments.pageInfo) || { - endCursor: null, hasNextPage: false, }; + if (commentsPageInfo.endCursor === undefined) { + commentsPageInfo.endCursor = + commentNodes.length > 0 + ? commentNodes[commentNodes.length - 1].node.createdAt + : null; + } + const featuredCommentsCount = commentNodes.filter( n => n.tags && n.tags.some((t: GQLTag) => t.code === GQLTAG.FEATURED) ).length; diff --git a/src/core/client/stream/local/local.graphql b/src/core/client/stream/local/local.graphql index 9046b60e2..469bef4d1 100644 --- a/src/core/client/stream/local/local.graphql +++ b/src/core/client/stream/local/local.graphql @@ -40,6 +40,14 @@ extend type Comment { # Remember last viewer action that could have caused a status change. lastViewerAction: COMMENT_VIEWER_ACTION + + # If true then Comment came in live. + enteredLive: Boolean +} + +extend type CommentsConnection { + # Contains comment that came in live and is still behind the `View New` button. + viewNewEdges: [CommentEdge!] } type Local { diff --git a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts index 3c4ba5ed8..a7a5e8a66 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts @@ -114,6 +114,7 @@ graphql` graphql` fragment CreateCommentReplyMutation_viewer on User { role + createdAt } `; /** end */ diff --git a/src/core/client/stream/tabs/Comments/PermalinkView/PermalinkView.tsx b/src/core/client/stream/tabs/Comments/PermalinkView/PermalinkView.tsx index e0e0cf187..9d114e46c 100644 --- a/src/core/client/stream/tabs/Comments/PermalinkView/PermalinkView.tsx +++ b/src/core/client/stream/tabs/Comments/PermalinkView/PermalinkView.tsx @@ -92,6 +92,7 @@ const PermalinkView: FunctionComponent = ({ comment={comment} story={story} settings={settings} + liveDirectRepliesInsertion /> diff --git a/src/core/client/stream/tabs/Comments/PermalinkView/__snapshots__/PermalinkView.spec.tsx.snap b/src/core/client/stream/tabs/Comments/PermalinkView/__snapshots__/PermalinkView.spec.tsx.snap index e56daed07..75c4dc8c6 100644 --- a/src/core/client/stream/tabs/Comments/PermalinkView/__snapshots__/PermalinkView.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/PermalinkView/__snapshots__/PermalinkView.spec.tsx.snap @@ -120,6 +120,7 @@ exports[`renders correctly 1`] = ` > & { + liveDirectRepliesInsertion?: boolean; + } + ) => + requestSubscription(environment, { + subscription: graphql` + subscription CommentReplyCreatedSubscription($ancestorID: ID!) { + commentReplyCreated(ancestorID: $ancestorID) { + comment { + id + createdAt + parent { + id + } + ...AllCommentsTabContainer_comment + } + } + } + `, + variables, + updater: store => { + const rootField = store.getRootField("commentReplyCreated"); + if (!rootField) { + return; + } + const comment = rootField.getLinkedRecord("comment")!; + comment.setValue(true, "enteredLive"); + + const parentProxy = store.get( + comment.getLinkedRecord("parent")!.getValue("id")! + )!; + + const depth = determineDepthTillAncestor(comment, variables.ancestorID); + if (depth === null) { + // could not trace back to ancestor, discard. + return; + } + // Comment is just outside our visible depth. + if (depth === 6) { + // Inform last comment in visible tree about the available replies. + // This will trigger to show the `Read More of this Conversation` link. + const replyCount = parentProxy.getValue("replyCount") || 0; + parentProxy.setValue(replyCount + 1, "replyCount"); + return; + } + + const connectionKey = "ReplyList_replies"; + const filters = { orderBy: "CREATED_AT_ASC" }; + const connection = ConnectionHandler.getConnection( + parentProxy, + connectionKey, + filters + ); + if (!connection) { + // If it has no connection, it could not have been + // in our visible tree. + return; + } + if (connection.getLinkedRecord("pageInfo").getValue("hasNextPage")) { + // It hasn't loaded all comments yet, ignore this one. + return; + } + const commentsEdge = store.create( + `edge-${comment.getValue("id")!}`, + "CommentsEdge" + ); + commentsEdge.setValue(comment.getValue("createdAt"), "cursor"); + commentsEdge.setLinkedRecord(comment, "node"); + if ( + parentProxy.getValue("id") === variables.ancestorID && + variables.liveDirectRepliesInsertion + ) { + ConnectionHandler.insertEdgeAfter(connection, commentsEdge); + } else { + const linked = connection.getLinkedRecords("viewNewEdges") || []; + connection.setLinkedRecords( + linked.concat(commentsEdge), + "viewNewEdges" + ); + } + }, + }) +); + +export default CommentReplyCreatedSubscription; diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyList.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyList.tsx index ec5269ee4..5f96e57d2 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyList.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyList.tsx @@ -2,6 +2,7 @@ import { Localized } from "fluent-react/compat"; import * as React from "react"; import { FunctionComponent } from "react"; +import FadeInTransition from "coral-framework/components/FadeInTransition"; import { PropTypesOf } from "coral-framework/types"; import { Button, HorizontalGutter } from "coral-ui/components"; @@ -21,6 +22,7 @@ export interface ReplyListProps { id: string; replyListElement?: React.ReactElement; showConversationLink?: boolean; + enteredLive?: boolean | null; } & PropTypesOf["comment"] & PropTypesOf["comment"] >; @@ -31,6 +33,8 @@ export interface ReplyListProps { indentLevel?: number; localReply?: boolean; disableReplies?: boolean; + viewNewCount?: number; + onViewNew?: () => void; } const ReplyList: FunctionComponent = props => { @@ -41,26 +45,30 @@ const ReplyList: FunctionComponent = props => { role="log" > {props.comments.map(comment => ( - - - - {comment.replyListElement} - - + + + + {comment.replyListElement} + + + ))} {props.hasMore && ( @@ -80,6 +88,22 @@ const ReplyList: FunctionComponent = props => { )} + {Boolean(props.viewNewCount && props.viewNewCount > 0) && ( + + + + + + )} ); }; diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx index 4e2633b47..31a34dc0b 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx @@ -14,20 +14,23 @@ const ReplyListContainerN = removeFragmentRefs(ReplyListContainer); it("renders correctly", () => { const props: PropTypesOf = { story: { - id: "story-id", + isClosed: false, }, comment: { id: "comment-id", status: "NONE", replies: { - edges: [{ node: { id: "comment-1" } }, { node: { id: "comment-2" } }], + edges: [ + { node: { id: "comment-1", enteredLive: false } }, + { node: { id: "comment-2", enteredLive: false } }, + ], + viewNewEdges: [], }, lastViewerAction: null, }, settings: { - reaction: { - icon: "thumb_up_alt", - label: "Respect", + disableCommenting: { + enabled: false, }, }, relay: { @@ -46,12 +49,12 @@ it("renders correctly", () => { it("renders correctly when replies are empty", () => { const props: PropTypesOf = { story: { - id: "story-id", + isClosed: false, }, comment: { id: "comment-id", status: "NONE", - replies: { edges: [] }, + replies: { edges: [], viewNewEdges: [] }, lastViewerAction: null, }, relay: { @@ -60,9 +63,8 @@ it("renders correctly when replies are empty", () => { } as any, viewer: null, settings: { - reaction: { - icon: "thumb_up_alt", - label: "Respect", + disableCommenting: { + enabled: false, }, }, indentLevel: 1, @@ -77,20 +79,23 @@ describe("when has more replies", () => { let finishLoading: ((error?: Error) => void) | null = null; const props: PropTypesOf = { story: { - id: "story-id", + isClosed: false, }, comment: { id: "comment-id", status: "NONE", replies: { - edges: [{ node: { id: "comment-1" } }, { node: { id: "comment-2" } }], + edges: [ + { node: { id: "comment-1", enteredLive: false } }, + { node: { id: "comment-2", enteredLive: false } }, + ], + viewNewEdges: [], }, lastViewerAction: null, }, settings: { - reaction: { - icon: "thumb_up_alt", - label: "Respect", + disableCommenting: { + enabled: false, }, }, relay: { diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx index 2b5e54f30..40777a43b 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx @@ -1,8 +1,13 @@ -import React, { FunctionComponent } from "react"; +import React, { FunctionComponent, useCallback, useEffect } from "react"; import { graphql, GraphQLTaggedNode, RelayPaginationProp } from "react-relay"; import { withProps } from "recompose"; -import { withPaginationContainer } from "coral-framework/lib/relay"; +import { + useLoadMore, + useMutation, + useSubscription, + withPaginationContainer, +} from "coral-framework/lib/relay"; import { FragmentKeys } from "coral-framework/lib/relay/types"; import { Omit, PropTypesOf } from "coral-framework/types"; import { ReplyListContainer1_comment as CommentData } from "coral-stream/__generated__/ReplyListContainer1_comment.graphql"; @@ -13,8 +18,10 @@ import { ReplyListContainer1PaginationQueryVariables } from "coral-stream/__gene import { ReplyListContainer5_comment as Comment5Data } from "coral-stream/__generated__/ReplyListContainer5_comment.graphql"; import { isCommentVisible } from "../helpers"; +import CommentReplyCreatedSubscription from "./CommentReplyCreatedSubscription"; import LocalReplyListContainer from "./LocalReplyListContainer"; import ReplyList from "./ReplyList"; +import ReplyListViewNewMutation from "./ReplyListViewNewMutation"; type UnpackArray = T extends ReadonlyArray ? U : any; type ReplyNode5 = UnpackArray["node"]; @@ -33,6 +40,13 @@ type Props = BaseProps & { ReplyListComponent: | React.ComponentType<{ [P in FragmentKeys]: any }> | undefined; + /** + * liveDirectRepliesInsertion if set to true, + * live replies to the first level of comments + * will be inserted directly into the comment stream + * instead of hiding behind a button. + */ + liveDirectRepliesInsertion?: boolean; }; // TODO: (cvle) If this could be autogenerated. @@ -41,71 +55,87 @@ type FragmentVariables = Omit< "commentID" >; -export class ReplyListContainer extends React.Component { - public state = { - disableShowAll: false, - }; - - public render() { - if ( - this.props.comment.replies == null || - this.props.comment.replies.edges.length === 0 - ) { - return null; - } - const comments = - // Comment is not visible after a viewer action, so don't render it anymore. - this.props.comment.lastViewerAction && - !isCommentVisible(this.props.comment) - ? [] - : 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, - })); - return ( - - ); - } - - private showAll = () => { - if (!this.props.relay.hasMore() || this.props.relay.isLoading()) { +export const ReplyListContainer: React.FunctionComponent = props => { + const [showAll, isLoadingShowAll] = useLoadMore(props.relay, 999999999); + const subcribeToCommentReplyCreated = useSubscription( + CommentReplyCreatedSubscription + ); + useEffect(() => { + // TODO: (cvle) check for story or settings state + // for whether or not we should turn on subscriptions: + // e.g. `if (!props.story.settings.live) { return; }` + if (props.story.isClosed || props.settings.disableCommenting.enabled) { return; } + if (props.indentLevel !== 1) { + return; + } + const disposable = subcribeToCommentReplyCreated({ + ancestorID: props.comment.id, + liveDirectRepliesInsertion: props.liveDirectRepliesInsertion, + }); + return () => { + disposable.dispose(); + }; + }, [ + subcribeToCommentReplyCreated, + props.comment.id, + props.indentLevel, + props.relay.hasMore(), + props.liveDirectRepliesInsertion, + ]); - this.setState({ disableShowAll: true }); - this.props.relay.loadMore( - 999999999, // Fetch All Replies - error => { - this.setState({ disableShowAll: false }); - if (error) { - // tslint:disable-next-line:no-console - console.error(error); - } - } - ); - }; -} + const viewNew = useMutation(ReplyListViewNewMutation); + const onViewNew = useCallback(() => { + viewNew({ commentID: props.comment.id }); + }, [props.comment.id, viewNew]); + + const viewNewCount = + (props.comment.replies.viewNewEdges && + props.comment.replies.viewNewEdges.length) || + 0; + + if ( + props.comment.replies == null || + (props.comment.replies.edges.length === 0 && viewNewCount === 0) + ) { + return null; + } + const comments = + // Comment is not visible after a viewer action, so don't render it anymore. + props.comment.lastViewerAction && !isCommentVisible(props.comment) + ? [] + : props.comment.replies.edges.map(edge => ({ + ...edge.node, + replyListElement: props.ReplyListComponent && ( + + ), + // ReplyListContainer5 contains replyCount. + showConversationLink: + ((edge.node as any) as ReplyNode5).replyCount > 0, + })); + return ( + + ); +}; function createReplyListContainer( indentLevel: number, @@ -168,12 +198,16 @@ const ReplyListContainer5 = createReplyListContainer( `, settings: graphql` fragment ReplyListContainer5_settings on Settings { + disableCommenting { + enabled + } ...LocalReplyListContainer_settings ...CommentContainer_settings } `, story: graphql` fragment ReplyListContainer5_story on Story { + isClosed ...CommentContainer_story ...LocalReplyListContainer_story } @@ -190,10 +224,14 @@ const ReplyListContainer5 = createReplyListContainer( lastViewerAction replies(first: $count, after: $cursor, orderBy: $orderBy) @connection(key: "ReplyList_replies") { + viewNewEdges { + cursor + } edges { node { id replyCount + enteredLive ...CommentContainer_comment ...IgnoredTombstoneOrHideContainer_comment ...LocalReplyListContainer_comment @@ -234,12 +272,16 @@ const ReplyListContainer4 = createReplyListContainer( `, settings: graphql` fragment ReplyListContainer4_settings on Settings { + disableCommenting { + enabled + } ...ReplyListContainer5_settings ...CommentContainer_settings } `, story: graphql` fragment ReplyListContainer4_story on Story { + isClosed ...ReplyListContainer5_story ...CommentContainer_story } @@ -256,9 +298,13 @@ const ReplyListContainer4 = createReplyListContainer( lastViewerAction replies(first: $count, after: $cursor, orderBy: $orderBy) @connection(key: "ReplyList_replies") { + viewNewEdges { + cursor + } edges { node { id + enteredLive ...CommentContainer_comment ...IgnoredTombstoneOrHideContainer_comment ...ReplyListContainer5_comment @@ -298,12 +344,16 @@ const ReplyListContainer3 = createReplyListContainer( `, settings: graphql` fragment ReplyListContainer3_settings on Settings { + disableCommenting { + enabled + } ...ReplyListContainer4_settings ...CommentContainer_settings } `, story: graphql` fragment ReplyListContainer3_story on Story { + isClosed ...ReplyListContainer4_story ...CommentContainer_story } @@ -320,9 +370,13 @@ const ReplyListContainer3 = createReplyListContainer( lastViewerAction replies(first: $count, after: $cursor, orderBy: $orderBy) @connection(key: "ReplyList_replies") { + viewNewEdges { + cursor + } edges { node { id + enteredLive ...CommentContainer_comment ...IgnoredTombstoneOrHideContainer_comment ...ReplyListContainer4_comment @@ -362,12 +416,16 @@ const ReplyListContainer2 = createReplyListContainer( `, settings: graphql` fragment ReplyListContainer2_settings on Settings { + disableCommenting { + enabled + } ...ReplyListContainer3_settings ...CommentContainer_settings } `, story: graphql` fragment ReplyListContainer2_story on Story { + isClosed ...ReplyListContainer3_story ...CommentContainer_story } @@ -384,9 +442,13 @@ const ReplyListContainer2 = createReplyListContainer( lastViewerAction replies(first: $count, after: $cursor, orderBy: $orderBy) @connection(key: "ReplyList_replies") { + viewNewEdges { + cursor + } edges { node { id + enteredLive ...CommentContainer_comment ...IgnoredTombstoneOrHideContainer_comment ...ReplyListContainer3_comment @@ -426,12 +488,16 @@ const ReplyListContainer1 = createReplyListContainer( `, settings: graphql` fragment ReplyListContainer1_settings on Settings { + disableCommenting { + enabled + } ...ReplyListContainer2_settings ...CommentContainer_settings } `, story: graphql` fragment ReplyListContainer1_story on Story { + isClosed ...ReplyListContainer2_story ...CommentContainer_story } @@ -448,9 +514,13 @@ const ReplyListContainer1 = createReplyListContainer( lastViewerAction replies(first: $count, after: $cursor, orderBy: $orderBy) @connection(key: "ReplyList_replies") { + viewNewEdges { + cursor + } edges { node { id + enteredLive ...CommentContainer_comment ...IgnoredTombstoneOrHideContainer_comment ...ReplyListContainer2_comment diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListViewNewMutation.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListViewNewMutation.tsx new file mode 100644 index 000000000..de23a3453 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListViewNewMutation.tsx @@ -0,0 +1,44 @@ +import { ConnectionHandler, Environment, RecordProxy } from "relay-runtime"; + +import { + commitLocalUpdatePromisified, + createMutation, +} from "coral-framework/lib/relay"; + +interface ReplyListViewNewInput { + commentID: string; +} + +const QueueViewNewMutation = createMutation( + "viewNew", + async (environment: Environment, input: ReplyListViewNewInput) => { + await commitLocalUpdatePromisified(environment, async store => { + const parentProxy = store.get(input.commentID); + if (!parentProxy) { + return; + } + const connectionKey = "ReplyList_replies"; + const filters = { orderBy: "CREATED_AT_ASC" }; + const connection = ConnectionHandler.getConnection( + parentProxy, + connectionKey, + filters + ); + if (!connection) { + return; + } + const viewNewEdges = connection.getLinkedRecords( + "viewNewEdges" + ) as RecordProxy[]; + if (!viewNewEdges || viewNewEdges.length === 0) { + return; + } + viewNewEdges.forEach(edge => { + ConnectionHandler.insertEdgeAfter(connection, edge); + }); + connection.setLinkedRecords([], "viewNewEdges"); + }); + } +); + +export default QueueViewNewMutation; diff --git a/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyList.spec.tsx.snap b/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyList.spec.tsx.snap index 70aaa5922..57bc72f2c 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyList.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyList.spec.tsx.snap @@ -6,88 +6,96 @@ exports[`renders correctly 1`] = ` id="coral-comments-replyList-log--comment-id" role="log" > - - - - - - - - + + + + + + + + - - + showConversationLink={true} + story={ + Object { + "id": "story-id", + } + } + viewer={null} + /> + + + `; @@ -97,82 +105,90 @@ exports[`when there is more disables load more button 1`] = ` id="coral-comments-replyList-log--comment-id" role="log" > - - - - - - - - + + + + + + + + - - + showConversationLink={false} + story={ + Object { + "id": "story-id", + } + } + viewer={null} + /> + + + - - - - - - - - + + + + + + + + - - + showConversationLink={false} + story={ + Object { + "id": "story-id", + } + } + viewer={null} + /> + + + `; @@ -110,15 +116,18 @@ exports[`when has more replies renders hasMore 1`] = ` "edges": Array [ Object { "node": Object { + "enteredLive": false, "id": "comment-1", }, }, Object { "node": Object { + "enteredLive": false, "id": "comment-2", }, }, ], + "viewNewEdges": Array [], }, "status": "NONE", } @@ -126,11 +135,13 @@ exports[`when has more replies renders hasMore 1`] = ` comments={ Array [ Object { + "enteredLive": false, "id": "comment-1", "replyListElement": undefined, "showConversationLink": false, }, Object { + "enteredLive": false, "id": "comment-2", "replyListElement": undefined, "showConversationLink": false, @@ -142,19 +153,20 @@ exports[`when has more replies renders hasMore 1`] = ` indentLevel={1} localReply={false} onShowAll={[Function]} + onViewNew={[Function]} settings={ Object { - "reaction": Object { - "icon": "thumb_up_alt", - "label": "Respect", + "disableCommenting": Object { + "enabled": false, }, } } story={ Object { - "id": "story-id", + "isClosed": false, } } + viewNewCount={0} viewer={null} /> `; @@ -169,15 +181,18 @@ exports[`when has more replies when showing all disables show all button 1`] = ` "edges": Array [ Object { "node": Object { + "enteredLive": false, "id": "comment-1", }, }, Object { "node": Object { + "enteredLive": false, "id": "comment-2", }, }, ], + "viewNewEdges": Array [], }, "status": "NONE", } @@ -185,11 +200,13 @@ exports[`when has more replies when showing all disables show all button 1`] = ` comments={ Array [ Object { + "enteredLive": false, "id": "comment-1", "replyListElement": undefined, "showConversationLink": false, }, Object { + "enteredLive": false, "id": "comment-2", "replyListElement": undefined, "showConversationLink": false, @@ -201,19 +218,20 @@ exports[`when has more replies when showing all disables show all button 1`] = ` indentLevel={1} localReply={false} onShowAll={[Function]} + onViewNew={[Function]} settings={ Object { - "reaction": Object { - "icon": "thumb_up_alt", - "label": "Respect", + "disableCommenting": Object { + "enabled": false, }, } } story={ Object { - "id": "story-id", + "isClosed": false, } } + viewNewCount={0} viewer={null} /> `; @@ -228,15 +246,18 @@ exports[`when has more replies when showing all enable show all button after loa "edges": Array [ Object { "node": Object { + "enteredLive": false, "id": "comment-1", }, }, Object { "node": Object { + "enteredLive": false, "id": "comment-2", }, }, ], + "viewNewEdges": Array [], }, "status": "NONE", } @@ -244,11 +265,13 @@ exports[`when has more replies when showing all enable show all button after loa comments={ Array [ Object { + "enteredLive": false, "id": "comment-1", "replyListElement": undefined, "showConversationLink": false, }, Object { + "enteredLive": false, "id": "comment-2", "replyListElement": undefined, "showConversationLink": false, @@ -260,19 +283,20 @@ exports[`when has more replies when showing all enable show all button after loa indentLevel={1} localReply={false} onShowAll={[Function]} + onViewNew={[Function]} settings={ Object { - "reaction": Object { - "icon": "thumb_up_alt", - "label": "Respect", + "disableCommenting": Object { + "enabled": false, }, } } story={ Object { - "id": "story-id", + "isClosed": false, } } + viewNewCount={0} viewer={null} /> `; diff --git a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx index e8d320e6e..10189d473 100644 --- a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx @@ -1,25 +1,34 @@ -import React, { FunctionComponent } from "react"; +import React, { FunctionComponent, useCallback, useEffect } from "react"; import { graphql, RelayPaginationProp } from "react-relay"; +import FadeInTransition from "coral-framework/components/FadeInTransition"; import { useLoadMore, + useLocal, + useMutation, + useSubscription, withPaginationContainer, } from "coral-framework/lib/relay"; +import { GQLCOMMENT_SORT } from "coral-framework/schema"; import { Omit, PropTypesOf } from "coral-framework/types"; -import { AllCommentsTabContainer_settings as SettingsData } from "coral-stream/__generated__/AllCommentsTabContainer_settings.graphql"; -import { AllCommentsTabContainer_story as StoryData } from "coral-stream/__generated__/AllCommentsTabContainer_story.graphql"; -import { AllCommentsTabContainer_viewer as ViewerData } from "coral-stream/__generated__/AllCommentsTabContainer_viewer.graphql"; +import { AllCommentsTabContainer_settings } from "coral-stream/__generated__/AllCommentsTabContainer_settings.graphql"; +import { AllCommentsTabContainer_story } from "coral-stream/__generated__/AllCommentsTabContainer_story.graphql"; +import { AllCommentsTabContainer_viewer } from "coral-stream/__generated__/AllCommentsTabContainer_viewer.graphql"; +import { AllCommentsTabContainerLocal } from "coral-stream/__generated__/AllCommentsTabContainerLocal.graphql"; import { AllCommentsTabContainerPaginationQueryVariables } from "coral-stream/__generated__/AllCommentsTabContainerPaginationQuery.graphql"; -import { Button, HorizontalGutter } from "coral-ui/components"; +import { Box, Button, HorizontalGutter } from "coral-ui/components"; import { Localized } from "fluent-react/compat"; + import { CommentContainer } from "../../Comment"; import IgnoredTombstoneOrHideContainer from "../../IgnoredTombstoneOrHideContainer"; import { ReplyListContainer } from "../../ReplyList"; +import AllCommentsTabViewNewMutation from "./AllCommentsTabViewNewMutation"; +import CommentCreatedSubscription from "./CommentCreatedSubscription"; interface Props { - story: StoryData; - settings: SettingsData; - viewer: ViewerData | null; + story: AllCommentsTabContainer_story; + settings: AllCommentsTabContainer_settings; + viewer: AllCommentsTabContainer_viewer | null; relay: RelayPaginationProp; } @@ -29,17 +38,85 @@ graphql` id ...CommentContainer_comment ...ReplyListContainer1_comment + ...IgnoredTombstoneOrHideContainer_comment } `; export const AllCommentsTabContainer: FunctionComponent = props => { + const [{ commentsOrderBy }] = useLocal( + graphql` + fragment AllCommentsTabContainerLocal on Local { + commentsOrderBy + } + ` + ); + const subscribeToCommentCreated = useSubscription(CommentCreatedSubscription); + useEffect(() => { + // TODO: (cvle) check for story or settings state + // for whether or not we should turn on subscriptions: + // e.g. `if (!props.story.settings.live) { return; }` + if (props.story.isClosed || props.settings.disableCommenting.enabled) { + return; + } + if ( + commentsOrderBy === GQLCOMMENT_SORT.CREATED_AT_ASC && + props.relay.hasMore() + ) { + // If sort by oldest we only need to know if there is more to load. + return; + } + if ( + ![ + GQLCOMMENT_SORT.CREATED_AT_ASC, + GQLCOMMENT_SORT.CREATED_AT_DESC, + ].includes(commentsOrderBy as GQLCOMMENT_SORT) + ) { + // Only chronological sort supports top level live updates of incoming comments. + return; + } + const disposable = subscribeToCommentCreated({ + storyID: props.story.id, + orderBy: commentsOrderBy, + }); + return () => { + disposable.dispose(); + }; + }, [ + commentsOrderBy, + subscribeToCommentCreated, + props.story.id, + props.relay.hasMore(), + ]); const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10); + const viewMore = useMutation(AllCommentsTabViewNewMutation); + const onViewMore = useCallback(() => viewMore({ storyID: props.story.id }), [ + props.story.id, + viewMore, + ]); const comments = props.story.comments.edges.map(edge => edge.node); + const viewNewCount = + (props.story.comments.viewNewEdges && + props.story.comments.viewNewEdges.length) || + 0; return ( <> + {Boolean(viewNewCount && viewNewCount > 0) && ( + + + + )} @@ -49,31 +126,32 @@ export const AllCommentsTabContainer: FunctionComponent = props => { viewer={props.viewer} comment={comment} > - - - - + + + + + + ))} {props.relay.hasMore() && ( - + @@ -104,12 +182,16 @@ const enhanced = withPaginationContainer< orderBy: { type: "COMMENT_SORT!", defaultValue: CREATED_AT_DESC } ) { id + isClosed comments(first: $count, after: $cursor, orderBy: $orderBy) @connection(key: "Stream_comments") { + viewNewEdges { + cursor + } edges { node { + enteredLive ...AllCommentsTabContainer_comment @relay(mask: false) - ...IgnoredTombstoneOrHideContainer_comment } } } @@ -137,6 +219,9 @@ const enhanced = withPaginationContainer< reaction { sortLabel } + disableCommenting { + enabled + } ...ReplyListContainer1_settings ...CommentContainer_settings } diff --git a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabQuery.tsx b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabQuery.tsx index b082b9e7d..96cf4ecd1 100644 --- a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabQuery.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabQuery.tsx @@ -5,11 +5,12 @@ import { } from "coral-framework/lib/relay"; import { AllCommentsTabQuery as QueryTypes } from "coral-stream/__generated__/AllCommentsTabQuery.graphql"; import { AllCommentsTabQueryLocal as Local } from "coral-stream/__generated__/AllCommentsTabQueryLocal.graphql"; -import { Delay, Flex, Spinner } from "coral-ui/components"; +import { Flex, Spinner } from "coral-ui/components"; import React, { FunctionComponent } from "react"; import { ReadyState } from "react-relay"; import AllCommentsTabContainer from "./AllCommentsTabContainer"; +import SpinnerWhileRendering from "./SpinnerWhileRendering"; interface Props { local: Local; @@ -20,29 +21,21 @@ export const render = (data: ReadyState) => { if (data.error) { return
{data.error.message}
; } - if (!data.props) { - return ( - - - - ); - } if (data.props) { return ( - + + + ); } - return ( - - - - - + + + ); }; diff --git a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabViewNewMutation.tsx b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabViewNewMutation.tsx new file mode 100644 index 000000000..0ccfd0fec --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabViewNewMutation.tsx @@ -0,0 +1,37 @@ +import { ConnectionHandler, Environment, RecordProxy } from "relay-runtime"; + +import { + commitLocalUpdatePromisified, + createMutation, +} from "coral-framework/lib/relay"; +import { GQLCOMMENT_SORT } from "coral-framework/schema"; + +interface Input { + storyID: string; +} + +const AllCommentsTabViewNewMutation = createMutation( + "viewNew", + async (environment: Environment, input: Input) => { + await commitLocalUpdatePromisified(environment, async store => { + const story = store.get(input.storyID)!; + const connection = ConnectionHandler.getConnection( + story, + "Stream_comments", + { + orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC, + } + )! as RecordProxy; + const viewNewEdges = connection.getLinkedRecords("viewNewEdges"); + if (!viewNewEdges || viewNewEdges.length === 0) { + return; + } + viewNewEdges.forEach(edge => { + ConnectionHandler.insertEdgeBefore(connection, edge); + }); + connection.setLinkedRecords([], "viewNewEdges"); + }); + } +); + +export default AllCommentsTabViewNewMutation; diff --git a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/CommentCreatedSubscription.tsx b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/CommentCreatedSubscription.tsx new file mode 100644 index 000000000..8101ba7f5 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/CommentCreatedSubscription.tsx @@ -0,0 +1,91 @@ +import { graphql, requestSubscription } from "react-relay"; +import { + ConnectionHandler, + Environment, + RecordProxy, + RecordSourceSelectorProxy, +} from "relay-runtime"; + +import { + createSubscription, + SubscriptionVariables, +} from "coral-framework/lib/relay"; +import { GQLCOMMENT_SORT, GQLCOMMENT_SORT_RL } from "coral-framework/schema"; +import { CommentCreatedSubscription } from "coral-stream/__generated__/CommentCreatedSubscription.graphql"; + +function updateForNewestFirst( + store: RecordSourceSelectorProxy, + storyID: string +) { + const rootField = store.getRootField("commentCreated"); + if (!rootField) { + return; + } + const comment = rootField.getLinkedRecord("comment")!; + comment.setValue(true, "enteredLive"); + const commentsEdge = store.create( + `edge-${comment.getValue("id")!}`, + "CommentsEdge" + ); + commentsEdge.setValue(comment.getValue("createdAt"), "cursor"); + commentsEdge.setLinkedRecord(comment, "node"); + const story = store.get(storyID)!; + const connection = ConnectionHandler.getConnection(story, "Stream_comments", { + orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC, + })!; + const linked = connection.getLinkedRecords("viewNewEdges") || []; + connection.setLinkedRecords(linked.concat(commentsEdge), "viewNewEdges"); +} + +function updateForOldestFirst( + store: RecordSourceSelectorProxy, + storyID: string +) { + const story = store.get(storyID)!; + const connection = ConnectionHandler.getConnection(story, "Stream_comments", { + orderBy: GQLCOMMENT_SORT.CREATED_AT_ASC, + })!; + const pageInfo = connection.getLinkedRecord("pageInfo") as RecordProxy; + pageInfo.setValue(true, "hasNextPage"); +} + +const CommentCreatedSubscription = createSubscription( + "subscribeToCommentCreated", + ( + environment: Environment, + variables: SubscriptionVariables & { + orderBy: GQLCOMMENT_SORT_RL; + } + ) => + requestSubscription(environment, { + subscription: graphql` + subscription CommentCreatedSubscription($storyID: ID!) { + commentCreated(storyID: $storyID) { + comment { + id + createdAt + ...AllCommentsTabContainer_comment + } + } + } + `, + variables, + updater: store => { + if (variables.orderBy === GQLCOMMENT_SORT.CREATED_AT_DESC) { + updateForNewestFirst(store, variables.storyID); + return; + } + if (variables.orderBy === GQLCOMMENT_SORT.CREATED_AT_ASC) { + updateForOldestFirst(store, variables.storyID); + return; + } + throw new Error( + `Unsupport new top level comment live updates for sort ${ + variables.orderBy + }` + ); + }, + }) +); + +export default CommentCreatedSubscription; diff --git a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/SpinnerWhileRendering.tsx b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/SpinnerWhileRendering.tsx new file mode 100644 index 000000000..3f1b289c0 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/SpinnerWhileRendering.tsx @@ -0,0 +1,62 @@ +import { Flex, Spinner } from "coral-ui/components"; +import React, { FunctionComponent, useEffect, useState } from "react"; + +interface Props { + children: React.ReactNode; +} + +function callWhenReallyIdle(callback: () => void) { + let handle: any = null; + const rIC = (cb: () => void) => { + if ((window as any).requestIdleCallback) { + handle = (window as any).requestIdleCallback(cb, { timeout: 300 }); + } else { + handle = setTimeout(cb, 0); + } + }; + + // Call `requestIdleCallback` multiple times to ensure + // that the browser is really idelling. + const times = 5; + let chained = callback; + for (let i = 0; i <= times; i++) { + const cur = chained; + chained = () => rIC(cur); + } + chained(); + + return () => { + if ((window as any).requestIdleCallback) { + (window as any).cancelIdleCallback(handle); + } else { + clearTimeout(handle); + } + }; +} + +/** + * Show spinner, wait for browser to idle and start rendering. + */ +const SpinnerWhileRendering: FunctionComponent = props => { + // In our tests, we don't actually "render", so just skip this. + if (process.env.NODE_ENV === "test") { + return <>{props.children}; + } + const [hidden, setHidden] = useState(true); + useEffect(() => { + // Ensure window has bee + return callWhenReallyIdle(() => setHidden(false)); + }, [setHidden]); + return ( + <> + {hidden && ( + + + + )} + {!hidden && props.children} + + ); +}; + +export default SpinnerWhileRendering; diff --git a/src/core/client/stream/tabs/Comments/Stream/FeaturedComments/FeaturedCommentsContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/FeaturedComments/FeaturedCommentsContainer.tsx index 68f6ddac5..ffb1cec06 100644 --- a/src/core/client/stream/tabs/Comments/Stream/FeaturedComments/FeaturedCommentsContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/FeaturedComments/FeaturedCommentsContainer.tsx @@ -29,8 +29,8 @@ export const FeaturedCommentsContainer: FunctionComponent = props => { return ( <> = props => { ))} {props.relay.hasMore() && ( - + diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts index 6efa2b577..6de116a39 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts @@ -77,6 +77,7 @@ function addCommentToStory( graphql` fragment CreateCommentMutation_viewer on User { role + createdAt } `; // tslint:disable-next-line:no-unused-expression diff --git a/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx index eec61e312..a2fcd0f9e 100644 --- a/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx @@ -89,7 +89,7 @@ export const StreamContainer: FunctionComponent = props => { /> )} {banned && } - + ) => { +export const render = ( + data: ReadyState, + commentsTab: COMMENTS_TAB +) => { if (data.error) { return
{data.error.message}
; } @@ -40,11 +44,19 @@ export const render = (data: ReadyState) => { } return ( - - - - - + <> + {// TODO: (cvle) For some reason this way of preloading + // causes weird errors in the + // tests. Needs further investigation. + process.env.NODE_ENV !== "test" && commentsTab === "ALL_COMMENTS" && ( + + )} + + + + + + ); }; @@ -73,23 +85,7 @@ const StreamQuery: FunctionComponent = props => { storyURL, }} render={data => { - if ( - // TODO: (cvle) For some reason this way of preloading - // causes weird errors in the - // tests. Needs further investigation. - process.env.NODE_ENV !== "test" && - !data.props && - !data.error - ) { - return ( - <> - {commentsTab === "ALL_COMMENTS" && ( - - )} - - ); - } - return render(data); + return render(data, commentsTab); }} /> diff --git a/src/core/client/stream/test/comments/featured/__snapshots__/renderFeaturedStream.spec.tsx.snap b/src/core/client/stream/test/comments/featured/__snapshots__/renderFeaturedStream.spec.tsx.snap index d0fa1e1d6..c4bd9ebce 100644 --- a/src/core/client/stream/test/comments/featured/__snapshots__/renderFeaturedStream.spec.tsx.snap +++ b/src/core/client/stream/test/comments/featured/__snapshots__/renderFeaturedStream.spec.tsx.snap @@ -173,7 +173,7 @@ exports[`renders comment stream 1`] = `
{ const { testRenderer } = await createTestRenderer(); const streamLog = await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-featuredComments-log") ); // Get amount of comments before. diff --git a/src/core/client/stream/test/comments/featured/renderFeaturedStream.spec.tsx b/src/core/client/stream/test/comments/featured/renderFeaturedStream.spec.tsx index f02905a26..927a97224 100644 --- a/src/core/client/stream/test/comments/featured/renderFeaturedStream.spec.tsx +++ b/src/core/client/stream/test/comments/featured/renderFeaturedStream.spec.tsx @@ -50,7 +50,7 @@ async function createTestRenderer( it("renders comment stream", async () => { const { testRenderer } = await createTestRenderer(); await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-featuredComments-log") ); expect(within(testRenderer.root).toJSON()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/test/comments/permalink/__snapshots__/permalinkView.spec.tsx.snap b/src/core/client/stream/test/comments/permalink/__snapshots__/permalinkView.spec.tsx.snap index b8eee3136..43e19586c 100644 --- a/src/core/client/stream/test/comments/permalink/__snapshots__/permalinkView.spec.tsx.snap +++ b/src/core/client/stream/test/comments/permalink/__snapshots__/permalinkView.spec.tsx.snap @@ -786,6 +786,33 @@ exports[`renders permalink view 1`] = ` />
+
+
+ + +   + + + In reply to + + Markus + + +
+
@@ -975,6 +1002,33 @@ exports[`renders permalink view 1`] = ` />
+
+
+ + +   + + + In reply to + + Markus + + +
+
diff --git a/src/core/client/stream/test/comments/permalink/liveDirectReplies.spec.tsx b/src/core/client/stream/test/comments/permalink/liveDirectReplies.spec.tsx new file mode 100644 index 000000000..54c237008 --- /dev/null +++ b/src/core/client/stream/test/comments/permalink/liveDirectReplies.spec.tsx @@ -0,0 +1,77 @@ +import { pureMerge } from "coral-common/utils"; +import { + GQLResolver, + SubscriptionToCommentReplyCreatedResolver, +} from "coral-framework/schema"; +import { + createResolversStub, + CreateTestRendererParams, + waitForElement, + within, +} from "coral-framework/testHelpers"; + +import { comments, settings, stories } from "../../fixtures"; +import create from "./create"; + +const story = stories[0]; +const rootComment = comments[0]; + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context, subscriptionHandler } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + story: () => story, + comment: () => comments[0], + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(story.id, "storyID"); + localRecord.setValue(rootComment.id, "commentID"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + + return { + testRenderer, + context, + subscriptionHandler, + }; +} + +it("direct replies to the permalink comment should immediately appear", async () => { + const liveComment = comments[3]; + const { testRenderer, subscriptionHandler } = await createTestRenderer(); + const container = await waitForElement(() => + within(testRenderer.root).getByTestID("current-tab-pane") + ); + expect(subscriptionHandler.has("commentReplyCreated")).toBe(true); + + expect(() => + within(container).getByTestID(`comment-${liveComment.id}`) + ).toThrow(); + + subscriptionHandler.dispatch( + "commentReplyCreated", + variables => { + if (variables.ancestorID !== rootComment.id) { + return; + } + return { + comment: pureMerge(liveComment, { + parent: rootComment, + }), + }; + } + ); + // Comment should immediately appear. + within(container).getByTestID(`comment-${liveComment.id}`); +}); diff --git a/src/core/client/stream/test/comments/permalink/permalinkView.spec.tsx b/src/core/client/stream/test/comments/permalink/permalinkView.spec.tsx index 488e84326..f1299e239 100644 --- a/src/core/client/stream/test/comments/permalink/permalinkView.spec.tsx +++ b/src/core/client/stream/test/comments/permalink/permalinkView.spec.tsx @@ -102,7 +102,7 @@ it("show all comments", async () => { .getByText("View Full Discussion") .props.onClick(mockEvent); await waitForElement(() => - within(tabPane).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); }); diff --git a/src/core/client/stream/test/comments/permalink/permalinkViewCommentNotFound.spec.tsx b/src/core/client/stream/test/comments/permalink/permalinkViewCommentNotFound.spec.tsx index 2978a816d..def40d754 100644 --- a/src/core/client/stream/test/comments/permalink/permalinkViewCommentNotFound.spec.tsx +++ b/src/core/client/stream/test/comments/permalink/permalinkViewCommentNotFound.spec.tsx @@ -79,7 +79,7 @@ it("show all comments", async () => { .props.onClick(mockEvent); await waitForElement(() => - within(tabPane).getByTestID("comments-stream-log") + within(tabPane).getByTestID("comments-allComments-log") ); }); mockEvent.preventDefault.verify(); diff --git a/src/core/client/stream/test/comments/stream/__snapshots__/loadMore.spec.tsx.snap b/src/core/client/stream/test/comments/stream/__snapshots__/loadMore.spec.tsx.snap index 96a0b0156..64af8903c 100644 --- a/src/core/client/stream/test/comments/stream/__snapshots__/loadMore.spec.tsx.snap +++ b/src/core/client/stream/test/comments/stream/__snapshots__/loadMore.spec.tsx.snap @@ -4,8 +4,8 @@ exports[`renders comment stream with load more button 1`] = `
+
+
+ + +   + + + In reply to + + Markus + + +
+
@@ -622,8 +649,8 @@ exports[`renders comment stream 1`] = `
+
+
+ + +   + + + In reply to + + Markus + + +
+
@@ -1131,6 +1185,33 @@ exports[`renders comment stream 1`] = ` />
+
+
+ + +   + + + In reply to + + Markus + + +
+
@@ -1343,6 +1424,33 @@ exports[`renders comment stream 1`] = ` />
+
+
+ + +   + + + In reply to + + Markus + + +
+
@@ -1555,6 +1663,33 @@ exports[`renders comment stream 1`] = ` />
+
+
+ + +   + + + In reply to + + Markus + + +
+
@@ -1767,6 +1902,33 @@ exports[`renders comment stream 1`] = ` />
+
+
+ + +   + + + In reply to + + Markus + + +
+
diff --git a/src/core/client/stream/test/comments/stream/__snapshots__/renderCommunityGuidelines.spec.tsx.snap b/src/core/client/stream/test/comments/stream/__snapshots__/renderCommunityGuidelines.spec.tsx.snap index d0a6f50e2..3acc191b3 100644 --- a/src/core/client/stream/test/comments/stream/__snapshots__/renderCommunityGuidelines.spec.tsx.snap +++ b/src/core/client/stream/test/comments/stream/__snapshots__/renderCommunityGuidelines.spec.tsx.snap @@ -157,7 +157,7 @@ exports[`renders comment stream with community guidelines 1`] = `
@@ -382,7 +382,7 @@ exports[`renders message box when logged in 1`] = `
@@ -658,7 +658,7 @@ exports[`renders message box when not logged in 1`] = `
@@ -843,7 +843,7 @@ exports[`renders message box when story isClosed 1`] = `
diff --git a/src/core/client/stream/test/comments/stream/__snapshots__/renderReplies.spec.tsx.snap b/src/core/client/stream/test/comments/stream/__snapshots__/renderReplies.spec.tsx.snap index fafe54b3e..f57c12e0f 100644 --- a/src/core/client/stream/test/comments/stream/__snapshots__/renderReplies.spec.tsx.snap +++ b/src/core/client/stream/test/comments/stream/__snapshots__/renderReplies.spec.tsx.snap @@ -88,6 +88,33 @@ exports[`renders reply list 1`] = ` />
+
+
+ + +   + + + In reply to + + Markus + + +
+
@@ -282,6 +309,33 @@ exports[`renders reply list 1`] = ` />
+
+
+ + +   + + + In reply to + + Markus + + +
+
@@ -471,6 +525,33 @@ exports[`renders reply list 1`] = ` />
+
+
+ + +   + + + In reply to + + Markus + + +
+
@@ -662,6 +743,33 @@ exports[`renders reply list 1`] = ` />
+
+
+ + +   + + + In reply to + + Markus + + +
+
diff --git a/src/core/client/stream/test/comments/stream/__snapshots__/renderStream.spec.tsx.snap b/src/core/client/stream/test/comments/stream/__snapshots__/renderStream.spec.tsx.snap index fcebd75d1..60c45aa59 100644 --- a/src/core/client/stream/test/comments/stream/__snapshots__/renderStream.spec.tsx.snap +++ b/src/core/client/stream/test/comments/stream/__snapshots__/renderStream.spec.tsx.snap @@ -173,7 +173,7 @@ exports[`renders comment stream 1`] = `
+
+
+ + +   + + + In reply to + + Markus + + +
+
diff --git a/src/core/client/stream/test/comments/stream/__snapshots__/sortStream.spec.tsx.snap b/src/core/client/stream/test/comments/stream/__snapshots__/sortStream.spec.tsx.snap index 0788958be..220750860 100644 --- a/src/core/client/stream/test/comments/stream/__snapshots__/sortStream.spec.tsx.snap +++ b/src/core/client/stream/test/comments/stream/__snapshots__/sortStream.spec.tsx.snap @@ -4,8 +4,8 @@ exports[`renders app with comment stream 1`] = `
{ timekeeper.freeze(firstComment.createdAt); const { testRenderer, tabPane } = await createTestRenderer(); await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); within(tabPane).getAllByText("Your account has been banned", { exact: false, diff --git a/src/core/client/stream/test/comments/stream/closedOrDisabledCommentStream.spec.tsx b/src/core/client/stream/test/comments/stream/closedOrDisabledCommentStream.spec.tsx index 68022a283..57ee5e5ec 100644 --- a/src/core/client/stream/test/comments/stream/closedOrDisabledCommentStream.spec.tsx +++ b/src/core/client/stream/test/comments/stream/closedOrDisabledCommentStream.spec.tsx @@ -90,7 +90,7 @@ it("auto close comment stream when story closed at has been reached", async () = ).toBeNull(); await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); jest.advanceTimersByTime(closeIn); diff --git a/src/core/client/stream/test/comments/stream/ignoreUser.spec.tsx b/src/core/client/stream/test/comments/stream/ignoreUser.spec.tsx index 03ca6fd7b..1d630256a 100644 --- a/src/core/client/stream/test/comments/stream/ignoreUser.spec.tsx +++ b/src/core/client/stream/test/comments/stream/ignoreUser.spec.tsx @@ -121,7 +121,7 @@ it("render stream with ignored user", async () => { stories[0] ); await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); expect( within(tabPane).queryByTestID(`comment-${firstComment.id}`) @@ -143,7 +143,7 @@ it("render stream with only staff comments, ignore user button should not be pre storyWithOnlyStaffComments ); await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); const moderator = moderators[0]; const username = within(tabPane).getByText(moderator!.username!, { @@ -176,7 +176,7 @@ it("render stream with regular comments, ignore user button should be present", stories[0] ); await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); const commenter = commenters[0]; const username = within(tabPane).getByText(commenter!.username!, { diff --git a/src/core/client/stream/test/comments/stream/liveCommentReplies.spec.tsx b/src/core/client/stream/test/comments/stream/liveCommentReplies.spec.tsx new file mode 100644 index 000000000..7122381fe --- /dev/null +++ b/src/core/client/stream/test/comments/stream/liveCommentReplies.spec.tsx @@ -0,0 +1,253 @@ +import { pureMerge } from "coral-common/utils"; +import { + GQLComment, + GQLResolver, + GQLStory, + SubscriptionToCommentReplyCreatedResolver, +} from "coral-framework/schema"; +import { + createFixture, + createResolversStub, + CreateTestRendererParams, + denormalizeComment, + denormalizeStory, + waitForElement, + within, +} from "coral-framework/testHelpers"; + +import { baseComment, baseStory, comments, settings } from "../../fixtures"; +import create from "./create"; + +const commentData = comments[0]; + +const rootComment = denormalizeComment( + createFixture({ + ...baseComment, + id: "my-comment", + body: "body 0", + replyCount: 1, + replies: { + ...baseComment.replies, + edges: [ + { + cursor: baseComment.createdAt, + node: { + ...baseComment, + id: "my-comment-1", + body: "body 1", + replyCount: 1, + replies: { + ...baseComment.replies, + edges: [ + { + cursor: baseComment.createdAt, + node: { + ...baseComment, + id: "my-comment-2", + body: "body 2", + replyCount: 1, + replies: { + ...baseComment.replies, + edges: [ + { + cursor: baseComment.createdAt, + node: { + ...baseComment, + id: "my-comment-3", + body: "body 3", + replyCount: 1, + replies: { + ...baseComment.replies, + edges: [ + { + cursor: baseComment.createdAt, + node: { + ...baseComment, + id: "my-comment-4", + body: "body 4", + replyCount: 1, + replies: { + ...baseComment.replies, + edges: [ + { + cursor: baseComment.createdAt, + node: { + ...baseComment, + id: "my-comment-5", + body: "body 5", + replyCount: 0, + replies: { + ...baseComment.replies, + edges: [], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }) +); + +const story = denormalizeStory( + createFixture( + { + id: "story-with-deep-replies", + url: "http://localhost/stories/story-with-replies", + comments: { + edges: [ + { + node: rootComment, + cursor: rootComment.createdAt, + }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + }, + baseStory + ) +); + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context, subscriptionHandler } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + story: () => story, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(story.id, "storyID"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + + return { + testRenderer, + context, + subscriptionHandler, + }; +} + +it("should show more replies", async () => { + const { testRenderer, subscriptionHandler } = await createTestRenderer(); + const container = await waitForElement(() => + within(testRenderer.root).getByTestID("comments-allComments-log") + ); + expect(subscriptionHandler.has("commentReplyCreated")).toBe(true); + expect(() => + within(container).getByTestID(`comment-${commentData.id}`) + ).toThrow(); + + subscriptionHandler.dispatch( + "commentReplyCreated", + variables => { + if (variables.ancestorID !== rootComment.id) { + return; + } + return { + comment: pureMerge(commentData, { + parent: { ...baseComment, id: "my-comment" }, + }), + }; + } + ); + + const showMoreButton = await waitForElement(() => + within(testRenderer.root).getByText("Show More Replies", { + exact: false, + selector: "button", + }) + ); + showMoreButton.props.onClick(); + within(container).getByTestID(`comment-${commentData.id}`); +}); + +it("should show Read More of this Conversation", async () => { + const { testRenderer, subscriptionHandler } = await createTestRenderer(); + const container = await waitForElement(() => + within(testRenderer.root).getByTestID("comments-allComments-log") + ); + expect(subscriptionHandler.has("commentReplyCreated")).toBe(true); + expect(() => + within(testRenderer.root).getByText("Read More of this Conversation", { + exact: false, + selector: "a", + }) + ).toThrow(); + + subscriptionHandler.dispatch( + "commentReplyCreated", + variables => { + if (variables.ancestorID !== rootComment.id) { + return; + } + return { + comment: pureMerge(commentData, { + parent: { ...baseComment, id: "my-comment-5" }, + }), + }; + } + ); + within(container).getByText("Read More of this Conversation", { + exact: false, + selector: "a", + }); +}); + +it("should not subscribe when story is closed", async () => { + const { testRenderer, subscriptionHandler } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + story: () => pureMerge(story, { isClosed: true }), + }, + }), + }); + await waitForElement(() => + within(testRenderer.root).getByTestID("comments-allComments-log") + ); + expect(subscriptionHandler.has("commentReplyCreated")).toBe(false); +}); + +it("should not subscribe when commenting is disabled", async () => { + const { testRenderer, subscriptionHandler } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + settings: () => + pureMerge(settings, { + disableCommenting: { + enabled: true, + }, + }), + }, + }), + }); + await waitForElement(() => + within(testRenderer.root).getByTestID("comments-allComments-log") + ); + expect(subscriptionHandler.has("commentReplyCreated")).toBe(false); +}); diff --git a/src/core/client/stream/test/comments/stream/liveTopLevelComments.spec.tsx b/src/core/client/stream/test/comments/stream/liveTopLevelComments.spec.tsx new file mode 100644 index 000000000..433e16a51 --- /dev/null +++ b/src/core/client/stream/test/comments/stream/liveTopLevelComments.spec.tsx @@ -0,0 +1,153 @@ +import { pureMerge } from "coral-common/utils"; +import { + GQLResolver, + SubscriptionToCommentCreatedResolver, +} from "coral-framework/schema"; +import { + act, + createResolversStub, + CreateTestRendererParams, + waitForElement, + within, +} from "coral-framework/testHelpers"; + +import { comments, settings, stories } from "../../fixtures"; +import create from "./create"; + +const story = stories[0]; + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context, subscriptionHandler } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + story: () => story, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue(story.id, "storyID"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + + return { + testRenderer, + context, + subscriptionHandler, + }; +} +it("should view more when ordering by newest", async () => { + const { testRenderer, subscriptionHandler } = await createTestRenderer(); + const container = await waitForElement(() => + within(testRenderer.root).getByTestID("comments-allComments-log") + ); + const commentData = comments[5]; + + expect(subscriptionHandler.has("commentCreated")).toBe(true); + expect(() => + within(container).getByTestID(`comment-${commentData.id}`) + ).toThrow(); + + subscriptionHandler.dispatch( + "commentCreated", + variables => { + if (variables.storyID !== story.id) { + return; + } + return { + comment: commentData, + }; + } + ); + + const viewMoreButton = await waitForElement(() => + within(testRenderer.root).getByText("View 1 New Comment", { + exact: false, + selector: "button", + }) + ); + viewMoreButton.props.onClick(); + within(container).getByTestID(`comment-${commentData.id}`); +}); + +it("should load more when ordering by oldest", async () => { + const { testRenderer, subscriptionHandler } = await createTestRenderer({ + initLocalState: localRecord => { + localRecord.setValue("CREATED_AT_ASC", "commentsOrderBy"); + }, + }); + await waitForElement(() => + within(testRenderer.root).getByTestID("comments-allComments-log") + ); + const commentData = comments[5]; + + expect(subscriptionHandler.has("commentCreated")).toBe(true); + expect(() => + within(testRenderer.root).getByText("Load More", { + exact: false, + selector: "button", + }) + ).toThrow(); + + await act(async () => { + subscriptionHandler.dispatch( + "commentCreated", + variables => { + if (variables.storyID !== story.id) { + return; + } + return { + comment: commentData, + }; + } + ); + + await waitForElement(() => + within(testRenderer.root).getByText("Load More", { + exact: false, + selector: "button", + }) + ); + }); +}); + +it("should not subscribe when story is closed", async () => { + const { testRenderer, subscriptionHandler } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + story: () => pureMerge(story, { isClosed: true }), + }, + }), + }); + await waitForElement(() => + within(testRenderer.root).getByTestID("comments-allComments-log") + ); + expect(subscriptionHandler.has("commentCreated")).toBe(false); +}); + +it("should not subscribe when commenting is disabled", async () => { + const { testRenderer, subscriptionHandler } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + settings: () => + pureMerge(settings, { + disableCommenting: { + enabled: true, + }, + }), + }, + }), + }); + await waitForElement(() => + within(testRenderer.root).getByTestID("comments-allComments-log") + ); + expect(subscriptionHandler.has("commentCreated")).toBe(false); +}); diff --git a/src/core/client/stream/test/comments/stream/loadMore.spec.tsx b/src/core/client/stream/test/comments/stream/loadMore.spec.tsx index 46124240d..9d92da0a0 100644 --- a/src/core/client/stream/test/comments/stream/loadMore.spec.tsx +++ b/src/core/client/stream/test/comments/stream/loadMore.spec.tsx @@ -97,14 +97,14 @@ beforeEach(() => { it("renders comment stream with load more button", async () => { const streamLog = await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); expect(within(streamLog).toJSON()).toMatchSnapshot(); }); it("loads more comments", async () => { const streamLog = await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); // Get amount of comments before. diff --git a/src/core/client/stream/test/comments/stream/postLocalReply.spec.tsx b/src/core/client/stream/test/comments/stream/postLocalReply.spec.tsx index f98de8ce8..62a732c0a 100644 --- a/src/core/client/stream/test/comments/stream/postLocalReply.spec.tsx +++ b/src/core/client/stream/test/comments/stream/postLocalReply.spec.tsx @@ -66,7 +66,7 @@ beforeEach(() => { it("renders comment stream", async () => { const streamLog = await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); // Wait for loading. expect(within(streamLog).toJSON()).toMatchSnapshot(); @@ -74,7 +74,7 @@ it("renders comment stream", async () => { it("post a reply", async () => { const streamLog = await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); const deepestReply = within(streamLog).getByTestID( diff --git a/src/core/client/stream/test/comments/stream/renderMessageBox.spec.tsx b/src/core/client/stream/test/comments/stream/renderMessageBox.spec.tsx index f0d80f2be..c6211eba0 100644 --- a/src/core/client/stream/test/comments/stream/renderMessageBox.spec.tsx +++ b/src/core/client/stream/test/comments/stream/renderMessageBox.spec.tsx @@ -36,7 +36,7 @@ async function createTestRenderer( }); await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); const tabPane = await waitForElement(() => diff --git a/src/core/client/stream/test/comments/stream/renderStream.spec.tsx b/src/core/client/stream/test/comments/stream/renderStream.spec.tsx index 6aa7a64a6..24759af19 100644 --- a/src/core/client/stream/test/comments/stream/renderStream.spec.tsx +++ b/src/core/client/stream/test/comments/stream/renderStream.spec.tsx @@ -42,7 +42,7 @@ async function createTestRenderer( it("renders comment stream", async () => { const { testRenderer } = await createTestRenderer(); await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); expect(within(testRenderer.root).toJSON()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/test/comments/stream/showAllReplies.spec.tsx b/src/core/client/stream/test/comments/stream/showAllReplies.spec.tsx index 81856cc10..10c2ebdeb 100644 --- a/src/core/client/stream/test/comments/stream/showAllReplies.spec.tsx +++ b/src/core/client/stream/test/comments/stream/showAllReplies.spec.tsx @@ -2,6 +2,7 @@ import { ReactTestRenderer } from "react-test-renderer"; import sinon from "sinon"; import { + act, createSinonStub, wait, waitForElement, @@ -113,13 +114,15 @@ it("show all replies", async () => { const commentsBefore = within(commentReplyList).getAllByTestID(/^comment-/) .length; - within(commentReplyList) - .getByText("Show All") - .props.onClick(); - // Wait for loading. - await wait(() => - expect(within(commentReplyList).queryByText("Show All")).toBeNull() - ); + await act(async () => { + within(commentReplyList) + .getByText("Show All") + .props.onClick(); + // Wait for loading. + await wait(() => + expect(within(commentReplyList).queryByText("Show All")).toBeNull() + ); + }); expect(within(commentReplyList).getAllByTestID(/^comment-/).length).toBe( commentsBefore + 1 diff --git a/src/core/client/stream/test/comments/stream/showConversation.spec.tsx b/src/core/client/stream/test/comments/stream/showConversation.spec.tsx index d171ab12c..e0789eb33 100644 --- a/src/core/client/stream/test/comments/stream/showConversation.spec.tsx +++ b/src/core/client/stream/test/comments/stream/showConversation.spec.tsx @@ -45,7 +45,7 @@ beforeEach(() => { it("renders deepest comment with link", async () => { const streamLog = await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); const deepestReply = within(streamLog).getByTestID( "comment-comment-with-deepest-replies-5" @@ -58,7 +58,7 @@ it("shows conversation", async () => { preventDefault: sinon.mock().once(), }; const streamLog = await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); await act(async () => { within(streamLog) diff --git a/src/core/client/stream/test/comments/stream/sortStream.spec.tsx b/src/core/client/stream/test/comments/stream/sortStream.spec.tsx index 56ae9ae4d..ed98fc3b2 100644 --- a/src/core/client/stream/test/comments/stream/sortStream.spec.tsx +++ b/src/core/client/stream/test/comments/stream/sortStream.spec.tsx @@ -67,7 +67,7 @@ it("renders app with comment stream", async () => { }); let streamLog = await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); const selectField = within(testRenderer.root).getByLabelText("Sort By"); const oldestOption = within(selectField).getByText("Oldest"); @@ -79,7 +79,7 @@ it("renders app with comment stream", async () => { }); streamLog = await waitForElement(() => - within(testRenderer.root).getByTestID("comments-stream-log") + within(testRenderer.root).getByTestID("comments-allComments-log") ); }); expect(within(streamLog).toJSON()).toMatchSnapshot(); diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index cc0a5abe9..1dc7b6757 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -155,6 +155,8 @@ export const baseComment = createFixture({ total: 0, }, }, + parent: undefined, + viewerActionPresence: { reaction: false, dontAgree: false, flag: false }, tags: [], }); @@ -405,9 +407,6 @@ export const stories = denormalizeStories( { node: comments[0], cursor: comments[0].createdAt }, { node: comments[1], cursor: comments[1].createdAt }, ], - pageInfo: { - hasNextPage: false, - }, }, }, { @@ -418,9 +417,6 @@ export const stories = denormalizeStories( { node: comments[2], cursor: comments[2].createdAt }, { node: comments[3], cursor: comments[3].createdAt }, ], - pageInfo: { - hasNextPage: false, - }, }, }, { @@ -434,9 +430,6 @@ export const stories = denormalizeStories( cursor: commentsFromStaff[0].createdAt, }, ], - pageInfo: { - hasNextPage: false, - }, }, }, ], diff --git a/src/core/server/graph/tenant/resolvers/Comment.ts b/src/core/server/graph/tenant/resolvers/Comment.ts index 951999e86..0fcf3c973 100644 --- a/src/core/server/graph/tenant/resolvers/Comment.ts +++ b/src/core/server/graph/tenant/resolvers/Comment.ts @@ -19,7 +19,7 @@ import { hasAncestors } from "coral-server/models/comment/helpers"; import TenantContext from "../context"; import { getURLWithCommentID } from "./util"; -const maybeLoadOnlyID = ( +export const maybeLoadOnlyID = ( ctx: TenantContext, info: GraphQLResolveInfo, id: string diff --git a/src/core/server/graph/tenant/resolvers/CommentCreatedPayload.ts b/src/core/server/graph/tenant/resolvers/CommentCreatedPayload.ts new file mode 100644 index 000000000..29cc7acc3 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/CommentCreatedPayload.ts @@ -0,0 +1,11 @@ +import { GQLCommentCreatedPayloadTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; + +import { maybeLoadOnlyID } from "./Comment"; +import { CommentCreatedInput } from "./Subscription/commentCreated"; + +export const CommentCreatedPayload: GQLCommentCreatedPayloadTypeResolver< + CommentCreatedInput +> = { + comment: ({ commentID }, args, ctx, info) => + maybeLoadOnlyID(ctx, info, commentID), +}; diff --git a/src/core/server/graph/tenant/resolvers/CommentEnteredModerationQueuePayload.ts b/src/core/server/graph/tenant/resolvers/CommentEnteredModerationQueuePayload.ts new file mode 100644 index 000000000..5f6f9b183 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/CommentEnteredModerationQueuePayload.ts @@ -0,0 +1,11 @@ +import { GQLCommentEnteredModerationQueuePayloadTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; + +import { maybeLoadOnlyID } from "./Comment"; +import { CommentEnteredModerationQueueInput } from "./Subscription/commentEnteredModerationQueue"; + +export const CommentEnteredModerationQueuePayload: GQLCommentEnteredModerationQueuePayloadTypeResolver< + CommentEnteredModerationQueueInput +> = { + comment: ({ commentID }, args, ctx, info) => + maybeLoadOnlyID(ctx, info, commentID), +}; diff --git a/src/core/server/graph/tenant/resolvers/CommentLeftModerationQueuePayload.ts b/src/core/server/graph/tenant/resolvers/CommentLeftModerationQueuePayload.ts new file mode 100644 index 000000000..2bcbaa318 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/CommentLeftModerationQueuePayload.ts @@ -0,0 +1,11 @@ +import { GQLCommentLeftModerationQueuePayloadTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; + +import { maybeLoadOnlyID } from "./Comment"; +import { CommentLeftModerationQueueInput } from "./Subscription/commentLeftModerationQueue"; + +export const CommentLeftModerationQueuePayload: GQLCommentLeftModerationQueuePayloadTypeResolver< + CommentLeftModerationQueueInput +> = { + comment: ({ commentID }, args, ctx, info) => + maybeLoadOnlyID(ctx, info, commentID), +}; diff --git a/src/core/server/graph/tenant/resolvers/CommentReplyCreatedPayload.ts b/src/core/server/graph/tenant/resolvers/CommentReplyCreatedPayload.ts new file mode 100644 index 000000000..2fb51ddc6 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/CommentReplyCreatedPayload.ts @@ -0,0 +1,11 @@ +import { GQLCommentReplyCreatedPayloadTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; + +import { maybeLoadOnlyID } from "./Comment"; +import { CommentReplyCreatedInput } from "./Subscription/commentReplyCreated"; + +export const CommentReplyCreatedPayload: GQLCommentReplyCreatedPayloadTypeResolver< + CommentReplyCreatedInput +> = { + comment: ({ commentID }, args, ctx, info) => + maybeLoadOnlyID(ctx, info, commentID), +}; diff --git a/src/core/server/graph/tenant/resolvers/CommentStatusUpdatedPayload.ts b/src/core/server/graph/tenant/resolvers/CommentStatusUpdatedPayload.ts new file mode 100644 index 000000000..a880e2ca6 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/CommentStatusUpdatedPayload.ts @@ -0,0 +1,13 @@ +import { GQLCommentStatusUpdatedPayloadTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; + +import { maybeLoadOnlyID } from "./Comment"; +import { CommentStatusUpdatedInput } from "./Subscription/commentStatusUpdated"; + +export const CommentStatusUpdatedPayload: GQLCommentStatusUpdatedPayloadTypeResolver< + CommentStatusUpdatedInput +> = { + moderator: ({ moderatorID }, args, ctx) => + moderatorID ? ctx.loaders.Users.user.load(moderatorID) : null, + comment: ({ commentID }, args, ctx, info) => + maybeLoadOnlyID(ctx, info, commentID), +}; diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentCreated.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentCreated.ts new file mode 100644 index 000000000..d23a87cfb --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentCreated.ts @@ -0,0 +1,21 @@ +import { SubscriptionToCommentCreatedResolver } from "coral-server/graph/tenant/schema/__generated__/types"; + +import { createIterator } from "./helpers"; +import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types"; + +export interface CommentCreatedInput extends SubscriptionPayload { + storyID: string; + commentID: string; +} + +export const commentCreated: SubscriptionToCommentCreatedResolver< + CommentCreatedInput +> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_CREATED, { + filter: (source, { storyID }) => { + if (source.storyID !== storyID) { + return false; + } + + return true; + }, +}); diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentEnteredModerationQueue.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentEnteredModerationQueue.ts index 20d8d5ca9..2d00f917e 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/commentEnteredModerationQueue.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentEnteredModerationQueue.ts @@ -31,8 +31,4 @@ export const commentEnteredModerationQueue: SubscriptionToCommentEnteredModerati return true; }, - resolve: ({ queue, commentID }, args, ctx) => ({ - queue: () => queue, - comment: () => ctx.loaders.Comments.comment.load(commentID), - }), }); diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentLeftModerationQueue.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentLeftModerationQueue.ts index b75460cd3..53542a2e2 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/commentLeftModerationQueue.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentLeftModerationQueue.ts @@ -30,8 +30,4 @@ export const commentLeftModerationQueue: SubscriptionToCommentLeftModerationQueu return true; }, - resolve: ({ queue, commentID }, args, ctx) => ({ - queue: () => queue, - comment: () => ctx.loaders.Comments.comment.load(commentID), - }), }); diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentReplyCreated.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentReplyCreated.ts new file mode 100644 index 000000000..ea7fcaab8 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentReplyCreated.ts @@ -0,0 +1,21 @@ +import { SubscriptionToCommentReplyCreatedResolver } from "coral-server/graph/tenant/schema/__generated__/types"; + +import { createIterator } from "./helpers"; +import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types"; + +export interface CommentReplyCreatedInput extends SubscriptionPayload { + ancestorIDs: string[]; + commentID: string; +} + +export const commentReplyCreated: SubscriptionToCommentReplyCreatedResolver< + CommentReplyCreatedInput +> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED, { + filter: (source, { ancestorID }) => { + if (!source.ancestorIDs.includes(ancestorID)) { + return false; + } + + return true; + }, +}); diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentStatusUpdated.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentStatusUpdated.ts index 376602319..a2c0f3447 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/commentStatusUpdated.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentStatusUpdated.ts @@ -25,16 +25,4 @@ export const commentStatusUpdated: SubscriptionToCommentStatusUpdatedResolver< return true; }, - resolve: ({ newStatus, oldStatus, moderatorID, commentID }, args, ctx) => ({ - newStatus: () => newStatus, - oldStatus: () => oldStatus, - moderator: () => { - if (moderatorID) { - return ctx.loaders.Users.user.load(moderatorID); - } - - return null; - }, - comment: () => ctx.loaders.Comments.comment.load(commentID), - }), }); diff --git a/src/core/server/graph/tenant/resolvers/Subscription/helpers.ts b/src/core/server/graph/tenant/resolvers/Subscription/helpers.ts index 0c0b09c25..1d1ff7de0 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/helpers.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/helpers.ts @@ -20,7 +20,7 @@ type Resolver = ( interface SubscriptionResolver { subscribe: Resolver>; - resolve?: Resolver; + resolve: Resolver; } export function createTenantAsyncIterator( @@ -87,7 +87,6 @@ export function createFilterFn( export interface CreateIteratorInput { filter?: FilterFn; - resolve?: Resolver; } export function createIterator< @@ -96,13 +95,13 @@ export function createIterator< TResult >( channel: SUBSCRIPTION_CHANNELS, - { filter, resolve }: CreateIteratorInput = {} + { filter }: CreateIteratorInput = {} ): SubscriptionResolver { return { subscribe: withFilter( createTenantAsyncIterator(channel), createFilterFn(filter) ), - resolve, + resolve: payload => payload, }; } diff --git a/src/core/server/graph/tenant/resolvers/Subscription/index.ts b/src/core/server/graph/tenant/resolvers/Subscription/index.ts index b7fe431aa..2a42932be 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/index.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/index.ts @@ -1,11 +1,15 @@ import { GQLSubscriptionTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; +import { commentCreated } from "./commentCreated"; import { commentEnteredModerationQueue } from "./commentEnteredModerationQueue"; import { commentLeftModerationQueue } from "./commentLeftModerationQueue"; +import { commentReplyCreated } from "./commentReplyCreated"; import { commentStatusUpdated } from "./commentStatusUpdated"; export const Subscription: GQLSubscriptionTypeResolver = { + commentCreated, commentEnteredModerationQueue, commentLeftModerationQueue, + commentReplyCreated, commentStatusUpdated, }; diff --git a/src/core/server/graph/tenant/resolvers/Subscription/types.ts b/src/core/server/graph/tenant/resolvers/Subscription/types.ts index 83df84793..c9977a18f 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/types.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/types.ts @@ -1,11 +1,15 @@ +import { CommentCreatedInput } from "./commentCreated"; import { CommentEnteredModerationQueueInput } from "./commentEnteredModerationQueue"; import { CommentLeftModerationQueueInput } from "./commentLeftModerationQueue"; +import { CommentReplyCreatedInput } from "./commentReplyCreated"; import { CommentStatusUpdatedInput } from "./commentStatusUpdated"; export enum SUBSCRIPTION_CHANNELS { COMMENT_ENTERED_MODERATION_QUEUE = "COMMENT_ENTERED_MODERATION_QUEUE", COMMENT_LEFT_MODERATION_QUEUE = "COMMENT_LEFT_MODERATION_QUEUE", COMMENT_STATUS_UPDATED = "COMMENT_STATUS_UPDATED", + COMMENT_REPLY_CREATED = "COMMENT_REPLY_CREATED", + COMMENT_CREATED = "COMMENT_CREATED", } export interface SubscriptionPayload { @@ -32,4 +36,12 @@ export type SUBSCRIPTION_INPUT = | SubscriptionType< SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED, CommentStatusUpdatedInput + > + | SubscriptionType< + SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED, + CommentReplyCreatedInput + > + | SubscriptionType< + SUBSCRIPTION_CHANNELS.COMMENT_CREATED, + CommentCreatedInput >; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index a69f6d44a..2cb75ddf9 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -9,8 +9,13 @@ import { BanStatusHistory } from "./BanStatusHistory"; import { CloseCommenting } from "./CloseCommenting"; import { Comment } from "./Comment"; import { CommentCounts } from "./CommentCounts"; +import { CommentCreatedPayload } from "./CommentCreatedPayload"; +import { CommentEnteredModerationQueuePayload } from "./CommentEnteredModerationQueuePayload"; +import { CommentLeftModerationQueuePayload } from "./CommentLeftModerationQueuePayload"; import { CommentModerationAction } from "./CommentModerationAction"; +import { CommentReplyCreatedPayload } from "./CommentReplyCreatedPayload"; import { CommentRevision } from "./CommentRevision"; +import { CommentStatusUpdatedPayload } from "./CommentStatusUpdatedPayload"; import { DisableCommenting } from "./DisableCommenting"; import { FacebookAuthIntegration } from "./FacebookAuthIntegration"; import { FeatureCommentPayload } from "./FeatureCommentPayload"; @@ -41,8 +46,13 @@ const Resolvers: GQLResolver = { CloseCommenting, Comment, CommentCounts, + CommentCreatedPayload, + CommentEnteredModerationQueuePayload, + CommentLeftModerationQueuePayload, CommentModerationAction, + CommentReplyCreatedPayload, CommentRevision, + CommentStatusUpdatedPayload, Cursor, DisableCommenting, FacebookAuthIntegration, diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index c71c97d1c..478a24e68 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -4633,6 +4633,28 @@ type CommentLeftModerationQueuePayload { comment: Comment! } +""" +CommentCreatedPayload is returned when a new top level Comment is created on a +Story. +""" +type CommentCreatedPayload { + """ + comment is the new top level Comment that was created on the Story. + """ + comment: Comment! +} + +""" +CommentReplyCreatedPayload is returned when a Comment is created as a reply to +another Comment where the selected ancestor Comment is in the ancestor chain. +""" +type CommentReplyCreatedPayload { + """ + comment is the new reply Comment that was created. + """ + comment: Comment! +} + type Subscription { """ commentEnteredModerationQueue returns when a Comment enters a ModerationQueue. @@ -4658,4 +4680,16 @@ type Subscription { """ commentStatusUpdated(id: ID): CommentStatusUpdatedPayload! @auth(roles: [MODERATOR, ADMIN]) + + """ + commentCreated returns when a Comment is created on the top level of a Story + that is visible. + """ + commentCreated(storyID: ID!): CommentCreatedPayload! + + """ + commentReplyCreated returns when a Comment is posted in the ancestor chain of + comments. + """ + commentReplyCreated(ancestorID: ID!): CommentReplyCreatedPayload! } diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 64ec7ba42..5fcdd717f 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -40,6 +40,8 @@ import { import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; import { + publishCommentCreated, + publishCommentReplyCreated, publishCommentStatusChanges, publishModerationQueueChanges, } from "coral-server/services/events"; @@ -214,9 +216,19 @@ export async function create( } const moderationQueue = calculateCounts(comment); - // Publish changes to the queue. + // Publish changes. publishModerationQueueChanges(publish, moderationQueue, comment); + // If this is a reply, publish it. + if (input.parentID) { + publishCommentReplyCreated(publish, comment); + } + + // If this comment is visible (and not a reply), publish it. + if (!input.parentID && hasVisibleStatus(comment)) { + publishCommentCreated(publish, comment); + } + // Compile the changes we want to apply to the story counts. const storyCounts: Required> = { // This is a new comment, so we need to increment for this status. diff --git a/src/core/server/services/events/comments.ts b/src/core/server/services/events/comments.ts index dc16ef066..2597ebf41 100644 --- a/src/core/server/services/events/comments.ts +++ b/src/core/server/services/events/comments.ts @@ -4,7 +4,7 @@ import { GQLMODERATION_QUEUE, } from "coral-server/graph/tenant/schema/__generated__/types"; import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; -import { Comment } from "coral-server/models/comment"; +import { Comment, hasVisibleStatus } from "coral-server/models/comment"; import { CommentModerationQueueCounts } from "coral-server/models/story/counts"; export function publishCommentStatusChanges( @@ -27,6 +27,36 @@ export function publishCommentStatusChanges( } } +export function publishCommentReplyCreated( + publish: Publisher, + comment: Pick +) { + if (comment.ancestorIDs.length > 0 && hasVisibleStatus(comment)) { + publish({ + channel: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED, + payload: { + ancestorIDs: comment.ancestorIDs, + commentID: comment.id, + }, + }); + } +} + +export function publishCommentCreated( + publish: Publisher, + comment: Pick +) { + if (!comment.parentID && hasVisibleStatus(comment)) { + publish({ + channel: SUBSCRIPTION_CHANNELS.COMMENT_CREATED, + payload: { + commentID: comment.id, + storyID: comment.storyID, + }, + }); + } +} + export function publishModerationQueueChanges( publish: Publisher, moderationQueue: Pick, diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 27caa32ac..7c64e1658 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -30,8 +30,16 @@ comments-featuredCommentTooltip-toggleButton = comments-streamQuery-storyNotFound = Story not found comments-postCommentForm-submit = Submit -comments-stream-loadMore = Load More comments-replyList-showAll = Show All +comments-replyList-showMoreReplies = Show More Replies + + +comments-viewNew = + { $count -> + [1] View {$count} New Comment + *[other] View {$count} New Comments + } +comments-loadMore = Load More comments-permalinkPopover = .description = A dialog showing a permalink to the comment