diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index a3ff2ca3e..18e31091f 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -34,3 +34,8 @@ export const storySearchChange = (value) => ({ export const clearState = () => ({ type: actions.MODERATION_CLEAR_STATE }); + +export const selectCommentId = (id) => ({ + type: actions.MODERATION_SELECT_COMMENT, + id, +}); diff --git a/client/coral-admin/src/components/CommentDetails.js b/client/coral-admin/src/components/CommentDetails.js index cd504a40f..418be0354 100644 --- a/client/coral-admin/src/components/CommentDetails.js +++ b/client/coral-admin/src/components/CommentDetails.js @@ -21,10 +21,11 @@ class CommentDetails extends Component { this.setState((state) => ({ showDetail: !state.showDetail })); + this.props.clearHeightCache && this.props.clearHeightCache(); } render() { - const {data, root, comment} = this.props; + const {data, root, comment, clearHeightCache} = this.props; const {showDetail} = this.state; const queryData = { root, @@ -44,12 +45,14 @@ class CommentDetails extends Component { {showDetail && } @@ -61,6 +64,7 @@ CommentDetails.propTypes = { data: PropTypes.object.isRequired, root: PropTypes.object.isRequired, comment: PropTypes.object.isRequired, + clearHeightCache: PropTypes.func, }; export default CommentDetails; diff --git a/client/coral-admin/src/constants/moderation.js b/client/coral-admin/src/constants/moderation.js index cae13947d..8eb939d76 100644 --- a/client/coral-admin/src/constants/moderation.js +++ b/client/coral-admin/src/constants/moderation.js @@ -6,3 +6,4 @@ export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH'; export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH'; export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE'; export const MODERATION_CLEAR_STATE = 'MODERATION_CLEAR_STATE'; +export const MODERATION_SELECT_COMMENT = 'MODERATION_SELECT_COMMENT'; diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index 43ae81573..6a64b380a 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -7,6 +7,7 @@ const initialState = { storySearchString: '', shortcutsNoteVisible: 'show', sortOrder: 'DESC', + selectedCommentId: '', }; export default function moderation (state = initialState, action) { @@ -51,6 +52,11 @@ export default function moderation (state = initialState, action) { ...state, sortOrder: action.order, }; + case actions.MODERATION_SELECT_COMMENT: + return { + ...state, + selectedCommentId: action.id, + }; default: return state; } diff --git a/client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js b/client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js new file mode 100644 index 000000000..4ba7c3298 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js @@ -0,0 +1,25 @@ +import React from 'react'; +import {Spinner} from 'coral-ui'; +import PropTypes from 'prop-types'; + +/** + * AutoLoadMore with call `loadMore` the moment it is rendered and shows a Spinner. + */ +class AutoLoadMore extends React.Component { + componentDidMount() { + if(!this.props.loading) { + this.props.loadMore(); + } + } + + render() { + return ; + } +} + +AutoLoadMore.propTypes = { + loading: PropTypes.bool.isRequired, + loadMore: PropTypes.func.isRequired, +}; + +export default AutoLoadMore; diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.css b/client/coral-admin/src/routes/Moderation/components/Comment.css index 10cc707fb..119688b73 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.css +++ b/client/coral-admin/src/routes/Moderation/components/Comment.css @@ -10,7 +10,9 @@ position: relative; transition: all 200ms; padding: 10px 0; + margin-top: 13px; min-height: 0; + outline: 0; /* Fix rendering issues in Safari by promoting this @@ -25,6 +27,10 @@ } } +.dangling { + background-color: #efefef; +} + .container { padding: 0 14px; } diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index cea49720b..293071cec 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -20,6 +20,16 @@ import t, {timeago} from 'coral-framework/services/i18n'; class Comment extends React.Component { + ref = null; + + handleRef = (ref) => this.ref = ref; + + handleFocusOrClick = () => { + if (!this.props.selected) { + this.props.selectComment(); + } + }; + showSuspendUserDialog = () => { const {comment, showSuspendUserDialog} = this.props; return showSuspendUserDialog({ @@ -55,6 +65,12 @@ class Comment extends React.Component { : this.props.rejectComment({commentId: this.props.comment.id}) ); + componentDidUpdate(prev) { + if (!prev.selected && this.props.selected) { + this.ref.focus(); + } + } + render() { const { comment, @@ -65,6 +81,8 @@ class Comment extends React.Component { root: {settings}, currentUserId, currentAsset, + clearHeightCache, + dangling, } = this.props; const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'; @@ -73,8 +91,11 @@ class Comment extends React.Component { return (
  • @@ -114,6 +135,7 @@ class Comment extends React.Component {
    @@ -145,6 +167,7 @@ class Comment extends React.Component {
    @@ -166,6 +189,7 @@ class Comment extends React.Component {
    @@ -176,6 +200,7 @@ class Comment extends React.Component { data={data} root={root} comment={comment} + clearHeightCache={clearHeightCache} />
  • ); @@ -185,12 +210,16 @@ class Comment extends React.Component { Comment.propTypes = { viewUserDetail: PropTypes.func.isRequired, acceptComment: PropTypes.func.isRequired, + selectComment: PropTypes.func, rejectComment: PropTypes.func.isRequired, + onClick: PropTypes.func, className: PropTypes.string, + dangling: PropTypes.bool, currentAsset: PropTypes.object, showBanUserDialog: PropTypes.func.isRequired, showSuspendUserDialog: PropTypes.func.isRequired, currentUserId: PropTypes.string.isRequired, + clearHeightCache: PropTypes.func, comment: PropTypes.shape({ id: PropTypes.string.isRequired, status: PropTypes.string.isRequired, diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index 392db36e3..da0745acc 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -12,15 +12,10 @@ import Slot from 'coral-framework/components/Slot'; import ViewOptions from './ViewOptions'; class Moderation extends Component { - constructor(props) { - super(props); - const comments = this.getComments(props); - this.state = { - selectedCommentId: comments[0] ? comments[0].id : null, - }; - - } + state = { + isLoadingMore: false, + }; componentWillMount() { const {toggleModal, singleView} = this.props; @@ -30,8 +25,6 @@ class Moderation extends Component { key('esc', () => toggleModal(false)); key('ctrl+f', () => this.openSearch()); key('t', () => this.nextQueue()); - key('j', () => this.select(true)); - key('k', () => this.select(false)); key('f', () => this.moderate(false)); key('d', () => this.moderate(true)); this.getMenuItems() @@ -74,8 +67,7 @@ class Moderation extends Component { } moderate = (accept) => { - const {acceptComment, rejectComment} = this.props; - const {selectedCommentId} = this.state; + const {acceptComment, rejectComment, moderation: {selectedCommentId}} = this.props; // Accept or reject only if there's a selected comment if(selectedCommentId != null){ @@ -96,74 +88,16 @@ class Moderation extends Component { return root[activeTab].nodes; } - scrollTo = (toId, smooth = true) => - document.querySelector(`#comment_${toId}`).scrollIntoView(smooth ? {behavior: 'smooth'} : {}); - - select = async (next, props = this.props, selectedCommentId = this.state.selectedCommentId) => { - const comments = this.getComments(props); - - // No comments to be selected. - if (comments.length === 0){ - return; - } - - // Find current index if we have a selected comment. - const index = selectedCommentId - ? comments.findIndex((comment) => comment.id === selectedCommentId) - : null; - - if (next) { - - // Grab first one if we don't have a selected comment yet. - if (!selectedCommentId) { - this.setState({selectedCommentId: comments[0].id}, () => this.scrollTo(comments[0].id)); - return; - } - - // Select next one when we still have more comments left. - if (index < comments.length - 1) { - this.setState({selectedCommentId: comments[index + 1].id}, () => this.scrollTo(comments[index + 1].id)); - return; - } else { - - // We hit the end of the list, load more comments if we have. - if (comments.length < this.getActiveTabCount()) { - const res = await this.loadMore(); - - // If `loadMore` was already in progress, res would be false. - if (res) { - - // Select next comment after loading has completed. - this.select(true); - } - } - return; - } - } else { - - // We have no selected comment, so just skip it. - if (!selectedCommentId) { - return; - } - - // If we still have previous comments take the one before. - if (index > 0) { - this.setState({selectedCommentId: comments[index - 1].id}, () => this.scrollTo(comments[index - 1].id)); - return; - } - } - } - loadMore = async () => { - if (!this.isLoadingMore) { - this.isLoadingMore = true; + if (!this.state.isLoadingMore) { + this.setState({isLoadingMore: true}); try { const result = await this.props.loadMore(this.props.activeTab); - this.isLoadingMore = false; + this.setState({isLoadingMore: false}); return result; } catch (e) { - this.isLoadingMore = false; + this.setState({isLoadingMore: false}); throw e; } } @@ -176,56 +110,12 @@ class Moderation extends Component { key.unbind('esc'); key.unbind('ctrl+f'); key.unbind('t'); - key.unbind('j'); - key.unbind('k'); key.unbind('f'); key.unbind('d'); this.getMenuItems() .forEach((menuItem, idx) => key.unbind(`${idx + 1}`)); } - componentWillReceiveProps(nextProps) { - - if (this.props.activeTab !== nextProps.activeTab) { - - // Reset selection when changing tabs. - this.select(true, nextProps, null); - } else { - - // Detect if comment has left the queue and find next or prev selected comment to set it - // as the new selectedCommentId. - const prevComments = this.getComments(this.props); - const nextComments = this.getComments(nextProps); - if (nextComments.length < prevComments.length) { - - // Comments have changed, now check if our selected comment has left the queue. - if ( - this.state.selectedCommentId && - !nextComments.some((comment) => comment.id === this.state.selectedCommentId) - ) { - - // Determine a comment to select. - const prevIndex = prevComments.findIndex((comment) => comment.id === this.state.selectedCommentId); - if (prevIndex !== prevComments.length - 1) { - this.setState({selectedCommentId: prevComments[prevIndex + 1].id}); - } else if(prevIndex > 0) { - this.setState({selectedCommentId: prevComments[prevIndex - 1].id}); - } else { - this.setState({selectedCommentId: null}); - } - } - } - } - } - - componentDidUpdate(prevProps) { - - // Scroll to comment when changing from single wiew to normal view. - if (prevProps.moderation.singleView !== this.props.moderation.singleView && this.state.selectedCommentId) { - this.scrollTo(this.state.selectedCommentId, false); - } - } - render () { const {root, data, moderation, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props; const {asset} = root; @@ -266,17 +156,22 @@ class Moderation extends Component { root={this.props.root} currentAsset={asset} comments={comments.nodes} + hasNextPage={comments.hasNextPage} activeTab={activeTab} singleView={moderation.singleView} - selectedCommentId={this.state.selectedCommentId} + selectedCommentId={moderation.selectedCommentId} showBanUserDialog={props.showBanUserDialog} showSuspendUserDialog={props.showSuspendUserDialog} acceptComment={props.acceptComment} rejectComment={props.rejectComment} loadMore={this.loadMore} + commentBelongToQueue={this.props.commentBelongToQueue} + isLoadingMore={this.state.isLoadingMore} commentCount={activeTabCount} currentUserId={this.props.auth.user.id} viewUserDetail={viewUserDetail} + selectCommentId={props.selectCommentId} + cleanUpQueue={props.cleanUpQueue} /> 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. +// the current comment The spare cursor functions as a backup in case one +// of the comments gets deleted. Additionally the new view based on the new +// cursors are also returned. function resetCursors(state, props) { + let idCursors = []; if (props.comments && props.comments.length) { - const idCursors = [props.comments[0].id]; + idCursors.push(props.comments[0].id); if (props.comments[1]) { idCursors.push(props.comments[1].id); } - return {idCursors}; } - return {idCursors: []}; + const view = getVisibleComments(props.comments, idCursors[0]); + return {idCursors, view}; } // invalidateCursor is called whenever a comment is removed which is referenced @@ -40,91 +43,306 @@ function invalidateCursor(invalidated, state, props) { idCursors.push(nextInLine.id); } } - return {idCursors}; + return idCursors; } +// getVisibileComments returns a list containing comments +// which comes after the `idCursor`. +function getVisibleComments(comments, idCursor) { + + if (!comments) { + return []; + } + + const view = []; + let pastCursor = false; + comments.forEach((comment) => { + if (comment.id === idCursor) { + pastCursor = true; + } + if (pastCursor) { + view.push(comment); + } + }); + return view; +} + +// Current keymapper to use for the CellMeasurer Cache. +let keyMapper = null; + +// CellMeasurerCache is used to measure the size of the elements +// of the virtual list. We use a global one with a keyMapper that +// should resolve to a comment id, which is then used to cache the height. +const cache = new CellMeasurerCache({ + fixedWidth: true, + defaultHeight: 250, + keyMapper: (index) => keyMapper(index), +}); + class ModerationQueue extends React.Component { - isLoadingMore = false; + listRef = null; + callbackCaches = { + clearHeightCache: {}, + selectCommentId: {}, + }; constructor(props) { super(props); this.state = { ...resetCursors(this.state, props), }; + + // Set keyMapper to map to comment ids. + keyMapper = (index) => { + const view = this.state.view; + if (index < view.length) { + return view[index].id; + } + else if (index === view.length) { + return 'loadMore'; + } + throw new Error(`unknown index ${index}`); + }; + + // Select first comment. + if (this.state.view.length) { + props.selectCommentId(this.state.view[0].id); + } } - componentDidUpdate (prev) { - const {comments, commentCount} = this.props; + componentDidMount() { + key('j', () => this.selectDown()); + key('k', () => this.selectUp()); - // if the user just moderated the last (visible) comment - // AND there are more comments available on the server, - // go ahead and load more comments - if (prev.comments.length > 0 && comments.length === 0 && commentCount > 0) { - this.props.loadMore(); - } + // TODO: Workaround for issue https://github.com/bvaughn/react-virtualized/issues/866 + this.reflowList(); + } + + componentWillUnmount() { + key.unbind('j'); + key.unbind('k'); + + // When switching queues, clean it up first. + // Removes dangling comments and reduce overly large + // lists and restore chronological order. + this.props.cleanUpQueue(this.props.activeTab); } componentWillReceiveProps(next) { const {comments: prevComments} = this.props; const {comments: nextComments} = next; - if (!prevComments && nextComments) { - this.setState(resetCursors); + // New comments where added and our cursor list is incomplete. + if (this.state.idCursors.length < 2 && nextComments.length > this.state.idCursors.length) { + this.setState(resetCursors(this.state, next)); return; } + let idCursors = this.state.idCursors; + + // Comments have been removed. if ( prevComments && nextComments && - nextComments.length < prevComments.length + nextComments.length < prevComments.length ) { // Invalidate first cursor if referenced comment was removed. if (this.state.idCursors[0] && !hasComment(nextComments, this.state.idCursors[0])) { - this.setState(invalidateCursor(0, this.state, next)); + idCursors = invalidateCursor(0, this.state, next); } // Invalidate second cursor if referenced comment was removed. if (this.state.idCursors[1] && !hasComment(nextComments, this.state.idCursors[1])) { - this.setState(invalidateCursor(1, this.state, next)); + idCursors = invalidateCursor(1, this.state, next); } + + // Selected comment was removed, determine and set next selected comment. + if ( + this.props.selectedCommentId && + !hasComment(nextComments, this.props.selectedCommentId) + ) { + const view = this.state.view; + let nextSelectedCommentId = null; + + // Determine a comment to select. + const prevIndex = view.findIndex((comment) => comment.id === this.props.selectedCommentId); + if (prevIndex !== view.length - 1) { + nextSelectedCommentId = view[prevIndex + 1].id; + } else if(prevIndex > 0) { + nextSelectedCommentId = view[prevIndex - 1].id; + } + this.props.selectCommentId(nextSelectedCommentId); + } + } + + // Comments changed. + if (prevComments !== nextComments) { + const nextView = getVisibleComments(nextComments, idCursors[0]); + this.setState({idCursors, view: nextView}); + + // TODO: removing or adding a comment from the list seems to render incorrect, is this a bug? + // Find first changed comment and perform a reflow. + const index = this.state.view.findIndex((comment, i) => !nextView[i] || nextView[i].id !== comment.id); + this.reflowList(index); } } - viewNewComments = () => { - this.setState(resetCursors); + componentDidUpdate (prev) { + const {commentCount, selectedCommentId} = this.props; + + // If the user just moderated the last (visible) comment + // AND there are more comments available on the server, + // go ahead and load more comments + if (prev.comments.length > 0 && this.getCommentCountWithoutDagling() === 0 && commentCount > 0) { + this.props.loadMore(); + } + + // Scroll to selected comment. + if (prev.selectedCommentId !== selectedCommentId && this.listRef) { + + const view = this.state.view; + const index = view.findIndex(({id}) => id === selectedCommentId); + + this.listRef.scrollToRow(index); + } + } + + // Returns comment counts without dangling comments. + getCommentCountWithoutDagling(props = this.props) { + return props.comments.filter((comment) => props.commentBelongToQueue(props.activeTab, comment)).length; + } + + async selectDown() { + const view = this.state.view; + const index = view.findIndex(({id}) => id === this.props.selectedCommentId); + if (index === view.length - 1 && this.getCommentCountWithoutDagling() !== this.props.commentCount) { + await this.props.loadMore(); + this.selectDown(); + return; + } + if (index < view.length - 1) { + this.props.selectCommentId(view[index + 1].id); + } + } + + selectUp() { + const view = this.state.view; + const index = view.findIndex(({id}) => id === this.props.selectedCommentId); + + if (index === 0 && view.length < this.props.comments.length) { + this.viewNewComments(() => this.selectUp()); + return; + } + if (index > 0) { + this.props.selectCommentId(view[index - 1].id); + } + } + + handleListRef = (list) => { + this.listRef = list; }; - // getVisibileComments returns a list containing comments - // which comes after the `idCursor`. - getVisibleComments() { - const {comments} = this.props; - const idCursor = this.state.idCursors[0]; + viewNewComments = (callback) => { + this.setState(resetCursors, () => { + this.reflowList(); + callback && callback(); + }); + }; - if (!comments) { - return []; + reflowList = throttle((index) => { + if (index >= 0) { + cache.clear(index); + this.listRef && this.listRef.recomputeRowHeights(index); + } + else { + cache.clearAll(); + this.listRef && this.listRef.recomputeRowHeights(); + } + }, 500); + + rowRenderer = ({ + index, // Index of row within collection + parent, + style // Style object to be applied to row (to position it) + }) => { + const view = this.state.view; + const rowCount = view.length + 1; + + let child = null; + let key = null; + + // Last element of list is our AutoLoadMore component and contains an + // id indicating that this is the last element in list. + if (index === rowCount - 1) { + key = 'end-of-comment-list'; + child = ( +
    + {this.props.hasNextPage && } +
    + ); + } + else { + const comment = view[index]; + + // Use callback cache so not to change the identity of these arrow functions. + // Otherwise shallow compare will fail to optimize. + if (!this.callbackCaches.clearHeightCache[index]) { + this.callbackCaches.clearHeightCache[index] = () => this.reflowList(index); + } + if (!this.callbackCaches.selectCommentId[comment.id]) { + this.callbackCaches.selectCommentId[comment.id] = () => this.props.selectCommentId(comment.id); + } + + key = comment.id; + child = ( +
    + +
    + ); } - const view = []; - let pastCursor = false; - comments.forEach((comment) => { - if (comment.id === idCursor) { - pastCursor = true; - } - if (pastCursor) { - view.push(comment); - } - }); - return view; - } + return ( + + {child} + + ); + }; render () { const { comments, selectedCommentId, - commentCount, singleView, viewUserDetail, - activeTab, ...props } = this.props; @@ -159,54 +377,35 @@ class ModerationQueue extends React.Component { ); } - const view = this.getVisibleComments(); + const view = this.state.view; return (
    this.viewNewComments()} count={comments.length - view.length} /> - - { - view - .map((comment) => { - return ; - }) - } - - - + + {({height, isScrolling, onChildScroll, scrollTop}) => ( + + )} +
    ); } @@ -216,14 +415,19 @@ ModerationQueue.propTypes = { viewUserDetail: PropTypes.func.isRequired, currentAsset: PropTypes.object, showBanUserDialog: PropTypes.func.isRequired, + selectCommentId: PropTypes.func.isRequired, showSuspendUserDialog: PropTypes.func.isRequired, rejectComment: PropTypes.func.isRequired, acceptComment: PropTypes.func.isRequired, - comments: PropTypes.array.isRequired, + commentBelongToQueue: PropTypes.func.isRequired, + cleanUpQueue: PropTypes.func.isRequired, commentCount: PropTypes.number.isRequired, loadMore: PropTypes.func.isRequired, selectedCommentId: PropTypes.string, singleView: PropTypes.bool, + isLoadingMore: PropTypes.bool, + hasNextPage: PropTypes.bool, + comments: PropTypes.array, activeTab: PropTypes.string.isRequired, data: PropTypes.object.isRequired, root: PropTypes.object.isRequired, diff --git a/client/coral-admin/src/routes/Moderation/components/ViewMore.css b/client/coral-admin/src/routes/Moderation/components/ViewMore.css index 2dcce0da5..e5c572ef5 100644 --- a/client/coral-admin/src/routes/Moderation/components/ViewMore.css +++ b/client/coral-admin/src/routes/Moderation/components/ViewMore.css @@ -13,6 +13,9 @@ background-color: #2376D8; cursor: pointer; text-transform: capitalize; + margin: 0; + margin-top: -18px; + margin-bottom: -4px; } .viewMore:hover { diff --git a/client/coral-admin/src/routes/Moderation/components/ViewOptions.css b/client/coral-admin/src/routes/Moderation/components/ViewOptions.css index 3e5aae32d..68f089c27 100644 --- a/client/coral-admin/src/routes/Moderation/components/ViewOptions.css +++ b/client/coral-admin/src/routes/Moderation/components/ViewOptions.css @@ -8,6 +8,7 @@ overflow: visible; height: 144px; min-height: auto; + margin-top: 16px; z-index: 10; @media (--tablet) { diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index ca6f0224a..9ea3baf00 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -11,7 +11,7 @@ import NotFoundAsset from '../components/NotFoundAsset'; import {isPremod, getModPath} from '../../../utils'; import {withSetCommentStatus} from 'coral-framework/graphql/mutations'; -import {handleCommentChange} from '../graphql'; +import {handleCommentChange, commentBelongToQueue, cleanUpQueue} from '../graphql'; import {showBanUserDialog} from 'actions/banUserDialog'; import {showSuspendUserDialog} from 'actions/suspendUserDialog'; @@ -23,7 +23,8 @@ import { toggleStorySearch, setSortOrder, storySearchChange, - clearState + clearState, + selectCommentId, } from 'actions/moderation'; import withQueueConfig from '../hoc/withQueueConfig'; import {notify} from 'coral-framework/actions/notification'; @@ -168,6 +169,14 @@ class ModerationContainer extends Component { } } + cleanUpQueue = (queue) => { + if (!this.props.data.loading) { + this.props.data.updateQuery((query) => { + return cleanUpQueue(query, queue, this.props.moderation.sortOrder, this.props.queueConfig); + }); + } + } + acceptComment = ({commentId}) => { return this.props.setCommentStatus({commentId, status: 'ACCEPTED'}); } @@ -176,9 +185,13 @@ class ModerationContainer extends Component { return this.props.setCommentStatus({commentId, status: 'REJECTED'}); } + commentBelongToQueue = (queue, comment) => { + return commentBelongToQueue(queue, comment, this.props.queueConfig); + } + loadMore = (tab) => { const variables = { - limit: 10, + limit: 20, cursor: this.props.root[tab].endCursor, sortOrder: this.props.data.variables.sortOrder, asset_id: this.props.data.variables.asset_id, @@ -194,7 +207,6 @@ class ModerationContainer extends Component { [tab]: { nodes: {$push: comments.nodes}, hasNextPage: {$set: comments.hasNextPage}, - startCursor: {$set: comments.startCursor}, endCursor: {$set: comments.endCursor}, }, }); @@ -246,6 +258,9 @@ class ModerationContainer extends Component { activeTab={this.activeTab} queueConfig={currentQueueConfig} handleCommentChange={this.handleCommentChange} + selectedCommentId={this.props.selectedCommentId} + commentBelongToQueue={this.commentBelongToQueue} + cleanUpQueue={this.cleanUpQueue} />; } } @@ -361,7 +376,8 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql` ${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''} ${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''} asset_id: $asset_id, - sortOrder: $sortOrder + sortOrder: $sortOrder, + limit: 20, }) { ...CoralAdmin_Moderation_CommentConnection } @@ -386,6 +402,9 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql` organizationName moderation } + me { + id + } ...${getDefinitionName(Comment.fragments.root)} } ${Comment.fragments.root} @@ -423,6 +442,7 @@ const mapDispatchToProps = (dispatch) => ({ storySearchChange, clearState, notify, + selectCommentId, }, dispatch), }); diff --git a/client/coral-admin/src/routes/Moderation/graphql.js b/client/coral-admin/src/routes/Moderation/graphql.js index 866218d73..5758315eb 100644 --- a/client/coral-admin/src/routes/Moderation/graphql.js +++ b/client/coral-admin/src/routes/Moderation/graphql.js @@ -16,16 +16,21 @@ function queueHasComment(root, queue, id) { return root[queue].nodes.find((c) => c.id === id); } -function removeCommentFromQueue(root, queue, id) { +function removeCommentFromQueue(root, queue, id, dangling = false) { if (!queueHasComment(root, queue, id)) { return root; } - return update(root, { + const changes = { [`${queue}Count`]: {$set: root[`${queue}Count`] - 1}, - [queue]: { + }; + + if (!dangling) { + changes[queue] = { nodes: {$apply: (nodes) => nodes.filter((c) => c.id !== id)}, - }, - }); + }; + } + + return update(root, changes); } function shouldCommentBeAdded(root, queue, comment, sortOrder) { @@ -40,26 +45,46 @@ function shouldCommentBeAdded(root, queue, comment, sortOrder) { : new Date(comment.created_at) >= cursor; } -function addCommentToQueue(root, queue, comment, sortOrder) { +function addCommentToQueue(root, queue, comment, sortOrder, cleanup) { if (queueHasComment(root, queue, comment.id)) { return root; } - const sortAlgo = sortOrder === 'ASC' ? ascending : descending; const changes = { [`${queue}Count`]: {$set: root[`${queue}Count`] + 1}, }; - if (shouldCommentBeAdded(root, queue, comment, sortOrder)) { - const nodes = root[queue].nodes.concat(comment).sort(sortAlgo); - changes[queue] = { - nodes: {$set: nodes}, - startCursor: {$set: nodes[0].created_at}, - endCursor: {$set: nodes[nodes.length - 1].created_at}, - }; + if (!shouldCommentBeAdded(root, queue, comment, sortOrder)) { + return update(root, changes); } - return update(root, changes); + const cursor = new Date(root[queue].startCursor); + const date = new Date(comment.created_at); + + let append = sortOrder === 'ASC' + ? date >= cursor + : date <= cursor; + + const nodes = append + ? root[queue].nodes.concat(comment) + : [comment].concat(...root[queue].nodes); + + changes[queue] = { + nodes: {$set: nodes}, + }; + + const next = update(root, changes); + + if (!cleanup) { + return next; + } + + return cleanUpQueue(next, queue, sortOrder); +} + +function sortComments(nodes, sortOrder) { + const sortAlgo = sortOrder === 'ASC' ? ascending : descending; + return nodes.sort(sortAlgo); } /** @@ -68,24 +93,88 @@ function addCommentToQueue(root, queue, comment, sortOrder) { function getCommentQueues(comment, queueConfig) { const queues = []; Object.keys(queueConfig).forEach((key) => { - const {action_type, statuses, tags} = queueConfig[key]; - let addToQueues = true; - if (statuses && statuses.indexOf(comment.status) === -1) { - addToQueues = false; - } - if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) { - addToQueues = false; - } - if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) { - addToQueues = false; - } - if (addToQueues) { + if (commentBelongToQueue(key, comment, queueConfig)) { queues.push(key); } }); return queues; } +/** + * Return whether or not the comment belongs to the queue. + */ +export function commentBelongToQueue(queue, comment, queueConfig) { + const {action_type, statuses, tags} = queueConfig[queue]; + let belong = true; + if (statuses && statuses.indexOf(comment.status) === -1) { + belong = false; + } + if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) { + belong = false; + } + if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) { + belong = false; + } + return belong; +} + +function isVisible(id) { + return !!document.getElementById(`comment_${id}`); +} + +function isEndOfListVisible(root, queue) { + return root[queue].nodes.length === 0 || !!document.getElementById('end-of-comment-list'); +} + +function applyCommentChanges(root, comment, queueConfig) { + const queues = Object.keys(queueConfig); + for (let i = 0; i < queues.length; i++) { + const queue = queues[i]; + const index = root[queue].nodes.findIndex(({id}) => id === comment.id); + if (index > -1) { + return update(root, { + [queue]: { + nodes: { + [index]: {$merge: comment}, + }, + }, + }); + } + } + return root; +} + +/** + * Remove dangling comments, sort and resize queues. + * If queueConfig is omitted, dangling comments are not removed. + */ +export function cleanUpQueue(root, queue, sortOrder, queueConfig) { + let nodes = root[queue].nodes; + let hasNextPage = root[queue].hasNextPage; + + if (queueConfig) { + nodes = root[queue].nodes.filter((comment) => commentBelongToQueue(queue, comment, queueConfig)); + } + + nodes = sortComments( + nodes, + sortOrder, + ); + + if (nodes.length > 2) { + nodes = nodes.slice(0, 2); + hasNextPage = true; + } + + return update(root, { + [queue]: { + nodes: {$set: nodes}, + endCursor: {$set: nodes[nodes.length - 1].created_at}, + hasNextPage: {$set: hasNextPage}, + }, + }); +} + /** * Assimilate comment changes into current store. * @param {Object} root current state of the store @@ -113,25 +202,27 @@ export function handleCommentChange(root, comment, sortOrder, notify, queueConfi Object.keys(queueConfig).forEach((queue) => { if (nextQueues.indexOf(queue) >= 0) { if (!queueHasComment(next, queue, comment.id)) { - next = addCommentToQueue(next, queue, comment, sortOrder); - if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sortOrder)) { + next = addCommentToQueue(next, queue, comment, sortOrder, activeQueue !== queue); + if (notify && activeQueue === queue && isEndOfListVisible(root, queue)) { showNotificationOnce(); } } } else if(queueHasComment(next, queue, comment.id)){ - next = removeCommentFromQueue(next, queue, comment.id); - if (notify && activeQueue === queue) { + const dangling = activeQueue === queue && comment.status_history[comment.status_history.length - 1].assigned_by.id !== root.me.id; + next = removeCommentFromQueue(next, queue, comment.id, dangling); + if (notify && isVisible(comment.id)) { showNotificationOnce(); } } - if ( - notify - && queueHasComment(next, queue, comment.id) - && activeQueue === queue - ) { + if (notify && isVisible(comment.id)) { showNotificationOnce(); } + + // We need to apply every comment change, because we use + // batched subscription handler which bypasses apollo that would + // have done that for us. + next = applyCommentChanges(next, comment, queueConfig); }); return next; } diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js index 15ec62a47..433d4e43b 100644 --- a/client/coral-framework/hocs/withQuery.js +++ b/client/coral-framework/hocs/withQuery.js @@ -84,6 +84,9 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { // Handle any pending susbcription data in the subscription queue at max once every second. // Updates are batched in written into apollo in one go. processSubscriptionQueue = throttle(() => { + if (!this.subscriptionQueue.length) { + return; + } const variables = typeof this.wrappedOptions === 'function' ? this.wrappedOptions(this.props).variables : this.wrappedOptions.variables; diff --git a/package.json b/package.json index 6a0e7a482..da084504d 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,7 @@ "react-test-renderer": "15.5", "react-toastify": "^1.5.0", "react-transition-group": "^1.1.3", + "react-virtualized": "9.13.0", "recompose": "^0.23.1", "redux": "^3.6.0", "redux-thunk": "^2.1.0", diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js b/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js index 0e9fc8c89..7e8d7d426 100644 --- a/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js +++ b/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js @@ -65,6 +65,14 @@ const COMMENT_FEATURED_SUBSCRIPTION = gql` commentFeatured(asset_id: $assetId) { comment { ...${getDefinitionName(Comment.fragments.comment)} + status_history { + type + created_at + assigned_by { + id + username + } + } } user { id diff --git a/yarn.lock b/yarn.lock index c5765d87d..282e501e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1045,7 +1045,7 @@ babel-register@^6.26.0: mkdirp "^0.5.1" source-map-support "^0.4.15" -babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0, babel-runtime@^6.6.1: +babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.23.0, babel-runtime@^6.26.0, babel-runtime@^6.6.1: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" dependencies: @@ -2435,7 +2435,7 @@ doctypes@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9" -dom-helpers@^3.2.0: +"dom-helpers@^2.4.0 || ^3.0.0", dom-helpers@^3.2.0: version "3.2.1" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a" @@ -5504,7 +5504,7 @@ longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848" dependencies: @@ -7535,6 +7535,16 @@ react-transition-group@^1.1.2, react-transition-group@^1.1.3: prop-types "^15.5.6" warning "^3.0.0" +react-virtualized@9.13.0: + version "9.13.0" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.13.0.tgz#83e4d984271a37631225e5fe6faeaeada6e59f53" + dependencies: + babel-runtime "^6.23.0" + classnames "^2.2.3" + dom-helpers "^2.4.0 || ^3.0.0" + loose-envify "^1.3.0" + prop-types "^15.5.4" + react@^15.3.1, react@^15.4.2: version "15.6.2" resolved "https://registry.yarnpkg.com/react/-/react-15.6.2.tgz#dba0434ab439cfe82f108f0f511663908179aa72"