[CORL-421, CORL-415] Live Comments on Stream (#2379)

* 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
This commit is contained in:
Vinh
2019-06-28 23:20:10 +00:00
committed by Wyatt Johnson
parent e77103d872
commit 414a4c2a42
72 changed files with 2105 additions and 496 deletions
@@ -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";
@@ -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";
@@ -32,8 +32,10 @@ export function denormalizeComment(
return createFixture<GQLComment>({
...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<GQLStory>) {
}))) ||
[];
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;
@@ -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 {
@@ -114,6 +114,7 @@ graphql`
graphql`
fragment CreateCommentReplyMutation_viewer on User {
role
createdAt
}
`;
/** end */
@@ -92,6 +92,7 @@ const PermalinkView: FunctionComponent<PermalinkViewProps> = ({
comment={comment}
story={story}
settings={settings}
liveDirectRepliesInsertion
/>
</div>
</HorizontalGutter>
@@ -120,6 +120,7 @@ exports[`renders correctly 1`] = `
>
<withProps(Relay(ReplyListContainer))
comment={Object {}}
liveDirectRepliesInsertion={true}
settings={Object {}}
story={Object {}}
viewer={Object {}}
@@ -0,0 +1,117 @@
import { graphql, requestSubscription } from "react-relay";
import { ConnectionHandler, Environment, RecordProxy } from "relay-runtime";
import {
createSubscription,
SubscriptionVariables,
} from "coral-framework/lib/relay";
import { CommentReplyCreatedSubscription } from "coral-stream/__generated__/CommentReplyCreatedSubscription.graphql";
/**
* Returns depth until ancestor.
*/
function determineDepthTillAncestor(comment: RecordProxy, ancestorID: string) {
let depth = 0;
let cur: RecordProxy | null = comment;
while (cur) {
cur = cur.getLinkedRecord("parent");
if (cur) {
depth++;
// Stop when reaching base ancestor.
if (cur!.getValue("id") === ancestorID) {
return depth;
}
}
}
return null;
}
const CommentReplyCreatedSubscription = createSubscription(
"subscribeToCommentCreated",
(
environment: Environment,
variables: SubscriptionVariables<CommentReplyCreatedSubscription> & {
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;
@@ -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<any>;
showConversationLink?: boolean;
enteredLive?: boolean | null;
} & PropTypesOf<typeof CommentContainer>["comment"] &
PropTypesOf<typeof IgnoredTombstoneOrHideContainer>["comment"]
>;
@@ -31,6 +33,8 @@ export interface ReplyListProps {
indentLevel?: number;
localReply?: boolean;
disableReplies?: boolean;
viewNewCount?: number;
onViewNew?: () => void;
}
const ReplyList: FunctionComponent<ReplyListProps> = props => {
@@ -41,26 +45,30 @@ const ReplyList: FunctionComponent<ReplyListProps> = props => {
role="log"
>
{props.comments.map(comment => (
<IgnoredTombstoneOrHideContainer
<FadeInTransition
key={comment.id}
viewer={props.viewer}
comment={comment}
active={Boolean(comment.enteredLive)}
>
<HorizontalGutter key={comment.id}>
<CommentContainer
key={comment.id}
viewer={props.viewer}
comment={comment}
story={props.story}
settings={props.settings}
indentLevel={props.indentLevel}
localReply={props.localReply}
disableReplies={props.disableReplies}
showConversationLink={!!comment.showConversationLink}
/>
{comment.replyListElement}
</HorizontalGutter>
</IgnoredTombstoneOrHideContainer>
<IgnoredTombstoneOrHideContainer
viewer={props.viewer}
comment={comment}
>
<HorizontalGutter key={comment.id}>
<CommentContainer
key={comment.id}
viewer={props.viewer}
comment={comment}
story={props.story}
settings={props.settings}
indentLevel={props.indentLevel}
localReply={props.localReply}
disableReplies={props.disableReplies}
showConversationLink={!!comment.showConversationLink}
/>
{comment.replyListElement}
</HorizontalGutter>
</IgnoredTombstoneOrHideContainer>
</FadeInTransition>
))}
{props.hasMore && (
<Indent level={props.indentLevel} noBorder>
@@ -80,6 +88,22 @@ const ReplyList: FunctionComponent<ReplyListProps> = props => {
</Localized>
</Indent>
)}
{Boolean(props.viewNewCount && props.viewNewCount > 0) && (
<Indent level={props.indentLevel} noBorder>
<Localized id="comments-replyList-showMoreReplies">
<Button
aria-controls={`coral-comments-replyList-log--${
props.comment.id
}`}
onClick={props.onViewNew}
variant="outlined"
fullWidth
>
Show More Replies
</Button>
</Localized>
</Indent>
)}
</HorizontalGutter>
);
};
@@ -14,20 +14,23 @@ const ReplyListContainerN = removeFragmentRefs(ReplyListContainer);
it("renders correctly", () => {
const props: PropTypesOf<typeof ReplyListContainerN> = {
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<typeof ReplyListContainerN> = {
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<typeof ReplyListContainerN> = {
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: {
@@ -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> = T extends ReadonlyArray<infer U> ? U : any;
type ReplyNode5 = UnpackArray<Comment5Data["replies"]["edges"]>["node"];
@@ -33,6 +40,13 @@ type Props = BaseProps & {
ReplyListComponent:
| React.ComponentType<{ [P in FragmentKeys<BaseProps>]: 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<Props> {
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 && (
<this.props.ReplyListComponent
viewer={this.props.viewer}
comment={edge.node}
story={this.props.story}
settings={this.props.settings}
/>
),
// ReplyListContainer5 contains replyCount.
showConversationLink:
((edge.node as any) as ReplyNode5).replyCount > 0,
}));
return (
<ReplyList
viewer={this.props.viewer}
comment={this.props.comment}
comments={comments}
story={this.props.story}
settings={this.props.settings}
onShowAll={this.showAll}
hasMore={this.props.relay.hasMore()}
disableShowAll={this.state.disableShowAll}
indentLevel={this.props.indentLevel}
localReply={this.props.localReply}
/>
);
}
private showAll = () => {
if (!this.props.relay.hasMore() || this.props.relay.isLoading()) {
export const ReplyListContainer: React.FunctionComponent<Props> = 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 && (
<props.ReplyListComponent
viewer={props.viewer}
comment={edge.node}
story={props.story}
settings={props.settings}
/>
),
// ReplyListContainer5 contains replyCount.
showConversationLink:
((edge.node as any) as ReplyNode5).replyCount > 0,
}));
return (
<ReplyList
viewer={props.viewer}
comment={props.comment}
comments={comments}
story={props.story}
settings={props.settings}
onShowAll={showAll}
hasMore={props.relay.hasMore()}
disableShowAll={isLoadingShowAll}
indentLevel={props.indentLevel}
localReply={props.localReply}
viewNewCount={viewNewCount}
onViewNew={onViewNew}
/>
);
};
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
@@ -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;
@@ -6,88 +6,96 @@ exports[`renders correctly 1`] = `
id="coral-comments-replyList-log--comment-id"
role="log"
>
<Relay(IgnoredTombstoneOrHideContainer)
comment={
Object {
"id": "comment-1",
}
}
<FadeInTransition
active={false}
key="comment-1"
viewer={null}
>
<ForwardRef(forwardRef)
key="comment-1"
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
comment={
Object {
"id": "comment-1",
}
<Relay(IgnoredTombstoneOrHideContainer)
comment={
Object {
"id": "comment-1",
}
disableReplies={false}
indentLevel={1}
key="comment-1"
localReply={false}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
showConversationLink={false}
story={
Object {
"id": "story-id",
}
}
viewer={null}
/>
</ForwardRef(forwardRef)>
</Relay(IgnoredTombstoneOrHideContainer)>
<Relay(IgnoredTombstoneOrHideContainer)
comment={
Object {
"id": "comment-2",
"showConversationLink": true,
}
}
key="comment-2"
viewer={null}
>
<ForwardRef(forwardRef)
key="comment-2"
viewer={null}
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
comment={
Object {
"id": "comment-2",
"showConversationLink": true,
<ForwardRef(forwardRef)
key="comment-1"
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
comment={
Object {
"id": "comment-1",
}
}
disableReplies={false}
indentLevel={1}
key="comment-1"
localReply={false}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
showConversationLink={false}
story={
Object {
"id": "story-id",
}
}
viewer={null}
/>
</ForwardRef(forwardRef)>
</Relay(IgnoredTombstoneOrHideContainer)>
</FadeInTransition>
<FadeInTransition
active={false}
key="comment-2"
>
<Relay(IgnoredTombstoneOrHideContainer)
comment={
Object {
"id": "comment-2",
"showConversationLink": true,
}
disableReplies={false}
indentLevel={1}
}
viewer={null}
>
<ForwardRef(forwardRef)
key="comment-2"
localReply={false}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
comment={
Object {
"id": "comment-2",
"showConversationLink": true,
}
}
}
showConversationLink={true}
story={
Object {
"id": "story-id",
disableReplies={false}
indentLevel={1}
key="comment-2"
localReply={false}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
}
viewer={null}
/>
</ForwardRef(forwardRef)>
</Relay(IgnoredTombstoneOrHideContainer)>
showConversationLink={true}
story={
Object {
"id": "story-id",
}
}
viewer={null}
/>
</ForwardRef(forwardRef)>
</Relay(IgnoredTombstoneOrHideContainer)>
</FadeInTransition>
</ForwardRef(forwardRef)>
`;
@@ -97,82 +105,90 @@ exports[`when there is more disables load more button 1`] = `
id="coral-comments-replyList-log--comment-id"
role="log"
>
<Relay(IgnoredTombstoneOrHideContainer)
comment={
Object {
"id": "comment-1",
}
}
<FadeInTransition
active={false}
key="comment-1"
viewer={null}
>
<ForwardRef(forwardRef)
key="comment-1"
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
comment={
Object {
"id": "comment-1",
}
<Relay(IgnoredTombstoneOrHideContainer)
comment={
Object {
"id": "comment-1",
}
indentLevel={1}
key="comment-1"
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
showConversationLink={false}
story={
Object {
"id": "story-id",
}
}
viewer={null}
/>
</ForwardRef(forwardRef)>
</Relay(IgnoredTombstoneOrHideContainer)>
<Relay(IgnoredTombstoneOrHideContainer)
comment={
Object {
"id": "comment-2",
}
}
key="comment-2"
viewer={null}
>
<ForwardRef(forwardRef)
key="comment-2"
viewer={null}
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
comment={
Object {
"id": "comment-2",
<ForwardRef(forwardRef)
key="comment-1"
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
comment={
Object {
"id": "comment-1",
}
}
indentLevel={1}
key="comment-1"
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
showConversationLink={false}
story={
Object {
"id": "story-id",
}
}
viewer={null}
/>
</ForwardRef(forwardRef)>
</Relay(IgnoredTombstoneOrHideContainer)>
</FadeInTransition>
<FadeInTransition
active={false}
key="comment-2"
>
<Relay(IgnoredTombstoneOrHideContainer)
comment={
Object {
"id": "comment-2",
}
indentLevel={1}
}
viewer={null}
>
<ForwardRef(forwardRef)
key="comment-2"
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
comment={
Object {
"id": "comment-2",
}
}
}
showConversationLink={false}
story={
Object {
"id": "story-id",
indentLevel={1}
key="comment-2"
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
}
viewer={null}
/>
</ForwardRef(forwardRef)>
</Relay(IgnoredTombstoneOrHideContainer)>
showConversationLink={false}
story={
Object {
"id": "story-id",
}
}
viewer={null}
/>
</ForwardRef(forwardRef)>
</Relay(IgnoredTombstoneOrHideContainer)>
</FadeInTransition>
<Indent
level={1}
noBorder={true}
@@ -201,82 +217,90 @@ exports[`when there is more renders a load more button 1`] = `
id="coral-comments-replyList-log--comment-id"
role="log"
>
<Relay(IgnoredTombstoneOrHideContainer)
comment={
Object {
"id": "comment-1",
}
}
<FadeInTransition
active={false}
key="comment-1"
viewer={null}
>
<ForwardRef(forwardRef)
key="comment-1"
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
comment={
Object {
"id": "comment-1",
}
<Relay(IgnoredTombstoneOrHideContainer)
comment={
Object {
"id": "comment-1",
}
indentLevel={1}
key="comment-1"
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
showConversationLink={false}
story={
Object {
"id": "story-id",
}
}
viewer={null}
/>
</ForwardRef(forwardRef)>
</Relay(IgnoredTombstoneOrHideContainer)>
<Relay(IgnoredTombstoneOrHideContainer)
comment={
Object {
"id": "comment-2",
}
}
key="comment-2"
viewer={null}
>
<ForwardRef(forwardRef)
key="comment-2"
viewer={null}
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
comment={
Object {
"id": "comment-2",
<ForwardRef(forwardRef)
key="comment-1"
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
comment={
Object {
"id": "comment-1",
}
}
indentLevel={1}
key="comment-1"
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
showConversationLink={false}
story={
Object {
"id": "story-id",
}
}
viewer={null}
/>
</ForwardRef(forwardRef)>
</Relay(IgnoredTombstoneOrHideContainer)>
</FadeInTransition>
<FadeInTransition
active={false}
key="comment-2"
>
<Relay(IgnoredTombstoneOrHideContainer)
comment={
Object {
"id": "comment-2",
}
indentLevel={1}
}
viewer={null}
>
<ForwardRef(forwardRef)
key="comment-2"
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
comment={
Object {
"id": "comment-2",
}
}
}
showConversationLink={false}
story={
Object {
"id": "story-id",
indentLevel={1}
key="comment-2"
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
}
viewer={null}
/>
</ForwardRef(forwardRef)>
</Relay(IgnoredTombstoneOrHideContainer)>
showConversationLink={false}
story={
Object {
"id": "story-id",
}
}
viewer={null}
/>
</ForwardRef(forwardRef)>
</Relay(IgnoredTombstoneOrHideContainer)>
</FadeInTransition>
<Indent
level={1}
noBorder={true}
@@ -10,15 +10,18 @@ exports[`renders correctly 1`] = `
"edges": Array [
Object {
"node": Object {
"enteredLive": false,
"id": "comment-1",
},
},
Object {
"node": Object {
"enteredLive": false,
"id": "comment-2",
},
},
],
"viewNewEdges": Array [],
},
"status": "NONE",
}
@@ -26,24 +29,25 @@ exports[`renders correctly 1`] = `
comments={
Array [
Object {
"enteredLive": false,
"id": "comment-1",
"replyListElement": <ReplyListComponent
comment={
Object {
"enteredLive": false,
"id": "comment-1",
}
}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
"disableCommenting": Object {
"enabled": false,
},
}
}
story={
Object {
"id": "story-id",
"isClosed": false,
}
}
viewer={null}
@@ -51,24 +55,25 @@ exports[`renders correctly 1`] = `
"showConversationLink": false,
},
Object {
"enteredLive": false,
"id": "comment-2",
"replyListElement": <ReplyListComponent
comment={
Object {
"enteredLive": false,
"id": "comment-2",
}
}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
"disableCommenting": Object {
"enabled": false,
},
}
}
story={
Object {
"id": "story-id",
"isClosed": false,
}
}
viewer={null}
@@ -81,19 +86,20 @@ exports[`renders correctly 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}
/>
`;
@@ -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}
/>
`;
@@ -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> = props => {
const [{ commentsOrderBy }] = useLocal<AllCommentsTabContainerLocal>(
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) && (
<Box mb={4} clone>
<Button
variant="outlined"
color="primary"
onClick={onViewMore}
fullWidth
>
<Localized id="comments-viewNew" $count={viewNewCount}>
<span>View {viewNewCount} New Comments</span>
</Localized>
</Button>
</Box>
)}
<HorizontalGutter
id="coral-comments-stream-log"
data-testid="comments-stream-log"
id="comments-allComments-log"
data-testid="comments-allComments-log"
role="log"
aria-live="polite"
>
@@ -49,31 +126,32 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = props => {
viewer={props.viewer}
comment={comment}
>
<HorizontalGutter>
<CommentContainer
viewer={props.viewer}
settings={props.settings}
comment={comment}
story={props.story}
/>
<ReplyListContainer
settings={props.settings}
viewer={props.viewer}
comment={comment}
story={props.story}
/>
</HorizontalGutter>
<FadeInTransition active={Boolean(comment.enteredLive)}>
<HorizontalGutter>
<CommentContainer
viewer={props.viewer}
settings={props.settings}
comment={comment}
story={props.story}
/>
<ReplyListContainer
settings={props.settings}
viewer={props.viewer}
comment={comment}
story={props.story}
/>
</HorizontalGutter>
</FadeInTransition>
</IgnoredTombstoneOrHideContainer>
))}
{props.relay.hasMore() && (
<Localized id="comments-stream-loadMore">
<Localized id="comments-loadMore">
<Button
id={"coral-comments-stream-loadMore"}
onClick={loadMore}
variant="outlined"
fullWidth
disabled={isLoadingMore}
aria-controls="coral-comments-stream-log"
aria-controls="comments-allComments-log"
>
Load More
</Button>
@@ -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
}
@@ -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<QueryTypes["response"]>) => {
if (data.error) {
return <div>{data.error.message}</div>;
}
if (!data.props) {
return (
<Flex justifyContent="center">
<Spinner />
</Flex>
);
}
if (data.props) {
return (
<AllCommentsTabContainer
settings={data.props.settings}
viewer={data.props.viewer}
story={data.props.story!}
/>
<SpinnerWhileRendering>
<AllCommentsTabContainer
settings={data.props.settings}
viewer={data.props.viewer}
story={data.props.story!}
/>
</SpinnerWhileRendering>
);
}
return (
<Delay>
<Flex justifyContent="center">
<Spinner />
</Flex>
</Delay>
<Flex justifyContent="center">
<Spinner />
</Flex>
);
};
@@ -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;
@@ -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<CommentCreatedSubscription> & {
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;
@@ -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> = 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 && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{!hidden && props.children}
</>
);
};
export default SpinnerWhileRendering;
@@ -29,8 +29,8 @@ export const FeaturedCommentsContainer: FunctionComponent<Props> = props => {
return (
<>
<HorizontalGutter
id="coral-comments-stream-log"
data-testid="comments-stream-log"
id="comments-featuredComments-log"
data-testid="comments-featuredComments-log"
role="log"
aria-live="polite"
spacing={3}
@@ -50,14 +50,13 @@ export const FeaturedCommentsContainer: FunctionComponent<Props> = props => {
</IgnoredTombstoneOrHideContainer>
))}
{props.relay.hasMore() && (
<Localized id="comments-stream-loadMore">
<Localized id="comments-loadMore">
<Button
id={"coral-comments-stream-loadMore"}
onClick={loadMore}
variant="outlined"
fullWidth
disabled={isLoadingMore}
aria-controls="coral-comments-stream-log"
aria-controls="comments-featuredComments-log"
>
Load More
</Button>
@@ -77,6 +77,7 @@ function addCommentToStory(
graphql`
fragment CreateCommentMutation_viewer on User {
role
createdAt
}
`;
// tslint:disable-next-line:no-unused-expression
@@ -89,7 +89,7 @@ export const StreamContainer: FunctionComponent<Props> = props => {
/>
)}
{banned && <BannedInfo />}
<HorizontalGutter spacing={5} className={styles.tabBarContainer}>
<HorizontalGutter spacing={4} className={styles.tabBarContainer}>
<SortMenu
className={styles.sortMenu}
orderBy={local.commentsOrderBy}
@@ -3,6 +3,7 @@ import {
QueryRenderer,
withLocalStateContainer,
} from "coral-framework/lib/relay";
import { COMMENTS_TAB } from "coral-stream/__generated__/StreamContainerLocal.graphql";
import { StreamQuery as QueryTypes } from "coral-stream/__generated__/StreamQuery.graphql";
import { StreamQueryLocal as Local } from "coral-stream/__generated__/StreamQueryLocal.graphql";
import { Delay, Flex, Spinner } from "coral-ui/components";
@@ -17,7 +18,10 @@ interface Props {
local: Local;
}
export const render = (data: ReadyState<QueryTypes["response"]>) => {
export const render = (
data: ReadyState<QueryTypes["response"]>,
commentsTab: COMMENTS_TAB
) => {
if (data.error) {
return <div>{data.error.message}</div>;
}
@@ -40,11 +44,19 @@ export const render = (data: ReadyState<QueryTypes["response"]>) => {
}
return (
<Delay>
<Flex justifyContent="center">
<Spinner />
</Flex>
</Delay>
<>
{// 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" && (
<AllCommentsTabQuery preload />
)}
<Delay>
<Flex justifyContent="center">
<Spinner />
</Flex>
</Delay>
</>
);
};
@@ -73,23 +85,7 @@ const StreamQuery: FunctionComponent<Props> = 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" && (
<AllCommentsTabQuery preload />
)}
</>
);
}
return render(data);
return render(data, commentsTab);
}}
/>
</>
@@ -173,7 +173,7 @@ exports[`renders comment stream 1`] = `
</div>
</div>
<div
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-5"
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-4"
>
<div
className="Box-root Flex-root StreamContainer-sortMenu Flex-flex Flex-itemGutter Flex-justifyFlexEnd Flex-alignCenter gutter"
@@ -278,8 +278,8 @@ exports[`renders comment stream 1`] = `
<div
aria-live="polite"
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-3"
data-testid="comments-stream-log"
id="coral-comments-stream-log"
data-testid="comments-featuredComments-log"
id="comments-featuredComments-log"
role="log"
>
<div
@@ -70,7 +70,7 @@ async function createTestRenderer(
it("loads more", async () => {
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.
@@ -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();
});
@@ -786,6 +786,33 @@ exports[`renders permalink view 1`] = `
/>
</div>
</div>
<div
className="Comment-subBar"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm InReplyTo-icon"
>
reply
</span>
<span>
 
</span>
<span
className="Box-root Typography-root Typography-timestamp Typography-colorTextPrimary InReplyTo-inReplyTo"
>
In reply to
<span
className="Box-root Typography-root Typography-heading5 Typography-colorTextPrimary InReplyTo-username"
>
Markus
</span>
</span>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
@@ -975,6 +1002,33 @@ exports[`renders permalink view 1`] = `
/>
</div>
</div>
<div
className="Comment-subBar"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm InReplyTo-icon"
>
reply
</span>
<span>
 
</span>
<span
className="Box-root Typography-root Typography-timestamp Typography-colorTextPrimary InReplyTo-inReplyTo"
>
In reply to
<span
className="Box-root Typography-root Typography-heading5 Typography-colorTextPrimary InReplyTo-username"
>
Markus
</span>
</span>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
@@ -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<GQLResolver> = {}
) {
const { testRenderer, context, subscriptionHandler } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
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<SubscriptionToCommentReplyCreatedResolver>(
"commentReplyCreated",
variables => {
if (variables.ancestorID !== rootComment.id) {
return;
}
return {
comment: pureMerge<typeof liveComment>(liveComment, {
parent: rootComment,
}),
};
}
);
// Comment should immediately appear.
within(container).getByTestID(`comment-${liveComment.id}`);
});
@@ -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")
);
});
@@ -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();
@@ -4,8 +4,8 @@ exports[`renders comment stream with load more button 1`] = `
<div
aria-live="polite"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
data-testid="comments-stream-log"
id="coral-comments-stream-log"
data-testid="comments-allComments-log"
id="comments-allComments-log"
role="log"
>
<div
@@ -387,10 +387,9 @@ exports[`renders comment stream with load more button 1`] = `
</div>
</div>
<button
aria-controls="coral-comments-stream-log"
aria-controls="comments-allComments-log"
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantOutlined Button-fullWidth"
disabled={false}
id="coral-comments-stream-loadMore"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -79,6 +79,33 @@ exports[`post a reply: open reply form 1`] = `
/>
</div>
</div>
<div
className="Comment-subBar"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm InReplyTo-icon"
>
reply
</span>
<span>
 
</span>
<span
className="Box-root Typography-root Typography-timestamp Typography-colorTextPrimary InReplyTo-inReplyTo"
>
In reply to
<span
className="Box-root Typography-root Typography-heading5 Typography-colorTextPrimary InReplyTo-username"
>
Markus
</span>
</span>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
@@ -622,8 +649,8 @@ exports[`renders comment stream 1`] = `
<div
aria-live="polite"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
data-testid="comments-stream-log"
id="coral-comments-stream-log"
data-testid="comments-allComments-log"
id="comments-allComments-log"
role="log"
>
<div
@@ -919,6 +946,33 @@ exports[`renders comment stream 1`] = `
/>
</div>
</div>
<div
className="Comment-subBar"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm InReplyTo-icon"
>
reply
</span>
<span>
 
</span>
<span
className="Box-root Typography-root Typography-timestamp Typography-colorTextPrimary InReplyTo-inReplyTo"
>
In reply to
<span
className="Box-root Typography-root Typography-heading5 Typography-colorTextPrimary InReplyTo-username"
>
Markus
</span>
</span>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
@@ -1131,6 +1185,33 @@ exports[`renders comment stream 1`] = `
/>
</div>
</div>
<div
className="Comment-subBar"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm InReplyTo-icon"
>
reply
</span>
<span>
 
