Merge branch 'master' of github.com:coralproject/talk into load-more-comments

This commit is contained in:
David Jay
2017-02-10 14:34:19 -08:00
120 changed files with 2753 additions and 629 deletions
+7 -9
View File
@@ -1,9 +1,10 @@
# Contributor's Guide
Welcome! We are very excited that you are interested in contributing to Talk.
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)!
By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct.html).
## Product Roadmap
@@ -13,7 +14,7 @@ You can view product ideas and our longer term roadmap here https://trello.com/b
## Contribute to the documentation
Clear docs are a prerequisite for a successful open source project. We value non-code and code contributions equally.
Clear docs are a prerequisite for a successful open source project. We value non-code and code contributions equally.
We are looking for _documentarians_ to:
@@ -40,7 +41,7 @@ Talk is designed to integrate into existing environments in a variety of ways:
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.
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
@@ -48,9 +49,9 @@ First, [set up a dev environment](https://github.com/coralproject/talk/blob/mast
### Build a New Feature / Plugin
Talk is beginning life as a Commenting Platform, but is architected to support many varieties of community engagement.
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.
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:
@@ -66,13 +67,10 @@ Examples:
### Work on the Core
There is always more work to be done to make an application more stable, scaleable and secure.
There is always more work to be done to make an application more stable, scaleable and secure.
If you see issues in the code or have ideas on how we may improve Talk, please consider:
* [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).
+60 -61
View File
@@ -1,95 +1,94 @@
# Installing a dev environment
By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct.html).
# Installation
## Requirements
### System
- Any flavor of Linux, OSX or Windows
- Any flavour of Linux, OSX or Windows
- 1GB memory (minimum)
- 5GB storage (minimum)
### Software
## Installation From Source
* [Node](https://nodejs.org/es/download/package-manager) v7 or later
* Mongo v3.2 or later
* Redis v3.2 or later
### Requirements
There are some runtime requirements for running Talk from source:
- [Node](https://nodejs.org/) v7 or later
- [MongoDB](https://www.mongodb.com/) v3.2 or later
- [Redis](https://redis.io/) v3.2 or later
- [Yarn](https://yarnpkg.com/) v0.19.1 or later
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
## First time setup
### Installing
### Installation
```bash
# Download the tarball containing the repository
curl -L https://github.com/coralproject/talk/tarball/master -o coralproject-talk.tar.gz
Navigate to a directory.
# Untar that file and change to that directory
tar xpf coralproject-talk.tar.gz
mv coralproject-talk-* coralproject-talk
cd coralproject-talk
```
git clone https://github.com/coralproject/talk
cd talk
yarn install
```
# Install package dependancies
yarn
### 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:
```
yarn 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:
```
# Build static files
yarn build
```
Build, then rebuild when a file is updated (development build):
### Running
```
yarn build-watch
Refer to the `README.md` file for required configuration variables to add to the
environment.
You can start the server after configuring the server using the command:
```bash
yarn start
```
You can see other scripts we've made available by consulting the `package.json`
file under the `scripts` key including:
### Testing
- `yarn test` run unit tests
- `yarn e2e` run end to end tests
- `yarn build-watch` watch for changes to client files and build static assets
- `yarn dev-start` watch for changes to server files and reload the server
Run all tests once:
## Installation From Docker Hub
`
yarn test
`
### Requirements
Run our end to end tests (will install Selenium and nightwatch):
There are some runtime requirements for running Talk for Docker:
`
yarn e2e
`
- [MongoDB](https://www.mongodb.com/) v3.2 or later
- [Redis](https://redis.io/) v3.2 or later
- [Docker](https://www.docker.com/) v1.13.0 or later
- [Docker Compose](https://docs.docker.com/compose/) v1.10.0 or later
_Please ensure all tests are passing before submitting a PR!_
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
## Troubleshooting
### Installing
```bash
# Create a directory for talk
mkdir coralproject-talk
cd coralproject-talk
##### Can't ping the redis server!
# Download the docker-compose.yml file from the repository
curl -LO https://raw.githubusercontent.com/coralproject/talk/master/docker-compose.yml
```
- Check that Redis Server is running.
- Check that TALK_REDIS_URL is set.
##### Authenticaiton doesn't work!
- Make sure Redis is the correct version.
At this stage, you should refer to the `README.md` file for required
configuration variables to add to the environment key for the `talk` service
listed in the `docker-compose.yml` file.
### Running
```bash
# Start the services using compose
docker-compose up -d
```
+2 -1
View File
@@ -12,7 +12,7 @@ See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CO
To set up a development environment or build from source, see [INSTALL.md](https://github.com/coralproject/talk/blob/master/INSTALL.md).
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!!
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!
### Configuration
@@ -37,6 +37,7 @@ Facebook Login enabled app.
- `TALK_SMTP_PASSWORD` (*required for email*) - password for the SMTP provider you are using.
- `TALK_SMTP_HOST` (*required for email*) - SMTP host url with format `smtp.domain.com`.
- `TALK_SMTP_PORT` (*required for email*) - SMTP port.
- `TALK_INSTALL_LOCK` (_optional for dynamic setup_) - Defaults to `FALSE`. When `TRUE`, disables the dynamic setup endpoint.
Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for
alternative methods of loading configuration during development.
+5
View File
@@ -94,6 +94,11 @@ if (app.get('env') !== 'production') {
endpointURL: '/api/v1/graph/ql'
}));
// GraphQL documention.
app.get('/admin/docs', (req, res) => {
res.render('admin/docs');
});
}
//==============================================================================
+11 -15
View File
@@ -4,7 +4,7 @@
* Module dependencies.
*/
const util = require('../util');
// const util = require('./util');
const program = require('./commander');
program
@@ -15,18 +15,14 @@ program
.command('users', 'work with the application auth')
.parse(process.argv);
// If there is no command listed, output help.
if (!process.argv.slice(2).length) {
program.outputHelp();
return;
}
// The ensures that the child process that is created here is always cleaned up
// properly when the parent process dies.
util.onshutdown([
(signal) => {
if ((program.runningCommand.killed === false) && (program.runningCommand.exitCode === null)) {
program.runningCommand.kill(signal);
}
/**
* When this provess exists, check to see if we have a running command, if we do
* check to see if it is still running. If it is, then kill it with a SIGINT
* signal. This is for the use case where we want to kill the process that is
* labled with the PID written out by the parent process.
*/
process.once('exit', () => {
if ((program.runningCommand.killed === false) && (program.runningCommand.exitCode === null)) {
program.runningCommand.kill('SIGINT');
}
]);
});
+1 -1
View File
@@ -10,7 +10,7 @@ const Table = require('cli-table');
const AssetModel = require('../models/asset');
const mongoose = require('../services/mongoose');
const scraper = require('../services/scraper');
const util = require('../util');
const util = require('./util');
// Register the shutdown criteria.
util.onshutdown([
+1 -1
View File
@@ -7,7 +7,7 @@
const program = require('./commander');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const util = require('../util');
const util = require('./util');
const mongoose = require('../services/mongoose');
const kue = require('../services/kue');
+2 -3
View File
@@ -2,13 +2,12 @@
const app = require('../app');
const program = require('./commander');
const debug = require('debug')('talk:server');
const http = require('http');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const kue = require('../services/kue');
const mongoose = require('../services/mongoose');
const util = require('../util');
const util = require('./util');
/**
* Get port from environment and store in Express.
@@ -79,7 +78,7 @@ function onListening() {
let bind = typeof addr === 'string'
? `pipe ${ addr}`
: `port ${ addr.port}`;
debug(`Listening on ${ bind}`);
console.log(`Listening on ${ bind}`);
}
/**
+162 -54
View File
@@ -9,7 +9,10 @@ const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingModel = require('../models/setting');
const SettingsService = require('../services/settings');
const util = require('../util');
const SetupService = require('../services/setup');
const UsersService = require('../services/users');
const util = require('./util');
const errors = require('../errors');
// Register the shutdown criteria.
util.onshutdown([
@@ -29,60 +32,165 @@ program
// Setup the application
//==============================================================================
SettingsService
.init()
.then((settings) => {
if (program.defaults) {
return settings.save();
}
const performSetup = () => {
console.log('We\'ll ask you some questions in order to setup your installation of Talk.\n');
return inquirer.prompt([
{
type: 'input',
name: 'organizationName',
message: 'Organization Name',
default: settings.organizationName,
validate: (input) => {
if (input && input.length > 0) {
return true;
}
return 'Organization Name is required.';
}
},
{
type: 'list',
choices: SettingModel.MODERATION_OPTIONS,
name: 'moderation',
default: settings.moderation,
message: 'Select a moderation mode'
},
{
type: 'confirm',
name: 'requireEmailConfirmation',
default: settings.requireEmailConfirmation,
message: 'Should emails always be confirmed'
}
])
.then((answers) => {
// Update the settings that were changed.
Object.keys(answers).forEach((key) => {
if (answers[key] !== undefined) {
settings[key] = answers[key];
}
if (program.defaults) {
return SettingsService
.init()
.then(() => {
console.log('Settings created.');
console.log('\nTalk is now installed!');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
return settings.save();
// Get the current settings, we are expecing an error here.
return SettingsService
.retrieve()
.then(() => {
// We should NOT have gotten a settings object, this means that the
// application is already setup. Error out here.
throw errors.ErrSettingsInit;
})
.catch((err) => {
// If the error is `not init`, then we're good, otherwise, it's something
// else.
if (err !== errors.ErrSettingsNotInit) {
throw err;
}
})
.then(() => {
// Create the base settings model.
let settings = new SettingModel();
console.log('We\'ll ask you some questions in order to setup your installation of Talk.\n');
return inquirer.prompt([
{
type: 'input',
name: 'organizationName',
message: 'Organization Name',
default: settings.organizationName,
validate: (input) => {
if (input && input.length > 0) {
return true;
}
return 'Organization Name is required.';
}
},
{
type: 'list',
choices: SettingModel.MODERATION_OPTIONS,
name: 'moderation',
default: settings.moderation,
message: 'Select a moderation mode'
},
{
type: 'confirm',
name: 'requireEmailConfirmation',
default: settings.requireEmailConfirmation,
message: 'Should emails always be confirmed'
}
])
.then((answers) => {
// Update the settings that were changed.
Object.keys(answers).forEach((key) => {
if (answers[key] !== undefined) {
settings[key] = answers[key];
}
});
console.log('\nWe\'ll ask you some questions about your first admin user.\n');
return inquirer.prompt([
{
type: 'input',
name: 'displayName',
message: 'Display Name',
filter: (displayName) => {
return UsersService
.isValidDisplayName(displayName, false)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'email',
message: 'Email',
format: 'email',
validate: (value) => {
if (value && value.length >= 3) {
return true;
}
return 'Email is required';
}
},
{
name: 'password',
message: 'Password',
type: 'password',
filter: (password) => {
return UsersService
.isValidPassword(password)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
filter: (confirmPassword) => {
return UsersService
.isValidPassword(confirmPassword)
.catch((err) => {
throw err.message;
});
}
},
]);
})
.then((user) => {
if (user.password !== user.confirmPassword) {
return Promise.reject(new Error('Passwords do not match'));
}
return SetupService.setup({
settings: settings.toObject(),
user: {
email: user.email,
displayName: user.displayName,
password: user.password
}
});
});
})
.then(({user}) => {
console.log('Settings created.');
console.log(`User ${user.id} created.`);
console.log('\nTalk is now installed!');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
})
.then(() => {
console.log('Talk is now installed!');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
};
// Start tthe setup process.
performSetup();
+1 -1
View File
@@ -9,7 +9,7 @@ const inquirer = require('inquirer');
const UsersService = require('../services/users');
const UserModel = require('../models/user');
const mongoose = require('../services/mongoose');
const util = require('../util');
const util = require('./util');
const Table = require('cli-table');
const validateRequired = (msg = 'Field is required', len = 1) => (input) => {
+12 -2
View File
@@ -2,7 +2,6 @@ const pkg = require('../package.json');
const dotenv = require('dotenv');
const fs = require('fs');
const program = require('commander');
const util = require('../util');
// 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
@@ -27,6 +26,10 @@ const parseArgs = require('minimist')(process.argv.slice(2), {
}
});
/**
* If the config flag is present, then we have to load the configuration from
* the file specified. We will then load those values into the environment.
*/
if (parseArgs.config) {
let envConfig = dotenv.parse(fs.readFileSync(parseArgs.config, {encoding: 'utf8'}));
@@ -35,8 +38,15 @@ if (parseArgs.config) {
});
}
// If the `--pid` flag is used, put the current pid there.
/**
* If the pid flag is present, then we have to create a pid file at the location
* specified.
*/
if (parseArgs.pid) {
const util = require('./util');
console.log('Wrote PID');
util.pid(parseArgs.pid);
}
View File
+15 -11
View File
@@ -1,21 +1,25 @@
import React from 'react';
import {Router, Route, IndexRoute, browserHistory} from 'react-router';
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 Configure from 'containers/Configure/Configure';
import LayoutContainer from 'containers/LayoutContainer';
import CommentStream from 'containers/CommentStream/CommentStream';
import InstallContainer from 'containers/Install/InstallContainer';
import CommunityContainer from 'containers/Community/CommunityContainer';
import ModerationContainer from 'containers/ModerationQueue/ModerationContainer';
const routes = (
<Route path='/admin' component={LayoutContainer}>
<IndexRoute component={ModerationContainer} />
<Route path='embed' component={CommentStream} />
<Route path='community' component={CommunityContainer} />
<Route path='configure' component={Configure} />
<Route path='streams' component={Streams} />
</Route>
<div>
<Route exact path="/admin/install" component={InstallContainer}/>
<Route path='/admin' component={LayoutContainer}>
<IndexRoute component={ModerationContainer} />
<Route path='embed' component={CommentStream} />
<Route path='community' component={CommunityContainer} />
<Route path='configure' component={Configure} />
<Route path='streams' component={Streams} />
</Route>
</div>
);
const AppRouter = () => <Router history={browserHistory} routes={routes} />;
+109
View File
@@ -0,0 +1,109 @@
import coralApi from 'coral-framework/helpers/response';
import * as actions from '../constants/install';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
export const nextStep = () => ({type: actions.NEXT_STEP});
export const previousStep = () => ({type: actions.PREVIOUS_STEP});
export const goToStep = step => ({type: actions.GO_TO_STEP, step});
const installRequest = () => ({type: actions.INSTALL_REQUEST});
const installSuccess = () => ({type: actions.INSTALL_SUCCESS});
const installFailure = error => ({type: actions.INSTALL_FAILURE, error});
const addError = (name, error) => ({type: actions.ADD_ERROR, name, error});
const hasError = error => ({type: actions.HAS_ERROR, error});
const clearErrors = () => ({type: actions.CLEAR_ERRORS});
const validation = (formData, dispatch, next) => {
if (!(formData != null)) {
return dispatch(hasError());
}
// Required Validation
const empty = Object.keys(formData).filter(name => {
const cond = !formData[name].length;
if (cond) {
// Adding Error
dispatch(addError(name, 'This field is required.'));
} else {
dispatch(addError(name, ''));
}
return cond;
});
if (empty.length) {
return dispatch(hasError());
}
// RegExp Validation
const validation = Object.keys(formData).filter(name => {
const cond = !validate[name](formData[name]);
if (cond) {
// Adding Error
dispatch(addError(name, errorMsj[name]));
} else {
dispatch(addError(name, ''));
}
return cond;
});
if (validation.length) {
return dispatch(hasError());
}
dispatch(clearErrors());
next();
};
export const submitSettings = () => (dispatch, getState) => {
const settingsFormData = getState().install.toJS().data.settings;
validation(settingsFormData, dispatch, function() {
dispatch(nextStep());
});
};
export const submitUser = () => (dispatch, getState) => {
const userFormData = getState().install.toJS().data.user;
validation(userFormData, dispatch, function() {
const data = getState().install.toJS().data;
dispatch(installRequest());
coralApi('/setup', {method: 'POST', body: data})
.then(result => {
console.log(result);
dispatch(installSuccess());
dispatch(nextStep());
})
.catch(error => {
console.error(error);
dispatch(installFailure(`${error.message}`));
});
});
};
export const updateSettingsFormData = (name, value) => ({type: actions.UPDATE_FORMDATA_SETTINGS, name, value});
export const updateUserFormData = (name, value) => ({type: actions.UPDATE_FORMDATA_USER, name, value});
const checkInstallRequest = () => ({type: actions.CHECK_INSTALL_REQUEST});
const checkInstallSuccess = installed => ({type: actions.CHECK_INSTALL_SUCCESS, installed});
const checkInstallFailure = error => ({type: actions.CHECK_INSTALL_FAILURE, error});
export const checkInstall = next => dispatch => {
dispatch(checkInstallRequest());
coralApi('/setup')
.then(({installed}) => {
dispatch(checkInstallSuccess(installed));
if (installed) {
next();
}
})
.catch(error => {
console.error(error);
dispatch(checkInstallFailure(`${error.message}`));
});
};
@@ -11,6 +11,7 @@ export const SAVE_SETTINGS_SUCCESS = 'SAVE_SETTINGS_SUCCESS';
export const SAVE_SETTINGS_FAILED = 'SAVE_SETTINGS_FAILED';
export const WORDLIST_UPDATED = 'WORDLIST_UPDATED';
export const DOMAINLIST_UPDATED = 'DOMAINLIST_UPDATED';
export const fetchSettings = () => dispatch => {
dispatch({type: SETTINGS_LOADING});
@@ -33,6 +34,10 @@ export const updateWordlist = (listName, list) => {
return {type: WORDLIST_UPDATED, listName, list};
};
export const updateDomainlist = (listName, list) => {
return {type: DOMAINLIST_UPDATED, listName, list};
};
export const saveSettingsToServer = () => (dispatch, getState) => {
let settings = getState().settings.toJS().settings;
if (settings.charCount) {
+40 -25
View File
@@ -6,34 +6,49 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
import {Logo} from './Logo';
export default ({handleLogout}) => (
export default ({handleLogout, restricted = false}) => (
<Header className={styles.header}>
<Logo />
<Navigation className={styles.nav}>
<IndexLink className={styles.navLink} to="/admin"
activeClassName={styles.active}>{lang.t('configure.moderate')}</IndexLink>
<Link className={styles.navLink} to="/admin/community"
activeClassName={styles.active}>{lang.t('configure.community')}</Link>
<Link className={styles.navLink} to="/admin/configure"
activeClassName={styles.active}>{lang.t('configure.configure')}</Link>
{
!restricted ?
<div>
<Navigation className={styles.nav}>
<IndexLink className={styles.navLink} to="/admin"
activeClassName={styles.active}>
{lang.t('configure.moderate')}
</IndexLink>
<Link className={styles.navLink} to="/admin/community"
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>
<li className={styles.settings}>
<div>
<IconButton name="settings" id="menu-settings"/>
<Menu target="menu-settings" align="right">
<MenuItem onClick={handleLogout}>Sign Out</MenuItem>
</Menu>
</div>
</li>
<li>
{`v${process.env.VERSION}`}
</li>
</ul>
</div>
activeClassName={styles.active}>
{lang.t('configure.streams')}
</Link>
</Navigation>
<div className={styles.rightPanel}>
<ul>
<li className={styles.settings}>
<div>
<IconButton name="settings" id="menu-settings"/>
<Menu target="menu-settings" align="right">
<MenuItem onClick={handleLogout}>Sign Out</MenuItem>
</Menu>
</div>
</li>
<li>
{`v${process.env.VERSION}`}
</li>
</ul>
</div>
</div>
:
null
}
</Header>
);
@@ -0,0 +1,16 @@
export const NEXT_STEP = 'NEXT_STEP';
export const GO_TO_STEP = 'GO_TO_STEP';
export const PREVIOUS_STEP = 'PREVIOUS_STEP';
export const ADD_ERROR = 'ADD_ERROR';
export const HAS_ERROR = 'HAS_ERROR';
export const SHOW_ERRORS = 'SHOW_ERRORS';
export const CLEAR_ERRORS = 'CLEAR_ERRORS';
export const INSTALL_REQUEST = 'INSTALL_REQUEST';
export const INSTALL_SUCCESS = 'INSTALL_SUCCESS';
export const INSTALL_FAILURE = 'INSTALL_FAILURE';
export const UPDATE_FORMDATA_USER = 'UPDATE_FORMDATA_USER';
export const UPDATE_FORMDATA_SETTINGS = 'UPDATE_FORMDATA_SETTINGS';
export const CHECK_INSTALL_REQUEST = 'CHECK_INSTALL_REQUEST';
export const CHECK_INSTALL_SUCCESS = 'CHECK_INSTALL_SUCCESS';
export const CHECK_INSTALL_FAILURE = 'CHECK_INSTALL_FAILURE';
@@ -5,6 +5,7 @@ import {
updateSettings,
saveSettingsToServer,
updateWordlist,
updateDomainlist
} from '../../actions/settings';
import {Button, List, Item} from 'coral-ui';
@@ -14,6 +15,7 @@ import translations from '../../translations.json';
import EmbedLink from './EmbedLink';
import CommentSettings from './CommentSettings';
import Wordlist from './Wordlist';
import Domainlist from './Domainlist';
import has from 'lodash/has';
class Configure extends Component {
@@ -47,6 +49,11 @@ class Configure extends Component {
this.props.dispatch(updateWordlist(listName, list));
}
onChangeDomainlist = (listName, list) => {
this.setState({changed: true});
this.props.dispatch(updateDomainlist(listName, list));
}
onSettingUpdate = (setting) => {
this.setState({changed: true});
this.props.dispatch(updateSettings(setting));
@@ -73,7 +80,14 @@ class Configure extends Component {
errors={this.state.errors}
settingsError={this.onSettingError}/>;
case 'embed':
return <EmbedLink title={pageTitle} />;
return has(this, 'props.settings.domains.whitelist')
? <div>
<Domainlist
domains={this.props.settings.domains.whitelist}
onChangeDomainlist={this.onChangeDomainlist}/>
<EmbedLink title={pageTitle} />
</div>
: <EmbedLink title={pageTitle} />;
case 'wordlist':
return has(this, 'props.settings.wordlist')
? <Wordlist
@@ -0,0 +1,26 @@
import React from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
import TagsInput from 'react-tagsinput';
import styles from './Configure.css';
import {Card} from 'coral-ui';
const Domainlist = ({domains, onChangeDomainlist}) => (
<div>
<h3>{lang.t('configure.domain-list-title')}</h3>
<Card id={styles.domainlist}>
<p className={styles.domainlistDesc}>{lang.t('configure.domain-list-text')}</p>
<TagsInput
value={domains}
inputProps={{placeholder: 'URL'}}
addOnPaste={true}
pasteSplit={data => data.split(',').map(d => d.trim())}
onChange={tags => onChangeDomainlist('whitelist', tags)}
/>
</Card>
</div>
);
export default Domainlist;
const lang = new I18n(translations);
@@ -0,0 +1,93 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import styles from './style.css';
import {Wizard, WizardNav} from 'coral-ui';
import {Layout} from '../../components/ui/Layout';
import {
nextStep,
previousStep,
goToStep,
updateUserFormData,
updateSettingsFormData,
submitSettings,
submitUser,
checkInstall
} from '../../actions/install';
import InitialStep from './components/Steps/InitialStep';
import AddOrganizationName from './components/Steps/AddOrganizationName';
import CreateYourAccount from './components/Steps/CreateYourAccount';
import FinalStep from './components/Steps/FinalStep';
class InstallContainer extends Component {
componentDidMount() {
const {checkInstall} = this.props;
checkInstall(() => {
this.context.router.push('/admin');
});
}
render() {
const {install} = this.props;
return (
<Layout restricted={true}>
<div className={styles.Install}>
{
!install.alreadyInstalled ? (
<div>
<h2>Welcome to the Coral Project</h2>
{ install.step !== 0 ? <WizardNav items={install.navItems} currentStep={install.step} icon='check'/> : null }
<Wizard currentStep={install.step} {...this.props}>
<InitialStep/>
<AddOrganizationName/>
<CreateYourAccount/>
<FinalStep/>
</Wizard>
</div>
) : (
<div>Talk is already installed</div>
)
}
</div>
</Layout>
);
}
}
InstallContainer.contextTypes = {
router: React.PropTypes.object
};
const mapStateToProps = state => ({
install: state.install.toJS()
});
const mapDispatchToProps = dispatch => ({
checkInstall: next => dispatch(checkInstall(next)),
nextStep: () => dispatch(nextStep()),
previousStep: () => dispatch(previousStep()),
goToStep: step => dispatch(goToStep(step)),
handleSettingsChange: e => {
const {name, value} = e.currentTarget;
dispatch(updateSettingsFormData(name, value));
},
handleUserChange: e => {
const {name, value} = e.currentTarget;
dispatch(updateUserFormData(name, value));
},
handleSettingsSubmit: e => {
e.preventDefault();
dispatch(submitSettings());
},
handleUserSubmit: e => {
e.preventDefault();
dispatch(submitUser());
}
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(InstallContainer);
@@ -0,0 +1,30 @@
import React from 'react';
import styles from './style.css';
import {FormField, Button} from 'coral-ui';
const AddOrganizationName = props => {
const {handleSettingsChange, handleSettingsSubmit, install} = props;
return (
<div className={styles.step}>
<p>
Please tell us the name of your organization. This will appear in emails when
inviting new team members
</p>
<div className={styles.form}>
<form onSubmit={handleSettingsSubmit}>
<FormField
className={styles.FormField}
id="organizationName"
type="text"
label='Organization name'
onChange={handleSettingsChange}
showErrors={install.showErrors}
errorMsg={install.errors.organizationName} />
<Button type="submit" cStyle='black' full>Save</Button>
</form>
</div>
</div>
);
};
export default AddOrganizationName;
@@ -0,0 +1,65 @@
import React from 'react';
import styles from './style.css';
import {FormField, Button, Spinner} from 'coral-ui';
const InitialStep = props => {
const {handleUserChange, handleUserSubmit, install} = props;
return (
<div className={styles.step}>
<div className={styles.form}>
<form onSubmit={handleUserSubmit}>
<FormField
className={styles.formField}
id="email"
type="email"
label='Email address'
onChange={handleUserChange}
showErrors={install.showErrors}
errorMsg={install.errors.email}
noValidate
/>
<FormField
className={styles.formField}
id="displayName"
type="text"
label='Username'
onChange={handleUserChange}
showErrors={install.showErrors}
errorMsg={install.errors.displayName}
/>
<FormField
className={styles.formField}
id="password"
type="password"
label='Password'
onChange={handleUserChange}
showErrors={install.showErrors}
errorMsg={install.errors.password}
/>
<FormField
className={styles.formField}
id="confirmPassword"
type="password"
label='Confirm Password'
onChange={handleUserChange}
showErrors={install.showErrors}
errorMsg={install.errors.confirmPassword}
/>
{
!props.install.isLoading ?
<Button cStyle='black' type="submit" full>Save</Button>
:
<Spinner />
}
{props.install.installRequest === 'FAILURE' && <div className={styles.error}>Error: {props.install.installRequestError}</div>}
</form>
</div>
</div>
);
};
export default InitialStep;
@@ -0,0 +1,20 @@
import React from 'react';
import styles from './style.css';
import {Button} from 'coral-ui';
import {Link} from 'react-router';
const InitialStep = () => {
return (
<div className={`${styles.step} ${styles.finalStep}`}>
<p>
Thanks for installing Talk! We sent an email to verify your email
address. While you finish setting the account, you can start engaging
with your readers now.
</p>
<Button raised><Link to='/admin'>Launch Talk</Link></Button>
<Button cStyle='black' raised><a href="http://coralproject.net">Close this Installer</a></Button>
</div>
);
};
export default InitialStep;
@@ -0,0 +1,19 @@
import React from 'react';
import styles from './style.css';
import {Button} from 'coral-ui';
const InitialStep = props => {
const {nextStep} = props;
return (
<div className={styles.step}>
<p>
The remainder of the Talk installation will take about ten minutes.
Once you complete the following two steps, you will have a free
installation and provision of Mongo and Redis.
</p>
<Button cStyle='green' onClick={nextStep} raised>Get Started</Button>
</div>
);
};
export default InitialStep;
@@ -0,0 +1,44 @@
import React from 'react';
import styles from './style.css';
import {Button, Select, Option, FormField} from 'coral-ui';
const InviteTeamMembers = props => {
const {nextStep} = props;
return (
<div className={styles.step}>
<h3>Invite Team Members </h3>
<p>
Once registered, new team members will receive an email to Create
their password.
</p>
<div className={styles.form}>
<form>
<FormField
className={styles.formField}
id="email"
type="email"
label='Email address' required/>
<FormField
className={styles.formField}
id="username"
type="text"
label='Username' required/>
<div className={styles.formField}>
<label htmlFor='role'>Assing a role</label>
<Select id='role' label='Select Role'>
<Option>Admin</Option>
<Option>Moderator</Option>
</Select>
</div>
<Button cStyle='black' onClick={nextStep} icon='arrow_forward' full>Invite team member</Button>
</form>
</div>
</div>
);
};
export default InviteTeamMembers;
@@ -0,0 +1,77 @@
.step {
padding: 20px 0;
h3 {
max-width: 450px;
margin: 0 auto;
text-align: left;
font-size: 1.4em;
font-weight: bold;
}
p {
max-width: 450px;
margin: 0 auto 30px;
font-size: 1.1em;
text-align: left;
}
> button {
min-width: 145px;
a {
text-decoration: none;
color: inherit;
}
}
.form {
max-width: 300px;
margin: 30px auto;
text-align: left;
form > button {
margin: 30px 0;
}
.formField {
text-align: left;
label {
text-align: left;
display: block;
margin: 10px 0;
font-weight: 400;
font-size: 1.08em;
}
> input {
border: solid 1px rgba(0, 0, 0, 0.23);
padding: 10px;
border-radius: 3px;
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s ease;
font-size: 1em;
&:focus {
border-color: black;
}
}
}
}
}
.finalStep {
button {
width: 225px;
margin-right: 10px;
}
}
.error {
background: #FFEBEE;
color: #B71C1C;
padding: 10px;
margin-bottom: 20px;
border-radius: 2px;
}
@@ -0,0 +1,12 @@
.Install {
max-width: 900px;
margin: 0 auto;
text-align: center;
padding: 50px 0;
h2 {
font-size: 2em;
font-weight: 500;
margin: 0;
}
}
+12 -3
View File
@@ -1,6 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import {client} from './services/client';
import store from './services/store';
import App from './components/App';
// Render the application into the DOM
ReactDOM.render(<App />, document.querySelector('#root'));
render(
<ApolloProvider client={client} store={store}>
<App />
</ApolloProvider>
, document.querySelector('#root')
);
+9 -9
View File
@@ -1,19 +1,19 @@
import {combineReducers} from 'redux';
import auth from 'reducers/auth';
import users from 'reducers/users';
import assets from 'reducers/assets';
import actions from 'reducers/actions';
import install from 'reducers/install';
import comments from 'reducers/comments';
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({
export default {
settings,
comments,
community,
auth,
actions,
assets,
users
});
users,
install
};
@@ -0,0 +1,91 @@
import {Map} from 'immutable';
import * as actions from '../constants/install';
const initialState = Map({
isLoading: false,
data: Map({
settings: Map({
organizationName: ''
}),
user: Map({
displayName: '',
email: '',
password: '',
confirmPassword: ''
})
}),
errors: Map({
organizationName: '',
displayName: '',
email: '',
password: '',
confirmPassword: ''
}),
showErrors: false,
hasError: false,
error: null,
step: 0,
navItems: [{
text: '1. Add Organization Name',
step: 1
},
{
text: '2. Create your account',
step: 2
}],
installRequest: null,
installRequestError: null,
alreadyInstalled: false
});
export default function install (state = initialState, action) {
switch (action.type) {
case actions.NEXT_STEP:
return state
.set('step', state.get('step') + 1);
case actions.PREVIOUS_STEP:
return state
.set('step', state.get('step') - 1);
case actions.GO_TO_STEP:
return state
.set('step', action.step);
case actions.UPDATE_FORMDATA_SETTINGS:
return state
.setIn(['data', 'settings', action.name], action.value);
case actions.UPDATE_FORMDATA_USER:
return state
.setIn(['data', 'user', action.name], action.value);
case actions.HAS_ERROR:
return state
.merge({
hasError: true,
showErrors: true
});
case actions.ADD_ERROR:
return state
.setIn(['errors', action.name], action.error);
case actions.CLEAR_ERRORS:
return state
.set('errors', Map());
case actions.INSTALL_REQUEST:
return state
.set('isLoading', true);
case actions.INSTALL_SUCCESS:
return state
.set('isLoading', false)
.set('installRequest', 'SUCCESS');
case actions.INSTALL_FAILURE:
return state
.merge({
isLoading: false,
installRequest: 'FAILURE',
installRequestError: action.error
});
case actions.CHECK_INSTALL_SUCCESS:
return state
.set('alreadyInstalled', action.installed);
default :
return state;
}
}
@@ -6,6 +6,9 @@ const initialState = Map({
wordlist: Map({
banned: List(),
suspect: List()
}),
domains: Map({
whitelist: List()
})
}),
saveSettingsError: null,
@@ -24,6 +27,7 @@ export default (state = initialState, action) => {
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);
case types.DOMAINLIST_UPDATED: return updateDomainlist(state, action);
default: return state;
}
};
@@ -40,6 +44,10 @@ const updateWordlist = (state, action) => {
return state.setIn(['settings', 'wordlist', action.listName], action.list);
};
const updateDomainlist = (state, action) => {
return state.setIn(['settings', 'domains', 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);
+19 -12
View File
@@ -1,17 +1,24 @@
import {createStore, applyMiddleware} from 'redux';
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from 'reducers';
import mainReducer from '../reducers';
import {client} from './client';
/**
* Create the store by merging the app reducers with
* the talk adapter. The talk adapter is the wire between
* this client and the coral backend. The idea is we can
* write different adapters for other platforms if we want
*/
const middlewares = [
applyMiddleware(client.middleware()),
applyMiddleware(thunk)
];
if (window.devToolsExtension) {
// we can't have the last argument of compose() be undefined
middlewares.push(window.devToolsExtension());
}
export default createStore(
mainReducer,
window.devToolsExtension && window.devToolsExtension(),
applyMiddleware(thunk)
combineReducers({
...mainReducer,
apollo: client.reducer()
}),
{},
compose(...middlewares)
);
+9 -2
View File
@@ -79,7 +79,9 @@
"comment-count-header": "Limit Comment Length",
"comment-count-text-pre": "Comments will be limited to ",
"comment-count-text-post": " characters.",
"comment-count-error": "Please enter a valid number."
"comment-count-error": "Please enter a valid number.",
"domain-list-title": "Domain Whitelist",
"domain-list-text": "Some instructions on how to type the urls."
},
"bandialog": {
"ban_user": "Ban User?",
@@ -187,7 +189,12 @@
"comment-count-header": "Limitar el largo del comentario",
"comment-count-text-pre": "El largo de comentarios será ",
"comment-count-text-post": " caracteres",
"comment-count-error": "Por favor escribe un número válido."
"comment-count-error": "Por favor escribe un número válido.",
"domain-list-title": "Lista de Dominios Permitidos",
"domain-list-text": "Instrucciones de como ingresar las URLs."
},
"embedlink": {
"copy": "Copiar"
},
"bandialog": {
"ban_user": "Quieres suspender el Usuario?",
+8
View File
@@ -0,0 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {GraphQLDocs} from 'graphql-docs';
import fetcher from './services/fetcher';
// Render the application into the DOM
ReactDOM.render(<GraphQLDocs fetcher={fetcher} />, document.querySelector('#root'));
+10
View File
@@ -0,0 +1,10 @@
export default function fetcher(query) {
return fetch(`${window.location.host}/api/v1/graph/ql`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({query}),
}).then((res) => res.json());
}
+25 -12
View File
@@ -10,6 +10,8 @@ import React, {PropTypes} from 'react';
import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton';
import AuthorName from 'coral-plugin-author-name/AuthorName';
import TagLabel from 'coral-plugin-tag-label/TagLabel';
import Content from 'coral-plugin-commentcontent/CommentContent';
import PubDate from 'coral-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
@@ -18,7 +20,9 @@ import LikeButton from 'coral-plugin-likes/LikeButton';
import styles from './Comment.css';
const getAction = (type, comment) => comment.actions.filter((a) => a.type === type)[0];
const getActionSummary = (type, comment) => comment.action_summaries
.filter((a) => a.__typename === type)[0];
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF') ;
class Comment extends React.Component {
@@ -35,7 +39,8 @@ class Comment extends React.Component {
setActiveReplyBox: PropTypes.func.isRequired,
refetch: PropTypes.func.isRequired,
showSignInDialog: PropTypes.func.isRequired,
postAction: PropTypes.func.isRequired,
postFlag: PropTypes.func.isRequired,
postLike: PropTypes.func.isRequired,
deleteAction: PropTypes.func.isRequired,
parentId: PropTypes.string,
addNotification: PropTypes.func.isRequired,
@@ -51,15 +56,19 @@ class Comment extends React.Component {
}),
comment: PropTypes.shape({
depth: PropTypes.number,
actions: PropTypes.array.isRequired,
action_summaries: PropTypes.array.isRequired,
body: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string
})
),
replies: PropTypes.arrayOf(
PropTypes.shape({
body: PropTypes.string.isRequired,
id: PropTypes.string.isRequired
})
),
})),
user: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
@@ -78,14 +87,15 @@ class Comment extends React.Component {
refetch,
addNotification,
showSignInDialog,
postAction,
postLike,
postFlag,
setActiveReplyBox,
activeReplyBox,
deleteAction
} = this.props;
const like = getAction('LIKE', comment);
const flag = getAction('FLAG', comment);
const like = getActionSummary('LikeActionSummary', comment);
const flag = getActionSummary('FlagActionSummary', comment);
return (
<div
@@ -95,13 +105,16 @@ class Comment extends React.Component {
<hr aria-hidden={true} />
<AuthorName
author={comment.user}/>
{ isStaff(comment.tags)
? <TagLabel isStaff={true}/>
: null }
<PubDate created_at={comment.created_at} />
<Content body={comment.body} />
<div className="commentActionsLeft">
<LikeButton
like={like}
id={comment.id}
postAction={postAction}
postLike={postLike}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser} />
@@ -117,7 +130,7 @@ class Comment extends React.Component {
flag={flag}
id={comment.id}
author_id={comment.user.id}
postAction={postAction}
postFlag={postFlag}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser} />
@@ -150,7 +163,8 @@ class Comment extends React.Component {
depth={depth + 1}
asset={asset}
currentUser={currentUser}
postAction={postAction}
postLike={postLike}
postFlag={postFlag}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
reactKey={reply.id}
@@ -158,7 +172,6 @@ class Comment extends React.Component {
comment={reply} />;
})
}
</div>
);
}
+6 -4
View File
@@ -10,7 +10,7 @@ const {addNotification, clearNotification} = notificationActions;
const {fetchAssetSuccess} = assetActions;
import {queryStream} from 'coral-framework/graphql/queries';
import {postComment, postAction, deleteAction} from 'coral-framework/graphql/mutations';
import {postComment, postFlag, postLike, deleteAction} from 'coral-framework/graphql/mutations';
import {editName} from 'coral-framework/actions/user';
import {Notification, notificationActions, authActions, assetActions, pym} from 'coral-framework';
@@ -105,7 +105,7 @@ class Embed extends Component {
minHeight: document.body.scrollHeight + 200
} : {};
if (loading) {
if (loading || !asset) {
return <Spinner />;
}
@@ -160,7 +160,8 @@ class Embed extends Component {
postItem={this.props.postItem}
asset={asset}
currentUser={user}
postAction={this.props.postAction}
postLike={this.props.postLike}
postFlag={this.props.postFlag}
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
comments={asset.comments} />
@@ -227,7 +228,8 @@ const mapDispatchToProps = dispatch => ({
export default compose(
connect(mapStateToProps, mapDispatchToProps),
postComment,
postAction,
postFlag,
postLike,
deleteAction,
queryStream
)(Embed);
+4 -2
View File
@@ -37,7 +37,8 @@ class Stream extends React.Component {
asset,
postItem,
addNotification,
postAction,
postFlag,
postLike,
deleteAction,
showSignInDialog,
refetch
@@ -56,7 +57,8 @@ class Stream extends React.Component {
postItem={postItem}
asset={asset}
currentUser={currentUser}
postAction={postAction}
postLike={postLike}
postFlag={postFlag}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
+2 -2
View File
@@ -2,8 +2,8 @@ import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import {client} from 'coral-framework/client';
import store from 'coral-framework/store';
import {client} from 'coral-framework/services/client';
import store from 'coral-framework/services/store';
import Embed from './Embed';
@@ -0,0 +1,8 @@
fragment actionSummaryView on ActionSummary {
__typename
count
current_user {
id
created_at
}
}
@@ -1,18 +1,18 @@
#import "../fragments/actionSummaryView.graphql"
fragment commentView on Comment {
id
body
created_at
status
tags {
name
}
user {
id
name: displayName
}
actions {
type: action_type
count
current: current_user {
id
created_at
}
action_summaries {
...actionSummaryView
}
}
@@ -1,3 +1,7 @@
mutation deleteAction ($id: ID!) {
deleteAction(id:$id)
deleteAction(id:$id) {
errors {
translation_key
}
}
}
@@ -1,6 +1,7 @@
import {graphql} from 'react-apollo';
import POST_COMMENT from './postComment.graphql';
import POST_ACTION from './postAction.graphql';
import POST_FLAG from './postFlag.graphql';
import POST_LIKE from './postLike.graphql';
import DELETE_ACTION from './deleteAction.graphql';
import commentView from '../fragments/commentView.graphql';
@@ -21,12 +22,23 @@ export const postComment = graphql(POST_COMMENT, {
}}),
});
export const postAction = graphql(POST_ACTION, {
export const postLike = graphql(POST_LIKE, {
props: ({mutate}) => ({
postAction: (action) => {
postLike: (like) => {
return mutate({
variables: {
action
like
}
});
}}),
});
export const postFlag = graphql(POST_FLAG, {
props: ({mutate}) => ({
postFlag: (flag) => {
return mutate({
variables: {
flag
}
});
}}),
@@ -1,5 +0,0 @@
mutation CreateAction ($action: CreateActionInput!) {
createAction(action:$action) {
id
}
}
@@ -2,6 +2,11 @@
mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) {
createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) {
...commentView
comment {
...commentView
}
errors {
translation_key
}
}
}
@@ -0,0 +1,10 @@
mutation CreateFlag($flag: CreateFlagInput!) {
createFlag(flag:$flag) {
flag {
id
}
errors {
translation_key
}
}
}
@@ -0,0 +1,10 @@
mutation CreateLike ($like: CreateLikeInput!) {
createLike(like:$like) {
like {
id
}
errors {
translation_key
}
}
}
@@ -14,7 +14,7 @@ function getQueryVariable(variable) {
}
// If no query is included, return a default string for development
return 'http://dev.default.stream';
return 'http://localhost/default/stream';
}
export const queryStream = graphql(STREAM_QUERY, {
+2 -1
View File
@@ -6,5 +6,6 @@ export default {
email: lang.t('error.email'),
password: lang.t('error.password'),
displayName: lang.t('error.displayName'),
confirmPassword: lang.t('error.confirmPassword')
confirmPassword: lang.t('error.confirmPassword'),
organizationName: lang.t('error.organizationName'),
};
+2 -1
View File
@@ -2,5 +2,6 @@ export default {
email: email => (/^([A-Za-z0-9_\-\.\+])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)),
password: pass => (/^(?=.{8,}).*$/.test(pass)),
confirmPassword: () => true,
displayName: displayName => (/^[a-zA-Z0-9_]+$/.test(displayName))
displayName: displayName => (/^[a-zA-Z0-9_]+$/.test(displayName)),
organizationName: org => (/^[a-zA-Z0-9_ ]+$/).test(org)
};
+2 -2
View File
@@ -1,5 +1,5 @@
import store from './store';
import pym from './PymConnection';
import store from './services/store';
import pym from './services/PymConnection';
import I18n from './modules/i18n/i18n';
import * as authActions from './actions/auth';
import * as assetActions from './actions/asset';
@@ -1,4 +1,4 @@
import Pym from '../../node_modules/pym.js';
import Pym from '../../../node_modules/pym.js';
const pym = new Pym.Child({polling: 100});
export default pym;
+14
View File
@@ -0,0 +1,14 @@
import ApolloClient, {addTypename} from 'apollo-client';
import getNetworkInterface from './transport';
export const client = new ApolloClient({
connectToDevTools: true,
queryTransformer: addTypename,
dataIdFromObject: (result) => {
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
return result.__typename + result.id; // eslint-disable-line no-underscore-dangle
}
return null;
},
networkInterface: getNetworkInterface()
});
@@ -1,6 +1,6 @@
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from './reducers';
import mainReducer from '../reducers';
import {client} from './client';
const middlewares = [
@@ -0,0 +1,16 @@
import {print} from 'graphql-tag/printer';
// quick way to add the subscribe and unsubscribe functions to the network interface
const addGraphQLSubscriptions = (networkInterface, wsClient) => {
return Object.assign(networkInterface, {
subscribe: (request, handler) => wsClient.subscribe({
query: print(request.query),
variables: request.variables,
}, handler),
unsubscribe: (id) => {
wsClient.unsubscribe(id);
},
});
};
export default addGraphQLSubscriptions;
@@ -0,0 +1,11 @@
import {createNetworkInterface} from 'apollo-client';
export default function getNetworkInterface(apiUrl = '/api/v1/graph/ql', headers = {}) {
return new createNetworkInterface({
uri: apiUrl,
opts: {
credentials: 'same-origin',
headers,
},
});
}
+2
View File
@@ -16,6 +16,7 @@
"password": "Password must be at least 8 characters",
"displayName": "Display names can contain letters, numbers and _ only",
"confirmPassword": "Passwords don't match. Please, check again",
"organizationName": "Organization name must only contain letters or numbers.",
"emailPasswordError": "Email and/or password combination incorrect.",
"EMAIL_REQUIRED": "An email address is required",
"PASSWORD_REQUIRED": "Must input a password",
@@ -39,6 +40,7 @@
"email": "No es un email válido",
"password": "La contraseña debe tener por lo menos 8 caracteres",
"displayName": "Los nombres pueden contener letras, números y _",
"organizationName": "El nombre de la organización debe contener letras y/o números.",
"confirmPassword": "Las contraseñas no coinciden",
"emailPasswordError": "Email y/o contraseña incorrecta.",
"EMAIL_REQUIRED": "Se requiere una dirección de correo electrónico",
+1 -23
View File
@@ -8,9 +8,6 @@ const name = 'coral-plugin-commentbox';
class CommentBox extends Component {
static propTypes = {
// updateItem: PropTypes.func,
// comments: PropTypes.array,
commentPostedHandler: PropTypes.func,
postItem: PropTypes.func.isRequired,
cancelButtonClicked: PropTypes.func,
@@ -29,10 +26,6 @@ class CommentBox extends Component {
postComment = () => {
const {
// child_id,
// updateItem,
// appendItemArray,
commentPostedHandler,
postItem,
assetId,
@@ -48,28 +41,13 @@ class CommentBox extends Component {
parent_id: parentId
};
// let related;
// let parent_type;
// if (parent_id) {
// comment.parent_id = parent_id;
// related = 'children';
// parent_type = 'comments';
// } else {
// related = 'comments';
// parent_type = 'assets';
// }
// if (child_id || parent_id) {
// updateItem(child_id || parent_id, 'showReply', false, 'comments');
// }
if (this.props.charCount && this.state.body.length > this.props.charCount) {
return;
}
postItem(comment, 'comments')
.then(({data}) => {
const postedComment = data.createComment;
const postedComment = data.createComment.comment;
// const commentId = postedComment.id;
if (postedComment.status === 'REJECTED') {
addNotification('error', lang.t('comment-post-banned-word'));
} else if (postedComment.status === 'PREMOD') {
+14 -13
View File
@@ -12,7 +12,7 @@ class FlagButton extends Component {
showMenu: false,
itemType: '',
reason: '',
note: '',
message: '',
step: 0,
posted: false,
localPost: null,
@@ -23,7 +23,7 @@ class FlagButton extends Component {
onReportClick = () => {
const {currentUser, flag, deleteAction} = this.props;
const {localPost, localDelete} = this.state;
const flagged = (flag && flag.current && !localDelete) || localPost;
const flagged = (flag && flag.current_user && !localDelete) || localPost;
if (!currentUser) {
const offset = document.getElementById(`c_${this.props.id}`).getBoundingClientRect().top - 75;
this.props.showSignInDialog(offset);
@@ -31,15 +31,15 @@ class FlagButton extends Component {
}
if (flagged) {
this.setState((prev) => prev.localPost ? {...prev, localPost: null, step: 0} : {...prev, localDelete: true});
deleteAction(localPost || flag.current.id);
deleteAction(localPost || flag.current_user.id);
} else {
this.setState({showMenu: !this.state.showMenu});
}
}
onPopupContinue = () => {
const {postAction, id, author_id} = this.props;
const {itemType, reason, step, posted} = this.state;
const {postFlag, id, author_id} = this.props;
const {itemType, reason, step, posted, message} = this.state;
// Proceed to the next step or close the menu if we've reached the end
if (step + 1 >= this.props.getPopupMenu.length) {
@@ -67,13 +67,14 @@ class FlagButton extends Component {
if (itemType === 'COMMENTS') {
this.setState({localPost: 'temp'});
}
postAction({
postFlag({
item_id,
item_type: itemType,
action_type: 'FLAG'
reason,
message
}).then(({data}) => {
if (itemType === 'COMMENTS') {
this.setState({localPost: data.createAction.id});
this.setState({localPost: data.createFlag.flag.id});
}
});
}
@@ -99,7 +100,7 @@ class FlagButton extends Component {
}
onNoteTextChange = (e) => {
this.setState({note: e.target.value});
this.setState({message: e.target.value});
}
handleClickOutside () {
@@ -109,7 +110,7 @@ class FlagButton extends Component {
render () {
const {flag, getPopupMenu} = this.props;
const {localPost, localDelete} = this.state;
const flagged = (flag && flag.current && !localDelete) || localPost;
const flagged = (flag && flag.current_user && !localDelete) || localPost;
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
return <div className={`${name}-container`}>
@@ -150,15 +151,15 @@ class FlagButton extends Component {
}
{
this.state.reason && <div>
<label htmlFor={'note'} className={`${name}-popup-radio-label`}>
<label htmlFor={'message'} className={`${name}-popup-radio-label`}>
{lang.t('flag-reason')}
</label><br/>
<textarea
className={`${name}-reason-text`}
id="note"
id="message"
rows={4}
onChange={this.onNoteTextChange}
value={this.state.note}/>
value={this.state.message}/>
</div>
}
</form>
+10 -15
View File
@@ -12,7 +12,7 @@ class LikeButton extends Component {
count: PropTypes.number
}),
id: PropTypes.string,
postAction: PropTypes.func.isRequired,
postLike: PropTypes.func.isRequired,
deleteAction: PropTypes.func.isRequired,
showSignInDialog: PropTypes.func.isRequired,
currentUser: PropTypes.shape({
@@ -26,9 +26,9 @@ class LikeButton extends Component {
}
render() {
const {like, id, postAction, deleteAction, showSignInDialog, currentUser} = this.props;
const {like, id, postLike, deleteAction, showSignInDialog, currentUser} = this.props;
const {localPost, localDelete} = this.state;
const liked = (like && like.current && !localDelete) || localPost;
const liked = (like && like.current_user && !localDelete) || localPost;
let count = like ? like.count : 0;
if (localPost) {count += 1;}
if (localDelete) {count -= 1;}
@@ -42,28 +42,23 @@ class LikeButton extends Component {
if (currentUser.banned) {
return;
}
if (!liked) {
this.setState({localPost: 'temp', localDelete: false});
postAction({
if (!liked) { // this comment has not yet been liked by this user.
this.setState({localPost: 'temp'});
postLike({
item_id: id,
item_type: 'COMMENTS',
action_type: 'LIKE'
item_type: 'COMMENTS'
}).then(({data}) => {
this.setState({localPost: data.createAction.id});
this.setState({localPost: data.createLike.like.id});
});
} else {
this.setState((prev) => prev.localPost ? {...prev, localPost: null} : {...prev, localDelete: true});
deleteAction(localPost || like.current.id);
deleteAction(localPost || like.current_user.id);
}
};
return <div className={`${name}-container`}>
<button onClick={onLikeClick} className={`${name}-button ${liked && 'likedButton'}`}>
{
liked
? <span className={`${name}-button-text`}>{lang.t('liked')}</span>
: <span className={`${name}-button-text`}>{lang.t('like')}</span>
}
<span className={`${name}-button-text`}>{lang.t(liked ? 'liked' : 'like')}</span>
<i className={`${name}-icon material-icons`}
aria-hidden={true}>thumb_up</i>
<span className={`${name}-like-count`}>{count > 0 && count}</span>
+3
View File
@@ -57,6 +57,9 @@ const StreamQuery = gql`fragment commentView on Comment {
user {
name: displayName
}
tags {
name
}
actions {
type: action_type
count
@@ -0,0 +1,9 @@
import React from 'react';
import styles from './styles.css';
const TagLabel = ({isStaff}) => <div className={`${styles.staff}`}>
{isStaff ? 'Staff' : ''}
</div>;
export default TagLabel;
+7
View File
@@ -0,0 +1,7 @@
.staff {
background-color: #4C1066;
color: white;
display: inline-block;
margin: 10px 10px;
padding: 5px 5px;
}
@@ -5,7 +5,7 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import {myCommentHistory} from 'coral-framework/graphql/queries';
import {link} from 'coral-framework/PymConnection';
import {link} from 'coral-framework/services/PymConnection';
import NotLoggedIn from '../components/NotLoggedIn';
import {Spinner} from 'coral-ui';
import SettingsHeader from '../components/SettingsHeader';
+6
View File
@@ -30,6 +30,12 @@
font-size: 18px;
vertical-align: middle;
}
&:disabled {
background: #E0E0E0;
color: #4f5c67;
font-weight: bold;
}
}
.type--black {
+3
View File
@@ -0,0 +1,3 @@
.Option {
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import {Option as OptionMDL} from 'react-mdl-selectfield';
import styles from './Option.css';
const Option = props => {
const {children, ...attrs} = props;
return (
<OptionMDL className={styles.Option} {...attrs}>
{children}
</OptionMDL>
);
};
export default Option;
+33
View File
@@ -0,0 +1,33 @@
.Select {
position: relative;
width: 100%;
height: 40px;
background: #2c2c2c;
padding: 10px 15px;
box-sizing: border-box;
color: white;
border-radius: 2px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
> div {
padding: 0;
}
i {
position: absolute;
top: 7px;
right: 7px;
}
input {
padding: 0;
font-size: 13px;
letter-spacing: 0.7px;
font-weight: 400;
}
&:hover {
cursor: pointer;
}
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import {SelectField} from 'react-mdl-selectfield';
import styles from './Select.css';
const Select = props => {
const {children, ...attrs} = props;
return (
<SelectField className={styles.Select} {...attrs}>
{children}
</SelectField>
);
};
export default Select;
+27
View File
@@ -0,0 +1,27 @@
import React, {PropTypes} from 'react';
const Wizard = (props) => {
const {children, currentStep, ...rest} = props;
return (
<section>
{React.Children.toArray(children)
.filter((child, i) => i === currentStep)
.map((child, i) =>
React.cloneElement(child, {
i,
currentStep,
...rest
})
)}
</section>
);
};
Wizard.propTypes = {
currentStep: PropTypes.number.isRequired,
nextStep: PropTypes.func.isRequired,
previousStep: PropTypes.func.isRequired,
goToStep: PropTypes.func.isRequired
};
export default Wizard;
+107
View File
@@ -0,0 +1,107 @@
.WizardNav {
ul {
list-style: none;
}
li {
border: solid 1px #DFDFDF;
background-color: #F0F0F0;
display: inline-block;
padding: 10px;
margin-right: 10px;
color: #A7A7A7;
position: relative;
padding-left: 40px;
border-radius: 1px;
&:hover {
cursor: pointer;
}
&.done {
border-color: #00796B;
background-color: #00796B;
color: #ffffff;
span {
&::after {
border-color: transparent transparent transparent #00796B;
}
}
i {
opacity: 1;
}
}
&.active {
background-color: #FFFFFF;
color: #636363;
font-weight: 500;
span {
&::after {
border-color: transparent transparent transparent #FFFFFF;
}
}
}
i {
transition: opacity 0.2s ease;
opacity: 0;
vertical-align: middle;
font-size: 16px;
margin-top: -5px;
margin-left: 8px;
}
span {
padding: 10px 20px;
margin-right: 10px;
position: absolute;
top: -1px;
right: -51px;
z-index: 10;
&::before {
content: "";
display: inline-block;
position: absolute;
border: 23px solid #DFDFDF;
border-color: transparent transparent transparent #DFDFDF;
top: 0;
left: 0;
}
&::after {
content: "";
display: inline-block;
position: absolute;
border: 23px solid white;
border-color: transparent transparent transparent #f0f0f0;
top: 0;
left: -1px;
}
}
&::before {
content: "";
display: inline-block;
position: absolute;
border: 23px solid #DFDFDF;
border-color: transparent transparent transparent #DFDFDF;
top: -1px;
left: 0;
}
&::after {
content: "";
display: inline-block;
position: absolute;
border: 23px solid white;
border-color: transparent transparent transparent #fafafa;
top: -1px;
left: -1px;
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import React, {PropTypes} from 'react';
import styles from './WizardNav.css';
import Icon from './Icon';
const WizardNav = props => {
const {goToStep = () => {}, currentStep, items, icon} = props;
return (
<nav className={styles.WizardNav}>
<ul>
{
items.map((item, i) => (
<li
key={i}
className={`${currentStep === item.step ? styles.active : ''} ${item.step < currentStep ? styles.done : ''}`}
onClick={() => goToStep(item.step)}>
{item.text}
{icon && <Icon name={icon} />}
<span/>
</li>
))
}
</ul>
</nav>
);
};
WizardNav.propTypes = {
currentStep: PropTypes.number.isRequired
};
export default WizardNav;
+4
View File
@@ -16,4 +16,8 @@ export {default as Card} from './components/Card';
export {default as FormField} from './components/FormField';
export {default as Success} from './components/Success';
export {default as Pager} from './components/Pager';
export {default as Wizard} from './components/Wizard';
export {default as WizardNav} from './components/WizardNav';
export {default as Select} from './components/Select';
export {default as Option} from './components/Option';
export {default as SnackBar} from './components/SnackBar';
+25
View File
@@ -0,0 +1,25 @@
version: '2'
services:
talk:
image: coralproject/talk:latest
restart: always
ports:
- "5000:5000"
depends_on:
- mongo
- redis
mongo:
image: mongo:3.2
restart: always
volumes:
- mongo:/data/db
redis:
image: redis:3.2
restart: always
volumes:
- redis:/data
volumes:
mongo:
external: false
redis:
external: false
+16 -1
View File
@@ -121,6 +121,7 @@ const ErrInvalidAssetURL = new APIError('asset_url is invalid', {
// ErrNotAuthorized is an error that is returned in the event an operation is
// deemed not authorized.
const ErrNotAuthorized = new APIError('not authorized', {
translation_key: 'NOT_AUTHORIZED',
status: 401
});
@@ -128,6 +129,18 @@ const ErrNotAuthorized = new APIError('not authorized', {
// initialized.
const ErrSettingsNotInit = new Error('settings not initialized, run `./bin/cli setup` to setup the application first');
// ErrSettingsInit is returned when the setup endpoint is hit and we are already
// initialized.
const ErrSettingsInit = new APIError('settings are already initialized', {
status: 500
});
// ErrInstallLock is returned when the setup endpoint is hit and the install
// lock is present.
const ErrInstallLock = new APIError('install lock active', {
status: 500
});
module.exports = {
ExtendableError,
APIError,
@@ -145,5 +158,7 @@ module.exports = {
ErrNotFound,
ErrInvalidAssetURL,
ErrAuthentication,
ErrNotAuthorized
ErrNotAuthorized,
ErrSettingsInit,
ErrInstallLock
};
+10
View File
@@ -5,6 +5,15 @@ const util = require('./util');
const ActionsService = require('../../services/actions');
const ActionModel = require('../../models/action');
/**
* Gets actions based on their item id's.
*/
const genActionsByItemID = (_, item_ids) => {
return ActionsService
.findByItemIdArray(item_ids)
.then(util.arrayJoinBy(item_ids, 'item_id'));
};
/**
* Looks up actions based on the requested id's all bounded by the user.
* @param {Object} context the context of the request
@@ -35,6 +44,7 @@ const getItemIdsByActionTypeAndItemType = (_, action_type, item_type) => {
*/
module.exports = (context) => ({
Actions: {
getByID: new DataLoader((ids) => genActionsByItemID(context, ids)),
getSummariesByItemID: new DataLoader((ids) => genActionSummariessByItemID(context, ids)),
getByTypes: ({action_type, item_type}) => getItemIdsByActionTypeAndItemType(context, action_type, item_type)
}
+93 -1
View File
@@ -1,4 +1,5 @@
const util = require('./util');
const DataLoader = require('dataloader');
const CommentModel = require('../../models/comment');
@@ -135,6 +136,95 @@ const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, author_
.limit(limit);
};
const genRecentReplies = (_, ids) => {
return CommentModel.aggregate([
// get all the replies for the comments in question
{$match: {
parent_id: {
$in: ids
}
}},
// sort these by their created at timestamp, CHRONOLOGICAL'y as these are
// replies
{$sort: {
created_at: 1
}},
// group these replies by their parent_id
{$group: {
_id: '$parent_id',
replies: {
$push: '$$ROOT'
}
}},
// project it so that we only retain the first 3 replies of each parent
// comment
{$project: {
_id: '$_id',
replies: {
$slice: [
'$replies',
0,
3
]
}
}},
{$unwind: '$replies'},
])
.then((replies) => replies.map((reply) => reply.replies))
.then(util.arrayJoinBy(ids, 'parent_id'));
};
const genRecentComments = (_, ids) => {
return CommentModel.aggregate([
// get all the replies for the comments in question
{$match: {
asset_id: {
$in: ids
}
}},
// sort these by their created at timestamp, CHRONOLOGICAL'y as these are
// replies
{$sort: {
created_at: 1
}},
// group these replies by their parent_id
{$group: {
_id: '$asset_id',
comments: {
$push: '$$ROOT'
}
}},
// project it so that we only retain the first 3 replies of each parent
// comment
{$project: {
_id: '$_id',
comments: {
$slice: [
'$comments',
0,
3
]
}
}},
// Unwind these comments.
{$unwind: '$comments'},
])
.then((replies) => replies.map((reply) => reply.comments))
.then(util.arrayJoinBy(ids, 'asset_id'));
};
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
@@ -144,6 +234,8 @@ module.exports = (context) => ({
Comments: {
getByQuery: (query) => getCommentsByQuery(context, query),
countByAssetID: new util.SharedCacheDataLoader('Comments.countByAssetID', 3600, (ids) => getCountsByAssetID(context, ids)),
countByParentID: new util.SharedCacheDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids))
countByParentID: new util.SharedCacheDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids)),
genRecentReplies: new DataLoader((ids) => genRecentReplies(context, ids)),
genRecentComments: new DataLoader((ids) => genRecentComments(context, ids))
}
});
+5 -3
View File
@@ -1,6 +1,7 @@
const ActionModel = require('../../models/action');
const ActionsService = require('../../services/actions');
const UsersService = require('../../services/users');
const errors = require('../../errors');
/**
* Creates an action on a item. If the item is a user flag, sets the user's status to
@@ -11,11 +12,12 @@ const UsersService = require('../../services/users');
* @param {String} action_type type of the action
* @return {Promise} resolves to the action created
*/
const createAction = ({user = {}}, {item_id, item_type, action_type, metadata = {}}) => {
const createAction = ({user = {}}, {item_id, item_type, action_type, group_id, metadata = {}}) => {
return ActionsService.insertUserAction({
item_id,
item_type,
user_id: user.id,
group_id,
action_type,
metadata
}).then((action) => {
@@ -58,8 +60,8 @@ module.exports = (context) => {
return {
Action: {
create: () => {},
delete: () => {}
create: () => Promise.reject(errors.ErrNotAuthorized),
delete: () => Promise.reject(errors.ErrNotAuthorized)
}
};
};
+7 -1
View File
@@ -36,6 +36,12 @@ const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id =
}
}
if (user.hasRoles('ADMIN')) {
return CommentsService
.addTag(comment.id, 'STAFF', user.id)
.then(() => comment);
}
return comment;
});
};
@@ -171,7 +177,7 @@ module.exports = (context) => {
return {
Comment: {
create: () => {}
create: () => Promise.reject(errors.ErrNotAuthorized)
}
};
};
+8
View File
@@ -1,4 +1,12 @@
const Action = {
__resolveType({action_type}) {
switch (action_type) {
case 'FLAG':
return 'FlagAction';
case 'LIKE':
return 'LikeAction';
}
},
// This will load the user for the specific action. We'll limit this to the
// admin users only or the current logged in user.
+10 -1
View File
@@ -1,3 +1,12 @@
const ActionSummary = {};
const ActionSummary = {
__resolveType({action_type}) {
switch (action_type) {
case 'FLAG':
return 'FlagActionSummary';
case 'LIKE':
return 'LikeActionSummary';
}
},
};
module.exports = ActionSummary;
+3
View File
@@ -1,4 +1,7 @@
const Asset = {
recentComments({id}, _, {loaders: {Comments}}) {
return Comments.genRecentComments.load(id);
},
comments({id}, {sort, limit}, {loaders: {Comments}}) {
return Comments.getByQuery({
asset_id: id,
+13 -1
View File
@@ -2,6 +2,9 @@ const Comment = {
user({author_id}, _, {loaders: {Users}}) {
return Users.getByID.load(author_id);
},
recentReplies({id}, _, {loaders: {Comments}}) {
return Comments.genRecentReplies.load(id);
},
replies({id, asset_id}, {sort, limit}, {loaders: {Comments}}) {
return Comments.getByQuery({
asset_id,
@@ -13,7 +16,16 @@ const Comment = {
replyCount({id}, _, {loaders: {Comments}}) {
return Comments.countByParentID.load(id);
},
actions({id}, _, {loaders: {Actions}}) {
actions({id}, _, {user, loaders: {Actions}}) {
// Only return the actions if the user is not an admin.
if (user && user.hasRoles('ADMIN')) {
return Actions.getByID.load(id);
}
return null;
},
action_summaries({id}, _, {loaders: {Actions}}) {
return Actions.getSummariesByItemID.load(id);
},
asset({asset_id}, _, {loaders: {Assets}}) {
+9
View File
@@ -0,0 +1,9 @@
const FlagAction = {
// Stored in the metadata, extract and return.
reason({metadata: {reason}}) {
return reason;
}
};
module.exports = FlagAction;
+7
View File
@@ -0,0 +1,7 @@
const FlagActionSummary = {
reason({group_id}) {
return group_id;
}
};
module.exports = FlagActionSummary;
+3
View File
@@ -0,0 +1,3 @@
const GenericUserError = {};
module.exports = GenericUserError;
+15 -3
View File
@@ -1,21 +1,33 @@
const Action = require('./action');
const ActionSummary = require('./action_summary');
const Action = require('./action');
const Asset = require('./asset');
const Comment = require('./comment');
const Date = require('./date');
const FlagActionSummary = require('./flag_action_summary');
const FlagAction = require('./flag_action');
const GenericUserError = require('./generic_user_error');
const LikeAction = require('./like_action');
const RootMutation = require('./root_mutation');
const RootQuery = require('./root_query');
const Settings = require('./settings');
const UserError = require('./user_error');
const User = require('./user');
const ValidationUserError = require('./validation_user_error');
module.exports = {
Action,
ActionSummary,
Action,
Asset,
Comment,
Date,
FlagActionSummary,
FlagAction,
GenericUserError,
LikeAction,
RootMutation,
RootQuery,
Settings,
User
UserError,
User,
ValidationUserError,
};
+5
View File
@@ -0,0 +1,5 @@
const LikeAction = {
};
module.exports = LikeAction;
+23 -4
View File
@@ -1,12 +1,31 @@
/**
* Wraps up a promise to return an object with the resolution of the promise
* keyed at `key` or an error caught at `errors`.
*/
const wrapResponse = (key) => (promise) => {
return promise.then((value) => {
let res = {};
if (key) {
res[key] = value;
}
return res;
}).catch((err) => ({
errors: [err]
}));
};
const RootMutation = {
createComment(_, {asset_id, parent_id, body}, {mutators: {Comment}}) {
return Comment.create({asset_id, parent_id, body});
return wrapResponse('comment')(Comment.create({asset_id, parent_id, body}));
},
createAction(_, {action}, {mutators: {Action}}) {
return Action.create(action);
createLike(_, {like: {item_id, item_type}}, {mutators: {Action}}) {
return wrapResponse('like')(Action.create({item_id, item_type, action_type: 'LIKE'}));
},
createFlag(_, {flag: {item_id, item_type, reason, message}}, {mutators: {Action}}) {
return wrapResponse('flag')(Action.create({item_id, item_type, action_type: 'FLAG', group_id: reason, metadata: {message}}));
},
deleteAction(_, {id}, {mutators: {Action}}) {
return Action.delete({id});
return wrapResponse(null)(Action.delete({id}));
},
};
+9 -1
View File
@@ -1,7 +1,15 @@
const User = {
actions({id}, _, {loaders: {Actions}}) {
action_summaries({id}, _, {loaders: {Actions}}) {
return Actions.getSummariesByItemID.load(id);
},
actions({id}, _, {user, loaders: {Actions}}) {
// Only return the actions if the user is not an admin.
if (user && user.hasRoles('ADMIN')) {
return Actions.getByID.load(id);
}
},
comments({id}, _, {loaders: {Comments}, user}) {
// If the user is not an admin, only return comment list for the owner of
+11
View File
@@ -0,0 +1,11 @@
const UserError = {
__resolveType({field_name}) {
if (field_name) {
return 'ValidationUserError';
}
return 'GenericUserError';
}
};
module.exports = UserError;
+3
View File
@@ -0,0 +1,3 @@
const ValidationUserError = {};
module.exports = ValidationUserError;
+7
View File
@@ -1,8 +1,15 @@
const tools = require('graphql-tools');
const maskErrors = require('graphql-errors').maskErrors;
const resolvers = require('./resolvers');
const typeDefs = require('./typeDefs');
const schema = tools.makeExecutableSchema({typeDefs, resolvers});
if (process.env.NODE_ENV === 'production') {
// Mask errors that are thrown if we are in a production environment.
maskErrors(schema);
}
module.exports = schema;
+357 -83
View File
@@ -1,16 +1,94 @@
# Establishes the ordering of the content by their created_at time stamp.
enum SORT_ORDER {
# newest to oldest order.
REVERSE_CHRONOLOGICAL
# oldest to newer order.
CHRONOLOGICAL
}
################################################################################
## Custom Scalar Types
################################################################################
# Date represented as an ISO8601 string.
scalar Date
################################################################################
## Users
################################################################################
# Roles that a user can have, these can be combined.
enum USER_ROLES {
# an administrator of the site
ADMIN
# a moderator of the site
MODERATOR
}
# Any person who can author comments, create actions, and view comments on a
# stream.
type User {
# The ID of the User.
id: ID!
# display name of a user.
displayName: String!
# Action summaries against the user.
action_summaries: [ActionSummary]
# Actions completed on the parent.
actions: [Action]
# the current roles of the user.
roles: [USER_ROLES]
# determines whether the user can edit their username
canEditName: Boolean
# returns all comments based on a query.
comments(query: CommentsQuery): [Comment]
}
type Tag {
# the actual tag for the comment.
name: String!
# the user that assigned the tag. If NULL then the system automatically tagged it.
assigned_by: String
# the time when the tag was assigned.
created_at: Date!
}
################################################################################
## Comments
################################################################################
# The statuses that a comment may have.
enum COMMENT_STATUS {
# The comment has been accepted by a moderator.
ACCEPTED
# The comment has been rejected by a moderator.
REJECTED
# The comment was created while the asset's premoderation option was on, and
# new comments that haven't been moderated yet are referred to as
# "premoderated" or "premod" comments.
PREMOD
}
# The types of action there are as enum's.
enum ACTION_TYPE {
# Represents a LikeAction.
LIKE
# Represents a FlagAction.
FLAG
}
# CommentsQuery allows the ability to query comments by a specific methods.
input CommentsQuery {
# current status of a comment.
statuses: [COMMENT_STATUS]
@@ -30,113 +108,181 @@ input CommentsQuery {
# skip results from the last created_at timestamp.
cursor: Date
# filter by a specific tag name.
tag: [String]
# sort the results by created_at.
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
}
enum USER_ROLES {
# an administrator of the site
ADMIN
# a moderator of the site
MODERATOR
}
# Any person who can author comments, create actions, and view comments on a
# stream.
type User {
id: ID!
# display name of a user.
displayName: String!
# actions against a specific user.
actions: [ActionSummary]
# the current roles of the user.
roles: [USER_ROLES]
# determines whether the user can edit their username
canEditName: Boolean
# returns all comments based on a query.
comments(query: CommentsQuery): [Comment]
}
# Comment is the base representation of user interaction in Talk.
type Comment {
# The ID of the comment.
id: ID!
# the actual comment data.
# The actual comment data.
body: String!
# the tags on the comment
tags: [Tag]
# the user who authored the comment.
user: User
# the recent replies made against this comment.
recentReplies: [Comment]
# the replies that were made to the comment.
replies(sort: SORT_ORDER = CHRONOLOGICAL, limit: Int = 3): [Comment]
# the count of replies on a comment
# The count of replies on a comment.
replyCount: Int
# the actions made against a comment.
actions: [ActionSummary]
# Actions completed on the parent.
actions: [Action]
# the asset that a comment was made on.
# Action summaries against a comment.
action_summaries: [ActionSummary]
# The asset that a comment was made on.
asset: Asset
# the current status of a comment.
# The current status of a comment.
status: COMMENT_STATUS
# the time when the comment was created
# The time when the comment was created
created_at: Date!
}
enum ITEM_TYPE {
ASSETS
COMMENTS
USERS
}
################################################################################
## Actions
################################################################################
enum ACTION_TYPE {
LIKE
FLAG
}
# An action rendered against a parent enity item.
interface Action {
type Action {
# The ID of the action.
id: ID!
action_type: ACTION_TYPE!
item_id: ID!
item_type: ITEM_TYPE!
# The author of the action.
user: User
user: User!
# The time when the Action was updated.
updated_at: Date
# The time when the Action was created.
created_at: Date
}
type ActionSummary {
action_type: ACTION_TYPE!
item_type: ITEM_TYPE!
# A summary of actions based on the specific grouping of the group_id.
interface ActionSummary {
# The count of actions with this group.
count: Int
# The current user's action.
current_user: Action
}
# LikeAction is used by users who "like" a specific entity.
type LikeAction implements Action {
# The ID of the action.
id: ID!
# The author of the action.
user: User
# The time when the Action was updated.
updated_at: Date
# The time when the Action was created.
created_at: Date
}
# LikeActionSummary is counts the amount of "likes" that a specific entity has.
type LikeActionSummary implements ActionSummary {
# The count of likes against the parent entity.
count: Int!
current_user: LikeAction
}
# A FLAG action that contains flag metadata.
type FlagAction implements Action {
# The ID of the Flag Action.
id: ID!
# The reason for which the Flag Action was created.
reason: String
# An optional message sent with the flagging action by the user.
message: String
# The user who created the action.
user: User
# The time when the Flag Action was updated.
updated_at: Date
# The time when the Flag Action was created.
created_at: Date
}
# Summary for Flag Action with a a unique reason.
type FlagActionSummary implements ActionSummary {
# The total count of flags with this reason.
count: Int!
# The reason for which the Flag Action was created.
reason: String
# The flag by the current user against the parent entity with this reason.
current_user: FlagAction
}
################################################################################
## Settings
################################################################################
# The moderation mode of the site.
enum MODERATION_MODE {
# Comments posted while in `PRE` mode will be labeled with a `PREMOD`
# status and will require a moderator decision before being visible.
PRE
# Comments posted while in `POST` will be visible immediately.
POST
}
# Site wide global settings.
type Settings {
# Moderation mode for the site.
moderation: MODERATION_MODE!
# Enables a requirement for email confirmation before a user can login.
requireEmailConfirmation: Boolean
infoBoxEnable: Boolean
infoBoxContent: String
closeTimeout: Int
closedMessage: String
charCountEnable: Boolean
charCount: Int
requireEmailConfirmation: Boolean
}
################################################################################
## Assets
################################################################################
# Where comments are made on.
type Asset {
# The current ID of the asset.
@@ -148,6 +294,9 @@ type Asset {
# The URL that the asset is located on.
url: String
# Returns recent comments
recentComments: [Comment]
# The top level comments that are attached to the asset.
comments(sort: SORT_ORDER = REVERSE_CHRONOLOGICAL, limit: Int = 10): [Comment]
@@ -165,53 +314,178 @@ type Asset {
created_at: Date
}
enum COMMENT_STATUS {
ACCEPTED
REJECTED
PREMOD
################################################################################
## Errors
################################################################################
# Any error rendered due to the user's input.
interface UserError {
# Translation key relating to a translatable string containing details to be
# displayed to the end user.
translation_key: String!
}
# A generic error not related to validation reasons.
type GenericUserError implements UserError {
# Translation key relating to a translatable string containing details to be
# displayed to the end user.
translation_key: String!
}
# A validation error that affects the input.
type ValidationUserError implements UserError {
# Translation key relating to a translatable string containing details to be
# displayed to the end user.
translation_key: String!
# The field in question that caused the error.
field_name: String!
}
################################################################################
## Queries
################################################################################
# Establishes the ordering of the content by their created_at time stamp.
enum SORT_ORDER {
# newest to oldest order.
REVERSE_CHRONOLOGICAL
# oldest to newer order.
CHRONOLOGICAL
}
# All queries that can be executed.
type RootQuery {
# retrieves site wide settings and defaults.
# Site wide settings and defaults.
settings: Settings
# retrieves all assets.
# All assets.
assets: [Asset]
# retrieves a specific asset.
# Find or create an asset by url, or just find with the ID.
asset(id: ID, url: String): Asset
# retrieves comments based on the input query.
# Comments returned based on a query.
comments(query: CommentsQuery!): [Comment]
# retrieves the current logged in user.
# The currently logged in user based on the request.
me: User
}
input CreateActionInput {
# the type of action.
action_type: ACTION_TYPE!
################################################################################
## Mutations
################################################################################
# the type of the item.
item_type: ITEM_TYPE!
# Response defines what can be expected from any response to a mutation action.
interface Response {
# the id of the item that is related to the action.
# An array of errors relating to the mutation that occured.
errors: [UserError]
}
# CreateCommentResponse is returned with the comment that was created and any
# errors that may have occured in the attempt to create it.
type CreateCommentResponse implements Response {
# The comment that was created.
comment: Comment
# An array of errors relating to the mutation that occured.
errors: [UserError]
}
# Used to represent the item type for an action.
enum ACTION_ITEM_TYPE {
# The action references a entity of type Asset.
ASSETS
# The action references a entity of type Comment.
COMMENTS
# The action references a entity of type User.
USERS
}
input CreateLikeInput {
# The item's id for which we are to create a like.
item_id: ID!
# The type of the item for which we are to create the like.
item_type: ACTION_ITEM_TYPE!
}
type CreateLikeResponse implements Response {
# The like that was created.
like: LikeAction
# An array of errors relating to the mutation that occured.
errors: [UserError]
}
input CreateFlagInput {
# The item's id for which we are to create a flag.
item_id: ID!
# The type of the item for which we are to create the flag.
item_type: ACTION_ITEM_TYPE!
# The reason for flagging the item.
reason: String!
# An optional message sent with the flagging action by the user.
message: String
}
# CreateFlagResponse is the response returned with possibly some errors
# relating to the creating the flag action attempt and possibly the flag that
# was created.
type CreateFlagResponse implements Response {
# The like that was created.
flag: FlagAction
# An array of errors relating to the mutation that occured.
errors: [UserError]
}
# DeleteActionResponse is the response returned with possibly some errors
# relating to the delete action attempt.
type DeleteActionResponse implements Response {
# An array of errors relating to the mutation that occured.
errors: [UserError]
}
# All mutations for the application are defined on this object.
type RootMutation {
# creates a comment on the asset.
createComment(asset_id: ID!, parent_id: ID, body: String!): Comment
# creates an action based on an input.
createAction(action: CreateActionInput!): Action
# Creates a comment on the asset.
createComment(asset_id: ID!, parent_id: ID, body: String!): CreateCommentResponse
# delete an action based on the action id.
deleteAction(id: ID!): Boolean
# Creates a like on an entity.
createLike(like: CreateLikeInput!): CreateLikeResponse
# Creates a flag on an entity.
createFlag(flag: CreateFlagInput!): CreateFlagResponse
# Delete an action based on the action id.
deleteAction(id: ID!): DeleteActionResponse
}
################################################################################
## Schema
################################################################################
schema {
query: RootQuery
mutation: RootMutation
+6
View File
@@ -29,6 +29,12 @@ const ActionSchema = new Schema({
},
item_id: String,
user_id: String,
// The element that summaries will additionally group on in addtion to their action_type, item_type, and
// item_id.
group_id: String,
// Additional metadata stored on the field.
metadata: Schema.Types.Mixed
}, {
timestamps: {
+19
View File
@@ -30,6 +30,24 @@ const StatusSchema = new Schema({
_id: false
});
/**
* The Mongo schema for a Comment Tag.
* @type {Schema}
*/
const TagSchema = new Schema({
name: String,
// The User ID of the user that assigned the status.
assigned_by: {
type: String,
default: null
},
created_at: Date
}, {
_id: false
});
/**
* The Mongo schema for a Comment.
* @type {Schema}
@@ -49,6 +67,7 @@ const CommentSchema = new Schema({
author_id: String,
status_history: [StatusSchema],
status: {type: String, default: null},
tags: [TagSchema],
parent_id: String
}, {
timestamps: {
+6
View File
@@ -62,6 +62,12 @@ const SettingSchema = new Schema({
requireEmailConfirmation: {
type: Boolean,
default: false
},
domains: {
whitelist: {
type: Array,
default: ['localhost']
}
}
}, {
timestamps: {

Some files were not shown because too many files have changed in this diff Show More