[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>
This commit is contained in:
Wyatt Johnson
2020-08-06 14:42:04 +00:00
committed by GitHub
co-authored by kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
parent 459e11a5fa
commit 6fabeb2755
15 changed files with 661 additions and 588 deletions
@@ -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"
);
}
}
},
};
@@ -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<Props, State> {
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<Props> = ({
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 && (
<FeaturedTag collapsed={this.props.collapsed} />
)}
{hasAnsweredTag && isQA && (
<AnsweredTag collapsed={this.props.collapsed} />
)}
</>
);
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 (
<div data-testid={`comment-${comment.id}`}>
<EditCommentFormContainer
settings={settings}
comment={comment}
story={story}
onClose={this.closeEditDialog}
/>
</div>
);
}
// 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 <RejectedTombstoneContainer comment={comment} />;
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 && <FeaturedTag collapsed={collapsed} />}
{hasAnsweredTag && isQA && <AnsweredTag collapsed={collapsed} />}
</>
);
const showModerationCaret: boolean =
!!viewer &&
story.canModerate &&
can(viewer, Ability.MODERATE) &&
!hideModerationCarat;
if (showEditDialog) {
return (
<div
className={cn(
CLASSES.comment.$root,
`${CLASSES.comment.reacted}-${comment.actionCounts.reaction.total}`,
className
)}
data-testid={`comment-${comment.id}`}
>
<HorizontalGutter>
{!comment.deleted && (
<IndentedComment
indentLevel={indentLevel}
collapsed={collapsed}
body={comment.body}
createdAt={comment.createdAt}
blur={comment.pending || false}
showEditedMarker={comment.editing.edited}
highlight={highlight}
toggleCollapsed={this.props.toggleCollapsed}
parentAuthorName={
comment.parent &&
comment.parent.author &&
comment.parent.author.username
}
staticUsername={
comment.author && (
<>
<UsernameContainer
className={cn(
styles.staticUsername,
CLASSES.comment.topBar.username
)}
comment={comment}
/>
<UserTagsContainer
className={CLASSES.comment.topBar.userTag}
story={story}
comment={comment}
settings={settings}
/>
<UserBadgesContainer
className={CLASSES.comment.topBar.userBadge}
comment={comment}
/>
</>
)
}
username={
comment.author && (
<>
<UsernameWithPopoverContainer
className={CLASSES.comment.topBar.username}
comment={comment}
viewer={viewer}
/>
<UserTagsContainer
className={CLASSES.comment.topBar.userTag}
story={story}
comment={comment}
settings={settings}
/>
<UserBadgesContainer
className={CLASSES.comment.topBar.userBadge}
comment={comment}
/>
</>
)
}
staticTopBarRight={commentTags}
topBarRight={
<Flex alignItems="center" itemGutter>
{commentTags}
{editable && (
<Button
color="stream"
variant="text"
onClick={this.openEditDialog}
className={cn(
CLASSES.comment.topBar.editButton,
styles.editButton
)}
data-testid="comment-edit-button"
>
<Flex alignItems="center" justifyContent="center">
<Icon className={styles.editIcon}>edit</Icon>
<Localized id="comments-commentContainer-editButton">
Edit
</Localized>
</Flex>
</Button>
)}
{showCaret && (
<CaretContainer
comment={comment}
story={story}
viewer={viewer!}
/>
)}
</Flex>
}
media={
<MediaSectionContainer comment={comment} settings={settings} />
}
footer={
<>
<Flex
justifyContent="space-between"
className={CLASSES.comment.actionBar.$root}
>
<ButtonsBar className={styles.actionBar}>
<ReactionButtonContainer
comment={comment}
settings={settings}
viewer={viewer}
readOnly={banned || suspended}
className={cn(
styles.actionButton,
CLASSES.comment.actionBar.reactButton
)}
reactedClassName={cn(
styles.actionButton,
CLASSES.comment.actionBar.reactedButton
)}
isQA={story.settings.mode === GQLSTORY_MODE.QA}
/>
{!disableReplies &&
!banned &&
!suspended &&
!scheduledForDeletion && (
<ReplyButton
id={`comments-commentContainer-replyButton-${comment.id}`}
onClick={this.toggleReplyDialog}
active={showReplyDialog}
disabled={
settings.disableCommenting.enabled ||
story.isClosed
}
className={cn(
styles.actionButton,
CLASSES.comment.actionBar.replyButton
)}
/>
)}
<PermalinkButtonContainer
story={story}
commentID={comment.id}
className={cn(
styles.actionButton,
CLASSES.comment.actionBar.shareButton
)}
/>
</ButtonsBar>
<ButtonsBar>
{!banned &&
!suspended &&
!this.props.hideReportButton && (
<ReportButton
onClick={this.onReportButtonClicked}
open={this.state.showReportFlow}
viewer={this.props.viewer}
comment={this.props.comment}
/>
)}
</ButtonsBar>
</Flex>
{showConversationLink && (
<ShowConversationLink
className={CLASSES.comment.readMoreOfConversation}
id={`comments-commentContainer-showConversation-${comment.id}`}
onClick={this.handleShowConversation}
href={getURLWithCommentID(
this.props.story.url,
this.props.comment.id
)}
/>
)}
</>
}
/>
)}
{this.state.showReportFlow && (
<ReportFlowContainer
viewer={viewer}
comment={comment}
onClose={this.onCloseReportFlow}
/>
)}
{showReplyDialog && !comment.deleted && (
<ReplyCommentFormContainer
settings={settings}
comment={comment}
story={story}
onClose={this.closeReplyDialog}
localReply={localReply}
/>
)}
{showRemoveAnswered && (
<Localized id="qa-unansweredTab-doneAnswering">
<Button
variant="regular"
color="regular"
className={styles.removeAnswered}
onClick={this.props.onRemoveAnswered}
>
Done
</Button>
</Localized>
)}
</HorizontalGutter>
<div data-testid={`comment-${comment.id}`}>
<EditCommentFormContainer
settings={settings}
comment={comment}
story={story}
onClose={toggleShowEditDialog}
/>
</div>
);
}
}
// Comment is not published after viewer rejected it.
if (comment.lastViewerAction === "REJECT" && comment.status === "REJECTED") {
return <RejectedTombstoneContainer comment={comment} />;
}
// Comment is not published after edit, so don't render it anymore.
if (comment.lastViewerAction === "EDIT" && !isPublished(comment.status)) {
return null;
}
return (
<div
className={cn(
CLASSES.comment.$root,
`${CLASSES.comment.reacted}-${comment.actionCounts.reaction.total}`,
className
)}
data-testid={`comment-${comment.id}`}
>
<HorizontalGutter>
{!comment.deleted && (
<IndentedComment
indentLevel={indentLevel}
collapsed={collapsed}
body={comment.body}
createdAt={comment.createdAt}
blur={!!comment.pending}
showEditedMarker={comment.editing.edited}
highlight={highlight}
toggleCollapsed={toggleCollapsed}
parentAuthorName={comment.parent?.author?.username}
staticUsername={
comment.author && (
<>
<UsernameContainer
className={cn(
styles.staticUsername,
CLASSES.comment.topBar.username
)}
comment={comment}
/>
<UserTagsContainer
className={CLASSES.comment.topBar.userTag}
story={story}
comment={comment}
settings={settings}
/>
<UserBadgesContainer
className={CLASSES.comment.topBar.userBadge}
comment={comment}
/>
</>
)
}
username={
comment.author && (
<>
<UsernameWithPopoverContainer
className={CLASSES.comment.topBar.username}
comment={comment}
viewer={viewer}
/>
<UserTagsContainer
className={CLASSES.comment.topBar.userTag}
story={story}
comment={comment}
settings={settings}
/>
<UserBadgesContainer
className={CLASSES.comment.topBar.userBadge}
comment={comment}
/>
</>
)
}
staticTopBarRight={commentTags}
topBarRight={
<Flex alignItems="center" itemGutter>
{commentTags}
{editable && (
<Button
color="stream"
variant="text"
onClick={openEditDialog}
className={cn(
CLASSES.comment.topBar.editButton,
styles.editButton
)}
data-testid="comment-edit-button"
>
<Flex alignItems="center" justifyContent="center">
<Icon className={styles.editIcon}>edit</Icon>
<Localized id="comments-commentContainer-editButton">
Edit
</Localized>
</Flex>
</Button>
)}
{showModerationCaret && (
<CaretContainer
comment={comment}
story={story}
viewer={viewer!}
/>
)}
</Flex>
}
media={
<MediaSectionContainer comment={comment} settings={settings} />
}
footer={
<>
<Flex
justifyContent="space-between"
className={CLASSES.comment.actionBar.$root}
>
<ButtonsBar className={styles.actionBar}>
<ReactionButtonContainer
comment={comment}
settings={settings}
viewer={viewer}
readOnly={isViewerBanned || isViewerSuspended}
className={cn(
styles.actionButton,
CLASSES.comment.actionBar.reactButton
)}
reactedClassName={cn(
styles.actionButton,
CLASSES.comment.actionBar.reactedButton
)}
isQA={story.settings.mode === GQLSTORY_MODE.QA}
/>
{!disableReplies &&
!isViewerBanned &&
!isViewerSuspended &&
!isViewerScheduledForDeletion && (
<ReplyButton
id={`comments-commentContainer-replyButton-${comment.id}`}
onClick={toggleShowReplyDialog}
active={showReplyDialog}
disabled={
settings.disableCommenting.enabled || story.isClosed
}
className={cn(
styles.actionButton,
CLASSES.comment.actionBar.replyButton
)}
/>
)}
<PermalinkButtonContainer
story={story}
commentID={comment.id}
className={cn(
styles.actionButton,
CLASSES.comment.actionBar.shareButton
)}
/>
</ButtonsBar>
<ButtonsBar>
{!isViewerBanned &&
!isViewerSuspended &&
!hideReportButton && (
<ReportButton
onClick={toggleShowReportFlow}
open={showReportFlow}
viewer={viewer}
comment={comment}
/>
)}
</ButtonsBar>
</Flex>
{showConversationLink && (
<ShowConversationLink
className={CLASSES.comment.readMoreOfConversation}
id={`comments-commentContainer-showConversation-${comment.id}`}
onClick={handleShowConversation}
href={getURLWithCommentID(story.url, comment.id)}
/>
)}
</>
}
/>
)}
{showReportFlow && (
<ReportFlowContainer
viewer={viewer}
comment={comment}
onClose={toggleShowReportFlow}
/>
)}
{showReplyDialog && !comment.deleted && (
<ReplyCommentFormContainer
settings={settings}
comment={comment}
story={story}
onClose={toggleShowReplyDialog}
localReply={localReply}
/>
)}
{showRemoveAnswered && (
<Localized id="qa-unansweredTab-doneAnswering">
<Button
variant="regular"
color="regular"
className={styles.removeAnswered}
onClick={onRemoveAnswered}
>
Done
</Button>
</Localized>
)}
</HorizontalGutter>
</div>
);
};
const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))(
withSetCommentIDMutation(
@@ -641,4 +623,5 @@ const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))(
);
export type CommentContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -21,38 +21,34 @@ export interface IndentedCommentProps
staticTopBarRight: React.ReactNode;
}
const IndentedComment: FunctionComponent<IndentedCommentProps> = (props) => {
const {
staticTopBarRight,
staticUsername,
indentLevel,
toggleCollapsed,
...rest
} = props;
const CommentToggleElement = (
<CommentToggle
{...rest}
toggleCollapsed={toggleCollapsed}
username={staticUsername}
topBarRight={staticTopBarRight}
/>
);
const CommentElement = <Comment {...rest} />;
const CommentwithIndent = (
const IndentedComment: FunctionComponent<IndentedCommentProps> = ({
staticTopBarRight,
staticUsername,
indentLevel,
toggleCollapsed,
blur,
...rest
}) => {
return (
<Indent
level={indentLevel}
collapsed={rest.collapsed}
className={cn(
{
[styles.open]: !rest.collapsed,
[styles.blur]: props.blur,
[styles.blur]: blur,
[CLASSES.comment.collapseToggle.collapsed]: rest.collapsed,
},
CLASSES.comment.collapseToggle.indent
)}
>
{rest.collapsed ? (
CommentToggleElement
<CommentToggle
{...rest}
toggleCollapsed={toggleCollapsed}
username={staticUsername}
topBarRight={staticTopBarRight}
/>
) : (
<Flex alignItems="flex-start" spacing={1}>
{toggleCollapsed && (
@@ -79,12 +75,11 @@ const IndentedComment: FunctionComponent<IndentedCommentProps> = (props) => {
</BaseButton>
</Localized>
)}
{CommentElement}
<Comment {...rest} />
</Flex>
)}
</Indent>
);
return CommentwithIndent;
};
export default IndentedComment;
@@ -165,7 +165,6 @@ exports[`hide reply button 1`] = `
}
/>
}
parentAuthorName={null}
showEditedMarker={false}
staticTopBarRight={<React.Fragment />}
staticUsername={
@@ -603,7 +602,6 @@ exports[`renders body only 1`] = `
}
/>
}
parentAuthorName={null}
showEditedMarker={false}
staticTopBarRight={<React.Fragment />}
staticUsername={
@@ -1041,7 +1039,6 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
}
/>
}
parentAuthorName={null}
showEditedMarker={false}
staticTopBarRight={<React.Fragment />}
staticUsername={
@@ -1479,7 +1476,6 @@ exports[`renders disabled reply when story is closed 1`] = `
}
/>
}
parentAuthorName={null}
showEditedMarker={false}
staticTopBarRight={<React.Fragment />}
staticUsername={
@@ -2391,7 +2387,6 @@ exports[`renders username and body 1`] = `
}
/>
}
parentAuthorName={null}
showEditedMarker={false}
staticTopBarRight={<React.Fragment />}
staticUsername={
@@ -2844,7 +2839,6 @@ exports[`shows conversation link 1`] = `
}
/>
}
parentAuthorName={null}
showEditedMarker={false}
staticTopBarRight={<React.Fragment />}
staticUsername={
@@ -19,6 +19,7 @@ jest.spyOn(React, "useContext").mockImplementation(() => context);
it("renders correctly", () => {
const props: PropTypesOf<typeof ReplyListContainerN> = {
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<typeof ReplyListContainerN> = {
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<typeof ReplyListContainerN> = {
story: {
isClosed: false,
closedAt: null,
settings: {
live: {
enabled: true,
@@ -107,6 +112,7 @@ describe("when has more replies", () => {
],
viewNewEdges: [],
},
pending: null,
lastViewerAction: null,
},
settings: {
@@ -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> = (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> = (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> = (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") {
@@ -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 {
@@ -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> = (props) => {
export const AllCommentsTabContainer: FunctionComponent<Props> = ({
story,
settings,
viewer,
relay,
}) => {
const [{ commentsOrderBy }] = useLocal<AllCommentsTabContainerLocal>(
graphql`
fragment AllCommentsTabContainerLocal on Local {
@@ -67,41 +74,66 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = (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> = (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> = (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> = (props) => {
className={CLASSES.allCommentsTabPane.viewNewButton}
fullWidth
>
{props.story.settings.mode === GQLSTORY_MODE.QA ? (
{story.settings.mode === GQLSTORY_MODE.QA ? (
<Localized id="qa-viewNew" $count={viewNewCount}>
<span>View {viewNewCount} New Questions</span>
</Localized>
@@ -166,17 +198,17 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = (props) => {
aria-live="polite"
size="oneAndAHalf"
>
{comments.length <= 0 && (
{story.comments.edges.length <= 0 && (
<NoComments
mode={props.story.settings.mode}
isClosed={props.story.isClosed}
mode={story.settings.mode}
isClosed={story.isClosed}
></NoComments>
)}
{comments.length > 0 &&
comments.map((comment) => (
{story.comments.edges.length > 0 &&
story.comments.edges.map(({ node: comment }) => (
<IgnoredTombstoneOrHideContainer
key={comment.id}
viewer={props.viewer}
viewer={viewer}
comment={comment}
>
<FadeInTransition active={Boolean(comment.enteredLive)}>
@@ -189,10 +221,10 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = (props) => {
>
<CommentContainer
collapsed={collapsed}
viewer={props.viewer}
settings={props.settings}
viewer={viewer}
settings={settings}
comment={comment}
story={props.story}
story={story}
toggleCollapsed={toggleCollapsed}
/>
<div
@@ -201,10 +233,10 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = (props) => {
})}
>
<ReplyListContainer
settings={props.settings}
viewer={props.viewer}
settings={settings}
viewer={viewer}
comment={comment}
story={props.story}
story={story}
/>
</div>
</HorizontalGutter>
@@ -213,7 +245,7 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = (props) => {
</FadeInTransition>
</IgnoredTombstoneOrHideContainer>
))}
{props.relay.hasMore() && (
{relay.hasMore() && (
<Localized id="comments-loadMore">
<Button
onClick={loadMoreAndEmit}
@@ -254,6 +286,7 @@ const enhanced = withPaginationContainer<
) {
id
isClosed
closedAt
settings {
live {
enabled
@@ -306,8 +339,8 @@ const enhanced = withPaginationContainer<
},
{
direction: "forward",
getConnectionFromProps(props) {
return props.story && props.story.comments;
getConnectionFromProps({ story }) {
return story && story.comments;
},
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
getFragmentVariables(prevVars, totalCount) {
@@ -316,14 +349,14 @@ const enhanced = withPaginationContainer<
count: totalCount,
};
},
getVariables(props, { count, cursor }, fragmentVariables) {
getVariables({ story }, { count, cursor }, fragmentVariables) {
return {
count,
cursor,
orderBy: fragmentVariables.orderBy,
// storyID isn't specified as an @argument for the fragment, but it should be a
// variable available for the fragment under the query root.
storyID: props.story.id,
storyID: story.id,
};
},
query: graphql`
@@ -1,6 +1,6 @@
import { commitLocalUpdate, Environment } from "relay-runtime";
import { createMutationContainer } from "coral-framework/lib/relay";
import { createMutation } from "coral-framework/lib/relay";
export interface SetStoryClosedInput {
storyID: string;
@@ -21,7 +21,4 @@ export async function commit(
});
}
export const withSetStoryClosedMutation = createMutationContainer(
"setStoryClosed",
commit
);
export default createMutation("setStoryClosed", commit);
@@ -1,75 +1,54 @@
import { setLongTimeout } from "long-settimeout";
import React from "react";
import { clearLongTimeout } from "long-settimeout";
import { FunctionComponent, useEffect } from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { createTimeoutAt } from "coral-common/utils";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import { StoryClosedTimeoutContainer_story as StoryData } from "coral-stream/__generated__/StoryClosedTimeoutContainer_story.graphql";
import {
SetStoryClosedMutation,
withSetStoryClosedMutation,
} from "./SetStoryClosedMutation";
import SetStoryClosedMutation from "./SetStoryClosedMutation";
interface Props {
story: StoryData;
setStoryClosed: SetStoryClosedMutation;
}
function createTimeout(callback: () => void, closedAt: string) {
const diff = new Date(closedAt).valueOf() - Date.now();
if (diff > 0) {
return setLongTimeout(callback, diff);
}
const StoryClosedTimeoutContainer: FunctionComponent<Props> = ({ story }) => {
const setStoryClosed = useMutation(SetStoryClosedMutation);
// Whenever the story is updated, or the mutation is updated, reapply the
// timer.
useEffect(() => {
if (!story.closedAt) {
return;
}
// Create a timer to update the story status after this happens.
const timer = createTimeoutAt(async () => {
await setStoryClosed({
storyID: story.id,
isClosed: true,
});
}, story.closedAt);
// When this component is disposed, dispose this timer.
return () => {
if (timer) {
clearLongTimeout(timer);
}
};
}, [story, setStoryClosed]);
return null;
}
};
class StoryClosedTimeoutContainer extends React.Component<Props> {
private timer: any = null;
constructor(props: Props) {
super(props);
if (props.story.closedAt) {
this.timer = createTimeout(this.handleClose, props.story.closedAt);
const enhanced = withFragmentContainer<Props>({
story: graphql`
fragment StoryClosedTimeoutContainer_story on Story {
id
closedAt
}
}
public UNSAFE_componentWillReceiveProps(nextProps: Props) {
if (nextProps.story.closedAt !== this.props.story.closedAt) {
clearTimeout(this.timer);
if (nextProps.story.closedAt) {
this.timer = createTimeout(this.handleClose, nextProps.story.closedAt);
}
}
}
public componentWillUnmount() {
clearTimeout(this.timer);
this.timer = null;
}
private handleClose = () => {
this.timer = null;
void this.props.setStoryClosed({
storyID: this.props.story.id,
isClosed: true,
});
};
public render() {
return null;
}
}
const enhanced = withSetStoryClosedMutation(
withFragmentContainer<Props>({
story: graphql`
fragment StoryClosedTimeoutContainer_story on Story {
id
closedAt
}
`,
})(StoryClosedTimeoutContainer)
);
`,
})(StoryClosedTimeoutContainer);
export default enhanced;
@@ -73,7 +73,9 @@ const createTestRenderer = async (
);
// Open reply form.
within(comment).getByTestID("comment-reply-button").props.onClick();
act(() =>
within(comment).getByTestID("comment-reply-button").props.onClick()
);
const rte = await waitForElement(
() =>
@@ -53,7 +53,9 @@ async function createTestRenderer(
);
// Open reply form.
within(comment).getByTestID("comment-reply-button").props.onClick();
act(() =>
within(comment).getByTestID("comment-reply-button").props.onClick()
);
const rte = await waitForElement(
() =>
@@ -152,7 +152,9 @@ it("edit a comment and handle non-published comment state", async () => {
await waitForElement(() =>
within(comment).getByText("will be reviewed", { exact: false })
);
within(comment).getByTestID("callout-close-button").props.onClick();
act(() => {
within(comment).getByTestID("callout-close-button").props.onClick();
});
expect(
within(testRenderer.root).queryByText("will be reviewed", { exact: false })
).toBeNull();
+12
View File
@@ -0,0 +1,12 @@
import { setLongTimeout } from "long-settimeout";
function createTimeoutAt(callback: () => void, timeoutAt: string) {
const diff = new Date(timeoutAt).valueOf() - Date.now();
if (diff > 0) {
return setLongTimeout(callback, diff);
}
return null;
}
export default createTimeoutAt;
+10 -9
View File
@@ -1,13 +1,14 @@
export { default as animationFrame } from "./animationFrame";
export { default as pascalCase } from "./pascalCase";
export { default as oncePerFrame } from "./oncePerFrame";
export { default as isBeforeDate } from "./isBeforeDate";
export { default as createTimeoutAt } from "./createTimeoutAt";
export { default as ensureEndSlash } from "./ensureEndSlash";
export { default as ensureNoEndSlash } from "./ensureNoEndSlash";
export { default as parseQuery } from "./parseQuery";
export { default as stringifyQuery } from "./stringifyQuery";
export { default as pureMerge } from "./pureMerge";
export { default as isPromiseLike } from "./isPromiseLike";
export { default as isPromise } from "./isPromise";
export { default as startsWith } from "./startsWith";
export { default as getOrigin } from "./getOrigin";
export { default as isBeforeDate } from "./isBeforeDate";
export { default as isPromise } from "./isPromise";
export { default as isPromiseLike } from "./isPromiseLike";
export { default as oncePerFrame } from "./oncePerFrame";
export { default as parseQuery } from "./parseQuery";
export { default as pascalCase } from "./pascalCase";
export { default as pureMerge } from "./pureMerge";
export { default as startsWith } from "./startsWith";
export { default as stringifyQuery } from "./stringifyQuery";