</span>
<span
className="Box-root Typography-root Typography-timestamp Typography-colorTextPrimary InReplyTo-inReplyTo"
>
In reply to
<span
className="Box-root Typography-root Typography-heading5 Typography-colorTextPrimary InReplyTo-username"
>
Markus
</span>
</span>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
@@ -1343,6 +1424,33 @@ exports[`renders comment stream 1`] = `
/>
</div>
</div>
<div
className="Comment-subBar"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm InReplyTo-icon"
>
reply
</span>
<span>
 
</span>
<span
className="Box-root Typography-root Typography-timestamp Typography-colorTextPrimary InReplyTo-inReplyTo"
>
In reply to
<span
className="Box-root Typography-root Typography-heading5 Typography-colorTextPrimary InReplyTo-username"
>
Markus
</span>
</span>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
@@ -1555,6 +1663,33 @@ exports[`renders comment stream 1`] = `
/>
</div>
</div>
<div
className="Comment-subBar"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm InReplyTo-icon"
>
reply
</span>
<span>
 
</span>
<span
className="Box-root Typography-root Typography-timestamp Typography-colorTextPrimary InReplyTo-inReplyTo"
>
In reply to
<span
className="Box-root Typography-root Typography-heading5 Typography-colorTextPrimary InReplyTo-username"
>
Markus
</span>
</span>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
@@ -1767,6 +1902,33 @@ exports[`renders comment stream 1`] = `
/>
</div>
</div>
<div
className="Comment-subBar"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm InReplyTo-icon"
>
reply
</span>
<span>
 
</span>
<span
className="Box-root Typography-root Typography-timestamp Typography-colorTextPrimary InReplyTo-inReplyTo"
>
In reply to
<span
className="Box-root Typography-root Typography-heading5 Typography-colorTextPrimary InReplyTo-username"
>
Markus
</span>
</span>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
@@ -157,7 +157,7 @@ exports[`renders comment stream with community guidelines 1`] = `
</div>
</div>
<div
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-5"
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-4"
>
<div
className="Box-root Flex-root StreamContainer-sortMenu Flex-flex Flex-itemGutter Flex-justifyFlexEnd Flex-alignCenter gutter"
@@ -262,8 +262,8 @@ exports[`renders comment stream with community guidelines 1`] = `
<div
aria-live="polite"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
data-testid="comments-stream-log"
id="coral-comments-stream-log"
data-testid="comments-allComments-log"
id="comments-allComments-log"
role="log"
>
<div
@@ -78,7 +78,7 @@ exports[`renders message box when commenting disabled 1`] = `
</div>
</div>
<div
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-5"
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-4"
>
<div
className="Box-root Flex-root StreamContainer-sortMenu Flex-flex Flex-itemGutter Flex-justifyFlexEnd Flex-alignCenter gutter"
@@ -183,8 +183,8 @@ exports[`renders message box when commenting disabled 1`] = `
<div
aria-live="polite"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
data-testid="comments-stream-log"
id="coral-comments-stream-log"
data-testid="comments-allComments-log"
id="comments-allComments-log"
role="log"
/>
</section>
@@ -382,7 +382,7 @@ exports[`renders message box when logged in 1`] = `
</form>
</div>
<div
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-5"
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-4"
>
<div
className="Box-root Flex-root StreamContainer-sortMenu Flex-flex Flex-itemGutter Flex-justifyFlexEnd Flex-alignCenter gutter"
@@ -487,8 +487,8 @@ exports[`renders message box when logged in 1`] = `
<div
aria-live="polite"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
data-testid="comments-stream-log"
id="coral-comments-stream-log"
data-testid="comments-allComments-log"
id="comments-allComments-log"
role="log"
/>
</section>
@@ -658,7 +658,7 @@ exports[`renders message box when not logged in 1`] = `
</div>
</div>
<div
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-5"
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-4"
>
<div
className="Box-root Flex-root StreamContainer-sortMenu Flex-flex Flex-itemGutter Flex-justifyFlexEnd Flex-alignCenter gutter"
@@ -763,8 +763,8 @@ exports[`renders message box when not logged in 1`] = `
<div
aria-live="polite"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
data-testid="comments-stream-log"
id="coral-comments-stream-log"
data-testid="comments-allComments-log"
id="comments-allComments-log"
role="log"
/>
</section>
@@ -843,7 +843,7 @@ exports[`renders message box when story isClosed 1`] = `
</div>
</div>
<div
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-5"
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-4"
>
<div
className="Box-root Flex-root StreamContainer-sortMenu Flex-flex Flex-itemGutter Flex-justifyFlexEnd Flex-alignCenter gutter"
@@ -948,8 +948,8 @@ exports[`renders message box when story isClosed 1`] = `
<div
aria-live="polite"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
data-testid="comments-stream-log"
id="coral-comments-stream-log"
data-testid="comments-allComments-log"
id="comments-allComments-log"
role="log"
/>
</section>
@@ -88,6 +88,33 @@ exports[`renders reply list 1`] = `
/>
</div>
</div>
<div
className="Comment-subBar"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm InReplyTo-icon"
>
reply
</span>
<span>
 
