mirror of
https://github.com/wassname/talk.git
synced 2026-07-09 23:28:05 +08:00
@@ -13,13 +13,87 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topRightMenu {
|
||||
.bylineSecondary {
|
||||
color: #696969;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editedMarker {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* element in the top right of the Comment */
|
||||
.topRight {
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.topRight > * {
|
||||
text-align: initial;
|
||||
}
|
||||
|
||||
.topRight .popover {
|
||||
margin-top: 1em;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.topRight .link.active,
|
||||
.topRight .active .link {
|
||||
padding-bottom: 0.125em;
|
||||
border-bottom: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.topRightMenu {
|
||||
cursor: pointer;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.topRightMenu > * {
|
||||
text-align: initial;
|
||||
.editCommentForm {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.editCommentForm .buttonContainerLeft {
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editCommentForm .buttonContainerLeft .editWindowRemaining {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.editCommentForm .button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editWindowAlmostOver {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #2376D8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Wizard used for Ignore User, Delete Comment confirmations */
|
||||
.Wizard {
|
||||
background-color: #2E343B;
|
||||
color: white;
|
||||
padding: 1em;
|
||||
max-width: 220px; /* consider moving to better class */
|
||||
}
|
||||
|
||||
.Wizard header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.Wizard .textAlignRight {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ import Slot from 'coral-framework/components/Slot';
|
||||
import LoadMore from './LoadMore';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import {TopRightMenu} from './TopRightMenu';
|
||||
import classnames from 'classnames';
|
||||
import {EditableCommentContent} from './EditableCommentContent';
|
||||
import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils';
|
||||
|
||||
import {getEditableUntilDate} from './util';
|
||||
import styles from './Comment.css';
|
||||
|
||||
const isStaff = tags => !tags.every(t => t.name !== 'STAFF');
|
||||
@@ -37,7 +39,17 @@ const ActionButton = ({children}) => {
|
||||
class Comment extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {replyBoxVisible: false};
|
||||
|
||||
// timeout to keep track of Comment edit window expiration
|
||||
this.editWindowExpiryTimeout = null;
|
||||
this.onClickEdit = this.onClickEdit.bind(this);
|
||||
this.stopEditing = this.stopEditing.bind(this);
|
||||
this.state = {
|
||||
|
||||
// Whether the comment should be editable (e.g. after a commenter clicking the 'Edit' button on their own comment)
|
||||
isEditing: false,
|
||||
replyBoxVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
@@ -84,7 +96,13 @@ class Comment extends React.Component {
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
editing: PropTypes.shape({
|
||||
edited: PropTypes.bool,
|
||||
|
||||
// ISO8601
|
||||
editableUntil: PropTypes.string,
|
||||
})
|
||||
}).isRequired,
|
||||
|
||||
// given a comment, return whether it should be rendered as ignored
|
||||
@@ -97,10 +115,46 @@ class Comment extends React.Component {
|
||||
removeCommentTag: React.PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func
|
||||
};
|
||||
ignoreUser: React.PropTypes.func,
|
||||
|
||||
render() {
|
||||
// edit a comment, passed (id, asset_id, { body })
|
||||
editComment: React.PropTypes.func,
|
||||
}
|
||||
|
||||
onClickEdit (e) {
|
||||
e.preventDefault();
|
||||
this.setState({isEditing: true});
|
||||
}
|
||||
|
||||
stopEditing () {
|
||||
if (this._isMounted) {
|
||||
this.setState({isEditing: false});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
if (this.editWindowExpiryTimeout) {
|
||||
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
|
||||
}
|
||||
|
||||
// if still in the edit window, set a timeout to re-render once it expires
|
||||
const msLeftToEdit = editWindowRemainingMs(this.props.comment);
|
||||
if (msLeftToEdit > 0) {
|
||||
this.editWindowExpiryTimeout = setTimeout(() => {
|
||||
|
||||
// re-render
|
||||
this.setState(this.state);
|
||||
}, msLeftToEdit);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.editWindowExpiryTimeout) {
|
||||
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
|
||||
}
|
||||
this._isMounted = false;
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
comment,
|
||||
parentId,
|
||||
@@ -190,8 +244,17 @@ class Comment extends React.Component {
|
||||
|
||||
{commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
: null}
|
||||
<PubDate created_at={comment.created_at} />
|
||||
: null }
|
||||
|
||||
<span className={styles.bylineSecondary}>
|
||||
<PubDate created_at={comment.created_at} />
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>(Edited)</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
|
||||
<Slot
|
||||
fill="commentInfoBar"
|
||||
data={this.props.data}
|
||||
@@ -200,18 +263,47 @@ class Comment extends React.Component {
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
{currentUser && comment.user.id !== currentUser.id
|
||||
? <span className={styles.topRightMenu}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
</span>
|
||||
: null}
|
||||
|
||||
<Content body={comment.body} />
|
||||
<Slot fill="commentContent" />
|
||||
{ (currentUser &&
|
||||
(comment.user.id === currentUser.id))
|
||||
|
||||
/* User can edit/delete their own comment for a short window after posting */
|
||||
? <span className={classnames(styles.topRight)}>
|
||||
{
|
||||
commentIsStillEditable(comment) &&
|
||||
<a
|
||||
className={classnames(styles.link, {[styles.active]: this.state.isEditing})}
|
||||
onClick={this.onClickEdit}>Edit</a>
|
||||
}
|
||||
</span>
|
||||
|
||||
/* TopRightMenu allows currentUser to ignore other users' comments */
|
||||
: <span className={classnames(styles.topRight, styles.topRightMenu)}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification} />
|
||||
</span>
|
||||
}
|
||||
|
||||
{
|
||||
this.state.isEditing
|
||||
? <EditableCommentContent
|
||||
editComment={this.props.editComment.bind(null, comment.id, asset.id)}
|
||||
addNotification={addNotification}
|
||||
asset={asset}
|
||||
comment={comment}
|
||||
currentUser={currentUser}
|
||||
maxCharCount={maxCharCount}
|
||||
parentId={parentId}
|
||||
stopEditing={this.stopEditing}
|
||||
/>
|
||||
: <div>
|
||||
<Content body={comment.body} />
|
||||
<Slot fill="commentContent" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<Slot
|
||||
fill="commentReactions"
|
||||
@@ -295,6 +387,7 @@ class Comment extends React.Component {
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postItem={postItem}
|
||||
editComment={this.props.editComment}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
@@ -330,3 +423,21 @@ class Comment extends React.Component {
|
||||
}
|
||||
|
||||
export default Comment;
|
||||
|
||||
// return whether the comment is editable
|
||||
function commentIsStillEditable (comment) {
|
||||
const editing = comment && comment.editing;
|
||||
if ( ! editing) {return false;}
|
||||
const editableUntil = getEditableUntilDate(comment);
|
||||
const editWindowExpired = (editableUntil - new Date) < 0;
|
||||
return ! editWindowExpired;
|
||||
}
|
||||
|
||||
// return number of milliseconds before edit window expires
|
||||
function editWindowRemainingMs (comment) {
|
||||
const editableUntil = getEditableUntilDate(comment);
|
||||
if ( ! editableUntil) {return;}
|
||||
const now = new Date();
|
||||
const editWindowRemainingMs = (editableUntil - now);
|
||||
return editWindowRemainingMs;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
/**
|
||||
* Countdown the number of seconds until a given Date
|
||||
*/
|
||||
export class CountdownSeconds extends React.Component {
|
||||
static propTypes = {
|
||||
until: PropTypes.instanceOf(Date).isRequired,
|
||||
classNameForMsRemaining: PropTypes.func,
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.countdownInterval = null;
|
||||
}
|
||||
componentDidMount() {
|
||||
const {until} = this.props;
|
||||
const now = new Date();
|
||||
if (until - now > 0) {
|
||||
this.countdownInterval = setInterval(() => {
|
||||
|
||||
// re-render
|
||||
this.forceUpdate();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.countdownInterval) {
|
||||
this.countdownInterval = clearInterval(this.countdownInterval);
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const now = new Date();
|
||||
const {until, classNameForMsRemaining} = this.props;
|
||||
const msRemaining = until - now;
|
||||
const secRemaining = msRemaining / 1000;
|
||||
const wholeSecRemaining = Math.floor(secRemaining);
|
||||
const plural = secRemaining !== 1;
|
||||
const units = lang.t(plural ? 'editComment.secondsPlural' : 'editComment.second');
|
||||
let classFromProp;
|
||||
if (typeof classNameForMsRemaining === 'function') {
|
||||
classFromProp = classNameForMsRemaining(msRemaining);
|
||||
}
|
||||
return (
|
||||
<span className={classFromProp}>
|
||||
{`${wholeSecRemaining} ${units}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {notifyForNewCommentStatus} from 'coral-plugin-commentbox/CommentBox';
|
||||
import {CommentForm} from 'coral-plugin-commentbox/CommentForm';
|
||||
import styles from './Comment.css';
|
||||
import {CountdownSeconds} from './CountdownSeconds';
|
||||
import {getEditableUntilDate} from './util';
|
||||
|
||||
import {Icon} from 'coral-ui';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
/**
|
||||
* Renders a Comment's body in such a way that the end-user can edit it and save changes
|
||||
*/
|
||||
export class EditableCommentContent extends React.Component {
|
||||
static propTypes = {
|
||||
|
||||
// show notification to the user (e.g. for errors)
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
settings: PropTypes.shape({
|
||||
charCountEnable: PropTypes.bool,
|
||||
}),
|
||||
}).isRequired,
|
||||
|
||||
// comment that is being edited
|
||||
comment: PropTypes.shape({
|
||||
body: PropTypes.string,
|
||||
editing: PropTypes.shape({
|
||||
edited: PropTypes.bool,
|
||||
|
||||
// ISO8601
|
||||
editableUntil: PropTypes.string,
|
||||
})
|
||||
}).isRequired,
|
||||
|
||||
// logged in user
|
||||
currentUser: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired
|
||||
}),
|
||||
maxCharCount: PropTypes.number,
|
||||
|
||||
// edit a comment, passed {{ body }}
|
||||
editComment: React.PropTypes.func,
|
||||
|
||||
// called when editing should be stopped
|
||||
stopEditing: React.PropTypes.func,
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.editComment = this.editComment.bind(this);
|
||||
this.editWindowExpiryTimeout = null;
|
||||
}
|
||||
componentDidMount() {
|
||||
const editableUntil = getEditableUntilDate(this.props.comment);
|
||||
const now = new Date();
|
||||
const editWindowRemainingMs = editableUntil && (editableUntil - now);
|
||||
if (editWindowRemainingMs > 0) {
|
||||
this.editWindowExpiryTimeout = setTimeout(() => {
|
||||
this.forceUpdate();
|
||||
}, editWindowRemainingMs);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.editWindowExpiryTimeout) {
|
||||
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
|
||||
}
|
||||
}
|
||||
async editComment(edit) {
|
||||
const {editComment, addNotification, stopEditing} = this.props;
|
||||
if (typeof editComment !== 'function') {return;}
|
||||
let response;
|
||||
let successfullyEdited = false;
|
||||
try {
|
||||
response = await editComment(edit);
|
||||
const errors = (response && response.data && response.data.editComment)
|
||||
? response.data.editComment.errors
|
||||
: null;
|
||||
if (errors && (errors.length === 1)) {
|
||||
throw errors[0];
|
||||
}
|
||||
successfullyEdited = true;
|
||||
} catch (error) {
|
||||
if (error.translation_key) {
|
||||
addNotification('error', lang.t(error.translation_key) || error.translation_key);
|
||||
} else if (error.networkError) {
|
||||
addNotification('error', lang.t('error.networkError'));
|
||||
} else {
|
||||
addNotification('error', lang.t('editComment.unexpectedError'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (successfullyEdited) {
|
||||
const status = response.data.editComment.comment.status;
|
||||
notifyForNewCommentStatus(this.props.addNotification, status);
|
||||
}
|
||||
if (successfullyEdited && typeof stopEditing === 'function') {
|
||||
stopEditing();
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const originalBody = this.props.comment.body;
|
||||
const editableUntil = getEditableUntilDate(this.props.comment);
|
||||
const editWindowExpired = (editableUntil - new Date()) < 0;
|
||||
return (
|
||||
<div className={styles.editCommentForm}>
|
||||
<CommentForm
|
||||
defaultValue={this.props.comment.body}
|
||||
charCountEnable={this.props.asset.settings.charCountEnable}
|
||||
maxCharCount={this.props.maxCharCount}
|
||||
saveCommentEnabled={(comment) => {
|
||||
|
||||
// should be disabled if user hasn't actually changed their
|
||||
// original comment
|
||||
return (comment.body !== originalBody) && ! editWindowExpired;
|
||||
}}
|
||||
saveComment={this.editComment}
|
||||
bodyLabel={lang.t('editComment.bodyInputLabel')}
|
||||
bodyPlaceholder=""
|
||||
submitText={<span>{lang.t('editComment.saveButton')}</span>}
|
||||
saveButtonCStyle="green"
|
||||
cancelButtonClicked={this.props.stopEditing}
|
||||
buttonClass={styles.button}
|
||||
buttonContainerStart={
|
||||
<div className={styles.buttonContainerLeft}>
|
||||
<span className={styles.editWindowRemaining}>
|
||||
{
|
||||
editWindowExpired
|
||||
? <span>
|
||||
{lang.t('editComment.editWindowExpired')}
|
||||
{
|
||||
typeof this.props.stopEditing === 'function'
|
||||
? <span> <a className={styles.link} onClick={this.props.stopEditing}>{lang.t('editComment.editWindowExpiredClose')}</a></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
: <span>
|
||||
<Icon name="timer"/> {lang.t('editComment.editWindowTimerPrefix')}
|
||||
<CountdownSeconds
|
||||
until={editableUntil}
|
||||
classNameForMsRemaining={(remainingMs) => (remainingMs <= 10 * 1000) ? styles.editWindowAlmostOver : '' }
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
.IgnoreUserWizard {
|
||||
background-color: #2E343B;
|
||||
color: white;
|
||||
padding: 1em;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.IgnoreUserWizard header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.IgnoreUserWizard .textAlignRight {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './IgnoreUserWizard.css';
|
||||
import styles from './Comment.css';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
// Guides the user through ignoring another user, including confirming their decision
|
||||
@@ -58,7 +58,7 @@ export class IgnoreUserWizard extends React.Component {
|
||||
const {step} = this.state;
|
||||
const elForThisStep = elsForStep[step - 1];
|
||||
return (
|
||||
<div className={styles.IgnoreUserWizard}>
|
||||
<div className={styles.Wizard}>
|
||||
{ elForThisStep }
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -137,6 +137,7 @@ class Stream extends React.Component {
|
||||
comment={highlightedComment}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={this.props.editComment}
|
||||
/>
|
||||
: <div>
|
||||
<NewCount
|
||||
@@ -178,6 +179,7 @@ class Stream extends React.Component {
|
||||
pluginProps={pluginProps}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={this.props.editComment}
|
||||
/>)
|
||||
)}
|
||||
</div>
|
||||
@@ -205,7 +207,10 @@ Stream.propTypes = {
|
||||
removeCommentTag: PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func
|
||||
ignoreUser: React.PropTypes.func,
|
||||
|
||||
// edit a comment, passed (id, asset_id, { body })
|
||||
editComment: React.PropTypes.func,
|
||||
};
|
||||
|
||||
export default Stream;
|
||||
|
||||
@@ -20,5 +20,4 @@
|
||||
position: relative;
|
||||
transform: rotate(180deg);
|
||||
top: 0;
|
||||
/*top: -0.25em;*/
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Given a comment, return when the comment can no longer be edited
|
||||
* @param {Object} comment
|
||||
* @returns {Date} when the comment can no longer be edited.
|
||||
*/
|
||||
export const getEditableUntilDate = (comment) => {
|
||||
const editing = comment && comment.editing;
|
||||
const editableUntil = editing && editing.editableUntil && new Date(Date.parse(editing.editableUntil));
|
||||
return editableUntil;
|
||||
};
|
||||
@@ -41,6 +41,10 @@ export default withFragments({
|
||||
id
|
||||
}
|
||||
}
|
||||
editing {
|
||||
edited
|
||||
editableUntil
|
||||
}
|
||||
${pluginFragments.spreads('comment')}
|
||||
}
|
||||
${pluginFragments.definitions('comment')}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {bindActionCreators} from 'redux';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import branch from 'recompose/branch';
|
||||
import renderComponent from 'recompose/renderComponent';
|
||||
import update from 'immutability-helper';
|
||||
|
||||
import {Spinner} from 'coral-ui';
|
||||
import {authActions, assetActions, pym} from 'coral-framework';
|
||||
@@ -80,8 +81,11 @@ export const withQuery = graphql(EMBED_QUERY, {
|
||||
assetUrl,
|
||||
commentId,
|
||||
hasComment: commentId !== '',
|
||||
excludeIgnored: Boolean(auth && auth.user && auth.user.id)
|
||||
}
|
||||
excludeIgnored: Boolean(auth && auth.user && auth.user.id),
|
||||
},
|
||||
reducer: (previousResult, action, variables) => {
|
||||
return reduceEditCommentActionsToUpdateStreamQuery(previousResult, action, variables);
|
||||
},
|
||||
}),
|
||||
props: ({data}) => separateDataAndRoot(data)
|
||||
});
|
||||
@@ -114,3 +118,86 @@ export default compose(
|
||||
branch(props => !props.auth.checkedInitialLogin && props.config, renderComponent(Spinner)),
|
||||
withQuery
|
||||
)(EmbedContainer);
|
||||
|
||||
/**
|
||||
* Reduce editComment mutation actions
|
||||
* producing a new queryStream result where asset.comments reflects the edit
|
||||
*/
|
||||
function reduceEditCommentActionsToUpdateStreamQuery(previousResult, action) {
|
||||
if ( ! (action.type === 'APOLLO_MUTATION_RESULT' && action.operationName === 'editComment')) {
|
||||
return previousResult;
|
||||
}
|
||||
const resultHasErrors = (result) => {
|
||||
try {
|
||||
return result.data.editComment.errors.length > 0;
|
||||
} catch (error) {
|
||||
|
||||
// expected if no errors;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (resultHasErrors(action.result)) {
|
||||
return previousResult;
|
||||
}
|
||||
const {variables: {id, edit}, result: {data: {editComment: {comment: {status}}}}} = action;
|
||||
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(previousResult, {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 {postComment, postFlag, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser} from 'coral-framework/graphql/mutations';
|
||||
import {postComment, postFlag, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser, editComment} from 'coral-framework/graphql/mutations';
|
||||
import {notificationActions, authActions} from 'coral-framework';
|
||||
import {editName} from 'coral-framework/actions/user';
|
||||
import {setCommentCountCache, setActiveReplyBox} from '../actions/stream';
|
||||
@@ -243,5 +243,6 @@ export default compose(
|
||||
removeCommentTag,
|
||||
ignoreUser,
|
||||
deleteAction,
|
||||
editComment,
|
||||
)(StreamContainer);
|
||||
|
||||
|
||||
@@ -191,12 +191,13 @@ hr {
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-textarea {
|
||||
color: #262626;
|
||||
flex: 1;
|
||||
padding: 5px;
|
||||
padding: 1em;
|
||||
min-height: 100px;
|
||||
margin-top: 10px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border: 1px solid #9E9E9E;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-button-container {
|
||||
@@ -317,9 +318,7 @@ button.comment__action-button[disabled],
|
||||
}
|
||||
|
||||
.coral-plugin-pubdate-text {
|
||||
color: #696969;
|
||||
display: inline-block;
|
||||
font-size: .75rem;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,4 +15,8 @@ fragment commentView on Comment {
|
||||
action_summaries {
|
||||
...actionSummaryView
|
||||
}
|
||||
editing {
|
||||
edited
|
||||
editableUntil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
mutation editComment ($id: ID!, $asset_id: ID!, $edit: EditCommentInput) {
|
||||
editComment(id:$id, asset_id:$asset_id, edit:$edit) {
|
||||
comment {
|
||||
status
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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 EDIT_COMMENT from './editComment.graphql';
|
||||
|
||||
import commentView from '../fragments/commentView.graphql';
|
||||
|
||||
@@ -171,3 +172,20 @@ export const stopIgnoringUser = graphql(STOP_IGNORING_USER, {
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const editComment = graphql(EDIT_COMMENT, {
|
||||
props: ({mutate}) => {
|
||||
return {
|
||||
editComment: (id, asset_id, edit) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
asset_id,
|
||||
edit,
|
||||
},
|
||||
refetchQueries: []
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,9 +22,21 @@
|
||||
"comment": "comment",
|
||||
"comments": "comments",
|
||||
"commentIsIgnored": "This comment is hidden because you ignored this user.",
|
||||
"editComment": {
|
||||
"bodyInputLabel": "Edit this comment",
|
||||
"saveButton": "Save changes",
|
||||
"editWindowExpired": "You can no longer edit this comment. The time window to do so has expired. Why not post another one?",
|
||||
"editWindowExpiredClose": "Close",
|
||||
"editWindowTimerPrefix": "Edit Window: ",
|
||||
"second": "second",
|
||||
"secondsPlural": "seconds",
|
||||
"unexpectedError": "Unexpected error while saving changes. Sorry!"
|
||||
},
|
||||
"error": {
|
||||
"editWindowExpired": "You can no longer edit this comment. The time window to do so has expired.",
|
||||
"emailNotVerified": "Email address {0} not verified.",
|
||||
"email": "Not a valid E-Mail",
|
||||
"networkError": "Failed to connect to server. Check your internet connection and try again.",
|
||||
"password": "Password must be at least 8 characters",
|
||||
"username": "Usernames can contain letters, numbers and _ only",
|
||||
"confirmPassword": "Passwords don't match. Please, check again",
|
||||
@@ -61,9 +73,21 @@
|
||||
"comments": "commentarios",
|
||||
"commentIsIgnored": "Este comentario está escondido porque has ignorado al usuario.",
|
||||
"showAllComments": "Mostrar todos los comentarios",
|
||||
"editComment": {
|
||||
"bodyInputLabel": "Editar este comentario",
|
||||
"saveButton": "Guardar cambios",
|
||||
"editWindowExpired": "Ya no puedes editar este comentario. La ventana de tiempo para hacerlo ha caducado. ¿Por qué no publicar otro?",
|
||||
"editWindowExpiredClose": "Cerca",
|
||||
"editWindowTimerPrefix": "Ventana de edición: ",
|
||||
"second": "segundo",
|
||||
"secondsPlural": "segundos",
|
||||
"unexpectedError": "Unexpected error while saving changes. Sorry!"
|
||||
},
|
||||
"error": {
|
||||
"editWindowExpired": "Ya no puedes editar este comentario. La ventana de tiempo para hacerlo ha caducado.",
|
||||
"emailNotVerified": "E-mail {0} no verificado.",
|
||||
"email": "No es un e-mail válido",
|
||||
"networkError": "Error al conectar con el servidor. Compruebe su conexión a Internet y vuelva a intentarlo.",
|
||||
"password": "La contraseña debe tener por lo menos 8 caracteres",
|
||||
"username": "Los nombres pueden contener letras, números y _",
|
||||
"organizationName": "El nombre de la organización debe contener letras y/o números.",
|
||||
|
||||
@@ -1,28 +1,46 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
import {connect} from 'react-redux';
|
||||
import {I18n} from '../coral-framework';
|
||||
import translations from './translations.json';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import {connect} from 'react-redux';
|
||||
import {CommentForm} from './CommentForm';
|
||||
|
||||
const name = 'coral-plugin-commentbox';
|
||||
export const name = 'coral-plugin-commentbox';
|
||||
|
||||
// Given a newly posted comment's status, show a notification to the user
|
||||
// if needed
|
||||
export const notifyForNewCommentStatus = (addNotification, status) => {
|
||||
if (status === 'REJECTED') {
|
||||
addNotification('error', lang.t('comment-post-banned-word'));
|
||||
} else if (status === 'PREMOD') {
|
||||
addNotification('success', lang.t('comment-post-notif-premod'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Container for posting a new Comment
|
||||
*/
|
||||
class CommentBox extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
username: '',
|
||||
body: '',
|
||||
|
||||
// incremented on successful post to clear form
|
||||
postedCount: 0,
|
||||
hooks: {
|
||||
preSubmit: [],
|
||||
postSubmit: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
postComment = () => {
|
||||
static get defaultProps() {
|
||||
return {
|
||||
setCommentCountCache: () => {}
|
||||
};
|
||||
}
|
||||
postComment = ({body}) => {
|
||||
const {
|
||||
commentPostedHandler,
|
||||
postItem,
|
||||
@@ -37,7 +55,7 @@ class CommentBox extends React.Component {
|
||||
let comment = {
|
||||
asset_id: assetId,
|
||||
parent_id: parentId,
|
||||
body: this.state.body,
|
||||
body,
|
||||
...this.props.commentBox
|
||||
};
|
||||
|
||||
@@ -53,11 +71,11 @@ class CommentBox extends React.Component {
|
||||
// Execute postSubmit Hooks
|
||||
this.state.hooks.postSubmit.forEach(hook => hook(data));
|
||||
|
||||
notifyForNewCommentStatus(addNotification, postedComment.status);
|
||||
|
||||
if (postedComment.status === 'REJECTED') {
|
||||
addNotification('error', lang.t('comment-post-banned-word'));
|
||||
!isReply && setCommentCountCache(commentCountCache);
|
||||
} else if (postedComment.status === 'PREMOD') {
|
||||
addNotification('success', lang.t('comment-post-notif-premod'));
|
||||
!isReply && setCommentCountCache(commentCountCache);
|
||||
}
|
||||
|
||||
@@ -69,7 +87,8 @@ class CommentBox extends React.Component {
|
||||
console.error(err);
|
||||
!isReply && setCommentCountCache(commentCountCache);
|
||||
});
|
||||
this.setState({body: ''});
|
||||
|
||||
this.setState({postedCount: this.state.postedCount + 1});
|
||||
}
|
||||
|
||||
registerHook = (hookType = '', hook = () => {}) => {
|
||||
@@ -126,69 +145,39 @@ class CommentBox extends React.Component {
|
||||
const {styles, isReply, authorId, maxCharCount} = this.props;
|
||||
let {cancelButtonClicked} = this.props;
|
||||
|
||||
const length = this.state.body.length;
|
||||
const enablePostComment = !length || (maxCharCount && length > maxCharCount);
|
||||
|
||||
if (isReply && typeof cancelButtonClicked !== 'function') {
|
||||
console.warn('the CommentBox component should have a cancelButtonClicked callback defined if it lives in a Reply');
|
||||
cancelButtonClicked = () => {};
|
||||
}
|
||||
|
||||
return <div>
|
||||
<div
|
||||
className={`${name}-container`}>
|
||||
<label
|
||||
htmlFor={ isReply ? 'replyText' : 'commentText'}
|
||||
className="screen-reader-text"
|
||||
aria-hidden={true}>
|
||||
{isReply ? lang.t('reply') : lang.t('comment')}
|
||||
</label>
|
||||
<textarea
|
||||
className={`${name}-textarea`}
|
||||
style={styles && styles.textarea}
|
||||
value={this.state.body}
|
||||
placeholder={lang.t('comment')}
|
||||
id={isReply ? 'replyText' : 'commentText'}
|
||||
onChange={this.handleChange}
|
||||
rows={3}/>
|
||||
<Slot fill='commentInputArea' />
|
||||
</div>
|
||||
<div className={`${name}-char-count ${length > maxCharCount ? `${name}-char-max` : ''}`}>
|
||||
{maxCharCount && `${maxCharCount - length} ${lang.t('characters-remaining')}`}
|
||||
</div>
|
||||
<div className={`${name}-button-container`}>
|
||||
<Slot
|
||||
fill="commentInputDetailArea"
|
||||
registerHook={this.registerHook}
|
||||
unregisterHook={this.unregisterHook}
|
||||
inline
|
||||
/>
|
||||
{
|
||||
isReply && (
|
||||
<Button
|
||||
cStyle='darkGrey'
|
||||
className={`${name}-cancel-button`}
|
||||
onClick={() => cancelButtonClicked('')}>
|
||||
{lang.t('cancel')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{ authorId && (
|
||||
<Button
|
||||
cStyle={enablePostComment ? 'lightGrey' : 'darkGrey'}
|
||||
className={`${name}-button`}
|
||||
onClick={this.postComment}
|
||||
disabled={enablePostComment ? 'disabled' : ''}>
|
||||
{lang.t('post')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<CommentForm
|
||||
styles={styles}
|
||||
key={this.state.postedCount}
|
||||
defaultValue={this.props.defaultValue}
|
||||
bodyInputId={isReply ? 'replyText' : 'commentText'}
|
||||
bodyLabel={isReply ? lang.t('reply') : lang.t('comment')}
|
||||
maxCharCount={maxCharCount}
|
||||
charCountEnable={this.props.charCountEnable}
|
||||
bodyPlaceholder={lang.t('comment')}
|
||||
bodyInputId={isReply ? 'replyText' : 'commentText'}
|
||||
saveComment={authorId && this.postComment}
|
||||
buttonContainerStart={<Slot
|
||||
fill="commentInputDetailArea"
|
||||
registerHook={this.registerHook}
|
||||
unregisterHook={this.unregisterHook}
|
||||
inline
|
||||
/>}
|
||||
cancelButtonClicked={cancelButtonClicked}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
CommentBox.propTypes = {
|
||||
|
||||
// Initial value for underlying comment body textarea
|
||||
defaultValue: PropTypes.string,
|
||||
charCountEnable: PropTypes.bool.isRequired,
|
||||
maxCharCount: PropTypes.number,
|
||||
commentPostedHandler: PropTypes.func,
|
||||
@@ -199,6 +188,7 @@ CommentBox.propTypes = {
|
||||
authorId: PropTypes.string.isRequired,
|
||||
isReply: PropTypes.bool.isRequired,
|
||||
canPost: PropTypes.bool,
|
||||
setCommentCountCache: PropTypes.func,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({commentBox}) => ({commentBox});
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
import classnames from 'classnames';
|
||||
import {I18n} from '../coral-framework';
|
||||
import translations from './translations.json';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
|
||||
import {name} from './CommentBox';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
/**
|
||||
* Common UI for Creating or Editing a Comment
|
||||
*/
|
||||
export class CommentForm extends React.Component {
|
||||
static propTypes = {
|
||||
|
||||
// Initial value for underlying comment body textarea
|
||||
defaultValue: PropTypes.string,
|
||||
charCountEnable: PropTypes.bool.isRequired,
|
||||
maxCharCount: PropTypes.number,
|
||||
cancelButtonClicked: PropTypes.func,
|
||||
|
||||
// Save the comment in the form.
|
||||
// Will be passed { body: String }
|
||||
saveComment: PropTypes.func.isRequired,
|
||||
|
||||
// DOM ID for form input that edits comment body
|
||||
bodyInputId: PropTypes.string,
|
||||
|
||||
// screen reader label for input that edits comment body
|
||||
bodyLabel: PropTypes.string,
|
||||
|
||||
// Placeholder for input that edits comment body
|
||||
bodyPlaceholder: PropTypes.string,
|
||||
|
||||
// render at start of button container (useful for extra buttons)
|
||||
buttonContainerStart: PropTypes.node,
|
||||
|
||||
// render inside submit button
|
||||
submitText: PropTypes.node,
|
||||
|
||||
styles: PropTypes.shape({
|
||||
textarea: PropTypes.string
|
||||
}),
|
||||
|
||||
// cStyle for enabled save <coral-ui/Button>
|
||||
saveButtonCStyle: PropTypes.string,
|
||||
|
||||
// return whether the save button should be enabled for the provided
|
||||
// comment ({ body }) (for reasons other than charCount)
|
||||
saveCommentEnabled: PropTypes.func,
|
||||
|
||||
// className to add to buttons
|
||||
buttonClass: PropTypes.string,
|
||||
}
|
||||
static get defaultProps() {
|
||||
return {
|
||||
bodyLabel: lang.t('comment'),
|
||||
bodyPlaceholder: lang.t('comment'),
|
||||
submitText: lang.t('post'),
|
||||
saveButtonCStyle: 'darkGrey',
|
||||
saveCommentEnabled: () => true,
|
||||
};
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onBodyChange = this.onBodyChange.bind(this);
|
||||
this.onClickSubmit = this.onClickSubmit.bind(this);
|
||||
this.state = {
|
||||
body: props.defaultValue || ''
|
||||
};
|
||||
}
|
||||
onBodyChange(e) {
|
||||
this.setState({body: e.target.value});
|
||||
}
|
||||
onClickSubmit(e) {
|
||||
e.preventDefault();
|
||||
const {saveComment} = this.props;
|
||||
const {body} = this.state;
|
||||
saveComment({body});
|
||||
}
|
||||
render() {
|
||||
const {maxCharCount, styles, saveCommentEnabled, buttonClass, charCountEnable} = this.props;
|
||||
|
||||
const body = this.state.body;
|
||||
const length = body.length;
|
||||
const isNotValidLength = (length) => !length || (maxCharCount && length > maxCharCount);
|
||||
const disablePostComment = (charCountEnable && isNotValidLength(length)) || ! saveCommentEnabled({body});
|
||||
|
||||
return <div>
|
||||
<div className={`${name}-container`}>
|
||||
<label
|
||||
htmlFor={this.props.bodyInputId}
|
||||
className="screen-reader-text"
|
||||
aria-hidden={true}>
|
||||
{this.props.bodyLabel}
|
||||
</label>
|
||||
<textarea
|
||||
style={styles && styles.textarea}
|
||||
className={`${name}-textarea`}
|
||||
value={this.state.body}
|
||||
placeholder={this.props.bodyPlaceholder}
|
||||
id={this.props.bodyInputId}
|
||||
onChange={this.onBodyChange}
|
||||
rows={3}/>
|
||||
<Slot fill='commentInputArea' />
|
||||
</div>
|
||||
{
|
||||
this.props.charCountEnable &&
|
||||
<div className={`${name}-char-count ${length > maxCharCount ? `${name}-char-max` : ''}`}>
|
||||
{maxCharCount && `${maxCharCount - length} ${lang.t('characters-remaining')}`}
|
||||
</div>
|
||||
}
|
||||
<div className={`${name}-button-container`}>
|
||||
{ this.props.buttonContainerStart }
|
||||
{
|
||||
typeof this.props.cancelButtonClicked === 'function' && (
|
||||
<Button
|
||||
cStyle='darkGrey'
|
||||
className={classnames(`${name}-cancel-button`, buttonClass)}
|
||||
onClick={this.props.cancelButtonClicked}>
|
||||
{lang.t('cancel')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
cStyle={disablePostComment ? 'lightGrey' : this.props.saveButtonCStyle}
|
||||
className={classnames(`${name}-button`, buttonClass)}
|
||||
onClick={this.onClickSubmit}
|
||||
disabled={disablePostComment ? 'disabled' : ''}>
|
||||
{this.props.submitText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,12 @@ const ErrLoginAttemptMaximumExceeded = new APIError('You have made too many inco
|
||||
status: 429
|
||||
});
|
||||
|
||||
class ErrEditWindowHasEnded extends APIError {
|
||||
constructor(message) {
|
||||
super(message || 'Edit window is over.', {status: 403, translation_key: 'error.editWindowExpired'});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ExtendableError,
|
||||
APIError,
|
||||
@@ -174,5 +180,6 @@ module.exports = {
|
||||
ErrPermissionUpdateUsername,
|
||||
ErrSettingsInit,
|
||||
ErrInstallLock,
|
||||
ErrLoginAttemptMaximumExceeded
|
||||
ErrLoginAttemptMaximumExceeded,
|
||||
ErrEditWindowHasEnded,
|
||||
};
|
||||
|
||||
+49
-11
@@ -61,11 +61,11 @@ const createComment = ({user, loaders: {Comments}, pubsub}, {body, asset_id, par
|
||||
|
||||
/**
|
||||
* Filters the comment object and outputs wordlist results.
|
||||
* @param {Object} context graphql context
|
||||
* @param {String} body body of a comment
|
||||
* @param {String} body body of a comment
|
||||
* @param {String} [asset_id] id of asset comment is posted on
|
||||
* @return {Object} resolves to the wordlist results
|
||||
*/
|
||||
const filterNewComment = (context, {body, asset_id}) => {
|
||||
const filterNewComment = ({body, asset_id}) => {
|
||||
|
||||
// Create a new instance of the Wordlist.
|
||||
const wl = new Wordlist();
|
||||
@@ -73,20 +73,19 @@ const filterNewComment = (context, {body, asset_id}) => {
|
||||
// Load the wordlist and filter the comment content.
|
||||
return Promise.all([
|
||||
wl.load().then(() => wl.scan('body', body)),
|
||||
AssetsService.rectifySettings(AssetsService.findById(asset_id))
|
||||
asset_id && AssetsService.rectifySettings(AssetsService.findById(asset_id))
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* This resolves a given comment's status to take into account moderator actions
|
||||
* are applied.
|
||||
* @param {Object} context graphql context
|
||||
* @param {String} body body of the comment
|
||||
* @param {String} asset_id asset for the comment
|
||||
* @param {String} [asset_id] asset for the comment
|
||||
* @param {Object} [wordlist={}] the results of the wordlist scan
|
||||
* @return {Promise} resolves to the comment's status
|
||||
*/
|
||||
const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}, settings) => {
|
||||
const resolveNewCommentStatus = ({asset_id, body}, wordlist = {}, settings = {}) => {
|
||||
|
||||
// Decide the status based on whether or not the current asset/settings
|
||||
// has pre-mod enabled or not. If the comment was rejected based on the
|
||||
@@ -98,7 +97,7 @@ const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}, setti
|
||||
status = Promise.resolve('REJECTED');
|
||||
} else if (settings.premodLinksEnable && linkify.test(body)) {
|
||||
status = Promise.resolve('PREMOD');
|
||||
} else {
|
||||
} else if (asset_id) {
|
||||
status = AssetsService
|
||||
.rectifySettings(AssetsService.findById(asset_id).then((asset) => {
|
||||
if (!asset) {
|
||||
@@ -125,6 +124,8 @@ const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}, setti
|
||||
}
|
||||
return moderation === 'PRE' ? 'PREMOD' : 'NONE';
|
||||
});
|
||||
} else {
|
||||
status = 'NONE';
|
||||
}
|
||||
|
||||
return status;
|
||||
@@ -142,12 +143,12 @@ const createPublicComment = (context, commentInput) => {
|
||||
|
||||
// First we filter the comment contents to ensure that we note any validation
|
||||
// issues.
|
||||
return filterNewComment(context, commentInput)
|
||||
return filterNewComment(commentInput)
|
||||
|
||||
// We then take the wordlist and the comment into consideration when
|
||||
// considering what status to assign the new comment, and resolve the new
|
||||
// status to set the comment to.
|
||||
.then(([wordlist, settings]) => resolveNewCommentStatus(context, commentInput, wordlist, settings)
|
||||
.then(([wordlist, settings]) => resolveNewCommentStatus(commentInput, wordlist, settings)
|
||||
|
||||
// Then we actually create the comment with the new status.
|
||||
.then((status) => createComment(context, commentInput, status))
|
||||
@@ -219,13 +220,45 @@ const addCommentTag = ({user, loaders: {Comments}}, {id, tag}) => {
|
||||
|
||||
/**
|
||||
* Removes a tag from a Comment
|
||||
* @param {String} id identifier of the comment (uuid)
|
||||
* @param {String} id identifier of the comment (uuid)
|
||||
* @param {String} tag name of the tag
|
||||
*/
|
||||
const removeCommentTag = ({user, loaders: {Comments}}, {id, tag}) => {
|
||||
return CommentsService.removeTag(id, tag);
|
||||
};
|
||||
|
||||
/**
|
||||
* Edit a Comment
|
||||
* @param {String} id identifier of the comment (uuid)
|
||||
* @param {Object} edit describes how to edit the comment
|
||||
* @param {String} edit.body the new Comment body
|
||||
*/
|
||||
const editComment = async ({user, loaders: {Comments}}, {id, asset_id, edit}) => {
|
||||
const {body} = edit;
|
||||
const determineStatusForComment = async ({body, asset_id}) => {
|
||||
const [wordlist, settings] = await filterNewComment({asset_id, body});
|
||||
const status = await resolveNewCommentStatus({asset_id, body}, wordlist, settings);
|
||||
return status;
|
||||
};
|
||||
const status = await determineStatusForComment({body, asset_id});
|
||||
try {
|
||||
await CommentsService.edit(id, asset_id, user.id, Object.assign({status}, edit));
|
||||
} catch (error) {
|
||||
switch (error.name) {
|
||||
case 'CommentNotFound':
|
||||
throw new errors.APIError('Comment not found', {
|
||||
status: 404,
|
||||
translation_key: 'NOT_FOUND',
|
||||
});
|
||||
case 'NotAuthorizedToEdit':
|
||||
throw errors.ErrNotAuthorized;
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return {status};
|
||||
};
|
||||
|
||||
module.exports = (context) => {
|
||||
let mutators = {
|
||||
Comment: {
|
||||
@@ -233,6 +266,7 @@ module.exports = (context) => {
|
||||
setCommentStatus: () => Promise.reject(errors.ErrNotAuthorized),
|
||||
addCommentTag: () => Promise.reject(errors.ErrNotAuthorized),
|
||||
removeCommentTag: () => Promise.reject(errors.ErrNotAuthorized),
|
||||
editComment: () => Promise.reject(errors.ErrNotAuthorized),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,5 +286,9 @@ module.exports = (context) => {
|
||||
mutators.Comment.removeCommentTag = (action) => removeCommentTag(context, action);
|
||||
}
|
||||
|
||||
if (context.user) {
|
||||
mutators.Comment.editComment = (action) => editComment(context, action);
|
||||
}
|
||||
|
||||
return mutators;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const CommentsService = require('../../services/comments');
|
||||
|
||||
const Comment = {
|
||||
parent({parent_id}, _, {loaders: {Comments}}) {
|
||||
if (parent_id == null) {
|
||||
@@ -45,6 +47,14 @@ const Comment = {
|
||||
},
|
||||
asset({asset_id}, _, {loaders: {Assets}}) {
|
||||
return Assets.getByID.load(asset_id);
|
||||
},
|
||||
editing(comment) {
|
||||
const editableUntil = CommentsService.getEditableUntilDate(comment);
|
||||
const edited = comment.body_history.length > 1;
|
||||
return {
|
||||
edited,
|
||||
editableUntil,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ const RootMutation = {
|
||||
createComment(_, {comment}, {mutators: {Comment}}) {
|
||||
return wrapResponse('comment')(Comment.create(comment));
|
||||
},
|
||||
editComment(_, args, {mutators: {Comment}}) {
|
||||
return wrapResponse('comment')(Comment.editComment(args));
|
||||
},
|
||||
createFlag(_, {flag: {item_id, item_type, reason, message}}, {mutators: {Action}}) {
|
||||
return wrapResponse('flag')(Action.create({item_id, item_type, action_type: 'FLAG', group_id: reason, metadata: {message}}));
|
||||
},
|
||||
|
||||
@@ -163,6 +163,11 @@ input CommentCountQuery {
|
||||
tag: [String]
|
||||
}
|
||||
|
||||
type EditInfo {
|
||||
edited: Boolean!
|
||||
editableUntil: Date
|
||||
}
|
||||
|
||||
# Comment is the base representation of user interaction in Talk.
|
||||
type Comment {
|
||||
|
||||
@@ -204,6 +209,9 @@ type Comment {
|
||||
|
||||
# The time when the comment was created
|
||||
created_at: Date!
|
||||
|
||||
# describes how the comment can be edited
|
||||
editing: EditInfo
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -721,6 +729,23 @@ type StopIgnoringUserResponse implements Response {
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
# Input to editComment mutation
|
||||
input EditCommentInput {
|
||||
# Update body of the comment
|
||||
body: String!
|
||||
}
|
||||
|
||||
type CommentInfoAfterEdit {
|
||||
# New status of the edited comment
|
||||
status: COMMENT_STATUS!
|
||||
}
|
||||
|
||||
type EditCommentResponse implements Response {
|
||||
comment: CommentInfoAfterEdit!
|
||||
# An array of errors relating to the mutation that occured.
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
# All mutations for the application are defined on this object.
|
||||
type RootMutation {
|
||||
|
||||
@@ -736,6 +761,9 @@ type RootMutation {
|
||||
# Delete an action based on the action id.
|
||||
deleteAction(id: ID!): DeleteActionResponse
|
||||
|
||||
# Edit a comment
|
||||
editComment(id: ID!, asset_id: ID!, edit: EditCommentInput): EditCommentResponse
|
||||
|
||||
# Sets User status. Requires the `ADMIN` role.
|
||||
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse
|
||||
|
||||
|
||||
@@ -51,6 +51,23 @@ const TagSchema = new Schema({
|
||||
_id: false
|
||||
});
|
||||
|
||||
/**
|
||||
* A record of old body values for a Comment
|
||||
*/
|
||||
const BodyHistoryItemSchema = new Schema({
|
||||
body: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
|
||||
// datetime until the comment body value was this.body
|
||||
created_at: {
|
||||
required: true,
|
||||
type: Date,
|
||||
default: Date,
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The Mongo schema for a Comment.
|
||||
* @type {Schema}
|
||||
@@ -66,6 +83,7 @@ const CommentSchema = new Schema({
|
||||
required: [true, 'The body is required.'],
|
||||
minlength: 2
|
||||
},
|
||||
body_history: [BodyHistoryItemSchema],
|
||||
asset_id: String,
|
||||
author_id: String,
|
||||
status_history: [StatusSchema],
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"graphql-subscriptions": "^0.3.1",
|
||||
"graphql-tools": "^0.10.1",
|
||||
"helmet": "^3.5.0",
|
||||
"immutability-helper": "^2.2.0",
|
||||
"inquirer": "^3.0.6",
|
||||
"joi": "^10.4.1",
|
||||
"jsonwebtoken": "^7.3.0",
|
||||
@@ -104,6 +105,7 @@
|
||||
"resolve": "^1.3.2",
|
||||
"semver": "^5.3.0",
|
||||
"simplemde": "^1.11.2",
|
||||
"timekeeper": "^1.0.0",
|
||||
"uuid": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+97
-6
@@ -3,6 +3,8 @@ const CommentModel = require('../models/comment');
|
||||
const ActionModel = require('../models/action');
|
||||
const ActionsService = require('./actions');
|
||||
|
||||
const {ErrEditWindowHasEnded} = require('../errors');
|
||||
|
||||
// const ALLOWED_TAGS = [
|
||||
// {name: 'STAFF'},
|
||||
// {name: 'BEST'},
|
||||
@@ -15,6 +17,8 @@ const STATUSES = [
|
||||
'NONE',
|
||||
];
|
||||
|
||||
const EDIT_WINDOW_MS = 30 * 1000; // 30 seconds
|
||||
|
||||
module.exports = class CommentsService {
|
||||
|
||||
/**
|
||||
@@ -33,16 +37,103 @@ module.exports = class CommentsService {
|
||||
status = 'NONE',
|
||||
} = comment;
|
||||
|
||||
comment.status_history = status ? [{
|
||||
type: status,
|
||||
created_at: new Date()
|
||||
}] : [];
|
||||
|
||||
let commentModel = new CommentModel(comment);
|
||||
const commentModel = new CommentModel(Object.assign({
|
||||
status_history: status ? [{
|
||||
type: status,
|
||||
created_at: new Date()
|
||||
}] : [],
|
||||
body_history: [{
|
||||
body: comment.body,
|
||||
created_at: new Date()
|
||||
}]
|
||||
}, comment));
|
||||
|
||||
return commentModel.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a Comment
|
||||
* @param {String} id comment.id you want to edit (or its ID)
|
||||
* @param {String} asset_id asset_id of the comment
|
||||
* @param {String} editor user.id of the user trying to edit the comment (will err if not comment author)
|
||||
* @param {String} body the new Comment body
|
||||
* @param {String} status the new Comment status
|
||||
*/
|
||||
static async edit(id, asset_id, editor, {body, status, ignoreEditWindow}) {
|
||||
if (status && ! STATUSES.includes(status)) {
|
||||
throw new Error(`status ${status} is not supported`);
|
||||
}
|
||||
const lastEditableCommentCreatedAt = new Date((new Date()).getTime() - EDIT_WINDOW_MS);
|
||||
const filter = Object.assign(
|
||||
{
|
||||
id,
|
||||
asset_id,
|
||||
author_id: editor,
|
||||
},
|
||||
ignoreEditWindow ? {} : {
|
||||
created_at: {
|
||||
$gt: lastEditableCommentCreatedAt,
|
||||
},
|
||||
}
|
||||
);
|
||||
const {nModified} = await CommentModel.update(
|
||||
filter,
|
||||
{
|
||||
$set: {
|
||||
body,
|
||||
status,
|
||||
},
|
||||
$push: {
|
||||
body_history: {
|
||||
body,
|
||||
created_at: new Date(),
|
||||
},
|
||||
status_history: {
|
||||
type: status,
|
||||
created_at: new Date(),
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
switch (nModified) {
|
||||
case 0: {
|
||||
|
||||
// disambiguate possible error cases
|
||||
const comment = await this.findById(id);
|
||||
|
||||
// return whether the comment should no longer be editable
|
||||
// because its edit window expired
|
||||
const editWindowExpired = (comment) => {
|
||||
const now = new Date;
|
||||
const editableUntil = this.getEditableUntilDate(comment);
|
||||
return now > editableUntil;
|
||||
};
|
||||
if ( ! comment || (comment.asset_id !== asset_id)) {
|
||||
throw Object.assign(new Error('Comment not found'), {
|
||||
name: 'CommentNotFound'
|
||||
});
|
||||
} else if (comment.author_id !== editor) {
|
||||
throw Object.assign(new Error('You aren\'t allowed to edit that comment'), {
|
||||
name: 'NotAuthorizedToEdit'
|
||||
});
|
||||
} else if (( ! ignoreEditWindow) && editWindowExpired(comment)) {
|
||||
throw new ErrEditWindowHasEnded();
|
||||
}
|
||||
throw new Error('Failed to edit comment. This could be because it can\'t be found, the edit window expired, or because you\'re not allowed to edit it.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Until when can the provided comment be edited?
|
||||
* @param {Comment} comment - comment to check last edit date of
|
||||
* @returns {Date} last date at which comment can be edited
|
||||
*/
|
||||
static getEditableUntilDate(comment) {
|
||||
const {created_at} = comment;
|
||||
return new Date(Number(created_at) + EDIT_WINDOW_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a tag if it doesn't already exist on the comment.
|
||||
* @throws if tag is already added to the comment
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
const expect = require('chai').expect;
|
||||
const {graphql} = require('graphql');
|
||||
const timekeeper = require('timekeeper');
|
||||
|
||||
const schema = require('../../../../graph/schema');
|
||||
const Context = require('../../../../graph/context');
|
||||
const UsersService = require('../../../../services/users');
|
||||
const AssetModel = require('../../../../models/asset');
|
||||
const SettingsService = require('../../../../services/settings');
|
||||
const CommentsService = require('../../../../services/comments');
|
||||
|
||||
describe('graph.mutations.editComment', () => {
|
||||
let asset;
|
||||
let user;
|
||||
let settings;
|
||||
beforeEach(async () => {
|
||||
timekeeper.reset();
|
||||
settings = await SettingsService.init();
|
||||
asset = await AssetModel.create({});
|
||||
user = await UsersService.createLocalUser(
|
||||
'usernameA@example.com', 'password', 'usernameA');
|
||||
});
|
||||
afterEach(async () => {
|
||||
await asset.remove();
|
||||
await user.remove();
|
||||
await settings.remove();
|
||||
});
|
||||
|
||||
const editCommentMutation = `
|
||||
mutation EditComment ($id: ID!, $asset_id: ID!, $edit: EditCommentInput) {
|
||||
editComment(id:$id, asset_id:$asset_id, edit:$edit) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
it('a user can edit their own comment', async () => {
|
||||
const context = new Context({user});
|
||||
const testStartedAt = new Date();
|
||||
const comment = await CommentsService.publicCreate({
|
||||
asset_id: asset.id,
|
||||
author_id: user.id,
|
||||
body: `hello there! ${ String(Math.random()).slice(2)}`,
|
||||
});
|
||||
|
||||
// body_history should be there
|
||||
expect(comment.body_history.length).to.equal(1);
|
||||
expect(comment.body_history[0].body).to.equal(comment.body);
|
||||
expect(comment.body_history[0].created_at).to.be.instanceOf(Date);
|
||||
expect(comment.body_history[0].created_at).to.be.at.least(testStartedAt);
|
||||
|
||||
// now edit
|
||||
const newBody = 'I have been edited.';
|
||||
const response = await graphql(schema, editCommentMutation, {}, context, {
|
||||
id: comment.id,
|
||||
asset_id: asset.id,
|
||||
edit: {
|
||||
body: newBody
|
||||
}
|
||||
});
|
||||
if (response.errors && response.errors.length) {
|
||||
console.error(response.errors);
|
||||
}
|
||||
expect(response.errors).to.be.empty;
|
||||
expect(response.data.editComment.errors).to.be.null;
|
||||
|
||||
// assert body has changed
|
||||
const commentAfterEdit = await CommentsService.findById(comment.id);
|
||||
expect(commentAfterEdit.body).to.equal(newBody);
|
||||
expect(commentAfterEdit.body_history).to.be.instanceOf(Array);
|
||||
expect(commentAfterEdit.body_history.length).to.equal(2);
|
||||
expect(commentAfterEdit.body_history[1].body).to.equal(newBody);
|
||||
expect(commentAfterEdit.body_history[1].created_at).to.be.instanceOf(Date);
|
||||
expect(commentAfterEdit.body_history[1].created_at).to.be.at.least(testStartedAt);
|
||||
expect(commentAfterEdit.status).to.equal('NONE');
|
||||
});
|
||||
|
||||
it('A user can\'t edit their comment outside of the edit comment time window', async () => {
|
||||
const comment = await CommentsService.publicCreate({
|
||||
asset_id: asset.id,
|
||||
author_id: user.id,
|
||||
body: `hello there! ${ String(Math.random()).slice(2)}`,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const oneHourFromNow = new Date(new Date(now).setHours(now.getHours() + 1));
|
||||
timekeeper.travel(oneHourFromNow);
|
||||
|
||||
const newBody = 'This body should never be set';
|
||||
const context = new Context({user});
|
||||
const response = await graphql(schema, editCommentMutation, {}, context, {
|
||||
id: comment.id,
|
||||
asset_id: asset.id,
|
||||
edit: {
|
||||
body: newBody
|
||||
}
|
||||
});
|
||||
expect(response.errors).to.be.empty;
|
||||
expect(response.data.editComment.errors).to.not.be.empty;
|
||||
expect(response.data.editComment.errors[0].translation_key).to.equal('error.editWindowExpired');
|
||||
const commentAfterEdit = await CommentsService.findById(comment.id);
|
||||
|
||||
// it *hasn't* changed from the original
|
||||
expect(commentAfterEdit.body).to.equal(comment.body);
|
||||
});
|
||||
|
||||
it('A user can\'t edit someone else\'s comment', async () => {
|
||||
const comment = await CommentsService.publicCreate({
|
||||
asset_id: asset.id,
|
||||
author_id: user.id,
|
||||
body: `hello there! ${ String(Math.random()).slice(2)}`,
|
||||
});
|
||||
|
||||
const userB = await UsersService.createLocalUser(
|
||||
'usernameB@example.com', 'password', 'usernameB');
|
||||
const newBody = 'This body should never be set';
|
||||
const context = new Context({user: userB});
|
||||
const response = await graphql(schema, editCommentMutation, {}, context, {
|
||||
id: comment.id,
|
||||
asset_id: asset.id,
|
||||
edit: {
|
||||
body: newBody
|
||||
}
|
||||
});
|
||||
expect(response.errors).to.be.empty;
|
||||
expect(response.data.editComment.errors).to.not.be.empty;
|
||||
expect(response.data.editComment.errors[0].translation_key).to.equal('NOT_AUTHORIZED');
|
||||
const commentAfterEdit = await CommentsService.findById(comment.id);
|
||||
|
||||
// it *hasn't* changed from the original
|
||||
expect(commentAfterEdit.body).to.equal(comment.body);
|
||||
});
|
||||
|
||||
it('A user Can\'t edit a comment id that doesn\'t exist', async () => {
|
||||
const fakeCommentId = 'nooooope';
|
||||
const newBody = 'This body should never be set';
|
||||
const context = new Context({user});
|
||||
const response = await graphql(schema, editCommentMutation, {}, context, {
|
||||
id: fakeCommentId,
|
||||
asset_id: asset.id,
|
||||
edit: {
|
||||
body: newBody
|
||||
}
|
||||
});
|
||||
expect(response.errors).to.be.empty;
|
||||
expect(response.data.editComment.errors[0].translation_key).to.equal('NOT_FOUND');
|
||||
});
|
||||
|
||||
const bannedWord = 'BANNED_WORD';
|
||||
[
|
||||
{
|
||||
description: 'premod: editing a REJECTED comment sets back to PREMOD',
|
||||
settings: {
|
||||
moderation: 'PRE',
|
||||
},
|
||||
beforeEdit: {
|
||||
body: 'I was offensive and thus REJECTED',
|
||||
status: 'REJECTED',
|
||||
},
|
||||
edit: {
|
||||
body: 'I have been edited to be less offensive',
|
||||
},
|
||||
afterEdit: {
|
||||
status: 'PREMOD',
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'editing an ACCEPTED comment to add a bad word sets status to REJECTED',
|
||||
settings: {
|
||||
moderation: 'POST',
|
||||
wordlist: {
|
||||
banned: [bannedWord]
|
||||
}
|
||||
},
|
||||
beforeEdit: {
|
||||
body: 'I\'m a perfectly acceptable comment',
|
||||
status: 'ACCEPTED',
|
||||
},
|
||||
edit: {
|
||||
body: `I have been sneakily edited to add a banned word: ${bannedWord}`
|
||||
},
|
||||
afterEdit: {
|
||||
status: 'REJECTED',
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'postmod: editing a REJECTED comment with banned word to remove banned word sets status to NONE',
|
||||
settings: {
|
||||
moderation: 'POST',
|
||||
wordlist: {
|
||||
banned: [bannedWord]
|
||||
}
|
||||
},
|
||||
beforeEdit: {
|
||||
body: `I'm a rejected comment with bad word ${bannedWord}`,
|
||||
status: 'REJECTED',
|
||||
},
|
||||
edit: {
|
||||
body: 'I have been edited to remove the bad word'
|
||||
},
|
||||
afterEdit: {
|
||||
status: 'NONE',
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'postmod + premodLinksEnable: editing an ACCEPTED comment to add a link sets status to PREMOD',
|
||||
settings: {
|
||||
moderation: 'POST',
|
||||
premodLinksEnable: true,
|
||||
},
|
||||
beforeEdit: {
|
||||
body: 'I\'m a perfectly acceptable comment',
|
||||
status: 'ACCEPTED',
|
||||
},
|
||||
edit: {
|
||||
body: 'I have been edited to add a link: https://coralproject.net/'
|
||||
},
|
||||
afterEdit: {
|
||||
status: 'PREMOD',
|
||||
},
|
||||
},
|
||||
].forEach(({description, settings, beforeEdit, edit, afterEdit, only}) => {
|
||||
const test = only ? it.only : it;
|
||||
test(description, async () => {
|
||||
await SettingsService.update(settings);
|
||||
const context = new Context({user});
|
||||
const comment = await CommentsService.publicCreate(Object.assign(
|
||||
{
|
||||
asset_id: asset.id,
|
||||
author_id: user.id,
|
||||
},
|
||||
beforeEdit,
|
||||
));
|
||||
|
||||
// now edit
|
||||
const newBody = edit.body;
|
||||
const response = await graphql(schema, editCommentMutation, {}, context, {
|
||||
id: comment.id,
|
||||
asset_id: asset.id,
|
||||
edit: {
|
||||
body: newBody
|
||||
}
|
||||
});
|
||||
if (response.errors && response.errors.length) {console.error(response.errors);}
|
||||
expect(response.errors).to.be.empty;
|
||||
const commentAfterEdit = await CommentsService.findById(comment.id);
|
||||
expect(commentAfterEdit.body).to.equal(newBody);
|
||||
expect(commentAfterEdit.status).to.equal(afterEdit.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,6 @@ describe('graph.mutations.ignoreUser', () => {
|
||||
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');
|
||||
|
||||
@@ -170,9 +170,6 @@ 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]];
|
||||
|
||||
Reference in New Issue
Block a user