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 48392cc51..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 (