merge conficts

This commit is contained in:
okbel
2017-12-20 16:06:55 -03:00
68 changed files with 2101 additions and 925 deletions
@@ -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,
});
@@ -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 {
<Slot
fill="adminCommentDetailArea"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
more={showDetail}
/>
{showDetail && <Slot
fill="adminCommentMoreDetails"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>}
</div>
@@ -61,6 +64,7 @@ CommentDetails.propTypes = {
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
comment: PropTypes.object.isRequired,
clearHeightCache: PropTypes.func,
};
export default CommentDetails;
@@ -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';
@@ -144,7 +144,7 @@ UserDetailContainer.propTypes = {
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) {
...CoralAdmin_Moderation_CommentConnection
...CoralAdmin_UserDetail_CommentConnection
}
}
${commentConnectionFragment}
@@ -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;
}
@@ -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 <Spinner />;
}
}
AutoLoadMore.propTypes = {
loading: PropTypes.bool.isRequired,
loadMore: PropTypes.func.isRequired,
};
export default AutoLoadMore;
@@ -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;
}
@@ -17,21 +17,36 @@ import RejectButton from 'coral-admin/src/components/RejectButton';
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();
}
};
viewUserDetail = () => {
const {viewUserDetail, comment} = this.props;
return viewUserDetail(comment.user.id);
};
approve = () => (this.props.comment.status === 'ACCEPTED'
? null
: this.props.acceptComment({commentId: this.props.comment.id})
);
approve = () =>
this.props.comment.status === 'ACCEPTED'
? null
: this.props.acceptComment({commentId: this.props.comment.id});
reject = () => (this.props.comment.status === 'REJECTED'
? null
: this.props.rejectComment({commentId: this.props.comment.id})
);
reject = () =>
this.props.comment.status === 'REJECTED'
? null
: this.props.rejectComment({commentId: this.props.comment.id});
componentDidUpdate(prev) {
if (!prev.selected && this.props.selected) {
this.ref.focus();
}
}
render() {
const {
@@ -42,6 +57,8 @@ class Comment extends React.Component {
root,
root: {settings},
currentAsset,
clearHeightCache,
dangling,
} = this.props;
const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
@@ -50,34 +67,48 @@ class Comment extends React.Component {
return (
<li
tabIndex={0}
className={cn(className, 'mdl-card', selectionStateCSS, styles.root, {[styles.selected]: selected}, 'talk-admin-moderate-comment')}
className={cn(
className,
'mdl-card',
selectionStateCSS,
styles.root,
{[styles.selected]: selected, [styles.dangling]: dangling},
'talk-admin-moderate-comment'
)}
id={`comment_${comment.id}`}
onClick={this.handleFocusOrClick}
ref={this.handleRef}
onFocus={this.handleFocusOrClick}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
<span
className={cn(styles.username, 'talk-admin-moderate-comment-username')}
onClick={this.viewUserDetail}>
className={cn(
styles.username,
'talk-admin-moderate-comment-username'
)}
onClick={this.viewUserDetail}
>
{comment.user.username}
</span>
<span className={styles.created}>
{timeago(comment.created_at)}
</span>
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
{comment.editing && comment.editing.edited ? (
<span>
&nbsp;<span className={styles.editedMarker}>
({t('comment.edited')})
</span>
</span>
) : null}
<div className={styles.adminCommentInfoBar}>
<CommentLabels
comment={comment}
/>
<CommentLabels comment={comment} />
<Slot
fill="adminCommentInfoBar"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
</div>
@@ -86,8 +117,11 @@ class Comment extends React.Component {
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
{!currentAsset && (
<Link to={`/admin/moderate/${comment.asset.id}`}>
{t('modqueue.moderate')}
</Link>
)}
</div>
<CommentAnimatedEdit body={comment.body}>
<div className={styles.itemBody}>
@@ -96,8 +130,7 @@ class Comment extends React.Component {
suspectWords={settings.wordlist.suspect}
bannedWords={settings.wordlist.banned}
body={comment.body}
/>
{' '}
/>{' '}
<a
className={styles.external}
href={`${comment.asset.url}?commentId=${comment.id}`}
@@ -109,6 +142,7 @@ class Comment extends React.Component {
<Slot
fill="adminCommentContent"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
<div className={styles.sideActions}>
@@ -130,6 +164,7 @@ class Comment extends React.Component {
<Slot
fill="adminSideActions"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
</div>
@@ -140,6 +175,7 @@ class Comment extends React.Component {
data={data}
root={root}
comment={comment}
clearHeightCache={clearHeightCache}
/>
</li>
);
@@ -149,10 +185,14 @@ 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,
currentUserId: PropTypes.string.isRequired,
clearHeightCache: PropTypes.func,
comment: PropTypes.shape({
id: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
@@ -162,12 +202,12 @@ Comment.propTypes = {
created_at: PropTypes.string.isRequired,
user: PropTypes.shape({
id: PropTypes.string,
status: PropTypes.string
status: PropTypes.string,
}).isRequired,
asset: PropTypes.shape({
title: PropTypes.string,
url: PropTypes.string,
id: PropTypes.string
id: PropTypes.string,
}),
}),
data: PropTypes.object.isRequired,
@@ -12,15 +12,9 @@ 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,17 +24,16 @@ 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()
.forEach((menuItem, idx) => key(`${idx + 1}`, () => this.selectQueue(menuItem)));
this.getMenuItems().forEach((menuItem, idx) =>
key(`${idx + 1}`, () => this.selectQueue(menuItem))
);
}
onClose = () => {
this.props.toggleModal(false);
}
};
nextQueue = () => {
const activeTab = this.props.activeTab;
@@ -48,39 +41,45 @@ class Moderation extends Component {
const menuItems = this.getMenuItems();
const activeTabIndex = menuItems.findIndex((item) => item === activeTab);
const nextQueueIndex = (activeTabIndex === menuItems.length - 1) ? 0 : activeTabIndex + 1;
const nextQueueIndex =
activeTabIndex === menuItems.length - 1 ? 0 : activeTabIndex + 1;
this.selectQueue(menuItems[nextQueueIndex]);
}
};
selectQueue = (key) => {
const assetId = this.props.data.variables.asset_id;
this.props.router.push(this.props.getModPath(key, assetId));
}
};
getMenuItems = () => Object.keys(this.props.queueConfig);
closeSearch = () => {
const {toggleStorySearch} = this.props;
toggleStorySearch(false);
}
};
openSearch = () => {
this.props.toggleStorySearch(true);
}
};
getActiveTabCount = (props = this.props) => {
return props.root[`${props.activeTab}Count`];
}
};
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){
if (selectedCommentId != null) {
const comments = this.getComments();
const commentIdx = comments.findIndex((comment) => comment.id === selectedCommentId);
const commentIdx = comments.findIndex(
(comment) => comment.id === selectedCommentId
);
const comment = comments[commentIdx];
if (accept) {
@@ -89,86 +88,27 @@ class Moderation extends Component {
comment.status !== 'REJECTED' && rejectComment({commentId: comment.id});
}
}
}
};
getComments = (props = this.props) => {
const {root, activeTab} = props;
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;
} catch (e) {
this.setState({isLoadingMore: false});
throw e;
}
}
return false;
}
};
componentWillUnmount() {
key.unbind('s');
@@ -176,58 +116,23 @@ 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}`));
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;
render() {
const {
root,
data,
moderation,
viewUserDetail,
activeTab,
getModPath,
queueConfig,
handleCommentChange,
...props
} = this.props;
const {asset} = root;
const assetId = asset && asset.id;
@@ -238,7 +143,7 @@ class Moderation extends Component {
key: queue,
name: queueConfig[queue].name,
icon: queueConfig[queue].icon,
count: root[`${queue}Count`]
count: root[`${queue}Count`],
}));
return (
@@ -255,7 +160,9 @@ class Moderation extends Component {
items={menuItems}
activeTab={activeTab}
/>
<div className={cn(styles.container, 'talk-admin-moderation-container')}>
<div
className={cn(styles.container, 'talk-admin-moderation-container')}
>
<ViewOptions
selectSort={this.props.setSortOrder}
sort={this.props.moderation.sortOrder}
@@ -266,15 +173,20 @@ 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}
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}
/>
<ModerationKeysModal
hideShortcutsNote={props.hideShortcutsNote}
@@ -295,7 +207,7 @@ class Moderation extends Component {
queryData={{root, asset}}
activeTab={activeTab}
handleCommentChange={handleCommentChange}
fill='adminModeration'
fill="adminModeration"
/>
</div>
);
@@ -305,12 +217,15 @@ class Moderation extends Component {
Moderation.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
toggleModal: PropTypes.func.isRequired,
selectedCommentId: PropTypes.string,
toggleStorySearch: PropTypes.func.isRequired,
getModPath: PropTypes.func.isRequired,
cleanUpQueue: PropTypes.func.isRequired,
storySearchChange: PropTypes.func.isRequired,
moderation: PropTypes.object.isRequired,
auth: PropTypes.object.isRequired,
queueConfig: PropTypes.object.isRequired,
commentBelongToQueue: PropTypes.func.isRequired,
handleCommentChange: PropTypes.func.isRequired,
setSortOrder: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
@@ -0,0 +1,3 @@
.root {
height: 100%;
}
@@ -2,29 +2,15 @@
padding: 8px 0;
list-style: none;
display: block;
min-height: 650px;
margin-top: 16px;
}
:global(html) {
height: inherit;
}
.list {
padding: 0;
margin: 0;
}
.commentLeave {
opacity: 1.0;
}
.commentLeaveActive {
opacity: 0;
transition: opacity 800ms;
}
.commentEnter {
opacity: 0;
}
.commentEnterActive {
opacity: 1.0;
transition: opacity 800ms;
outline: none;
}
@@ -4,26 +4,34 @@ import PropTypes from 'prop-types';
import Comment from '../containers/Comment';
import styles from './ModerationQueue.css';
import EmptyCard from '../../../components/EmptyCard';
import LoadMore from '../../../components/LoadMore';
import AutoLoadMore from './AutoLoadMore';
import ViewMore from './ViewMore';
import t from 'coral-framework/services/i18n';
import {CSSTransitionGroup} from 'react-transition-group';
import {
WindowScroller,
CellMeasurer,
CellMeasurerCache,
List,
} from 'react-virtualized';
import throttle from 'lodash/throttle';
import key from 'keymaster';
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.
// 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 +48,322 @@ 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;
}
if (
prevComments && nextComments &&
nextComments.length < prevComments.length
) {
let idCursors = this.state.idCursors;
// Comments have been removed.
if (
prevComments &&
nextComments &&
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));
if (
this.state.idCursors[0] &&
!hasComment(nextComments, this.state.idCursors[0])
) {
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));
if (
this.state.idCursors[1] &&
!hasComment(nextComments, this.state.idCursors[1])
) {
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 = (
<div style={style} id={'end-of-comment-list'}>
{this.props.hasNextPage && (
<AutoLoadMore
loadMore={this.props.loadMore}
loading={this.props.isLoadingMore}
/>
)}
</div>
);
} 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 = (
<div style={style}>
<Comment
data={this.props.data}
root={this.props.root}
comment={comment}
dangling={
!this.props.commentBelongToQueue(this.props.activeTab, comment)
}
selected={comment.id === this.props.selectedCommentId}
viewUserDetail={this.props.viewUserDetail}
acceptComment={this.props.acceptComment}
rejectComment={this.props.rejectComment}
currentAsset={this.props.currentAsset}
currentUserId={this.props.currentUserId}
clearHeightCache={this.callbackCaches.clearHeightCache[index]}
selectComment={this.callbackCaches.selectCommentId[comment.id]}
/>
</div>
);
}
const view = [];
let pastCursor = false;
comments.forEach((comment) => {
if (comment.id === idCursor) {
pastCursor = true;
}
if (pastCursor) {
view.push(comment);
}
});
return view;
}
return (
<CellMeasurer
cache={cache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}
>
{child}
</CellMeasurer>
);
};
render () {
render() {
const {
comments,
selectedCommentId,
commentCount,
singleView,
viewUserDetail,
activeTab,
...props
} = this.props;
@@ -137,7 +376,9 @@ class ModerationQueue extends React.Component {
}
if (singleView) {
const index = comments.findIndex((comment) => comment.id === selectedCommentId);
const index = comments.findIndex(
(comment) => comment.id === selectedCommentId
);
const comment = comments[index];
return (
<div className={styles.root}>
@@ -157,67 +398,55 @@ class ModerationQueue extends React.Component {
);
}
const view = this.getVisibleComments();
const view = this.state.view;
return (
<div className={styles.root}>
<ViewMore
viewMore={this.viewNewComments}
viewMore={() => this.viewNewComments()}
count={comments.length - view.length}
/>
<CSSTransitionGroup
key={activeTab}
component={'ul'}
className={styles.list}
transitionName={{
enter: styles.commentEnter,
enterActive: styles.commentEnterActive,
leave: styles.commentLeave,
leaveActive: styles.commentLeaveActive,
}}
transitionEnter={true}
transitionLeave={true}
transitionEnterTimeout={1000}
transitionLeaveTimeout={1000}
>
{
view
.map((comment) => {
return <Comment
data={this.props.data}
root={this.props.root}
key={comment.id}
comment={comment}
selected={comment.id === selectedCommentId}
viewUserDetail={viewUserDetail}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
currentAsset={props.currentAsset}
currentUserId={this.props.currentUserId}
/>;
})
}
</CSSTransitionGroup>
<LoadMore
loadMore={this.props.loadMore}
showLoadMore={comments.length < commentCount}
/>
<WindowScroller onResize={this.reflowList}>
{({height, isScrolling, onChildScroll, scrollTop}) => (
<List
ref={this.handleListRef}
autoHeight
className={styles.list}
style={{
width: '100%',
}}
height={height}
width={1280}
scrollTop={scrollTop}
isScrolling={isScrolling}
onScroll={onChildScroll}
rowCount={view.length + 1}
deferredMeasurementCache={cache}
rowRenderer={this.rowRenderer}
rowHeight={cache.rowHeight}
/>
)}
</WindowScroller>
</div>
);
}
}
ModerationQueue.propTypes = {
selectCommentId: PropTypes.func.isRequired,
selectedCommentId: PropTypes.string,
viewUserDetail: PropTypes.func.isRequired,
currentAsset: PropTypes.object,
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,
@@ -13,6 +13,9 @@
background-color: #2376D8;
cursor: pointer;
text-transform: capitalize;
margin: 0;
margin-top: -18px;
margin-bottom: -4px;
}
.viewMore:hover {
@@ -8,7 +8,8 @@
overflow: visible;
height: 144px;
min-height: auto;
z-index: 4;
margin-top: 16px;
z-index: 10;
@media (--tablet) {
width: 650px;
@@ -11,7 +11,12 @@ 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 {viewUserDetail} from '../../../actions/userDetail';
import {
toggleModal,
@@ -20,7 +25,8 @@ import {
toggleStorySearch,
setSortOrder,
storySearchChange,
clearState
clearState,
selectCommentId,
} from 'actions/moderation';
import withQueueConfig from '../hoc/withQueueConfig';
import {notify} from 'coral-framework/actions/notification';
@@ -63,13 +69,15 @@ class ModerationContainer extends Component {
};
get activeTab() {
const {root: {asset, settings}} = this.props;
const id = getAssetId(this.props);
const tab = getTab(this.props);
// Grab premod from asset or from settings if it's defined.
const setting = id && asset && asset.settings ? asset.settings.moderation : settings.moderation;
const setting =
id && asset && asset.settings
? asset.settings.moderation
: settings.moderation;
const queue = isPremod(setting) ? 'premod' : 'new';
const activeTab = tab ? tab : queue;
@@ -82,60 +90,101 @@ class ModerationContainer extends Component {
{
document: COMMENT_ADDED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentAdded: comment}}}) => {
updateQuery: (
prev,
{subscriptionData: {data: {commentAdded: comment}}}
) => {
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_ACCEPTED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body));
updateQuery: (
prev,
{subscriptionData: {data: {commentAccepted: comment}}}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
.assigned_by;
const notifyText =
this.props.auth.user.id === user.id
? ''
: t(
'modqueue.notify_accepted',
user.username,
prepareNotificationText(comment.body)
);
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_REJECTED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body));
updateQuery: (
prev,
{subscriptionData: {data: {commentRejected: comment}}}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
.assigned_by;
const notifyText =
this.props.auth.user.id === user.id
? ''
: t(
'modqueue.notify_rejected',
user.username,
prepareNotificationText(comment.body)
);
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_RESET_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentReset: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_reset', user.username, prepareNotificationText(comment.body));
updateQuery: (
prev,
{subscriptionData: {data: {commentReset: comment}}}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
.assigned_by;
const notifyText =
this.props.auth.user.id === user.id
? ''
: t(
'modqueue.notify_reset',
user.username,
prepareNotificationText(comment.body)
);
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_EDITED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => {
updateQuery: (
prev,
{subscriptionData: {data: {commentEdited: comment}}}
) => {
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_FLAGGED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => {
updateQuery: (
prev,
{subscriptionData: {data: {commentFlagged: comment}}}
) => {
return this.handleCommentChange(prev, comment);
},
},
];
this.subscriptions = parameters.map((param) => this.props.data.subscribeToMoreThrottled(param));
this.subscriptions = parameters.map((param) =>
this.props.data.subscribeToMoreThrottled(param)
);
}
unsubscribe() {
@@ -158,24 +207,42 @@ class ModerationContainer extends Component {
}
componentWillReceiveProps(nextProps) {
// Resubscribe when we change between assets.
if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) {
if (
this.props.data.variables.asset_id !== nextProps.data.variables.asset_id
) {
this.resubscribe(nextProps.data.variables);
}
}
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'});
}
};
rejectComment = ({commentId}) => {
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,
@@ -186,20 +253,19 @@ class ModerationContainer extends Component {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables,
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
updateQuery: (prev, {fetchMoreResult: {comments}}) => {
return update(prev, {
[tab]: {
nodes: {$push: comments.nodes},
hasNextPage: {$set: comments.hasNextPage},
startCursor: {$set: comments.startCursor},
endCursor: {$set: comments.endCursor},
},
});
}
},
});
};
render () {
render() {
const {root, root: {asset, settings}, data} = this.props;
const assetId = getAssetId(this.props);
@@ -209,20 +275,19 @@ class ModerationContainer extends Component {
if (assetId) {
if (asset === null) {
// Not found.
return <NotFoundAsset assetId={assetId} />;
}
}
if(data.loading) {
if (data.loading) {
// loading.
return <Spinner />;
}
const premodEnabled = assetId ? isPremod(asset.settings.moderation) :
isPremod(settings.moderation);
const premodEnabled = assetId
? isPremod(asset.settings.moderation)
: isPremod(settings.moderation);
const currentQueueConfig = Object.assign({}, this.props.queueConfig);
@@ -234,16 +299,21 @@ class ModerationContainer extends Component {
delete currentQueueConfig.premod;
}
return <Moderation
{...this.props}
getModPath={getModPath}
loadMore={this.loadMore}
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
activeTab={this.activeTab}
queueConfig={currentQueueConfig}
handleCommentChange={this.handleCommentChange}
/>;
return (
<Moderation
{...this.props}
getModPath={getModPath}
loadMore={this.loadMore}
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
activeTab={this.activeTab}
queueConfig={currentQueueConfig}
handleCommentChange={this.handleCommentChange}
selectedCommentId={this.props.selectedCommentId}
commentBelongToQueue={this.commentBelongToQueue}
cleanUpQueue={this.cleanUpQueue}
/>
);
}
}
const COMMENT_ADDED_SUBSCRIPTION = gql`
@@ -350,27 +420,57 @@ const commentConnectionFragment = gql`
${Comment.fragments.comment}
`;
const withModQueueQuery = withQuery(({queueConfig}) => gql`
const withModQueueQuery = withQuery(
({queueConfig}) => gql`
query CoralAdmin_Moderation($asset_id: ID, $sortOrder: SORT_ORDER, $allAssets: Boolean!, $nullStatuses: [COMMENT_STATUS!]) {
${Object.keys(queueConfig).map((queue) => `
${Object.keys(queueConfig).map(
(queue) => `
${queue}: comments(query: {
statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
statuses: ${
queueConfig[queue].statuses
? `[${queueConfig[queue].statuses.join(', ')}],`
: '$nullStatuses'
}
${
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
}
`)}
${Object.keys(queueConfig).map((queue) => `
`
)}
${Object.keys(queueConfig).map(
(queue) => `
${queue}Count: commentCount(query: {
statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
statuses: ${
queueConfig[queue].statuses
? `[${queueConfig[queue].statuses.join(', ')}],`
: '$nullStatuses'
}
${
queueConfig[queue].tags
? `tags: ["${queueConfig[queue].tags.join('", "')}"],`
: ''
}
${
queueConfig[queue].action_type
? `action_type: ${queueConfig[queue].action_type}`
: ''
}
asset_id: $asset_id,
})
`)}
`
)}
asset(id: $asset_id) @skip(if: $allAssets) {
id
title
@@ -383,24 +483,29 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
organizationName
moderation
}
me {
id
}
...${getDefinitionName(Comment.fragments.root)}
}
${Comment.fragments.root}
${commentConnectionFragment}
`, {
options: (props) => {
const id = getAssetId(props);
return {
variables: {
asset_id: id,
sortOrder: props.moderation.sortOrder,
allAssets: id === null,
nullStatuses: null,
},
fetchPolicy: 'network-only'
};
},
});
`,
{
options: (props) => {
const id = getAssetId(props);
return {
variables: {
asset_id: id,
sortOrder: props.moderation.sortOrder,
allAssets: id === null,
nullStatuses: null,
},
fetchPolicy: 'network-only',
};
},
}
);
const mapStateToProps = (state) => ({
moderation: state.moderation,
@@ -408,22 +513,26 @@ const mapStateToProps = (state) => ({
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
toggleModal,
singleView,
hideShortcutsNote,
toggleStorySearch,
viewUserDetail,
setSortOrder,
storySearchChange,
clearState,
notify,
}, dispatch),
...bindActionCreators(
{
toggleModal,
singleView,
hideShortcutsNote,
toggleStorySearch,
viewUserDetail,
setSortOrder,
storySearchChange,
clearState,
notify,
selectCommentId,
},
dispatch
),
});
export default compose(
withQueueConfig(baseQueueConfig),
connect(mapStateToProps, mapDispatchToProps),
withSetCommentStatus,
withModQueueQuery,
withModQueueQuery
)(ModerationContainer);
@@ -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,92 @@ 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 (!nodes.length) {
return root;
}
if (queueConfig) {
nodes = root[queue].nodes.filter((comment) => commentBelongToQueue(queue, comment, queueConfig));
}
nodes = sortComments(
nodes,
sortOrder,
);
if (nodes.length > 100) {
nodes = nodes.slice(0, 100);
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 +206,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;
}
@@ -193,7 +193,12 @@ export default {
},
updateQueries: {
CoralEmbedStream_Embed: (prev, {mutationResult: {data: {createComment: {comment}}}}) => {
if (prev.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED' || comment.status === 'SYSTEM_WITHHELD') {
if (
prev.me.roles.indexOf('ADMIN') === -1 && prev.asset.settings.moderation === 'PRE' ||
comment.status === 'PREMOD' ||
comment.status === 'REJECTED' ||
comment.status === 'SYSTEM_WITHHELD'
) {
return prev;
}
return insertCommentIntoEmbedQuery(prev, comment);
+2 -16
View File
@@ -38,7 +38,7 @@ function applyToCommentsOrigin(root, callback) {
function findAndInsertComment(parent, comment) {
const isAsset = parent.__typename === 'Asset';
const [connectionField, countField, action] = isAsset
? ['comments', 'commentCount', '$unshift']
? ['comments', 'totalCommentCount', '$unshift']
: ['replies', 'replyCount', '$push'];
if (
@@ -67,19 +67,12 @@ function findAndInsertComment(parent, comment) {
}
export function insertCommentIntoEmbedQuery(root, comment) {
// Increase total comment count by one.
root = update(root, {
asset: {
totalCommentCount: {$apply: (c) => c + 1},
},
});
return applyToCommentsOrigin(root, (origin) => findAndInsertComment(origin, comment));
}
function findAndRemoveComment(parent, id) {
const [connectionField, countField] = parent.__typename === 'Asset'
? ['comments', 'commentCount']
? ['comments', 'totalCommentCount']
: ['replies', 'replyCount'];
const connection = parent[connectionField];
@@ -104,13 +97,6 @@ function findAndRemoveComment(parent, id) {
}
export function removeCommentFromEmbedQuery(root, id) {
// Decrease total comment by one.
root = update(root, {
asset: {
totalCommentCount: {$apply: (c) => c - 1},
},
});
return applyToCommentsOrigin(root, (origin) => findAndRemoveComment(origin, id));
}
@@ -297,6 +297,7 @@ const fragments = {
ignoredUsers {
id
}
roles
}
settings {
organizationName
@@ -336,7 +337,6 @@ const fragments = {
charCount
requireEmailConfirmation
}
commentCount @skip(if: $hasComment)
totalCommentCount @skip(if: $hasComment)
comments(query: {limit: 10, excludeIgnored: $excludeIgnored, sortOrder: $sortOrder, sortBy: $sortBy}) @skip(if: $hasComment) {
nodes {
@@ -357,7 +357,6 @@ const fragments = {
const mapStateToProps = (state) => ({
auth: state.auth,
refetching: state.embed.refetching,
commentCountCache: state.stream.commentCountCache,
activeReplyBox: state.stream.activeReplyBox,
commentId: state.stream.commentId,
assetId: state.stream.assetId,
+20 -7
View File
@@ -49,8 +49,9 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
memoized = null;
resolvedDocument = null;
lastNetworkStatus = null;
data = null;
name = '';
apolloData = null;
data = null;
// Pending subscription data.
subscriptionQueue = [];
@@ -83,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;
@@ -151,6 +155,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
};
nextData(data) {
this.apolloData = data;
this.emitWhenNeeded(data);
// If data was previously set, we update it in a immutable way.
@@ -181,16 +186,24 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
variables: data.variables,
networkStatus: data.networkStatus,
loading: data.loading,
startPolling: data.startPolling,
stopPolling: data.stopPolling,
refetch: data.refetch,
updateQuery: data.updateQuery,
subscribeToMoreThrottled: this.subscribeToMoreThrottled,
startPolling: (...args) => {
return this.apolloData.startPolling(...args);
},
stopPolling: (...args) => {
return this.apolloData.stopPolling(...args);
},
updateQuery: (...args) => {
return this.apolloData.updateQuery(...args);
},
refetch: (...args) => {
return this.apolloData.refetch(...args);
},
subscribeToMore: (stmArgs) => {
const resolvedDocument = this.resolveDocument(stmArgs.document);
// Resolve document fragments before passing it to `apollo-client`.
return data.subscribeToMore({
return this.apolloData.subscribeToMore({
...stmArgs,
document: resolvedDocument,
onError: (err) => {
@@ -209,7 +222,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
{variables: lmArgs.variables});
// Resolve document fragments before passing it to `apollo-client`.
return data.fetchMore({
return this.apolloData.fetchMore({
...lmArgs,
query: resolvedDocument,
})
@@ -61,7 +61,7 @@ class ProfileContainer extends Component {
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
}
if (loading) {
if (loading || !me) {
return <Spinner />;
}
+13
View File
@@ -0,0 +1,13 @@
.dialog {
}
:global(.backdrop) {
left: 0;
top: 0;
width: 100%;
height: 100%;
position: absolute;
background-color: black;
opacity: 0.1;
}
+11 -7
View File
@@ -2,6 +2,8 @@ import React, {Component} from 'react';
import PropTypes from 'prop-types';
import dialogPolyfill from 'dialog-polyfill';
import 'dialog-polyfill/dialog-polyfill.css';
import styles from './Dialog.css';
import {Portal} from 'react-portal';
export default class Dialog extends Component {
static propTypes = {
@@ -52,13 +54,15 @@ export default class Dialog extends Component {
const {children, className = '', onClose, onCancel, open, ...rest} = this.props; // eslint-disable-line
return (
<dialog
ref={(el) => { this.dialog = el; }}
className={`mdl-dialog ${className}`}
{...rest}
>
{children}
</dialog>
<Portal>
<dialog
ref={(el) => { this.dialog = el; }}
className={`mdl-dialog ${className} ${styles.dialog}`}
{...rest}
>
{children}
</dialog>
</Portal>
);
}
}