Merge pull request #477 from gobengo/ignore

Ignore User
This commit is contained in:
Kim Gardner
2017-04-13 20:05:02 -04:00
committed by GitHub
40 changed files with 1035 additions and 167 deletions
@@ -9,12 +9,12 @@
right: 0;
}
ul {
.wrapper ul {
list-style: none;
padding: 0;
}
ul ul {
.wrapper ul ul {
padding-left: 20px
}
@@ -23,12 +23,12 @@ ul ul {
margin: 12px 12px 12px 0;
}
h4 {
.wrapper h4 {
font-size: 14px;
margin-bottom: 5px;
}
p {
.wrapper p {
max-width: 380px;
}
+11
View File
@@ -12,3 +12,14 @@
filter: blur(2px);
pointer-events: none;
}
.topRightMenu {
float: right;
text-align: right;
cursor: pointer;
margin-top: 5px;
}
.topRightMenu > * {
text-align: initial;
}
+45 -23
View File
@@ -20,6 +20,8 @@ import LikeButton from 'coral-plugin-likes/LikeButton';
import {BestButton, IfUserCanModifyBest, BEST_TAG, commentIsBest, BestIndicator} from 'coral-plugin-best/BestButton';
import LoadMore from 'coral-embed-stream/src/LoadMore';
import {Slot} from 'coral-framework';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import {TopRightMenu} from './TopRightMenu';
import styles from './Comment.css';
@@ -84,11 +86,17 @@ class Comment extends React.Component {
}).isRequired
}).isRequired,
// given a comment, return whether it should be rendered as ignored
commentIsIgnored: React.PropTypes.func,
// dispatch action to add a tag to a comment
addCommentTag: React.PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: React.PropTypes.func,
// dispatch action to ignore another user
ignoreUser: React.PropTypes.func,
}
render () {
@@ -111,7 +119,9 @@ class Comment extends React.Component {
deleteAction,
addCommentTag,
removeCommentTag,
ignoreUser,
disableReply,
commentIsIgnored,
} = this.props;
const like = getActionSummary('LikeActionSummary', comment);
@@ -121,10 +131,10 @@ class Comment extends React.Component {
commentClass += comment.id === 'pending' ? ` ${styles.pendingComment}` : '';
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
const notifyOnError = (fn, errorToMessage) => async () => {
const notifyOnError = (fn, errorToMessage) => async function (...args) {
if (typeof errorToMessage !== 'function') {errorToMessage = (error) => error.message;}
try {
return await fn();
return await fn(...args);
} catch (error) {
addNotification('error', errorToMessage(error));
throw error;
@@ -160,6 +170,16 @@ class Comment extends React.Component {
<PubDate created_at={comment.created_at} />
<Slot fill="commentInfoBar" commentId={comment.id} />
{ (currentUser && (comment.user.id !== currentUser.id))
? <span className={styles.topRightMenu}>
<TopRightMenu
comment={comment}
ignoreUser={ignoreUser}
addNotification={addNotification} />
</span>
: null
}
<Content body={comment.body} />
<div className="commentActionsLeft comment__action-container">
<ActionButton>
@@ -225,27 +245,29 @@ class Comment extends React.Component {
{
comment.replies &&
comment.replies.map(reply => {
return <Comment
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
parentId={comment.id}
postItem={postItem}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
currentUser={currentUser}
postLike={postLike}
postFlag={postFlag}
deleteAction={deleteAction}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
showSignInDialog={showSignInDialog}
reactKey={reply.id}
key={reply.id}
comment={reply}
/>;
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
parentId={comment.id}
postItem={postItem}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
currentUser={currentUser}
postLike={postLike}
postFlag={postFlag}
deleteAction={deleteAction}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
ignoreUser={ignoreUser}
showSignInDialog={showSignInDialog}
reactKey={reply.id}
key={reply.id}
comment={reply} />;
})
}
{
+32 -24
View File
@@ -14,7 +14,7 @@ const {fetchAssetSuccess} = assetActions;
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments';
import {queryStream} from 'coral-framework/graphql/queries';
import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag} from 'coral-framework/graphql/mutations';
import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser} from 'coral-framework/graphql/mutations';
import {editName} from 'coral-framework/actions/user';
import {updateCountCache, viewAllComments} from 'coral-framework/actions/asset';
import {notificationActions, authActions, assetActions, pym} from 'coral-framework';
@@ -71,6 +71,9 @@ class Embed extends React.Component {
// dispatch action to remove a tag from a comment
removeCommentTag: React.PropTypes.func,
// dispatch action to ignore another user
ignoreUser: React.PropTypes.func,
}
componentDidMount () {
@@ -155,13 +158,14 @@ class Embed extends React.Component {
? asset.comments[0].created_at
: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString();
const userBox = <UserBox user={user} logout={() => this.props.logout().then(refetch)} changeTab={this.changeTab}/>;
return (
<div style={expandForLogin}>
<div className="commentStream">
<TabBar onChange={this.changeTab} activeTab={activeTab}>
<Tab><Count count={asset.totalCommentCount}/>
</Tab>
<Tab>{lang.t('MY_COMMENTS')}</Tab>
<Tab><Count count={asset.totalCommentCount}/></Tab>
<Tab>{lang.t('myProfile')}</Tab>
<Tab restricted={!isAdmin}>Configure Stream</Tab>
</TabBar>
{
@@ -174,8 +178,8 @@ class Embed extends React.Component {
this.props.data.refetch();
}}>{lang.t('showAllComments')}</Button>
}
{loggedIn && <UserBox user={user} logout={() => this.props.logout().then(refetch)} changeTab={this.changeTab}/>}
<TabContent show={activeTab === 0}>
{ loggedIn ? userBox : null }
{
openStream
? <div id="commentBox">
@@ -266,10 +270,12 @@ class Embed extends React.Component {
postDontAgree={this.props.postDontAgree}
addCommentTag={this.props.addCommentTag}
removeCommentTag={this.props.removeCommentTag}
ignoreUser={this.props.ignoreUser}
loadMore={this.props.loadMore}
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
comments={asset.comments} />
comments={asset.comments}
ignoredUsers={this.props.userData.ignoredUsers} />
</div>
<LoadMore
topLevel={true}
@@ -279,22 +285,23 @@ class Embed extends React.Component {
loadMore={this.props.loadMore} />
</div>
}
</TabContent>
<TabContent show={activeTab === 1}>
<ProfileContainer
loggedIn={loggedIn}
userData={this.props.userData}
showSignInDialog={this.props.showSignInDialog}
/>
</TabContent>
<TabContent show={activeTab === 2}>
<RestrictedContent restricted={!loggedIn}>
<ConfigureStreamContainer
status={status}
onClick={this.toggleStatus}
/>
</RestrictedContent>
</TabContent>
</TabContent>
<TabContent show={activeTab === 1}>
<ProfileContainer
loggedIn={loggedIn}
userData={this.props.userData}
showSignInDialog={this.props.showSignInDialog}
/>
</TabContent>
<TabContent show={activeTab === 2}>
<RestrictedContent restricted={!loggedIn}>
{ loggedIn ? userBox : null }
<ConfigureStreamContainer
status={status}
onClick={this.toggleStatus}
/>
</RestrictedContent>
</TabContent>
</div>
</div>
);
@@ -304,7 +311,7 @@ class Embed extends React.Component {
const mapStateToProps = state => ({
auth: state.auth.toJS(),
userData: state.user.toJS(),
asset: state.asset.toJS()
asset: state.asset.toJS(),
});
const mapDispatchToProps = dispatch => ({
@@ -317,7 +324,7 @@ const mapDispatchToProps = dispatch => ({
updateCountCache: (id, count) => dispatch(updateCountCache(id, count)),
viewAllComments: () => dispatch(viewAllComments()),
logout: () => dispatch(logout()),
dispatch: d => dispatch(d)
dispatch: d => dispatch(d),
});
export default compose(
@@ -328,6 +335,7 @@ export default compose(
postDontAgree,
addCommentTag,
removeCommentTag,
ignoreUser,
deleteAction,
queryStream,
)(Embed);
@@ -0,0 +1,14 @@
.IgnoreUserWizard {
background-color: #2E343B;
color: white;
padding: 1em;
max-width: 220px;
}
.IgnoreUserWizard header {
font-weight: bold;
}
.IgnoreUserWizard .textAlignRight {
text-align: right;
}
@@ -0,0 +1,66 @@
import React, {PropTypes} from 'react';
import styles from './IgnoreUserWizard.css';
import {Button} from 'coral-ui';
// Guides the user through ignoring another user, including confirming their decision
export class IgnoreUserWizard extends React.Component {
static propTypes = {
// comment on which this menu appears
user: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired,
cancel: PropTypes.func.isRequired,
// actually submit the ignore. Provide {id: user id to ignore}
ignoreUser: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.state = {
// what step of the wizard is the user on
step: 1
};
this.onClickCancel = this.onClickCancel.bind(this);
}
onClickCancel() {
this.props.cancel();
}
render() {
const {user, ignoreUser} = this.props;
const goToStep = (stepNum) => this.setState({step: stepNum});
const step1 = (
<div>
<header>Ignore User</header>
<p>When you ignore a user, all comments they wrote on the site will be hidden from you. You can undo this later from the Profile tab.</p>
<div className={styles.textAlignRight}>
<Button cStyle='cancel' onClick={this.onClickCancel}>Cancel</Button>
<Button onClick={() => goToStep(2)}>Ignore user</Button>
</div>
</div>
);
const onClickIgnoreUser = async () => {
await ignoreUser({id: user.id});
};
const step2Confirmation = (
<div>
<header>Ignore User</header>
<p>Are you sure you want to ignore { user.name }?</p>
<div className={styles.textAlignRight}>
<Button cStyle='cancel' onClick={this.onClickCancel}>Cancel</Button>
<Button onClick={onClickIgnoreUser}>Ignore user</Button>
</div>
</div>
);
const elsForStep = [step1, step2Confirmation];
const {step} = this.state;
const elForThisStep = elsForStep[step - 1];
return (
<div className={styles.IgnoreUserWizard}>
{ elForThisStep }
</div>
);
}
}
@@ -0,0 +1,22 @@
import React from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-framework/translations';
const lang = new I18n(translations);
// Render in place of a Comment when the author of the comment is ignored
const IgnoredCommentTombstone = () => (
<div>
<hr aria-hidden={true} />
<p style={{
backgroundColor: '#F0F0F0',
textAlign: 'center',
padding: '1em',
color: '#3E4F71',
}}>
{lang.t('commentIsIgnored')}
</p>
</div>
);
export default IgnoredCommentTombstone;
+39 -24
View File
@@ -1,5 +1,6 @@
import React, {PropTypes} from 'react';
import Comment from './Comment';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
class Stream extends React.Component {
@@ -19,6 +20,12 @@ class Stream extends React.Component {
// dispatch action to remove a tag from a comment
removeCommentTag: PropTypes.func,
// dispatch action to ignore another user
ignoreUser: React.PropTypes.func,
// list of user ids that should be rendered as ignored
ignoredUsers: React.PropTypes.arrayOf(React.PropTypes.string)
}
constructor(props) {
@@ -42,35 +49,43 @@ class Stream extends React.Component {
showSignInDialog,
addCommentTag,
removeCommentTag,
pluginProps
pluginProps,
ignoreUser,
ignoredUsers,
} = this.props;
const commentIsIgnored = (comment) => ignoredUsers && ignoredUsers.includes(comment.user.id);
return (
<div id='stream'>
{
comments.map(comment =>
<Comment
disableReply={!open}
setActiveReplyBox={this.props.setActiveReplyBox}
activeReplyBox={this.props.activeReplyBox}
addNotification={addNotification}
depth={0}
postItem={postItem}
asset={asset}
currentUser={currentUser}
postLike={postLike}
postFlag={postFlag}
postDontAgree={postDontAgree}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
loadMore={loadMore}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
reactKey={comment.id}
comment={comment}
pluginProps={pluginProps}
/>
commentIsIgnored(comment)
? <IgnoredCommentTombstone
key={comment.id}
/>
: <Comment
disableReply={!open}
setActiveReplyBox={this.props.setActiveReplyBox}
activeReplyBox={this.props.activeReplyBox}
addNotification={addNotification}
depth={0}
postItem={postItem}
asset={asset}
currentUser={currentUser}
postLike={postLike}
postFlag={postFlag}
postDontAgree={postDontAgree}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
ignoreUser={ignoreUser}
commentIsIgnored={commentIsIgnored}
loadMore={loadMore}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
reactKey={comment.id}
comment={comment}
pluginProps={pluginProps}
/>
)
}
</div>
@@ -0,0 +1,24 @@
.Toggleable:focus {
outline: none;
}
/**
* Up/Down Chevrons for the top right menu
*/
.chevron {
}
.chevron:before {
content: '⌃';
display: inline-block;
position: relative;
top: 0.25em;
}
/* Down Arrow */
.chevron.down:before {
display: inline-block;
position: relative;
transform: rotate(180deg);
top: 0;
/*top: -0.25em;*/
}
@@ -0,0 +1,94 @@
import React, {PropTypes} from 'react';
import classnames from 'classnames';
import {IgnoreUserWizard} from './IgnoreUserWizard';
import styles from './TopRightMenu.css';
// TopRightMenu appears as a dropdown in the top right of the comment.
// when you click the down cehvron, it expands and shows IgnoreUserWizard
// when you click 'cancel' in the wizard, it closes the menu
export class TopRightMenu extends React.Component {
static propTypes = {
// comment on which this menu appears
comment: PropTypes.shape({
user: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired
}).isRequired,
ignoreUser: PropTypes.func,
// show notification to the user (e.g. for errors)
addNotification: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.state = {
timesReset: 0
};
}
render() {
const {comment, ignoreUser, addNotification} = this.props;
// timesReset is used as Toggleable key so it re-renders on reset (closing the toggleable)
const reset = () => this.setState({timesReset: this.state.timesReset + 1});
const ignoreUserAndCloseMenuAndNotifyOnError = async ({id}) => {
// close menu
reset();
// ignore user
try {
await ignoreUser({id});
} catch (error) {
addNotification('error', 'Failed to ignore user');
throw error;
}
};
return (
<Toggleable key={this.state.timesReset}>
<div style={{position: 'absolute', right: 0, zIndex: 1}}>
<IgnoreUserWizard
user={comment.user}
cancel={reset}
ignoreUser={ignoreUserAndCloseMenuAndNotifyOnError}
/>
</div>
</Toggleable>
);
}
}
const upArrow = <span className={classnames(styles.chevron, styles.up)}></span>;
const downArrow = <span className={classnames(styles.chevron, styles.down)}></span>;
class Toggleable extends React.Component {
constructor(props) {
super(props);
this.toggle = this.toggle.bind(this);
this.close = this.close.bind(this);
this.state = {
isOpen: false
};
}
toggle() {
this.setState({isOpen: ! this.state.isOpen});
}
close() {
this.setState({isOpen: false});
}
render() {
const {children} = this.props;
const {isOpen} = this.state;
return (
// /*onBlur={ this.close } */
<span className={styles.Toggleable} tabIndex="0" >
<span className={styles.toggler}
onClick={this.toggle}>{isOpen ? upArrow : downArrow}</span>
{isOpen ? children : null}
</span>
);
}
}
@@ -1,8 +1,6 @@
* {
font-weight: inherit;
font-family: inherit;
font-style: inherit;
font-size: 100%;
}
html, body {
+2 -2
View File
@@ -5,9 +5,9 @@ class Slot extends Component {
render() {
const {fill, ...rest} = this.props;
return (
<div>
<span>
{getSlotElements(fill, rest)}
</div>
</span>
);
}
}
+2
View File
@@ -6,3 +6,5 @@ export const COMMENTS_BY_USER_SUCCESS = 'COMMENTS_BY_USER_SUCCESS';
export const COMMENTS_BY_USER_FAILURE = 'COMMENTS_BY_USER_FAILURE';
export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
export const UPDATE_USERNAME = 'UPDATE_USERNAME';
export const IGNORE_USER_SUCCESS = 'IGNORE_USER_SUCCESS';
export const STOP_IGNORING_USER_SUCCESS = 'STOP_IGNORING_USER_SUCCESS';
@@ -0,0 +1,7 @@
mutation ignoreUser ($id: ID!) {
ignoreUser(id:$id) {
errors {
translation_key
}
}
}
@@ -6,6 +6,12 @@ import POST_DONT_AGREE from './postDontAgree.graphql';
import DELETE_ACTION from './deleteAction.graphql';
import ADD_COMMENT_TAG from './addCommentTag.graphql';
import REMOVE_COMMENT_TAG from './removeCommentTag.graphql';
import IGNORE_USER from './ignoreUser.graphql';
import STOP_IGNORING_USER from './stopIgnoringUser.graphql';
import MY_IGNORED_USERS from '../queries/myIgnoredUsers.graphql';
import STREAM_QUERY from '../queries/streamQuery.graphql';
import {variablesForStreamQuery} from '../queries';
import commentView from '../fragments/commentView.graphql';
@@ -148,3 +154,40 @@ export const removeCommentTag = graphql(REMOVE_COMMENT_TAG, {
});
}}),
});
export const ignoreUser = graphql(IGNORE_USER, {
props: ({mutate}) => ({
ignoreUser: ({id}) => {
return mutate({
variables: {
id,
},
refetchQueries: [{
query: MY_IGNORED_USERS,
}]
});
}}),
});
export const stopIgnoringUser = graphql(STOP_IGNORING_USER, {
props: ({mutate, ownProps}) => {
return {
stopIgnoringUser: ({id}) => {
return mutate({
variables: {
id,
},
refetchQueries: [
{
query: MY_IGNORED_USERS,
},
{
query: STREAM_QUERY,
variables: variablesForStreamQuery(ownProps),
}
]
});
}
};
}
});
@@ -0,0 +1,7 @@
mutation stopIgnoringUser ($id: ID!) {
stopIgnoringUser(id:$id) {
errors {
translation_key
}
}
}
+29 -15
View File
@@ -3,6 +3,7 @@ import STREAM_QUERY from './streamQuery.graphql';
import LOAD_MORE from './loadMore.graphql';
import GET_COUNTS from './getCounts.graphql';
import MY_COMMENT_HISTORY from './myCommentHistory.graphql';
import MY_IGNORED_USERS from './myIgnoredUsers.graphql';
import uniqBy from 'lodash/uniqBy';
import sortBy from 'lodash/sortBy';
import isNil from 'lodash/isNil';
@@ -28,10 +29,10 @@ export const getCounts = (data) => ({asset_id, limit, sort}) => {
variables: {
asset_id,
limit,
sort
sort,
excludeIgnored: data.variables.excludeIgnored,
},
updateQuery: (oldData, {fetchMoreResult:{asset}}) => {
return {
...oldData,
asset: {
@@ -52,7 +53,8 @@ export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, s
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
sort, // CHRONOLOGICAL or REVERSE_CHRONOLOGICAL
excludeIgnored: data.variables.excludeIgnored,
},
updateQuery: (oldData, {fetchMoreResult:{new_top_level_comments}}) => {
let updatedAsset;
@@ -119,21 +121,25 @@ export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, s
});
};
export const variablesForStreamQuery = ({auth}) => {
// where the query string is from the embeded iframe url
let comment_id = getQueryVariable('comment_id');
let has_comment = comment_id != null;
return {
asset_id: getQueryVariable('asset_id'),
asset_url: getQueryVariable('asset_url'),
comment_id: has_comment ? comment_id : 'no-comment',
has_comment,
excludeIgnored: Boolean(auth && auth.user && auth.user.id),
};
};
// load the comment stream.
export const queryStream = graphql(STREAM_QUERY, {
options: () => {
// where the query string is from the embeded iframe url
let comment_id = getQueryVariable('comment_id');
let has_comment = comment_id != null;
options: (props) => {
return {
variables: {
asset_id: getQueryVariable('asset_id'),
asset_url: getQueryVariable('asset_url'),
comment_id: has_comment ? comment_id : 'no-comment',
has_comment
}
variables: variablesForStreamQuery(props)
};
},
props: ({data}) => ({
@@ -144,3 +150,11 @@ export const queryStream = graphql(STREAM_QUERY, {
});
export const myCommentHistory = graphql(MY_COMMENT_HISTORY, {});
export const myIgnoredUsers = graphql(MY_IGNORED_USERS, {
props: ({data}) => {
return ({
myIgnoredUsersData: data
});
}
});
@@ -1,9 +1,9 @@
#import "../fragments/commentView.graphql"
query LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER) {
new_top_level_comments: comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort}) {
query 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}) {
...commentView
replyCount
replyCount(excludeIgnored: $excludeIgnored)
replies(limit: 3) {
...commentView
}
@@ -0,0 +1,6 @@
query myIgnoredUsers {
myIgnoredUsers {
id,
username,
}
}
@@ -1,18 +1,18 @@
#import "../fragments/commentView.graphql"
query AssetQuery($asset_id: ID, $asset_url: String, $comment_id: ID!, $has_comment: Boolean!) {
query AssetQuery($asset_id: ID, $asset_url: String, $comment_id: ID!, $has_comment: Boolean!, $excludeIgnored: Boolean) {
# the comment here is for loading one comment and it's children, probably after following a permalink
# $has_comment is derived from the comment_id query param in the iframe url,
# which is in turn pulled from the host page url
comment(id: $comment_id) @include(if: $has_comment) {
...commentView
replyCount
replyCount(excludeIgnored: $excludeIgnored)
replies {
...commentView
}
parent {
...commentView
replyCount
replyCount(excludeIgnored: $excludeIgnored)
replies {
...commentView
}
@@ -36,15 +36,15 @@ query AssetQuery($asset_id: ID, $asset_url: String, $comment_id: ID!, $has_comme
charCount
requireEmailConfirmation
}
commentCount
totalCommentCount
lastComment {
id
}
comments(limit: 10) {
commentCount(excludeIgnored: $excludeIgnored)
totalCommentCount(excludeIgnored: $excludeIgnored)
comments(limit: 10, excludeIgnored: $excludeIgnored) {
...commentView
replyCount
replies(limit: 3) {
replyCount(excludeIgnored: $excludeIgnored)
replies(limit: 3, excludeIgnored: $excludeIgnored) {
...commentView
}
}
+12 -4
View File
@@ -1,4 +1,4 @@
import {Map} from 'immutable';
import {Map, Set} from 'immutable';
import * as authActions from '../constants/auth';
import * as actions from '../constants/user';
import * as assetActions from '../constants/assets';
@@ -8,7 +8,8 @@ const initialState = Map({
profiles: [],
settings: {},
myComments: [],
myAssets: [] // the assets from which myComments (above) originated
myAssets: [], // the assets from which myComments (above) originated
ignoredUsers: Set(),
});
const purge = user => {
@@ -38,7 +39,14 @@ export default function user (state = initialState, action) {
return state.set('myAssets', action.assets);
case actions.LOGOUT_SUCCESS:
return initialState;
default :
return state;
case 'APOLLO_MUTATION_RESULT':
switch (action.operationName) {
case 'ignoreUser':
return state.updateIn(['ignoredUsers'], i => i.add(action.variables.id));
case 'stopIgnoringUser':
return state.updateIn(['ignoredUsers'], i => i.delete(action.variables.id));
}
break;
}
return state;
}
+4 -1
View File
@@ -2,6 +2,7 @@
"en": {
"MY_COMMENTS": "My Comments",
"profile": "Profile",
"myProfile": "My profile",
"successUpdateSettings": "The changes you have made have been applied to the comment stream on this article",
"successNameUpdate": "Your username has been updated",
"contentNotAvailable": "This content is not available",
@@ -20,6 +21,7 @@
"newCount": "View {0} new {1}",
"comment": "comment",
"comments": "comments",
"commentIsIgnored": "This comment is hidden because you ignored this user.",
"error": {
"emailNotVerified": "Email address {0} not verified.",
"email": "Not a valid E-Mail",
@@ -44,7 +46,7 @@
"es": {
"profile": "Pérfil",
"MY_COMMENTS": "Mis Comentarios",
"profile": "Pérfil",
"myProfile": "Mi pérfil",
"successUpdateSettings": "La configuración de este articulo fue actualizada",
"successBioUpdate": "Tu biografia fue actualizada",
"contentNotAvailable": "El contenido no se encuentra disponible",
@@ -57,6 +59,7 @@
"newCount": "Ver {0} {1} más",
"comment": "commentario",
"comments": "commentarios",
"commentIsIgnored": "Este comentario está escondido porque has ignorado al usuario.",
"showAllComments": "Mostrar todos los comentarios",
"error": {
"emailNotVerified": "E-mail {0} no verificado.",
+4
View File
@@ -1,6 +1,7 @@
@custom-media --big-viewport (min-width: 780px);
.myComment {
margin: 1em 0;
border-bottom: 1px solid lightgrey;
display: flex;
align-items: baseline;
@@ -24,6 +25,9 @@
.sidebar {
ul {
margin-top: 0;
margin-bottom: 0;
list-style-type: none;
min-width: 136px;
}
@@ -0,0 +1,24 @@
.ignoredUser {
display: table-row;
}
.ignoredUserList {
display: table;
}
.ignoredUser > * {
display: table-cell;
}
.stopListening {
color: #D0011B;
}
.link {
text-decoration: underline;
cursor: pointer;
}
.stopListening:before {
content: '\00a0\00a0\00a0\00a0';
}
@@ -0,0 +1,43 @@
import React, {Component, PropTypes} from 'react';
import styles from './IgnoredUsers.css';
export class IgnoredUsers extends Component {
static propTypes = {
users: PropTypes.arrayOf(PropTypes.shape({
username: PropTypes.string,
id: PropTypes.string,
})).isRequired,
// accepts { id }
stopIgnoring: PropTypes.func.isRequired,
}
render() {
const {users, stopIgnoring} = this.props;
return (
<div>
{
users.length
? <p>Because you ignored these, you do not see their comments.</p>
: null
}
<dl className={styles.ignoredUserList}>
{
users.map(({username, id}) => (
<span className={styles.ignoredUser} key={id}>
<dt key={id}>{ username }</dt>
<dd className={styles.stopListening}>
<a
onClick={() => stopIgnoring({id})}
className={styles.link}>Stop ignoring</a>
</dd>
</span>
))
}
</dl>
</div>
);
}
}
export default IgnoredUsers;
@@ -1,8 +0,0 @@
.header h1 {
margin: 4px 0;
}
.header h2 {
font-size: 13px;
}
@@ -1,12 +0,0 @@
import React, {PropTypes} from 'react';
import styles from './ProfileHeader.css';
const ProfileHeader = ({username}) => (
<div className={styles.header}>
<h1>{username}</h1>
</div>
);
ProfileHeader.propTypes = {username: PropTypes.string.isRequired};
export default ProfileHeader;
@@ -3,10 +3,12 @@ import {compose} from 'react-apollo';
import React, {Component} from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import {myCommentHistory} from 'coral-framework/graphql/queries';
import {myCommentHistory, myIgnoredUsers} from 'coral-framework/graphql/queries';
import {stopIgnoringUser} from 'coral-framework/graphql/mutations';
import {link} from 'coral-framework/services/PymConnection';
import NotLoggedIn from '../components/NotLoggedIn';
import IgnoredUsers from '../components/IgnoredUsers';
import {Spinner} from 'coral-ui';
import CommentHistory from 'coral-plugin-history/CommentHistory';
@@ -30,7 +32,7 @@ class ProfileContainer extends Component {
}
render() {
const {loggedIn, asset, showSignInDialog, data} = this.props;
const {loggedIn, asset, showSignInDialog, data, myIgnoredUsersData, stopIgnoringUser} = this.props;
const {me} = this.props.data;
if (!loggedIn || !me) {
@@ -41,16 +43,35 @@ class ProfileContainer extends Component {
return <Spinner/>;
}
const localProfile = this.props.user.profiles.find(p => p.provider === 'local');
const emailAddress = localProfile && localProfile.id;
return (
<div>
{
<h2>{this.props.userData.username}</h2>
{ emailAddress
? <p>{ emailAddress }</p>
: null
}
// Hiding bio until moderation can get figured out
/* <TabBar onChange={this.handleTabChange} activeTab={activeTab} cStyle='material'>
<Tab>{lang.t('allComments')} ({user.myComments.length})</Tab>
<Tab>{lang.t('profileSettings')}</Tab>
</TabBar>
<TabContent show={activeTab === 0}> */
{
myIgnoredUsersData.myIgnoredUsers && myIgnoredUsersData.myIgnoredUsers.length
? (
<div>
<h3>Ignored users</h3>
<IgnoredUsers
users={myIgnoredUsersData.myIgnoredUsers}
stopIgnoring={stopIgnoringUser}
/>
</div>
)
: null
}
<hr />
<h3>My comments</h3>
{
me.comments.length ?
<CommentHistory
comments={me.comments}
@@ -59,12 +80,6 @@ class ProfileContainer extends Component {
/>
:
<p>{lang.t('userNoComment')}</p>
// Hiding user bio pending effective moderation system.
/* </TabContent>
<TabContent show={activeTab === 1}>
<BioContainer bio={userData.settings.bio} handleSave={this.handleSave} {...this.props} />
</TabContent> */
}
</div>
@@ -85,5 +100,7 @@ const mapDispatchToProps = () => ({
export default compose(
connect(mapStateToProps, mapDispatchToProps),
myCommentHistory
myCommentHistory,
myIgnoredUsers,
stopIgnoringUser,
)(ProfileContainer);
+93 -1
View File
@@ -6,6 +6,7 @@ const {
const DataLoader = require('dataloader');
const CommentModel = require('../../models/comment');
const UsersService = require('../../services/users');
/**
* Returns the comment count for all comments that are public based on their
@@ -39,6 +40,31 @@ const getCountsByAssetID = (context, asset_ids) => {
.then((results) => results.map((result) => result ? result.count : 0));
};
/**
* Returns the count of all public comments on an asset id, also filtering by personalization options.
*
* @param {Array<String>} id The ID of the asset
* @param {Array<String>} excludeIgnored Exclude comments ignored by the requesting user
*/
const getCountsByAssetIDPersonalized = async (context, {assetId, excludeIgnored}) => {
const query = {
asset_id: assetId,
status: {
$in: ['NONE', 'ACCEPTED'],
},
};
const user = context.user;
if (excludeIgnored && user) {
// load afresh, as `user` may be from cache and not have recent ignores
const freshUser = await UsersService.findById(user.id);
const ignoredUsers = freshUser.ignoresUsers;
query.author_id = {$nin: ignoredUsers};
}
const count = await CommentModel.where(query).count();
return count;
};
/**
* Returns the comment count for all comments that are public based on their
* asset ids.
@@ -72,6 +98,32 @@ const getParentCountsByAssetID = (context, asset_ids) => {
.then((results) => results.map((result) => result ? result.count : 0));
};
/**
* Returns the count of top-level comments on an asset id, also filtering by personalization options.
*
* @param {Array<String>} id The ID of the asset
* @param {Array<String>} excludeIgnored Exclude comments ignored by the requesting user
*/
const getParentCountByAssetIDPersonalized = async (context, {assetId, excludeIgnored}) => {
const query = {
asset_id: assetId,
parent_id: null,
status: {
$in: ['NONE', 'ACCEPTED'],
},
};
const user = context.user;
if (excludeIgnored && user) {
// load afresh, as `user` may be from cache and not have recent ignores
const freshUser = await UsersService.findById(user.id);
const ignoredUsers = freshUser.ignoresUsers;
query.author_id = {$nin: ignoredUsers};
}
const count = await CommentModel.where(query).count();
return count;
};
/**
* Returns the comment count for all comments that are public based on their
* parent ids.
@@ -104,6 +156,33 @@ const getCountsByParentID = (context, parent_ids) => {
.then((results) => results.map((result) => result ? result.count : 0));
};
/**
* Returns the count of comments for the provided parent_id, also filtering by personalization options.
*
* @param {Array<String>} id The ID of the parent comment
* @param {Array<String>} excludeIgnored Exclude comments ignored by context.user
*/
const getCountByParentIDPersonalized = async (context, {id, excludeIgnored}) => {
const query = {
parent_id: {
$in: [id]
},
status: {
$in: ['NONE', 'ACCEPTED']
}
};
const user = context.user;
if (excludeIgnored && user) {
// load afresh, as `user` may be from cache and not have recent ignores
const freshUser = await UsersService.findById(user.id);
const ignoredUsers = freshUser.ignoresUsers;
query.author_id = {$nin: ignoredUsers};
}
const count = await CommentModel.where(query).count();
return count;
};
/**
* Retrieves the count of comments based on the passed in query.
* @param {Object} context graph context
@@ -142,7 +221,7 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id}) =
* @param {Object} context graph context
* @param {Object} query query terms to apply to the comments query
*/
const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sort}) => {
const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sort, excludeIgnored}) => {
let comments = CommentModel.find();
// Only administrators can search for comments with statuses that are not
@@ -184,6 +263,16 @@ const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, author_
comments = comments.where({parent_id});
}
if (excludeIgnored && user) {
// load afresh, as `user` may be from cache and not have recent ignores
const freshUser = await UsersService.findById(user.id);
const ignoredUsers = freshUser.ignoresUsers;
comments = comments.where({
author_id: {$nin: ignoredUsers}
});
}
if (cursor) {
if (sort === 'REVERSE_CHRONOLOGICAL') {
comments = comments.where({
@@ -344,8 +433,11 @@ module.exports = (context) => ({
getByQuery: (query) => getCommentsByQuery(context, query),
getCountByQuery: (query) => getCommentCountByQuery(context, query),
countByAssetID: new SharedCounterDataLoader('Comments.totalCommentCount', 3600, (ids) => getCountsByAssetID(context, ids)),
countByAssetIDPersonalized: (query) => getCountsByAssetIDPersonalized(context, query),
parentCountByAssetID: new SharedCounterDataLoader('Comments.countByAssetID', 3600, (ids) => getParentCountsByAssetID(context, ids)),
parentCountByAssetIDPersonalized: (query) => getParentCountByAssetIDPersonalized(context, query),
countByParentID: new SharedCounterDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids)),
countByParentIDPersonalized: (query) => getCountByParentIDPersonalized(context, query),
genRecentReplies: new DataLoader((ids) => genRecentReplies(context, ids)),
genRecentComments: new DataLoader((ids) => genRecentComments(context, ids))
}
+11 -1
View File
@@ -13,11 +13,21 @@ const suspendUser = ({user}, {id, message}) => {
});
};
const ignoreUser = async ({user}, userToIgnore) => {
return await UsersService.ignoreUsers(user.id, [userToIgnore.id]);
};
const stopIgnoringUser = async ({user}, userToStopIgnoring) => {
return await UsersService.stopIgnoringUsers(user.id, [userToStopIgnoring.id]);
};
module.exports = (context) => {
let mutators = {
User: {
setUserStatus: () => Promise.reject(errors.ErrNotAuthorized),
suspendUser: () => Promise.reject(errors.ErrNotAuthorized)
suspendUser: () => Promise.reject(errors.ErrNotAuthorized),
ignoreUser: (action) => ignoreUser(context, action),
stopIgnoringUser: (action) => stopIgnoringUser(context, action),
}
};
+11 -6
View File
@@ -9,26 +9,31 @@ const Asset = {
recentComments({id}, _, {loaders: {Comments}}) {
return Comments.genRecentComments.load(id);
},
comments({id}, {sort, limit}, {loaders: {Comments}}) {
comments({id}, {sort, limit, excludeIgnored}, {loaders: {Comments}}) {
return Comments.getByQuery({
asset_id: id,
sort,
limit,
parent_id: null
parent_id: null,
excludeIgnored,
});
},
commentCount({id, commentCount}, _, {loaders: {Comments}}) {
commentCount({id, commentCount}, {excludeIgnored}, {user, loaders: {Comments}}) {
if (user && excludeIgnored) {
return Comments.parentCountByAssetIDPersonalized({assetId: id, excludeIgnored});
}
if (commentCount != null) {
return commentCount;
}
return Comments.parentCountByAssetID.load(id);
},
totalCommentCount({id, totalCommentCount}, _, {loaders: {Comments}}) {
totalCommentCount({id, totalCommentCount}, {excludeIgnored}, {user, loaders: {Comments}}) {
if (user && excludeIgnored) {
return Comments.countByAssetIDPersonalized({assetId: id, excludeIgnored});
}
if (totalCommentCount != null) {
return totalCommentCount;
}
return Comments.countByAssetID.load(id);
},
settings({settings = null}, _, {loaders: {Settings}}) {
+8 -4
View File
@@ -12,16 +12,20 @@ const Comment = {
recentReplies({id}, _, {loaders: {Comments}}) {
return Comments.genRecentReplies.load(id);
},
replies({id, asset_id}, {sort, limit}, {loaders: {Comments}}) {
replies({id, asset_id}, {sort, limit, excludeIgnored}, {loaders: {Comments}}) {
return Comments.getByQuery({
asset_id,
parent_id: id,
sort,
limit
limit,
excludeIgnored,
});
},
replyCount({id}, _, {loaders: {Comments}}) {
return Comments.countByParentID.load(id);
replyCount({id}, {excludeIgnored}, {user, loaders: {Comments}}) {
if (user && excludeIgnored) {
return Comments.countByParentIDPersonalized({id, excludeIgnored});
}
return Comments.countByParentID.load(id);
},
actions({id}, _, {user, loaders: {Actions}}) {
+6
View File
@@ -23,6 +23,12 @@ const RootMutation = {
suspendUser(_, {id, message}, {mutators: {User}}) {
return wrapResponse(null)(User.suspendUser({id, message}));
},
ignoreUser(_, {id}, {mutators: {User}}) {
return wrapResponse(null)(User.ignoreUser({id}));
},
stopIgnoringUser(_, {id}, {mutators: {User}}) {
return wrapResponse(null)(User.stopIgnoringUser({id}));
},
setCommentStatus(_, {id, status}, {mutators: {Comment}}) {
return wrapResponse(null)(Comment.setCommentStatus({id, status}));
},
+13 -3
View File
@@ -19,15 +19,15 @@ const RootQuery = {
// This endpoint is used for loading moderation queues, so hide it in the
// event that we aren't an admin.
comments(_, {query: {action_type, statuses, asset_id, parent_id, limit, cursor, sort}}, {user, loaders: {Comments, Actions}}) {
let query = {statuses, asset_id, parent_id, limit, cursor, sort};
comments(_, {query: {action_type, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored}}, {user, loaders: {Comments, Actions}}) {
let query = {statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored};
if (user != null && user.hasRoles('ADMIN') && action_type) {
return Actions.getByTypes({action_type, item_type: 'COMMENTS'})
.then((ids) => {
// Perform the query using the available resolver.
return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort});
return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored});
});
}
@@ -83,6 +83,16 @@ const RootQuery = {
return user;
},
myIgnoredUsers: async (_, args, {user, loaders: {Users}}) => {
// get currentUser again since context.user was out of date when running test/graph/mutations/ignoreUser
const currentUser = (await Users.getByQuery({ids: [user.id], limit: 1}))[0];
if ( ! (currentUser && Array.isArray(currentUser.ignoresUsers) && currentUser.ignoresUsers.length)) {
return [];
}
return await Users.getByQuery({ids: currentUser.ignoresUsers});
},
// This endpoint is used for loading the user moderation queues (users whose username has been flagged),
// so hide it in the event that we aren't an admin.
users(_, {query: {action_type, limit, cursor, sort}}, {user, loaders: {Users, Actions}}) {
+29 -5
View File
@@ -140,6 +140,9 @@ input CommentsQuery {
# Sort the results by created_at.
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
# Exclude comments ignored by the requesting user
excludeIgnored: Boolean
}
# CommentCountQuery allows the ability to query comment counts by specific
@@ -185,10 +188,10 @@ type Comment {
recentReplies: [Comment]
# the replies that were made to the comment.
replies(sort: SORT_ORDER = CHRONOLOGICAL, limit: Int = 3): [Comment]
replies(sort: SORT_ORDER = CHRONOLOGICAL, limit: Int = 3, excludeIgnored: Boolean): [Comment]
# The count of replies on a comment.
replyCount: Int
replyCount(excludeIgnored: Boolean): Int
# Actions completed on the parent. Requires the `ADMIN` role.
actions: [Action]
@@ -420,13 +423,13 @@ type Asset {
recentComments: [Comment]
# The top level comments that are attached to the asset.
comments(sort: SORT_ORDER = REVERSE_CHRONOLOGICAL, limit: Int = 10): [Comment]
comments(sort: SORT_ORDER = REVERSE_CHRONOLOGICAL, limit: Int = 10, excludeIgnored: Boolean): [Comment]
# The count of top level comments on the asset.
commentCount: Int
commentCount(excludeIgnored: Boolean): Int
# The total count of all comments made on the asset.
totalCommentCount: Int
totalCommentCount(excludeIgnored: Boolean): Int
# The settings (rectified with the global settings) that should be applied to
# this asset.
@@ -540,6 +543,9 @@ type RootQuery {
# role.
me: User
# Users that the currently logged in user ignores
myIgnoredUsers: [User]
# Users returned based on a query.
users(query: UsersQuery): [User]
@@ -706,6 +712,18 @@ type RemoveCommentTagResponse implements Response {
errors: [UserError]
}
# Response to ignoreUser mutation
type IgnoreUserResponse implements Response {
# An array of errors relating to the mutation that occured.
errors: [UserError]
}
# Response to stopIgnoringUser mutation
type StopIgnoringUserResponse implements Response {
# An array of errors relating to the mutation that occured.
errors: [UserError]
}
# All mutations for the application are defined on this object.
type RootMutation {
@@ -738,6 +756,12 @@ type RootMutation {
# Remove tag from comment.
removeCommentTag(id: ID!, tag: String!): RemoveCommentTagResponse
# Ignore comments by another user
ignoreUser(id: ID!): IgnoreUserResponse
# Stop Ignoring comments by another user
stopIgnoringUser(id: ID!): StopIgnoringUserResponse
}
################################################################################
+7 -1
View File
@@ -117,7 +117,13 @@ const UserSchema = new mongoose.Schema({
type: String,
default: ''
}
}
},
ignoresUsers: [{
// user id of another user
type: String,
}]
}, {
// This will ensure that we have proper timestamps available on this model.
+39
View File
@@ -1,3 +1,4 @@
const assert = require('assert');
const bcrypt = require('bcrypt');
const url = require('url');
const jwt = require('jsonwebtoken');
@@ -834,4 +835,42 @@ module.exports = class UsersService {
throw err;
});
}
/**
* Ignore another user
* @param {String} userId the id of the user that is ignoring another users
* @param {String[]} usersToIgnore Array of user IDs to ignore
*/
static ignoreUsers(userId, usersToIgnore) {
assert(Array.isArray(usersToIgnore), 'usersToIgnore is an array');
assert(usersToIgnore.every(u => typeof u === 'string'), 'usersToIgnore is an array of string user IDs');
if (usersToIgnore.includes(userId)) {
throw new Error('Users cannot ignore themselves');
}
// TODO: For each usersToIgnore, make sure they exist?
return UserModel.update({id: userId}, {
$addToSet: {
ignoresUsers: {
$each: usersToIgnore
}
}
});
}
/**
* Stop ignoring other users
* @param {String} userId the id of the user that is ignoring another users
* @param {String[]} usersToStopIgnoring Array of user IDs to stop ignoring
*/
static async stopIgnoringUsers(userId, usersToStopIgnoring) {
assert(Array.isArray(usersToStopIgnoring), 'usersToStopIgnoring is an array');
assert(usersToStopIgnoring.every(u => typeof u === 'string'), 'usersToStopIgnoring is an array of string user IDs');
await UserModel.update({id: userId}, {
$pullAll: {
ignoresUsers: usersToStopIgnoring
}
});
console.log('Mongo wrote stopIgnoringUsers', usersToStopIgnoring);
}
};
+129
View File
@@ -0,0 +1,129 @@
const expect = require('chai').expect;
const {graphql} = require('graphql');
const schema = require('../../../graph/schema');
const Context = require('../../../graph/context');
const UsersService = require('../../../services/users');
const SettingsService = require('../../../services/settings');
const ignoreUserMutation = `
mutation ignoreUser ($id: ID!) {
ignoreUser(id:$id) {
errors {
translation_key
}
}
}
`;
const getMyIgnoredUsersQuery = `
query myIgnoredUsers {
myIgnoredUsers {
id,
username
}
}
`;
describe('graph.mutations.ignoreUser', () => {
beforeEach(async () => {
await SettingsService.init();
});
// @TODO (bengo) - test a user can't ignore themselves
it('users can ignoreUser', async () => {
const user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
const userToIgnore = await UsersService.createLocalUser('usernameB@example.com', 'password', 'usernameB');
const context = new Context({user});
const ignoreUserResponse = await graphql(schema, ignoreUserMutation, {}, context, {id: userToIgnore.id});
if (ignoreUserResponse.errors && ignoreUserResponse.errors.length) {
console.error(ignoreUserResponse.errors);
}
expect(ignoreUserResponse.errors).to.be.empty;
// now check my ignored users
const myIgnoredUsersResponse = await graphql(schema, getMyIgnoredUsersQuery, {}, context, {});
if (myIgnoredUsersResponse.errors && myIgnoredUsersResponse.errors.length) {
console.error(myIgnoredUsersResponse.errors);
}
expect(myIgnoredUsersResponse.errors).to.be.empty;
const myIgnoredUsers = myIgnoredUsersResponse.data.myIgnoredUsers;
expect(myIgnoredUsers.length).to.equal(1);
expect(myIgnoredUsers[0].id).to.equal(userToIgnore.id);
expect(myIgnoredUsers[0].username).to.equal(userToIgnore.username);
});
it('users cannot ignore themselves', async () => {
const user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
const context = new Context({user});
const ignoreUserResponse = await graphql(schema, ignoreUserMutation, {}, context, {id: user.id});
expect(ignoreUserResponse.errors).to.not.be.empty;
// now check my ignored users
const myIgnoredUsersResponse = await graphql(schema, getMyIgnoredUsersQuery, {}, context, {});
if (myIgnoredUsersResponse.errors && myIgnoredUsersResponse.errors.length) {
console.error(myIgnoredUsersResponse.errors);
}
expect(myIgnoredUsersResponse.errors).to.be.empty;
const myIgnoredUsers = myIgnoredUsersResponse.data.myIgnoredUsers;
expect(myIgnoredUsers.length).to.equal(0);
});
});
describe('graph.mutations.stopIgnoringUser', () => {
beforeEach(async () => {
await SettingsService.init();
});
it('users can stop ignoring another user they ignore', async () => {
// We're going to ignore 2 users,
// then stopIgnoring 1 of them
// then assert myIgnoredUsers only lists the one remaining
const user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
const usersToIgnore = await Promise.all([
UsersService.createLocalUser('usernameB@example.com', 'password', 'usernameB'),
UsersService.createLocalUser('usernameC@example.com', 'password', 'usernameC'),
]);
const context = new Context({user});
// ignore two users
const ignoreUserResponses = await Promise.all(usersToIgnore.map(u => graphql(schema, ignoreUserMutation, {}, context, {id: u.id})));
ignoreUserResponses.forEach(response => {
if (response.errors && response.errors.length) {
console.error(response.errors);
}
expect(response.errors).to.be.empty;
});
const stopIgnoringUserMutation = `
mutation stopIgnoringUser ($id: ID!) {
stopIgnoringUser(id:$id) {
errors {
translation_key
}
}
}
`;
// stop ignoring one user
const stopIgnoringUserResponse = await graphql(schema, stopIgnoringUserMutation, {}, context, {id: usersToIgnore[0].id});
if (stopIgnoringUserResponse.errors && stopIgnoringUserResponse.errors.length) {
console.error(stopIgnoringUserResponse.errors);
}
expect(stopIgnoringUserResponse.errors).to.be.empty;
// now check my ignored users
const myIgnoredUsersResponse = await graphql(schema, getMyIgnoredUsersQuery, {}, context, {});
if (myIgnoredUsersResponse.errors && myIgnoredUsersResponse.errors.length) {
console.error(myIgnoredUsersResponse.errors);
}
expect(myIgnoredUsersResponse.errors).to.be.empty;
const myIgnoredUsers = myIgnoredUsersResponse.data.myIgnoredUsers;
expect(myIgnoredUsers.length).to.equal(1);
expect(myIgnoredUsers[0].id).to.equal(usersToIgnore[1].id);
expect(myIgnoredUsers[0].username).to.equal(usersToIgnore[1].username);
});
});
+93
View File
@@ -0,0 +1,93 @@
const expect = require('chai').expect;
const {graphql} = require('graphql');
const schema = require('../../../graph/schema');
const Context = require('../../../graph/context');
const UsersService = require('../../../services/users');
const SettingsService = require('../../../services/settings');
const Asset = require('../../../models/asset');
const CommentsService = require('../../../services/comments');
describe('graph.queries.asset', () => {
beforeEach(async () => {
await SettingsService.init();
});
it('can get comments edge', async () => {
const assetId = 'fakeAssetId';
const assetUrl = 'https://bengo.is';
await Asset.create({id: assetId, url: assetUrl});
const user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
const context = new Context({user});
await CommentsService.publicCreate([1, 2].map(() => ({
author_id: user.id,
asset_id: assetId,
body: `hello there! ${ String(Math.random()).slice(2)}`,
})));
const assetCommentsQuery = `
query assetCommentsQuery($assetId: ID!, $assetUrl: String!) {
asset(id: $assetId, url: $assetUrl) {
comments(limit: 10) {
id,
body,
}
}
}
`;
const assetCommentsResponse = await graphql(schema, assetCommentsQuery, {}, context, {assetId, assetUrl});
const comments = assetCommentsResponse.data.asset.comments;
expect(comments.length).to.equal(2);
});
it('can query comments edge to exclude comments ignored by user', async () => {
const assetId = 'fakeAssetId1';
const assetUrl = 'https://bengo.is/1';
await Asset.create({id: assetId, url: assetUrl});
const userA = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
const userB = await UsersService.createLocalUser('usernameB@example.com', 'password', 'usernameB');
const userC = await UsersService.createLocalUser('usernameC@example.com', 'password', 'usernameC');
const context = new Context({user: userA});
// create 2 comments each for userB, userC
await Promise.all([userB, userC].map(user => CommentsService.publicCreate([1, 2].map(() => ({
author_id: user.id,
asset_id: assetId,
body: `hello there! ${ String(Math.random()).slice(2)}`,
})))));
// ignore userB
const ignoreUserMutation = `
mutation ignoreUser ($id: ID!) {
ignoreUser(id:$id) {
errors {
translation_key
}
}
}
`;
const ignoreUserResponse = await graphql(schema, ignoreUserMutation, {}, context, {id: userB.id});
if (ignoreUserResponse.errors && ignoreUserResponse.errors.length) {
console.error(ignoreUserResponse.errors);
}
expect(ignoreUserResponse.errors).to.be.empty;
const assetCommentsWithoutIgnoredQuery = `
query assetCommentsQuery($assetId: ID!, $assetUrl: String!, $excludeIgnored: Boolean!) {
asset(id: $assetId, url: $assetUrl) {
comments(limit: 10, excludeIgnored: $excludeIgnored) {
id,
body,
}
}
}
`;
const assetCommentsResponse = await graphql(schema, assetCommentsWithoutIgnoredQuery, {}, context, {assetId, assetUrl, excludeIgnored: true});
const comments = assetCommentsResponse.data.asset.comments;
expect(comments.length).to.equal(2);
});
});
+18
View File
@@ -169,6 +169,24 @@ describe('services.UsersService', () => {
});
});
describe('#ignoreUser', () => {
// @TODO: assert cannot ignore yourself
it('should add user id to ignoredUsers set', async () => {
const user = mockUsers[0];
const usersToIgnore = [mockUsers[1], mockUsers[2]];
await UsersService.ignoreUsers(user.id, usersToIgnore.map(u => u.id));
const userAfterIgnoring = await UsersService.findById(user.id);
expect(userAfterIgnoring.ignoresUsers.length).to.equal(2);
// ignore same user another time, make sure it's not added to the list.
await UsersService.ignoreUsers(user.id, usersToIgnore.slice(0, 1).map(u => u.id));
const userAfterIgnoring2 = await UsersService.findById(user.id);
expect(userAfterIgnoring2.ignoresUsers.length).to.equal(2);
});
});
describe('#ban', () => {
it('should set the status to banned', () => {
return UsersService