From 6fabeb2755a077dab5ec9b88bbe17e445e826c91 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 6 Aug 2020 08:42:04 -0600 Subject: [PATCH] [CORL-1243] Subscription Errors (#3074) * fix: fixed subscription event error * fix: further subscription refinements * fix: review fixes * fix: exhastive deps Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../createManagedSubscriptionClient.ts | 20 + .../Comments/Comment/CommentContainer.tsx | 845 +++++++++--------- .../tabs/Comments/Comment/IndentedComment.tsx | 39 +- .../CommentContainer.spec.tsx.snap | 6 - .../ReplyList/ReplyListContainer.spec.tsx | 6 + .../Comments/ReplyList/ReplyListContainer.tsx | 39 +- .../ReplyListContainer.spec.tsx.snap | 10 + .../AllCommentsTabContainer.tsx | 135 +-- .../SetStoryClosedMutation.ts | 7 +- .../StoryClosedTimeoutContainer.tsx | 99 +- .../permalink/permalinkViewPostReply.spec.tsx | 4 +- .../stream/charCountReplyComment.spec.tsx | 4 +- .../test/comments/stream/editComment.spec.tsx | 4 +- src/core/common/utils/createTimeoutAt.ts | 12 + src/core/common/utils/index.ts | 19 +- 15 files changed, 661 insertions(+), 588 deletions(-) create mode 100644 src/core/common/utils/createTimeoutAt.ts diff --git a/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts b/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts index 93a89c9ee..3acb76196 100644 --- a/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts +++ b/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts @@ -92,6 +92,7 @@ export default function createManagedSubscriptionClient( cacheConfig, observer, }; + request.subscribe = () => { if (!subscriptionClient) { subscriptionClient = new SubscriptionClient(url, { @@ -165,14 +166,26 @@ export default function createManagedSubscriptionClient( } } + // When the subscription is disposed, this will be true. + let disposed = false; + return { dispose: () => { + // Guard against double disposes. Multiple calls to dispose will now + // result in no-ops. + if (disposed) { + return; + } + disposed = true; + + let closed = false; const i = requests.findIndex((r) => r === request); if (i !== -1) { // Unsubscribe if available. if (request.unsubscribe) { request.unsubscribe(); } + // Remove from requests list. requests.splice(i, 1); @@ -182,6 +195,7 @@ export default function createManagedSubscriptionClient( (requests.length === 0 || requests.every((r) => !r.unsubscribe)) ) { closeClient(); + closed = true; } } @@ -196,6 +210,12 @@ export default function createManagedSubscriptionClient( "with variables:", JSON.stringify(request.variables) ); + + if (closed) { + window.console.debug( + "subscription client disconnecting, no more subscriptions being tracked" + ); + } } }, }; diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx index 5d92096a5..0ffb977b1 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx @@ -1,12 +1,19 @@ import { Localized } from "@fluent/react/compat"; import cn from "classnames"; import { EventEmitter2 } from "eventemitter2"; -import { setLongTimeout } from "long-settimeout"; -import React, { Component, MouseEvent } from "react"; +import { clearLongTimeout, LongTimeout, setLongTimeout } from "long-settimeout"; +import React, { + FunctionComponent, + MouseEvent, + useCallback, + useEffect, + useState, +} from "react"; import { graphql } from "react-relay"; import { isBeforeDate } from "coral-common/utils"; import { getURLWithCommentID } from "coral-framework/helpers"; +import { useToggleState } from "coral-framework/hooks"; import { withContext } from "coral-framework/lib/bootstrap"; import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer"; import { @@ -87,453 +94,428 @@ interface Props { toggleCollapsed?: () => void; } -interface State { - showReplyDialog: boolean; - showEditDialog: boolean; - editable: boolean; - showReportFlow: boolean; -} - -export class CommentContainer extends Component { - private uneditableTimer: any; - - public state = { - showReplyDialog: false, - showEditDialog: false, - editable: this.isEditable(), - showReportFlow: false, - }; - - constructor(props: Props) { - super(props); - if (this.isEditable()) { - this.uneditableTimer = this.updateWhenNotEditable(); - } - } - - public componentWillUnmount() { - clearTimeout(this.uneditableTimer); - } - - private isEditable() { - const isMyComment = !!( - this.props.viewer && - this.props.comment.author && - this.props.viewer.id === this.props.comment.author.id - ); - const banned = Boolean( - this.props.viewer && - this.props.viewer.status.current.includes(GQLUSER_STATUS.BANNED) - ); - const suspended = Boolean( - this.props.viewer && - this.props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED) - ); - return ( - !banned && - !suspended && - isMyComment && - !!this.props.comment.editing.editableUntil && - isBeforeDate(this.props.comment.editing.editableUntil) - ); - } - - private toggleReplyDialog = () => { - if (this.props.viewer) { - this.setState((state) => { - if (!state.showReplyDialog) { - ShowReplyFormEvent.emit(this.props.eventEmitter, { - commentID: this.props.comment.id, - }); - } - return { - showReplyDialog: !state.showReplyDialog, - }; +export const CommentContainer: FunctionComponent = ({ + className, + collapsed, + comment, + disableReplies, + hideAnsweredTag, + hideModerationCarat, + highlight, + indentLevel, + localReply, + onRemoveAnswered, + settings, + showConversationLink, + hideReportButton, + story, + toggleCollapsed, + eventEmitter, + setCommentID, + viewer, + showAuthPopup, +}) => { + const [showReplyDialog, setShowReplyDialog] = useState(false); + const [ + showEditDialog, + setShowEditDialog, + toggleShowEditDialog, + ] = useToggleState(false); + const [showReportFlow, , toggleShowReportFlow] = useToggleState(false); + const handleShowConversation = useCallback( + (e: MouseEvent) => { + ViewConversationEvent.emit(eventEmitter, { + commentID: comment.id, + from: "COMMENT_STREAM", }); - } else { - void this.props.showAuthPopup({ view: "SIGN_IN" }); - } - }; - private openEditDialog = () => { - if (this.props.viewer) { - ShowEditFormEvent.emit(this.props.eventEmitter, { - commentID: this.props.comment.id, + // Prevent the event from acting. + e.preventDefault(); + + // If the feature for read more new tab is enabled, then redirect the + // user. + if (settings.featureFlags.includes(GQLFEATURE_FLAG.READ_MORE_NEW_TAB)) { + const url = getURLWithCommentID(story.url, comment.id); + + window.open(url, "_blank", "noreferrer"); + } else { + void setCommentID({ id: comment.id }); + } + + // Return false to prevent the navigation from occuring. + return false; + }, + [eventEmitter, comment.id, settings.featureFlags, story.url, setCommentID] + ); + + const isLoggedIn = !!viewer; + + const openEditDialog = useCallback(() => { + if (isLoggedIn) { + ShowEditFormEvent.emit(eventEmitter, { + commentID: comment.id, }); - this.setState((state) => ({ - showEditDialog: true, - })); + setShowEditDialog(true); } else { - void this.props.showAuthPopup({ view: "SIGN_IN" }); + void showAuthPopup({ view: "SIGN_IN" }); } - }; + }, [isLoggedIn, eventEmitter, comment.id, setShowEditDialog, showAuthPopup]); - private closeEditDialog = () => { - this.setState((state) => ({ - showEditDialog: false, - })); - }; + const toggleShowReplyDialog = useCallback(() => { + if (isLoggedIn) { + if (!showReplyDialog) { + ShowReplyFormEvent.emit(eventEmitter, { + commentID: comment.id, + }); + } - private closeReplyDialog = () => { - this.setState((state) => ({ - showReplyDialog: false, - })); - }; - - private updateWhenNotEditable() { - const ms = - new Date(this.props.comment.editing.editableUntil!).getTime() - - Date.now(); - if (ms > 0) { - return setLongTimeout(() => this.setState({ editable: false }), ms); - } - return; - } - - private handleShowConversation = (e: MouseEvent) => { - ViewConversationEvent.emit(this.props.eventEmitter, { - commentID: this.props.comment.id, - from: "COMMENT_STREAM", - }); - e.preventDefault(); - - if ( - this.props.settings.featureFlags.includes( - GQLFEATURE_FLAG.READ_MORE_NEW_TAB - ) - ) { - const url = getURLWithCommentID( - this.props.story.url, - this.props.comment.id - ); - - window.open(url, "_blank", "noreferrer"); + setShowReplyDialog((v) => !v); } else { - void this.props.setCommentID({ id: this.props.comment.id }); + void showAuthPopup({ view: "SIGN_IN" }); + } + }, [ + isLoggedIn, + showReplyDialog, + setShowReplyDialog, + showAuthPopup, + eventEmitter, + comment, + ]); + + const isViewerBanned = !!viewer?.status.current.includes( + GQLUSER_STATUS.BANNED + ); + const isViewerSuspended = !!viewer?.status.current.includes( + GQLUSER_STATUS.SUSPENDED + ); + const isViewerScheduledForDeletion = !!viewer?.scheduledDeletionDate; + + const isViewerComment = + // Can't edit a comment if you aren't logged in! + !!viewer && + // Can't edit a comment if there isn't an author on it! + !!comment.author && + // Can't edit a comment if the comment isn't the viewers! + viewer.id === comment.author.id; + + // The editable initial value is the result of the the funtion here. + const [editable, setEditable] = useState(() => { + // Can't edit a comment that the viewer didn't write! If the user is banned + // or suspended too they can't edit. + if (!isViewerComment || isViewerBanned || isViewerSuspended) { + return false; } - return false; - }; - - private onReportButtonClicked = () => { - this.setState({ - showReportFlow: !this.state.showReportFlow, - }); - }; - private onCloseReportFlow = () => { - this.setState({ - showReportFlow: false, - }); - }; - - public render() { - const { - comment, - settings, - story, - indentLevel, - localReply, - disableReplies, - showConversationLink, - highlight, - viewer, - className, - hideAnsweredTag, - collapsed, - } = this.props; - const { showReplyDialog, showEditDialog, editable } = this.state; - const hasFeaturedTag = Boolean( - comment.tags.find((t) => t.code === GQLTAG.FEATURED) - ); - // We are in a Q&A if the story mode is set to QA. - const isQA = Boolean(story.settings.mode === GQLSTORY_MODE.QA); - // Author is expert if comment is tagged Expert and the - // story mode is Q&A. - const authorIsExpert = - isQA && comment.tags.find((t) => t.code === GQLTAG.EXPERT); - // Only show a button to clear removed answers if - // this comment is by an expert, reply to a top level - // comment (question) with an answer - const showRemoveAnswered = Boolean( - !comment.deleted && - isQA && - authorIsExpert && - indentLevel === 1 && - this.props.onRemoveAnswered - ); - // When we're in Q&A and we are not un-answered (answered) - // and we're a top level comment (no parent), then we - // are an answered question - const hasAnsweredTag = Boolean( - !hideAnsweredTag && - isQA && - comment.tags.every((t) => t.code !== GQLTAG.UNANSWERED) && - !comment.parent - ); - const commentTags = ( - <> - {hasFeaturedTag && !isQA && ( - - )} - {hasAnsweredTag && isQA && ( - - )} - - ); - const banned = Boolean( - this.props.viewer && - this.props.viewer.status.current.includes(GQLUSER_STATUS.BANNED) - ); - const suspended = Boolean( - this.props.viewer && - this.props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED) - ); - const scheduledForDeletion = Boolean( - this.props.viewer && this.props.viewer.scheduledDeletionDate - ); - const showCaret = - this.props.viewer && - this.props.story.canModerate && - can(this.props.viewer, Ability.MODERATE) && - !this.props.hideModerationCarat; - - if (showEditDialog) { - return ( -
- -
- ); - } - // Comment is not published after viewer rejected it. + // Can't edit a comment if the editable date is before the current date! if ( - comment.lastViewerAction === "REJECT" && - comment.status === "REJECTED" + !comment.editing.editableUntil || + !isBeforeDate(comment.editing.editableUntil) ) { - return ; + return false; } - // Comment is not published after edit, so don't render it anymore. - if (comment.lastViewerAction === "EDIT" && !isPublished(comment.status)) { - return null; + + // Comment is editable! + return true; + }); + + useEffect(() => { + // If the comment is not editable now, it can't be editable in the future, + // so exit! + if (!editable) { + return; } + + // The comment is editable, we should register a callback to remove that + // status when it is no longer editable. We know that the `editableUntil` is + // available because it was editable! + const editableFor = + new Date(comment.editing.editableUntil!).getTime() - Date.now(); + if (editableFor <= 0) { + // Can't schedule a timer for the past! The comment is no longer editable. + setEditable(false); + return; + } + + // Setup the timeout. + const timeout: LongTimeout | null = setLongTimeout(() => { + // Mark the comment as not editable. + setEditable(false); + }, editableFor); + + return () => { + // When this component is disposed, also clear the timeout. + clearLongTimeout(timeout); + }; + }, [comment.editing.editableUntil, editable]); + + const hasFeaturedTag = comment.tags.some((t) => t.code === GQLTAG.FEATURED); + + // We are in a Q&A if the story mode is set to QA. + const isQA = story.settings.mode === GQLSTORY_MODE.QA; + + // Author is expert if comment is tagged Expert and the + // story mode is Q&A. + const authorIsExpert: boolean = + isQA && comment.tags.some((t) => t.code === GQLTAG.EXPERT); + + // Only show a button to clear removed answers if this comment is by an + // expert, reply to a top level comment (question) with an answer. + const showRemoveAnswered: boolean = + !comment.deleted && + isQA && + authorIsExpert && + indentLevel === 1 && + !!onRemoveAnswered; + + // When we're in Q&A and we are not un-answered (answered) and we're a top + // level comment (no parent), then we are an answered question. + const hasAnsweredTag: boolean = + !hideAnsweredTag && + isQA && + comment.tags.every((t) => t.code !== GQLTAG.UNANSWERED) && + !comment.parent; + + const commentTags = ( + <> + {hasFeaturedTag && !isQA && } + {hasAnsweredTag && isQA && } + + ); + + const showModerationCaret: boolean = + !!viewer && + story.canModerate && + can(viewer, Ability.MODERATE) && + !hideModerationCarat; + + if (showEditDialog) { return ( -
- - {!comment.deleted && ( - - - - - - ) - } - username={ - comment.author && ( - <> - - - - - ) - } - staticTopBarRight={commentTags} - topBarRight={ - - {commentTags} - {editable && ( - - )} - {showCaret && ( - - )} - - } - media={ - - } - footer={ - <> - - - - {!disableReplies && - !banned && - !suspended && - !scheduledForDeletion && ( - - )} - - - - {!banned && - !suspended && - !this.props.hideReportButton && ( - - )} - - - {showConversationLink && ( - - )} - - } - /> - )} - {this.state.showReportFlow && ( - - )} - {showReplyDialog && !comment.deleted && ( - - )} - {showRemoveAnswered && ( - - - - )} - +
+
); } -} + + // Comment is not published after viewer rejected it. + if (comment.lastViewerAction === "REJECT" && comment.status === "REJECTED") { + return ; + } + + // Comment is not published after edit, so don't render it anymore. + if (comment.lastViewerAction === "EDIT" && !isPublished(comment.status)) { + return null; + } + + return ( +
+ + {!comment.deleted && ( + + + + + + ) + } + username={ + comment.author && ( + <> + + + + + ) + } + staticTopBarRight={commentTags} + topBarRight={ + + {commentTags} + {editable && ( + + )} + {showModerationCaret && ( + + )} + + } + media={ + + } + footer={ + <> + + + + {!disableReplies && + !isViewerBanned && + !isViewerSuspended && + !isViewerScheduledForDeletion && ( + + )} + + + + {!isViewerBanned && + !isViewerSuspended && + !hideReportButton && ( + + )} + + + {showConversationLink && ( + + )} + + } + /> + )} + {showReportFlow && ( + + )} + {showReplyDialog && !comment.deleted && ( + + )} + {showRemoveAnswered && ( + + + + )} + +
+ ); +}; const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))( withSetCommentIDMutation( @@ -641,4 +623,5 @@ const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))( ); export type CommentContainerProps = PropTypesOf; + export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Comment/IndentedComment.tsx b/src/core/client/stream/tabs/Comments/Comment/IndentedComment.tsx index 69bb4021d..e42e2d270 100644 --- a/src/core/client/stream/tabs/Comments/Comment/IndentedComment.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/IndentedComment.tsx @@ -21,38 +21,34 @@ export interface IndentedCommentProps staticTopBarRight: React.ReactNode; } -const IndentedComment: FunctionComponent = (props) => { - const { - staticTopBarRight, - staticUsername, - indentLevel, - toggleCollapsed, - ...rest - } = props; - const CommentToggleElement = ( - - ); - const CommentElement = ; - const CommentwithIndent = ( +const IndentedComment: FunctionComponent = ({ + staticTopBarRight, + staticUsername, + indentLevel, + toggleCollapsed, + blur, + ...rest +}) => { + return ( {rest.collapsed ? ( - CommentToggleElement + ) : ( {toggleCollapsed && ( @@ -79,12 +75,11 @@ const IndentedComment: FunctionComponent = (props) => { )} - {CommentElement} + )} ); - return CommentwithIndent; }; export default IndentedComment; diff --git a/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap index 929e74592..232b9b295 100644 --- a/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap @@ -165,7 +165,6 @@ exports[`hide reply button 1`] = ` } /> } - parentAuthorName={null} showEditedMarker={false} staticTopBarRight={} staticUsername={ @@ -603,7 +602,6 @@ exports[`renders body only 1`] = ` } /> } - parentAuthorName={null} showEditedMarker={false} staticTopBarRight={} staticUsername={ @@ -1041,7 +1039,6 @@ exports[`renders disabled reply when commenting has been disabled 1`] = ` } /> } - parentAuthorName={null} showEditedMarker={false} staticTopBarRight={} staticUsername={ @@ -1479,7 +1476,6 @@ exports[`renders disabled reply when story is closed 1`] = ` } /> } - parentAuthorName={null} showEditedMarker={false} staticTopBarRight={} staticUsername={ @@ -2391,7 +2387,6 @@ exports[`renders username and body 1`] = ` } /> } - parentAuthorName={null} showEditedMarker={false} staticTopBarRight={} staticUsername={ @@ -2844,7 +2839,6 @@ exports[`shows conversation link 1`] = ` } /> } - parentAuthorName={null} showEditedMarker={false} staticTopBarRight={} staticUsername={ diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx index 32d185c1f..8da124e97 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx @@ -19,6 +19,7 @@ jest.spyOn(React, "useContext").mockImplementation(() => context); it("renders correctly", () => { const props: PropTypesOf = { story: { + closedAt: null, isClosed: false, settings: { live: { enabled: true } }, }, @@ -32,6 +33,7 @@ it("renders correctly", () => { ], viewNewEdges: [], }, + pending: null, lastViewerAction: null, }, settings: { @@ -56,6 +58,7 @@ it("renders correctly when replies are empty", () => { const props: PropTypesOf = { story: { isClosed: false, + closedAt: null, settings: { live: { enabled: true, @@ -66,6 +69,7 @@ it("renders correctly when replies are empty", () => { id: "comment-id", status: "NONE", replies: { edges: [], viewNewEdges: [] }, + pending: null, lastViewerAction: null, }, relay: { @@ -91,6 +95,7 @@ describe("when has more replies", () => { const props: PropTypesOf = { story: { isClosed: false, + closedAt: null, settings: { live: { enabled: true, @@ -107,6 +112,7 @@ describe("when has more replies", () => { ], viewNewEdges: [], }, + pending: null, lastViewerAction: null, }, settings: { diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx index 8ad9edf40..f51977fb7 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx @@ -1,7 +1,9 @@ +import { clearLongTimeout } from "long-settimeout"; import React, { FunctionComponent, useCallback, useEffect } from "react"; import { graphql, GraphQLTaggedNode, RelayPaginationProp } from "react-relay"; import { withProps } from "recompose"; +import { createTimeoutAt } from "coral-common/utils"; import { useViewerNetworkEvent } from "coral-framework/lib/events"; import { useLoadMore, @@ -79,6 +81,11 @@ export const ReplyListContainer: React.FunctionComponent = (props) => { CommentReplyCreatedSubscription ); useEffect(() => { + // If the comment is pending, no need to subscribe the comment! + if (props.comment.pending) { + return; + } + if (!props.story.settings.live.enabled) { return; } @@ -86,13 +93,34 @@ export const ReplyListContainer: React.FunctionComponent = (props) => { if (props.story.isClosed || props.settings.disableCommenting.enabled) { return; } + if (props.indentLevel !== 1) { return; } + const disposable = subcribeToCommentReplyCreated({ ancestorID: props.comment.id, liveDirectRepliesInsertion: props.liveDirectRepliesInsertion, }); + + // If the story is scheduled to be closed, cancel the subscriptions because + // we can't add any more comments! + if (props.story.closedAt) { + const timer = createTimeoutAt(() => { + disposable.dispose(); + }, props.story.closedAt); + + return () => { + // Cancel the timer if there was one enabled. + if (timer) { + clearLongTimeout(timer); + } + + // Dispose the subscriptions. + disposable.dispose(); + }; + } + return () => { disposable.dispose(); }; @@ -100,8 +128,11 @@ export const ReplyListContainer: React.FunctionComponent = (props) => { subcribeToCommentReplyCreated, props.comment.id, props.indentLevel, - props.relay.hasMore(), + props.comment.pending, + props.settings.disableCommenting.enabled, props.liveDirectRepliesInsertion, + props.story.isClosed, + props.story.closedAt, props.story.settings.live.enabled, ]); @@ -230,6 +261,7 @@ const ReplyListContainer3 = createReplyListContainer( story: graphql` fragment ReplyListContainer3_story on Story { isClosed + closedAt settings { live { enabled @@ -248,6 +280,7 @@ const ReplyListContainer3 = createReplyListContainer( ) { id status + pending lastViewerAction replies(first: $count, after: $cursor, orderBy: $orderBy) @connection(key: "ReplyList_replies") { @@ -308,6 +341,7 @@ const ReplyListContainer2 = createReplyListContainer( story: graphql` fragment ReplyListContainer2_story on Story { isClosed + closedAt settings { live { enabled @@ -326,6 +360,7 @@ const ReplyListContainer2 = createReplyListContainer( ) { id status + pending lastViewerAction replies(first: $count, after: $cursor, orderBy: $orderBy) @connection(key: "ReplyList_replies") { @@ -385,6 +420,7 @@ const ReplyListContainer1 = createReplyListContainer( story: graphql` fragment ReplyListContainer1_story on Story { isClosed + closedAt settings { live { enabled @@ -403,6 +439,7 @@ const ReplyListContainer1 = createReplyListContainer( ) { id status + pending lastViewerAction replies(first: $count, after: $cursor, orderBy: $orderBy) @connection(key: "ReplyList_replies") { diff --git a/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyListContainer.spec.tsx.snap b/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyListContainer.spec.tsx.snap index e3fb7bda9..84aa1a50a 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyListContainer.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyListContainer.spec.tsx.snap @@ -6,6 +6,7 @@ exports[`renders correctly 1`] = ` Object { "id": "comment-id", "lastViewerAction": null, + "pending": null, "replies": Object { "edges": Array [ Object { @@ -47,6 +48,7 @@ exports[`renders correctly 1`] = ` } story={ Object { + "closedAt": null, "isClosed": false, "settings": Object { "live": Object { @@ -78,6 +80,7 @@ exports[`renders correctly 1`] = ` } story={ Object { + "closedAt": null, "isClosed": false, "settings": Object { "live": Object { @@ -106,6 +109,7 @@ exports[`renders correctly 1`] = ` } story={ Object { + "closedAt": null, "isClosed": false, "settings": Object { "live": Object { @@ -127,6 +131,7 @@ exports[`when has more replies renders hasMore 1`] = ` Object { "id": "comment-id", "lastViewerAction": null, + "pending": null, "replies": Object { "edges": Array [ Object { @@ -178,6 +183,7 @@ exports[`when has more replies renders hasMore 1`] = ` } story={ Object { + "closedAt": null, "isClosed": false, "settings": Object { "live": Object { @@ -197,6 +203,7 @@ exports[`when has more replies when showing all disables show all button 1`] = ` Object { "id": "comment-id", "lastViewerAction": null, + "pending": null, "replies": Object { "edges": Array [ Object { @@ -248,6 +255,7 @@ exports[`when has more replies when showing all disables show all button 1`] = ` } story={ Object { + "closedAt": null, "isClosed": false, "settings": Object { "live": Object { @@ -267,6 +275,7 @@ exports[`when has more replies when showing all enable show all button after loa Object { "id": "comment-id", "lastViewerAction": null, + "pending": null, "replies": Object { "edges": Array [ Object { @@ -318,6 +327,7 @@ exports[`when has more replies when showing all enable show all button after loa } story={ Object { + "closedAt": null, "isClosed": false, "settings": Object { "live": Object { diff --git a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx index 18fa3c0b3..02da47770 100644 --- a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx @@ -1,8 +1,10 @@ import { Localized } from "@fluent/react/compat"; import cn from "classnames"; +import { clearLongTimeout } from "long-settimeout"; import React, { FunctionComponent, useCallback, useEffect } from "react"; import { graphql, RelayPaginationProp } from "react-relay"; +import { createTimeoutAt } from "coral-common/utils"; import FadeInTransition from "coral-framework/components/FadeInTransition"; import { useViewerNetworkEvent } from "coral-framework/lib/events"; import { @@ -54,7 +56,12 @@ graphql` } `; -export const AllCommentsTabContainer: FunctionComponent = (props) => { +export const AllCommentsTabContainer: FunctionComponent = ({ + story, + settings, + viewer, + relay, +}) => { const [{ commentsOrderBy }] = useLocal( graphql` fragment AllCommentsTabContainerLocal on Local { @@ -67,41 +74,66 @@ export const AllCommentsTabContainer: FunctionComponent = (props) => { CommentReleasedSubscription ); useEffect(() => { - if (!props.story.settings.live.enabled) { + // If live updates are disabled, don't subscribe to new comments!! + if (!story.settings.live.enabled) { return; } - if (props.story.isClosed || props.settings.disableCommenting.enabled) { + // If the story is closed or commenting is disabled, then don't subscribe + // to new comments because there isn't any! + if (story.isClosed || 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; + + // Check the sort ordering to apply extra logic. + switch (commentsOrderBy) { + case GQLCOMMENT_SORT.CREATED_AT_ASC: + if (relay.hasMore()) { + // Oldest first when there is more than one page of content can't + // possibly have new comments to show in view! + return; + } + + // We have all the comments for this story in view! Comments could load! + break; + case GQLCOMMENT_SORT.CREATED_AT_DESC: + // Newest first can always get more comments in view. + break; + default: + // Only chronological sort supports top level live updates of incoming + // comments. + return; } const disposable = combineDisposables( subscribeToCommentCreated({ - storyID: props.story.id, + storyID: story.id, orderBy: commentsOrderBy, }), subscribeToCommentReleased({ - storyID: props.story.id, + storyID: story.id, orderBy: commentsOrderBy, }) ); + // If the story is scheduled to be closed, cancel the subscriptions because + // we can't add any more comments! + if (story.closedAt) { + const timer = createTimeoutAt(() => { + disposable.dispose(); + }, story.closedAt); + + return () => { + // Cancel the timer if there was one enabled. + if (timer) { + clearLongTimeout(timer); + } + + // Dispose the subscriptions. + disposable.dispose(); + }; + } + return () => { disposable.dispose(); }; @@ -109,14 +141,18 @@ export const AllCommentsTabContainer: FunctionComponent = (props) => { commentsOrderBy, subscribeToCommentCreated, subscribeToCommentReleased, - props.story.id, - props.relay.hasMore(), - props.story.settings.live.enabled, + story.id, + story.isClosed, + story.closedAt, + story.settings.live.enabled, + settings.disableCommenting.enabled, + relay.hasMore(), ]); - const [loadMore, isLoadingMore] = useLoadMore(props.relay, 20); + + const [loadMore, isLoadingMore] = useLoadMore(relay, 20); const beginLoadMoreEvent = useViewerNetworkEvent(LoadMoreAllCommentsEvent); const loadMoreAndEmit = useCallback(async () => { - const loadMoreEvent = beginLoadMoreEvent({ storyID: props.story.id }); + const loadMoreEvent = beginLoadMoreEvent({ storyID: story.id }); try { await loadMore(); loadMoreEvent.success(); @@ -125,17 +161,13 @@ export const AllCommentsTabContainer: FunctionComponent = (props) => { // eslint-disable-next-line no-console console.error(error); } - }, [loadMore, beginLoadMoreEvent, props.story.id]); + }, [loadMore, beginLoadMoreEvent, story.id]); const viewMore = useMutation(AllCommentsTabViewNewMutation); - const onViewMore = useCallback(() => viewMore({ storyID: props.story.id }), [ - props.story.id, + const onViewMore = useCallback(() => viewMore({ storyID: story.id }), [ + 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; + const viewNewCount = story.comments.viewNewEdges?.length || 0; return ( <> {Boolean(viewNewCount && viewNewCount > 0) && ( @@ -147,7 +179,7 @@ export const AllCommentsTabContainer: FunctionComponent = (props) => { className={CLASSES.allCommentsTabPane.viewNewButton} fullWidth > - {props.story.settings.mode === GQLSTORY_MODE.QA ? ( + {story.settings.mode === GQLSTORY_MODE.QA ? ( View {viewNewCount} New Questions @@ -166,17 +198,17 @@ export const AllCommentsTabContainer: FunctionComponent = (props) => { aria-live="polite" size="oneAndAHalf" > - {comments.length <= 0 && ( + {story.comments.edges.length <= 0 && ( )} - {comments.length > 0 && - comments.map((comment) => ( + {story.comments.edges.length > 0 && + story.comments.edges.map(({ node: comment }) => ( @@ -189,10 +221,10 @@ export const AllCommentsTabContainer: FunctionComponent = (props) => { >
= (props) => { })} >
@@ -213,7 +245,7 @@ export const AllCommentsTabContainer: FunctionComponent = (props) => {
))} - {props.relay.hasMore() && ( + {relay.hasMore() && (