</span>
<span
className="Box-root Typography-root Typography-timestamp Typography-colorTextPrimary InReplyTo-inReplyTo"
>
In reply to
<span
className="Box-root Typography-root Typography-heading5 Typography-colorTextPrimary InReplyTo-username"
>
Markus
</span>
</span>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
@@ -282,6 +309,33 @@ exports[`renders reply list 1`] = `
/>
</div>
</div>
<div
className="Comment-subBar"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm InReplyTo-icon"
>
reply
</span>
<span>
 
</span>
<span
className="Box-root Typography-root Typography-timestamp Typography-colorTextPrimary InReplyTo-inReplyTo"
>
In reply to
<span
className="Box-root Typography-root Typography-heading5 Typography-colorTextPrimary InReplyTo-username"
>
Markus
</span>
</span>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
@@ -471,6 +525,33 @@ exports[`renders reply list 1`] = `
/>
</div>
</div>
<div
className="Comment-subBar"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm InReplyTo-icon"
>
reply
</span>
<span>
 
</span>
<span
className="Box-root Typography-root Typography-timestamp Typography-colorTextPrimary InReplyTo-inReplyTo"
>
In reply to
<span
className="Box-root Typography-root Typography-heading5 Typography-colorTextPrimary InReplyTo-username"
>
Markus
</span>
</span>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
@@ -662,6 +743,33 @@ exports[`renders reply list 1`] = `
/>
</div>
</div>
<div
className="Comment-subBar"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm InReplyTo-icon"
>
reply
</span>
<span>
 
</span>
<span
className="Box-root Typography-root Typography-timestamp Typography-colorTextPrimary InReplyTo-inReplyTo"
>
In reply to
<span
className="Box-root Typography-root Typography-heading5 Typography-colorTextPrimary InReplyTo-username"
>
Markus
</span>
</span>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
@@ -173,7 +173,7 @@ exports[`renders comment stream 1`] = `
</div>
</div>
<div
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-5"
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-4"
>
<div
className="Box-root Flex-root StreamContainer-sortMenu Flex-flex Flex-itemGutter Flex-justifyFlexEnd Flex-alignCenter gutter"
@@ -278,8 +278,8 @@ exports[`renders comment stream 1`] = `
<div
aria-live="polite"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
data-testid="comments-stream-log"
id="coral-comments-stream-log"
data-testid="comments-allComments-log"
id="comments-allComments-log"
role="log"
>
<div
@@ -79,6 +79,33 @@ exports[`renders deepest comment with link 1`] = `
/>
</div>
</div>
<div
className="Comment-subBar"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm InReplyTo-icon"
>
reply
</span>
<span>
 
