Merge branch 'master' of github.com:coralproject/talk into moderate-bio

This commit is contained in:
David Jay
2017-01-03 14:29:05 -05:00
100 changed files with 2864 additions and 1037 deletions
+7 -8
View File
@@ -1,16 +1,15 @@
{
"sourceMaps": true,
"presets": [
"stage-0",
"es2015"
],
"plugins": [
["transform-decorators-legacy"],
["transform-react-jsx"],
["transform-object-assign"],
["transform-class-properties"],
["transform-async-to-generator"],
["transform-object-rest-spread"],
["transform-class-properties"]
"add-module-exports",
"transform-async-to-generator",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
"transform-object-rest-spread",
"transform-react-jsx"
]
}
-1
View File
@@ -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],
+52 -69
View File
@@ -1,95 +1,78 @@
# Contribution Guide
# Contributor's Guide
We're very excited that you're interested in contributing to Talk! There is much to do. Before you begin, please review this document to get a sense of the practices and philosophies that hold this project together.
Welcome! We are very excited that you are interested in contributing to Talk.
This document is a companion to help you approach contributing. If it does not do so, please [let us know how we can improve it](https://github.com/coralproject/talk/issues)!
## Doing the Work
## Product Roadmap
We are here to make it as seamless as possible to contribute to Talk. The following lists are meant to make it straightforward to perform the mechanics of working on the project so you can focus your energy toward writing and reviewing content.
You can view what the Coral Team is working on next here https://www.pivotaltracker.com/n/projects/1863625.
You can view product ideas and our longer term roadmap here https://trello.com/b/ILND751a/talk.
### Code Reviews
## Contribute to the documentation
Clear docs are a prerequisite for a successful open source project. We value non-code and code contributions equally.
One of the most valuable aspects of working in software. It is something that should challenge the reviewer and author alike. It is a way of focusing knowledge, experience and opinions for the benefit of the project and the participants.
We are looking for _documentarians_ to:
Code reviews are a collaboration to make _the work_ as good as it can be. Code reviews are not a good venue for providing direct instruction to _the author._ Focus on positive, incremental improvements that can be made on the work at hand.
* make clarity, grammar and completeness updates,
* create new / missing sections, and
* take the lead in making sections, or the over all structure better.
Please take your time when writing and reviewing code. Here are some fundamental questions to open up a reviewing headspace.
### But how?
**Is the code clear, efficient and a pleasure to read?**
* Our public docs site can be updated [here](https://github.com/coralproject/docs).
* [Let us know](https://github.com/coralproject/talk/wiki/Contact-Us) if you'd like permission to update our wiki.
* Update any of our .md docs files by following [this guide](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging).
* Our API docs need to be kept honest. [Update them here](https://github.com/coralproject/talk/blob/master/docs/swagger.yaml).
Somewhere at the intersection of good variable names, well laid out file structures, consistent formatting and appropriate comments lies beautiful code. Code is language spoken to at least two very distinct audiences, the computer that interprets it and the developer who encounters it. Both should be at the front of your mind when reviewing code.
## Integrate into your environment
Thinking like a computer, you could ask:
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
* Is the code using memory efficiently?
* Is data being moved around unnecessarily?
* Are multiple network requests being made where fewer would do?
* Is there excess processing happening in a synchronous flow that may disrupt user experience?
* Are there large libraries included for small gains?
Talk is designed to integrate into existing environments in a variety of ways:
Then, returning to your human roots... Is the code readable?
* [Auth integrations](https://github.com/coralproject/talk/wiki/Security#authentication-strategies)
* [Push assets into Talk](https://github.com/coralproject/talk/blob/master/routes/api/assets/index.js)
* Monitoring Hooks (coming in 2017)
* Can I understand what is happening here (and maybe even why) by simply opening up the file, starting at the top and reading downward?
* Do comments convey clear, full thoughts in a narrative language that provides background for the code choices?
* Are the files separated logically such that each one contains a clear concept of code?
If you're considering deploying Talk, [please let us know](https://github.com/coralproject/talk/wiki/Contact-Us)! We are quite literally doing this for you and want to help you succeed any way we can.
If you are writing custom integration code in your fork of Talk, please consider keeping it generic and filing a Pull Request to contribute it back to the project! See our [forking and merging guidelines](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging) for more info.
## Write some code
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
### Build a New Feature / Plugin
Talk is beginning life as a Commenting Platform, but is architected to support many varieties of community engagement.
Please [contact us](https://github.com/coralproject/talk/wiki/Contact-Us) early and often if you'd like to help. We would love to hear your ideas for features and plugins and help you find a way to productively engage the project.
To get an idea of where the Coral Team is going, see:
* our [product/design Trello board](https://trello.com/b/ILND751a/talk),
* our [current stories](https://www.pivotaltracker.com/n/projects/1863625), and
* our [issues](https://github.com/coralproject/talk/issues).
**Is the API documentation up to date? Are all client calls written against the docs?**
Examples:
We use [swagger](https://github.com/coralproject/talk/blob/master/swagger.yaml) to track our API documentation.
* If APIs are created or updated, is the swagger.yml file up to date? There's nothing more frustrating than trying to develop against docs that are out of date or wrong. We need to be meticulous here as it's the little differences that can cause the most frustration and tricky bugs.
* If client code calls APIs, are they written against the swagger.yml file? Are all return codes handled?
**Is there sufficient test coverage?**
Our tests folder is set up to mirror the code folders: [https://github.com/coralproject/talk/tree/master/tests](https://github.com/coralproject/talk/tree/master/tests)
* Can you a sense of the logic behind the code by reading the tests?
* Can you see both what should happen and what should _never, ever_ be allowed to happen?
* Are there future cases that are guarded against via the creation of unit tests (aka, making sure things are typed, specifically checking for all values that will be used, etc...)?
* [Add An Emoji Button to Comments](https://github.com/coralproject/talk/wiki/Add-An-Emoji-Button-to-Comments)
### Forking, Branching and Merging
### Work on the Core
Talk follows the _master as tip_ repo structure. `master` is the bleeding edge. It should be _as stable as possible_ but may suffer instabilities, generally during times that fundamental architectural elements are added.
There is always more work to be done to make an application more stable, scaleable and secure.
Releases are _tagged_ off the master branch.
If you see issues in the code or have ideas on how we may improve Talk, please consider:
Contributions to Talk follow this process. There are a lot of steps, but mechanically following these steps will standardize communication, help stop errors and let you focus on your contribution.
* At the outset of a piece of work, a branch or fork is made from master.
* The work is done in that fork.
* As soon as the work has taken shape, a PR is created for discussion. (If the PR is created for review before it's ready to merge, please make that clear in the description/title.)
* At least one other contributor to the project must review all code (see Code Reviews below.)
* If there are merge conflicts with master, merge master into the branch.
* Ensure that [circleci](https://circleci.com/) passes all tests for your branch. (If you have forked and do not have circleci set up, you and the reviewer should independently ensure that all the of Continuous Integration steps pass before merging.)
* If merge conflicts exist with `master`, merge `master` into your branch and re-run CI before merging into master.
* Merge to master, but _you're not quite done yet!_
* Deploy master to staging (or have a core member do so.)
* Ensure that all your changes are working on staging.
* Have your reviewer verify the same.
* ... aaaand the work is delivered!
* [contributing a fix](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging),
* [filing an issue](https://github.com/coralproject/talk/issues), or
* or otherwise [letting us know](https://github.com/coralproject/talk/wiki/Contact-Us).
## Continuous Integration
We use circleci to run our ci: [https://circleci.com/gh/coralproject/talk](https://circleci.com/gh/coralproject/talk)
Our pipeline will _test_, _lint_, and _build_ all pushes to the repo.
Any branch not passing CI will not be merged into master.
If you're working in a fork, please run each of the steps locally before submitting a PR.
## Coding Style
### API Design
When building APIs, we follow these principles:
* Follow [RESTful](https://en.wikipedia.org/wiki/Representational_state_transfer) principles for basic operations.
* Avoid routing yourself into a corner, for example, by putting a variable other than an object's id directly after an object.
* Put non-required, flexible variables into query params, required/identity based values in request params.
+95
View File
@@ -0,0 +1,95 @@
# Installing a dev environment
By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct.html).
## Requirements
### System
- Any flavor of Linux, OSX or Windows
- 1GB memory (minimum)
- 5GB storage (minimum)
### Software
* [Node](https://nodejs.org/es/download/package-manager) v7 or later
* Mongo v3.2 or later
* Redis v3.2 or later
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
## First time setup
### Installation
Navigate to a directory.
```
git clone https://github.com/coralproject/talk
cd talk
npm install
```
### Environmental Variables
Talk uses environmental variables for configuration. You can learn about them in the [README file](README.md).
## Workflows
### The server
Starting the server:
```
npm start
```
Browse to `http://localhost:3000` (or your custom port.)
### Building the front end
Our build process will build all front end components registered [here](https://github.com/coralproject/talk/blob/6052cac1d3494f8060325a88bb2ce03c88c2f94c/webpack.config.dev.js#L9-L15).
One time build:
```
npm build
```
Build, then rebuild when a file is updated (development build):
```
npm build-watch
```
### Testing
Run all tests once:
`
npm test
`
Run our end to end tests (will install Selenium and nightwatch):
`
npm run e2e
`
_Please ensure all tests are passing before submitting a PR!_
## Troubleshooting
##### Can't ping the redis server!
- Check that Redis Server is running.
- Check that TALK_REDIS_URL is set.
##### Authenticaiton doesn't work!
- Make sure Redis is the correct version.
+27 -32
View File
@@ -1,62 +1,57 @@
# Talk [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk)
A commenting platform from The Coral Project. [https://coralproject.net](https://coralproject.net)
A commenting platform from [The Coral Project](https://coralproject.net).
## Contributing to Talk
### Product Roadmap
You can view what the Coral Team is working on next here: https://www.pivotaltracker.com/n/projects/1863625
See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md)!
You can view product ideas and our longer term roadmap here: https://trello.com/b/ILND751a/talk
## Usage
### Local Dependencies
Node
### Installation
Mongo
To set up a development environment or build from source, see [INSTALL.md](https://github.com/coralproject/talk/blob/master/INSTALL.md).
### Getting Started
`npm install`
Run it once to install the dependencies.
To launch a Talk server of your own from your browser without any need to muck about in a terminal or think about engineering concepts, stay tuned. We will launch [our installer](https://github.com/coralproject/talk-install) shortly!!
`npm start`
Runs Talk.
### Configuration
The Talk application requires specific configuration options to be available
inside the environment in order to run, those variables are listed here:
- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to
- `TALK_MONGO_URL` (*required*) - the database connection string for the MongoDB database.
- `TALK_REDIS_URL` (*required*) - the database connection string for the Redis database.
- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to
secure cookies.
- `TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook
Login enabled app.
- `TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your
Facebook Login enabled app.
- `TALK_ROOT_URL` (*required*) - root url of the installed application externally
- `TALK_ROOT_URL` (*required*) - root url of the installed application externally
available in the format: `<scheme>://<host>` without the path.
- `TALK_SMTP_PROVIDER` (*required*) - SMTP provider name.
- `TALK_SMTP_EMAIL` (*required*) - the address to send emails from using the
SMTP provider.
- `TALK_SMTP_USERNAME` (*required*) - username of the SMTP provider you are using.
- `TALK_SMTP_PASSWORD` (*required*) - password for the SMTP provider you are using.
- `TALK_SMTP_HOST` (*required*) - SMTP host url with format `smtp.domain.com`.
- `TALK_SMTP_PORT` (*required*) - SMTP port.
### Running with Docker
Make sure you have Docker running first and then run `docker-compose up -d`
### Testing
`npm test`
### Install from Source
### Lint
`npm run lint`
### Helpful URLs
Comment stream: http://localhost:3000/
Comment stream embedded on sample article: http://localhost:3000/assets/samplearticle.html
Moderator view: http://localhost:3000/admin
### Docs
`swagger.yaml`
If you want to run Talk in development mode from source (without docker) you can read the [INSTALL file](INSTALL.md).
### License
**Apache-2.0**
Copyright 2016 Mozilla Foundation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "The Coral Project: Talk",
"env": {
"TALK_SESSION_SECRET": {
"description": "The session secret",
"generator": "secret"
},
"TALK_FACEBOOK_APP_ID": {
"value": "",
"required": true
},
"TALK_FACEBOOK_APP_SECRET": {
"value": "",
"required": true
},
"NODE_ENV": "production",
"TALK_SMTP_PORT": "2525",
"REWRITE_ENV": "TALK_PORT:PORT,TALK_MONGO_URL:MONGO_URI,TALK_REDIS_URL:REDIS_URL,TALK_SMTP_HOST:POSTMARK_SMTP_SERVER,TALK_SMTP_USERNAME:POSTMARK_API_TOKEN,TALK_SMTP_PASSWORD:POSTMARK_API_TOKEN",
"NPM_CONFIG_PRODUCTION": "false"
},
"addons": [{
"plan": "mongolab:sandbox",
"as": "MONGO"
}, {
"plan": "rediscloud:30",
"as": "REDIS"
}, {
"plan": "postmark:10k",
"as": "POSTMARK"
}],
"image": "heroku/nodejs",
"success_url": "/admin/setup"
}
+4 -5
View File
@@ -1,10 +1,9 @@
#!/usr/bin/env node
/**
* Setup the debug paramater.
*/
process.env.DEBUG = process.env.TALK_DEBUG;
// Perform rewrites to the runtime environment variables based on the contents
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
// entrypoint for the entire application.
require('env-rewrite').rewrite();
/**
* Module dependencies.
-6
View File
@@ -1,11 +1,5 @@
#!/usr/bin/env node
/**
* Setup the debug paramater.
*/
process.env.DEBUG = process.env.TALK_DEBUG;
/**
* Module dependencies.
*/
-6
View File
@@ -1,11 +1,5 @@
#!/usr/bin/env node
/**
* Setup the debug paramater.
*/
process.env.DEBUG = process.env.TALK_DEBUG;
/**
* Module dependencies.
*/
-6
View File
@@ -1,11 +1,5 @@
#!/usr/bin/env node
/**
* Setup the debug paramater.
*/
process.env.DEBUG = process.env.TALK_DEBUG;
const app = require('../app');
const debug = require('debug')('talk:server');
const http = require('http');
-6
View File
@@ -1,11 +1,5 @@
#!/usr/bin/env node
/**
* Setup the debug paramater.
*/
process.env.DEBUG = process.env.TALK_DEBUG;
/**
* Module dependencies.
*/
-6
View File
@@ -1,11 +1,5 @@
#!/usr/bin/env node
/**
* Setup the debug paramater.
*/
process.env.DEBUG = process.env.TALK_DEBUG;
/**
* Module dependencies.
*/
+4 -2
View File
@@ -1,18 +1,20 @@
import React from 'react';
import {Router, Route, IndexRoute, browserHistory} from 'react-router';
import ModerationQueue from 'containers/ModerationQueue/ModerationQueue';
import ModerationContainer from 'containers/ModerationQueue/ModerationContainer';
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';
const routes = (
<Route path='/admin' component={LayoutContainer}>
<IndexRoute component={ModerationQueue} />
<IndexRoute component={ModerationContainer} />
<Route path='embed' component={CommentStream} />
<Route path='community' component={CommunityContainer} />
<Route path='configure' component={Configure} />
<Route path='streams' component={Streams} />
</Route>
);
+36
View File
@@ -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}));
};
+55 -14
View File
@@ -1,30 +1,71 @@
import coralApi from '../../../coral-framework/helpers/response';
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: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
return Promise.all([
coralApi('/queue/comments/pending'),
coralApi('/comments?status=rejected'),
coralApi('/comments?action_type=flag')
])
.then(([pending, rejected, flagged]) => {
/* Combine seperate calls into a single object */
flagged.comments.forEach(comment => comment.flagged = true);
return {
comments: [...pending.comments, ...rejected.comments, ...flagged.comments],
users: [...pending.users, ...rejected.users, ...flagged.users],
actions: [...pending.actions, ...rejected.actions, ...flagged.actions]
};
})
.then(({comments, users, actions}) => {
/* Post comments and users to redux store. Actions will be posted when they are needed. */
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});
});
};
};
// Create a new comment
export const createComment = (name, body) => {
return dispatch => {
const comment = {body, name};
return coralApi('/comments', {method: 'POST', comment})
.then(res => dispatch({type: commentTypes.COMMENT_CREATE_SUCCESS, comment: res}))
.catch(error => dispatch({type: commentTypes.COMMENT_CREATE_FAILED, error}));
};
};
/**
* Action disptacher related to comments
*/
export const updateStatus = (status, id) => (dispatch, getState) => {
dispatch({type: 'COMMENT_STATUS_UPDATE', id, status});
dispatch({type: 'COMMENT_UPDATE', comment: getState().comments.get('byId').get(id)});
// 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: commentTypes.COMMENT_STATUS_UPDATE_REQUEST, id: comment.id, status});
return coralApi(`/comments/${comment.id}/status`, {method: 'PUT', body: {status}})
.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: 'COMMENT_FLAG', id});
dispatch({type: commentTypes.COMMENT_FLAG, id});
dispatch({type: 'COMMENT_UPDATE', comment: getState().comments.get('byId').get(id)});
};
export const createComment = (name, body) => dispatch => {
dispatch({type: 'COMMENT_CREATE', name, body});
};
// Dialog Actions
export const showBanUserDialog = (userId, userName, commentId) => {
return dispatch => {
dispatch({type: 'SHOW_BANUSER_DIALOG', userId, userName, commentId});
};
return {type: commentTypes.SHOW_BANUSER_DIALOG, userId, userName, commentId};
};
export const hideBanUserDialog = (showDialog) => {
return dispatch => {
dispatch({type: '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 -7
View File
@@ -1,14 +1,15 @@
import coralApi from '../../../coral-framework/helpers/response';
import * as actions from '../constants/user';
/**
* Action disptacher related to users
*/
//
// export const banUser = (status, author_id) => (dispatch) => {
// dispatch({type: 'USER_STATUS_UPDATE', author_id, status});
// };
export const banUser = (status, userId, commentId) => {
// change status of a user
export const userStatusUpdate = (status, userId, commentId) => {
return dispatch => {
dispatch({type: 'USER_BAN', status, userId, commentId});
dispatch({type: 'MODERATION_QUEUE_FETCH'});
dispatch({type: actions.UPDATE_STATUS_REQUEST});
return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
.then(res => dispatch({type: actions.UPDATE_STATUS_SUCCESS, res}))
.catch(error => dispatch({type: actions.UPDATE_STATUS_FAILURE, error}));
};
};
+13 -8
View File
@@ -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();
@@ -30,8 +31,8 @@ export default props => {
<div>
{links ?
<span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
<div className={styles.actions}>
{props.actions.map((action, i) => getActionButton(action, i, props))}
<div className={`actions ${styles.actions}`}>
{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>
@@ -63,21 +66,23 @@ const getActionButton = (action, i, props) => {
if (action === 'ban') {
return (
<Button
disabled={banned ? 'disabled' : ''}
className='ban'
cStyle='black'
disabled={banned ? 'disabled' : ''}
onClick={() => props.onClickShowBanDialog(author.id, author.displayName, comment.id)}
key={i} >
key={i}
>
{lang.t('comment.ban_user')}
</Button>
);
}
return (
<FabButton
className={styles.actionButton}
icon={props.actionsMap[action].icon}
className={`${action} ${styles.actionButton}`}
cStyle={action}
icon={props.actionsMap[action].icon}
key={i}
onClick={() => props.onClickAction(props.actionsMap[action].status, comment.id)}
onClick={() => props.onClickAction(props.actionsMap[action].status, comment)}
/>
);
};
@@ -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,21 @@ 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,
// list of actions (flags, etc) associated with the comments
modActions: PropTypes.arrayOf(PropTypes.string).isRequired,
loading: PropTypes.bool,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired
}
constructor (props) {
super(props);
@@ -44,22 +58,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,22 +136,27 @@ 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 (
<ul className={`${styles.list} ${singleView ? styles.singleView : ''}`} {...key}>
<ul
className={`${styles.list} ${singleView ? styles.singleView : ''}`} {...key}
id='commentList'
>
{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';
+10 -1
View File
@@ -1,3 +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_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_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';
+3
View File
@@ -0,0 +1,3 @@
export const UPDATE_STATUS_REQUEST = 'UPDATE_STATUS_REQUEST';
export const UPDATE_STATUS_SUCCESS = 'UPDATE_STATUS_SUCCESS';
export const UPDATE_STATUS_FAILURE = 'UPDATE_STATUS_FAILURE';
@@ -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,109 +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;
@@ -0,0 +1,99 @@
import React from 'react';
import {connect} from 'react-redux';
import key from 'keymaster';
import {
updateStatus,
showBanUserDialog,
hideBanUserDialog,
fetchModerationQueueComments
} from 'actions/comments';
import {userStatusUpdate} from 'actions/users';
import ModerationQueue from './ModerationQueue';
class ModerationContainer extends React.Component {
constructor(props) {
super(props);
this.state = {
activeTab: 'pending',
singleView: false,
modalOpen: false
};
this.onClose = this.onClose.bind(this);
this.onTabClick = this.onTabClick.bind(this);
}
componentWillMount() {
this.props.fetchModerationQueueComments();
key('s', () => this.setState({singleView: !this.state.singleView}));
key('shift+/', () => this.setState({modalOpen: true}));
key('esc', () => this.setState({modalOpen: false}));
}
componentWillUnmount() {
key.unbind('s');
key.unbind('shift+/');
key.unbind('esc');
}
componentDidMount() {
// Hack for dynamic mdl tabs
if (typeof componentHandler !== 'undefined') {
// FIXME: fix this hack
componentHandler.upgradeAllRegistered(); // eslint-disable-line no-undef
}
}
onTabClick(activeTab) {
this.setState({activeTab});
}
onClose() {
this.setState({modalOpen: false});
}
render () {
const {comments} = this.props;
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'premod');
const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'rejected');
const flaggedIds = comments.ids.filter(id => comments.byId[id].flagged === true);
return (
<ModerationQueue
onTabClick={this.onTabClick}
onClose={this.onClose}
premodIds={premodIds}
rejectedIds={rejectedIds}
flaggedIds={flaggedIds}
{...this.props}
{...this.state}
/>
);
}
}
const mapStateToProps = state => ({
comments: state.comments.toJS(),
settings: state.settings.toJS(),
users: state.users.toJS()
});
const mapDispatchToProps = dispatch => {
return {
fetchModerationQueueComments: () => dispatch(fetchModerationQueueComments()),
showBanUserDialog: (userId, userName, commentId) => dispatch(showBanUserDialog(userId, userName, commentId)),
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
banUser: (userId, commentId) => dispatch(userStatusUpdate('banned', userId, commentId)).then(() => {
dispatch(fetchModerationQueueComments());
}),
updateStatus: (action, comment) => dispatch(updateStatus(action, comment))
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ModerationContainer);
@@ -1,166 +1,71 @@
import React from 'react';
import {connect} from 'react-redux';
import {Icon} from 'react-mdl';
import key from 'keymaster';
import styles from './ModerationQueue.css';
import ModerationKeysModal from 'components/ModerationKeysModal';
import CommentList from 'components/CommentList';
import BanUserDialog from 'components/BanUserDialog';
import {updateStatus, showBanUserDialog, hideBanUserDialog} from 'actions/comments';
import {banUser} from 'actions/users';
import styles from './ModerationQueue.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
/*
* Renders the moderation queue as a tabbed layout with 3 moderation
* queues :
* * pending: filtered by status Untouched
* * rejected: filtered by status Rejected
* * flagged: with a flagged action on them
*/
class ModerationQueue extends React.Component {
constructor (props) {
super(props);
this.state = {activeTab: 'pending', singleView: false, modalOpen: false};
}
// Fetch comments and bind singleView key before render
componentWillMount () {
this.props.dispatch({type: 'MODERATION_QUEUE_FETCH'});
key('s', () => this.setState({singleView: !this.state.singleView}));
key('shift+/', () => this.setState({modalOpen: true}));
key('esc', () => this.setState({modalOpen: false}));
}
// Unbind singleView key before unmount
componentWillUnmount () {
key.unbind('s');
key.unbind('shift+/');
key.unbind('esc');
}
// Hack for dynamic mdl tabs
componentDidMount () {
if (typeof componentHandler !== 'undefined') {
// FIXME: fix this hack
componentHandler.upgradeAllRegistered(); // eslint-disable-line no-undef
}
}
// Dispatch the update status action
onCommentAction (action, id) {
// If not banning then change the status to approved or flagged as action = status
this.props.dispatch(updateStatus(action, id));
}
showBanUserDialog (userId, userName, commentId) {
this.props.dispatch(showBanUserDialog(userId, userName, commentId));
}
hideBanUserDialog () {
this.props.dispatch(hideBanUserDialog(false));
}
banUser (userId, commentId) {
this.props.dispatch(banUser('banned', userId, commentId));
}
showShortcuts = () => {
this.setState({modalOpen: true});
}
onTabClick (activeTab) {
this.setState({activeTab});
}
// Render the tabbed lists moderation queues
render () {
const {comments, users} = this.props;
const {activeTab, singleView, modalOpen} = this.state;
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'premod');
const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'rejected');
const flaggedIds = comments.ids.filter(id => comments.byId[id].flagged === true);
return (
<div>
<div className='mdl-tabs mdl-js-tabs mdl-js-ripple-effect'>
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<a href='#pending' onClick={() => this.onTabClick('pending')}
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.pending')}</a>
<a href='#rejected' onClick={() => this.onTabClick('rejected')}
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.rejected')}</a>
<a href='#flagged' onClick={() => this.onTabClick('flagged')}
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.flagged')}</a>
<a href='#shortcuts' onClick={this.showShortcuts}
className={`mdl-tabs__tab ${styles.tab} ${styles.showShortcuts}`}>
<Icon name='keyboard' />
<span>{lang.t('modqueue.showshortcuts')}</span>
</a>
</div>
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
<CommentList
isActive={activeTab === 'pending'}
singleView={singleView}
commentIds={premodIds}
comments={comments.byId}
users={users.byId}
onClickAction={(action, commentId) => this.onCommentAction(action, commentId)}
onClickShowBanDialog={(userId, userName, commentId) => this.showBanUserDialog(userId, userName, commentId)}
actions={['reject', 'approve', 'ban']}
loading={comments.loading} />
<BanUserDialog
open={comments.showBanUserDialog}
handleClose={() => this.hideBanUserDialog()}
onClickBanUser={(userId, commentId) => this.banUser(userId, commentId)}
user={comments.banUser}/>
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
<CommentList
isActive={activeTab === 'rejected'}
singleView={singleView}
commentIds={rejectedIds}
comments={comments.byId}
users={users.byId}
onClickAction={(action, id) => this.onCommentAction(action, id)}
actions={['approve']}
loading={comments.loading} />
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
<CommentList
isActive={activeTab === 'flagged'}
singleView={singleView}
commentIds={flaggedIds}
comments={comments.byId}
users={users.byId}
onClickAction={(action, id) => this.onCommentAction(action, id)}
actions={['reject', 'approve']}
loading={comments.loading} />
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='shortcuts'>
<ModerationKeysModal open={modalOpen}
onClose={() => this.setState({modalOpen: false})} />
</div>
</div>
</div>
);
}
}
const mapStateToProps = state => ({
comments: state.comments.toJS(),
users: state.users.toJS()
});
export default connect(mapStateToProps)(ModerationQueue);
const lang = new I18n(translations);
export default ({onTabClick, ...props}) => (
<div>
<div className='mdl-tabs mdl-js-tabs mdl-js-ripple-effect'>
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<a href='#pending' onClick={() => onTabClick('pending')}
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.pending')}</a>
<a href='#rejected' onClick={() => onTabClick('rejected')}
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.rejected')}</a>
<a href='#flagged' onClick={() => onTabClick('flagged')}
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.flagged')}</a>
</div>
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
<CommentList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'pending'}
singleView={props.singleView}
commentIds={props.premodIds}
comments={props.comments.byId}
users={props.users.byId}
onClickAction={props.updateStatus}
onClickShowBanDialog={props.showBanUserDialog}
modActions={['reject', 'approve', 'ban']}
loading={props.comments.loading}/>
<BanUserDialog
open={props.comments.showBanUserDialog}
handleClose={props.hideBanUserDialog}
onClickBanUser={props.banUser}
user={props.comments.banUser}
/>
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
<CommentList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'rejected'}
singleView={props.singleView}
commentIds={props.rejectedIds}
comments={props.comments.byId}
users={props.users.byId}
onClickAction={props.updateStatus}
modActions={['approve']}
loading={props.comments.loading}
/>
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
<CommentList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'rejected'}
singleView={props.singleView}
commentIds={props.flaggedIds}
comments={props.comments.byId}
users={props.users.byId}
onClickAction={props.updateStatus}
modActions={['reject', 'approve']}
loading={props.comments.loading}/>
</div>
<ModerationKeysModal open={props.modalOpen} onClose={props.closeModal} />
</div>
</div>
);
@@ -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);
};
+24
View File
@@ -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()));
};
+10 -8
View File
@@ -1,4 +1,5 @@
import * as actions from '../constants/comments';
import * as userActions from '../constants/user';
import {Map, List, fromJS} from 'immutable';
/**
@@ -23,16 +24,17 @@ const initialState = Map({
// Handle the comment actions
export default (state = initialState, action) => {
switch (action.type) {
case 'MODERATION_QUEUE_FETCH': return state.set('loading', true);
case 'COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS': return replaceComments(action, state);
case 'COMMENTS_MODERATION_QUEUE_FAILED': return state.set('loading', false);
case 'COMMENT_STATUS_UPDATE': return updateStatus(state, action);
case 'COMMENT_FLAG': return flag(state, action);
case 'COMMENT_CREATE_SUCCESS': return addComment(state, action);
case 'COMMENT_STREAM_FETCH_SUCCESS': return replaceComments(action, state);
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_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_SUCESS: 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
View File
@@ -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
});
+14 -2
View File
@@ -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.list);
};
const saveComplete = (state, action) => {
const s = state.set('fetchingSettings', false).set('saveSettingsError', null);
const settings = s.get('settings').merge(action.settings);
+1 -2
View File
@@ -2,7 +2,6 @@
import {createStore, applyMiddleware} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from 'reducers';
import talkAdapter from 'services/talk-adapter';
/**
* Create the store by merging the app reducers with
@@ -14,5 +13,5 @@ import talkAdapter from 'services/talk-adapter';
export default createStore(
mainReducer,
window.devToolsExtension && window.devToolsExtension(),
applyMiddleware(thunk, talkAdapter)
applyMiddleware(thunk)
);
@@ -1,99 +0,0 @@
import coralApi from '../../../coral-framework/helpers/response';
/**
* The adapter is a redux middleware that interecepts the actions that need
* to interface with the backend, do the job and return the results.
* The idea is that if we expose the required actions to handle to devs, the
* moderation app can be platform agnostic. This same client could work not only
* for the coral but also for wordpress comments, disqus and many more.
*/
// Intercept redux actions and act over the ones we are interested
export default store => next => action => {
switch (action.type) {
case 'MODERATION_QUEUE_FETCH':
fetchModerationQueueComments(store);
break;
case 'COMMENT_UPDATE':
updateComment(store, action.comment);
break;
case 'COMMENT_CREATE':
createComment(store, action.name, action.body);
break;
case 'USER_BAN':
userStatusUpdate(store, action.status, action.userId, action.commentId);
break;
}
next(action);
};
// Get comments to fill each of the three lists on the mod queue
const fetchModerationQueueComments = store =>
Promise.all([
coralApi('/queue/comments/pending'),
coralApi('/queue/users/pending'),
coralApi('/comments?status=rejected'),
coralApi('/comments?action_type=flag')
])
.then(([pendingComments, pendingUsers, rejected, flagged]) => {
/* Combine seperate calls into a single object */
let all = {};
all.comments = pendingComments.comments
.concat(rejected.comments)
.concat(flagged.comments.map(comment => {
comment.flagged = true;
return comment;
}));
all.users = pendingComments.users
.concat(pendingUsers.users)
.concat(rejected.users)
.concat(flagged.users);
all.actions = pendingComments.actions
.concat(pendingUsers.actions)
.concat(rejected.actions)
.concat(flagged.actions);
return all;
})
.then(all => {
/* Post comments and users to redux store. Actions will be posted when they are needed. */
store.dispatch({type: 'USERS_MODERATION_QUEUE_FETCH_SUCCESS',
users: all.users});
store.dispatch({type: 'COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS',
comments: all.comments});
});
// .catch(error => store.dispatch({type: 'COMMENTS_MODERATION_QUEUE_FETCH_FAILED', error}));
// Update a comment. Now to update a comment we need to send back the whole object
const updateComment = (store, comment) => {
coralApi(`/comments/${comment.get('id')}/status`, {method: 'PUT', body: {status: comment.get('status')}})
.then(res => store.dispatch({type: 'COMMENT_UPDATE_SUCCESS', res}))
.catch(error => store.dispatch({type: 'COMMENT_UPDATE_FAILED', error}));
};
// Create a new comment
const createComment = (store, name, comment) => {
const body = {
status: 'Untouched',
body: comment,
name: name,
createdAt: Date.now()
};
return coralApi('/comments', {method: 'POST', body})
.then(res => store.dispatch({type: 'COMMENT_CREATE_SUCCESS', comment: res}))
.catch(error => store.dispatch({type: 'COMMENT_CREATE_FAILED', error}));
};
// Ban a user
const userStatusUpdate = (store, status, userId, commentId) => {
return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
.then(res => store.dispatch({type: 'USER_BAN_SUCESS', res}))
.catch(error => store.dispatch({type: 'USER_BAN_FAILED', error}));
};
+44 -3
View File
@@ -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": ""
}
}
}
+6 -5
View File
@@ -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;
}
+3 -1
View File
@@ -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
*
+1 -1
View File
@@ -18,7 +18,7 @@ const getPopupMenu = [
{val: 'other', text: lang.t('other')}
],
button: lang.t('continue'),
sets: 'detail'
sets: 'reason'
};
},
() => {
+22 -26
View File
@@ -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>
+3 -2
View File
@@ -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'
};
},
() => {
+4 -2
View File
@@ -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"
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
const name = 'coral-plugin-flags';
const name = 'coral-plugin-likes';
const LikeButton = ({like, id, postAction, deleteAction, addItem, showSignInDialog, updateItem, currentUser, banned}) => {
const liked = like && like.current_user;
@@ -10,7 +10,11 @@ import CommentHistory from 'coral-plugin-history/CommentHistory';
import SettingsHeader from '../components/SettingsHeader';
import RestrictedContent from 'coral-framework/components/RestrictedContent';
class SignInContainer extends Component {
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
const lang = new I18n(translations);
class SettingsContainer extends Component {
constructor (props) {
super(props);
this.state = {
@@ -59,7 +63,7 @@ class SignInContainer extends Component {
? <CommentHistory
comments={commentsMostRecentFirst}
assets={user.myAssets.map(id => items.assets[id])} />
: <p>Loading comment history...</p>
: <p>{lang.t('user-no-comment')}</p>
}
</TabContent>
<TabContent show={activeTab === 1}>
@@ -83,4 +87,4 @@ const mapDispatchToProps = dispatch => ({
export default connect(
mapStateToProps,
mapDispatchToProps
)(SignInContainer);
)(SettingsContainer);
+8
View File
@@ -0,0 +1,8 @@
{
"en":{
"user-no-comment": "This user has not yet left a comment."
},
"es":{
"user-no-comment": "Aún no ha escrito ningún comentario."
}
}
@@ -9,10 +9,11 @@ import ForgotContent from './ForgotContent';
const SignDialog = ({open, view, handleClose, offset, ...props}) => (
<Dialog
className={styles.dialog}
id="signInDialog"
open={open}
style={{
position: 'relative',
top: offset !== 0 && offset
top: offset !== 0 && offset
}}>
<span className={styles.close} onClick={handleClose}>×</span>
{view === 'SIGNIN' && <SignInContent {...props} />}
+3 -1
View File
@@ -9,7 +9,9 @@ const UserBox = ({className, user, logout, ...props}) => (
className={`${styles.userBox} ${className ? className : ''}`}
{...props}
>
{lang.t('signIn.loggedInAs')} <a>{user.displayName}</a>. {lang.t('signIn.notYou')} <a onClick={logout}>{lang.t('signIn.logout')}</a>
{lang.t('signIn.loggedInAs')}
<a>{user.displayName}</a>. {lang.t('signIn.notYou')}
<a onClick={logout} id='logout'>{lang.t('signIn.logout')}</a>
</div>
);
@@ -113,6 +113,7 @@ input.error{
font-weight: bold;
cursor: pointer;
margin: 0px;
margin-left: 4px;
padding-bottom: 2px;
border-bottom: solid 1px black;
}
+3 -1
View File
@@ -42,7 +42,9 @@ export default class Dialog extends Component {
componentWillUnmount() {
const dialog = this.dialog;
dialog.removeEventListener('cancel', this.props.onCancel);
if (dialog) {
dialog.removeEventListener('cancel', this.props.onCancel);
}
}
render() {
@@ -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
View File
@@ -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 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -616,13 +616,12 @@ 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
});
/**
+17 -8
View File
@@ -19,6 +19,7 @@ module.exports = {
},
'test_settings': {
'default': {
'launch_url' : 'http://localhost:3000',
'selenium_port': 6666,
'selenium_host': 'localhost',
'silent': true,
@@ -26,19 +27,27 @@ module.exports = {
'browserName': 'chrome',
'javascriptEnabled': true,
'acceptSslCerts': true,
'webStorageEnabled' : true,
'databaseEnabled' : true,
'applicationCacheEnabled' : false,
'nativeEvents' : true
'webStorageEnabled': true,
'databaseEnabled': true,
'applicationCacheEnabled': false,
'nativeEvents': true
},
'screenshots' : {
'enabled' : true,
'on_failure' : true,
'on_error' : true,
'path' : './tests/e2e/reports'
'enabled': true,
'on_failure': true,
'on_error': true,
'path': './tests/e2e/reports'
},
'exclude': [
'./tests/e2e/tests/EmbedStreamTests.js'
]
},
'integration': {
'launch_url': 'http://localhost:3000'
}
}
};
// "chromeOptions" : {
// "args" : ["start-fullscreen"]
// }
+17 -12
View File
@@ -5,15 +5,16 @@
"main": "app.js",
"scripts": {
"start": "./bin/cli serve --jobs",
"build": "NODE_ENV=production ./node_modules/.bin/webpack --config webpack.config.js --bail",
"build-watch": "NODE_ENV=development ./node_modules/.bin/webpack --config webpack.config.dev.js --watch",
"lint": "./node_modules/.bin/eslint bin/* .",
"lint-fix": "./node_modules/.bin/eslint bin/* . --fix",
"test": "NODE_ENV=test ./node_modules/.bin/mocha --compilers js:babel-core/register tests/helpers/*.js --require ignore-styles --recursive tests",
"test-watch": "NODE_ENV=test ./node_modules/.bin/mocha --compilers js:babel-core/register --recursive -w tests",
"pree2e": "NODE_ENV=test ./scripts/pree2e.sh",
"e2e": "NODE_ENV=test ./node_modules/.bin/nightwatch",
"embed-start": "NODE_ENV=development npm run build && ./bin/cli serve --jobs"
"build": "NODE_ENV=production webpack --config webpack.config.js --bail",
"build-watch": "NODE_ENV=development webpack --config webpack.config.dev.js --watch",
"lint": "eslint bin/* .",
"lint-fix": "eslint bin/* . --fix",
"test": "NODE_ENV=test mocha --compilers js:babel-core/register tests/helpers/*.js --require ignore-styles --recursive tests",
"test-watch": "NODE_ENV=test mocha --compilers js:babel-core/register --recursive -w tests",
"pree2e": "NODE_ENV=test scripts/pree2e.sh",
"e2e": "NODE_ENV=test nightwatch",
"embed-start": "NODE_ENV=development npm run build && ./bin/cli serve --jobs",
"postinstall": "npm run build"
},
"config": {
"pre-git": {
@@ -53,6 +54,7 @@
"connect-redis": "^3.1.0",
"debug": "^2.2.0",
"ejs": "^2.5.2",
"env-rewrite": "^1.0.2",
"express": "^4.14.0",
"express-session": "^1.14.2",
"helmet": "^3.1.0",
@@ -74,10 +76,11 @@
},
"devDependencies": {
"autoprefixer": "^6.5.2",
"babel-core": "^6.18.2",
"babel-core": "^6.21.0",
"babel-eslint": "^7.1.0",
"babel-jest": "^15.0.0",
"babel-loader": "^6.2.7",
"babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-transform-async-to-generator": "^6.16.0",
"babel-plugin-transform-class-properties": "^6.18.0",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
@@ -115,7 +118,7 @@
"material-design-lite": "^1.2.1",
"mocha": "^3.1.2",
"mocha-junit-reporter": "^1.12.1",
"nightwatch": "^0.9.9",
"nightwatch": "^0.9.11",
"node-fetch": "^1.6.3",
"postcss-loader": "^1.1.0",
"postcss-modules": "^0.5.2",
@@ -126,17 +129,19 @@
"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",
"regenerator": "^0.8.46",
"selenium-standalone": "^5.8.0",
"selenium-standalone": "latest",
"style-loader": "^0.13.1",
"supertest": "^2.0.1",
"timeago.js": "^2.0.3",
+35 -7
View File
@@ -12,18 +12,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]) => {
@@ -107,7 +136,6 @@ router.put('/:asset_id/status', (req, res, next) => {
}
})
.then(() => {
res.status(204).json();
})
.catch((err) => {
+12 -4
View File
@@ -96,7 +96,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => {
// premod, set it to `premod`.
let status;
if (req.wordlist.matched) {
if (req.wordlist.banned) {
status = Promise.resolve('rejected');
} else {
status = Asset
@@ -134,6 +134,15 @@ router.post('/', wordlist.filter('body'), (req, res, next) => {
status,
author_id: req.user.id
}))
.then((comment) => {
if (req.wordlist.suspect) {
return Comment
.addAction(comment.id, null, 'flag', {field: 'body', details: 'Matched suspect word filters.'})
.then(() => comment);
}
return comment;
})
.then((comment) => {
// The comment was created! Send back the created comment.
@@ -190,12 +199,11 @@ router.post('/:comment_id/actions', (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);
})
+3 -4
View File
@@ -119,7 +119,7 @@ router.post('/request-password-reset', (req, res, next) => {
const options = {
subject: 'Password Reset Requested - Talk',
from: 'noreply@coralproject.net',
from: process.env.TALK_SMTP_FROM_ADDRESS,
to: email,
html: resetEmailTemplate({
token,
@@ -162,12 +162,11 @@ router.put('/:user_id/bio', (req, res, next) => {
router.post('/:user_id/actions', 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);
})
+11 -3
View File
@@ -1,7 +1,15 @@
#!/bin/bash
# install selenium
../node_modules/selenium-standalone/bin/selenium-standalone install
selenium-standalone install
# start the app server
npm start &
# Creating Admin Test User
{ echo admin@test.com; echo test; echo test; echo Admin Test User; echo admin;} | dotenv ./bin/cli-users create
# Creating Moderator Test User
{ echo moderator@test.com; echo test; echo test; echo Moderator Test User; echo moderator;} | dotenv ./bin/cli-users create
# Creating Commenter Test User
{ echo commenter@test.com; echo test; echo test; echo Commenter Test User; echo ;} | dotenv ./bin/cli-users create
npm start
+1
View File
@@ -1,6 +1,7 @@
const nodemailer = require('nodemailer');
const smtpRequiredProps = [
'TALK_SMTP_FROM_ADDRESS',
'TALK_SMTP_USERNAME',
'TALK_SMTP_PASSWORD',
'TALK_SMTP_HOST'
+171 -132
View File
@@ -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;
});
});
});
+16 -2
View File
@@ -1,4 +1,18 @@
module.exports = {
waitForConditionTimeout: 20000,
baseUrl: 'localhost:3011/'
waitForConditionTimeout: 8000,
baseUrl: 'http://localhost:3000',
users: {
admin: {
email: 'admin@test.com',
pass: 'test'
},
moderator: {
email: 'moderator@test.com',
pass: 'test'
},
commenter: {
email: 'commenter@test.com',
pass: 'test'
}
},
};
+33
View File
@@ -0,0 +1,33 @@
const embedStreamCommands = {
url: function () {
return `${this.api.launchUrl}/admin`;
},
ready() {
return this
.waitForElementVisible('body', 2000);
},
approveComment() {
return this
.waitForElementVisible('@commentList')
.waitForElementVisible('@approveButton')
.click('@approveButton');
}
};
module.exports = {
commands: [embedStreamCommands],
elements: {
commentList: {
selector: '#commentList'
},
banButton: {
selector: '#commentList .actions:first-child .ban'
},
rejectButton: {
selector: '#commentList .actions:first-child .reject'
},
approveButton: {
selector: '#commentList .actions:first-child .approve'
}
}
};
-45
View File
@@ -1,45 +0,0 @@
const fetch = require('node-fetch');
const embedCommands = {
ready() {
return this.resizeWindow(1200, 800)
.url(client.globals.baseUrl)
.waitForElementVisible('body', 2000)
.frame('coralStreamIframe');
},
setConfig(config, baseUrl) {
return fetch(`${baseUrl}/api/v1/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(config)
});
},
enterComment() {
const comment = 'This is a test comment';
return this
.waitForElementVisible('@commentBox')
.setValue('@commentBox', comment)
.click('@postButton')
.waitForElementVisible('.comment', 1000);
},
validateComment(comment) {
return this
.assert.equal(comment, '.comment');
}
};
module.exports = {
commands: [embedCommands],
elements: {
commentBox: {
selector: '#commentBox'
},
postButton: {
selector: '#commentBox .coral-plugin-commentbox-button'
}
}
};
+160
View File
@@ -0,0 +1,160 @@
const embedStreamCommands = {
url: function () {
return this
.api.launchUrl;
},
ready() {
return this
.waitForElementVisible('body', 4000)
.waitForElementVisible('iframe#coralStreamIframe')
.api.frame('coralStreamIframe');
},
signUp(user) {
return this
.waitForElementVisible('@signInButton', 2000)
.click('@signInButton')
.waitForElementVisible('@signInDialog')
.waitForElementVisible('@registerButton')
.click('@registerButton')
.setValue('@signInDialogEmail', user.email)
.setValue('@signInDialogPassword', user.pass)
.setValue('@signUpDialogConfirmPassword', user.pass)
.setValue('@signUpDialogDisplayName', user.displayName)
.waitForElementVisible('@signUpButton')
.click('@signUpButton')
.waitForElementVisible('@logInButton')
.click('@logInButton')
.waitForElementVisible('@logoutButton', 5000);
},
login(user) {
return this
.waitForElementVisible('@signInButton', 2000)
.click('@signInButton')
.waitForElementVisible('@signInDialog')
.waitForElementVisible('@signInDialogEmail')
.waitForElementVisible('@signInDialogPassword')
.setValue('@signInDialogEmail', user.email)
.setValue('@signInDialogPassword', user.pass)
.waitForElementVisible('@logInButton')
.click('@logInButton')
.waitForElementVisible('@logoutButton', 5000);
},
logout() {
return this
.waitForElementVisible('@logoutButton')
.click('@logoutButton')
.waitForElementVisible('@signInButton', 2000);
},
postComment(comment = 'Test Comment') {
return this
.waitForElementVisible('@commentBox', 2000)
.setValue('@commentBox', comment)
.click('@postButton');
},
likeComment() {
return this
.waitForElementVisible('@likeButton')
.click('@likeButton');
},
flagComment() {
return this
.waitForElementVisible('@flagButton')
.click('@flagButton');
},
flagUsername() {
return this
.waitForElementVisible('@flagButton')
.click('@flagButton');
},
getPermalink(fn) {
return this
.waitForElementVisible('@permalinkButton')
.click('@permalinkButton')
.waitForElementVisible('@permalinkPopUp')
.getValue('@permalinkInput', result => fn(result.value));
}
};
module.exports = {
commands: [embedStreamCommands],
elements: {
signInButton: {
selector: '#coralSignInButton'
},
signInDialog:{
selector: '#signInDialog'
},
signInDialogEmail: {
selector: '#signInDialog #email'
},
signInDialogPassword: {
selector: '#signInDialog #password'
},
signUpDialogConfirmPassword: {
selector: '#signInDialog #confirmPassword'
},
signUpDialogDisplayName: {
selector: '#signInDialog #displayName'
},
logInButton: {
selector: '#coralLogInButton'
},
signUpButton: {
selector: '#coralSignUpButton'
},
logoutButton: {
selector: '.commentStream #logout'
},
commentBox: {
selector: '.coral-plugin-commentbox-textarea'
},
postButton: {
selector: '#commentBox .coral-plugin-commentbox-button'
},
likeButton: {
selector: '.comment .coral-plugin-likes-container .coral-plugin-likes-button'
},
likeText: {
selector: '.comment .coral-plugin-likes-container .coral-plugin-likes-button .coral-plugin-likes-button-text'
},
likesCount: {
selector: '.comment .coral-plugin-likes-container .coral-plugin-likes-button .coral-plugin-likes-like-count'
},
flagButton: {
selector: '.comment .coral-plugin-flags-container .coral-plugin-flags-button'
},
flagPopUp: {
selector: '.comment .coral-plugin-flags-popup'
},
flagCommentOption: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-radio-label[for="comments"]'
},
flagUsernameOption: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-radio-label[for="user"]'
},
flagOtherOption: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-radio-label[for="other"]'
},
flagHeaderMessage: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-header'
},
flagButtonText: {
selector: '.comment .coral-plugin-flags-button-text'
},
flagDoneButton: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-button'
},
permalinkButton: {
selector: '.comment .coral-plugin-permalinks-button'
},
permalinkPopUp: {
selector: '.comment .coral-plugin-permalinks-popover.active'
},
permalinkInput: {
selector: '.comment .coral-plugin-permalinks-popover.active input'
},
registerButton: {
selector: '#signInDialog #coralRegister'
}
}
};
@@ -6,8 +6,5 @@ module.exports = {
.url(baseUrl)
.assert.title('Coral Talk')
.waitForElementPresent('body', 1000);
},
after: client => {
client.end();
}
};
+44
View File
@@ -0,0 +1,44 @@
module.exports = {
'@tags': ['embedStream'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.navigate()
.ready();
},
'Login as commenter': client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.login(users.commenter);
},
'Add test comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.postComment('Test Comment');
},
'Logout': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.logout();
},
'Login as admin': client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.login(users.admin);
},
'Approve test comment': client => {
const adminPage = client.page.adminPage();
adminPage
.navigate()
.ready();
adminPage
.approveComment();
},
after: client => {
client.end();
}
};
+23
View File
@@ -0,0 +1,23 @@
module.exports = {
'@tags': ['login', 'admin'],
before(client) {
const embedStreamPage = client.page.embedStreamPage();
const {launchUrl} = client;
client
.url(launchUrl);
embedStreamPage
.ready();
},
'Admin logs in': client => {
const {users} = client.globals;
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.login(users.admin);
},
after: client => {
client.end();
}
};
@@ -0,0 +1,34 @@
module.exports = {
'@tags': ['flag', 'comments', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.navigate()
.ready();
embedStreamPage
.login(users.commenter);
},
'Commenter flags a comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.flagComment()
.waitForElementVisible('@flagPopUp')
.waitForElementVisible('@flagCommentOption')
.click('@flagCommentOption')
.waitForElementVisible('@flagDoneButton')
.click('@flagDoneButton')
.waitForElementVisible('@flagOtherOption')
.click('@flagOtherOption')
.waitForElementVisible('@flagDoneButton')
.click('@flagDoneButton')
.click('@flagDoneButton')
.expect.element('@flagButtonText').text.to.equal('Reported');
},
after: client => {
client.end();
}
};
@@ -0,0 +1,33 @@
module.exports = {
'@tags': ['flag', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.navigate()
.ready();
embedStreamPage
.login(users.commenter);
},
'Commenter flags a username': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.flagUsername()
.waitForElementVisible('@flagPopUp')
.waitForElementVisible('@flagUsernameOption')
.click('@flagUsernameOption')
.waitForElementVisible('@flagDoneButton')
.click('@flagDoneButton')
.waitForElementVisible('@flagOtherOption')
.click('@flagOtherOption')
.waitForElementVisible('@flagDoneButton')
.click('@flagDoneButton')
.click('@flagDoneButton');
},
after: client => {
client.end();
}
};
@@ -0,0 +1,26 @@
module.exports = {
'@tags': ['like', 'comments', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.navigate()
.ready();
embedStreamPage
.login(users.commenter);
},
'Commenter likes a comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.likeComment()
.waitForElementVisible('@likesCount', 2000)
.expect.element('@likeText').text.to.equal('Liked');
},
after: client => {
client.end();
}
};
+20
View File
@@ -0,0 +1,20 @@
module.exports = {
'@tags': ['login', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.navigate()
.ready();
},
'Commenter logs in': client => {
const {users} = client.globals;
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.login(users.commenter);
},
after: client => {
client.end();
}
};
@@ -0,0 +1,34 @@
let permalink = '';
module.exports = {
'@tags': ['permalink', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.navigate()
.ready();
embedStreamPage
.login(users.commenter);
},
'Commenter gets the permalink of a comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.getPermalink(value => {
permalink = value;
});
},
'Commenter navigates to the permalink': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.navigate(permalink);
client.assert.urlContains(permalink);
},
after: client => {
client.end();
}
};
+38
View File
@@ -0,0 +1,38 @@
module.exports = {
'@tags': ['write', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.navigate()
.ready();
embedStreamPage
.login(users.commenter);
},
'Commenter posts a comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.postComment('I read the comments');
},
after: client => {
const adminPage = client.page.adminPage();
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.logout()
.login(users.admin);
adminPage
.navigate()
.ready();
adminPage
.approveComment();
client.end();
}
};
+23
View File
@@ -0,0 +1,23 @@
module.exports = {
'@tags': ['login', 'moderator'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {launchUrl} = client;
client
.url(launchUrl);
embedStreamPage
.ready();
},
'Moderator logs in': client => {
const {users} = client.globals;
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.login(users.moderator);
},
after: client => {
client.end();
}
};
@@ -0,0 +1,20 @@
module.exports = {
'@tags': ['flag', 'comments', 'visitor'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.navigate()
.ready();
},
'Visitor tries to flag a comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.flagComment()
.waitForElementVisible('@signInDialog', 2000);
},
after: client => {
client.end();
}
};
@@ -0,0 +1,20 @@
module.exports = {
'@tags': ['like', 'comments', 'visitor'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.navigate()
.ready();
},
'Visitor tries to like a comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.likeComment()
.waitForElementVisible('@signInDialog', 2000);
},
after: client => {
client.end();
}
};
+24
View File
@@ -0,0 +1,24 @@
module.exports = {
'@tags': ['signup', 'visitor'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.navigate()
.ready();
},
'Visitor signs up': client => {
const embedStreamPage = client.page.embedStreamPage();
const hash = Math.floor(Math.random() * (999 - 0));
embedStreamPage
.signUp({
email: `visitor_${hash}@test.com`,
displayName: 'Visitor',
pass: 'testtest'
});
},
after: client => {
client.end();
}
};
+35 -2
View File
@@ -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', () => {
+46 -12
View File
@@ -8,22 +8,18 @@ const expect = chai.expect;
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 = [{
@@ -168,12 +164,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', () => {
return chai.request(app)
@@ -198,6 +204,33 @@ describe('/api/v1/comments', () => {
});
});
it('should create a comment with no status and a flag if it contains a suspected word', () => {
return chai.request(app)
.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('metadata');
expect(action.metadata).to.have.property('field', 'body');
expect(action.metadata).to.have.property('details', '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')
@@ -440,12 +473,13 @@ describe('/api/v1/comments/:comment_id/actions', () => {
return chai.request(app)
.post('/api/v1/comments/abc/actions')
.set(passport.inject({id: '456', roles: ['admin']}))
.send({'action_type': 'flag', 'detail': 'Comment is too awesome.'})
.send({'action_type': 'flag', 'metadata': {'reason': 'Comment is too awesome.'}})
.then((res) => {
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');
});
});
+5 -3
View File
@@ -31,14 +31,16 @@ describe('/api/v1/users/:user_id/actions', () => {
return chai.request(app)
.post('/api/v1/users/abc/actions')
.set(passport.inject({id: '456', roles: ['admin']}))
.send({'action_type': 'flag', 'detail': 'Bio is too awesome.'})
.send({'action_type': 'flag', 'metadata': {'reason': 'Bio is too awesome.'}})
.then((res) => {
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
View File
@@ -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');
});
});
+51
View File
@@ -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>
+1 -1
View File
@@ -36,7 +36,7 @@
<script type='text/javascript' src='<%= basePath %>/pym.v1.min.js'></script>
<script>
var ready = false;
var pymParent = new pym.Parent('coralStreamEmbed', '/embed/stream', {title: 'Talk Comments', id:'coralStreamIframe'});
var pymParent = new pym.Parent('coralStreamEmbed', '/embed/stream', {title: 'Talk Comments', id:'coralStreamIframe', name: 'coralStreamIframe'});
pymParent.onMessage('height', function(height) {document.querySelector('#coralStreamEmbed iframe').height = height + 'px'})
pymParent.onMessage('childReady', function () {
var interval = setInterval(function () {