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.js b/client/coral-embed-stream/src/components/Comment.js index 48392cc51..2c69f3979 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -26,6 +26,41 @@ 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 +72,7 @@ const ActionButton = ({children}) => { }; class Comment extends React.Component { + constructor(props) { super(props); @@ -49,7 +85,29 @@ 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, + ...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)); + } + } } static propTypes = { @@ -67,6 +125,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 +186,45 @@ class Comment extends React.Component { } } + 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 `userId` 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 +260,20 @@ 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 flagSummary = getActionSummary('FlagActionSummary', comment); const dontAgreeSummary = getActionSummary( 'DontAgreeActionSummary', @@ -369,46 +468,45 @@ class Comment extends React.Component { assetId={asset.id} /> : 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) + ? + : ; + })} +
+ view.length} + loadMore={this.loadNewReplies} + /> +
); } 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..725d5c488 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -1,6 +1,5 @@ import React, {PropTypes} from 'react'; import LoadMore from './LoadMore'; -import NewCount from './NewCount'; import Comment from '../containers/Comment'; import SuspendedAccount from './SuspendedAccount'; @@ -13,9 +12,81 @@ 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'; +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 +95,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 +133,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 +192,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 +232,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) ? : ; })}
diff --git a/client/coral-embed-stream/src/constants/stream.js b/client/coral-embed-stream/src/constants/stream.js index cb17edb2f..4be4dc125 100644 --- a/client/coral-embed-stream/src/constants/stream.js +++ b/client/coral-embed-stream/src/constants/stream.js @@ -1,5 +1,3 @@ export const SET_ACTIVE_REPLY_BOX = 'SET_ACTIVE_REPLY_BOX'; -export const SET_COMMENT_COUNT_CACHE = 'SET_COMMENT_COUNT_CACHE'; export const ADDTL_COMMENTS_ON_LOAD_MORE = 10; -export const NEW_COMMENT_COUNT_POLL_INTERVAL = 20000; export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS'; diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index 05dd9fb91..74d4e9d70 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -14,7 +14,7 @@ import Embed from '../components/Embed'; import Stream from './Stream'; import {setActiveTab} from '../actions/embed'; -import {setCommentCountCache, viewAllComments} from '../actions/stream'; +import {viewAllComments} from '../actions/stream'; const {logout, checkLogin} = authActions; const {fetchAssetSuccess} = assetActions; @@ -33,13 +33,6 @@ class EmbedContainer extends React.Component { // TODO: remove asset data from redux store. fetchAssetSuccess(nextProps.root.asset); - - const {setCommentCountCache, commentCountCache} = this.props; - const {asset} = nextProps.root; - - if (commentCountCache === -1) { - setCommentCountCache(asset.commentCount); - } } } @@ -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..ea42b5bd6 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -2,7 +2,7 @@ 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, @@ -11,23 +11,68 @@ 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 {getDefinitionName} from 'coral-framework/utils'; +import {findCommentInEmbedQuery, insertCommentIntoEmbedQuery, removeCommentFromEmbedQuery} 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, + subscribeToUpdates = () => { + this.props.data.subscribeToMore({ + document: COMMENTS_EDITED_SUBSCRIPTION, + variables: { + assetId: this.props.root.asset.id, + }, + updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => { - // Apollo requires this, even though we don't use it... - updateQuery: (data) => data, + // 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); + } + }, + }); + 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); + } }); }; @@ -95,38 +140,6 @@ class StreamContainer extends React.Component { }); } - 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, @@ -157,15 +170,7 @@ class StreamContainer extends React.Component { }; componentDidMount() { - if (this.props.previousTab) { - this.props.data.refetch() - .then(({data: {asset: {commentCount}}}) => { - return this.props.setCommentCountCache(commentCount); - }); - } - this.countPoll = setInterval(() => { - this.getCounts(this.props.data.variables); - }, NEW_COMMENT_COUNT_POLL_INTERVAL); + this.subscribeToUpdates(); } componentWillUnmount() { @@ -177,7 +182,6 @@ class StreamContainer extends React.Component { {...this.props} loadMore={this.loadMore} loadMoreComments={this.loadMoreComments} - loadNewComments={this.loadNewComments} loadNewReplies={this.loadNewReplies} />; } @@ -191,26 +195,47 @@ const ascending = (a, b) => { return 0; }; -const descending = (a, b) => ascending(a, b) * -1; +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 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 +266,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 { @@ -331,7 +338,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..3ab9c9e3b 100644 --- a/client/coral-embed-stream/src/graphql/utils.js +++ b/client/coral-embed-stream/src/graphql/utils.js @@ -1,11 +1,11 @@ import update from 'immutability-helper'; -function findAndInsertComment(parent, id, comment) { +function findAndInsertComment(parent, comment) { const [connectionField, countField, action] = parent.comments ? ['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 +21,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, { @@ -41,19 +41,19 @@ export function insertCommentIntoEmbedQuery(root, id, comment) { return update(root, { comment: { parent: { - $apply: (node) => findAndInsertComment(node, id, comment), + $apply: (node) => findAndInsertComment(node, comment), }, }, }); } return update(root, { comment: { - $apply: (node) => findAndInsertComment(node, id, comment), + $apply: (node) => findAndInsertComment(node, comment), }, }); } return update(root, { - asset: {$apply: (asset) => findAndInsertComment(asset, id, comment)}, + asset: {$apply: (asset) => findAndInsertComment(asset, comment)}, }); } @@ -78,7 +78,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); } @@ -112,3 +112,38 @@ export function removeCommentFromEmbedQuery(root, id) { asset: {$apply: (asset) => findAndRemoveComment(asset, id)}, }); } + +function findComment(nodes, callback) { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (callback(node)) { + return node; + } + 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); +} diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js index c77bc9848..fd906c75e 100644 --- a/client/coral-framework/services/client.js +++ b/client/coral-framework/services/client.js @@ -1,16 +1,16 @@ import ApolloClient, {addTypename} from 'apollo-client'; import {networkInterface} from './transport'; +import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws'; -// import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws'; +const wsClient = new SubscriptionClient(`ws://${location.host}/api/v1/live`, { + reconnect: true +}); + +const networkInterfaceWithSubscriptions = addGraphQLSubscriptions( + networkInterface, + wsClient, +); -// 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, @@ -21,7 +21,7 @@ export const client = new ApolloClient({ } return null; }, - networkInterface + networkInterface: networkInterfaceWithSubscriptions, }); export default client; 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/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() {