Merge branch 'master' into user-comment-history

This commit is contained in:
Riley Davis
2017-06-01 10:52:24 -06:00
committed by GitHub
24 changed files with 517 additions and 385 deletions
+4 -2
View File
@@ -44,9 +44,11 @@ sign and verify tokens via a `HS256` algorithm.
Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for
alternative methods of loading configuration during development.
## Plugins
### Using Trust
Talk ships with a plugin architecture that allows developers to significantly extend the platform. For more information, see our [plugin documentation](docs/PLUGINS.md).
Talk ships with core components we call "Trust". This allows Talk to automate certain actions based on previous user behavior.
Our first feature is the notion of Karma. Talk will automatically pre-moderate comments of users who have a negative karma score. You can [see more how karma works here](/services/karma.js).
## Supported Browsers
+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>;
};
@@ -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
}
}
}`
+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
@@ -232,7 +232,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
@@ -256,6 +256,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
################################################################################
@@ -465,14 +481,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
@@ -579,7 +592,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
@@ -31,6 +31,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\
+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);
}
});