mirror of
https://github.com/wassname/talk.git
synced 2026-07-19 11:28:50 +08:00
[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:
co-authored by
kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
parent
459e11a5fa
commit
6fabeb2755
@@ -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;
|
||||
|
||||
-6
@@ -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") {
|
||||
|
||||
+10
@@ -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 {
|
||||
|
||||
+84
-51
@@ -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`
|
||||
|
||||
+2
-5
@@ -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);
|
||||
|
||||
+39
-60
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user