</span>
<span
className="Box-root Typography-root Typography-timestamp Typography-colorTextPrimary InReplyTo-inReplyTo"
>
In reply to
<span
className="Box-root Typography-root Typography-heading5 Typography-colorTextPrimary InReplyTo-username"
>
Markus
</span>
</span>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
@@ -4,8 +4,8 @@ exports[`renders app with comment stream 1`] = `
<div
aria-live="polite"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
data-testid="comments-stream-log"
id="coral-comments-stream-log"
data-testid="comments-allComments-log"
id="comments-allComments-log"
role="log"
>
<div
@@ -80,7 +80,7 @@ it("disables comment stream", async () => {
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,
@@ -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);
@@ -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!, {
@@ -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<GQLComment>({
...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<GQLStory>(
{
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<GQLResolver> = {}
) {
const { testRenderer, context, subscriptionHandler } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
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<SubscriptionToCommentReplyCreatedResolver>(
"commentReplyCreated",
variables => {
if (variables.ancestorID !== rootComment.id) {
return;
}
return {
comment: pureMerge<typeof commentData>(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<SubscriptionToCommentReplyCreatedResolver>(
"commentReplyCreated",
variables => {
if (variables.ancestorID !== rootComment.id) {
return;
}
return {
comment: pureMerge<typeof commentData>(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<GQLResolver>({
Query: {
story: () => pureMerge<typeof story>(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<GQLResolver>({
Query: {
settings: () =>
pureMerge<typeof settings>(settings, {
disableCommenting: {
enabled: true,
},
}),
},
}),
});
await waitForElement(() =>
within(testRenderer.root).getByTestID("comments-allComments-log")
);
expect(subscriptionHandler.has("commentReplyCreated")).toBe(false);
});
@@ -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<GQLResolver> = {}
) {
const { testRenderer, context, subscriptionHandler } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
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<SubscriptionToCommentCreatedResolver>(
"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<SubscriptionToCommentCreatedResolver>(
"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<GQLResolver>({
Query: {
story: () => pureMerge<typeof story>(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<GQLResolver>({
Query: {
settings: () =>
pureMerge<typeof settings>(settings, {
disableCommenting: {
enabled: true,
},
}),
},
}),
});
await waitForElement(() =>
within(testRenderer.root).getByTestID("comments-allComments-log")
);
expect(subscriptionHandler.has("commentCreated")).toBe(false);
});
@@ -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.
@@ -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(
@@ -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(() =>
@@ -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();
});
@@ -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
@@ -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)
@@ -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();
+2 -9
View File
@@ -155,6 +155,8 @@ export const baseComment = createFixture<GQLComment>({
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,
},
},
},
],
@@ -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
@@ -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),
};
@@ -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),
};
@@ -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),
};
@@ -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),
};
@@ -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),
};
@@ -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;
},
});
@@ -31,8 +31,4 @@ export const commentEnteredModerationQueue: SubscriptionToCommentEnteredModerati
return true;
},
resolve: ({ queue, commentID }, args, ctx) => ({
queue: () => queue,
comment: () => ctx.loaders.Comments.comment.load(commentID),
}),
});
@@ -30,8 +30,4 @@ export const commentLeftModerationQueue: SubscriptionToCommentLeftModerationQueu
return true;
},
resolve: ({ queue, commentID }, args, ctx) => ({
queue: () => queue,
comment: () => ctx.loaders.Comments.comment.load(commentID),
}),
});
@@ -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;
},
});
@@ -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),
}),
});
@@ -20,7 +20,7 @@ type Resolver<TParent, TArgs, TResult> = (
interface SubscriptionResolver<TParent, TArgs, TResult> {
subscribe: Resolver<TParent, TArgs, AsyncIterator<TResult>>;
resolve?: Resolver<TParent, TArgs, TResult>;
resolve: Resolver<TParent, TArgs, TParent>;
}
export function createTenantAsyncIterator<TParent, TArgs, TResult>(
@@ -87,7 +87,6 @@ export function createFilterFn<TParent, TArgs>(
export interface CreateIteratorInput<TParent, TArgs, TResult> {
filter?: FilterFn<TParent, TArgs, TenantContext>;
resolve?: Resolver<TParent, TArgs, TResult>;
}
export function createIterator<
@@ -96,13 +95,13 @@ export function createIterator<
TResult
>(
channel: SUBSCRIPTION_CHANNELS,
{ filter, resolve }: CreateIteratorInput<TParent, TArgs, TResult> = {}
{ filter }: CreateIteratorInput<TParent, TArgs, TResult> = {}
): SubscriptionResolver<TParent, TArgs, TResult> {
return {
subscribe: withFilter(
createTenantAsyncIterator(channel),
createFilterFn(filter)
),
resolve,
resolve: payload => payload,
};
}
@@ -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,
};
@@ -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
>;
@@ -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,
@@ -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!
}
+13 -1
View File
@@ -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<Omit<StoryCounts, "action">> = {
// This is a new comment, so we need to increment for this status.
+31 -1
View File
@@ -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<Comment, "id" | "status" | "ancestorIDs">
) {
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<Comment, "id" | "storyID" | "parentID" | "status">
) {
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<CommentModerationQueueCounts, "queues">,
+9 -1
View File
@@ -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