Merge branch 'master' into i18n-emails

This commit is contained in:
gaba
2017-06-01 16:26:44 -07:00
25 changed files with 518 additions and 385 deletions
+27 -27
View File
@@ -1,4 +1,5 @@
import {add} from 'coral-framework/services/graphqlRegistry';
import update from 'immutability-helper';
const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected'];
const extension = {
@@ -11,54 +12,53 @@ const extension = {
}),
SetCommentStatus: ({variables: {commentId, status}}) => ({
updateQueries: {
CoralAdmin_Moderation: (oldData) => {
CoralAdmin_Moderation: (prev) => {
const comment = queues.reduce((comment, queue) => {
return comment ? comment : oldData[queue].find((c) => c.id === commentId);
return comment ? comment : prev[queue].nodes.find((c) => c.id === commentId);
}, null);
let accepted = oldData.accepted;
let acceptedCount = oldData.acceptedCount;
let rejected = oldData.rejected;
let rejectedCount = oldData.rejectedCount;
let acceptedNodes = prev.accepted.nodes;
let acceptedCount = prev.acceptedCount;
let rejectedNodes = prev.rejected.nodes;
let rejectedCount = prev.rejectedCount;
if (status !== comment.status) {
if (status === 'ACCEPTED') {
comment.status = 'ACCEPTED';
acceptedCount++;
accepted = [comment, ...accepted];
acceptedNodes = [comment, ...acceptedNodes];
}
else if (status === 'REJECTED') {
comment.status = 'REJECTED';
rejectedCount++;
rejected = [comment, ...rejected];
rejectedNodes = [comment, ...rejectedNodes];
}
}
const premod = oldData.premod.filter((c) => c.id !== commentId);
const flagged = oldData.flagged.filter((c) => c.id !== commentId);
const premodCount = premod.length < oldData.premod.length ? oldData.premodCount - 1 : oldData.premodCount;
const flaggedCount = flagged.length < oldData.flagged.length ? oldData.flaggedCount - 1 : oldData.flaggedCount;
const premodNodes = prev.premod.nodes.filter((c) => c.id !== commentId);
const flaggedNodes = prev.flagged.nodes.filter((c) => c.id !== commentId);
const premodCount = premodNodes.length < prev.premod.nodes.length ? prev.premodCount - 1 : prev.premodCount;
const flaggedCount = flaggedNodes.length < prev.flagged.nodes.length ? prev.flaggedCount - 1 : prev.flaggedCount;
if (status === 'REJECTED') {
accepted = oldData.accepted.filter((c) => c.id !== commentId);
acceptedCount = accepted.length < oldData.accepted.length ? oldData.acceptedCount - 1 : oldData.acceptedCount;
acceptedNodes = prev.accepted.nodes.filter((c) => c.id !== commentId);
acceptedCount = acceptedNodes.length < prev.accepted.nodes.length ? prev.acceptedCount - 1 : prev.acceptedCount;
}
else if (status === 'ACCEPTED') {
rejected = oldData.rejected.filter((c) => c.id !== commentId);
rejectedCount = rejected.length < oldData.rejected.length ? oldData.rejectedCount - 1 : oldData.rejectedCount;
rejectedNodes = prev.rejected.nodes.filter((c) => c.id !== commentId);
rejectedCount = rejectedNodes.length < prev.rejected.nodes.length ? prev.rejectedCount - 1 : prev.rejectedCount;
}
return {
...oldData,
premodCount: Math.max(0, premodCount),
flaggedCount: Math.max(0, flaggedCount),
acceptedCount: Math.max(0, acceptedCount),
rejectedCount: Math.max(0, rejectedCount),
premod,
flagged,
accepted,
rejected,
};
return update(prev, {
premodCount: {$set: Math.max(0, premodCount)},
flaggedCount: {$set: Math.max(0, flaggedCount)},
acceptedCount: {$set: Math.max(0, acceptedCount)},
rejectedCount: {$set: Math.max(0, rejectedCount)},
premod: {nodes: {$set: premodNodes}},
flagged: {nodes: {$set: flaggedNodes}},
accepted: {nodes: {$set: acceptedNodes}},
rejected: {nodes: {$set: rejectedNodes}},
});
}
}
}),
@@ -2,32 +2,19 @@ import React, {PropTypes} from 'react';
import {Button} from 'coral-ui';
import styles from './styles.css';
const LoadMore = ({comments, loadMore, sort, tab, assetId, showLoadMore}) =>
const LoadMore = ({loadMore, showLoadMore}) =>
<div className={styles.loadMoreContainer}>
{
showLoadMore && <Button
className={styles.loadMore}
onClick={() => {
const lastComment = comments[comments.length - 1];
const cursor = lastComment ? lastComment.created_at : null;
return loadMore({
cursor,
sort,
tab,
asset_id: assetId
});
}}>
onClick={loadMore}>
Load More
</Button>
}
</div>;
LoadMore.propTypes = {
comments: PropTypes.array.isRequired,
loadMore: PropTypes.func.isRequired,
sort: PropTypes.oneOf(['CHRONOLOGICAL', 'REVERSE_CHRONOLOGICAL']).isRequired,
tab: PropTypes.oneOf(['rejected', 'premod', 'flagged', 'all', 'accepted']).isRequired,
assetId: PropTypes.string,
showLoadMore: PropTypes.bool.isRequired
};
@@ -143,7 +143,7 @@ export default class Moderation extends Component {
data={this.props.data}
root={this.props.root}
currentAsset={asset}
comments={comments}
comments={comments.nodes}
activeTab={activeTab}
singleView={moderation.singleView}
selectedIndex={this.state.selectedIndex}
@@ -21,14 +21,18 @@ class ModerationQueue extends React.Component {
comments: PropTypes.array.isRequired
}
loadMore = () => {
this.props.loadMore(this.props.activeTab);
}
componentDidUpdate (prev) {
const {loadMore, comments, commentCount, sort, activeTab: tab, assetId: asset_id} = this.props;
const {comments, commentCount} = 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 && comments.length === 0 && commentCount > 0) {
loadMore({sort, tab, asset_id});
this.loadMore();
}
}
@@ -38,9 +42,6 @@ class ModerationQueue extends React.Component {
selectedIndex,
commentCount,
singleView,
loadMore,
activeTab,
sort,
viewUserDetail,
...props
} = this.props;
@@ -75,12 +76,8 @@ class ModerationQueue extends React.Component {
}
</ul>
<LoadMore
comments={comments}
loadMore={loadMore}
sort={sort}
tab={activeTab}
loadMore={this.loadMore}
showLoadMore={comments.length < commentCount}
assetId={props.assetId}
/>
</div>
);
@@ -7,6 +7,7 @@ import withQuery from 'coral-framework/hocs/withQuery';
import {getDefinitionName} from 'coral-framework/utils';
import * as notification from 'coral-admin/src/services/notification';
import t, {timeago} from 'coral-framework/services/i18n';
import update from 'immutability-helper';
import {withSetUserStatus, withSuspendUser, withSetCommentStatus} from 'coral-framework/graphql/mutations';
@@ -80,12 +81,12 @@ class ModerationContainer extends Component {
return this.props.setCommentStatus({commentId, status: 'REJECTED'});
}
loadMore = ({limit = 10, cursor, sort, tab, asset_id}) => {
let variables = {
limit,
cursor,
sort,
asset_id
loadMore = (tab) => {
const variables = {
limit: 10,
cursor: this.props.root[tab].endCursor,
sort: this.props.data.variables.sort,
asset_id: this.props.data.variables.asset_id,
};
switch(tab) {
case 'all':
@@ -108,14 +109,12 @@ class ModerationContainer extends Component {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables,
updateQuery: (oldData, {fetchMoreResult:{comments}}) => {
return {
...oldData,
[tab]: [
...oldData[tab],
...comments
]
};
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
return update(prev, {
[tab]: {
nodes: {$push: comments.nodes},
},
});
}
});
};
@@ -145,11 +144,13 @@ class ModerationContainer extends Component {
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $sort: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sort: $sort, action_type: $action_type}) {
...${getDefinitionName(Comment.fragments.comment)}
action_summaries {
count
... on FlagActionSummary {
reason
nodes {
...${getDefinitionName(Comment.fragments.comment)}
action_summaries {
count
... on FlagActionSummary {
reason
}
}
}
}
@@ -157,6 +158,18 @@ const LOAD_MORE_QUERY = gql`
${Comment.fragments.comment}
`;
const commentConnectionFragment = gql`
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
nodes {
...${getDefinitionName(Comment.fragments.comment)}
}
hasNextPage
startCursor
endCursor
}
${Comment.fragments.comment}
`;
const withModQueueQuery = withQuery(gql`
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER) {
all: comments(query: {
@@ -164,21 +177,21 @@ const withModQueueQuery = withQuery(gql`
asset_id: $asset_id,
sort: $sort
}) {
...${getDefinitionName(Comment.fragments.comment)}
...CoralAdmin_Moderation_CommentConnection
}
accepted: comments(query: {
statuses: [ACCEPTED],
asset_id: $asset_id,
sort: $sort
}) {
...${getDefinitionName(Comment.fragments.comment)}
...CoralAdmin_Moderation_CommentConnection
}
premod: comments(query: {
statuses: [PREMOD],
asset_id: $asset_id,
sort: $sort
}) {
...${getDefinitionName(Comment.fragments.comment)}
...CoralAdmin_Moderation_CommentConnection
}
flagged: comments(query: {
action_type: FLAG,
@@ -186,14 +199,14 @@ const withModQueueQuery = withQuery(gql`
statuses: [NONE, PREMOD],
sort: $sort
}) {
...${getDefinitionName(Comment.fragments.comment)}
...CoralAdmin_Moderation_CommentConnection
}
rejected: comments(query: {
statuses: [REJECTED],
asset_id: $asset_id,
sort: $sort
}) {
...${getDefinitionName(Comment.fragments.comment)}
...CoralAdmin_Moderation_CommentConnection
}
assets: assets {
id
@@ -224,7 +237,7 @@ const withModQueueQuery = withQuery(gql`
organizationName
}
}
${Comment.fragments.comment}
${commentConnectionFragment}
`, {
options: ({params: {id = null}, moderation: {sortOrder}}) => {
return {
@@ -87,12 +87,7 @@ class Comment extends React.Component {
name: PropTypes.string
})
),
replies: PropTypes.arrayOf(
PropTypes.shape({
body: PropTypes.string.isRequired,
id: PropTypes.string.isRequired
})
),
replies: PropTypes.object,
user: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
@@ -195,7 +190,7 @@ class Comment extends React.Component {
let commentClass = parentId
? `reply ${styles.Reply}`
: `comment ${styles.Comment}`;
commentClass += comment.id === 'pending' ? ` ${styles.pendingComment}` : '';
commentClass += comment.id.indexOf('pending') >= 0 ? ` ${styles.pendingComment}` : '';
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
const notifyOnError = (fn, errorToMessage) =>
@@ -375,7 +370,7 @@ class Comment extends React.Component {
/>
: null}
{comment.replies &&
comment.replies.map((reply) => {
comment.replies.nodes.map((reply) => {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
@@ -408,13 +403,10 @@ class Comment extends React.Component {
{comment.replies &&
<div className="coral-load-more-replies">
<LoadMore
assetId={asset.id}
comments={comment.replies}
parentId={comment.id}
topLevel={false}
replyCount={comment.replyCount}
moreComments={comment.replyCount > comment.replies.length}
loadMore={loadMore}
moreComments={comment.replyCount > comment.replies.nodes.length}
loadMore={() => loadMore(comment.id)}
/>
</div>}
</div>
@@ -1,26 +1,7 @@
import React, {PropTypes} from 'react';
import {ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream';
import {Button} from 'coral-ui';
import t from 'coral-framework/services/i18n';
const loadMoreComments = (assetId, comments, loadMore, parentId, replyCount) => {
let cursor = null;
if (comments.length) {
cursor = parentId
? comments[0].created_at
: comments[comments.length - 1].created_at;
}
loadMore({
limit: parentId ? replyCount : ADDTL_COMMENTS_ON_LOAD_MORE,
cursor,
asset_id: assetId,
parent_id: parentId,
sort: parentId ? 'CHRONOLOGICAL' : 'REVERSE_CHRONOLOGICAL'
});
};
class LoadMore extends React.Component {
componentDidMount () {
@@ -40,13 +21,13 @@ class LoadMore extends React.Component {
}
render () {
const {assetId, comments, loadMore, moreComments, parentId, replyCount, topLevel} = this.props;
const {topLevel, moreComments, loadMore, replyCount} = this.props;
return moreComments
? <div className='coral-load-more'>
<Button
onClick={() => {
this.initialState = false;
loadMoreComments(assetId, comments, loadMore, parentId, replyCount);
loadMore();
}}>
{topLevel ? t('framework.view_more_comments') : this.replyCountFormat(replyCount)}
</Button>
@@ -56,11 +37,8 @@ class LoadMore extends React.Component {
}
LoadMore.propTypes = {
assetId: PropTypes.string.isRequired,
comments: PropTypes.array.isRequired,
moreComments: PropTypes.bool.isRequired,
topLevel: PropTypes.bool.isRequired,
replyCount: PropTypes.number,
topLevel: PropTypes.bool.isRequired,
loadMore: PropTypes.func.isRequired
};
@@ -2,15 +2,10 @@ import React, {PropTypes} from 'react';
import t from 'coral-framework/services/i18n';
const onLoadMoreClick = ({loadMore, commentCount, firstCommentDate, assetId, setCommentCountCache}) => (e) => {
const onLoadMoreClick = ({loadMore, commentCount, setCommentCountCache}) => (e) => {
e.preventDefault();
setCommentCountCache(commentCount);
loadMore({
asset_id: assetId,
limit: 500,
cursor: firstCommentDate,
sort: 'CHRONOLOGICAL'
}, true);
loadMore();
};
const NewCount = (props) => {
@@ -33,8 +28,6 @@ NewCount.propTypes = {
commentCount: PropTypes.number.isRequired,
commentCountCache: PropTypes.number,
loadMore: PropTypes.func.isRequired,
assetId: PropTypes.string.isRequired,
firstCommentDate: PropTypes.string.isRequired
};
export default NewCount;
@@ -31,7 +31,6 @@ class Stream extends React.Component {
addNotification,
postFlag,
postDontAgree,
loadMore,
deleteAction,
showSignInDialog,
addCommentTag,
@@ -55,14 +54,6 @@ class Stream extends React.Component {
user.suspension.until &&
new Date(user.suspension.until) > new Date();
const hasOlderComments = !!(asset &&
asset.lastComment &&
asset.lastComment.id !== asset.comments[asset.comments.length - 1].id);
// Find the created_at date of the first comment. If no comments exist, set the date to a week ago.
const firstCommentDate = asset.comments[0]
? asset.comments[0].created_at
: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString();
const commentIsIgnored = (comment) => {
return (
me &&
@@ -138,7 +129,7 @@ class Stream extends React.Component {
highlighted={comment.id}
postFlag={this.props.postFlag}
postDontAgree={this.props.postDontAgree}
loadMore={this.props.loadMore}
loadMore={this.props.loadNewReplies}
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
key={highlightedComment.id}
@@ -153,13 +144,11 @@ class Stream extends React.Component {
<NewCount
commentCount={asset.commentCount}
commentCountCache={commentCountCache}
loadMore={this.props.loadMore}
firstCommentDate={firstCommentDate}
assetId={asset.id}
setCommentCountCache={this.props.setCommentCountCache}
loadMore={this.props.loadNewComments}
/>
<div className="embed__stream">
{comments.map((comment) => {
{comments && comments.nodes.map((comment) => {
return commentIsIgnored(comment)
? <IgnoredCommentTombstone key={comment.id} />
: <Comment
@@ -179,7 +168,7 @@ class Stream extends React.Component {
removeCommentTag={removeCommentTag}
ignoreUser={ignoreUser}
commentIsIgnored={commentIsIgnored}
loadMore={loadMore}
loadMore={this.props.loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
@@ -194,10 +183,8 @@ class Stream extends React.Component {
</div>
<LoadMore
topLevel={true}
assetId={asset.id}
comments={asset.comments}
moreComments={hasOlderComments}
loadMore={this.props.loadMore}
moreComments={asset.comments.hasNextPage}
loadMore={this.props.loadMoreComments}
/>
</div>}
</div>
@@ -2,14 +2,12 @@ import React from 'react';
import {gql, compose} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import uniqBy from 'lodash/uniqBy';
import sortBy from 'lodash/sortBy';
import isNil from 'lodash/isNil';
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from '../constants/stream';
import {NEW_COMMENT_COUNT_POLL_INTERVAL, ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream';
import {
withPostComment, withPostFlag, withPostDontAgree, withDeleteAction,
withAddCommentTag, withRemoveCommentTag, withIgnoreUser, withEditComment,
} from 'coral-framework/graphql/mutations';
import update from 'immutability-helper';
import {notificationActions, authActions} from 'coral-framework';
import {editName} from 'coral-framework/actions/user';
@@ -33,80 +31,128 @@ class StreamContainer extends React.Component {
});
};
// handle paginated requests for more Comments pertaining to the Asset
loadMore = ({limit, cursor, parent_id = null, asset_id, sort}, newComments) => {
loadNewReplies = (parent_id) => {
const comment = this.props.root.comment
? this.props.root.comment.parent || this.props.root.comment // highlighted comment.
: this.props.root.asset.comments.nodes.filter((comment) => comment.id === parent_id)[0];
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
limit, // how many comments are we returning
cursor, // the date of the first/last comment depending on the sort order
parent_id, // if null, we're loading more top-level comments, if not, we're loading more replies to a comment
asset_id, // the id of the asset we're currently on
sort, // CHRONOLOGICAL or REVERSE_CHRONOLOGICAL
limit: parent_id ? 999999 : ADDTL_COMMENTS_ON_LOAD_MORE,
cursor: comment.replies.endCursor,
parent_id,
asset_id: this.props.root.asset.id,
sort: 'CHRONOLOGICAL',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (oldData, {fetchMoreResult:{new_top_level_comments}}) => {
let updatedAsset;
if (!isNil(oldData.comment)) { // loaded replies on a highlighted (permalinked) comment
let comment = {};
if (oldData.comment && oldData.comment.parent) {
// put comments (replies) onto the oldData.comment.parent object
// the initial comment permalinked was a reply
const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.parent.replies], 'id');
comment.parent = {...oldData.comment.parent, replies: sortBy(uniqReplies, 'created_at')};
} else if (oldData.comment) {
// put the comments (replies) directly onto oldData.comment
// the initial comment permalinked was a top-level comment
const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.replies], 'id');
comment.replies = sortBy(uniqReplies, 'created_at');
}
updatedAsset = {
...oldData,
comment: {
...oldData.comment,
...comment
}
};
} else if (parent_id) { // If loading more replies
updatedAsset = {
...oldData,
asset: {
...oldData.asset,
comments: oldData.asset.comments.map((comment) => {
// since the dipslayed replies and the returned replies can overlap,
// pull out the unique ones.
const uniqueReplies = uniqBy([...new_top_level_comments, ...comment.replies], 'id');
// since we just gave the returned replies precedence, they're now out of order.
// resort according to date.
return comment.id === parent_id
? {...comment, replies: sortBy(uniqueReplies, 'created_at')}
: comment;
})
}
};
} else { // If loading more top-level comments
updatedAsset = {
...oldData,
asset: {
...oldData.asset,
comments: newComments ? [...new_top_level_comments.reverse(), ...oldData.asset.comments]
: [...oldData.asset.comments, ...new_top_level_comments]
}
};
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
if (!comments.nodes.length) {
return prev;
}
return updatedAsset;
}
const updateNode = (node) =>
update(node, {
replies: {
endCursor: {$set: comments.endCursor},
nodes: {$apply: (nodes) => nodes
.concat(comments.nodes.filter(
(comment) => !nodes.some((node) => node.id === comment.id)
))
.sort(ascending)
},
},
});
// highlighted comment.
if (prev.comment) {
if (prev.comment.parent) {
return update(prev, {
comment: {
parent: {$apply: (comment) => updateNode(comment)},
}
});
}
return update(prev, {
comment: {$apply: (comment) => updateNode(comment)},
});
}
return update(prev, {
asset: {
comments: {
nodes: {
$apply: (nodes) => nodes.map(
(node) => node.id !== parent_id
? node
: updateNode(node)
)
},
},
},
});
},
});
}
loadNewComments = () => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
limit: ADDTL_COMMENTS_ON_LOAD_MORE,
cursor: this.props.root.asset.comments.startCursor,
parent_id: null,
asset_id: this.props.root.asset.id,
sort: 'CHRONOLOGICAL',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
if (!comments.nodes.length) {
return prev;
}
return update(prev, {
asset: {
comments: {
startCursor: {$set: comments.endCursor},
nodes: {$apply: (nodes) => comments.nodes.filter(
(comment) => !nodes.some((node) => node.id === comment.id)
)
.concat(nodes)
.sort(descending)
},
},
},
});
},
});
};
loadMoreComments = () => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
limit: ADDTL_COMMENTS_ON_LOAD_MORE,
cursor: this.props.root.asset.comments.endCursor,
parent_id: null,
asset_id: this.props.root.asset.id,
sort: 'REVERSE_CHRONOLOGICAL',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
if (!comments.nodes.length) {
return prev;
}
return update(prev, {
asset: {
comments: {
hasNextPage: {$set: comments.hasNextPage},
endCursor: {$set: comments.endCursor},
nodes: {$push: comments.nodes},
},
},
});
},
});
};
@@ -127,18 +173,44 @@ class StreamContainer extends React.Component {
}
render() {
return <Stream {...this.props} loadMore={this.loadMore}/>;
return <Stream
{...this.props}
loadMore={this.loadMore}
loadMoreComments={this.loadMoreComments}
loadNewComments={this.loadNewComments}
loadNewReplies={this.loadNewReplies}
/>;
}
}
const ascending = (a, b) => {
const dateA = new Date(a.created_at);
const dateB = new Date(b.created_at);
if (dateA < dateB) { return -1; }
if (dateA > dateB) { return 1; }
return 0;
};
const descending = (a, b) => ascending(a, b) * -1;
const LOAD_COMMENT_COUNTS_QUERY = gql`
query CoralEmbedStream_LoadCommentCounts($assetUrl: String, $assetId: ID, $excludeIgnored: Boolean) {
query CoralEmbedStream_LoadCommentCounts($assetUrl: String, , $commentId: ID!, $assetId: ID, $hasComment: Boolean!, $excludeIgnored: Boolean) {
comment(id: $commentId) @include(if: $hasComment) {
id
parent {
id
replyCount(excludeIgnored: $excludeIgnored)
}
replyCount(excludeIgnored: $excludeIgnored)
}
asset(id: $assetId, url: $assetUrl) {
id
commentCount(excludeIgnored: $excludeIgnored)
comments(limit: 10) {
id
replyCount(excludeIgnored: $excludeIgnored)
comments(limit: 10) @skip(if: $hasComment) {
nodes {
id
replyCount(excludeIgnored: $excludeIgnored)
}
}
}
}
@@ -146,12 +218,42 @@ const LOAD_COMMENT_COUNTS_QUERY = gql`
const LOAD_MORE_QUERY = gql`
query CoralEmbedStream_LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER, $excludeIgnored: Boolean) {
new_top_level_comments: comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) {
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies(limit: 3) {
comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies(limit: 3, excludeIgnored: $excludeIgnored) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
}
hasNextPage
startCursor
endCursor
}
}
hasNextPage
startCursor
endCursor
}
}
${Comment.fragments.comment}
`;
const commentFragment = gql`
fragment CoralEmbedStream_Stream_comment on Comment {
id
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
}
hasNextPage
startCursor
endCursor
}
}
${Comment.fragments.comment}
@@ -161,17 +263,9 @@ const fragments = {
root: gql`
fragment CoralEmbedStream_Stream_root on RootQuery {
comment(id: $commentId) @include(if: $hasComment) {
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies {
...${getDefinitionName(Comment.fragments.comment)}
}
...CoralEmbedStream_Stream_comment
parent {
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies {
...${getDefinitionName(Comment.fragments.comment)}
}
...CoralEmbedStream_Stream_comment
}
}
asset(id: $assetId, url: $assetUrl) {
@@ -193,17 +287,15 @@ const fragments = {
charCount
requireEmailConfirmation
}
lastComment {
id
}
commentCount(excludeIgnored: $excludeIgnored)
totalCommentCount(excludeIgnored: $excludeIgnored)
comments(limit: 10, excludeIgnored: $excludeIgnored) {
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies(limit: 3, excludeIgnored: $excludeIgnored) {
...${getDefinitionName(Comment.fragments.comment)}
comments(limit: 10, excludeIgnored: $excludeIgnored) @skip(if: $hasComment) {
nodes {
...CoralEmbedStream_Stream_comment
}
hasNextPage
startCursor
endCursor
}
}
me {
@@ -218,7 +310,7 @@ const fragments = {
...${getDefinitionName(Comment.fragments.root)}
}
${Comment.fragments.root}
${Comment.fragments.comment}
${commentFragment}
`,
};
+41 -101
View File
@@ -1,13 +1,20 @@
import {gql} from 'react-apollo';
import {add} from 'coral-framework/services/graphqlRegistry';
import update from 'immutability-helper';
import uuid from 'uuid/v4';
import {insertCommentIntoEmbedQuery, removeCommentFromEmbedQuery} from './utils';
const extension = {
fragments: {
EditCommentResponse: gql`
fragment CoralEmbedStream_EditCommentResponse on EditCommentResponse {
comment {
id
status
body
editing {
edited
}
}
}
`,
@@ -50,7 +57,12 @@ const extension = {
comment {
...CoralEmbedStream_CreateCommentResponse_Comment
replies {
...CoralEmbedStream_CreateCommentResponse_Comment
nodes {
...CoralEmbedStream_CreateCommentResponse_Comment
}
startCursor
endCursor
hasNextPage
}
}
}
@@ -116,8 +128,11 @@ const extension = {
}) => ({
optimisticResponse: {
createComment: {
__typename: 'CreateCommentResponse',
comment: {
__typename: 'Comment',
user: {
__typename: 'User',
id: auth.toJS().user.id,
name: auth.toJS().user.username
},
@@ -128,114 +143,39 @@ const extension = {
action_summaries: [],
tags,
status: null,
id: 'pending'
replyCount: 0,
replies: {
__typename: 'CommentConnection',
nodes: [],
hasNextPage: false,
startCursor: new Date().toISOString(),
endCursor: new Date().toISOString(),
},
editing: {
__typename: 'EditInfo',
editableUntil: new Date().toISOString(),
edited: false,
},
id: `pending-${uuid()}`,
}
}
},
updateQueries: {
CoralEmbedStream_Embed: (previousData, {mutationResult: {data: {createComment: {comment}}}}) => {
if (previousData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
return previousData;
CoralEmbedStream_Embed: (prev, {mutationResult: {data: {createComment: {comment}}}}) => {
if (prev.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
return prev;
}
let updatedAsset;
// If posting a reply
if (parent_id) {
updatedAsset = {
...previousData,
asset: {
...previousData.asset,
comments: previousData.asset.comments.map((oldComment) => {
return oldComment.id === parent_id
? {...oldComment, replies: [...oldComment.replies, comment], replyCount: oldComment.replyCount + 1}
: oldComment;
})
}
};
} else {
// If posting a top-level comment
updatedAsset = {
...previousData,
asset: {
...previousData.asset,
commentCount: previousData.asset.commentCount + 1,
comments: [comment, ...previousData.asset.comments]
}
};
}
return updatedAsset;
}
return insertCommentIntoEmbedQuery(prev, parent_id, comment);
},
}
}),
EditComment: ({
variables: {id, edit},
}) => ({
EditComment: () => ({
updateQueries: {
CoralEmbedStream_Embed: (previousData, {mutationResult: {data: {editComment: {comment}}}}) => {
const {status} = comment;
const updateCommentWithEdit = (comment, edit) => {
const {body} = edit;
const editedComment = update(comment, {
$merge: {
body
},
editing: {$merge:{edited:true}}
});
return editedComment;
};
const commentIsStillVisible = (comment) => {
return !((id === comment.id) && (['PREMOD', 'REJECTED'].includes(status)));
};
const resultReflectingEdit = update(previousData, {
asset: {
comments: {
$apply: (comments) => {
return comments.filter(commentIsStillVisible).map((comment) => {
let replyWasEditedToBeHidden = false;
if (comment.id === id) {
return updateCommentWithEdit(comment, edit);
}
const commentWithUpdatedReplies = update(comment, {
replies: {
$apply: (comments) => {
return comments
.filter((c) => {
if (commentIsStillVisible(c)) {
return true;
}
replyWasEditedToBeHidden = true;
return false;
})
.map((comment) => {
if (comment.id === id) {
return updateCommentWithEdit(comment, edit);
}
return comment;
});
}
},
});
// If a reply was edited to be hdiden, then this parent needs its replyCount to be decremented.
if (replyWasEditedToBeHidden) {
return update(commentWithUpdatedReplies, {
replyCount: {
$apply: (replyCount) => {
return replyCount - 1;
}
}
});
}
return commentWithUpdatedReplies;
});
}
}
}
});
return resultReflectingEdit;
CoralEmbedStream_Embed: (prev, {mutationResult: {data: {editComment: {comment}}}}) => {
if (!['PREMOD', 'REJECTED'].includes(comment.status)) {
return null;
}
return removeCommentFromEmbedQuery(prev, comment.id);
},
},
}),
@@ -0,0 +1,114 @@
import update from 'immutability-helper';
function findAndInsertComment(parent, id, comment) {
const [connectionField, countField, action] = parent.comments
? ['comments', 'commentCount', '$unshift']
: ['replies', 'replyCount', '$push'];
if (!id || parent.id === id) {
return update(parent, {
[connectionField]: {
nodes: {[action]: [comment]},
},
[countField]: {$apply: (c) => c + 1},
});
}
const connection = parent[connectionField];
if (!connection) {
return parent;
}
return update(parent, {
[connectionField]: {
nodes: {
$apply: (nodes) =>
nodes.map((node) => findAndInsertComment(node, id, comment))
},
},
});
}
export function insertCommentIntoEmbedQuery(root, id, comment) {
// Increase total comment count by one.
root = update(root, {
asset: {
totalCommentCount: {$apply: (c) => c + 1},
},
});
if (root.comment) {
if (root.comment.parent) {
return update(root, {
comment: {
parent: {
$apply: (node) => findAndInsertComment(node, id, comment),
},
},
});
}
return update(root, {
comment: {
$apply: (node) => findAndInsertComment(node, id, comment),
},
});
}
return update(root, {
asset: {$apply: (asset) => findAndInsertComment(asset, id, comment)},
});
}
function findAndRemoveComment(parent, id) {
const [connectionField, countField] = parent.comments
? ['comments', 'commentCount']
: ['replies', 'replyCount'];
const connection = parent[connectionField];
if (!connection) {
return parent;
}
let next = connection.nodes.filter((node) => node.id !== id);
if (next.length === connection.nodes.length) {
next = next.map((node) => findAndRemoveComment(node, id));
}
let changes = {
[connectionField]: {
nodes: {$set: next},
},
};
if (parent[countField] && next.length !== connection.nodes.length) {
changes[countField] = {$set: changes[countField] - 1};
}
return update(parent, changes);
}
export function removeCommentFromEmbedQuery(root, id) {
// Decrease total comment by one.
root = update(root, {
asset: {
totalCommentCount: {$apply: (c) => c - 1},
},
});
if (root.comment) {
if (root.comment.parent) {
return update(root, {
comment: {
parent: {
$apply: (node) => findAndRemoveComment(node, id),
},
},
});
}
return update(root, {
comment: {
$apply: (node) => findAndRemoveComment(node, id),
},
});
}
return update(root, {
asset: {$apply: (asset) => findAndRemoveComment(asset, id)},
});
}
@@ -6,7 +6,7 @@ const name = 'coral-plugin-comment-count';
const CommentCount = ({count}) => {
return <div className={`${name}-text`}>
{`${count} ${count === 1 ? t('comment.comment') : t('comment_plural')}`}
{`${count} ${count === 1 ? t('comment_singular') : t('comment_plural')}`}
</div>;
};
@@ -167,6 +167,7 @@ class CommentBox extends React.Component {
fill="commentInputDetailArea"
registerHook={this.registerHook}
unregisterHook={this.unregisterHook}
isReply={isReply}
inline
/>}
cancelButtonClicked={cancelButtonClicked}
@@ -72,8 +72,8 @@ class ProfileContainer extends Component {
<hr />
<h3>{t('framework.my_comments')}</h3>
{me.comments.length
? <CommentHistory comments={me.comments} asset={asset} link={link} />
{me.comments.nodes.length
? <CommentHistory comments={me.comments.nodes} asset={asset} link={link} />
: <p>{t('user_no_comment')}</p>}
</div>
);
@@ -90,14 +90,16 @@ const withQuery = graphql(
username,
}
comments {
id
body
asset {
nodes {
id
title
url
body
asset {
id
title
url
}
created_at
}
created_at
}
}
}`
+4 -2
View File
@@ -8,7 +8,7 @@ summary:
[![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk)
Online comments are broken. Our open-source Talk tool rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here.](https://coralproject.net/products/talk.html)
Online comments are broken. Mozilla's open-source, plugin-based tool Talk rethinks how moderation, comment display, and conversation function online, creating the opportunity for better, smarter discussions designed to fit your needs. [Read more about Talk here.](https://coralproject.net/products/talk.html)
Third party licenses are available via the `/client/3rdpartylicenses.txt`
endpoint when the server is running with built assets.
@@ -30,6 +30,8 @@ See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CO
- iPad
- iPad Pro
- iPhone 7 Plus
- iPhone 7
- iPhone 6 Plus
- iPhone 6
- iPhone 5
@@ -42,7 +44,7 @@ See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CO
### Android Devices
- Galaxy S5
- Galaxy S5x
- Nexus 5X
- Nexus 6P
+2 -1
View File
@@ -49,7 +49,8 @@ const getAssetsForMetrics = async ({loaders: {Actions, Comments}}) => {
const ids = actions.map(({item_id}) => item_id);
return Comments.getByQuery({ids});
return Comments.getByQuery({ids})
.then((connection) => connection.nodes);
};
/**
+18 -3
View File
@@ -293,9 +293,24 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a
}
}
return comments
.sort({created_at: sort === 'REVERSE_CHRONOLOGICAL' ? -1 : 1})
.limit(limit);
let query = comments
.sort({created_at: sort === 'REVERSE_CHRONOLOGICAL' ? -1 : 1});
if (limit) {
query = query.limit(limit + 1);
}
return query.then((nodes) => {
let hasNextPage = false;
if (limit && nodes.length > limit) {
hasNextPage = true;
nodes.splice(limit, 1);
}
return Promise.resolve({
startCursor: nodes.length ? nodes[0].created_at : null,
endCursor: nodes.length ? nodes[nodes.length - 1].created_at : null,
hasNextPage,
nodes,
});
});
};
/**
+2 -2
View File
@@ -338,9 +338,9 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
const status = await resolveNewCommentStatus(context, {asset_id, body}, wordlist, settings);
// Execute the edit.
await CommentsService.edit(id, context.user.id, {body, status});
const comment = await CommentsService.edit(id, context.user.id, {body, status});
return {status};
return comment;
};
module.exports = (context) => {
-7
View File
@@ -1,11 +1,4 @@
const Asset = {
lastComment({id}, _, {loaders: {Comments}}) {
return Comments.getByQuery({
asset_id: id,
limit: 1,
parent_id: null
}).then((data) => data[0]);
},
recentComments({id}, _, {loaders: {Comments}}) {
return Comments.genRecentComments.load(id);
},
+20 -7
View File
@@ -76,7 +76,7 @@ type User {
ignoredUsers: [User!]
# returns all comments based on a query.
comments(query: CommentsQuery): [Comment!]
comments(query: CommentsQuery): CommentConnection!
# reliable is the reference to a given user's Reliability. If the requesting
# user does not have permission to access the reliability, null will be
@@ -229,7 +229,7 @@ type Comment {
recentReplies: [Comment!]
# the replies that were made to the comment.
replies(sort: SORT_ORDER = CHRONOLOGICAL, limit: Int = 3, excludeIgnored: Boolean): [Comment!]
replies(sort: SORT_ORDER = CHRONOLOGICAL, limit: Int = 3, excludeIgnored: Boolean): CommentConnection!
# The count of replies on a comment.
replyCount(excludeIgnored: Boolean): Int
@@ -253,6 +253,22 @@ type Comment {
editing: EditInfo
}
# CommentConnection represents a paginable subset of a comment list.
type CommentConnection {
# Indicates that there are more comments after this subset.
hasNextPage: Boolean!
# Cursor of first comment in subset.
startCursor: Date
# Cursor of last comment in subset.
endCursor: Date
# Subset of comments.
nodes: [Comment!]!
}
################################################################################
## Actions
################################################################################
@@ -462,14 +478,11 @@ type Asset {
# The URL that the asset is located on.
url: String
# Returns last comment
lastComment: Comment
# Returns recent comments
recentComments: [Comment!]
# The top level comments that are attached to the asset.
comments(sort: SORT_ORDER = REVERSE_CHRONOLOGICAL, limit: Int = 10, excludeIgnored: Boolean): [Comment!]
comments(sort: SORT_ORDER = REVERSE_CHRONOLOGICAL, limit: Int = 10, excludeIgnored: Boolean): CommentConnection!
# The count of top level comments on the asset.
commentCount(excludeIgnored: Boolean): Int
@@ -576,7 +589,7 @@ type RootQuery {
asset(id: ID, url: String): Asset
# Comments returned based on a query.
comments(query: CommentsQuery!): [Comment!]
comments(query: CommentsQuery!): CommentConnection
# Return the count of comments satisfied by the query. Note that this edge is
# expensive as it is not batched. Requires the `ADMIN` role.
+1
View File
@@ -27,6 +27,7 @@ en:
characters_remaining: "characters remaining"
comment_is_best: "This comment is one of the best"
comment_offensive: "This comment is offensive"
comment_singular: Comment
comment_plural: Comments
comment_post_banned_word: "Your comment contains one or more words that are not permitted, so it will not be published. If you think this message is incorrect, please contact our moderation team."
comment_post_notif: "Your comment has been posted."
+1
View File
@@ -27,6 +27,7 @@ es:
characters_remaining: "carácteres restantes"
comment_is_best: "Este comentario es uno de los mejores"
comment_offensive: "Este comentario es ofensivo"
comment_singular: Comentario
comment_plural: Comentarios
comment_post_banned_word: "Tu comentario contiene una o más palabras que no están permitidas en nuestro espacio, por lo que no será publicado. Si crees que es un error, por favor contacta a nuestro equipo de moderación."
comment_post_notif: "Tu comentario ha sido publicado."
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "1.7.0",
"version": "1.8.0",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"scripts": {
+20 -9
View File
@@ -45,16 +45,25 @@ describe('graph.queries.asset', () => {
query assetCommentsQuery($id: ID!) {
asset(id: $id) {
comments(limit: 10) {
id
body
nodes {
id
body
created_at
}
startCursor
endCursor
hasNextPage
}
}
}
`;
const res = await graphql(schema, assetCommentsQuery, {}, context, {id: asset.id});
expect(res.erros).is.empty;
const comments = res.data.asset.comments;
expect(comments.length).to.equal(2);
const {nodes, startCursor, endCursor, hasNextPage} = res.data.asset.comments;
expect(nodes.length).to.equal(2);
expect(startCursor).to.equal(nodes[0].created_at);
expect(endCursor).to.equal(nodes[1].created_at);
expect(hasNextPage).to.be.false;
});
it('can query comments edge to exclude comments ignored by user', async () => {
@@ -65,7 +74,7 @@ describe('graph.queries.asset', () => {
asset_id: asset.id,
body: `hello there! ${String(Math.random()).slice(2)}`,
})));
// Add the second user to the list of ignored users.
context.user.ignoresUsers.push(users[1].id);
@@ -73,8 +82,10 @@ describe('graph.queries.asset', () => {
query assetCommentsQuery($id: ID!, $url: String!, $excludeIgnored: Boolean!) {
asset(id: $id, url: $url) {
comments(limit: 10, excludeIgnored: $excludeIgnored) {
id
body
nodes {
id
body
}
}
}
}
@@ -90,8 +101,8 @@ describe('graph.queries.asset', () => {
console.error(res.errors);
}
expect(res.errors).is.empty;
const comments = res.data.asset.comments;
expect(comments.length).to.equal(1);
const nodes = res.data.asset.comments.nodes;
expect(nodes.length).to.equal(1);
}
});