Merge branch 'master' into query-mutation-api

Conflicts:
	client/coral-embed-stream/src/components/Comment.js
	client/coral-embed-stream/src/containers/Embed.js
	client/coral-embed-stream/src/containers/Stream.js
	client/coral-embed-stream/src/index.js
	client/coral-framework/graphql/fragments/commentView.graphql
	client/coral-framework/graphql/mutations/index.js
	client/coral-framework/hocs/index.js
	client/coral-framework/hocs/withFragments.js
This commit is contained in:
Chi Vinh Le
2017-05-12 19:50:36 +07:00
70 changed files with 2447 additions and 662 deletions
+2
View File
@@ -20,5 +20,7 @@ plugins/*
!plugins/coral-plugin-respect
!plugins/coral-plugin-offtopic
!plugins/coral-plugin-like
!plugins/coral-plugin-mod
!plugins/coral-plugin-love
**/node_modules/*
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright 2016 Mozilla Foundation
Copyright 2017 Mozilla Foundation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,6 +1,6 @@
# Talk [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk)
Talk is currently in Beta! [Read more about Talk here.](https://coralproject.net/products/talk.html)
Online comments are broken. Our open-source Talk tool rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here.](https://coralproject.net/products/talk.html)
Third party licenses are available via the `/client/3rdpartylicenses.txt`
endpoint when the server is running with built assets.
@@ -1,16 +1,17 @@
import React, {PropTypes} from 'react';
import timeago from 'timeago.js';
import Linkify from 'react-linkify';
import Highlighter from 'react-highlight-words';
import {Link} from 'react-router';
import Linkify from 'react-linkify';
import styles from './styles.css';
import {Icon} from 'coral-ui';
import FlagBox from './FlagBox';
import styles from './styles.css';
import CommentType from './CommentType';
import Highlighter from 'react-highlight-words';
import Slot from 'coral-framework/components/Slot';
import {getActionSummary} from 'coral-framework/utils';
import ActionButton from 'coral-admin/src/components/ActionButton';
import BanUserButton from 'coral-admin/src/components/BanUserButton';
import {getActionSummary} from 'coral-framework/utils';
const linkify = new Linkify();
@@ -18,11 +19,19 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-admin/src/translations.json';
const lang = new I18n(translations);
const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) => {
const Comment = ({
actions = [],
comment,
suspectWords,
bannedWords,
...props
}) => {
const links = linkify.getMatches(comment.body);
const linkText = links ? links.map(link => link.raw) : [];
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions = comment.actions && comment.actions.filter(a => a.__typename === 'FlagAction');
const flagActions =
comment.actions &&
comment.actions.filter(a => a.__typename === 'FlagAction');
let commentType = '';
if (comment.status === 'PREMOD') {
commentType = 'premod';
@@ -33,12 +42,17 @@ const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) =
// since words are checked against word boundaries on the backend,
// this should be the behavior on the front end as well.
// currently the highlighter plugin does not support this out of the box.
const searchWords = [...suspectWords, ...bannedWords].filter(w => {
return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body);
}).concat(linkText);
const searchWords = [...suspectWords, ...bannedWords]
.filter(w => {
return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body);
})
.concat(linkText);
return (
<li tabIndex={props.index} className={`mdl-card ${props.selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem} ${props.selected ? styles.selected : ''}`}>
<li
tabIndex={props.index}
className={`mdl-card ${props.selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem} ${props.selected ? styles.selected : ''}`}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
@@ -46,53 +60,95 @@ const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) =
{comment.user.name}
</span>
<span className={styles.created}>
{timeago().format(comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
{timeago().format(
comment.created_at || Date.now() - props.index * 60 * 1000,
lang.getLocale().replace('-', '_')
)}
</span>
<BanUserButton user={comment.user} onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')} />
<BanUserButton
user={comment.user}
onClick={() =>
props.showBanUserDialog(
comment.user,
comment.id,
comment.status,
comment.status !== 'REJECTED'
)}
/>
<CommentType type={commentType} />
</div>
{comment.user.status === 'banned' ?
<span className={styles.banned}>
<Icon name='error_outline'/>
{lang.t('comment.banned_user')}
</span>
{comment.user.status === 'banned'
? <span className={styles.banned}>
<Icon name="error_outline" />
{lang.t('comment.banned_user')}
</span>
: null}
<Slot fill="adminCommentInfoBar" comment={comment} />
</div>
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!props.currentAsset && (
<Link to={`/admin/moderate/${comment.asset.id}`}>Moderate </Link>
)}
{!props.currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>Moderate </Link>}
</div>
<div className={styles.itemBody}>
<p className={styles.body}>
<Highlighter
searchWords={searchWords}
textToHighlight={comment.body} /> <a className={styles.external} href={`${comment.asset.url}#${comment.id}`} target="_blank"><Icon name='open_in_new' /> {lang.t('comment.view_context')}</a>
textToHighlight={comment.body}
/>
{' '}
<a
className={styles.external}
href={`${comment.asset.url}#${comment.id}`}
target="_blank"
>
<Icon name="open_in_new" /> {lang.t('comment.view_context')}
</a>
</p>
<Slot fill="adminCommentContent" comment={comment} />
<div className={styles.sideActions}>
{links ? <span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
{links
? <span className={styles.hasLinks}>
<Icon name="error_outline" /> Contains Link
</span>
: null}
<div className={`actions ${styles.actions}`}>
{actions.map((action, i) => {
const active = (action === 'REJECT' && comment.status === 'REJECTED') ||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
return <ActionButton key={i}
type={action}
user={comment.user}
status={comment.status}
active={active}
acceptComment={() => comment.status === 'ACCEPTED' ? null : props.acceptComment({commentId: comment.id})}
rejectComment={() => comment.status === 'REJECTED' ? null : props.rejectComment({commentId: comment.id})} />;
const active =
(action === 'REJECT' && comment.status === 'REJECTED') ||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
return (
<ActionButton
key={i}
type={action}
user={comment.user}
status={comment.status}
active={active}
acceptComment={() =>
(comment.status === 'ACCEPTED'
? null
: props.acceptComment({commentId: comment.id}))}
rejectComment={() =>
(comment.status === 'REJECTED'
? null
: props.rejectComment({commentId: comment.id}))}
/>
);
})}
</div>
<Slot fill="adminSideActions" comment={comment} />
</div>
</div>
</div>
{
flagActions && flagActions.length
? <FlagBox actions={flagActions} actionSummaries={flagActionSummaries} />
: null
}
<div>
<Slot fill="adminCommentDetailArea" comment={comment} />
</div>
{flagActions && flagActions.length
? <FlagBox
actions={flagActions}
actionSummaries={flagActionSummaries}
/>
: null}
</li>
);
};
@@ -0,0 +1,6 @@
import {ADD_EXTERNAL_CONFIG} from '../constants/config';
export const addExternalConfig = config => ({
type: ADD_EXTERNAL_CONFIG,
config
});
@@ -7,4 +7,3 @@ export const setActiveTab = (tab) => (dispatch, getState) => {
dispatch(viewAllComments());
}
};
@@ -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>&nbsp;<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}
postComment={postComment}
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>&nbsp;<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;
};
@@ -0,0 +1 @@
export const ADD_EXTERNAL_CONFIG = 'ADD_EXTERNAL_CONFIG';
@@ -41,6 +41,10 @@ export default withFragments({
id
}
}
editing {
edited
editableUntil
}
${pluginFragments.spreads('comment')}
}
${pluginFragments.definitions('comment')}
@@ -11,21 +11,18 @@ import {authActions, assetActions, pym} from 'coral-framework';
import {getDefinitionName} from 'coral-framework/utils';
import {withQuery} from 'coral-framework/hocs';
import Embed from '../components/Embed';
import {setCommentCountCache, viewAllComments} from '../actions/stream';
import {setActiveTab} from '../actions/embed';
import Stream from './Stream';
import {setActiveTab} from '../actions/embed';
import {setCommentCountCache, viewAllComments} from '../actions/stream';
const {logout, checkLogin} = authActions;
const {fetchAssetSuccess} = assetActions;
class EmbedContainer extends React.Component {
componentDidMount() {
pym.sendMessage('childReady');
}
componentWillReceiveProps(nextProps) {
if(this.props.root.me && !nextProps.root.me) {
if (this.props.root.me && !nextProps.root.me) {
// Refetch because on logout `excludeIgnored` becomes `false`.
// TODO: logout via mutation and obsolete this?
@@ -33,7 +30,7 @@ class EmbedContainer extends React.Component {
}
const {fetchAssetSuccess} = this.props;
if(!isEqual(nextProps.root.asset, this.props.root.asset)) {
if (!isEqual(nextProps.root.asset, this.props.root.asset)) {
// TODO: remove asset data from redux store.
fetchAssetSuccess(nextProps.root.asset);
@@ -48,7 +45,7 @@ class EmbedContainer extends React.Component {
}
componentDidUpdate(prevProps) {
if(!isEqual(prevProps.root.comment, this.props.root.comment)) {
if (!isEqual(prevProps.root.comment, this.props.root.comment)) {
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0);
@@ -95,24 +92,25 @@ const mapStateToProps = state => ({
assetId: state.stream.assetId,
assetUrl: state.stream.assetUrl,
activeTab: state.embed.activeTab,
config: state.config
});
const mapDispatchToProps = dispatch =>
bindActionCreators({
fetchAssetSuccess,
checkLogin,
setCommentCountCache,
viewAllComments,
logout,
setActiveTab,
}, dispatch);
bindActionCreators(
{
logout,
checkLogin,
setActiveTab,
viewAllComments,
fetchAssetSuccess,
setCommentCountCache
},
dispatch
);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
branch(
props => !props.auth.checkedInitialLogin,
renderComponent(Spinner),
),
branch(props => !props.auth.checkedInitialLogin && props.config, renderComponent(Spinner)),
withEmbedQuery,
)(EmbedContainer);
@@ -8,8 +8,9 @@ import isNil from 'lodash/isNil';
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from '../constants/stream';
import {
withPostComment, withPostFlag, withPostDontAgree, withDeleteAction,
withAddCommentTag, withRemoveCommentTag, withIgnoreUser,
withAddCommentTag, withRemoveCommentTag, withIgnoreUser, withEditComment,
} from 'coral-framework/graphql/mutations';
import {notificationActions, authActions} from 'coral-framework';
import {editName} from 'coral-framework/actions/user';
import {setCommentCountCache, setActiveReplyBox} from '../actions/stream';
@@ -246,5 +247,6 @@ export default compose(
withRemoveCommentTag,
withIgnoreUser,
withDeleteAction,
withEditComment,
)(StreamContainer);
+94 -11
View File
@@ -1,8 +1,19 @@
import {gql} from 'react-apollo';
import {registerConfig} from 'coral-framework/services/registry';
import update from 'immutability-helper';
const config = {
fragments: {
EditCommentResponse: gql`
fragment CoralEmbedStream_EditCommentResponse on EditCommentResponse {
comment {
status
}
errors {
translation_key
}
}
`,
StopIgnoringUserResponse: gql`
fragment CoralEmbedStream_StopIgnoringUserResponse on StopIgnoringUserResponse {
errors {
@@ -103,6 +114,10 @@ const config = {
created_at
}
}
editing {
edited
editableUntil
}
}
`,
},
@@ -144,9 +159,9 @@ const config = {
}
},
updateQueries: {
EmbedQuery: (oldData, {mutationResult: {data: {createComment: {comment}}}}) => {
if (oldData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
return oldData;
EmbedQuery: (previousData, {mutationResult: {data: {createComment: {comment}}}}) => {
if (previousData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
return previousData;
}
let updatedAsset;
@@ -154,10 +169,10 @@ const config = {
// If posting a reply
if (parent_id) {
updatedAsset = {
...oldData,
...previousData,
asset: {
...oldData.asset,
comments: oldData.asset.comments.map((oldComment) => {
...previousData.asset,
comments: previousData.asset.comments.map((oldComment) => {
return oldComment.id === parent_id
? {...oldComment, replies: [...oldComment.replies, comment], replyCount: oldComment.replyCount + 1}
: oldComment;
@@ -168,11 +183,11 @@ const config = {
// If posting a top-level comment
updatedAsset = {
...oldData,
...previousData,
asset: {
...oldData.asset,
commentCount: oldData.asset.commentCount + 1,
comments: [comment, ...oldData.asset.comments]
...previousData.asset,
commentCount: previousData.asset.commentCount + 1,
comments: [comment, ...previousData.asset.comments]
}
};
}
@@ -180,7 +195,75 @@ const config = {
return updatedAsset;
}
}
})
}),
EditComment: ({
variables: {id, edit},
}) => ({
updateQueries: {
EmbedQuery: (previousData, {mutationResult: {data: {editComment: {comment: {status}}}}}) => {
const updateCommentWithEdit = (comment, edit) => {
const {body} = edit;
const editedComment = update(comment, {
$merge: {
body
},
editing: {$merge:{edited:true}}
});
return editedComment;
};
const commentIsStillVisible = (comment) => {
return ! ((id === comment.id) && (['PREMOD', 'REJECTED'].includes(status)));
};
const resultReflectingEdit = update(previousData, {
asset: {
comments: {
$apply: comments => {
return comments.filter(commentIsStillVisible).map(comment => {
let replyWasEditedToBeHidden = false;
if (comment.id === id) {
return updateCommentWithEdit(comment, edit);
}
const commentWithUpdatedReplies = update(comment, {
replies: {
$apply: (comments) => {
return comments
.filter(c => {
if (commentIsStillVisible(c)) {
return true;
}
replyWasEditedToBeHidden = true;
return false;
})
.map(comment => {
if (comment.id === id) {
return updateCommentWithEdit(comment, edit);
}
return comment;
});
}
},
});
// If a reply was edited to be hdiden, then this parent needs its replyCount to be decremented.
if (replyWasEditedToBeHidden) {
return update(commentWithUpdatedReplies, {
replyCount: {
$apply: (replyCount) => {
return replyCount - 1;
}
}
});
}
return commentWithUpdatedReplies;
});
}
}
}
});
return resultReflectingEdit;
},
},
}),
},
};
+8
View File
@@ -5,10 +5,12 @@ import {ApolloProvider} from 'react-apollo';
import {client} from 'coral-framework/services/client';
import {checkLogin} from 'coral-framework/actions/auth';
import './graphql';
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
import reducers from './reducers';
import localStore, {injectReducers} from 'coral-framework/services/store';
import AppRouter from './AppRouter';
import {pym} from 'coral-framework';
injectReducers(reducers);
@@ -17,6 +19,12 @@ const store = (window.opener && window.opener.coralStore) ? window.opener.coralS
// Don't run this in the popup.
if (store === localStore) {
store.dispatch(checkLogin());
pym.sendMessage('getConfig');
pym.onMessage('config', config => {
store.dispatch(addExternalConfig(JSON.parse(config)));
});
}
render(
@@ -0,0 +1,15 @@
import {ADD_EXTERNAL_CONFIG} from '../constants/config';
const initialState = {};
export default function config(state = initialState, action) {
switch (action.type) {
case ADD_EXTERNAL_CONFIG:
return {
...state,
...action.config
};
default:
return state;
}
}
@@ -1,7 +1,9 @@
import stream from './stream';
import embed from './embed';
import config from './config';
import stream from './stream';
export default {
stream,
embed,
stream,
config
};
+3 -4
View File
@@ -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;
}
+43 -40
View File
@@ -22,18 +22,18 @@ const snackbarStyles = {
transform: 'translate(-50%, 20px)',
bottom: 0,
boxSizing: 'border-box',
fontFamily: 'Helvetica, \'Helvetica Neue\', Verdana, sans-serif'
fontFamily: 'Helvetica, "Helvetica Neue", Verdana, sans-serif'
};
// This function should return value of window.Coral
const Coral = {};
const Talk = Coral.Talk = {};
const Talk = (Coral.Talk = {});
// build the URL to load in the pym iframe
function buildStreamIframeUrl(talkBaseUrl, query) {
let url = [
talkBaseUrl,
(talkBaseUrl.match(/\/$/) ? '' : '/'), // make sure no double-'/' if opts.talk already ends with '/'
talkBaseUrl.match(/\/$/) ? '' : '/', // make sure no double-'/' if opts.talk already ends with '/'
'embed/stream?'
].join('');
@@ -44,11 +44,21 @@ function buildStreamIframeUrl(talkBaseUrl, query) {
// Set up postMessage listeners/handlers on the pymParent
// e.g. to resize the iframe, and navigate the host page
function configurePymParent(pymParent) {
function configurePymParent(pymParent, opts) {
let notificationOffset = 200;
let ready = false;
let cachedHeight;
const snackbar = document.createElement('div');
// Sends config to pymChild
function sendConfig(config) {
pymParent.sendMessage('config', JSON.stringify(config));
}
// Sends config to the child
pymParent.onMessage('getConfig', function() {
sendConfig(opts || {});
});
snackbar.id = 'coral-notif';
for (let key in snackbarStyles) {
@@ -69,12 +79,12 @@ function configurePymParent(pymParent) {
}
});
pymParent.onMessage('coral-clear-notification', function () {
pymParent.onMessage('coral-clear-notification', function() {
snackbar.style.opacity = 0;
});
// remove the permalink comment id from the hash
pymParent.onMessage('coral-view-all-comments', function () {
pymParent.onMessage('coral-view-all-comments', function() {
window.history.replaceState(
{},
document.title,
@@ -82,7 +92,7 @@ function configurePymParent(pymParent) {
);
});
pymParent.onMessage('coral-alert', function (message) {
pymParent.onMessage('coral-alert', function(message) {
const [type, text] = message.split('|');
snackbar.style.transform = 'translate(-50%, 20px)';
snackbar.style.opacity = 0;
@@ -110,39 +120,21 @@ function configurePymParent(pymParent) {
pymParent.sendMessage('position', position);
});
// Tell child when parent's DOMContentLoaded
pymParent.onMessage('childReady', function () {
const interval = setInterval(function () {
if (ready) {
window.clearInterval(interval);
// TODO: It's weird to me that this is sent here
pymParent.sendMessage('DOMContentLoaded');
}
}, 100);
});
// When end-user clicks link in iframe, open it in parent context
pymParent.onMessage('navigate', function (url) {
pymParent.onMessage('navigate', function(url) {
window.open(url, '_blank').focus();
});
// wait till images and other iframes are loaded before scrolling the page.
// or do we want to be more aggressive and scroll when we hit DOM ready?
document.addEventListener('DOMContentLoaded', function () {
ready = true;
});
// get dimensions of viewport
const viewport = () => {
let e = window, a = 'inner';
if ( !( 'innerWidth' in window ) ){
if (!('innerWidth' in window)) {
a = 'client';
e = document.documentElement || document.body;
}
return {
width : e[`${a}Width`],
height : e[`${a}Height`]
width: e[`${a}Width`],
height: e[`${a}Height`]
};
};
}
@@ -156,18 +148,24 @@ function configurePymParent(pymParent) {
* @param {String} [opts.asset_url] - Asset URL
* @param {String} [opts.asset_id] - Asset ID
*/
Talk.render = function (el, opts) {
Talk.render = function(el, opts) {
if (!el) {
throw new Error('Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.');
throw new Error(
'Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'
);
}
if (typeof el !== 'object') {
throw new Error(`Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})`);
throw new Error(
`Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})`
);
}
opts = opts || {};
// TODO: infer this URL without explicit user input (if possible, may have to be added at build/render time of this script)
if (!opts.talk) {
throw new Error('Coral.Talk.render() expects opts.talk as the Talk Base URL');
throw new Error(
'Coral.Talk.render() expects opts.talk as the Talk Base URL'
);
}
// Ensure el has an id, as pym can't directly accept the HTMLElement.
@@ -186,16 +184,21 @@ Talk.render = function (el, opts) {
try {
query.asset_url = document.querySelector('link[rel="canonical"]').href;
} catch (e) {
console.warn('This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.');
console.warn(
'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.'
);
query.asset_url = window.location.origin + window.location.pathname;
}
}
configurePymParent(new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), {
title: opts.title,
id: `${el.id}_iframe`,
name: `${el.id}_iframe`
}));
configurePymParent(
new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), {
title: opts.title,
id: `${el.id}_iframe`,
name: `${el.id}_iframe`
}),
opts
);
};
export default Coral;
@@ -1,3 +1,7 @@
.inline {
display: inline-block;
}
.debug {
background-color: coral;
}
+9 -3
View File
@@ -1,12 +1,13 @@
import React from 'react';
import cn from 'classnames';
import styles from './Slot.css';
import {connect} from 'react-redux';
import {getSlotElements} from 'coral-framework/helpers/plugins';
export default function Slot ({fill, inline = false, ...rest}) {
function Slot ({fill, inline = false, plugin_config: config, ...rest}) {
return (
<div className={cn({[styles.inline]: inline})}>
{getSlotElements(fill, rest)}
<div className={cn({[styles.inline]: inline, [styles.debug]: config.debug})}>
{getSlotElements(fill, {...rest, config})}
</div>
);
}
@@ -14,3 +15,8 @@ export default function Slot ({fill, inline = false, ...rest}) {
Slot.propTypes = {
fill: React.PropTypes.string
};
const mapStateToProps = ({config: {plugin_config = {}}}) => ({plugin_config});
export default connect(mapStateToProps, null)(Slot);
@@ -8,4 +8,3 @@ export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE';
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
@@ -1,9 +0,0 @@
export const UPDATE_CONFIG_REQUEST = 'UPDATE_CONFIG_REQUEST';
export const UPDATE_CONFIG_SUCCESS = 'UPDATE_CONFIG_SUCCESS';
export const UPDATE_CONFIG_FAILURE = 'UPDATE_CONFIG_FAILURE';
export const UPDATE_CONFIG = 'UPDATE_CONFIG';
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
export const ADD_ITEM = 'ADD_ITEM';
@@ -20,6 +20,27 @@ export const withPostComment = withMutation(
}),
});
export const withEditComment = withMutation(
gql`
mutation EditComment($id: ID!, $asset_id: ID!, $edit: EditCommentInput) {
editComment(id:$id, asset_id:$asset_id, edit:$edit) {
...EditCommentResponse
}
}
`, {
props: ({mutate}) => ({
editComment: (id, asset_id, edit) => {
return mutate({
variables: {
id,
asset_id,
edit,
},
});
}
}),
});
export const withPostFlag = withMutation(
gql`
mutation PostFlag($flag: CreateFlagInput!) {
+3
View File
@@ -0,0 +1,3 @@
export function getDisplayName(WrappedComponent) {
return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}
+1 -1
View File
@@ -21,7 +21,7 @@ export function getSlotElements(slot, props = {}) {
.filter(o => o.module.slots[slot])
.map(o => o.module.slots[slot]));
return components
.map((component, i) => React.createElement(component, {...props, key: i}));
.map((component, i) => React.createElement(component, {key: i, ...props}));
}
function getComponentFragments(components) {
@@ -0,0 +1,4 @@
export function capitalize(str) {
const newString = new String(str);
return newString.charAt(0).toUpperCase() + newString.slice(1);
}
+2
View File
@@ -1,3 +1,5 @@
export {default as withFragments} from './withFragments';
export {default as withMutation} from './withMutation';
export {default as withQuery} from './withQuery';
export {default as withReaction} from './withReaction';
+1 -2
View File
@@ -1,8 +1,7 @@
import React from 'react';
import {getDisplayName} from 'recompose';
import {getDisplayName} from '../helpers/hoc';
// TODO: revisit `filtering` after https://github.com/apollographql/graphql-anywhere/issues/38.
export default fragments => WrappedComponent => {
class WithFragments extends React.Component {
render() {
@@ -3,6 +3,7 @@ import {graphql} from 'react-apollo';
import merge from 'lodash/merge';
import uniq from 'lodash/uniq';
import flatten from 'lodash/flatten';
import isEmpty from 'lodash/isEmpty';
import {getMutationOptions, resolveFragments} from 'coral-framework/services/registry';
import {store} from 'coral-framework/services/store';
import {getDefinitionName} from '../utils';
@@ -71,6 +72,9 @@ export default (document, config) => WrappedComponent => {
updateQueries,
update,
};
if (isEmpty(wrappedConfig.optimisticResponse)) {
delete wrappedConfig.optimisticResponse;
}
return data.mutate(wrappedConfig);
};
return config.props({...data, mutate});
+243
View File
@@ -0,0 +1,243 @@
import React from 'react';
import get from 'lodash/get';
import uuid from 'uuid/v4';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {getDisplayName} from '../helpers/hoc';
import {compose, gql, graphql} from 'react-apollo';
import withFragments from 'coral-framework/hocs/withFragments';
import {showSignInDialog} from 'coral-framework/actions/auth';
import {capitalize} from 'coral-framework/helpers/strings';
import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils';
export default reaction => WrappedComponent => {
if (typeof reaction !== 'string') {
console.error('Reaction must be a valid string');
return null;
}
reaction = reaction.toLowerCase();
class WithReactions extends React.Component {
render() {
const {comment} = this.props;
const reactionSummary = getMyActionSummary(
`${capitalize(reaction)}ActionSummary`,
comment
);
const count = getTotalActionCount(
`${capitalize(reaction)}ActionSummary`,
comment
);
const alreadyReacted = () => !!reactionSummary;
const withReactionProps = {reactionSummary, count, alreadyReacted};
return <WrappedComponent {...this.props} {...withReactionProps} />;
}
}
const isReaction = a =>
a.__typename === `${capitalize(reaction)}ActionSummary`;
const COMMENT_FRAGMENT = gql`
fragment ${capitalize(reaction)}Button_updateFragment on Comment {
action_summaries {
... on ${capitalize(reaction)}ActionSummary {
count
current_user {
id
}
}
}
}
`;
const withDeleteReaction = graphql(
gql`
mutation deleteReaction($id: ID!) {
deleteAction(id:$id) {
errors {
translation_key
}
}
}
`,
{
props: ({mutate, ownProps}) => ({
deleteReaction: () => {
const reactionSummary = getMyActionSummary(
`${capitalize(reaction)}ActionSummary`,
ownProps.comment
);
const reactionData = {
id: reactionSummary.current_user.id,
commentId: ownProps.comment.id
};
return mutate({
variables: {id: reactionData.id},
optimisticResponse: {
deleteAction: {
__typename: 'DeleteActionResponse',
errors: null
}
},
update: proxy => {
const fragmentId = `Comment_${reactionData.commentId}`;
// Read the data from our cache for this query.
const data = proxy.readFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId
});
// Check whether we liked this comment.
const idx = data.action_summaries.findIndex(isReaction);
if (
idx < 0 ||
get(data.action_summaries[idx], 'current_user.id') !== reactionData.id
) {
return;
}
data.action_summaries[idx] = {
...data.action_summaries[idx],
count: data.action_summaries[idx].count - 1,
current_user: null
};
// Write our data back to the cache.
proxy.writeFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId,
data
});
}
});
}
})
}
);
const withPostReaction = graphql(
gql`
mutation create${capitalize(reaction)}($${reaction}: Create${capitalize(reaction)}Input!) {
create${capitalize(reaction)}(${reaction}: $${reaction}) {
${reaction} {
id
}
errors {
translation_key
}
}
}
`,
{
props: ({mutate, ownProps}) => ({
postReaction: () => {
const reactionData = {
item_id: ownProps.comment.id,
item_type: 'COMMENTS'
};
return mutate({
variables: {[reaction]: reactionData},
optimisticResponse: {
[`create${capitalize(reaction)}`]: {
__typename: `Create${capitalize(reaction)}Response`,
errors: null,
[reaction]: {
__typename: `${capitalize(reaction)}Action`,
id: uuid()
}
}
},
update: (proxy, mutationResult) => {
const fragmentId = `Comment_${reactionData.item_id}`;
// Read the data from our cache for this query.
const data = proxy.readFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId
});
// Add our comment from the mutation to the end.
let idx = data.action_summaries.findIndex(isReaction);
// Check whether we already reactioned this comment.
if (idx >= 0 && data.action_summaries[idx].current_user) {
return;
}
if (idx < 0) {
// Add initial action when it doesn't exist.
data.action_summaries.push({
__typename: `${capitalize(reaction)}ActionSummary`,
count: 0,
current_user: null
});
idx = data.action_summaries.length - 1;
}
data.action_summaries[idx] = {
...data.action_summaries[idx],
count: data.action_summaries[idx].count + 1,
current_user: mutationResult.data[
`create${capitalize(reaction)}`
][reaction]
};
// Write our data back to the cache.
proxy.writeFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId,
data
});
}
});
}
})
}
);
const mapDispatchToProps = dispatch =>
bindActionCreators({showSignInDialog}, dispatch);
const enhance = compose(
withFragments({
root: gql`
fragment ${capitalize(reaction)}Button_root on RootQuery {
me {
status
}
}
`,
comment: gql`
fragment ${capitalize(reaction)}Button_comment on Comment {
action_summaries {
... on ${capitalize(reaction)}ActionSummary {
count
current_user {
id
}
}
}
}`
}),
connect(null, mapDispatchToProps),
withDeleteReaction,
withPostReaction
);
WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`;
return enhance(WithReactions);
};
+24
View File
@@ -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
View File
@@ -0,0 +1 @@
export {withReaction} from '../coral-framework/hocs';
+53 -63
View File
@@ -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,
postComment,
@@ -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>;
}
}
+6 -12
View File
@@ -15,8 +15,7 @@ class FlagButton extends Component {
message: '',
step: 0,
posted: false,
localPost: null,
localDelete: false
localPost: null
}
componentDidUpdate () {
@@ -27,17 +26,12 @@ class FlagButton extends Component {
// When the "report" button is clicked expand the menu
onReportClick = () => {
const {currentUser, deleteAction, flaggedByCurrentUser, flag} = this.props;
const {localPost, localDelete} = this.state;
const localFlagged = (flaggedByCurrentUser && !localDelete) || localPost;
const {currentUser} = this.props;
if (!currentUser) {
this.props.showSignInDialog();
return;
}
if (localFlagged) {
this.setState((prev) => prev.localPost ? {...prev, localPost: null, step: 0} : {...prev, localDelete: true});
deleteAction(localPost || flag.current_user.id);
} else if (this.state.showMenu){
if (this.state.showMenu) {
this.closeMenu();
} else {
this.setState({showMenu: true});
@@ -136,14 +130,14 @@ class FlagButton extends Component {
render () {
const {getPopupMenu, flaggedByCurrentUser} = this.props;
const {localPost, localDelete} = this.state;
const flagged = (flaggedByCurrentUser && !localDelete) || localPost;
const {localPost} = this.state;
const flagged = flaggedByCurrentUser || localPost;
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
return <div className={`${name}-container`}>
<button
ref={ref => this.flagButton = ref}
onClick={!this.props.banned ? this.onReportClick : null}
onClick={!this.props.banned && !flaggedByCurrentUser && !localPost ? this.onReportClick : null}
className={`${name}-button`}>
{
flagged
+8 -1
View File
@@ -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
View File
@@ -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;
};
+10
View File
@@ -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,
};
}
};
+3
View File
@@ -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}}));
},
+28
View File
@@ -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
+18
View File
@@ -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],
+2
View File
@@ -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": {
+14
View File
@@ -0,0 +1,14 @@
{
"presets": [
"es2015"
],
"plugins": [
"add-module-exports",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
"transform-object-rest-spread",
"transform-async-to-generator",
"transform-react-jsx"
]
}
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
@@ -0,0 +1,52 @@
import React from 'react';
import {Icon} from 'coral-ui';
import styles from './styles.css';
import {I18n} from 'coral-framework';
import translations from './translations.json';
import {withReaction} from 'coral-plugin-api';
const lang = new I18n(translations);
class LoveButton extends React.Component {
handleClick = () => {
const {
postReaction,
deleteReaction,
showSignInDialog,
alreadyReacted
} = this.props;
const {root: {me}, comment} = this.props;
// If the current user does not exist, trigger sign in dialog.
if (!me) {
showSignInDialog();
return;
}
// If the current user is banned, do nothing.
if (me.status === 'BANNED') {
return;
}
if (alreadyReacted()) {
deleteReaction();
} else {
postReaction();
}
};
render() {
const {count, alreadyReacted} = this.props;
return (
<button
className={`${styles.button} ${alreadyReacted() ? styles.loved : ''}`}
onClick={this.handleClick}
>
<span>{lang.t(alreadyReacted() ? 'loved' : 'love')}</span>
<Icon name="favorite" />
<span>{count > 0 && count}</span>
</button>
);
}
}
export default withReaction('love')(LoveButton);
@@ -0,0 +1,7 @@
import LoveButton from './LoveButton';
export default {
slots: {
commentReactions: [LoveButton]
}
};
@@ -0,0 +1,26 @@
.respect {
display: inline-block;
}
.button {
color: #2a2a2a;
margin: 5px 10px 5px 0px;
background: none;
padding: 0px;
border: none;
font-size: inherit;
&:hover {
color: #767676;
cursor: pointer;
}
&.loved {
color: #e52338;
&:hover {
color: #e52839;
cursor: pointer;
}
}
}
@@ -0,0 +1,10 @@
{
"en": {
"love": "Love",
"loved": "Loved"
},
"es": {
"love": "Amo",
"loved": "Amé"
}
}
+37
View File
@@ -0,0 +1,37 @@
const {readFileSync} = require('fs');
const path = require('path');
const wrapResponse = require('../../graph/helpers/response');
module.exports = {
typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8'),
resolvers: {
RootMutation: {
createLove(_, {love: {item_id, item_type}}, {mutators: {Action}}) {
return wrapResponse('love')(Action.create({item_id, item_type, action_type: 'LOVE'}));
}
}
},
hooks: {
Action: {
__resolveType: {
post({action_type}) {
switch (action_type) {
case 'LOVE':
return 'LoveAction';
}
}
}
},
ActionSummary: {
__resolveType: {
post({action_type}) {
switch (action_type) {
case 'LOVE':
return 'LoveActionSummary';
}
}
}
}
}
};
@@ -0,0 +1,70 @@
enum ACTION_TYPE {
# Represents a Love.
LOVE
}
enum ASSET_METRICS_SORT {
# Represents a LoveAction.
LOVE
}
input CreateLoveInput {
# The item's id for which we are to create a love.
item_id: ID!
# The type of the item for which we are to create the love.
item_type: ACTION_ITEM_TYPE!
}
# LoveAction is used by users who "love" a specific entity.
type LoveAction implements Action {
# The ID of the action.
id: ID!
# The author of the action.
user: User
# The time when the Action was updated.
updated_at: Date
# The time when the Action was created.
created_at: Date
}
type LoveActionSummary implements ActionSummary {
# The count of actions with this group.
count: Int
# The current user's action.
current_user: LoveAction
}
# A summary of counts related to all the Loves on an Asset.
type LoveAssetActionSummary implements AssetActionSummary {
# Number of loves associated with actionable types on this this Asset.
actionCount: Int
# Number of unique actionable types that are referenced by the loves.
actionableItemCount: Int
}
type CreateLoveResponse implements Response {
# The love that was created.
love: LoveAction
# An array of errors relating to the mutation that occurred.
errors: [UserError]
}
type RootMutation {
# Creates a love on an entity.
createLove(love: CreateLoveInput!): CreateLoveResponse
}
+14
View File
@@ -0,0 +1,14 @@
{
"presets": [
"es2015"
],
"plugins": [
"add-module-exports",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
"transform-object-rest-spread",
"transform-async-to-generator",
"transform-react-jsx"
]
}
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
@@ -0,0 +1,8 @@
import React from 'react';
import styles from './styles.css'
export default (props) => (
<div className={styles.box}>
Comment Status: {props.comment.status}
</div>
)
@@ -0,0 +1,32 @@
import React from 'react';
import Box from './Box';
import {Button} from 'coral-ui'
import styles from './styles.css';
export default class Footer extends React.Component {
constructor() {
super();
this.state = {
show: false
};
}
handleClick = () => {
this.setState(state => ({
show: !state.show
}))
}
render() {
const {show} = this.state;
return (
<div className={styles.container}>
<Button cStyle="darkGrey" onClick={this.handleClick}>
Show Comment Status
</Button>
{show ? <Box comment={this.props.comment} /> : null}
</div>
);
}
}
@@ -0,0 +1,8 @@
.container {
padding: 0 14px 10px;
}
.box {
font-size: 12px;
padding: 10px 14px;
}
+7
View File
@@ -0,0 +1,7 @@
import Container from './components/Container';
export default {
slots: {
adminCommentDetailArea: [Container],
}
};
+4
View File
@@ -0,0 +1,4 @@
const {readFileSync} = require('fs');
const path = require('path');
module.exports = {};
+98 -7
View File
@@ -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
@@ -240,7 +331,7 @@ module.exports = class CommentsService {
* @param {String} action the new action to the comment
* @return {Promise}
*/
static addAction(item_id, user_id, action_type, metadata) {
static addAction(item_id, user_id, action_type, metadata = {}) {
return ActionsService.insertUserAction({
item_id,
item_type: 'COMMENTS',
+253
View File
@@ -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');
-3
View File
@@ -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]];
+5 -1
View File
@@ -28,7 +28,11 @@
Coral.Talk.render(document.getElementById('coralStreamEmbed'), {
talk: '/',
asset_url: '<%= asset_url ? asset_url : '' %>',
asset_id: '<%= asset_id ? asset_id : '' %>'
asset_id: '<%= asset_id ? asset_id : '' %>',
plugin_config: {
test: 'data',
debug: false
}
})
"></script>
</main>
+398 -389
View File
File diff suppressed because it is too large Load Diff