diff --git a/client/blocks/comments/comment-actions.jsx b/client/blocks/comments/comment-actions.jsx index 0fb3c8bb31dd..31dc6fcb5346 100644 --- a/client/blocks/comments/comment-actions.jsx +++ b/client/blocks/comments/comment-actions.jsx @@ -23,7 +23,7 @@ const CommentActions = ( { showModerationTools, translate, activeEditCommentId, - activeReplyCommentID, + activeReplyCommentId, commentId, handleReply, onReplyCancel, @@ -35,7 +35,7 @@ const CommentActions = ( { editCommentCancel, } ) => { const showReplyButton = post && post.discussion && post.discussion.comments_open === true; - const showCancelReplyButton = activeReplyCommentID === commentId; + const showCancelReplyButton = activeReplyCommentId === commentId; const showCancelEditButton = activeEditCommentId === commentId; const isApproved = status === 'approved'; diff --git a/client/blocks/comments/form.jsx b/client/blocks/comments/form.jsx index cbd31f22b183..bcd45fda9d7d 100644 --- a/client/blocks/comments/form.jsx +++ b/client/blocks/comments/form.jsx @@ -42,7 +42,7 @@ class PostCommentForm extends React.Component { componentDidMount() { // If it's a reply, give the input focus if commentText exists ( can not exist if comments are closed ) - if ( this.props.parentCommentID && this._textareaNode ) { + if ( this.props.parentCommentId && this._textareaNode ) { this._textareaNode.focus(); } } @@ -129,8 +129,8 @@ class PostCommentForm extends React.Component { this.props.deleteComment( post.site_ID, post.ID, this.props.placeholderId ); } - if ( this.props.parentCommentID ) { - this.props.replyComment( commentText, post.site_ID, post.ID, this.props.parentCommentID ); + if ( this.props.parentCommentId ) { + this.props.replyComment( commentText, post.site_ID, post.ID, this.props.parentCommentId ); } else { this.props.writeComment( commentText, post.site_ID, post.ID ); } @@ -138,7 +138,7 @@ class PostCommentForm extends React.Component { recordAction( 'posted_comment' ); recordGaEvent( 'Clicked Post Comment Button' ); recordTrackForPost( 'calypso_reader_article_commented_on', post, { - parent_post_id: this.props.parentCommentID ? this.props.parentCommentID : undefined, + parent_post_id: this.props.parentCommentId ? this.props.parentCommentId : undefined, } ); this.resetCommentText(); @@ -256,7 +256,7 @@ class PostCommentForm extends React.Component { PostCommentForm.propTypes = { post: React.PropTypes.object.isRequired, - parentCommentID: React.PropTypes.number, + parentCommentId: React.PropTypes.number, placeholderId: React.PropTypes.string, // can only be 'placeholder-123' commentText: React.PropTypes.string, onUpdateCommentText: React.PropTypes.func.isRequired, diff --git a/client/blocks/comments/post-comment-list.jsx b/client/blocks/comments/post-comment-list.jsx index ff93bcd26212..25b9733b7c5b 100644 --- a/client/blocks/comments/post-comment-list.jsx +++ b/client/blocks/comments/post-comment-list.jsx @@ -69,7 +69,7 @@ class PostCommentList extends React.Component { }; state = { - activeReplyCommentID: null, + activeReplyCommentId: null, amountOfCommentsToTake: this.props.initialSize, commentsFilter: 'all', activeEditCommentId: null, @@ -199,7 +199,7 @@ class PostCommentList extends React.Component { key={ commentId } showModerationTools={ this.props.showModerationTools } activeEditCommentId={ this.state.activeEditCommentId } - activeReplyCommentID={ this.state.activeReplyCommentID } + activeReplyCommentId={ this.state.activeReplyCommentId } onEditCommentClick={ onEditCommentClick } onEditCommentCancel={ this.onEditCommentCancel } onReplyClick={ this.onReplyClick } @@ -221,7 +221,7 @@ class PostCommentList extends React.Component { onEditCommentCancel = () => this.setState( { activeEditCommentId: null } ); onReplyClick = commentID => { - this.setState( { activeReplyCommentID: commentID } ); + this.setState( { activeReplyCommentId: commentID } ); recordAction( 'comment_reply_click' ); recordGaEvent( 'Clicked Reply to Comment' ); recordTrack( 'calypso_reader_comment_reply_click', { @@ -235,7 +235,7 @@ class PostCommentList extends React.Component { recordGaEvent( 'Clicked Cancel Reply to Comment' ); recordTrack( 'calypso_reader_comment_reply_cancel_click', { blog_id: this.props.post.site_ID, - comment_id: this.state.activeReplyCommentID, + comment_id: this.state.activeReplyCommentId, } ); this.resetActiveReplyComment(); }; @@ -245,7 +245,7 @@ class PostCommentList extends React.Component { }; resetActiveReplyComment = () => { - this.setState( { activeReplyCommentID: null } ); + this.setState( { activeReplyCommentId: null } ); }; renderCommentsList = commentIds => { @@ -261,7 +261,7 @@ class PostCommentList extends React.Component { const commentText = this.state.commentText; // Are we displaying the comment form at the top-level? - if ( this.state.activeReplyCommentID && ! this.state.errors ) { + if ( this.state.activeReplyCommentId && ! this.state.errors ) { return null; } diff --git a/client/blocks/comments/post-comment.jsx b/client/blocks/comments/post-comment.jsx index 489e09779cf8..29ed7a3a41cf 100644 --- a/client/blocks/comments/post-comment.jsx +++ b/client/blocks/comments/post-comment.jsx @@ -22,18 +22,13 @@ import { getStreamUrl } from 'reader/route'; import PostCommentContent from './post-comment-content'; import PostCommentForm from './form'; import CommentEditForm from './comment-edit-form'; -import { PLACEHOLDER_STATE } from 'state/comments/constants'; +import { PLACEHOLDER_STATE, POST_COMMENT_DISPLAY_TYPES } from 'state/comments/constants'; import { decodeEntities } from 'lib/formatting'; import PostCommentWithError from './post-comment-with-error'; import PostTrackback from './post-trackback.jsx'; import CommentActions from './comment-actions'; - -// values conveniently also correspond to css classNames to apply -export const POST_COMMENT_DISPLAY_TYPES = { - singleLine: 'is-single-line', - excerpt: 'is-excerpt', - full: 'is-full', -}; +import ConversationCaterpillar from 'blocks/conversation-caterpillar'; +import { viewComment } from 'state/comments/actions'; class PostComment extends Component { static propTypes = { @@ -63,7 +58,7 @@ class PostComment extends Component { maxChildrenToShow: 5, onCommentSubmit: noop, showNestingReplyArrow: false, - displayType: POST_COMMENT_DISPLAY_TYPES.full, + displayType: POST_COMMENT_DISPLAY_TYPES.excerpt, }; state = { @@ -71,6 +66,29 @@ class PostComment extends Component { showFull: false, }; + reportCommentView = ( props = this.props ) => { + const { commentId, post, toShow } = props; + const { site_ID: siteId, ID: postId } = post; + const comment = get( this.props.commentsTree, [ commentId, 'data' ], {} ); + + if ( comment && toShow && toShow[ commentId ] ) { + props.viewComment( { commentId, siteId, postId, date: comment.date } ); + } + }; + + componentDidMount() { + this.reportCommentView(); + } + componentWillUpdate( nextProps ) { + if ( + !! this.props.commentsTree[ this.props.commentId ] && + ( this.props.commentId !== nextProps.commentId || + ! this.props.commentsTree[ this.props.commentId ] ) + ) { + this.reportCommentView( nextProps ); + } + } + handleReadMoreClicked = () => this.setState( { showFull: true } ); handleToggleRepliesClick = () => { @@ -94,11 +112,13 @@ class PostComment extends Component { }; renderRepliesList() { + const { toShow, depth, commentId } = this.props; const commentChildrenIds = get( this.props.commentsTree, [ this.props.commentId, 'children' ] ); // Hide children if more than maxChildrenToShow, but not if replying const exceedsMaxChildrenToShow = commentChildrenIds && commentChildrenIds.length < this.props.maxChildrenToShow; const showReplies = this.state.showReplies || exceedsMaxChildrenToShow; + const childDepth = ! toShow || ( toShow && toShow[ commentId ] ) ? depth + 1 : depth; // No children to show if ( ! commentChildrenIds || commentChildrenIds.length < 1 ) { @@ -130,7 +150,7 @@ class PostComment extends Component { return (
- { !! replyVisibilityText + { !! replyVisibilityText && ! this.props.showCaterpillar ? : null } - { showReplies + { showReplies || this.props.showCaterpillar ?
    { commentChildrenIds.map( childId => @@ -155,7 +175,7 @@ class PostComment extends Component { } renderCommentForm() { - if ( this.props.activeReplyCommentID !== this.props.commentId ) { + if ( this.props.activeReplyCommentId !== this.props.commentId ) { return null; } @@ -163,7 +183,7 @@ class PostComment extends Component { ; }; + renderCaterpillar = () => { + const { showCaterpillar, commentsTree, toShow, post, commentId } = this.props; + const childrenIds = get( commentsTree, [ this.props.commentId, 'children' ] ); + + const actuallyShowCaterpillar = showCaterpillar && some( childrenIds, id => ! toShow[ id ] ); + + if ( ! actuallyShowCaterpillar ) { + return null; + } + return ( + + ); + }; + render() { - const { commentsTree, commentId, depth, maxDepth } = this.props; + const { commentsTree, commentId, depth, maxDepth, toShow } = this.props; const comment = get( commentsTree, [ commentId, 'data' ] ); - const displayType = this.state.showFull - ? POST_COMMENT_DISPLAY_TYPES.full - : this.props.displayType; + const isPingbackOrTrackback = comment.type === 'trackback' || comment.type === 'pingback'; + + if ( this.props.hidePingbacksAndTrackbacks && isPingbackOrTrackback ) { + return null; + } + + if ( ! comment ) { + return null; + } + if ( toShow && ! toShow[ commentId ] ) { + return ( +
    + { this.renderRepliesList() } +
    + ); + } + + const displayType = + this.state.showFull || ! this.props.showCaterpillar + ? POST_COMMENT_DISPLAY_TYPES.full + : this.props.displayType; // todo: connect this constants to the state (new selector) const haveReplyWithError = some( @@ -224,7 +280,7 @@ class PostComment extends Component { } // Trackback / Pingback - if ( comment.type === 'trackback' || comment.type === 'pingback' ) { + if ( isPingbackOrTrackback ) { return ; } @@ -303,7 +359,7 @@ class PostComment extends Component { comment={ comment } showModerationTools={ this.props.showModerationTools } activeEditCommentId={ this.props.activeEditCommentId } - activeReplyCommentID={ this.props.activeReplyCommentID } + activeReplyCommentId={ this.props.activeReplyCommentId } commentId={ this.props.commentId } editComment={ this.props.onEditCommentClick } editCommentCancel={ this.props.onEditCommentCancel } @@ -312,12 +368,16 @@ class PostComment extends Component { /> { haveReplyWithError ? null : this.renderCommentForm() } + { this.renderCaterpillar() } { this.renderRepliesList() } ); } } -export default connect( state => ( { - currentUser: getCurrentUser( state ), -} ) )( PostComment ); +export default connect( + state => ( { + currentUser: getCurrentUser( state ), + } ), + { viewComment } +)( PostComment ); diff --git a/client/blocks/conversation-caterpillar/index.jsx b/client/blocks/conversation-caterpillar/index.jsx index e597e05a962f..04ede9898b6b 100644 --- a/client/blocks/conversation-caterpillar/index.jsx +++ b/client/blocks/conversation-caterpillar/index.jsx @@ -4,15 +4,18 @@ */ import React, { PropTypes } from 'react'; import { connect } from 'react-redux'; -import { map, get, last, uniqBy, size, filter, takeRight } from 'lodash'; +import { map, get, last, uniqBy, size, filter, takeRight, compact } from 'lodash'; import { localize } from 'i18n-calypso'; /*** * Internal dependencies */ import Gravatar from 'components/gravatar'; -import { getDateSortedPostComments } from 'state/comments/selectors'; +import { getDateSortedPostComments, getHiddenCommentsForPost } from 'state/comments/selectors'; import Card from 'components/card'; +import { expandComments } from 'state/comments/actions'; +// import { NUMBER_OF_COMMENTS_PER_FETCH } from 'state/comments/actions'; +import { POST_COMMENT_DISPLAY_TYPES } from 'state/comments/constants'; const MAX_GRAVATARS_TO_DISPLAY = 10; @@ -20,17 +23,53 @@ class ConversationCaterpillarComponent extends React.Component { static propTypes = { blogId: PropTypes.number.isRequired, postId: PropTypes.number.isRequired, + parentCommentId: PropTypes.number, + isRoot: PropTypes.bool, + + // connected props comments: PropTypes.array.isRequired, }; + static defaultProps = { + isRoot: false, + }; + + handleTickle = () => { + const { comments, hiddenComments, blogId, postId, parentCommentId } = this.props; + const filteredComments = parentCommentId + ? filter( comments, c => c.parent && c.parent.ID === parentCommentId ) + : comments; + + const commentsToExpand = takeRight( + filter( filteredComments, comment => hiddenComments[ comment.ID ] ), + 10 + ); + this.props.expandComments( { + siteId: blogId, + postId, + commentIds: map( commentsToExpand, 'ID' ), + displayType: POST_COMMENT_DISPLAY_TYPES.excerpt, + } ); + this.props.expandComments( { + siteId: blogId, + postId, + commentIds: compact( map( commentsToExpand, c => get( c, 'parent.ID', null ) ) ), + displayType: POST_COMMENT_DISPLAY_TYPES.singleLine, + } ); + }; + render() { - const { comments, translate } = this.props; - const commentCount = size( comments ); + const { comments, translate, hiddenComments, parentCommentId } = this.props; + const filteredComments = parentCommentId + ? filter( comments, c => c.parent && c.parent.ID === parentCommentId ) + : comments; + const expandableComments = filter( filteredComments, comment => hiddenComments[ comment.ID ] ); + const commentCount = size( expandableComments ); // Only display authors with a gravatar, and only display each author once - const uniqueAuthors = uniqBy( map( comments, 'author' ), 'ID' ); + const uniqueAuthors = uniqBy( map( expandableComments, 'author' ), 'ID' ); const displayedAuthors = takeRight( - filter( uniqueAuthors, 'has_avatar' ), + filter( uniqueAuthors, 'avatar_URL' ), MAX_GRAVATARS_TO_DISPLAY ); const displayedAuthorsCount = size( displayedAuthors ); @@ -39,7 +78,7 @@ class ConversationCaterpillarComponent extends React.Component { // At the moment, we just show authors for the entire comments array return ( - +
    { map( displayedAuthors, ( author, index ) => { let gravClasses = 'conversation-caterpillar__gravatar'; @@ -100,10 +139,15 @@ class ConversationCaterpillarComponent extends React.Component { export const ConversationCaterpillar = localize( ConversationCaterpillarComponent ); -const ConnectedConversationCaterpillar = connect( ( state, ownProps ) => { - return { - comments: getDateSortedPostComments( state, ownProps.blogId, ownProps.postId ), - }; -} )( ConversationCaterpillar ); +const ConnectedConversationCaterpillar = connect( + ( state, ownProps ) => { + const { blogId, postId } = ownProps; + return { + comments: getDateSortedPostComments( state, blogId, postId ), + hiddenComments: getHiddenCommentsForPost( state, blogId, postId ), + }; + }, + { expandComments } +)( ConversationCaterpillar ); export default ConnectedConversationCaterpillar; diff --git a/client/blocks/conversation-caterpillar/style.scss b/client/blocks/conversation-caterpillar/style.scss index b54e66aac277..db5e8a30e64a 100644 --- a/client/blocks/conversation-caterpillar/style.scss +++ b/client/blocks/conversation-caterpillar/style.scss @@ -1,6 +1,9 @@ .conversation-caterpillar.card { cursor: pointer; display: flex; + padding: 0px; + padding-top: 10px; + box-shadow: none; } .conversation-caterpillar__gravatars, diff --git a/client/blocks/conversations/docs/example.jsx b/client/blocks/conversations/docs/example.jsx index e05bde45933b..f99eef186a9f 100644 --- a/client/blocks/conversations/docs/example.jsx +++ b/client/blocks/conversations/docs/example.jsx @@ -1,3 +1,4 @@ +/** @format */ /** * External dependencies */ @@ -10,6 +11,9 @@ import { ConversationCommentList } from 'blocks/conversations/list'; import { posts } from 'blocks/reader-post-card/docs/fixtures'; import { commentsTree } from 'blocks/conversations/docs/fixtures'; +const blogId = 3584907; +const postId = 39375; + const ConversationCommentListExample = () => { const post = posts[ 0 ]; @@ -17,11 +21,11 @@ const ConversationCommentListExample = () => {
    ); diff --git a/client/blocks/conversations/list.jsx b/client/blocks/conversations/list.jsx index 71fcf724e4fd..089c0bbbdae2 100644 --- a/client/blocks/conversations/list.jsx +++ b/client/blocks/conversations/list.jsx @@ -10,54 +10,154 @@ import { map } from 'lodash'; * Internal dependencies */ import PostComment from 'blocks/comments/post-comment'; -import { getPostCommentsTree } from 'state/comments/selectors'; +import { + getPostCommentsTree, + commentsFetchingStatus, + getAllCommentSinceLatestViewed, + getRootNeedsCaterpillar, + getExpansionsForPost, + getHiddenCommentsForPost, +} from 'state/comments/selectors'; +import { requestPostComments } from 'state/comments/actions'; import ConversationCaterpillar from 'blocks/conversation-caterpillar'; - -export class ConversationCommentList extends React.Component { +import { recordAction, recordGaEvent, recordTrack } from 'reader/stats'; +import PostCommentForm from 'blocks/comments/form'; +export class ConversationCommentList extends React.PureComponent { static propTypes = { post: PropTypes.object.isRequired, // required by PostComment - commentIds: PropTypes.array.isRequired, }; static defaultProps = { - showCaterpillar: false, + showCaterpillar: true, }; - render() { - const { commentIds, commentsTree, post, showCaterpillar } = this.props; - if ( ! commentIds ) { - return null; + state = { + activeReplyCommentId: null, + activeEditCommentId: null, + }; + + reqMoreComments = ( props = this.props ) => { + if ( ! this.props.showCaterpillar ) { + return; } + const { blogId, postId } = props; + const { haveEarlierCommentsToFetch, haveLaterCommentsToFetch } = props.commentsFetchingStatus; + + if ( haveEarlierCommentsToFetch || haveLaterCommentsToFetch ) { + props.requestPostComments( { siteId: blogId, postId } ); + } + }; + componentWillMount() { + this.reqMoreComments(); + } + + // componentWillReceiveProps( nextProps ) { + // this.reqMoreComments( nextProps ); + // } + + resetActiveReplyComment = () => this.setState( { activeReplyCommentId: null } ); + onEditCommentClick = commentId => this.setState( { activeEditCommentId: commentId } ); + onEditCommentCancel = () => this.setState( { activeEditCommentId: null } ); + onUpdateCommentText = commentText => this.setState( { commentText: commentText } ); + + onReplyClick = commentId => { + this.setState( { activeReplyCommentId: commentId } ); + recordAction( 'comment_reply_click' ); + recordGaEvent( 'Clicked Reply to Comment' ); + recordTrack( 'calypso_reader_comment_reply_click', { + blog_id: this.props.post.site_ID, + comment_id: commentId, + } ); + }; + + onReplyCancel = () => { + recordAction( 'comment_reply_cancel_click' ); + recordGaEvent( 'Clicked Cancel Reply to Comment' ); + recordTrack( 'calypso_reader_comment_reply_cancel_click', { + blog_id: this.props.post.site_ID, + comment_id: this.state.activeReplyCommentId, + } ); + this.resetActiveReplyComment(); + }; + + render() { + const { + postId, + blogId, + newComments, + commentsTree, + post, + showCaterpillar, + needsCaterpillar, + expansions, + } = this.props; + + const toShow = { ...newComments, ...expansions }; + return (
    + { showCaterpillar && + needsCaterpillar && + }
      - { map( commentIds, commentId => { + { map( commentsTree.children, commentId => { return ( ); } ) } + { ! this.state.activeReplyCommentId && + }
    - { showCaterpillar && - }
    ); } } -const ConnectedConversationCommentList = connect( ( state, ownProps ) => { - const { site_ID: siteId, ID: postId } = ownProps.post; +const ConnectedConversationCommentList = connect( + ( state, ownProps ) => { + const { post } = ownProps; + const { discussion, site_ID: blogId, ID: postId } = post; - return { - commentsTree: getPostCommentsTree( state, siteId, postId, 'all' ), - }; -} )( ConversationCommentList ); + return { + commentsTree: getPostCommentsTree( state, blogId, postId, 'all' ), + commentsFetchingStatus: + commentsFetchingStatus( state, blogId, postId, discussion.comment_count ) || {}, + blogId, + postId, + newComments: getAllCommentSinceLatestViewed( state, blogId, postId ), + needsCaterpillar: getRootNeedsCaterpillar( state, blogId, postId ), + expansions: getExpansionsForPost( state, blogId, postId ), + hiddenComments: getHiddenCommentsForPost( state, blogId, postId ), + }; + }, + { requestPostComments } +)( ConversationCommentList ); export default ConnectedConversationCommentList; diff --git a/client/blocks/reader-post-card/compact.jsx b/client/blocks/reader-post-card/compact.jsx index 64c6bfdb50f4..7614def70f4a 100644 --- a/client/blocks/reader-post-card/compact.jsx +++ b/client/blocks/reader-post-card/compact.jsx @@ -13,10 +13,10 @@ import ReaderExcerpt from 'blocks/reader-excerpt'; import ReaderPostOptionsMenu from 'blocks/reader-post-options-menu'; import FeaturedAsset from './featured-asset'; -const CompactPost = ( { post, postByline, children, isDiscover } ) => { +const CompactPost = ( { post, postByline, children, isDiscover, onClick } ) => { /* eslint-disable wpcalypso/jsx-classname-namespace */ return ( -
    +
    ); } else if ( isPhotoPost ) { @@ -251,7 +252,7 @@ class ReaderPostCard extends React.Component { const followUrl = feed ? feed.feed_URL : post.site_URL; return ( - + { ! compact && postByline } { showPrimaryFollowButton && followUrl && diff --git a/client/lib/feed-stream-store/actions.js b/client/lib/feed-stream-store/actions.js index 62ba78f2ce87..368ceaf53f04 100644 --- a/client/lib/feed-stream-store/actions.js +++ b/client/lib/feed-stream-store/actions.js @@ -95,12 +95,12 @@ export function receivePage( id, error, data ) { } if ( post.comments ) { // conversations! - reduxDispatch( { - type: COMMENTS_RECEIVE, - siteId: post.site_ID, - postId: post.ID, - comments: post.comments, - } ); + // reduxDispatch( { + // type: COMMENTS_RECEIVE, + // siteId: post.site_ID, + // postId: post.ID, + // comments: post.comments, + // } ); } } ); } diff --git a/client/state/action-types.js b/client/state/action-types.js index a46e63d7332b..0373ace6e597 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -542,6 +542,8 @@ export const PUSH_NOTIFICATIONS_TOGGLE_ENABLED = 'PUSH_NOTIFICATIONS_TOGGLE_ENAB export const PUSH_NOTIFICATIONS_TOGGLE_UNBLOCK_INSTRUCTIONS = 'PUSH_NOTIFICATIONS_TOGGLE_UNBLOCK_INSTRUCTIONS'; export const PUSH_NOTIFICATIONS_UNREGISTER_DEVICE = 'PUSH_NOTIFICATIONS_UNREGISTER_DEVICE'; export const READER_EXPAND_CARD = 'READER_EXPAND_CARD'; +export const READER_EXPAND_CATERPILLAR = 'READER_EXPAND_CATERPILLAR'; +export const READER_EXPAND_COMMENTS = 'READER_EXPAND_COMMENTS'; export const READER_FEED_REQUEST = 'READER_FEED_REQUEST'; export const READER_FEED_REQUEST_FAILURE = 'READER_FEED_REQUEST_FAILURE'; export const READER_FEED_REQUEST_SUCCESS = 'READER_FEED_REQUEST_SUCCESS'; @@ -623,6 +625,7 @@ export const READER_UNFOLLOW_TAG_REQUEST = 'READER_UNFOLLOW_TAG_REQUEST'; export const READER_UNSUBSCRIBE_TO_NEW_COMMENT_EMAIL = 'READER_UNSUBSCRIBE_TO_NEW_COMMENT_EMAIL'; export const READER_UNSUBSCRIBE_TO_NEW_POST_EMAIL = 'READER_UNSUBSCRIBE_TO_NEW_POST_EMAIL'; export const READER_UPDATE_NEW_POST_EMAIL_SUBSCRIPTION = 'READER_UPDATE_NEW_POST_EMAIL_SUBSCRIPTION'; +export const READER_VIEW_COMMENT = 'READER_VIEW_COMMENT'; export const RECEIPT_FETCH = 'RECEIPT_FETCH'; export const RECEIPT_FETCH_COMPLETED = 'RECEIPT_FETCH_COMPLETED'; export const RECEIPT_FETCH_FAILED = 'RECEIPT_FETCH_FAILED'; diff --git a/client/state/comments/actions.js b/client/state/comments/actions.js index 6fb89c7cd769..e3b770885042 100644 --- a/client/state/comments/actions.js +++ b/client/state/comments/actions.js @@ -1,3 +1,4 @@ +/** @format */ /** * Internal dependencies */ @@ -17,13 +18,16 @@ import { COMMENTS_WRITE, COMMENT_REQUEST, COMMENTS_TREE_SITE_REQUEST, + READER_VIEW_COMMENT, + READER_EXPAND_COMMENTS, + READER_EXPAND_CATERPILLAR, } from '../action-types'; import { NUMBER_OF_COMMENTS_PER_FETCH } from './constants'; export const requestComment = ( { siteId, commentId } ) => ( { type: COMMENT_REQUEST, siteId, - commentId + commentId, } ); /*** @@ -37,7 +41,7 @@ export function requestPostComments( { siteId, postId, status = 'approved', - direction = 'before' + direction = 'before', } ) { if ( ! isEnabled( 'comments/filters-in-posts' ) ) { status = 'approved'; @@ -94,7 +98,12 @@ export const requestCommentsTreeForSite = query => ( { * @param {Boolean} options.showSuccessNotice Announce the delete success with a notice (default: true) * @returns {Object} action that deletes a comment */ -export const deleteComment = ( siteId, postId, commentId, options = { showSuccessNotice: true } ) => ( { +export const deleteComment = ( + siteId, + postId, + commentId, + options = { showSuccessNotice: true } +) => ( { type: COMMENTS_DELETE, siteId, postId, @@ -196,7 +205,7 @@ export function editComment( siteId, postId, commentId, content ) { postId, commentId, content: data.content, - } ), + } ) ) .catch( () => dispatch( { @@ -204,7 +213,22 @@ export function editComment( siteId, postId, commentId, content ) { siteId, postId, commentId, - } ), + } ) ); }; } + +export const viewComment = ( { siteId, postId, commentId, date } ) => ( { + type: READER_VIEW_COMMENT, + payload: { siteId, postId, commentId, date }, +} ); + +export const expandComments = ( { siteId, commentIds, postId, displayType } ) => ( { + type: READER_EXPAND_COMMENTS, + payload: { siteId, commentIds, postId, displayType }, +} ); + +export const expandCaterpillar = ( { siteId, postId, commentId } ) => ( { + type: READER_EXPAND_CATERPILLAR, + payload: { siteId, commentId, postId }, +} ); diff --git a/client/state/comments/constants.js b/client/state/comments/constants.js index ad5d1694a78c..80c5fbfa8587 100644 --- a/client/state/comments/constants.js +++ b/client/state/comments/constants.js @@ -5,6 +5,13 @@ export const PLACEHOLDER_STATE = { ERROR: 'ERROR', }; +// values conveniently also correspond to css classNames to apply +export const POST_COMMENT_DISPLAY_TYPES = { + singleLine: 'is-single-line', + excerpt: 'is-excerpt', + full: 'is-full', +}; + export const APPROVED_STATUS = 'approved'; export const DISAPPROVED_STATUS = 'unapproved'; export const SPAM_STATUS = 'spam'; diff --git a/client/state/comments/reducer.js b/client/state/comments/reducer.js index d0c0f8ef2b05..16209f3f7407 100644 --- a/client/state/comments/reducer.js +++ b/client/state/comments/reducer.js @@ -1,7 +1,8 @@ +/** @format */ /** * External dependencies */ -import { isUndefined, orderBy, has, map, unionBy, reject, isEqual, get } from 'lodash'; +import { isUndefined, orderBy, has, map, unionBy, reject, isEqual, get, reduce } from 'lodash'; /** * Internal dependencies @@ -17,6 +18,8 @@ import { COMMENTS_LIKE, COMMENTS_UNLIKE, COMMENTS_TREE_SITE_ADD, + READER_EXPAND_COMMENTS, + READER_VIEW_COMMENT, } from '../action-types'; import { combineReducers, createReducer, keyedReducer } from 'state/utils'; import { PLACEHOLDER_STATE, NUMBER_OF_COMMENTS_PER_FETCH } from './constants'; @@ -24,7 +27,7 @@ import trees from './trees/reducer'; const getCommentDate = ( { date } ) => new Date( date ); -export const getStateKey = ( siteId, postId ) => `${ siteId }-${ postId }`; +export const getStateKey = ( siteId, id ) => `${ siteId }-${ id }`; export const deconstructStateKey = key => { const [ siteId, postId ] = key.split( '-' ); @@ -92,7 +95,7 @@ export function items( state = {}, action ) { ...state, [ stateKey ]: map( state[ stateKey ], - updateComment( commentId, { i_like: true, like_count } ), + updateComment( commentId, { i_like: true, like_count } ) ), }; case COMMENTS_UNLIKE: @@ -100,7 +103,7 @@ export function items( state = {}, action ) { ...state, [ stateKey ]: map( state[ stateKey ], - updateComment( commentId, { i_like: false, like_count } ), + updateComment( commentId, { i_like: false, like_count } ) ), }; case COMMENTS_ERROR: @@ -112,7 +115,7 @@ export function items( state = {}, action ) { updateComment( commentId, { placeholderState: PLACEHOLDER_STATE.ERROR, placeholderError: error, - } ), + } ) ), }; } @@ -120,6 +123,8 @@ export function items( state = {}, action ) { return state; } +items.hasCustomPersistence = true; + export const fetchStatusInitialState = { before: true, after: true, @@ -169,7 +174,7 @@ export const fetchStatus = createReducer( ? state : { ...state, [ stateKey ]: nextState }; }, - }, + } ); /*** @@ -189,7 +194,74 @@ export const totalCommentsCount = createReducer( const key = getStateKey( action.siteId, action.postId ); return { ...state, [ key ]: state[ key ] + 1 }; }, + } +); + +export const commentWatermarkSchema = { + type: 'object', + patternProperties: { + //be careful to escape regexes properly + 'd+-d+': { type: 'object' }, }, +}; + +/** + * For each post on each site, contains the last scene comment in convo tool + */ +export const watermark = createReducer( + {}, + { + [ READER_VIEW_COMMENT ]: ( state, action ) => { + const { siteId, postId, date, commentId } = action.payload; + const stateKey = getStateKey( siteId, postId ); + + if ( state[ stateKey ] && new Date( state[ stateKey ].date ) > new Date( date ) ) { + return state; + } + + return { + ...state, + [ stateKey ]: { date, commentId }, + }; + }, + }, + commentWatermarkSchema +); + +export const expansions = createReducer( + {}, + { + [ READER_EXPAND_COMMENTS ]: ( state, action ) => { + const { siteId, postId, commentIds, displayType } = action.payload; + const stateKey = getStateKey( siteId, postId ); + const newVal = reduce( + commentIds, + ( accum, id ) => { + accum[ id ] = displayType; + return accum; + }, + {} + ); + + return { + ...state, + [ stateKey ]: Object.assign( {}, state[ stateKey ], newVal ), + }; + }, + [ READER_VIEW_COMMENT ]: ( state, action ) => { + const { siteId, postId, commentId } = action.payload; + const stateKey = getStateKey( siteId, postId ); + + if ( has( state, `${ stateKey }.${ commentId }` ) ) { + return state; + } + + return { + ...state, + [ stateKey ]: Object.assign( {}, state[ stateKey ], { [ commentId ]: 'is-excerpt' } ), + }; + }, + } ); /** @@ -210,7 +282,7 @@ export const errors = createReducer( [ key ]: { error: true }, }; }, - }, + } ); export const treesInitializedReducer = ( state = {}, action ) => { @@ -220,13 +292,18 @@ export const treesInitializedReducer = ( state = {}, action ) => { return state; }; -export const treesInitialized = keyedReducer( 'siteId', keyedReducer( 'status', treesInitializedReducer ) ); +export const treesInitialized = keyedReducer( + 'siteId', + keyedReducer( 'status', treesInitializedReducer ) +); export default combineReducers( { + expansions, items, fetchStatus, errors, totalCommentsCount, trees, treesInitialized, + watermark, } ); diff --git a/client/state/comments/selectors.js b/client/state/comments/selectors.js index 124fb06bb83f..29197b050d2b 100644 --- a/client/state/comments/selectors.js +++ b/client/state/comments/selectors.js @@ -1,7 +1,8 @@ +/** @format */ /*** * External dependencies */ -import { filter, find, get, keyBy, last, first, map, size, flatMap, sortBy } from 'lodash'; +import { filter, find, get, keyBy, last, first, map, size, flatMap, sortBy, pickBy } from 'lodash'; /** * Internal dependencies @@ -47,6 +48,59 @@ export const getCommentById = createSelector( get( state.comments, 'errors' ), ] ); + +/** + * Plan of attack: + * + * assumptions: + * - all comments for a post are in state. + * + * reducers: + * - expandedComments ( full, excerpt, singleline, hidden, null) + * - watermark: latest seen commentId + * + * selectors: + * - getCommentsSinceComment + * - getCommentNeedsCaterpillar( commentid ) --> bool. return true if any child is invisible + * - getNextBatchOfAuthors + * + * how to connect it all together: + * 1. when displaying convo stream, dispatch to put all comments since last viewed comment 'excerpt' + * 1a. this also has the effect of putting all immediate parents to singleLine and all further parents to invisible + * 2. clicking ReadMore for any single comment will move it to 'full' + * 3. caterpillars are shown when any child has unexpanded children? + * + */ + +export const getLatestCommentViewed = ( state, siteId, postId ) => { + const key = getStateKey( siteId, postId ); + return get( state.comments.watermark[ key ], 'commentId' ); +}; + +export const getAllCommentsSinceComment = createSelector( + ( state, siteId, postId, commentId ) => { + const comment = getCommentById( { state, commentId, siteId } ); + const allSortedComments = getDateSortedPostComments( state, siteId, postId ); + + if ( ! comment ) { + return keyBy( allSortedComments, 'ID' ); + } + + const onlyNewComments = filter( + allSortedComments, + c => new Date( c.date ) > new Date( comment.date ) + ); + + return keyBy( onlyNewComments, 'ID' ); + }, + ( state, siteId, postId ) => [ get( state.comments, 'items' )[ getStateKey( siteId, postId ) ] ] +); + +export const getAllCommentSinceLatestViewed = ( state, siteId, postId ) => { + const latestViewedCommentId = getLatestCommentViewed( state, siteId, postId ); + return getAllCommentsSinceComment( state, siteId, postId, latestViewedCommentId ); +}; + /*** * Get total number of comments on the server for a given post * @param {Object} state redux state @@ -68,6 +122,22 @@ export const getPostMostRecentCommentDate = createSelector( ( state, siteId, pos return items && first( items ) ? new Date( get( first( items ), 'date' ) ) : undefined; }, getPostCommentItems ); +export const getExpansionsForPost = ( state, siteId, postId ) => + state.comments.expansions[ getStateKey( siteId, postId ) ]; + +export const getHiddenCommentsForPost = createSelector( + ( state, siteId, postId ) => { + const comments = keyBy( getPostCommentItems( state, siteId, postId ), 'ID' ); + const expanded = getExpansionsForPost( state, siteId, postId ); + return pickBy( comments, comment => ! get( expanded, comment.ID ) ); + }, + state => [ state.comments.items, state.comments.expansions ] +); + +export const getRootNeedsCaterpillar = ( state, siteId, postId ) => + size( getExpansionsForPost( state, siteId, postId ) ) !== + size( getPostCommentItems( state, siteId, postId ) ); + /*** * Get oldest comment date for a given post * @param {Object} state redux state @@ -78,7 +148,7 @@ export const getPostMostRecentCommentDate = createSelector( ( state, siteId, pos export const getPostOldestCommentDate = createSelector( ( state, siteId, postId ) => { const items = getPostCommentItems( state, siteId, postId ); return items && last( items ) ? new Date( get( last( items ), 'date' ) ) : undefined; -}, getPostCommentItems ); +}, state => [ state.comments.items ] ); /*** * Get newest comment date for a given post @@ -119,7 +189,7 @@ export const getPostCommentsTree = createSelector( children: map( filter( items, { parent: false } ), 'ID' ).reverse(), }; }, - getPostCommentItems + state => [ state.comments.items ] ); export const commentsFetchingStatus = ( state, siteId, postId, commentTotal = 0 ) => {