diff --git a/bin/cli b/bin/cli index 6237d4740..314f33dd4 100755 --- a/bin/cli +++ b/bin/cli @@ -18,7 +18,6 @@ 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' @@ -41,20 +40,3 @@ if (!commands.includes(command)) { } process.exit(1); } - -// /** -// * When this process exists, check to see if we have a running command, if we do -// * check to see if it is still running. If it is, then kill it with a SIGINT -// * signal. This is for the use case where we want to kill the process that is -// * labeled with the PID written out by the parent process. -// */ -// process.once('exit', () => { -// if ( - -// // program.runningCommand && -// program.runningCommand.killed === false && -// program.runningCommand.exitCode === null -// ) { -// program.runningCommand.kill('SIGINT'); -// } -// }); diff --git a/bin/cli-migration b/bin/cli-migration index ca93360ad..63e16f97f 100755 --- a/bin/cli-migration +++ b/bin/cli-migration @@ -5,6 +5,7 @@ */ const util = require('./util'); +const _ = require('lodash'); const program = require('commander'); const inquirer = require('inquirer'); const mongoose = require('../services/mongoose'); @@ -25,46 +26,60 @@ async function createMigration(name) { } } -async function runMigrations() { +async function runMigrations(options) { + const { yes, queryBatchSize, updateBatchSize } = options; try { - let { backedUp } = await inquirer.prompt([ - { - type: 'confirm', - name: 'backedUp', - message: 'Did you perform a database backup', - default: false, - }, - ]); + if (!yes) { + const { backedUp } = await inquirer.prompt([ + { + type: 'confirm', + name: 'backedUp', + message: 'Did you perform a database backup', + default: false, + }, + ]); - if (!backedUp) { - throw new Error( - 'Please backup your databases prior to migrations occuring' - ); + if (!backedUp) { + throw new Error( + 'Please backup your databases prior to migrations occuring' + ); + } } // Get the migrations to run. - let migrations = await MigrationService.listPending(); + const migrations = await MigrationService.listPending(); console.log('Now going to run the following migrations:\n'); - for (let { filename } of migrations) { + for (const { filename } of migrations) { console.log(`\tmigrations/${filename}`); } - let { confirm } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirm', - message: 'Proceed with migrations', - default: false, - }, - ]); + if (!yes) { + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: 'Proceed with migrations', + default: false, + }, + ]); - if (confirm) { - // Run the migrations. - await MigrationService.run(migrations); + if (confirm) { + // Run the migrations. + await MigrationService.run(migrations, { + queryBatchSize, + updateBatchSize, + }); + } else { + console.warn('Skipping migrations'); + } } else { - console.warn('Skipping migrations'); + // Run the migrations. + await MigrationService.run(migrations, { + queryBatchSize, + updateBatchSize, + }); } util.shutdown(); @@ -83,8 +98,25 @@ program .description('creates a new migration') .action(createMigration); +// Bypasses issue that defaults + coercion doesn't work well together. +// Ref: https://github.com/tj/commander.js/issues/400#issuecomment-310860869 +const parse10 = _.ary(_.partialRight(parseInt, 10), 1); + program .command('run') + .option( + '-q, --query-batch-size ', + 'change the size of queried documents that are batched at a time', + parse10, + 10000 + ) + .option( + '-u, --update-batch-size ', + 'change the size of documents that are batched before the update is sent', + parse10, + 20000 + ) + .option('-y, --yes', 'will answer yes to all questions') .description('runs all pending migrations') .action(runMigrations); diff --git a/bin/cli-plugins b/bin/cli-plugins index a99e97649..883682ede 100755 --- a/bin/cli-plugins +++ b/bin/cli-plugins @@ -235,7 +235,7 @@ async function reconcileLocalPlugins({ skipRemote, dryRun }) { if (output.status) { throw new Error( - 'Could not install local plugin dependencies, errors occured during install' + 'Could not install local plugin dependencies, errors occurred during install' ); } @@ -253,59 +253,61 @@ async function reconcilePluginDeps({ dryRun, upgradeRemote, }) { - let startTime = new Date(); + try { + let startTime = new Date(); - // We don't need to do anything if we skip everything.... - if (skipLocal && skipRemote) { - return; - } + // We don't need to do anything if we skip everything.... + if (skipLocal && skipRemote) { + return; + } - // Traverse local plugins and install dependencies if enabled. - if (!skipLocal) { - await reconcileLocalPlugins({ skipRemote, dryRun }); - } + // Traverse local plugins and install dependencies if enabled. + if (!skipLocal) { + await reconcileLocalPlugins({ skipRemote, dryRun }); + } - // Locate any external plugins and install them. - if (!skipRemote) { - let results = []; - try { - results = await reconcileRemotePlugins({ + // Locate any external plugins and install them. + if (!skipRemote) { + const results = await reconcileRemotePlugins({ skipLocal, skipRemote, dryRun, upgradeRemote, }); - } catch (e) { - throw e; + + let status; + if (dryRun) { + status = '[dry-run] success'.green; + } else { + status = 'success'.green; + } + + let message; + if (results.upgradable.length === 0 && results.fetchable.length === 0) { + message = 'Already up-to-date.'; + } else if (results.upgradable.length === 0) { + message = `Fetched ${results.fetchable.length} new plugins.`; + } else if (results.fetchable.length === 0) { + message = `Upgraded ${results.upgradable.length} new plugins.`; + } else { + message = `Fetched ${results.fetchable.length} new plugins, upgraded ${ + results.upgradable.length + } plugins.`; + } + + console.log(`\n${status} ${message}`); } - let status; - if (dryRun) { - status = '[dry-run] success'.green; - } else { - status = 'success'.green; - } + let endTime = new Date(); - let message; - if (results.upgradable.length === 0 && results.fetchable.length === 0) { - message = 'Already up-to-date.'; - } else if (results.upgradable.length === 0) { - message = `Fetched ${results.fetchable.length} new plugins.`; - } else if (results.fetchable.length === 0) { - message = `Upgraded ${results.upgradable.length} new plugins.`; - } else { - message = `Fetched ${results.fetchable.length} new plugins, upgraded ${ - results.upgradable.length - } plugins.`; - } - - console.log(`\n${status} ${message}`); + let totalTime = ((endTime.getTime() - startTime.getTime()) / 1000).toFixed( + 2 + ); + console.log(`✨ Done in ${totalTime}s.`); + } catch (err) { + console.error(err); + process.exit(1); } - - let endTime = new Date(); - - let totalTime = ((endTime.getTime() - startTime.getTime()) / 1000).toFixed(2); - console.log(`✨ Done in ${totalTime}s.`); } async function createSeedPlugin() { diff --git a/bin/cli-setup b/bin/cli-setup index b0f5e2efa..282a8a2d7 100755 --- a/bin/cli-setup +++ b/bin/cli-setup @@ -13,6 +13,7 @@ const MODERATION_OPTIONS = require('../models/enum/moderation_options'); const SettingsService = require('../services/settings'); const SetupService = require('../services/setup'); const UsersService = require('../services/users'); +const MigrationService = require('../services/migration'); const errors = require('../errors'); // Register the shutdown criteria. @@ -51,6 +52,12 @@ const performSetup = async () => { if (program.defaults) { await SettingsService.init(); + // Get the migrations to run. + let migrations = await MigrationService.listPending(); + + // Perform all migrations. + await MigrationService.run(migrations); + console.log('Settings created.'); console.log('\nTalk is now installed!'); @@ -194,7 +201,7 @@ const performSetup = async () => { ); }; -// Start tthe setup process. +// Start the setup process. performSetup() .then(() => { util.shutdown(); diff --git a/bin/cli-users b/bin/cli-users index 6e901f529..ea308ae44 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -8,6 +8,7 @@ const util = require('./util'); const program = require('commander'); const inquirer = require('inquirer'); const { graphql } = require('graphql'); +const helpers = require('../services/migration/helpers'); const { stripIndent } = require('common-tags'); const Table = require('cli-table'); @@ -28,11 +29,23 @@ const CommentModel = require('../models/comment'); const ActionModel = require('../models/action'); const USER_ROLES = require('../models/enum/user_roles'); const mongoose = require('../services/mongoose'); -const databaseVerifications = require('./verifications/database'); // Register the shutdown criteria. util.onshutdown([() => mongoose.disconnect()]); +/** + * transforms a specific action to a removal action on the target model. + */ +const actionDecrTransformer = ({ item_id, action_type, group_id }) => ({ + query: { id: item_id }, + update: { + $inc: { + [`action_counts.${action_type.toLowerCase()}`]: -1, + [`action_counts.${action_type.toLowerCase()}_${group_id.toLowerCase()}`]: -1, + }, + }, +}); + /** * Deletes a user and cleans up their associated verifications. */ @@ -63,8 +76,27 @@ async function deleteUser(userID) { return util.shutdown(); } + const { transformSingleWithCursor } = helpers({ + queryBatchSize: 10000, + updateBatchSize: 10000, + }); + console.warn("Removing user's actions"); + // Remove all actions against comments. + await transformSingleWithCursor( + ActionModel.collection.find({ user_id: user.id, item_type: 'COMMENTS' }), + actionDecrTransformer, + CommentModel + ); + + // Remove all actions against users. + await transformSingleWithCursor( + ActionModel.collection.find({ user_id: user.id, item_type: 'USERS' }), + actionDecrTransformer, + UserModel + ); + // Remove all the user's actions. await ActionModel.where({ user_id: user.id }) .setOptions({ multi: true }) @@ -72,18 +104,34 @@ async function deleteUser(userID) { console.warn("Removing user's comments"); + // Removes all the user's reply counts on each of the comments that they + // have commented on. + await transformSingleWithCursor( + CommentModel.collection.aggregate([ + { $match: { author_id: user.id } }, + { + $group: { + _id: '$parent_id', + count: { $sum: 1 }, + }, + }, + ]), + ({ _id: parent_id, count }) => ({ + query: { id: parent_id }, + update: { + $inc: { + reply_count: -1 * count, + }, + }, + }), + CommentModel + ); + // Remove all the user's comments. await CommentModel.where({ author_id: user.id }) .setOptions({ multi: true }) .remove(); - console.warn('Updating the database indexes'); - - // Update the counts that might have changed. - for (const verification of databaseVerifications) { - await verification({ fix: true, limit: Infinity, batch: 1000 }); - } - console.warn('Removing the user'); // Remove the user. diff --git a/bin/cli-verify b/bin/cli-verify deleted file mode 100755 index e32c7d169..000000000 --- a/bin/cli-verify +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node - -/** - * Module dependencies. - */ - -const util = require('./util'); -const program = require('commander'); -const mongoose = require('../services/mongoose'); -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/util.js b/bin/util.js index 0a9adb966..e80f123a5 100644 --- a/bin/util.js +++ b/bin/util.js @@ -63,5 +63,6 @@ process.once('SIGUSR2', () => util.shutdown(0, 'SIGUSR2')); // ignoring them. In the future, promise rejections that are not handled will // terminate the Node.js process with a non-zero exit code. process.on('unhandledRejection', err => { - throw err; + console.error(err); + process.exit(1); }); diff --git a/bin/verifications/database/action_counts.js b/bin/verifications/database/action_counts.js deleted file mode 100644 index 684c17bca..000000000 --- a/bin/verifications/database/action_counts.js +++ /dev/null @@ -1,194 +0,0 @@ -const UserModel = require('../../../models/user'); -const CommentModel = require('../../../models/comment'); -const ActionsService = require('../../../services/actions'); -const { arrayJoinBy } = require('../../../graph/loaders/util'); -const { get } = require('lodash'); -const debug = require('debug')('talk:cli:verify'); - -const MODELS = [UserModel, CommentModel]; - -async function processBatch(Model, documents) { - // Get an array of all the document id's. - const documentIDs = documents.map(({ id }) => id); - - // Store all the operations on this batch in this array that we'll return - // later. - const operations = []; - - // Get the action summaries for this batch. - const totalActionSummaries = await ActionsService.getActionSummaries( - documentIDs - ).then(arrayJoinBy(documentIDs, 'item_id')); - - // Iterate over the documents. - for (let i = 0; i < documents.length; i++) { - const document = documents[i]; - const actionSummaries = totalActionSummaries[i]; - - let ops = []; - - for (const actionSummary of actionSummaries) { - if (actionSummary.group_id === null) { - continue; - } - - // And we generate the group id. - const ACTION_TYPE = actionSummary.action_type.toLowerCase(); - const GROUP_ID = 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 ( - get(document, ['action_counts', ACTION_COUNT_FIELD]) !== - actionSummary.count - ) { - // Batch updates for those changes. - ops.push({ - [`action_counts.${ACTION_COUNT_FIELD}`]: actionSummary.count, - }); - } - } - - // Group all the action summaries together from all the different group - // ids. - const groupedActionSummaries = actionSummaries.reduce( - (acc, actionSummary) => { - // action_type is already snake cased (as it would have had to be when it - // was inserted in the database). - const ACTION_TYPE = 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 (get(document, ['action_counts', ACTION_COUNT_FIELD]) !== count) { - // Batch updates for those changes. - ops.push({ - [`action_counts.${ACTION_COUNT_FIELD}`]: count, - }); - } - } - - // If this comment has action summaries that should be updated, then - // perform an update! - if (ops.length > 0) { - operations.push({ - updateOne: { - filter: { - id: document.id, - }, - update: { - $set: Object.assign({}, ...ops), - }, - }, - }); - } - } - - return operations; -} - -module.exports = async ({ fix, batch }) => { - for (const Model of MODELS) { - const cursor = Model.collection - .find({}) - .project({ - id: 1, - action_counts: 1, - }) - .sort({ created_at: 1 }); - - let operations = []; - let documents = []; - - // While there are documents to process. - while (await cursor.hasNext()) { - // Load the document. - const document = await cursor.next(); - - // Push the document into the documents array. - documents.push(document); - - // Check to see if the length of the documents array requires us to - // process it. - if (documents.length > batch) { - // Process this batch. - let batchOperations = await processBatch(Model, documents); - - // Push the batch operations into the model operations. - operations.push(...batchOperations); - - // Clear this batch contents. - documents = []; - } - } - - // Check to see if there are any documents left over. - if (documents.length > 0) { - // Process this batch. - let batchOperations = await processBatch(Model, documents); - - // Push the batch operations into the model operations. - operations.push(...batchOperations); - } - - const OPERATIONS_LENGTH = operations.length; - - console.log( - `action_counts.js: ${OPERATIONS_LENGTH} ${ - Model.collection.name - } need their action counts fixed.` - ); - - // If fix was enabled, execute the batch writes. - if (OPERATIONS_LENGTH > 0) { - if (fix) { - debug( - `action_counts.js: fixing ${OPERATIONS_LENGTH} ${ - Model.collection.name - }...` - ); - - while (operations.length) { - let result = await Model.collection.bulkWrite( - operations.splice(0, batch) - ); - - debug( - `action_counts.js: fixed batch of ${result.modifiedCount} ${ - Model.collection.name - }.` - ); - } - - console.log( - `action_counts.js: applied all ${OPERATIONS_LENGTH} fixes to ${ - Model.collection.name - }.` - ); - } else { - console.warn( - 'Skipping fixing, --fix was not enabled, pass --fix to fix these errors' - ); - } - } - } -}; diff --git a/bin/verifications/database/comment_replies.js b/bin/verifications/database/comment_replies.js deleted file mode 100644 index 309023c75..000000000 --- a/bin/verifications/database/comment_replies.js +++ /dev/null @@ -1,140 +0,0 @@ -const CommentModel = require('../../../models/comment'); -const { singleJoinBy } = require('../../../graph/loaders/util'); -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))); - - // Loop over the comments, with their action summaries. - for (let i = 0; i < comments.length; i++) { - let comment = comments[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, - }); - } - - // 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 deleted file mode 100644 index 58a46bd85..000000000 --- a/bin/verifications/database/index.js +++ /dev/null @@ -1,10 +0,0 @@ -// 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 derived. -module.exports = [require('./comment_replies'), require('./action_counts')]; diff --git a/circle.yml b/circle.yml index d45828540..bd5c2c520 100644 --- a/circle.yml +++ b/circle.yml @@ -37,7 +37,7 @@ dependencies: # Install node dependencies. - yarn --version - - yarn global add node-gyp nsp --force + - yarn global add node-gyp --force - yarn post: @@ -59,8 +59,6 @@ test: - yarn test # Run the end to end tests - yarn e2e:ci - # Check dependancies using nsp. - - nsp check deployment: release: diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index d2b8f1a3f..9a0452ea1 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -11,6 +11,7 @@ import { HIDE_BANUSER_DIALOG, SHOW_REJECT_USERNAME_DIALOG, HIDE_REJECT_USERNAME_DIALOG, + SET_INDICATOR_TRACK, } from '../constants/community'; import t from 'coral-framework/services/i18n'; @@ -68,3 +69,9 @@ export const showRejectUsernameDialog = user => ({ export const hideRejectUsernameDialog = () => ({ type: HIDE_REJECT_USERNAME_DIALOG, }); + +// Enable or disable the activity indicator subscriptions. +export const setIndicatorTrack = track => ({ + type: SET_INDICATOR_TRACK, + track, +}); diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index cd09b04b2..7899b7685 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -31,10 +31,16 @@ export const storySearchChange = value => ({ }); export const clearState = () => ({ - type: actions.MODERATION_CLEAR_STATE, + type: actions.CLEAR_STATE, }); export const selectCommentId = id => ({ - type: actions.MODERATION_SELECT_COMMENT, + type: actions.SELECT_COMMENT, id, }); + +// Enable or disable the activity indicator subscriptions. +export const setIndicatorTrack = track => ({ + type: actions.SET_INDICATOR_TRACK, + track, +}); diff --git a/client/coral-admin/src/components/Header.js b/client/coral-admin/src/components/Header.js index e6bd73f52..6b2274d27 100644 --- a/client/coral-admin/src/components/Header.js +++ b/client/coral-admin/src/components/Header.js @@ -15,6 +15,7 @@ const CoralHeader = ({ showShortcuts = () => {}, auth, root, + data, }) => { return (
@@ -31,7 +32,7 @@ const CoralHeader = ({ activeClassName={styles.active} > {t('configure.moderate')} - + )} {t('configure.community')} - + {can(auth.user, 'UPDATE_CONFIG') && ( @@ -119,6 +120,7 @@ CoralHeader.propTypes = { showShortcuts: PropTypes.func, handleLogout: PropTypes.func.isRequired, root: PropTypes.object.isRequired, + data: PropTypes.object.isRequired, }; export default CoralHeader; diff --git a/client/coral-admin/src/components/ModerationKeysModal.js b/client/coral-admin/src/components/ModerationKeysModal.js index c8066b81d..4e0eb3ac3 100644 --- a/client/coral-admin/src/components/ModerationKeysModal.js +++ b/client/coral-admin/src/components/ModerationKeysModal.js @@ -23,7 +23,7 @@ export default class ModerationKeysModal extends React.Component { 'ctrl+f': 'modqueue.toggle_search', t: 'modqueue.next_queue', [`1...${this.props.queueCount}`]: 'modqueue.jump_to_queue', - s: 'modqueue.singleview', + z: 'modqueue.singleview', '?': 'modqueue.thismenu', }, }, diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js index d226ac744..71d2ed1a3 100644 --- a/client/coral-admin/src/components/UserDetail.js +++ b/client/coral-admin/src/components/UserDetail.js @@ -2,7 +2,6 @@ import React from 'react'; import cn from 'classnames'; import PropTypes from 'prop-types'; import capitalize from 'lodash/capitalize'; -import { getErrorMessages } from 'coral-framework/utils'; import styles from './UserDetail.css'; import AccountHistory from './AccountHistory'; import { Slot } from 'coral-framework/components'; @@ -29,43 +28,23 @@ import UserInfoTooltip from './UserInfoTooltip'; class UserDetail extends React.Component { rejectThenReload = async info => { - try { - await this.props.rejectComment(info); - this.props.data.refetch(); - } catch (err) { - console.error(err); - this.props.notify('error', getErrorMessages(err)); - } + await this.props.rejectComment(info); + this.props.data.refetch(); }; acceptThenReload = async info => { - try { - await this.props.acceptComment(info); - this.props.data.refetch(); - } catch (err) { - console.error(err); - this.props.notify('error', getErrorMessages(err)); - } + await this.props.acceptComment(info); + this.props.data.refetch(); }; bulkAcceptThenReload = async () => { - try { - await this.props.bulkAccept(); - this.props.data.refetch(); - } catch (err) { - console.error(err); - this.props.notify('error', getErrorMessages(err)); - } + await this.props.bulkAccept(); + this.props.data.refetch(); }; bulkRejectThenReload = async () => { - try { - await this.props.bulkReject(); - this.props.data.refetch(); - } catch (err) { - console.error(err); - this.props.notify('error', getErrorMessages(err)); - } + await this.props.bulkReject(); + this.props.data.refetch(); }; changeTab = tab => { @@ -94,6 +73,16 @@ class UserDetail extends React.Component { ); } + renderError() { + return ( + + +
{this.props.data.error.message}
+
+
+ ); + } + getActionMenuLabel() { const { root: { user } } = this.props; @@ -345,6 +334,10 @@ class UserDetail extends React.Component { } render() { + if (this.props.data.error) { + return this.renderError(); + } + if (this.props.loading) { return this.renderLoading(); } @@ -371,7 +364,6 @@ UserDetail.propTypes = { selectedCommentIds: PropTypes.array.isRequired, viewUserDetail: PropTypes.any.isRequired, loadMore: PropTypes.any.isRequired, - notify: PropTypes.func.isRequired, showSuspendUserDialog: PropTypes.func, showBanUserDialog: PropTypes.func, unbanUser: PropTypes.func.isRequired, diff --git a/client/coral-admin/src/constants/community.js b/client/coral-admin/src/constants/community.js index a9cc2f1e3..1ffe3f8d6 100644 --- a/client/coral-admin/src/constants/community.js +++ b/client/coral-admin/src/constants/community.js @@ -18,3 +18,5 @@ export const SHOW_REJECT_USERNAME_DIALOG = `${prefix}_SHOW_REJECT_USERNAME_DIALO export const HIDE_REJECT_USERNAME_DIALOG = `${prefix}_HIDE_REJECT_USERNAME_DIALOG`; export const SET_SEARCH_VALUE = `${prefix}_SET_SEARCH_VALUE`; + +export const SET_INDICATOR_TRACK = `${prefix}_SET_INDICATOR_TRACK`; diff --git a/client/coral-admin/src/constants/moderation.js b/client/coral-admin/src/constants/moderation.js index 8eb939d76..c233692dc 100644 --- a/client/coral-admin/src/constants/moderation.js +++ b/client/coral-admin/src/constants/moderation.js @@ -1,9 +1,12 @@ -export const TOGGLE_MODAL = 'TOGGLE_MODAL'; -export const SINGLE_VIEW = 'SINGLE_VIEW'; -export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE'; -export const SET_SORT_ORDER = 'MODERATION_SET_SORT_ORDER'; -export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH'; -export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH'; -export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE'; -export const MODERATION_CLEAR_STATE = 'MODERATION_CLEAR_STATE'; -export const MODERATION_SELECT_COMMENT = 'MODERATION_SELECT_COMMENT'; +const prefix = `MODERATION`; + +export const TOGGLE_MODAL = `${prefix}_TOGGLE_MODAL`; +export const SINGLE_VIEW = `${prefix}_SINGLE_VIEW`; +export const HIDE_SHORTCUTS_NOTE = `${prefix}_HIDE_SHORTCUTS_NOTE`; +export const SET_SORT_ORDER = `${prefix}_SET_SORT_ORDER`; +export const SHOW_STORY_SEARCH = `${prefix}_SHOW_STORY_SEARCH`; +export const HIDE_STORY_SEARCH = `${prefix}_HIDE_STORY_SEARCH`; +export const STORY_SEARCH_CHANGE_VALUE = `${prefix}_STORY_SEARCH_CHANGE_VALUE`; +export const CLEAR_STATE = `${prefix}_CLEAR_STATE`; +export const SELECT_COMMENT = `${prefix}_SELECT_COMMENT`; +export const SET_INDICATOR_TRACK = `${prefix}_SET_INDICATOR_TRACK`; diff --git a/client/coral-admin/src/containers/BanUserDialog.js b/client/coral-admin/src/containers/BanUserDialog.js index d2dc642a7..3617902ff 100644 --- a/client/coral-admin/src/containers/BanUserDialog.js +++ b/client/coral-admin/src/containers/BanUserDialog.js @@ -10,8 +10,6 @@ import { } from 'coral-framework/graphql/mutations'; import { compose } from 'react-apollo'; import t from 'coral-framework/services/i18n'; -import { getErrorMessages } from 'coral-framework/utils'; -import { notify } from 'coral-framework/actions/notification'; class BanUserDialogContainer extends Component { banUser = async () => { @@ -22,16 +20,11 @@ class BanUserDialogContainer extends Component { banUser, setCommentStatus, hideBanUserDialog, - notify, } = this.props; - try { - await banUser({ id: userId, message: '' }); - hideBanUserDialog(); - if (commentId && commentStatus && commentStatus !== 'REJECTED') { - await setCommentStatus({ commentId, status: 'REJECTED' }); - } - } catch (err) { - notify('error', getErrorMessages(err)); + await banUser({ id: userId, message: '' }); + hideBanUserDialog(); + if (commentId && commentStatus && commentStatus !== 'REJECTED') { + await setCommentStatus({ commentId, status: 'REJECTED' }); } }; @@ -78,14 +71,13 @@ const mapDispatchToProps = dispatch => ({ ...bindActionCreators( { hideBanUserDialog, - notify, }, dispatch ), }); export default compose( + connect(mapStateToProps, mapDispatchToProps), withBanUser, - withSetCommentStatus, - connect(mapStateToProps, mapDispatchToProps) + withSetCommentStatus )(BanUserDialogContainer); diff --git a/client/coral-admin/src/containers/CommentLabels.js b/client/coral-admin/src/containers/CommentLabels.js index 4cee8b1cc..ee65eb636 100644 --- a/client/coral-admin/src/containers/CommentLabels.js +++ b/client/coral-admin/src/containers/CommentLabels.js @@ -25,6 +25,7 @@ export default withFragments({ id role } + created_at } ${getSlotFragmentSpreads(slots, 'comment')} } diff --git a/client/coral-admin/src/containers/Header.js b/client/coral-admin/src/containers/Header.js index dae096517..d3be66764 100644 --- a/client/coral-admin/src/containers/Header.js +++ b/client/coral-admin/src/containers/Header.js @@ -7,7 +7,7 @@ import { getDefinitionName } from 'coral-framework/utils'; export default withQuery( gql` - query TalkAdmin_Header { + query TalkAdmin_Header($nullID: ID) { ...${getDefinitionName(ModerationIndicator.fragments.root)} ...${getDefinitionName(CommunityIndicator.fragments.root)} } @@ -16,7 +16,7 @@ export default withQuery( `, { options: { - pollInterval: 10000, + variables: { nullID: null }, }, } )(Header); diff --git a/client/coral-admin/src/containers/SuspendUserDialog.js b/client/coral-admin/src/containers/SuspendUserDialog.js index e058faad6..f03d48f52 100644 --- a/client/coral-admin/src/containers/SuspendUserDialog.js +++ b/client/coral-admin/src/containers/SuspendUserDialog.js @@ -11,7 +11,6 @@ import { import { compose, gql } from 'react-apollo'; 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'; @@ -28,17 +27,13 @@ class SuspendUserDialogContainer extends Component { notify, } = this.props; hideSuspendUserDialog(); - try { - await suspendUser({ id: userId, message, until }); - notify( - 'success', - t('suspenduser.notify_suspend_until', username, timeago(until)) - ); - if (commentId && commentStatus && commentStatus !== 'REJECTED') { - await setCommentStatus({ commentId, status: 'REJECTED' }); - } - } catch (err) { - notify('error', getErrorMessages(err)); + await suspendUser({ id: userId, message, until }); + notify( + 'success', + t('suspenduser.notify_suspend_until', username, timeago(until)) + ); + if (commentId && commentStatus && commentStatus !== 'REJECTED') { + await setCommentStatus({ commentId, status: 'REJECTED' }); } }; diff --git a/client/coral-admin/src/containers/UserDetail.js b/client/coral-admin/src/containers/UserDetail.js index bd34ac4d8..fb1508a2f 100644 --- a/client/coral-admin/src/containers/UserDetail.js +++ b/client/coral-admin/src/containers/UserDetail.js @@ -24,7 +24,6 @@ import { } from 'coral-framework/graphql/mutations'; import UserDetailComment from './UserDetailComment'; import update from 'immutability-helper'; -import { notify } from 'coral-framework/actions/notification'; import { showBanUserDialog } from 'actions/banUserDialog'; import { showSuspendUserDialog } from 'actions/suspendUserDialog'; @@ -130,6 +129,7 @@ class UserDetailContainer extends React.Component { acceptComment={this.acceptComment} rejectComment={this.rejectComment} loading={loading} + error={this.props.data && this.props.data.error} loadMore={this.loadMore} {...this.props} /> @@ -271,7 +271,6 @@ const mapDispatchToProps = dispatch => ({ viewUserDetail, hideUserDetail, toggleSelectAllCommentInUserDetail, - notify, }, dispatch ), diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js index 3a24702cd..95595c265 100644 --- a/client/coral-admin/src/graphql/index.js +++ b/client/coral-admin/src/graphql/index.js @@ -173,13 +173,17 @@ export default { }, updateQueries: { TalkAdmin_Community_FlaggedAccounts: (prev, { mutationResult }) => { + const decrement = { + flaggedUsernamesCount: { $apply: count => count - 1 }, + }; + // Remove from list after the mutation was "really" completed. if (get(mutationResult, 'data.approveUsername.isOptimistic')) { - return prev; + return update(prev, decrement); } const updated = update(prev, { - flaggedUsernamesCount: { $apply: count => count - 1 }, + ...decrement, flaggedUsers: { nodes: { $apply: nodes => nodes.filter(node => node.id !== id) }, }, @@ -227,13 +231,17 @@ export default { }, updateQueries: { TalkAdmin_Community_FlaggedAccounts: (prev, { mutationResult }) => { + const decrement = { + flaggedUsernamesCount: { $apply: count => count - 1 }, + }; + // Remove from list after the mutation was "really" completed. if (get(mutationResult, 'data.rejectUsername.isOptimistic')) { - return prev; + return update(prev, decrement); } const updated = update(prev, { - flaggedUsernamesCount: { $apply: count => count - 1 }, + ...decrement, flaggedUsers: { nodes: { $apply: nodes => nodes.filter(node => node.id !== id), diff --git a/client/coral-admin/src/reducers/community.js b/client/coral-admin/src/reducers/community.js index 9fe17ad8f..68e471935 100644 --- a/client/coral-admin/src/reducers/community.js +++ b/client/coral-admin/src/reducers/community.js @@ -9,6 +9,7 @@ import { HIDE_BANUSER_DIALOG, SHOW_REJECT_USERNAME_DIALOG, HIDE_REJECT_USERNAME_DIALOG, + SET_INDICATOR_TRACK, } from '../constants/community'; const initialState = { @@ -24,6 +25,10 @@ const initialState = { user: {}, banDialog: false, rejectUsernameDialog: false, + // If true the activity indicator will track flagged account changes + // in order to determine the current queue count. Set this to false + // if the queue count is determined by other means. + indicatorTrack: true, }; export default function community(state = initialState, action) { @@ -91,6 +96,11 @@ export default function community(state = initialState, action) { ...state, searchValue: action.value, }; + case SET_INDICATOR_TRACK: + return { + ...state, + indicatorTrack: action.track, + }; default: return state; } diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index e2c6a32ff..3dee12785 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -8,14 +8,19 @@ const initialState = { shortcutsNoteVisible: 'show', sortOrder: 'DESC', selectedCommentId: '', + // If true the activity indicator will turn on subscriptions + // in order to determine queue counts. Set this to false + // if the queue count is determined by other means. + indicatorTrack: true, }; export default function moderation(state = initialState, action) { switch (action.type) { - case actions.MODERATION_CLEAR_STATE: + case actions.CLEAR_STATE: return { ...initialState, shortcutsNoteVisible: state.shortcutsNoteVisible, + indicatorTrack: state.indicatorTrack, }; case actions.TOGGLE_MODAL: return { @@ -52,11 +57,16 @@ export default function moderation(state = initialState, action) { ...state, sortOrder: action.order, }; - case actions.MODERATION_SELECT_COMMENT: + case actions.SELECT_COMMENT: return { ...state, selectedCommentId: action.id, }; + case actions.SET_INDICATOR_TRACK: + return { + ...state, + indicatorTrack: action.track, + }; default: return state; } diff --git a/client/coral-admin/src/routes/Community/components/FlaggedUser.css b/client/coral-admin/src/routes/Community/components/FlaggedUser.css index 75a91ce29..436f7307d 100644 --- a/client/coral-admin/src/routes/Community/components/FlaggedUser.css +++ b/client/coral-admin/src/routes/Community/components/FlaggedUser.css @@ -10,7 +10,7 @@ margin: 0 auto; position: relative; transition: background 200ms, box-shadow 200ms, margin-bottom 200ms; - padding: 10px 0 0; + padding: 10px 0 10px; min-height: 220px; &:hover { diff --git a/client/coral-admin/src/routes/Community/containers/FlaggedAccounts.js b/client/coral-admin/src/routes/Community/containers/FlaggedAccounts.js index 09240b8f3..60ae4ee99 100644 --- a/client/coral-admin/src/routes/Community/containers/FlaggedAccounts.js +++ b/client/coral-admin/src/routes/Community/containers/FlaggedAccounts.js @@ -6,16 +6,18 @@ import withQuery from 'coral-framework/hocs/withQuery'; import { Spinner } from 'coral-ui'; import PropTypes from 'prop-types'; import { withApproveUsername } from 'coral-framework/graphql/mutations'; -import { showRejectUsernameDialog } from '../../../actions/community'; +import { + showRejectUsernameDialog, + setIndicatorTrack, +} from '../../../actions/community'; import { viewUserDetail } from '../../../actions/userDetail'; import { getDefinitionName } from 'coral-framework/utils'; import { appendNewNodes } from 'plugin-api/beta/client/utils'; import update from 'immutability-helper'; -import { handleFlaggedUsernameChange } from '../graphql'; +import { handleFlaggedAccountsChange } from '../graphql'; import { notify } from 'coral-framework/actions/notification'; import { isFlaggedUserDangling } from '../utils'; import t from 'coral-framework/services/i18n'; -import { notifyOnMutationError } from 'coral-framework/hocs'; import FlaggedAccounts from '../components/FlaggedAccounts'; import FlaggedUser from '../containers/FlaggedUser'; @@ -32,10 +34,6 @@ function whoFlagged(user) { class FlaggedAccountsContainer extends Component { subscriptions = []; - constructor(props) { - super(props); - } - getCountWithoutDangling() { return this.props.root.flaggedUsers.nodes.filter( node => !isFlaggedUserDangling(node) @@ -50,7 +48,7 @@ class FlaggedAccountsContainer extends Component { prev, { subscriptionData: { data: { usernameFlagged: user } } } ) => { - return handleFlaggedUsernameChange(prev, user, () => { + return handleFlaggedAccountsChange(prev, user, () => { const msg = t( 'flagged_usernames.notify_flagged', whoFlagged(user), @@ -66,7 +64,7 @@ class FlaggedAccountsContainer extends Component { prev, { subscriptionData: { data: { usernameApproved: user } } } ) => { - return handleFlaggedUsernameChange(prev, user, () => { + return handleFlaggedAccountsChange(prev, user, () => { const msg = t( 'flagged_usernames.notify_approved', whoChangedTheStatus(user.state.status.username), @@ -82,7 +80,7 @@ class FlaggedAccountsContainer extends Component { prev, { subscriptionData: { data: { usernameRejected: user } } } ) => { - return handleFlaggedUsernameChange(prev, user, () => { + return handleFlaggedAccountsChange(prev, user, () => { const msg = t( 'flagged_usernames.notify_rejected', whoChangedTheStatus(user.state.status.username), @@ -102,7 +100,7 @@ class FlaggedAccountsContainer extends Component { }, } ) => { - return handleFlaggedUsernameChange(prev, user, () => { + return handleFlaggedAccountsChange(prev, user, () => { const msg = t( 'flagged_usernames.notify_changed', previousUsername, @@ -125,10 +123,14 @@ class FlaggedAccountsContainer extends Component { } componentWillMount() { + // Stop activity indicator tracking, as we'll handle it here. + this.props.setIndicatorTrack(false); this.subscribeToUpdates(); } componentWillUnmount() { + // Restart activity indicator tracking. + this.props.setIndicatorTrack(true); this.unsubscribe(); } @@ -197,6 +199,7 @@ FlaggedAccountsContainer.propTypes = { approveUsername: PropTypes.func, data: PropTypes.object, root: PropTypes.object, + setIndicatorTrack: PropTypes.func, }; const LOAD_MORE_QUERY = gql` @@ -288,6 +291,7 @@ const mapDispatchToProps = dispatch => showRejectUsernameDialog, viewUserDetail, notify, + setIndicatorTrack, }, dispatch ); @@ -295,7 +299,6 @@ const mapDispatchToProps = dispatch => export default compose( connect(null, mapDispatchToProps), withApproveUsername, - notifyOnMutationError(['approveUsername']), withQuery( gql` query TalkAdmin_Community_FlaggedAccounts { diff --git a/client/coral-admin/src/routes/Community/containers/Indicator.js b/client/coral-admin/src/routes/Community/containers/Indicator.js index 04a351fb6..9e5be831a 100644 --- a/client/coral-admin/src/routes/Community/containers/Indicator.js +++ b/client/coral-admin/src/routes/Community/containers/Indicator.js @@ -1,14 +1,152 @@ +import React, { Component } from 'react'; import { compose, gql } from 'react-apollo'; import Indicator from '../../../components/Indicator'; import { withFragments } from 'plugin-api/beta/client/hocs'; -import { branch, renderNothing } from 'recompose'; +import { handleIndicatorChange } from '../graphql'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; -const hideIfNoData = hasNoData => branch(hasNoData, renderNothing); +class IndicatorContainer extends Component { + subscriptions = []; + + subscribeToUpdates() { + const parameters = [ + { + document: USERNAME_FLAGGED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { usernameFlagged: user } } } + ) => { + return handleIndicatorChange(prev, user); + }, + }, + { + document: USERNAME_APPROVED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { usernameApproved: user } } } + ) => { + return handleIndicatorChange(prev, user); + }, + }, + { + document: USERNAME_REJECTED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { usernameRejected: user } } } + ) => { + return handleIndicatorChange(prev, user); + }, + }, + { + document: USERNAME_CHANGED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { usernameChanged: { user } } } } + ) => { + return handleIndicatorChange(prev, user); + }, + }, + ]; + + this.subscriptions = parameters.map(param => + this.props.data.subscribeToMore(param) + ); + } + + unsubscribe() { + this.subscriptions.forEach(unsubscribe => unsubscribe()); + this.subscriptions = []; + } + + componentWillMount() { + if (this.props.track) { + this.subscribeToUpdates(); + } + } + + componentWillUnmount() { + this.unsubscribe(); + } + + componentWillReceiveProps(nextProps) { + if (!this.props.track && nextProps.track) { + this.subscribeToUpdates(); + } + if (this.props.track && !nextProps.track) { + this.unsubscribe(); + } + } + + render() { + if (!this.props.root || !this.props.root.flaggedUsernamesCount) { + return null; + } + + return ; + } +} + +IndicatorContainer.propTypes = { + data: PropTypes.object, + root: PropTypes.object, + track: PropTypes.bool, +}; + +const fields = ` + state { + status { + username { + status + } + } + } +`; + +const USERNAME_FLAGGED_SUBSCRIPTION = gql` + subscription TalkAdmin_CommunityIndicator_UsernameFlagged { + usernameFlagged { + ${fields} + } + } +`; + +const USERNAME_APPROVED_SUBSCRIPTION = gql` + subscription TalkAdmin_ComunityIndicator_UsernameApproved { + usernameApproved { + ${fields} + } + } +`; + +const USERNAME_REJECTED_SUBSCRIPTION = gql` + subscription TalkAdmin_CommunityIndicator_UsernameRejected { + usernameRejected { + ${fields} + } + } +`; + +const USERNAME_CHANGED_SUBSCRIPTION = gql` + subscription TalkAdmin_ComunityIndicator_UsernameChanged { + usernameChanged { + previousUsername + user { + ${fields} + } + } + } +`; + +const mapStateToProps = state => ({ + track: state.community.indicatorTrack, +}); const enhance = compose( + connect(mapStateToProps), withFragments({ root: gql` - fragment TalkAdmin_Community_Indicator_root on RootQuery { + fragment TalkAdmin_CommunityIndicator_root on RootQuery { flaggedUsernamesCount: userCount( query: { action_type: FLAG @@ -17,8 +155,7 @@ const enhance = compose( ) } `, - }), - hideIfNoData(props => !props.root.flaggedUsernamesCount) + }) ); -export default enhance(Indicator); +export default enhance(IndicatorContainer); diff --git a/client/coral-admin/src/routes/Community/containers/RejectUsernameDialog.js b/client/coral-admin/src/routes/Community/containers/RejectUsernameDialog.js index f9ada0945..325eadfb5 100644 --- a/client/coral-admin/src/routes/Community/containers/RejectUsernameDialog.js +++ b/client/coral-admin/src/routes/Community/containers/RejectUsernameDialog.js @@ -4,8 +4,6 @@ import { hideRejectUsernameDialog } from '../../../actions/community'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import { compose } from 'react-apollo'; -import { notify } from 'coral-framework/actions/notification'; -import { notifyOnMutationError } from 'coral-framework/hocs'; const mapStateToProps = state => ({ user: state.community.user, @@ -16,13 +14,11 @@ const mapDispatchToProps = dispatch => bindActionCreators( { handleClose: hideRejectUsernameDialog, - notify, }, dispatch ); export default compose( connect(mapStateToProps, mapDispatchToProps), - withRejectUsername, - notifyOnMutationError(['rejectUsername']) + withRejectUsername )(RejectUsernameDialog); diff --git a/client/coral-admin/src/routes/Community/graphql.js b/client/coral-admin/src/routes/Community/graphql.js index af10c8e86..8d6941c41 100644 --- a/client/coral-admin/src/routes/Community/graphql.js +++ b/client/coral-admin/src/routes/Community/graphql.js @@ -49,13 +49,13 @@ function decrementFlaggedUserCount(root) { } /** - * Assimilate flagged user changes into current store. + * Assimilate flagged acount changes into current store. * @param {Object} root current state of the store * @param {Object} user user that was changed * @param {function} notify callback to show notification * @return {Object} next state of the store */ -export function handleFlaggedUsernameChange(root, user, notify) { +export function handleFlaggedAccountsChange(root, user, notify) { if (user.state.status.username.status !== 'SET') { // Check if change came from current user, if so ignore it. const lastChange = @@ -87,7 +87,7 @@ export function handleFlaggedUsernameChange(root, user, notify) { break; case 'APPROVED': case 'REJECTED': - return root; + return decrementFlaggedUserCount(root); default: } } @@ -105,3 +105,21 @@ export function handleFlaggedUsernameChange(root, user, notify) { } } } + +/** + * Track indicator status + * @param {Object} root current state of the store + * @param {Object} user user that was changed + * @return {Object} next state of the store + */ +export function handleIndicatorChange(root, user) { + switch (user.state.status.username.status) { + case 'SET': + case 'CHANGED': + return incrementFlaggedUserCount(root); + case 'APPROVED': + case 'REJECTED': + return decrementFlaggedUserCount(root); + default: + } +} diff --git a/client/coral-admin/src/routes/Configure/components/Configure.js b/client/coral-admin/src/routes/Configure/components/Configure.js index 2b3e44f1e..f81bf7735 100644 --- a/client/coral-admin/src/routes/Configure/components/Configure.js +++ b/client/coral-admin/src/routes/Configure/components/Configure.js @@ -86,7 +86,6 @@ export default class Configure extends Component { } Configure.propTypes = { - notify: PropTypes.func.isRequired, savePending: PropTypes.func.isRequired, auth: PropTypes.object.isRequired, data: PropTypes.object.isRequired, diff --git a/client/coral-admin/src/routes/Configure/containers/Configure.js b/client/coral-admin/src/routes/Configure/containers/Configure.js index 0d9d5faf9..ce33fa1f4 100644 --- a/client/coral-admin/src/routes/Configure/containers/Configure.js +++ b/client/coral-admin/src/routes/Configure/containers/Configure.js @@ -4,10 +4,9 @@ import { bindActionCreators } from 'redux'; import { compose, gql } from 'react-apollo'; import { withQuery, withMergedSettings } from 'coral-framework/hocs'; import { Spinner } from 'coral-ui'; -import { notify } from 'coral-framework/actions/notification'; import PropTypes from 'prop-types'; import { withUpdateSettings } from 'coral-framework/graphql/mutations'; -import { getErrorMessages, getDefinitionName } from 'coral-framework/utils'; +import { getDefinitionName } from 'coral-framework/utils'; import StreamSettings from './StreamSettings'; import TechSettings from './TechSettings'; import ModerationSettings from './ModerationSettings'; @@ -16,22 +15,21 @@ import Configure from '../components/Configure'; class ConfigureContainer extends Component { savePending = async () => { - try { - await this.props.updateSettings(this.props.pending); - this.props.clearPending(); - } catch (err) { - this.props.notify('error', getErrorMessages(err)); - } + await this.props.updateSettings(this.props.pending); + this.props.clearPending(); }; render() { + if (this.props.data.error) { + return
{this.props.data.error.message}
; + } + if (this.props.data.loading) { return ; } return ( ({ variables: {}, + fetchPolicy: 'network-only', }), } ); @@ -81,7 +80,6 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => bindActionCreators( { - notify, clearPending, setActiveSection, }, @@ -89,9 +87,9 @@ const mapDispatchToProps = dispatch => ); export default compose( + connect(mapStateToProps, mapDispatchToProps), withUpdateSettings, withConfigureQuery, - connect(mapStateToProps, mapDispatchToProps), withMergedSettings('root.settings', 'pending', 'mergedSettings') )(ConfigureContainer); @@ -99,7 +97,6 @@ ConfigureContainer.propTypes = { updateSettings: PropTypes.func.isRequired, clearPending: PropTypes.func.isRequired, setActiveSection: PropTypes.func.isRequired, - notify: PropTypes.func.isRequired, auth: PropTypes.object.isRequired, data: PropTypes.object.isRequired, root: PropTypes.object.isRequired, diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index 760dfcbb1..c1de008fe 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -19,7 +19,7 @@ class Moderation extends Component { componentWillMount() { const { toggleModal, singleView } = this.props; - key('s', () => singleView()); + key('z', () => singleView()); key('shift+/', () => toggleModal(true)); key('esc', () => toggleModal(false)); key('ctrl+f', () => this.openSearch()); @@ -113,7 +113,7 @@ class Moderation extends Component { }; componentWillUnmount() { - key.unbind('s'); + key.unbind('z'); key.unbind('shift+/'); key.unbind('esc'); key.unbind('ctrl+f'); diff --git a/client/coral-admin/src/routes/Moderation/containers/Indicator.js b/client/coral-admin/src/routes/Moderation/containers/Indicator.js index bd33a58ef..1475b6e2d 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Indicator.js +++ b/client/coral-admin/src/routes/Moderation/containers/Indicator.js @@ -1,25 +1,207 @@ +import React, { Component } from 'react'; import { compose, gql } from 'react-apollo'; import Indicator from '../../../components/Indicator'; import { withFragments } from 'plugin-api/beta/client/hocs'; -import { branch, renderNothing } from 'recompose'; +import { handleIndicatorChange, subscriptionFields } from '../graphql'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import withQueueConfig from '../hoc/withQueueConfig'; +import baseQueueConfig from '../queueConfig'; +import Slot from 'coral-framework/components/Slot'; -const hideIfNoData = hasNoData => branch(hasNoData, renderNothing); +class IndicatorContainer extends Component { + subscriptions = []; + + handleCommentChange = (root, comment) => { + return handleIndicatorChange(root, comment, this.props.queueConfig); + }; + + subscribeToUpdates() { + const parameters = [ + { + document: COMMENT_ADDED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentAdded: comment } } } + ) => { + return this.handleCommentChange(prev, comment); + }, + }, + { + document: COMMENT_FLAGGED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentFlagged: comment } } } + ) => { + return this.handleCommentChange(prev, comment); + }, + }, + { + document: COMMENT_EDITED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentEdited: comment } } } + ) => { + return this.handleCommentChange(prev, comment); + }, + }, + { + document: COMMENT_ACCEPTED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentAccepted: comment } } } + ) => { + return this.handleCommentChange(prev, comment); + }, + }, + { + document: COMMENT_REJECTED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentRejected: comment } } } + ) => { + return this.handleCommentChange(prev, comment); + }, + }, + { + document: COMMENT_RESET_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentReset: comment } } } + ) => { + return this.handleCommentChange(prev, comment); + }, + }, + ]; + + this.subscriptions = parameters.map(param => + this.props.data.subscribeToMore(param) + ); + } + + unsubscribe() { + this.subscriptions.forEach(unsubscribe => unsubscribe()); + this.subscriptions = []; + } + + componentWillMount() { + if (this.props.track) { + this.subscribeToUpdates(); + } + } + + componentWillUnmount() { + this.unsubscribe(); + } + + componentWillReceiveProps(nextProps) { + if (!this.props.track && nextProps.track) { + this.subscribeToUpdates(); + } + if (this.props.track && !nextProps.track) { + this.unsubscribe(); + } + } + + render() { + if ( + !this.props.root || + (!this.props.root.premodCount && !this.props.root.reportedCount) + ) { + return null; + } + + return ( + + + + + ); + } +} + +IndicatorContainer.propTypes = { + data: PropTypes.object, + root: PropTypes.object, + track: PropTypes.bool, + queueConfig: PropTypes.object, +}; + +const COMMENT_ADDED_SUBSCRIPTION = gql` + subscription TalkAdmin_ModerationIndicator_CommentAdded { + commentAdded(statuses: null) { + ${subscriptionFields} + } + } +`; + +const COMMENT_FLAGGED_SUBSCRIPTION = gql` + subscription TalkAdmin_ModerationIndicator_CommentFlagged { + commentFlagged { + ${subscriptionFields} + } + } +`; + +const COMMENT_EDITED_SUBSCRIPTION = gql` + subscription TalkAdmin_ModerationIndicator_CommentEdited { + commentEdited { + ${subscriptionFields} + } + } +`; + +const COMMENT_ACCEPTED_SUBSCRIPTION = gql` + subscription TalkAdmin_ModerationIndicator_CommentAccepted { + commentAccepted { + ${subscriptionFields} + } + } +`; + +const COMMENT_REJECTED_SUBSCRIPTION = gql` + subscription TalkAdmin_ModerationIndicator_CommentRejected { + commentRejected { + ${subscriptionFields} + } + } +`; + +const COMMENT_RESET_SUBSCRIPTION = gql` + subscription TalkAdmin_ModerationIndicator_CommentReset { + commentReset { + ${subscriptionFields} + } + } +`; + +const mapStateToProps = state => ({ + track: state.moderation.indicatorTrack, +}); const enhance = compose( + connect(mapStateToProps), withFragments({ root: gql` fragment TalkAdmin_Moderation_Indicator_root on RootQuery { - premodCount: commentCount(query: { statuses: [PREMOD] }) + premodCount: commentCount( + query: { statuses: [PREMOD], asset_id: $nullID } + ) reportedCount: commentCount( query: { statuses: [NONE, PREMOD, SYSTEM_WITHHELD] action_type: FLAG + asset_id: $nullID } ) } `, }), - hideIfNoData(props => !props.root.premodCount && !props.root.reportedCount) + withQueueConfig(baseQueueConfig) ); -export default enhance(Indicator); +export default enhance(IndicatorContainer); diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index fd96c598b..c0e2e60a6 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -15,6 +15,7 @@ import { handleCommentChange, commentBelongToQueue, cleanUpQueue, + subscriptionFields, } from '../graphql'; import { viewUserDetail } from '../../../actions/userDetail'; @@ -27,6 +28,7 @@ import { storySearchChange, clearState, selectCommentId, + setIndicatorTrack, } from 'actions/moderation'; import withQueueConfig from '../hoc/withQueueConfig'; import { notify } from 'coral-framework/actions/notification'; @@ -198,21 +200,42 @@ class ModerationContainer extends Component { } componentWillMount() { + if (!this.props.data.variables.asset_id) { + // Stop activity indicator tracking, as we'll handle it here. + this.props.setIndicatorTrack(false); + } this.props.clearState(); this.subscribeToUpdates(); } componentWillUnmount() { + if (!this.props.data.variables.asset_id) { + // Restart activity indicator tracking. + this.props.setIndicatorTrack(true); + } this.unsubscribe(); } componentWillReceiveProps(nextProps) { + const currentAssetId = this.props.data.variables.asset_id; + const nextAssetId = nextProps.data.variables.asset_id; + // Resubscribe when we change between assets. - if ( - this.props.data.variables.asset_id !== nextProps.data.variables.asset_id - ) { + if (currentAssetId !== nextAssetId) { this.resubscribe(nextProps.data.variables); } + + // We are only subscribing to a specific asset_id, so activity indicator + // needs to do its own tracking. + if (!currentAssetId && nextAssetId) { + this.props.setIndicatorTrack(true); + } + + // We are subscribing to all comment changes, and as such there is no + // need for the activity indicator to do the same. + if (currentAssetId && !nextAssetId) { + this.props.setIndicatorTrack(false); + } } cleanUpQueue = queue => { @@ -269,10 +292,6 @@ class ModerationContainer extends Component { const { root, root: { asset, settings }, data } = this.props; const assetId = getAssetId(this.props); - if (data.error) { - return
Error
; - } - if (assetId) { if (asset === null) { // Not found. @@ -280,6 +299,10 @@ class ModerationContainer extends Component { } } + if (data.error) { + return
{data.error.message}
; + } + if (data.loading && data.networkStatus !== 3) { // loading. return ; @@ -316,10 +339,12 @@ class ModerationContainer extends Component { ); } } + const COMMENT_ADDED_SUBSCRIPTION = gql` subscription CommentAdded($asset_id: ID){ commentAdded(asset_id: $asset_id, statuses: null){ ...${getDefinitionName(Comment.fragments.comment)} + ${subscriptionFields} } } ${Comment.fragments.comment} @@ -329,6 +354,7 @@ const COMMENT_EDITED_SUBSCRIPTION = gql` subscription CommentEdited($asset_id: ID){ commentEdited(asset_id: $asset_id){ ...${getDefinitionName(Comment.fragments.comment)} + ${subscriptionFields} } } ${Comment.fragments.comment} @@ -338,6 +364,7 @@ const COMMENT_FLAGGED_SUBSCRIPTION = gql` subscription CommentFlagged($asset_id: ID){ commentFlagged(asset_id: $asset_id){ ...${getDefinitionName(Comment.fragments.comment)} + ${subscriptionFields} } } ${Comment.fragments.comment} @@ -347,14 +374,7 @@ const COMMENT_ACCEPTED_SUBSCRIPTION = gql` subscription CommentAccepted($asset_id: ID){ commentAccepted(asset_id: $asset_id){ ...${getDefinitionName(Comment.fragments.comment)} - status_history { - type - created_at - assigned_by { - id - username - } - } + ${subscriptionFields} } } ${Comment.fragments.comment} @@ -364,14 +384,7 @@ const COMMENT_REJECTED_SUBSCRIPTION = gql` subscription CommentRejected($asset_id: ID){ commentRejected(asset_id: $asset_id){ ...${getDefinitionName(Comment.fragments.comment)} - status_history { - type - created_at - assigned_by { - id - username - } - } + ${subscriptionFields} } } ${Comment.fragments.comment} @@ -381,14 +394,7 @@ const COMMENT_RESET_SUBSCRIPTION = gql` subscription CommentReset($asset_id: ID){ commentReset(asset_id: $asset_id){ ...${getDefinitionName(Comment.fragments.comment)} - status_history { - type - created_at - assigned_by { - id - username - } - } + ${subscriptionFields} } } ${Comment.fragments.comment} @@ -525,6 +531,7 @@ const mapDispatchToProps = dispatch => ({ clearState, notify, selectCommentId, + setIndicatorTrack, }, dispatch ), diff --git a/client/coral-admin/src/routes/Moderation/graphql.js b/client/coral-admin/src/routes/Moderation/graphql.js index 481c42ab9..aa6267880 100644 --- a/client/coral-admin/src/routes/Moderation/graphql.js +++ b/client/coral-admin/src/routes/Moderation/graphql.js @@ -91,6 +91,87 @@ function getCommentQueues(comment, queueConfig) { return queues; } +function getOlderDate(a, b) { + if (a) { + a = new Date(a); + } + if (b) { + b = new Date(b); + } + + if (!b) { + return a; + } + + if (!a) { + return b; + } + return a < b ? b : a; +} + +function determineLatestChange(comment) { + let lc = null; + + comment.body_history.forEach(item => { + lc = getOlderDate(lc, item.created_at); + }); + + comment.status_history.forEach(item => { + lc = getOlderDate(lc, item.created_at); + }); + + comment.actions.forEach(item => { + lc = getOlderDate(lc, item.created_at); + }); + + return lc; +} + +function reconstructPreviousCommentState(comment) { + const statusHistory = comment.status_history; + const bodyHistory = comment.body_history; + const actions = comment.actions; + const lastChangeDate = determineLatestChange(comment); + const previousComment = { + ...comment, + body_history: bodyHistory.filter( + item => new Date(item.created_at) < lastChangeDate + ), + status_history: statusHistory.filter( + item => new Date(item.created_at) < lastChangeDate + ), + actions: actions.filter(item => new Date(item.created_at) < lastChangeDate), + }; + + // Comment did not exist previously. + if (!previousComment.status_history.length) { + return null; + } + + previousComment.status = + previousComment.status_history[ + previousComment.status_history.length - 1 + ].type; + + previousComment.body = + previousComment.body_history[previousComment.body_history.length - 1].body; + + return previousComment; +} + +/** + * getPreviousCommentQueues determines queues that this comment previously belonged to. + */ +function getPreviousCommentQueues(comment, queueConfig) { + const previousCommentState = reconstructPreviousCommentState(comment); + + if (!previousCommentState) { + return []; + } + + return getCommentQueues(previousCommentState, queueConfig); +} + /** * Return whether or not the comment belongs to the queue. */ @@ -203,17 +284,7 @@ export function handleCommentChange( let next = root; // Queues that this comment previously belonged to. - const prevQueues = - comment.status_history.length <= 1 - ? [] - : getCommentQueues( - { - ...comment, - status: - comment.status_history[comment.status_history.length - 2].type, - }, - queueConfig - ); + const prevQueues = getPreviousCommentQueues(comment, queueConfig); // Queues that this comment needs to be placed. const nextQueues = getCommentQueues(comment, queueConfig); @@ -291,3 +362,54 @@ export function handleCommentChange( }); return next; } + +const indicatorQueues = ['premod', 'reported']; + +/** + * Track indicator status + * @param {Object} root current state of the store + * @param {Object} comment comment that was changed + * @return {Object} next state of the store + */ +export function handleIndicatorChange(root, comment, queueConfig) { + let next = root; + + // Queues that this comment previously belonged to. + const prevQueues = getPreviousCommentQueues(comment, queueConfig); + + // Queues that this comment needs to be placed. + const nextQueues = getCommentQueues(comment, queueConfig); + + for (const queue of indicatorQueues) { + if (prevQueues.indexOf(queue) === -1 && nextQueues.indexOf(queue) >= 0) { + next = increaseCommentCount(next, queue); + } + if (prevQueues.indexOf(queue) >= 0 && nextQueues.indexOf(queue) === -1) { + next = decreaseCommentCount(next, queue); + } + } + + return next; +} + +export const subscriptionFields = ` + status + body + body_history { + body + created_at + } + actions { + __typename + created_at + } + status_history { + type + assigned_by { + id + } + created_at + } + updated_at + created_at +`; diff --git a/client/coral-auth-callback/src/index.js b/client/coral-auth-callback/src/index.js new file mode 100644 index 000000000..b7f3ed226 --- /dev/null +++ b/client/coral-auth-callback/src/index.js @@ -0,0 +1,14 @@ +document.addEventListener('DOMContentLoaded', () => { + // Get the auth element and parse it as JSON by decoding it. + const auth = document.getElementById('auth'); + const doc = document.implementation.createHTMLDocument(''); + doc.body.innerHTML = auth.innerText; + + // Set the item in localStorage. + localStorage.setItem('auth', doc.body.textContent); + + // Close the window. + setTimeout(() => { + window.close(); + }, 50); +}); diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js index dcded6b45..279a1dcf6 100644 --- a/client/coral-embed-stream/src/graphql/index.js +++ b/client/coral-embed-stream/src/graphql/index.js @@ -198,7 +198,7 @@ export default { { mutationResult: { data: { createComment: { comment } } } } ) => { if ( - (prev.me.role !== 'ADMIN' && + (!['ADMIN', 'MODERATOR'].includes(prev.me.role) && prev.asset.settings.moderation === 'PRE') || comment.status === 'PREMOD' || comment.status === 'REJECTED' || diff --git a/client/coral-embed-stream/src/tabs/configure/components/Settings.js b/client/coral-embed-stream/src/tabs/configure/components/Settings.js index 2c4a42153..7e11b1311 100644 --- a/client/coral-embed-stream/src/tabs/configure/components/Settings.js +++ b/client/coral-embed-stream/src/tabs/configure/components/Settings.js @@ -57,7 +57,7 @@ class Settings extends React.Component { {this.props.data.error.message}
; + } + return ( { - try { - await this.props.updateAssetSettings( - this.props.asset.id, - this.props.pending - ); - this.props.clearPending(); - } catch (err) { - this.props.notify('error', getErrorMessages(err)); - } + await this.props.updateAssetSettings( + this.props.asset.id, + this.props.pending + ); + this.props.clearPending(); }; render() { @@ -98,7 +90,6 @@ SettingsContainer.propTypes = { mergedSettings: PropTypes.object.isRequired, updateAssetSettings: PropTypes.func.isRequired, clearPending: PropTypes.func.isRequired, - notify: PropTypes.func.isRequired, updatePending: PropTypes.func.isRequired, canSave: PropTypes.bool.isRequired, }; @@ -135,7 +126,6 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => bindActionCreators( { - notify, clearPending, updatePending, }, @@ -143,9 +133,9 @@ const mapDispatchToProps = dispatch => ); const enhance = compose( + connect(mapStateToProps, mapDispatchToProps), withSettingsFragments, withUpdateAssetSettings, - connect(mapStateToProps, mapDispatchToProps), withMergedSettings('asset.settings', 'pending', 'mergedSettings') ); diff --git a/client/coral-embed-stream/src/tabs/stream/components/AllCommentsPane.js b/client/coral-embed-stream/src/tabs/stream/components/AllCommentsPane.js index ed1fe73f9..3190ab155 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/AllCommentsPane.js +++ b/client/coral-embed-stream/src/tabs/stream/components/AllCommentsPane.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import LoadMore from './LoadMore'; import NewCount from './NewCount'; import { TransitionGroup } from 'react-transition-group'; -import { forEachError } from 'coral-framework/utils'; import Comment from '../containers/Comment'; import NoComments from './NoComments'; @@ -91,11 +90,8 @@ class AllCommentsPane extends React.Component { .then(() => { this.setState({ loadingState: 'success' }); }) - .catch(error => { + .catch(() => { this.setState({ loadingState: 'error' }); - forEachError(error, ({ msg }) => { - this.props.notify('error', msg); - }); }); }; diff --git a/client/coral-embed-stream/src/tabs/stream/components/Comment.js b/client/coral-embed-stream/src/tabs/stream/components/Comment.js index f4e8404e4..cd0fab9e9 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/Comment.js +++ b/client/coral-embed-stream/src/tabs/stream/components/Comment.js @@ -24,7 +24,6 @@ import { EditableCommentContent } from './EditableCommentContent'; import { getActionSummary, iPerformedThisAction, - forEachError, isCommentActive, getShallowChanges, } from 'coral-framework/utils'; @@ -261,11 +260,8 @@ export default class Comment extends React.Component { loadingState: 'success', }); }) - .catch(error => { + .catch(() => { this.setState({ loadingState: 'error' }); - forEachError(error, ({ msg }) => { - this.props.notify('error', msg); - }); }); emit('ui.Comment.showMoreReplies', { id }); return; diff --git a/client/coral-embed-stream/src/tabs/stream/components/EditableCommentContent.js b/client/coral-embed-stream/src/tabs/stream/components/EditableCommentContent.js index bfd2c8bad..f4cfc42e8 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/EditableCommentContent.js +++ b/client/coral-embed-stream/src/tabs/stream/components/EditableCommentContent.js @@ -6,7 +6,6 @@ import styles from './Comment.css'; import { CountdownSeconds } from './CountdownSeconds'; import { getEditableUntilDate } from './util'; import { can } from 'coral-framework/services/perms'; -import { forEachError } from 'coral-framework/utils'; import { Icon } from 'coral-ui'; import t from 'coral-framework/services/i18n'; @@ -80,7 +79,7 @@ export class EditableCommentContent extends React.Component { this.setState({ loadingState: 'loading' }); - const { editComment, notify, stopEditing } = this.props; + const { editComment, stopEditing } = this.props; if (typeof editComment !== 'function') { return; } @@ -95,7 +94,6 @@ export class EditableCommentContent extends React.Component { } } catch (error) { this.setState({ loadingState: 'error' }); - forEachError(error, ({ msg }) => notify('error', msg)); } }; diff --git a/client/coral-embed-stream/src/tabs/stream/components/Stream.js b/client/coral-embed-stream/src/tabs/stream/components/Stream.js index d82f49498..6288a2b6b 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/Stream.js +++ b/client/coral-embed-stream/src/tabs/stream/components/Stream.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { StreamError } from './StreamError'; +import StreamError from './StreamError'; import Comment from '../containers/Comment'; import BannedAccount from '../../../components/BannedAccount'; import ChangeUsername from '../containers/ChangeUsername'; diff --git a/client/coral-embed-stream/src/tabs/stream/components/StreamError.js b/client/coral-embed-stream/src/tabs/stream/components/StreamError.js index 0955de2ef..79d6b03fc 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/StreamError.js +++ b/client/coral-embed-stream/src/tabs/stream/components/StreamError.js @@ -1,6 +1,6 @@ import React from 'react'; import styles from './StreamError.css'; -export const StreamError = ({ children }) => ( +export default ({ children }) => (
{children}
); diff --git a/client/coral-embed-stream/src/tabs/stream/containers/Stream.js b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js index 70cbadae5..51b4d9d9e 100644 --- a/client/coral-embed-stream/src/tabs/stream/containers/Stream.js +++ b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js @@ -38,6 +38,7 @@ import { insertFetchedCommentsIntoEmbedQuery, nest, } from '../../../graphql/utils'; +import StreamError from '../components/StreamError'; const { showSignInDialog, editName } = authActions; const { notify } = notificationActions; @@ -208,6 +209,10 @@ class StreamContainer extends React.Component { } render() { + if (this.props.data.error) { + return {this.props.data.error.message}; + } + if ( !this.props.asset || (this.props.asset.comment === undefined && !this.props.asset.comments) @@ -424,7 +429,8 @@ export default compose( withEmit, connect(mapStateToProps, mapDispatchToProps), withPostComment, - withPostFlag, + // `talk-plugin-flags` has a custom error handling logic. + withPostFlag({ notifyOnError: false }), withPostDontAgree, withDeleteAction, withEditComment diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js index 275f6df42..174f25a63 100644 --- a/client/coral-framework/hocs/index.js +++ b/client/coral-framework/hocs/index.js @@ -6,4 +6,3 @@ export { default as withEmit } from './withEmit'; export { default as excludeIf } from './excludeIf'; export { default as connect } from './connect'; export { default as withMergedSettings } from './withMergedSettings'; -export { default as notifyOnMutationError } from './notifyOnMutationError'; diff --git a/client/coral-framework/hocs/notifyOnMutationError.js b/client/coral-framework/hocs/notifyOnMutationError.js deleted file mode 100644 index c9429ad15..000000000 --- a/client/coral-framework/hocs/notifyOnMutationError.js +++ /dev/null @@ -1,35 +0,0 @@ -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { compose } from 'react-apollo'; -import { notify } from 'coral-framework/actions/notification'; -import { forEachError } from 'coral-framework/utils'; -import { withProps } from 'recompose'; - -const notifyOnMutationError = keys => - compose( - connect(null, dispatch => - bindActionCreators( - { - notify, - }, - dispatch - ) - ), - withProps(ownProps => - keys.reduce((props, key) => { - props[key] = async (...args) => { - try { - return await ownProps[key](...args); - } catch (e) { - forEachError(e, ({ msg }) => { - ownProps.notify('error', msg); - }); - throw e; - } - }; - return props; - }, {}) - ) - ); - -export default notifyOnMutationError; diff --git a/client/coral-framework/hocs/withMutation.js b/client/coral-framework/hocs/withMutation.js index b72e56f40..dae38071f 100644 --- a/client/coral-framework/hocs/withMutation.js +++ b/client/coral-framework/hocs/withMutation.js @@ -4,11 +4,16 @@ import merge from 'lodash/merge'; import uniq from 'lodash/uniq'; import flatten from 'lodash/flatten'; import isEmpty from 'lodash/isEmpty'; -import { getDefinitionName, getResponseErrors } from '../utils'; +import { + getDefinitionName, + getResponseErrors, + getErrorMessages, +} from '../utils'; import PropTypes from 'prop-types'; import t from 'coral-framework/services/i18n'; import hoistStatics from 'recompose/hoistStatics'; import union from 'lodash/union'; +import { notify } from 'coral-framework/actions/notification'; class ResponseErrors extends Error { constructor(errors) { @@ -27,11 +32,7 @@ class ResponseError { } } -/** - * Exports a HOC with the same signature as `graphql`, that will - * apply mutation options registered in the graphRegistry. - */ -export default (document, config = {}) => +const createHOC = (document, config, { notifyOnError = true }) => hoistStatics(WrappedComponent => { config = { ...config, @@ -46,10 +47,18 @@ export default (document, config = {}) => graphql: PropTypes.object, }; + static propTypes = { + notify: PropTypes.func, + }; + get graphqlRegistry() { return this.context.graphql.registry; } + notifyErrors(messages) { + this.context.store.dispatch(notify('error', messages)); + } + resolveDocument(documentOrCallback) { return this.context.graphql.resolveDocument( documentOrCallback, @@ -165,6 +174,11 @@ export default (document, config = {}) => variables, error, }); + + // Show errors as notifications. + if (notifyOnError) { + this.notifyErrors(getErrorMessages(error)); + } throw error; }); }; @@ -213,3 +227,18 @@ export default (document, config = {}) => } }; }); + +/** + * Exports a HOC with the same signature as `graphql`, that will + * apply mutation options registered in the graphRegistry. + * + * The returned HOC accepts a settings object with the following properties: + * notifyOnError: show a notification to the user when an error occured. + * Defaults to true. + */ +export default (document, config = {}) => settingsOrComponent => { + if (typeof settingsOrComponent === 'function') { + return createHOC(document, config, {})(settingsOrComponent); + } + return createHOC(document, config, settingsOrComponent); +}; diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js index d5609c340..c4b9074b4 100644 --- a/client/coral-framework/hocs/withQuery.js +++ b/client/coral-framework/hocs/withQuery.js @@ -9,6 +9,8 @@ import PropTypes from 'prop-types'; import hoistStatics from 'recompose/hoistStatics'; import { getOperationName } from 'apollo-client/queries/getFromAST'; import throttle from 'lodash/throttle'; +import get from 'lodash/get'; +import { notify } from 'coral-framework/actions/notification'; const withSkipOnErrors = reducer => (prev, action, ...rest) => { if ( @@ -36,21 +38,23 @@ function networkStatusToString(networkStatus) { return 'ready'; case 8: return 'error'; + default: + throw new Error(`Unknown network status ${networkStatus}`); } - throw new Error(`Unknown network status ${networkStatus}`); } -/** - * Exports a HOC with the same signature as `graphql`, that will - * apply query options registered in the graphRegistry. - */ -export default (document, config = {}) => +const createHOC = (document, config, { notifyOnError = true }) => hoistStatics(WrappedComponent => { return class WithQuery extends React.Component { static contextTypes = { eventEmitter: PropTypes.object, graphql: PropTypes.object, client: PropTypes.object, + store: PropTypes.object, + }; + + static propTypes = { + notify: PropTypes.func, }; // Lazily resolve fragments from graphRegistry to support circular dependencies. @@ -166,10 +170,24 @@ export default (document, config = {}) => return () => this.client.networkInterface.unsubscribe(id); }; + notifyErrors(messages) { + this.context.store.dispatch(notify('error', messages)); + } + nextData(data) { this.apolloData = data; this.emitWhenNeeded(data); + if ( + get(data, 'error.message') && + get(this, 'data.error.message') !== get(data, 'error.message') + ) { + // Show errors as notifications. + if (notifyOnError) { + this.notifyErrors(data.error.message); + } + } + // If data was previously set, we update it in a immutable way. if (this.data) { if (this.data.loading && !data.loading) { @@ -319,3 +337,18 @@ export default (document, config = {}) => } }; }); + +/** + * Exports a HOC with the same signature as `graphql`, that will + * apply query options registered in the graphRegistry. + * + * The returned HOC accepts a settings object with the following properties: + * notifyOnError: show a notification to the user when an error occured. + * Defaults to true. + */ +export default (document, config = {}) => settingsOrComponent => { + if (typeof settingsOrComponent === 'function') { + return createHOC(document, config, {})(settingsOrComponent); + } + return createHOC(document, config, settingsOrComponent); +}; diff --git a/client/coral-settings/containers/ProfileContainer.js b/client/coral-settings/containers/ProfileContainer.js index fb9d8de17..e1bcf7f13 100644 --- a/client/coral-settings/containers/ProfileContainer.js +++ b/client/coral-settings/containers/ProfileContainer.js @@ -65,6 +65,10 @@ class ProfileContainer extends Component { const { me } = this.props.root; const loading = this.props.data.loading; + if (this.props.data.error) { + return
{this.props.data.error.message}
; + } + if (!auth.loggedIn) { return ; } diff --git a/client/talk-plugin-commentbox/CommentBox.js b/client/talk-plugin-commentbox/CommentBox.js index a8da78f3f..8eac1ff2f 100644 --- a/client/talk-plugin-commentbox/CommentBox.js +++ b/client/talk-plugin-commentbox/CommentBox.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import t from 'coral-framework/services/i18n'; import { can } from 'coral-framework/services/perms'; -import { forEachError } from 'coral-framework/utils'; import Slot from 'coral-framework/components/Slot'; import { connect } from 'react-redux'; @@ -93,9 +92,8 @@ class CommentBox extends React.Component { commentPostedHandler(); } }) - .catch(err => { + .catch(() => { this.setState({ loadingState: 'error' }); - forEachError(err, ({ msg }) => notify('error', msg)); }); }; diff --git a/client/talk-plugin-flags/components/FlagButton.js b/client/talk-plugin-flags/components/FlagButton.js index 64841a893..b2048e105 100644 --- a/client/talk-plugin-flags/components/FlagButton.js +++ b/client/talk-plugin-flags/components/FlagButton.js @@ -60,9 +60,10 @@ export default class FlagButton extends Component { }); }; - onPopupContinue = () => { + onPopupContinue = async () => { const { postFlag, postDontAgree, id, author_id } = this.props; const { itemType, reason, step, message } = this.state; + let failed = false; switch (step) { case 0: @@ -75,13 +76,9 @@ export default class FlagButton extends Component { return; } break; - } - - // Proceed to the next step or close the menu if we've reached the end - if (step + 1 >= this.props.getPopupMenu.length) { - this.closeMenu(); - } else { - this.setState({ step: step + 1 }); + case this.props.getPopupMenu.length: + this.closeMenu(); + return; } // If itemType and reason are both set, post the action @@ -96,43 +93,46 @@ export default class FlagButton extends Component { break; } - if (itemType === 'COMMENTS') { - this.setState({ localPost: 'temp' }); - } - let action = { item_id, item_type: itemType, message, }; + if (reason === REASONS.comment.noagree) { - postDontAgree(action) - .then(({ data }) => { - if (itemType === 'COMMENTS') { - this.setState({ localPost: data.createDontAgree.dontagree.id }); - } - }) - .catch(err => { - this.props.notify('error', getErrorMessages(err)); - console.error(err); - }); - } else { - postFlag({ ...action, reason }) - .then(({ data }) => { - if (itemType === 'COMMENTS') { - this.setState({ localPost: data.createFlag.flag.id }); - } - }) - .catch(errors => { - forEachError(errors, ({ error, msg }) => { - if (error.translation_key === 'ALREADY_EXISTS') { - msg = t('already_flagged_username'); - } - this.props.notify('error', msg); + const result = await postDontAgree(action); + try { + if (itemType === 'COMMENTS') { + this.setState({ + localPost: result.data.createDontAgree.dontagree.id, }); + } + } catch (err) { + this.props.notify('error', getErrorMessages(err)); + console.error(err); + failed = true; + } + } else { + try { + const result = await postFlag({ ...action, reason }); + if (itemType === 'COMMENTS') { + this.setState({ localPost: result.data.createFlag.flag.id }); + } + } catch (errors) { + forEachError(errors, ({ error, msg }) => { + if (error.translation_key === 'ALREADY_EXISTS') { + msg = t('already_flagged_username'); + } + this.props.notify('error', msg); }); + failed = true; + } } } + + if (!failed) { + this.setState({ step: step + 1 }); + } }; onPopupOptionClick = sets => e => { diff --git a/client/talk-plugin-history/CommentHistory.js b/client/talk-plugin-history/CommentHistory.js index a56350c4a..a17a347c5 100644 --- a/client/talk-plugin-history/CommentHistory.js +++ b/client/talk-plugin-history/CommentHistory.js @@ -2,7 +2,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import Comment from './Comment'; import LoadMore from './LoadMore'; -import { forEachError } from 'plugin-api/beta/client/utils'; class CommentHistory extends React.Component { state = { @@ -16,11 +15,8 @@ class CommentHistory extends React.Component { .then(() => { this.setState({ loadingState: 'success' }); }) - .catch(error => { + .catch(() => { this.setState({ loadingState: 'error' }); - forEachError(error, ({ msg }) => { - this.props.notify('error', msg); - }); }); }; @@ -55,7 +51,6 @@ class CommentHistory extends React.Component { CommentHistory.propTypes = { comments: PropTypes.object.isRequired, loadMore: PropTypes.func, - notify: PropTypes.func, link: PropTypes.func, data: PropTypes.object, root: PropTypes.object, diff --git a/docs/_docs/03-03-product-guide-moderator-features.md b/docs/_docs/03-03-product-guide-moderator-features.md index 064257623..671461f31 100644 --- a/docs/_docs/03-03-product-guide-moderator-features.md +++ b/docs/_docs/03-03-product-guide-moderator-features.md @@ -133,16 +133,18 @@ Talk also allows you to moderate a commenters recent comments from this view. Talk also supports a number of keyboard shortcuts that moderators can leverage to moderate quickly: -| Shortcut | Action | -| -------- | ------------------------------- | -| `j` | Go to the next comment | -| `k` | Go to the previous comment | -| `ctrl+f` | Open search | -| `t` | Switch queues | -| `s` | Toggle single comment edit view | -| `?` | Open this menu | -| `d` | Approve | -| `f` | Reject | +| Shortcut | Action | +| -------- | -------------------------- | +| `j` | Go to the next comment | +| `k` | Go to the previous comment | +| `ctrl+f` | Open search | +| `t` | Switch queues | +| `z` | Zen mode | +| `?` | Open this menu | +| `d` | Approve | +| `f` | Reject | + +Note: "Zen mode" allows a moderator to view and action only one comment at a time. Enjoy the silence! ### Stories diff --git a/docs/_docs/06-01-migrating-4.md b/docs/_docs/06-01-migrating-4.md index b66921cf0..c8af6844d 100644 --- a/docs/_docs/06-01-migrating-4.md +++ b/docs/_docs/06-01-migrating-4.md @@ -40,28 +40,6 @@ documents rather than performing a nice table alter. If the process crashes during the migration, simply re-run it. The migration operations are designed to act atomically, and be idempotent to documents already updated. -## Database Verifications - -In `v3.*`, we introduced the concept of "verifying the database". Some of our -operations update cached values that live along side the original document to -improve performance. Running the cli command for verifying the database's cache -ensures that all the cached values are up to date. - -Running the following will start the database verification process: - -```bash -./bin/cli verify db --fix -``` -You can notice the `--fix` option, without it, the tool should instead perform -a dry run of the operations it intends to perform. -{: .code-aside} - -This process, like the migration process, should take some time to complete on -large databases. - -Once you have updated your databases, that's all you have to do! Talk should now -function even better and faster with all the new features we poured into v4.0.0! - ## Template Change In `v4.0.0`, we introduced extensive support for compressing our javascript diff --git a/graph/connectors.js b/graph/connectors.js index a27b9d38c..976f05b71 100644 --- a/graph/connectors.js +++ b/graph/connectors.js @@ -24,6 +24,7 @@ const Limit = require('../services/limit'); const Mailer = require('../services/mailer'); const Metadata = require('../services/metadata'); const Migration = require('../services/migration'); +const Moderation = require('../services/moderation'); const Mongoose = require('../services/mongoose'); const Passport = require('../services/passport'); const Plugins = require('../services/plugins'); @@ -62,6 +63,7 @@ const connectors = { Mailer, Metadata, Migration, + Moderation, Mongoose, Passport, Plugins, diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 6106f819c..7e0607287 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -1,13 +1,11 @@ const errors = require('../../errors'); const ActionModel = require('../../models/action'); -const AssetsService = require('../../services/assets'); const ActionsService = require('../../services/actions'); const TagsService = require('../../services/tags'); const CommentsService = require('../../services/comments'); const KarmaService = require('../../services/karma'); const merge = require('lodash/merge'); -const linkify = require('linkify-it')().tlds(require('tlds')); -const Wordlist = require('../../services/wordlist'); + const { CREATE_COMMENT, SET_COMMENT_STATUS, @@ -15,10 +13,6 @@ const { EDIT_COMMENT, } = require('../../perms/constants'); const debug = require('debug')('talk:graph:mutators:comment'); -const { - DISABLE_AUTOFLAG_SUSPECT_WORDS, - IGNORE_FLAGS_AGAINST_STAFF, -} = require('../../config'); const resolveTagsForComment = async ( { user, loaders: { Tags } }, @@ -188,279 +182,27 @@ const createComment = async ( return comment; }; -/** - * Filters the comment object and outputs wordlist results. - * @param {Object} context graphql context - * @param {String} body body of a comment - * @param {String} [asset_id] id of asset comment is posted on - * @return {Object} resolves to the wordlist results - */ -const filterNewComment = async (context, { body, asset_id }) => { - // Load the settings. - const [settings, asset] = await Promise.all([ - context.loaders.Settings.load(), - context.loaders.Assets.getByID.load(asset_id), - ]); - - // Create a new instance of the Wordlist. - const wl = new Wordlist(); - - // Load the wordlist. - wl.upsert(settings.wordlist); - - // Load the wordlist and filter the comment content. - return [ - // Scan the word. - wl.scan('body', body), - - // Return the asset's settings. - await AssetsService.rectifySettings(asset, settings), - ]; -}; - -/** - * moderationPhases is an array of phases carried out in order until a status is - * returned. - */ -const moderationPhases = [ - // This phase checks to see if the comment is long enough. - (context, comment) => { - // Check to see if the body is too short, if it is, then complain about it! - if (comment.body.length < 2) { - throw errors.ErrCommentTooShort; - } - }, - - // This phase checks to see if the asset being processed is closed or not. - (context, comment, { asset }) => { - // Check to see if the asset has closed commenting... - if (asset.isClosed) { - throw new errors.ErrAssetCommentingClosed(asset.closedMessage); - } - }, - - // This phase checks the comment against the wordlist. - (context, comment, { wordlist }) => { - // Decide the status based on whether or not the current asset/settings - // has pre-mod enabled or not. If the comment was rejected based on the - // wordlist, then reject it, otherwise if the moderation setting is - // premod, set it to `premod`. - if (wordlist.banned) { - // Add the flag related to Trust to the comment. - return { - status: 'REJECTED', - actions: [ - { - action_type: 'FLAG', - user_id: null, - group_id: 'BANNED_WORD', - metadata: {}, - }, - ], - }; - } - - // If the comment has a suspect word or a link, we need to add a - // flag to it to indicate that it needs to be looked at. - // Otherwise just return the new comment. - - // If the wordlist has matched the suspect word filter and we haven't disabled - // auto-flagging suspect words, then we should flag the comment! - if (wordlist.suspect && !DISABLE_AUTOFLAG_SUSPECT_WORDS) { - // TODO: this is kind of fragile, we should refactor this to resolve - // all these const's that we're using like 'COMMENTS', 'FLAG' to be - // defined in a checkable schema. - return { - actions: [ - { - action_type: 'FLAG', - user_id: null, - group_id: 'SUSPECT_WORD', - metadata: {}, - }, - ], - }; - } - }, - - // This phase checks to see if the comment's length exceeds maximum. - (context, comment, { assetSettings: { charCountEnable, charCount } }) => { - // Reject if the comment is too long - if (charCountEnable && comment.body.length > charCount) { - // Add the flag related to Trust to the comment. - return { - status: 'REJECTED', - actions: [ - { - action_type: 'FLAG', - user_id: null, - group_id: 'BODY_COUNT', - metadata: { - count: comment.body.length, - }, - }, - ], - }; - } - }, - - // If a given user is a staff member, always approve their comment. - context => { - if (IGNORE_FLAGS_AGAINST_STAFF && context.user && context.user.isStaff()) { - return { - status: 'ACCEPTED', - }; - } - }, - - // This phase checks the comment if it has any links in it if the check is - // enabled. - (context, comment, { assetSettings: { premodLinksEnable } }) => { - if (premodLinksEnable && linkify.test(comment.body)) { - // Add the flag related to Trust to the comment. - return { - status: 'SYSTEM_WITHHELD', - actions: [ - { - action_type: 'FLAG', - user_id: null, - group_id: 'LINKS', - metadata: { - links: comment.body, - }, - }, - ], - }; - } - }, - - // This phase checks to see if the user making the comment is allowed to do so - // considering their reliability (Trust) status. - context => { - if (context.user && context.user.metadata) { - // If the user is not a reliable commenter (passed the unreliability - // threshold by having too many rejected comments) then we can change the - // status of the comment to `SYSTEM_WITHHELD`, therefore pushing the user's - // comments away from the public eye until a moderator can manage them. This of - // course can only be applied if the comment's current status is `NONE`, - // we don't want to interfere if the comment was rejected. - if ( - KarmaService.isReliable('comment', context.user.metadata.trust) === - false - ) { - // Add the flag related to Trust to the comment. - return { - status: 'SYSTEM_WITHHELD', - actions: [ - { - action_type: 'FLAG', - user_id: null, - group_id: 'TRUST', - metadata: { - trust: context.user.metadata.trust, - }, - }, - ], - }; - } - } - }, - - // This phase checks to see if the comment was already prescribed a status. - (context, comment) => { - // If the status was already defined, don't redefine it. It's only defined - // when specific external conditions exist, we don't want to override that. - if (comment.status && comment.status.length > 0) { - return { - status: comment.status, - }; - } - }, - - // This phase checks to see if the settings have premod enabled, if they do, - // the comment is premod, otherwise, it's just none. - (context, comment, { assetSettings: { moderation } }) => { - // If the settings say that we're in premod mode, then the comment is in - // premod status. - if (moderation === 'PRE') { - return { - status: 'PREMOD', - }; - } - - return { - status: 'NONE', - }; - }, -]; - -/** - * This resolves a given comment's status and actions. - * @param {Object} context graphql context - * @param {String} body body of the comment - * @param {String} [asset_id] asset for the comment - * @param {Object} [wordlist={}] the results of the wordlist scan - * @return {Promise} resolves to the comment's status and actions - */ -const resolveCommentModeration = async (context, comment) => { - // First we filter the comment contents to ensure that we note any validation - // issues. - let [wordlist, settings] = await filterNewComment(context, comment); - - // Get the asset from the loader. - const asset = await context.loaders.Assets.getByID.load(comment.asset_id); - if (!asset) { - // And leave now if this asset wasn't found. - throw errors.ErrNotFound; - } - - // Combine the asset and the settings to get the asset settings. - const assetSettings = await AssetsService.rectifySettings(asset, settings); - - let actions = comment.actions || []; - - // Loop over all the moderation phases and see if we've resolved the status. - for (const phase of moderationPhases) { - const result = await phase(context, comment, { - asset, - assetSettings, - settings, - wordlist, - }); - - if (result) { - if (result.actions) { - actions.push(...result.actions); - } - - // If this result contained a status, then we've finished resolving - // phases! - if (result.status) { - return { status: result.status, actions }; - } - } - } -}; - /** * createPublicComment is designed to create a comment from a public source. It * validates the comment, and performs some automated moderator actions based on * the settings. - * @param {Object} context the graphql context + * @param {Object} ctx the graphql context * @param {Object} commentInput the new comment to be created * @return {Promise} resolves to a new comment */ -const createPublicComment = async (context, comment) => { +const createPublicComment = async (ctx, comment) => { + const { connectors: { services: { Moderation } } } = ctx; + // We then take the wordlist and the comment into consideration when // considering what status to assign the new comment, and resolve the new // status to set the comment to. - let { actions, status } = await resolveCommentModeration(context, comment); + let { actions, status } = await Moderation.process(ctx, comment); // Assign status to comment. comment.status = status; // Then we actually create the comment with the new status. - const result = await createComment(context, comment); + const result = await createComment(ctx, comment); // Create all the actions that were determined during the moderation check // phase. @@ -522,18 +264,20 @@ const setStatus = async ({ user, loaders: { Comments } }, { id, status }) => { * @param {Object} edit describes how to edit the comment * @param {String} edit.body the new Comment body */ -const edit = async (context, { id, asset_id, edit: { body } }) => { +const edit = async (ctx, { id, asset_id, edit: { body } }) => { + const { connectors: { services: { Moderation } } } = ctx; + // Build up the new comment we're setting. We need to check this with // moderation now. let comment = { id, asset_id, body }; // Determine the new status of the comment. - const { actions, status } = await resolveCommentModeration(context, comment); + const { actions, status } = await Moderation.process(ctx, comment); // Execute the edit. comment = await CommentsService.edit({ id, - author_id: context.user.id, + author_id: ctx.user.id, body, status, }); @@ -543,7 +287,7 @@ const edit = async (context, { id, asset_id, edit: { body } }) => { await createActions(comment.id, actions); // Publish the edited comment via the subscription. - context.pubsub.publish('commentEdited', comment); + ctx.pubsub.publish('commentEdited', comment); return comment; }; diff --git a/graph/resolvers/util.js b/graph/resolvers/util.js index ff9ab9c95..992148b4f 100644 --- a/graph/resolvers/util.js +++ b/graph/resolvers/util.js @@ -50,8 +50,12 @@ const decorateWithPermissionCheck = (typeResolver, protect) => { */ const decorateUserField = (typeResolver, field) => { // The default resolver for the user decorator is loading the user by id. - let fieldResolver = (obj, args, ctx) => - ctx.loaders.Users.getByID.load(obj[field]); + let fieldResolver = (obj, args, ctx) => { + if (!obj[field]) { + return null; + } + return ctx.loaders.Users.getByID.load(obj[field]); + }; // The resolver can be overridden however. This decorator will simply wrap the // field with a permission check. diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 79d1c5b0f..cec8e0015 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -453,6 +453,11 @@ type EditInfo { editableUntil: Date } +type CommentBodyHistory { + body: String! + created_at: Date! +} + type CommentStatusHistory { type: COMMENT_STATUS! created_at: Date! @@ -471,6 +476,9 @@ type Comment { # The actual comment data. body: String! + # The body history of the comment. + body_history: [CommentBodyHistory!]! + # the tags on the comment tags: [TagLink!] @@ -502,6 +510,9 @@ type Comment { # The time when the comment was created created_at: Date! + # The time when the comment was updated. + updated_at: Date + # describes how the comment can be edited editing: EditInfo diff --git a/locales/da.yml b/locales/da.yml index d4544043c..b3cf32ea4 100644 --- a/locales/da.yml +++ b/locales/da.yml @@ -274,7 +274,7 @@ da: shift_key: "⇧" shortcuts: "Genveje" show_shortcuts: "Vis genveje" - singleview: "Skift enkeltkommentar redigerings visning" + singleview: "Zen mode" thismenu: "Åben denne menu" thousand: "k" try_these: "Prøv disse" diff --git a/locales/en.yml b/locales/en.yml index 8a9ce4bcb..efcd7a1d9 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -330,7 +330,7 @@ en: shortcuts: "Shortcuts" sort: "Sort" show_shortcuts: "Show Shortcuts" - singleview: "Toggle single comment edit view" + singleview: "Zen mode" thismenu: "Open this menu" jump_to_queue: "Jump to specific queue" thousand: k diff --git a/locales/es.yml b/locales/es.yml index 010119728..6b695851e 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -291,7 +291,7 @@ es: shortcuts: Atajos sort: "Ordenar" show_shortcuts: "Mostrar Atajos" - singleview: "Colocar vista de edición de comentario único" + singleview: "Modo zen" thismenu: "Abrir este menu" thousand: k try_these: "Intentar estos" diff --git a/locales/fr.yml b/locales/fr.yml index e9f291b65..890582f7a 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -225,7 +225,7 @@ fr: shift_key: ⇧ shortcuts: Raccourcis show_shortcuts: "Afficher les raccourcis" - singleview: "Passer en mode d'édition de commentaire unique" + singleview: "Mode zen" spam_ads: Spam / Publicités thismenu: "Ouvrir ce menu" thousand: k diff --git a/locales/nl_NL.yml b/locales/nl_NL.yml index 43e7d90f5..be8888600 100644 --- a/locales/nl_NL.yml +++ b/locales/nl_NL.yml @@ -325,7 +325,7 @@ nl_NL: shortcuts: "Sneltoetsen" sort: "Sorteer" show_shortcuts: "Toon sneltoetsen" - singleview: "Schakel wijzigen enkele reactie aan of uit" + singleview: "Zen-modus" thismenu: "Open dit menu" jump_to_queue: "Spring naar specifieke wachtrij" thousand: k diff --git a/locales/pt_BR.yml b/locales/pt_BR.yml index 7ed111287..bc36409cc 100644 --- a/locales/pt_BR.yml +++ b/locales/pt_BR.yml @@ -276,7 +276,7 @@ pt_BR: shift_key: "⇧" shortcuts: "Atalhos" show_shortcuts: "Ver atalhos" - singleview: "Alternar vista de edição de comentário único" + singleview: "Modo zen" spam_ads: Spam/Anuncios thismenu: "Abra este menu" thousand: k diff --git a/locales/zh_CN.yml b/locales/zh_CN.yml index 480115cbc..1b1709a38 100644 --- a/locales/zh_CN.yml +++ b/locales/zh_CN.yml @@ -290,7 +290,7 @@ zh_CN: shortcuts: "快捷键" sort: "排序" show_shortcuts: "显示快捷键" - singleview: "展开单评论编辑视图" + singleview: "禅宗模式" thismenu: "开启该菜单" jump_to_queue: "跳转到特定序列" thousand: "千" diff --git a/locales/zh_TW.yml b/locales/zh_TW.yml index f18971a8f..0edceedb4 100644 --- a/locales/zh_TW.yml +++ b/locales/zh_TW.yml @@ -290,7 +290,7 @@ zh_TW: shortcuts: "快捷鍵" sort: "排序" show_shortcuts: "顯示快捷鍵" - singleview: "切換單個評論編輯視圖" + singleview: "禪宗模式" thismenu: "打開這個菜單" jump_to_queue: "跳轉到特定隊列" thousand: 千 diff --git a/migrations/1496771633_tags.js b/migrations/1496771633_tags.js index b21ff671e..6893a5c47 100644 --- a/migrations/1496771633_tags.js +++ b/migrations/1496771633_tags.js @@ -1,65 +1,38 @@ const CommentModel = require('../models/comment'); -module.exports = { - async up() { - // Find all comments that have tags. - let comments = await CommentModel.aggregate([ - { - $match: { - tags: { - $exists: true, - $ne: [], - }, - }, - }, - { - $project: { - id: true, - tags: true, - }, - }, - ]); +// OLD +// +// [ +// { +// name: 'OFF_TOPIC', +// assigned_by: '', +// created_at: new Date() +// } +// ] - // If no comments were found, nothing needs to be done! - if (comments.length <= 0) { - return; - } - - const updates = []; - - // Loop over the comments retrieved, updating the tag structure. - for (let { id, tags } of comments) { - // OLD - // - // [ - // { - // name: 'OFF_TOPIC', - // assigned_by: '', - // created_at: new Date() - // } - // ] - - // NEW - // - // [ - // { - // tag: { - // name: 'OFF_TOPIC', - // permissions: { - // public: true, - // self: false, - // roles: [] - // }, - // models: ['COMMENTS'], - // created_at: new Date() - // }, - // assigned_by: '', - // created_at: new Date() - // } - // ] - - // Remap the tag structure. - tags = tags.map(({ name, assigned_by, created_at }) => ({ +// NEW +// +// [ +// { +// tag: { +// name: 'OFF_TOPIC', +// permissions: { +// public: true, +// self: false, +// roles: [] +// }, +// models: ['COMMENTS'], +// created_at: new Date() +// }, +// assigned_by: '', +// created_at: new Date() +// } +// ] +const transformTags = ({ id, tags }) => ({ + query: { id }, + update: { + $set: { + tags: tags.map(({ name, assigned_by, created_at }) => ({ tag: { name, permissions: { @@ -72,22 +45,34 @@ module.exports = { }, assigned_by, created_at, - })); + })), + }, + }, +}); - updates.push({ query: { id }, update: { $set: { tags } } }); - } +module.exports = { + async up({ transformSingleWithCursor }) { + // Find all comments that have tags. + const cursor = CommentModel.collection.aggregate( + [ + { + $match: { + tags: { + $exists: true, + $ne: [], + }, + }, + }, + { + $project: { + id: true, + tags: true, + }, + }, + ], + { allowDiskUse: true } + ); - if (updates.length > 0) { - // Create a new batch operation. - let batch = CommentModel.collection.initializeUnorderedBulkOp(); - - for (const { query, update } of updates) { - // Execute the batch operation. - batch.find(query).updateOne(update); - } - - // Execute the batch update operation. - await batch.execute(); - } + await transformSingleWithCursor(cursor, transformTags, CommentModel); }, }; diff --git a/migrations/1507310316_flags.js b/migrations/1507310316_flags.js index d29a2c25b..1ff3b4248 100644 --- a/migrations/1507310316_flags.js +++ b/migrations/1507310316_flags.js @@ -12,7 +12,7 @@ const mapping = { }; module.exports = { - async up() { + async up({ processManyUpdates }) { const updates = []; for (const item_type in mapping) { const mappings = mapping[item_type]; @@ -44,15 +44,7 @@ module.exports = { } if (updates.length > 0) { - // Setup the batch operation. - const batch = ActionModel.collection.initializeUnorderedBulkOp(); - - for (const { query, update } of updates) { - batch.find(query).update(update); - } - - // Execute the batch update operation. - await batch.execute(); + await processManyUpdates(ActionModel, updates); } }, }; diff --git a/migrations/1510174676_user_status.js b/migrations/1510174676_user_status.js index b3d7c0984..80271557b 100644 --- a/migrations/1510174676_user_status.js +++ b/migrations/1510174676_user_status.js @@ -1,60 +1,132 @@ const UserModel = require('../models/user'); const merge = require('lodash/merge'); -const getUserBatch = async () => { - let query = { - status: { - $in: ['ACTIVE', 'BANNED', 'PENDING', 'APPROVED'], +const transformUser = user => { + const created_at = Date.now(); + + const { id, status, canEditName, suspension, disabled } = user; + + let update = { + $unset: { + canEditName: '', + suspension: '', + disabled: '', + }, + $set: { + status: { + // The username status is specific to each case. + username: { + history: [], + }, + + // The user is not banned by default. + banned: { + status: false, + history: [], + }, + + // The user is not suspended by default. + suspension: { + until: null, + history: [], + }, + }, + updated_at: created_at, }, }; - // Find all the users that need migrating. - return UserModel.collection.find(query); -}; - -module.exports = { - async up() { - const created_at = Date.now(); - - // Get the first batch of users. - let cursor = await getUserBatch(); - - const updates = []; - while (await cursor.hasNext()) { - const user = await cursor.next(); - - const { id, status, canEditName, suspension, disabled } = user; - - let update = { - $unset: { - canEditName: '', - suspension: '', - disabled: '', + if (disabled) { + update = merge(update, { + $set: { + status: { + banned: { + status: true, + history: [ + { + status: true, + created_at, + }, + ], + }, }, - $set: { - status: { - // The username status is specific to each case. - username: { - history: [], - }, + }, + }); + } - // The user is not banned by default. - banned: { - status: false, - history: [], - }, + // If the user has an "until" property of their suspension, then we need + // to reflect that in the new status object. + if (suspension && suspension.until !== null) { + update = merge(update, { + $set: { + status: { + suspension: { + until: suspension.until, + history: [ + { + until: suspension.until, + created_at, + }, + ], + }, + }, + }, + }); + } - // The user is not suspended by default. - suspension: { - until: null, - history: [], + switch (status) { + case 'ACTIVE': + if (canEditName) { + update = merge(update, { + $set: { + status: { + username: { + status: 'UNSET', + history: [ + { + status: 'UNSET', + created_at, + }, + ], + }, }, }, - updated_at: created_at, - }, - }; - - if (disabled) { + }); + } else { + update = merge(update, { + $set: { + status: { + username: { + status: 'SET', + history: [ + { + status: 'SET', + created_at, + }, + ], + }, + }, + }, + }); + } + break; + case 'BANNED': + if (canEditName) { + update = merge(update, { + $set: { + status: { + username: { + status: 'REJECTED', + history: [ + { + status: 'REJECTED', + created_at, + }, + ], + }, + }, + }, + }); + } else { update = merge(update, { $set: { status: { @@ -67,22 +139,11 @@ module.exports = { }, ], }, - }, - }, - }); - } - - // If the user has an "until" property of their suspension, then we need - // to reflect that in the new status object. - if (suspension && suspension.until !== null) { - update = merge(update, { - $set: { - status: { - suspension: { - until: suspension.until, + username: { + status: 'SET', history: [ { - until: suspension.until, + status: 'SET', created_at, }, ], @@ -91,138 +152,57 @@ module.exports = { }, }); } - - switch (status) { - case 'ACTIVE': - if (canEditName) { - update = merge(update, { - $set: { - status: { - username: { - status: 'UNSET', - history: [ - { - status: 'UNSET', - created_at, - }, - ], - }, - }, - }, - }); - } else { - update = merge(update, { - $set: { - status: { - username: { - status: 'SET', - history: [ - { - status: 'SET', - created_at, - }, - ], - }, - }, - }, - }); - } - break; - case 'BANNED': - if (canEditName) { - update = merge(update, { - $set: { - status: { - username: { - status: 'REJECTED', - history: [ - { - status: 'REJECTED', - created_at, - }, - ], - }, - }, - }, - }); - } else { - update = merge(update, { - $set: { - status: { - banned: { - status: true, - history: [ - { - status: true, - created_at, - }, - ], - }, - username: { - status: 'SET', - history: [ - { - status: 'SET', - created_at, - }, - ], - }, - }, - }, - }); - } - break; - case 'PENDING': - update = merge(update, { - $set: { - status: { - username: { + break; + case 'PENDING': + update = merge(update, { + $set: { + status: { + username: { + status: 'CHANGED', + history: [ + { status: 'CHANGED', - history: [ - { - status: 'CHANGED', - created_at, - }, - ], + created_at, }, - }, + ], }, - }); - break; - case 'APPROVED': - update = merge(update, { - $set: { - status: { - username: { + }, + }, + }); + break; + case 'APPROVED': + update = merge(update, { + $set: { + status: { + username: { + status: 'APPROVED', + history: [ + { status: 'APPROVED', - history: [ - { - status: 'APPROVED', - created_at, - }, - ], + created_at, }, - }, + ], }, - }); - break; - default: - throw new Error(`${status} is an invalid status`); - } + }, + }, + }); + break; + default: + throw new Error(`${status} is an invalid status`); + } - updates.push({ query: { id }, update }); - } + return { query: { id }, update }; +}; - if (updates.length > 0) { - // Create a new batch operation. - let bulk = UserModel.collection.initializeUnorderedBulkOp(); +module.exports = { + async up({ transformSingleWithCursor }) { + // Get the first batch of users. + const cursor = UserModel.collection.find({ + status: { + $in: ['ACTIVE', 'BANNED', 'PENDING', 'APPROVED'], + }, + }); - for (const { query, update } of updates) { - bulk.find(query).updateOne(update); - } - - // Execute the bulk update operation. - await bulk.execute(); - } + await transformSingleWithCursor(cursor, transformUser, UserModel); }, }; diff --git a/migrations/1511801783_user_roles.js b/migrations/1511801783_user_roles.js index 08a0a1abc..961a696e2 100644 --- a/migrations/1511801783_user_roles.js +++ b/migrations/1511801783_user_roles.js @@ -13,18 +13,16 @@ const findNewRole = roles => { }; module.exports = { - async up() { - const cursor = await UserModel.collection.find({ + async up({ transformSingleWithCursor }) { + const cursor = UserModel.collection.find({ roles: { $exists: true, }, }); - const updates = []; - while (await cursor.hasNext()) { - const user = await cursor.next(); - - updates.push({ + await transformSingleWithCursor( + cursor, + user => ({ query: { id: user.id, }, @@ -38,19 +36,8 @@ module.exports = { roles: '', }, }, - }); - } - - if (updates.length > 0) { - // Create a new batch operation. - const bulk = UserModel.collection.initializeUnorderedBulkOp(); - - for (const { query, update } of updates) { - bulk.find(query).updateOne(update); - } - - // Execute the bulk update operation. - await bulk.execute(); - } + }), + UserModel + ); }, }; diff --git a/migrations/1516920154_action_counts.js b/migrations/1516920154_action_counts.js new file mode 100644 index 000000000..7558b5eb0 --- /dev/null +++ b/migrations/1516920154_action_counts.js @@ -0,0 +1,124 @@ +const ActionModel = require('../models/action'); +const UserModel = require('../models/user'); +const CommentModel = require('../models/comment'); + +module.exports = { + async up({ transformSingleWithCursor }) { + const models = [ + { Model: CommentModel, item_type: 'COMMENTS' }, + { Model: UserModel, item_type: 'USERS' }, + ]; + for (const { Model, item_type } of models) { + let cursor = ActionModel.collection.aggregate( + [ + { + $match: { + group_id: { $ne: null }, + item_type, + }, + }, + { + $group: { + // group unique documents by these properties, we are leveraging the + // fact that each uuid is completely unique. + _id: { + item_id: '$item_id', + action_type: '$action_type', + group_id: '$group_id', + }, + + // and sum up all actions matching the above grouping criteria + count: { + $sum: 1, + }, + }, + }, + { + $project: { + // suppress the _id field + _id: false, + + // map the fields from the _id grouping down a level + item_id: '$_id.item_id', + action_type: { $toLower: '$_id.action_type' }, + group_id: { $toLower: '$_id.group_id' }, + + // map the field directly + count: '$count', + }, + }, + ], + { allowDiskUse: true } + ); + + // Transform those documents. + await transformSingleWithCursor( + cursor, + ({ item_id, action_type, group_id, count }) => ({ + query: { id: item_id }, + update: { + $set: { + [`action_counts.${action_type}_${group_id}`]: count, + }, + }, + }), + Model + ); + + // Secondly, we'll collect the group group id's (all the actions for a + // specific action type) to update counts of. + cursor = ActionModel.collection.aggregate( + [ + { + $match: { + item_type, + }, + }, + { + $group: { + // group unique documents by these properties, we are leveraging the + // fact that each uuid is completely unique. + _id: { + item_id: '$item_id', + action_type: '$action_type', + }, + + // and sum up all actions matching the above grouping criteria + count: { + $sum: 1, + }, + }, + }, + { + $project: { + // suppress the _id field + _id: false, + + // map the fields from the _id grouping down a level + item_id: '$_id.item_id', + action_type: { $toLower: '$_id.action_type' }, + + // map the field directly + count: '$count', + }, + }, + ], + { allowDiskUse: true } + ); + + // Transform those documents. + await transformSingleWithCursor( + cursor, + ({ item_id, action_type, count }) => ({ + query: { id: item_id }, + update: { + $set: { + [`action_counts.${action_type}`]: count, + }, + }, + }), + Model + ); + } + }, +}; diff --git a/migrations/1516920160_reply_counts.js b/migrations/1516920160_reply_counts.js new file mode 100644 index 000000000..fc81bd912 --- /dev/null +++ b/migrations/1516920160_reply_counts.js @@ -0,0 +1,33 @@ +const CommentModel = require('../models/comment'); + +const transformComments = ({ _id: parent_id, reply_count }) => ({ + query: { id: parent_id, reply_count: { $ne: reply_count } }, + update: { $set: { reply_count } }, +}); + +module.exports = { + async up({ transformSingleWithCursor }) { + const cursor = CommentModel.collection.aggregate( + [ + { + $match: { + parent_id: { $ne: null }, + status: { $in: ['NONE', 'ACCEPTED'] }, + }, + }, + { + $group: { + _id: '$parent_id', + reply_count: { + $sum: 1, + }, + }, + }, + ], + { allowDiskUse: true } + ); + + // Transform those documents. + await transformSingleWithCursor(cursor, transformComments, CommentModel); + }, +}; diff --git a/models/asset.js b/models/asset.js index 68ca08bbf..6fdea3b78 100644 --- a/models/asset.js +++ b/models/asset.js @@ -2,6 +2,7 @@ const mongoose = require('../services/mongoose'); const Schema = mongoose.Schema; const uuid = require('uuid'); const TagLinkSchema = require('./schema/tag_link'); +const get = require('lodash/get'); const AssetSchema = new Schema( { @@ -45,8 +46,8 @@ const AssetSchema = new Schema( // the base settings from the base Settings object. This is to be accessed // always after running `rectifySettings` against it. settings: { - type: Schema.Types.Mixed, default: {}, + type: Object, }, // Tags are added by the self or by administrators. @@ -85,9 +86,12 @@ AssetSchema.index( * Returns true if the asset is closed, false else. */ AssetSchema.virtual('isClosed').get(function() { - return Boolean( - this.closedAt && this.closedAt.getTime() <= new Date().getTime() - ); + const closedAt = get(this, 'closedAt', null); + if (closedAt === null) { + return false; + } + + return closedAt.getTime() <= new Date().getTime(); }); const Asset = mongoose.model('Asset', AssetSchema); diff --git a/package.json b/package.json index 90abdf2e7..b3afdc9b6 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "talk": { "migration": { - "minVersion": 1511801783 + "minVersion": 1516920160 } }, "repository": { @@ -211,7 +211,7 @@ "mocha-junit-reporter": "^1.12.1", "nightwatch": "^0.9.16", "nodemon": "^1.11.0", - "pre-git": "^3.16.0", + "pre-commit": "^1.2.2", "selenium-standalone": "^6.11.0", "sinon": "^3.2.1", "sinon-chai": "^2.13.0", @@ -220,24 +220,12 @@ "engines": { "node": "^8" }, - "config": { - "pre-git": { - "pre-commit": [ - "yarn lint", - "yarn test:client", - "yarn test:server" - ], - "pre-push": [ - "yarn lint", - "yarn test:client", - "yarn test:server" - ], - "post-commit": [], - "post-checkout": [], - "post-merge": [] - } - }, - "release": { - "analyzeCommits": "simple-commit-message" + "pre-commit": { + "silent": false, + "run": [ + "lint", + "test:client", + "test:server" + ] } } diff --git a/plugin-api/beta/client/hocs/withReaction.js b/plugin-api/beta/client/hocs/withReaction.js index f5a0167b7..6020d4002 100644 --- a/plugin-api/beta/client/hocs/withReaction.js +++ b/plugin-api/beta/client/hocs/withReaction.js @@ -9,11 +9,7 @@ import withFragments from 'coral-framework/hocs/withFragments'; import withMutation from 'coral-framework/hocs/withMutation'; import { notify } from 'coral-framework/actions/notification'; import { capitalize } from 'coral-framework/helpers/strings'; -import { - getMyActionSummary, - getTotalActionCount, - getErrorMessages, -} from 'coral-framework/utils'; +import { getMyActionSummary, getTotalActionCount } from 'coral-framework/utils'; import hoistStatics from 'recompose/hoistStatics'; import * as PropTypes from 'prop-types'; import { getDefinitionName } from '../utils'; @@ -282,7 +278,6 @@ export default (reaction, options = {}) => }) .catch(err => { this.duringMutation = false; - this.props.notify('error', getErrorMessages(err)); throw err; }); }; @@ -307,7 +302,6 @@ export default (reaction, options = {}) => }) .catch(err => { this.duringMutation = false; - this.props.notify('error', getErrorMessages(err)); throw err; }); }; diff --git a/plugin-api/beta/client/hocs/withTags.js b/plugin-api/beta/client/hocs/withTags.js index 7b95a5e6c..958028a4b 100644 --- a/plugin-api/beta/client/hocs/withTags.js +++ b/plugin-api/beta/client/hocs/withTags.js @@ -1,13 +1,11 @@ import React from 'react'; import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; import { compose, gql } from 'react-apollo'; import { getDisplayName } from 'coral-framework/helpers/hoc'; import { capitalize } from 'coral-framework/helpers/strings'; import { withAddTag, withRemoveTag } from 'coral-framework/graphql/mutations'; import withFragments from 'coral-framework/hocs/withFragments'; -import { notify } from 'coral-framework/actions/notification'; -import { getErrorMessages, isTagged } from 'coral-framework/utils'; +import { isTagged } from 'coral-framework/utils'; import hoistStatics from 'recompose/hoistStatics'; import { getDefinitionName } from '../utils'; @@ -38,7 +36,7 @@ export default (tag, options = {}) => loading = false; postTag = () => { - const { comment, asset, notify } = this.props; + const { comment, asset } = this.props; if (this.loading) { return; @@ -59,13 +57,12 @@ export default (tag, options = {}) => }) .catch(err => { this.loading = false; - notify('error', getErrorMessages(err)); throw err; }); }; deleteTag = () => { - const { comment, asset, notify } = this.props; + const { comment, asset } = this.props; if (this.loading) { return; @@ -84,7 +81,6 @@ export default (tag, options = {}) => }) .catch(err => { this.loading = false; - notify('error', getErrorMessages(err)); throw err; }); }; @@ -114,9 +110,6 @@ export default (tag, options = {}) => user: state.auth.user, }); - const mapDispatchToProps = dispatch => - bindActionCreators({ notify }, dispatch); - const enhance = compose( withFragments({ ...fragments, @@ -146,7 +139,7 @@ export default (tag, options = {}) => }), withAddTag, withRemoveTag, - connect(mapStateToProps, mapDispatchToProps) + connect(mapStateToProps, null) ); WithTags.displayName = `WithTags(${getDisplayName(WrappedComponent)})`; diff --git a/plugins/talk-plugin-featured-comments/client/components/ModTag.js b/plugins/talk-plugin-featured-comments/client/components/ModTag.js index 420ee9417..659b2cea4 100644 --- a/plugins/talk-plugin-featured-comments/client/components/ModTag.js +++ b/plugins/talk-plugin-featured-comments/client/components/ModTag.js @@ -64,7 +64,6 @@ export default class ModTag extends React.Component { ModTag.propTypes = { alreadyTagged: PropTypes.bool, deleteTag: PropTypes.func, - notify: PropTypes.func, openFeaturedDialog: PropTypes.func, comment: PropTypes.object, asset: PropTypes.object, diff --git a/plugins/talk-plugin-featured-comments/client/components/TabPane.js b/plugins/talk-plugin-featured-comments/client/components/TabPane.js index 084de7d0a..9bdba6fe8 100644 --- a/plugins/talk-plugin-featured-comments/client/components/TabPane.js +++ b/plugins/talk-plugin-featured-comments/client/components/TabPane.js @@ -1,7 +1,6 @@ import React from 'react'; import Comment from '../containers/Comment'; import LoadMore from './LoadMore'; -import { getErrorMessages } from 'plugin-api/beta/client/utils'; class TabPane extends React.Component { state = { @@ -15,9 +14,8 @@ class TabPane extends React.Component { .then(() => { this.setState({ loadingState: 'success' }); }) - .catch(error => { + .catch(() => { this.setState({ loadingState: 'error' }); - this.props.notify('error', getErrorMessages(error)); }); }; diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModActionButton.js b/plugins/talk-plugin-featured-comments/client/containers/ModActionButton.js index 1f210cb95..7c8f94fb3 100644 --- a/plugins/talk-plugin-featured-comments/client/containers/ModActionButton.js +++ b/plugins/talk-plugin-featured-comments/client/containers/ModActionButton.js @@ -13,8 +13,8 @@ const mapDispatchToProps = dispatch => ); const enhance = compose( - withTags('featured'), - connect(null, mapDispatchToProps) + connect(null, mapDispatchToProps), + withTags('featured') ); export default enhance(ModActionButton); diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModIndicatorSubscription.js b/plugins/talk-plugin-featured-comments/client/containers/ModIndicatorSubscription.js new file mode 100644 index 000000000..86ae6bb2c --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/containers/ModIndicatorSubscription.js @@ -0,0 +1,63 @@ +import React from 'react'; +import { gql } from 'react-apollo'; +import { subscriptionFields } from 'coral-admin/src/routes/Moderation/graphql'; + +class ModIndicatorSubscription extends React.Component { + subscriptions = null; + + componentWillMount() { + const configs = [ + { + document: COMMENT_FEATURED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentFeatured: { comment } } } } + ) => { + return this.props.handleCommentChange(prev, comment); + }, + }, + { + document: COMMENT_UNFEATURED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentUnfeatured: { comment } } } } + ) => { + return this.props.handleCommentChange(prev, comment); + }, + }, + ]; + this.subscriptions = configs.map(config => + this.props.data.subscribeToMore(config) + ); + } + + componentWillUnmount() { + this.subscriptions.forEach(unsubscribe => unsubscribe()); + } + + render() { + return null; + } +} + +const COMMENT_FEATURED_SUBSCRIPTION = gql` + subscription TalkFeaturedComments_Indicator_CommentFeatured { + commentFeatured { + comment { + ${subscriptionFields} + } + } + } +`; + +const COMMENT_UNFEATURED_SUBSCRIPTION = gql` + subscription TalkFeaturedComments_Indicator_CommentUnfeatured { + commentUnfeatured { + comment { + ${subscriptionFields} + } + } + } +`; + +export default ModIndicatorSubscription; diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js b/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js index 3e7eb3aaf..6d0fdd811 100644 --- a/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js +++ b/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js @@ -5,6 +5,7 @@ import Comment from 'coral-admin/src/routes/Moderation/containers/Comment'; import { getDefinitionName } from 'coral-framework/utils'; import truncate from 'lodash/truncate'; import t from 'coral-framework/services/i18n'; +import { subscriptionFields } from 'coral-admin/src/routes/Moderation/graphql'; function prepareNotificationText(text) { return truncate(text, { length: 50 }).replace('\n', ' '); @@ -79,14 +80,7 @@ const COMMENT_FEATURED_SUBSCRIPTION = gql` commentFeatured(asset_id: $assetId) { comment { ...${getDefinitionName(Comment.fragments.comment)} - status_history { - type - created_at - assigned_by { - id - username - } - } + ${subscriptionFields} } user { id @@ -102,6 +96,7 @@ const COMMENT_UNFEATURED_SUBSCRIPTION = gql` commentUnfeatured(asset_id: $assetId){ comment { ...${getDefinitionName(Comment.fragments.comment)} + ${subscriptionFields} } user { id diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModTag.js b/plugins/talk-plugin-featured-comments/client/containers/ModTag.js index 990a8a2e9..42c653418 100644 --- a/plugins/talk-plugin-featured-comments/client/containers/ModTag.js +++ b/plugins/talk-plugin-featured-comments/client/containers/ModTag.js @@ -3,12 +3,10 @@ import { withTags, connect } from 'plugin-api/beta/client/hocs'; import { gql, compose } from 'react-apollo'; import { bindActionCreators } from 'redux'; import { openFeaturedDialog } from '../actions'; -import { notify } from 'plugin-api/beta/client/actions/notification'; const mapDispatchToProps = dispatch => bindActionCreators( { - notify, openFeaturedDialog, }, dispatch @@ -24,8 +22,8 @@ const fragments = { `, }; const enhance = compose( - withTags('featured', { fragments }), - connect(null, mapDispatchToProps) + connect(null, mapDispatchToProps), + withTags('featured', { fragments }) ); export default enhance(ModTag); diff --git a/plugins/talk-plugin-featured-comments/client/containers/TabPane.js b/plugins/talk-plugin-featured-comments/client/containers/TabPane.js index 5bf92fb0a..297f5aedf 100644 --- a/plugins/talk-plugin-featured-comments/client/containers/TabPane.js +++ b/plugins/talk-plugin-featured-comments/client/containers/TabPane.js @@ -4,7 +4,6 @@ import { compose, gql } from 'react-apollo'; import TabPane from '../components/TabPane'; import { withFragments, connect } from 'plugin-api/beta/client/hocs'; import Comment from '../containers/Comment'; -import { notify } from 'plugin-api/beta/client/actions/notification'; import { viewComment } from 'coral-embed-stream/src/actions/stream'; import { appendNewNodes, @@ -81,7 +80,6 @@ const mapDispatchToProps = dispatch => bindActionCreators( { viewComment, - notify, }, dispatch ); diff --git a/plugins/talk-plugin-featured-comments/client/index.js b/plugins/talk-plugin-featured-comments/client/index.js index 1f0bac704..2572a54f4 100644 --- a/plugins/talk-plugin-featured-comments/client/index.js +++ b/plugins/talk-plugin-featured-comments/client/index.js @@ -6,6 +6,7 @@ import update from 'immutability-helper'; import ModTag from './containers/ModTag'; import ModActionButton from './containers/ModActionButton'; import ModSubscription from './containers/ModSubscription'; +import ModIndicatorSubscription from './containers/ModIndicatorSubscription'; import FeaturedDialog from './containers/FeaturedDialog'; import { gql } from 'react-apollo'; import reducer from './reducer'; @@ -23,6 +24,7 @@ export default { moderationActions: [ModActionButton], adminModeration: [ModSubscription, FeaturedDialog], adminCommentInfoBar: [ModTag], + adminModerationIndicator: [ModIndicatorSubscription], }, mutations: { IgnoreUser: ({ variables }) => ({ diff --git a/plugins/talk-plugin-featured-comments/index.js b/plugins/talk-plugin-featured-comments/index.js index f5a9c8ae5..6dd3114e1 100644 --- a/plugins/talk-plugin-featured-comments/index.js +++ b/plugins/talk-plugin-featured-comments/index.js @@ -36,7 +36,7 @@ module.exports = { commentFeatured: (options, args) => ({ commentFeatured: { filter: ({ comment }, { user }) => { - if (args.asset_id === null) { + if (!args.asset_id) { return check(user, ['ADMIN', 'MODERATOR']); } return comment.asset_id === args.asset_id; @@ -46,7 +46,7 @@ module.exports = { commentUnfeatured: (options, args) => ({ commentUnfeatured: { filter: ({ comment }, { user }) => { - if (args.asset_id === null) { + if (!args.asset_id) { return check(user, ['ADMIN', 'MODERATOR']); } return comment.asset_id === args.asset_id; diff --git a/plugins/talk-plugin-ignore-user/client/containers/IgnoreUserConfirmation.js b/plugins/talk-plugin-ignore-user/client/containers/IgnoreUserConfirmation.js index 733dab756..5aa834034 100644 --- a/plugins/talk-plugin-ignore-user/client/containers/IgnoreUserConfirmation.js +++ b/plugins/talk-plugin-ignore-user/client/containers/IgnoreUserConfirmation.js @@ -10,21 +10,16 @@ import { bindActionCreators } from 'redux'; import { closeMenu } from 'plugins/talk-plugin-author-menu/client/actions'; import { notify } from 'plugin-api/beta/client/actions/notification'; import { t } from 'plugin-api/beta/client/services'; -import { getErrorMessages } from 'plugin-api/beta/client/utils'; class IgnoreUserConfirmationContainer extends React.Component { ignoreUser = () => { const { ignoreUser, notify, comment, closeMenu } = this.props; - ignoreUser(comment.user.id) - .then(() => { - notify( - 'success', - t('talk-plugin-ignore-user.notify_success', comment.user.username) - ); - }) - .catch(err => { - notify('error', getErrorMessages(err)); - }); + ignoreUser(comment.user.id).then(() => { + notify( + 'success', + t('talk-plugin-ignore-user.notify_success', comment.user.username) + ); + }); closeMenu(); }; diff --git a/plugins/talk-plugin-moderation-actions/client/containers/ApproveCommentAction.js b/plugins/talk-plugin-moderation-actions/client/containers/ApproveCommentAction.js index 70cb2ce4b..1f7bed3bb 100644 --- a/plugins/talk-plugin-moderation-actions/client/containers/ApproveCommentAction.js +++ b/plugins/talk-plugin-moderation-actions/client/containers/ApproveCommentAction.js @@ -1,23 +1,16 @@ import React from 'react'; import { compose } from 'react-apollo'; -import { bindActionCreators } from 'redux'; -import { getErrorMessages } from 'plugin-api/beta/client/utils'; -import { notify } from 'plugin-api/beta/client/actions/notification'; import ApproveCommentAction from '../components/ApproveCommentAction'; -import { connect, withSetCommentStatus } from 'plugin-api/beta/client/hocs'; +import { withSetCommentStatus } from 'plugin-api/beta/client/hocs'; class ApproveCommentActionContainer extends React.Component { approveComment = async () => { - const { setCommentStatus, comment, hideMenu, notify } = this.props; + const { setCommentStatus, comment, hideMenu } = this.props; - try { - await setCommentStatus({ - commentId: comment.id, - status: 'ACCEPTED', - }); - } catch (err) { - notify('error', getErrorMessages(err)); - } + await setCommentStatus({ + commentId: comment.id, + status: 'ACCEPTED', + }); hideMenu(); }; @@ -32,17 +25,6 @@ class ApproveCommentActionContainer extends React.Component { } } -const mapDispatchToProps = dispatch => - bindActionCreators( - { - notify, - }, - dispatch - ); - -const enhance = compose( - connect(null, mapDispatchToProps), - withSetCommentStatus -); +const enhance = compose(withSetCommentStatus); export default enhance(ApproveCommentActionContainer); diff --git a/plugins/talk-plugin-moderation-actions/client/containers/BanUserDialog.js b/plugins/talk-plugin-moderation-actions/client/containers/BanUserDialog.js index 6d64addca..8dd3cce7d 100644 --- a/plugins/talk-plugin-moderation-actions/client/containers/BanUserDialog.js +++ b/plugins/talk-plugin-moderation-actions/client/containers/BanUserDialog.js @@ -3,19 +3,16 @@ import PropTypes from 'prop-types'; import { compose } from 'react-apollo'; import { bindActionCreators } from 'redux'; import { closeBanDialog, closeMenu } from '../actions'; -import { notify } from 'plugin-api/beta/client/actions/notification'; import { connect, withSetCommentStatus, withBanUser, } from 'plugin-api/beta/client/hocs'; -import { getErrorMessages } from 'plugin-api/beta/client/utils'; import BanUserDialog from '../components/BanUserDialog'; class BanUserDialogContainer extends React.Component { banUser = async () => { const { - notify, authorId, commentId, commentStatus, @@ -25,23 +22,19 @@ class BanUserDialogContainer extends React.Component { banUser, } = this.props; - try { - await banUser({ - id: authorId, - message: '', + await banUser({ + id: authorId, + message: '', + }); + + closeMenu(); + closeBanDialog(); + + if (commentStatus !== 'REJECTED') { + await setCommentStatus({ + commentId: commentId, + status: 'REJECTED', }); - - closeMenu(); - closeBanDialog(); - - if (commentStatus !== 'REJECTED') { - await setCommentStatus({ - commentId: commentId, - status: 'REJECTED', - }); - } - } catch (err) { - notify('error', getErrorMessages(err)); } }; @@ -70,7 +63,6 @@ const mapStateToProps = ({ talkPluginModerationActions: state }) => ({ const mapDispatchToProps = dispatch => bindActionCreators( { - notify, closeBanDialog, closeMenu, }, diff --git a/plugins/talk-plugin-moderation-actions/client/containers/RejectCommentAction.js b/plugins/talk-plugin-moderation-actions/client/containers/RejectCommentAction.js index e49fdddba..54ca0420d 100644 --- a/plugins/talk-plugin-moderation-actions/client/containers/RejectCommentAction.js +++ b/plugins/talk-plugin-moderation-actions/client/containers/RejectCommentAction.js @@ -1,23 +1,16 @@ import React from 'react'; import { compose } from 'react-apollo'; -import { bindActionCreators } from 'redux'; -import { getErrorMessages } from 'plugin-api/beta/client/utils'; -import { notify } from 'plugin-api/beta/client/actions/notification'; import RejectCommentAction from '../components/RejectCommentAction'; -import { connect, withSetCommentStatus } from 'plugin-api/beta/client/hocs'; +import { withSetCommentStatus } from 'plugin-api/beta/client/hocs'; class RejectCommentActionContainer extends React.Component { rejectComment = async () => { - const { setCommentStatus, comment, hideMenu, notify } = this.props; + const { setCommentStatus, comment, hideMenu } = this.props; - try { - await setCommentStatus({ - commentId: comment.id, - status: 'REJECTED', - }); - } catch (err) { - notify('error', getErrorMessages(err)); - } + await setCommentStatus({ + commentId: comment.id, + status: 'REJECTED', + }); hideMenu(); }; @@ -27,17 +20,6 @@ class RejectCommentActionContainer extends React.Component { } } -const mapDispatchToProps = dispatch => - bindActionCreators( - { - notify, - }, - dispatch - ); - -const enhance = compose( - connect(null, mapDispatchToProps), - withSetCommentStatus -); +const enhance = compose(withSetCommentStatus); export default enhance(RejectCommentActionContainer); diff --git a/public/javascripts/admin.js b/public/javascripts/admin.js deleted file mode 100644 index 2c8b7c42a..000000000 --- a/public/javascripts/admin.js +++ /dev/null @@ -1,8 +0,0 @@ -function showError(error) { - try { - let err = JSON.parse(error); - $('.error-console').text(err.message).addClass('active'); - } catch (err) { - $('.error-console').text(error).addClass('active'); - } -} diff --git a/public/javascripts/auth-callback.js b/public/javascripts/auth-callback.js deleted file mode 100644 index f91f4d743..000000000 --- a/public/javascripts/auth-callback.js +++ /dev/null @@ -1,4 +0,0 @@ -document.addEventListener('DOMContentLoaded', function(event) { - localStorage.setItem('auth', document.getElementById('auth').innerText); - setTimeout(function() { window.close(); }, 50); -}); \ No newline at end of file diff --git a/services/comments.js b/services/comments.js index e18e04970..68f991324 100644 --- a/services/comments.js +++ b/services/comments.js @@ -19,6 +19,7 @@ module.exports = class CommentsService { static async publicCreate(input) { // Extract the parent_id from the comment, if there is one. const { status = 'NONE', parent_id = null } = input; + const created_at = new Date(); // Check to see if we are replying to a comment, and if that comment is // visible. @@ -37,14 +38,14 @@ module.exports = class CommentsService { ? [ { type: status, - created_at: new Date(), + created_at, }, ] : [], body_history: [ { body: input.body, - created_at: new Date(), + created_at, }, ], }, @@ -85,6 +86,7 @@ module.exports = class CommentsService { */ static async edit({ id, author_id, body, status }) { const EDITABLE_STATUSES = ['NONE', 'PREMOD', 'ACCEPTED']; + const created_at = new Date(); const query = { id, @@ -112,11 +114,11 @@ module.exports = class CommentsService { $push: { body_history: { body, - created_at: new Date(), + created_at, }, status_history: { type: status, - created_at: new Date(), + created_at, }, }, }); @@ -160,53 +162,13 @@ module.exports = class CommentsService { editedComment.body = body; editedComment.body_history.push({ body, - created_at: new Date(), + created_at, }); editedComment.status_history.push({ type: status, - created_at: new Date(), + created_at, }); - // We should adjust the comment's status such that if it was approved - // previously, we should mark the comment as 'NONE' or 'PREMOD', which ever - // was most recent if the new comment is destined to be `NONE` or `PREMOD`. - if (originalComment.status === 'ACCEPTED' && status === 'NONE') { - const lastUnmoderatedStatus = CommentsService.lastUnmoderatedStatus( - originalComment - ); - - // If the last moderated status was found and the current comment doesn't - // match this already. - if (lastUnmoderatedStatus && status !== lastUnmoderatedStatus) { - // Update the comment model (if at this point, the status is still - // accepted) with the previously unmoderated status - await CommentModel.update( - { - id, - status, - }, - { - $set: { - status: lastUnmoderatedStatus, - }, - $push: { - status_history: { - type: lastUnmoderatedStatus, - created_at: new Date(), - }, - }, - } - ); - - // Update the returned comment. - editedComment.status = lastUnmoderatedStatus; - editedComment.status_history.push({ - type: lastUnmoderatedStatus, - created_at: new Date(), - }); - } - } - await events.emitAsync(COMMENTS_EDIT, originalComment, editedComment); return editedComment; diff --git a/services/migration/helpers.js b/services/migration/helpers.js new file mode 100644 index 000000000..5eb33aadf --- /dev/null +++ b/services/migration/helpers.js @@ -0,0 +1,107 @@ +const debug = require('debug')('talk:services:migration'); + +/** + * processUpdates processes batches of updates on the given model. + * + * @param {Object} model mongoose model that should perform the operations on + * @param {Array} updates array of updates to execute + */ +const processUpdates = async (model, updates) => { + // Create a new batch operation. + const bulk = model.collection.initializeUnorderedBulkOp(); + + for (const { query, update } of updates) { + bulk.find(query).updateOne(update); + } + + // Execute the bulk update operation. + await bulk.execute(); +}; + +const debugProcessStatistics = (count, totalCount) => { + if (totalCount > 0) { + debug( + `processed ${(count / totalCount * 100).toFixed( + 2 + )}% (${count}/${totalCount}) updates` + ); + } else { + debug(`processed ${count} updates`); + } +}; + +const transformSingleWithCursor = ({ + queryBatchSize, + updateBatchSize, +}) => async (query, process, Model) => { + debug('starting transform'); + + // We'll manage the updates that we store inside this object. + let updates = []; + + // Count the elements in the transformation. + let totalCount = 0; + try { + totalCount = await query.count(); + } catch (err) {} + + // First we'll collect all the individual actions with specific group id's. + const cursor = await query.batchSize(queryBatchSize); + + let count = 0; + while (await cursor.hasNext()) { + const result = await cursor.next(); + + const transformed = await process(result); + if (transformed) { + updates.push(transformed); + } + + if (updates.length > updateBatchSize) { + // Process the updates. + await processUpdates(Model, updates); + count += updates.length; + debugProcessStatistics(count, totalCount); + + // Clear the updates array. + updates = []; + } + } + + if (updates.length > 0) { + // Process the updates. + await processUpdates(Model, updates); + count += updates.length; + debugProcessStatistics(count, totalCount); + + // Clear the updates array. + updates = []; + } + + debug('finished transform'); +}; + +/** + * processManyUpdates processes batches of updates on many models with the given + * model. + * + * @param {Object} model mongoose model that should perform the operations on + * @param {Array} updates array of updates to execute + */ +const processManyUpdates = async (model, updates) => { + // Create a new batch operation. + const bulk = model.collection.initializeUnorderedBulkOp(); + + for (const { query, update } of updates) { + bulk.find(query).update(update); + } + + // Execute the bulk update operation. + await bulk.execute(); +}; + +module.exports = ctx => ({ + processManyUpdates, + processUpdates, + transformSingleWithCursor: transformSingleWithCursor(ctx), +}); diff --git a/services/migration.js b/services/migration/index.js similarity index 82% rename from services/migration.js rename to services/migration/index.js index f8f09d469..e0da3cddf 100644 --- a/services/migration.js +++ b/services/migration/index.js @@ -1,17 +1,20 @@ -const MigrationModel = require('../models/migration'); +const MigrationModel = require('../../models/migration'); const fs = require('fs'); +const ms = require('ms'); const path = require('path'); const Joi = require('joi'); const debug = require('debug')('talk:services:migration'); const sc = require('snake-case'); -const { talk: { migration: { minVersion } } } = require('../package.json'); +const helpers = require('./helpers'); +const { stripIndent } = require('common-tags'); +const { talk: { migration: { minVersion } } } = require('../../package.json'); -const migrationTemplate = `module.exports = { - async up() { - - } -}; +const migrationTemplate = stripIndent` + module.exports = { + async up({ queryBatchSize, updateBatchSize }) { + } + }; `; class MigrationService { @@ -44,7 +47,7 @@ class MigrationService { static async listPending() { // Get all the migration files. let migrationFiles = fs.readdirSync( - path.join(__dirname, '..', 'migrations') + path.join(__dirname, '..', '..', 'migrations') ); // Ensure that all migrations follow this format. @@ -61,6 +64,7 @@ class MigrationService { // Parse the migrations from the file listing. let migrations = migrationFiles + .filter(filename => versionRe.test(filename)) .map(filename => { // Parse the version from the filename. let matches = filename.match(versionRe); @@ -75,7 +79,7 @@ class MigrationService { } // Read the migration from the filesystem. - let migration = require(`../migrations/${filename}`); + let migration = require(`../../migrations/${filename}`); Joi.assert( migration, migrationSchema, @@ -109,17 +113,26 @@ class MigrationService { * * @param {Array} migrations a list of migrations returned by `listPending` */ - static async run(migrations) { + static async run( + migrations, + { queryBatchSize = 10000, updateBatchSize = 20000 } = {} + ) { if (migrations.length === 0) { console.log('No migrations to run!'); return; } + // Create the context helpers. + const ctx = helpers({ queryBatchSize, updateBatchSize }); + for (let { filename, version, migration } of migrations) { try { + const startTime = new Date(); console.log(`Starting migration ${filename}`); - await migration.up(); - console.log(`Finished migration ${filename}`); + await migration.up(ctx); + const endTime = new Date(); + const totalTime = endTime.getTime() - startTime.getTime(); + console.log(`Finished migration ${filename} in ${ms(totalTime)}`); } catch (e) { console.error(`Migration ${filename} failed`); throw e; diff --git a/services/moderation/index.js b/services/moderation/index.js new file mode 100644 index 000000000..4d87e190f --- /dev/null +++ b/services/moderation/index.js @@ -0,0 +1,130 @@ +const errors = require('../../errors'); +const get = require('lodash/get'); + +// Load in the phases to use. +const { + wordlist, + commentLength, + assetClosed, + karma, + staff, + links, + premod, +} = require('./phases'); + +// This phase checks to see if the comment was already prescribed a status. This +// essentially provides a hook for plugins to inject their own comments. +const applyPreexisting = (ctx, comment) => { + const status = get(comment, 'status'); + + // If the status was already defined, don't redefine it. It's only defined + // when specific external conditions exist, we don't want to override that. + if (status) { + return { + status, + }; + } +}; + +// Applies the defaulted status. +const applyStatus = status => () => ({ status }); + +/** + * phases is an array of moderation phases carried out in order until a status is + * returned. + */ +const phases = [ + commentLength, + assetClosed, + wordlist, + staff, + links, + karma, + applyPreexisting, + premod, + applyStatus('NONE'), +]; + +/** + * compose will create a moderation pipeline for which is executable with the + * passed actions. + * + * @param {Array} phases the set of moderation phases to pass the comment and + * their options through. + */ +const compose = phases => async (ctx, comment, options) => { + const actions = get(comment, 'actions', []); + + // Loop over all the moderation phases and see if we've resolved the status. + for (const phase of phases) { + const result = await phase(ctx, comment, options); + if (result) { + if (result.actions) { + actions.push(...result.actions); + } + + // If this result contained a status, then we've finished resolving + // phases! + if (result.status) { + return { status: result.status, actions }; + } + } + } +}; + +/** + * fetchOptions will generate the options used by the moderation service to + * determine the end status. + * + * @param {Object} ctx graph context + * @param {Object} comment comment object to use + */ +const fetchOptions = async (ctx, comment) => { + const { + connectors: { services: { Assets: AssetsService } }, + loaders: { Settings, Assets }, + } = ctx; + + // Load the settings. + const settings = await Settings.load(); + + // Pull the asset id out of the comment. + const assetID = get(comment, 'asset_id', null); + if (assetID === null) { + // And leave now if this asset wasn't found. + throw errors.ErrNotFound; + } + + // Load the asset. + const asset = await Assets.getByID.load(assetID); + if (!asset) { + // And leave now if this asset wasn't found. + throw errors.ErrNotFound; + } + + // Combine the asset and the settings to get the asset settings. + asset.settings = await AssetsService.rectifySettings(asset, settings); + + // Create the options that will be consumed by the phases. + return { + asset, + settings, + }; +}; + +/** + * process the comment and return moderation details. + * + * @param {Object} ctx graphql context + * @param {Object} comment comment to perform the moderation phases on + */ +const process = async (ctx, comment) => { + // Fetch the options to use for the moderation phases. + const options = await fetchOptions(ctx, comment); + + // Compose a moderation pipeline from the moderation phases and execute it on + // the comment. + return compose(phases)(ctx, comment, options); +}; + +module.exports.process = process; diff --git a/services/moderation/phases/assetClosed.js b/services/moderation/phases/assetClosed.js new file mode 100644 index 000000000..075028764 --- /dev/null +++ b/services/moderation/phases/assetClosed.js @@ -0,0 +1,9 @@ +const { ErrAssetCommentingClosed } = require('../../../errors'); + +// This phase checks to see if the asset being processed is closed or not. +module.exports = (ctx, comment, { asset }) => { + // Check to see if the asset has closed commenting... + if (asset.isClosed) { + throw new ErrAssetCommentingClosed(asset.closedMessage); + } +}; diff --git a/services/moderation/phases/commentLength.js b/services/moderation/phases/commentLength.js new file mode 100644 index 000000000..925115326 --- /dev/null +++ b/services/moderation/phases/commentLength.js @@ -0,0 +1,31 @@ +const { ErrCommentTooShort } = require('../../../errors'); + +// This phase checks to see if the comment is long enough. +module.exports = ( + ctx, + comment, + { asset: { settings: { charCountEnable, charCount } } } +) => { + // Check to see if the body is too short, if it is, then complain about it! + if (comment.body.length < 2) { + throw ErrCommentTooShort; + } + + // Reject if the comment is too long + if (charCountEnable && comment.body.length > charCount) { + // Add the flag related to Trust to the comment. + return { + status: 'REJECTED', + actions: [ + { + action_type: 'FLAG', + user_id: null, + group_id: 'BODY_COUNT', + metadata: { + count: comment.body.length, + }, + }, + ], + }; + } +}; diff --git a/services/moderation/phases/index.js b/services/moderation/phases/index.js new file mode 100644 index 000000000..a1c2e7bf5 --- /dev/null +++ b/services/moderation/phases/index.js @@ -0,0 +1,7 @@ +module.exports.wordlist = require('./wordlist'); +module.exports.commentLength = require('./commentLength'); +module.exports.assetClosed = require('./assetClosed'); +module.exports.karma = require('./karma'); +module.exports.staff = require('./staff'); +module.exports.links = require('./links'); +module.exports.premod = require('./premod'); diff --git a/services/moderation/phases/karma.js b/services/moderation/phases/karma.js new file mode 100644 index 000000000..ad55b37a7 --- /dev/null +++ b/services/moderation/phases/karma.js @@ -0,0 +1,33 @@ +const get = require('lodash/get'); + +// This phase checks to see if the user making the comment is allowed to do so +// considering their reliability (Trust) status. +module.exports = ctx => { + const { connectors: { services: { Karma } } } = ctx; + const trust = get(ctx, 'user.metadata.trust', null); + + if (trust !== null) { + // If the user is not a reliable commenter (passed the unreliability + // threshold by having too many rejected comments) then we can change the + // status of the comment to `SYSTEM_WITHHELD`, therefore pushing the user's + // comments away from the public eye until a moderator can manage them. This of + // course can only be applied if the comment's current status is `NONE`, + // we don't want to interfere if the comment was rejected. + if (Karma.isReliable('comment', trust) === false) { + // Add the flag related to Trust to the comment. + return { + status: 'SYSTEM_WITHHELD', + actions: [ + { + action_type: 'FLAG', + user_id: null, + group_id: 'TRUST', + metadata: { + trust, + }, + }, + ], + }; + } + } +}; diff --git a/services/moderation/phases/links.js b/services/moderation/phases/links.js new file mode 100644 index 000000000..0aed4ecd9 --- /dev/null +++ b/services/moderation/phases/links.js @@ -0,0 +1,26 @@ +const linkify = require('linkify-it')().tlds(require('tlds')); + +// This phase checks the comment if it has any links in it if the check is +// enabled. +module.exports = ( + ctx, + comment, + { asset: { settings: { premodLinksEnable } } } +) => { + if (premodLinksEnable && linkify.test(comment.body)) { + // Add the flag related to Trust to the comment. + return { + status: 'SYSTEM_WITHHELD', + actions: [ + { + action_type: 'FLAG', + user_id: null, + group_id: 'LINKS', + metadata: { + links: comment.body, + }, + }, + ], + }; + } +}; diff --git a/services/moderation/phases/premod.js b/services/moderation/phases/premod.js new file mode 100644 index 000000000..cbd3627b1 --- /dev/null +++ b/services/moderation/phases/premod.js @@ -0,0 +1,15 @@ +// This phase checks to see if the settings have premod enabled, if they do, +// the comment is premod, otherwise, it's just none. +module.exports = (ctx, comment, { asset: { settings: { moderation } } }) => { + // If the settings say that we're in premod mode, then the comment is in + // premod status. + if (moderation === 'PRE') { + return { + status: 'PREMOD', + }; + } + + return { + status: 'NONE', + }; +}; diff --git a/services/moderation/phases/staff.js b/services/moderation/phases/staff.js new file mode 100644 index 000000000..4b6b74946 --- /dev/null +++ b/services/moderation/phases/staff.js @@ -0,0 +1,10 @@ +const { IGNORE_FLAGS_AGAINST_STAFF } = require('../../../config'); + +// If a given user is a staff member, always approve their comment. +module.exports = ctx => { + if (IGNORE_FLAGS_AGAINST_STAFF && ctx.user && ctx.user.isStaff()) { + return { + status: 'ACCEPTED', + }; + } +}; diff --git a/services/moderation/phases/wordlist.js b/services/moderation/phases/wordlist.js new file mode 100644 index 000000000..b71e0361e --- /dev/null +++ b/services/moderation/phases/wordlist.js @@ -0,0 +1,56 @@ +const { DISABLE_AUTOFLAG_SUSPECT_WORDS } = require('../../../config'); + +// This phase checks the comment against the wordlist. +module.exports = async (ctx, comment, { settings }) => { + const { connectors: { services: { Wordlist } } } = ctx; + + // Create a new instance of the Wordlist. + const wl = new Wordlist(); + + // Load the wordlist. + wl.upsert(settings.wordlist); + + // Scan the comment body for wordlist violations. + const { banned = null, suspect = null } = wl.scan('body', comment.body); + + // Decide the status based on whether or not the current asset/settings + // has pre-mod enabled or not. If the comment was rejected based on the + // wordlist, then reject it, otherwise if the moderation setting is + // premod, set it to `premod`. + if (banned) { + // Add the flag related to Trust to the comment. + return { + status: 'REJECTED', + actions: [ + { + action_type: 'FLAG', + user_id: null, + group_id: 'BANNED_WORD', + metadata: {}, + }, + ], + }; + } + + // If the comment has a suspect word or a link, we need to add a + // flag to it to indicate that it needs to be looked at. + // Otherwise just return the new comment. + + // If the wordlist has matched the suspect word filter and we haven't disabled + // auto-flagging suspect words, then we should flag the comment! + if (suspect && !DISABLE_AUTOFLAG_SUSPECT_WORDS) { + // TODO: this is kind of fragile, we should refactor this to resolve + // all these const's that we're using like 'COMMENTS', 'FLAG' to be + // defined in a checkable schema. + return { + actions: [ + { + action_type: 'FLAG', + user_id: null, + group_id: 'SUSPECT_WORD', + metadata: {}, + }, + ], + }; + } +}; diff --git a/test/server/graph/mutations/createComment.js b/test/server/graph/mutations/createComment.js index 8a5a98aa1..720297e12 100644 --- a/test/server/graph/mutations/createComment.js +++ b/test/server/graph/mutations/createComment.js @@ -49,6 +49,9 @@ describe('graph.mutations.createComment', () => { return graphql(schema, query, {}, context).then( ({ data, errors }) => { + if (errors) { + console.error(errors); + } expect(errors).to.be.undefined; if (error) { expect(data.createComment).to.have.property('comment').null; @@ -98,7 +101,9 @@ describe('graph.mutations.createComment', () => { async () => { const context = new Context({ user }); const { data, errors } = await graphql(schema, query, {}, context); - + if (errors) { + console.error(errors); + } expect(errors).to.be.undefined; if (error) { expect(data.createComment).to.have.property('comment').null; diff --git a/test/server/migrations/1510174676_user_status.js b/test/server/migrations/1510174676_user_status.js index 8e1ceca5d..f9af655d8 100644 --- a/test/server/migrations/1510174676_user_status.js +++ b/test/server/migrations/1510174676_user_status.js @@ -1,10 +1,14 @@ const migration = require('../../../migrations/1510174676_user_status'); const UserModel = require('../../../models/user'); +const helpers = require('../../../services/migration/helpers'); const chai = require('chai'); chai.use(require('chai-datetime')); const { expect } = chai; +const performMigration = () => + migration.up(helpers({ queryBatchSize: 100, updateBatchSize: 100 })); + describe('migration.1510174676_user_status', () => { describe('active user', () => { beforeEach(async () => { @@ -24,7 +28,7 @@ describe('migration.1510174676_user_status', () => { expect(user).to.have.property('canEditName', false); // Perform the migration. - await migration.up(); + await performMigration(); user = await UserModel.collection.findOne({ id: '123' }); @@ -54,7 +58,7 @@ describe('migration.1510174676_user_status', () => { expect(user).to.have.property('canEditName', true); // Perform the migration. - await migration.up(); + await performMigration(); user = await UserModel.collection.findOne({ id: '123' }); @@ -85,7 +89,7 @@ describe('migration.1510174676_user_status', () => { expect(user.canEditName).to.equal(true); // Perform the migration. - await migration.up(); + await performMigration(); user = await UserModel.collection.findOne({ id: '123' }); @@ -117,7 +121,7 @@ describe('migration.1510174676_user_status', () => { expect(user.canEditName).to.equal(false); // Perform the migration. - await migration.up(); + await performMigration(); user = await UserModel.collection.findOne({ id: '123' }); @@ -153,7 +157,7 @@ describe('migration.1510174676_user_status', () => { const until = user.suspension.until; // Perform the migration. - await migration.up(); + await performMigration(); user = await UserModel.collection.findOne({ id: '123' }); @@ -187,7 +191,7 @@ describe('migration.1510174676_user_status', () => { expect(user.status).to.equal('BANNED'); // Perform the migration. - await migration.up(); + await performMigration(); user = await UserModel.collection.findOne({ id: '123' }); diff --git a/test/server/services/comments.js b/test/server/services/comments.js index 2d0b37163..e0fc6da67 100644 --- a/test/server/services/comments.js +++ b/test/server/services/comments.js @@ -227,12 +227,12 @@ describe('services.CommentsService', () => { id: originalComment.id, author_id: '123', body: 'This is a body!', - status: 'NONE', + status: 'PREMOD', }); expect(editedComment).to.have.property('status', 'PREMOD'); - expect(editedComment.status_history).to.have.length(4); - expect(editedComment.status_history[3]).to.have.property( + expect(editedComment.status_history).to.have.length(3); + expect(editedComment.status_history[2]).to.have.property( 'type', 'PREMOD' ); @@ -240,8 +240,8 @@ describe('services.CommentsService', () => { retrivedComment = await CommentsService.findById(originalComment.id); expect(retrivedComment).to.have.property('status', 'PREMOD'); - expect(retrivedComment.status_history).to.have.length(4); - expect(retrivedComment.status_history[3]).to.have.property( + expect(retrivedComment.status_history).to.have.length(3); + expect(retrivedComment.status_history[2]).to.have.property( 'type', 'PREMOD' ); diff --git a/views/admin/confirm-email.ejs b/views/admin/confirm-email.ejs index 0bc54990b..f8f6c62f2 100644 --- a/views/admin/confirm-email.ejs +++ b/views/admin/confirm-email.ejs @@ -20,9 +20,17 @@ - - - + diff --git a/webpack.config.js b/webpack.config.js index d46c2cb7d..3367727f5 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -19,7 +19,11 @@ const targetPlugins = manager.section('targets').plugins; debug(`Using ${pluginsPath} as the plugin configuration path`); -const buildTargets = ['coral-admin', 'coral-docs']; +const buildTargets = [ + 'coral-admin', + 'coral-docs', + { name: 'coral-auth-callback', disablePolyfill: true }, +]; const buildEmbeds = ['stream']; @@ -156,9 +160,14 @@ const config = { modules: [ path.resolve(__dirname, 'plugins'), path.resolve(__dirname, 'client'), - ...buildTargets.map(target => - path.join(__dirname, 'client', target, 'src') - ), + ...buildTargets.map(target => { + if (typeof target !== 'string') { + target = target.name; + } + + return path.join(__dirname, 'client', target, 'src'); + }), + ...buildEmbeds.map(embed => path.join(__dirname, 'client', `coral-embed-${embed}`, 'src') ), @@ -276,10 +285,19 @@ module.exports = [ // All framework targets/embeds/plugins. applyConfig([ // Load in all the targets. - ...buildTargets.map(target => ({ - name: `${target}/bundle`, - path: path.join(__dirname, 'client/', target, '/src/index'), - })), + ...buildTargets.map(target => { + let disablePolyfill = false; + if (typeof target !== 'string') { + disablePolyfill = target.disablePolyfill; + target = target.name; + } + + return { + name: `${target}/bundle`, + path: path.join(__dirname, 'client/', target, '/src/index'), + disablePolyfill, + }; + }), // Load in all the embeds. ...buildEmbeds.map(embed => ({ diff --git a/yarn.lock b/yarn.lock index 4d5d3f52d..d13397fee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -55,8 +55,8 @@ to-fast-properties "^2.0.0" "@coralproject/eslint-config-talk@^0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@coralproject/eslint-config-talk/-/eslint-config-talk-0.1.0.tgz#3ddc5f6fb4362a1cd05a5fea56cdb3095afc8cc3" + version "0.1.1" + resolved "https://registry.yarnpkg.com/@coralproject/eslint-config-talk/-/eslint-config-talk-0.1.1.tgz#71991b4937a3ffe657128d7f1170da4b5fb75c9e" dependencies: babel-eslint "^8.0.1" eslint-config-prettier "^2.9.0" @@ -212,14 +212,6 @@ alphanum-sort@^1.0.1, alphanum-sort@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" -always-error@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/always-error/-/always-error-1.0.0.tgz#95c84042cfa86f38c86ca6c2cc42c0a0103441b2" - -am-i-a-dependency@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/am-i-a-dependency/-/am-i-a-dependency-1.1.2.tgz#f9d3422304d6f642f821e4c407565035f6167f1f" - amdefine@>=0.0.4: version "1.0.1" resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" @@ -230,10 +222,6 @@ ansi-align@^2.0.0: dependencies: string-width "^2.0.0" -ansi-escapes@^1.1.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" - ansi-escapes@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-2.0.0.tgz#5bae52be424878dd9783e8910e3fc2922e83c81b" @@ -242,10 +230,6 @@ ansi-escapes@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.0.0.tgz#ec3e8b4e9f8064fc02c3ac9b65f1c275bda8ef92" -ansi-regex@^1.0.0, ansi-regex@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-1.1.1.tgz#41c847194646375e6a1a5d10c3ca054ef9fc980d" - ansi-regex@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" @@ -447,7 +431,7 @@ arrify@^1.0.0, arrify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" -asap@^2.0.0, asap@~2.0.3: +asap@~2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" @@ -1249,15 +1233,11 @@ block-stream@*: dependencies: inherits "~2.0.0" -bluebird@2.9.24: - version "2.9.24" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.9.24.tgz#14a2e75f0548323dc35aa440d92007ca154e967c" - bluebird@3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c" -bluebird@3.5.1, bluebird@^3.0.6, bluebird@^3.3.4, bluebird@^3.4.6, bluebird@^3.5.0: +bluebird@^3.0.6, bluebird@^3.3.4, bluebird@^3.4.6, bluebird@^3.5.0: version "3.5.1" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" @@ -1629,14 +1609,6 @@ chain-function@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc" -chalk@2.3.0, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.0.tgz#b5ea48efc9c1793dccc9b4767c93914d3f2d52ba" - dependencies: - ansi-styles "^3.1.0" - escape-string-regexp "^1.0.5" - supports-color "^4.0.0" - chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" @@ -1647,6 +1619,14 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" +chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.0.tgz#b5ea48efc9c1793dccc9b4767c93914d3f2d52ba" + dependencies: + ansi-styles "^3.1.0" + escape-string-regexp "^1.0.5" + supports-color "^4.0.0" + change-emitter@^0.1.2: version "0.1.6" resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" @@ -1665,32 +1645,10 @@ charenc@~0.0.1: version "0.0.2" resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" -chdir-promise@0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/chdir-promise/-/chdir-promise-0.4.1.tgz#1888bb33719699c9fb72138c07556503c4913e85" - dependencies: - check-more-types "2.24.0" - debug "2.6.8" - lazy-ass "1.6.0" - q "1.5.0" - spots "0.5.0" - check-error@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" -check-more-types@2.23.0: - version "2.23.0" - resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.23.0.tgz#6226264d30b1095aa1c0a5b874edbdd5d2d0a66f" - -check-more-types@2.24.0: - version "2.24.0" - resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600" - -check-more-types@2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.3.0.tgz#b8397c69dc92a3e645f18932c045b09c74419ec4" - cheerio@^0.20.0: version "0.20.0" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-0.20.0.tgz#5c710f2bab95653272842ba01c6ea61b3545ec35" @@ -1779,28 +1737,18 @@ cli-boxes@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143" -cli-cursor@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" - dependencies: - restore-cursor "^1.0.1" - cli-cursor@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" dependencies: restore-cursor "^2.0.0" -cli-table@0.3.1, cli-table@^0.3.1: +cli-table@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23" dependencies: colors "1.0.3" -cli-width@^1.0.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-1.1.1.tgz#a4d293ef67ebb7b88d4a4d42c0ccf00c4d1e366d" - cli-width@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" @@ -1911,14 +1859,10 @@ colors@1.0.3, colors@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" -colors@1.1.2, colors@^1.1.2, colors@~1.1.2: +colors@^1.1.2, colors@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" -colors@~0.6.0-1: - version "0.6.2" - resolved "https://registry.yarnpkg.com/colors/-/colors-0.6.2.tgz#2423fe6678ac0c5dae8852e5d0e5be08c997abcc" - combined-stream@^1.0.5, combined-stream@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009" @@ -1931,10 +1875,6 @@ combined-stream@~0.0.4: dependencies: delayed-stream "0.0.5" -commander@2.11.0: - version "2.11.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563" - commander@2.8.x: version "2.8.1" resolved "https://registry.yarnpkg.com/commander/-/commander-2.8.1.tgz#06be367febfda0c330aa1e2a072d3dc9762425d4" @@ -1951,10 +1891,6 @@ commander@^2.11.0, commander@^2.9.0: version "2.12.2" resolved "https://registry.yarnpkg.com/commander/-/commander-2.12.2.tgz#0f5946c427ed9ec0d91a46bb9def53e54650e555" -commander@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.1.0.tgz#d121bbae860d9992a3d517ba96f56588e47c6781" - commander@~2.13.0: version "2.13.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c" @@ -2072,19 +2008,6 @@ content-type@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" -conventional-commit-message@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/conventional-commit-message/-/conventional-commit-message-1.1.0.tgz#ece8c661a168e983692e1d5a14875acb59510f6a" - dependencies: - check-more-types "2.3.0" - cz-conventional-changelog "1.1.5" - lazy-ass "1.3.0" - word-wrap "1.1.0" - -conventional-commit-types@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/conventional-commit-types/-/conventional-commit-types-2.2.0.tgz#5db95739d6c212acbe7b6f656a11b940baa68946" - convert-source-map@^1.4.0, convert-source-map@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.0.tgz#9acd70851c6d5dfdd93d9282e5edf94a03ff46b5" @@ -2374,26 +2297,6 @@ cyclist@~0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" -cz-conventional-changelog@1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/cz-conventional-changelog/-/cz-conventional-changelog-1.1.5.tgz#0a4d1550c4e2fb6a3aed8f6cd858c21760e119b8" - dependencies: - word-wrap "^1.0.3" - -cz-conventional-changelog@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cz-conventional-changelog/-/cz-conventional-changelog-2.1.0.tgz#2f4bc7390e3244e4df293e6ba351e4c740a7c764" - dependencies: - conventional-commit-types "^2.0.0" - lodash.map "^4.5.1" - longest "^1.0.1" - right-pad "^1.0.1" - word-wrap "^1.0.3" - -d3-helpers@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/d3-helpers/-/d3-helpers-0.3.0.tgz#4b31dce4a2121a77336384574d893fbed5fb293d" - d@1: version "1.0.0" resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f" @@ -2604,7 +2507,13 @@ dns-prefetch-control@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/dns-prefetch-control/-/dns-prefetch-control-0.1.0.tgz#60ddb457774e178f1f9415f0cabb0e85b0b300b2" -doctrine@^2.0.0, doctrine@^2.0.2: +doctrine@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + dependencies: + esutils "^2.0.2" + +doctrine@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.0.2.tgz#68f96ce8efc56cc42651f1faadb4f175273b0075" dependencies: @@ -2843,7 +2752,7 @@ error-ex@^1.2.0, error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -es-abstract@^1.4.3, es-abstract@^1.7.0: +es-abstract@^1.4.3: version "1.9.0" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.9.0.tgz#690829a07cae36b222e7fd9b75c0d0573eb25227" dependencies: @@ -2853,7 +2762,7 @@ es-abstract@^1.4.3, es-abstract@^1.7.0: is-callable "^1.1.3" is-regex "^1.0.4" -es-abstract@^1.6.1: +es-abstract@^1.6.1, es-abstract@^1.7.0: version "1.10.0" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.10.0.tgz#1ecb36c197842a00d8ee4c2dfd8646bb97d60864" dependencies: @@ -2970,8 +2879,8 @@ eslint-config-prettier@^2.9.0: get-stdin "^5.0.1" eslint-plugin-jest@^21.6.1: - version "21.6.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-21.6.1.tgz#adca015bbdb8d23b210438ff9e1cee1dd9ec35df" + version "21.7.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-21.7.0.tgz#651f1c6ce999af3ac59ab8bf8a376d742fd0fc23" eslint-plugin-mocha@^4.11.0: version "4.11.0" @@ -2980,8 +2889,8 @@ eslint-plugin-mocha@^4.11.0: ramda "^0.24.1" eslint-plugin-prettier@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-2.4.0.tgz#85cab0775c6d5e3344ef01e78d960f166fb93aae" + version "2.5.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-2.5.0.tgz#39a91dd7528eaf19cd42c0ee3f2c1f684606a05f" dependencies: fast-diff "^1.1.1" jest-docblock "^21.0.0" @@ -3156,10 +3065,6 @@ execa@^0.7.0: signal-exit "^3.0.0" strip-eof "^1.0.0" -exit-hook@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" - expand-brackets@^0.1.4: version "0.1.5" resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" @@ -3317,13 +3222,6 @@ fd-slicer@~1.0.1: dependencies: pend "~1.2.0" -figures@^1.3.5: - version "1.7.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" - dependencies: - escape-string-regexp "^1.0.5" - object-assign "^4.1.0" - figures@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" @@ -3400,16 +3298,6 @@ find-cache-dir@^1.0.0: make-dir "^1.0.0" pkg-dir "^2.0.0" -find-parent-dir@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/find-parent-dir/-/find-parent-dir-0.3.0.tgz#33c44b429ab2b2f0646299c5f9f718f376ff8d54" - -find-up@2.1.0, find-up@^2.0.0, find-up@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - dependencies: - locate-path "^2.0.0" - find-up@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" @@ -3417,12 +3305,11 @@ find-up@^1.0.0: path-exists "^2.0.0" pinkie-promise "^2.0.0" -findup@0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/findup/-/findup-0.1.5.tgz#8ad929a3393bac627957a7e5de4623b06b0e2ceb" +find-up@^2.0.0, find-up@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" dependencies: - colors "~0.6.0-1" - commander "~2.1.0" + locate-path "^2.0.0" flat-cache@^1.2.1: version "1.3.0" @@ -3683,57 +3570,6 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" -ggit@1.23.1: - version "1.23.1" - resolved "https://registry.yarnpkg.com/ggit/-/ggit-1.23.1.tgz#e513c2f222a6249a46e4d0df354b8604b5baeedb" - dependencies: - always-error "1.0.0" - bluebird "3.5.0" - chdir-promise "0.4.1" - check-more-types "2.24.0" - cli-table "0.3.1" - colors "1.1.2" - commander "2.11.0" - d3-helpers "0.3.0" - debug "2.6.8" - find-up "2.1.0" - glob "7.1.2" - lazy-ass "1.6.0" - lodash "3.10.1" - moment "2.18.1" - optimist "0.6.1" - pluralize "6.0.0" - q "2.0.3" - quote "0.4.0" - ramda "0.24.1" - semver "5.4.1" - -ggit@2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/ggit/-/ggit-2.4.0.tgz#b99d981f3ede2a3a8a8e4bbff578bc277d400588" - dependencies: - always-error "1.0.0" - bluebird "3.5.1" - chdir-promise "0.4.1" - check-more-types "2.24.0" - cli-table "0.3.1" - colors "1.1.2" - commander "2.11.0" - d3-helpers "0.3.0" - debug "3.1.0" - find-up "2.1.0" - glob "7.1.2" - lazy-ass "1.6.0" - lodash "4.17.4" - moment "2.19.1" - moment-timezone "0.5.13" - optimist "0.6.1" - pluralize "7.0.0" - q "2.0.3" - quote "0.4.0" - ramda "0.25.0" - semver "5.4.1" - git-up@^2.0.0: version "2.0.9" resolved "https://registry.yarnpkg.com/git-up/-/git-up-2.0.9.tgz#219bfd27c82daeead8495beb386dc18eae63636d" @@ -3797,17 +3633,6 @@ glob@7.1.1: once "^1.3.0" path-is-absolute "^1.0.0" -glob@7.1.2, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - glob@^5.0.3: version "5.0.15" resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" @@ -3818,6 +3643,17 @@ glob@^5.0.3: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + global-dirs@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445" @@ -4204,10 +4040,6 @@ hpkp@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/hpkp/-/hpkp-2.0.0.tgz#10e142264e76215a5d30c44ec43de64dee6d1672" -hr@0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/hr/-/hr-0.1.3.tgz#d9aa30f5929dabfd0b65ba395938a3e184dbcafe" - hsts@2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/hsts/-/hsts-2.1.0.tgz#cbd6c918a2385fee1dd5680bfb2b3a194c0121cc" @@ -4418,44 +4250,6 @@ inquirer-autocomplete-prompt@^0.12.1: inquirer "3.2.0" run-async "^2.3.0" -inquirer-confirm@0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/inquirer-confirm/-/inquirer-confirm-0.2.2.tgz#6f406d037bf9d9e455ef0f953929f357fe9a8848" - dependencies: - bluebird "2.9.24" - inquirer "0.8.2" - -inquirer@0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-0.12.0.tgz#1ef2bfd63504df0bc75785fff8c2c41df12f077e" - dependencies: - ansi-escapes "^1.1.0" - ansi-regex "^2.0.0" - chalk "^1.0.0" - cli-cursor "^1.0.1" - cli-width "^2.0.0" - figures "^1.3.5" - lodash "^4.3.0" - readline2 "^1.0.1" - run-async "^0.1.0" - rx-lite "^3.1.2" - string-width "^1.0.1" - strip-ansi "^3.0.0" - through "^2.3.6" - -inquirer@0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-0.8.2.tgz#41586548e1c5d9b3f81df7325034baacab6f58ab" - dependencies: - ansi-regex "^1.1.1" - chalk "^1.0.0" - cli-width "^1.0.1" - figures "^1.3.5" - lodash "^3.3.1" - readline2 "^0.1.1" - rx "^2.4.3" - through "^2.3.6" - inquirer@3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.2.0.tgz#45b44c2160c729d7578c54060b3eed94487bb42b" @@ -4475,7 +4269,7 @@ inquirer@3.2.0: strip-ansi "^4.0.0" through "^2.3.6" -inquirer@3.3.0, inquirer@^3.0.6, inquirer@^3.2.2: +inquirer@^3.0.6, inquirer@^3.2.2: version "3.3.0" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.3.0.tgz#9dd2f2ad765dcab1ff0443b491442a20ba227dc9" dependencies: @@ -5417,31 +5211,12 @@ kue@0.11.6: optionalDependencies: reds "^0.2.5" -largest-semantic-change@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/largest-semantic-change/-/largest-semantic-change-1.0.0.tgz#25dc538bdaaa8bbdc30276b1ebf902d47a34bf0e" - dependencies: - check-more-types "2.23.0" - lazy-ass "1.5.0" - latest-version@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15" dependencies: package-json "^4.0.0" -lazy-ass@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/lazy-ass/-/lazy-ass-1.3.0.tgz#7d0d14eef3ec9702c6f30c60ea81f1a8d3f900fb" - -lazy-ass@1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/lazy-ass/-/lazy-ass-1.5.0.tgz#ca15be243c7c475b8565cdbfa0f9c2f374f2a01d" - -lazy-ass@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/lazy-ass/-/lazy-ass-1.6.0.tgz#7999655e8646c17f089fdd187d150d3324d54513" - lazy-cache@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" @@ -5732,10 +5507,6 @@ lodash.keysin@^4.0.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.keysin/-/lodash.keysin-4.2.0.tgz#8cc3fb35c2d94acc443a1863e02fa40799ea6f28" -lodash.map@^4.5.1: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.map/-/lodash.map-4.6.0.tgz#771ec7839e3473d9c4cde28b19394c3562f4f6d3" - lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -5796,11 +5567,7 @@ lodash.values@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.values/-/lodash.values-4.3.0.tgz#a3a6c2b0ebecc5c2cba1c17e6e620fe81b53d347" -lodash@3.10.1, lodash@^3.3.1: - version "3.10.1" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" - -lodash@4.17.4, lodash@^4.0.0, lodash@^4.1.0, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0: +lodash@^4.0.0, lodash@^4.1.0, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0: version "4.17.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" @@ -6115,24 +5882,14 @@ mocha@^3.1.2: mkdirp "0.5.1" supports-color "3.1.2" -moment-timezone@0.5.13: - version "0.5.13" - resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.13.tgz#99ce5c7d827262eb0f1f702044177f60745d7b90" - dependencies: - moment ">= 2.9.0" - -moment@2.18.1: - version "2.18.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f" - -moment@2.19.1, moment@^2.10.3: - version "2.19.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.1.tgz#56da1a2d1cbf01d38b7e1afc31c10bcfa1929167" - -moment@2.x.x, "moment@>= 2.9.0": +moment@2.x.x: version "2.19.4" resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.4.tgz#17e5e2c6ead8819c8ecfad83a0acccb312e94682" +moment@^2.10.3: + version "2.19.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.1.tgz#56da1a2d1cbf01d38b7e1afc31c10bcfa1929167" + moment@^2.18.1: version "2.20.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd" @@ -6232,14 +5989,6 @@ murmurhash-js@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz#b06278e21fc6c37fa5313732b0412bcb6ae15f51" -mute-stream@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.4.tgz#a9219960a6d5d5d046597aee51252c6655f7177e" - -mute-stream@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" - mute-stream@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" @@ -6678,10 +6427,6 @@ once@^1.3.0, once@^1.3.1, once@^1.3.3, once@^1.4.0: dependencies: wrappy "1" -onetime@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" - onetime@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" @@ -6728,6 +6473,10 @@ os-locale@^2.0.0: lcid "^1.0.0" mem "^1.1.0" +os-shim@^0.1.2: + version "0.1.3" + resolved "https://registry.yarnpkg.com/os-shim/-/os-shim-0.1.3.tgz#6b62c3791cf7909ea35ed46e17658bb417cb3917" + os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" @@ -7038,21 +6787,13 @@ platform@1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.4.tgz#6f0fb17edaaa48f21442b3a975c063130f1c3ebd" -pluralize@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-6.0.0.tgz#d9b51afad97d3d51075cc1ddba9b132cacccb7ba" - -pluralize@7.0.0, pluralize@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777" - pluralize@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-1.2.1.tgz#d1a21483fd22bb41e58a12fa3421823140897c45" -pop-iterate@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/pop-iterate/-/pop-iterate-1.0.1.tgz#ceacfdab4abf353d7a0f2aaa2c1fc7b3f9413ba3" +pluralize@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777" popsicle@^6.2.0: version "6.2.2" @@ -7477,24 +7218,13 @@ postcss@^6.0.1: source-map "^0.6.1" supports-color "^4.4.0" -pre-git@^3.16.0: - version "3.16.0" - resolved "https://registry.yarnpkg.com/pre-git/-/pre-git-3.16.0.tgz#a7656bc5f277185fd213c78f39f24f2cb603eb61" +pre-commit@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/pre-commit/-/pre-commit-1.2.2.tgz#dbcee0ee9de7235e57f79c56d7ce94641a69eec6" dependencies: - bluebird "3.5.1" - chalk "2.3.0" - check-more-types "2.24.0" - conventional-commit-message "1.1.0" - cz-conventional-changelog "2.1.0" - debug "2.6.9" - ggit "2.4.0" - inquirer "3.3.0" - lazy-ass "1.6.0" - require-relative "0.8.7" - shelljs "0.7.8" - simple-commit-message "3.3.2" - validate-commit-msg "2.14.0" - word-wrap "1.2.3" + cross-spawn "^5.0.1" + spawn-sync "^1.0.15" + which "1.2.x" prebuild-install@^2.3.0: version "2.3.0" @@ -7785,18 +7515,10 @@ q@1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/q/-/q-1.4.1.tgz#55705bcd93c5f3673530c2c2cbc0c2b3addc286e" -q@1.5.0, q@^1.1.2: +q@^1.1.2: version "1.5.0" resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1" -q@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/q/-/q-2.0.3.tgz#75b8db0255a1a5af82f58c3f3aaa1efec7d0d134" - dependencies: - asap "^2.0.0" - pop-iterate "^1.0.1" - weak-map "^1.0.5" - qs@6.5.1, qs@^6.1.0, qs@^6.2.0, qs@~6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" @@ -7832,10 +7554,6 @@ querystring@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" -quote@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/quote/-/quote-0.4.0.tgz#10839217f6c1362b89194044d29b233fd7f32f01" - raf@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.0.tgz#a28876881b4bc2ca9117d4138163ddb80f781575" @@ -7846,14 +7564,10 @@ railroad-diagrams@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e" -ramda@0.24.1, ramda@^0.24.1: +ramda@^0.24.1: version "0.24.1" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.24.1.tgz#c3b7755197f35b8dc3502228262c4c91ddb6b857" -ramda@0.25.0: - version "0.25.0" - resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" - randexp@^0.4.2: version "0.4.6" resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3" @@ -8159,21 +7873,6 @@ readdirp@^2.0.0: readable-stream "^2.0.2" set-immediate-shim "^1.0.1" -readline2@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/readline2/-/readline2-0.1.1.tgz#99443ba6e83b830ef3051bfd7dc241a82728d568" - dependencies: - mute-stream "0.0.4" - strip-ansi "^2.0.1" - -readline2@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - mute-stream "0.0.5" - rechoir@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -8425,10 +8124,6 @@ require-main-filename@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" -require-relative@0.8.7: - version "0.8.7" - resolved "https://registry.yarnpkg.com/require-relative/-/require-relative-0.8.7.tgz#7999539fc9e047a37928fa196f8e1563dabd36de" - require-uncached@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/require-uncached/-/require-uncached-1.0.3.tgz#4e0d56d6c9662fd31e43011c4b95aa49955421d3" @@ -8467,13 +8162,6 @@ resolve@^1.1.7: dependencies: path-parse "^1.0.5" -restore-cursor@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" - dependencies: - exit-hook "^1.0.0" - onetime "^1.0.0" - restore-cursor@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" @@ -8495,10 +8183,6 @@ right-align@^0.1.1: dependencies: align-text "^0.1.1" -right-pad@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/right-pad/-/right-pad-1.0.1.tgz#8ca08c2cbb5b55e74dafa96bf7fd1a27d568c8d0" - rimraf@2, rimraf@^2.5.1, rimraf@^2.5.4, rimraf@^2.6.1: version "2.6.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" @@ -8525,12 +8209,6 @@ rst-selector-parser@^2.2.3: lodash.flattendeep "^4.4.0" nearley "^2.7.10" -run-async@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-0.1.0.tgz#c8ad4a5e110661e402a7d21b530e009f25f8e389" - dependencies: - once "^1.3.0" - run-async@^2.2.0, run-async@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" @@ -8553,14 +8231,6 @@ rx-lite@*, rx-lite@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444" -rx-lite@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102" - -rx@^2.4.3: - version "2.5.3" - resolved "https://registry.yarnpkg.com/rx/-/rx-2.5.3.tgz#21adc7d80f02002af50dae97fd9dbf248755f566" - safe-buffer@5.1.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" @@ -8640,15 +8310,11 @@ semver-diff@^2.0.0: dependencies: semver "^5.0.3" -semver-regex@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-1.0.0.tgz#92a4969065f9c70c694753d55248fc68f8f652c9" - "semver@2 || 3 || 4 || 5", semver@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.0.tgz#dc4bbc7a6ca9d916dee5d43516f0092b58f7b8ab" -semver@5.4.1, semver@^5.0.3, semver@^5.1.0, semver@^5.4.1: +semver@^5.0.3, semver@^5.1.0, semver@^5.4.1: version "5.4.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e" @@ -8764,7 +8430,7 @@ shell-quote@^1.6.1: array-reduce "~0.0.0" jsonify "~0.0.0" -shelljs@0.7.8, shelljs@^0.7.0: +shelljs@^0.7.0: version "0.7.8" resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.7.8.tgz#decbcf874b0d1e5fb72e14b164a9683048e9acb3" dependencies: @@ -8780,22 +8446,6 @@ signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" -simple-commit-message@3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/simple-commit-message/-/simple-commit-message-3.3.2.tgz#52bdadb7f4f680d8b29c07af1826a6611f7ee783" - dependencies: - am-i-a-dependency "1.1.2" - check-more-types "2.24.0" - debug "2.6.9" - ggit "1.23.1" - hr "0.1.3" - inquirer "0.12.0" - inquirer-confirm "0.2.2" - largest-semantic-change "1.0.0" - lazy-ass "1.6.0" - semver "5.4.1" - word-wrap "1.2.3" - simple-get@^1.4.2: version "1.4.3" resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-1.4.3.tgz#e9755eda407e96da40c5e5158c9ea37b33becbeb" @@ -8956,6 +8606,13 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" +spawn-sync@^1.0.15: + version "1.0.15" + resolved "https://registry.yarnpkg.com/spawn-sync/-/spawn-sync-1.0.15.tgz#b00799557eb7fb0c8376c29d44e8a1ea67e57476" + dependencies: + concat-stream "^1.4.7" + os-shim "^0.1.2" + spdx-correct@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-1.0.2.tgz#4b3073d933ff51f3912f03ac5519498a4150db40" @@ -8976,10 +8633,6 @@ split@0.3: dependencies: through "2" -spots@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/spots/-/spots-0.5.0.tgz#b7aa0f1ac389a5a6d57c21e98da1d53839405fe1" - sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -9108,12 +8761,6 @@ stringstream@~0.0.4, stringstream@~0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" -strip-ansi@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-2.0.1.tgz#df62c1aa94ed2f114e1d0f21fd1d50482b79a60e" - dependencies: - ansi-regex "^1.0.0" - strip-ansi@^3.0.0, strip-ansi@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" @@ -9738,15 +9385,6 @@ v8flags@^2.1.1: dependencies: user-home "^1.1.1" -validate-commit-msg@2.14.0: - version "2.14.0" - resolved "https://registry.yarnpkg.com/validate-commit-msg/-/validate-commit-msg-2.14.0.tgz#e5383691012cbb270dcc0bc2a4effebe14890eac" - dependencies: - conventional-commit-types "^2.0.0" - find-parent-dir "^0.3.0" - findup "0.1.5" - semver-regex "1.0.0" - validate-npm-package-license@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz#2804babe712ad3379459acfbe24746ab2c303fbc" @@ -9807,10 +9445,6 @@ watchpack@^1.4.0: chokidar "^1.7.0" graceful-fs "^4.1.2" -weak-map@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/weak-map/-/weak-map-1.0.5.tgz#79691584d98607f5070bd3b70a40e6bb22e401eb" - webidl-conversions@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-2.0.1.tgz#3bf8258f7d318c7443c36f2e169402a1a6703506" @@ -9898,6 +9532,12 @@ which@1, which@^1.2.12, which@^1.2.9: dependencies: isexe "^2.0.0" +which@1.2.x: + version "1.2.14" + resolved "https://registry.yarnpkg.com/which/-/which-1.2.14.tgz#9a87c4378f03e827cecaf1acdf56c736c01c14e5" + dependencies: + isexe "^2.0.0" + wide-align@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.2.tgz#571e0f1b0604636ebc0dfc21b0339bbe31341710" @@ -9940,14 +9580,6 @@ with@^5.0.0: acorn "^3.1.0" acorn-globals "^3.0.0" -word-wrap@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.1.0.tgz#356153d61d10610d600785c5d701288e0ae764a6" - -word-wrap@1.2.3, word-wrap@^1.0.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - wordwrap@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f"