mirror of
https://github.com/wassname/talk.git
synced 2026-06-30 04:20:14 +08:00
Merge branch 'master' into csrf
This commit is contained in:
@@ -28,7 +28,6 @@
|
||||
"yoda": [1],
|
||||
"no-path-concat": [2],
|
||||
"eol-last": [1],
|
||||
"no-continue": [1],
|
||||
"no-nested-ternary": [1],
|
||||
"no-tabs": [2],
|
||||
"no-unneeded-ternary": [1],
|
||||
|
||||
@@ -4,6 +4,7 @@ import {Router, Route, IndexRoute, browserHistory} from 'react-router';
|
||||
import ModerationQueue from 'containers/ModerationQueue/ModerationQueue';
|
||||
import CommentStream from 'containers/CommentStream/CommentStream';
|
||||
import Configure from 'containers/Configure/Configure';
|
||||
import Streams from 'containers/Streams/Streams';
|
||||
import CommunityContainer from 'containers/Community/CommunityContainer';
|
||||
import LayoutContainer from 'containers/LayoutContainer';
|
||||
|
||||
@@ -13,6 +14,7 @@ const routes = (
|
||||
<Route path='embed' component={CommentStream} />
|
||||
<Route path='community' component={CommunityContainer} />
|
||||
<Route path='configure' component={Configure} />
|
||||
<Route path='streams' component={Streams} />
|
||||
</Route>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
FETCH_ASSETS_REQUEST,
|
||||
FETCH_ASSETS_SUCCESS,
|
||||
FETCH_ASSETS_FAILURE,
|
||||
UPDATE_ASSET_STATE_REQUEST,
|
||||
UPDATE_ASSET_STATE_SUCCESS,
|
||||
UPDATE_ASSET_STATE_FAILURE
|
||||
} from '../constants/assets';
|
||||
import coralApi from '../../../coral-framework/helpers/response';
|
||||
|
||||
/**
|
||||
* Action disptacher related to assets
|
||||
*/
|
||||
|
||||
// Fetch a page of assets
|
||||
// Get comments to fill each of the three lists on the mod queue
|
||||
export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch) => {
|
||||
dispatch({type: FETCH_ASSETS_REQUEST});
|
||||
return coralApi(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`)
|
||||
.then(({result, count}) =>
|
||||
dispatch({type: FETCH_ASSETS_SUCCESS,
|
||||
assets: result,
|
||||
count
|
||||
}))
|
||||
.catch(error => dispatch({type: FETCH_ASSETS_FAILURE, error}));
|
||||
};
|
||||
|
||||
// Update an asset state
|
||||
// Get comments to fill each of the three lists on the mod queue
|
||||
export const updateAssetState = (id, closedAt) => (dispatch) => {
|
||||
dispatch({type: UPDATE_ASSET_STATE_REQUEST});
|
||||
return coralApi(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}})
|
||||
.then(() =>
|
||||
dispatch({type: UPDATE_ASSET_STATE_SUCCESS}))
|
||||
.catch(error => dispatch({type: UPDATE_ASSET_STATE_FAILURE, error}));
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import coralApi from '../../../coral-framework/helpers/response';
|
||||
import * as commentActions from '../constants/comments';
|
||||
import * as commentTypes from '../constants/comments';
|
||||
import * as actionTypes from '../constants/actions';
|
||||
|
||||
// Get comments to fill each of the three lists on the mod queue
|
||||
export const fetchModerationQueueComments = () => {
|
||||
return dispatch => {
|
||||
dispatch({type: commentActions.COMMENTS_MODERATION_QUEUE_FETCH});
|
||||
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
|
||||
return Promise.all([
|
||||
coralApi('/queue/comments/pending'),
|
||||
coralApi('/comments?status=rejected'),
|
||||
@@ -20,11 +21,12 @@ export const fetchModerationQueueComments = () => {
|
||||
actions: [...pending.actions, ...rejected.actions, ...flagged.actions]
|
||||
};
|
||||
})
|
||||
.then(({comments, users}) => {
|
||||
.then(({comments, users, actions}) => {
|
||||
|
||||
/* Post comments and users to redux store. Actions will be posted when they are needed. */
|
||||
dispatch({type: commentActions.USERS_MODERATION_QUEUE_FETCH_SUCCESS, users});
|
||||
dispatch({type: commentActions.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS, comments});
|
||||
dispatch({type: commentTypes.USERS_MODERATION_QUEUE_FETCH_SUCCESS, users});
|
||||
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS, comments});
|
||||
dispatch({type: actionTypes.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS, actions});
|
||||
|
||||
});
|
||||
};
|
||||
@@ -35,8 +37,8 @@ export const createComment = (name, body, _csrf) => {
|
||||
return dispatch => {
|
||||
const comment = {body, name, _csrf};
|
||||
return coralApi('/comments', {method: 'POST', comment})
|
||||
.then(res => dispatch({type: commentActions.COMMENT_CREATE_SUCCESS, comment: res}))
|
||||
.catch(error => dispatch({type: commentActions.COMMENT_CREATE_FAILED, error}));
|
||||
.then(res => dispatch({type: commentTypes.COMMENT_CREATE_SUCCESS, comment: res}))
|
||||
.catch(error => dispatch({type: commentTypes.COMMENT_CREATE_FAILED, error}));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -47,23 +49,23 @@ export const createComment = (name, body, _csrf) => {
|
||||
// Update a comment. Now to update a comment we need to send back the whole object
|
||||
export const updateStatus = (status, comment) => {
|
||||
return dispatch => {
|
||||
dispatch({type: commentActions.COMMENT_STATUS_UPDATE, id: comment.id, status});
|
||||
dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_REQUEST, id: comment.id, status});
|
||||
return coralApi(`/comments/${comment.id}/status`, {method: 'PUT', body: {status}})
|
||||
.then(res => dispatch({type: commentActions.COMMENT_UPDATE_SUCCESS, res}))
|
||||
.catch(error => dispatch({type: commentActions.COMMENT_UPDATE_FAILED, error}));
|
||||
.then(res => dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_SUCCESS, res}))
|
||||
.catch(error => dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_FAILURE, error}));
|
||||
};
|
||||
};
|
||||
|
||||
export const flagComment = id => (dispatch, getState) => {
|
||||
dispatch({type: commentActions.COMMENT_FLAG, id});
|
||||
dispatch({type: commentTypes.COMMENT_FLAG, id});
|
||||
dispatch({type: 'COMMENT_UPDATE', comment: getState().comments.get('byId').get(id)});
|
||||
};
|
||||
|
||||
// Dialog Actions
|
||||
export const showBanUserDialog = (userId, userName, commentId) => {
|
||||
return {type: commentActions.SHOW_BANUSER_DIALOG, userId, userName, commentId};
|
||||
return {type: commentTypes.SHOW_BANUSER_DIALOG, userId, userName, commentId};
|
||||
};
|
||||
|
||||
export const hideBanUserDialog = (showDialog) => {
|
||||
return {type: commentActions.HIDE_BANUSER_DIALOG, showDialog};
|
||||
return {type: commentTypes.HIDE_BANUSER_DIALOG, showDialog};
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ export const SAVE_SETTINGS_LOADING = 'SAVE_SETTINGS_LOADING';
|
||||
export const SAVE_SETTINGS_SUCCESS = 'SAVE_SETTINGS_SUCCESS';
|
||||
export const SAVE_SETTINGS_FAILED = 'SAVE_SETTINGS_FAILED';
|
||||
|
||||
export const WORDLIST_UPDATED = 'WORDLIST_UPDATED';
|
||||
|
||||
export const fetchSettings = () => dispatch => {
|
||||
dispatch({type: SETTINGS_LOADING});
|
||||
coralApi('/settings')
|
||||
@@ -21,10 +23,16 @@ export const fetchSettings = () => dispatch => {
|
||||
});
|
||||
};
|
||||
|
||||
// for updating top-level settings
|
||||
export const updateSettings = settings => {
|
||||
return {type: SETTINGS_UPDATED, settings};
|
||||
};
|
||||
|
||||
// this is a nested property, so it needs a special action.
|
||||
export const updateWordlist = (listName, list) => {
|
||||
return {type: WORDLIST_UPDATED, listName, list};
|
||||
};
|
||||
|
||||
export const saveSettingsToServer = () => (dispatch, getState) => {
|
||||
let settings = getState().settings.toJS().settings;
|
||||
if (settings.charCount) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
|
||||
import {Icon} from 'react-mdl';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import {FabButton, Button} from 'coral-ui';
|
||||
|
||||
const linkify = new Linkify();
|
||||
@@ -31,7 +32,7 @@ export default props => {
|
||||
{links ?
|
||||
<span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{props.actions.map((action, i) => getActionButton(action, i, props))}
|
||||
{props.modActions.map((action, i) => getActionButton(action, i, props))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -42,7 +43,9 @@ export default props => {
|
||||
<div className={styles.itemBody}>
|
||||
<span className={styles.body}>
|
||||
<Linkify component='span' properties={{style: linkStyles}}>
|
||||
{comment.body}
|
||||
<Highlighter
|
||||
searchWords={props.suspectWords}
|
||||
textToHighlight={comment.body} />
|
||||
</Linkify>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
|
||||
import React from 'react';
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './CommentList.css';
|
||||
import key from 'keymaster';
|
||||
import Hammer from 'hammerjs';
|
||||
import Comment from 'components/Comment';
|
||||
|
||||
// Each action has different meaning and configuration
|
||||
const actions = {
|
||||
const modActions = {
|
||||
'reject': {status: 'rejected', icon: 'close', key: 'r'},
|
||||
'approve': {status: 'accepted', icon: 'done', key: 't'},
|
||||
'flag': {status: 'flagged', icon: 'flag', filter: 'Untouched'},
|
||||
@@ -15,6 +14,23 @@ const actions = {
|
||||
|
||||
// Renders a comment list and allow performing actions
|
||||
export default class CommentList extends React.Component {
|
||||
static propTypes = {
|
||||
isActive: PropTypes.bool,
|
||||
singleView: PropTypes.bool,
|
||||
commentIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
comments: PropTypes.object.isRequired,
|
||||
users: PropTypes.object.isRequired,
|
||||
onClickAction: PropTypes.func,
|
||||
modActions: PropTypes.arrayOf(PropTypes.string),
|
||||
loading: PropTypes.bool,
|
||||
|
||||
// list of actions (flags, etc) associated with the comments
|
||||
actions: PropTypes.shape({
|
||||
ids: PropTypes.arrayOf(PropTypes.string)
|
||||
}),
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string)
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
@@ -44,22 +60,22 @@ export default class CommentList extends React.Component {
|
||||
|
||||
// Add swipe to approve or reject
|
||||
bindGestures () {
|
||||
const {actions} = this.props;
|
||||
const {modActions} = this.props;
|
||||
this._hammer = new Hammer(this.base);
|
||||
this._hammer.get('swipe').set({direction: Hammer.DIRECTION_HORIZONTAL});
|
||||
|
||||
if (actions.indexOf('reject') !== -1) {
|
||||
if (modActions.indexOf('reject') !== -1) {
|
||||
this._hammer.on('swipeleft', () => this.props.singleView && this.actionKeyHandler('Rejected'));
|
||||
}
|
||||
if (actions.indexOf('approve') !== -1) {
|
||||
if (modActions.indexOf('approve') !== -1) {
|
||||
this._hammer.on('swiperight', () => this.props.singleView && this.actionKeyHandler('Approved'));
|
||||
}
|
||||
}
|
||||
|
||||
// Add key handlers. Each action has one and added j/k for moving around
|
||||
bindKeyHandlers () {
|
||||
this.props.actions.filter(action => actions[action].key).forEach(action => {
|
||||
key(actions[action].key, 'commentList', () => this.props.isActive && this.actionKeyHandler(actions[action].status));
|
||||
this.props.modActions.filter(action => modActions[action].key).forEach(action => {
|
||||
key(modActions[action].key, 'commentList', () => this.props.isActive && this.actionKeyHandler(modActions[action].status));
|
||||
});
|
||||
key('j', 'commentList', () => this.props.isActive && this.moveKeyHandler('down'));
|
||||
key('k', 'commentList', () => this.props.isActive && this.moveKeyHandler('up'));
|
||||
@@ -122,7 +138,7 @@ export default class CommentList extends React.Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const {singleView, commentIds, comments, users, hideActive, key} = this.props;
|
||||
const {singleView, commentIds, comments, users, hideActive, key, suspectWords} = this.props;
|
||||
const {active} = this.state;
|
||||
|
||||
return (
|
||||
@@ -133,14 +149,16 @@ export default class CommentList extends React.Component {
|
||||
{commentIds.map((commentId, index) => {
|
||||
const comment = comments[commentId];
|
||||
const author = users[comment.author_id];
|
||||
return <Comment comment={comment}
|
||||
return <Comment
|
||||
suspectWords={suspectWords}
|
||||
comment={comment}
|
||||
author={author}
|
||||
key={index}
|
||||
index={index}
|
||||
onClickAction={this.onClickAction}
|
||||
onClickShowBanDialog={this.onClickShowBanDialog}
|
||||
actions={this.props.actions}
|
||||
actionsMap={actions}
|
||||
modActions={this.props.modActions}
|
||||
actionsMap={modActions}
|
||||
isActive={commentId === active}
|
||||
hideActive={hideActive} />;
|
||||
})}
|
||||
|
||||
@@ -16,6 +16,8 @@ export default ({handleLogout}) => (
|
||||
activeClassName={styles.active}>{lang.t('configure.community')}</Link>
|
||||
<Link className={styles.navLink} to="/admin/configure"
|
||||
activeClassName={styles.active}>{lang.t('configure.configure')}</Link>
|
||||
<Link className={styles.navLink} to="/admin/streams"
|
||||
activeClassName={styles.active}>{lang.t('configure.streams')}</Link>
|
||||
</Navigation>
|
||||
<div className={styles.rightPanel}>
|
||||
<ul>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS = 'ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS';
|
||||
@@ -0,0 +1,6 @@
|
||||
export const FETCH_ASSETS_REQUEST = 'FETCH_ASSETS_REQUEST';
|
||||
export const FETCH_ASSETS_SUCCESS = 'FETCH_ASSETS_SUCCESS';
|
||||
export const FETCH_ASSETS_FAILURE = 'FETCH_ASSETS_FAILURE';
|
||||
export const UPDATE_ASSET_STATE_REQUEST = 'UPDATE_ASSET_STATE_REQUEST';
|
||||
export const UPDATE_ASSET_STATE_SUCCESS = 'UPDATE_ASSET_STATE_SUCCESS';
|
||||
export const UPDATE_ASSET_STATE_FAILURE = 'UPDATE_ASSET_STATE_FAILURE';
|
||||
@@ -1,13 +1,12 @@
|
||||
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
|
||||
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
|
||||
export const USER_BAN_SUCESS = 'USER_BAN_SUCESS';
|
||||
export const USERS_MODERATION_QUEUE_FETCH_SUCCESS = 'USERS_MODERATION_QUEUE_FETCH_SUCCESS';
|
||||
export const COMMENTS_MODERATION_QUEUE_FETCH = 'COMMENTS_MODERATION_QUEUE_FETCH';
|
||||
export const COMMENTS_MODERATION_QUEUE_FETCH_REQUEST = 'COMMENTS_MODERATION_QUEUE_FETCH_REQUEST';
|
||||
export const COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS = 'COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS';
|
||||
export const COMMENT_CREATE_SUCCESS = 'COMMENT_CREATE_SUCCESS';
|
||||
export const COMMENT_CREATE_FAILED = 'COMMENT_CREATE_FAILED';
|
||||
export const COMMENT_STREAM_FETCH_SUCCESS = 'COMMENT_STREAM_FETCH_SUCCESS';
|
||||
export const COMMENT_UPDATE_SUCCESS = 'COMMENT_UPDATE_SUCCESS';
|
||||
export const COMMENT_UPDATE_FAILED = 'COMMENT_UPDATE_FAILED';
|
||||
export const COMMENT_STATUS_UPDATE = 'COMMENT_STATUS_UPDATE';
|
||||
export const COMMENT_STATUS_UPDATE_SUCCESS = 'COMMENT_STATUS_UPDATE_SUCCESS';
|
||||
export const COMMENT_STATUS_UPDATE_FAILURE = 'COMMENT_STATUS_UPDATE_FAILURE';
|
||||
export const COMMENT_STATUS_UPDATE_REQUEST = 'COMMENT_STATUS_UPDATE_REQUEST';
|
||||
export const COMMENT_FLAG = 'COMMENT_FLAG';
|
||||
|
||||
@@ -7,7 +7,7 @@ import styles from './Community.css';
|
||||
import Table from './Table';
|
||||
import Loading from './Loading';
|
||||
import NoResults from './NoResults';
|
||||
import Pager from './Pager';
|
||||
import Pager from 'coral-ui/components/Pager';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
|
||||
@@ -69,110 +69,115 @@ const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
const CommentSettings = ({fetchingSettings, updateSettings, settingsError, settings, errors}) => {
|
||||
const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError, settings, errors}) => {
|
||||
if (fetchingSettings) {
|
||||
|
||||
/* maybe a spinner here at some point */
|
||||
return <p>Loading settings...</p>;
|
||||
}
|
||||
|
||||
return <List>
|
||||
<ListItem className={`${styles.configSetting} ${settings.moderation === 'pre' ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<ListItemAction>
|
||||
<Checkbox
|
||||
onChange={updateModeration(updateSettings, settings.moderation)}
|
||||
checked={settings.moderation === 'pre'} />
|
||||
</ListItemAction>
|
||||
<ListItemContent>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.enable-pre-moderation')}</div>
|
||||
<p className={settings.moderation === 'pre' ? '' : styles.disabledSettingText}>
|
||||
{lang.t('configure.enable-pre-moderation-text')}
|
||||
</p>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={`${styles.configSetting} ${settings.charCountEnable ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<ListItemAction>
|
||||
<Checkbox
|
||||
onChange={updateCharCountEnable(updateSettings, settings.charCountEnable)}
|
||||
checked={settings.charCountEnable} />
|
||||
</ListItemAction>
|
||||
<ListItemContent>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.comment-count-header')}</div>
|
||||
<p className={settings.charCountEnable ? '' : styles.disabledSettingText}>
|
||||
<span>{lang.t('configure.comment-count-text-pre')}</span>
|
||||
<input type='text'
|
||||
className={`${styles.charCountTexfield} ${settings.charCountEnable && styles.charCountTexfieldEnabled}`}
|
||||
htmlFor='charCount'
|
||||
onChange={updateCharCount(updateSettings, settingsError)}
|
||||
value={settings.charCount}/>
|
||||
<span>{lang.t('configure.comment-count-text-post')}</span>
|
||||
{
|
||||
errors.charCount &&
|
||||
<span className={styles.settingsError}>
|
||||
<br/>
|
||||
<Icon name="error_outline"/>
|
||||
{lang.t('configure.comment-count-error')}
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem threeLine className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<ListItemAction>
|
||||
<Checkbox
|
||||
onChange={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
|
||||
checked={settings.infoBoxEnable} />
|
||||
</ListItemAction>
|
||||
<ListItemContent>
|
||||
{lang.t('configure.include-comment-stream')}
|
||||
<p>
|
||||
{lang.t('configure.include-comment-stream-desc')}
|
||||
</p>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
|
||||
<ListItemContent>
|
||||
<Textfield
|
||||
onChange={updateInfoBoxContent(updateSettings)}
|
||||
value={settings.infoBoxContent}
|
||||
label={lang.t('configure.include-text')}
|
||||
rows={3}/>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={styles.configSettingInfoBox}>
|
||||
<ListItemContent>
|
||||
{lang.t('configure.close-after')}
|
||||
<br />
|
||||
<Textfield
|
||||
type='number'
|
||||
pattern='[0-9]+'
|
||||
style={{width: 50}}
|
||||
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout)}
|
||||
value={getTimeoutAmount(settings.closedTimeout)}
|
||||
label={lang.t('configure.closed-comments-label')} />
|
||||
<div className={styles.configTimeoutSelect}>
|
||||
<SelectField
|
||||
label="comments closed time window"
|
||||
value={getTimeoutMeasure(settings.closedTimeout)}
|
||||
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout, true)}>
|
||||
<Option value={'hours'}>{lang.t('configure.hours')}</Option>
|
||||
<Option value={'days'}>{lang.t('configure.days')}</Option>
|
||||
<Option value={'weeks'}>{lang.t('configure.weeks')}</Option>
|
||||
</SelectField>
|
||||
</div>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={styles.configSettingInfoBox}>
|
||||
<ListItemContent>
|
||||
{lang.t('configure.closed-comments-desc')}
|
||||
<Textfield
|
||||
onChange={updateClosedMessage(updateSettings)}
|
||||
value={settings.closedMessage}
|
||||
label={lang.t('configure.closed-comments-label')}
|
||||
rows={3}/>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
</List>;
|
||||
return (
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
<List>
|
||||
<ListItem className={`${styles.configSetting} ${settings.moderation === 'pre' ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<ListItemAction>
|
||||
<Checkbox
|
||||
onChange={updateModeration(updateSettings, settings.moderation)}
|
||||
checked={settings.moderation === 'pre'} />
|
||||
</ListItemAction>
|
||||
<ListItemContent>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.enable-pre-moderation')}</div>
|
||||
<p className={settings.moderation === 'pre' ? '' : styles.disabledSettingText}>
|
||||
{lang.t('configure.enable-pre-moderation-text')}
|
||||
</p>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={`${styles.configSetting} ${settings.charCountEnable ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<ListItemAction>
|
||||
<Checkbox
|
||||
onChange={updateCharCountEnable(updateSettings, settings.charCountEnable)}
|
||||
checked={settings.charCountEnable} />
|
||||
</ListItemAction>
|
||||
<ListItemContent>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.comment-count-header')}</div>
|
||||
<p className={settings.charCountEnable ? '' : styles.disabledSettingText}>
|
||||
<span>{lang.t('configure.comment-count-text-pre')}</span>
|
||||
<input type='text'
|
||||
className={`${styles.charCountTexfield} ${settings.charCountEnable && styles.charCountTexfieldEnabled}`}
|
||||
htmlFor='charCount'
|
||||
onChange={updateCharCount(updateSettings, settingsError)}
|
||||
value={settings.charCount}/>
|
||||
<span>{lang.t('configure.comment-count-text-post')}</span>
|
||||
{
|
||||
errors.charCount &&
|
||||
<span className={styles.settingsError}>
|
||||
<br/>
|
||||
<Icon name="error_outline"/>
|
||||
{lang.t('configure.comment-count-error')}
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem threeLine className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<ListItemAction>
|
||||
<Checkbox
|
||||
onChange={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
|
||||
checked={settings.infoBoxEnable} />
|
||||
</ListItemAction>
|
||||
<ListItemContent>
|
||||
{lang.t('configure.include-comment-stream')}
|
||||
<p>
|
||||
{lang.t('configure.include-comment-stream-desc')}
|
||||
</p>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
|
||||
<ListItemContent>
|
||||
<Textfield
|
||||
onChange={updateInfoBoxContent(updateSettings)}
|
||||
value={settings.infoBoxContent}
|
||||
label={lang.t('configure.include-text')}
|
||||
rows={3}/>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={styles.configSettingInfoBox}>
|
||||
<ListItemContent>
|
||||
{lang.t('configure.close-after')}
|
||||
<br />
|
||||
<Textfield
|
||||
type='number'
|
||||
pattern='[0-9]+'
|
||||
style={{width: 50}}
|
||||
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout)}
|
||||
value={getTimeoutAmount(settings.closedTimeout)}
|
||||
label={lang.t('configure.closed-comments-label')} />
|
||||
<div className={styles.configTimeoutSelect}>
|
||||
<SelectField
|
||||
label="comments closed time window"
|
||||
value={getTimeoutMeasure(settings.closedTimeout)}
|
||||
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout, true)}>
|
||||
<Option value={'hours'}>{lang.t('configure.hours')}</Option>
|
||||
<Option value={'days'}>{lang.t('configure.days')}</Option>
|
||||
<Option value={'weeks'}>{lang.t('configure.weeks')}</Option>
|
||||
</SelectField>
|
||||
</div>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={styles.configSettingInfoBox}>
|
||||
<ListItemContent>
|
||||
{lang.t('configure.closed-comments-desc')}
|
||||
<Textfield
|
||||
onChange={updateClosedMessage(updateSettings)}
|
||||
value={settings.closedMessage}
|
||||
label={lang.t('configure.closed-comments-label')}
|
||||
rows={3}/>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentSettings;
|
||||
|
||||
@@ -112,12 +112,12 @@
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
#bannedWordlist {
|
||||
#bannedWordlist, #suspectWordlist {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.bannedWordHeader {
|
||||
.wordlistHeader {
|
||||
font-weight: bold;
|
||||
font-size:18px;
|
||||
margin-bottom:3px;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {fetchSettings, updateSettings, saveSettingsToServer} from '../../actions/settings';
|
||||
import {
|
||||
fetchSettings,
|
||||
updateSettings,
|
||||
saveSettingsToServer,
|
||||
updateWordlist,
|
||||
} from '../../actions/settings';
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
@@ -14,6 +19,7 @@ import translations from '../../translations.json';
|
||||
import EmbedLink from './EmbedLink';
|
||||
import CommentSettings from './CommentSettings';
|
||||
import Wordlist from './Wordlist';
|
||||
import has from 'lodash/has';
|
||||
|
||||
class Configure extends React.Component {
|
||||
constructor (props) {
|
||||
@@ -21,7 +27,6 @@ class Configure extends React.Component {
|
||||
|
||||
this.state = {
|
||||
activeSection: 'comments',
|
||||
wordlist: [],
|
||||
changed: false,
|
||||
errors: {}
|
||||
};
|
||||
@@ -31,15 +36,6 @@ class Configure extends React.Component {
|
||||
this.props.dispatch(fetchSettings());
|
||||
}
|
||||
|
||||
componentWillUpdate = (newProps) => {
|
||||
if ((!this.props.settings
|
||||
|| !this.props.settings.wordlist)
|
||||
&& newProps.settings.wordlist
|
||||
&& newProps.settings.wordlist.length !== 0 ) {
|
||||
this.setState({wordlist: newProps.settings.wordlist.join(', ')});
|
||||
}
|
||||
}
|
||||
|
||||
saveSettings = () => {
|
||||
this.props.dispatch(saveSettingsToServer());
|
||||
this.setState({changed: false});
|
||||
@@ -49,15 +45,9 @@ class Configure extends React.Component {
|
||||
this.setState({activeSection});
|
||||
}
|
||||
|
||||
onChangeWordlist = (event) => {
|
||||
event.preventDefault();
|
||||
const newlist = event.target.value;
|
||||
this.setState({wordlist: newlist.toLowerCase(), changed: true});
|
||||
this.props.dispatch(updateSettings({
|
||||
wordlist: newlist.toLowerCase()
|
||||
.split(',')
|
||||
.map((word) => word.trim())
|
||||
}));
|
||||
onChangeWordlist = (listName, list) => {
|
||||
this.setState({changed: true});
|
||||
this.props.dispatch(updateWordlist(listName, list));
|
||||
}
|
||||
|
||||
onSettingUpdate = (setting) => {
|
||||
@@ -74,46 +64,46 @@ class Configure extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
getSection = (section) => {
|
||||
getSection (section) {
|
||||
const pageTitle = this.getPageTitle(section);
|
||||
switch(section){
|
||||
case 'comments':
|
||||
return <CommentSettings
|
||||
title={pageTitle}
|
||||
fetchingSettings={this.props.fetchingSettings}
|
||||
settings={this.props.settings}
|
||||
updateSettings={this.onSettingUpdate}
|
||||
errors={this.state.errors}
|
||||
settingsError={this.onSettingError}/>;
|
||||
case 'embed':
|
||||
return <EmbedLink/>;
|
||||
return <EmbedLink title={pageTitle} />;
|
||||
case 'wordlist':
|
||||
return <Wordlist
|
||||
wordlist={this.state.wordlist}
|
||||
onChangeWordlist={this.onChangeWordlist}/>;
|
||||
return has(this, 'props.settings.wordlist')
|
||||
? <Wordlist
|
||||
bannedWords={this.props.settings.wordlist.banned}
|
||||
suspectWords={this.props.settings.wordlist.suspect}
|
||||
onChangeWordlist={this.onChangeWordlist} />
|
||||
: <p>loading wordlists</p>;
|
||||
}
|
||||
}
|
||||
|
||||
getPageTitle = (section) => {
|
||||
getPageTitle (section) {
|
||||
switch(section) {
|
||||
case 'comments':
|
||||
return lang.t('configure.comment-settings');
|
||||
case 'embed':
|
||||
return lang.t('configure.embed-comment-stream');
|
||||
case 'wordlist':
|
||||
return lang.t('configure.wordlist');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
let pageTitle = this.getPageTitle(this.state.activeSection);
|
||||
const section = this.getSection(this.state.activeSection);
|
||||
|
||||
const showSave = Object.keys(this.state.errors).reduce(
|
||||
(bool, error) => this.state.errors[error] ? false : bool, this.state.changed);
|
||||
|
||||
if (this.props.fetchingSettings) {
|
||||
pageTitle += ' - Loading...';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
@@ -151,7 +141,6 @@ class Configure extends React.Component {
|
||||
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
<h1>{pageTitle}</h1>
|
||||
{ this.props.saveFetchingError }
|
||||
{ this.props.fetchSettingsError }
|
||||
{ section }
|
||||
|
||||
@@ -31,16 +31,21 @@ class EmbedLink extends Component {
|
||||
render () {
|
||||
const embedText = `<div id='coralStreamEmbed'></div><script type='text/javascript' src='${window.location.protocol}//pym.nprapps.org/pym.v1.min.js'></script><script>var pymParent = new pym.Parent('coralStreamEmbed', '${window.location.protocol}//${window.location.host}/embed/stream', {title: 'Comments'});</script>`;
|
||||
|
||||
return <List>
|
||||
<ListItem className={styles.configSettingEmbed}>
|
||||
<p>{lang.t('configure.copy-and-paste')}</p>
|
||||
<textarea rows={5} type='text' className={styles.embedInput} value={embedText} readOnly={true}/>
|
||||
<Button raised colored className={styles.copyButton} onClick={this.copyToClipBoard}>
|
||||
{lang.t('embedlink.copy')}
|
||||
</Button>
|
||||
<div className={styles.copiedText}>{this.state.copied && 'Copied!'}</div>
|
||||
</ListItem>
|
||||
</List>;
|
||||
return (
|
||||
<div>
|
||||
<h3>{this.props.title}</h3>
|
||||
<List>
|
||||
<ListItem className={styles.configSettingEmbed}>
|
||||
<p>{lang.t('configure.copy-and-paste')}</p>
|
||||
<textarea rows={5} type='text' className={styles.embedInput} value={embedText} readOnly={true}/>
|
||||
<Button raised colored className={styles.copyButton} onClick={this.copyToClipBoard}>
|
||||
{lang.t('embedlink.copy')}
|
||||
</Button>
|
||||
<div className={styles.copiedText}>{this.state.copied && 'Copied!'}</div>
|
||||
</ListItem>
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
import React from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import styles from './Configure.css';
|
||||
import {
|
||||
Card
|
||||
} from 'react-mdl';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
|
||||
const Wordlist = ({wordlist, onChangeWordlist}) => <Card id={styles.bannedWordlist} shadow={2}>
|
||||
<p className={styles.bannedWordHeader}>{lang.t('configure.banned-word-header')}</p>
|
||||
<p className={styles.bannedWordText}>{lang.t('configure.banned-word-text')}</p>
|
||||
<textarea
|
||||
rows={5}
|
||||
type='text'
|
||||
className={styles.bannedWordInput}
|
||||
onChange={onChangeWordlist}
|
||||
value={wordlist}/>
|
||||
</Card>;
|
||||
import styles from './Configure.css';
|
||||
|
||||
import {Card} from 'react-mdl';
|
||||
|
||||
const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
|
||||
<div>
|
||||
<h3>{lang.t('configure.banned-words-title')}</h3>
|
||||
<Card id={styles.bannedWordlist} shadow={2}>
|
||||
<p className={styles.wordlistHeader}>{lang.t('configure.banned-word-header')}</p>
|
||||
<p className={styles.wordlistDesc}>{lang.t('configure.banned-word-text')}</p>
|
||||
<TagsInput
|
||||
value={bannedWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={data => data.split(',').map(d => d.trim())}
|
||||
onChange={tags => onChangeWordlist('banned', tags)} />
|
||||
</Card>
|
||||
<h3>{lang.t('configure.suspect-words-title')}</h3>
|
||||
<Card id={styles.suspectWordlist} shadow={2}>
|
||||
<p className={styles.wordlistHeader}>{lang.t('configure.suspect-word-header')}</p>
|
||||
<p className={styles.wordlistDesc}>{lang.t('configure.suspect-word-text')}</p>
|
||||
<TagsInput
|
||||
value={suspectWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={data => data.split(',').map(d => d.trim())}
|
||||
onChange={tags => onChangeWordlist('suspect', tags)} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Wordlist;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
fetchModerationQueueComments
|
||||
} from 'actions/comments';
|
||||
import {userStatusUpdate} from 'actions/users';
|
||||
import {fetchSettings} from 'actions/settings';
|
||||
import styles from './ModerationQueue.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
@@ -36,6 +37,7 @@ class ModerationQueue extends React.Component {
|
||||
|
||||
// Fetch comments and bind singleView key before render
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchSettings());
|
||||
this.props.dispatch(fetchModerationQueueComments());
|
||||
key('s', () => this.setState({singleView: !this.state.singleView}));
|
||||
key('shift+/', () => this.setState({modalOpen: true}));
|
||||
@@ -86,7 +88,7 @@ class ModerationQueue extends React.Component {
|
||||
|
||||
// Render the tabbed lists moderation queues
|
||||
render () {
|
||||
const {comments, users} = this.props;
|
||||
const {comments, users, settings} = this.props;
|
||||
const {activeTab, singleView, modalOpen} = this.state;
|
||||
|
||||
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'premod');
|
||||
@@ -106,6 +108,7 @@ class ModerationQueue extends React.Component {
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
|
||||
<CommentList
|
||||
suspectWords={settings.settings.wordlist.suspect}
|
||||
isActive={activeTab === 'pending'}
|
||||
singleView={singleView}
|
||||
commentIds={premodIds}
|
||||
@@ -113,7 +116,7 @@ class ModerationQueue extends React.Component {
|
||||
users={users.byId}
|
||||
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
|
||||
onClickShowBanDialog={(userId, userName, commentId) => this.showBanUserDialog(userId, userName, commentId)}
|
||||
actions={['reject', 'approve', 'ban']}
|
||||
modActions={['reject', 'approve', 'ban']}
|
||||
loading={comments.loading} />
|
||||
<BanUserDialog
|
||||
open={comments.showBanUserDialog}
|
||||
@@ -123,24 +126,26 @@ class ModerationQueue extends React.Component {
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
|
||||
<CommentList
|
||||
suspectWords={settings.settings.wordlist.suspect}
|
||||
isActive={activeTab === 'rejected'}
|
||||
singleView={singleView}
|
||||
commentIds={rejectedIds}
|
||||
comments={comments.byId}
|
||||
users={users.byId}
|
||||
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
|
||||
actions={['approve']}
|
||||
modActions={['approve']}
|
||||
loading={comments.loading} />
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
|
||||
<CommentList
|
||||
isActive={activeTab === 'rejected'}
|
||||
suspectWords={settings.settings.wordlist.suspect}
|
||||
singleView={singleView}
|
||||
commentIds={flaggedIds}
|
||||
comments={comments.byId}
|
||||
users={users.byId}
|
||||
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
|
||||
actions={['reject', 'approve']}
|
||||
modActions={['reject', 'approve']}
|
||||
loading={comments.loading} />
|
||||
</div>
|
||||
<ModerationKeysModal open={modalOpen}
|
||||
@@ -152,6 +157,8 @@ class ModerationQueue extends React.Component {
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
actions: state.actions.toJS(),
|
||||
settings: state.settings.toJS(),
|
||||
comments: state.comments.toJS(),
|
||||
users: state.users.toJS()
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
.container {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.leftColumn {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
width: calc(90% - 200px);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
vertical-align: middle;
|
||||
font-size: 18px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
padding: 3px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
width: 90%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.searchBoxInput {
|
||||
border: none;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.searchBoxInput:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.optionHeader {
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.optionDetail {
|
||||
font-size: 16px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.streamsTable {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.radio {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.statusMenu {
|
||||
border-radius: 3px;
|
||||
width: 10em;
|
||||
text-align: center;
|
||||
float: right;
|
||||
border: 1px solid #ccc;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.statusMenuOpen {
|
||||
padding: 10px;
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
.statusMenuIcon {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.statusMenuClosed {
|
||||
padding: 10px;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, {Component} from 'react';
|
||||
import styles from './Streams.css';
|
||||
import {connect} from 'react-redux';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import {fetchAssets, updateAssetState} from '../../actions/assets';
|
||||
import translations from '../../translations.json';
|
||||
import {
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Icon,
|
||||
DataTable,
|
||||
TableHeader
|
||||
} from 'react-mdl';
|
||||
import Pager from 'coral-ui/components/Pager';
|
||||
|
||||
const limit = 25;
|
||||
|
||||
class Streams extends Component {
|
||||
|
||||
state = {
|
||||
search: '',
|
||||
sort: 'desc',
|
||||
filter: 'all',
|
||||
statusMenus: {},
|
||||
timer: null,
|
||||
page: 0
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.props.fetchAssets(0, limit, '', this.state.sortBy);
|
||||
}
|
||||
|
||||
onSettingChange = (setting) => (e) => {
|
||||
let options = this.state;
|
||||
this.setState({[setting]: e.target.value});
|
||||
options[setting] = e.target.value;
|
||||
this.props.fetchAssets(0, limit, options.search, options.sort, options.filter);
|
||||
}
|
||||
|
||||
onSearchChange = (e) => {
|
||||
this.setState((prevState) => {
|
||||
prevState.search = e.target.value;
|
||||
clearTimeout(prevState.timer);
|
||||
const fetchAssets = this.props.fetchAssets;
|
||||
prevState.timer = setTimeout(() => {
|
||||
fetchAssets(0, limit, e.target.value, this.state.sort, this.state.filter);
|
||||
}, 350);
|
||||
return prevState;
|
||||
});
|
||||
}
|
||||
|
||||
renderDate = (date) => {
|
||||
const d = new Date(date);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
onStatusClick = (closeStream, id, statusMenuOpen) => () => {
|
||||
if (statusMenuOpen) {
|
||||
this.setState(prev => {
|
||||
prev.statusMenus[id] = false;
|
||||
return prev;
|
||||
});
|
||||
this.props.updateAssetState(id, closeStream ? Date.now() : null)
|
||||
.then(() => {
|
||||
const {search, sort, filter, page} = this.state;
|
||||
this.props.fetchAssets(page, limit, search, sort, filter);
|
||||
});
|
||||
} else {
|
||||
this.setState(prev => {
|
||||
prev.statusMenus[id] = true;
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderStatus = (closedAt, {id}) => {
|
||||
const closed = closedAt && new Date(closedAt).getTime() < Date.now();
|
||||
const statusMenuOpen = this.state.statusMenus[id];
|
||||
return <div className={styles.statusMenu}>
|
||||
<div
|
||||
className={closed ? styles.statusMenuClosed : styles.statusMenuOpen}
|
||||
onClick={this.onStatusClick(closed, id, statusMenuOpen)}>
|
||||
{closed ? lang.t('streams.closed') : lang.t('streams.open')}
|
||||
{!statusMenuOpen && <Icon className={styles.statusMenuIcon} name='keyboard_arrow_down'/>}
|
||||
</div>
|
||||
{
|
||||
statusMenuOpen &&
|
||||
<div
|
||||
className={!closed ? styles.statusMenuClosed : styles.statusMenuOpen}
|
||||
onClick={this.onStatusClick(!closed, id, statusMenuOpen)}>
|
||||
{!closed ? lang.t('streams.closed') : lang.t('streams.open')}
|
||||
</div>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
|
||||
onPageClick = (page) => {
|
||||
this.setState({page});
|
||||
const {search, sort, filter} = this.state;
|
||||
this.props.fetchAssets((page - 1) * limit, limit, search, sort, filter);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {search, sort, filter} = this.state;
|
||||
const {assets} = this.props;
|
||||
|
||||
return <div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
|
||||
<div className={styles.searchBox}>
|
||||
<Icon name='search' className={styles.searchIcon}/>
|
||||
<input
|
||||
type='text'
|
||||
value={search}
|
||||
className={styles.searchBoxInput}
|
||||
onChange={this.onSearchChange}
|
||||
placeholder={lang.t('streams.search')}/>
|
||||
</div>
|
||||
|
||||
<div className={styles.optionHeader}>{lang.t('streams.filter-streams')}</div>
|
||||
<div className={styles.optionDetail}>{lang.t('streams.stream-status')}</div>
|
||||
<RadioGroup
|
||||
name='status filter'
|
||||
value={filter}
|
||||
childContainer='div'
|
||||
onChange={this.onSettingChange('filter')}>
|
||||
<Radio value='all'>{lang.t('streams.all')}</Radio>
|
||||
<Radio value='open'>{lang.t('streams.open')}</Radio>
|
||||
<Radio value='closed'>{lang.t('streams.closed')}</Radio>
|
||||
</RadioGroup>
|
||||
<div className={styles.optionHeader}>{lang.t('streams.sort-by')}</div>
|
||||
<RadioGroup
|
||||
name='sort by'
|
||||
value={sort}
|
||||
childContainer='div'
|
||||
onChange={this.onSettingChange('sort')}>
|
||||
<Radio value='desc'>{lang.t('streams.newest')}</Radio>
|
||||
<Radio value='asc'>{lang.t('streams.oldest')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className={styles.mainContent}>
|
||||
<DataTable
|
||||
className={styles.streamsTable}
|
||||
rows={assets.ids.map((id) => assets.byId[id])}>
|
||||
<TableHeader name="title">{lang.t('streams.article')}</TableHeader>
|
||||
<TableHeader numeric name="publication_date" cellFormatter={this.renderDate}>
|
||||
{lang.t('streams.pubdate')}
|
||||
</TableHeader>
|
||||
<TableHeader numeric name="closedAt" cellFormatter={this.renderStatus}>
|
||||
{lang.t('streams.status')}
|
||||
</TableHeader>
|
||||
</DataTable>
|
||||
<Pager
|
||||
totalPages={Math.ceil((assets.count || 0) / limit)}
|
||||
page={this.state.page}
|
||||
onNewPageHandler={this.onPageClick}
|
||||
/>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({assets}) => {
|
||||
return {
|
||||
assets: assets.toJS()
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = (dispatch) => {
|
||||
return {
|
||||
fetchAssets: (...args) => {
|
||||
dispatch(fetchAssets.apply(this, args));
|
||||
},
|
||||
updateAssetState: (...args) => dispatch(updateAssetState.apply(this, args))
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Streams);
|
||||
|
||||
const lang = new I18n(translations);
|
||||
@@ -0,0 +1,24 @@
|
||||
import {Map, Set} from 'immutable';
|
||||
import * as types from '../constants/actions';
|
||||
|
||||
const initialState = Map({
|
||||
ids: Set()
|
||||
});
|
||||
|
||||
export default (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case types.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS: return addActions(state, action);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const addActions = (state, action) => {
|
||||
const ids = action.actions.map(action => action.item_id);
|
||||
const map = action.actions.reduce((memo, action) => {
|
||||
memo[action.item_id] = action;
|
||||
return memo;
|
||||
}, {});
|
||||
const newIds = state.get('ids').concat(ids);
|
||||
return state.merge(map).set('ids', newIds);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import {Map, List, fromJS} from 'immutable';
|
||||
import {FETCH_ASSETS_SUCCESS, UPDATE_ASSET_STATE_REQUEST} from '../constants/assets';
|
||||
|
||||
const initialState = Map({
|
||||
byId: Map(),
|
||||
ids: List()
|
||||
});
|
||||
|
||||
export default (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case FETCH_ASSETS_SUCCESS:
|
||||
return replaceAssets(action, state);
|
||||
case UPDATE_ASSET_STATE_REQUEST:
|
||||
return state.setIn(['byId', action.id, 'closedAt'], action.closedAt);
|
||||
default: return state;
|
||||
}
|
||||
};
|
||||
|
||||
const replaceAssets = (action, state) => {
|
||||
const assets = fromJS(action.assets.reduce((prev, curr) => { prev[curr.id] = curr; return prev; }, {}));
|
||||
return state.set('byId', assets)
|
||||
.set('count', action.count)
|
||||
.set('ids', List(assets.keys()));
|
||||
};
|
||||
@@ -24,15 +24,16 @@ const initialState = Map({
|
||||
// Handle the comment actions
|
||||
export default (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case actions.COMMENTS_MODERATION_QUEUE_FETCH: return state.set('loading', true);
|
||||
case actions.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST: return state.set('loading', true);
|
||||
case actions.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS: return replaceComments(action, state);
|
||||
case actions.COMMENTS_MODERATION_QUEUE_FAILED: return state.set('loading', false);
|
||||
case actions.COMMENT_STATUS_UPDATE: return updateStatus(state, action);
|
||||
case actions.COMMENT_STATUS_UPDATE_REQUEST: return updateStatus(state, action);
|
||||
case actions.COMMENT_FLAG: return flag(state, action);
|
||||
case actions.COMMENT_CREATE_SUCCESS: return addComment(state, action);
|
||||
case actions.COMMENT_STREAM_FETCH_SUCCESS: return replaceComments(action, state);
|
||||
case actions.SHOW_BANUSER_DIALOG: return setBanUser(state, true, action);
|
||||
case actions.HIDE_BANUSER_DIALOG: return setBanUser(state, false, action);
|
||||
case actions.USER_BAN_SUCCESS: return setBanUser(state, false, action);
|
||||
case userActions.UPDATE_STATUS_SUCCESS: return setBanUser(state, false, action);
|
||||
default: return state;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import settings from 'reducers/settings';
|
||||
import community from 'reducers/community';
|
||||
import users from 'reducers/users';
|
||||
import auth from 'reducers/auth';
|
||||
import actions from 'reducers/actions';
|
||||
import assets from 'reducers/assets';
|
||||
|
||||
// Combine all reducers into a main one
|
||||
export default combineReducers({
|
||||
@@ -11,5 +13,7 @@ export default combineReducers({
|
||||
comments,
|
||||
community,
|
||||
auth,
|
||||
actions,
|
||||
assets,
|
||||
users
|
||||
});
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import {Map} from 'immutable';
|
||||
import {Map, List} from 'immutable';
|
||||
import * as types from '../actions/settings';
|
||||
|
||||
const initialState = Map({
|
||||
settings: Map(),
|
||||
settings: Map({
|
||||
wordlist: Map({
|
||||
banned: List(),
|
||||
suspect: List()
|
||||
})
|
||||
}),
|
||||
saveSettingsError: null,
|
||||
fetchSettingsError: null,
|
||||
fetchingSettings: false
|
||||
@@ -18,16 +23,23 @@ export default (state = initialState, action) => {
|
||||
case types.SAVE_SETTINGS_LOADING: return state.set('fetchingSettings', true).set('saveSettingsError', null);
|
||||
case types.SAVE_SETTINGS_SUCCESS: return saveComplete(state, action);
|
||||
case types.SAVE_SETTINGS_FAILED: return settingsSaveFailed(state, action);
|
||||
case types.WORDLIST_UPDATED: return updateWordlist(state, action);
|
||||
default: return state;
|
||||
}
|
||||
};
|
||||
|
||||
// only for updating top-level settings
|
||||
const updateSettings = (state, action) => {
|
||||
const s = state.set('fetchingSettings', false).set('fetchSettingsError', null);
|
||||
const settings = s.get('settings').merge(action.settings);
|
||||
return s.set('settings', settings);
|
||||
};
|
||||
|
||||
// any nested settings must have a specialized setter
|
||||
const updateWordlist = (state, action) => {
|
||||
return state.setIn(['settings', 'wordlist', action.listName], action.wordlist);
|
||||
};
|
||||
|
||||
const saveComplete = (state, action) => {
|
||||
const s = state.set('fetchingSettings', false).set('saveSettingsError', null);
|
||||
const settings = s.get('settings').merge(action.settings);
|
||||
|
||||
@@ -49,13 +49,18 @@
|
||||
"comment-settings": "Comment Settings",
|
||||
"embed-comment-stream": "Embed Comment Stream",
|
||||
"banned-word-header": "Write the bannned words list",
|
||||
"banned-word-text": "Comments which contain these words or phrases, not separated by commas and not case sensitive, will be automatically removed from the comment stream.",
|
||||
"wordlist": "Banned words list",
|
||||
"suspect-word-header": "Write the suspect words list",
|
||||
"banned-word-text": "Comments which contain these words or phrases (not case-sensitive) will be automatically removed from the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
|
||||
"suspect-word-text": "Comments which contain these words or phrases (not case-sensitive) will be highlighted in the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
|
||||
"wordlist": "Banned & Suspect Words",
|
||||
"banned-words-title": "Banned words list",
|
||||
"suspect-words-title": "Suspect words list",
|
||||
"save-changes": "Save Changes",
|
||||
"copy-and-paste": "Copy and paste code below into your CMS to embed your comment box in your articles",
|
||||
"moderate": "Moderate",
|
||||
"configure": "Configure",
|
||||
"community": "Community",
|
||||
"streams": "Streams",
|
||||
"closed-comments-desc": "Write a message for closed threads",
|
||||
"closed-comments-label": "Write a message...",
|
||||
"hours": "Hours",
|
||||
@@ -73,6 +78,22 @@
|
||||
"note": "Note: Banning this user will also place this comment in the Rejected queue.",
|
||||
"cancel": "Cancel",
|
||||
"yes_ban_user": "Yes, Ban User"
|
||||
},
|
||||
"streams": {
|
||||
"search": "Search",
|
||||
"filter-streams": "Filter Streams",
|
||||
"stream-status": "Stream Status",
|
||||
"all": "All",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"newest": "Newest",
|
||||
"oldest": "Oldest",
|
||||
"sort-by": "Sort By",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"article": "Article",
|
||||
"pubdate": "Publication Date",
|
||||
"status": "Status"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
@@ -113,9 +134,13 @@
|
||||
"include-text": "Incluir tu texto aqui.",
|
||||
"comment-settings": "Configuración de Comentarios",
|
||||
"embed-comment-stream": "Colocar Hilo de Comentarios",
|
||||
"wordlist": "Lista de palabras no permitidas",
|
||||
"wordlist": "Palabras Suspendidas y Suspechosas",
|
||||
"banned-word-header": "Escribir las palabras no permitidas",
|
||||
"suspect-word-header": "Write the suspect words list",
|
||||
"banned-word-text": "Comentarios que contengan estas palabras o frases, no separadas por comas y en mayusculas o minusuculas, serán automaticamente separadas de los comentarios publicados.",
|
||||
"suspect-word-text": "Comments which contain these words or phrases (not case-sensitive) will be highlighted in the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
|
||||
"banned-words-title": "Banned words list",
|
||||
"suspect-words-title": "Suspect words list",
|
||||
"save-changes": "Guardar Cambios",
|
||||
"copy-and-paste": "Copiar y pegar el código de más abajo en tu CMS para colocar la caja de comentarios en tus articulos",
|
||||
"moderate": "Moderar",
|
||||
@@ -139,6 +164,22 @@
|
||||
"note": "Nota: Suspender este usuario también va a colocar este comentario en la cola de Rechazados.",
|
||||
"cancel": "Cancelar",
|
||||
"yes_ban_user": "Si, Suspendan el usuario"
|
||||
},
|
||||
"streams": {
|
||||
"search": "",
|
||||
"filter-streams": "",
|
||||
"stream-status": "",
|
||||
"all": "",
|
||||
"open": "",
|
||||
"closed": "",
|
||||
"newest": "",
|
||||
"oldest": "",
|
||||
"sort-by": "",
|
||||
"open": "",
|
||||
"closed": "",
|
||||
"article": "",
|
||||
"pubdate": "",
|
||||
"status": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,6 @@ hr {
|
||||
.coral-plugin-commentbox-char-count {
|
||||
color: #ccc;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-char-max {
|
||||
@@ -230,7 +229,7 @@ hr {
|
||||
|
||||
.coral-plugin-flags-popup-header {
|
||||
font-weight: bolder;
|
||||
font-size: 16px;
|
||||
font-size: 1.33rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -240,7 +239,8 @@ hr {
|
||||
|
||||
.coral-plugin-flags-popup-radio-label {
|
||||
margin:5px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.coral-plugin-flags-popup-counter {
|
||||
@@ -254,8 +254,9 @@ hr {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.coral-plugin-flags-other-text {
|
||||
.coral-plugin-flags-reason-text {
|
||||
margin-left: 20px;
|
||||
margin-top: 5px;
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
@@ -290,7 +291,7 @@ hr {
|
||||
.close-comments-alert {
|
||||
background-color: #d65344;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-size: 1.33rem;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
|
||||
@@ -208,7 +208,9 @@ export function postItem (item, type, id) {
|
||||
*
|
||||
* @params
|
||||
* id - the id of the item on which the action is taking place
|
||||
* action - the name of the action
|
||||
* action - the action object.
|
||||
* Must include an 'action_type' string.
|
||||
* May optionally include a `metadata` object with arbitrary action data.
|
||||
* user - the user performing the action
|
||||
* host - the coral host
|
||||
*
|
||||
|
||||
@@ -18,7 +18,7 @@ const getPopupMenu = [
|
||||
{val: 'other', text: lang.t('other')}
|
||||
],
|
||||
button: lang.t('continue'),
|
||||
sets: 'detail'
|
||||
sets: 'reason'
|
||||
};
|
||||
},
|
||||
() => {
|
||||
|
||||
@@ -10,10 +10,9 @@ class FlagButton extends Component {
|
||||
|
||||
state = {
|
||||
showMenu: false,
|
||||
showOther: false,
|
||||
itemType: '',
|
||||
detail: '',
|
||||
otherText: '',
|
||||
reason: '',
|
||||
note: '',
|
||||
step: 0,
|
||||
posted: false
|
||||
}
|
||||
@@ -30,7 +29,7 @@ class FlagButton extends Component {
|
||||
|
||||
onPopupContinue = () => {
|
||||
const {postAction, addItem, updateItem, flag, id, author_id} = this.props;
|
||||
const {itemType, field, detail, step, otherText, posted} = this.state;
|
||||
const {itemType, field, reason, step, note, posted} = this.state;
|
||||
|
||||
// Proceed to the next step or close the menu if we've reached the end
|
||||
if (step + 1 >= this.props.getPopupMenu.length) {
|
||||
@@ -39,11 +38,10 @@ class FlagButton extends Component {
|
||||
this.setState({step: step + 1});
|
||||
}
|
||||
|
||||
// If itemType and detail are both set, post the action
|
||||
if (itemType && detail && !posted) {
|
||||
// If itemType and reason are both set, post the action
|
||||
if (itemType && reason && !posted) {
|
||||
|
||||
// Set the text from the "other" field if it exists.
|
||||
const updatedDetail = otherText || detail;
|
||||
let item_id;
|
||||
switch(itemType) {
|
||||
case 'comments':
|
||||
@@ -55,8 +53,11 @@ class FlagButton extends Component {
|
||||
}
|
||||
const action = {
|
||||
action_type: 'flag',
|
||||
field,
|
||||
detail: updatedDetail
|
||||
metadata: {
|
||||
field,
|
||||
reason,
|
||||
note
|
||||
}
|
||||
};
|
||||
postAction(item_id, itemType, action)
|
||||
.then((action) => {
|
||||
@@ -70,11 +71,6 @@ class FlagButton extends Component {
|
||||
|
||||
onPopupOptionClick = (sets) => (e) => {
|
||||
|
||||
// If the "other" option is clicked, show the other textbox
|
||||
if(sets === 'detail' && e.target.value === 'other') {
|
||||
this.setState({showOther: true});
|
||||
}
|
||||
|
||||
// If flagging a user, indicate that this is referencing the username rather than the bio
|
||||
if(sets === 'itemType' && e.target.value === 'user') {
|
||||
this.setState({field: 'username'});
|
||||
@@ -92,8 +88,8 @@ class FlagButton extends Component {
|
||||
this.setState({[sets]: e.target.value});
|
||||
}
|
||||
|
||||
onOtherTextChange = (e) => {
|
||||
this.setState({otherText: e.target.value});
|
||||
onNoteTextChange = (e) => {
|
||||
this.setState({note: e.target.value});
|
||||
}
|
||||
|
||||
handleClickOutside () {
|
||||
@@ -142,16 +138,16 @@ class FlagButton extends Component {
|
||||
)
|
||||
}
|
||||
{
|
||||
this.state.showOther && <div>
|
||||
<input
|
||||
className={`${name}-other-text`}
|
||||
type="text"
|
||||
id="otherText"
|
||||
onChange={this.onOtherTextChange}
|
||||
value={this.state.otherText}/>
|
||||
<label htmlFor={'otherText'} className={`${name}-popup-radio-label screen-reader-text`}>
|
||||
lang.t('flag-reason')
|
||||
</label><br/>
|
||||
this.state.reason && <div>
|
||||
<label htmlFor={'note'} className={`${name}-popup-radio-label`}>
|
||||
{lang.t('flag-reason')}
|
||||
</label><br/>
|
||||
<textarea
|
||||
className={`${name}-reason-text`}
|
||||
id="note"
|
||||
rows={4}
|
||||
onChange={this.onNoteTextChange}
|
||||
value={this.state.note}/>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
|
||||
@@ -22,12 +22,13 @@ const getPopupMenu = [
|
||||
[
|
||||
{val: 'I don\'t agree with this comment', text: lang.t('no-agree-comment')},
|
||||
{val: 'This comment is offensive', text: lang.t('comment-offensive')},
|
||||
{val: 'This comment reveals personally identifiable infomration', text: lang.t('personal-info')},
|
||||
{val: 'This looks like an ad/marketing', text: lang.t('marketing')},
|
||||
{val: 'other', text: lang.t('other')}
|
||||
]
|
||||
: [
|
||||
{val: 'This username is offensive', text: lang.t('username-offensive')},
|
||||
{val: 'I don\'t like this username', text: lang.t('no-like-username')},
|
||||
{val: 'This user is impersonating', text: lang.t('user-impersonating')},
|
||||
{val: 'This looks like an ad/marketing', text: lang.t('marketing')},
|
||||
{val: 'other', text: lang.t('other')}
|
||||
];
|
||||
@@ -35,7 +36,7 @@ const getPopupMenu = [
|
||||
header: lang.t('step-2-header'),
|
||||
options,
|
||||
button: lang.t('continue'),
|
||||
sets: 'detail'
|
||||
sets: 'reason'
|
||||
};
|
||||
},
|
||||
() => {
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
"bio-offensive": "This bio is offensive",
|
||||
"no-like-bio": "I don't like this bio",
|
||||
"marketing": "This looks like an ad/marketing",
|
||||
"user-impersonating": "This user is impersonating",
|
||||
"thank-you": "We value your safety and feedback. A moderator will review your flag.",
|
||||
"flag-reason": "Reason for flag",
|
||||
"flag-reason": "Reason for flag (Optional)",
|
||||
"other": "Other"
|
||||
},
|
||||
"es": {
|
||||
@@ -42,9 +43,10 @@
|
||||
"no-like-username": "No me gusta ese nombre de usuario",
|
||||
"bio-offensive": "Esta bio es ofensiva",
|
||||
"no-like-bio": "No me gusta esta bio",
|
||||
"user-impersonating": "Este usario suplanta a alguien",
|
||||
"marketing": "Esto parece una publicidad/marketing",
|
||||
"thank-you": "Nos interesa tu protección y comentarios. Un moderador va a mirar tu marca.",
|
||||
"flag-reason": "Razón por la que marcar",
|
||||
"flag-reason": "Razón por la que marcar (Opcional)",
|
||||
"other": "Otro"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -12,7 +12,7 @@ const Pager = ({totalPages, page, onNewPageHandler}) => (
|
||||
<div className="pager">
|
||||
<ul>
|
||||
{
|
||||
(totalPages > page) ?
|
||||
(totalPages > page && totalPages > 1) ?
|
||||
<li
|
||||
className={`mdl-button mdl-js-button ${styles.li}`}
|
||||
onClick={() => onNewPageHandler(page - 1)}>
|
||||
@@ -23,7 +23,7 @@ const Pager = ({totalPages, page, onNewPageHandler}) => (
|
||||
}
|
||||
{Rows(page, totalPages, onNewPageHandler)}
|
||||
{
|
||||
(page < totalPages) ?
|
||||
(page < totalPages && totalPages > 1) ?
|
||||
<li
|
||||
className={`mdl-button mdl-js-button ${styles.li}`}
|
||||
onClick={() => onNewPageHandler(page + 1)}>
|
||||
@@ -42,4 +42,3 @@ Pager.propTypes = {
|
||||
};
|
||||
|
||||
export default Pager;
|
||||
|
||||
+498
-37
@@ -18,12 +18,6 @@ paths:
|
||||
tags:
|
||||
- Comments
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
description: Performs a search based on the comment's status.
|
||||
type: string
|
||||
enum:
|
||||
- flag
|
||||
- name: action_type
|
||||
in: query
|
||||
description: Performs a search based on the actions that have been added to it.
|
||||
@@ -38,7 +32,7 @@ paths:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
- $ref: '#/definitions/Comment'
|
||||
$ref: '#/definitions/Comment'
|
||||
500:
|
||||
description: An error occured.
|
||||
schema:
|
||||
@@ -49,9 +43,19 @@ paths:
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
description: The comment to create.
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/Comment'
|
||||
type: object
|
||||
properties:
|
||||
body:
|
||||
type: string
|
||||
description: The text of the comment to create.
|
||||
asset_id:
|
||||
type: string
|
||||
description: The parent asset of this comment.
|
||||
parent_id:
|
||||
type: string
|
||||
description: The parent comment of this comment (null if the comment is not a reply.)
|
||||
responses:
|
||||
201:
|
||||
description: The comment that was created.
|
||||
@@ -61,6 +65,7 @@ paths:
|
||||
description: An error occured.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
|
||||
/comments/{comment_id}:
|
||||
get:
|
||||
tags:
|
||||
@@ -98,6 +103,7 @@ paths:
|
||||
description: An error occured.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
|
||||
/comments/{comment_id}/status:
|
||||
put:
|
||||
tags:
|
||||
@@ -108,6 +114,7 @@ paths:
|
||||
in: path
|
||||
description: The id of the comment to retrieve.
|
||||
type: string
|
||||
format: uuid
|
||||
required: true
|
||||
- name: body
|
||||
in: body
|
||||
@@ -119,6 +126,11 @@ paths:
|
||||
status:
|
||||
type: string
|
||||
description: The status to update to.
|
||||
enum:
|
||||
- new
|
||||
- flagged
|
||||
- accepted
|
||||
- rejected
|
||||
responses:
|
||||
204:
|
||||
description: The comment status was updated.
|
||||
@@ -126,15 +138,15 @@ paths:
|
||||
description: An error occured.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
/comments/{comment_id}/actions:
|
||||
/comments/{item_id}/actions:
|
||||
post:
|
||||
tags:
|
||||
- Comments
|
||||
- Actions
|
||||
parameters:
|
||||
- name: comment_id
|
||||
- name: item_id
|
||||
in: path
|
||||
description: The id of the comment to retrieve.
|
||||
description: The id of the item which is the target of the action.
|
||||
type: string
|
||||
required: true
|
||||
- name: body
|
||||
@@ -146,7 +158,13 @@ paths:
|
||||
properties:
|
||||
action_type:
|
||||
type: string
|
||||
description: The action to add
|
||||
description: The type of action to add
|
||||
enum:
|
||||
- like
|
||||
- flag
|
||||
metadata:
|
||||
type: object
|
||||
description: An arbitrary object describing the action, should be consistent per action type.
|
||||
responses:
|
||||
201:
|
||||
description: The action created.
|
||||
@@ -240,6 +258,19 @@ paths:
|
||||
description: An error occured.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
/auth/facebook/callback:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
responses:
|
||||
200:
|
||||
description: Logs in the user after FB Auth.
|
||||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
500:
|
||||
description: An error occured.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
/queue/comments/pending:
|
||||
get:
|
||||
tags:
|
||||
@@ -249,9 +280,23 @@ paths:
|
||||
200:
|
||||
description: The comments that are not moderated.
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
- $ref: '#/definitions/Comment'
|
||||
type: object
|
||||
properties:
|
||||
comments:
|
||||
type: array
|
||||
description: The comments that have yet to be moderated.
|
||||
items:
|
||||
$ref: '#/definitions/Comment'
|
||||
users:
|
||||
type: array
|
||||
description: The users authoring these comments.
|
||||
items:
|
||||
$ref: '#/definitions/User'
|
||||
actions:
|
||||
type: array
|
||||
description: The actions which have taken place on these comments.
|
||||
items:
|
||||
$ref: '#/definitions/Actions'
|
||||
500:
|
||||
description: An error occured.
|
||||
schema:
|
||||
@@ -280,6 +325,17 @@ paths:
|
||||
in: query
|
||||
type: string
|
||||
description: Field to sort by.
|
||||
- name: filter
|
||||
in: query
|
||||
type: string
|
||||
enum:
|
||||
- open
|
||||
- closed
|
||||
description: Comment status to filter by.
|
||||
- name: search
|
||||
in: query
|
||||
type: string
|
||||
description: String to search by.
|
||||
responses:
|
||||
200:
|
||||
description: Assets listed.
|
||||
@@ -358,6 +414,34 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
|
||||
/assets/{asset_id}/status:
|
||||
put:
|
||||
parameters:
|
||||
- name: asset_id
|
||||
required: true
|
||||
in: path
|
||||
type: string
|
||||
format: uuid
|
||||
description: The id of the asset to be updated
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
closedAt:
|
||||
type: number
|
||||
description: The Unix timestamp when the stream will be or was previously closed.
|
||||
closedMessage:
|
||||
type: string
|
||||
description: The message to display to users when the stream is closed.
|
||||
responses:
|
||||
204:
|
||||
description: Status update successful.
|
||||
500:
|
||||
description: An error has occurred.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
/stream:
|
||||
get:
|
||||
tags:
|
||||
@@ -368,7 +452,7 @@ paths:
|
||||
parameters:
|
||||
- name: asset_url
|
||||
in: query
|
||||
description: The asset url to get the comment stream from.
|
||||
description: The url of the asset for which to get the comment stream.
|
||||
type: string
|
||||
format: url
|
||||
responses:
|
||||
@@ -377,22 +461,23 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
assets:
|
||||
type: array
|
||||
items:
|
||||
- $ref: '#/definitions/Asset'
|
||||
asset:
|
||||
$ref: '#/definitions/Asset'
|
||||
comments:
|
||||
type: array
|
||||
description: All comments for this asset.
|
||||
items:
|
||||
- $ref: '#/definitions/Comment'
|
||||
$ref: '#/definitions/Comment'
|
||||
users:
|
||||
type: array
|
||||
description: All authors of comments on this asset.
|
||||
items:
|
||||
- $ref: '#/definitions/User'
|
||||
$ref: '#/definitions/User'
|
||||
actions:
|
||||
type: array
|
||||
description: All actions on comments on this asset and their authors.
|
||||
items:
|
||||
- $ref: '#/definitions/Actions'
|
||||
$ref: '#/definitions/Actions'
|
||||
500:
|
||||
description: An error occured.
|
||||
schema:
|
||||
@@ -401,7 +486,7 @@ paths:
|
||||
get:
|
||||
responses:
|
||||
200:
|
||||
description: The settings.
|
||||
description: All global settings.
|
||||
schema:
|
||||
$ref: '#/definitions/Settings'
|
||||
500:
|
||||
@@ -409,6 +494,14 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
put:
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
description: Settings to be updated.
|
||||
schema:
|
||||
type: object
|
||||
description: Any allowed setting and value.
|
||||
responses:
|
||||
204:
|
||||
description: The settings were updated.
|
||||
@@ -416,6 +509,241 @@ paths:
|
||||
description: An error occured.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
/users:
|
||||
get:
|
||||
parameters:
|
||||
- name: value
|
||||
in: query
|
||||
type: string
|
||||
description: A term to search users' displayNames and email addresses.
|
||||
- name: sort
|
||||
in: query
|
||||
type: string
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
description: Determines whether users sorted in are ascending or descending order.
|
||||
- name: field
|
||||
in: query
|
||||
type: string
|
||||
description: The field used to sort.
|
||||
- name: page
|
||||
in: query
|
||||
type: number
|
||||
description: The page of search results to return.
|
||||
- name: limit
|
||||
in: query
|
||||
type: number
|
||||
description: The number of search restults per page.
|
||||
responses:
|
||||
200:
|
||||
description: A paginated array of users matching search terms.
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
type: array
|
||||
description: Users matching search criteria.
|
||||
items:
|
||||
$ref: '#/definitions/User'
|
||||
limit:
|
||||
type: number
|
||||
description: Results per page.
|
||||
count:
|
||||
type: number
|
||||
description: Total number of results.
|
||||
page:
|
||||
type: number
|
||||
description: The current page.
|
||||
totalPages:
|
||||
type: number
|
||||
description: The total number of pages.
|
||||
500:
|
||||
description: An error occured.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
post:
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
description: User to be created.
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
password:
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
responses:
|
||||
201:
|
||||
description: The user that has been created.
|
||||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
/users/update-password:
|
||||
post:
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: The token that was in the url of the email link.
|
||||
password:
|
||||
type: string
|
||||
description: The new password.
|
||||
responses:
|
||||
204:
|
||||
description: Password update successful.
|
||||
500:
|
||||
description: An error occured.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
/request-password-reset:
|
||||
post:
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
description: The email address of the user whos password is being reset.
|
||||
responses:
|
||||
204:
|
||||
description: Returned regardless of whether the user was found in the DB.
|
||||
500:
|
||||
description: An error occured.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
/users/{user_id}/role:
|
||||
post:
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
description: ID of the user to be updated.
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
role:
|
||||
type: string
|
||||
description: Role to be added to the user.
|
||||
enum:
|
||||
- admin
|
||||
- moderator
|
||||
responses:
|
||||
204:
|
||||
description: Role update successful.
|
||||
500:
|
||||
description: An error occured.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
/users/{user_id}/status:
|
||||
post:
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
type: string
|
||||
format: uuid
|
||||
required: true
|
||||
description: ID of the user to be updated.
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
description: Status for the user to be set to.
|
||||
enum:
|
||||
- active
|
||||
- banned
|
||||
comment_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: The id of the comment which triggered this status change.
|
||||
responses:
|
||||
200:
|
||||
description: Status update successful.
|
||||
500:
|
||||
description: An error occured.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
/users/{user_id}/bio:
|
||||
put:
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
description: The id of the user being updated.
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
bio:
|
||||
type: string
|
||||
description: The bio that should be set for this user.
|
||||
responses:
|
||||
200:
|
||||
description: Status update successful.
|
||||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
500:
|
||||
description: An error occured.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
/{user_id}/actions:
|
||||
post:
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
description: The user on which this action is being taken.
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
action_type:
|
||||
description: The type of action being taken on this user.
|
||||
type: string
|
||||
enum:
|
||||
- flag
|
||||
metadata:
|
||||
type: object
|
||||
description: Arbitrary data to be included with the action.
|
||||
responses:
|
||||
200:
|
||||
description: The newly created action.
|
||||
schema:
|
||||
$ref: '#/definitions/Action'
|
||||
500:
|
||||
description: An error occured.
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
|
||||
definitions:
|
||||
Error:
|
||||
type: object
|
||||
@@ -423,10 +751,6 @@ definitions:
|
||||
message:
|
||||
type: string
|
||||
description: The error that occured.
|
||||
Item:
|
||||
type: object
|
||||
ModerationAction:
|
||||
type: string
|
||||
Comment:
|
||||
type: object
|
||||
properties:
|
||||
@@ -453,31 +777,66 @@ definitions:
|
||||
asset_id:
|
||||
type: string
|
||||
description: Display name of comment
|
||||
status_history:
|
||||
type: array
|
||||
description: A history of status changes for this comment.
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- accepted
|
||||
- rejected
|
||||
- premod
|
||||
assigned_by:
|
||||
type: string
|
||||
description: ID of the user who assigned this status.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Date when status was assigned.
|
||||
|
||||
Actions:
|
||||
type: object
|
||||
description: A summary of actions taken on a particular item which is with the comment stream.
|
||||
properties:
|
||||
item_id:
|
||||
type: string
|
||||
description: The ID of the item which these actions target
|
||||
item_type:
|
||||
type: string # comment, user...
|
||||
type: string
|
||||
description: The type of item which these actions target (comment, user, etc.)
|
||||
type:
|
||||
type: string # flagged, likes, upvotes...
|
||||
type: string
|
||||
description: The type of action (like, flag, etc.)
|
||||
count:
|
||||
type: integer
|
||||
description: The number of this type of actions performed on this item.
|
||||
metadata:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
description: Metadata from the actions performed on this item. This metadata can be defined differently for each action type.
|
||||
current_user:
|
||||
type: boolean
|
||||
type: object
|
||||
description: Will include the action performed by the currently logged in user if that user has taken an action on this item. Otherwise will return null.
|
||||
Action:
|
||||
type: object
|
||||
description: A single action taken by a user.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The uuid.v4 id of the action.
|
||||
type:
|
||||
type: string
|
||||
description: The type of action being taken (like, flag, etc.)
|
||||
user_id:
|
||||
type: string
|
||||
moderation:
|
||||
type: string
|
||||
enum:
|
||||
- pre
|
||||
- post
|
||||
description: The ID of the user taking this action.
|
||||
metadata:
|
||||
type: object
|
||||
description: An object which contains arbitrary metadata about the action. Should be consistent for each action_type.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -518,10 +877,112 @@ definitions:
|
||||
type: string
|
||||
format: datetime
|
||||
description: When this asset was published.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Creation Date-Time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Updated Date-Time
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The uuid.v4 id of the user.
|
||||
displayName:
|
||||
type: string
|
||||
description: The name appearing next to the user's comments.
|
||||
disabled:
|
||||
type: boolean
|
||||
description: Indicates whether the user's account has been disabled (ie if the user is banned).
|
||||
password:
|
||||
type: string
|
||||
description: This provides a source of identity proof for users who login using the local provider. A local provider will be assumed for users who do not have any social profiles.
|
||||
profiles:
|
||||
type: array
|
||||
description: The array of identities for a given user. Any one user can have multiple profiles associated with them (eg facebook, google, etc.)
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: A unique identifier for the profile.
|
||||
provider:
|
||||
type: string
|
||||
description: The ame of the identity provider being used (e.g. 'facebook', 'twitter', etc.)
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Roles occupied by the user (e.g. 'admin', 'moderator', etc.)
|
||||
status:
|
||||
type: string
|
||||
description: The current status of the user in the system.
|
||||
enum:
|
||||
- active
|
||||
- banned
|
||||
settings:
|
||||
type: object
|
||||
description: User-specific settings
|
||||
properties:
|
||||
bio:
|
||||
type: string
|
||||
description: A bio visible to other users.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Creation Date-Time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Updated Date-Time
|
||||
Settings:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The id of the settings object. Defaults to 1 for global settings.
|
||||
moderation:
|
||||
type: string
|
||||
enum:
|
||||
- pre
|
||||
- post
|
||||
description: Indicates whether moderation occurs before or after a comment is made publicly visible.
|
||||
infoBoxEnable:
|
||||
type: boolean
|
||||
description: Indicates whether an informational box will be shown above the comment input box.
|
||||
infoBoxContent:
|
||||
type: string
|
||||
description: The text to appear in the informational box.
|
||||
closedTimeout:
|
||||
type: number
|
||||
format: int32
|
||||
description: The time after which streams will be automatically closed in seconds. Null will keep streams open forever.
|
||||
closedMessage:
|
||||
type: string
|
||||
description: The message displayed when a stream is closed.
|
||||
wordlist:
|
||||
type: array
|
||||
description: A list of banned word which will cause a comment to be automatically rejected.
|
||||
items:
|
||||
type: string
|
||||
charCount:
|
||||
type: number
|
||||
format: int32
|
||||
description: The maximum number of characters allowed in a comment.
|
||||
charCountEnable:
|
||||
type: boolean
|
||||
description: Indicates whether a maximum character count should be enabled for comments.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Creation Date-Time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Updated Date-Time
|
||||
Job:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
const Setting = require('./models/setting');
|
||||
const wordlist = require('./services/wordlist');
|
||||
|
||||
module.exports = () => Promise.all([
|
||||
|
||||
// Upsert the settings object.
|
||||
Setting
|
||||
.init({id: '1', moderation: 'pre'})
|
||||
.then(() => {
|
||||
|
||||
// Load in the wordlist now that settings have been init'd.
|
||||
return wordlist.init();
|
||||
})
|
||||
|
||||
Setting.init({id: '1', moderation: 'pre'})
|
||||
]);
|
||||
|
||||
+11
-3
@@ -13,8 +13,7 @@ const ActionSchema = new Schema({
|
||||
item_type: String,
|
||||
item_id: String,
|
||||
user_id: String,
|
||||
field: String, // Used when an action references a particular field of an object. (e.g. a flag on a username or bio)
|
||||
detail: String, // Describes the reason for an action (e.g. 'Username is offensive')
|
||||
metadata: Object, //Holds arbitrary metadata about the action.
|
||||
}, {
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
@@ -39,8 +38,17 @@ ActionSchema.statics.findById = function(id) {
|
||||
*/
|
||||
ActionSchema.statics.insertUserAction = (action) => {
|
||||
|
||||
// Actions are made unique by using a query that can be reproducable, i.e.,
|
||||
// not containing user inputable values.
|
||||
let query = {
|
||||
action_type: action.action_type,
|
||||
item_type: action.item_type,
|
||||
item_id: action.item_id,
|
||||
user_id: action.user_id
|
||||
};
|
||||
|
||||
// Create/Update the action.
|
||||
return Action.findOneAndUpdate(action, action, {
|
||||
return Action.findOneAndUpdate(query, action, {
|
||||
|
||||
// Ensure that if it's new, we return the new object created.
|
||||
new: true,
|
||||
|
||||
+9
-5
@@ -25,10 +25,6 @@ const AssetSchema = new Schema({
|
||||
type: Date,
|
||||
default: null
|
||||
},
|
||||
settings: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: null
|
||||
},
|
||||
closedAt: {
|
||||
type: Date,
|
||||
default: null
|
||||
@@ -44,7 +40,15 @@ const AssetSchema = new Schema({
|
||||
subsection: String,
|
||||
author: String,
|
||||
publication_date: Date,
|
||||
modified_date: Date
|
||||
modified_date: Date,
|
||||
|
||||
// This object is used exclusivly for storing settings that are to override
|
||||
// the base settings from the base Settings object. This is to be accessed
|
||||
// always after running `rectifySettings` against it.
|
||||
settings: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: null
|
||||
},
|
||||
}, {
|
||||
versionKey: false,
|
||||
timestamps: {
|
||||
|
||||
+2
-3
@@ -287,13 +287,12 @@ CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.u
|
||||
* @param {String} action the new action to the comment
|
||||
* @return {Promise}
|
||||
*/
|
||||
CommentSchema.statics.addAction = (item_id, user_id, action_type, field, detail) => Action.insertUserAction({
|
||||
CommentSchema.statics.addAction = (item_id, user_id, action_type, metadata) => Action.insertUserAction({
|
||||
item_id,
|
||||
item_type: 'comments',
|
||||
user_id,
|
||||
action_type,
|
||||
field,
|
||||
detail
|
||||
metadata
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+8
-1
@@ -3,6 +3,13 @@ const Schema = mongoose.Schema;
|
||||
const _ = require('lodash');
|
||||
const cache = require('../services/cache');
|
||||
|
||||
const WordlistSchema = new Schema({
|
||||
banned: [String],
|
||||
suspect: [String]
|
||||
}, {
|
||||
_id: false
|
||||
});
|
||||
|
||||
/**
|
||||
* SettingSchema manages application settings that get used on front and backend.
|
||||
* @type {Schema}
|
||||
@@ -38,7 +45,7 @@ const SettingSchema = new Schema({
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
wordlist: [String],
|
||||
wordlist: WordlistSchema,
|
||||
charCount: {
|
||||
type: Number,
|
||||
default: 5000
|
||||
|
||||
+2
-3
@@ -614,11 +614,10 @@ UserService.addBio = (id, bio) => (
|
||||
* @param {String} action the new action to the user
|
||||
* @return {Promise}
|
||||
*/
|
||||
UserService.addAction = (item_id, user_id, action_type, field, detail) => Action.insertUserAction({
|
||||
UserService.addAction = (item_id, user_id, action_type, metadata) => Action.insertUserAction({
|
||||
item_id,
|
||||
item_type: 'users',
|
||||
user_id,
|
||||
action_type,
|
||||
field,
|
||||
detail
|
||||
metadata
|
||||
});
|
||||
|
||||
@@ -130,12 +130,14 @@
|
||||
"react": "15.3.2",
|
||||
"react-addons-test-utils": "15.3.2",
|
||||
"react-dom": "15.3.2",
|
||||
"react-highlight-words": "^0.6.0",
|
||||
"react-linkify": "^0.1.3",
|
||||
"react-mdl": "^1.7.2",
|
||||
"react-mdl-selectfield": "^0.2.0",
|
||||
"react-onclickoutside": "^5.7.1",
|
||||
"react-redux": "^4.4.5",
|
||||
"react-router": "^3.0.0",
|
||||
"react-tagsinput": "^3.14.0",
|
||||
"redux": "^3.6.0",
|
||||
"redux-mock-store": "^1.2.1",
|
||||
"redux-thunk": "^2.1.0",
|
||||
|
||||
@@ -20,18 +20,47 @@ router.get('/', (req, res, next) => {
|
||||
skip = 0,
|
||||
sort = 'asc',
|
||||
field = 'created_at',
|
||||
filter = 'all',
|
||||
search = ''
|
||||
} = req.query;
|
||||
|
||||
const FilterOpenAssets = (query, filter) => {
|
||||
switch(filter) {
|
||||
case 'open':
|
||||
return query.merge({
|
||||
$or: [
|
||||
{
|
||||
closedAt: null
|
||||
},
|
||||
{
|
||||
closedAt: {
|
||||
$gt: Date.now()
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
case 'closed':
|
||||
return query.merge({
|
||||
closedAt: {
|
||||
$lt: Date.now()
|
||||
}
|
||||
});
|
||||
default:
|
||||
return query;
|
||||
}
|
||||
};
|
||||
|
||||
// Find all the assets.
|
||||
Promise.all([
|
||||
Asset
|
||||
.search(search)
|
||||
|
||||
// Find the actuall assets.
|
||||
FilterOpenAssets(Asset.search(search), filter)
|
||||
.sort({[field]: (sort === 'asc') ? 1 : -1})
|
||||
.skip(skip)
|
||||
.limit(limit),
|
||||
Asset
|
||||
.search(search)
|
||||
.skip(parseInt(skip))
|
||||
.limit(parseInt(limit)),
|
||||
|
||||
// Get the count of actual assets.
|
||||
FilterOpenAssets(Asset.search(search), filter)
|
||||
.count()
|
||||
])
|
||||
.then(([result, count]) => {
|
||||
@@ -115,7 +144,6 @@ router.put('/:asset_id/status', (req, res, next) => {
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
res.status(204).json();
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -104,7 +104,7 @@ router.post('/', parseForm, csrfProtection, wordlist.filter('body'), (req, res,
|
||||
// premod, set it to `premod`.
|
||||
let status;
|
||||
|
||||
if (req.wordlist.matched) {
|
||||
if (req.wordlist.banned) {
|
||||
status = Promise.resolve('rejected');
|
||||
} else {
|
||||
status = Asset
|
||||
@@ -142,6 +142,15 @@ router.post('/', parseForm, csrfProtection, wordlist.filter('body'), (req, res,
|
||||
status,
|
||||
author_id: req.user.id
|
||||
}))
|
||||
.then((comment) => {
|
||||
if (req.wordlist.suspect) {
|
||||
return Comment
|
||||
.addAction(comment.id, null, 'flag', 'body', 'Matched suspect word filters.')
|
||||
.then(() => comment);
|
||||
}
|
||||
|
||||
return comment;
|
||||
})
|
||||
.then((comment) => {
|
||||
|
||||
// The comment was created! Send back the created comment.
|
||||
@@ -198,12 +207,11 @@ router.post('/:comment_id/actions', parseForm, csrfProtection, (req, res, next)
|
||||
|
||||
const {
|
||||
action_type,
|
||||
field,
|
||||
detail
|
||||
metadata
|
||||
} = req.body;
|
||||
|
||||
Comment
|
||||
.addAction(req.params.comment_id, req.user.id, action_type, field, detail)
|
||||
.addAction(req.params.comment_id, req.user.id, action_type, metadata)
|
||||
.then((action) => {
|
||||
res.status(201).json(action);
|
||||
})
|
||||
|
||||
@@ -170,12 +170,11 @@ router.put('/:user_id/bio', (req, res, next) => {
|
||||
router.post('/:user_id/actions', parseForm, csrfProtection, authorization.needed(), (req, res, next) => {
|
||||
const {
|
||||
action_type,
|
||||
field,
|
||||
detail
|
||||
metadata
|
||||
} = req.body;
|
||||
|
||||
User
|
||||
.addAction(req.params.user_id, req.user.id, action_type, field, detail)
|
||||
.addAction(req.params.user_id, req.user.id, action_type, metadata)
|
||||
.then((action) => {
|
||||
res.status(201).json(action);
|
||||
})
|
||||
|
||||
+171
-132
@@ -8,157 +8,196 @@ const Setting = require('../models/setting');
|
||||
* The root wordlist object.
|
||||
* @type {Object}
|
||||
*/
|
||||
const wordlist = {
|
||||
list: [],
|
||||
enabled: false
|
||||
};
|
||||
class Wordlist {
|
||||
|
||||
/**
|
||||
* Loads wordlists in from the naughty-words package based on languages
|
||||
* selected.
|
||||
* @param {Array} languages language codes to add to the wordlist
|
||||
*/
|
||||
wordlist.init = () => {
|
||||
return Setting
|
||||
.retrieve()
|
||||
.then((settings) => {
|
||||
constructor() {
|
||||
this.lists = {
|
||||
banned: [],
|
||||
suspect: []
|
||||
};
|
||||
}
|
||||
|
||||
// Insert the settings wordlist.
|
||||
wordlist.insert(settings.wordlist);
|
||||
/**
|
||||
* Loads wordlists in from the database
|
||||
*/
|
||||
load() {
|
||||
return Setting
|
||||
.retrieve()
|
||||
.then((settings) => {
|
||||
|
||||
// Insert the settings wordlist.
|
||||
this.upsert(settings.wordlist);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the wordlist data
|
||||
* @param {Array} list list of words to be set to the wordlist
|
||||
*/
|
||||
upsert(lists) {
|
||||
|
||||
// Add the words to this array, but also lowercase the words so that an
|
||||
// easy comparison can take place.
|
||||
['banned', 'suspect'].forEach((k) => {
|
||||
if (!(k in lists)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lists[k] = Wordlist.parseList(lists[k]);
|
||||
|
||||
debug(`Added ${lists[k].length} words to the ${k} wordlist.`);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Inserts the wordlist data and enables the wordlist.
|
||||
* @param {Array} list list of words to be added to the wordlist
|
||||
*/
|
||||
wordlist.insert = (list) => {
|
||||
return Promise.resolve(this);
|
||||
}
|
||||
|
||||
// Add the words to this array, but also lowercase the words so that an
|
||||
// easy comparison can take place.
|
||||
wordlist.list = _.uniq(wordlist.list.concat(list.map((word) => {
|
||||
return tokenizer.tokenize(word.toLowerCase());
|
||||
})));
|
||||
/**
|
||||
* Parses the list content.
|
||||
* @param {Array} list array of words to parse for a list.
|
||||
* @return {Array} the parsed list
|
||||
*/
|
||||
static parseList(list) {
|
||||
return _.uniq(list.map((word) => tokenizer.tokenize(word.toLowerCase())));
|
||||
}
|
||||
|
||||
debug(`Added ${list.length} words to the wordlist, now the wordlist is ${wordlist.list.length} entries long.`);
|
||||
/**
|
||||
* Tests the phrase to see if it contains any of the defined blockwords.
|
||||
* @param {String} phrase value to check for blockwords.
|
||||
* @return {Boolean} true if a blockword is found, false otherwise.
|
||||
*/
|
||||
match(list, phrase) {
|
||||
|
||||
// Enable the wordlist.
|
||||
wordlist.enabled = true;
|
||||
// Lowercase the word to ensure that we don't miss a match due to
|
||||
// capitalization.
|
||||
let lowerPhraseWords = tokenizer.tokenize(phrase.toLowerCase());
|
||||
|
||||
return Promise.resolve(wordlist);
|
||||
};
|
||||
// This will return true in the event that at least one blockword is found
|
||||
// in the phrase.
|
||||
return list.some((blockphrase) => {
|
||||
|
||||
/**
|
||||
* Tests the phrase to see if it contains any of the defined blockwords.
|
||||
* @param {String} phrase value to check for blockwords.
|
||||
* @return {Boolean} true if a blockword is found, false otherwise.
|
||||
*/
|
||||
wordlist.match = (phrase) => {
|
||||
// First, let's see if we can find the first word in the blockphrase in the
|
||||
// source phrase.
|
||||
let idx = lowerPhraseWords.indexOf(blockphrase[0]);
|
||||
|
||||
// Lowercase the word to ensure that we don't miss a match due to
|
||||
// capitalization.
|
||||
let lowerPhraseWords = tokenizer.tokenize(phrase.toLowerCase());
|
||||
if (idx === -1) {
|
||||
|
||||
// This will return true in the event that at least one blockword is found
|
||||
// in the phrase.
|
||||
return wordlist.list.some((blockphrase) => {
|
||||
|
||||
// First, let's see if we can find the first word in the blockphrase in the
|
||||
// source phrase.
|
||||
let idx = lowerPhraseWords.indexOf(blockphrase[0]);
|
||||
|
||||
if (idx === -1) {
|
||||
|
||||
// The first blockword in the blockphrase did not match the source phrase
|
||||
// anywhere.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Here we'll quick respond with true in the event that the blockphrase was
|
||||
// just a single word.
|
||||
if (blockphrase.length === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// We found the first word in the source phrase! Lets ensure it matches the
|
||||
// rest of the blockphrase...
|
||||
|
||||
// Check to see if it even has the length to support this word!
|
||||
if (lowerPhraseWords.length < idx + blockphrase.length - 1) {
|
||||
|
||||
// We couldn't possibly have the entire phrase here because we don't have
|
||||
// enough entries!
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 1; i < blockphrase.length; i++) {
|
||||
|
||||
// Check to see if the next word also matches!
|
||||
if (lowerPhraseWords[idx + i] !== blockphrase[i]) {
|
||||
// The first blockword in the blockphrase did not match the source phrase
|
||||
// anywhere.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Here we'll quick respond with true in the event that the blockphrase was
|
||||
// just a single word.
|
||||
if (blockphrase.length === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// We found the first word in the source phrase! Lets ensure it matches the
|
||||
// rest of the blockphrase...
|
||||
|
||||
// Check to see if it even has the length to support this word!
|
||||
if (lowerPhraseWords.length < idx + blockphrase.length - 1) {
|
||||
|
||||
// We couldn't possibly have the entire phrase here because we don't have
|
||||
// enough entries!
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 1; i < blockphrase.length; i++) {
|
||||
|
||||
// Check to see if the next word also matches!
|
||||
if (lowerPhraseWords[idx + i] !== blockphrase[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// We've walked over all the words of the blockphrase, and haven't had a
|
||||
// mismatch... It does contain the whole word!
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the filtering based on the loaded wordlists.
|
||||
*/
|
||||
filter(body, ...fields) {
|
||||
|
||||
// Start with the sensible default that the content does not contain
|
||||
// profanity.
|
||||
let errors = {};
|
||||
|
||||
// Loop over all the fields from the body that we want to check.
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
let field = fields[i];
|
||||
|
||||
let phrase = _.get(body, field, false);
|
||||
|
||||
// If the field doesn't exist in the body, then it can't be profane!
|
||||
if (!phrase) {
|
||||
|
||||
// Return that there wasn't a profane word here.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the field contains a banned word.
|
||||
if (this.match(this.lists.banned, phrase)) {
|
||||
debug(`the field "${field}" contained a phrase "${phrase}" which contained a banned word/phrase`);
|
||||
|
||||
errors.banned = ErrContainsProfanity;
|
||||
|
||||
// Stop looping through the fields now, we discovered the worst possible
|
||||
// situation (a banned word).
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if the field contains a banned word.
|
||||
if (this.match(this.lists.suspect, phrase)) {
|
||||
debug(`the field "${field}" contained a phrase "${phrase}" which contained a suspected word/phrase`);
|
||||
|
||||
errors.suspect = ErrContainsProfanity;
|
||||
|
||||
// Continue looping through the fields now, we discovered a possible bad
|
||||
// word (suspect).
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// We've walked over all the words of the blockphrase, and haven't had a
|
||||
// mismatch... It does contain the whole word!
|
||||
return true;
|
||||
});
|
||||
};
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect middleware for scanning request bodies for wordlisted words and
|
||||
* attaching a ErrContainsProfanity to the req.wordlisted parameter, otherwise
|
||||
* it will just set that parameter to false.
|
||||
* @param {Array} fields selectors for the body to extract the fields to be
|
||||
* tested
|
||||
* @return {Function} the Connect middleware
|
||||
*/
|
||||
static filter(...fields) {
|
||||
return (req, res, next) => {
|
||||
|
||||
// Create a new instance of the Wordlist.
|
||||
const wl = new Wordlist();
|
||||
|
||||
wl
|
||||
.load()
|
||||
.then(() => {
|
||||
|
||||
// Perform a filtering operation using the new instance of the
|
||||
// Wordlist.
|
||||
req.wordlist = wl.filter(req.body, ...fields);
|
||||
|
||||
// Call the next piece of middleware.
|
||||
next();
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ErrContainsProfanity is returned in the event that the middleware detects
|
||||
// profanity/wordlisted words in the payload.
|
||||
const ErrContainsProfanity = new Error('contains profanity');
|
||||
ErrContainsProfanity.status = 400;
|
||||
|
||||
/**
|
||||
* Connect middleware for scanning request bodies for wordlisted words and
|
||||
* attaching a ErrContainsProfanity to the req.wordlisted parameter, otherwise
|
||||
* it will just set that parameter to false.
|
||||
* @param {Array} fields selectors for the body to extract the fields to be
|
||||
* tested
|
||||
* @return {Function} the Connect middleware
|
||||
*/
|
||||
wordlist.filter = (...fields) => (req, res, next) => {
|
||||
|
||||
// Start with the sensible default that the content does not contain
|
||||
// profanity.
|
||||
req.wordlist = {
|
||||
matched: false
|
||||
};
|
||||
|
||||
// If the wordlist isn't enabled, then don't actually perform checking and
|
||||
// forward the request!
|
||||
if (!wordlist.enabled) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Loop over all the fields from the body that we want to check.
|
||||
const containsProfanity = fields.some((field) => {
|
||||
let phrase = _.get(req.body, field, false);
|
||||
|
||||
// If the field doesn't exist in the body, then it can't be profane!
|
||||
if (!phrase) {
|
||||
|
||||
// Return that there wasn't a profane word here.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the field contains a profane word.
|
||||
if (wordlist.match(phrase)) {
|
||||
debug(`the field "${field}" contained a phrase "${phrase}" which contained a wordlisted word/phrase`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// The body could contain some profanity, address that here.
|
||||
if (containsProfanity) {
|
||||
req.wordlist.matched = ErrContainsProfanity;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = wordlist;
|
||||
module.exports = Wordlist;
|
||||
module.exports.ErrContainsProfanity = ErrContainsProfanity;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'react';
|
||||
import 'redux';
|
||||
import {expect} from 'chai';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import * as actions from '../../../../client/coral-admin/src/actions/assets';
|
||||
import {Map} from 'immutable';
|
||||
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
const mockStore = configureStore();
|
||||
|
||||
describe('Asset actions', () => {
|
||||
let store;
|
||||
|
||||
const assets = [
|
||||
{
|
||||
url: 'http://test.com',
|
||||
id: '123',
|
||||
status: 'closed'
|
||||
},
|
||||
{
|
||||
url: 'http://test.org',
|
||||
id: '456',
|
||||
status: 'open'
|
||||
}
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore(new Map({}));
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
describe('FETCH_ASSETS_REQUEST', () => {
|
||||
|
||||
it('should fetch a list of assets', () => {
|
||||
|
||||
fetchMock.get('*', JSON.stringify({
|
||||
result: assets,
|
||||
count: 2
|
||||
}));
|
||||
|
||||
return actions.fetchAssets(2, 20)(store.dispatch)
|
||||
.then(() => {
|
||||
expect(store.getActions()[0]).to.have.property('type', 'FETCH_ASSETS_REQUEST');
|
||||
expect(store.getActions()[1]).to.have.property('type', 'FETCH_ASSETS_SUCCESS');
|
||||
expect(store.getActions()[1]).to.have.property('count', 2);
|
||||
expect(store.getActions()[1]).to.have.property('assets').
|
||||
and.to.deep.equal(assets);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error appropriatly', () => {
|
||||
|
||||
fetchMock.get('*', 404);
|
||||
|
||||
return actions.fetchAssets(2, 20)(store.dispatch)
|
||||
.then(() => {
|
||||
expect(store.getActions()[0]).to.have.property('type', 'FETCH_ASSETS_REQUEST');
|
||||
expect(store.getActions()[1]).to.have.property('type', 'FETCH_ASSETS_FAILURE');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPDATE_ASSET_STATE_REQUEST', () => {
|
||||
|
||||
it('should update an asset', () => {
|
||||
|
||||
fetchMock.put('*', JSON.stringify(assets[0]));
|
||||
|
||||
return actions.updateAssetState('123', 'status', 'open')(store.dispatch)
|
||||
.then(() => {
|
||||
expect(store.getActions()[0]).to.have.property('type', 'UPDATE_ASSET_STATE_REQUEST');
|
||||
expect(store.getActions()[1]).to.have.property('type', 'UPDATE_ASSET_STATE_SUCCESS');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it('should return an error appropriately', () => {
|
||||
|
||||
fetchMock.put('*', 404);
|
||||
|
||||
return actions.updateAssetState('123', 'status', 'open')(store.dispatch)
|
||||
.then(() => {
|
||||
expect(store.getActions()[0]).to.have.property('type', 'UPDATE_ASSET_STATE_REQUEST');
|
||||
expect(store.getActions()[1]).to.have.property('type', 'UPDATE_ASSET_STATE_FAILURE');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import {Map, fromJS} from 'immutable';
|
||||
import {expect} from 'chai';
|
||||
import assetsReducer from '../../../../client/coral-admin/src/reducers/assets';
|
||||
|
||||
describe ('assetsReducer', () => {
|
||||
describe('FETCH_ASSETS_SUCCESS', () => {
|
||||
it('should replace the existing assets', () => {
|
||||
const action = {
|
||||
type: 'FETCH_ASSETS_SUCCESS',
|
||||
count: 200,
|
||||
assets: [
|
||||
{
|
||||
id: '123',
|
||||
url: 'http://test.com',
|
||||
closedAt: 'tomorrow'
|
||||
},
|
||||
{
|
||||
id: '456',
|
||||
url: 'http://test2.com',
|
||||
closedAt: 'thursday'
|
||||
},
|
||||
]
|
||||
};
|
||||
const store = new Map({});
|
||||
const result = assetsReducer(store, action);
|
||||
expect(result.getIn(['byId', '123']).toJS()).to.deep.equal({
|
||||
url: 'http://test.com',
|
||||
closedAt: 'tomorrow',
|
||||
id: '123'
|
||||
});
|
||||
expect(result.getIn(['ids']).toJS()).to.deep.equal([
|
||||
'123',
|
||||
'456'
|
||||
]);
|
||||
expect(result.getIn(['count'])).to.equal(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPDATE_ASSET_STATE_REQUEST', () => {
|
||||
it('should update the state of a particular asset', () => {
|
||||
const action = {
|
||||
type: 'UPDATE_ASSET_STATE_REQUEST',
|
||||
id: '123',
|
||||
closedAt: null
|
||||
};
|
||||
const store = new fromJS({
|
||||
byId: {
|
||||
'123': {
|
||||
id: '123',
|
||||
url: 'http://test.com',
|
||||
closedAt: Date.now()
|
||||
},
|
||||
'456': {
|
||||
id: '456',
|
||||
url: 'http://test2.com',
|
||||
closedAt: 'thursday'
|
||||
}
|
||||
}
|
||||
});
|
||||
const result = assetsReducer(store, action);
|
||||
expect(result.getIn(['byId', '123', 'closedAt'])).to.equal.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,12 +18,13 @@ describe('/api/v1/assets', () => {
|
||||
url: 'https://coralproject.net/news/asset1',
|
||||
title: 'Asset 1',
|
||||
description: 'term1',
|
||||
id: '1'
|
||||
closedAt: Date.now()
|
||||
},
|
||||
{
|
||||
url: 'https://coralproject.net/news/asset2',
|
||||
title: 'Asset 2',
|
||||
description: 'term2'
|
||||
description: 'term2',
|
||||
closedAt: null
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -81,6 +82,38 @@ describe('/api/v1/assets', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return only closed assets', () => {
|
||||
return chai.request(app)
|
||||
.get('/api/v1/assets?filter=closed')
|
||||
.set(passport.inject({roles: ['admin']}))
|
||||
.then((res) => {
|
||||
const body = res.body;
|
||||
|
||||
expect(body).to.have.property('count', 1);
|
||||
expect(body).to.have.property('result');
|
||||
|
||||
const assets = body.result;
|
||||
|
||||
expect(assets[0]).to.have.property('title', 'Asset 1');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return only opened assets', () => {
|
||||
return chai.request(app)
|
||||
.get('/api/v1/assets?filter=open')
|
||||
.set(passport.inject({roles: ['admin']}))
|
||||
.then((res) => {
|
||||
const body = res.body;
|
||||
|
||||
expect(body).to.have.property('count', 1);
|
||||
expect(body).to.have.property('result');
|
||||
|
||||
const assets = body.result;
|
||||
|
||||
expect(assets[0]).to.have.property('title', 'Asset 2');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('#put', () => {
|
||||
|
||||
@@ -10,22 +10,18 @@ const agent = chai.request.agent(app);
|
||||
chai.should();
|
||||
chai.use(require('chai-http'));
|
||||
|
||||
const wordlist = require('../../../../services/wordlist');
|
||||
const Comment = require('../../../../models/comment');
|
||||
const Asset = require('../../../../models/asset');
|
||||
const Action = require('../../../../models/action');
|
||||
const User = require('../../../../models/user');
|
||||
|
||||
const Setting = require('../../../../models/setting');
|
||||
const settings = {id: '1', moderation: 'pre'};
|
||||
const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
|
||||
|
||||
describe('/api/v1/comments', () => {
|
||||
|
||||
// Ensure that the settings are always available.
|
||||
beforeEach(() => Promise.all([
|
||||
wordlist.insert(['bad words']),
|
||||
Setting.init(settings)
|
||||
]));
|
||||
beforeEach(() => Setting.init(settings));
|
||||
|
||||
describe('#get', () => {
|
||||
const comments = [{
|
||||
@@ -171,12 +167,22 @@ describe('/api/v1/comments', () => {
|
||||
describe('#post', () => {
|
||||
|
||||
let asset_id;
|
||||
let postmod_asset_id;
|
||||
|
||||
beforeEach(() => Asset.findOrCreateByUrl('https://coralproject.net/section/article-is-the-best').then((asset) => {
|
||||
beforeEach(() => Promise.all([
|
||||
Asset.findOrCreateByUrl('https://coralproject.net/section/article-is-the-best').then((asset) => {
|
||||
|
||||
// Update the asset id.
|
||||
asset_id = asset.id;
|
||||
}));
|
||||
// Update the asset id.
|
||||
asset_id = asset.id;
|
||||
}),
|
||||
Asset.findOrCreateByUrl('https://coralproject.net/section/postmod-article-is-the-best').then((asset) => {
|
||||
|
||||
// Update the asset id.
|
||||
postmod_asset_id = asset.id;
|
||||
|
||||
return Asset.overrideSettings(postmod_asset_id, {moderation: 'post'});
|
||||
}),
|
||||
]));
|
||||
|
||||
it('should create a comment', () => {
|
||||
agent
|
||||
@@ -211,6 +217,37 @@ describe('/api/v1/comments', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a comment with no status and a flag if it contains a suspected word', () => {
|
||||
agent
|
||||
.get('/api/v1/auth')
|
||||
.then((resa) => {
|
||||
expect(resa.status).to.be.equal(200);
|
||||
expect(resa.body).to.have.property('csrfToken');
|
||||
return agent.post('/api/v1/comments')
|
||||
.set(passport.inject({roles: []}))
|
||||
.send({'body': 'suspect words are the most suspicious', 'author_id': '123', 'asset_id': postmod_asset_id, 'parent_id': ''})
|
||||
.then((res) => {
|
||||
expect(res).to.have.status(201);
|
||||
expect(res.body).to.have.property('id');
|
||||
expect(res.body).to.have.property('status', null);
|
||||
|
||||
return Promise.all([
|
||||
res.body,
|
||||
Action.findByType('flag', 'comments')
|
||||
]);
|
||||
})
|
||||
.then(([comment, actions]) => {
|
||||
expect(actions).to.have.length(1);
|
||||
|
||||
let action = actions[0];
|
||||
|
||||
expect(action).to.have.property('item_id', comment.id);
|
||||
expect(action).to.have.property('field', 'body');
|
||||
expect(action).to.have.property('detail', 'Matched suspect word filters.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a comment with a premod status if it\'s asset is has pre-moderation enabled', () => {
|
||||
return Asset
|
||||
.findOrCreateByUrl('https://coralproject.net/article1')
|
||||
@@ -478,7 +515,8 @@ describe('/api/v1/comments/:comment_id/actions', () => {
|
||||
expect(res).to.have.status(201);
|
||||
expect(res).to.have.body;
|
||||
expect(res.body).to.have.property('action_type', 'flag');
|
||||
expect(res.body).to.have.property('detail', 'Comment is too awesome.');
|
||||
expect(res.body).to.have.property('metadata')
|
||||
.and.to.deep.equal({'reason': 'Comment is too awesome.'});
|
||||
expect(res.body).to.have.property('item_id', 'abc');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,9 +42,11 @@ describe('/api/v1/users/:user_id/actions', () => {
|
||||
expect(res).to.have.status(201);
|
||||
expect(res).to.have.body;
|
||||
expect(res.body).to.have.property('action_type', 'flag');
|
||||
expect(res.body).to.have.property('detail', 'Bio is too awesome.');
|
||||
expect(res.body).to.have.property('metadata')
|
||||
.and.to.deep.equal({'reason': 'Bio is too awesome.'});
|
||||
expect(res.body).to.have.property('item_id', 'abc');
|
||||
});
|
||||
})
|
||||
.catch(err => console.error(err.message));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+45
-72
@@ -1,54 +1,58 @@
|
||||
const expect = require('chai').expect;
|
||||
|
||||
const wordlist = require('../../services/wordlist');
|
||||
const Wordlist = require('../../services/wordlist');
|
||||
|
||||
describe('wordlist: services', () => {
|
||||
|
||||
before(() => wordlist.insert([
|
||||
'BAD',
|
||||
'bad',
|
||||
'how to murder',
|
||||
'how to kill'
|
||||
]));
|
||||
const wordlists = {
|
||||
banned: [
|
||||
'cookies',
|
||||
'how to do bad things',
|
||||
'how to do really bad things'
|
||||
],
|
||||
suspect: [
|
||||
'do bad things'
|
||||
]
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
expect(wordlist.list).to.not.be.empty;
|
||||
expect(wordlist.enabled).to.be.true;
|
||||
});
|
||||
let wordlist = new Wordlist();
|
||||
|
||||
describe('#init', () => {
|
||||
|
||||
before(() => wordlist.upsert(wordlists));
|
||||
|
||||
it('has entries', () => {
|
||||
expect(wordlist.list).to.not.be.empty;
|
||||
expect(wordlist.enabled).to.be.true;
|
||||
expect(wordlist.lists.banned).to.not.be.empty;
|
||||
expect(wordlist.lists.suspect).to.not.be.empty;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('#match', () => {
|
||||
|
||||
const bannedList = Wordlist.parseList(wordlists.banned);
|
||||
|
||||
it('does match on a bad word', () => {
|
||||
[
|
||||
'how to kill',
|
||||
'what is bad',
|
||||
'bad',
|
||||
'BAD.',
|
||||
'how to murder',
|
||||
'How To mUrDer'
|
||||
'how to do really bad things',
|
||||
'what is cookies',
|
||||
'cookies',
|
||||
'COOKIES.',
|
||||
'how to do bad things',
|
||||
'How To do bad things!'
|
||||
].forEach((word) => {
|
||||
expect(wordlist.match(word)).to.be.true;
|
||||
expect(wordlist.match(bannedList, word)).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
it('does not match on a good word', () => {
|
||||
[
|
||||
'how to',
|
||||
'kill',
|
||||
'bads',
|
||||
'cookie',
|
||||
'how to be a great person?',
|
||||
'how to not kill?'
|
||||
'how to not do really bad things?'
|
||||
].forEach((word) => {
|
||||
expect(wordlist.match(word)).to.be.false;
|
||||
expect(wordlist.match(bannedList, word)).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,62 +60,31 @@ describe('wordlist: services', () => {
|
||||
|
||||
describe('#filter', () => {
|
||||
|
||||
it('matches on bodies containing bad words', (done) => {
|
||||
before(() => wordlist.upsert(wordlists));
|
||||
|
||||
let req = {
|
||||
body: {
|
||||
content: 'how to kill?'
|
||||
}
|
||||
};
|
||||
|
||||
wordlist.filter('content')(req, {}, (err) => {
|
||||
expect(err).to.be.undefined;
|
||||
expect(req).to.have.property('wordlist');
|
||||
expect(req.wordlist).to.have.property('matched');
|
||||
expect(req.wordlist.matched).to.be.equal(wordlist.ErrContainsProfanity);
|
||||
|
||||
done();
|
||||
});
|
||||
it('matches on bodies containing bad words', () => {
|
||||
let errors = wordlist.filter({
|
||||
content: 'how to do really bad things?'
|
||||
}, 'content');
|
||||
|
||||
expect(errors).to.have.property('banned', Wordlist.ErrContainsProfanity);
|
||||
});
|
||||
|
||||
it('does not match on bodies not containing bad words', (done) => {
|
||||
|
||||
let req = {
|
||||
body: {
|
||||
content: 'how to be a great person?'
|
||||
}
|
||||
};
|
||||
|
||||
wordlist.filter('content')(req, {}, (err) => {
|
||||
expect(err).to.be.undefined;
|
||||
expect(req).to.have.property('wordlist');
|
||||
expect(req.wordlist).to.have.property('matched');
|
||||
expect(req.wordlist.matched).to.be.false;
|
||||
|
||||
done();
|
||||
});
|
||||
it('does not match on bodies not containing bad words', () => {
|
||||
let errors = wordlist.filter({
|
||||
content: 'how to not do really bad things?'
|
||||
}, 'content');
|
||||
|
||||
expect(errors).to.not.have.property('banned');
|
||||
});
|
||||
|
||||
it('does not match on bodies not containing the bad word field', (done) => {
|
||||
|
||||
let req = {
|
||||
body: {
|
||||
author: 'how to kill?',
|
||||
content: 'how to be a great person?'
|
||||
}
|
||||
};
|
||||
|
||||
wordlist.filter('content')(req, {}, (err) => {
|
||||
expect(err).to.be.undefined;
|
||||
expect(req).to.have.property('wordlist');
|
||||
expect(req.wordlist).to.have.property('matched');
|
||||
expect(req.wordlist.matched).to.be.false;
|
||||
|
||||
done();
|
||||
});
|
||||
it('does not match on bodies not containing the bad word field', () => {
|
||||
let errors = wordlist.filter({
|
||||
author: 'how to do really bad things?',
|
||||
content: 'how to be a great person?'
|
||||
}, 'content');
|
||||
|
||||
expect(errors).to.not.have.property('banned');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -13,6 +13,57 @@
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
}
|
||||
/* putting this here until I can get webpack to behave */
|
||||
.react-tagsinput {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
overflow: hidden;
|
||||
padding-left: 5px;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.react-tagsinput--focused {
|
||||
border-color: rgb(142, 76, 65);
|
||||
}
|
||||
|
||||
.react-tagsinput-tag {
|
||||
background-color: rgb(255, 220, 214);
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgb(244, 126, 107);
|
||||
color: rgb(244, 126, 107);
|
||||
display: inline-block;
|
||||
font-family: sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 5px;
|
||||
margin-right: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.react-tagsinput-remove {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
color: rgb(101, 24, 23);
|
||||
}
|
||||
|
||||
.react-tagsinput-tag a::before {
|
||||
content: " ×";
|
||||
}
|
||||
|
||||
.react-tagsinput-input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #777;
|
||||
font-family: sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 6px;
|
||||
margin-top: 1px;
|
||||
outline: none;
|
||||
padding: 5px;
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user