diff --git a/.eslintrc.json b/.eslintrc.json index 293657679..2186efd8b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -20,6 +20,8 @@ "no-template-curly-in-string": [1], "no-unsafe-negation": [1], "array-callback-return": [1], + "arrow-parens": ["warn", "always"], + "template-curly-spacing": "warn", "eqeqeq": [2, "smart"], "no-eval": [2], "no-global-assign": [2], @@ -36,6 +38,11 @@ "no-unneeded-ternary": [1], "object-curly-spacing": [1], "space-infix-ops": ["error"], + "space-in-parens": ["error", "never"], + "space-unary-ops": ["error", { + "words": true, + "nonwords": false + }], "no-const-assign": [2], "no-duplicate-imports": [2], "prefer-template": [1], diff --git a/.gitignore b/.gitignore index a619f0988..f0c3b2a4b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,7 @@ plugins/* !plugins/coral-plugin-respect !plugins/coral-plugin-offtopic !plugins/coral-plugin-like +!plugins/coral-plugin-mod +!plugins/coral-plugin-love **/node_modules/* diff --git a/LICENSE b/LICENSE index 41cbbd5aa..597d8fa73 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2016 Mozilla Foundation +Copyright 2017 Mozilla Foundation Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index ef621b59c..718847d9e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Talk [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) -Talk is currently in Beta! [Read more about Talk here.](https://coralproject.net/products/talk.html) +Online comments are broken. Our open-source Talk tool rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here.](https://coralproject.net/products/talk.html) Third party licenses are available via the `/client/3rdpartylicenses.txt` endpoint when the server is running with built assets. @@ -24,10 +24,13 @@ The Talk application looks for the following configuration values either as envi - `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_ROOT_URL` (*required*) - root url of the installed application externally available in the format: `://` without the path. +- `TALK_JWT_SECRET` (*required*) - a long and cryptographical secure random string which will be used to +sign and verify tokens via a `HS256` algorithm. +- `TALK_JWT_EXPIRY` (_optional_) - the expiry duration (`exp`) for the tokens issued for logged in sessions (Default `1 day`) +- `TALK_JWT_ISSUER` (_optional_) - the issuer (`iss`) claim for login JWT tokens (Default `process.env.TALK_ROOT_URL`) +- `TALK_JWT_AUDIENCE` (_optional_) - the audience (`aud`) claim for login JWT tokens (Default `talk`) - `TALK_SMTP_EMAIL` (*required for email*) - the address to send emails from using the SMTP provider. - `TALK_SMTP_USERNAME` (*required for email*) - username of the SMTP provider you are using. @@ -42,9 +45,43 @@ available in the format: `://` without the path. Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for alternative methods of loading configuration during development. -### License +## Supported Browsers - Copyright 2016 Mozilla Foundation +### Web + +- Chrome: latest 2 versions +- Firefox: latest 2 versions, and most recent extended support version, if any +- Safari: latest 2 versions +- Internet Explorer: IE Edge, 11 + +### iOS Devices + +- iPad +- iPad Pro +- iPhone 6 Plus +- iPhone 6 +- iPhone 5 + +### iOS Browsers + +- Chrome for iOS: latest version +- Firefox for iOS: latest version +- Safari for iOS: latest version + +### Android Devices + +- Galaxy S5 +- Nexus 5X +- Nexus 6P + +### Android Browsers + +- Chrome for Android: latest version +- Firefox for Android: latest version + +## License + + Copyright 2017 Mozilla Foundation Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/app.js b/app.js index a0a5b7789..f5243f81a 100644 --- a/app.js +++ b/app.js @@ -3,12 +3,11 @@ const bodyParser = require('body-parser'); const morgan = require('morgan'); const path = require('path'); const helmet = require('helmet'); +const authentication = require('./middleware/authentication'); const {passport} = require('./services/passport'); const plugins = require('./services/plugins'); const enabled = require('debug').enabled; -const csrf = require('csurf'); const errors = require('./errors'); -const session = require('./services/session'); const {createGraphOptions} = require('./graph'); const apollo = require('graphql-server-express'); @@ -37,12 +36,6 @@ app.use('/public', express.static(path.join(__dirname, 'public'))); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); -//============================================================================== -// SESSION MIDDLEWARE -//============================================================================== - -app.use(session); - //============================================================================== // PASSPORT MIDDLEWARE //============================================================================== @@ -60,7 +53,10 @@ plugins.get('server', 'passport').forEach((plugin) => { // Setup the PassportJS Middleware. app.use(passport.initialize()); -app.use(passport.session()); + +// Attach the authentication middleware, this will be responsible for decoding +// (if present) the JWT on the request. +app.use('/api', authentication); //============================================================================== // GraphQL Router @@ -84,29 +80,6 @@ if (app.get('env') !== 'production') { } -//============================================================================== -// CSRF MIDDLEWARE -//============================================================================== - -if (process.env.TEST_MODE === 'unit') { - - // Add this fake test token in the event we are in unit test mode, and don't - // include the CSRF protection. - app.locals.csrfToken = 'UNIT_TESTS'; - -} else { - - // Setup route middlewares for CSRF protection. - // Default ignore methods are GET, HEAD, OPTIONS - app.use(csrf({})); - app.use((req, res, next) => { - res.locals.csrfToken = req.csrfToken(); - - next(); - }); - -} - //============================================================================== // ROUTES //============================================================================== diff --git a/bin/cli-serve b/bin/cli-serve index eca23303b..7978c6c85 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -9,13 +9,15 @@ const kue = require('../services/kue'); const mongoose = require('../services/mongoose'); const util = require('./util'); const {createSubscriptionManager} = require('../graph/subscriptions'); +const { + PORT +} = require('../config'); /** * Get port from environment and store in Express. */ -const port = normalizePort(process.env.TALK_PORT || '3000'); - +const port = normalizePort(PORT); app.set('port', port); /** diff --git a/bin/commander.js b/bin/commander.js index ad346a911..328a25b29 100644 --- a/bin/commander.js +++ b/bin/commander.js @@ -3,11 +3,6 @@ const dotenv = require('dotenv'); const fs = require('fs'); const program = require('commander'); -// 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(); - //============================================================================== // Setting up the program command line arguments. //============================================================================== diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index af3423acc..989316dd9 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -1,5 +1,5 @@ import React from 'react'; -import {Router, Route, IndexRoute, IndexRedirect, browserHistory} from 'react-router'; +import {Router, Route, IndexRedirect, browserHistory} from 'react-router'; import Stories from 'containers/Stories/Stories'; import Configure from 'containers/Configure/Configure'; @@ -18,7 +18,7 @@ const routes = (
- + diff --git a/client/coral-admin/src/actions/assets.js b/client/coral-admin/src/actions/assets.js index a4a3513a8..f5c2f848a 100644 --- a/client/coral-admin/src/actions/assets.js +++ b/client/coral-admin/src/actions/assets.js @@ -24,7 +24,7 @@ export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filte assets: result, count })) - .catch(error => dispatch({type: FETCH_ASSETS_FAILURE, error})); + .catch((error) => dispatch({type: FETCH_ASSETS_FAILURE, error})); }; // Update an asset state @@ -34,9 +34,9 @@ export const updateAssetState = (id, closedAt) => (dispatch) => { 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})); + .catch((error) => dispatch({type: UPDATE_ASSET_STATE_FAILURE, error})); }; -export const updateAssets = assets => dispatch => { +export const updateAssets = (assets) => (dispatch) => { dispatch({type: UPDATE_ASSETS, assets}); }; diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index cb7467e1a..454ccadf2 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -1,75 +1,96 @@ import * as actions from '../constants/auth'; +import * as Storage from 'coral-framework/helpers/storage'; import coralApi from 'coral-framework/helpers/response'; +import {handleAuthToken} from 'coral-framework/actions/auth'; -// Log In. -export const handleLogin = (email, password, recaptchaResponse) => dispatch => { +//============================================================================== +// SIGN IN +//============================================================================== + +export const handleLogin = (email, password, recaptchaResponse) => (dispatch) => { dispatch({type: actions.LOGIN_REQUEST}); const params = {method: 'POST', body: {email, password}}; if (recaptchaResponse) { params.headers = {'X-Recaptcha-Response': recaptchaResponse}; } return coralApi('/auth/local', params) - .then(({user}) => { + .then(({user, token}) => { if (!user) { + Storage.removeItem('token'); return dispatch(checkLoginFailure('not logged in')); } - - const isAdmin = !!user.roles.filter(i => i === 'ADMIN').length; + dispatch(handleAuthToken(token)); + const isAdmin = !!user.roles.filter((i) => i === 'ADMIN').length; dispatch(checkLoginSuccess(user, isAdmin)); }) - .catch(error => { - + .catch((error) => { if (error.translation_key === 'login_maximum_exceeded') { - dispatch({type: actions.LOGIN_MAXIMUM_EXCEEDED, message: error.translation_key}); + dispatch({ + type: actions.LOGIN_MAXIMUM_EXCEEDED, + message: error.translation_key + }); } else { dispatch({type: actions.LOGIN_FAILURE, message: error.translation_key}); } }); }; -const forgotPassowordRequest = () => ({type: actions.FETCH_FORGOT_PASSWORD_REQUEST}); -const forgotPassowordSuccess = () => ({type: actions.FETCH_FORGOT_PASSWORD_SUCCESS}); -const forgotPassowordFailure = () => ({type: actions.FETCH_FORGOT_PASSWORD_FAILURE}); +//============================================================================== +// FORGOT PASSWORD +//============================================================================== -export const requestPasswordReset = email => dispatch => { +const forgotPassowordRequest = () => ({ + type: actions.FETCH_FORGOT_PASSWORD_REQUEST +}); + +const forgotPassowordSuccess = () => ({ + type: actions.FETCH_FORGOT_PASSWORD_SUCCESS +}); + +const forgotPassowordFailure = () => ({ + type: actions.FETCH_FORGOT_PASSWORD_FAILURE +}); + +export const requestPasswordReset = (email) => (dispatch) => { dispatch(forgotPassowordRequest(email)); return coralApi('/account/password/reset', {method: 'POST', body: {email}}) .then(() => dispatch(forgotPassowordSuccess())) - .catch(error => dispatch(forgotPassowordFailure(error))); + .catch((error) => dispatch(forgotPassowordFailure(error))); }; -// Check Login +//============================================================================== +// CHECK LOGIN +//============================================================================== -const checkLoginRequest = () => ({type: actions.CHECK_LOGIN_REQUEST}); -const checkLoginSuccess = (user, isAdmin) => ({type: actions.CHECK_LOGIN_SUCCESS, user, isAdmin}); -const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error}); +const checkLoginRequest = () => ({ + type: actions.CHECK_LOGIN_REQUEST +}); -export const checkLogin = () => dispatch => { +const checkLoginSuccess = (user, isAdmin) => ({ + type: actions.CHECK_LOGIN_SUCCESS, + user, + isAdmin +}); + +const checkLoginFailure = (error) => ({ + type: actions.CHECK_LOGIN_FAILURE, + error +}); + +export const checkLogin = () => (dispatch) => { dispatch(checkLoginRequest()); return coralApi('/auth') .then(({user}) => { if (!user) { + Storage.removeItem('token'); return dispatch(checkLoginFailure('not logged in')); } - const isAdmin = !!user.roles.filter(i => i === 'ADMIN').length; + const isAdmin = !!user.roles.filter((i) => i === 'ADMIN').length; dispatch(checkLoginSuccess(user, isAdmin)); }) - .catch(error => { + .catch((error) => { console.error(error); dispatch(checkLoginFailure(`${error.translation_key}`)); }); }; - -// LogOut Actions - -const logOutRequest = () => ({type: actions.LOGOUT_REQUEST}); -const logOutSuccess = () => ({type: actions.LOGOUT_SUCCESS}); -const logOutFailure = () => ({type: actions.LOGOUT_FAILURE}); - -export const logout = () => dispatch => { - dispatch(logOutRequest()); - return coralApi('/auth', {method: 'DELETE'}) - .then(() => dispatch(logOutSuccess())) - .catch(error => dispatch(logOutFailure(error))); -}; diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index 0f53ea3da..9a8743762 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -16,7 +16,7 @@ import { import coralApi from '../../../coral-framework/helpers/response'; -export const fetchAccounts = (query = {}) => dispatch => { +export const fetchAccounts = (query = {}) => (dispatch) => { dispatch(requestFetchAccounts()); coralApi(`/users?${qs.stringify(query)}`) @@ -30,14 +30,14 @@ export const fetchAccounts = (query = {}) => dispatch => { totalPages }); }) - .catch(error => dispatch({type: FETCH_COMMENTERS_FAILURE, error})); + .catch((error) => dispatch({type: FETCH_COMMENTERS_FAILURE, error})); }; const requestFetchAccounts = () => ({ type: FETCH_COMMENTERS_REQUEST }); -export const updateSorting = sort => ({ +export const updateSorting = (sort) => ({ type: SORT_UPDATE, sort }); diff --git a/client/coral-admin/src/actions/config.js b/client/coral-admin/src/actions/config.js index dca0f7117..4f3528be0 100644 --- a/client/coral-admin/src/actions/config.js +++ b/client/coral-admin/src/actions/config.js @@ -1,6 +1,6 @@ export const CONFIG_UPDATED = 'CONFIG_UPDATED'; -export const fetchConfig = () => dispatch => { +export const fetchConfig = () => (dispatch) => { let json = document.getElementById('data'); let data = JSON.parse(json.textContent); dispatch({type: CONFIG_UPDATED, data}); diff --git a/client/coral-admin/src/actions/install.js b/client/coral-admin/src/actions/install.js index ceecf16c9..21ff58de1 100644 --- a/client/coral-admin/src/actions/install.js +++ b/client/coral-admin/src/actions/install.js @@ -5,14 +5,14 @@ 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}); +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 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 hasError = (error) => ({type: actions.HAS_ERROR, error}); const clearErrors = () => ({type: actions.CLEAR_ERRORS}); const validation = (formData, dispatch, next) => { @@ -21,11 +21,11 @@ const validation = (formData, dispatch, next) => { } const validKeys = Object.keys(formData) - .filter(name => name !== 'domains'); + .filter((name) => name !== 'domains'); // Required Validation const empty = validKeys - .filter(name => { + .filter((name) => { const cond = !formData[name].length; if (cond) { @@ -45,7 +45,7 @@ const validation = (formData, dispatch, next) => { // RegExp Validation const validation = validKeys - .filter(name => { + .filter((name) => { const cond = !validate[name](formData[name]); if (cond) { @@ -88,7 +88,7 @@ export const finishInstall = () => (dispatch, getState) => { dispatch(installSuccess()); dispatch(nextStep()); }) - .catch(error => { + .catch((error) => { console.error(error); dispatch(installFailure(`${error.translation_key}`)); }); @@ -99,10 +99,10 @@ export const updateUserFormData = (name, value) => ({type: actions.UPDATE_FORMDA export const updatePermittedDomains = (value) => ({type: actions.UPDATE_PERMITTED_DOMAINS_SETTINGS, 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}); +const checkInstallSuccess = (installed) => ({type: actions.CHECK_INSTALL_SUCCESS, installed}); +const checkInstallFailure = (error) => ({type: actions.CHECK_INSTALL_FAILURE, error}); -export const checkInstall = next => dispatch => { +export const checkInstall = (next) => (dispatch) => { dispatch(checkInstallRequest()); coralApi('/setup') .then(({installed}) => { @@ -111,7 +111,7 @@ export const checkInstall = next => dispatch => { next(); } }) - .catch(error => { + .catch((error) => { console.error(error); dispatch(checkInstallFailure(`${error.translation_key}`)); }); diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index b7117181e..9c9d5a571 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -1,10 +1,10 @@ import * as actions from 'constants/moderation'; -export const toggleModal = open => ({type: actions.TOGGLE_MODAL, open}); +export const toggleModal = (open) => ({type: actions.TOGGLE_MODAL, open}); export const singleView = () => ({type: actions.SINGLE_VIEW}); // Ban User Dialog -export const showBanUserDialog = (user, commentId, showRejectedNote) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId, showRejectedNote}); +export const showBanUserDialog = (user, commentId, commentStatus, showRejectedNote) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId, commentStatus, showRejectedNote}); export const hideBanUserDialog = (showDialog) => ({type: actions.HIDE_BANUSER_DIALOG, showDialog}); // hide shortcuts note diff --git a/client/coral-admin/src/actions/settings.js b/client/coral-admin/src/actions/settings.js index d6eb183ad..b0530e77e 100644 --- a/client/coral-admin/src/actions/settings.js +++ b/client/coral-admin/src/actions/settings.js @@ -13,19 +13,19 @@ export const SAVE_SETTINGS_FAILED = 'SAVE_SETTINGS_FAILED'; export const WORDLIST_UPDATED = 'WORDLIST_UPDATED'; export const DOMAINLIST_UPDATED = 'DOMAINLIST_UPDATED'; -export const fetchSettings = () => dispatch => { +export const fetchSettings = () => (dispatch) => { dispatch({type: SETTINGS_LOADING}); coralApi('/settings') - .then(settings => { + .then((settings) => { dispatch({type: SETTINGS_RECEIVED, settings}); }) - .catch(error => { + .catch((error) => { dispatch({type: SETTINGS_FETCH_ERROR, error}); }); }; // for updating top-level settings -export const updateSettings = settings => { +export const updateSettings = (settings) => { return {type: SETTINGS_UPDATED, settings}; }; @@ -48,7 +48,7 @@ export const saveSettingsToServer = () => (dispatch, getState) => { .then(() => { dispatch({type: SAVE_SETTINGS_SUCCESS, settings}); }) - .catch(error => { + .catch((error) => { dispatch({type: SAVE_SETTINGS_FAILED, error}); }); }; diff --git a/client/coral-admin/src/actions/users.js b/client/coral-admin/src/actions/users.js index 954d89820..2642db90d 100644 --- a/client/coral-admin/src/actions/users.js +++ b/client/coral-admin/src/actions/users.js @@ -9,8 +9,8 @@ export const userStatusUpdate = (status, userId, commentId) => { return (dispatch) => { dispatch({type: userTypes.UPDATE_STATUS_REQUEST}); return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}}) - .then(res => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res})) - .catch(error => dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error})); + .then((res) => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res})) + .catch((error) => dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error})); }; }; @@ -18,7 +18,7 @@ export const userStatusUpdate = (status, userId, commentId) => { export const sendNotificationEmail = (userId, subject, body) => { return (dispatch) => { return coralApi(`/users/${userId}/email`, {method: 'POST', body: {subject, body}}) - .catch(error => dispatch({type: userTypes.USER_EMAIL_FAILURE, error})); + .catch((error) => dispatch({type: userTypes.USER_EMAIL_FAILURE, error})); }; }; @@ -26,6 +26,6 @@ export const sendNotificationEmail = (userId, subject, body) => { export const enableUsernameEdit = (userId) => { return (dispatch) => { return coralApi(`/users/${userId}/username-enable`, {method: 'POST'}) - .catch(error => dispatch({type: userTypes.USERNAME_ENABLE_FAILURE, error})); + .catch((error) => dispatch({type: userTypes.USERNAME_ENABLE_FAILURE, error})); }; }; diff --git a/client/coral-admin/src/components/AdminLogin.js b/client/coral-admin/src/components/AdminLogin.js index a36e0fe0d..f539a5e67 100644 --- a/client/coral-admin/src/components/AdminLogin.js +++ b/client/coral-admin/src/components/AdminLogin.js @@ -13,7 +13,7 @@ class AdminLogin extends React.Component { this.state = {email: '', password: '', requestPassword: false}; } - handleSignIn = e => { + handleSignIn = (e) => { e.preventDefault(); this.props.handleLogin(this.state.email, this.state.password); } @@ -27,7 +27,7 @@ class AdminLogin extends React.Component { this.props.handleLogin(this.state.email, this.state.password, recaptchaResponse); } - handleRequestPassword = e => { + handleRequestPassword = (e) => { e.preventDefault(); this.props.requestPasswordReset(this.state.email); } @@ -40,11 +40,11 @@ class AdminLogin extends React.Component { this.setState({email: e.target.value})} /> + onChange={(e) => this.setState({email: e.target.value})} /> this.setState({password: e.target.value})} + onChange={(e) => this.setState({password: e.target.value})} type='password' />

- Forgot your password? { + Forgot your password? { e.preventDefault(); this.setState({requestPassword: true}); }}>Request a new one. @@ -81,7 +81,7 @@ class AdminLogin extends React.Component { this.setState({email: e.target.value})} /> + onChange={(e) => this.setState({email: e.target.value})} /> -

diff --git a/client/coral-admin/src/components/EmptyCard.js b/client/coral-admin/src/components/EmptyCard.js index 42337a892..ae291bcad 100644 --- a/client/coral-admin/src/components/EmptyCard.js +++ b/client/coral-admin/src/components/EmptyCard.js @@ -1,7 +1,7 @@ import React, {PropTypes} from 'react'; import {Card} from 'coral-ui'; -const EmptyCard = props => ( +const EmptyCard = (props) => ( {props.children} diff --git a/client/coral-admin/src/components/ModerationKeysModal.js b/client/coral-admin/src/components/ModerationKeysModal.js index 1dac10142..606e30ff2 100644 --- a/client/coral-admin/src/components/ModerationKeysModal.js +++ b/client/coral-admin/src/components/ModerationKeysModal.js @@ -58,8 +58,8 @@ export default class ModerationKeysModal extends React.Component { - {Object.keys(shortcut.shortcuts).map(key => ( - + {Object.keys(shortcut.shortcuts).map((key) => ( + {key} {lang.t(shortcut.shortcuts[key])} diff --git a/client/coral-admin/src/components/ModerationList.css b/client/coral-admin/src/components/ModerationList.css index 1e871f97c..4d4f37c9a 100644 --- a/client/coral-admin/src/components/ModerationList.css +++ b/client/coral-admin/src/components/ModerationList.css @@ -193,10 +193,12 @@ box-shadow: none; color: white; background-color: #519954; + cursor: not-allowed; } .reject__active, .rejected__active { color: white; background-color: #D03235; box-shadow: none; + cursor: not-allowed; } diff --git a/client/coral-admin/src/components/ModerationList.js b/client/coral-admin/src/components/ModerationList.js index 47e91e551..fae85e492 100644 --- a/client/coral-admin/src/components/ModerationList.js +++ b/client/coral-admin/src/components/ModerationList.js @@ -72,7 +72,7 @@ export default class ModerationList extends React.Component { // Add key handlers. Each action has one and added j/k for moving around bindKeyHandlers () { const {modActions, isActive} = this.props; - modActions.filter(action => menuOptionsMap[action].key).forEach(action => { + modActions.filter((action) => menuOptionsMap[action].key).forEach((action) => { key(menuOptionsMap[action].key, 'moderationList', () => isActive && this.actionKeyHandler(menuOptionsMap[action].status)); }); key('j', 'moderationList', () => isActive && this.moveKeyHandler('down')); diff --git a/client/coral-admin/src/constants/auth.js b/client/coral-admin/src/constants/auth.js index 93cd61544..a0c06f72e 100644 --- a/client/coral-admin/src/constants/auth.js +++ b/client/coral-admin/src/constants/auth.js @@ -2,11 +2,7 @@ export const CHECK_LOGIN_REQUEST = 'CHECK_LOGIN_REQUEST'; export const CHECK_LOGIN_SUCCESS = 'CHECK_LOGIN_SUCCESS'; export const CHECK_LOGIN_FAILURE = 'CHECK_LOGIN_FAILURE'; -export const CHECK_CSRF_TOKEN = 'CHECK_CSRF_TOKEN'; - -export const LOGOUT_REQUEST = 'LOGOUT_REQUEST'; -export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS'; -export const LOGOUT_FAILURE = 'LOGOUT_FAILURE'; +export const LOGOUT = 'LOGOUT'; export const LOGIN_REQUEST = 'LOGIN_REQUEST'; export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; diff --git a/client/coral-admin/src/containers/Community/CommunityContainer.js b/client/coral-admin/src/containers/Community/CommunityContainer.js index ecee9336c..899958e17 100644 --- a/client/coral-admin/src/containers/Community/CommunityContainer.js +++ b/client/coral-admin/src/containers/Community/CommunityContainer.js @@ -148,12 +148,12 @@ class CommunityContainer extends Component { } } -const mapStateToProps = state => ({ +const mapStateToProps = (state) => ({ community: state.community.toJS() }); -const mapDispatchToProps = dispatch => ({ - fetchAccounts: query => dispatch(fetchAccounts(query)), +const mapDispatchToProps = (dispatch) => ({ + fetchAccounts: (query) => dispatch(fetchAccounts(query)), showBanUserDialog: (user) => dispatch(showBanUserDialog(user)), hideBanUserDialog: () => dispatch(hideBanUserDialog(false)), showSuspendUserDialog: (user) => dispatch(showSuspendUserDialog(user)), diff --git a/client/coral-admin/src/containers/Community/CommunityLayout.js b/client/coral-admin/src/containers/Community/CommunityLayout.js index d6022fed9..0665b967d 100644 --- a/client/coral-admin/src/containers/Community/CommunityLayout.js +++ b/client/coral-admin/src/containers/Community/CommunityLayout.js @@ -1,6 +1,6 @@ import React from 'react'; -const CommunityLayout = props => ( +const CommunityLayout = (props) => (
{props.children}
diff --git a/client/coral-admin/src/containers/Community/Table.js b/client/coral-admin/src/containers/Community/Table.js index 49a2a2520..e43b81813 100644 --- a/client/coral-admin/src/containers/Community/Table.js +++ b/client/coral-admin/src/containers/Community/Table.js @@ -53,7 +53,7 @@ class Table extends Component { this.onCommenterStatusChange(row.id, status)}> + onChange={(status) => this.onCommenterStatusChange(row.id, status)}> @@ -62,7 +62,7 @@ class Table extends Component { this.onRoleChange(row.id, role)}> + onChange={(role) => this.onRoleChange(row.id, role)}> @@ -76,4 +76,4 @@ class Table extends Component { } } -export default connect(state => ({commenters: state.community.get('accounts')}))(Table); +export default connect((state) => ({commenters: state.community.get('accounts')}))(Table); diff --git a/client/coral-admin/src/containers/Community/components/User.js b/client/coral-admin/src/containers/Community/components/User.js index 8e620b6a2..3e6b5588b 100644 --- a/client/coral-admin/src/containers/Community/components/User.js +++ b/client/coral-admin/src/containers/Community/components/User.js @@ -7,7 +7,7 @@ import I18n from 'coral-i18n/modules/i18n/i18n'; const lang = new I18n(); // Render a single user for the list -const User = props => { +const User = (props) => { const {user, modActionButtons} = props; let userStatus = user.status; @@ -35,7 +35,7 @@ const User = props => {
flag{lang.t('community.flags')}({ user.actions.length }): { user.action_summaries.map( - (action, i ) => { + (action, i) => { return {lang.t(`community.${action.reason}`)} ({action.count}) ; @@ -44,7 +44,7 @@ const User = props => {
{ user.action_summaries.map( - (action_sum, i ) => { + (action_sum, i) => { return
{lang.t(`community.${action_sum.reason}`)} ({action_sum.count}) diff --git a/client/coral-admin/src/containers/Configure/Configure.js b/client/coral-admin/src/containers/Configure/Configure.js index b7f4eb602..2b8ecf04d 100644 --- a/client/coral-admin/src/containers/Configure/Configure.js +++ b/client/coral-admin/src/containers/Configure/Configure.js @@ -170,7 +170,7 @@ class Configure extends Component { } } -const mapStateToProps = state => ({ +const mapStateToProps = (state) => ({ settings: state.settings.toJS() }); export default connect(mapStateToProps)(Configure); diff --git a/client/coral-admin/src/containers/Configure/Domainlist.js b/client/coral-admin/src/containers/Configure/Domainlist.js index 719a7f88d..b3d88073e 100644 --- a/client/coral-admin/src/containers/Configure/Domainlist.js +++ b/client/coral-admin/src/containers/Configure/Domainlist.js @@ -17,8 +17,8 @@ const Domainlist = ({domains, onChangeDomainlist}) => { value={domains} inputProps={{placeholder: 'URL'}} addOnPaste={true} - pasteSplit={data => data.split(',').map(d => d.trim())} - onChange={tags => onChangeDomainlist('whitelist', tags)} + pasteSplit={(data) => data.split(',').map((d) => d.trim())} + onChange={(tags) => onChangeDomainlist('whitelist', tags)} />
diff --git a/client/coral-admin/src/containers/Configure/EmbedLink.js b/client/coral-admin/src/containers/Configure/EmbedLink.js index 425afb082..561517238 100644 --- a/client/coral-admin/src/containers/Configure/EmbedLink.js +++ b/client/coral-admin/src/containers/Configure/EmbedLink.js @@ -30,7 +30,7 @@ class EmbedLink extends Component { location.protocol, '//', location.hostname, - location.port ? (`:${ window.location.port}`) : '' + location.port ? (`:${window.location.port}`) : '' ].join(''); const coralJsUrl = [talkBaseUrl, '/embed.js'].join(''); const nonce = String(Math.random()).slice(2); diff --git a/client/coral-admin/src/containers/Configure/StreamSettings.js b/client/coral-admin/src/containers/Configure/StreamSettings.js index 0abedf65a..ad52af833 100644 --- a/client/coral-admin/src/containers/Configure/StreamSettings.js +++ b/client/coral-admin/src/containers/Configure/StreamSettings.js @@ -170,7 +170,7 @@ export default StreamSettings; // To see if we are talking about weeks, days or hours // We talk the remainder of the division and see if it's 0 -const getTimeoutMeasure = ts => { +const getTimeoutMeasure = (ts) => { if (ts % TIMESTAMPS['weeks'] === 0) { return 'weeks'; } else if (ts % TIMESTAMPS['days'] === 0) { @@ -182,6 +182,6 @@ const getTimeoutMeasure = ts => { // Dividing the amount by it's measure (hours, days, weeks) we // obtain the amount of time -const getTimeoutAmount = ts => ts / TIMESTAMPS[getTimeoutMeasure(ts)]; +const getTimeoutAmount = (ts) => ts / TIMESTAMPS[getTimeoutMeasure(ts)]; const lang = new I18n(); diff --git a/client/coral-admin/src/containers/Configure/Wordlist.js b/client/coral-admin/src/containers/Configure/Wordlist.js index 577fa6ac5..a92a29782 100644 --- a/client/coral-admin/src/containers/Configure/Wordlist.js +++ b/client/coral-admin/src/containers/Configure/Wordlist.js @@ -15,8 +15,8 @@ const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => ( value={bannedWords} inputProps={{placeholder: 'word or phrase'}} addOnPaste={true} - pasteSplit={data => data.split(',').map(d => d.trim())} - onChange={tags => onChangeWordlist('banned', tags)} + pasteSplit={(data) => data.split(',').map((d) => d.trim())} + onChange={(tags) => onChangeWordlist('banned', tags)} /> @@ -28,8 +28,8 @@ const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => ( value={suspectWords} inputProps={{placeholder: 'word or phrase'}} addOnPaste={true} - pasteSplit={data => data.split(',').map(d => d.trim())} - onChange={tags => onChangeWordlist('suspect', tags)} /> + pasteSplit={(data) => data.split(',').map((d) => d.trim())} + onChange={(tags) => onChangeWordlist('suspect', tags)} /> diff --git a/client/coral-admin/src/containers/Dashboard/ActivityWidget.js b/client/coral-admin/src/containers/Dashboard/ActivityWidget.js index e1a48d8e4..b2d0c054c 100644 --- a/client/coral-admin/src/containers/Dashboard/ActivityWidget.js +++ b/client/coral-admin/src/containers/Dashboard/ActivityWidget.js @@ -16,7 +16,7 @@ const ActivityWidget = ({assets}) => {
{ assets.length - ? assets.map(asset => { + ? assets.map((asset) => { return (
Moderate diff --git a/client/coral-admin/src/containers/Dashboard/Dashboard.js b/client/coral-admin/src/containers/Dashboard/Dashboard.js index f5d0aa39b..e764bf596 100644 --- a/client/coral-admin/src/containers/Dashboard/Dashboard.js +++ b/client/coral-admin/src/containers/Dashboard/Dashboard.js @@ -6,7 +6,6 @@ import {getMetrics} from 'coral-admin/src/graphql/queries'; import FlagWidget from './FlagWidget'; import ActivityWidget from './ActivityWidget'; import CountdownTimer from 'coral-admin/src/components/CountdownTimer'; -import {showBanUserDialog, hideBanUserDialog} from 'coral-admin/src/actions/moderation'; import {Spinner} from 'coral-ui'; @@ -36,19 +35,14 @@ class Dashboard extends React.Component { } } -const mapStateToProps = state => { +const mapStateToProps = (state) => { return { settings: state.settings.toJS(), moderation: state.moderation.toJS() }; }; -const mapDispatchToProps = dispatch => ({ - showBanUserDialog: (user, commentId) => dispatch(showBanUserDialog(user, commentId)), - hideBanUserDialog: () => dispatch(hideBanUserDialog(false)) -}); - export default compose( - connect(mapStateToProps, mapDispatchToProps), + connect(mapStateToProps), getMetrics )(Dashboard); diff --git a/client/coral-admin/src/containers/Dashboard/FlagWidget.js b/client/coral-admin/src/containers/Dashboard/FlagWidget.js index ae7fdc13d..7095abca2 100644 --- a/client/coral-admin/src/containers/Dashboard/FlagWidget.js +++ b/client/coral-admin/src/containers/Dashboard/FlagWidget.js @@ -17,10 +17,10 @@ const FlagWidget = ({assets}) => {
{ assets.length - ? assets.map(asset => { + ? assets.map((asset) => { let flagSummary = null; if (asset.action_summaries) { - flagSummary = asset.action_summaries.find(s => s.__typename === 'FlagAssetActionSummary'); + flagSummary = asset.action_summaries.find((s) => s.__typename === 'FlagAssetActionSummary'); } return ( diff --git a/client/coral-admin/src/containers/Dashboard/LikeWidget.js b/client/coral-admin/src/containers/Dashboard/LikeWidget.js index 2581008c1..630f657af 100644 --- a/client/coral-admin/src/containers/Dashboard/LikeWidget.js +++ b/client/coral-admin/src/containers/Dashboard/LikeWidget.js @@ -17,8 +17,8 @@ const LikeWidget = ({assets}) => {
{ assets.length - ? assets.map(asset => { - const likeSummary = asset.action_summaries.find(s => s.type === 'LikeAssetActionSummary'); + ? assets.map((asset) => { + const likeSummary = asset.action_summaries.find((s) => s.type === 'LikeAssetActionSummary'); return (
Moderate diff --git a/client/coral-admin/src/containers/Dashboard/MostLikedCommentsWidget.js b/client/coral-admin/src/containers/Dashboard/MostLikedCommentsWidget.js index 7d125f999..0621c6022 100644 --- a/client/coral-admin/src/containers/Dashboard/MostLikedCommentsWidget.js +++ b/client/coral-admin/src/containers/Dashboard/MostLikedCommentsWidget.js @@ -7,7 +7,7 @@ import BanUserDialog from 'coral-admin/src/components/BanUserDialog'; const lang = new I18n(); -const MostLikedCommentsWidget = props => { +const MostLikedCommentsWidget = (props) => { const { comments, moderation, diff --git a/client/coral-admin/src/containers/Install/InstallContainer.js b/client/coral-admin/src/containers/Install/InstallContainer.js index 75c41387a..67f266123 100644 --- a/client/coral-admin/src/containers/Install/InstallContainer.js +++ b/client/coral-admin/src/containers/Install/InstallContainer.js @@ -64,32 +64,32 @@ InstallContainer.contextTypes = { router: React.PropTypes.object }; -const mapStateToProps = state => ({ +const mapStateToProps = (state) => ({ install: state.install.toJS() }); -const mapDispatchToProps = dispatch => ({ +const mapDispatchToProps = (dispatch) => ({ nextStep: () => dispatch(nextStep()), - goToStep: step => dispatch(goToStep(step)), + goToStep: (step) => dispatch(goToStep(step)), previousStep: () => dispatch(previousStep()), finishInstall: () => dispatch(finishInstall()), - checkInstall: next => dispatch(checkInstall(next)), - handleDomainsChange: value => { + checkInstall: (next) => dispatch(checkInstall(next)), + handleDomainsChange: (value) => { dispatch(updatePermittedDomains(value)); }, - handleSettingsChange: e => { + handleSettingsChange: (e) => { const {name, value} = e.currentTarget; dispatch(updateSettingsFormData(name, value)); }, - handleUserChange: e => { + handleUserChange: (e) => { const {name, value} = e.currentTarget; dispatch(updateUserFormData(name, value)); }, - handleSettingsSubmit: e => { + handleSettingsSubmit: (e) => { e.preventDefault(); dispatch(submitSettings()); }, - handleUserSubmit: e => { + handleUserSubmit: (e) => { e.preventDefault(); dispatch(submitUser()); } diff --git a/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js b/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js index 020b51423..eb8a95716 100644 --- a/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js +++ b/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js @@ -6,7 +6,7 @@ const lang = new I18n(); import I18n from 'coral-i18n/modules/i18n/i18n'; -const AddOrganizationName = props => { +const AddOrganizationName = (props) => { const {handleSettingsChange, handleSettingsSubmit, install} = props; return (
diff --git a/client/coral-admin/src/containers/Install/components/Steps/CreateYourAccount.js b/client/coral-admin/src/containers/Install/components/Steps/CreateYourAccount.js index d250a4e93..b8a83d34a 100644 --- a/client/coral-admin/src/containers/Install/components/Steps/CreateYourAccount.js +++ b/client/coral-admin/src/containers/Install/components/Steps/CreateYourAccount.js @@ -6,7 +6,7 @@ const lang = new I18n(); import I18n from 'coral-i18n/modules/i18n/i18n'; -const InitialStep = props => { +const InitialStep = (props) => { const {handleUserChange, handleUserSubmit, install} = props; return (
diff --git a/client/coral-admin/src/containers/Install/components/Steps/InitialStep.js b/client/coral-admin/src/containers/Install/components/Steps/InitialStep.js index 6e3d66f84..316af3d87 100644 --- a/client/coral-admin/src/containers/Install/components/Steps/InitialStep.js +++ b/client/coral-admin/src/containers/Install/components/Steps/InitialStep.js @@ -6,7 +6,7 @@ const lang = new I18n(); import I18n from 'coral-i18n/modules/i18n/i18n'; -const InitialStep = props => { +const InitialStep = (props) => { const {nextStep} = props; return (
diff --git a/client/coral-admin/src/containers/Install/components/Steps/InviteTeamMembers.js b/client/coral-admin/src/containers/Install/components/Steps/InviteTeamMembers.js index 35a4a41de..880aa69a4 100644 --- a/client/coral-admin/src/containers/Install/components/Steps/InviteTeamMembers.js +++ b/client/coral-admin/src/containers/Install/components/Steps/InviteTeamMembers.js @@ -2,7 +2,7 @@ import React from 'react'; import styles from './style.css'; import {Button, Select, Option, TextField} from 'coral-ui'; -const InviteTeamMembers = props => { +const InviteTeamMembers = (props) => { const {nextStep} = props; return (
diff --git a/client/coral-admin/src/containers/Install/components/Steps/PermittedDomainsStep.js b/client/coral-admin/src/containers/Install/components/Steps/PermittedDomainsStep.js index da720799d..23621f2ca 100644 --- a/client/coral-admin/src/containers/Install/components/Steps/PermittedDomainsStep.js +++ b/client/coral-admin/src/containers/Install/components/Steps/PermittedDomainsStep.js @@ -7,7 +7,7 @@ const lang = new I18n(); import I18n from 'coral-i18n/modules/i18n/i18n'; -const PermittedDomainsStep = props => { +const PermittedDomainsStep = (props) => { const {finishInstall, install, handleDomainsChange} = props; const domains = install.data.settings.domains.whitelist; return ( @@ -19,8 +19,8 @@ const PermittedDomainsStep = props => { value={domains} inputProps={{placeholder: 'URL'}} addOnPaste={true} - pasteSplit={data => data.split(',').map(d => d.trim())} - onChange={tags => handleDomainsChange(tags)} + pasteSplit={(data) => data.split(',').map((d) => d.trim())} + onChange={(tags) => handleDomainsChange(tags)} /> diff --git a/client/coral-admin/src/containers/LayoutContainer.js b/client/coral-admin/src/containers/LayoutContainer.js index 42e2baade..31110d3bd 100644 --- a/client/coral-admin/src/containers/LayoutContainer.js +++ b/client/coral-admin/src/containers/LayoutContainer.js @@ -1,20 +1,21 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import Layout from '../components/ui/Layout'; -import {checkLogin, handleLogin, logout, requestPasswordReset} from '../actions/auth'; -import {toggleModal as toggleShortcutModal} from '../actions/moderation'; import {fetchConfig} from '../actions/config'; -import {FullLoading} from '../components/FullLoading'; import AdminLogin from '../components/AdminLogin'; +import {logout} from 'coral-framework/actions/auth'; +import {FullLoading} from '../components/FullLoading'; +import {toggleModal as toggleShortcutModal} from '../actions/moderation'; +import {checkLogin, handleLogin, requestPasswordReset} from '../actions/auth'; class LayoutContainer extends Component { - componentWillMount () { + componentWillMount() { const {checkLogin, fetchConfig} = this.props; checkLogin(); fetchConfig(); } - render () { + render() { const { isAdmin, loggedIn, @@ -24,39 +25,54 @@ class LayoutContainer extends Component { passwordRequestSuccess } = this.props.auth; - const {handleLogout, toggleShortcutModal, TALK_RECAPTCHA_PUBLIC} = this.props; - if (loadingUser) { return ; } + const { + handleLogout, + toggleShortcutModal, + TALK_RECAPTCHA_PUBLIC + } = this.props; + if (loadingUser) { + return ; + } if (!isAdmin) { - return ; + return ( + + ); } if (isAdmin && loggedIn) { - return ; + return ( + + ); } return ; } } -const mapStateToProps = state => ({ +const mapStateToProps = (state) => ({ auth: state.auth.toJS(), - TALK_RECAPTCHA_PUBLIC: state.config.get('data').get('TALK_RECAPTCHA_PUBLIC', null) + TALK_RECAPTCHA_PUBLIC: state.config + .get('data') + .get('TALK_RECAPTCHA_PUBLIC', null) }); -const mapDispatchToProps = dispatch => ({ +const mapDispatchToProps = (dispatch) => ({ checkLogin: () => dispatch(checkLogin()), fetchConfig: () => dispatch(fetchConfig()), - handleLogin: (username, password, recaptchaResponse) => dispatch(handleLogin(username, password, recaptchaResponse)), - requestPasswordReset: email => dispatch(requestPasswordReset(email)), - toggleShortcutModal: toggle => dispatch(toggleShortcutModal(toggle)), + handleLogin: (username, password, recaptchaResponse) => + dispatch(handleLogin(username, password, recaptchaResponse)), + requestPasswordReset: (email) => dispatch(requestPasswordReset(email)), + toggleShortcutModal: (toggle) => dispatch(toggleShortcutModal(toggle)), handleLogout: () => dispatch(logout()) }); -export default connect( - mapStateToProps, - mapDispatchToProps -)(LayoutContainer); +export default connect(mapStateToProps, mapDispatchToProps)(LayoutContainer); diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index 7a3aab909..3d1c1dd7a 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -43,12 +43,13 @@ class ModerationContainer extends Component { const {acceptComment, rejectComment} = this.props; const {selectedIndex} = this.state; const comments = this.getComments(); - const commentId = {commentId: comments[selectedIndex].id}; + const comment = comments[selectedIndex]; + const commentId = {commentId: comment.id}; if (accept) { - acceptComment(commentId); + comment.status !== 'ACCEPTED' && acceptComment(commentId); } else { - rejectComment(commentId); + comment.status !== 'REJECTED' && rejectComment(commentId); } } @@ -60,14 +61,14 @@ class ModerationContainer extends Component { select = (next) => () => { if (next) { - this.setState(prevState => + this.setState((prevState) => ({ ...prevState, selectedIndex: prevState.selectedIndex < this.getComments().length - 1 ? prevState.selectedIndex + 1 : prevState.selectedIndex })); } else { - this.setState(prevState => + this.setState((prevState) => ({ ...prevState, selectedIndex: prevState.selectedIndex > 0 ? @@ -125,7 +126,7 @@ class ModerationContainer extends Component { } if (providedAssetId) { - asset = assets.find(asset => asset.id === this.props.params.id); + asset = assets.find((asset) => asset.id === this.props.params.id); if (!asset) { return ; @@ -185,6 +186,7 @@ class ModerationContainer extends Component { open={moderation.banDialog} user={moderation.user} commentId={moderation.commentId} + commentStatus={moderation.commentStatus} handleClose={props.hideBanUserDialog} handleBanUser={props.banUser} showRejectedNote={moderation.showRejectedNote} @@ -200,19 +202,19 @@ class ModerationContainer extends Component { } } -const mapStateToProps = state => ({ +const mapStateToProps = (state) => ({ moderation: state.moderation.toJS(), settings: state.settings.toJS(), assets: state.assets.get('assets') }); -const mapDispatchToProps = dispatch => ({ - toggleModal: toggle => dispatch(toggleModal(toggle)), +const mapDispatchToProps = (dispatch) => ({ + toggleModal: (toggle) => dispatch(toggleModal(toggle)), onClose: () => dispatch(toggleModal(false)), singleView: () => dispatch(singleView()), - updateAssets: assets => dispatch(updateAssets(assets)), + updateAssets: (assets) => dispatch(updateAssets(assets)), fetchSettings: () => dispatch(fetchSettings()), - showBanUserDialog: (user, commentId, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, showRejectedNote)), + showBanUserDialog: (user, commentId, commentStatus, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, commentStatus, showRejectedNote)), hideBanUserDialog: () => dispatch(hideBanUserDialog(false)), hideShortcutsNote: () => dispatch(hideShortcutsNote()), }); diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationLayout.js b/client/coral-admin/src/containers/ModerationQueue/ModerationLayout.js index 0db97cda0..02a798900 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationLayout.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationLayout.js @@ -1,6 +1,6 @@ import React from 'react'; -const ModerationLayout = props => ( +const ModerationLayout = (props) => (
{props.children}
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js index 09e145f14..66129855b 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js @@ -1,27 +1,36 @@ import React, {PropTypes} from 'react'; import timeago from 'timeago.js'; -import Linkify from 'react-linkify'; -import Highlighter from 'react-highlight-words'; import {Link} from 'react-router'; +import Linkify from 'react-linkify'; -import styles from './styles.css'; import {Icon} from 'coral-ui'; import FlagBox from './FlagBox'; +import styles from './styles.css'; import CommentType from './CommentType'; +import Highlighter from 'react-highlight-words'; +import Slot from 'coral-framework/components/Slot'; +import {getActionSummary} from 'coral-framework/utils'; import ActionButton from 'coral-admin/src/components/ActionButton'; import BanUserButton from 'coral-admin/src/components/BanUserButton'; -import {getActionSummary} from 'coral-framework/utils'; const linkify = new Linkify(); import I18n from 'coral-i18n/modules/i18n/i18n'; const lang = new I18n(); -const Comment = ({actions = [], comment, ...props}) => { +const Comment = ({ + actions = [], + comment, + suspectWords, + bannedWords, + ...props +}) => { const links = linkify.getMatches(comment.body); - const linkText = links ? links.map(link => link.raw) : []; + const linkText = links ? links.map((link) => link.raw) : []; const flagActionSummaries = getActionSummary('FlagActionSummary', comment); - const flagActions = comment.actions && comment.actions.filter(a => a.__typename === 'FlagAction'); + const flagActions = + comment.actions && + comment.actions.filter((a) => a.__typename === 'FlagAction'); let commentType = ''; if (comment.status === 'PREMOD') { commentType = 'premod'; @@ -29,8 +38,20 @@ const Comment = ({actions = [], comment, ...props}) => { commentType = 'flagged'; } + // since words are checked against word boundaries on the backend, + // this should be the behavior on the front end as well. + // currently the highlighter plugin does not support this out of the box. + const searchWords = [...suspectWords, ...bannedWords] + .filter((w) => { + return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body); + }) + .concat(linkText); + return ( -
  • +
  • @@ -38,53 +59,95 @@ const Comment = ({actions = [], comment, ...props}) => { {comment.user.name} - {timeago().format(comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} + {timeago().format( + comment.created_at || Date.now() - props.index * 60 * 1000, + lang.getLocale().replace('-', '_') + )} - props.showBanUserDialog(comment.user, comment.id, comment.status !== 'REJECTED')} /> + + props.showBanUserDialog( + comment.user, + comment.id, + comment.status, + comment.status !== 'REJECTED' + )} + />
    - {comment.user.status === 'banned' ? - - - {lang.t('comment.banned_user')} - + {comment.user.status === 'banned' + ? + + {lang.t('comment.banned_user')} + : null} +
    Story: {comment.asset.title} - {!props.currentAsset && ( - Moderate → - )} + {!props.currentAsset && + Moderate →}

    + searchWords={searchWords} + textToHighlight={comment.body} + /> + {' '} + + {lang.t('comment.view_context')} +

    +
    - {links ? Contains Link : null} + {links + ? + Contains Link + + : null}
    {actions.map((action, i) => { - const active = (action === 'REJECT' && comment.status === 'REJECTED') || - (action === 'APPROVE' && comment.status === 'ACCEPTED'); - return props.acceptComment({commentId: comment.id})} - rejectComment={() => props.rejectComment({commentId: comment.id})} />; + const active = + (action === 'REJECT' && comment.status === 'REJECTED') || + (action === 'APPROVE' && comment.status === 'ACCEPTED'); + return ( + + (comment.status === 'ACCEPTED' + ? null + : props.acceptComment({commentId: comment.id}))} + rejectComment={() => + (comment.status === 'REJECTED' + ? null + : props.rejectComment({commentId: comment.id}))} + /> + ); })}
    +
    - { - flagActions && flagActions.length - ? - : null - } +
    + +
    + {flagActions && flagActions.length + ? + : null}
  • ); }; @@ -105,6 +168,7 @@ Comment.propTypes = { }), asset: PropTypes.shape({ title: PropTypes.string, + url: PropTypes.string, id: PropTypes.string }) }) diff --git a/client/coral-admin/src/containers/ModerationQueue/components/CommentType.js b/client/coral-admin/src/containers/ModerationQueue/components/CommentType.js index 5ad4138ca..346b5f1e6 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/CommentType.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/CommentType.js @@ -2,7 +2,7 @@ import React, {PropTypes} from 'react'; import styles from './CommentType.css'; import {Icon} from 'coral-ui'; -const CommentType = props => { +const CommentType = (props) => { const typeData = getTypeData(props.type); return ( @@ -12,7 +12,7 @@ const CommentType = props => { ); }; -const getTypeData = type => { +const getTypeData = (type) => { switch (type) { case 'premod': return {icon: 'query_builder', text: 'Pre-Mod', className: 'premod'}; diff --git a/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js index a3839b390..3247e06f0 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js @@ -54,7 +54,7 @@ class FlagBox extends Component {
      {actionSummaries.map((summary, i) => { - const actionList = actions.filter(a => a.reason === summary.reason); + const actionList = actions.filter((a) => a.reason === summary.reason); return (
    • diff --git a/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js b/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js index e823be9fe..5d6e8b9de 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js @@ -3,7 +3,7 @@ import {Link} from 'react-router'; import {Icon} from 'coral-ui'; import styles from './styles.css'; -const ModerationHeader = props => ( +const ModerationHeader = (props) => (
      { diff --git a/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js b/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js index 8c70ab75f..c0e092da1 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js @@ -27,12 +27,6 @@ const ModerationMenu = ( activeClassName={styles.active}> {lang.t('modqueue.all')} - - {lang.t('modqueue.approved')} - {lang.t('modqueue.flagged')} + + {lang.t('modqueue.approved')} + selectSort(sort)}> + onChange={(sort) => selectSort(sort)}> diff --git a/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js b/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js index 90610a577..ee5d2ccd1 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js @@ -2,7 +2,7 @@ import React from 'react'; import {Link} from 'react-router'; import styles from './styles.css'; -const NotFound = props => ( +const NotFound = (props) => (

      The provided asset id {props.assetId} does not exist. diff --git a/client/coral-admin/src/containers/ModerationQueue/components/styles.css b/client/coral-admin/src/containers/ModerationQueue/components/styles.css index a22fbbc64..966a37526 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/styles.css +++ b/client/coral-admin/src/containers/ModerationQueue/components/styles.css @@ -423,3 +423,24 @@ span { position: relative; top: 7px; } + +.external { + font-size: .7em; + text-decoration: none; + color: #063b9a; + cursor: pointer; + font-weight: normal; + margin-left: 10px; + white-space: nowrap; + + &:hover { + text-decoration: underline; + opacity: .9; + } + + i { + font-size: 12px; + top: 2px; + position: relative; + } +} diff --git a/client/coral-admin/src/containers/Stories/Stories.js b/client/coral-admin/src/containers/Stories/Stories.js index aa905b94c..637c8cf6f 100644 --- a/client/coral-admin/src/containers/Stories/Stories.js +++ b/client/coral-admin/src/containers/Stories/Stories.js @@ -54,7 +54,7 @@ class Stories extends Component { onStatusClick = (closeStream, id, statusMenuOpen) => () => { if (statusMenuOpen) { - this.setState(prev => { + this.setState((prev) => { prev.statusMenus[id] = false; return prev; }); @@ -64,7 +64,7 @@ class Stories extends Component { this.props.fetchAssets(page, limit, search, sort, filter); }); } else { - this.setState(prev => { + this.setState((prev) => { prev.statusMenus[id] = true; return prev; }); diff --git a/client/coral-admin/src/containers/Streams/Stories.js b/client/coral-admin/src/containers/Streams/Stories.js index a92f8a3d6..d5cb3a5f6 100644 --- a/client/coral-admin/src/containers/Streams/Stories.js +++ b/client/coral-admin/src/containers/Streams/Stories.js @@ -54,7 +54,7 @@ class Stories extends Component { onStatusClick = (closeStream, id, statusMenuOpen) => () => { if (statusMenuOpen) { - this.setState(prev => { + this.setState((prev) => { prev.statusMenus[id] = false; return prev; }); @@ -64,7 +64,7 @@ class Stories extends Component { this.props.fetchAssets(page, limit, search, sort, filter); }); } else { - this.setState(prev => { + this.setState((prev) => { prev.statusMenus[id] = true; return prev; }); diff --git a/client/coral-admin/src/graphql/fragments/commentView.graphql b/client/coral-admin/src/graphql/fragments/commentView.graphql index 51b0a3f44..1f3b8b1d3 100644 --- a/client/coral-admin/src/graphql/fragments/commentView.graphql +++ b/client/coral-admin/src/graphql/fragments/commentView.graphql @@ -11,6 +11,7 @@ fragment commentView on Comment { asset { id title + url } action_summaries { count diff --git a/client/coral-admin/src/graphql/mutations/index.js b/client/coral-admin/src/graphql/mutations/index.js index 6b21c8421..16c391907 100644 --- a/client/coral-admin/src/graphql/mutations/index.js +++ b/client/coral-admin/src/graphql/mutations/index.js @@ -56,7 +56,7 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, { updateQueries: { ModQueue: (oldData) => { const comment = views.reduce((comment, view) => { - return comment ? comment : oldData[view].find(c => c.id === commentId); + return comment ? comment : oldData[view].find((c) => c.id === commentId); }, null); let accepted; let acceptedCount = oldData.acceptedCount; @@ -70,9 +70,9 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, { accepted = [comment, ...oldData.accepted]; } - const premod = oldData.premod.filter(c => c.id !== commentId); - const flagged = oldData.flagged.filter(c => c.id !== commentId); - const rejected = oldData.rejected.filter(c => c.id !== commentId); + const premod = oldData.premod.filter((c) => c.id !== commentId); + const flagged = oldData.flagged.filter((c) => c.id !== commentId); + const rejected = oldData.rejected.filter((c) => c.id !== commentId); const premodCount = premod.length < oldData.premod.length ? oldData.premodCount - 1 : oldData.premodCount; const flaggedCount = flagged.length < oldData.flagged.length ? oldData.flaggedCount - 1 : oldData.flaggedCount; const rejectedCount = rejected.length < oldData.rejected.length ? oldData.rejectedCount - 1 : oldData.rejectedCount; @@ -101,7 +101,7 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, { updateQueries: { ModQueue: (oldData) => { const comment = views.reduce((comment, view) => { - return comment ? comment : oldData[view].find(c => c.id === commentId); + return comment ? comment : oldData[view].find((c) => c.id === commentId); }, null); let rejected; let rejectedCount = oldData.rejectedCount; @@ -115,9 +115,9 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, { rejected = [comment, ...oldData.rejected]; } - const premod = oldData.premod.filter(c => c.id !== commentId); - const flagged = oldData.flagged.filter(c => c.id !== commentId); - const accepted = oldData.accepted.filter(c => c.id !== commentId); + const premod = oldData.premod.filter((c) => c.id !== commentId); + const flagged = oldData.flagged.filter((c) => c.id !== commentId); + const accepted = oldData.accepted.filter((c) => c.id !== commentId); const premodCount = premod.length < oldData.premod.length ? oldData.premodCount - 1 : oldData.premodCount; const flaggedCount = flagged.length < oldData.flagged.length ? oldData.flaggedCount - 1 : oldData.flaggedCount; const acceptedCount = accepted.length < oldData.accepted.length ? oldData.acceptedCount - 1 : oldData.acceptedCount; diff --git a/client/coral-admin/src/reducers/auth.js b/client/coral-admin/src/reducers/auth.js index a7054ddfa..1e080d37e 100644 --- a/client/coral-admin/src/reducers/auth.js +++ b/client/coral-admin/src/reducers/auth.js @@ -26,10 +26,8 @@ export default function auth (state = initialState, action) { .set('loadingUser', false) .set('isAdmin', action.isAdmin) .set('user', action.user); - case actions.LOGOUT_SUCCESS: + case actions.LOGOUT: return initialState; - case actions.LOGIN_REQUEST: - return state.set('loginError', null); case actions.LOGIN_SUCCESS: return state.set('loginMaxExceeded', false).set('loginError', null); case actions.LOGIN_FAILURE: diff --git a/client/coral-admin/src/reducers/community.js b/client/coral-admin/src/reducers/community.js index d051ae0ff..7127c6fbe 100644 --- a/client/coral-admin/src/reducers/community.js +++ b/client/coral-admin/src/reducers/community.js @@ -53,17 +53,17 @@ export default function community (state = initialState, action) { } case SET_ROLE : { const commenters = state.get('accounts'); - const idx = commenters.findIndex(el => el.id === action.id); + const idx = commenters.findIndex((el) => el.id === action.id); commenters[idx].roles[0] = action.role; - return state.set('accounts', commenters.map(id => id)); + return state.set('accounts', commenters.map((id) => id)); } case SET_COMMENTER_STATUS: { const commenters = state.get('accounts'); - const idx = commenters.findIndex(el => el.id === action.id); + const idx = commenters.findIndex((el) => el.id === action.id); commenters[idx].status = action.status; - return state.set('accounts', commenters.map(id => id)); + return state.set('accounts', commenters.map((id) => id)); } case SORT_UPDATE : diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index a40ac865f..8f51cea6b 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -6,6 +6,7 @@ const initialState = Map({ modalOpen: false, user: Map({}), commentId: null, + commentStatus: null, banDialog: false, shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show' }); @@ -14,12 +15,14 @@ export default function moderation (state = initialState, action) { switch (action.type) { case actions.HIDE_BANUSER_DIALOG: return state - .set('banDialog', false); + .set('banDialog', false) + .set('commentStatus', null); case actions.SHOW_BANUSER_DIALOG: return state .merge({ user: Map(action.user), commentId: action.commentId, + commentStatus: action.commentStatus, showRejectedNote: action.showRejectedNote, banDialog: true }); diff --git a/client/coral-admin/src/services/client.js b/client/coral-admin/src/services/client.js index 7d65f3f92..ff8216373 100644 --- a/client/coral-admin/src/services/client.js +++ b/client/coral-admin/src/services/client.js @@ -1,5 +1,5 @@ import ApolloClient, {addTypename} from 'apollo-client'; -import getNetworkInterface from './transport'; +import {networkInterface} from 'coral-framework/services/transport'; import fragmentMatcher from './fragmentMatcher'; export const client = new ApolloClient({ @@ -12,5 +12,5 @@ export const client = new ApolloClient({ } return null; }, - networkInterface: getNetworkInterface() + networkInterface }); diff --git a/client/coral-admin/src/services/fragmentMatcher.js b/client/coral-admin/src/services/fragmentMatcher.js index 531708f31..3b57de0b1 100644 --- a/client/coral-admin/src/services/fragmentMatcher.js +++ b/client/coral-admin/src/services/fragmentMatcher.js @@ -39,7 +39,7 @@ const fm = new IntrospectionFragmentMatcher({ {name: 'DefaultAction'}, {name: 'FlagAction'}, {name: 'DontAgreeAction'} - ], + ] }, { kind: 'INTERFACE', @@ -48,18 +48,18 @@ const fm = new IntrospectionFragmentMatcher({ {name: 'DefaultActionSummary'}, {name: 'FlagActionSummary'}, {name: 'DontAgreeActionSummary'} - ], + ] }, { kind: 'INTERFACE', name: 'AssetActionSummary', possibleTypes: [ {name: 'DefaultAssetActionSummary'}, - {name: 'FlagAssetActionSummary'}, + {name: 'FlagAssetActionSummary'} ] } - ], - }, + ] + } } }); diff --git a/client/coral-admin/src/services/transport.js b/client/coral-admin/src/services/transport.js deleted file mode 100644 index 2bd6ac636..000000000 --- a/client/coral-admin/src/services/transport.js +++ /dev/null @@ -1,11 +0,0 @@ -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, - }, - }); -} diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json index 3f9392656..68a3b93d5 100644 --- a/client/coral-admin/src/translations.json +++ b/client/coral-admin/src/translations.json @@ -72,6 +72,7 @@ "flagged": "flagged", "anon": "Anonymous", "ban_user": "Ban User", + "view_context": "View context", "banned_user": "Banned User" }, "user": { @@ -259,6 +260,7 @@ "comment": { "flagged": "marcado", "anon": "Anónimo", + "view_context": "Ver contexto", "ban_user": "Suspender Usuario", "banned_user": "Usuario Suspendido" }, diff --git a/client/coral-configure/containers/ConfigureStreamContainer.js b/client/coral-configure/containers/ConfigureStreamContainer.js index 9c53fdf28..166431cca 100644 --- a/client/coral-configure/containers/ConfigureStreamContainer.js +++ b/client/coral-configure/containers/ConfigureStreamContainer.js @@ -125,9 +125,9 @@ const mapStateToProps = (state) => ({ asset: state.asset.toJS() }); -const mapDispatchToProps = dispatch => ({ - updateStatus: status => dispatch(updateOpenStatus(status)), - updateConfiguration: newConfig => dispatch(updateConfiguration(newConfig)), +const mapDispatchToProps = (dispatch) => ({ + updateStatus: (status) => dispatch(updateOpenStatus(status)), + updateConfiguration: (newConfig) => dispatch(updateConfiguration(newConfig)), }); export default compose( diff --git a/client/coral-embed-stream/src/actions/config.js b/client/coral-embed-stream/src/actions/config.js new file mode 100644 index 000000000..4aecb808b --- /dev/null +++ b/client/coral-embed-stream/src/actions/config.js @@ -0,0 +1,6 @@ +import {ADD_EXTERNAL_CONFIG} from '../constants/config'; + +export const addExternalConfig = (config) => ({ + type: ADD_EXTERNAL_CONFIG, + config +}); diff --git a/client/coral-embed-stream/src/actions/embed.js b/client/coral-embed-stream/src/actions/embed.js index 863494c68..98505cb99 100644 --- a/client/coral-embed-stream/src/actions/embed.js +++ b/client/coral-embed-stream/src/actions/embed.js @@ -7,4 +7,3 @@ export const setActiveTab = (tab) => (dispatch, getState) => { dispatch(viewAllComments()); } }; - diff --git a/client/coral-embed-stream/src/components/Comment.css b/client/coral-embed-stream/src/components/Comment.css index 8d7c7ebf9..5fab57d95 100644 --- a/client/coral-embed-stream/src/components/Comment.css +++ b/client/coral-embed-stream/src/components/Comment.css @@ -13,13 +13,87 @@ pointer-events: none; } -.topRightMenu { +.bylineSecondary { + color: #696969; + font-size: 12px; +} + +.editedMarker { + font-style: italic; +} + +/* element in the top right of the Comment */ +.topRight { float: right; + margin-top: 10px; text-align: right; +} + +.topRight > * { + text-align: initial; +} + +.topRight .popover { + margin-top: 1em; + right: 0px; +} + +.topRight .link.active, +.topRight .active .link { + padding-bottom: 0.125em; + border-bottom: 2px solid currentColor; +} + +.topRightMenu { cursor: pointer; margin-top: 5px; } -.topRightMenu > * { - text-align: initial; +.editCommentForm { + margin-bottom: 10px; +} + +.editCommentForm .buttonContainerLeft { + margin-right: auto; + display: flex; + justify-content: center; + flex-direction: column; +} + +.editCommentForm .buttonContainerLeft .editWindowRemaining { + margin-right: 1em; +} + +.editCommentForm .button { + flex-shrink: 0; +} + +.editWindowAlmostOver { + font-weight: bold; +} + +.link { + color: #2376D8; + cursor: pointer; +} + +.popover { + position: absolute; + z-index: 1; +} + +/* Wizard used for Ignore User, Delete Comment confirmations */ +.Wizard { + background-color: #2E343B; + color: white; + padding: 1em; + max-width: 220px; /* consider moving to better class */ +} + +.Wizard header { + font-weight: bold; +} + +.Wizard .textAlignRight { + text-align: right; } diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 4349fe38d..83a175874 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -19,11 +19,13 @@ import Slot from 'coral-framework/components/Slot'; import LoadMore from './LoadMore'; import IgnoredCommentTombstone from './IgnoredCommentTombstone'; import {TopRightMenu} from './TopRightMenu'; +import classnames from 'classnames'; +import {EditableCommentContent} from './EditableCommentContent'; import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils'; - +import {getEditableUntilDate} from './util'; import styles from './Comment.css'; -const isStaff = tags => !tags.every(t => t.name !== 'STAFF'); +const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF'); // hold actions links (e.g. Reply) along the comment footer const ActionButton = ({children}) => { @@ -37,7 +39,17 @@ const ActionButton = ({children}) => { class Comment extends React.Component { constructor(props) { super(props); - this.state = {replyBoxVisible: false}; + + // timeout to keep track of Comment edit window expiration + this.editWindowExpiryTimeout = null; + this.onClickEdit = this.onClickEdit.bind(this); + this.stopEditing = this.stopEditing.bind(this); + this.state = { + + // Whether the comment should be editable (e.g. after a commenter clicking the 'Edit' button on their own comment) + isEditing: false, + replyBoxVisible: false, + }; } static propTypes = { @@ -53,7 +65,7 @@ class Comment extends React.Component { parentId: PropTypes.string, highlighted: PropTypes.string, addNotification: PropTypes.func.isRequired, - postItem: PropTypes.func.isRequired, + postComment: PropTypes.func.isRequired, depth: PropTypes.number.isRequired, asset: PropTypes.shape({ id: PropTypes.string, @@ -84,7 +96,13 @@ class Comment extends React.Component { user: PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired - }).isRequired + }).isRequired, + editing: PropTypes.shape({ + edited: PropTypes.bool, + + // ISO8601 + editableUntil: PropTypes.string, + }) }).isRequired, // given a comment, return whether it should be rendered as ignored @@ -97,17 +115,53 @@ class Comment extends React.Component { removeCommentTag: React.PropTypes.func, // dispatch action to ignore another user - ignoreUser: React.PropTypes.func - }; + ignoreUser: React.PropTypes.func, - render() { + // edit a comment, passed (id, asset_id, { body }) + editComment: React.PropTypes.func, + } + + onClickEdit (e) { + e.preventDefault(); + this.setState({isEditing: true}); + } + + stopEditing () { + if (this._isMounted) { + this.setState({isEditing: false}); + } + } + + componentDidMount() { + this._isMounted = true; + if (this.editWindowExpiryTimeout) { + this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout); + } + + // if still in the edit window, set a timeout to re-render once it expires + const msLeftToEdit = editWindowRemainingMs(this.props.comment); + if (msLeftToEdit > 0) { + this.editWindowExpiryTimeout = setTimeout(() => { + + // re-render + this.setState(this.state); + }, msLeftToEdit); + } + } + componentWillUnmount() { + if (this.editWindowExpiryTimeout) { + this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout); + } + this._isMounted = false; + } + render () { const { comment, parentId, currentUser, asset, depth, - postItem, + postComment, addNotification, showSignInDialog, highlighted, @@ -133,9 +187,9 @@ class Comment extends React.Component { ); let myFlag = null; if (iPerformedThisAction('FlagActionSummary', comment)) { - myFlag = flagSummary.find(s => s.current_user); + myFlag = flagSummary.find((s) => s.current_user); } else if (iPerformedThisAction('DontAgreeActionSummary', comment)) { - myFlag = dontAgreeSummary.find(s => s.current_user); + myFlag = dontAgreeSummary.find((s) => s.current_user); } let commentClass = parentId @@ -147,7 +201,7 @@ class Comment extends React.Component { const notifyOnError = (fn, errorToMessage) => async function(...args) { if (typeof errorToMessage !== 'function') { - errorToMessage = error => error.message; + errorToMessage = (error) => error.message; } try { return await fn(...args); @@ -190,8 +244,17 @@ class Comment extends React.Component { {commentIsBest(comment) ? - : null} - + : null } + + + + { + (comment.editing && comment.editing.edited) + ?  (Edited) + : null + } + + - {currentUser && comment.user.id !== currentUser.id - ? - - - : null} - - + { (currentUser && + (comment.user.id === currentUser.id)) + + /* User can edit/delete their own comment for a short window after posting */ + ? + { + commentIsStillEditable(comment) && + Edit + } + + + /* TopRightMenu allows currentUser to ignore other users' comments */ + : + + + } + + { + this.state.isEditing + ? + :

      + + +
      + } +
      : null} {comment.replies && - comment.replies.map(reply => { + comment.replies.map((reply) => { return commentIsIgnored(reply) ? : 0) { + this.countdownInterval = setInterval(() => { + + // re-render + this.forceUpdate(); + }, 1000); + } + } + componentWillUnmount() { + if (this.countdownInterval) { + this.countdownInterval = clearInterval(this.countdownInterval); + } + } + render() { + const now = new Date(); + const {until, classNameForMsRemaining} = this.props; + const msRemaining = until - now; + const secRemaining = msRemaining / 1000; + const wholeSecRemaining = Math.floor(secRemaining); + const plural = secRemaining !== 1; + const units = lang.t(plural ? 'editComment.secondsPlural' : 'editComment.second'); + let classFromProp; + if (typeof classNameForMsRemaining === 'function') { + classFromProp = classNameForMsRemaining(msRemaining); + } + return ( + + {`${wholeSecRemaining} ${units}`} + + ); + } +} diff --git a/client/coral-embed-stream/src/components/EditableCommentContent.js b/client/coral-embed-stream/src/components/EditableCommentContent.js new file mode 100644 index 000000000..186317393 --- /dev/null +++ b/client/coral-embed-stream/src/components/EditableCommentContent.js @@ -0,0 +1,153 @@ +import React, {PropTypes} from 'react'; +import {notifyForNewCommentStatus} from 'coral-plugin-commentbox/CommentBox'; +import {CommentForm} from 'coral-plugin-commentbox/CommentForm'; +import styles from './Comment.css'; +import {CountdownSeconds} from './CountdownSeconds'; +import {getEditableUntilDate} from './util'; + +import {Icon} from 'coral-ui'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from 'coral-framework/translations'; +const lang = new I18n(translations); + +/** + * Renders a Comment's body in such a way that the end-user can edit it and save changes + */ +export class EditableCommentContent extends React.Component { + static propTypes = { + + // show notification to the user (e.g. for errors) + addNotification: PropTypes.func.isRequired, + asset: PropTypes.shape({ + settings: PropTypes.shape({ + charCountEnable: PropTypes.bool, + }), + }).isRequired, + + // comment that is being edited + comment: PropTypes.shape({ + body: PropTypes.string, + editing: PropTypes.shape({ + edited: PropTypes.bool, + + // ISO8601 + editableUntil: PropTypes.string, + }) + }).isRequired, + + // logged in user + currentUser: PropTypes.shape({ + id: PropTypes.string.isRequired + }), + maxCharCount: PropTypes.number, + + // edit a comment, passed {{ body }} + editComment: React.PropTypes.func, + + // called when editing should be stopped + stopEditing: React.PropTypes.func, + } + constructor(props) { + super(props); + this.editComment = this.editComment.bind(this); + this.editWindowExpiryTimeout = null; + } + componentDidMount() { + const editableUntil = getEditableUntilDate(this.props.comment); + const now = new Date(); + const editWindowRemainingMs = editableUntil && (editableUntil - now); + if (editWindowRemainingMs > 0) { + this.editWindowExpiryTimeout = setTimeout(() => { + this.forceUpdate(); + }, editWindowRemainingMs); + } + } + componentWillUnmount() { + if (this.editWindowExpiryTimeout) { + this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout); + } + } + async editComment(edit) { + const {editComment, addNotification, stopEditing} = this.props; + if (typeof editComment !== 'function') {return;} + let response; + let successfullyEdited = false; + try { + response = await editComment(edit); + const errors = (response && response.data && response.data.editComment) + ? response.data.editComment.errors + : null; + if (errors && (errors.length === 1)) { + throw errors[0]; + } + successfullyEdited = true; + } catch (error) { + if (error.translation_key) { + addNotification('error', lang.t(`error.${error.translation_key}`)); + } else if (error.networkError) { + addNotification('error', lang.t('error.networkError')); + } else { + addNotification('error', lang.t('editComment.unexpectedError')); + throw error; + } + } + if (successfullyEdited) { + const status = response.data.editComment.comment.status; + notifyForNewCommentStatus(this.props.addNotification, status); + } + if (successfullyEdited && typeof stopEditing === 'function') { + stopEditing(); + } + } + render() { + const originalBody = this.props.comment.body; + const editableUntil = getEditableUntilDate(this.props.comment); + const editWindowExpired = (editableUntil - new Date()) < 0; + return ( +
      + { + + // should be disabled if user hasn't actually changed their + // original comment + return (comment.body !== originalBody) && !editWindowExpired; + }} + saveComment={this.editComment} + bodyLabel={lang.t('editComment.bodyInputLabel')} + bodyPlaceholder="" + submitText={{lang.t('editComment.saveButton')}} + saveButtonCStyle="green" + cancelButtonClicked={this.props.stopEditing} + buttonClass={styles.button} + buttonContainerStart={ +
      + + { + editWindowExpired + ? + {lang.t('editComment.editWindowExpired')} + { + typeof this.props.stopEditing === 'function' + ?  {lang.t('editComment.editWindowExpiredClose')} + : null + } + + : + {lang.t('editComment.editWindowTimerPrefix')} + (remainingMs <= 10 * 1000) ? styles.editWindowAlmostOver : '' } + /> + + } + +
      + } + /> +
      + ); + } +} diff --git a/client/coral-embed-stream/src/components/IgnoreUserWizard.css b/client/coral-embed-stream/src/components/IgnoreUserWizard.css deleted file mode 100644 index 838f2f76a..000000000 --- a/client/coral-embed-stream/src/components/IgnoreUserWizard.css +++ /dev/null @@ -1,14 +0,0 @@ -.IgnoreUserWizard { - background-color: #2E343B; - color: white; - padding: 1em; - max-width: 220px; -} - -.IgnoreUserWizard header { - font-weight: bold; -} - -.IgnoreUserWizard .textAlignRight { - text-align: right; -} diff --git a/client/coral-embed-stream/src/components/IgnoreUserWizard.js b/client/coral-embed-stream/src/components/IgnoreUserWizard.js index 371e6d48b..7e72bb924 100644 --- a/client/coral-embed-stream/src/components/IgnoreUserWizard.js +++ b/client/coral-embed-stream/src/components/IgnoreUserWizard.js @@ -1,5 +1,5 @@ import React, {PropTypes} from 'react'; -import styles from './IgnoreUserWizard.css'; +import styles from './Comment.css'; import {Button} from 'coral-ui'; // Guides the user through ignoring another user, including confirming their decision @@ -58,7 +58,7 @@ export class IgnoreUserWizard extends React.Component { const {step} = this.state; const elForThisStep = elsForStep[step - 1]; return ( -
      +
      { elForThisStep }
      ); diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index 46ac75dca..2297a295b 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -15,7 +15,7 @@ import ChangeUsernameContainer from 'coral-sign-in/containers/ChangeUsernameContainer'; class Stream extends React.Component { - setActiveReplyBox = reactKey => { + setActiveReplyBox = (reactKey) => { if (!this.props.auth.user) { this.props.showSignInDialog(); } else { @@ -26,7 +26,7 @@ class Stream extends React.Component { render() { const { root: {asset, asset: {comments}, comment, myIgnoredUsers}, - postItem, + postComment, addNotification, postFlag, postDontAgree, @@ -58,7 +58,7 @@ class Stream extends React.Component { const firstCommentDate = asset.comments[0] ? asset.comments[0].created_at : new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(); - const commentIsIgnored = comment => + const commentIsIgnored = (comment) => myIgnoredUsers && myIgnoredUsers.includes(comment.user.id); return (
      @@ -84,7 +84,7 @@ class Stream extends React.Component { {user ? :
      {comments.map( - comment => + (comment) => (commentIsIgnored(comment) ? : ) )}
      @@ -196,7 +198,7 @@ class Stream extends React.Component { Stream.propTypes = { addNotification: PropTypes.func.isRequired, - postItem: PropTypes.func.isRequired, + postComment: PropTypes.func.isRequired, // dispatch action to add a tag to a comment addCommentTag: PropTypes.func, @@ -205,7 +207,10 @@ Stream.propTypes = { removeCommentTag: PropTypes.func, // dispatch action to ignore another user - ignoreUser: React.PropTypes.func + ignoreUser: React.PropTypes.func, + + // edit a comment, passed (id, asset_id, { body }) + editComment: React.PropTypes.func, }; export default Stream; diff --git a/client/coral-embed-stream/src/components/TopRightMenu.css b/client/coral-embed-stream/src/components/TopRightMenu.css index 5cddae125..0ce8a119b 100644 --- a/client/coral-embed-stream/src/components/TopRightMenu.css +++ b/client/coral-embed-stream/src/components/TopRightMenu.css @@ -20,5 +20,4 @@ position: relative; transform: rotate(180deg); top: 0; - /*top: -0.25em;*/ } diff --git a/client/coral-embed-stream/src/components/TopRightMenu.js b/client/coral-embed-stream/src/components/TopRightMenu.js index e6b264c27..84d64f061 100644 --- a/client/coral-embed-stream/src/components/TopRightMenu.js +++ b/client/coral-embed-stream/src/components/TopRightMenu.js @@ -72,7 +72,7 @@ class Toggleable extends React.Component { }; } toggle() { - this.setState({isOpen: ! this.state.isOpen}); + this.setState({isOpen: !this.state.isOpen}); } close() { this.setState({isOpen: false}); diff --git a/client/coral-embed-stream/src/components/util.js b/client/coral-embed-stream/src/components/util.js new file mode 100644 index 000000000..fe5258853 --- /dev/null +++ b/client/coral-embed-stream/src/components/util.js @@ -0,0 +1,10 @@ +/** + * Given a comment, return when the comment can no longer be edited + * @param {Object} comment + * @returns {Date} when the comment can no longer be edited. + */ +export const getEditableUntilDate = (comment) => { + const editing = comment && comment.editing; + const editableUntil = editing && editing.editableUntil && new Date(Date.parse(editing.editableUntil)); + return editableUntil; +}; diff --git a/client/coral-embed-stream/src/constants/config.js b/client/coral-embed-stream/src/constants/config.js new file mode 100644 index 000000000..5821316c5 --- /dev/null +++ b/client/coral-embed-stream/src/constants/config.js @@ -0,0 +1 @@ +export const ADD_EXTERNAL_CONFIG = 'ADD_EXTERNAL_CONFIG'; diff --git a/client/coral-embed-stream/src/containers/Comment.js b/client/coral-embed-stream/src/containers/Comment.js index 9b48e0809..72a12c92e 100644 --- a/client/coral-embed-stream/src/containers/Comment.js +++ b/client/coral-embed-stream/src/containers/Comment.js @@ -1,6 +1,6 @@ import {gql} from 'react-apollo'; import Comment from '../components/Comment'; -import withFragments from 'coral-framework/hocs/withFragments'; +import {withFragments} from 'coral-framework/hocs'; import {getSlotsFragments} from 'coral-framework/helpers/plugins'; const pluginFragments = getSlotsFragments([ @@ -41,6 +41,10 @@ export default withFragments({ id } } + editing { + edited + editableUntil + } ${pluginFragments.spreads('comment')} } ${pluginFragments.definitions('comment')} diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index d5399fc60..0ea577530 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -1,5 +1,5 @@ import React from 'react'; -import {compose, gql, graphql} from 'react-apollo'; +import {compose, gql} from 'react-apollo'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import isEqual from 'lodash/isEqual'; @@ -8,31 +8,28 @@ import renderComponent from 'recompose/renderComponent'; import {Spinner} from 'coral-ui'; import {authActions, assetActions, pym} from 'coral-framework'; -import {getDefinitionName, separateDataAndRoot} from 'coral-framework/utils'; +import {getDefinitionName} from 'coral-framework/utils'; +import {withQuery} from 'coral-framework/hocs'; import Embed from '../components/Embed'; -import {setCommentCountCache, viewAllComments} from '../actions/stream'; -import {setActiveTab} from '../actions/embed'; import Stream from './Stream'; +import {setActiveTab} from '../actions/embed'; +import {setCommentCountCache, viewAllComments} from '../actions/stream'; + const {logout, checkLogin} = authActions; const {fetchAssetSuccess} = assetActions; class EmbedContainer extends React.Component { - componentDidMount() { - pym.sendMessage('childReady'); - } - componentWillReceiveProps(nextProps) { - if(this.props.root.me && !nextProps.root.me) { + if (this.props.auth.loggedIn !== nextProps.auth.loggedIn) { - // Refetch because on logout `excludeIgnored` becomes `false`. - // TODO: logout via mutation and obsolete this? + // Refetch after login/logout. this.props.data.refetch(); } const {fetchAssetSuccess} = this.props; - if(!isEqual(nextProps.root.asset, this.props.root.asset)) { + if (!isEqual(nextProps.root.asset, this.props.root.asset)) { // TODO: remove asset data from redux store. fetchAssetSuccess(nextProps.root.asset); @@ -47,7 +44,7 @@ class EmbedContainer extends React.Component { } componentDidUpdate(prevProps) { - if(!isEqual(prevProps.root.comment, this.props.root.comment)) { + if (!isEqual(prevProps.root.comment, this.props.root.comment)) { // Scroll to a permalinked comment if one is in the URL once the page is done rendering. setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0); @@ -75,7 +72,7 @@ const EMBED_QUERY = gql` ${Stream.fragments.root} `; -export const withQuery = graphql(EMBED_QUERY, { +export const withEmbedQuery = withQuery(EMBED_QUERY, { options: ({auth, commentId, assetId, assetUrl}) => ({ variables: { assetId, @@ -85,34 +82,33 @@ export const withQuery = graphql(EMBED_QUERY, { excludeIgnored: Boolean(auth && auth.user && auth.user.id), }, }), - props: ({data}) => separateDataAndRoot(data), }); -const mapStateToProps = state => ({ +const mapStateToProps = (state) => ({ auth: state.auth.toJS(), commentCountCache: state.stream.commentCountCache, commentId: state.stream.commentId, assetId: state.stream.assetId, assetUrl: state.stream.assetUrl, activeTab: state.embed.activeTab, + config: state.config }); -const mapDispatchToProps = dispatch => - bindActionCreators({ - fetchAssetSuccess, - checkLogin, - setCommentCountCache, - viewAllComments, - logout, - setActiveTab, - }, dispatch); +const mapDispatchToProps = (dispatch) => + bindActionCreators( + { + logout, + checkLogin, + setActiveTab, + viewAllComments, + fetchAssetSuccess, + setCommentCountCache + }, + dispatch + ); export default compose( connect(mapStateToProps, mapDispatchToProps), - branch( - props => !props.auth.checkedInitialLogin, - renderComponent(Spinner), - ), - withQuery, + branch((props) => !props.auth.checkedInitialLogin && props.config, renderComponent(Spinner)), + withEmbedQuery, )(EmbedContainer); - diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index 16714df06..daac49c51 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -6,13 +6,17 @@ import uniqBy from 'lodash/uniqBy'; import sortBy from 'lodash/sortBy'; import isNil from 'lodash/isNil'; import {NEW_COMMENT_COUNT_POLL_INTERVAL} from '../constants/stream'; -import {postComment, postFlag, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser} from 'coral-framework/graphql/mutations'; +import { + withPostComment, withPostFlag, withPostDontAgree, withDeleteAction, + withAddCommentTag, withRemoveCommentTag, withIgnoreUser, withEditComment, +} from 'coral-framework/graphql/mutations'; + import {notificationActions, authActions} from 'coral-framework'; import {editName} from 'coral-framework/actions/user'; import {setCommentCountCache, setActiveReplyBox} from '../actions/stream'; import Stream from '../components/Stream'; import Comment from './Comment'; -import withFragments from 'coral-framework/hocs/withFragments'; +import {withFragments} from 'coral-framework/hocs'; import {getDefinitionName} from 'coral-framework/utils'; const {showSignInDialog} = authActions; @@ -25,7 +29,7 @@ class StreamContainer extends React.Component { variables, // Apollo requires this, even though we don't use it... - updateQuery: data => data, + updateQuery: (data) => data, }); }; @@ -75,7 +79,7 @@ class StreamContainer extends React.Component { ...oldData, asset: { ...oldData.asset, - comments: oldData.asset.comments.map(comment => { + comments: oldData.asset.comments.map((comment) => { // since the dipslayed replies and the returned replies can overlap, // pull out the unique ones. @@ -199,10 +203,6 @@ const fragments = { } } } - myIgnoredUsers { - id, - username, - } me { status } @@ -213,7 +213,7 @@ const fragments = { `, }; -const mapStateToProps = state => ({ +const mapStateToProps = (state) => ({ auth: state.auth.toJS(), commentCountCache: state.stream.commentCountCache, activeReplyBox: state.stream.activeReplyBox, @@ -224,7 +224,7 @@ const mapStateToProps = state => ({ previousTab: state.embed.previousTab, }); -const mapDispatchToProps = dispatch => +const mapDispatchToProps = (dispatch) => bindActionCreators({ showSignInDialog, addNotification, @@ -236,12 +236,13 @@ const mapDispatchToProps = dispatch => export default compose( withFragments(fragments), connect(mapStateToProps, mapDispatchToProps), - postComment, - postFlag, - postDontAgree, - addCommentTag, - removeCommentTag, - ignoreUser, - deleteAction, + withPostComment, + withPostFlag, + withPostDontAgree, + withAddCommentTag, + withRemoveCommentTag, + withIgnoreUser, + withDeleteAction, + withEditComment, )(StreamContainer); diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js new file mode 100644 index 000000000..5ab1f0c37 --- /dev/null +++ b/client/coral-embed-stream/src/graphql/index.js @@ -0,0 +1,270 @@ +import {gql} from 'react-apollo'; +import {add} from 'coral-framework/services/graphqlRegistry'; +import update from 'immutability-helper'; + +const extension = { + fragments: { + EditCommentResponse: gql` + fragment CoralEmbedStream_EditCommentResponse on EditCommentResponse { + comment { + status + } + errors { + translation_key + } + } + `, + StopIgnoringUserResponse: gql` + fragment CoralEmbedStream_StopIgnoringUserResponse on StopIgnoringUserResponse { + errors { + translation_key + } + } + `, + IgnoreUserResponse: gql` + fragment CoralEmbedStream_IgnoreUserResponse on IgnoreUserResponse { + errors { + translation_key + } + } + `, + RemoveCommentTagResponse: gql` + fragment CoralEmbedStream_RemoveCommentTagResponse on RemoveCommentTagResponse { + comment { + id + tags { + name + } + } + errors { + translation_key + } + } + `, + AddCommentTagResponse: gql` + fragment CoralEmbedStream_AddCommentTagResponse on AddCommentTagResponse { + comment { + id + tags { + name + } + } + errors { + translation_key + } + } + `, + DeleteActionResponse: gql` + fragment CoralEmbedStream_DeleteActionResponse on DeleteActionResponse { + errors { + translation_key + } + } + `, + CreateFlagResponse: gql` + fragment CoralEmbedStream_CreateFlagResponse on CreateFlagResponse { + flag { + id + } + errors { + translation_key + } + } + `, + CreateDontAgreeResponse : gql` + fragment CoralEmbedStream_CreateDontAgreeResponse on CreateDontAgreeResponse { + dontagree { + id + } + errors { + translation_key + } + } + `, + CreateCommentResponse: gql` + fragment CoralEmbedStream_CreateCommentResponse on CreateCommentResponse { + comment { + ...CoralEmbedStream_CreateCommentResponse_Comment + replies { + ...CoralEmbedStream_CreateCommentResponse_Comment + } + } + errors { + translation_key + } + } + + fragment CoralEmbedStream_CreateCommentResponse_Comment on Comment { + id + body + created_at + status + replyCount + tags { + name + } + user { + id + name: username + } + action_summaries { + count + current_user { + id + created_at + } + } + editing { + edited + editableUntil + } + } + `, + }, + mutations: { + IgnoreUser: () => ({ + + // TODO: don't rely on refetching. + refetchQueries: [ + 'EmbedQuery', 'EmbedStreamProfileQuery', + ], + }), + StopIgnoringUser: () => ({ + + // TODO: don't rely on refetching. + refetchQueries: [ + 'EmbedQuery', 'EmbedStreamProfileQuery', + ], + }), + PostComment: ({ + variables: {comment: {asset_id, body, parent_id, tags = []}}, + state: {auth}, + }) => ({ + optimisticResponse: { + createComment: { + comment: { + user: { + id: auth.toJS().user.id, + name: auth.toJS().user.username + }, + created_at: new Date().toISOString(), + body, + parent_id, + asset_id, + action_summaries: [], + tags, + status: null, + id: 'pending' + } + } + }, + updateQueries: { + EmbedQuery: (previousData, {mutationResult: {data: {createComment: {comment}}}}) => { + if (previousData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') { + return previousData; + } + + let updatedAsset; + + // If posting a reply + if (parent_id) { + updatedAsset = { + ...previousData, + asset: { + ...previousData.asset, + comments: previousData.asset.comments.map((oldComment) => { + return oldComment.id === parent_id + ? {...oldComment, replies: [...oldComment.replies, comment], replyCount: oldComment.replyCount + 1} + : oldComment; + }) + } + }; + } else { + + // If posting a top-level comment + updatedAsset = { + ...previousData, + asset: { + ...previousData.asset, + commentCount: previousData.asset.commentCount + 1, + comments: [comment, ...previousData.asset.comments] + } + }; + } + + return updatedAsset; + } + } + }), + EditComment: ({ + variables: {id, edit}, + }) => ({ + updateQueries: { + EmbedQuery: (previousData, {mutationResult: {data: {editComment: {comment: {status}}}}}) => { + const updateCommentWithEdit = (comment, edit) => { + const {body} = edit; + const editedComment = update(comment, { + $merge: { + body + }, + editing: {$merge:{edited:true}} + }); + return editedComment; + }; + const commentIsStillVisible = (comment) => { + return !((id === comment.id) && (['PREMOD', 'REJECTED'].includes(status))); + }; + const resultReflectingEdit = update(previousData, { + asset: { + comments: { + $apply: (comments) => { + return comments.filter(commentIsStillVisible).map((comment) => { + let replyWasEditedToBeHidden = false; + if (comment.id === id) { + return updateCommentWithEdit(comment, edit); + } + const commentWithUpdatedReplies = update(comment, { + replies: { + $apply: (comments) => { + return comments + .filter((c) => { + if (commentIsStillVisible(c)) { + return true; + } + replyWasEditedToBeHidden = true; + return false; + }) + .map((comment) => { + if (comment.id === id) { + return updateCommentWithEdit(comment, edit); + } + return comment; + }); + } + }, + }); + + // If a reply was edited to be hdiden, then this parent needs its replyCount to be decremented. + if (replyWasEditedToBeHidden) { + return update(commentWithUpdatedReplies, { + replyCount: { + $apply: (replyCount) => { + return replyCount - 1; + } + } + }); + } + return commentWithUpdatedReplies; + }); + } + } + } + }); + return resultReflectingEdit; + }, + }, + }), + }, +}; + +add(extension); diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js index 2fd1e2731..0306079f3 100644 --- a/client/coral-embed-stream/src/index.js +++ b/client/coral-embed-stream/src/index.js @@ -4,10 +4,13 @@ import {ApolloProvider} from 'react-apollo'; import {client} from 'coral-framework/services/client'; import {checkLogin} from 'coral-framework/actions/auth'; +import './graphql'; +import {addExternalConfig} from 'coral-embed-stream/src/actions/config'; import reducers from './reducers'; import localStore, {injectReducers} from 'coral-framework/services/store'; import AppRouter from './AppRouter'; +import {pym} from 'coral-framework'; injectReducers(reducers); @@ -16,6 +19,12 @@ const store = (window.opener && window.opener.coralStore) ? window.opener.coralS // Don't run this in the popup. if (store === localStore) { store.dispatch(checkLogin()); + + pym.sendMessage('getConfig'); + + pym.onMessage('config', (config) => { + store.dispatch(addExternalConfig(JSON.parse(config))); + }); } render( diff --git a/client/coral-embed-stream/src/reducers/config.js b/client/coral-embed-stream/src/reducers/config.js new file mode 100644 index 000000000..a602e7f59 --- /dev/null +++ b/client/coral-embed-stream/src/reducers/config.js @@ -0,0 +1,15 @@ +import {ADD_EXTERNAL_CONFIG} from '../constants/config'; + +const initialState = {}; + +export default function config(state = initialState, action) { + switch (action.type) { + case ADD_EXTERNAL_CONFIG: + return { + ...state, + ...action.config + }; + default: + return state; + } +} diff --git a/client/coral-embed-stream/src/reducers/index.js b/client/coral-embed-stream/src/reducers/index.js index a9049fc14..590b87eea 100644 --- a/client/coral-embed-stream/src/reducers/index.js +++ b/client/coral-embed-stream/src/reducers/index.js @@ -1,7 +1,9 @@ -import stream from './stream'; import embed from './embed'; +import config from './config'; +import stream from './stream'; export default { - stream, embed, + stream, + config }; diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index d1afed204..42a0d7c85 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -191,12 +191,13 @@ hr { } .coral-plugin-commentbox-textarea { + color: #262626; flex: 1; - padding: 5px; + padding: 1em; min-height: 100px; margin-top: 10px; font-size: 16px; - border: 1px solid #ccc; + border: 1px solid #9E9E9E; } .coral-plugin-commentbox-button-container { @@ -317,9 +318,7 @@ button.comment__action-button[disabled], } .coral-plugin-pubdate-text { - color: #696969; display: inline-block; - font-size: .75rem; margin-left: 5px; } diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js index 950fa91bc..570a63aed 100644 --- a/client/coral-embed/src/index.js +++ b/client/coral-embed/src/index.js @@ -22,18 +22,18 @@ const snackbarStyles = { transform: 'translate(-50%, 20px)', bottom: 0, boxSizing: 'border-box', - fontFamily: 'Helvetica, \'Helvetica Neue\', Verdana, sans-serif' + fontFamily: 'Helvetica, "Helvetica Neue", Verdana, sans-serif' }; // This function should return value of window.Coral const Coral = {}; -const Talk = Coral.Talk = {}; +const Talk = (Coral.Talk = {}); // build the URL to load in the pym iframe function buildStreamIframeUrl(talkBaseUrl, query) { let url = [ talkBaseUrl, - (talkBaseUrl.match(/\/$/) ? '' : '/'), // make sure no double-'/' if opts.talk already ends with '/' + talkBaseUrl.match(/\/$/) ? '' : '/', // make sure no double-'/' if opts.talk already ends with '/' 'embed/stream?' ].join(''); @@ -44,11 +44,21 @@ function buildStreamIframeUrl(talkBaseUrl, query) { // Set up postMessage listeners/handlers on the pymParent // e.g. to resize the iframe, and navigate the host page -function configurePymParent(pymParent) { +function configurePymParent(pymParent, opts) { let notificationOffset = 200; - let ready = false; let cachedHeight; const snackbar = document.createElement('div'); + + // Sends config to pymChild + function sendConfig(config) { + pymParent.sendMessage('config', JSON.stringify(config)); + } + + // Sends config to the child + pymParent.onMessage('getConfig', function() { + sendConfig(opts || {}); + }); + snackbar.id = 'coral-notif'; for (let key in snackbarStyles) { @@ -69,12 +79,12 @@ function configurePymParent(pymParent) { } }); - pymParent.onMessage('coral-clear-notification', function () { + pymParent.onMessage('coral-clear-notification', function() { snackbar.style.opacity = 0; }); // remove the permalink comment id from the hash - pymParent.onMessage('coral-view-all-comments', function () { + pymParent.onMessage('coral-view-all-comments', function() { window.history.replaceState( {}, document.title, @@ -82,7 +92,7 @@ function configurePymParent(pymParent) { ); }); - pymParent.onMessage('coral-alert', function (message) { + pymParent.onMessage('coral-alert', function(message) { const [type, text] = message.split('|'); snackbar.style.transform = 'translate(-50%, 20px)'; snackbar.style.opacity = 0; @@ -110,39 +120,21 @@ function configurePymParent(pymParent) { pymParent.sendMessage('position', position); }); - // Tell child when parent's DOMContentLoaded - pymParent.onMessage('childReady', function () { - const interval = setInterval(function () { - if (ready) { - window.clearInterval(interval); - - // TODO: It's weird to me that this is sent here - pymParent.sendMessage('DOMContentLoaded'); - } - }, 100); - }); - // When end-user clicks link in iframe, open it in parent context - pymParent.onMessage('navigate', function (url) { + pymParent.onMessage('navigate', function(url) { window.open(url, '_blank').focus(); }); - // wait till images and other iframes are loaded before scrolling the page. - // or do we want to be more aggressive and scroll when we hit DOM ready? - document.addEventListener('DOMContentLoaded', function () { - ready = true; - }); - // get dimensions of viewport const viewport = () => { let e = window, a = 'inner'; - if ( !( 'innerWidth' in window ) ){ + if (!('innerWidth' in window)) { a = 'client'; e = document.documentElement || document.body; } return { - width : e[`${a}Width`], - height : e[`${a}Height`] + width: e[`${a}Width`], + height: e[`${a}Height`] }; }; } @@ -156,18 +148,24 @@ function configurePymParent(pymParent) { * @param {String} [opts.asset_url] - Asset URL * @param {String} [opts.asset_id] - Asset ID */ -Talk.render = function (el, opts) { +Talk.render = function(el, opts) { if (!el) { - throw new Error('Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'); + throw new Error( + 'Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.' + ); } if (typeof el !== 'object') { - throw new Error(`Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})`); + throw new Error( + `Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})` + ); } opts = opts || {}; // TODO: infer this URL without explicit user input (if possible, may have to be added at build/render time of this script) if (!opts.talk) { - throw new Error('Coral.Talk.render() expects opts.talk as the Talk Base URL'); + throw new Error( + 'Coral.Talk.render() expects opts.talk as the Talk Base URL' + ); } // Ensure el has an id, as pym can't directly accept the HTMLElement. @@ -186,16 +184,21 @@ Talk.render = function (el, opts) { try { query.asset_url = document.querySelector('link[rel="canonical"]').href; } catch (e) { - console.warn('This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.'); + console.warn( + 'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.' + ); query.asset_url = window.location.origin + window.location.pathname; } } - configurePymParent(new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), { - title: opts.title, - id: `${el.id}_iframe`, - name: `${el.id}_iframe` - })); + configurePymParent( + new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), { + title: opts.title, + id: `${el.id}_iframe`, + name: `${el.id}_iframe` + }), + opts + ); }; export default Coral; diff --git a/client/coral-framework/actions/asset.js b/client/coral-framework/actions/asset.js index 364fd62c0..3f1544290 100644 --- a/client/coral-framework/actions/asset.js +++ b/client/coral-framework/actions/asset.js @@ -6,14 +6,14 @@ import I18n from 'coral-i18n/modules/i18n/i18n'; const lang = new I18n(); export const fetchAssetRequest = () => ({type: actions.FETCH_ASSET_REQUEST}); -export const fetchAssetSuccess = asset => ({type: actions.FETCH_ASSET_SUCCESS, asset}); -export const fetchAssetFailure = error => ({type: actions.FETCH_ASSET_FAILURE, error}); +export const fetchAssetSuccess = (asset) => ({type: actions.FETCH_ASSET_SUCCESS, asset}); +export const fetchAssetFailure = (error) => ({type: actions.FETCH_ASSET_FAILURE, error}); const updateAssetSettingsRequest = () => ({type: actions.UPDATE_ASSET_SETTINGS_REQUEST}); -const updateAssetSettingsSuccess = settings => ({type: actions.UPDATE_ASSET_SETTINGS_SUCCESS, settings}); +const updateAssetSettingsSuccess = (settings) => ({type: actions.UPDATE_ASSET_SETTINGS_SUCCESS, settings}); const updateAssetSettingsFailure = () => ({type: actions.UPDATE_ASSET_SETTINGS_FAILURE}); -export const updateConfiguration = newConfig => (dispatch, getState) => { +export const updateConfiguration = (newConfig) => (dispatch, getState) => { const assetId = getState().asset.toJS().id; dispatch(updateAssetSettingsRequest()); coralApi(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig}) @@ -21,10 +21,10 @@ export const updateConfiguration = newConfig => (dispatch, getState) => { dispatch(addNotification('success', lang.t('framework.success_update_settings'))); dispatch(updateAssetSettingsSuccess(newConfig)); }) - .catch(error => dispatch(updateAssetSettingsFailure(error))); + .catch((error) => dispatch(updateAssetSettingsFailure(error))); }; -export const updateOpenStream = closedBody => (dispatch, getState) => { +export const updateOpenStream = (closedBody) => (dispatch, getState) => { const assetId = getState().asset.toJS().id; dispatch(fetchAssetRequest()); coralApi(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody}) @@ -32,13 +32,13 @@ export const updateOpenStream = closedBody => (dispatch, getState) => { dispatch(addNotification('success', lang.t('framework.success_update_settings'))); dispatch(fetchAssetSuccess(closedBody)); }) - .catch(error => dispatch(fetchAssetFailure(error))); + .catch((error) => dispatch(fetchAssetFailure(error))); }; const openStream = () => ({type: actions.OPEN_COMMENTS}); const closeStream = () => ({type: actions.CLOSE_COMMENTS}); -export const updateOpenStatus = status => dispatch => { +export const updateOpenStatus = (status) => (dispatch) => { if (status === 'open') { dispatch(openStream()); dispatch(updateOpenStream({closedAt: null})); diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index d26cf3257..abb5fd79e 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -1,37 +1,15 @@ -import {gql} from 'react-apollo'; -import client from 'coral-framework/services/client'; -import I18n from '../../coral-i18n/modules/i18n/i18n'; -const lang = new I18n(); +import {pym} from 'coral-framework'; +import * as Storage from '../helpers/storage'; import * as actions from '../constants/auth'; import coralApi, {base} from '../helpers/response'; -import {pym} from 'coral-framework'; +import jwtDecode from 'jwt-decode'; -const ME_QUERY = gql` - query Me { - me { - status - comments { - id - body - asset { - id - title - url - } - created_at - } - } - } -`; - -function fetchMe() { - return client.query({ - fetchPolicy: 'network-only', - query: ME_QUERY}); -} +const lang = new I18n(translations); +import translations from './../translations'; +import I18n from '../../coral-framework/modules/i18n/i18n'; // Dialog Actions -export const showSignInDialog = () => dispatch => { +export const showSignInDialog = () => (dispatch) => { const signInPopUp = window.open( '/embed/stream/login', 'Login', @@ -49,27 +27,41 @@ export const showSignInDialog = () => dispatch => { signInPopUp.onunload = () => { if (loaded) { dispatch(checkLogin()); - fetchMe(); } }; dispatch({type: actions.SHOW_SIGNIN_DIALOG}); }; -export const hideSignInDialog = () => dispatch => { +export const hideSignInDialog = () => (dispatch) => { dispatch({type: actions.HIDE_SIGNIN_DIALOG}); window.close(); }; -export const createUsernameRequest = () => ({type: actions.CREATE_USERNAME_REQUEST}); -export const showCreateUsernameDialog = () => ({type: actions.SHOW_CREATEUSERNAME_DIALOG}); -export const hideCreateUsernameDialog = () => ({type: actions.HIDE_CREATEUSERNAME_DIALOG}); +export const createUsernameRequest = () => ({ + type: actions.CREATE_USERNAME_REQUEST +}); +export const showCreateUsernameDialog = () => ({ + type: actions.SHOW_CREATEUSERNAME_DIALOG +}); +export const hideCreateUsernameDialog = () => ({ + type: actions.HIDE_CREATEUSERNAME_DIALOG +}); -const createUsernameSuccess = () => ({type: actions.CREATE_USERNAME_SUCCESS}); -const createUsernameFailure = error => ({type: actions.CREATE_USERNAME_FAILURE, error}); +const createUsernameSuccess = () => ({ + type: actions.CREATE_USERNAME_SUCCESS +}); -export const updateUsername = ({username}) => ({type: actions.UPDATE_USERNAME, username}); +const createUsernameFailure = (error) => ({ + type: actions.CREATE_USERNAME_FAILURE, + error +}); -export const createUsername = (userId, formData) => dispatch => { +export const updateUsername = ({username}) => ({ + type: actions.UPDATE_USERNAME, + username +}); + +export const createUsername = (userId, formData) => (dispatch) => { dispatch(createUsernameRequest()); coralApi('/account/username', {method: 'PUT', body: formData}) .then(() => { @@ -77,18 +69,18 @@ export const createUsername = (userId, formData) => dispatch => { dispatch(hideCreateUsernameDialog()); dispatch(updateUsername(formData)); }) - .catch(error => { + .catch((error) => { dispatch(createUsernameFailure(lang.t(`error.${error.translation_key}`))); }); }; -export const changeView = view => dispatch => { +export const changeView = (view) => (dispatch) => { dispatch({ type: actions.CHANGE_VIEW, view }); - switch(view) { + switch (view) { case 'SIGNUP': window.resizeTo(500, 800); break; @@ -100,26 +92,49 @@ export const changeView = view => dispatch => { } }; -export const cleanState = () => ({type: actions.CLEAN_STATE}); +export const cleanState = () => ({ + type: actions.CLEAN_STATE +}); // Sign In Actions -const signInRequest = () => ({type: actions.FETCH_SIGNIN_REQUEST}); +const signInRequest = () => ({ + type: actions.FETCH_SIGNIN_REQUEST +}); -// TODO: revisit login redux flow. -// const signInSuccess = (user, isAdmin) => ({type: actions.FETCH_SIGNIN_SUCCESS, user, isAdmin}); -// -const signInFailure = error => ({type: actions.FETCH_SIGNIN_FAILURE, error}); +const signInFailure = (error) => ({ + type: actions.FETCH_SIGNIN_FAILURE, + error +}); + +//============================================================================== +// AUTH TOKEN +//============================================================================== + +export const handleAuthToken = (token) => (dispatch) => { + Storage.setItem('exp', jwtDecode(token).exp); + Storage.setItem('token', token); + dispatch({type: 'HANDLE_AUTH_TOKEN'}); +}; + +//============================================================================== +// SIGN IN +//============================================================================== export const fetchSignIn = (formData) => (dispatch) => { dispatch(signInRequest()); return coralApi('/auth/local', {method: 'POST', body: formData}) - .then(() => dispatch(hideSignInDialog())) - .catch(error => { + .then(({token}) => { + dispatch(handleAuthToken(token)); + dispatch(hideSignInDialog()); + }) + .catch((error) => { if (error.metadata) { // the user might not have a valid email. prompt the user user re-request the confirmation email - dispatch(signInFailure(lang.t('error.email_not_verified', error.metadata))); + dispatch( + signInFailure(lang.t('error.email_not_verified', error.metadata)) + ); } else { // invalid credentials @@ -128,13 +143,23 @@ export const fetchSignIn = (formData) => (dispatch) => { }); }; -// Sign In - Facebook +//============================================================================== +// SIGN IN - FACEBOOK +//============================================================================== -const signInFacebookRequest = () => ({type: actions.FETCH_SIGNIN_FACEBOOK_REQUEST}); -const signInFacebookSuccess = user => ({type: actions.FETCH_SIGNIN_FACEBOOK_SUCCESS, user}); -const signInFacebookFailure = error => ({type: actions.FETCH_SIGNIN_FACEBOOK_FAILURE, error}); +const signInFacebookRequest = () => ({ + type: actions.FETCH_SIGNIN_FACEBOOK_REQUEST +}); +const signInFacebookSuccess = (user) => ({ + type: actions.FETCH_SIGNIN_FACEBOOK_SUCCESS, + user +}); +const signInFacebookFailure = (error) => ({ + type: actions.FETCH_SIGNIN_FACEBOOK_FAILURE, + error +}); -export const fetchSignInFacebook = () => dispatch => { +export const fetchSignInFacebook = () => (dispatch) => { dispatch(signInFacebookRequest()); window.open( `${base}/auth/facebook`, @@ -143,11 +168,15 @@ export const fetchSignInFacebook = () => dispatch => { ); }; -// Sign Up Facebook +//============================================================================== +// SIGN UP - FACEBOOK +//============================================================================== -const signUpFacebookRequest = () => ({type: actions.FETCH_SIGNUP_FACEBOOK_REQUEST}); +const signUpFacebookRequest = () => ({ + type: actions.FETCH_SIGNUP_FACEBOOK_REQUEST +}); -export const fetchSignUpFacebook = () => dispatch => { +export const fetchSignUpFacebook = () => (dispatch) => { dispatch(signUpFacebookRequest()); window.open( `${base}/auth/facebook`, @@ -156,7 +185,7 @@ export const fetchSignUpFacebook = () => dispatch => { ); }; -export const facebookCallback = (err, data) => dispatch => { +export const facebookCallback = (err, data) => (dispatch) => { if (err) { dispatch(signInFacebookFailure(err)); return; @@ -173,20 +202,26 @@ export const facebookCallback = (err, data) => dispatch => { } }; -// Sign Up Actions +//============================================================================== +// SIGN UP +//============================================================================== const signUpRequest = () => ({type: actions.FETCH_SIGNUP_REQUEST}); -const signUpSuccess = user => ({type: actions.FETCH_SIGNUP_SUCCESS, user}); -const signUpFailure = error => ({type: actions.FETCH_SIGNUP_FAILURE, error}); +const signUpSuccess = (user) => ({type: actions.FETCH_SIGNUP_SUCCESS, user}); +const signUpFailure = (error) => ({type: actions.FETCH_SIGNUP_FAILURE, error}); export const fetchSignUp = (formData, redirectUri) => (dispatch) => { dispatch(signUpRequest()); - coralApi('/users', {method: 'POST', body: formData, headers: {'X-Pym-Url': redirectUri}}) + coralApi('/users', { + method: 'POST', + body: formData, + headers: {'X-Pym-Url': redirectUri} + }) .then(({user}) => { dispatch(signUpSuccess(user)); }) - .catch(error => { + .catch((error) => { let errorMessage = lang.t(`error.${error.message}`); // if there is no translation defined, just show the error string @@ -197,75 +232,105 @@ export const fetchSignUp = (formData, redirectUri) => (dispatch) => { }); }; -// Forgot Password Actions +//============================================================================== +// FORGOT PASSWORD +//============================================================================== -const forgotPassowordRequest = () => ({type: actions.FETCH_FORGOT_PASSWORD_REQUEST}); -const forgotPassowordSuccess = () => ({type: actions.FETCH_FORGOT_PASSWORD_SUCCESS}); -const forgotPassowordFailure = () => ({type: actions.FETCH_FORGOT_PASSWORD_FAILURE}); +const forgotPasswordRequest = () => ({ + type: actions.FETCH_FORGOT_PASSWORD_REQUEST +}); -export const fetchForgotPassword = email => (dispatch) => { - dispatch(forgotPassowordRequest(email)); +const forgotPasswordSuccess = () => ({ + type: actions.FETCH_FORGOT_PASSWORD_SUCCESS +}); + +const forgotPasswordFailure = () => ({ + type: actions.FETCH_FORGOT_PASSWORD_FAILURE +}); + +export const fetchForgotPassword = (email) => (dispatch) => { + dispatch(forgotPasswordRequest(email)); const redirectUri = pym.parentUrl || location.href; - coralApi('/account/password/reset', {method: 'POST', body: {email, loc: redirectUri}}) - .then(() => dispatch(forgotPassowordSuccess())) - .catch(error => dispatch(forgotPassowordFailure(error))); + coralApi('/account/password/reset', { + method: 'POST', + body: {email, loc: redirectUri} + }) + .then(() => dispatch(forgotPasswordSuccess())) + .catch((error) => dispatch(forgotPasswordFailure(error))); }; -// LogOut Actions +//============================================================================== +// LOGOUT +//============================================================================== -const logOutRequest = () => ({type: actions.LOGOUT_REQUEST}); -const logOutSuccess = () => ({type: actions.LOGOUT_SUCCESS}); -const logOutFailure = () => ({type: actions.LOGOUT_FAILURE}); - -export const logout = () => dispatch => { - dispatch(logOutRequest()); - return coralApi('/auth', {method: 'DELETE'}) - .then(() => { - dispatch(logOutSuccess()); - fetchMe(); - }) - .catch(error => dispatch(logOutFailure(error))); +export const logout = () => (dispatch) => { + return coralApi('/auth', {method: 'DELETE'}).then(() => { + dispatch({type: actions.LOGOUT}); + Storage.removeItem('token'); + }); }; -// LogOut Actions - -export const validForm = () => ({type: actions.VALID_FORM}); -export const invalidForm = error => ({type: actions.INVALID_FORM, error}); - -// Check Login +//============================================================================== +// CHECK LOGIN +//============================================================================== const checkLoginRequest = () => ({type: actions.CHECK_LOGIN_REQUEST}); -const checkLoginSuccess = (user, isAdmin) => ({type: actions.CHECK_LOGIN_SUCCESS, user, isAdmin}); -const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error}); +const checkLoginFailure = (error) => ({type: actions.CHECK_LOGIN_FAILURE, error}); -export const checkLogin = () => dispatch => { +const checkLoginSuccess = (user, isAdmin) => ({ + type: actions.CHECK_LOGIN_SUCCESS, + user, + isAdmin +}); + +export const checkLogin = () => (dispatch) => { dispatch(checkLoginRequest()); coralApi('/auth') .then((result) => { if (!result.user) { + Storage.removeItem('token'); throw new Error('Not logged in'); } - const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length; + const isAdmin = !!result.user.roles.filter((i) => i === 'ADMIN').length; dispatch(checkLoginSuccess(result.user, isAdmin)); }) - .catch(error => { + .catch((error) => { console.error(error); dispatch(checkLoginFailure(`${error.translation_key}`)); }); }; -const verifyEmailRequest = () => ({type: actions.VERIFY_EMAIL_REQUEST}); -const verifyEmailSuccess = () => ({type: actions.VERIFY_EMAIL_SUCCESS}); -const verifyEmailFailure = () => ({type: actions.VERIFY_EMAIL_FAILURE}); +export const validForm = () => ({type: actions.VALID_FORM}); +export const invalidForm = (error) => ({type: actions.INVALID_FORM, error}); -export const requestConfirmEmail = (email, redirectUri) => dispatch => { +//============================================================================== +// VERIFY EMAIL +//============================================================================== + +const verifyEmailRequest = () => ({ + type: actions.VERIFY_EMAIL_REQUEST +}); + +const verifyEmailSuccess = () => ({ + type: actions.VERIFY_EMAIL_SUCCESS +}); + +const verifyEmailFailure = () => ({ + type: actions.VERIFY_EMAIL_FAILURE +}); + +export const requestConfirmEmail = (email, redirectUri) => (dispatch) => { dispatch(verifyEmailRequest()); - return coralApi('/users/resend-verify', {method: 'POST', body: {email}, headers: {'X-Pym-Url': redirectUri}}) + return coralApi('/users/resend-verify', { + method: 'POST', + body: {email}, + headers: {'X-Pym-Url': redirectUri} + }) .then(() => { dispatch(verifyEmailSuccess()); }) - .catch(err => { + .catch((err) => { // email might have already been verifyed dispatch(verifyEmailFailure(err)); diff --git a/client/coral-framework/actions/user.js b/client/coral-framework/actions/user.js index 06e321110..60a54d2d9 100644 --- a/client/coral-framework/actions/user.js +++ b/client/coral-framework/actions/user.js @@ -5,7 +5,7 @@ import * as actions from '../constants/auth'; import I18n from 'coral-i18n/modules/i18n/i18n'; const lang = new I18n(); -const editUsernameFailure = error => ({type: actions.EDIT_USERNAME_FAILURE, error}); +const editUsernameFailure = (error) => ({type: actions.EDIT_USERNAME_FAILURE, error}); const editUsernameSuccess = () => ({type: actions.EDIT_USERNAME_SUCCESS}); export const editName = (username) => (dispatch) => { @@ -14,7 +14,7 @@ export const editName = (username) => (dispatch) => { dispatch(editUsernameSuccess()); dispatch(addNotification('success', lang.t('framework.success_name_update'))); }) - .catch(error => { + .catch((error) => { dispatch(editUsernameFailure(lang.t(`error.${error.translation_key}`))); }); }; diff --git a/client/coral-framework/components/Slot.css b/client/coral-framework/components/Slot.css index 6048da181..6a95d79ad 100644 --- a/client/coral-framework/components/Slot.css +++ b/client/coral-framework/components/Slot.css @@ -1,3 +1,7 @@ .inline { display: inline-block; } + +.debug { + background-color: coral; +} \ No newline at end of file diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index 8e2dd9502..939068e83 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -1,12 +1,13 @@ import React from 'react'; import cn from 'classnames'; import styles from './Slot.css'; +import {connect} from 'react-redux'; import {getSlotElements} from 'coral-framework/helpers/plugins'; -export default function Slot ({fill, inline = false, ...rest}) { +function Slot ({fill, inline = false, plugin_config: config, ...rest}) { return ( -
      - {getSlotElements(fill, rest)} +
      + {getSlotElements(fill, {...rest, config})}
      ); } @@ -14,3 +15,8 @@ export default function Slot ({fill, inline = false, ...rest}) { Slot.propTypes = { fill: React.PropTypes.string }; + +const mapStateToProps = ({config: {plugin_config = {}}}) => ({plugin_config}); + +export default connect(mapStateToProps, null)(Slot); + diff --git a/client/coral-framework/constants/asset.js b/client/coral-framework/constants/asset.js index c26dd099d..40f746706 100644 --- a/client/coral-framework/constants/asset.js +++ b/client/coral-framework/constants/asset.js @@ -8,4 +8,3 @@ export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE'; export const OPEN_COMMENTS = 'OPEN_COMMENTS'; export const CLOSE_COMMENTS = 'CLOSE_COMMENTS'; - diff --git a/client/coral-framework/constants/auth.js b/client/coral-framework/constants/auth.js index ce5d860b4..24599ac50 100644 --- a/client/coral-framework/constants/auth.js +++ b/client/coral-framework/constants/auth.js @@ -33,9 +33,7 @@ export const FETCH_FORGOT_PASSWORD_REQUEST = 'FETCH_FORGOT_PASSWORD_REQUEST'; export const FETCH_FORGOT_PASSWORD_SUCCESS = 'FETCH_FORGOT_PASSWORD_SUCCESS'; export const FETCH_FORGOT_PASSWORD_FAILURE = 'FETCH_FORGOT_PASSWORD_FAILURE'; -export const LOGOUT_REQUEST = 'LOGOUT_REQUEST'; -export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS'; -export const LOGOUT_FAILURE = 'LOGOUT_FAILURE'; +export const LOGOUT = 'LOGOUT'; export const INVALID_FORM = 'INVALID_FORM'; export const VALID_FORM = 'VALID_FORM'; @@ -44,8 +42,6 @@ export const CHECK_LOGIN_REQUEST = 'CHECK_LOGIN_REQUEST'; export const CHECK_LOGIN_SUCCESS = 'CHECK_LOGIN_SUCCESS'; export const CHECK_LOGIN_FAILURE = 'CHECK_LOGIN_FAILURE'; -export const CHECK_CSRF_TOKEN = 'CHECK_CSRF_TOKEN'; - export const VERIFY_EMAIL_REQUEST = 'VERIFY_EMAIL_REQUEST'; export const VERIFY_EMAIL_SUCCESS = 'VERIFY_EMAIL_SUCCESS'; export const VERIFY_EMAIL_FAILURE = 'VERIFY_EMAIL_FAILURE'; diff --git a/client/coral-framework/constants/config.js b/client/coral-framework/constants/config.js deleted file mode 100644 index 5dca44ba9..000000000 --- a/client/coral-framework/constants/config.js +++ /dev/null @@ -1,9 +0,0 @@ -export const UPDATE_CONFIG_REQUEST = 'UPDATE_CONFIG_REQUEST'; -export const UPDATE_CONFIG_SUCCESS = 'UPDATE_CONFIG_SUCCESS'; -export const UPDATE_CONFIG_FAILURE = 'UPDATE_CONFIG_FAILURE'; - -export const UPDATE_CONFIG = 'UPDATE_CONFIG'; - -export const OPEN_COMMENTS = 'OPEN_COMMENTS'; -export const CLOSE_COMMENTS = 'CLOSE_COMMENTS'; -export const ADD_ITEM = 'ADD_ITEM'; diff --git a/client/coral-framework/graphql/fragments.js b/client/coral-framework/graphql/fragments.js new file mode 100644 index 000000000..f4948ed05 --- /dev/null +++ b/client/coral-framework/graphql/fragments.js @@ -0,0 +1,2 @@ +// fragments defined here are automatically registered. +export default {}; diff --git a/client/coral-framework/graphql/fragments/actionSummaryView.graphql b/client/coral-framework/graphql/fragments/actionSummaryView.graphql deleted file mode 100644 index 4ac232bf6..000000000 --- a/client/coral-framework/graphql/fragments/actionSummaryView.graphql +++ /dev/null @@ -1,8 +0,0 @@ -fragment actionSummaryView on ActionSummary { - __typename - count - current_user { - id - created_at - } -} diff --git a/client/coral-framework/graphql/fragments/commentView.graphql b/client/coral-framework/graphql/fragments/commentView.graphql deleted file mode 100644 index 0ed5e00b8..000000000 --- a/client/coral-framework/graphql/fragments/commentView.graphql +++ /dev/null @@ -1,18 +0,0 @@ -#import "../fragments/actionSummaryView.graphql" - -fragment commentView on Comment { - id - body - created_at - status - tags { - name - } - user { - id - name: username - } - action_summaries { - ...actionSummaryView - } -} diff --git a/client/coral-framework/graphql/mutations.js b/client/coral-framework/graphql/mutations.js new file mode 100644 index 000000000..0744df35b --- /dev/null +++ b/client/coral-framework/graphql/mutations.js @@ -0,0 +1,171 @@ +import {gql} from 'react-apollo'; +import withMutation from '../hocs/withMutation'; + +export const withPostComment = withMutation( + gql` + mutation PostComment($comment: CreateCommentInput!) { + createComment(comment: $comment) { + ...CreateCommentResponse + } + } + `, { + props: ({mutate}) => ({ + postComment: (comment) => { + return mutate({ + variables: { + comment + }, + }); + } + }), + }); + +export const withEditComment = withMutation( + gql` + mutation EditComment($id: ID!, $asset_id: ID!, $edit: EditCommentInput) { + editComment(id:$id, asset_id:$asset_id, edit:$edit) { + ...EditCommentResponse + } + } + `, { + props: ({mutate}) => ({ + editComment: (id, asset_id, edit) => { + return mutate({ + variables: { + id, + asset_id, + edit, + }, + }); + } + }), + }); + +export const withPostFlag = withMutation( + gql` + mutation PostFlag($flag: CreateFlagInput!) { + createFlag(flag: $flag) { + ...CreateFlagResponse + } + } + `, { + props: ({mutate}) => ({ + postFlag: (flag) => { + return mutate({ + variables: { + flag + } + }); + }}), + }); + +export const withPostDontAgree = withMutation( + gql` + mutation CreateDontAgree($dontagree: CreateDontAgreeInput!) { + createDontAgree(dontagree: $dontagree) { + ...CreateDontAgreeResponse + } + } + `, { + props: ({mutate}) => ({ + postDontAgree: (dontagree) => { + return mutate({ + variables: { + dontagree + } + }); + }}), + }); + +export const withDeleteAction = withMutation( + gql` + mutation DeleteAction($id: ID!) { + deleteAction(id:$id) { + ...DeleteActionResponse + } + } + `, { + props: ({mutate}) => ({ + deleteAction: (id) => { + return mutate({ + variables: { + id + } + }); + }}), + }); + +export const withAddCommentTag = withMutation( + gql` + mutation AddCommentTag($id: ID!, $tag: String!) { + addCommentTag(id:$id, tag:$tag) { + ...AddCommentTagResponse + } + } + `, { + props: ({mutate}) => ({ + addCommentTag: ({id, tag}) => { + return mutate({ + variables: { + id, + tag + } + }); + }}), + }); + +export const withRemoveCommentTag = withMutation( + gql` + mutation RemoveCommentTag($id: ID!, $tag: String!) { + removeCommentTag(id:$id, tag:$tag) { + ...RemoveCommentTagResponse + } + } + `, { + props: ({mutate}) => ({ + removeCommentTag: ({id, tag}) => { + return mutate({ + variables: { + id, + tag + } + }); + }}), + }); + +export const withIgnoreUser = withMutation( + gql` + mutation IgnoreUser($id: ID!) { + ignoreUser(id:$id) { + ...IgnoreUserResponse + } + } + `, { + props: ({mutate}) => ({ + ignoreUser: ({id}) => { + return mutate({ + variables: { + id, + }, + }); + }}), + }); + +export const withStopIgnoringUser = withMutation( + gql` + mutation StopIgnoringUser($id: ID!) { + stopIgnoringUser(id:$id) { + ...StopIgnoringUserResponse + } + } + `, { + props: ({mutate}) => ({ + stopIgnoringUser: ({id}) => { + return mutate({ + variables: { + id, + }, + }); + }}), + }); + diff --git a/client/coral-framework/graphql/mutations/addCommentTag.graphql b/client/coral-framework/graphql/mutations/addCommentTag.graphql deleted file mode 100644 index 5fd63868e..000000000 --- a/client/coral-framework/graphql/mutations/addCommentTag.graphql +++ /dev/null @@ -1,13 +0,0 @@ -mutation AddCommentTag ($id: ID!, $tag: String!) { - addCommentTag(id:$id, tag:$tag) { - comment { - id - tags { - name - } - } - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/deleteAction.graphql b/client/coral-framework/graphql/mutations/deleteAction.graphql deleted file mode 100644 index f8adf371c..000000000 --- a/client/coral-framework/graphql/mutations/deleteAction.graphql +++ /dev/null @@ -1,7 +0,0 @@ -mutation deleteAction ($id: ID!) { - deleteAction(id:$id) { - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/ignoreUser.graphql b/client/coral-framework/graphql/mutations/ignoreUser.graphql deleted file mode 100644 index ad3c399f3..000000000 --- a/client/coral-framework/graphql/mutations/ignoreUser.graphql +++ /dev/null @@ -1,7 +0,0 @@ -mutation ignoreUser ($id: ID!) { - ignoreUser(id:$id) { - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/index.js b/client/coral-framework/graphql/mutations/index.js deleted file mode 100644 index bdd7fd1a1..000000000 --- a/client/coral-framework/graphql/mutations/index.js +++ /dev/null @@ -1,173 +0,0 @@ -import {graphql} from 'react-apollo'; -import POST_COMMENT from './postComment.graphql'; -import POST_FLAG from './postFlag.graphql'; -import POST_DONT_AGREE from './postDontAgree.graphql'; -import DELETE_ACTION from './deleteAction.graphql'; -import ADD_COMMENT_TAG from './addCommentTag.graphql'; -import REMOVE_COMMENT_TAG from './removeCommentTag.graphql'; -import IGNORE_USER from './ignoreUser.graphql'; -import STOP_IGNORING_USER from './stopIgnoringUser.graphql'; - -import commentView from '../fragments/commentView.graphql'; - -export const postComment = graphql(POST_COMMENT, { - options: () => ({ - fragments: commentView - }), - props: ({ownProps, mutate}) => ({ - postItem: comment => { - const {asset_id, body, parent_id, tags = []} = comment; - return mutate({ - variables: { - comment - }, - optimisticResponse: { - createComment: { - comment: { - user: { - id: ownProps.auth.user.id, - name: ownProps.auth.user.username - }, - created_at: new Date().toISOString(), - body, - parent_id, - asset_id, - action_summaries: [], - tags, - status: null, - id: 'pending' - } - } - }, - updateQueries: { - EmbedQuery: (oldData, {mutationResult: {data: {createComment: {comment}}}}) => { - - if (oldData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') { - return oldData; - } - - let updatedAsset; - - // If posting a reply - if (parent_id) { - updatedAsset = { - ...oldData, - asset: { - ...oldData.asset, - comments: oldData.asset.comments.map((oldComment) => { - return oldComment.id === parent_id - ? {...oldComment, replies: [...oldComment.replies, comment], replyCount: oldComment.replyCount + 1} - : oldComment; - }) - } - }; - } else { - - // If posting a top-level comment - updatedAsset = { - ...oldData, - asset: { - ...oldData.asset, - commentCount: oldData.asset.commentCount + 1, - comments: [comment, ...oldData.asset.comments] - } - }; - } - - return updatedAsset; - } - } - }); - } - }), -}); - -export const postFlag = graphql(POST_FLAG, { - props: ({mutate}) => ({ - postFlag: (flag) => { - return mutate({ - variables: { - flag - } - }); - }}), -}); - -export const postDontAgree = graphql(POST_DONT_AGREE, { - props: ({mutate}) => ({ - postDontAgree: (dontagree) => { - return mutate({ - variables: { - dontagree - } - }); - }}), -}); - -export const deleteAction = graphql(DELETE_ACTION, { - props: ({mutate}) => ({ - deleteAction: (id) => { - return mutate({ - variables: { - id - } - }); - }}), -}); - -export const addCommentTag = graphql(ADD_COMMENT_TAG, { - props: ({mutate}) => ({ - addCommentTag: ({id, tag}) => { - return mutate({ - variables: { - id, - tag - } - }); - }}), -}); - -export const removeCommentTag = graphql(REMOVE_COMMENT_TAG, { - props: ({mutate}) => ({ - removeCommentTag: ({id, tag}) => { - return mutate({ - variables: { - id, - tag - } - }); - }}), -}); - -// TODO: don't rely on refetching. -export const ignoreUser = graphql(IGNORE_USER, { - props: ({mutate}) => ({ - ignoreUser: ({id}) => { - return mutate({ - variables: { - id, - }, - refetchQueries: [ - 'EmbedQuery', 'myIgnoredUsers', - ] - }); - }}), -}); - -// TODO: don't rely on refetching. -export const stopIgnoringUser = graphql(STOP_IGNORING_USER, { - props: ({mutate}) => { - return { - stopIgnoringUser: ({id}) => { - return mutate({ - variables: { - id, - }, - refetchQueries: [ - 'EmbedQuery', 'myIgnoredUsers', - ] - }); - } - }; - } -}); diff --git a/client/coral-framework/graphql/mutations/postComment.graphql b/client/coral-framework/graphql/mutations/postComment.graphql deleted file mode 100644 index f98558804..000000000 --- a/client/coral-framework/graphql/mutations/postComment.graphql +++ /dev/null @@ -1,16 +0,0 @@ -#import "../fragments/commentView.graphql" - -mutation CreateComment ($comment: CreateCommentInput!) { - createComment(comment: $comment) { - comment { - ...commentView - replyCount - replies { - ...commentView - } - } - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/postDontAgree.graphql b/client/coral-framework/graphql/mutations/postDontAgree.graphql deleted file mode 100644 index 6e36d48b8..000000000 --- a/client/coral-framework/graphql/mutations/postDontAgree.graphql +++ /dev/null @@ -1,10 +0,0 @@ -mutation CreateDontAgree($dontagree: CreateDontAgreeInput!) { - createDontAgree(dontagree:$dontagree) { - dontagree { - id - } - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/postFlag.graphql b/client/coral-framework/graphql/mutations/postFlag.graphql deleted file mode 100644 index cabc2feef..000000000 --- a/client/coral-framework/graphql/mutations/postFlag.graphql +++ /dev/null @@ -1,10 +0,0 @@ -mutation CreateFlag($flag: CreateFlagInput!) { - createFlag(flag:$flag) { - flag { - id - } - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/removeCommentTag.graphql b/client/coral-framework/graphql/mutations/removeCommentTag.graphql deleted file mode 100644 index 3826b0703..000000000 --- a/client/coral-framework/graphql/mutations/removeCommentTag.graphql +++ /dev/null @@ -1,13 +0,0 @@ -mutation RemoveCommentTag ($id: ID!, $tag: String!) { - removeCommentTag(id:$id, tag:$tag) { - comment { - id - tags { - name - } - } - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/mutations/stopIgnoringUser.graphql b/client/coral-framework/graphql/mutations/stopIgnoringUser.graphql deleted file mode 100644 index 042452ff5..000000000 --- a/client/coral-framework/graphql/mutations/stopIgnoringUser.graphql +++ /dev/null @@ -1,7 +0,0 @@ -mutation stopIgnoringUser ($id: ID!) { - stopIgnoringUser(id:$id) { - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/queries/index.js b/client/coral-framework/graphql/queries/index.js deleted file mode 100644 index d76614a68..000000000 --- a/client/coral-framework/graphql/queries/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import {graphql} from 'react-apollo'; -import MY_COMMENT_HISTORY from './myCommentHistory.graphql'; -import MY_IGNORED_USERS from './myIgnoredUsers.graphql'; - -export const myCommentHistory = graphql(MY_COMMENT_HISTORY, {}); - -export const myIgnoredUsers = graphql(MY_IGNORED_USERS, { - props: ({data}) => { - return ({ - myIgnoredUsersData: data - }); - } -}); diff --git a/client/coral-framework/graphql/queries/myCommentHistory.graphql b/client/coral-framework/graphql/queries/myCommentHistory.graphql deleted file mode 100644 index 0b37b192a..000000000 --- a/client/coral-framework/graphql/queries/myCommentHistory.graphql +++ /dev/null @@ -1,14 +0,0 @@ -query myCommentHistory { - me { - comments { - id - body - asset { - id - title - url - } - created_at - } - } -} diff --git a/client/coral-framework/graphql/queries/myIgnoredUsers.graphql b/client/coral-framework/graphql/queries/myIgnoredUsers.graphql deleted file mode 100644 index d81531e37..000000000 --- a/client/coral-framework/graphql/queries/myIgnoredUsers.graphql +++ /dev/null @@ -1,6 +0,0 @@ -query myIgnoredUsers { - myIgnoredUsers { - id, - username, - } -} diff --git a/client/coral-framework/helpers/hoc.js b/client/coral-framework/helpers/hoc.js new file mode 100644 index 000000000..bb034b179 --- /dev/null +++ b/client/coral-framework/helpers/hoc.js @@ -0,0 +1,3 @@ +export function getDisplayName(WrappedComponent) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component'; +} diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index 20aee7abb..f14963833 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -3,14 +3,14 @@ import merge from 'lodash/merge'; import flatten from 'lodash/flatten'; import flattenDeep from 'lodash/flattenDeep'; import uniq from 'lodash/uniq'; +import pick from 'lodash/pick'; import plugins from 'pluginsConfig'; -import {gql} from 'react-apollo'; -import {getDefinitionName} from 'coral-framework/utils'; +import {getDefinitionName, mergeDocuments} from 'coral-framework/utils'; export const pluginReducers = merge( ...plugins - .filter(o => o.module.reducer) - .map(o => ({...o.module.reducer})) + .filter((o) => o.module.reducer) + .map((o) => ({...o.module.reducer})) ); /** @@ -18,26 +18,35 @@ export const pluginReducers = merge( */ export function getSlotElements(slot, props = {}) { const components = flatten(plugins - .filter(o => o.module.slots[slot]) - .map(o => o.module.slots[slot])); + .filter((o) => o.module.slots[slot]) + .map((o) => o.module.slots[slot])); return components - .map((component, i) => React.createElement(component, {...props, key: i})); + .map((component, i) => React.createElement(component, {key: i, ...props})); } function getComponentFragments(components) { - return components - .map(c => c.fragments) - .filter(fragments => fragments) + const res = components + .map((c) => c.fragments) + .filter((fragments) => fragments) .reduce((res, fragments) => { - Object.keys(fragments).forEach(key => { + Object.keys(fragments).forEach((key) => { if (!(key in res)) { - res[key] = {spreads: '', definitions: ''}; + res[key] = {spreads: [], definitions: []}; } - res[key].spreads += `...${getDefinitionName(fragments[key])}\n`; - res[key].definitions = gql`${res[key].definitions}${fragments[key]}`; + res[key].spreads.push(getDefinitionName(fragments[key])); + res[key].definitions.push(fragments[key]); }); return res; }, {}); + + Object.keys(res).forEach((key) => { + + // Assemble arguments for `gql` to call it directly without using template literals. + res[key].spreads = `...${res[key].spreads.join('\n...')}\n`; + res[key].definitions = mergeDocuments(res[key].definitions); + }); + + return res; } /** @@ -56,10 +65,10 @@ export function getSlotsFragments(slots) { if (!Array.isArray(slots)) { slots = [slots]; } - const components = uniq(flattenDeep(slots.map(slot => { + const components = uniq(flattenDeep(slots.map((slot) => { return plugins - .filter(o => o.module.slots[slot]) - .map(o => o.module.slots[slot]); + .filter((o) => o.module.slots[slot]) + .map((o) => o.module.slots[slot]); }))); const fragments = getComponentFragments(components); @@ -73,3 +82,9 @@ export function getSlotsFragments(slots) { }; } +export function getGraphQLExtensions() { + return plugins + .map((o) => pick(o.module, ['mutations', 'queries', 'fragments'])) + .filter((o) => o); +} + diff --git a/client/coral-framework/helpers/response.js b/client/coral-framework/helpers/response.js index 0fce878cd..794a73e30 100644 --- a/client/coral-framework/helpers/response.js +++ b/client/coral-framework/helpers/response.js @@ -1,31 +1,22 @@ -export const base = '/api/v1'; +import * as Storage from './storage'; const buildOptions = (inputOptions = {}) => { - - const csurfDOM = document.head.querySelector('[property=csrf]'); - const defaultOptions = { method: 'GET', headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' + Accept: 'application/json', + Authorization: `Bearer ${Storage.getItem('token')}`, + 'Content-Type': 'application/json' }, - credentials: 'same-origin', - _csrf: csurfDOM ? csurfDOM.content : false + credentials: 'same-origin' }; let options = Object.assign({}, defaultOptions, inputOptions); - options.headers = Object.assign({}, defaultOptions.headers, inputOptions.headers); - - if (options._csrf) { - switch (options.method.toLowerCase()) { - case 'post': - case 'put': - case 'delete': - options.headers['x-csrf-token'] = options._csrf; - break; - } - } + options.headers = Object.assign( + {}, + defaultOptions.headers, + inputOptions.headers + ); if (options.method.toLowerCase() !== 'get') { options.body = JSON.stringify(options.body); @@ -34,9 +25,9 @@ const buildOptions = (inputOptions = {}) => { return options; }; -const handleResp = res => { +const handleResp = (res) => { if (res.status > 399) { - return res.json().then(err => { + return res.json().then((err) => { let message = err.message || res.status; const error = new Error(); @@ -58,6 +49,8 @@ const handleResp = res => { } }; +export const base = '/api/v1'; + export default (url, options) => { return fetch(`${base}${url}`, buildOptions(options)).then(handleResp); }; diff --git a/client/coral-framework/helpers/storage.js b/client/coral-framework/helpers/storage.js new file mode 100644 index 000000000..fa20fa168 --- /dev/null +++ b/client/coral-framework/helpers/storage.js @@ -0,0 +1,93 @@ +let available, error; + +function storageAvailable(type) { + let storage = window[type], x = '__storage_test__'; + try { + storage.setItem(x, x); + storage.removeItem(x); + return true; + } catch (e) { + error = e; + return ( + e instanceof DOMException && + + // everything except Firefox + (e.code === 22 || + + // Firefox + + e.code === 1014 || + + // test name field too, because code might not be present + + // everything except Firefox + e.name === 'QuotaExceededError' || + + // Firefox + e.name === 'NS_ERROR_DOM_QUOTA_REACHED') && + + // acknowledge QuotaExceededError only if there's something already stored + storage.length !== 0 + ); + } +} + +function lazyCheckStorage() { + if (typeof available === 'undefined') { + available = storageAvailable('localStorage'); + } +} + +export function getItem(item = '') { + lazyCheckStorage(); + + if (available) { + return localStorage.getItem(item); + } else { + console.error( + `Cannot get from localStorage. localStorage is not available. ${error}` + ); + } +} + +export function setItem(item = '', value) { + lazyCheckStorage(); + + if (available) { + return localStorage.setItem(item, value); + } else { + console.error( + `Cannot set localStorage. localStorage is not available. ${error}` + ); + } +} + +export function removeItem(item = '') { + lazyCheckStorage(); + + if (available) { + return localStorage.removeItem(item); + } else { + console.error( + `Cannot remove item from localStorage. localStorage is not available. ${error}` + ); + } +} + +export function clear() { + lazyCheckStorage(); + + if (available) { + return localStorage.clear(); + } else { + console.error( + `Cannot clear localStorage. localStorage is not available. ${error}` + ); + } +} + +// Enable this to debug WEB Storage events +// window.addEventListener('storage', function(e) { +// const msg = `${e.key} " was changed in page ${e.url} from ${e.oldValue} to ${e.newValue}`; +// console.log(msg); +// }); diff --git a/client/coral-framework/helpers/strings.js b/client/coral-framework/helpers/strings.js new file mode 100644 index 000000000..0a267da59 --- /dev/null +++ b/client/coral-framework/helpers/strings.js @@ -0,0 +1,4 @@ +export function capitalize(str) { + const newString = new String(str); + return newString.charAt(0).toUpperCase() + newString.slice(1); +} diff --git a/client/coral-framework/helpers/validate.js b/client/coral-framework/helpers/validate.js index fc1316723..dd36ae07f 100644 --- a/client/coral-framework/helpers/validate.js +++ b/client/coral-framework/helpers/validate.js @@ -1,7 +1,7 @@ export default { - email: email => (/^([A-Za-z0-9_\-\.\+])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)), - password: pass => (/^(?=.{8,}).*$/.test(pass)), + email: (email) => (/^([A-Za-z0-9_\-\.\+])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)), + password: (pass) => (/^(?=.{8,}).*$/.test(pass)), confirmPassword: () => true, - username: username => (/^[a-zA-Z0-9_]+$/.test(username)), - organizationName: org => (/^[a-zA-Z0-9_ ]+$/).test(org) + username: (username) => (/^[a-zA-Z0-9_]+$/.test(username)), + organizationName: (org) => (/^[a-zA-Z0-9_ ]+$/).test(org) }; diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js new file mode 100644 index 000000000..ef4c2ac74 --- /dev/null +++ b/client/coral-framework/hocs/index.js @@ -0,0 +1,5 @@ +export {default as withFragments} from './withFragments'; +export {default as withMutation} from './withMutation'; +export {default as withQuery} from './withQuery'; +export {default as withReaction} from './withReaction'; + diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js index a2686d94b..d62d62ae3 100644 --- a/client/coral-framework/hocs/withFragments.js +++ b/client/coral-framework/hocs/withFragments.js @@ -1,12 +1,8 @@ import React from 'react'; +import {getDisplayName} from '../helpers/hoc'; // TODO: revisit `filtering` after https://github.com/apollographql/graphql-anywhere/issues/38. - -function getDisplayName(WrappedComponent) { - return WrappedComponent.displayName || WrappedComponent.name || 'Component'; -} - -export default fragments => WrappedComponent => { +export default (fragments) => (WrappedComponent) => { class WithFragments extends React.Component { render() { return ; diff --git a/client/coral-framework/hocs/withMutation.js b/client/coral-framework/hocs/withMutation.js new file mode 100644 index 000000000..044bcdfad --- /dev/null +++ b/client/coral-framework/hocs/withMutation.js @@ -0,0 +1,96 @@ +import * as React from 'react'; +import {graphql} from 'react-apollo'; +import merge from 'lodash/merge'; +import uniq from 'lodash/uniq'; +import flatten from 'lodash/flatten'; +import isEmpty from 'lodash/isEmpty'; +import {getMutationOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry'; +import {store} from 'coral-framework/services/store'; +import {getDefinitionName} from '../utils'; + +/** + * Exports a HOC with the same signature as `graphql`, that will + * apply mutation options registered in the graphRegistry. + */ +export default (document, config) => (WrappedComponent) => { + config = { + ...config, + options: config.options || {}, + props: config.props || ((data) => ({mutate: data.mutate()})), + }; + const wrappedProps = (data) => { + const name = getDefinitionName(document); + const callbacks = getMutationOptions(name); + const mutate = (base) => { + const variables = base.variables || config.options.variables; + const configs = callbacks.map((cb) => cb({variables, state: store.getState()})); + + const optimisticResponse = merge( + base.optimisticResponse || config.options.optimisticResponse, + ...configs.map((cfg) => cfg.optimisticResponse), + ); + + const refetchQueries = flatten(uniq([ + base.refetchQueries || config.options.refetchQueries, + ...configs.map((cfg) => cfg.refetchQueries), + ].filter((i) => i))); + + const updateCallbacks = + [base.update || config.options.update] + .concat(...configs.map((cfg) => cfg.update)) + .filter((i) => i); + + const update = (proxy, result) => { + updateCallbacks.forEach((cb) => cb(proxy, result)); + }; + + const updateQueries = + [ + base.updateQueries || config.options.updateQueries, + ...configs.map((cfg) => cfg.updateQueries) + ] + .filter((i) => i) + .reduce((res, map) => { + Object.keys(map).forEach((key) => { + if (!(key in res)) { + res[key] = map[key]; + } else { + const existing = res[key]; + res[key] = (prev, result) => { + const next = existing(prev, result); + return map[key](next, result); + }; + } + }); + return res; + }, {}); + + const wrappedConfig = { + variables, + optimisticResponse, + refetchQueries, + updateQueries, + update, + }; + if (isEmpty(wrappedConfig.optimisticResponse)) { + delete wrappedConfig.optimisticResponse; + } + return data.mutate(wrappedConfig); + }; + return config.props({...data, mutate}); + }; + + // Lazily resolve fragments from graphRegistry to support circular dependencies. + let memoized = null; + const getWrapped = () => { + if (!memoized) { + memoized = graphql(resolveFragments(document), {...config, props: wrappedProps})(WrappedComponent); + } + return memoized; + }; + + return (props) => { + const Wrapped = getWrapped(); + return ; + }; +}; diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js new file mode 100644 index 000000000..036f9c149 --- /dev/null +++ b/client/coral-framework/hocs/withQuery.js @@ -0,0 +1,49 @@ +import * as React from 'react'; +import {graphql} from 'react-apollo'; +import {getQueryOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry'; +import {getDefinitionName, separateDataAndRoot} from '../utils'; + +/** + * Exports a HOC with the same signature as `graphql`, that will + * apply query options registered in the graphRegistry. + */ +export default (document, config) => (WrappedComponent) => { + config = { + ...config, + options: config.options || {}, + props: config.props || (({data}) => separateDataAndRoot(data)), + }; + + const wrappedOptions = (data) => { + const base = (typeof config.options === 'function') ? config.options(data) : config.options; + const name = getDefinitionName(document); + const configs = getQueryOptions(name); + const reducerCallbacks = + [base.reducer || ((i) => i)] + .concat(...configs.map((cfg) => cfg.reducer)) + .filter((i) => i); + + const reducer = reducerCallbacks.reduce( + (a, b) => (prev, ...rest) => + b(a(prev, ...rest), ...rest), + ); + + return { + ...base, + reducer, + }; + }; + + let memoized = null; + const getWrapped = () => { + if (!memoized) { + memoized = graphql(resolveFragments(document), {...config, options: wrappedOptions})(WrappedComponent); + } + return memoized; + }; + + return (props) => { + const Wrapped = getWrapped(); + return ; + }; +}; diff --git a/client/coral-framework/hocs/withReaction.js b/client/coral-framework/hocs/withReaction.js new file mode 100644 index 000000000..f2897e606 --- /dev/null +++ b/client/coral-framework/hocs/withReaction.js @@ -0,0 +1,243 @@ +import React from 'react'; +import get from 'lodash/get'; +import uuid from 'uuid/v4'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {getDisplayName} from '../helpers/hoc'; +import {compose, gql, graphql} from 'react-apollo'; +import withFragments from 'coral-framework/hocs/withFragments'; +import {showSignInDialog} from 'coral-framework/actions/auth'; +import {capitalize} from 'coral-framework/helpers/strings'; +import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils'; + +export default (reaction) => (WrappedComponent) => { + if (typeof reaction !== 'string') { + console.error('Reaction must be a valid string'); + return null; + } + + reaction = reaction.toLowerCase(); + + class WithReactions extends React.Component { + render() { + const {comment} = this.props; + + const reactionSummary = getMyActionSummary( + `${capitalize(reaction)}ActionSummary`, + comment + ); + + const count = getTotalActionCount( + `${capitalize(reaction)}ActionSummary`, + comment + ); + + const alreadyReacted = () => !!reactionSummary; + + const withReactionProps = {reactionSummary, count, alreadyReacted}; + + return ; + } + } + + const isReaction = (a) => + a.__typename === `${capitalize(reaction)}ActionSummary`; + + const COMMENT_FRAGMENT = gql` + fragment ${capitalize(reaction)}Button_updateFragment on Comment { + action_summaries { + ... on ${capitalize(reaction)}ActionSummary { + count + current_user { + id + } + } + } + } + `; + + const withDeleteReaction = graphql( + gql` + mutation deleteReaction($id: ID!) { + deleteAction(id:$id) { + errors { + translation_key + } + } + } + `, + { + props: ({mutate, ownProps}) => ({ + deleteReaction: () => { + + const reactionSummary = getMyActionSummary( + `${capitalize(reaction)}ActionSummary`, + ownProps.comment + ); + + const reactionData = { + id: reactionSummary.current_user.id, + commentId: ownProps.comment.id + }; + + return mutate({ + variables: {id: reactionData.id}, + optimisticResponse: { + deleteAction: { + __typename: 'DeleteActionResponse', + errors: null + } + }, + update: (proxy) => { + const fragmentId = `Comment_${reactionData.commentId}`; + + // Read the data from our cache for this query. + const data = proxy.readFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId + }); + + // Check whether we liked this comment. + const idx = data.action_summaries.findIndex(isReaction); + if ( + idx < 0 || + get(data.action_summaries[idx], 'current_user.id') !== reactionData.id + ) { + return; + } + + data.action_summaries[idx] = { + ...data.action_summaries[idx], + count: data.action_summaries[idx].count - 1, + current_user: null + }; + + // Write our data back to the cache. + proxy.writeFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId, + data + }); + } + }); + } + }) + } + ); + + const withPostReaction = graphql( + gql` + mutation create${capitalize(reaction)}($${reaction}: Create${capitalize(reaction)}Input!) { + create${capitalize(reaction)}(${reaction}: $${reaction}) { + ${reaction} { + id + } + errors { + translation_key + } + } + } + `, + { + props: ({mutate, ownProps}) => ({ + postReaction: () => { + + const reactionData = { + item_id: ownProps.comment.id, + item_type: 'COMMENTS' + }; + + return mutate({ + variables: {[reaction]: reactionData}, + optimisticResponse: { + [`create${capitalize(reaction)}`]: { + __typename: `Create${capitalize(reaction)}Response`, + errors: null, + [reaction]: { + __typename: `${capitalize(reaction)}Action`, + id: uuid() + } + } + }, + update: (proxy, mutationResult) => { + const fragmentId = `Comment_${reactionData.item_id}`; + + // Read the data from our cache for this query. + const data = proxy.readFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId + }); + + // Add our comment from the mutation to the end. + let idx = data.action_summaries.findIndex(isReaction); + + // Check whether we already reactioned this comment. + if (idx >= 0 && data.action_summaries[idx].current_user) { + return; + } + + if (idx < 0) { + + // Add initial action when it doesn't exist. + data.action_summaries.push({ + __typename: `${capitalize(reaction)}ActionSummary`, + count: 0, + current_user: null + }); + idx = data.action_summaries.length - 1; + } + + data.action_summaries[idx] = { + ...data.action_summaries[idx], + count: data.action_summaries[idx].count + 1, + current_user: mutationResult.data[ + `create${capitalize(reaction)}` + ][reaction] + }; + + // Write our data back to the cache. + proxy.writeFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId, + data + }); + } + }); + } + }) + } + ); + + const mapDispatchToProps = (dispatch) => + bindActionCreators({showSignInDialog}, dispatch); + + const enhance = compose( + withFragments({ + root: gql` + fragment ${capitalize(reaction)}Button_root on RootQuery { + me { + status + } + } + `, + comment: gql` + fragment ${capitalize(reaction)}Button_comment on Comment { + action_summaries { + ... on ${capitalize(reaction)}ActionSummary { + count + current_user { + id + } + } + } + }` + }), + connect(null, mapDispatchToProps), + withDeleteReaction, + withPostReaction + ); + + WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`; + + return enhance(WithReactions); +}; diff --git a/client/coral-framework/loaders/plugins-loader.js b/client/coral-framework/loaders/plugins-loader.js index c3534df32..4feae42ca 100644 --- a/client/coral-framework/loaders/plugins-loader.js +++ b/client/coral-framework/loaders/plugins-loader.js @@ -11,7 +11,7 @@ const {stripIndent} = require('common-tags'); function getPluginList(config) { if (config && config.client) { - return config.client.map(x => typeof x === 'string' ? x : Object.keys(x)[0]); + return config.client.map((x) => typeof x === 'string' ? x : Object.keys(x)[0]); } return []; diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js index 83ea49ce5..0796f7574 100644 --- a/client/coral-framework/reducers/auth.js +++ b/client/coral-framework/reducers/auth.js @@ -20,7 +20,7 @@ const initialState = Map({ fromSignUp: false }); -const purge = user => { +const purge = (user) => { const {settings, profiles, ...userData} = user; // eslint-disable-line return fromJS(userData); }; @@ -64,9 +64,6 @@ export default function auth (state = initialState, action) { .set('view', action.view); case actions.CLEAN_STATE: return initialState; - case actions.CHECK_CSRF_TOKEN: - return state - .set('_csrf', action._csrf); case actions.FETCH_SIGNIN_REQUEST: return state .set('isLoading', true); @@ -116,7 +113,7 @@ export default function auth (state = initialState, action) { return state .set('isLoading', false) .set('successSignUp', true); - case actions.LOGOUT_SUCCESS: + case actions.LOGOUT: return state .set('user', null) .set('isLoading', false) diff --git a/client/coral-framework/reducers/user.js b/client/coral-framework/reducers/user.js index efa967cf0..2969665e3 100644 --- a/client/coral-framework/reducers/user.js +++ b/client/coral-framework/reducers/user.js @@ -12,7 +12,7 @@ const initialState = Map({ ignoredUsers: Set(), }); -const purge = user => { +const purge = (user) => { const {_id, created_at, updated_at, __v, roles, ...userData} = user; // eslint-disable-line return userData; }; @@ -42,9 +42,9 @@ export default function user (state = initialState, action) { case 'APOLLO_MUTATION_RESULT': switch (action.operationName) { case 'ignoreUser': - return state.updateIn(['ignoredUsers'], i => i.add(action.variables.id)); + return state.updateIn(['ignoredUsers'], (i) => i.add(action.variables.id)); case 'stopIgnoringUser': - return state.updateIn(['ignoredUsers'], i => i.delete(action.variables.id)); + return state.updateIn(['ignoredUsers'], (i) => i.delete(action.variables.id)); } break; } diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js index 07bd13ca1..47ea9c352 100644 --- a/client/coral-framework/services/client.js +++ b/client/coral-framework/services/client.js @@ -1,5 +1,5 @@ import ApolloClient, {addTypename} from 'apollo-client'; -import getNetworkInterface from './transport'; +import {networkInterface} from './transport'; // import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws'; @@ -11,8 +11,6 @@ import getNetworkInterface from './transport'; // getNetworkInterface(), // wsClient, // ); -const networkInterface = getNetworkInterface(); - export const client = new ApolloClient({ connectToDevTools: true, queryTransformer: addTypename, diff --git a/client/coral-framework/services/graphqlRegistry.js b/client/coral-framework/services/graphqlRegistry.js new file mode 100644 index 000000000..8b917c465 --- /dev/null +++ b/client/coral-framework/services/graphqlRegistry.js @@ -0,0 +1,198 @@ +import {getDefinitionName, mergeDocuments} from 'coral-framework/utils'; +import {getGraphQLExtensions} from 'coral-framework/helpers/plugins'; +import globalFragments from 'coral-framework/graphql/fragments'; +import uniq from 'lodash/uniq'; + +const fragments = {}; +const mutationOptions = {}; +const queryOptions = {}; + +const getTypeName = (ast) => ast.definitions[0].typeCondition.name.value; + +/** + * Add fragment + * + * Example: + * addFragment('MyFragment', gql` + * fragment Plugin_MyFragment on Comment { + * body + * } + * `); + */ +export function addFragment(key, document) { + const type = getTypeName(document); + const name = getDefinitionName(document); + if (!(key in fragments)) { + fragments[key] = {type, names: [name], documents: [document]}; + } else { + if (type !== fragments[key].type) { + console.error(`Type mismatch ${type} !== ${fragments[key].type}`); + } + fragments[key].names.push(name); + fragments[key].documents.push(document); + } +} + +/** + * Add mutation options. + * + * Example: + * // state is the current redux state, which is sometimes + * // necessary to fill the optimistic response. + * addMutationOptions('PostComment', ({variables, state}) => ({ + * optimisticResponse: { + * CreateComment: { + * extra: '', + * }, + * }, + * refetchQueries: [], + * updateQueries: { + * EmbedQuery: (previous, data) => { + * return previous; + * }, + * }, + * update: (proxy, result) => { + * }, + * }) + */ +export function addMutationOptions(key, config) { + if (!(key in mutationOptions)) { + mutationOptions[key] = [config]; + } else { + mutationOptions[key].push(config); + } +} + +/** + * Add query options. + * + * Example: + * addQueryOptions('EmbedQuery', { + * reducer: (previousResult, action, variables) => previousResult, + * }); + */ +export function addQueryOptions(key, config) { + if (!(key in queryOptions)) { + queryOptions[key] = [config]; + } else { + queryOptions[key].push(config); + } +} + +/** + * Add all fragments, mutation options, and query options defined in the object. + * + * Example: + * add({ + * fragments: { + * CreateCommentResponse: gql` + * fragment CoralRandomEmoji_CreateCommentResponse on CreateCommentResponse { + * [...] + * }`, + * }, + * mutations: { + * // state is the current redux state, which is sometimes + * // necessary to fill the optimistic response. + * PostComment: ({variables, state}) => ({ + * optimisticResponse: { + * [...] + * }, + * refetchQueries: [], + * updateQueries: { + * EmbedQuery: (previous, data) => { + * return previous; + * }, + * }, + * update: (proxy, result) => { + * }, + * }) + * }, + * queries: { + * EmbedQuery: { + * reducer: (previousResult, action, variables) => { + * return previousResult; + * }, + * }, + * }, + * }); + */ +export function add(extension) { + Object.keys(extension.fragments || []).forEach((key) => addFragment(key, extension.fragments[key])); + Object.keys(extension.mutations || []).forEach((key) => addMutationOptions(key, extension.mutations[key])); + Object.keys(extension.queries || []).forEach((key) => addQueryOptions(key, extension.queries[key])); +} + +/** + * Get a list of mutation options. + */ +export function getMutationOptions(key) { + init(); + return mutationOptions[key] || []; +} + +/** + * Get a list of query options. + */ +export function getQueryOptions(key) { + init(); + return queryOptions[key] || []; +} + +/** + * Get a document with a fragment named `key`, which contains + * all fragments added under this key. + */ +export function getFragmentDocument(key) { + init(); + + if (!(key in fragments)) { + return ''; + } + + let documents = fragments[key] ? fragments[key].documents : []; + let fields = fragments[key] ? `...${fragments[key].names.join('\n...')}\n` : ' __typename'; + + // Assemble arguments for `gql` to call it directly without using template literals. + const main = ` + fragment ${key} on ${fragments[key].type} { + ${fields} + } + `; + return mergeDocuments([main, ...documents]); +} + +// The fragments and configs are lazily loaded to allow circular dependencies to work. +// TODO: We might want to change this to an explicit add after we have lazy Queries and Mutations. +let initialized = false; + +function init() { + if (initialized) { return; } + initialized = true; + + // Add fragments from framework. + [globalFragments].forEach((map) => + Object.keys(map).forEach((key) => addFragment(key, map[key])) + ); + + // Add configs from plugins. + getGraphQLExtensions().forEach((ext) => add(ext)); +} + +export function resolveFragments(document) { + if (document.loc.source) { + + // resolve fragments from registry + const matchedSubFragments = document.loc.source.body.match(/\.\.\.(.*)/g) || []; + const subFragments = + uniq(matchedSubFragments.map((f) => f.replace('...', ''))) + .map((key) => getFragmentDocument(key)) + .filter((i) => i); + + if (subFragments.length > 0) { + return mergeDocuments([document, ...subFragments]); + } + } else { + console.warn('Can only resolve fragments from documents definied using the gql tag.'); + } + return document; +} diff --git a/client/coral-framework/services/store.js b/client/coral-framework/services/store.js index f6ace1fd3..22e126d75 100644 --- a/client/coral-framework/services/store.js +++ b/client/coral-framework/services/store.js @@ -3,7 +3,7 @@ import thunk from 'redux-thunk'; import mainReducer from '../reducers'; import {client} from './client'; -const apolloErrorReporter = () => next => action => { +const apolloErrorReporter = () => (next) => (action) => { if (action.type === 'APOLLO_QUERY_ERROR') { console.error(action.error); } diff --git a/client/coral-framework/services/transport.js b/client/coral-framework/services/transport.js index 2bd6ac636..e421ca3da 100644 --- a/client/coral-framework/services/transport.js +++ b/client/coral-framework/services/transport.js @@ -1,11 +1,31 @@ import {createNetworkInterface} from 'apollo-client'; +import * as Storage from '../helpers/storage'; -export default function getNetworkInterface(apiUrl = '/api/v1/graph/ql', headers = {}) { - return new createNetworkInterface({ - uri: apiUrl, - opts: { - credentials: 'same-origin', - headers, - }, - }); -} +//============================================================================== +// NETWORK INTERFACE +//============================================================================== + +const networkInterface = createNetworkInterface({ + uri: '/api/v1/graph/ql', + opts: { + credentials: 'same-origin' + } +}); + +//============================================================================== +// MIDDLEWARES +//============================================================================== + +networkInterface.use([{ + applyMiddleware(req, next) { + if (!req.options.headers) { + req.options.headers = {}; // Create the header object if needed. + } + req.options.headers['authorization'] = `Bearer ${Storage.getItem('token')}`; + next(); + } +}]); + +export { + networkInterface +}; diff --git a/client/coral-framework/translations.json b/client/coral-framework/translations.json new file mode 100644 index 000000000..2e4c405b4 --- /dev/null +++ b/client/coral-framework/translations.json @@ -0,0 +1,110 @@ +{ + "en": { + "MY_COMMENTS": "My Comments", + "profile": "Profile", + "myProfile": "My profile", + "successUpdateSettings": "The changes you have made have been applied to the comment stream on this article", + "successNameUpdate": "Your username has been updated", + "contentNotAvailable": "This content is not available", + "bannedAccountMsg": "Your account is currently suspended. This means that you cannot Like, Report, or write comments. Please contact us if you have any questions.", + "editName": { + "msg": "Your account is currently suspended because your username has been deemed inappropriate. To restore your account, please enter a new username. Please contact us if you have any questions.", + "label": "New Username", + "button": "Submit", + "error": "Usernames can contain letters, numbers and _ only" + }, + "viewMoreComments": "view more comments", + "showAllComments": "Show all comments", + "viewReply": "view reply", + "viewAllRepliesInitial": "view all {0} replies", + "viewAllReplies": "view {0} replies", + "newCount": "View {0} new {1}", + "comment": "comment", + "comments": "comments", + "commentIsIgnored": "This comment is hidden because you ignored this user.", + "editComment": { + "bodyInputLabel": "Edit this comment", + "saveButton": "Save changes", + "editWindowExpired": "You can no longer edit this comment. The time window to do so has expired. Why not post another one?", + "editWindowExpiredClose": "Close", + "editWindowTimerPrefix": "Edit Window: ", + "second": "second", + "secondsPlural": "seconds", + "unexpectedError": "Unexpected error while saving changes. Sorry!" + }, + "error": { + "COMMENT_TOO_SHORT": "Your comment must have something in it", + "EDIT_WINDOW_ENDED": "You can no longer edit this comment. The time window to do so has expired.", + "emailNotVerified": "Email address {0} not verified.", + "email": "Not a valid E-Mail", + "networkError": "Failed to connect to server. Check your internet connection and try again.", + "password": "Password must be at least 8 characters", + "username": "Usernames can contain letters, numbers and _ only", + "confirmPassword": "Passwords don't match. Please, check again", + "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", + "PASSWORD_LENGTH": "Password is too short", + "EMAIL_IN_USE": "Email address already in use", + "EMAIL_USERNAME_IN_USE": "Email address or username already in use", + "USERNAME_IN_USE": "Username already in use", + "USERNAME_REQUIRED": "Must input a username", + "NO_SPECIAL_CHARACTERS": "Usernames can contain letters, numbers and _ only", + "PROFANITY_ERROR": "Usernames must not contain profanity. Please contact the administrator if you believe this to be in error.", + "NOT_AUTHORIZED": "Not authorized.", + "EDIT_USERNAME_NOT_AUTHORIZED": "You do not have permission to update your username." + } + }, + "es": { + "profile": "Pérfil", + "MY_COMMENTS": "Mis Comentarios", + "myProfile": "Mi pérfil", + "successUpdateSettings": "La configuración de este articulo fue actualizada", + "successBioUpdate": "Tu biografia fue actualizada", + "contentNotAvailable": "El contenido no se encuentra disponible", + "bannedAccountMsg": "Tu cuenta se encuentra suspendida. Esto significa que no puedes gustar, marcar o escribir commentarios.", + "editNameMsg": "", + "viewMoreComments": "Ver commentarios más", + "viewReply": "ver respuesta", + "viewAllRepliesInitial": "ver todas las {0} respuestas", + "viewAllReplies": "ver {0} respuestas", + "newCount": "Ver {0} {1} más", + "comment": "commentario", + "comments": "commentarios", + "commentIsIgnored": "Este comentario está escondido porque has ignorado al usuario.", + "showAllComments": "Mostrar todos los comentarios", + "editComment": { + "bodyInputLabel": "Editar este comentario", + "saveButton": "Guardar cambios", + "editWindowExpired": "Ya no puedes editar este comentario. La ventana de tiempo para hacerlo ha caducado. ¿Por qué no publicar otro?", + "editWindowExpiredClose": "Cerca", + "editWindowTimerPrefix": "Ventana de edición: ", + "second": "segundo", + "secondsPlural": "segundos", + "unexpectedError": "Unexpected error while saving changes. Sorry!" + }, + "error": { + "editWindowExpired": "Ya no puedes editar este comentario. La ventana de tiempo para hacerlo ha caducado.", + "emailNotVerified": "E-mail {0} no verificado.", + "email": "No es un e-mail válido", + "networkError": "Error al conectar con el servidor. Compruebe su conexión a Internet y vuelva a intentarlo.", + "password": "La contraseña debe tener por lo menos 8 caracteres", + "username": "Los nombres pueden contener letras, números y _", + "organizationName": "El nombre de la organización debe contener letras y/o números.", + "confirmPassword": "Las contraseñas no coinciden", + "emailPasswordError": "E-mail y/o contraseña incorrecta.", + "EMAIL_REQUIRED": "Se requiere un e-mail", + "PASSWORD_REQUIRED": "Debe ingresar una contraseña", + "PASSWORD_LENGTH": "La contraseña es muy corta", + "EMAIL_IN_USE": "El e-mail se encuentra en uso", + "EMAIL_USERNAME_IN_USE": "E-mail o Nombre en uso.", + "USERNAME_IN_USE": "Nombre en uso.", + "USERNAME_REQUIRED": "Debe ingresar un nombre", + "NO_SPECIAL_CHARACTERS": "Los nombres pueden contener letras, números y _", + "PROFANITY_ERROR": "Los nombres no pueden contener blasfemias. Por favor contacte al o la administradora si cree que esto es un error", + "NOT_AUTHORIZED": "Acción no autorizada.", + "EDIT_USERNAME_NOT_AUTHORIZED": "No tiene permiso para editar el nombre de usuario." + } + } +} diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 598fb59ab..512759e98 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -1,6 +1,8 @@ +import {gql} from 'react-apollo'; + export const getTotalActionCount = (type, comment) => { return comment.action_summaries - .filter(s => s.__typename === type) + .filter((s) => s.__typename === type) .reduce((total, summary) => { return total + summary.count; }, 0); @@ -10,14 +12,14 @@ export const iPerformedThisAction = (type, comment) => { // if there is a current_user on any of the ActionSummary(s), the user performed this action return comment.action_summaries - .filter(a => a.__typename === type) - .some(a => a.current_user); + .filter((a) => a.__typename === type) + .some((a) => a.current_user); }; export const getMyActionSummary = (type, comment) => { return comment.action_summaries - .filter(a => a.__typename === type) - .find(a => a.current_user); + .filter((a) => a.__typename === type) + .find((a) => a.current_user); }; /** @@ -27,7 +29,7 @@ export const getMyActionSummary = (type, comment) => { */ export const getActionSummary = (type, comment) => { - return comment.action_summaries.filter(a => a.__typename === type); + return comment.action_summaries.filter((a) => a.__typename === type); }; /** @@ -61,3 +63,11 @@ export function separateDataAndRoot( root, }; } + +export function mergeDocuments(documents) { + const main = typeof documents[0] === 'string' ? documents[0] : documents[0].loc.source.body; + const substitutions = documents.slice(1); + const literals = [main, ...substitutions.map(() => '\n')]; + return gql.apply(null, [literals, ...substitutions]); +} + diff --git a/client/coral-i18n/locales/en.yml b/client/coral-i18n/locales/en.yml index 3f59afe1f..8a9e41514 100644 --- a/client/coral-i18n/locales/en.yml +++ b/client/coral-i18n/locales/en.yml @@ -128,6 +128,7 @@ done: Done embedlink: copy: "Copy to Clipboard" error: + comment_too_short: "Your comment must have something in it" NOT_AUTHORIZED: "Your username or password is not recognized by our system." NO_SPECIAL_CHARACTERS: "Usernames can contain letters numbers and _ only" PASSWORD_LENGTH: "Password is too short" @@ -135,8 +136,10 @@ error: USERNAME_IN_USE: "Username already in use" USERNAME_REQUIRED: "Must input a username" confirm_password: "Passwords don't match. Please check again" + edit_window_ended: "You can no longer edit this comment. The time window to do so has expired." edit_username_not_authorized: "You do not have permission to update your username." email: "Not a valid E-Mail" + network_error: "Failed to connect to server. Check your internet connection and try again." email_in_use: "Email address already in use" email_not_verified: "E-mail address {0} not verified." email_password: "E-mail and/or password combination incorrect." diff --git a/client/coral-i18n/locales/es.yml b/client/coral-i18n/locales/es.yml index 9ac245db6..2dbbf83b5 100644 --- a/client/coral-i18n/locales/es.yml +++ b/client/coral-i18n/locales/es.yml @@ -128,6 +128,7 @@ done: hecho embedlink: copy: Copy to Clipboard error: + comment_too_short: "Tu comentario debe tener algo escrito" NOT_AUTHORIZED: Acción no autorizada. NO_SPECIAL_CHARACTERS: Los nombres pueden contener letras números y _ PASSWORD_LENGTH: La contraseña es muy corta @@ -135,8 +136,10 @@ error: USERNAME_IN_USE: Nombre en uso. USERNAME_REQUIRED: Debe ingresar un nombre confirm_password: Las contraseñas no coinciden + edit_window_ended: "No puedes editar este comentario. El tiempo de edición ha expirado." edit_username_not_authorized: No tiene permiso para editar el nombre de usuario. email: No es un e-mail válido + network_error: "Error al conectar con el servidor. Compruebe su conexión a Internet y vuelva a intentarlo." email_in_use: El e-mail se encuentra en uso email_not_verified: E-mail {0} no verificado. email_password: "E-mail y/o contraseña incorrecta." diff --git a/client/coral-plugin-api/index.js b/client/coral-plugin-api/index.js new file mode 100644 index 000000000..f0f5ad9cc --- /dev/null +++ b/client/coral-plugin-api/index.js @@ -0,0 +1 @@ +export {withReaction} from '../coral-framework/hocs'; diff --git a/client/coral-plugin-author-name/AuthorName.js b/client/coral-plugin-author-name/AuthorName.js index 67f61bc33..66b907ad8 100644 --- a/client/coral-plugin-author-name/AuthorName.js +++ b/client/coral-plugin-author-name/AuthorName.js @@ -6,7 +6,7 @@ export default class AuthorName extends Component { state = {showTooltip: false} handleClick = () => { - this.setState(state => ({ + this.setState((state) => ({ showTooltip: !state.showTooltip })); } diff --git a/client/coral-plugin-best/BestButton.js b/client/coral-plugin-best/BestButton.js index 685d30721..503fb4da2 100644 --- a/client/coral-plugin-best/BestButton.js +++ b/client/coral-plugin-best/BestButton.js @@ -7,7 +7,7 @@ import classnames from 'classnames'; // tag string for best comments export const BEST_TAG = 'BEST'; export const commentIsBest = ({tags} = {}) => { - const isBest = Array.isArray(tags) && tags.some(t => t.name === BEST_TAG); + const isBest = Array.isArray(tags) && tags.some((t) => t.name === BEST_TAG); return isBest; }; @@ -15,7 +15,7 @@ const name = 'coral-plugin-best'; const lang = new I18n(); // It would be best if the backend/api held this business logic -const canModifyBestTag = ({roles = []} = {}) => roles && ['ADMIN', 'MODERATOR'].some(role => roles.includes(role)); +const canModifyBestTag = ({roles = []} = {}) => roles && ['ADMIN', 'MODERATOR'].some((role) => roles.includes(role)); // Put this on a comment to show that it is best @@ -29,7 +29,7 @@ export const BestIndicator = ({children = }) => ( * Component that only renders children if the provided user prop can modify best tags */ export const IfUserCanModifyBest = ({user, children}) => { - if ( ! ( user && canModifyBestTag(user))) {return null;} + if (!(user && canModifyBestTag(user))) {return null;} return children; }; @@ -63,7 +63,7 @@ export class BestButton extends Component { async onClickAddBest(e) { e.preventDefault(); const {addBest} = this.props; - if ( ! addBest) { + if (!addBest) { console.warn('BestButton#onClickAddBest called even though there is no addBest prop. doing nothing'); return; } @@ -78,7 +78,7 @@ export class BestButton extends Component { async onClickRemoveBest(e) { e.preventDefault(); const {removeBest} = this.props; - if ( ! removeBest) { + if (!removeBest) { console.warn('BestButton#onClickAddBest called even though there is no removeBest prop. doing nothing'); return; } @@ -93,7 +93,7 @@ export class BestButton extends Component { render() { const {isBest, addBest, removeBest} = this.props; const {isSaving} = this.state; - const disabled = isSaving || ! (isBest ? removeBest : addBest); + const disabled = isSaving || !(isBest ? removeBest : addBest); return (