diff --git a/.dockerignore b/.dockerignore index 80bb20e2c..96d04e69b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,7 +6,7 @@ node_modules scripts !scripts/generateIntrospectionResult.js -# documentation should not be visable in production. +# documentation should not be visible in production. docs # static assets are rebuild in the docker container. @@ -14,6 +14,7 @@ dist # tests are not run in the docker container. test +__tests__ # we won't use the .git folder in production. .git diff --git a/.eslintignore b/.eslintignore index cd0b1a983..e09212b62 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,6 +1,5 @@ dist docs -client/lib **/*.html plugins/* !plugins/talk-plugin-facebook-auth @@ -14,5 +13,11 @@ plugins/* !plugins/talk-plugin-comment-content !plugins/talk-plugin-permalink !plugins/talk-plugin-featured-comments +!plugins/talk-plugin-sort-newest +!plugins/talk-plugin-sort-oldest +!plugins/talk-plugin-sort-most-replied +!plugins/talk-plugin-sort-most-liked +!plugins/talk-plugin-sort-most-loved +!plugins/talk-plugin-sort-most-respected node_modules diff --git a/.eslintrc.json b/.eslintrc.json index 12355ac34..237650932 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -7,51 +7,53 @@ "parserOptions": { "ecmaVersion": 2017 }, + "plugins": [ + "promise", + "json" + ], "rules": { "indent": ["error", 2 ], - "no-console": [ - 0 - ], + "no-console": "off", "linebreak-style": ["error", "unix"], "quotes": ["error", "single"], "semi": ["error", "always"], - "no-template-curly-in-string": [1], - "no-unsafe-negation": [1], - "array-callback-return": [1], + "no-template-curly-in-string": "warn", + "no-unsafe-negation": "warn", + "array-callback-return": "warn", "arrow-parens": ["warn", "always"], "template-curly-spacing": "warn", - "eqeqeq": [2, "smart"], - "no-eval": [2], - "no-global-assign": [2], - "no-implied-eval": [2], + "eqeqeq": ["error", "smart"], + "no-eval": "error", + "no-global-assign": "error", + "no-implied-eval": "error", "lines-around-comment": ["warn", {"beforeLineComment": true}], "spaced-comment": ["warn", "always", { "line": { "exceptions": ["-", "="] } }], - "no-script-url": [2], - "no-throw-literal": [2], - "yoda": [1], - "no-path-concat": [2], - "eol-last": [1], - "no-nested-ternary": [1], - "no-tabs": [2], - "no-unneeded-ternary": [1], - "object-curly-spacing": [1], + "no-script-url": "error", + "no-throw-literal": "error", + "yoda": "warn", + "no-path-concat": "error", + "eol-last": "warn", + "no-nested-ternary": "warn", + "no-tabs": "error", + "no-unneeded-ternary": "warn", + "object-curly-spacing": "warn", "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], + "no-const-assign": "error", + "no-duplicate-imports": "error", + "prefer-template": "warn", "comma-spacing": ["error", { "after": true }], - "no-var": [2], - "no-lonely-if": [2], - "curly": [2], + "no-var": "error", + "no-lonely-if": "error", + "curly": "error", "no-unused-vars": ["error", { "argsIgnorePattern": "^_|next", "varsIgnorePattern": "^_" @@ -61,6 +63,13 @@ }], "newline-per-chained-call": ["error", { "ignoreChainWithDepth": 2 - }] + }], + "promise/no-return-wrap": "error", + "promise/param-names": "error", + "promise/catch-or-return": "error", + "promise/no-native": "off", + "promise/no-nesting": "warn", + "promise/no-promise-in-callback": "warn", + "promise/no-callback-in-promise": "warn" } } diff --git a/.gitignore b/.gitignore index 31f1f3ca0..2856ab79b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ client/coral-framework/graphql/introspection.json *.swp *.DS_STORE -test/e2e/reports coverage/ plugins.json @@ -31,5 +30,12 @@ plugins/* !plugins/talk-plugin-permalink !plugins/talk-plugin-featured-comments !plugins/talk-plugin-toxic-comments +!plugins/talk-plugin-sort-newest +!plugins/talk-plugin-sort-oldest +!plugins/talk-plugin-sort-most-replied +!plugins/talk-plugin-sort-most-liked +!plugins/talk-plugin-sort-most-loved +!plugins/talk-plugin-sort-most-respected + **/node_modules/* story.html diff --git a/Dockerfile b/Dockerfile index 2dbd7bbbb..95aa74d8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:8 +FROM node:8-alpine # Create app directory RUN mkdir -p /usr/src/app @@ -12,6 +12,9 @@ EXPOSE 5000 # Bundle app source COPY . /usr/src/app +# Ensure the runtime of the container is in production mode. +ENV NODE_ENV production + # Install app dependencies and build static assets. RUN yarn global add node-gyp && \ yarn install --frozen-lockfile && \ @@ -19,7 +22,4 @@ RUN yarn global add node-gyp && \ yarn build && \ yarn cache clean -# Ensure the runtime of the container is in production mode. -ENV NODE_ENV production - CMD ["yarn", "start"] diff --git a/Dockerfile.onbuild b/Dockerfile.onbuild index 06d006b42..b9cf8ce87 100644 --- a/Dockerfile.onbuild +++ b/Dockerfile.onbuild @@ -7,8 +7,5 @@ ONBUILD COPY . /usr/src/app # we need to have webpack available. We then build the new dependancies and # clear out the development dependancies again. After this we of course need to # clear out the yarn cache, this saves quite a lot of size. -ONBUILD RUN NODE_ENV=development yarn install --frozen-lockfile && \ - NODE_ENV=production cli plugins reconcile && \ - NODE_ENV=production yarn build && \ - NODE_ENV=production yarn install --production --force && \ - yarn cache clean \ No newline at end of file +ONBUILD RUN cli plugins reconcile && \ + yarn build diff --git a/README.md b/README.md index a58ac7c74..4bec86d07 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Talk [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) +[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://dashboard.heroku.com/new?template=https%3A%2F%2Fgithub.com%2Fcoralproject%2Ftalk&env[TALK_FACEBOOK_APP_ID]=ignore&env[TALK_FACEBOOK_APP_SECRET]=ignore) 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) @@ -19,7 +20,7 @@ endpoint when the server is running with built assets. - Blog: https://blog.coralproject.net -- Community Guides for Journalism: https://guides.coralproject.net/ +- Community Guides for Journalism: https://guides.coralproject.net/ ## License diff --git a/app.json b/app.json index 5faa23bc2..6c85e3cc2 100644 --- a/app.json +++ b/app.json @@ -1,10 +1,15 @@ { "name": "The Coral Project: Talk", "env": { - "TALK_SESSION_SECRET": { + "TALK_JWT_SECRET": { "description": "The session secret", "generator": "secret" }, + "TALK_ROOT_URL": { + "description": "Please copy the App Name you choose above. If you did not choose one, please do so now and copy it here. Talk on Heroku will not work without this setting.", + "value":"https://.herokuapp.com", + "required": true + }, "TALK_FACEBOOK_APP_ID": { "value": "", "required": true @@ -14,8 +19,7 @@ "required": true }, "NODE_ENV": "production", - "TALK_SMTP_PORT": "2525", - "REWRITE_ENV": "TALK_PORT:PORT,TALK_MONGO_URL:MONGO_URI,TALK_REDIS_URL:REDIS_URL,TALK_SMTP_HOST:POSTMARK_SMTP_SERVER,TALK_SMTP_USERNAME:POSTMARK_API_TOKEN,TALK_SMTP_PASSWORD:POSTMARK_API_TOKEN", + "REWRITE_ENV": "TALK_MONGO_URL:MONGO_URI,TALK_REDIS_URL:REDIS_URL,TALK_SMTP_HOST:MAILGUN_SMTP_SERVER,TALK_SMTP_PORT:MAILGUN_SMTP_PORT,TALK_SMTP_USERNAME:MAILGUN_SMTP_LOGIN,TALK_SMTP_PASSWORD:MAILGUN_SMTP_PASSWORD", "NPM_CONFIG_PRODUCTION": "false" }, "addons": [{ @@ -25,8 +29,8 @@ "plan": "rediscloud:30", "as": "REDIS" }, { - "plan": "postmark:10k", - "as": "POSTMARK" + "plan": "mailgun:starter", + "as": "MAILGUN" }], "image": "heroku/nodejs", "success_url": "/admin/install" diff --git a/bin/cli b/bin/cli index 52d67c9af..f618b24be 100755 --- a/bin/cli +++ b/bin/cli @@ -4,8 +4,9 @@ * Module dependencies. */ -// const util = require('./util'); const program = require('./commander'); +const {head, map} = require('lodash'); +const Matcher = require('did-you-mean'); program .command('serve', 'serve the application') @@ -16,12 +17,28 @@ program .command('token', 'work with the access tokens') .command('users', 'work with the application auth') .command('migration', 'provides utilities for migrating the database') + .command('verify', 'provides utilities for performing data verification') .command( 'plugins', 'provides utilities for interacting with the plugin system' ) .parse(process.argv); +// If the command wasn't found, output help. +const cmds = map(program.commands, '_name'); +const cmd = head(program.args); +if (!cmds.includes(cmd)) { + const m = new Matcher(cmds); + const similarCMDs = m.list(cmd); + + console.error(`cli '${cmd}' is not a talk cli command. See 'cli --help'.`); + if (similarCMDs.length > 0) { + const sc = similarCMDs.map(({value}) => `\t${value}\n`).join(''); + console.error(`\nThe most similar commands are\n${sc}`); + } + process.exit(1); +} + /** * When this provess exists, check to see if we have a running command, if we do * check to see if it is still running. If it is, then kill it with a SIGINT @@ -30,6 +47,8 @@ program */ process.once('exit', () => { if ( + + // program.runningCommand && program.runningCommand.killed === false && program.runningCommand.exitCode === null ) { diff --git a/bin/cli-jobs b/bin/cli-jobs index f22e1068b..c5aefc518 100755 --- a/bin/cli-jobs +++ b/bin/cli-jobs @@ -78,18 +78,23 @@ function rangeJobsByState(state = 'complete', limit) { /** * Cleans up the jobs that are in the queue. */ -function cleanupJobs(options) { +async function cleanupJobs(options) { const n = 100; - Promise.all([ - rangeJobsByState('complete', n), - options.stuck ? rangeJobsByState('failed', n) : false - ]) - .then((joblists) => joblists.filter((jobs) => jobs).map(removeJobs)) - .then(() => { + try { + const joblists = await Promise.all([ + rangeJobsByState('complete', n), + options.stuck ? rangeJobsByState('failed', n) : false + ]); + + await joblists.filter((jobs) => jobs).map(removeJobs); + util.shutdown(); console.log('Removed old jobs'); - }); + } catch (err) { + console.error(err); + util.shutdown(1); + } } //============================================================================== diff --git a/bin/cli-plugins b/bin/cli-plugins index 1de1c97d4..b7e301fce 100755 --- a/bin/cli-plugins +++ b/bin/cli-plugins @@ -53,7 +53,7 @@ function versionMatch(name, version) { } } -const EXTERNAL = /^\w[a-z\-0-9\.]+$/; // Match "react", "path", "fs", "lodash.random", etc. +const EXTERNAL = /^\w[a-z\-0-9.]+$/; // Match "react", "path", "fs", "lodash.random", etc. function reconcilePackages({quiet = false, upgradeRemote = false}) { const fetchable = []; diff --git a/bin/cli-users b/bin/cli-users index 057c0a806..c6555ab28 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -98,39 +98,32 @@ function getUserCreateAnswers(options) { /** * Prompts for input and registers a user based on those. */ -function createUser(options) { - getUserCreateAnswers(options) - .then((answers) => { - if (answers.password !== answers.confirmPassword) { - return Promise.reject(new Error('Passwords do not match')); - } +async function createUser(options) { + try { + const answers = await getUserCreateAnswers(options); + if (answers.password !== answers.confirmPassword) { + throw new Error('Passwords do not match'); + } - return answers; - }) - .then((answers) => { - return UsersService - .createLocalUser(answers.email.trim(), answers.password.trim(), answers.username.trim()) - .then((user) => { - console.log(`Created user ${user.id}.`); + const user = await UsersService.createLocalUser(answers.email.trim(), answers.password.trim(), answers.username.trim()); + console.log(`Created user ${user.id}.`); - if (answers.roles.length > 0) { - return Promise.all(answers.roles.map((role) => { - return UsersService - .addRoleToUser(user.id, role) - .then(() => { - console.log(`Added the role ${role} to User ${user.id}.`); - }); - })); - } - }); - }) - .then(() => { - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(); - }); + if (answers.roles.length > 0) { + return Promise.all(answers.roles.map((role) => { + return UsersService + .addRoleToUser(user.id, role) + .then(() => { + console.log(`Added the role ${role} to User ${user.id}.`); + }); + })); + } + + util.shutdown(); + + } catch (err) { + console.error(err); + util.shutdown(); + } } /** @@ -169,21 +162,21 @@ function passwd(userID) { validate: validateRequired('Confirm Password is required') } ]) - .then((answers) => { - if (answers.password !== answers.confirmPassword) { - return Promise.reject(new Error('Password mismatch')); - } + .then((answers) => { + if (answers.password !== answers.confirmPassword) { + throw new Error('Password mismatch'); + } - return UsersService.changePassword(userID, answers.password); - }) - .then(() => { - console.log('Password changed.'); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); + return UsersService.changePassword(userID, answers.password); + }) + .then(() => { + console.log('Password changed.'); + util.shutdown(); + }) + .catch((err) => { + console.error(err); + util.shutdown(1); + }); } /** diff --git a/bin/cli-verify b/bin/cli-verify new file mode 100755 index 000000000..6e67e0016 --- /dev/null +++ b/bin/cli-verify @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +const program = require('./commander'); +const mongoose = require('../services/mongoose'); +const util = require('./util'); +const databaseVerifications = require('./verifications/database'); + +// Register the shutdown criteria. +util.onshutdown([ + () => mongoose.disconnect() +]); + +async function database({fix = false, limit = Infinity, batch = 1000}) { + try { + for (const verification of databaseVerifications) { + await verification({fix, limit, batch}); + } + } catch (err) { + console.error(`Failed to process all the ${databaseVerifications.length} verifications`, err); + util.shutdown(1); + return; + } + + util.shutdown(); +} + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .command('db') + .description('verifies the database integrity') + .option('-f, --fix', 'fix the problems found with database inconsistencies') + .option('-l, --limit [size]', 'limit the amount of documents to process in a single pass, this will ensure only a maximum number of batch operations are issued [default: inf]', parseInt) + .option('-b, --batch [size]', 'batch size to process verifications and repairs of documents [default: 1000]', parseInt) + .action(database); + +program.parse(process.argv); + +// If there is no command listed, output help. +if (!process.argv.slice(2).length) { + program.outputHelp(); + util.shutdown(); +} diff --git a/bin/verifications/database/comments.js b/bin/verifications/database/comments.js new file mode 100644 index 000000000..231badabb --- /dev/null +++ b/bin/verifications/database/comments.js @@ -0,0 +1,193 @@ +const CommentModel = require('../../../models/comment'); +const ActionsService = require('../../../services/actions'); +const {arrayJoinBy, singleJoinBy} = require('../../../graph/loaders/util'); +const sc = require('snake-case'); +const debug = require('debug')('talk:cli:verify'); + +const getBatch = async (limit, offset) => CommentModel + .find({}) + .select({'id': 1, 'action_counts': 1, 'reply_count': 1}) + .limit(limit) + .skip(offset) + .sort('created_at'); + +module.exports = async ({fix, limit, batch}) => { + let operations = []; + + // Count how many comments there are to process. + const totalCount = await CommentModel.count(); + + let offset = 0; + let comments = []; + let commentIDs = []; + + console.log(`Processing ${totalCount} comments in batches of ${limit}...`); + + // Keep processing documents until there are is none left. + while (offset < totalCount) { + + // Get a batch of comments. + comments = await getBatch(batch, offset); + commentIDs = comments.map(({id}) => id); + + // Get their reply counts. + let allReplyCounts = await CommentModel + .aggregate([ + { + $match: { + parent_id: { + $in: commentIDs, + }, + status: { + $in: ['NONE', 'ACCEPTED'] + } + } + }, + { + $group: { + _id: '$parent_id', + count: { + $sum: 1 + } + } + } + ]) + .then(singleJoinBy(commentIDs, '_id')) + .then((results) => results.map((result) => result ? result.count : 0)); + + // Get their action summaries. + let allActionSummaries = await ActionsService + .getActionSummaries(commentIDs) + .then(arrayJoinBy(commentIDs, 'item_id')); + + // Loop over the comments, with their action summaries. + for (let i = 0; i < comments.length; i++) { + let comment = comments[i]; + let actionSummaries = allActionSummaries[i]; + let replyCount = allReplyCounts[i]; + + // And check to see if the action summaries we just computed match what is + // currently set for the comments. + let commentOperations = []; + + // If the reply count needs to be updated, then update it! + if (comment.reply_count !== replyCount) { + commentOperations.push({ + reply_count: replyCount, + }); + } + + // First we process all the group id's. + for (let actionSummary of actionSummaries) { + if (actionSummary.group_id === null) { + continue; + } + + // And we generate the group id. + const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase()); + const GROUP_ID = sc(actionSummary.group_id.toLowerCase()); + + if (GROUP_ID.length <= 0) { + continue; + } + + // And we add a new batch operation if the action summary is associated + // with a group. + const ACTION_COUNT_FIELD = `${ACTION_TYPE}_${GROUP_ID}`; + + // Check that the action summaries match the cached counts. + if (!comment.action_counts || !(ACTION_COUNT_FIELD in comment.action_counts) || comment.action_counts[ACTION_COUNT_FIELD] !== actionSummary.count) { + + // Batch updates for those changes. + commentOperations.push({ + [`action_counts.${ACTION_COUNT_FIELD}`]: actionSummary.count, + }); + } + } + + // Group all the action summaries together from all the different group + // ids. + let groupedActionSummaries = actionSummaries.reduce((acc, actionSummary) => { + const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase()); + + if (!(ACTION_TYPE in acc)) { + acc[ACTION_TYPE] = 0; + } + + acc[ACTION_TYPE] += actionSummary.count; + + return acc; + }, {}); + + for (const ACTION_COUNT_FIELD of Object.keys(groupedActionSummaries)) { + const count = groupedActionSummaries[ACTION_COUNT_FIELD]; + + // Check that the action summaries match the cached counts. + if (!comment.action_counts || !(ACTION_COUNT_FIELD in comment.action_counts) || comment.action_counts[ACTION_COUNT_FIELD] !== count) { + + // Batch updates for those changes. + commentOperations.push({ + [`action_counts.${ACTION_COUNT_FIELD}`]: count, + }); + } + } + + // If this comment has action summaries that should be updated, then + // perform an update! + if (commentOperations.length > 0) { + operations.push({ + updateOne: { + filter: { + id: comment.id + }, + update: { + $set: Object.assign({}, ...commentOperations), + }, + }, + }); + } + } + + debug(`Processed batch of ${comments.length} comments.`); + + if (operations.length >= limit) { + debug(`Queued operations are ${operations.length}, reached limit of ${limit}, not processing any more.`); + + if (operations.length > limit) { + debug(`${operations.length - limit} operations have been truncated to enforce the limit`); + } + + break; + } + + offset += batch; + } + + const OPERATIONS_LENGTH = operations.length; + + if (limit < Infinity && offset + comments.length < totalCount) { + console.log(`Processed ${offset + comments.length}/${totalCount} comments because we reached the update limit of ${limit}.`); + } else { + console.log(`Processed all ${totalCount} comments.`); + } + + console.log(`${OPERATIONS_LENGTH} documents need fixing.`); + + // If fix was enabled, execute the batch writes. + if (OPERATIONS_LENGTH > 0) { + if (fix) { + debug(`Fixing ${OPERATIONS_LENGTH} documents...`); + + while (operations.length) { + let batchOperations = operations.splice(0, batch); + let result = await CommentModel.collection.bulkWrite(batchOperations); + + debug(`Fixed batch of ${result.modifiedCount} documents.`); + } + + console.log(`Applied all ${OPERATIONS_LENGTH} fixes.`); + } else { + console.warn('Skipping fixing, --fix was not enabled, pass --fix to fix these errors'); + } + } +}; diff --git a/bin/verifications/database/index.js b/bin/verifications/database/index.js new file mode 100644 index 000000000..3dafeb3bf --- /dev/null +++ b/bin/verifications/database/index.js @@ -0,0 +1,12 @@ +// This will import all the verifications that should be run by the: +// +// cli verify database +// +// command. They exist in the form: +// +// async ({fix = false, batch = 1000}) => {} +// +// where their options are derrived. +module.exports = [ + require('./comments'), +]; diff --git a/circle.yml b/circle.yml index 373b45012..f6b35970c 100644 --- a/circle.yml +++ b/circle.yml @@ -40,8 +40,6 @@ test: override: # Run the tests using the junit reporter. - MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml MOCHA_REPORTER=mocha-junit-reporter yarn test - # Run the e2e test suite. - # - E2E_REPORT_PATH=$CIRCLE_TEST_REPORTS/e2e yarn e2e deployment: release: diff --git a/client/.babelrc b/client/.babelrc index 60be246eb..2af1a3dad 100644 --- a/client/.babelrc +++ b/client/.babelrc @@ -9,6 +9,7 @@ "transform-object-assign", "transform-object-rest-spread", "transform-async-to-generator", - "transform-react-jsx" + "transform-react-jsx", + "syntax-dynamic-import" ] } \ No newline at end of file diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 5735a91a1..cde3942fa 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -4,6 +4,7 @@ "es6": true, "mocha": true }, + "extends": "../.eslintrc.json", "parserOptions": { "sourceType": "module", "ecmaFeatures": { diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index 9772ebf17..dcd660c25 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -1,6 +1,6 @@ import React from 'react'; import {Router, Route, IndexRedirect, IndexRoute} from 'react-router'; -import {history} from 'coral-framework/helpers/router'; +import PropTypes from 'prop-types'; import Configure from 'routes/Configure'; import Dashboard from 'routes/Dashboard'; @@ -47,6 +47,14 @@ const routes = ( ); -const AppRouter = () => ; +class AppRouter extends React.Component { + static contextTypes = { + history: PropTypes.object, + }; + + render() { + return ; + } +} export default AppRouter; diff --git a/client/coral-admin/src/actions/assets.js b/client/coral-admin/src/actions/assets.js index b9ebe0ada..1ce6ad6ac 100644 --- a/client/coral-admin/src/actions/assets.js +++ b/client/coral-admin/src/actions/assets.js @@ -8,7 +8,6 @@ import { UPDATE_ASSETS } from '../constants/assets'; -import coralApi from '../../../coral-framework/helpers/request'; import t from 'coral-framework/services/i18n'; /** @@ -17,9 +16,9 @@ import t from 'coral-framework/services/i18n'; // Fetch a page of assets // Get comments to fill each of the three lists on the mod queue -export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch) => { +export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch, _, {rest}) => { dispatch({type: FETCH_ASSETS_REQUEST}); - return coralApi(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`) + return rest(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`) .then(({result, count}) => dispatch({type: FETCH_ASSETS_SUCCESS, assets: result, @@ -34,9 +33,9 @@ export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filte // Update an asset state // Get comments to fill each of the three lists on the mod queue -export const updateAssetState = (id, closedAt) => (dispatch) => { +export const updateAssetState = (id, closedAt) => (dispatch, _, {rest}) => { dispatch({type: UPDATE_ASSET_STATE_REQUEST, id, closedAt}); - return coralApi(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}}) + return rest(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}}) .then(() => dispatch({type: UPDATE_ASSET_STATE_SUCCESS})) .catch((error) => { console.error(error); diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index 0c1264e1f..422258f50 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -1,16 +1,13 @@ import bowser from 'bowser'; import * as actions from '../constants/auth'; -import coralApi from 'coral-framework/helpers/request'; -import * as Storage from 'coral-framework/helpers/storage'; -import {handleAuthToken} from 'coral-framework/actions/auth'; -import {resetWebsocket} from 'coral-framework/services/client'; import t from 'coral-framework/services/i18n'; +import jwtDecode from 'jwt-decode'; //============================================================================== // SIGN IN //============================================================================== -export const handleLogin = (email, password, recaptchaResponse) => (dispatch) => { +export const handleLogin = (email, password, recaptchaResponse) => (dispatch, _, {rest, client, storage}) => { dispatch({type: actions.LOGIN_REQUEST}); const params = { @@ -27,18 +24,18 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) => }; } - return coralApi('/auth/local', params) + return rest('/auth/local', params) .then(({user, token}) => { if (!user) { - if (!bowser.safari && !bowser.ios) { - Storage.removeItem('token'); + if (!bowser.safari && !bowser.ios && storage) { + storage.removeItem('token'); } return dispatch(checkLoginFailure('not logged in')); } dispatch(handleAuthToken(token)); - resetWebsocket(); + client.resetWebsocket(); dispatch(checkLoginSuccess(user)); }) .catch((error) => { @@ -84,11 +81,11 @@ const forgotPasswordFailure = (error) => ({ error, }); -export const requestPasswordReset = (email) => (dispatch) => { +export const requestPasswordReset = (email) => (dispatch, _, {rest}) => { dispatch(forgotPasswordRequest(email)); const redirectUri = location.href; - return coralApi('/account/password/reset', {method: 'POST', body: {email, loc: redirectUri}}) + return rest('/account/password/reset', {method: 'POST', body: {email, loc: redirectUri}}) .then(() => dispatch(forgotPasswordSuccess())) .catch((error) => { console.error(error); @@ -116,18 +113,18 @@ const checkLoginFailure = (error) => ({ error }); -export const checkLogin = () => (dispatch) => { +export const checkLogin = () => (dispatch, _, {rest, client, storage}) => { dispatch(checkLoginRequest()); - return coralApi('/auth') + return rest('/auth') .then(({user}) => { if (!user) { - if (!bowser.safari && !bowser.ios) { - Storage.removeItem('token'); + if (!bowser.safari && !bowser.ios && storage) { + storage.removeItem('token'); } return dispatch(checkLoginFailure('not logged in')); } - resetWebsocket(); + client.resetWebsocket(); dispatch(checkLoginSuccess(user)); }) .catch((error) => { @@ -136,3 +133,33 @@ export const checkLogin = () => (dispatch) => { dispatch(checkLoginFailure(errorMessage)); }); }; + +//============================================================================== +// LOGOUT +//============================================================================== + +export const logout = () => (dispatch, _, {rest, client, storage}) => { + return rest('/auth', {method: 'DELETE'}).then(() => { + if (storage) { + storage.removeItem('token'); + } + + // Reset the websocket. + client.resetWebsocket(); + + dispatch({type: actions.LOGOUT}); + }); +}; + +//============================================================================== +// AUTH TOKEN +//============================================================================== + +export const handleAuthToken = (token) => (dispatch, _, {storage}) => { + if (storage) { + storage.setItem('exp', jwtDecode(token).exp); + storage.setItem('token', token); + } + dispatch({type: 'HANDLE_AUTH_TOKEN'}); +}; + diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index 70c9b7592..890d834b4 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -14,13 +14,12 @@ import { HIDE_REJECT_USERNAME_DIALOG } from '../constants/community'; -import coralApi from '../../../coral-framework/helpers/request'; import t from 'coral-framework/services/i18n'; -export const fetchAccounts = (query = {}) => (dispatch) => { +export const fetchAccounts = (query = {}) => (dispatch, _, {rest}) => { dispatch(requestFetchAccounts()); - coralApi(`/users?${qs.stringify(query)}`) + rest(`/users?${qs.stringify(query)}`) .then(({result, page, count, limit, totalPages}) =>{ dispatch({ type: FETCH_COMMENTERS_SUCCESS, @@ -51,18 +50,18 @@ export const newPage = () => ({ type: COMMENTERS_NEW_PAGE }); -export const setRole = (id, role) => (dispatch) => { - return coralApi(`/users/${id}/role`, {method: 'POST', body: {role}}) - .then(() => { - return dispatch({type: SET_ROLE, id, role}); - }); +export const setRole = (id, role) => (dispatch, _, {rest}) => { + return rest(`/users/${id}/role`, {method: 'POST', body: {role}}) + .then(() => { + return dispatch({type: SET_ROLE, id, role}); + }); }; -export const setCommenterStatus = (id, status) => (dispatch) => { - return coralApi(`/users/${id}/status`, {method: 'POST', body: {status}}) - .then(() => { - return dispatch({type: SET_COMMENTER_STATUS, id, status}); - }); +export const setCommenterStatus = (id, status) => (dispatch, _, {rest}) => { + return rest(`/users/${id}/status`, {method: 'POST', body: {status}}) + .then(() => { + return dispatch({type: SET_COMMENTER_STATUS, id, status}); + }); }; // Ban User Dialog diff --git a/client/coral-admin/src/actions/install.js b/client/coral-admin/src/actions/install.js index 6393c58dd..d6b27115a 100644 --- a/client/coral-admin/src/actions/install.js +++ b/client/coral-admin/src/actions/install.js @@ -1,4 +1,3 @@ -import coralApi from 'coral-framework/helpers/request'; import * as actions from '../constants/install'; import validate from 'coral-framework/helpers/validate'; import errorMsj from 'coral-framework/helpers/error'; @@ -27,19 +26,19 @@ const validation = (formData, dispatch, next) => { // Required Validation const empty = validKeys - .filter((name) => { - const cond = !formData[name].length; + .filter((name) => { + const cond = !formData[name].length; - if (cond) { + if (cond) { - // Adding Error - dispatch(addError(name, 'This field is required.')); - } else { - dispatch(addError(name, '')); - } + // Adding Error + dispatch(addError(name, 'This field is required.')); + } else { + dispatch(addError(name, '')); + } - return cond; - }); + return cond; + }); if (empty.length) { dispatch(hasError()); @@ -106,10 +105,10 @@ export const submitUser = () => (dispatch, getState) => { }); }; -export const finishInstall = () => (dispatch, getState) => { +export const finishInstall = () => (dispatch, getState, {rest}) => { const data = getState().install.data; dispatch(installRequest()); - return coralApi('/setup', {method: 'POST', body: data}) + return rest('/setup', {method: 'POST', body: data}) .then(() => { dispatch(installSuccess()); dispatch(nextStep()); @@ -129,9 +128,9 @@ const checkInstallRequest = () => ({type: actions.CHECK_INSTALL_REQUEST}); const checkInstallSuccess = (installed) => ({type: actions.CHECK_INSTALL_SUCCESS, installed}); const checkInstallFailure = (error) => ({type: actions.CHECK_INSTALL_FAILURE, error}); -export const checkInstall = (next) => (dispatch) => { +export const checkInstall = (next) => (dispatch, _, {rest}) => { dispatch(checkInstallRequest()); - coralApi('/setup') + rest('/setup') .then(({installed}) => { dispatch(checkInstallSuccess(installed)); if (installed) { diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index 5136db4c3..a3ff2ca3e 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -4,15 +4,17 @@ export const toggleModal = (open) => ({type: actions.TOGGLE_MODAL, open}); export const singleView = () => ({type: actions.SINGLE_VIEW}); // hide shortcuts note -export const hideShortcutsNote = () => { +export const hideShortcutsNote = () => (dispatch, _, {storage}) => { try { - window.localStorage.setItem('coral:shortcutsNote', 'hide'); + if (storage) { + storage.setItem('coral:shortcutsNote', 'hide'); + } } catch (e) { // above will fail in Safari private mode } - return {type: actions.HIDE_SHORTCUTS_NOTE}; + dispatch({type: actions.HIDE_SHORTCUTS_NOTE}); }; export const setSortOrder = (order) => ({ diff --git a/client/coral-admin/src/actions/settings.js b/client/coral-admin/src/actions/settings.js index ca9062b9e..35f2013c9 100644 --- a/client/coral-admin/src/actions/settings.js +++ b/client/coral-admin/src/actions/settings.js @@ -1,4 +1,3 @@ -import coralApi from '../../../coral-framework/helpers/request'; import t from 'coral-framework/services/i18n'; export const SETTINGS_LOADING = 'SETTINGS_LOADING'; @@ -14,9 +13,9 @@ 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, _, {rest}) => { dispatch({type: SETTINGS_LOADING}); - coralApi('/settings') + rest('/settings') .then((settings) => { dispatch({type: SETTINGS_RECEIVED, settings}); }) @@ -41,13 +40,13 @@ export const updateDomainlist = (listName, list) => { return {type: DOMAINLIST_UPDATED, listName, list}; }; -export const saveSettingsToServer = () => (dispatch, getState) => { +export const saveSettingsToServer = () => (dispatch, getState, {rest}) => { let settings = getState().settings; if (settings.charCount) { settings.charCount = parseInt(settings.charCount); } dispatch({type: SAVE_SETTINGS_LOADING}); - coralApi('/settings', {method: 'PUT', body: settings}) + rest('/settings', {method: 'PUT', body: settings}) .then(() => { dispatch({type: SAVE_SETTINGS_SUCCESS, settings}); }) diff --git a/client/coral-admin/src/actions/users.js b/client/coral-admin/src/actions/users.js index 7ba051a90..994a79a79 100644 --- a/client/coral-admin/src/actions/users.js +++ b/client/coral-admin/src/actions/users.js @@ -1,4 +1,3 @@ -import coralApi from '../../../coral-framework/helpers/request'; import * as userTypes from '../constants/users'; import t from 'coral-framework/services/i18n'; @@ -7,9 +6,9 @@ import t from 'coral-framework/services/i18n'; */ // change status of a user export const userStatusUpdate = (status, userId, commentId) => { - return (dispatch) => { + return (dispatch, _, {rest}) => { dispatch({type: userTypes.UPDATE_STATUS_REQUEST}); - return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}}) + return rest(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}}) .then((res) => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res})) .catch((error) => { console.error(error); @@ -21,8 +20,8 @@ export const userStatusUpdate = (status, userId, commentId) => { // change status of a user export const sendNotificationEmail = (userId, subject, body) => { - return (dispatch) => { - return coralApi(`/users/${userId}/email`, {method: 'POST', body: {subject, body}}) + return (dispatch, _, {rest}) => { + return rest(`/users/${userId}/email`, {method: 'POST', body: {subject, body}}) .catch((error) => { console.error(error); const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); @@ -33,8 +32,8 @@ export const sendNotificationEmail = (userId, subject, body) => { // let a user edit their username export const enableUsernameEdit = (userId) => { - return (dispatch) => { - return coralApi(`/users/${userId}/username-enable`, {method: 'POST'}) + return (dispatch, _, {rest}) => { + return rest(`/users/${userId}/username-enable`, {method: 'POST'}) .catch((error) => { console.error(error); const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); diff --git a/client/coral-admin/src/components/AdminLogin.js b/client/coral-admin/src/components/AdminLogin.js index e75e66d23..014773a1b 100644 --- a/client/coral-admin/src/components/AdminLogin.js +++ b/client/coral-admin/src/components/AdminLogin.js @@ -69,23 +69,23 @@ class AdminLogin extends React.Component { ); const requestPasswordForm = ( this.props.passwordRequestSuccess - ?

{ - location.href = location.href; - }}> + ?

{ + location.href = location.href; + }}> {this.props.passwordRequestSuccess} Sign in

- :
- this.setState({email: e.target.value})} /> - - + :
+ this.setState({email: e.target.value})} /> + + ); return ( diff --git a/client/coral-admin/src/components/CommentBodyHighlighter.js b/client/coral-admin/src/components/CommentBodyHighlighter.js index 39be90d81..9a430f7c7 100644 --- a/client/coral-admin/src/components/CommentBodyHighlighter.js +++ b/client/coral-admin/src/components/CommentBodyHighlighter.js @@ -17,6 +17,7 @@ export default ({suspectWords, bannedWords, body, ...rest}) => { return ( diff --git a/client/coral-admin/src/components/CommentType.css b/client/coral-admin/src/components/CommentType.css index e4851275e..969d9511d 100644 --- a/client/coral-admin/src/components/CommentType.css +++ b/client/coral-admin/src/components/CommentType.css @@ -3,12 +3,11 @@ color: white; background: grey; box-sizing: border-box; - padding: 0px 5px; - border-radius: 2px; + padding: 2px 5px; font-size: 12px; height: 24px; letter-spacing: 0.4px; - margin-bottom: 1px; + line-height: 22px; > i { font-size: 14px; diff --git a/client/coral-admin/src/components/ReplyBadge.js b/client/coral-admin/src/components/ReplyBadge.js new file mode 100644 index 000000000..5c8bb88ba --- /dev/null +++ b/client/coral-admin/src/components/ReplyBadge.js @@ -0,0 +1,11 @@ +import React from 'react'; +import {Badge} from 'coral-ui'; +import t from 'coral-framework/services/i18n'; + +const ReplyBadge = () => ( + + {t('modqueue.reply')} + +); + +export default ReplyBadge; diff --git a/client/coral-admin/src/components/SuspendUserDialog.js b/client/coral-admin/src/components/SuspendUserDialog.js index d2ef23891..0ed947506 100644 --- a/client/coral-admin/src/components/SuspendUserDialog.js +++ b/client/coral-admin/src/components/SuspendUserDialog.js @@ -65,7 +65,7 @@ class SuspendUserDialog extends React.Component { {t('suspenduser.title_suspend')}

- {t('suspenduser.description_suspend', username)} + {t('suspenduser.description_suspend', username)}

{t('suspenduser.select_duration')} @@ -103,7 +103,7 @@ class SuspendUserDialog extends React.Component { {t('suspenduser.title_notify')}

- {t('suspenduser.description_notify', username)} + {t('suspenduser.description_notify', username)}

{t('suspenduser.write_message')} diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js index 11cd444e1..a7eadbac3 100644 --- a/client/coral-admin/src/components/UserDetail.js +++ b/client/coral-admin/src/components/UserDetail.js @@ -139,29 +139,29 @@ export default class UserDetail extends React.Component {
{ selectedCommentIds.length === 0 - ? ( -
    -
  • All
  • -
  • Rejected
  • -
- ) - : ( -
- - - {`${selectedCommentIds.length} comments selected`} -
- ) + ? ( +
    +
  • All
  • +
  • Rejected
  • +
+ ) + : ( +
+ + + {`${selectedCommentIds.length} comments selected`} +
+ ) }
@@ -190,7 +190,7 @@ export default class UserDetail extends React.Component { className={styles.loadMore} loadMore={loadMore} showLoadMore={hasNextPage} - /> + /> ); diff --git a/client/coral-admin/src/components/UserDetailComment.css b/client/coral-admin/src/components/UserDetailComment.css index b042aea39..2dfd61cf3 100644 --- a/client/coral-admin/src/components/UserDetailComment.css +++ b/client/coral-admin/src/components/UserDetailComment.css @@ -55,7 +55,7 @@ position: relative; } -.commentType { +.badgeBar { position: absolute; right: 0px; } diff --git a/client/coral-admin/src/components/UserDetailComment.js b/client/coral-admin/src/components/UserDetailComment.js index 9b4936847..4e31d338b 100644 --- a/client/coral-admin/src/components/UserDetailComment.js +++ b/client/coral-admin/src/components/UserDetailComment.js @@ -3,6 +3,7 @@ import {Link} from 'react-router'; import {Icon} from 'coral-ui'; import FlagBox from './FlagBox'; +import ReplyBadge from './ReplyBadge'; import styles from './UserDetailComment.css'; import CommentType from './CommentType'; import {getActionSummary} from 'coral-framework/utils'; @@ -53,10 +54,14 @@ class UserDetailComment extends React.Component { { (comment.editing && comment.editing.edited) - ?  ({t('comment.edited')}) - : null + ?  ({t('comment.edited')}) + : null } - + +
+ {comment.hasParent && } + +
Story: {comment.asset.title} @@ -116,10 +121,10 @@ class UserDetailComment extends React.Component {
{flagActions && flagActions.length ? + actions={flagActions} + actionSummaries={flagActionSummaries} + viewUserDetail={viewUserDetail} + /> : null} ); diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index ca1cc1f30..b43fa8b35 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -13,9 +13,9 @@ const CoralHeader = ({ }) => (
-
- { - auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? +
+ { + auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? : null - } -
-
+
); diff --git a/client/coral-admin/src/containers/Layout.js b/client/coral-admin/src/containers/Layout.js index ae022f2ef..f2d71c93b 100644 --- a/client/coral-admin/src/containers/Layout.js +++ b/client/coral-admin/src/containers/Layout.js @@ -3,12 +3,11 @@ import {connect} from 'react-redux'; import Layout from '../components/ui/Layout'; import {fetchConfig} from '../actions/config'; import AdminLogin from '../components/AdminLogin'; -import {logout} from 'coral-framework/actions/auth'; import {FullLoading} from '../components/FullLoading'; import BanUserDialog from './BanUserDialog'; import SuspendUserDialog from './SuspendUserDialog'; import {toggleModal as toggleShortcutModal} from '../actions/moderation'; -import {checkLogin, handleLogin, requestPasswordReset} from '../actions/auth'; +import {checkLogin, handleLogin, requestPasswordReset, logout} from '../actions/auth'; import {can} from 'coral-framework/services/perms'; import UserDetail from 'coral-admin/src/containers/UserDetail'; @@ -56,10 +55,10 @@ class LayoutContainer extends Component { toggleShortcutModal={toggleShortcutModal} {...this.props} > - - - - {this.props.children} + + + + {this.props.children} ); } else if (loggedIn) { diff --git a/client/coral-admin/src/containers/SuspendUserDialog.js b/client/coral-admin/src/containers/SuspendUserDialog.js index bd95328a3..f60014832 100644 --- a/client/coral-admin/src/containers/SuspendUserDialog.js +++ b/client/coral-admin/src/containers/SuspendUserDialog.js @@ -5,22 +5,24 @@ import SuspendUserDialog from '../components/SuspendUserDialog'; import {hideSuspendUserDialog} from '../actions/suspendUserDialog'; import {withSetCommentStatus, withSuspendUser} from 'coral-framework/graphql/mutations'; import {compose, gql} from 'react-apollo'; -import * as notification from 'coral-admin/src/services/notification'; import t, {timeago} from 'coral-framework/services/i18n'; import withQuery from 'coral-framework/hocs/withQuery'; +import {getErrorMessages} from 'coral-framework/utils'; import get from 'lodash/get'; +import {notify} from 'coral-framework/actions/notification'; class SuspendUserDialogContainer extends Component { suspendUser = async ({message, until}) => { - const {userId, username, commentStatus, commentId, hideSuspendUserDialog, setCommentStatus, suspendUser} = this.props; + const {userId, username, commentStatus, commentId, hideSuspendUserDialog, setCommentStatus, suspendUser, notify} = this.props; hideSuspendUserDialog(); try { const result = await suspendUser({id: userId, message, until}); if (result.data.suspendUser.errors) { throw result.data.suspendUser.errors; } - notification.success( + notify( + 'success', t('suspenduser.notify_suspend_until', username, timeago(until)), ); if (commentId && commentStatus && commentStatus !== 'REJECTED') { @@ -33,7 +35,7 @@ class SuspendUserDialogContainer extends Component { } } catch(err) { - notification.showMutationErrors(err); + notify('error', getErrorMessages(err)); } }; @@ -69,6 +71,7 @@ const mapStateToProps = ({suspendUserDialog: {open, userId, username, commentId, const mapDispatchToProps = (dispatch) => ({ ...bindActionCreators({ hideSuspendUserDialog, + notify, }, dispatch), }); diff --git a/client/coral-admin/src/containers/UserDetail.js b/client/coral-admin/src/containers/UserDetail.js index 01b28f200..0eb81a9a1 100644 --- a/client/coral-admin/src/containers/UserDetail.js +++ b/client/coral-admin/src/containers/UserDetail.js @@ -88,13 +88,13 @@ class UserDetailContainer extends React.Component { }); } }) - .then(() => { - this.isLoadingMore = false; - }) - .catch((err) => { - this.isLoadingMore = false; - throw err; - }); + .then(() => { + this.isLoadingMore = false; + }) + .catch((err) => { + this.isLoadingMore = false; + throw err; + }); }; componentWillReceiveProps(next) { @@ -124,7 +124,7 @@ class UserDetailContainer extends React.Component { } const LOAD_MORE_QUERY = gql` - query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $author_id: ID!, $statuses: [COMMENT_STATUS!]) { + query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $author_id: ID!, $statuses: [COMMENT_STATUS!]) { comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) { ...CoralAdmin_Moderation_CommentConnection } diff --git a/client/coral-admin/src/containers/UserDetailComment.js b/client/coral-admin/src/containers/UserDetailComment.js index a70a9394d..9f0e3a793 100644 --- a/client/coral-admin/src/containers/UserDetailComment.js +++ b/client/coral-admin/src/containers/UserDetailComment.js @@ -9,6 +9,7 @@ export default withFragments({ body created_at status + hasParent asset { id title diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js index 915fe2989..dea68c5e8 100644 --- a/client/coral-admin/src/graphql/index.js +++ b/client/coral-admin/src/graphql/index.js @@ -1,6 +1,4 @@ -import {add} from 'coral-framework/services/graphqlRegistry'; - -const extension = { +export default { mutations: { SetUserStatus: () => ({ refetchQueries: ['CoralAdmin_Community'], @@ -11,4 +9,3 @@ const extension = { }, }; -add(extension); diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js index 14437c51b..5df2ce391 100644 --- a/client/coral-admin/src/index.js +++ b/client/coral-admin/src/index.js @@ -1,32 +1,34 @@ import React from 'react'; import {render} from 'react-dom'; -import {ApolloProvider} from 'react-apollo'; import smoothscroll from 'smoothscroll-polyfill'; -import EventEmitter from 'eventemitter2'; -import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider'; - -import {getClient} from 'coral-framework/services/client'; -import store from './services/store'; - +import TalkProvider from 'coral-framework/components/TalkProvider'; +import {createContext} from 'coral-framework/services/bootstrap'; +import reducers from './reducers'; import App from './components/App'; - import 'react-mdl/extra/material.js'; -import './graphql'; -import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins'; +import graphqlExtension from './graphql'; +import pluginsConfig from 'pluginsConfig'; +import {toast} from 'react-toastify'; +import {createNotificationService} from './services/notification'; +import {hideShortcutsNote} from './actions/moderation'; -const eventEmitter = new EventEmitter(); +function hidrateStore({store, storage}) { + if (storage && storage.getItem('coral:shortcutsNote') === 'hide') { + store.dispatch(hideShortcutsNote()); + } +} -// TODO: pass redux actions through the emitter. - -loadPluginsTranslations(); -injectPluginsReducers(); smoothscroll.polyfill(); +const notification = createNotificationService(toast); +const context = createContext({reducers, graphqlExtension, pluginsConfig, notification}); + +// hidrate Store with external data. +hidrateStore(context); + render( - - - - - + + + , document.querySelector('#root') ); diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index e0a608488..43ae81573 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -5,14 +5,17 @@ const initialState = { modalOpen: false, storySearchVisible: false, storySearchString: '', - shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show', - sortOrder: 'REVERSE_CHRONOLOGICAL', + shortcutsNoteVisible: 'show', + sortOrder: 'DESC', }; export default function moderation (state = initialState, action) { switch (action.type) { case actions.MODERATION_CLEAR_STATE: - return initialState; + return { + ...initialState, + shortcutsNoteVisible: state.shortcutsNoteVisible, + }; case actions.TOGGLE_MODAL: return { ...state, diff --git a/client/coral-admin/src/routes/Community/components/FlaggedAccounts.js b/client/coral-admin/src/routes/Community/components/FlaggedAccounts.js index bb8504293..826e1299a 100644 --- a/client/coral-admin/src/routes/Community/components/FlaggedAccounts.js +++ b/client/coral-admin/src/routes/Community/components/FlaggedAccounts.js @@ -14,21 +14,21 @@ const FlaggedAccounts = (props) => {
{ hasResults - ? commenters.map((commenter, index) => { - return { + return ; - }) - : {t('community.no_flagged_accounts')} + }) + : {t('community.no_flagged_accounts')} }
diff --git a/client/coral-admin/src/routes/Community/components/People.js b/client/coral-admin/src/routes/Community/components/People.js index 464f3bc5b..aef4d250a 100644 --- a/client/coral-admin/src/routes/Community/components/People.js +++ b/client/coral-admin/src/routes/Community/components/People.js @@ -45,12 +45,12 @@ const People = ({commenters, searchValue, onSearchChange, ...props}) => {
{ hasResults - ? - : {t('community.no_results')} + : {t('community.no_results')} } this.setState({stage: stage + 1}); const suspend = () => { rejectUsername({id: user.user.id, message: this.state.email}) - .then(() => { - this.props.handleClose(); - }); + .then(() => { + this.props.handleClose(); + }); }; const suspendModalActions = [ @@ -71,21 +71,21 @@ class RejectUsernameDialog extends Component { const {stage} = this.state; return -
- {t(stages[stage].title, t('reject_username.username'))} -
-
-
- {t(stages[stage].description, t('reject_username.username'))} -
- { - stage === 1 && + className={styles.suspendDialog} + id="rejectUsernameDialog" + open={open} + onClose={handleClose} + onCancel={handleClose} + title={t('reject_username.suspend_user')}> +
+ {t(stages[stage].title, t('reject_username.username'))} +
+
+
+ {t(stages[stage].description, t('reject_username.username'))} +
+ { + stage === 1 &&
{t('reject_username.write_message')}
@@ -96,16 +96,16 @@ class RejectUsernameDialog extends Component { onChange={this.onEmailChange}/>
- } -
- {Object.keys(stages[stage].options).map((key, i) => ( - - ))} -
-
-
; + } +
+ {Object.keys(stages[stage].options).map((key, i) => ( + + ))} +
+ + ; } } diff --git a/client/coral-admin/src/routes/Community/components/Table.js b/client/coral-admin/src/routes/Community/components/Table.js index f851b1f84..c5bb494c0 100644 --- a/client/coral-admin/src/routes/Community/components/Table.js +++ b/client/coral-admin/src/routes/Community/components/Table.js @@ -9,9 +9,9 @@ export default ({headers, commenters, onHeaderClickHandler, onRoleChange, onComm
{headers.map((header, i) =>( ))} diff --git a/client/coral-admin/src/routes/Community/components/User.js b/client/coral-admin/src/routes/Community/components/User.js index 2d921a1c7..5225e0ee9 100644 --- a/client/coral-admin/src/routes/Community/components/User.js +++ b/client/coral-admin/src/routes/Community/components/User.js @@ -59,7 +59,7 @@ const User = (props) => {
flag{t('community.flags')}({ user.actions.length }): - { user.action_summaries.map( + { user.action_summaries.map( (action, i) => { return {shortReasons[action.reason]} ({action.count}) diff --git a/client/coral-admin/src/routes/Community/containers/Community.js b/client/coral-admin/src/routes/Community/containers/Community.js index b04fc0281..bfb634f18 100644 --- a/client/coral-admin/src/routes/Community/containers/Community.js +++ b/client/coral-admin/src/routes/Community/containers/Community.js @@ -77,14 +77,14 @@ export const withCommunityQuery = withQuery(gql` } } `, { - options: ({params: {action_type = 'FLAG'}}) => { - return { - variables: { - action_type: action_type - } - }; - } -}); + options: ({params: {action_type = 'FLAG'}}) => { + return { + variables: { + action_type: action_type + } + }; + } + }); const mapStateToProps = (state) => ({ community: state.community, diff --git a/client/coral-admin/src/routes/Configure/components/Configure.js b/client/coral-admin/src/routes/Configure/components/Configure.js index 84e47dfb7..15ff69b69 100644 --- a/client/coral-admin/src/routes/Configure/components/Configure.js +++ b/client/coral-admin/src/routes/Configure/components/Configure.js @@ -111,20 +111,20 @@ export default class Configure extends Component { (bool, error) => this.state.errors[error] ? false : bool, this.state.changed); return ( -
-
- - - {t('configure.stream_settings')} - - - {t('configure.moderation_settings')} - - - {t('configure.tech_settings')} - - -
+
+
+ + + {t('configure.stream_settings')} + + + {t('configure.moderation_settings')} + + + {t('configure.tech_settings')} + + +
{ showSave ? - : + : + {t('configure.save_changes')} + } -
+
-
-
- { this.props.saveFetchingError } - { this.props.fetchSettingsError } - { section } -
+
+ { this.props.saveFetchingError } + { this.props.fetchSettingsError } + { section } +
+
); } } diff --git a/client/coral-admin/src/routes/Configure/components/StreamSettings.js b/client/coral-admin/src/routes/Configure/components/StreamSettings.js index 1bd7534af..1427f949e 100644 --- a/client/coral-admin/src/routes/Configure/components/StreamSettings.js +++ b/client/coral-admin/src/routes/Configure/components/StreamSettings.js @@ -4,7 +4,7 @@ import t from 'coral-framework/services/i18n'; import styles from './Configure.css'; import {Checkbox, Textfield} from 'react-mdl'; import {Card, Icon, TextArea} from 'coral-ui'; -import MarkdownEditor from 'coral-admin/src/components/MarkdownEditor'; +import MarkdownEditor from 'coral-framework/components/MarkdownEditor'; const TIMESTAMPS = { weeks: 60 * 60 * 24 * 7, @@ -93,15 +93,15 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => { value={settings.charCount} disabled={settings.charCountEnable ? '' : 'disabled'} /> - {t('configure.comment_count_text_post')} - { - errors.charCount && + {t('configure.comment_count_text_post')} + { + errors.charCount &&
{t('configure.comment_count_error')}
- } + }

diff --git a/client/coral-admin/src/routes/Dashboard/components/ActivityWidget.js b/client/coral-admin/src/routes/Dashboard/components/ActivityWidget.js index d92b2bd14..5b43a605c 100644 --- a/client/coral-admin/src/routes/Dashboard/components/ActivityWidget.js +++ b/client/coral-admin/src/routes/Dashboard/components/ActivityWidget.js @@ -14,19 +14,19 @@ const ActivityWidget = ({assets}) => {
{ assets.length - ? assets.map((asset) => { - return ( -
- Moderate -

{asset.commentCount}

- -

{asset.title}

-
-

{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}

-
- ); - }) - :
{t('dashboard.no_activity')}
+ ? assets.map((asset) => { + return ( +
+ Moderate +

{asset.commentCount}

+ +

{asset.title}

+
+

{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}

+
+ ); + }) + :
{t('dashboard.no_activity')}
}
diff --git a/client/coral-admin/src/routes/Dashboard/components/CountdownTimer.js b/client/coral-admin/src/routes/Dashboard/components/CountdownTimer.js index 9e1995b7f..6864acb6b 100644 --- a/client/coral-admin/src/routes/Dashboard/components/CountdownTimer.js +++ b/client/coral-admin/src/routes/Dashboard/components/CountdownTimer.js @@ -5,17 +5,24 @@ import {Icon} from 'coral-ui'; import t from 'coral-framework/services/i18n'; const refreshIntervalSeconds = 60 * 5; +// TODO: refactor out storage code into redux. + class CountdownTimer extends React.Component { + static contextTypes = { + storage: PropTypes.object, + }; + static propTypes = { handleTimeout: PropTypes.func.isRequired } - constructor (props) { - super(props); + constructor (props, context) { + super(props, context); + const {storage} = context; try { - if (window.localStorage.getItem('coral:dashboardNote') === null) { - window.localStorage.setItem('coral:dashboardNote', 'show'); + if (storage && storage.getItem('coral:dashboardNote') === null) { + storage.setItem('coral:dashboardNote', 'show'); } } catch (e) { @@ -24,7 +31,7 @@ class CountdownTimer extends React.Component { this.state = { secondsUntilRefresh: refreshIntervalSeconds, - dashboardNote: window.localStorage.getItem('coral:dashboardNote') || 'show' + dashboardNote: (storage && storage.getItem('coral:dashboardNote')) || 'show' }; } @@ -54,8 +61,11 @@ class CountdownTimer extends React.Component { } dismissNote = () => { + const {storage} = this.context; try { - window.localStorage.setItem('coral:dashboardNote', 'hide'); + if (storage) { + storage.setItem('coral:dashboardNote', 'hide'); + } } catch (e) { // when setItem fails in Safari Private mode @@ -64,7 +74,8 @@ class CountdownTimer extends React.Component { } render () { - const hideReloadNote = window.localStorage.getItem('coral:dashboardNote') === 'hide' || + const {storage} = this.context; + const hideReloadNote = (storage && storage.getItem('coral:dashboardNote') === 'hide') || this.state.dashboardNote === 'hide'; // for Safari Incognito return (

{

{ assets.length - ? assets.map((asset) => { - let flagSummary = null; - if (asset.action_summaries) { - flagSummary = asset.action_summaries.find((s) => s.__typename === 'FlagAssetActionSummary'); - } + ? assets.map((asset) => { + let flagSummary = null; + if (asset.action_summaries) { + flagSummary = asset.action_summaries.find((s) => s.__typename === 'FlagAssetActionSummary'); + } - return ( -
- Moderate -

{flagSummary ? flagSummary.actionCount : 0}

- -

{asset.title}

-
-

{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}

-
- ); - }) - :
{t('dashboard.no_flags')}
+ return ( +
+ Moderate +

{flagSummary ? flagSummary.actionCount : 0}

+ +

{asset.title}

+
+

{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}

+
+ ); + }) + :
{t('dashboard.no_flags')}
}
diff --git a/client/coral-admin/src/routes/Dashboard/components/LikeWidget.js b/client/coral-admin/src/routes/Dashboard/components/LikeWidget.js index a61f888bf..aeb31c318 100644 --- a/client/coral-admin/src/routes/Dashboard/components/LikeWidget.js +++ b/client/coral-admin/src/routes/Dashboard/components/LikeWidget.js @@ -15,20 +15,20 @@ const LikeWidget = ({assets}) => {
{ assets.length - ? assets.map((asset) => { - const likeSummary = asset.action_summaries.find((s) => s.type === 'LikeAssetActionSummary'); - return ( -
- Moderate -

{likeSummary ? likeSummary.actionCount : 0}

- -

{asset.title}

-
-

{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}

-
- ); - }) - :
{t('dashboard.no_likes')}
+ ? assets.map((asset) => { + const likeSummary = asset.action_summaries.find((s) => s.type === 'LikeAssetActionSummary'); + return ( +
+ Moderate +

{likeSummary ? likeSummary.actionCount : 0}

+ +

{asset.title}

+
+

{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}

+
+ ); + }) + :
{t('dashboard.no_likes')}
}
diff --git a/client/coral-admin/src/routes/Dashboard/containers/Dashboard.js b/client/coral-admin/src/routes/Dashboard/containers/Dashboard.js index 74faedcf7..2bb192f2e 100644 --- a/client/coral-admin/src/routes/Dashboard/containers/Dashboard.js +++ b/client/coral-admin/src/routes/Dashboard/containers/Dashboard.js @@ -22,10 +22,10 @@ class DashboardContainer extends React.Component { export const witDashboardQuery = withQuery(gql` query CoralAdmin_Dashboard($from: Date!, $to: Date!) { - assetsByFlag: assetMetrics(from: $from, to: $to, sort: FLAG) { + assetsByFlag: assetMetrics(from: $from, to: $to, sortBy: FLAG) { ...CoralAdmin_Metrics } - assetsByActivity: assetMetrics(from: $from, to: $to, sort: ACTIVITY) { + assetsByActivity: assetMetrics(from: $from, to: $to, sortBy: ACTIVITY) { ...CoralAdmin_Metrics } } @@ -42,15 +42,15 @@ export const witDashboardQuery = withQuery(gql` } } `, { - options: ({settings: {dashboardWindowStart, dashboardWindowEnd}}) => { - return { - variables: { - from: dashboardWindowStart, - to: dashboardWindowEnd - } - }; - } -}); + options: ({settings: {dashboardWindowStart, dashboardWindowEnd}}) => { + return { + variables: { + from: dashboardWindowStart, + to: dashboardWindowEnd + } + }; + } + }); const mapStateToProps = (state) => { return { diff --git a/client/coral-admin/src/routes/Install/components/Steps/CreateYourAccount.js b/client/coral-admin/src/routes/Install/components/Steps/CreateYourAccount.js index 2a5b87e5f..d4d43dc92 100644 --- a/client/coral-admin/src/routes/Install/components/Steps/CreateYourAccount.js +++ b/client/coral-admin/src/routes/Install/components/Steps/CreateYourAccount.js @@ -19,7 +19,7 @@ const InitialStep = (props) => { showErrors={install.showErrors} errorMsg={install.errors.email} noValidate - /> + /> { onChange={handleUserChange} showErrors={install.showErrors} errorMsg={install.errors.username} - /> + /> { onChange={handleUserChange} showErrors={install.showErrors} errorMsg={install.errors.password} - /> + /> { onChange={handleUserChange} showErrors={install.showErrors} errorMsg={install.errors.confirmPassword} - /> + /> { !props.install.isLoading ? - - : - + + : + } {props.install.installRequest === 'FAILURE' &&
Error: {props.install.installRequestError}
} diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index 1335a8c52..0fa2ba8bb 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -2,6 +2,7 @@ import React, {PropTypes} from 'react'; import {Link} from 'react-router'; import {Icon} from 'coral-ui'; +import ReplyBadge from 'coral-admin/src/components/ReplyBadge'; import FlagBox from 'coral-admin/src/components/FlagBox'; import styles from './styles.css'; import CommentType from 'coral-admin/src/components/CommentType'; @@ -90,8 +91,8 @@ class Comment extends React.Component { { (comment.editing && comment.editing.edited) - ?  ({t('comment.edited')}) - : null + ?  ({t('comment.edited')}) + : null } {currentUserId !== comment.user.id && @@ -107,6 +108,7 @@ class Comment extends React.Component { }
+ {comment.hasParent && } {flagActions && flagActions.length ? + actions={flagActions} + actionSummaries={flagActionSummaries} + viewUserDetail={viewUserDetail} + /> : null} ); diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js b/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js index 7b427ac54..a28e90c65 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js @@ -36,8 +36,8 @@ const ModerationMenu = ({ label="Sort" value={sort} onChange={(sort) => selectSort(sort)}> - - + +
diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 7f96c7c90..e8b5b8191 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -97,7 +97,7 @@ class ModerationQueue extends React.Component { rejectComment={props.rejectComment} currentAsset={props.currentAsset} currentUserId={this.props.currentUserId} - />; + />; }) } @@ -110,7 +110,7 @@ class ModerationQueue extends React.Component { + /> ); } diff --git a/client/coral-admin/src/routes/Moderation/components/StorySearch.js b/client/coral-admin/src/routes/Moderation/components/StorySearch.js index f19560549..748f80b0f 100644 --- a/client/coral-admin/src/routes/Moderation/components/StorySearch.js +++ b/client/coral-admin/src/routes/Moderation/components/StorySearch.js @@ -61,20 +61,20 @@ const StorySearch = (props) => { { loading - ? - : assets.map((story, i) => { - const storyOpen = story.closedAt === null || new Date(story.closedAt) > new Date(); + ? + : assets.map((story, i) => { + const storyOpen = story.closedAt === null || new Date(story.closedAt) > new Date(); - return ; - }) + return ; + }) } {assets.length === 0 &&
No results
} diff --git a/client/coral-admin/src/routes/Moderation/containers/Comment.js b/client/coral-admin/src/routes/Moderation/containers/Comment.js index 87b29a779..cc127ef4b 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Comment.js +++ b/client/coral-admin/src/routes/Moderation/containers/Comment.js @@ -37,6 +37,7 @@ export default withFragments({ count ... on FlagActionSummary { reason + __typename } } actions { @@ -48,11 +49,14 @@ export default withFragments({ id username } + __typename } + __typename } editing { edited } + hasParent ${getSlotFragmentSpreads(slots, 'comment')} } ` diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index d5ca5c840..722faaaa3 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -26,25 +26,27 @@ import { storySearchChange, clearState } from 'actions/moderation'; +import withQueueConfig from '../hoc/withQueueConfig'; +import {notify} from 'coral-framework/actions/notification'; import {Spinner} from 'coral-ui'; import Moderation from '../components/Moderation'; import Comment from './Comment'; -import queueConfig from '../queueConfig'; +import baseQueueConfig from '../queueConfig'; function prepareNotificationText(text) { return truncate(text, {length: 50}).replace('\n', ' '); } function getAssetId(props) { - if (props.params.tabOrId && !(props.params.tabOrId in queueConfig)) { + if (props.params.tabOrId && !(props.params.tabOrId in props.queueConfig)) { return props.params.tabOrId; } return props.params.id || null; } function getTab(props) { - if (props.params.tabOrId && props.params.tabOrId in queueConfig) { + if (props.params.tabOrId && props.params.tabOrId in props.queueConfig) { return props.params.tabOrId; } return props.params.tab || null; @@ -53,8 +55,15 @@ function getTab(props) { class ModerationContainer extends Component { subscriptions = []; - handleCommentChange = (root, comment, notify) => { - return handleCommentChange(root, comment, this.props.data.variables.sort, notify, queueConfig, this.activeTab); + handleCommentChange = (root, comment, notifyText) => { + return handleCommentChange( + root, + comment, + this.props.data.variables.sortOrder, + () => notifyText && this.props.notify('info', notifyText), + this.props.queueConfig, + this.activeTab + ); }; get activeTab() { @@ -78,10 +87,10 @@ class ModerationContainer extends Component { variables, updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => { const user = comment.status_history[comment.status_history.length - 1].assigned_by; - const notify = this.props.auth.user.id === user.id + const notifyText = this.props.auth.user.id === user.id ? '' : t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body)); - return this.handleCommentChange(prev, comment, notify); + return this.handleCommentChange(prev, comment, notifyText); }, }); @@ -90,10 +99,10 @@ class ModerationContainer extends Component { variables, updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => { const user = comment.status_history[comment.status_history.length - 1].assigned_by; - const notify = this.props.auth.user.id === user.id + const notifyText = this.props.auth.user.id === user.id ? '' : t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body)); - return this.handleCommentChange(prev, comment, notify); + return this.handleCommentChange(prev, comment, notifyText); }, }); @@ -101,8 +110,8 @@ class ModerationContainer extends Component { document: COMMENT_EDITED_SUBSCRIPTION, variables, updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => { - const notify = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body)); - return this.handleCommentChange(prev, comment, notify); + const notifyText = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body)); + return this.handleCommentChange(prev, comment, notifyText); }, }); @@ -111,8 +120,8 @@ class ModerationContainer extends Component { variables, updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => { const user = comment.actions[comment.actions.length - 1].user; - const notify = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)); - return this.handleCommentChange(prev, comment, notify); + const notifyText = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)); + return this.handleCommentChange(prev, comment, notifyText); }, }); @@ -159,10 +168,10 @@ class ModerationContainer extends Component { const variables = { limit: 10, cursor: this.props.root[tab].endCursor, - sort: this.props.data.variables.sort, + sortOrder: this.props.data.variables.sortOrder, asset_id: this.props.data.variables.asset_id, - statuses: queueConfig[tab].statuses, - action_type: queueConfig[tab].action_type, + statuses: this.props.queueConfig[tab].statuses, + action_type: this.props.queueConfig[tab].action_type, }; return this.props.data.fetchMore({ query: LOAD_MORE_QUERY, @@ -206,7 +215,7 @@ class ModerationContainer extends Component { } const premodEnabled = assetId ? isPremod(asset.settings.moderation) : isPremod(settings.moderation); - const currentQueueConfig = Object.assign({}, queueConfig); + const currentQueueConfig = Object.assign({}, this.props.queueConfig); if (premodEnabled) { delete currentQueueConfig.new; } else { @@ -279,8 +288,8 @@ const COMMENT_REJECTED_SUBSCRIPTION = gql` `; const LOAD_MORE_QUERY = gql` - query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $sort: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) { - comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sort: $sort, action_type: $action_type}) { + query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $sortOrder: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) { + comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sortOrder: $sortOrder, action_type: $action_type}) { nodes { ...${getDefinitionName(Comment.fragments.comment)} } @@ -304,15 +313,15 @@ const commentConnectionFragment = gql` ${Comment.fragments.comment} `; -const withModQueueQuery = withQuery(gql` - query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) { +const withModQueueQuery = withQuery(({queueConfig}) => gql` + query CoralAdmin_Moderation($asset_id: ID, $sortOrder: SORT_ORDER, $allAssets: Boolean!) { ${Object.keys(queueConfig).map((queue) => ` ${queue}: comments(query: { ${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''} ${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''} ${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''} asset_id: $asset_id, - sort: $sort + sortOrder: $sortOrder }) { ...CoralAdmin_Moderation_CommentConnection } @@ -345,14 +354,14 @@ const withModQueueQuery = withQuery(gql` return { variables: { asset_id: id, - sort: props.moderation.sortOrder, + sortOrder: props.moderation.sortOrder, allAssets: id === null } }; }, }); -const withQueueCountPolling = withQuery(gql` +const withQueueCountPolling = withQuery(({queueConfig}) => gql` query CoralAdmin_ModerationCountPoll($asset_id: ID) { ${Object.keys(queueConfig).map((queue) => ` ${queue}Count: commentCount(query: { @@ -393,11 +402,13 @@ const mapDispatchToProps = (dispatch) => ({ viewUserDetail, setSortOrder, storySearchChange, - clearState + clearState, + notify, }, dispatch), }); export default compose( + withQueueConfig(baseQueueConfig), connect(mapStateToProps, mapDispatchToProps), withSetCommentStatus, withQueueCountPolling, diff --git a/client/coral-admin/src/routes/Moderation/containers/StorySearch.js b/client/coral-admin/src/routes/Moderation/containers/StorySearch.js index 4175479e6..04472d219 100644 --- a/client/coral-admin/src/routes/Moderation/containers/StorySearch.js +++ b/client/coral-admin/src/routes/Moderation/containers/StorySearch.js @@ -98,14 +98,14 @@ export const withAssetSearchQuery = withQuery(gql` } } `, { - options: ({moderation: {storySearchString = ''}}) => { - return { - variables: { - value: storySearchString - } - }; - } -}); + options: ({moderation: {storySearchString = ''}}) => { + return { + variables: { + value: storySearchString + } + }; + } + }); export default compose( withRouter, diff --git a/client/coral-admin/src/routes/Moderation/graphql.js b/client/coral-admin/src/routes/Moderation/graphql.js index 0d4991f72..28ca259d8 100644 --- a/client/coral-admin/src/routes/Moderation/graphql.js +++ b/client/coral-admin/src/routes/Moderation/graphql.js @@ -1,5 +1,4 @@ import update from 'immutability-helper'; -import * as notification from 'coral-admin/src/services/notification'; const limit = 10; @@ -29,29 +28,29 @@ function removeCommentFromQueue(root, queue, id) { }); } -function shouldCommentBeAdded(root, queue, comment, sort) { +function shouldCommentBeAdded(root, queue, comment, sortOrder) { if (root[`${queue}Count`] < limit) { // Adding all comments until first limit has reached. return true; } const cursor = new Date(root[queue].endCursor); - return sort === 'CHRONOLOGICAL' + return sortOrder === 'ASC' ? new Date(comment.created_at) <= cursor : new Date(comment.created_at) >= cursor; } -function addCommentToQueue(root, queue, comment, sort) { +function addCommentToQueue(root, queue, comment, sortOrder) { if (queueHasComment(root, queue, comment.id)) { return root; } - const sortAlgo = sort === 'CHRONOLOGICAL' ? ascending : descending; + const sortAlgo = sortOrder === 'ASC' ? ascending : descending; const changes = { [`${queue}Count`]: {$set: root[`${queue}Count`] + 1}, }; - if (shouldCommentBeAdded(root, queue, comment, sort)) { + if (shouldCommentBeAdded(root, queue, comment, sortOrder)) { const nodes = root[queue].nodes.concat(comment).sort(sortAlgo); changes[queue] = { nodes: {$set: nodes}, @@ -91,16 +90,13 @@ function getCommentQueues(comment, queueConfig) { * Assimilate comment changes into current store. * @param {Object} root current state of the store * @param {Object} comment comment that was changed - * @param {string} sort current sort order of the queues - * @param {Object} [notify] show know notifications if set - * @param {string} notify.activeQueue current active queue - * @param {string} notify.text notification text to show - * @param {bool} notify.anyQueue if true show the notification when the comment is shown + * @param {string} sortOrder current sort order of the queues + * @param {string} notify callback to show notification * in the current active queue besides the 'all' queue. * @param {Object} queueConfig queue configuration * @return {Object} next state of the store */ -export function handleCommentChange(root, comment, sort, notify, queueConfig, activeQueue) { +export function handleCommentChange(root, comment, sortOrder, notify, queueConfig, activeQueue) { let next = root; const nextQueues = getCommentQueues(comment, queueConfig); @@ -110,22 +106,22 @@ export function handleCommentChange(root, comment, sort, notify, queueConfig, ac if (notificationShown) { return; } - notification.info(notify); + notify(); notificationShown = true; }; Object.keys(queueConfig).forEach((queue) => { if (nextQueues.indexOf(queue) >= 0) { if (!queueHasComment(next, queue, comment.id)) { - next = addCommentToQueue(next, queue, comment, sort); - if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) { - showNotificationOnce(comment); + next = addCommentToQueue(next, queue, comment, sortOrder); + if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sortOrder)) { + showNotificationOnce(); } } } else if(queueHasComment(next, queue, comment.id)){ next = removeCommentFromQueue(next, queue, comment.id); if (notify && activeQueue === queue) { - showNotificationOnce(comment); + showNotificationOnce(); } } @@ -134,7 +130,7 @@ export function handleCommentChange(root, comment, sort, notify, queueConfig, ac && queueHasComment(next, queue, comment.id) && activeQueue === queue ) { - showNotificationOnce(comment); + showNotificationOnce(); } }); return next; diff --git a/client/coral-admin/src/routes/Moderation/hoc/withQueueConfig.js b/client/coral-admin/src/routes/Moderation/hoc/withQueueConfig.js new file mode 100644 index 000000000..a56051810 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/hoc/withQueueConfig.js @@ -0,0 +1,26 @@ +import React from 'react'; +import hoistStatics from 'recompose/hoistStatics'; +import PropTypes from 'prop-types'; + +/** + * WithQueueConfig takes a `queueConfig` parameter that is + * passed down as a prop enriched with queue config data from plugins. + */ +export default (queueConfig) => hoistStatics((WrappedComponent) => { + class WithQueueConfig extends React.Component { + static contextTypes = { + plugins: PropTypes.object, + }; + + pluginsConfig = this.context.plugins.getModQueueConfigs(); + + render() { + return ; + } + } + + return WithQueueConfig; +}); diff --git a/client/coral-admin/src/routes/Moderation/queueConfig.js b/client/coral-admin/src/routes/Moderation/queueConfig.js index b9e9e5320..9a23ccc6c 100644 --- a/client/coral-admin/src/routes/Moderation/queueConfig.js +++ b/client/coral-admin/src/routes/Moderation/queueConfig.js @@ -1,5 +1,4 @@ import t from 'coral-framework/services/i18n'; -import {getModQueueConfigs} from 'coral-framework/helpers/plugins'; export default { premod: { @@ -33,5 +32,4 @@ export default { icon: 'question_answer', name: t('modqueue.all'), }, - ...getModQueueConfigs(), }; diff --git a/client/coral-admin/src/routes/Stories/components/Stories.js b/client/coral-admin/src/routes/Stories/components/Stories.js index 7f9098fba..5b931e6fc 100644 --- a/client/coral-admin/src/routes/Stories/components/Stories.js +++ b/client/coral-admin/src/routes/Stories/components/Stories.js @@ -134,20 +134,20 @@ export default class Stories extends Component { {t('streams.closed')}
{t('streams.sort_by')}
- - {t('streams.newest')} - {t('streams.oldest')} - - + + {t('streams.newest')} + {t('streams.oldest')} + + { assetsIds.length - ?
+ ?
{t('streams.article')} @@ -162,7 +162,7 @@ export default class Stories extends Component { page={this.state.page} onNewPageHandler={this.onPageClick} />
- : {t('streams.empty_result')} + : {t('streams.empty_result')} }
); diff --git a/client/coral-admin/src/services/notification.js b/client/coral-admin/src/services/notification.js index 5e8673060..aca5bdc80 100644 --- a/client/coral-admin/src/services/notification.js +++ b/client/coral-admin/src/services/notification.js @@ -1,26 +1,18 @@ -import t from 'coral-framework/services/i18n'; -import {toast} from 'react-toastify'; - -export function success(msg) { - return toast(msg, {type: 'success'}); -} - -export function error(msg) { - return toast(msg, {type: 'error'}); -} - -export function info(msg) { - return toast(msg, {type: 'info'}); -} - -export function showMutationErrors(error) { - console.error(error); - if (error.errors) { - error.errors.forEach((err) => { - toast( - err.translation_key ? t(`error.${err.translation_key}`) : err, - {type: 'error'} - ); - }); - } +/** + * createNotificationService returns a notification services based on toast. + * @param {Object} toast + * @return {Object} notification service + */ +export function createNotificationService(toast) { + return { + success(msg) { + toast(msg, {type: 'success'}); + }, + error(msg) { + toast(msg, {type: 'error'}); + }, + info(msg) { + toast(msg, {type: 'info'}); + }, + }; } diff --git a/client/coral-admin/src/services/store.js b/client/coral-admin/src/services/store.js deleted file mode 100644 index ca29c15c8..000000000 --- a/client/coral-admin/src/services/store.js +++ /dev/null @@ -1,31 +0,0 @@ -import {createStore, combineReducers, applyMiddleware, compose} from 'redux'; -import thunk from 'redux-thunk'; -import mainReducer from '../reducers'; -import {getClient} from 'coral-framework/services/client'; - -const middlewares = [ - applyMiddleware(getClient().middleware()), - applyMiddleware(thunk) -]; - -if (window.devToolsExtension) { - - // we can't have the last argument of compose() be undefined - middlewares.push(window.devToolsExtension()); -} - -const coralReducers = { - ...mainReducer, - apollo: getClient().reducer() -}; - -const store = createStore( - combineReducers(coralReducers), - {}, - compose(...middlewares) -); - -store.coralReducers = coralReducers; - -window.coralStore = store; -export default store; diff --git a/client/coral-configure/components/ConfigureCommentStream.css b/client/coral-configure/components/ConfigureCommentStream.css index a8cbc36cb..5575e073b 100644 --- a/client/coral-configure/components/ConfigureCommentStream.css +++ b/client/coral-configure/components/ConfigureCommentStream.css @@ -12,10 +12,6 @@ padding: 0; } -.wrapper ul ul { - padding-left: 20px -} - .checkbox { vertical-align: top; margin: 12px 12px 12px 0; @@ -36,4 +32,4 @@ .hidden { display: none; -} +} \ No newline at end of file diff --git a/client/coral-configure/components/ConfigureCommentStream.js b/client/coral-configure/components/ConfigureCommentStream.js index 5ee9b706f..ec5a43c15 100644 --- a/client/coral-configure/components/ConfigureCommentStream.js +++ b/client/coral-configure/components/ConfigureCommentStream.js @@ -1,5 +1,6 @@ import React from 'react'; -import {Button, Checkbox, TextField} from 'coral-ui'; +import {Button, Checkbox} from 'coral-ui'; +import QuestionBoxBuilder from './QuestionBoxBuilder'; import styles from './ConfigureCommentStream.css'; @@ -55,17 +56,14 @@ export default ({handleChange, handleApply, changed, ...props}) => ( title: t('configure.enable_questionbox'), description: t('configure.enable_questionbox_description') }} /> -
- -
+ } - diff --git a/client/coral-configure/components/DefaultIcon.css b/client/coral-configure/components/DefaultIcon.css new file mode 100644 index 000000000..6125d2f6a --- /dev/null +++ b/client/coral-configure/components/DefaultIcon.css @@ -0,0 +1,27 @@ +.iconBubble{ + position: absolute; + top: 8px; + left: 10px; + color: #9E9E9E; + font-size: 24px; + z-index: 0; +} + +.iconPerson{ + z-index: 2; + top: 12px; + left: 12px; + position: absolute; + font-size: 33px; + color: #262626; +} + +.qbIconContainer { + position: relative; + border: 0; + color: white; + display: inline-block; + padding: 5px 20px; + vertical-align: middle; + width: 10px; +} \ No newline at end of file diff --git a/client/coral-configure/components/DefaultIcon.js b/client/coral-configure/components/DefaultIcon.js new file mode 100644 index 000000000..5f6f4876c --- /dev/null +++ b/client/coral-configure/components/DefaultIcon.js @@ -0,0 +1,13 @@ +import React from 'react'; +import cn from 'classnames'; +import styles from './DefaultIcon.css'; +import {Icon} from 'coral-ui'; + +const DefaultIcon = ({className}) => ( +
+ + +
+); + +export default DefaultIcon; diff --git a/client/talk-plugin-infobox/Markdown.js b/client/coral-configure/components/Markdown.js similarity index 100% rename from client/talk-plugin-infobox/Markdown.js rename to client/coral-configure/components/Markdown.js diff --git a/client/coral-configure/components/QuestionBoxBuilder.css b/client/coral-configure/components/QuestionBoxBuilder.css new file mode 100644 index 000000000..758f350b9 --- /dev/null +++ b/client/coral-configure/components/QuestionBoxBuilder.css @@ -0,0 +1,45 @@ +.qbBuilder { + margin-left: 50px; +} + +.qbItemIconList { + padding: 0; + margin: 10px 0; +} + +.qbItemIcon { + background: #F0F0F0; + width: 45px; + height: 45px; + font-size: 24px; + text-align: center; + line-height: 45px; + color: #252525; + border-radius: 3px; + display: inline-block; + overflow: hidden; + margin-right: 10px; + position: relative; + border: solid 2px #F0F0F0; + transition: border 0.3s cubic-bezier(.4,0,.2,1); +} + +.qbItemIcon:hover { + cursor: pointer; +} + +.qbItemIconActive { + border: solid 2px #00796B; +} + +.defaultIcon { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.qb { + margin: 10px 0; +} \ No newline at end of file diff --git a/client/coral-configure/components/QuestionBoxBuilder.js b/client/coral-configure/components/QuestionBoxBuilder.js new file mode 100644 index 000000000..285df40ed --- /dev/null +++ b/client/coral-configure/components/QuestionBoxBuilder.js @@ -0,0 +1,98 @@ +import React from 'react'; +import QuestionBox from 'talk-plugin-questionbox/QuestionBox'; +import {Icon, Spinner} from 'coral-ui'; +import DefaultIcon from './DefaultIcon'; +import cn from 'classnames'; +import styles from './QuestionBoxBuilder.css'; + +class QuestionBoxBuilder extends React.Component { + constructor() { + super(); + + this.state = { + loading: true + }; + } + + componentWillMount() { + this.loadEditor(); + } + + async loadEditor() { + const MarkdownEditor = await import('coral-framework/components/MarkdownEditor'); + + return this.setState({ + loading : false, + MarkdownEditor + }); + } + + render() { + const {handleChange, questionBoxIcon, questionBoxContent} = this.props; + const {loading, MarkdownEditor} = this.state; + + if (loading) { + return ; + } + + return ( +
+

Include an Icon

+ +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ + + + handleChange({}, {questionBoxContent: value})} + /> + +
+ ); + } +} + +export default QuestionBoxBuilder; diff --git a/client/coral-configure/containers/ConfigureStreamContainer.js b/client/coral-configure/containers/ConfigureStreamContainer.js index 1db8f497a..c009bbb76 100644 --- a/client/coral-configure/containers/ConfigureStreamContainer.js +++ b/client/coral-configure/containers/ConfigureStreamContainer.js @@ -2,7 +2,7 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import {compose} from 'react-apollo'; -import {updateOpenStatus, updateConfiguration} from 'coral-framework/actions/asset'; +import {updateOpenStatus, updateConfiguration} from 'coral-embed-stream/src/actions/asset'; import CloseCommentsInfo from '../components/CloseCommentsInfo'; import ConfigureCommentStream from '../components/ConfigureCommentStream'; @@ -27,10 +27,9 @@ class ConfigureStreamContainer extends Component { handleApply (e) { e.preventDefault(); const {elements} = e.target; + const {questionBoxIcon, questionBoxContent} = this.state.dirtySettings; const premod = elements.premod.checked; const questionBoxEnable = elements.qboxenable.checked; - const questionBoxContent = elements.qboxcontent.value; - const premodLinksEnable = elements.plinksenable.checked; const {changed} = this.state; @@ -38,6 +37,7 @@ class ConfigureStreamContainer extends Component { moderation: premod ? 'PRE' : 'POST', questionBoxEnable, questionBoxContent, + questionBoxIcon, premodLinksEnable }; @@ -51,14 +51,18 @@ class ConfigureStreamContainer extends Component { } } - handleChange (e) { - const changes = {}; + handleChange (e, newChanges) { + let changes = {}; + + if (changes) { + changes = {...newChanges}; + } if (e.target && e.target.id === 'qboxenable') { changes.questionBoxEnable = e.target.checked; } - if (e.target && e.target.id === 'qboxcontent') { - changes.questionBoxContent = e.target.value; + if (e.currentTarget && e.currentTarget.id === 'qboxicon') { + changes.questionBoxIcon = e.currentTarget.dataset.icon; } if (e.target && e.target.id === 'plinksenable') { changes.premodLinksEnable = e.target.value; @@ -105,12 +109,13 @@ class ConfigureStreamContainer extends Component { changed={this.state.changed} premodLinksEnable={dirtySettings.premodLinksEnable} premod={premod} + questionBoxIcon={dirtySettings.questionBoxIcon} questionBoxEnable={dirtySettings.questionBoxEnable} questionBoxContent={dirtySettings.questionBoxContent} />

{closedAt === 'open' ? t('configure.close') : t('configure.open')} {t('configure.comment_stream')}

- {(closedAt === 'open' && closedTimeout) ?

{t('configure.comment_stream_will_close')} {this.getClosedIn()}.

: ''} + {(closedAt === 'open' && closedTimeout) ?

{t('configure.comment_stream_will_close')} {this.getClosedIn()}.

: ''} ); -const AppRouter = () => ; +class AppRouter extends React.Component { + static contextTypes = { + history: PropTypes.object, + }; + + render() { + return ; + } +} export default AppRouter; diff --git a/client/coral-framework/actions/asset.js b/client/coral-embed-stream/src/actions/asset.js similarity index 79% rename from client/coral-framework/actions/asset.js rename to client/coral-embed-stream/src/actions/asset.js index aab3caf3a..1fc9f09f7 100644 --- a/client/coral-framework/actions/asset.js +++ b/client/coral-embed-stream/src/actions/asset.js @@ -1,6 +1,5 @@ import * as actions from '../constants/asset'; -import coralApi from '../helpers/request'; -import {addNotification} from '../actions/notification'; +import {notify} from 'coral-framework/actions/notification'; import t from 'coral-framework/services/i18n'; @@ -12,12 +11,12 @@ const updateAssetSettingsRequest = () => ({type: actions.UPDATE_ASSET_SETTINGS_R const updateAssetSettingsSuccess = (settings) => ({type: actions.UPDATE_ASSET_SETTINGS_SUCCESS, settings}); const updateAssetSettingsFailure = (error) => ({type: actions.UPDATE_ASSET_SETTINGS_FAILURE, error}); -export const updateConfiguration = (newConfig) => (dispatch, getState) => { +export const updateConfiguration = (newConfig) => (dispatch, getState, {rest}) => { const assetId = getState().asset.id; dispatch(updateAssetSettingsRequest()); - coralApi(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig}) + rest(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig}) .then(() => { - dispatch(addNotification('success', t('framework.success_update_settings'))); + dispatch(notify('success', t('framework.success_update_settings'))); dispatch(updateAssetSettingsSuccess(newConfig)); }) .catch((error) => { @@ -26,12 +25,12 @@ export const updateConfiguration = (newConfig) => (dispatch, getState) => { }); }; -export const updateOpenStream = (closedBody) => (dispatch, getState) => { +export const updateOpenStream = (closedBody) => (dispatch, getState, {rest}) => { const assetId = getState().asset.id; dispatch(fetchAssetRequest()); - coralApi(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody}) + rest(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody}) .then(() => { - dispatch(addNotification('success', t('framework.success_update_settings'))); + dispatch(notify('success', t('framework.success_update_settings'))); dispatch(fetchAssetSuccess(closedBody)); }) .catch((error) => { diff --git a/client/coral-framework/actions/auth.js b/client/coral-embed-stream/src/actions/auth.js similarity index 85% rename from client/coral-framework/actions/auth.js rename to client/coral-embed-stream/src/actions/auth.js index 7c6d3bf89..b96d6f459 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-embed-stream/src/actions/auth.js @@ -1,12 +1,8 @@ import jwtDecode from 'jwt-decode'; import bowser from 'bowser'; import * as actions from '../constants/auth'; -import * as Storage from '../helpers/storage'; -import coralApi, {base} from '../helpers/request'; -import pym from '../services/pym'; -import {addNotification} from '../actions/notification'; +import {notify} from 'coral-framework/actions/notification'; -import {resetWebsocket} from 'coral-framework/services/client'; import t from 'coral-framework/services/i18n'; export const showSignInDialog = () => ({ @@ -59,9 +55,9 @@ export const updateUsername = ({username}) => ({ username }); -export const createUsername = (userId, formData) => (dispatch) => { +export const createUsername = (userId, formData) => (dispatch, _, {rest}) => { dispatch(createUsernameRequest()); - coralApi('/account/username', {method: 'PUT', body: formData}) + rest('/account/username', {method: 'PUT', body: formData}) .then(() => { dispatch(createUsernameSuccess()); dispatch(hideCreateUsernameDialog()); @@ -111,9 +107,11 @@ const signInFailure = (error) => ({ // AUTH TOKEN //============================================================================== -export const handleAuthToken = (token) => (dispatch) => { - Storage.setItem('exp', jwtDecode(token).exp); - Storage.setItem('token', token); +export const handleAuthToken = (token) => (dispatch, _, {storage}) => { + if (storage) { + storage.setItem('exp', jwtDecode(token).exp); + storage.setItem('token', token); + } dispatch({type: 'HANDLE_AUTH_TOKEN'}); }; @@ -123,10 +121,10 @@ export const handleAuthToken = (token) => (dispatch) => { //============================================================================== export const fetchSignIn = (formData) => { - return (dispatch) => { + return (dispatch, _, {rest}) => { dispatch(signInRequest()); - return coralApi('/auth/local', {method: 'POST', body: formData}) + return rest('/auth/local', {method: 'POST', body: formData}) .then(({token}) => { if (!bowser.safari && !bowser.ios) { dispatch(handleAuthToken(token)); @@ -171,10 +169,10 @@ const signInFacebookFailure = (error) => ({ error }); -export const fetchSignInFacebook = () => (dispatch) => { +export const fetchSignInFacebook = () => (dispatch, _, {rest}) => { dispatch(signInFacebookRequest()); window.open( - `${base}/auth/facebook`, + `${rest.uri}/auth/facebook`, 'Continue with Facebook', 'menubar=0,resizable=0,width=500,height=500,top=200,left=500' ); @@ -188,10 +186,10 @@ const signUpFacebookRequest = () => ({ type: actions.FETCH_SIGNUP_FACEBOOK_REQUEST }); -export const fetchSignUpFacebook = () => (dispatch) => { +export const fetchSignUpFacebook = () => (dispatch, _, {rest}) => { dispatch(signUpFacebookRequest()); window.open( - `${base}/auth/facebook`, + `${rest.uri}/auth/facebook`, 'Continue with Facebook', 'menubar=0,resizable=0,width=500,height=500,top=200,left=500' ); @@ -220,11 +218,11 @@ 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}); -export const fetchSignUp = (formData) => (dispatch, getState) => { +export const fetchSignUp = (formData) => (dispatch, getState, {rest}) => { const redirectUri = getState().auth.redirectUri; dispatch(signUpRequest()); - coralApi('/users', { + rest('/users', { method: 'POST', body: formData, headers: {'X-Pym-Url': redirectUri} @@ -256,10 +254,10 @@ const forgotPasswordFailure = (error) => ({ error, }); -export const fetchForgotPassword = (email) => (dispatch, getState) => { +export const fetchForgotPassword = (email) => (dispatch, getState, {rest}) => { dispatch(forgotPasswordRequest(email)); const redirectUri = getState().auth.redirectUri; - coralApi('/account/password/reset', { + rest('/account/password/reset', { method: 'POST', body: {email, loc: redirectUri} }) @@ -275,16 +273,18 @@ export const fetchForgotPassword = (email) => (dispatch, getState) => { // LOGOUT //============================================================================== -export const logout = () => (dispatch) => { - return coralApi('/auth', {method: 'DELETE'}).then(() => { - Storage.removeItem('token'); +export const logout = () => async (dispatch, _, {rest, client, pym, storage}) => { + await rest('/auth', {method: 'DELETE'}); - // Reset the websocket. - resetWebsocket(); + if (storage) { + storage.removeItem('token'); + } - dispatch({type: actions.LOGOUT}); - pym.sendMessage('coral-auth-changed'); - }); + // Reset the websocket. + client.resetWebsocket(); + + dispatch({type: actions.LOGOUT}); + pym.sendMessage('coral-auth-changed'); }; //============================================================================== @@ -300,17 +300,19 @@ const checkLoginSuccess = (user, isAdmin) => ({ isAdmin }); -export const checkLogin = () => (dispatch) => { +export const checkLogin = () => (dispatch, _, {rest, client, pym, storage}) => { dispatch(checkLoginRequest()); - coralApi('/auth') + rest('/auth') .then((result) => { if (!result.user) { - Storage.removeItem('token'); + if (storage) { + storage.removeItem('token'); + } throw new Error('Not logged in'); } // Reset the websocket. - resetWebsocket(); + client.resetWebsocket(); dispatch(checkLoginSuccess(result.user)); pym.sendMessage('coral-auth-changed', JSON.stringify(result.user)); @@ -322,10 +324,10 @@ export const checkLogin = () => (dispatch) => { }) .catch((error) => { console.error(error); - if (error.status && error.status === 401) { + if (error.status && error.status === 401 && storage) { // Unauthorized. - Storage.removeItem('token'); + storage.removeItem('token'); } const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); dispatch(checkLoginFailure(errorMessage)); @@ -351,10 +353,10 @@ const verifyEmailFailure = () => ({ type: actions.VERIFY_EMAIL_FAILURE }); -export const requestConfirmEmail = (email) => (dispatch, getState) => { +export const requestConfirmEmail = (email) => (dispatch, getState, {rest}) => { const redirectUri = getState().auth.redirectUri; dispatch(verifyEmailRequest()); - return coralApi('/users/resend-verify', { + return rest('/users/resend-verify', { method: 'POST', body: {email}, headers: {'X-Pym-Url': redirectUri} @@ -387,11 +389,11 @@ export const setRedirectUri = (uri) => ({ const editUsernameFailure = (error) => ({type: actions.EDIT_USERNAME_FAILURE, error}); const editUsernameSuccess = () => ({type: actions.EDIT_USERNAME_SUCCESS}); -export const editName = (username) => (dispatch) => { - return coralApi('/account/username', {method: 'PUT', body: {username}}) +export const editName = (username) => (dispatch, _, {rest}) => { + return rest('/account/username', {method: 'PUT', body: {username}}) .then(() => { dispatch(editUsernameSuccess()); - dispatch(addNotification('success', t('framework.success_name_update'))); + dispatch(notify('success', t('framework.success_name_update'))); }) .catch((error) => { console.error(error); diff --git a/client/coral-embed-stream/src/actions/stream.js b/client/coral-embed-stream/src/actions/stream.js index c20760259..8326d8753 100644 --- a/client/coral-embed-stream/src/actions/stream.js +++ b/client/coral-embed-stream/src/actions/stream.js @@ -1,11 +1,12 @@ -import pym from 'coral-framework/services/pym'; import * as actions from '../constants/stream'; import {buildUrl} from 'coral-framework/utils/url'; import queryString from 'query-string'; export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id}); -export const viewAllComments = () => { +export const setSort = ({sortOrder, sortBy}) => ({type: actions.SET_SORT, sortOrder, sortBy}); + +export const viewAllComments = () => (dispatch, _, {pym}) => { const search = queryString.stringify({ ...queryString.parse(location.search), @@ -24,10 +25,10 @@ export const viewAllComments = () => { pym.sendMessage('coral-view-all-comments'); } catch (e) { /* not sure if we're worried about old browsers */ } - return {type: actions.VIEW_ALL_COMMENTS}; + dispatch({type: actions.VIEW_ALL_COMMENTS}); }; -export const viewComment = (id) => { +export const viewComment = (id) => (dispatch, _, {pym}) => { const search = queryString.stringify({ ...queryString.parse(location.search), @@ -46,7 +47,7 @@ export const viewComment = (id) => { pym.sendMessage('coral-view-comment', id); } catch (e) { /* not sure if we're worried about old browsers */ } - return {type: actions.VIEW_COMMENT, id}; + dispatch({type: actions.VIEW_COMMENT, id}); }; export const addCommentClassName = (className) => ({ diff --git a/client/coral-embed-stream/src/components/AllCommentsPane.js b/client/coral-embed-stream/src/components/AllCommentsPane.js index c141f0618..427d3a68b 100644 --- a/client/coral-embed-stream/src/components/AllCommentsPane.js +++ b/client/coral-embed-stream/src/components/AllCommentsPane.js @@ -63,7 +63,7 @@ class AllCommentsPane extends React.Component { } if ( - prevComments && nextComments && + prevComments && nextComments && nextComments.nodes.length < prevComments.nodes.length ) { @@ -87,7 +87,7 @@ class AllCommentsPane extends React.Component { }) .catch((error) => { this.setState({loadingState: 'error'}); - forEachError(error, ({msg}) => {this.props.addNotification('error', msg);}); + forEachError(error, ({msg}) => {this.props.notify('error', msg);}); }); } @@ -129,7 +129,7 @@ class AllCommentsPane extends React.Component { ignoreUser, setActiveReplyBox, activeReplyBox, - addNotification, + notify, disableReply, postComment, asset, @@ -160,31 +160,31 @@ class AllCommentsPane extends React.Component { return commentIsIgnored(comment) ? : ; + commentClassNames={commentClassNames} + data={data} + root={root} + disableReply={disableReply} + setActiveReplyBox={setActiveReplyBox} + activeReplyBox={activeReplyBox} + notify={notify} + depth={0} + postComment={postComment} + asset={asset} + currentUser={currentUser} + postFlag={postFlag} + postDontAgree={postDontAgree} + ignoreUser={ignoreUser} + commentIsIgnored={commentIsIgnored} + loadMore={loadNewReplies} + deleteAction={deleteAction} + showSignInDialog={showSignInDialog} + key={comment.id} + comment={comment} + charCountEnable={charCountEnable} + maxCharCount={maxCharCount} + editComment={editComment} + emit={emit} + />; })} !tags.every((t) => t.tag.name !== 'STAFF'); const hasTag = (tags, lookupTag) => !!tags.filter((t) => t.tag.name === lookupTag).length; @@ -108,7 +108,7 @@ export default class Comment extends React.Component { const {comment: {replies: prevReplies}} = this.props; const {comment: {replies: nextReplies}} = next; if ( - prevReplies && nextReplies && + prevReplies && nextReplies && nextReplies.nodes.length < prevReplies.nodes.length ) { @@ -152,7 +152,7 @@ export default class Comment extends React.Component { deleteAction: PropTypes.func.isRequired, parentId: PropTypes.string, highlighted: PropTypes.string, - addNotification: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, postComment: PropTypes.func.isRequired, depth: PropTypes.number.isRequired, liveUpdates: PropTypes.bool, @@ -205,7 +205,7 @@ export default class Comment extends React.Component { onClickEdit (e) { e.preventDefault(); if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) { - this.props.addNotification('error', t('error.NOT_AUTHORIZED')); + this.props.notify('error', t('error.NOT_AUTHORIZED')); return; } this.setState({isEditing: true}); @@ -235,7 +235,7 @@ export default class Comment extends React.Component { }) .catch((error) => { this.setState({loadingState: 'error'}); - forEachError(error, ({msg}) => {this.props.addNotification('error', msg);}); + forEachError(error, ({msg}) => {this.props.notify('error', msg);}); }); emit('ui.Comment.showMoreReplies', {id}); return; @@ -252,7 +252,7 @@ export default class Comment extends React.Component { if (can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) { this.props.setActiveReplyBox(this.props.comment.id); } else { - this.props.addNotification('error', t('error.NOT_AUTHORIZED')); + this.props.notify('error', t('error.NOT_AUTHORIZED')); } return; } @@ -331,7 +331,7 @@ export default class Comment extends React.Component { deleteAction, disableReply, maxCharCount, - addNotification, + notify, charCountEnable, showSignInDialog, liveUpdates, @@ -343,7 +343,7 @@ export default class Comment extends React.Component { const view = this.getVisibileReplies(); - // Inactive comments can be viewed by moderators and admins (e.g. using permalinks). + // Inactive comments can be viewed by moderators and admins (e.g. using permalinks). const isActive = isCommentActive(comment.status); const {loadingState} = this.state; @@ -434,18 +434,33 @@ export default class Comment extends React.Component { inline /> -
+
+
+ + -
- {isStaff(comment.tags) ? Staff : null} + + { (comment.editing && comment.editing.edited) - ?  ({t('comment.edited')}) - : null + ?  ({t('comment.edited')}) + : null } @@ -475,7 +490,7 @@ export default class Comment extends React.Component { + notify={notify} /> } { !isActive && @@ -485,23 +500,23 @@ export default class Comment extends React.Component {
{ this.state.isEditing - ? - :
- + :
+
}
@@ -539,7 +554,7 @@ export default class Comment extends React.Component { id={comment.id} author_id={comment.user.id} postFlag={postFlag} - addNotification={addNotification} + notify={notify} postDontAgree={postDontAgree} deleteAction={deleteAction} showSignInDialog={showSignInDialog} @@ -555,29 +570,29 @@ export default class Comment extends React.Component { {activeReplyBox === comment.id ? + commentPostedHandler={this.commentPostedHandler} + charCountEnable={charCountEnable} + maxCharCount={maxCharCount} + setActiveReplyBox={setActiveReplyBox} + parentId={(depth < THREADING_LEVEL) ? comment.id : parentId} + notify={notify} + postComment={postComment} + currentUser={currentUser} + assetId={asset.id} + /> : null} - {view.map((reply) => { - return commentIsIgnored(reply) - ? - : { + return commentIsIgnored(reply) + ? + : ; - })} + })}
{ if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) { - this.props.addNotification('error', t('error.NOT_AUTHORIZED')); + this.props.notify('error', t('error.NOT_AUTHORIZED')); return; } this.setState({loadingState: 'loading'}); - const {editComment, addNotification, stopEditing} = this.props; + const {editComment, notify, stopEditing} = this.props; if (typeof editComment !== 'function') {return;} let response; try { response = await editComment({body: this.state.body}); this.setState({loadingState: 'success'}); const status = response.data.editComment.comment.status; - notifyForNewCommentStatus(this.props.addNotification, status); + notifyForNewCommentStatus(this.props.notify, status); if (typeof stopEditing === 'function') { stopEditing(); } } catch (error) { this.setState({loadingState: 'error'}); - forEachError(error, ({msg}) => addNotification('error', msg)); + forEachError(error, ({msg}) => notify('error', msg)); } } @@ -136,15 +136,15 @@ export class EditableCommentContent extends React.Component { { this.isEditWindowExpired() - ? + ? {t('edit_comment.edit_window_expired')} { typeof this.props.stopEditing === 'function' - ?  {t('edit_comment.edit_window_expired_close')} - : null + ?  {t('edit_comment.edit_window_expired_close')} + : null } - : + : {t('edit_comment.edit_window_timer_prefix')} { return
{ count ? - - : null + + : null }
; }; diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index b37d232f7..4d46c8e87 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -49,39 +49,30 @@ class Stream extends React.Component { ); }; - render() { + renderHighlightedComment() { const { data, root, activeReplyBox, setActiveReplyBox, - appendItemArray, commentClassNames, - root: {asset, asset: {comment, comments, totalCommentCount}}, + root: {asset, asset: {comment}}, postComment, - addNotification, + notify, editComment, postFlag, postDontAgree, deleteAction, showSignInDialog, - updateItem, ignoreUser, - activeStreamTab, - setActiveStreamTab, loadNewReplies, - loadMoreComments, - viewAllComments, - auth: {loggedIn, user}, - editName, + auth: {user}, emit, } = this.props; - const {keepCommentBox} = this.state; - const open = !asset.isClosed; // even though the permalinked comment is the highlighted one, we're displaying its parent + replies - let highlightedComment = comment && getTopLevelParent(comment); - if (highlightedComment) { + let topLevelComment = getTopLevelParent(comment); + if (topLevelComment) { // Inactive comments can be viewed by moderators and admins (e.g. using permalinks). const isInactive = !isCommentActive(comment.status); @@ -89,10 +80,153 @@ class Stream extends React.Component { // the highlighted comment is not active and as such not in the replies, so we // attach it to the right parent. - highlightedComment = attachCommentToParent(highlightedComment, comment); + topLevelComment = attachCommentToParent(topLevelComment, comment); } } + return ( +
+ +
+ ); + } + + renderTabPanel() { + const { + data, + root, + activeReplyBox, + setActiveReplyBox, + commentClassNames, + root: {asset, asset: {comments, totalCommentCount}}, + postComment, + notify, + editComment, + postFlag, + postDontAgree, + deleteAction, + showSignInDialog, + ignoreUser, + activeStreamTab, + setActiveStreamTab, + loadNewReplies, + loadMoreComments, + auth: {user}, + emit, + sortOrder, + sortBy, + loading, + } = this.props; + + const slotProps = {data}; + const slotQueryData = {root, asset}; + + // `key` of `StreamTabPanel` depends on sorting so that we always reset + // the state when changing sorting. + return ( +
+
+ +
+ + All Comments {totalCommentCount} + + } + appendTabPanes={ + + + + } + sub + /> +
+ ); + } + + render() { + const { + data, + root, + appendItemArray, + root: {asset, asset: {comment: highlightedComment, comments}}, + postComment, + notify, + updateItem, + viewAllComments, + auth: {loggedIn, user}, + editName, + } = this.props; + const {keepCommentBox} = this.state; + const open = !asset.isClosed; + const banned = user && user.status === 'BANNED'; const temporarilySuspended = user && @@ -103,7 +237,7 @@ class Stream extends React.Component { const slotProps = {data}; const slotQueryData = {root, asset}; - if (!comment && !comments) { + if (!highlightedComment && !comments) { console.error('Talk: No comments came back from the graph given that query. Please, check the query params.'); return ; } @@ -111,7 +245,7 @@ class Stream extends React.Component { return (
- {comment && + {highlightedComment &&
+
:

{asset.settings.closedMessage}

} )} - {/* the highlightedComment is isolated after the user followed a permalink */} {highlightedComment - ? ( -
- -
- ) - :
-
- -
- - All Comments {totalCommentCount} - - } - appendTabPanes={ - - - - } - sub - /> -
- } + ? this.renderHighlightedComment() + : this.renderTabPanel() + }
); } } Stream.propTypes = { - addNotification: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, postComment: PropTypes.func.isRequired, // dispatch action to ignore another user diff --git a/client/coral-embed-stream/src/components/StreamTabPanel.css b/client/coral-embed-stream/src/components/StreamTabPanel.css new file mode 100644 index 000000000..9398d373c --- /dev/null +++ b/client/coral-embed-stream/src/components/StreamTabPanel.css @@ -0,0 +1,3 @@ +.spinnerContainer { + margin-top: 16px; +} diff --git a/client/coral-embed-stream/src/components/StreamTabPanel.js b/client/coral-embed-stream/src/components/StreamTabPanel.js index 0f2092092..a511ac555 100644 --- a/client/coral-embed-stream/src/components/StreamTabPanel.js +++ b/client/coral-embed-stream/src/components/StreamTabPanel.js @@ -1,19 +1,23 @@ import React from 'react'; -import {TabBar, TabContent} from 'coral-ui'; +import {Spinner, TabBar, TabContent} from 'coral-ui'; import PropTypes from 'prop-types'; +import styles from './StreamTabPanel.css'; class StreamTabPanel extends React.Component { render() { - const {activeTab, setActiveTab, tabs, tabPanes, sub} = this.props; + const {activeTab, setActiveTab, tabs, tabPanes, sub, loading} = this.props; return (
{tabs} - - {tabPanes} - + {loading + ?
+ : + {tabPanes} + + }
); } diff --git a/client/coral-embed-stream/src/components/SuspendedAccount.js b/client/coral-embed-stream/src/components/SuspendedAccount.js index 1a41bac91..df2b6162c 100644 --- a/client/coral-embed-stream/src/components/SuspendedAccount.js +++ b/client/coral-embed-stream/src/components/SuspendedAccount.js @@ -44,40 +44,40 @@ class SuspendedAccount extends Component { return { - canEditName ? + canEditName ? t('framework.edit_name.msg') : {t('framework.banned_account_header')}
{t('framework.banned_account_body')}
- }
+ } { canEditName ? -
-
- {alert} -
- - this.setState({username: e.target.value})} - rows={3}/>
- -
: null +
+
+ {alert} +
+ + this.setState({username: e.target.value})} + rows={3}/>
+ +
: null }
; } diff --git a/client/coral-embed-stream/src/components/TopRightMenu.js b/client/coral-embed-stream/src/components/TopRightMenu.js index 64acb2290..47147e853 100644 --- a/client/coral-embed-stream/src/components/TopRightMenu.js +++ b/client/coral-embed-stream/src/components/TopRightMenu.js @@ -18,7 +18,7 @@ export class TopRightMenu extends React.Component { ignoreUser: PropTypes.func, // show notification to the user (e.g. for errors) - addNotification: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, } constructor(props) { super(props); @@ -27,7 +27,7 @@ export class TopRightMenu extends React.Component { }; } render() { - const {comment, ignoreUser, addNotification} = this.props; + const {comment, ignoreUser, notify} = this.props; // timesReset is used as Toggleable key so it re-renders on reset (closing the toggleable) const reset = () => this.setState({timesReset: this.state.timesReset + 1}); @@ -40,7 +40,7 @@ export class TopRightMenu extends React.Component { try { await ignoreUser({id}); } catch (error) { - addNotification('error', 'Failed to ignore user'); + notify('error', 'Failed to ignore user'); throw error; } }; @@ -51,7 +51,7 @@ export class TopRightMenu extends React.Component { user={comment.user} cancel={reset} ignoreUser={ignoreUserAndCloseMenuAndNotifyOnError} - /> + />
); diff --git a/client/coral-framework/constants/asset.js b/client/coral-embed-stream/src/constants/asset.js similarity index 100% rename from client/coral-framework/constants/asset.js rename to client/coral-embed-stream/src/constants/asset.js diff --git a/client/coral-framework/constants/auth.js b/client/coral-embed-stream/src/constants/auth.js similarity index 100% rename from client/coral-framework/constants/auth.js rename to client/coral-embed-stream/src/constants/auth.js diff --git a/client/coral-embed-stream/src/constants/stream.js b/client/coral-embed-stream/src/constants/stream.js index 7ac9427cf..b4deb24e1 100644 --- a/client/coral-embed-stream/src/constants/stream.js +++ b/client/coral-embed-stream/src/constants/stream.js @@ -6,3 +6,4 @@ export const ADD_COMMENT_CLASSNAME = 'ADD_COMMENT_CLASSNAME'; export const REMOVE_COMMENT_CLASSNAME = 'REMOVE_COMMENT_CLASSNAME'; export const THREADING_LEVEL = process.env.TALK_THREADING_LEVEL; export const SET_ACTIVE_TAB = 'CORAL_STREAM_SET_ACTIVE_TAB'; +export const SET_SORT = 'CORAL_STREAM_SET_SORT'; diff --git a/client/coral-embed-stream/src/containers/Comment.js b/client/coral-embed-stream/src/containers/Comment.js index 005a24ae1..a4bfbc0eb 100644 --- a/client/coral-embed-stream/src/containers/Comment.js +++ b/client/coral-embed-stream/src/containers/Comment.js @@ -15,7 +15,9 @@ const slots = [ 'commentActions', 'commentContent', 'commentReactions', - 'commentAvatar' + 'commentAvatar', + 'commentAuthorName', + 'commentAuthorTags' ]; /** @@ -96,6 +98,7 @@ const withCommentFragments = withFragments({ asset: gql` fragment CoralEmbedStream_Comment_asset on Asset { __typename + id ${getSlotFragmentSpreads(slots, 'asset')} } `, @@ -103,7 +106,7 @@ const withCommentFragments = withFragments({ fragment CoralEmbedStream_Comment_comment on Comment { ...CoralEmbedStream_Comment_SingleComment ${nest(` - replies(limit: 3, excludeIgnored: $excludeIgnored) { + replies(query: {limit: 3, excludeIgnored: $excludeIgnored}) { nodes { ...CoralEmbedStream_Comment_SingleComment ...nest diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index 731cab264..cd374291d 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -8,22 +8,25 @@ import branch from 'recompose/branch'; import renderComponent from 'recompose/renderComponent'; import {Spinner} from 'coral-ui'; -import * as authActions from 'coral-framework/actions/auth'; -import * as assetActions from 'coral-framework/actions/asset'; -import pym from 'coral-framework/services/pym'; +import * as authActions from '../actions/auth'; +import * as assetActions from '../actions/asset'; import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils'; import {withQuery} from 'coral-framework/hocs'; import Embed from '../components/Embed'; import Stream from './Stream'; -import {addNotification} from 'coral-framework/actions/notification'; +import {notify} from 'coral-framework/actions/notification'; import t from 'coral-framework/services/i18n'; - +import PropTypes from 'prop-types'; import {setActiveTab} from '../actions/embed'; const {logout, checkLogin, focusSignInDialog, blurSignInDialog, hideSignInDialog} = authActions; const {fetchAssetSuccess} = assetActions; class EmbedContainer extends React.Component { + static contextTypes = { + pym: PropTypes.object, + }; + subscriptions = []; subscribeToUpdates(props = this.props) { @@ -31,19 +34,19 @@ class EmbedContainer extends React.Component { const newSubscriptions = [{ document: USER_BANNED_SUBSCRIPTION, updateQuery: () => { - addNotification('info', t('your_account_has_been_banned')); + notify('info', t('your_account_has_been_banned')); }, }, { document: USER_SUSPENDED_SUBSCRIPTION, updateQuery: () => { - addNotification('info', t('your_account_has_been_suspended')); + notify('info', t('your_account_has_been_suspended')); }, }, { document: USERNAME_REJECTED_SUBSCRIPTION, updateQuery: () => { - addNotification('info', t('your_username_has_been_rejected')); + notify('info', t('your_username_has_been_rejected')); }, }]; @@ -95,7 +98,7 @@ class EmbedContainer extends React.Component { if (!get(prevProps, 'root.asset.comment') && get(this.props, 'root.asset.comment')) { // Scroll to a permalinked comment if one is in the URL once the page is done rendering. - setTimeout(() => pym.scrollParentToChildEl('talk-embed-stream-container'), 0); + setTimeout(() => this.context.pym.scrollParentToChildEl('talk-embed-stream-container'), 0); } } @@ -151,7 +154,15 @@ const slots = [ ]; const EMBED_QUERY = gql` - query CoralEmbedStream_Embed($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) { + query CoralEmbedStream_Embed( + $assetId: ID, + $assetUrl: String, + $commentId: ID!, + $hasComment: Boolean!, + $excludeIgnored: Boolean, + $sortBy: SORT_COMMENTS_BY!, + $sortOrder: SORT_ORDER!, + ) { me { id status @@ -163,13 +174,15 @@ const EMBED_QUERY = gql` `; export const withEmbedQuery = withQuery(EMBED_QUERY, { - options: ({auth, commentId, assetId, assetUrl}) => ({ + options: ({auth, commentId, assetId, assetUrl, sortBy, sortOrder}) => ({ variables: { assetId, assetUrl, commentId, hasComment: commentId !== '', excludeIgnored: Boolean(auth && auth.user && auth.user.id), + sortBy, + sortOrder, }, }), }); @@ -180,7 +193,9 @@ const mapStateToProps = (state) => ({ assetId: state.stream.assetId, assetUrl: state.stream.assetUrl, activeTab: state.embed.activeTab, - config: state.config + config: state.config, + sortOrder: state.stream.sortOrder, + sortBy: state.stream.sortBy, }); const mapDispatchToProps = (dispatch) => @@ -190,7 +205,7 @@ const mapDispatchToProps = (dispatch) => checkLogin, setActiveTab, fetchAssetSuccess, - addNotification, + notify, focusSignInDialog, blurSignInDialog, hideSignInDialog, diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index ef5838453..194bfc5d9 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -8,7 +8,7 @@ import { withDeleteAction, withIgnoreUser, withEditComment } from 'coral-framework/graphql/mutations'; -import * as authActions from 'coral-framework/actions/auth'; +import * as authActions from 'coral-embed-stream/src/actions/auth'; import * as notificationActions from 'coral-framework/actions/notification'; import {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/stream'; import Stream from '../components/Stream'; @@ -26,14 +26,18 @@ import { } from '../graphql/utils'; const {showSignInDialog, editName} = authActions; -const {addNotification} = notificationActions; +const {notify} = notificationActions; class StreamContainer extends React.Component { - subscriptions = []; + commentsAddedSubscription = null; + commentsEditedSubscription = null; - subscribeToUpdates() { - const newSubscriptions = [{ + subscribeToCommentsEdited() { + this.commentsEditedSubscription = this.props.data.subscribeToMore({ document: COMMENTS_EDITED_SUBSCRIPTION, + variables: { + assetId: this.props.root.asset.id, + }, updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => { // Ignore mutations from me. @@ -51,9 +55,15 @@ class StreamContainer extends React.Component { return removeCommentFromEmbedQuery(prev, commentEdited.id); } }, - }, - { + }); + } + + subscribeToCommentsAdded() { + this.commentsAddedSubscription = this.props.data.subscribeToMore({ document: COMMENTS_ADDED_SUBSCRIPTION, + variables: { + assetId: this.props.root.asset.id, + }, updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => { // Ignore mutations from me. @@ -74,22 +84,27 @@ class StreamContainer extends React.Component { return prev; } + // Newest top-level comments are only added when sorting by 'newest first'. + if (!commentAdded.parent && !this.isSortedByNewestFirst()) { + return prev; + } return insertCommentIntoEmbedQuery(prev, commentAdded); - } - }]; - - this.subscriptions = newSubscriptions.map((s) => this.props.data.subscribeToMore({ - document: s.document, - variables: { - assetId: this.props.root.asset.id, }, - updateQuery: s.updateQuery, - })); + }); } - unsubscribe() { - this.subscriptions.forEach((unsubscribe) => unsubscribe()); - this.subscriptions = []; + unsubscribeCommentsAdded() { + if (this.commentsAddedSubscription) { + this.commentsAddedSubscription(); + this.commentsAddedSubscription = null; + } + } + + unsubscribeCommentsEdited() { + if (this.commentsEditedSubscription) { + this.commentsEditedSubscription(); + this.commentsEditedSubscription = null; + } } loadNewReplies = (parent_id) => { @@ -102,7 +117,7 @@ class StreamContainer extends React.Component { cursor: comment.replies.endCursor, parent_id, asset_id: this.props.root.asset.id, - sort: 'CHRONOLOGICAL', + sortOrder: 'ASC', excludeIgnored: this.props.data.variables.excludeIgnored, }, updateQuery: (prev, {fetchMoreResult:{comments}}) => { @@ -119,7 +134,8 @@ class StreamContainer extends React.Component { cursor: this.props.root.asset.comments.endCursor, parent_id: null, asset_id: this.props.root.asset.id, - sort: 'REVERSE_CHRONOLOGICAL', + sortOrder: this.props.data.variables.sortOrder, + sortBy: this.props.data.variables.sortBy, excludeIgnored: this.props.data.variables.excludeIgnored, }, updateQuery: (prev, {fetchMoreResult:{comments}}) => { @@ -128,36 +144,55 @@ class StreamContainer extends React.Component { }); }; + isSortedByNewestFirst({sortBy, sortOrder} = this.props) { + return sortBy === 'CREATED_AT' && sortOrder === 'DESC'; + } + componentDidMount() { if (this.props.previousTab) { this.props.data.refetch(); } - this.subscribeToUpdates(); + + if (this.isSortedByNewestFirst()) { + this.subscribeToCommentsAdded(); + } + + this.subscribeToCommentsEdited(); } componentWillUnmount() { - this.unsubscribe(); + this.unsubscribeCommentsAdded(); + this.unsubscribeCommentsEdited(); clearInterval(this.countPoll); } + componentWillReceiveProps(nextProps) { + if (this.props.sortOrder !== nextProps.sortOrder || this.props.sortBy !== nextProps.sortBy) { + nextProps.data.refetch(); + } + } + userIsDegraged({auth: {user}} = this.props) { return !can(user, 'INTERACT_WITH_COMMUNITY'); } render() { - if (this.props.refetching - || !this.props.root.asset + if (!this.props.root.asset || !this.props.root.asset.comment && !this.props.root.asset.comments ) { return ; } + + const streamLoading = this.props.refetching || this.props.data.loading; + return ; } } @@ -203,8 +238,26 @@ const COMMENTS_EDITED_SUBSCRIPTION = gql` `; const LOAD_MORE_QUERY = gql` - query CoralEmbedStream_LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER, $excludeIgnored: Boolean) { - comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) { + query CoralEmbedStream_LoadMoreComments( + $limit: Int = 5 + $cursor: Cursor + $parent_id: ID + $asset_id: ID + $sortOrder: SORT_ORDER + $sortBy: SORT_COMMENTS_BY = CREATED_AT + $excludeIgnored: Boolean + ) { + comments( + query: { + limit: $limit + cursor: $cursor + parent_id: $parent_id + asset_id: $asset_id + sortOrder: $sortOrder + sortBy: $sortBy + excludeIgnored: $excludeIgnored + } + ) { nodes { ...CoralEmbedStream_Stream_comment } @@ -219,6 +272,7 @@ const LOAD_MORE_QUERY = gql` const slots = [ 'streamTabs', 'streamTabPanes', + 'streamFilter', ]; const fragments = { @@ -247,15 +301,16 @@ const fragments = { premodLinksEnable questionBoxEnable questionBoxContent + questionBoxIcon closeTimeout closedMessage charCountEnable charCount requireEmailConfirmation } - commentCount(excludeIgnored: $excludeIgnored) @skip(if: $hasComment) - totalCommentCount(excludeIgnored: $excludeIgnored) @skip(if: $hasComment) - comments(limit: 10, excludeIgnored: $excludeIgnored) @skip(if: $hasComment) { + commentCount @skip(if: $hasComment) + totalCommentCount @skip(if: $hasComment) + comments(query: {limit: 10, excludeIgnored: $excludeIgnored, sortOrder: $sortOrder, sortBy: $sortBy}) @skip(if: $hasComment) { nodes { ...CoralEmbedStream_Stream_comment } @@ -298,12 +353,14 @@ const mapStateToProps = (state) => ({ previousStreamTab: state.stream.previousTab, commentClassNames: state.stream.commentClassNames, pluginConfig: state.config.plugin_config, + sortOrder: state.stream.sortOrder, + sortBy: state.stream.sortBy, }); const mapDispatchToProps = (dispatch) => bindActionCreators({ showSignInDialog, - addNotification, + notify, setActiveReplyBox, editName, viewAllComments, diff --git a/client/coral-embed-stream/src/containers/StreamTabPanel.js b/client/coral-embed-stream/src/containers/StreamTabPanel.js index 7b91d58ee..fde6e3be4 100644 --- a/client/coral-embed-stream/src/containers/StreamTabPanel.js +++ b/client/coral-embed-stream/src/containers/StreamTabPanel.js @@ -2,13 +2,15 @@ import React from 'react'; import StreamTabPanel from '../components/StreamTabPanel'; import {connect} from 'react-redux'; import omit from 'lodash/omit'; -import {getSlotComponents, getSlotComponentProps} from 'coral-framework/helpers/plugins'; import {Tab, TabPane} from 'coral-ui'; import {getShallowChanges} from 'coral-framework/utils'; import isEqual from 'lodash/isEqual'; import PropTypes from 'prop-types'; class StreamTabPanelContainer extends React.Component { + static contextTypes = { + plugins: PropTypes.object, + }; componentDidMount() { this.fallbackAllTab(); @@ -43,28 +45,37 @@ class StreamTabPanelContainer extends React.Component { } getSlotComponents(slot, props = this.props) { - return getSlotComponents(slot, props.reduxState, props.slotProps, props.queryData); + const {plugins} = this.context; + return plugins.getSlotComponents(slot, props.reduxState, props.slotProps, props.queryData); } getPluginTabElements(props = this.props) { - return this.getSlotComponents(props.tabSlot).map((PluginComponent) => ( - - - - )); + const {plugins} = this.context; + return this.getSlotComponents(props.tabSlot).map((PluginComponent) => { + const pluginProps = plugins.getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData); + return ( + + + + ); + }); } getPluginTabPaneElements(props = this.props) { - return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => ( - - - - )); + const {plugins} = this.context; + return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => { + const pluginProps = plugins.getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData); + return ( + + + + ); + }); } render() { @@ -75,6 +86,7 @@ class StreamTabPanelContainer extends React.Component { setActiveTab={this.props.setActiveTab} tabs={this.getPluginTabElements().concat(this.props.appendTabs)} tabPanes={this.getPluginTabPaneElements().concat(this.props.appendTabPanes)} + loading={this.props.loading} sub={this.props.sub} /> ); @@ -99,6 +111,7 @@ StreamTabPanelContainer.propTypes = { queryData: PropTypes.object, className: PropTypes.string, sub: PropTypes.bool, + loading: PropTypes.bool, }; const mapStateToProps = (state) => ({ diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js index b4c5ce163..9ec9a2132 100644 --- a/client/coral-embed-stream/src/graphql/index.js +++ b/client/coral-embed-stream/src/graphql/index.js @@ -1,10 +1,9 @@ import {gql} from 'react-apollo'; -import {add} from 'coral-framework/services/graphqlRegistry'; import update from 'immutability-helper'; import uuid from 'uuid/v4'; import {insertCommentIntoEmbedQuery, removeCommentFromEmbedQuery} from './utils'; -const extension = { +export default { fragments: { EditCommentResponse: gql` fragment CoralEmbedStream_EditCommentResponse on EditCommentResponse { @@ -223,4 +222,3 @@ const extension = { }, }; -add(extension); diff --git a/client/coral-embed-stream/src/graphql/utils.js b/client/coral-embed-stream/src/graphql/utils.js index 11d3d411b..0c46b45e3 100644 --- a/client/coral-embed-stream/src/graphql/utils.js +++ b/client/coral-embed-stream/src/graphql/utils.js @@ -1,5 +1,5 @@ import update from 'immutability-helper'; -import {insertCommentsSorted} from 'coral-framework/utils'; +import {appendNewNodes} from 'coral-framework/utils'; function determineCommentDepth(comment) { let depth = 0; @@ -36,11 +36,15 @@ function applyToCommentsOrigin(root, callback) { } function findAndInsertComment(parent, comment) { - const [connectionField, countField, action] = parent.__typename === 'Asset' + const isAsset = parent.__typename === 'Asset'; + const [connectionField, countField, action] = isAsset ? ['comments', 'commentCount', '$unshift'] : ['replies', 'replyCount', '$push']; - if (!comment.parent || parent.id === comment.parent.id) { + if ( + !comment.parent && isAsset // A top level comment in the asset. + || comment.parent && parent.id === comment.parent.id // A reply at the correct parent. + ) { return update(parent, { [connectionField]: { nodes: {[action]: [comment]}, @@ -56,7 +60,7 @@ function findAndInsertComment(parent, comment) { [connectionField]: { nodes: { $apply: (nodes) => - nodes.map((node) => findAndInsertComment(node, comment)) + nodes.map((node) => findAndInsertComment(node, comment)) }, }, }); @@ -159,14 +163,7 @@ function findAndInsertFetchedComments(parent, comments, parent_id) { [connectionField]: { hasNextPage: {$set: comments.hasNextPage}, endCursor: {$set: comments.endCursor}, - nodes: {$apply: (nodes) => { - if (isAsset) { - return nodes.concat(comments.nodes); - } - return insertCommentsSorted(nodes, comments.nodes.filter( - (comment) => !nodes.some((node) => node.id === comment.id) - )); - }}, + nodes: {$apply: (nodes) => appendNewNodes(nodes, comments.nodes)}, }, }); } @@ -179,7 +176,7 @@ function findAndInsertFetchedComments(parent, comments, parent_id) { [connectionField]: { nodes: { $apply: (nodes) => - nodes.map((node) => findAndInsertFetchedComments(node, comments, parent_id)) + nodes.map((node) => findAndInsertFetchedComments(node, comments, parent_id)) }, }, }); @@ -198,7 +195,7 @@ export function attachCommentToParent(topLevelComment, comment) { return update(topLevelComment, { replies: { nodes: { - $apply: (nodes) => insertCommentsSorted(nodes, comment), + $apply: (nodes) => appendNewNodes(nodes, [comment]), }, }, replyCount: { diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js index b8b7804dc..5d72ea3e7 100644 --- a/client/coral-embed-stream/src/index.js +++ b/client/coral-embed-stream/src/index.js @@ -1,27 +1,19 @@ import React from 'react'; import {render} from 'react-dom'; -import {ApolloProvider} from 'react-apollo'; -import {checkLogin, handleAuthToken, logout} from 'coral-framework/actions/auth'; -import './graphql'; +import {checkLogin, handleAuthToken, logout} from 'coral-embed-stream/src/actions/auth'; +import graphqlExtension from './graphql'; import {addExternalConfig} from 'coral-embed-stream/src/actions/config'; -import {getStore, injectReducers, addListener} from 'coral-framework/services/store'; -import {getClient} from 'coral-framework/services/client'; -import {createReduxEmitter} from 'coral-framework/services/events'; -import pym from 'coral-framework/services/pym'; +import {createContext} from 'coral-framework/services/bootstrap'; import AppRouter from './AppRouter'; -import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins'; import reducers from './reducers'; -import EventEmitter from 'eventemitter2'; -import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider'; +import TalkProvider from 'coral-framework/components/TalkProvider'; +import pluginsConfig from 'pluginsConfig'; -const store = getStore(); -const client = getClient(); -const eventEmitter = new EventEmitter({wildcard: true}); +const context = createContext({reducers, graphqlExtension, pluginsConfig}); -loadPluginsTranslations(); -injectPluginsReducers(); -injectReducers(reducers); +// TODO: move init code into `bootstrap` service after auth has been refactored. +const {store, pym} = context; function inIframe() { try { @@ -57,21 +49,11 @@ if (!window.opener) { } else { init(); } - - // Pass any events through our parent. - eventEmitter.onAny((eventName, value) => { - pym.sendMessage('event', JSON.stringify({eventName, value})); - }); - - // Add a redux listener to pass through all actions to our event emitter. - addListener(createReduxEmitter(eventEmitter)); } render( - - - - - + + + , document.querySelector('#talk-embed-stream-container') ); diff --git a/client/coral-framework/reducers/asset.js b/client/coral-embed-stream/src/reducers/asset.js similarity index 100% rename from client/coral-framework/reducers/asset.js rename to client/coral-embed-stream/src/reducers/asset.js diff --git a/client/coral-framework/reducers/auth.js b/client/coral-embed-stream/src/reducers/auth.js similarity index 100% rename from client/coral-framework/reducers/auth.js rename to client/coral-embed-stream/src/reducers/auth.js diff --git a/client/coral-embed-stream/src/reducers/index.js b/client/coral-embed-stream/src/reducers/index.js index 61fe950c9..5c553b04f 100644 --- a/client/coral-embed-stream/src/reducers/index.js +++ b/client/coral-embed-stream/src/reducers/index.js @@ -1,8 +1,14 @@ +import auth from './auth'; +import asset from './asset'; import embed from './embed'; import config from './config'; import stream from './stream'; +import {reducer as commentBox} from '../../../talk-plugin-commentbox'; export default { + auth, + asset, + commentBox, embed, config, stream, diff --git a/client/coral-embed-stream/src/reducers/stream.js b/client/coral-embed-stream/src/reducers/stream.js index 86d5301b7..8e21e23b0 100644 --- a/client/coral-embed-stream/src/reducers/stream.js +++ b/client/coral-embed-stream/src/reducers/stream.js @@ -1,5 +1,5 @@ import * as actions from '../constants/stream'; -import * as authActions from 'coral-framework/constants/auth'; +import * as authActions from '../constants/auth'; function getQueryVariable(variable) { let query = window.location.search.substring(1); @@ -23,6 +23,8 @@ const initialState = { commentClassNames: [], activeTab: process.env.TALK_DEFAULT_STREAM_TAB, previousTab: '', + sortBy: 'CREATED_AT', + sortOrder: 'DESC', }; export default function stream(state = initialState, action) { @@ -66,6 +68,12 @@ export default function stream(state = initialState, action) { ...state.commentClassNames.slice(action.idx + 1) ] }; + case actions.SET_SORT : + return { + ...state, + sortOrder: action.sortOrder ? action.sortOrder : state.sortOrder, + sortBy: action.sortBy ? action.sortBy : state.sortBy, + }; default: return state; } diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index 6ba376096..010a65ab1 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -79,6 +79,12 @@ body { } /* Info Box Styles */ + +.hidden { + visibility: hidden; + display: none; +} + .talk-plugin-infobox-info { top: 0; border: 0; @@ -93,7 +99,6 @@ body { border-radius: 2px; } - .talk-plugin-infobox-info em{ font-style: italic; } @@ -113,66 +118,6 @@ body { } /* Question Box Styles */ -.talk-plugin-questionbox-info { - top: 0; - border: 0; - background: #F0F0F0; - color: black; - width: 100%; - text-align: left; - padding-left: 0px; - margin-bottom: 0px; - font-weight: bold; - font-size: 14px; - overflow: hidden; - min-height: 50px; - display: flex; -} - -.talk-plugin-questionbox-icon.bubble{ - position: absolute; - top: 8px; - left: 10px; - color: #9E9E9E; - font-size: 24px; - z-index: 0; -} - -.talk-plugin-questionbox-icon.person{ - z-index: 2; - top: 12px; - left: 12px; - position: absolute; - font-size: 33px; - color: #262626; -} - -.talk-plugin-questionbox-box { - position: relative; - border: 0; - color: white; - padding: 20px; - margin-left: 0px !important; - margin-right: 10px; - display: inline-block; - width: 10px; - min-height: 100%; - padding: 5px 20px; - vertical-align: middle; -} - -.talk-plugin-questionbox-content { - padding: 5px; - display: flex; - align-items: center; - justify-content: center; - font-weight: 400; -} - -.hidden { - visibility: hidden; - display: none; -} .talk-stream-comments-container { position: relative; @@ -238,16 +183,6 @@ body { line-height: 1.3; } -.talk-plugin-author-name-text { - display: inline-block; - margin: 10px 5px 10px 0; - font-weight: bold; -} - -.talk-plugin-author-name-bio-flag { - float: right; -} - /* Tag Labels */ .talk-plugin-tag-label { diff --git a/client/coral-framework/actions/notification.js b/client/coral-framework/actions/notification.js index 57a7950f5..872936d15 100644 --- a/client/coral-framework/actions/notification.js +++ b/client/coral-framework/actions/notification.js @@ -1,12 +1,22 @@ -import pym from '../services/pym'; import * as actions from '../constants/notification'; -export const addNotification = (notifType, text) => { - pym.sendMessage('coral-alert', `${notifType}|${text}`); - return {type: actions.ADD_NOTIFICATION, notifType, text}; -}; +export const notify = (kind, msg) => (dispatch, _, {notification}) => { + const messages = Array.isArray(msg) ? msg : [msg]; -export const clearNotification = () => { - pym.sendMessage('coral-clear-notification'); - return {type: actions.CLEAR_NOTIFICATION}; + messages.forEach((message) => { + switch (kind) { + case 'error': + notification.error(message); + break; + case 'info': + notification.info(message); + break; + case 'success': + notification.success(message); + break; + default: + throw new Error(`Unknown notification kind ${kind}`); + } + dispatch({type: actions.NOTIFY, kind, message}); + }); }; diff --git a/client/coral-framework/components/ClickOutside.js b/client/coral-framework/components/ClickOutside.js index 5348a9af7..054a9e6fe 100644 --- a/client/coral-framework/components/ClickOutside.js +++ b/client/coral-framework/components/ClickOutside.js @@ -1,13 +1,16 @@ import {Component, cloneElement, Children} from 'react'; import PropTypes from 'prop-types'; import {findDOMNode} from 'react-dom'; -import pym from 'coral-framework/services/pym'; export default class ClickOutside extends Component { static propTypes = { onClickOutside: PropTypes.func.isRequired }; + static contextTypes = { + pym: PropTypes.object, + }; + domNode = null; handleClick = (e) => { @@ -18,14 +21,20 @@ export default class ClickOutside extends Component { }; componentDidMount() { + const {pym} = this.context; this.domNode = findDOMNode(this); document.addEventListener('click', this.handleClick, true); - pym.onMessage('click', this.handleClick); + if (pym) { + pym.onMessage('click', this.handleClick); + } } componentWillUnmount() { + const {pym} = this.context; document.removeEventListener('click', this.handleClick, true); - pym.messageHandlers.click = pym.messageHandlers.click.filter((h) => h !== this.handleClick); + if (pym) { + pym.messageHandlers.click = pym.messageHandlers.click.filter((h) => h !== this.handleClick); + } } render() { diff --git a/client/coral-framework/components/CommentAuthorName.css b/client/coral-framework/components/CommentAuthorName.css new file mode 100644 index 000000000..c06360000 --- /dev/null +++ b/client/coral-framework/components/CommentAuthorName.css @@ -0,0 +1,3 @@ +.authorName { + font-weight: bold; +} \ No newline at end of file diff --git a/client/coral-framework/components/CommentAuthorName.js b/client/coral-framework/components/CommentAuthorName.js new file mode 100644 index 000000000..5a2e60c70 --- /dev/null +++ b/client/coral-framework/components/CommentAuthorName.js @@ -0,0 +1,9 @@ +import React from 'react'; +import styles from './CommentAuthorName.css'; + +const CommentAuthorName = ({comment}) => + + {comment.user.username} + ; + +export default CommentAuthorName; diff --git a/client/coral-framework/components/EventEmitterProvider.js b/client/coral-framework/components/EventEmitterProvider.js deleted file mode 100644 index 36c0bccdb..000000000 --- a/client/coral-framework/components/EventEmitterProvider.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -const PropTypes = require('prop-types'); - -class EventEmitterProvider extends React.Component { - getChildContext() { - return {eventEmitter: this.props.eventEmitter}; - } - - render() { - return this.props.children; - } -} - -EventEmitterProvider.childContextTypes = { - eventEmitter: PropTypes.object, -}; - -export default EventEmitterProvider; diff --git a/client/coral-framework/components/IfSlotIsEmpty.js b/client/coral-framework/components/IfSlotIsEmpty.js index e9211fc57..557ee3b3d 100644 --- a/client/coral-framework/components/IfSlotIsEmpty.js +++ b/client/coral-framework/components/IfSlotIsEmpty.js @@ -1,11 +1,13 @@ import React from 'react'; import {connect} from 'react-redux'; -import {isSlotEmpty} from 'coral-framework/helpers/plugins'; import PropTypes from 'prop-types'; import omit from 'lodash/omit'; import {getShallowChanges} from 'coral-framework/utils'; class IfSlotIsEmpty extends React.Component { + static contextTypes = { + plugins: PropTypes.object, + }; shouldComponentUpdate(next) { @@ -22,7 +24,7 @@ class IfSlotIsEmpty extends React.Component { isSlotEmpty(props = this.props) { const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props; - return isSlotEmpty(slot, reduxState, rest); + return this.context.plugins.isSlotEmpty(slot, reduxState, rest); } render() { diff --git a/client/coral-framework/components/IfSlotIsNotEmpty.js b/client/coral-framework/components/IfSlotIsNotEmpty.js index e7a0e83ce..4e3abfae4 100644 --- a/client/coral-framework/components/IfSlotIsNotEmpty.js +++ b/client/coral-framework/components/IfSlotIsNotEmpty.js @@ -1,11 +1,13 @@ import React from 'react'; import {connect} from 'react-redux'; -import {isSlotEmpty} from 'coral-framework/helpers/plugins'; import PropTypes from 'prop-types'; import omit from 'lodash/omit'; import {getShallowChanges} from 'coral-framework/utils'; class IfSlotIsNotEmpty extends React.Component { + static contextTypes = { + plugins: PropTypes.object, + }; shouldComponentUpdate(next) { @@ -22,7 +24,7 @@ class IfSlotIsNotEmpty extends React.Component { isSlotEmpty(props = this.props) { const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props; - return isSlotEmpty(slot, reduxState, rest); + return this.context.plugins.isSlotEmpty(slot, reduxState, rest); } render() { diff --git a/client/coral-framework/components/Markdown.js b/client/coral-framework/components/Markdown.js new file mode 100644 index 000000000..482467987 --- /dev/null +++ b/client/coral-framework/components/Markdown.js @@ -0,0 +1,23 @@ +import React, {PureComponent, PropTypes} from 'react'; +import marked from 'marked'; + +const renderer = new marked.Renderer(); + +// Set link target to `_parent` to work properly in an embed. +renderer.link = (href, title, text) => + `${text}`; + +marked.setOptions({renderer}); + +export default class Markdown extends PureComponent { + render() { + const {content, ...rest} = this.props; + const __html = marked(content); + return
; + } +} + +Markdown.propTypes = { + content: PropTypes.string, +}; + diff --git a/client/coral-admin/src/components/MarkdownEditor.css b/client/coral-framework/components/MarkdownEditor.css similarity index 99% rename from client/coral-admin/src/components/MarkdownEditor.css rename to client/coral-framework/components/MarkdownEditor.css index 93bf7a9dd..201ba10bc 100644 --- a/client/coral-admin/src/components/MarkdownEditor.css +++ b/client/coral-framework/components/MarkdownEditor.css @@ -467,7 +467,6 @@ $fullscreenZIndex: 10; text-align: center; text-decoration: none!important; color: #2c3e50!important; - width: 30px; height: 30px; margin: 0; border: 1px solid transparent; @@ -475,6 +474,8 @@ $fullscreenZIndex: 10; cursor: pointer; outline: 0; margin-right: 2px; + font-size: 1.5em; + width: 25px; &.active { background: #fcfcfc; border-color: #95a5a6; diff --git a/client/coral-admin/src/components/MarkdownEditor.js b/client/coral-framework/components/MarkdownEditor.js similarity index 100% rename from client/coral-admin/src/components/MarkdownEditor.js rename to client/coral-framework/components/MarkdownEditor.js diff --git a/client/coral-framework/components/Popup.js b/client/coral-framework/components/Popup.js index 1fe3408ea..33041442f 100644 --- a/client/coral-framework/components/Popup.js +++ b/client/coral-framework/components/Popup.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; export default class Popup extends Component { ref = null; detectCloseInterval = null; + resetCallbackInterval = null; constructor(props) { super(props); @@ -41,16 +42,26 @@ export default class Popup extends Component { this.ref.onunload = () => { this.onUnload(); - const interval = setInterval(() => { + if (this.resetCallbackInterval) { + clearInterval(this.resetCallbackInterval); + } + + this.resetCallbackInterval = setInterval(() => { if (this.ref && this.ref.onload === null) { + clearInterval(this.resetCallbackInterval); + this.resetCallbackInterval = null; this.setCallbacks(); - clearInterval(interval); } }, 50); + if (this.detectCloseInterval) { + clearInterval(this.detectCloseInterval); + } + this.detectCloseInterval = setInterval(() => { if (!this.ref || this.ref.closed) { clearInterval(this.detectCloseInterval); + this.detectCloseInterval = null; this.onClose(); } }, 50); diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index c0cca143c..06f1d4cb7 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -2,14 +2,18 @@ import React from 'react'; import cn from 'classnames'; import styles from './Slot.css'; import {connect} from 'react-redux'; -import {getSlotElements, getSlotComponentProps} from 'coral-framework/helpers/plugins'; import omit from 'lodash/omit'; +import PropTypes from 'prop-types'; import isEqual from 'lodash/isEqual'; import {getShallowChanges} from 'coral-framework/utils'; const emptyConfig = {}; class Slot extends React.Component { + static contextTypes = { + plugins: PropTypes.object, + }; + shouldComponentUpdate(next) { // Prevent Slot from rerendering when only reduxState has changed and @@ -25,35 +29,83 @@ class Slot extends React.Component { return changes.length !== 0; } - getSlotProps({fill: _a, inline: _b, className: _c, reduxState: _d, defaultComponent_: _e, queryData: _f, ...rest} = this.props) { + getSlotProps(props = this.props) { + const { + fill: _a, + inline: _b, + className: _c, + reduxState: _d, + defaultComponent_: _e, + queryData: _f, + childFactory: _g, + component: _h, + ...rest + } = props; return rest; } getChildren(props = this.props) { - return getSlotElements(props.fill, props.reduxState, this.getSlotProps(props), props.queryData); + const {plugins} = this.context; + return plugins.getSlotElements(props.fill, props.reduxState, this.getSlotProps(props), props.queryData); } render() { - const {inline = false, className, reduxState, defaultComponent: DefaultComponent, queryData} = this.props; + const { + inline = false, + className, + reduxState, + component: Component, + childFactory, + defaultComponent: DefaultComponent, + queryData, + } = this.props; + const {plugins} = this.context; let children = this.getChildren(); const pluginConfig = reduxState.config.pluginConfig || emptyConfig; if (children.length === 0 && DefaultComponent) { - children = ; + const props = plugins.getSlotComponentProps(DefaultComponent, reduxState, this.getSlotProps(this.props), queryData); + children = ; + } + + if (childFactory) { + children = children.map(childFactory); } return ( -
+ {children} -
+ ); } } +Slot.defaultProps = { + component: 'div', +}; + Slot.propTypes = { - fill: React.PropTypes.string.isRequired, + fill: PropTypes.string.isRequired, + + /** + * You may specify the component to use as the root wrapper. + * Defaults to 'div'. + */ + component: PropTypes.any, // props coming from graphql must be passed through this property. - queryData: React.PropTypes.object, + queryData: PropTypes.object, + + /** + * You may need to apply reactive updates to a child as it is exiting. + * This is generally done by using `cloneElement` however in the case of an exiting + * child the element has already been removed and not accessible to the consumer. + * + * If you do need to update a child as it leaves you can provide a `childFactory` + * to wrap every child, even the ones that are leaving. + * + * @type Function(child: ReactElement) -> ReactElement + */ + childFactory: PropTypes.func, }; const mapStateToProps = (state) => ({ diff --git a/client/coral-framework/components/TalkProvider.js b/client/coral-framework/components/TalkProvider.js new file mode 100644 index 000000000..418bb3760 --- /dev/null +++ b/client/coral-framework/components/TalkProvider.js @@ -0,0 +1,40 @@ +import React from 'react'; +const PropTypes = require('prop-types'); +import {ApolloProvider} from 'react-apollo'; + +class TalkProvider extends React.Component { + getChildContext() { + return { + eventEmitter: this.props.eventEmitter, + pym: this.props.pym, + plugins: this.props.plugins, + rest: this.props.rest, + graphqlRegistry: this.props.graphqlRegistry, + notification: this.props.notification, + storage: this.props.storage, + history: this.props.history, + }; + } + + render() { + const {children, client, store} = this.props; + return ( + + {children} + + ); + } +} + +TalkProvider.childContextTypes = { + pym: PropTypes.object, + eventEmitter: PropTypes.object, + plugins: PropTypes.object, + rest: PropTypes.func, + graphqlRegistry: PropTypes.object, + notification: PropTypes.object, + storage: PropTypes.object, + history: PropTypes.object, +}; + +export default TalkProvider; diff --git a/client/coral-framework/components/index.js b/client/coral-framework/components/index.js index b7aaef665..8ad08aa09 100644 --- a/client/coral-framework/components/index.js +++ b/client/coral-framework/components/index.js @@ -1 +1,2 @@ export {default as Slot} from './Slot'; +export {default as CommentAuthorName} from './CommentAuthorName'; diff --git a/client/coral-framework/constants/notification.js b/client/coral-framework/constants/notification.js index a7334119a..af5828622 100644 --- a/client/coral-framework/constants/notification.js +++ b/client/coral-framework/constants/notification.js @@ -1,2 +1 @@ -export const ADD_NOTIFICATION = 'ADD_NOTIFICATION'; -export const CLEAR_NOTIFICATION = 'CLEAR_NOTIFICATION'; +export const NOTIFY = 'NOTIFY'; diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js deleted file mode 100644 index 3173115c2..000000000 --- a/client/coral-framework/helpers/plugins.js +++ /dev/null @@ -1,173 +0,0 @@ -import React from 'react'; -import uniq from 'lodash/uniq'; -import pick from 'lodash/pick'; -import merge from 'lodash/merge'; -import flattenDeep from 'lodash/flattenDeep'; -import isEmpty from 'lodash/isEmpty'; -import flatten from 'lodash/flatten'; -import mapValues from 'lodash/mapValues'; -import {loadTranslations} from 'coral-framework/services/i18n'; -import {injectReducers} from 'coral-framework/services/store'; -import {getDisplayName} from 'coral-framework/helpers/hoc'; -import camelize from './camelize'; -import plugins from 'pluginsConfig'; -import uuid from 'uuid/v4'; - -// This is returned for pluginConfig when it is empty. -const emptyConfig = {}; - -export function getSlotComponents(slot, reduxState, props = {}, queryData = {}) { - const pluginConfig = reduxState.config.plugin_config || emptyConfig; - return flatten(plugins - - // Filter out components that have slots and have been disabled in `plugin_config` - .filter((o) => o.module.slots && (!pluginConfig || !pluginConfig[o.name] || !pluginConfig[o.name].disable_components)) - - .filter((o) => o.module.slots[slot]) - .map((o) => o.module.slots[slot]) - ) - .filter((component) => { - if(!component.isExcluded) { - return true; - } - let resolvedProps = getSlotComponentProps(component, reduxState, props, queryData); - if (component.mapStateToProps) { - resolvedProps = {...resolvedProps, ...component.mapStateToProps(reduxState)}; - } - return !component.isExcluded(resolvedProps); - }); -} - -export function isSlotEmpty(slot, reduxState, props = {}, queryData = {}) { - return getSlotComponents(slot, reduxState, props, queryData).length === 0; -} - -// Memoize the warnings so we only show them once. -const memoizedWarnings = []; - -// withWarnings decorates the props of queryData with a proxy that -// prints a warning when accessing deeper props. -function withWarnings(component, queryData) { - if (process.env.NODE_ENV !== 'production' && window.Proxy) { - - // Show warnings when accessing queryData only when not in production. - return mapValues(queryData, (value, key) => { - - // Keep null values.. - if (!queryData[key]) { - return queryData[key]; - } - return new Proxy(queryData[key], { - get(target, name) { - - // Only care about the components defined in the plugins. - if (component.talkPluginName) { - const warning = `'${getDisplayName(component)}' of '${component.talkPluginName}' accessed '${key}.${name}' but did not define fragments using the withFragment HOC`; - if (memoizedWarnings.indexOf(warning) === -1) { - console.warn(warning); - memoizedWarnings.push(warning); - } - } - return queryData[key][name]; - } - }); - }); - } - - return queryData; -} - -/** - * getSlotComponentProps calculate the props we would pass to the slot component. - * query datas are only passed to the component if it is defined in `component.fragments`. - */ -export function getSlotComponentProps(component, reduxState, props, queryData) { - const pluginConfig = reduxState.config.plugin_config || emptyConfig; - return { - ...props, - config: pluginConfig, - ...( - component.fragments - ? pick(queryData, Object.keys(component.fragments)) - : withWarnings(component, queryData) - ) - }; -} - -/** - * Returns React Elements for given slot. - */ -export function getSlotElements(slot, reduxState, props = {}, queryData = {}) { - return getSlotComponents(slot, reduxState, props, queryData) - .map((component, i) => { - return React.createElement(component, {key: i, ...getSlotComponentProps(component, reduxState, props, queryData)}); - }); -} - -export function getSlotFragments(slot, part) { - const components = uniq(flattenDeep(plugins - .filter((o) => o.module.slots ? o.module.slots[slot] : false) - .map((o) => o.module.slots[slot]) - )); - - const documents = components - .map((c) => c.fragments) - .filter((fragments) => fragments && fragments[part]) - .reduce((res, fragments) => { - res.push(fragments[part]); - return res; - }, []); - - return documents; -} - -export function getGraphQLExtensions() { - return plugins - .map((o) => pick(o.module, ['mutations', 'queries', 'fragments'])) - .filter((o) => !isEmpty(o)); -} - -export function getModQueueConfigs() { - return merge(...plugins - .map((o) => o.module.modQueues) - .filter((o) => o)); -} - -function getTranslations() { - return plugins - .map((o) => o.module.translations) - .filter((o) => o); -} - -export function loadPluginsTranslations() { - getTranslations().forEach((t) => loadTranslations(t)); -} - -export function injectPluginsReducers() { - const reducers = merge( - ...plugins - .filter((o) => o.module.reducer) - .map((o) => ({[camelize(o.name)] : o.module.reducer})) - ); - injectReducers(reducers); -} - -function addMetaDataToSlotComponents() { - - // Add talkPluginName to Slot Components. - plugins.forEach((plugin) => { - const slots = plugin.module.slots; - slots && Object.keys(slots).forEach((slot) => { - slots[slot].forEach((component) => { - - // Attach plugin name to the component - component.talkPluginName = plugin.name; - - // Attach uuid to the component - component.talkUuid = uuid(); - }); - }); - }); -} - -addMetaDataToSlotComponents(); diff --git a/client/coral-framework/helpers/request.js b/client/coral-framework/helpers/request.js deleted file mode 100644 index ffddc9c5e..000000000 --- a/client/coral-framework/helpers/request.js +++ /dev/null @@ -1,84 +0,0 @@ -import bowser from 'bowser'; -import * as Storage from './storage'; -import merge from 'lodash/merge'; -import {getStore} from 'coral-framework/services/store'; -import {BASE_PATH} from 'coral-framework/constants/url'; - -/** - * getAuthToken returns the active auth token or null - * Note: this method does not have access to the cookie based token used by - * browsers that don't allow us to use cross domain iframe local storage. - * @return {string|null} - */ -export const getAuthToken = () => { - let state = getStore().getState(); - - if (state.config.auth_token) { - - // if an auth_token exists in config, use it. - return state.config.auth_token; - - } else if (!bowser.safari && !bowser.ios) { - - // Use local storage auth tokens where there's a stable api. - return Storage.getItem('token'); - } - - return null; -}; - -const buildOptions = (inputOptions = {}) => { - const defaultOptions = { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - credentials: 'same-origin' - }; - - let options = merge({}, defaultOptions, inputOptions); - - // Apply authToken header - let authToken = getAuthToken(); - if (authToken !== null) { - options.headers.Authorization = `Bearer ${authToken}`; - } - - if (options.method.toLowerCase() !== 'get') { - options.body = JSON.stringify(options.body); - } - - return options; -}; - -const handleResp = (res) => { - if (res.status > 399) { - return res.json().then((err) => { - let message = err.message || res.status; - const error = new Error(); - - if (err.error && err.error.metadata && err.error.metadata.message) { - error.metadata = err.error.metadata.message; - } - - if (err.error && err.error.translation_key) { - error.translation_key = err.error.translation_key; - } - - error.message = message; - error.status = res.status; - throw error; - }); - } else if (res.status === 204) { - return res.text(); - } else { - return res.json(); - } -}; - -export const base = `${BASE_PATH}api/v1`; - -export default (url, options) => { - return fetch(`${base}${url}`, buildOptions(options)).then(handleResp); -}; diff --git a/client/coral-framework/helpers/router.js b/client/coral-framework/helpers/router.js deleted file mode 100644 index e6277e9d8..000000000 --- a/client/coral-framework/helpers/router.js +++ /dev/null @@ -1,7 +0,0 @@ -import {useBasename} from 'history'; -import {browserHistory} from 'react-router'; -import {BASE_PATH} from 'coral-framework/constants/url'; - -export const history = useBasename(() => browserHistory)({ - basename: BASE_PATH -}); diff --git a/client/coral-framework/helpers/storage.js b/client/coral-framework/helpers/storage.js deleted file mode 100644 index ec83c3fb3..000000000 --- a/client/coral-framework/helpers/storage.js +++ /dev/null @@ -1,92 +0,0 @@ -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}` - ); - } -} - -// 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/validate.js b/client/coral-framework/helpers/validate.js index dd36ae07f..ef64a2c84 100644 --- a/client/coral-framework/helpers/validate.js +++ b/client/coral-framework/helpers/validate.js @@ -1,5 +1,5 @@ export default { - email: (email) => (/^([A-Za-z0-9_\-\.\+])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)), + email: (email) => (/^.+@.+\..+$/.test(email)), password: (pass) => (/^(?=.{8,}).*$/.test(pass)), confirmPassword: () => true, username: (username) => (/^[a-zA-Z0-9_]+$/.test(username)), diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js index 9bc06290e..be6d07fed 100644 --- a/client/coral-framework/hocs/withFragments.js +++ b/client/coral-framework/hocs/withFragments.js @@ -1,9 +1,9 @@ import React from 'react'; import graphql from 'graphql-anywhere'; -import {resolveFragments} from 'coral-framework/services/graphqlRegistry'; import mapValues from 'lodash/mapValues'; import hoistStatics from 'recompose/hoistStatics'; import {getShallowChanges} from 'coral-framework/utils'; +import PropTypes from 'prop-types'; import union from 'lodash/union'; // TODO: Should not depend on `props.data` @@ -63,7 +63,22 @@ function hasEqualLeaves(a, b, path = '') { export default (fragments) => hoistStatics((BaseComponent) => { class WithFragments extends React.Component { - fragments = mapValues(fragments, (val) => resolveFragments(val)); + static contextTypes = { + graphqlRegistry: PropTypes.object, + }; + + get graphqlRegistry() { + return this.context.graphqlRegistry; + } + + resolveDocument(documentOrCallback) { + const document = typeof documentOrCallback === 'function' + ? documentOrCallback(this.props, this.context) + : documentOrCallback; + return this.graphqlRegistry.resolveFragments(document); + } + + fragments = mapValues(fragments, (val) => this.resolveDocument(val)); fragmentKeys = Object.keys(fragments).sort(); // Cache variables between lifecycles to speed up render. diff --git a/client/coral-framework/hocs/withMutation.js b/client/coral-framework/hocs/withMutation.js index 7c3dcfa1a..f16809322 100644 --- a/client/coral-framework/hocs/withMutation.js +++ b/client/coral-framework/hocs/withMutation.js @@ -4,7 +4,6 @@ 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 {getDefinitionName, getResponseErrors} from '../utils'; import PropTypes from 'prop-types'; import t from 'coral-framework/services/i18n'; @@ -43,8 +42,20 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { static contextTypes = { eventEmitter: PropTypes.object, store: PropTypes.object, + graphqlRegistry: PropTypes.object, }; + get graphqlRegistry() { + return this.context.graphqlRegistry; + } + + resolveDocument(documentOrCallback) { + const document = typeof documentOrCallback === 'function' + ? documentOrCallback(this.props, this.context) + : documentOrCallback; + return this.graphqlRegistry.resolveFragments(document); + } + // Lazily resolve fragments from graphRegistry to support circular dependencies. memoized = null; @@ -56,7 +67,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { propsWrapper = (data) => { const name = getDefinitionName(document); - const callbacks = getMutationOptions(name); + const callbacks = this.graphqlRegistry.getMutationOptions(name); const mutate = (base) => { const variables = base.variables || config.options.variables; const configs = callbacks.map((cb) => cb({variables, state: this.context.store.getState()})); @@ -73,8 +84,8 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { const updateCallbacks = [base.update || config.options.update] - .concat(...configs.map((cfg) => cfg.update)) - .filter((i) => i); + .concat(...configs.map((cfg) => cfg.update)) + .filter((i) => i); const update = (proxy, result) => { if (getResponseErrors(result)) { @@ -90,28 +101,28 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { 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] = (prev, result) => { - if (getResponseErrors(result.mutationResult)) { + .filter((i) => i) + .reduce((res, map) => { + Object.keys(map).forEach((key) => { + if (!(key in res)) { + res[key] = (prev, result) => { + if (getResponseErrors(result.mutationResult)) { // Do not run updates when we have mutation errors. - return prev; - } - return map[key](prev, result) || prev; - }; - } else { - const existing = res[key]; - res[key] = (prev, result) => { - const next = existing(prev, result); - return map[key](next, result) || next; - }; - } - }); - return res; - }, {}); + return prev; + } + return map[key](prev, result) || prev; + }; + } else { + const existing = res[key]; + res[key] = (prev, result) => { + const next = existing(prev, result); + return map[key](next, result) || next; + }; + } + }); + return res; + }, {}); const wrappedConfig = { variables, @@ -167,7 +178,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { getWrapped = () => { if (!this.memoized) { - this.memoized = graphql(resolveFragments(document), {...config, props: this.propsWrapper})(WrappedComponent); + this.memoized = graphql(this.resolveDocument(document), {...config, props: this.propsWrapper})(WrappedComponent); } return this.memoized; }; diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js index f19c3209e..101dc45a9 100644 --- a/client/coral-framework/hocs/withQuery.js +++ b/client/coral-framework/hocs/withQuery.js @@ -1,6 +1,5 @@ import * as React from 'react'; import {graphql} from 'react-apollo'; -import {getQueryOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry'; import {getDefinitionName, separateDataAndRoot, getResponseErrors} from '../utils'; import PropTypes from 'prop-types'; import hoistStatics from 'recompose/hoistStatics'; @@ -37,17 +36,28 @@ function networkStatusToString(networkStatus) { * apply query options registered in the graphRegistry. */ export default (document, config = {}) => hoistStatics((WrappedComponent) => { - const name = getDefinitionName(document); - return class WithQuery extends React.Component { static contextTypes = { eventEmitter: PropTypes.object, + graphqlRegistry: PropTypes.object, }; // Lazily resolve fragments from graphRegistry to support circular dependencies. memoized = null; lastNetworkStatus = null; data = null; + name = ''; + + get graphqlRegistry() { + return this.context.graphqlRegistry; + } + + resolveDocument(documentOrCallback) { + const document = typeof documentOrCallback === 'function' + ? documentOrCallback(this.props, this.context) + : documentOrCallback; + return this.graphqlRegistry.resolveFragments(document); + } emitWhenNeeded(data) { const {variables, networkStatus} = data; @@ -93,11 +103,12 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { refetch: data.refetch, updateQuery: data.updateQuery, subscribeToMore: (stmArgs) => { + const resolvedDocument = this.resolveDocument(stmArgs.document); // Resolve document fragments before passing it to `apollo-client`. return data.subscribeToMore({ ...stmArgs, - document: resolveFragments(stmArgs.document), + document: resolvedDocument, onError: (err) => { if (stmArgs.onErr) { return stmArgs.onErr(err); @@ -107,7 +118,8 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { }); }, fetchMore: (lmArgs) => { - const fetchName = getDefinitionName(lmArgs.query); + const resolvedDocument = this.resolveDocument(lmArgs.query); + const fetchName = getDefinitionName(resolvedDocument); this.context.eventEmitter.emit( `query.${name}.fetchMore.${fetchName}.begin`, {variables: lmArgs.variables}); @@ -115,20 +127,20 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { // Resolve document fragments before passing it to `apollo-client`. return data.fetchMore({ ...lmArgs, - query: resolveFragments(lmArgs.query), + query: resolvedDocument, }) - .then((res) => { - this.context.eventEmitter.emit( - `query.${name}.fetchMore.${fetchName}.success`, - {variables: lmArgs.variables, data: res.data}); - return Promise.resolve(res); - }) - .catch((err) => { - this.context.eventEmitter.emit( - `query.${name}.fetchMore.${fetchName}.error`, - {variables: lmArgs.variables, error: err}); - throw err; - }); + .then((res) => { + this.context.eventEmitter.emit( + `query.${name}.fetchMore.${fetchName}.success`, + {variables: lmArgs.variables, data: res.data}); + return Promise.resolve(res); + }) + .catch((err) => { + this.context.eventEmitter.emit( + `query.${name}.fetchMore.${fetchName}.error`, + {variables: lmArgs.variables, error: err}); + throw err; + }); }, }; } @@ -156,11 +168,11 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { const base = (typeof this.wrappedConfig.options === 'function') ? this.wrappedConfig.options(data) : this.wrappedConfig.options; - const configs = getQueryOptions(name); + const configs = this.graphqlRegistry.getQueryOptions(name); const reducerCallbacks = [base.reducer || ((i) => i)] - .concat(...configs.map((cfg) => cfg.reducer)) - .filter((i) => i); + .concat(...configs.map((cfg) => cfg.reducer)) + .filter((i) => i); const reducer = withSkipOnErrors( reducerCallbacks.reduce( @@ -178,8 +190,10 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { getWrapped = () => { if (!this.memoized) { + const resolvedDocument = this.resolveDocument(document); + this.name = getDefinitionName(resolvedDocument); this.memoized = graphql( - resolveFragments(document), + resolvedDocument, {...this.wrappedConfig, options: this.wrappedOptions}, )(WrappedComponent); } diff --git a/client/coral-framework/reducers/index.js b/client/coral-framework/reducers/index.js deleted file mode 100644 index 6b9b730ca..000000000 --- a/client/coral-framework/reducers/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import auth from './auth'; -import asset from './asset'; -import {reducer as commentBox} from '../../talk-plugin-commentbox'; - -export default { - auth, - asset, - commentBox, -}; diff --git a/client/coral-framework/services/bootstrap.js b/client/coral-framework/services/bootstrap.js new file mode 100644 index 000000000..ac94d8b29 --- /dev/null +++ b/client/coral-framework/services/bootstrap.js @@ -0,0 +1,128 @@ +import {createStore} from './store'; +import {createClient, apolloErrorReporter} from './client'; +import pym from './pym'; +import EventEmitter from 'eventemitter2'; +import {createReduxEmitter} from './events'; +import {createRestClient} from './rest'; +import thunk from 'redux-thunk'; +import {loadTranslations} from './i18n'; +import bowser from 'bowser'; +import {BASE_PATH} from 'coral-framework/constants/url'; +import {createPluginsService} from './plugins'; +import {createNotificationService} from './notification'; +import {createGraphQLRegistry} from './graphqlRegistry'; +import globalFragments from 'coral-framework/graphql/fragments'; +import {createStorage} from 'coral-framework/services/storage'; +import {createHistory} from 'coral-framework/services/history'; + +/** + * getAuthToken returns the active auth token or null + * Note: this method does not have access to the cookie based token used by + * browsers that don't allow us to use cross domain iframe local storage. + * @return {string|null} + */ +const getAuthToken = (store, storage) => { + let state = store.getState(); + + if (state.config.auth_token) { + + // if an auth_token exists in config, use it. + return state.config.auth_token; + + } else if (!bowser.safari && !bowser.ios && storage) { + + // Use local storage auth tokens where there's a stable api. + return storage.getItem('token'); + } + + return null; +}; + +/** + * createContext setups and returns Talk dependencies that should be + * passed to `TalkProvider`. + * @param {Object} [config] configuration + * @param {Object} [config.reducers] extra reducers to add to redux + * @param {Array} [config.pluginsConfig] plugin configuration as returned by importing pluginsConfig + * @param {Object} [config.graphqlExtensions] additional extension to the graphql framework + * @param {Object} [config.notification] replace default notification service + * @return {Object} context + */ +export function createContext({reducers = {}, pluginsConfig = [], graphqlExtension = {}, notification} = {}) { + const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; + const eventEmitter = new EventEmitter({wildcard: true}); + const storage = createStorage(); + const history = createHistory(BASE_PATH); + let store = null; + const token = () => { + + // Try to get the token from localStorage. If it isn't here, it may + // be passed as a cookie. + + // NOTE: THIS IS ONLY EVER EVALUATED ONCE, IN ORDER TO SEND A DIFFERNT + // TOKEN YOU MUST DISCONNECT AND RECONNECT THE WEBSOCKET CLIENT. + return getAuthToken(store, storage); + }; + const rest = createRestClient({ + uri: `${BASE_PATH}api/v1`, + token, + }); + const client = createClient({ + uri: `${BASE_PATH}api/v1/graph/ql`, + liveUri: `${protocol}://${location.host}${BASE_PATH}api/v1/live`, + token, + }); + const plugins = createPluginsService(pluginsConfig); + const graphqlRegistry = createGraphQLRegistry(plugins.getSlotFragments.bind(plugins)); + if (!notification) { + + // Use default notification service (pym based) + notification = createNotificationService(pym); + } + const context = { + client, + pym, + plugins, + eventEmitter, + rest, + graphqlRegistry, + notification, + storage, + history, + }; + + // Load framework fragments. + Object.keys(globalFragments).forEach((key) => graphqlRegistry.addFragment(key, globalFragments[key])); + + // Register graphql extension + graphqlRegistry.add(graphqlExtension); + + // Register plugin graphql extensions. + plugins.getGraphQLExtensions().forEach((ext) => graphqlRegistry.add(ext)); + + // Load plugin translations. + plugins.getTranslations().forEach((t) => loadTranslations(t)); + + // Pass any events through our parent. + eventEmitter.onAny((eventName, value) => { + pym.sendMessage('event', JSON.stringify({eventName, value})); + }); + + const finalReducers = { + ...reducers, + ...plugins.getReducers(), + apollo: client.reducer(), + }; + + store = createStore(finalReducers, [ + client.middleware(), + thunk.withExtraArgument(context), + apolloErrorReporter, + createReduxEmitter(eventEmitter), + ]); + + return { + ...context, + store, + }; +} diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js index 6f044f621..5907488e3 100644 --- a/client/coral-framework/services/client.js +++ b/client/coral-framework/services/client.js @@ -1,64 +1,66 @@ -import ApolloClient, {addTypename, IntrospectionFragmentMatcher} from 'apollo-client'; -import {networkInterface} from './transport'; +import ApolloClient, {addTypename, IntrospectionFragmentMatcher, createNetworkInterface} from 'apollo-client'; import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws'; import MessageTypes from 'subscriptions-transport-ws/dist/message-types'; -import {getAuthToken} from '../helpers/request'; import introspectionQueryResultData from '../graphql/introspection.json'; -import {BASE_PATH} from 'coral-framework/constants/url'; -let client, wsClient = null, wsClientToken = null; - -export function resetWebsocket() { - if (wsClient === null) { - - // Nothing to reset! - return; +// Redux middleware to report any errors to the console. +export const apolloErrorReporter = () => (next) => (action) => { + if (action.type === 'APOLLO_QUERY_ERROR') { + console.error(action.error); } + return next(action); +}; - // Close socket connection which will also unregister subscriptions on the server-side. - wsClient.close(); - - // Reconnect to the server. - wsClient.connect(); - - // Reregister all subscriptions (uses non public api). - // See: https://github.com/apollographql/subscriptions-transport-ws/issues/171 - Object.keys(wsClient.operations).forEach((id) => { - wsClient.sendMessage(id, MessageTypes.GQL_START, wsClient.operations[id].options); - }); +function resolveToken(token) { + return typeof token === 'function' ? token() : token; } -export function getClient(options = {}) { - if (client) { - return client; - } - - const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; - wsClient = new SubscriptionClient(`${protocol}://${location.host}${BASE_PATH}api/v1/live`, { +/** + * createClient setups and returns an Apollo GraphQL Client + * @param {Object} [options] configuration + * @param {string|function} [options.token] auth token + * @param {string} [options.uri] uri of the graphql server + * @param {string} [options.liveUri] uri of the graphql subscription server + * @return {Object} apollo client + */ +export function createClient(options = {}) { + const {token, uri, liveUri} = options; + const wsClient = new SubscriptionClient(liveUri, { reconnect: true, lazy: true, connectionParams: { - get token() { - - wsClientToken = getAuthToken(); - - // Try to get the token from localStorage. If it isn't here, it may - // be passed as a cookie. - - // NOTE: THIS IS ONLY EVER EVALUATED ONCE, IN ORDER TO SEND A DIFFERNT - // TOKEN YOU MUST DISCONNECT AND RECONNECT THE WEBSOCKET CLIENT. - return wsClientToken; - } + get token() { return resolveToken(token); }, } }); + const networkInterface = createNetworkInterface({ + uri, + opts: { + credentials: 'same-origin' + } + }); + + networkInterface.use([{ + applyMiddleware(req, next) { + if (!req.options.headers) { + req.options.headers = {}; // Create the header object if needed. + } + + let authToken = resolveToken(token); + if (authToken) { + req.options.headers['authorization'] = `Bearer ${authToken}`; + } + + next(); + } + }]); + const networkInterfaceWithSubscriptions = addGraphQLSubscriptions( networkInterface, wsClient, ); - client = new ApolloClient({ - ...options, + const client = new ApolloClient({ connectToDevTools: true, addTypename: true, fragmentMatcher: new IntrospectionFragmentMatcher({introspectionQueryResultData}), @@ -72,5 +74,20 @@ export function getClient(options = {}) { networkInterface: networkInterfaceWithSubscriptions, }); + client.resetWebsocket = () => { + + // Close socket connection which will also unregister subscriptions on the server-side. + wsClient.close(); + + // Reconnect to the server. + wsClient.connect(); + + // Reregister all subscriptions (uses non public api). + // See: https://github.com/apollographql/subscriptions-transport-ws/issues/171 + Object.keys(wsClient.operations).forEach((id) => { + wsClient.sendMessage(id, MessageTypes.GQL_START, wsClient.operations[id].options); + }); + }; + return client; } diff --git a/client/coral-framework/services/events.js b/client/coral-framework/services/events.js index a1a2f4448..e1b13afd9 100644 --- a/client/coral-framework/services/events.js +++ b/client/coral-framework/services/events.js @@ -1,5 +1,11 @@ +/** + * createReduxEmitter returns a redux middleware proxying redux actions to + * the event emitter + * @param {Object} eventEmitter + * @return {function} redux middleware + */ export function createReduxEmitter(eventEmitter) { - return (action) => { + return () => (next) => (action) => { // Handle apollo actions. if (action.type.startsWith('APOLLO_')) { @@ -7,8 +13,10 @@ export function createReduxEmitter(eventEmitter) { const {operationName, variables, result: {data}} = action; eventEmitter.emit(`subscription.${operationName}.data`, {variables, data}); } - return; + return next(action); } eventEmitter.emit(`action.${action.type}`, {action}); + + return next(action); }; } diff --git a/client/coral-framework/services/graphqlRegistry.js b/client/coral-framework/services/graphqlRegistry.js index 104ec41dd..938bf7f78 100644 --- a/client/coral-framework/services/graphqlRegistry.js +++ b/client/coral-framework/services/graphqlRegistry.js @@ -1,6 +1,4 @@ import {getDefinitionName, mergeDocuments} from 'coral-framework/utils'; -import {getGraphQLExtensions, getSlotFragments} from 'coral-framework/helpers/plugins'; -import globalFragments from 'coral-framework/graphql/fragments'; import uniq from 'lodash/uniq'; import {gql} from 'react-apollo'; @@ -14,272 +12,267 @@ import {gql} from 'react-apollo'; */ gql.disableFragmentWarnings(); -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}`); +class GraphQLRegistry { + fragments = {}; + mutationOptions = {}; + queryOptions = {}; + + constructor(getSlotFragments) { + this.getSlotFragments = getSlotFragments; + } + + /** + * Add fragment + * + * Example: + * addFragment('MyFragment', gql` + * fragment Plugin_MyFragment on Comment { + * body + * } + * `); + */ + addFragment(key, document) { + const type = getTypeName(document); + const name = getDefinitionName(document); + if (!(key in this.fragments)) { + this.fragments[key] = {type, names: [name], documents: [document]}; + } else { + if (type !== this.fragments[key].type) { + console.error(`Type mismatch ${type} !== ${this.fragments[key].type}`); + } + this.fragments[key].names.push(name); + this.fragments[key].documents.push(document); } - 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] || []; -} - -/** - * getSlotFragmentDocument handles `key`s in the form of TalkSlot_SlotName_Resource. - * It parses the slot name and the resource and usees the plugin API to assemble - * the fragment document. - */ -function getSlotFragmentDocument(key) { - const match = key.match(/TalkSlot_(.*)_(.*)/); - if (!match) { - return ''; } - const slot = match[1][0].toLowerCase() + match[1].substr(1); - const resource = match[2]; - const documents = getSlotFragments(slot, resource); - - if (documents.length === 0) { - return ''; - } - - const names = documents.map((d) => getDefinitionName(d)); - const typeName = getTypeName(documents[0]); - - // Assemble arguments for `gql` to call it directly without using template literals. - const main = ` - fragment ${key} on ${typeName} { - ...${names.join('\n...')}\n + /** + * 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) => { + * }, + * }) + */ + addMutationOptions(key, config) { + if (!(key in this.mutationOptions)) { + this.mutationOptions[key] = [config]; + } else { + this.mutationOptions[key].push(config); } - `; - return mergeDocuments([main, ...documents]); -} - -/** - * getRegistryFragmentDocument assembles a fragment document using - * all registered fragment under given `key`. - */ -function getRegistryFragmentDocument(key) { - if (!(key in fragments)) { - return ''; } - let documents = fragments[key].documents; - let fields = `...${fragments[key].names.join('\n...')}\n`; - - // Assemble arguments for `gql` to call it directly without using template literals. - const main = ` - fragment ${key} on ${fragments[key].type} { - ${fields} + /** + * Add query options. + * + * Example: + * addQueryOptions('EmbedQuery', { + * reducer: (previousResult, action, variables) => previousResult, + * }); + */ + addQueryOptions(key, config) { + if (!(key in this.queryOptions)) { + this.queryOptions[key] = [config]; + } else { + this.queryOptions[key].push(config); } - `; - return mergeDocuments([main, ...documents]); -} + } -/** - * getFragmentDocument returns a fragment that assembles all registered - * fragments under given `key` or if `key` refers to Slot fragments it will - * return the slot fragments specified by this key. - */ -export function getFragmentDocument(key) { - init(); - return getRegistryFragmentDocument(key) || getSlotFragmentDocument(key); -} + /** + * 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; + * }, + * }, + * }, + * }); + */ + add(extension) { + Object.keys(extension.fragments || []).forEach((key) => this.addFragment(key, extension.fragments[key])); + Object.keys(extension.mutations || []).forEach((key) => this.addMutationOptions(key, extension.mutations[key])); + Object.keys(extension.queries || []).forEach((key) => this.addQueryOptions(key, extension.queries[key])); + } -// 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; + /** + * Get a list of mutation options. + */ + getMutationOptions(key) { + return this.mutationOptions[key] || []; + } -function init() { - if (initialized) { return; } - initialized = true; + /** + * Get a list of query options. + */ + getQueryOptions(key) { + return this.queryOptions[key] || []; + } - // 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)); -} - -/** - * resolveFragments finds fragment spread names and attachs - * the related fragment document to the given root document. - */ -export function resolveFragments(document) { - if (document.loc.source) { - - // Remember keys that we have already resolved. - const resolvedKeys = []; - - // Spreads from slots that are empty and need to be removed. - // (works around the issue that we don't know the resource type - // if we don't have a fragment) - const spreadsToBeRemoved = []; - - // fragments to be attached. - const subFragments = []; - - // body contains the final result. - let body = document.loc.source.body; - - let done = false; - while (!done) { - done = true; - - const matchedSubFragments = body.match(/\.\.\.([_a-zA-Z][_a-zA-Z0-9]*)/g) || []; - uniq(matchedSubFragments.map((f) => f.replace('...', ''))) - .filter((key) => resolvedKeys.indexOf(key) === -1) - .forEach((key) => { - const doc = getFragmentDocument(key); - if (doc) { - subFragments.push(doc); - - // We found a new fragment, so we are not done yet. - done = false; - } else if(key.startsWith('TalkSlot_')) { - spreadsToBeRemoved.push(key); - } - resolvedKeys.push(key); - }); - - body = mergeDocuments([body, ...subFragments]).loc.source.body; + /** + * getSlotFragmentDocument handles `key`s in the form of TalkSlot_SlotName_Resource. + * It parses the slot name and the resource and usees the plugin API to assemble + * the fragment document. + */ + getSlotFragmentDocument(key) { + const match = key.match(/TalkSlot_(.*)_(.*)/); + if (!match) { + return ''; } - spreadsToBeRemoved.forEach((key) => { - const regex = new RegExp(`\\.\\.\\.${key}\n`, 'g'); - body = body.replace(regex, ''); - }); + const slot = match[1][0].toLowerCase() + match[1].substr(1); + const resource = match[2]; + const documents = this.getSlotFragments(slot, resource); - return gql`${body}`; - } else { - console.warn('Can only resolve fragments from documents definied using the gql tag.'); + if (documents.length === 0) { + return ''; + } + + const names = documents.map((d) => getDefinitionName(d)); + const typeName = getTypeName(documents[0]); + + // Assemble arguments for `gql` to call it directly without using template literals. + const main = ` + fragment ${key} on ${typeName} { + ...${names.join('\n...')}\n + } + `; + return mergeDocuments([main, ...documents]); + } + + /** + * getRegistryFragmentDocument assembles a fragment document using + * all registered fragment under given `key`. + */ + getRegistryFragmentDocument(key) { + if (!(key in this.fragments)) { + return ''; + } + + let documents = this.fragments[key].documents; + let fields = `...${this.fragments[key].names.join('\n...')}\n`; + + // Assemble arguments for `gql` to call it directly without using template literals. + const main = ` + fragment ${key} on ${this.fragments[key].type} { + ${fields} + } + `; + return mergeDocuments([main, ...documents]); + } + + /** + * getFragmentDocument returns a fragment that assembles all registered + * fragments under given `key` or if `key` refers to Slot fragments it will + * return the slot fragments specified by this key. + */ + getFragmentDocument(key) { + return this.getRegistryFragmentDocument(key) || this.getSlotFragmentDocument(key); + } + + /** + * resolveFragments finds fragment spread names and attachs + * the related fragment document to the given root document. + */ + resolveFragments(document) { + if (document.loc.source) { + + // Remember keys that we have already resolved. + const resolvedKeys = []; + + // Spreads from slots that are empty and need to be removed. + // (works around the issue that we don't know the resource type + // if we don't have a fragment) + const spreadsToBeRemoved = []; + + // fragments to be attached. + const subFragments = []; + + // body contains the final result. + let body = document.loc.source.body; + + let done = false; + while (!done) { + done = true; + + const matchedSubFragments = body.match(/\.\.\.([_a-zA-Z][_a-zA-Z0-9]*)/g) || []; + uniq(matchedSubFragments.map((f) => f.replace('...', ''))) + .filter((key) => resolvedKeys.indexOf(key) === -1) + .forEach((key) => { + const doc = this.getFragmentDocument(key); + if (doc) { + subFragments.push(doc); + + // We found a new fragment, so we are not done yet. + done = false; + } else if(key.startsWith('TalkSlot_')) { + spreadsToBeRemoved.push(key); + } + resolvedKeys.push(key); + }); + + body = mergeDocuments([body, ...subFragments]).loc.source.body; + } + + spreadsToBeRemoved.forEach((key) => { + const regex = new RegExp(`\\.\\.\\.${key}\n`, 'g'); + body = body.replace(regex, ''); + }); + + return gql`${body}`; + } else { + console.warn('Can only resolve fragments from documents definied using the gql tag.'); + } + return document; } - return document; +} + +/** + * createGraphQLRegistry + * @param {Function} getSlotFragments A callback with signature `(slot, part) => [documents]` to retrieve slot fragments. + * @return {Object} graphql registry + */ +export function createGraphQLRegistry(getSlotFragments) { + return new GraphQLRegistry(getSlotFragments); } diff --git a/client/coral-framework/services/history.js b/client/coral-framework/services/history.js new file mode 100644 index 000000000..c80d4b920 --- /dev/null +++ b/client/coral-framework/services/history.js @@ -0,0 +1,17 @@ +import {browserHistory} from 'react-router'; +import {useBasename} from 'history'; + +/** + * createHistory returns the history service for react router + * @param {string} basename base path of the url + * @return {Object} histor service + */ +export function createHistory(basename) { + if (!basename) { + return browserHistory; + } + + return useBasename(() => browserHistory)({ + basename + }); +} diff --git a/client/coral-framework/services/notification.js b/client/coral-framework/services/notification.js new file mode 100644 index 000000000..5730200ad --- /dev/null +++ b/client/coral-framework/services/notification.js @@ -0,0 +1,18 @@ +/** + * createNotificationService returns a notification services based on pym. + * @param {Object} pym + * @return {Object} notification service + */ +export function createNotificationService(pym) { + return { + success(msg) { + pym.sendMessage('coral-alert', `success|${msg}`); + }, + error(msg) { + pym.sendMessage('coral-alert', `error|${msg}`); + }, + info(msg) { + pym.sendMessage('coral-alert', `info|${msg}`); + }, + }; +} diff --git a/client/coral-framework/services/plugins.js b/client/coral-framework/services/plugins.js new file mode 100644 index 000000000..301428c12 --- /dev/null +++ b/client/coral-framework/services/plugins.js @@ -0,0 +1,184 @@ +import React from 'react'; +import uniq from 'lodash/uniq'; +import pick from 'lodash/pick'; +import merge from 'lodash/merge'; +import flattenDeep from 'lodash/flattenDeep'; +import isEmpty from 'lodash/isEmpty'; +import flatten from 'lodash/flatten'; +import mapValues from 'lodash/mapValues'; +import {getDisplayName} from 'coral-framework/helpers/hoc'; +import camelize from '../helpers/camelize'; +import uuid from 'uuid/v4'; + +// This is returned for pluginConfig when it is empty. +const emptyConfig = {}; + +// Memoize the warnings so we only show them once. +const memoizedWarnings = []; + +// withWarnings decorates the props of queryData with a proxy that +// prints a warning when accessing deeper props. +function withWarnings(component, queryData) { + if (process.env.NODE_ENV !== 'production' && window.Proxy) { + + // Show warnings when accessing queryData only when not in production. + return mapValues(queryData, (value, key) => { + + // Keep null values.. + if (!queryData[key]) { + return queryData[key]; + } + return new Proxy(queryData[key], { + get(target, name) { + + // Detect access from React DevTools and ignore those. + const error = new Error(); + const accessFromDevTools = ['backend.js', 'dehydrate'] + .every((keyword) => error.stack && error.stack.includes(keyword)); + + // Only care about the components defined in the plugins. + if (component.talkPluginName && !accessFromDevTools) { + const warning = `'${getDisplayName(component)}' of '${component.talkPluginName}' accessed '${key}.${name}' but did not define fragments using the withFragment HOC`; + if (memoizedWarnings.indexOf(warning) === -1) { + console.warn(warning); + memoizedWarnings.push(warning); + } + } + return queryData[key][name]; + } + }); + }); + } + + return queryData; +} + +function addMetaDataToSlotComponents(plugins) { + + // Add talkPluginName to Slot Components. + plugins.forEach((plugin) => { + const slots = plugin.module.slots; + slots && Object.keys(slots).forEach((slot) => { + slots[slot].forEach((component) => { + + // Attach plugin name to the component + component.talkPluginName = plugin.name; + + // Attach uuid to the component + component.talkUuid = uuid(); + }); + }); + }); +} + +class PluginsService { + constructor(plugins) { + this.plugins = plugins; + addMetaDataToSlotComponents(plugins); + } + + getSlotComponents(slot, reduxState, props = {}, queryData = {}) { + const pluginConfig = reduxState.config.plugin_config || emptyConfig; + return flatten(this.plugins + + // Filter out components that have slots and have been disabled in `plugin_config` + .filter((o) => o.module.slots && (!pluginConfig || !pluginConfig[o.name] || !pluginConfig[o.name].disable_components)) + + .filter((o) => o.module.slots[slot]) + .map((o) => o.module.slots[slot]) + ) + .filter((component) => { + if(!component.isExcluded) { + return true; + } + let resolvedProps = this.getSlotComponentProps(component, reduxState, props, queryData); + if (component.mapStateToProps) { + resolvedProps = {...resolvedProps, ...component.mapStateToProps(reduxState)}; + } + return !component.isExcluded(resolvedProps); + }); + } + + isSlotEmpty(slot, reduxState, props = {}, queryData = {}) { + return this.getSlotComponents(slot, reduxState, props, queryData).length === 0; + } + + /** + * getSlotComponentProps calculate the props we would pass to the slot component. + * query datas are only passed to the component if it is defined in `component.fragments`. + */ + getSlotComponentProps(component, reduxState, props, queryData) { + const pluginConfig = reduxState.config.plugin_config || emptyConfig; + return { + ...props, + config: pluginConfig, + ...( + component.fragments + ? pick(queryData, Object.keys(component.fragments)) + : withWarnings(component, queryData) + ) + }; + } + + /** + * Returns React Elements for given slot. + */ + getSlotElements(slot, reduxState, props = {}, queryData = {}) { + return this.getSlotComponents(slot, reduxState, props, queryData) + .map((component, i) => { + return React.createElement(component, {key: i, ...this.getSlotComponentProps(component, reduxState, props, queryData)}); + }); + } + + getSlotFragments(slot, part) { + const components = uniq(flattenDeep(this.plugins + .filter((o) => o.module.slots ? o.module.slots[slot] : false) + .map((o) => o.module.slots[slot]) + )); + + const documents = components + .map((c) => c.fragments) + .filter((fragments) => fragments && fragments[part]) + .reduce((res, fragments) => { + res.push(fragments[part]); + return res; + }, []); + + return documents; + } + + getGraphQLExtensions() { + return this.plugins + .map((o) => pick(o.module, ['mutations', 'queries', 'fragments'])) + .filter((o) => !isEmpty(o)); + } + + getModQueueConfigs() { + return merge(...this.plugins + .map((o) => o.module.modQueues) + .filter((o) => o)); + } + + getTranslations() { + return this.plugins + .map((o) => o.module.translations) + .filter((o) => o); + } + + getReducers() { + return merge( + ...this.plugins + .filter((o) => o.module.reducer) + .map((o) => ({[camelize(o.name)] : o.module.reducer})) + ); + } +} + +/** + * createPluginsService returns a plugins service. + * @param {Array} plugins config as returned from importing `pluginsConfig` + * @return {Object} plugins service + */ +export function createPluginsService(pluginsConfig) { + return new PluginsService(pluginsConfig); +} diff --git a/client/coral-framework/services/rest.js b/client/coral-framework/services/rest.js new file mode 100644 index 000000000..e5f9e5e09 --- /dev/null +++ b/client/coral-framework/services/rest.js @@ -0,0 +1,69 @@ +import merge from 'lodash/merge'; + +const buildOptions = (inputOptions = {}) => { + const defaultOptions = { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'same-origin' + }; + + const options = merge({}, defaultOptions, inputOptions); + if (options.method.toLowerCase() !== 'get') { + options.body = JSON.stringify(options.body); + } + + return options; +}; + +const handleResp = (res) => { + if (res.status > 399) { + return res.json().then((err) => { + let message = err.message || res.status; + const error = new Error(); + + if (err.error && err.error.metadata && err.error.metadata.message) { + error.metadata = err.error.metadata.message; + } + + if (err.error && err.error.translation_key) { + error.translation_key = err.error.translation_key; + } + + error.message = message; + error.status = res.status; + throw error; + }); + } else if (res.status === 204) { + return res.text(); + } else { + return res.json(); + } +}; + +/** + * createRestClient setups and returns a Rest Client + * @param {Object} options configuration + * @param {string} options.uri uri of the rest server + * @param {string|function} [options.token] auth token + * @return {Object} rest client + */ +export function createRestClient(options) { + const {token, uri} = options; + const client = (path, options) => { + const authToken = typeof token === 'function' ? token() : token; + let opts = options; + if (authToken) { + opts = merge({}, options, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + } + return fetch(`${uri}${path}`, buildOptions(opts)).then(handleResp); + }; + client.uri = uri; + return client; +} diff --git a/client/coral-framework/services/storage.js b/client/coral-framework/services/storage.js new file mode 100644 index 000000000..9f06595fa --- /dev/null +++ b/client/coral-framework/services/storage.js @@ -0,0 +1,43 @@ + +function getStorage(type) { + let storage = window[type], x = '__storage_test__'; + try { + storage.setItem(x, x); + storage.removeItem(x); + } catch (e) { + const ignore = ( + 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 + ); + if (!ignore) { + console.warning(e); // eslint-disable-line + return null; + } + } + return storage; +} + +/** + * createStorage returns a localStorage wrapper if available + * @return {Object} localStorage wrapper + */ +export function createStorage() { + return getStorage('localStorage'); +} diff --git a/client/coral-framework/services/store.js b/client/coral-framework/services/store.js index f87a887a3..3ccfe9e2f 100644 --- a/client/coral-framework/services/store.js +++ b/client/coral-framework/services/store.js @@ -1,68 +1,27 @@ -import {createStore, combineReducers, applyMiddleware, compose} from 'redux'; -import thunk from 'redux-thunk'; -import mainReducer from '../reducers'; -import {getClient} from './client'; - -let listeners = []; - -export function injectReducers(reducers) { - const store = getStore(); - store.coralReducers = {...store.coralReducers, ...reducers}; - store.replaceReducer(combineReducers(store.coralReducers)); -} +import {createStore as reduxCreateStore, combineReducers, applyMiddleware, compose} from 'redux'; /** - * Add a action listener to the redux store. - * The action is passed as the first argument to the callback. + * createStore creates a Redux Store + * @param {Object} reducers addtional reducers + * @param {Array} [middlewares] additional middlewares + * @return {Object} redux store */ -export function addListener(cb) { - listeners.push(cb); -} - -export function getStore() { - if (window.coralStore) { - return window.coralStore; - } - - const apolloErrorReporter = () => (next) => (action) => { - if (action.type === 'APOLLO_QUERY_ERROR') { - console.error(action.error); - } - return next(action); - }; - - const customListener = () => (next) => (action) => { - listeners.forEach((cb) => {cb(action);}); - return next(action); - }; - - const middlewares = [ +export function createStore(reducers, middlewares = []) { + const enhancers = [ applyMiddleware( - getClient().middleware(), - thunk, - apolloErrorReporter, - customListener, + ...middlewares, ), ]; if (window.devToolsExtension) { // we can't have the last argument of compose() be undefined - middlewares.push(window.devToolsExtension()); + enhancers.push(window.devToolsExtension()); } - const coralReducers = { - ...mainReducer, - apollo: getClient().reducer() - }; - - window.coralStore = createStore( - combineReducers(coralReducers), + return reduxCreateStore( + combineReducers(reducers), {}, - compose(...middlewares) + compose(...enhancers) ); - - window.coralStore.coralReducers = coralReducers; - - return window.coralStore; } diff --git a/client/coral-framework/services/transport.js b/client/coral-framework/services/transport.js deleted file mode 100644 index 060bfc34b..000000000 --- a/client/coral-framework/services/transport.js +++ /dev/null @@ -1,37 +0,0 @@ -import {createNetworkInterface} from 'apollo-client'; -import {getAuthToken} from '../helpers/request'; -import {BASE_PATH} from 'coral-framework/constants/url'; - -//============================================================================== -// NETWORK INTERFACE -//============================================================================== - -const networkInterface = createNetworkInterface({ - uri: `${BASE_PATH}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. - } - - let authToken = getAuthToken(); - if (authToken) { - req.options.headers['authorization'] = `Bearer ${authToken}`; - } - - next(); - } -}]); - -export { - networkInterface -}; diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 2b4b6378f..4424e62c5 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -2,6 +2,7 @@ import {gql} from 'react-apollo'; import t from 'coral-framework/services/i18n'; import union from 'lodash/union'; import {capitalize} from 'coral-framework/helpers/strings'; +export * from 'coral-framework/helpers/strings'; export const getTotalActionCount = (type, comment) => { return comment.action_summaries @@ -25,7 +26,7 @@ export const getMyActionSummary = (type, comment) => { .find((a) => a.current_user); }; - /** +/** * getActionSummary * retrieves the action summaries based on the type and the comment * array could be length > 1, as in the case of FlagActionSummary @@ -147,25 +148,18 @@ export function forEachError(error, callback) { }); } -const ascending = (a, b) => { - const dateA = new Date(a.created_at); - const dateB = new Date(b.created_at); - if (dateA < dateB) { return -1; } - if (dateA > dateB) { return 1; } - return 0; -}; +export function getErrorMessages(error) { + const result = []; + forEachError(error, ({msg}) => result.push(msg)); + return result; +} -const descending = (a, b) => ascending(a, b) * -1; +export function appendNewNodes(nodesA, nodesB) { + return nodesA.concat(nodesB.filter((nodeB) => !nodesA.some((nodeA) => nodeA.id === nodeB.id))); +} -export function insertCommentsSorted(nodes, comments, sortOrder = 'CHRONOLOGICAL') { - const added = nodes.concat(comments); - if (sortOrder === 'CHRONOLOGICAL') { - return added.sort(ascending); - } - if (sortOrder === 'REVERSE_CHRONOLOGICAL') { - return added.sort(descending); - } - throw new Error(`Unknown sort order ${sortOrder}`); +export function prependNewNodes(nodesA, nodesB) { + return nodesB.filter((nodeB) => !nodesA.some((nodeA) => nodeA.id === nodeB.id)).concat(nodesA); } export const isTagged = (tags, which) => tags.some((t) => t.tag.name === which); @@ -190,3 +184,16 @@ export function getShallowChanges(a, b) { return union(Object.keys(a), Object.keys(b)) .filter((key) => a[key] !== b[key]); } + +// TODO: replace with something less fragile. +// NOT_REACTION_TYPES are the action summaries that are not reactions. +const NOT_REACTION_TYPES = [ + 'FlagActionSummary', + 'DontAgreeActionSummary', +]; + +export function getTotalReactionsCount(actionSummaries) { + return actionSummaries + .filter(({__typename}) => !NOT_REACTION_TYPES.includes(__typename)) + .reduce((total, {count}) => total + count, 0); +} diff --git a/client/coral-framework/utils/user.js b/client/coral-framework/utils/user.js index 044583026..ccdf8ba42 100644 --- a/client/coral-framework/utils/user.js +++ b/client/coral-framework/utils/user.js @@ -1,4 +1,4 @@ - /** +/** * getReliability * retrieves reliability value as string */ diff --git a/client/coral-settings/components/IgnoredUsers.js b/client/coral-settings/components/IgnoredUsers.js index 32e485e4c..a8723161f 100644 --- a/client/coral-settings/components/IgnoredUsers.js +++ b/client/coral-settings/components/IgnoredUsers.js @@ -2,7 +2,7 @@ import React, {Component, PropTypes} from 'react'; import t from 'coral-framework/services/i18n'; import styles from './IgnoredUsers.css'; -export class IgnoredUsers extends Component { +class IgnoredUsers extends Component { static propTypes = { users: PropTypes.arrayOf(PropTypes.shape({ username: PropTypes.string, diff --git a/client/coral-settings/containers/ProfileContainer.js b/client/coral-settings/containers/ProfileContainer.js index f1b3787dd..db47de8e2 100644 --- a/client/coral-settings/containers/ProfileContainer.js +++ b/client/coral-settings/containers/ProfileContainer.js @@ -11,8 +11,11 @@ import NotLoggedIn from '../components/NotLoggedIn'; import IgnoredUsers from '../components/IgnoredUsers'; import {Spinner} from 'coral-ui'; import CommentHistory from 'talk-plugin-history/CommentHistory'; -import {showSignInDialog, checkLogin} from 'coral-framework/actions/auth'; -import {insertCommentsSorted} from 'plugin-api/beta/client/utils'; + +// TODO: Auth logic needs refactoring. +import {showSignInDialog, checkLogin} from 'coral-embed-stream/src/actions/auth'; + +import {appendNewNodes} from 'plugin-api/beta/client/utils'; import update from 'immutability-helper'; import {getSlotFragmentSpreads} from 'coral-framework/utils'; @@ -34,12 +37,12 @@ class ProfileContainer extends Component { limit: 5, cursor: this.props.root.me.comments.endCursor, }, - updateQuery: (previous, {fetchMoreResult:{comments}}) => { + updateQuery: (previous, {fetchMoreResult:{me: {comments}}}) => { const updated = update(previous, { me: { comments: { nodes: { - $apply: (nodes) => insertCommentsSorted(nodes, comments.nodes, 'REVERSE_CHRONOLOGICAL'), + $apply: (nodes) => appendNewNodes(nodes, comments.nodes), }, hasNextPage: {$set: comments.hasNextPage}, endCursor: {$set: comments.endCursor}, @@ -76,12 +79,12 @@ class ProfileContainer extends Component { {me.ignoredUsers && me.ignoredUsers.length ?
-

{t('framework.ignored_users')}

- -
+

{t('framework.ignored_users')}

+ +
: null}
@@ -105,6 +108,11 @@ const CommentFragment = gql` nodes { id body + replyCount + action_summaries { + count + __typename + } asset { id title @@ -120,9 +128,11 @@ const CommentFragment = gql` `; const LOAD_MORE_QUERY = gql` - query TalkSettings_LoadMoreComments($limit: Int, $cursor: Date) { - comments(query: {limit: $limit, cursor: $cursor}) { - ...TalkSettings_CommentConnectionFragment + query TalkSettings_LoadMoreComments($limit: Int, $cursor: Cursor) { + me { + comments(query: {limit: $limit, cursor: $cursor}) { + ...TalkSettings_CommentConnectionFragment + } } } ${CommentFragment} diff --git a/client/coral-ui/components/Badge.css b/client/coral-ui/components/Badge.css new file mode 100644 index 000000000..db68ebd58 --- /dev/null +++ b/client/coral-ui/components/Badge.css @@ -0,0 +1,20 @@ +.badge { + display: inline-block; + color: white; + background: grey; + box-sizing: border-box; + padding: 2px 5px; + font-size: 12px; + height: 24px; + letter-spacing: 0.4px; + line-height: 22px; + background-color: #3D73D5; + margin-right: 4px; +} + +.icon { + font-size: 14px; + vertical-align: text-top; + margin: 0; + margin-right: 4px; +} \ No newline at end of file diff --git a/client/coral-ui/components/Badge.js b/client/coral-ui/components/Badge.js new file mode 100644 index 000000000..c1ba8100b --- /dev/null +++ b/client/coral-ui/components/Badge.js @@ -0,0 +1,13 @@ +import React from 'react'; +import styles from './Badge.css'; +import Icon from './Icon'; +import cn from 'classnames'; + +const Badge = ({className, children, icon, props}) => ( + + {icon && } + {children} + +); + +export default Badge; diff --git a/client/coral-ui/components/CoralLogo.js b/client/coral-ui/components/CoralLogo.js index 002b0cabc..91331523f 100644 --- a/client/coral-ui/components/CoralLogo.js +++ b/client/coral-ui/components/CoralLogo.js @@ -3,34 +3,34 @@ import styles from './CoralLogo.css'; const CoralLogo = ({className = ''}) => ( - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - + ); CoralLogo.propTypes = { diff --git a/client/coral-ui/components/Dialog.js b/client/coral-ui/components/Dialog.js index 85ab570d3..6e2489fea 100644 --- a/client/coral-ui/components/Dialog.js +++ b/client/coral-ui/components/Dialog.js @@ -56,7 +56,7 @@ export default class Dialog extends Component { className={`mdl-dialog ${className}`} {...rest} > - {children} + {children} ); } diff --git a/client/coral-ui/components/Pager.js b/client/coral-ui/components/Pager.js index ffd1b9d0a..d14f2fc60 100644 --- a/client/coral-ui/components/Pager.js +++ b/client/coral-ui/components/Pager.js @@ -3,7 +3,7 @@ import styles from './Pager.css'; const Rows = (curr, total, onClickHandler) => Array.from(Array(total)).map((e, i) =>
  • onClickHandler(i + 1)}> + key={i} onClick={() => onClickHandler(i + 1)}> {i + 1}
  • ); @@ -17,18 +17,18 @@ const Pager = ({totalPages, page, onNewPageHandler}) => ( onClick={() => onNewPageHandler(page - 1)}> Prev - : + : null } {Rows(page, totalPages, onNewPageHandler)} { (page < totalPages && totalPages > 1) ? -
  • onNewPageHandler(page + 1)}> +
  • onNewPageHandler(page + 1)}> Next -
  • - : - null + + : + null }
    diff --git a/client/coral-ui/components/Wizard.js b/client/coral-ui/components/Wizard.js index eefa2a0b1..bc87a8bcc 100644 --- a/client/coral-ui/components/Wizard.js +++ b/client/coral-ui/components/Wizard.js @@ -7,12 +7,12 @@ const Wizard = (props) => { {React.Children.toArray(children) .filter((child, i) => i === currentStep) .map((child, i) => - React.cloneElement(child, { - i, - currentStep, - ...rest - }) - )} + React.cloneElement(child, { + i, + currentStep, + ...rest + }) + )} ); }; diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js index 8a538d120..8f261bcca 100644 --- a/client/coral-ui/index.js +++ b/client/coral-ui/index.js @@ -26,3 +26,4 @@ export {default as Option} from './components/Option'; export {default as SnackBar} from './components/SnackBar'; export {default as TextArea} from './components/TextArea'; export {default as Drawer} from './components/Drawer'; +export {default as Badge} from './components/Badge'; diff --git a/client/talk-plugin-author-name/AuthorName.js b/client/talk-plugin-author-name/AuthorName.js deleted file mode 100644 index 89835a742..000000000 --- a/client/talk-plugin-author-name/AuthorName.js +++ /dev/null @@ -1,31 +0,0 @@ -import React, {Component} from 'react'; -const packagename = 'talk-plugin-author-name'; - -export default class AuthorName extends Component { - - state = {showTooltip: false} - - handleClick = () => { - this.setState((state) => ({ - showTooltip: !state.showTooltip - })); - } - - handleMouseLeave = () => { - setTimeout(() => { - this.setState({ - showTooltip: false - }); - }, 500); - } - - render () { - const {author} = this.props; - return ( -
    - {author && author.username} -
    - ); - } -} diff --git a/client/talk-plugin-author-name/styles.css b/client/talk-plugin-author-name/styles.css deleted file mode 100644 index b7870a862..000000000 --- a/client/talk-plugin-author-name/styles.css +++ /dev/null @@ -1,34 +0,0 @@ -.authorName { - color: black; - display: inline-block; - margin: 10px 8px 10px 0; -} - -.hasBio { - &:hover { - cursor: pointer; - } -} - -.arrowDown { - top: 0; - width: 0; - height: 0; - margin-top: -2px; - margin-left: 2px; - display: inline-block; - vertical-align: middle; - border-bottom: 0; - border-left: 3px solid transparent; - border-right: 3px solid transparent; - border-top: 3px solid #000000; -} - -.arrowUp { - width: 0; - height: 0; - border-top: 0; - border-left: 3px solid transparent; - border-right: 3px solid transparent; - border-bottom: 3px solid black; -} diff --git a/client/talk-plugin-commentbox/CommentBox.js b/client/talk-plugin-commentbox/CommentBox.js index d0fa7d72f..6ee2009ee 100644 --- a/client/talk-plugin-commentbox/CommentBox.js +++ b/client/talk-plugin-commentbox/CommentBox.js @@ -12,11 +12,11 @@ export const name = 'talk-plugin-commentbox'; // Given a newly posted comment's status, show a notification to the user // if needed -export const notifyForNewCommentStatus = (addNotification, status) => { +export const notifyForNewCommentStatus = (notify, status) => { if (status === 'REJECTED') { - addNotification('error', t('comment_box.comment_post_banned_word')); + notify('error', t('comment_box.comment_post_banned_word')); } else if (status === 'PREMOD') { - addNotification('success', t('comment_box.comment_post_notif_premod')); + notify('success', t('comment_box.comment_post_notif_premod')); } }; @@ -45,12 +45,12 @@ class CommentBox extends React.Component { postComment, assetId, parentId, - addNotification, + notify, currentUser, } = this.props; if (!can(currentUser, 'INTERACT_WITH_COMMUNITY')) { - addNotification('error', t('error.NOT_AUTHORIZED')); + notify('error', t('error.NOT_AUTHORIZED')); return; } @@ -73,7 +73,7 @@ class CommentBox extends React.Component { // Execute postSubmit Hooks this.state.hooks.postSubmit.forEach((hook) => hook(data)); - notifyForNewCommentStatus(addNotification, postedComment.status); + notifyForNewCommentStatus(notify, postedComment.status); if (commentPostedHandler) { commentPostedHandler(); @@ -81,7 +81,7 @@ class CommentBox extends React.Component { }) .catch((err) => { this.setState({loadingState: 'error'}); - forEachError(err, ({msg}) => addNotification('error', msg)); + forEachError(err, ({msg}) => notify('error', msg)); }); } @@ -186,7 +186,7 @@ CommentBox.propTypes = { currentUser: PropTypes.object.isRequired, isReply: PropTypes.bool.isRequired, canPost: PropTypes.bool, - addNotification: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, }; const mapStateToProps = ({commentBox}) => ({commentBox}); diff --git a/client/talk-plugin-flags/components/FlagButton.js b/client/talk-plugin-flags/components/FlagButton.js index 1d8438ba0..a225bda41 100644 --- a/client/talk-plugin-flags/components/FlagButton.js +++ b/client/talk-plugin-flags/components/FlagButton.js @@ -42,7 +42,7 @@ export default class FlagButton extends Component { this.setState({showMenu: true}); } } else { - this.props.addNotification('error', t('error.NOT_AUTHORIZED')); + this.props.notify('error', t('error.NOT_AUTHORIZED')); } } @@ -93,18 +93,24 @@ export default class FlagButton extends Component { }; if (reason === 'COMMENT_NOAGREE') { postDontAgree(action) - .then(({data}) => { - if (itemType === 'COMMENTS') { - this.setState({localPost: data.createDontAgree.dontagree.id}); - } - }); + .then(({data}) => { + if (itemType === 'COMMENTS') { + this.setState({localPost: data.createDontAgree.dontagree.id}); + } + }) + .catch((err) => { + console.error(err); + }); } else { postFlag({...action, reason}) - .then(({data}) => { - if (itemType === 'COMMENTS') { - this.setState({localPost: data.createFlag.flag.id}); - } - }); + .then(({data}) => { + if (itemType === 'COMMENTS') { + this.setState({localPost: data.createFlag.flag.id}); + } + }) + .catch((err) => { + console.error(err); + }); } } } @@ -153,15 +159,15 @@ export default class FlagButton extends Component { className={cn(`${name}-button`, {[`${name}-button-flagged`]: flagged}, styles.button)}> { flagged - ? {t('reported')} - : {t('report')} + ? {t('reported')} + : {t('report')} } flag + aria-hidden={true}>flag { this.state.showMenu && @@ -190,10 +196,10 @@ export default class FlagButton extends Component { } { this.state.reason &&
    -
    -
    onHeaderClickHandler({field: header.field})}> + key={i} + className="mdl-data-table__cell--non-numeric" + onClick={() => onHeaderClickHandler({field: header.field})}> {header.title}