diff --git a/client/coral-embed-stream/src/actions/stream.js b/client/coral-embed-stream/src/actions/stream.js
index 81b57a0fe..6a9e47838 100644
--- a/client/coral-embed-stream/src/actions/stream.js
+++ b/client/coral-embed-stream/src/actions/stream.js
@@ -2,7 +2,6 @@ import {pym} from 'coral-framework';
import * as actions from '../constants/stream';
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
-export const setCommentCountCache = (amount) => ({type: actions.SET_COMMENT_COUNT_CACHE, amount});
function removeParam(key, sourceURL) {
let rtn = sourceURL.split('?')[0];
diff --git a/client/coral-embed-stream/src/components/Comment.css b/client/coral-embed-stream/src/components/Comment.css
index 5fab57d95..b0fca75c8 100644
--- a/client/coral-embed-stream/src/components/Comment.css
+++ b/client/coral-embed-stream/src/components/Comment.css
@@ -97,3 +97,13 @@
.Wizard .textAlignRight {
text-align: right;
}
+
+@keyframes enter {
+ 0% {background-color: rgba(0, 0, 0, 0);}
+ 50% {background-color: rgba(255,255,0, 0.2);}
+ 100% {background-color: rgba(0, 0, 0, 0);}
+}
+
+.enter {
+ animation: enter 1000ms;
+}
diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js
index f56c2ec0e..71117563d 100644
--- a/client/coral-embed-stream/src/components/Comment.js
+++ b/client/coral-embed-stream/src/components/Comment.js
@@ -8,6 +8,9 @@ import Content from 'coral-plugin-commentcontent/CommentContent';
import PubDate from 'coral-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
import FlagComment from 'coral-plugin-flags/FlagComment';
+import {TransitionGroup} from 'react-transition-group';
+import cn from 'classnames';
+
import {
BestButton,
IfUserCanModifyBest,
@@ -19,13 +22,47 @@ import Slot from 'coral-framework/components/Slot';
import LoadMore from './LoadMore';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import {TopRightMenu} from './TopRightMenu';
-import classnames from 'classnames';
import {EditableCommentContent} from './EditableCommentContent';
import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils';
import {getEditableUntilDate} from './util';
import styles from './Comment.css';
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF');
+const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
+
+// resetCursors will return the id cursors of the first and second newest comment in
+// the current reply list. The cursors are used to dertermine which
+// comments to show. The spare cursor functions as a backup in case one
+// of the comments gets deleted.
+function resetCursors(state, props) {
+ const replies = props.comment.replies;
+ if (replies && replies.nodes.length) {
+ const idCursors = [replies.nodes[replies.nodes.length - 1].id];
+ if (replies.nodes.length >= 2) {
+ idCursors.push(replies.nodes[replies.nodes.length - 2].id);
+ }
+ return {idCursors};
+ }
+ return {idCursors: []};
+}
+
+// invalidateCursor is called whenever a comment is removed which is referenced
+// by one of the 2 id cursors. It returns a new set of id cursors calculated
+// using the help of the backup cursor.
+function invalidateCursor(invalidated, state, props) {
+ const alt = invalidated === 1 ? 0 : 1;
+ const replies = props.comment.replies;
+ const idCursors = [];
+ if (state.idCursors[alt]) {
+ idCursors.push(state.idCursors[alt]);
+ const index = replies.nodes.findIndex((node) => node.id === idCursors[0]);
+ const prevInLine = replies.nodes[index - 1];
+ if (prevInLine) {
+ idCursors.push(prevInLine.id);
+ }
+ }
+ return {idCursors};
+}
// hold actions links (e.g. Reply) along the comment footer
const ActionButton = ({children}) => {
@@ -37,6 +74,7 @@ const ActionButton = ({children}) => {
};
class Comment extends React.Component {
+
constructor(props) {
super(props);
@@ -49,9 +87,47 @@ class Comment extends React.Component {
// Whether the comment should be editable (e.g. after a commenter clicking the 'Edit' button on their own comment)
isEditing: false,
replyBoxVisible: false,
+ animateEnter: false,
+ ...resetCursors({}, props),
};
}
+ componentWillReceiveProps(next) {
+ const {comment: {replies: prevReplies}} = this.props;
+ const {comment: {replies: nextReplies}} = next;
+ if (
+ prevReplies && nextReplies &&
+ nextReplies.nodes.length < prevReplies.nodes.length
+ ) {
+
+ // Invalidate first cursor if referenced comment was removed.
+ if (this.state.idCursors[0] && !hasComment(nextReplies.nodes, this.state.idCursors[0])) {
+ this.setState(invalidateCursor(0, this.state, next));
+ }
+
+ // Invalidate second cursor if referenced comment was removed.
+ if (this.state.idCursors[1] && !hasComment(nextReplies.nodes, this.state.idCursors[1])) {
+ this.setState(invalidateCursor(1, this.state, next));
+ }
+ }
+ }
+
+ componentWillEnter(callback) {
+ callback();
+ const userId = this.props.currentUser ? this.props.currentUser.id : null;
+ if (this.props.comment.id.indexOf('pending') >= 0) {
+ return;
+ }
+ if (userId && this.props.comment.user.id === userId) {
+
+ // This comment was just added by currentUser.
+ if (Date.now() - Number(new Date(this.props.comment.created_at)) < 30 * 1000) {
+ return;
+ }
+ }
+ this.setState({animateEnter: true});
+ }
+
static propTypes = {
reactKey: PropTypes.string.isRequired,
@@ -67,6 +143,7 @@ class Comment extends React.Component {
addNotification: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
depth: PropTypes.number.isRequired,
+ liveUpdates: PropTypes.bool.isRequired,
asset: PropTypes.shape({
id: PropTypes.string,
title: PropTypes.string,
@@ -127,6 +204,50 @@ class Comment extends React.Component {
}
}
+ hasIgnoredReplies() {
+ return this.props.comment.replies &&
+ this.props.comment.replies.nodes.some((reply) => this.props.commentIsIgnored(reply));
+ }
+
+ loadNewReplies = () => {
+ const {replies, replyCount, id} = this.props.comment;
+ if (replyCount > replies.nodes.length) {
+ this.props.loadMore(id).then(() => {
+ this.setState(resetCursors(this.state, this.props));
+ });
+ return;
+ }
+ this.setState(resetCursors);
+ };
+
+ // getVisibileReplies returns a list containing comments
+ // which were authored by current user or comes before the `idCursor`.
+ getVisibileReplies() {
+ const {comment: {replies}, currentUser, liveUpdates} = this.props;
+ const idCursor = this.state.idCursors[0];
+ const userId = currentUser ? currentUser.id : null;
+
+ if (!replies) {
+ return [];
+ }
+
+ if (liveUpdates) {
+ return replies.nodes;
+ }
+
+ const view = [];
+ let pastCursor = false;
+ replies.nodes.forEach((comment) => {
+ if (idCursor && !pastCursor || comment.user.id === userId) {
+ view.push(comment);
+ }
+ if (comment.id === idCursor) {
+ pastCursor = true;
+ }
+ });
+ return view;
+ }
+
componentDidMount() {
this._isMounted = true;
if (this.editWindowExpiryTimeout) {
@@ -162,19 +283,22 @@ class Comment extends React.Component {
highlighted,
postFlag,
postDontAgree,
- loadMore,
setActiveReplyBox,
activeReplyBox,
deleteAction,
addCommentTag,
removeCommentTag,
ignoreUser,
+ liveUpdates,
disableReply,
commentIsIgnored,
maxCharCount,
charCountEnable
} = this.props;
+ const view = this.getVisibileReplies();
+ const hasMoreComments = comment.replies && (comment.replies.hasNextPage || comment.replies.nodes.length > view.length);
+ const replyCount = this.hasIgnoredReplies() ? '' : comment.replyCount;
const flagSummary = getActionSummary('FlagActionSummary', comment);
const dontAgreeSummary = getActionSummary(
'DontAgreeActionSummary',
@@ -226,7 +350,7 @@ class Comment extends React.Component {
return (
);
}
diff --git a/client/coral-embed-stream/src/components/IgnoredCommentTombstone.js b/client/coral-embed-stream/src/components/IgnoredCommentTombstone.js
index 18fb0338c..bbcc5d5f6 100644
--- a/client/coral-embed-stream/src/components/IgnoredCommentTombstone.js
+++ b/client/coral-embed-stream/src/components/IgnoredCommentTombstone.js
@@ -3,18 +3,22 @@ import React from 'react';
import t from 'coral-framework/services/i18n';
// Render in place of a Comment when the author of the comment is ignored
-const IgnoredCommentTombstone = () => (
-
-
-
- {t('framework.comment_is_ignored')}
-
-
-);
+class IgnoredCommentTombstone extends React.Component {
+ render() {
+ return (
+
+
+
+ {t('framework.comment_is_ignored')}
+
+
+ );
+ }
+}
export default IgnoredCommentTombstone;
diff --git a/client/coral-embed-stream/src/components/LoadMore.js b/client/coral-embed-stream/src/components/LoadMore.js
index 31001fcd2..ca2816d10 100644
--- a/client/coral-embed-stream/src/components/LoadMore.js
+++ b/client/coral-embed-stream/src/components/LoadMore.js
@@ -9,6 +9,9 @@ class LoadMore extends React.Component {
}
replyCountFormat = (count) => {
+ if (!count) {
+ return t('framework.view_all_replies_unknown_number');
+ }
if (count === 1) {
return t('framework.view_reply');
}
diff --git a/client/coral-embed-stream/src/components/NewCount.js b/client/coral-embed-stream/src/components/NewCount.js
index 43dce13e0..1c135ab9b 100644
--- a/client/coral-embed-stream/src/components/NewCount.js
+++ b/client/coral-embed-stream/src/components/NewCount.js
@@ -2,22 +2,14 @@ import React, {PropTypes} from 'react';
import t from 'coral-framework/services/i18n';
-const onLoadMoreClick = ({loadMore, commentCount, setCommentCountCache}) => (e) => {
- e.preventDefault();
- setCommentCountCache(commentCount);
- loadMore();
-};
-
-const NewCount = (props) => {
- const newComments = props.commentCount - props.commentCountCache;
-
+const NewCount = ({count, loadMore}) => {
return
{
- props.commentCountCache && newComments > 0 ?
-
- {newComments === 1
- ? t('framework.new_count', newComments, t('framework.comment'))
- : t('framework.new_count', newComments, t('framework.comments'))}
+ count ?
+
+ {count === 1
+ ? t('framework.new_count', count, t('framework.comment'))
+ : t('framework.new_count', count, t('framework.comments'))}
: null
}
@@ -25,8 +17,7 @@ const NewCount = (props) => {
};
NewCount.propTypes = {
- commentCount: PropTypes.number.isRequired,
- commentCountCache: PropTypes.number,
+ count: PropTypes.number.isRequired,
loadMore: PropTypes.func.isRequired,
};
diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js
index 11f2b7819..31012c9f1 100644
--- a/client/coral-embed-stream/src/components/Stream.js
+++ b/client/coral-embed-stream/src/components/Stream.js
@@ -1,8 +1,7 @@
import React, {PropTypes} from 'react';
import LoadMore from './LoadMore';
-import NewCount from './NewCount';
-import Comment from '../containers/Comment';
+import Comment from '../components/Comment';
import SuspendedAccount from './SuspendedAccount';
import RestrictedMessageBox
from 'coral-framework/components/RestrictedMessageBox';
@@ -13,9 +12,82 @@ import {ModerationLink} from 'coral-plugin-moderation';
import CommentBox from 'coral-plugin-commentbox/CommentBox';
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
+import NewCount from './NewCount';
import t, {timeago} from 'coral-framework/services/i18n';
+import {TransitionGroup} from 'react-transition-group';
+
+const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
+
+// resetCursors will return the id cursors of the first and second comment of
+// the current comment list. The cursors are used to dertermine which
+// comments to show. The spare cursor functions as a backup in case one
+// of the comments gets deleted.
+function resetCursors(state, props) {
+ const comments = props.root.asset.comments;
+ if (comments && comments.nodes.length) {
+ const idCursors = [comments.nodes[0].id];
+ if (comments.nodes[1]) {
+ idCursors.push(comments.nodes[1].id);
+ }
+ return {idCursors};
+ }
+ return {idCursors: []};
+}
+
+// invalidateCursor is called whenever a comment is removed which is referenced
+// by one of the 2 id cursors. It returns a new set of id cursors calculated
+// using the help of the backup cursor.
+function invalidateCursor(invalidated, state, props) {
+ const alt = invalidated === 1 ? 0 : 1;
+ const comments = props.root.asset.comments;
+ const idCursors = [];
+ if (state.idCursors[alt]) {
+ idCursors.push(state.idCursors[alt]);
+ const index = comments.nodes.findIndex((node) => node.id === idCursors[0]);
+ const nextInLine = comments.nodes[index + 1];
+ if (nextInLine) {
+ idCursors.push(nextInLine.id);
+ }
+ }
+ return {idCursors};
+}
class Stream extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = resetCursors(this.state, props);
+ }
+
+ componentWillReceiveProps(next) {
+ const {root: {asset: {comments: prevComments}}} = this.props;
+ const {root: {asset: {comments: nextComments}}} = next;
+
+ if (!prevComments && nextComments) {
+ this.setState(resetCursors);
+ return;
+ }
+ if (
+ prevComments && nextComments &&
+ nextComments.nodes.length < prevComments.nodes.length
+ ) {
+
+ // Invalidate first cursor if referenced comment was removed.
+ if (this.state.idCursors[0] && !hasComment(nextComments.nodes, this.state.idCursors[0])) {
+ this.setState(invalidateCursor(0, this.state, next));
+ }
+
+ // Invalidate second cursor if referenced comment was removed.
+ if (this.state.idCursors[1] && !hasComment(nextComments.nodes, this.state.idCursors[1])) {
+ this.setState(invalidateCursor(1, this.state, next));
+ }
+ }
+ }
+
+ viewNewComments = () => {
+ this.setState(resetCursors);
+ };
+
setActiveReplyBox = (reactKey) => {
if (!this.props.auth.user) {
this.props.showSignInDialog();
@@ -24,6 +96,30 @@ class Stream extends React.Component {
}
};
+ // getVisibileComments returns a list containing comments
+ // which were authored by current user or comes after the `idCursor`.
+ getVisibleComments() {
+ const {root: {asset: {comments}}, auth: {user}} = this.props;
+ const idCursor = this.state.idCursors[0];
+ const userId = user ? user.id : null;
+
+ if (!comments) {
+ return [];
+ }
+
+ const view = [];
+ let pastCursor = false;
+ comments.nodes.forEach((comment) => {
+ if (comment.id === idCursor) {
+ pastCursor = true;
+ }
+ if (pastCursor || comment.user.id === userId) {
+ view.push(comment);
+ }
+ });
+ return view;
+ }
+
render() {
const {
root: {asset, asset: {comments}, comment, me},
@@ -38,9 +134,9 @@ class Stream extends React.Component {
pluginProps,
ignoreUser,
auth: {loggedIn, user},
- commentCountCache,
editName
} = this.props;
+ const view = this.getVisibleComments();
const open = asset.closedAt === null;
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
@@ -97,8 +193,6 @@ class Stream extends React.Component {
postComment={this.props.postComment}
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
- setCommentCountCache={this.props.setCommentCountCache}
- commentCountCache={commentCountCache}
assetId={asset.id}
premod={asset.settings.moderation}
isReply={false}
@@ -139,16 +233,15 @@ class Stream extends React.Component {
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={this.props.editComment}
+ liveUpdates={true}
/>
:
-
- {comments && comments.nodes.map((comment) => {
+
+ {view.map((comment) => {
return commentIsIgnored(comment)
?
: ;
})}
-
+
pym.scrollParentToChildEl('coralStream'), 0);
@@ -87,7 +80,6 @@ export const withEmbedQuery = withQuery(EMBED_QUERY, {
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
- commentCountCache: state.stream.commentCountCache,
commentId: state.stream.commentId,
assetId: state.stream.assetId,
assetUrl: state.stream.assetUrl,
@@ -103,7 +95,6 @@ const mapDispatchToProps = (dispatch) =>
setActiveTab,
viewAllComments,
fetchAssetSuccess,
- setCommentCountCache
},
dispatch
);
diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js
index f2f5f67db..6b4ae71ed 100644
--- a/client/coral-embed-stream/src/containers/Stream.js
+++ b/client/coral-embed-stream/src/containers/Stream.js
@@ -2,34 +2,94 @@ import React from 'react';
import {gql, compose} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
-import {NEW_COMMENT_COUNT_POLL_INTERVAL, ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream';
+import {ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream';
import {
withPostComment, withPostFlag, withPostDontAgree, withDeleteAction,
withAddCommentTag, withRemoveCommentTag, withIgnoreUser, withEditComment,
} from 'coral-framework/graphql/mutations';
-import update from 'immutability-helper';
import {notificationActions, authActions} from 'coral-framework';
import {editName} from 'coral-framework/actions/user';
-import {setCommentCountCache, setActiveReplyBox} from '../actions/stream';
+import {setActiveReplyBox} from '../actions/stream';
import Stream from '../components/Stream';
import Comment from './Comment';
import {withFragments} from 'coral-framework/hocs';
+import {Spinner} from 'coral-ui';
import {getDefinitionName} from 'coral-framework/utils';
+import {
+ findCommentInEmbedQuery,
+ insertCommentIntoEmbedQuery,
+ removeCommentFromEmbedQuery,
+ insertFetchedCommentsIntoEmbedQuery,
+} from '../graphql/utils';
const {showSignInDialog} = authActions;
const {addNotification} = notificationActions;
class StreamContainer extends React.Component {
- getCounts = (variables) => {
- return this.props.data.fetchMore({
- query: LOAD_COMMENT_COUNTS_QUERY,
- variables,
+ subscriptions = [];
- // Apollo requires this, even though we don't use it...
- updateQuery: (data) => data,
+ subscribeToUpdates() {
+ const sub1 = this.props.data.subscribeToMore({
+ document: COMMENTS_EDITED_SUBSCRIPTION,
+ variables: {
+ assetId: this.props.root.asset.id,
+ },
+ updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => {
+
+ // Ignore mutations from me.
+ // TODO: need way to detect mutations created by this client, and allow mutations from other clients.
+ if (this.props.auth.user && commentEdited.user.id === this.props.auth.user.id) {
+ return prev;
+ }
+
+ // Exit when comment is not in the query.
+ if (!findCommentInEmbedQuery(prev, commentEdited.id)) {
+ return prev;
+ }
+
+ if (['PREMOD', 'REJECTED'].includes(commentEdited.status)) {
+ return removeCommentFromEmbedQuery(prev, commentEdited.id);
+ }
+ },
});
- };
+
+ const sub2 = this.props.data.subscribeToMore({
+ document: COMMENTS_ADDED_SUBSCRIPTION,
+ variables: {
+ assetId: this.props.root.asset.id,
+ },
+ updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => {
+
+ // Ignore mutations from me.
+ // TODO: need way to detect mutations created by this client, and allow mutations from other clients.
+ if (this.props.auth.user && commentAdded.user.id === this.props.auth.user.id) {
+ return prev;
+ }
+
+ // Exit if author is ignored.
+ if (
+ this.props.root.me &&
+ this.props.root.me.ignoredUsers.some(({id}) => id === commentAdded.user.id)) {
+ return prev;
+ }
+
+ // Exit when comment is already in the query.
+ if (findCommentInEmbedQuery(prev, commentAdded.id)) {
+ return prev;
+ }
+
+ return insertCommentIntoEmbedQuery(prev, commentAdded);
+ }
+ });
+
+ this.subscriptions.push(sub1, sub2);
+ }
+
+ unsubscribe() {
+ this.subscriptions.forEach((unsubscribe) => unsubscribe());
+ this.subscriptions = [];
+ }
loadNewReplies = (parent_id) => {
const comment = this.props.root.comment
@@ -47,86 +107,11 @@ class StreamContainer extends React.Component {
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
- if (!comments.nodes.length) {
- return prev;
- }
-
- const updateNode = (node) =>
- update(node, {
- replies: {
- endCursor: {$set: comments.endCursor},
- nodes: {$apply: (nodes) => nodes
- .concat(comments.nodes.filter(
- (comment) => !nodes.some((node) => node.id === comment.id)
- ))
- .sort(ascending)
- },
- },
- });
-
- // highlighted comment.
- if (prev.comment) {
- if (prev.comment.parent) {
- return update(prev, {
- comment: {
- parent: {$apply: (comment) => updateNode(comment)},
- }
- });
- }
- return update(prev, {
- comment: {$apply: (comment) => updateNode(comment)},
- });
- }
-
- return update(prev, {
- asset: {
- comments: {
- nodes: {
- $apply: (nodes) => nodes.map(
- (node) => node.id !== parent_id
- ? node
- : updateNode(node)
- )
- },
- },
- },
- });
+ return insertFetchedCommentsIntoEmbedQuery(prev, comments, parent_id);
},
});
}
- loadNewComments = () => {
- return this.props.data.fetchMore({
- query: LOAD_MORE_QUERY,
- variables: {
- limit: ADDTL_COMMENTS_ON_LOAD_MORE,
- cursor: this.props.root.asset.comments.startCursor,
- parent_id: null,
- asset_id: this.props.root.asset.id,
- sort: 'CHRONOLOGICAL',
- excludeIgnored: this.props.data.variables.excludeIgnored,
- },
- updateQuery: (prev, {fetchMoreResult:{comments}}) => {
- if (!comments.nodes.length) {
- return prev;
- }
- return update(prev, {
- asset: {
- comments: {
- startCursor: {$set: comments.endCursor},
- nodes: {$apply: (nodes) => comments.nodes.filter(
- (comment) => !nodes.some((node) => node.id === comment.id)
- )
- .concat(nodes)
- .sort(descending)
- },
- },
- },
- });
- },
- });
- };
-
loadMoreComments = () => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
@@ -139,78 +124,77 @@ class StreamContainer extends React.Component {
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
- if (!comments.nodes.length) {
- return prev;
- }
-
- return update(prev, {
- asset: {
- comments: {
- hasNextPage: {$set: comments.hasNextPage},
- endCursor: {$set: comments.endCursor},
- nodes: {$push: comments.nodes},
- },
- },
- });
+ return insertFetchedCommentsIntoEmbedQuery(prev, comments);
},
});
};
componentDidMount() {
if (this.props.previousTab) {
- this.props.data.refetch()
- .then(({data: {asset: {commentCount}}}) => {
- return this.props.setCommentCountCache(commentCount);
- });
+ this.props.data.refetch();
}
- this.countPoll = setInterval(() => {
- this.getCounts(this.props.data.variables);
- }, NEW_COMMENT_COUNT_POLL_INTERVAL);
+ this.subscribeToUpdates();
}
componentWillUnmount() {
+ this.unsubscribe();
clearInterval(this.countPoll);
}
render() {
+ if (this.props.refetching) {
+ return ;
+ }
return ;
}
}
-const ascending = (a, b) => {
- const dateA = new Date(a.created_at);
- const dateB = new Date(b.created_at);
- if (dateA < dateB) { return -1; }
- if (dateA > dateB) { return 1; }
- return 0;
-};
+const commentFragment = gql`
+ fragment CoralEmbedStream_Stream_comment on Comment {
+ id
+ ...${getDefinitionName(Comment.fragments.comment)}
+ replyCount(excludeIgnored: $excludeIgnored)
+ replies(excludeIgnored: $excludeIgnored) {
+ nodes {
+ id
+ ...${getDefinitionName(Comment.fragments.comment)}
+ }
+ hasNextPage
+ startCursor
+ endCursor
+ }
+ }
+ ${Comment.fragments.comment}
+`;
-const descending = (a, b) => ascending(a, b) * -1;
-
-const LOAD_COMMENT_COUNTS_QUERY = gql`
- query CoralEmbedStream_LoadCommentCounts($assetUrl: String, , $commentId: ID!, $assetId: ID, $hasComment: Boolean!, $excludeIgnored: Boolean) {
- comment(id: $commentId) @include(if: $hasComment) {
- id
+const COMMENTS_ADDED_SUBSCRIPTION = gql`
+ subscription onCommentAdded($assetId: ID!, $excludeIgnored: Boolean){
+ commentAdded(asset_id: $assetId){
parent {
id
- replyCount(excludeIgnored: $excludeIgnored)
}
- replyCount(excludeIgnored: $excludeIgnored)
+ ...CoralEmbedStream_Stream_comment
}
- asset(id: $assetId, url: $assetUrl) {
+ }
+ ${commentFragment}
+`;
+
+const COMMENTS_EDITED_SUBSCRIPTION = gql`
+ subscription onCommentEdited($assetId: ID!){
+ commentEdited(asset_id: $assetId){
id
- commentCount(excludeIgnored: $excludeIgnored)
- comments(limit: 10) @skip(if: $hasComment) {
- nodes {
- id
- replyCount(excludeIgnored: $excludeIgnored)
- }
+ body
+ status
+ editing {
+ edited
+ }
+ user {
+ id
}
}
}
@@ -241,24 +225,6 @@ const LOAD_MORE_QUERY = gql`
${Comment.fragments.comment}
`;
-const commentFragment = gql`
- fragment CoralEmbedStream_Stream_comment on Comment {
- id
- ...${getDefinitionName(Comment.fragments.comment)}
- replyCount(excludeIgnored: $excludeIgnored)
- replies {
- nodes {
- id
- ...${getDefinitionName(Comment.fragments.comment)}
- }
- hasNextPage
- startCursor
- endCursor
- }
- }
- ${Comment.fragments.comment}
-`;
-
const fragments = {
root: gql`
fragment CoralEmbedStream_Stream_root on RootQuery {
@@ -316,6 +282,7 @@ const fragments = {
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
+ refetching: state.embed.refetching,
commentCountCache: state.stream.commentCountCache,
activeReplyBox: state.stream.activeReplyBox,
commentId: state.stream.commentId,
@@ -331,7 +298,6 @@ const mapDispatchToProps = (dispatch) =>
addNotification,
setActiveReplyBox,
editName,
- setCommentCountCache,
}, dispatch);
export default compose(
diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js
index 1b4ac500b..04225eb96 100644
--- a/client/coral-embed-stream/src/graphql/index.js
+++ b/client/coral-embed-stream/src/graphql/index.js
@@ -91,6 +91,9 @@ const extension = {
edited
editableUntil
}
+ parent {
+ id
+ }
}
`,
},
@@ -144,6 +147,9 @@ const extension = {
tags,
status: null,
replyCount: 0,
+ parent: parent_id
+ ? {id: parent_id}
+ : null,
replies: {
__typename: 'CommentConnection',
nodes: [],
@@ -165,7 +171,7 @@ const extension = {
if (prev.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
return prev;
}
- return insertCommentIntoEmbedQuery(prev, parent_id, comment);
+ return insertCommentIntoEmbedQuery(prev, comment);
},
}
}),
diff --git a/client/coral-embed-stream/src/graphql/utils.js b/client/coral-embed-stream/src/graphql/utils.js
index 163432479..eff1e2439 100644
--- a/client/coral-embed-stream/src/graphql/utils.js
+++ b/client/coral-embed-stream/src/graphql/utils.js
@@ -1,11 +1,33 @@
import update from 'immutability-helper';
-function findAndInsertComment(parent, id, comment) {
- const [connectionField, countField, action] = parent.comments
+function applyToCommentsOrigin(root, callback) {
+ if (root.comment) {
+ if (root.comment.parent) {
+ return update(root, {
+ comment: {
+ parent: {
+ $apply: (node) => callback(node),
+ },
+ },
+ });
+ }
+ return update(root, {
+ comment: {
+ $apply: (node) => callback(node),
+ },
+ });
+ }
+ return update(root, {
+ asset: {$apply: (asset) => callback(asset)},
+ });
+}
+
+function findAndInsertComment(parent, comment) {
+ const [connectionField, countField, action] = parent.__typename === 'Asset'
? ['comments', 'commentCount', '$unshift']
: ['replies', 'replyCount', '$push'];
- if (!id || parent.id === id) {
+ if (!comment.parent || parent.id === comment.parent.id) {
return update(parent, {
[connectionField]: {
nodes: {[action]: [comment]},
@@ -21,13 +43,13 @@ function findAndInsertComment(parent, id, comment) {
[connectionField]: {
nodes: {
$apply: (nodes) =>
- nodes.map((node) => findAndInsertComment(node, id, comment))
+ nodes.map((node) => findAndInsertComment(node, comment))
},
},
});
}
-export function insertCommentIntoEmbedQuery(root, id, comment) {
+export function insertCommentIntoEmbedQuery(root, comment) {
// Increase total comment count by one.
root = update(root, {
@@ -35,30 +57,11 @@ export function insertCommentIntoEmbedQuery(root, id, comment) {
totalCommentCount: {$apply: (c) => c + 1},
},
});
-
- if (root.comment) {
- if (root.comment.parent) {
- return update(root, {
- comment: {
- parent: {
- $apply: (node) => findAndInsertComment(node, id, comment),
- },
- },
- });
- }
- return update(root, {
- comment: {
- $apply: (node) => findAndInsertComment(node, id, comment),
- },
- });
- }
- return update(root, {
- asset: {$apply: (asset) => findAndInsertComment(asset, id, comment)},
- });
+ return applyToCommentsOrigin(root, (origin) => findAndInsertComment(origin, comment));
}
function findAndRemoveComment(parent, id) {
- const [connectionField, countField] = parent.comments
+ const [connectionField, countField] = parent.__typename === 'Asset'
? ['comments', 'commentCount']
: ['replies', 'replyCount'];
@@ -78,7 +81,7 @@ function findAndRemoveComment(parent, id) {
};
if (parent[countField] && next.length !== connection.nodes.length) {
- changes[countField] = {$set: changes[countField] - 1};
+ changes[countField] = {$set: parent[countField] - 1};
}
return update(parent, changes);
}
@@ -91,24 +94,88 @@ export function removeCommentFromEmbedQuery(root, id) {
totalCommentCount: {$apply: (c) => c - 1},
},
});
+ return applyToCommentsOrigin(root, (origin) => findAndRemoveComment(origin, id));
+}
- if (root.comment) {
- if (root.comment.parent) {
- return update(root, {
- comment: {
- parent: {
- $apply: (node) => findAndRemoveComment(node, id),
- },
- },
- });
+function findComment(nodes, callback) {
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i];
+ if (callback(node)) {
+ return node;
}
- return update(root, {
- comment: {
- $apply: (node) => findAndRemoveComment(node, id),
+ if (node.replies) {
+ const find = findComment(node.replies.nodes, callback);
+ if (find){
+ return find;
+ }
+ }
+ }
+ return false;
+}
+
+export function findCommentInEmbedQuery(root, callbackOrId) {
+ let callback = callbackOrId;
+ if (typeof callbackOrId === 'string') {
+ callback = (node) => node.id === callbackOrId;
+ }
+ if (root.comment) {
+ if (callback(root.comment)) {
+ return root.comment;
+ }
+ if (root.comment.parent && callback(root.comment.parent)) {
+ return root.comment.parent;
+ }
+ }
+ if (!root.asset.comments) {
+ return false;
+ }
+ return findComment(root.asset.comments.nodes, callback);
+}
+
+const ascending = (a, b) => {
+ const dateA = new Date(a.created_at);
+ const dateB = new Date(b.created_at);
+ if (dateA < dateB) { return -1; }
+ if (dateA > dateB) { return 1; }
+ return 0;
+};
+
+function findAndInsertFetchedComments(parent, comments, parent_id) {
+ const isAsset = parent.__typename === 'Asset';
+ const connectionField = isAsset ? 'comments' : 'replies';
+ if (!parent_id && connectionField === 'comments' || parent.id === parent_id) {
+ return update(parent, {
+ [connectionField]: {
+ hasNextPage: {$set: comments.hasNextPage},
+ endCursor: {$set: comments.endCursor},
+ nodes: {$apply: (nodes) => {
+ if (isAsset) {
+ return nodes.concat(comments.nodes);
+ }
+ return nodes
+ .concat(comments.nodes.filter(
+ (comment) => !nodes.some((node) => node.id === comment.id)
+ ))
+ .sort(ascending);
+ }},
},
});
}
- return update(root, {
- asset: {$apply: (asset) => findAndRemoveComment(asset, id)},
+
+ const connection = parent[connectionField];
+ if (!connection) {
+ return parent;
+ }
+ return update(parent, {
+ [connectionField]: {
+ nodes: {
+ $apply: (nodes) =>
+ nodes.map((node) => findAndInsertFetchedComments(node, comments, parent_id))
+ },
+ },
});
}
+
+export function insertFetchedCommentsIntoEmbedQuery(root, comments, parent_id) {
+ return applyToCommentsOrigin(root, (origin) => findAndInsertFetchedComments(origin, comments, parent_id));
+}
diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js
index 4dee04f1e..821399488 100644
--- a/client/coral-embed-stream/src/index.js
+++ b/client/coral-embed-stream/src/index.js
@@ -6,13 +6,14 @@ import {checkLogin} from 'coral-framework/actions/auth';
import './graphql';
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
import {getStore, injectReducers} from 'coral-framework/services/store';
-import {client} from 'coral-framework/services/client';
+import {getClient} from 'coral-framework/services/client';
import AppRouter from './AppRouter';
import {pym} from 'coral-framework';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
import reducers from './reducers';
const store = getStore();
+const client = getClient();
loadPluginsTranslations();
injectPluginsReducers();
diff --git a/client/coral-embed-stream/src/reducers/embed.js b/client/coral-embed-stream/src/reducers/embed.js
index 0fd661543..8ccf4cbbc 100644
--- a/client/coral-embed-stream/src/reducers/embed.js
+++ b/client/coral-embed-stream/src/reducers/embed.js
@@ -3,6 +3,7 @@ import * as actions from '../constants/embed';
const initialState = {
activeTab: 'stream',
previousTab: '',
+ refetching: false,
};
export default function stream(state = initialState, action) {
@@ -13,6 +14,22 @@ export default function stream(state = initialState, action) {
activeTab: action.tab,
previousTab: state.activeTab,
};
+ case 'APOLLO_QUERY_INIT':
+ if (action.queryString.indexOf('query CoralEmbedStream_Embed(') >= 0) {
+ return {
+ ...state,
+ refetching: action.isRefetch,
+ };
+ }
+ return state;
+ case 'APOLLO_QUERY_RESULT':
+ if (action.operationName === 'CoralEmbedStream_Embed') {
+ return {
+ ...state,
+ refetching: false,
+ };
+ }
+ return state;
default:
return state;
}
diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js
index c77bc9848..facb23d06 100644
--- a/client/coral-framework/services/client.js
+++ b/client/coral-framework/services/client.js
@@ -1,28 +1,35 @@
import ApolloClient, {addTypename} from 'apollo-client';
import {networkInterface} from './transport';
+import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
-// import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
+let client;
-// TODO: replace absolute reference with something loaded from the store/page.
-// const wsClient = new SubscriptionClient('ws://localhost:3000/api/v1/live', {
-// reconnect: true
-// });
-// const networkInterface = addGraphQLSubscriptions(
-// getNetworkInterface(),
-// wsClient,
-// );
-export const client = new ApolloClient({
- connectToDevTools: true,
- addTypename: true,
- queryTransformer: addTypename,
- dataIdFromObject: (result) => {
- if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
- return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle
- }
- return null;
- },
- networkInterface
-});
+export function getClient() {
+ if (client) {
+ return client;
+ }
-export default client;
+ const wsClient = new SubscriptionClient(`ws://${location.host}/api/v1/live`, {
+ reconnect: true
+ });
+ const networkInterfaceWithSubscriptions = addGraphQLSubscriptions(
+ networkInterface,
+ wsClient,
+ );
+
+ client = new ApolloClient({
+ connectToDevTools: true,
+ addTypename: true,
+ queryTransformer: addTypename,
+ dataIdFromObject: (result) => {
+ if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
+ return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle
+ }
+ return null;
+ },
+ networkInterface: networkInterfaceWithSubscriptions,
+ });
+
+ return client;
+}
diff --git a/client/coral-framework/services/store.js b/client/coral-framework/services/store.js
index 0530d8c28..4d6dafe20 100644
--- a/client/coral-framework/services/store.js
+++ b/client/coral-framework/services/store.js
@@ -1,28 +1,7 @@
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from '../reducers';
-import {client} from './client';
-
-const apolloErrorReporter = () => (next) => (action) => {
- if (action.type === 'APOLLO_QUERY_ERROR') {
- console.error(action.error);
- }
- return next(action);
-};
-
-const middlewares = [
- applyMiddleware(
- client.middleware(),
- thunk,
- apolloErrorReporter,
- ),
-];
-
-if (window.devToolsExtension) {
-
- // we can't have the last argument of compose() be undefined
- middlewares.push(window.devToolsExtension());
-}
+import {getClient} from './client';
export function injectReducers(reducers) {
const store = getStore();
@@ -35,9 +14,30 @@ export function getStore() {
return window.coralStore;
}
+ const apolloErrorReporter = () => (next) => (action) => {
+ if (action.type === 'APOLLO_QUERY_ERROR') {
+ console.error(action.error);
+ }
+ return next(action);
+ };
+
+ const middlewares = [
+ applyMiddleware(
+ getClient().middleware(),
+ thunk,
+ apolloErrorReporter,
+ ),
+ ];
+
+ if (window.devToolsExtension) {
+
+ // we can't have the last argument of compose() be undefined
+ middlewares.push(window.devToolsExtension());
+ }
+
const coralReducers = {
...mainReducer,
- apollo: client.reducer()
+ apollo: getClient().reducer()
};
window.coralStore = createStore(
diff --git a/client/coral-framework/services/subscriptions.js b/client/coral-framework/services/subscriptions.js
deleted file mode 100644
index 818a6fb33..000000000
--- a/client/coral-framework/services/subscriptions.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import {print} from 'graphql-tag/printer';
-
-// quick way to add the subscribe and unsubscribe functions to the network interface
-const addGraphQLSubscriptions = (networkInterface, wsClient) => {
- return Object.assign(networkInterface, {
- subscribe: (request, handler) => wsClient.subscribe({
- query: print(request.query),
- variables: request.variables,
- }, handler),
- unsubscribe: (id) => {
- wsClient.unsubscribe(id);
- },
- });
-};
-
-export default addGraphQLSubscriptions;
diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js
index 3cd48d2b0..5105667ed 100644
--- a/client/coral-plugin-commentbox/CommentBox.js
+++ b/client/coral-plugin-commentbox/CommentBox.js
@@ -36,18 +36,10 @@ class CommentBox extends React.Component {
}
};
}
- static get defaultProps() {
- return {
- setCommentCountCache: () => {}
- };
- }
postComment = ({body}) => {
const {
commentPostedHandler,
postComment,
- setCommentCountCache,
- commentCountCache,
- isReply,
assetId,
parentId,
addNotification,
@@ -60,8 +52,6 @@ class CommentBox extends React.Component {
...this.props.commentBox
};
- !isReply && setCommentCountCache(commentCountCache + 1);
-
// Execute preSubmit Hooks
this.state.hooks.preSubmit.forEach((hook) => hook());
@@ -74,19 +64,12 @@ class CommentBox extends React.Component {
notifyForNewCommentStatus(addNotification, postedComment.status);
- if (postedComment.status === 'REJECTED') {
- !isReply && setCommentCountCache(commentCountCache);
- } else if (postedComment.status === 'PREMOD') {
- !isReply && setCommentCountCache(commentCountCache);
- }
-
if (commentPostedHandler) {
commentPostedHandler();
}
})
.catch((err) => {
console.error(err);
- !isReply && setCommentCountCache(commentCountCache);
});
this.setState({postedCount: this.state.postedCount + 1});
@@ -190,7 +173,6 @@ CommentBox.propTypes = {
authorId: PropTypes.string.isRequired,
isReply: PropTypes.bool.isRequired,
canPost: PropTypes.bool,
- setCommentCountCache: PropTypes.func,
};
const mapStateToProps = ({commentBox}) => ({commentBox});
diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js
index 7d234c755..88c391078 100644
--- a/graph/mutators/comment.js
+++ b/graph/mutators/comment.js
@@ -340,6 +340,12 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
// Execute the edit.
const comment = await CommentsService.edit(id, context.user.id, {body, status});
+ if (context.pubsub) {
+
+ // Publish the edited comment via the subscription.
+ context.pubsub.publish('commentEdited', comment);
+ }
+
return comment;
};
diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js
index 2e8412169..33ec0ef60 100644
--- a/graph/resolvers/comment.js
+++ b/graph/resolvers/comment.js
@@ -49,7 +49,7 @@ const Comment = {
},
async editing(comment, _, {loaders: {Settings}}) {
const settings = await Settings.load();
- const editableUntil = new Date(Number(comment.created_at) + settings.editCommentWindowLength);
+ const editableUntil = new Date(Number(new Date(comment.created_at)) + settings.editCommentWindowLength);
return {
edited: comment.edited,
editableUntil: editableUntil
diff --git a/graph/resolvers/subscription.js b/graph/resolvers/subscription.js
index b3f5e655c..a593c1eb6 100644
--- a/graph/resolvers/subscription.js
+++ b/graph/resolvers/subscription.js
@@ -1,6 +1,9 @@
const Subscription = {
commentAdded(comment) {
return comment;
+ },
+ commentEdited(comment) {
+ return comment;
}
};
diff --git a/graph/subscriptions.js b/graph/subscriptions.js
index 2eb676cc4..0442c237d 100644
--- a/graph/subscriptions.js
+++ b/graph/subscriptions.js
@@ -25,6 +25,11 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu
filter: (comment) => comment.asset_id === args.asset_id
},
}),
+ commentEdited: (options, args) => ({
+ commentEdited: {
+ filter: (comment) => comment.asset_id === args.asset_id
+ },
+ }),
});
/**
diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql
index aed610ad7..187e5bba5 100644
--- a/graph/typeDefs.graphql
+++ b/graph/typeDefs.graphql
@@ -883,6 +883,7 @@ type RootMutation {
type Subscription {
commentAdded(asset_id: ID!): Comment
+ commentEdited(asset_id: ID!): Comment
}
################################################################################
diff --git a/locales/en.yml b/locales/en.yml
index 90182abb7..403befecc 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -224,6 +224,7 @@ en:
success_bio_update: "Your biography has been updated"
success_name_update: "Your username has been updated"
success_update_settings: "The changes you have made have been applied to the comment stream on this article"
+ view_all_replies_unknown_number: "view all replies"
view_all_replies: "view {0} replies"
view_all_replies_initial: "view all {0} replies"
view_more_comments: "view more comments"
diff --git a/locales/es.yml b/locales/es.yml
index 9e7a8bcba..0802a7604 100644
--- a/locales/es.yml
+++ b/locales/es.yml
@@ -223,6 +223,7 @@ es:
success_bio_update: "Tu biografia fue actualizada"
success_name_update: "Tu nombre de usuario ha sido actualizado"
success_update_settings: "La configuración de este articulo fue actualizada"
+ view_all_replies_unknown_number: "ver todas las respuestas"
view_all_replies: "ver {0} respuestas"
view_all_replies_initial: "ver todas las {0} respuestas"
view_more_comments: "Ver más comentarios"
diff --git a/models/comment.js b/models/comment.js
index 27a2bfae3..1ffeb3fd6 100644
--- a/models/comment.js
+++ b/models/comment.js
@@ -104,7 +104,10 @@ const CommentSchema = new Schema({
timestamps: {
createdAt: 'created_at',
updatedAt: 'updated_at'
- }
+ },
+ toJSON: {
+ virtuals: true,
+ },
});
CommentSchema.virtual('edited').get(function() {
diff --git a/package.json b/package.json
index 14893de54..766aff70c 100644
--- a/package.json
+++ b/package.json
@@ -104,6 +104,7 @@
"react-apollo": "^1.1.0",
"react-recaptcha": "^2.2.6",
"react-toastify": "^1.5.0",
+ "react-transition-group": "^1.1.3",
"recompose": "^0.23.1",
"redis": "^2.7.1",
"resolve": "^1.3.2",
diff --git a/yarn.lock b/yarn.lock
index 9e3b3084d..4242faec0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6909,7 +6909,7 @@ react-toastify@^1.5.0:
prop-types "^15.5.8"
react-transition-group "^1.1.2"
-react-transition-group@^1.1.2:
+react-transition-group@^1.1.2, react-transition-group@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-1.1.3.tgz#5e02cf6e44a863314ff3c68a0c826c2d9d70b221"
dependencies: