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 (
@@ -263,17 +387,17 @@ class Comment extends React.Component { (comment.user.id === currentUser.id)) /* User can edit/delete their own comment for a short window after posting */ - ? + ? { commentIsStillEditable(comment) && Edit } /* TopRightMenu allows currentUser to ignore other users' comments */ - : + : : null} - {comment.replies && - comment.replies.nodes.map((reply) => { - return commentIsIgnored(reply) - ? - : ; - })} - {comment.replies && -
- comment.replies.nodes.length} - loadMore={() => loadMore(comment.id)} - /> -
} + + + {view.map((reply) => { + return commentIsIgnored(reply) + ? + : ; + })} + +
+ +
); } 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 ? - : 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: