Merge branch 'next' into reply

This commit is contained in:
Kiwi
2018-01-10 18:08:12 +01:00
committed by GitHub
221 changed files with 7193 additions and 4100 deletions
+2
View File
@@ -13,6 +13,7 @@ client/coral-framework/graphql/introspection.json
.idea/
*.swp
*.DS_STORE
.prettierrc.json
coverage/
test/e2e/reports/
@@ -52,3 +53,4 @@ plugins/*
!plugins/talk-plugin-slack-notifications
**/node_modules/*
yarn-error.log
+7 -1
View File
@@ -1,5 +1,11 @@
{
"exec": "npm-run-all --parallel generate-introspection start:development",
"verbose": true,
"ignore": ["test/*", "client/*", "dist/*", "plugins/*/client"],
"ext": "js,json,graphql,yml"
"ext": "js,json,graphql,yml",
"watch": [
".",
"bin/cli",
"bin/cli-serve"
]
}
+17
View File
@@ -10,6 +10,7 @@ const {HELMET_CONFIGURATION} = require('./config');
const {MOUNT_PATH} = require('./url');
const routes = require('./routes');
const debug = require('debug')('talk:app');
const {ENABLE_TRACING, APOLLO_ENGINE_KEY, PORT} = require('./config');
const app = express();
@@ -41,6 +42,22 @@ if (process.env.NODE_ENV !== 'test') {
app.use(morgan('dev'));
}
if (ENABLE_TRACING && APOLLO_ENGINE_KEY) {
const {Engine} = require('apollo-engine');
const engine = new Engine({
engineConfig: {
apiKey: APOLLO_ENGINE_KEY
},
graphqlPort: PORT,
endpoint: `${MOUNT_PATH}api/v1/graph/ql`,
});
engine.start();
app.use(engine.expressMiddleware());
}
// Trust the first proxy in front of us, this will enable us to trust the fact
// that SSL was terminated correctly.
app.set('trust proxy', 1);
+26 -28
View File
@@ -4,7 +4,8 @@
* Module dependencies.
*/
const program = require('./commander');
require('./util');
const program = require('commander');
const {head, map} = require('lodash');
const Matcher = require('did-you-mean');
@@ -18,40 +19,37 @@ program
.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'
)
.command('plugins', 'provides utilities for interacting with the plugin system')
.parse(process.argv);
// If the command wasn't found, output help.
const cmds = map(program.commands, '_name');
const cmd = head(program.args);
if (!cmds.includes(cmd)) {
const m = new Matcher(cmds);
const similarCMDs = m.list(cmd);
const commands = map(program.commands, '_name');
const command = head(program.args);
if (!commands.includes(command)) {
const m = new Matcher(commands);
const similarCommands = m.list(command);
console.error(`cli '${cmd}' is not a talk cli command. See 'cli --help'.`);
if (similarCMDs.length > 0) {
const sc = similarCMDs.map(({value}) => `\t${value}\n`).join('');
console.error(`cli '${command}' is not a talk cli command. See 'cli --help'.`);
if (similarCommands.length > 0) {
const sc = similarCommands.map(({value}) => `\t${value}\n`).join('');
console.error(`\nThe most similar commands are\n${sc}`);
}
process.exit(1);
}
/**
* When this provess exists, check to see if we have a running command, if we do
* check to see if it is still running. If it is, then kill it with a SIGINT
* signal. This is for the use case where we want to kill the process that is
* labled with the PID written out by the parent process.
*/
process.once('exit', () => {
if (
// /**
// * 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');
}
});
// // program.runningCommand &&
// program.runningCommand.killed === false &&
// program.runningCommand.exitCode === null
// ) {
// program.runningCommand.kill('SIGINT');
// }
// });
+2 -2
View File
@@ -4,7 +4,8 @@
* Module dependencies.
*/
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const parseDuration = require('ms');
const Table = require('cli-table');
const AssetModel = require('../models/asset');
@@ -12,7 +13,6 @@ const CommentModel = require('../models/comment');
const AssetsService = require('../services/assets');
const mongoose = require('../services/mongoose');
const scraper = require('../services/scraper');
const util = require('./util');
const inquirer = require('inquirer');
// Register the shutdown criteria.
+2 -2
View File
@@ -4,10 +4,10 @@
* Module dependencies.
*/
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const util = require('./util');
const mongoose = require('../services/mongoose');
const kue = require('../services/kue');
+1 -1
View File
@@ -4,8 +4,8 @@
* Module dependencies.
*/
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const MigrationService = require('../services/migration');
+2 -1
View File
@@ -7,7 +7,8 @@
// Interface heavily inspired by the yarn package manager:
// https://yarnpkg.com/
const program = require('./commander');
require('./util');
const program = require('commander');
const inquirer = require('inquirer');
// Make things colorful!
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const serve = require('../serve');
//==============================================================================
+2 -2
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env node
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingsService = require('../services/settings');
const util = require('./util');
// Register the shutdown criteria.
util.onshutdown([() => mongoose.disconnect()]);
+2 -2
View File
@@ -4,7 +4,8 @@
* Module dependencies.
*/
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingModel = require('../models/setting');
@@ -12,7 +13,6 @@ const MODERATION_OPTIONS = require('../models/enum/moderation_options');
const SettingsService = require('../services/settings');
const SetupService = require('../services/setup');
const UsersService = require('../services/users');
const util = require('./util');
const errors = require('../errors');
// Register the shutdown criteria.
+2 -2
View File
@@ -4,10 +4,10 @@
* Module dependencies.
*/
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const mongoose = require('../services/mongoose');
const TokensService = require('../services/tokens');
const util = require('./util');
const Table = require('cli-table');
// Register the shutdown criteria.
+182 -412
View File
@@ -4,133 +4,36 @@
* Module dependencies.
*/
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const inquirer = require('inquirer');
const {graphql} = require('graphql');
const {stripIndent} = require('common-tags');
const Table = require('cli-table');
// Make things colorful!
require('colors');
// Register the autocomplete plugin.
inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt'));
const schema = require('../graph/schema');
const Context = require('../graph/context');
const UsersService = require('../services/users');
const UserModel = require('../models/user');
const CommentModel = require('../models/comment');
const ActionModel = require('../models/action');
const USER_ROLES = require('../models/enum/user_roles');
const mongoose = require('../services/mongoose');
const util = require('./util');
const Table = require('cli-table');
const databaseVerifications = require('./verifications/database');
const validateRequired = (msg = 'Field is required', len = 1) => (input) => {
if (input && input.length >= len) {
return true;
}
return msg;
};
// Regeister the shutdown criteria.
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
function getUserCreateAnswers(options) {
if (options.flag_mode) {
let user = {
email: options.email,
password: options.password,
confirmPassword: options.password,
username: options.name,
roles: []
};
if (options.role && USER_ROLES.indexOf(options.role) > -1) {
user.roles = [options.role];
}
return Promise.resolve(user);
}
return inquirer.prompt([
{
name: 'email',
message: 'Email',
format: 'email',
validate: validateRequired('Email is required')
},
{
name: 'password',
message: 'Password',
type: 'password',
filter: (password) => {
return UsersService
.isValidPassword(password)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
filter: (confirmPassword) => {
return UsersService
.isValidPassword(confirmPassword)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'username',
message: 'Username',
filter: (username) => {
return UsersService
.isValidUsername(username)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'roles',
message: 'User Role',
type: 'checkbox',
choices: USER_ROLES
}
]);
}
/**
* Prompts for input and registers a user based on those.
*/
async function createUser(options) {
try {
const answers = await getUserCreateAnswers(options);
if (answers.password !== answers.confirmPassword) {
throw new Error('Passwords do not match');
}
const user = await UsersService.createLocalUser(answers.email.trim(), answers.password.trim(), answers.username.trim());
console.log(`Created user ${user.id}.`);
if (answers.roles.length > 0) {
return Promise.all(answers.roles.map((role) => {
return UsersService
.addRoleToUser(user.id, role)
.then(() => {
console.log(`Added the role ${role} to User ${user.id}.`);
});
}));
}
util.shutdown();
} catch (err) {
console.error(err);
util.shutdown(1);
}
}
/**
* Deletes a user.
* Deletes a user and cleans up their associated verifications.
*/
async function deleteUser(userID) {
@@ -142,23 +45,50 @@ async function deleteUser(userID) {
throw new Error(`user with id ${userID} not found`);
}
printUserAsTable(user);
console.warn(stripIndent`
This will delete the above user.
This might take a long time if there is a lot of data, please confirm that
you want to continue.
`);
const {confirm} = await inquirer.prompt({
type: 'confirm',
name: 'confirm',
message: 'Continue',
default: false,
});
if (!confirm) {
return util.shutdown();
}
console.warn('Removing user\'s actions');
// Remove all the user's actions.
await ActionModel
.where({user_id: user.id})
.setOptions({multi: true})
.remove();
console.warn('Removing user\'s comments');
// 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.
await user.remove();
@@ -169,146 +99,90 @@ async function deleteUser(userID) {
}
}
function printUserAsTable(user) {
let table = new Table({});
table.push(
{'ID': user.id.gray},
{'Username': user.username},
{'Emails': user.profiles.filter(({provider}) => provider === 'local').map(({id})=> id)
.join(', ')},
{'Tags': user.tags ? user.tags.map(({tag: {name}}) => name) : ''},
{'Role': user.role},
{'Verified': user.hasVerifiedEmail},
{'Username': user.status.username.status},
{'Banned': user.banned},
{'Suspension': user.suspended ? `Until ${user.status.suspension.until}` : false},
);
console.log(table.toString());
}
/**
* Changes the password for a user.
* Searches for users based on their username and email address.
*/
function passwd(userID) {
inquirer.prompt([
{
name: 'password',
message: 'Password',
type: 'password',
validate: validateRequired('Password is required')
},
{
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
validate: validateRequired('Confirm Password is required')
async function searchUsers() {
const ctx = Context.forSystem();
const searchQuery = `
query SearchUsers($value: String) {
users(query: {value: $value}) {
nodes {
id
username
role
profiles {
id
provider
}
}
}
}
])
.then((answers) => {
if (answers.password !== answers.confirmPassword) {
throw new Error('Password mismatch');
}
`;
return UsersService.changePassword(userID, answers.password);
})
.then(() => {
console.log('Password changed.');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Updates the user from the options array.
*/
function updateUser(userID, options) {
const updates = [];
if (options.email && typeof options.email === 'string' && options.email.length > 0) {
let q = UserModel.update({
'id': userID,
'profiles.provider': 'local'
}, {
$set: {
'profiles.$.id': options.email
}
});
updates.push(q);
}
if (options.name && typeof options.name === 'string' && options.name.length > 0) {
let q = UserModel.update({
'id': userID
}, {
$set: {
username: options.name
}
});
updates.push(q);
}
Promise
.all(updates.map((q) => q.exec()))
.then(() => {
console.log(`User ${userID} updated.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Lists all the users registered in the database.
*/
function listUsers() {
UsersService
.all()
.then((users) => {
let table = new Table({
head: [
'ID',
'Username',
'Profiles',
'Roles',
'Status',
'State'
]
});
users.forEach((user) => {
let state = user.disabled ? 'Disabled' : 'Enabled';
const profile = user.profiles.find(({provider}) => provider === 'local');
if (profile && profile.metadata && profile.metadata.confirmed_at) {
state += ', Verified';
} else {
state += ', Unverified';
try {
const answers = await inquirer.prompt({
type: 'autocomplete',
name: 'userID',
message: 'Search for a user',
source: async (answers, value) => {
if (value === null) {
value = '';
}
table.push([
user.id,
user.username,
user.profiles.map((p) => p.provider).join(', '),
user.roles.join(', '),
user.status,
state
]);
});
const {data, errors} = await graphql(schema, searchQuery, {}, ctx, {
value,
});
if (errors && errors.length > 0) {
throw errors[0];
}
console.log(table.toString());
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
if (data.users === null) {
return [];
}
/**
* Merges two users using the specified ID's.
* @param {String} dstUserID id of the user to which is the target of the merge
* @param {String} srcUserID id of the user to which is the source of the merge
*/
function mergeUsers(dstUserID, srcUserID) {
UsersService
.mergeUsers(dstUserID, srcUserID)
.then(() => {
console.log(`User ${srcUserID} was merged into user ${dstUserID}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
return data.users.nodes.map((user) => {
const emails = user.profiles
.filter(({provider}) => provider === 'local')
.map(({id})=> id)
.join(', ');
return {
name: `${user.username} (${emails}) ${user.id.gray} - ${user.role.gray}`,
value: user.id,
};
});
}
});
const {userID} = answers;
const user = await UserModel.findOne({id: userID});
printUserAsTable(user);
util.shutdown(0);
} catch (err) {
console.error(err);
util.shutdown(1);
}
}
/**
@@ -316,127 +190,75 @@ function mergeUsers(dstUserID, srcUserID) {
* @param {String} userUD id of the user to add the role to
* @param {String} role the role to add
*/
function addRole(userID, role) {
async function setUserRole(userID) {
try {
const {role} = await inquirer.prompt([
{
name: 'role',
message: 'User Role',
type: 'list',
choices: USER_ROLES
}
]);
if (USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${USER_ROLES.join(', ')}.`);
await UsersService.setRole(userID, role);
console.log(`Set User ${userID} to the ${role} role.`);
util.shutdown();
} catch (err) {
console.error(err);
util.shutdown(1);
return;
}
UsersService
.addRoleToUser(userID, role)
.then(() => {
console.log(`Added the ${role} role to User ${userID}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Removes a role from a user
* @param {String} userUD id of the user to remove the role from
* @param {String} role the role to remove
*/
function removeRole(userID, role) {
if (USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${USER_ROLES.join(', ')}.`);
util.shutdown(1);
return;
}
UsersService
.removeRoleFromUser(userID, role)
.then(() => {
console.log(`Removed the ${role} role from User ${userID}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Ban a user
* @param {String} userID id of the user to ban
*/
function ban(userID) {
UsersService
.setStatus(userID, 'BANNED')
.then(() => {
console.log(`Banned the User ${userID}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Unban a user
* @param {String} userUD id of the user to remove the role from
*/
function unban(userID) {
UsersService
.setStatus(userID, 'ACTIVE')
.then(() => {
console.log(`Unban the User ${userID}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Disable a given user.
* @param {String} userID the ID of a user to disable
*/
function disableUser(userID) {
UsersService
.disableUser(userID)
.then(() => {
console.log(`User ${userID} was disabled.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Enabled a given user.
* @param {String} userID the ID of a user to enable
*/
function enableUser(userID) {
UsersService
.enableUser(userID)
.then(() => {
console.log(`User ${userID} was enabled.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Verifies an email address for a user.
*
* @param userID the user's id
* @param email the user's email address to be verified
* @param email the user's email address to be verified, otherwise verifies the
* first email if there is one, if there are multiple, you get a
* prompt.
*/
async function verify(userID, email) {
async function verifyUserEmail(userID, email) {
try {
// Get the user.
const user = await UserModel.findOne({id: userID});
if (!user) {
throw new Error(`user with ID ${userID} cannot be found`);
}
// Get all the user's email addresses.
const emails = user.profiles.filter(({provider}) => provider === 'local').map(({id}) => id);
if (!emails || emails.length === 0) {
throw new Error('user did not have any email addresses');
}
if (!email && emails.length === 1) {
// The email wasn't passed, and there is only one option.
email = emails[0];
} else if (!emails.includes(email)){
// The email passed doesn't belong to this user.
throw new Error(`user does not have the email ${email}`);
} else if (emails.length > 1) {
// The email wasn't passed, and there is more than one choice.
const answers = await inquirer.prompt([
{
name: 'email',
message: 'Select Email to Verify',
type: 'list',
choices: emails
}
]);
email = answers.email;
}
// Verify the email.
await UsersService.confirmEmail(userID, email);
console.log(`User ${userID} had their email ${email} verified.`);
util.shutdown();
@@ -450,77 +272,25 @@ async function verify(userID, email) {
// Setting up the program command line arguments.
//==============================================================================
program
.command('create')
.option('--email [email]', 'Email to use')
.option('--password [password]', 'Password to use')
.option('--name [name]', 'Name to use')
.option('--role [role]', 'Role to add')
.option('-f, --flag_mode', 'Source from flags instead of prompting')
.description('create a new user')
.action(createUser);
program
.command('delete <userID>')
.description('delete a user')
.action(deleteUser);
program
.command('passwd <userID>')
.description('change a password for a user')
.action(passwd);
.command('search')
.description('searches for a user based on their stored username and email')
.action(searchUsers);
program
.command('update <userID>')
.option('--email [email]', 'Email to use')
.option('--name [name]', 'Name to use')
.description('update a user')
.action(updateUser);
program
.command('list')
.description('list all the users in the database')
.action(listUsers);
program
.command('merge <dstUserID> <srcUserID>')
.description('merge srcUser into the dstUser')
.action(mergeUsers);
program
.command('addrole <userID> <role>')
.description('adds a role to a given user')
.action(addRole);
program
.command('removerole <userID> <role>')
.description('removes a role from a given user')
.action(removeRole);
program
.command('ban <userID>')
.description('ban a given user')
.action(ban);
program
.command('uban <userID>')
.description('unban a given user')
.action(unban);
program
.command('disable <userID>')
.description('disable a given user from logging in')
.action(disableUser);
program
.command('enable <userID>')
.description('enable a given user from logging in')
.action(enableUser);
.command('set-role <userID> <role>')
.description('sets the role on a user')
.action(setUserRole);
program
.command('verify <userID> <email>')
.description('verifies the given user\'s email address')
.action(verify);
.action(verifyUserEmail);
program.parse(process.argv);
+2 -2
View File
@@ -4,9 +4,9 @@
* Module dependencies.
*/
const program = require('./commander');
const mongoose = require('../services/mongoose');
const util = require('./util');
const program = require('commander');
const mongoose = require('../services/mongoose');
const databaseVerifications = require('./verifications/database');
// Register the shutdown criteria.
-51
View File
@@ -1,51 +0,0 @@
const pkg = require('../package.json');
const dotenv = require('dotenv');
const fs = require('fs');
const program = require('commander');
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
const parseArgs = require('minimist')(process.argv.slice(2), {
alias: {
'c': 'config'
},
string: [
'config',
'pid'
],
default: {
'config': null,
'pid': null
}
});
/**
* If the config flag is present, then we have to load the configuration from
* the file specified. We will then load those values into the environment.
*/
if (parseArgs.config) {
let envConfig = dotenv.parse(fs.readFileSync(parseArgs.config, {encoding: 'utf8'}));
Object.keys(envConfig).forEach((k) => {
process.env[k] = envConfig[k];
});
}
/**
* If the pid flag is present, then we have to create a pid file at the location
* specified.
*/
if (parseArgs.pid) {
const util = require('./util');
console.log('Wrote PID');
util.pid(parseArgs.pid);
}
module.exports = program
.version(pkg.version)
.option('-c, --config [path]', 'Specify the configuration file to load')
.option('--pid [path]', 'Specify a path to output the current PID to');
+11 -38
View File
@@ -1,5 +1,8 @@
// Setup the environment.
require('../services/env');
const debug = require('debug')('talk:util');
const fs = require('fs');
const util = module.exports = {};
@@ -49,46 +52,16 @@ util.onshutdown = (jobs) => {
util.toshutdown = util.toshutdown.concat(jobs);
};
/**
* Register a PID file to be maintained for the lifespan of the process.
* @param {String} path path to the PID file to create
*/
util.pid = (path) => {
if (!/\//.test(path)) {
if (!/\.pid/.test(path)) {
path += '.pid';
}
path = `/tmp/${path}`;
}
const pid = `${process.pid.toString()}\n`;
fs.writeFile(path, pid, (err) => {
if (err) {
console.error(`Can't write PID file: ${err}`);
throw err;
}
// Add the cleanup for the fs onto the shutdown.
util.onshutdown([
() => new Promise((resolve, reject) => {
// Remove the pid file.
fs.unlink(path, (err) => {
if (err) {
return reject(err);
}
return resolve();
});
})
]);
});
};
// Attach to the SIGTERM + SIGINT handles to ensure a clean shutdown in the
// event that we have an external event. SIGUSR2 is called when the app is asked
// to be 'killed', same procedure here.
process.on('SIGTERM', () => util.shutdown(0, 'SIGTERM'));
process.on('SIGINT', () => util.shutdown(0, 'SIGINT'));
process.once('SIGUSR2', () => util.shutdown(0, 'SIGUSR2'));
// Makes the script crash on unhandled rejections instead of silently
// 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;
});
+180
View File
@@ -0,0 +1,180 @@
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');
}
}
}
};
@@ -1,7 +1,5 @@
const CommentModel = require('../../../models/comment');
const ActionsService = require('../../../services/actions');
const {arrayJoinBy, singleJoinBy} = require('../../../graph/loaders/util');
const sc = require('snake-case');
const {singleJoinBy} = require('../../../graph/loaders/util');
const debug = require('debug')('talk:cli:verify');
const getBatch = async (limit, offset) => CommentModel
@@ -55,15 +53,9 @@ module.exports = async ({fix, limit, batch}) => {
.then(singleJoinBy(commentIDs, '_id'))
.then((results) => results.map((result) => result ? result.count : 0));
// Get their action summaries.
let allActionSummaries = await ActionsService
.getActionSummaries(commentIDs)
.then(arrayJoinBy(commentIDs, 'item_id'));
// Loop over the comments, with their action summaries.
for (let i = 0; i < comments.length; i++) {
let comment = comments[i];
let actionSummaries = allActionSummaries[i];
let replyCount = allReplyCounts[i];
// And check to see if the action summaries we just computed match what is
@@ -77,61 +69,6 @@ module.exports = async ({fix, limit, batch}) => {
});
}
// First we process all the group id's.
for (let actionSummary of actionSummaries) {
if (actionSummary.group_id === null) {
continue;
}
// And we generate the group id.
const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase());
const GROUP_ID = sc(actionSummary.group_id.toLowerCase());
if (GROUP_ID.length <= 0) {
continue;
}
// And we add a new batch operation if the action summary is associated
// with a group.
const ACTION_COUNT_FIELD = `${ACTION_TYPE}_${GROUP_ID}`;
// Check that the action summaries match the cached counts.
if (!comment.action_counts || !(ACTION_COUNT_FIELD in comment.action_counts) || comment.action_counts[ACTION_COUNT_FIELD] !== actionSummary.count) {
// Batch updates for those changes.
commentOperations.push({
[`action_counts.${ACTION_COUNT_FIELD}`]: actionSummary.count,
});
}
}
// Group all the action summaries together from all the different group
// ids.
let groupedActionSummaries = actionSummaries.reduce((acc, actionSummary) => {
const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase());
if (!(ACTION_TYPE in acc)) {
acc[ACTION_TYPE] = 0;
}
acc[ACTION_TYPE] += actionSummary.count;
return acc;
}, {});
for (const ACTION_COUNT_FIELD of Object.keys(groupedActionSummaries)) {
const count = groupedActionSummaries[ACTION_COUNT_FIELD];
// Check that the action summaries match the cached counts.
if (!comment.action_counts || !(ACTION_COUNT_FIELD in comment.action_counts) || comment.action_counts[ACTION_COUNT_FIELD] !== count) {
// Batch updates for those changes.
commentOperations.push({
[`action_counts.${ACTION_COUNT_FIELD}`]: count,
});
}
}
// If this comment has action summaries that should be updated, then
// perform an update!
if (commentOperations.length > 0) {
+3 -2
View File
@@ -6,7 +6,8 @@
//
// async ({fix = false, batch = 1000}) => {}
//
// where their options are derrived.
// where their options are derived.
module.exports = [
require('./comments'),
require('./comment_replies'),
require('./action_counts'),
];
+5 -2
View File
@@ -7,6 +7,8 @@ machine:
environment:
PATH: "${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin"
NODE_ENV: "test"
MOCHA_FILE: "${CIRCLE_TEST_REPORTS}/junit/test-results.xml"
MOCHA_REPORTER: "mocha-junit-reporter"
pre:
# TODO: use the following to add in support for MongoDB 3.4.
# # Upgrade the database version to 3.4.
@@ -47,10 +49,11 @@ database:
test:
override:
# Run the tests using the junit reporter.
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml MOCHA_REPORTER=mocha-junit-reporter yarn test
- yarn test
# Run the end to end tests
- yarn e2e:ci
# Check dependancies using nsp.
- nsp check
- yarn e2e-ci
deployment:
release:
@@ -4,4 +4,3 @@ export const showBanUserDialog = ({userId, username, commentId, commentStatus})
({type: SHOW_BAN_USER_DIALOG, userId, username, commentId, commentStatus});
export const hideBanUserDialog = () => ({type: HIDE_BAN_USER_DIALOG});
@@ -7,8 +7,6 @@ import {
SORT_UPDATE,
SET_PAGE,
SET_SEARCH_VALUE,
SET_ROLE,
SET_COMMENTER_STATUS,
SHOW_BANUSER_DIALOG,
HIDE_BANUSER_DIALOG,
SHOW_REJECT_USERNAME_DIALOG,
@@ -56,20 +54,6 @@ export const setSearchValue = (value) => ({
value,
});
export const setRole = (id, role) => (dispatch, _, {rest}) => {
return rest(`/users/${id}/role`, {method: 'POST', body: {role}})
.then(() => {
return dispatch({type: SET_ROLE, id, role});
});
};
export const setCommenterStatus = (id, status) => (dispatch, _, {rest}) => {
return rest(`/users/${id}/status`, {method: 'POST', body: {status}})
.then(() => {
return dispatch({type: SET_COMMENTER_STATUS, id, status});
});
};
// Ban User Dialog
export const showBanUserDialog = (user) => ({type: SHOW_BANUSER_DIALOG, user});
export const hideBanUserDialog = () => ({type: HIDE_BANUSER_DIALOG});
+2 -2
View File
@@ -3,12 +3,12 @@ import * as actions from 'constants/userDetail';
export const viewUserDetail = (userId) => ({type: actions.VIEW_USER_DETAIL, userId});
export const hideUserDetail = () => ({type: actions.HIDE_USER_DETAIL});
export const changeUserDetailStatuses = (tab) => {
export const changeTab = (tab) => {
let statuses = null;
if (tab === 'rejected') {
statuses = ['REJECTED'];
}
return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses};
return {type: actions.CHANGE_TAB_USER_DETAIL, tab, statuses};
};
export const clearUserDetailSelections = () => ({type: actions.CLEAR_USER_DETAIL_SELECTIONS});
@@ -0,0 +1,30 @@
.table {
flex-direction: column;
}
.table, .headerRow, .row {
display: flex;
}
.headerRowItem, .item {
flex: 1;
padding: 20px;
&:nth-child(2) {
flex: 2;
}
}
.headerRowItem {
color: #595959;
font-weight: bold;
}
.headerRow, .row {
border-bottom: 1px solid #e0e0e0;
}
.action {
color: black;
font-weight: bold;
}
@@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import {murmur3} from 'murmurhash-js';
import styles from './AccountHistory.css';
import cn from 'classnames';
import flatten from 'lodash/flatten';
import orderBy from 'lodash/orderBy';
import moment from 'moment';
const buildUserHistory = (userState = {}) => {
return orderBy(flatten(Object.keys(userState.status)
.filter((k) => k !== '__typename')
.map((k) => userState.status[k].history)), 'created_at', 'desc');
};
const buildActionResponse = (typename, status) => {
const actionResponses = {
'UsernameStatusHistory' : `Username Status: ${status}`,
'BannedStatusHistory': status ? 'User banned' : 'Ban removed',
'SuspensionStatusHistory': status ? 'Account Suspended' : 'Suspension removed'
};
return actionResponses[typename];
};
class AccountHistory extends React.Component {
render() {
const {userState} = this.props;
const userHistory = buildUserHistory(userState);
return (
<div>
<div className={cn(styles.table, 'talk-admin-account-history')}>
<div className={cn(styles.headerRow, 'talk-admin-account-history-header-row')}>
<div className={styles.headerRowItem}>Date</div>
<div className={styles.headerRowItem}>Action</div>
<div className={styles.headerRowItem}>Moderation</div>
</div>
{
userHistory.map((h) => (
<div className={cn(styles.row, 'talk-admin-account-history-row')} key={`${h.__typename}_${murmur3(h.created_at)}`}>
<div className={cn(styles.item, 'talk-admin-account-history-row-date')}>
{moment(new Date(h.created_at)).format('MMM DD, YYYY')}
</div>
<div className={cn(styles.item, styles.action, 'talk-admin-account-history-row-status')}>
{buildActionResponse(h.__typename, h.status)}
</div>
<div className={cn(styles.item, 'talk-admin-account-history-row-assigned-by')}>
{h.assigned_by ? h.assigned_by.username : 'SYSTEM'}
</div>
</div>
))
}
</div>
</div>
);
}
}
AccountHistory.propTypes = {
history: PropTypes.array,
userState: PropTypes.object,
};
export default AccountHistory;
@@ -8,9 +8,6 @@
color: black;
> :global(.mdl-menu__container) {
margin-left: 10px;
> :global(.mdl-menu__outline) {
box-shadow: none;
}
}
}
@@ -18,12 +15,13 @@
box-shadow: none;
color: white;
background-color: #616161;
border-color: #616161;
}
.arrowIcon {
margin-left: 6px;
margin-right: 0;
vertical-align: middle;
vertical-align: middle;
margin-right: 0;
font-size: 14px;
}
@@ -33,8 +31,10 @@
}
.menuItem {
background-color: #2a2a2a;
color: white;
color: #2a2a2a;
background-color: white;
font-size: 0.95em;
&:first-child {
margin-bottom: 1px;
border-radius: 2px 2px 0px 0px;
@@ -43,7 +43,8 @@
border-radius: 0px 0px 2px 2px;
}
&:hover, &:active, &:focus {
background-color: #767676;
background-color: #e2e2e2;
border-color: #616161;
}
&[disabled], &[disabled]:hover, &[disabled]:focus, &[disabled]:active {
background-color: #262626;
@@ -32,18 +32,18 @@ class ActionsMenu extends React.Component {
};
render() {
const {className = ''} = this.props;
const {className = '', buttonClassNames = '', label = ''} = this.props;
return (
<div className={cn(styles.root, className)} onBlur={this.syncOpenState} >
<Button
cStyle='actions'
className={cn(styles.button, {[styles.buttonOpen]: this.state.open})}
className={cn(styles.button, {[styles.buttonOpen]: this.state.open}, buttonClassNames)}
disabled={false}
id={this.id}
onClick={this.syncOpenState}
icon={this.props.icon}
raised>
{t('modqueue.actions')}
{label ? label : t('modqueue.actions')}
<Icon
name={this.state.open ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
className={styles.arrowIcon}
@@ -61,6 +61,8 @@ ActionsMenu.propTypes = {
icon: PropTypes.string,
children: PropTypes.node,
className: PropTypes.string,
label: PropTypes.string,
buttonClassNames: PropTypes.string,
};
export default ActionsMenu;
@@ -1,153 +1,153 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 500px;
width: 400px;
top: 50%;
transform: translateY(-50%);
height: 184px;
padding: 20px;
border-radius: 4px;
}
h2 {
color: black;
font-size: 1.76em;
font-weight: 500;
margin: 0;
}
.header {
color: black;
font-size: 1.5em;
font-weight: 500;
margin: 0 0 8px 0;
}
h3 {
color: black;
font-size: 1.4em;
font-weight: 500;
margin: 0;
}
.subheader {
color: black;
font-size: 1.3em;
font-weight: 500;
margin: 0 0 8px 0;
}
.textField {
margin-top: 15px;
margin-top: 15px;
}
.textField label {
font-size: 1.08em;
font-weight: bold;
margin-bottom: 5px;
font-size: 1.08em;
font-weight: bold;
margin-bottom: 5px;
}
.textField input {
width: 100%;
display: block;
border: none;
outline: none;
border: 1px solid rgba(0,0,0,.12);
padding: 10px 6px;
box-sizing: border-box;
border-radius: 2px;
margin: 5px auto;
width: 100%;
display: block;
border: none;
outline: none;
border: 1px solid rgba(0,0,0,.12);
padding: 10px 6px;
box-sizing: border-box;
border-radius: 2px;
margin: 5px auto;
}
.footer {
margin: 20px auto 10px;
text-align: center;
margin: 20px auto 10px;
text-align: center;
}
.footer span {
display: block;
margin-bottom: 5px;
display: block;
margin-bottom: 5px;
}
.footer a {
color: #2c69b6;
cursor: pointer;
margin: 0 5px;
color: #2c69b6;
cursor: pointer;
margin: 0 5px;
}
.socialConnections {
margin-bottom: 20px;
margin-bottom: 20px;
}
.signInButton {
margin-top: 10px;
margin-top: 10px;
}
.close {
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
}
.close:hover {
color: #6b6b6b;
color: #6b6b6b;
}
input.error{
border: solid 2px #f44336;
border: solid 2px #f44336;
}
.errorMsg, .hint {
color: grey;
font-weight: 600;
padding: 3px 0 16px;
color: grey;
font-weight: 600;
padding: 3px 0 16px;
}
.alert {
padding: 10px;
margin-bottom: 20px;
border-radius: 2px;
padding: 10px;
margin-bottom: 20px;
border-radius: 2px;
}
.alert--success {
border: solid 1px #1ec00e;
background: #cbf1b8;
color: #006900;
border: solid 1px #1ec00e;
background: #cbf1b8;
color: #006900;
}
.alert--error {
background: #FFEBEE;
color: #B71C1C;
background: #FFEBEE;
color: #B71C1C;
}
.userBox a {
color: #2c69b6;
cursor: pointer;
margin: 0px;
color: #2c69b6;
cursor: pointer;
margin: 0px;
}
.attention {
display: inline-block;
width: 15px;
height: 15px;
background: #B71C1C;
color: #FFEBEE;
font-weight: bolder;
padding: 4px;
vertical-align: middle;
border-radius: 20px;
box-sizing: border-box;
font-size: 9px;
line-height: 7px;
text-align: center;
margin-right: 5px;
display: inline-block;
width: 15px;
height: 15px;
background: #B71C1C;
color: #FFEBEE;
font-weight: bolder;
padding: 4px;
vertical-align: middle;
border-radius: 20px;
box-sizing: border-box;
font-size: 9px;
line-height: 7px;
text-align: center;
margin-right: 5px;
}
.action {
margin-top: 15px;
margin-top: 15px;
}
.passwordRequestSuccess {
border: 1px solid green;
background-color: lightgreen;
padding: 10px;
border: 1px solid green;
background-color: lightgreen;
padding: 10px;
}
.passwordRequestFailure {
border: 1px solid orange;
background-color: 1px solid coral;
padding: 10px;
border: 1px solid orange;
background-color: 1px solid coral;
padding: 10px;
}
.cancel {
@@ -160,6 +160,20 @@ input.error{
}
.buttons {
margin: 20px;
text-align: center;
margin-top: 8px;
margin-bottom: 6px;
text-align: right;
}
.legend {
padding: 0;
font-weight: bold;
}
.messageInput {
border-radius: 3px;
width: 100%;
padding: 10px;
font-size: 14px;
box-sizing: border-box;
}
@@ -7,39 +7,132 @@ import styles from './BanUserDialog.css';
import Button from 'coral-ui/components/Button';
import t from 'coral-framework/services/i18n';
const BanUserDialog = ({open, onCancel, onPerform, username, info}) => (
<Dialog
className={cn(styles.dialog, 'talk-ban-user-dialog')}
id="banUserDialog"
open={open}
onCancel={onCancel}
title={t('bandialog.ban_user')}>
<span className={styles.close} onClick={onCancel}>×</span>
<div className={styles.header}>
<h2>{t('bandialog.ban_user')}</h2>
</div>
<div className={styles.separator}>
<h3>{t('bandialog.are_you_sure', username)}</h3>
<i>{info}</i>
</div>
<div className={styles.buttons}>
<Button
className={cn(styles.cancel, 'talk-ban-user-dialog-button-cancel')}
cStyle="cancel"
onClick={onCancel}
raised >
{t('bandialog.cancel')}
</Button>
<Button
className={cn(styles.ban, 'talk-ban-user-dialog-button-confirm')}
cStyle="black"
onClick={onPerform}
raised >
{t('bandialog.yes_ban_user')}
</Button>
</div>
</Dialog>
);
const initialState = {step: 0, message: ''};
class BanUserDialog extends React.Component {
state = initialState;
componentWillReceiveProps(next) {
if (this.props.open && !next.open) {
this.setState(initialState);
}
}
handleMessageChange = (e) => {
const {value: message} = e;
this.setState({message});
}
goToStep1 = () => {
this.setState({
step: 1,
message: t(
'bandialog.email_message_ban',
this.props.username,
),
});
}
renderStep0() {
const {
onCancel,
username,
info,
} = this.props;
return (
<section>
<h2 className={styles.header}>
{t('bandialog.ban_user')}
</h2>
<h3 className={styles.subheader}>
{t('bandialog.are_you_sure', username)}
</h3>
<p className={styles.description}>
{info}
</p>
<div className={styles.buttons}>
<Button
className={cn('talk-ban-user-dialog-button-cancel')}
cStyle="white"
onClick={onCancel}
raised >
{t('bandialog.cancel')}
</Button>
<Button
className={cn('talk-ban-user-dialog-button-confirm')}
cStyle="black"
onClick={this.goToStep1}
raised >
{t('bandialog.yes_ban_user')}
</Button>
</div>
</section>
);
}
renderStep1() {
const {
onCancel,
onPerform,
} = this.props;
const {message} = this.state;
return (
<section>
<h2 className={styles.header}>
{t('bandialog.notify_ban_headline')}
</h2>
<p className={styles.description}>
{t('bandialog.notify_ban_description')}
</p>
<fieldset>
<legend className={styles.legend}>{t('bandialog.write_a_message')}</legend>
<textarea
rows={5}
className={styles.messageInput}
value={message}
onChange={this.handleMessageChange}
/>
</fieldset>
<div className={styles.buttons}>
<Button
className={cn('talk-ban-user-dialog-button-cancel')}
cStyle="white"
onClick={onCancel}
raised >
{t('bandialog.cancel')}
</Button>
<Button
className={cn('talk-ban-user-dialog-button-confirm')}
cStyle="black"
onClick={onPerform}
raised >
{t('bandialog.send')}
</Button>
</div>
</section>
);
}
render() {
const {step} = this.state;
const {open, onCancel} = this.props;
return (
<Dialog
className={cn(styles.dialog, 'talk-ban-user-dialog')}
id="banUserDialog"
open={open}
onCancel={onCancel}
title={t('bandialog.ban_user')} >
<span className={styles.close} onClick={onCancel}>×</span>
{step === 0 && this.renderStep0()}
{step === 1 && this.renderStep1()}
</Dialog>
);
}
}
BanUserDialog.propTypes = {
open: PropTypes.bool,
@@ -17,7 +17,7 @@ function getUserFlaggedType(actions) {
.some((action) =>
action.__typename === 'FlagAction' &&
action.user &&
action.user.roles.some((role) => staffRoles.includes(role))
staffRoles.includes(action.user.role)
) ? 'Staff' : 'User';
}
@@ -4,7 +4,7 @@ import {Button} from 'coral-ui';
import styles from './LoadMore.css';
import cn from 'classnames';
const LoadMore = ({loadMore, showLoadMore, className, ...rest}) =>
const LoadMore = ({loadMore, showLoadMore, className = '', ...rest}) =>
<div {...rest} className={cn(className, styles.loadMoreContainer)}>
{
showLoadMore && <Button
@@ -16,6 +16,7 @@ const LoadMore = ({loadMore, showLoadMore, className, ...rest}) =>
</div>;
LoadMore.propTypes = {
className: PropTypes.string,
loadMore: PropTypes.func.isRequired,
showLoadMore: PropTypes.bool.isRequired
};
@@ -28,7 +28,7 @@
padding: 15px;
box-sizing: border-box;
box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.15);
z-index: 10;
z-index: 4;
.ctaHeader {
font-size: 16px;
@@ -62,7 +62,7 @@ class SuspendUserDialog extends React.Component {
const {onCancel, username} = this.props;
const {duration} = this.state;
return (
<section>
<section className="talk-admin-suspend-user-dialog-step-0">
<h1 className={styles.header}>
{t('suspenduser.title_suspend')}
</h1>
@@ -97,10 +97,10 @@ class SuspendUserDialog extends React.Component {
}
renderStep1() {
const {onCancel, username} = this.props;
const {message} = this.state;
const {onCancel, username} = this.props;
return (
<section>
<section className="talk-admin-suspend-user-dialog-step-1">
<h1 className={styles.header}>
{t('suspenduser.title_notify')}
</h1>
@@ -94,73 +94,52 @@
width: calc(100% - 90px);
}
.commentStatuses {
.tabBar {
padding: 0 0 0 10px;
margin: 0;
align-self: center;
list-style: none;
box-sizing: border-box;
li {
display: inline-block;
margin-right: 10px;
cursor: pointer;
padding: 0 10px;
}
border: none;
}
.active {
.tab {
display: inline-block;
margin-right: 10px;
cursor: pointer;
padding: 0 10px;
}
.tabButton {
border: none;
background: white;
border-radius: 0;
font-size: 1em;
padding: 5px;
cursor: pointer;
}
.tabButtonActive {
font-weight: bold;
border-bottom: 3px solid #F36451;
}
.bulkActionGroup {
height: 52px;
background-color: #efefef;
padding: 0 0 0 10px;
display: flex;
i {
margin-right: 0;
}
.bulkAction {
display: inline-block;
width: 48px;
height: 48px;
transform: scale(.7);
min-width: 0;
}
.bulkAction:last-child {
margin-left: -10px;
}
.username {
display: inline-block;
vertical-align: middle;
}
.selectedCommentsInfo {
align-self: center;
font-weight: 500;
margin-left: 15px;
.actionsMenu {
display: inline-block;
}
.loadMore>button {
background-color: #696969;
&:hover {
background-color: #404040;
color: white;
}
.actionsMenuSuspended {
background-color: #F29336;
border-color: #F29336;
color: white;
}
.toggleAll {
padding: 0 10px 0 0;
align-self: center;
.actionsMenuBanned {
background-color: #E45241;
border-color: #E45241;
color: white;
}
.commentList {
clear: both;
}
.bulkActionHeader {
display: flex;
justify-content: space-between;
height: 52px;
&.selected {
background-color: #efefef;
}
}
+176 -102
View File
@@ -1,42 +1,21 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import Comment from '../containers/UserDetailComment';
import capitalize from 'lodash/capitalize';
import {getErrorMessages} from 'coral-framework/utils';
import styles from './UserDetail.css';
import {Icon, Drawer, Spinner} from 'coral-ui';
import AccountHistory from './AccountHistory';
import {Slot} from 'coral-framework/components';
import UserDetailCommentList from '../components/UserDetailCommentList';
import {getReliability, isSuspended, isBanned} from 'coral-framework/utils/user';
import ButtonCopyToClipboard from './ButtonCopyToClipboard';
import ClickOutside from 'coral-framework/components/ClickOutside';
import LoadMore from '../components/LoadMore';
import cn from 'classnames';
import capitalize from 'lodash/capitalize';
import {getReliability} from 'coral-framework/utils/user';
import ApproveButton from './ApproveButton';
import RejectButton from './RejectButton';
import {getErrorMessages} from 'coral-framework/utils';
import {Icon, Drawer, Spinner, TabBar, Tab, TabContent, TabPane} from 'coral-ui';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import UserInfoTooltip from './UserInfoTooltip';
export default class UserDetail extends React.Component {
static propTypes = {
userId: PropTypes.string.isRequired,
hideUserDetail: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
changeStatus: PropTypes.func.isRequired,
toggleSelect: PropTypes.func.isRequired,
bulkAccept: PropTypes.func.isRequired,
bulkReject: PropTypes.func.isRequired,
toggleSelectAll: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
data: PropTypes.shape({
refetch: PropTypes.func.isRequired,
}),
activeTab: PropTypes.string.isRequired,
selectedCommentIds: PropTypes.array.isRequired,
viewUserDetail: PropTypes.any.isRequired,
loadMore: PropTypes.any.isRequired,
notify: PropTypes.func.isRequired
}
class UserDetail extends React.Component {
rejectThenReload = async (info) => {
try {
@@ -82,13 +61,19 @@ export default class UserDetail extends React.Component {
}
}
showAll = () => {
this.props.changeStatus('all');
changeTab = (tab) => {
this.props.changeTab(tab);
}
showRejected = () => {
this.props.changeStatus('rejected');
}
showSuspenUserDialog = () => this.props.showSuspendUserDialog({
userId: this.props.root.user.id,
username: this.props.root.user.username,
});
showBanUserDialog = () => this.props.showBanUserDialog({
userId: this.props.root.user.id,
username: this.props.root.user.username,
});
renderLoading() {
return (
@@ -100,15 +85,27 @@ export default class UserDetail extends React.Component {
);
}
getActionMenuLabel() {
const {root: {user}} = this.props;
if (isBanned(user)) {
return 'Banned';
} else if (isSuspended(user)) {
return 'Suspended';
}
return '';
}
renderLoaded() {
const {
data,
root,
root: {
me,
user,
totalComments,
rejectedComments,
comments: {nodes, hasNextPage}
},
activeTab,
selectedCommentIds,
@@ -116,20 +113,61 @@ export default class UserDetail extends React.Component {
hideUserDetail,
viewUserDetail,
loadMore,
toggleSelectAll
toggleSelectAll,
unbanUser,
unsuspendUser,
modal,
} = this.props;
// if totalComments is 0, you're dividing by zero
let rejectedPercent = (rejectedComments / totalComments) * 100;
if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
// if totalComments is 0, you're dividing by zero, which is naughty
if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
rejectedPercent = 0;
}
const banned = isBanned(user);
const suspended = isSuspended(user);
return (
<ClickOutside onClickOutside={hideUserDetail}>
<Drawer onClose={hideUserDetail}>
<h3>{user.username}</h3>
<ClickOutside onClickOutside={modal ? null : hideUserDetail}>
<Drawer className="talk-admin-user-detail-drawer" onClose={hideUserDetail}>
<h3 className={cn(styles.username, 'talk-admin-user-detail-username')}>
{user.username}
</h3>
{user.id &&
<ActionsMenu
icon="person"
className={cn(styles.actionsMenu, 'talk-admin-user-detail-actions-menu')}
buttonClassNames={cn({
[styles.actionsMenuSuspended]: suspended,
[styles.actionsMenuBanned]: banned,
}, 'talk-admin-user-detail-actions-button')}
label={this.getActionMenuLabel()}>
{suspended ? <ActionsMenuItem
onClick={() => unsuspendUser({id: user.id})}>
Remove Suspension
</ActionsMenuItem> : <ActionsMenuItem
disabled={me.id === user.id}
onClick={this.showSuspenUserDialog}>
Suspend User
</ActionsMenuItem>}
{banned ? <ActionsMenuItem
onClick={() => unbanUser({id: user.id})}>
Remove Ban
</ActionsMenuItem> : <ActionsMenuItem
disabled={me.id === user.id}
onClick={this.showBanUserDialog}>
Ban User
</ActionsMenuItem>}
</ActionsMenu>
}
{(banned || suspended) && <UserInfoTooltip user={user} banned={banned} suspended={suspended} />}
<div>
<ul className={styles.userDetailList}>
@@ -173,65 +211,72 @@ export default class UserDetail extends React.Component {
data={this.props.data}
queryData={{root, user}}
/>
<hr />
<div className={(selectedCommentIds.length > 0) ? cn(styles.bulkActionHeader, styles.selected) : styles.bulkActionHeader}>
{
selectedCommentIds.length === 0
? (
<ul className={styles.commentStatuses}>
<li className={activeTab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
<li className={activeTab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
</ul>
)
: (
<div className={styles.bulkActionGroup}>
<ApproveButton
onClick={this.bulkAcceptThenReload}
minimal
/>
<RejectButton
onClick={this.bulkRejectThenReload}
minimal
/>
<span className={styles.selectedCommentsInfo}> {selectedCommentIds.length} comments selected</span>
</div>
)
}
<div className={styles.toggleAll}>
<input
type='checkbox'
id='toogleAll'
checked={selectedCommentIds.length > 0 && selectedCommentIds.length === nodes.length}
onChange={(e) => {
toggleSelectAll(nodes.map((comment) => comment.id), e.target.checked);
}} />
<label htmlFor='toogleAll'>Select all</label>
</div>
</div>
<div className={styles.commentList}>
{
nodes.map((comment) => {
const selected = selectedCommentIds.indexOf(comment.id) !== -1;
return <Comment
key={comment.id}
user={user}
root={root}
data={data}
comment={comment}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
selected={selected}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
/>;
})
}
</div>
<LoadMore
className={styles.loadMore}
loadMore={loadMore}
showLoadMore={hasNextPage}
/>
<TabBar
onTabClick={this.changeTab}
activeTab={activeTab}
className={cn(styles.tabBar, 'talk-admin-user-detail-tab-bar')}
aria-controls='talk-admin-user-detail-content'
tabClassNames={{
button: styles.tabButton,
buttonActive: styles.tabButtonActive,
}} >
<Tab
tabId={'all'}
className={cn(styles.tab, styles.button, 'talk-admin-user-detail-all-tab')} >
All
</Tab>
<Tab
tabId={'rejected'}
className={cn(styles.tab, 'talk-admin-user-detail-rejected-tab')} >
Rejected
</Tab>
<Tab tabId={'history'} className={cn(styles.tab, styles.button, 'talk-admin-user-detail-history-tab')}>
Account History
</Tab>
</TabBar>
<TabContent activeTab={activeTab} className='talk-admin-user-detail-content'>
<TabPane tabId={'all'} className={'talk-admin-user-detail-all-tab-pane'}>
<UserDetailCommentList
user={user}
root={root}
data={data}
loadMore={loadMore}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
selectedCommentIds={selectedCommentIds}
toggleSelectAll={toggleSelectAll}
bulkAcceptThenReload={this.bulkAcceptThenReload}
bulkRejectThenReload={this.bulkRejectThenReload}
/>
</TabPane>
<TabPane tabId={'rejected'} className={'talk-admin-user-detail-rejected-tab-pane'}>
<UserDetailCommentList
user={user}
root={root}
data={data}
loadMore={loadMore}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
selectedCommentIds={selectedCommentIds}
toggleSelectAll={toggleSelectAll}
bulkAcceptThenReload={this.bulkAcceptThenReload}
bulkRejectThenReload={this.bulkRejectThenReload}
/>
</TabPane>
<TabPane tabId={'history'} className={'talk-admin-user-detail-history-tab-pane'}>
<AccountHistory
userState={user.state}
/>
</TabPane>
</TabContent>
</Drawer>
</ClickOutside>
);
@@ -244,3 +289,32 @@ export default class UserDetail extends React.Component {
return this.renderLoaded();
}
}
UserDetail.propTypes = {
userId: PropTypes.string.isRequired,
hideUserDetail: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
changeTab: PropTypes.func.isRequired,
toggleSelect: PropTypes.func.isRequired,
bulkAccept: PropTypes.func.isRequired,
bulkReject: PropTypes.func.isRequired,
toggleSelectAll: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
data: PropTypes.shape({
refetch: PropTypes.func.isRequired,
}),
activeTab: PropTypes.string.isRequired,
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,
unsuspendUser: PropTypes.func.isRequired,
modal: PropTypes.bool,
};
export default UserDetail;
@@ -117,6 +117,8 @@ class UserDetailComment extends React.Component {
}
UserDetailComment.propTypes = {
selected: PropTypes.bool,
data: PropTypes.object,
user: PropTypes.object.isRequired,
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
@@ -0,0 +1,56 @@
.commentList {
clear: both;
}
.loadMore > button {
background-color: #696969;
&:hover {
background-color: #404040;
color: white;
}
}
.bulkActionHeader {
display: flex;
justify-content: space-between;
height: 52px;
justify-content: flex-end;
&.selected {
background-color: #efefef;
}
}
.bulkActionGroup {
height: 52px;
background-color: #efefef;
padding: 0 0 0 10px;
display: flex;
flex: 1;
i {
margin-right: 0;
}
.bulkAction {
display: inline-block;
width: 48px;
height: 48px;
transform: scale(.7);
min-width: 0;
}
.bulkAction:last-child {
margin-left: -10px;
}
}
.selectedCommentsInfo {
align-self: center;
font-weight: 500;
margin-left: 15px;
}
.toggleAll {
padding-right: 14px;
align-self: center;
}
@@ -0,0 +1,102 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import styles from './UserDetailCommentList.css';
import LoadMore from '../components/LoadMore';
import Comment from '../containers/UserDetailComment';
import RejectButton from './RejectButton';
import ApproveButton from './ApproveButton';
const UserDetailCommentList = (props) => {
const {
data,
root,
root: {
user,
comments: {
nodes,
hasNextPage
}
},
acceptComment,
rejectComment,
selectedCommentIds,
toggleSelect,
viewUserDetail,
loadMore,
toggleSelectAll,
bulkAcceptThenReload,
bulkRejectThenReload,
} = props;
return (
<div className={cn(styles.commentList, 'talk-admin-user-detail-comment-list')}>
<div className={(selectedCommentIds.length > 0) ? cn(styles.bulkActionHeader, styles.selected) : styles.bulkActionHeader}>
{selectedCommentIds.length > 0 && (
<div className={styles.bulkActionGroup}>
<ApproveButton
onClick={bulkAcceptThenReload}
minimal
/>
<RejectButton
onClick={bulkRejectThenReload}
minimal
/>
<span className={styles.selectedCommentsInfo}> {selectedCommentIds.length} comments selected</span>
</div>
)}
<div className={styles.toggleAll}>
<input
type='checkbox'
id='toogleAll'
checked={selectedCommentIds.length > 0 && selectedCommentIds.length === nodes.length}
onChange={(e) => {
toggleSelectAll(nodes.map((comment) => comment.id), e.target.checked);
}} />
<label htmlFor='toogleAll'>Select all</label>
</div>
</div>
{
nodes.map((comment) => {
const selected = selectedCommentIds.indexOf(comment.id) !== -1;
return <Comment
key={comment.id}
user={user}
root={root}
data={data}
comment={comment}
acceptComment={acceptComment}
rejectComment={rejectComment}
selected={selected}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
/>;
})
}
<LoadMore
className={styles.loadMore}
loadMore={loadMore}
showLoadMore={hasNextPage}
/>
</div>
);
};
UserDetailCommentList.propTypes = {
root: PropTypes.object.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
data: PropTypes.object.isRequired,
selectedCommentIds: PropTypes.array.isRequired,
viewUserDetail: PropTypes.any.isRequired,
loadMore: PropTypes.any.isRequired,
toggleSelect: PropTypes.func.isRequired,
toggleSelectAll: PropTypes.func.isRequired,
bulkAcceptThenReload: PropTypes.func.isRequired,
bulkRejectThenReload: PropTypes.func.isRequired,
};
export default UserDetailCommentList;
@@ -0,0 +1,69 @@
.userInfo {
position: relative;
display: inline-block;
}
.icon {
font-size: 16px;
color: #616161;
-ms-user-select:none;
-moz-user-select: none;
-webkit-user-select: none;
-webkit-touch-callout:none;
user-select: none;
-webkit-tap-highlight-color:rgba(0,0,0,0);
}
.icon:hover {
cursor: pointer;
}
.menu {
background-color: white;
border: solid 1px #999;
border-radius: 3px;
padding: 10px;
position: absolute;
-webkit-box-shadow: 0 2px 2px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12), 0 3px 1px -2px rgba(0,0,0,0.2);
box-shadow: 0 2px 2px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12), 0 3px 1px -2px rgba(0,0,0,0.2);
z-index: 10;
top: 32px;
right: 0px;
width: 140px;
text-align: left;
color: #616161;
}
.menu::before{
content: '';
border: 10px solid transparent;
border-top-color: #999;
position: absolute;
right: 0px;
top: -20px;
transform: rotate(180deg);
}
.menu::after{
content: '';
border: 10px solid transparent;
border-top-color: white;
position: absolute;
right: 0px;
top: -19px;
transform: rotate(180deg);
}
.descriptionList {
padding: 0;
margin: 0;
list-style: none;
}
.strongItem {
margin-right: 3px;
}
.descriptionItem {
font-size: 0.9em;
}
@@ -0,0 +1,95 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import {Icon} from 'coral-ui';
import styles from './UserInfoTooltip.css';
import ClickOutside from 'coral-framework/components/ClickOutside';
import moment from 'moment';
const initialState = {menuVisible: false};
class UserInfoTooltip extends React.Component {
state = initialState;
toogleMenu = () => {
this.setState({menuVisible: !this.state.menuVisible});
}
hideMenu = () => {
this.setState({menuVisible: false});
}
getLastHistoryItem = (user, status = 'banned') => {
const userHistory = user.state.status[status].history;
return userHistory[userHistory.length - 1];
}
render() {
const {menuVisible} = this.state;
const {user, banned, suspended} = this.props;
return (
<ClickOutside onClickOutside={this.hideMenu}>
<div className={cn(styles.userInfo, 'talk-admin-user-info-tooltip')}>
<span onClick={this.toogleMenu} className={cn(styles.icon, 'talk-admin-user-info-tooltip-icon')}>
<Icon name="info_outline" />
</span>
{menuVisible && (
<div className={cn(styles.menu, 'talk-admin-user-info-tooltip-menu')}>
{
banned && (
<div className={cn(styles.description, 'talk-admin-user-info-tooltip-description-banned')}>
<ul className={cn(styles.descriptionList, 'talk-admin-user-info-tooltip-description-list')}>
<li className={cn(styles.descriptionItem, 'talk-admin-user-info-tooltip-description-item')}>
<strong className={styles.strongItem}>Banned On</strong>
<span>{moment(new Date(this.getLastHistoryItem(user, 'banned').created_at)).format('MMMM Do YYYY, h:mm:ss a')}</span>
</li>
<li className={cn(styles.descriptionItem, 'talk-admin-user-info-tooltip-description-item')}>
<strong className={styles.strongItem}>By</strong>
<span>{this.getLastHistoryItem(user, 'banned').assigned_by.username}</span>
</li>
</ul>
</div>
)
}
{
suspended && (
<div className={cn(styles.description, 'talk-admin-user-info-tooltip-description-suspended')}>
<ul className={cn(styles.descriptionList, 'talk-admin-user-info-tooltip-description-list')}>
<li className={cn(styles.descriptionItem, 'talk-admin-user-info-tooltip-description-item')}>
<strong className={styles.strongItem}>Suspension</strong>
<span></span>
</li>
<li className={cn(styles.descriptionItem, 'talk-admin-user-info-tooltip-description-item')}>
<strong className={styles.strongItem}>By</strong>
<span>{this.getLastHistoryItem(user, 'suspension').assigned_by.username}</span>
</li>
<li className={cn(styles.descriptionItem, 'talk-admin-user-info-tooltip-description-item')}>
<strong className={styles.strongItem}>Start</strong>
<span>{moment(new Date(this.getLastHistoryItem(user, 'suspension').created_at)).format('MMMM Do YYYY, h:mm:ss a')}</span>
</li>
<li className={cn(styles.descriptionItem, 'talk-admin-user-info-tooltip-description-item')}>
<strong className={styles.strongItem}>End</strong>
<span>{moment(new Date(this.getLastHistoryItem(user, 'suspension').until)).format('MMMM Do YYYY, h:mm:ss a')}</span>
</li>
</ul>
</div>
)
}
</div>
)}
</div>
</ClickOutside>
);
}
}
UserInfoTooltip.propTypes = {
user: PropTypes.object,
banned: PropTypes.bool,
suspended: PropTypes.bool,
};
export default UserInfoTooltip;
@@ -6,8 +6,6 @@ export const FETCH_USERS_FAILURE = `${prefix}_FETCH_USERS_FAILURE`;
export const SORT_UPDATE = `${prefix}_SORT_UPDATE`;
export const SET_PAGE = `${prefix}_SET_PAGE`;
export const SET_ROLE = `${prefix}_SET_ROLE`;
export const SET_COMMENTER_STATUS = `${prefix}_SET_COMMENTER_STATUS`;
export const FETCH_FLAGGED_COMMENTERS_REQUEST = `${prefix}_FETCH_FLAGGED_COMMENTERS_REQUEST`;
export const FETCH_FLAGGED_COMMENTERS_SUCCESS = `${prefix}_FETCH_FLAGGED_COMMENTERS_SUCCESS`;
@@ -1,6 +1,6 @@
export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL';
export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL';
export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES';
export const CHANGE_TAB_USER_DETAIL = 'CHANGE_TAB_USER_DETAIL';
export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT';
export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT';
export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS';
@@ -1,9 +1,10 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import BanUserDialog from '../components/BanUserDialog';
import {hideBanUserDialog} from '../actions/banUserDialog';
import {withSetUserStatus, withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {withBanUser, withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {compose} from 'react-apollo';
import t from 'coral-framework/services/i18n';
import {getErrorMessages} from 'coral-framework/utils';
@@ -12,9 +13,9 @@ import {notify} from 'coral-framework/actions/notification';
class BanUserDialogContainer extends Component {
banUser = async () => {
const {userId, commentId, commentStatus, setUserStatus, setCommentStatus, hideBanUserDialog, notify} = this.props;
const {userId, commentId, commentStatus, banUser, setCommentStatus, hideBanUserDialog, notify} = this.props;
try {
await setUserStatus({userId, status: 'BANNED'});
await banUser({id: userId, message: ''});
hideBanUserDialog();
if (commentId && commentStatus && commentStatus !== 'REJECTED') {
await setCommentStatus({commentId, status: 'REJECTED'});
@@ -46,6 +47,14 @@ class BanUserDialogContainer extends Component {
}
}
BanUserDialogContainer.propTypes = {
banUser: PropTypes.func.isRequired,
hideBanUserDialog: PropTypes.func,
open: PropTypes.bool,
username: PropTypes.string,
commentStatus: PropTypes.string,
};
const mapStateToProps = ({banUserDialog: {open, userId, username, commentId, commentStatus}}) => ({
open,
userId,
@@ -62,7 +71,7 @@ const mapDispatchToProps = (dispatch) => ({
});
export default compose(
withSetUserStatus,
withBanUser,
withSetCommentStatus,
connect(
mapStateToProps,
@@ -25,7 +25,7 @@ export default withFragments({
}
user {
id
roles
role
}
}
${getSlotFragmentSpreads(slots, 'comment')}
+5 -1
View File
@@ -14,7 +14,11 @@ export default withQuery(gql`
})
flaggedUsernamesCount: userCount(query: {
action_type: FLAG,
statuses: [PENDING]
state: {
status: {
username: [SET, CHANGED]
}
}
})
}
`, {
@@ -1,4 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import {compose, gql} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
@@ -8,15 +9,17 @@ import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
import {
viewUserDetail,
hideUserDetail,
changeUserDetailStatuses,
changeTab,
clearUserDetailSelections,
toggleSelectCommentInUserDetail,
toggleSelectAllCommentInUserDetail
} from 'coral-admin/src/actions/userDetail';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {withSetCommentStatus, withUnbanUser, withUnsuspendUser} 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';
const commentConnectionFragment = gql`
fragment CoralAdmin_UserDetail_CommentConnection on CommentConnection {
@@ -114,7 +117,7 @@ class UserDetailContainer extends React.Component {
return <UserDetail
bulkReject={this.bulkReject}
bulkAccept={this.bulkAccept}
changeStatus={this.props.changeUserDetailStatuses}
changeTab={this.props.changeTab}
toggleSelect={this.props.toggleSelectCommentInUserDetail}
toggleSelectAll={this.props.toggleSelectAllCommentInUserDetail}
acceptComment={this.acceptComment}
@@ -125,6 +128,19 @@ class UserDetailContainer extends React.Component {
}
}
UserDetailContainer.propTypes = {
changeTab: PropTypes.func,
toggleSelectCommentInUserDetail: PropTypes.func,
toggleSelectAllCommentInUserDetail: PropTypes.func,
data: PropTypes.object,
root: PropTypes.object,
setCommentStatus: PropTypes.func,
clearUserDetailSelections: PropTypes.func,
selectedCommentIds: PropTypes.array,
unbanUser: PropTypes.func.isRequired,
unsuspendUser: PropTypes.func.isRequired,
};
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) {
@@ -147,8 +163,45 @@ export const withUserDetailQuery = withQuery(gql`
reliable {
flagger
}
state {
status {
suspension {
until
history {
until
created_at
assigned_by {
username
}
}
}
banned {
status
history {
status
assigned_by {
username
}
created_at
}
}
username {
status
history {
status
assigned_by {
username
}
created_at
}
}
}
}
${getSlotFragmentSpreads(slots, 'user')}
}
me {
id
}
totalComments: commentCount(query: {author_id: $author_id, statuses: []})
rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
comments: comments(query: {
@@ -177,11 +230,14 @@ const mapStateToProps = (state) => ({
selectedCommentIds: state.userDetail.selectedCommentIds,
statuses: state.userDetail.statuses,
activeTab: state.userDetail.activeTab,
modal: state.ui.modal
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
changeUserDetailStatuses,
showBanUserDialog,
showSuspendUserDialog,
changeTab,
clearUserDetailSelections,
toggleSelectCommentInUserDetail,
viewUserDetail,
@@ -195,4 +251,6 @@ export default compose(
connect(mapStateToProps, mapDispatchToProps),
withUserDetailQuery,
withSetCommentStatus,
withUnbanUser,
withUnsuspendUser,
)(UserDetailContainer);
+125 -7
View File
@@ -1,35 +1,154 @@
import update from 'immutability-helper';
import {mapLeaves} from 'coral-framework/utils';
import {gql} from 'react-apollo';
const userStatusFragment = gql`
fragment Talk_UpdateUserStatus on User {
state {
status {
banned {
status
}
suspension {
until
}
}
}
}`;
const userRoleFragment = gql`
fragment Talk_UpdateUserRole on User {
role
}`;
export default {
mutations: {
SetUserStatus: ({variables: {status, userId}}) => ({
SetUserRole: ({variables: {id, role}}) => ({
update: (proxy) => {
const fragmentId = `User_${id}`;
const data = proxy.readFragment({fragment: userRoleFragment, id: fragmentId});
const updated = update(data, {
role: {
$set: role
}
});
proxy.writeFragment({fragment: userRoleFragment, id: fragmentId, data: updated});
},
}),
SuspendUser: ({variables: {input: {id, until}}}) => ({
update: (proxy) => {
const fragmentId = `User_${id}`;
const data = proxy.readFragment({fragment: userStatusFragment, id: fragmentId});
const updated = update(data, {
state : {
status: {
suspension: {
until: {$set: until}
}
}
}
});
proxy.writeFragment({fragment: userStatusFragment, id: fragmentId, data: updated});
}
}),
UnsuspendUser: ({variables: {input: {id}}}) => ({
update: (proxy) => {
const fragmentId = `User_${id}`;
const data = proxy.readFragment({fragment: userStatusFragment, id: fragmentId});
const updated = update(data, {
state : {
status: {
suspension: {
until: {$set: null}
}
}
}
});
proxy.writeFragment({fragment: userStatusFragment, id: fragmentId, data: updated});
},
}),
BanUser: ({variables: {input: {id}}}) => ({
update: (proxy) => {
const fragmentId = `User_${id}`;
const data = proxy.readFragment({fragment: userStatusFragment, id: fragmentId});
const updated = update(data, {
state : {
status: {
banned: {
status: {$set: true}
}
}
}
});
proxy.writeFragment({fragment: userStatusFragment, id: fragmentId, data: updated});
}
}),
UnbanUser: ({variables: {input: {id}}}) => ({
update: (proxy) => {
const fragmentId = `User_${id}`;
const data = proxy.readFragment({fragment: userStatusFragment, id: fragmentId});
const updated = update(data, {
state : {
status: {
banned: {
status: {$set: false}
}
}
}
});
proxy.writeFragment({fragment: userStatusFragment, id: fragmentId, data: updated});
}
}),
SetUserBanStatus: ({variables: {status, id}}) => ({
updateQueries: {
TalkAdmin_Community: (prev) => {
if (status !== 'APPROVED') {
if (!status) {
return prev;
}
const updated = update(prev, {
users: {
nodes: {$apply: (nodes) => nodes.filter((node) => node.id !== userId)},
nodes: {$apply: (nodes) => nodes.filter((node) => node.id !== id)},
},
});
return updated;
}
}
}),
RejectUsername: ({variables: {input: {id: userId}}}) => ({
ApproveUsername: ({variables: {id}}) => ({
updateQueries: {
TalkAdmin_Community: (prev) => {
const updated = update(prev, {
users: {
nodes: {$apply: (nodes) => nodes.filter((node) => node.id !== userId)},
flaggedUsers: {
nodes: {$apply: (nodes) => nodes.filter((node) => node.id !== id)},
},
});
return updated;
}
}
}),
RejectUsername: ({variables: {id: userId}}) => ({
updateQueries: {
TalkAdmin_Community: (prev) => {
const updated = update(prev, {
flaggedUsers: {
nodes: {$apply: (nodes) => nodes.filter((node) => node.id !== userId)},
},
});
return updated;
}
},
}),
UpdateSettings: ({variables: {input}}) => ({
updateQueries: {
TalkAdmin_Configure: (prev) => {
@@ -42,4 +161,3 @@ export default {
}),
},
};
@@ -5,8 +5,6 @@ import {
SORT_UPDATE,
SET_PAGE,
SET_SEARCH_VALUE,
SET_ROLE,
SET_COMMENTER_STATUS,
SHOW_BANUSER_DIALOG,
HIDE_BANUSER_DIALOG,
SHOW_REJECT_USERNAME_DIALOG,
@@ -60,27 +58,6 @@ export default function community (state = initialState, action) {
...state,
pagePeople: action.page,
};
case SET_ROLE : {
const commenters = state.users;
const idx = commenters.findIndex((el) => el.id === action.id);
commenters[idx].roles[0] = action.role;
return {
...state,
users: commenters.map((id) => id),
};
}
case SET_COMMENTER_STATUS: {
const commenters = state.users;
const idx = commenters.findIndex((el) => el.id === action.id);
commenters[idx].status = action.status;
return {
...state,
users: commenters.map((id) => id),
};
}
case SORT_UPDATE :
return {
...state,
+2
View File
@@ -8,6 +8,7 @@ import config from './config';
import banUserDialog from './banUserDialog';
import suspendUserDialog from './suspendUserDialog';
import userDetail from './userDetail';
import ui from './ui';
export default {
auth,
@@ -20,4 +21,5 @@ export default {
moderation,
install,
config,
ui,
};
+33
View File
@@ -0,0 +1,33 @@
import {SHOW_BAN_USER_DIALOG, HIDE_BAN_USER_DIALOG} from '../constants/banUserDialog';
import {SHOW_SUSPEND_USER_DIALOG, HIDE_SUSPEND_USER_DIALOG} from '../constants/suspendUserDialog';
const initialState = {
modal: false
};
export default function config (state = initialState, action) {
switch (action.type) {
case SHOW_BAN_USER_DIALOG:
return {
...state,
modal: true,
};
case SHOW_SUSPEND_USER_DIALOG:
return {
...state,
modal: true,
};
case HIDE_BAN_USER_DIALOG:
return {
...state,
modal: false,
};
case HIDE_SUSPEND_USER_DIALOG:
return {
...state,
modal: false,
};
default:
return state;
}
}
@@ -25,7 +25,7 @@ export default function banUserDialog(state = initialState, action) {
...state,
selectedCommentIds: [],
};
case actions.CHANGE_USER_DETAIL_STATUSES:
case actions.CHANGE_TAB_USER_DETAIL:
return {
...state,
activeTab: action.tab,
@@ -12,7 +12,11 @@ class Community extends Component {
const activeTab = route.path === ':id' ? 'flagged' : route.path;
if (activeTab === 'people') {
return <People community={community} />;
return <People
community={community}
data={this.props.data}
root={this.props.root}
/>;
}
return (
@@ -13,8 +13,6 @@ class FlaggedAccounts extends React.Component {
const {
users,
loadMore,
showBanUserDialog,
showSuspendUserDialog,
showRejectUsernameDialog,
approveUser,
me,
@@ -48,8 +46,6 @@ class FlaggedAccounts extends React.Component {
<FlaggedUser
user={user}
key={user.id}
showBanUserDialog={showBanUserDialog}
showSuspendUserDialog={showSuspendUserDialog}
showRejectUsernameDialog={showRejectUsernameDialog}
approveUser={approveUser}
me={me}
@@ -74,8 +70,6 @@ class FlaggedAccounts extends React.Component {
FlaggedAccounts.propTypes = {
users: PropTypes.object,
loadMore: PropTypes.func,
showBanUserDialog: PropTypes.func,
showSuspendUserDialog: PropTypes.func,
showRejectUsernameDialog: PropTypes.func,
approveUser: PropTypes.func,
me: PropTypes.object,
@@ -4,8 +4,6 @@ import PropTypes from 'prop-types';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
import {username} from 'talk-plugin-flags/helpers/flagReasons';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import ApproveButton from 'coral-admin/src/components/ApproveButton';
import RejectButton from 'coral-admin/src/components/RejectButton';
@@ -19,18 +17,10 @@ const shortReasons = {
class User extends React.Component {
showSuspenUserDialog = () => this.props.showSuspendUserDialog({
userId: this.props.user.id,
username: this.props.user.username,
});
showBanUserDialog = () => this.props.showBanUserDialog({
userId: this.props.user.id,
username: this.props.user.username,
});
viewAuthorDetail = () => this.props.viewUserDetail(this.props.user.id);
showRejectUsernameDialog = () => this.props.showRejectUsernameDialog({id: this.props.user.id});
approveUser = () => this.props.approveUser({
userId: this.props.user.id,
});
@@ -40,7 +30,6 @@ class User extends React.Component {
user,
viewUserDetail,
selected,
me,
className,
} = this.props;
@@ -55,20 +44,6 @@ class User extends React.Component {
className={styles.button}>
{user.username}
</button>
{me.id !== user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
disabled={user.status === 'BANNED'}
onClick={this.showSuspenUserDialog}>
Suspend User
</ActionsMenuItem>
<ActionsMenuItem
disabled={user.status === 'BANNED'}
onClick={this.showBanUserDialog}>
Ban User
</ActionsMenuItem>
</ActionsMenu>
}
</div>
</div>
<div className={cn('talk-admin-community-flagged-user-body', styles.body)}>
@@ -136,8 +111,6 @@ class User extends React.Component {
}
User.propTypes = {
showSuspendUserDialog: PropTypes.func,
showBanUserDialog: PropTypes.func,
viewUserDetail: PropTypes.func,
showRejectUsernameDialog: PropTypes.func,
approveUser: PropTypes.func,
@@ -1,3 +1,45 @@
.container {
padding: 10px;
display: flex;
padding-bottom: 200px;
}
.leftColumn {
padding: 42px 56px;
width: 234px;
}
.searchBox {
width: 100%;
padding: 9px;
border: 1px solid #ccc;
border-radius: 2px;
display: flex;
background: white;
box-sizing: border-box;
height: 40px;
i {
color: #A1A1A1
}
input {
display: block;
width: 100%;
height: 100%;
border: none;
font-size: 16px;
padding: 0 2px 0 15px;
box-sizing: border-box;
}
}
.mainContent {
width: calc(100% - 300px);
padding: 34px 14px;
box-sizing: border-box;
}
.dataTable {
width: 100%;
border-left: none;
@@ -8,10 +50,6 @@ th.header {
font-size: 1.1em;
}
th.header:hover {
cursor: pointer;
}
th.header:nth-child(2), th.header:nth-child(3) {
width: 100px;
}
@@ -67,3 +105,28 @@ th.header:nth-child(2), th.header:nth-child(3) {
}
}
.actionsMenu {
font-size: 1em;
}
.actionsMenu .actionsMenuButton {
-webkit-transform: scale(1);
transform: scale(1);
font-size: 0.98em;
}
.actionsMenuSuspended {
background-color: #F29336;
border-color: #F29336;
color: white;
}
.actionsMenuBanned {
background-color: #E45241;
border-color: #E45241;
color: white;
}
.loadMore {
margin-top: 24px;
}
@@ -1,75 +1,191 @@
import React from 'react';
import cn from 'classnames';
import styles from './styles.css';
import Table from './Table';
import {Icon} from 'coral-ui';
import styles from './People.css';
import {Icon, Dropdown, Option} from 'coral-ui';
import EmptyCard from '../../../components/EmptyCard';
import t from 'coral-framework/services/i18n';
import LoadMore from '../../../components/LoadMore';
import PropTypes from 'prop-types';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import {isSuspended, isBanned} from 'coral-framework/utils/user';
import moment from 'moment';
const People = (props) => {
const {
users = [],
searchValue,
onSearchChange,
onHeaderClickHandler,
onPageChange,
totalPages,
page,
setRole,
setCommenterStatus,
viewUserDetail,
} = props;
const headers = [
{
title: t('community.username_and_email'),
field: 'username'
},
{
title: t('community.account_creation_date'),
field: 'created_at'
},
{
title: t('community.status'),
field: 'status'
},
{
title: t('community.newsroom_role'),
field: 'role'
}
];
const hasResults = !!users.length;
class People extends React.Component {
return (
<div className={cn(styles.container, 'talk-admin-community-people-container')}>
<div className={styles.leftColumn}>
<div className={styles.searchBox}>
<Icon name='search' className={styles.searchIcon}/>
<input
id="commenters-search"
type="text"
className={styles.searchBoxInput}
value={searchValue}
onChange={onSearchChange}
placeholder={t('streams.search')}
/>
getActionMenuLabel = (user) => {
if (isBanned(user)) {
return 'Banned';
} else if (isSuspended(user)) {
return 'Suspended';
}
return '';
};
unsuspendUser = (input) => {
this.props.unsuspendUser(input);
}
unbanUser = (input) => {
this.props.unbanUser(input);
}
showBanUserDialog = (input) => {
this.props.showBanUserDialog(input);
}
showSuspendUserDialog = (input) => {
this.props.showSuspendUserDialog(input);
}
render () {
const {
onSearchChange,
users = [],
setUserRole,
viewUserDetail,
loadMore,
} = this.props;
const hasResults = !!users.nodes.length;
return (
<div className={cn(styles.container, 'talk-admin-community-people-container')}>
<div className={styles.leftColumn}>
<div className={styles.searchBox}>
<Icon name='search' className={styles.searchIcon}/>
<input
id="commenters-search"
type="text"
className={styles.searchBoxInput}
defaultValue=''
onChange={onSearchChange}
placeholder={t('streams.search')}
/>
</div>
</div>
<div className={styles.mainContent}>
{
hasResults
? <div>
<div>
<table className={`mdl-data-table ${styles.dataTable}`}>
<thead>
<tr>
{headers.map((header, i) =>(
<th
key={i}
className={cn('mdl-data-table__cell--non-numeric', styles.header)}
scope="col" >
{header.title}
</th>
))}
</tr>
</thead>
<tbody>
{users.nodes.map((user)=> (
<tr key={user.id} className="talk-admin-community-people-row">
<td className="mdl-data-table__cell--non-numeric">
<button onClick={() => {viewUserDetail(user.id);}} className={cn(styles.username, styles.button)}>{user.username}</button>
<span className={styles.email}>{user.profiles.map(({id}) => id)}</span>
</td>
<td className="mdl-data-table__cell--non-numeric">
{moment(new Date(user.created_at)).format('MMMM Do YYYY, h:mm:ss a')}
</td>
<td className="mdl-data-table__cell--non-numeric">
<ActionsMenu
icon="person"
className={cn(styles.actionsMenu, 'talk-admin-community-people-dd-status')}
buttonClassNames={cn(styles.actionsMenuButton, {
[styles.actionsMenuSuspended]: isSuspended(user),
[styles.actionsMenuBanned]: isBanned(user),
}, 'talk-admin-user-detail-actions-button')}
label={this.getActionMenuLabel(user)} >
{isSuspended(user) ? <ActionsMenuItem
onClick={() => this.unsuspendUser({id: user.id})}>
Remove Suspension
</ActionsMenuItem> : <ActionsMenuItem
onClick={() => this.showSuspendUserDialog({
userId: user.id,
username: user.username,
})}>
Suspend User
</ActionsMenuItem>}
{isBanned(user) ? <ActionsMenuItem
onClick={() => this.unbanUser({id: user.id})}>
Remove Ban
</ActionsMenuItem> : <ActionsMenuItem
onClick={() => this.showBanUserDialog({
userId: user.id,
username: user.username,
})}>
Ban User
</ActionsMenuItem>}
</ActionsMenu>
</td>
<td className="mdl-data-table__cell--non-numeric">
<Dropdown
containerClassName="talk-admin-community-people-dd-role"
value={user.role}
placeholder={t('community.role')}
onChange={(role) => setUserRole(user.id, role)}>
<Option value={'COMMENTER'} label={t('community.commenter')} />
<Option value={'STAFF'} label={t('community.staff')} />
<Option value={'MODERATOR'} label={t('community.moderator')} />
<Option value={'ADMIN'} label={t('community.admin')} />
</Dropdown>
</td>
</tr>
))}
</tbody>
</table>
</div>
<LoadMore
className={styles.loadMore}
loadMore={loadMore}
showLoadMore={users.hasNextPage}
/>
</div>
: <EmptyCard>{t('community.no_results')}</EmptyCard>
}
</div>
</div>
<div className={styles.mainContent}>
{
hasResults
? <Table
users={users}
setRole={setRole}
viewUserDetail={viewUserDetail}
setCommenterStatus={setCommenterStatus}
onHeaderClickHandler={onHeaderClickHandler}
pageCount={totalPages}
onPageChange={onPageChange}
page={page}
/>
: <EmptyCard>{t('community.no_results')}</EmptyCard>
}
</div>
</div>
);
};
);
}
}
People.propTypes = {
onHeaderClickHandler: PropTypes.func,
users: PropTypes.array,
page: PropTypes.number.isRequired,
searchValue: PropTypes.string,
onSearchChange: PropTypes.func,
totalPages: PropTypes.number,
onPageChange: PropTypes.func,
setCommenterStatus: PropTypes.func.isRequired,
setRole: PropTypes.func.isRequired,
users: PropTypes.object.isRequired,
onSearchChange: PropTypes.func.isRequired,
setUserRole: PropTypes.func.isRequired,
viewUserDetail: PropTypes.func.isRequired,
unbanUser: PropTypes.func.isRequired,
unsuspendUser: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func,
showBanUserDialog: PropTypes.func,
loadMore: PropTypes.func.isRequired,
};
export default People;
@@ -2,7 +2,7 @@
.modalButtons {
display: flex;
justify-content: flex-end;
margin-top: 50px;
margin-top: 20px;
}
.emailInput {
@@ -45,7 +45,7 @@ class RejectUsernameDialog extends Component {
const next = () => this.setState({stage: stage + 1});
const suspend = async () => {
try {
await rejectUsername({id: user.id, message: this.state.email});
await rejectUsername(user.id);
this.props.handleClose();
} catch (err) {
@@ -70,7 +70,7 @@ class RejectUsernameDialog extends Component {
const {stage} = this.state;
return <Dialog
className={cn(styles.suspendDialog, 'talk-reject-username-dialog')}
className={cn(styles.suspendDialog, 'talk-admin-reject-username-dialog')}
id="rejectUsernameDialog"
open={open}
onClose={handleClose}
@@ -79,28 +79,31 @@ class RejectUsernameDialog extends Component {
<div className={styles.title}>
{t(stages[stage].title, t('reject_username.username'))}
</div>
<div className={styles.container}>
<div className={cn(styles.container, `talk-admin-reject-username-dialog-step-${stage}`)}>
<div className={styles.description}>
{t(stages[stage].description, t('reject_username.username'))}
</div>
{
{/* {
// Suspension Message: This functionality it's not entirely done on the BE - It will be released soon.
stage === 1 &&
<div className={styles.writeContainer}>
<div className={styles.emailMessage}>{t('reject_username.write_message')}</div>
<div className={styles.emailContainer}>
<textarea
rows={5}
className={cn(styles.emailInput, 'talk-reject-username-dialog-suspension-message')}
className={cn(styles.emailInput, 'talk-admin-reject-username-dialog-suspension-message')}
value={this.state.email}
onChange={this.onEmailChange}/>
</div>
</div>
}
<div className={cn(styles.modalButtons, 'talk-reject-username-dialog-buttons')}>
} */}
<div className={cn(styles.modalButtons, 'talk-admin-reject-username-dialog-buttons')}>
{Object.keys(stages[stage].options).map((key, i) => (
<Button
key={i}
className={cn('talk-reject-username-dialog-button', `talk-reject-username-dialog-button-${key}`)}
className={cn('talk-admin-username-dialog-button', `talk-admin-reject-username-dialog-button-${key}`)}
onClick={this.onActionClick(stage, i)} >
{t(stages[stage].options[key], t('reject_username.username'))}
</Button>
@@ -1,98 +0,0 @@
import React from 'react';
import styles from './Table.css';
import t from 'coral-framework/services/i18n';
import PropTypes from 'prop-types';
import {Paginate, Dropdown, Option} from 'coral-ui';
import cn from 'classnames';
const headers = [
{
title: t('community.username_and_email'),
field: 'username'
},
{
title: t('community.account_creation_date'),
field: 'created_at'
},
{
title: t('community.status'),
field: 'status'
},
{
title: t('community.newsroom_role'),
field: 'role'
}
];
const Table = ({users, setRole, onHeaderClickHandler, setCommenterStatus, viewUserDetail, pageCount, page, onPageChange}) => (
<div>
<table className={`mdl-data-table ${styles.dataTable}`}>
<thead>
<tr>
{headers.map((header, i) =>(
<th
key={i}
className={cn('mdl-data-table__cell--non-numeric', styles.header)}
scope="col"
onClick={() => onHeaderClickHandler({field: header.field})}>
{header.title}
</th>
))}
</tr>
</thead>
<tbody>
{users.map((row, i)=> (
<tr key={i} className="talk-admin-community-people-row">
<td className="mdl-data-table__cell--non-numeric">
<button onClick={() => {viewUserDetail(row.id);}} className={cn(styles.username, styles.button)}>{row.username}</button>
<span className={styles.email}>{row.profiles.map(({id}) => id)}</span>
</td>
<td className="mdl-data-table__cell--non-numeric">
{row.created_at}
</td>
<td className="mdl-data-table__cell--non-numeric">
<Dropdown
containerClassName="talk-admin-community-people-dd-status"
value={row.status}
placeholder={t('community.status')}
onChange={(status) => setCommenterStatus(row.id, status)}>
<Option value={'ACTIVE'} label={t('community.active')} />
<Option value={'BANNED'} label={t('community.banned')} />
</Dropdown>
</td>
<td className="mdl-data-table__cell--non-numeric">
<Dropdown
containerClassName="talk-admin-community-people-dd-role"
value={row.roles[0] || ''}
placeholder={t('community.role')}
onChange={(role) => setRole(row.id, role)}>
<Option value={''} label={t('community.none')} />
<Option value={'STAFF'} label={t('community.staff')} />
<Option value={'MODERATOR'} label={t('community.moderator')} />
<Option value={'ADMIN'} label={t('community.admin')} />
</Dropdown>
</td>
</tr>
))}
</tbody>
</table>
<Paginate
pageCount={pageCount}
page={page - 1}
onPageChange={onPageChange}
/>
</div>
);
Table.propTypes = {
users: PropTypes.array,
onHeaderClickHandler: PropTypes.func.isRequired,
setRole: PropTypes.func.isRequired,
setCommenterStatus: PropTypes.func.isRequired,
viewUserDetail: PropTypes.func.isRequired,
pageCount: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
};
export default Table;
@@ -240,3 +240,6 @@
}
}
/**
** This stylesheet is deprecated. DO NOT ADD CODE.
**/
@@ -3,9 +3,10 @@ import {bindActionCreators} from 'redux';
import {compose, gql} from 'react-apollo';
import withQuery from 'coral-framework/hocs/withQuery';
import {getDefinitionName} from 'coral-framework/utils';
import {withSetUserStatus, withRejectUsername} from 'coral-framework/graphql/mutations';
import {withRejectUsername} from 'coral-framework/graphql/mutations';
import FlaggedAccounts from '../containers/FlaggedAccounts';
import FlaggedUser from '../containers/FlaggedUser';
import People from '../containers/People';
import {hideRejectUsernameDialog} from '../../../actions/community';
import Community from '../components/Community';
@@ -20,17 +21,25 @@ const mapDispatchToProps = (dispatch) =>
const withData = withQuery(gql`
query TalkAdmin_Community {
flaggedUsernamesCount: userCount(query: {
action_type: FLAG,
statuses: [PENDING]
})
flaggedUsernamesCount: userCount(
query:{
action_type: FLAG,
state: {
status: {
username: [SET, CHANGED]
}
}
}
)
...${getDefinitionName(FlaggedAccounts.fragments.root)}
...${getDefinitionName(FlaggedUser.fragments.root)}
...${getDefinitionName(People.fragments.root)}
me {
...${getDefinitionName(FlaggedUser.fragments.me)}
__typename
}
}
${People.fragments.root}
${FlaggedAccounts.fragments.root}
${FlaggedUser.fragments.root}
${FlaggedUser.fragments.me}
@@ -42,7 +51,6 @@ const withData = withQuery(gql`
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withSetUserStatus,
withRejectUsername,
withData
)(Community);
@@ -5,10 +5,7 @@ import {compose, gql} from 'react-apollo';
import {withFragments} from 'plugin-api/beta/client/hocs';
import {Spinner} from 'coral-ui';
import PropTypes from 'prop-types';
import {withSetUserStatus} from 'coral-framework/graphql/mutations';
import {showBanUserDialog} from 'actions/banUserDialog';
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
import {withApproveUsername} from 'coral-framework/graphql/mutations';
import {showRejectUsernameDialog} from '../../../actions/community';
import {viewUserDetail} from '../../../actions/userDetail';
import {getDefinitionName} from 'coral-framework/utils';
@@ -24,11 +21,8 @@ class FlaggedAccountsContainer extends Component {
super(props);
}
approveUser = ({userId}) => {
return this.props.setUserStatus({
userId,
status: 'APPROVED'
});
approveUser = ({userId: id}) => {
return this.props.approveUsername(id);
}
loadMore = () => {
@@ -36,16 +30,16 @@ class FlaggedAccountsContainer extends Component {
query: LOAD_MORE_QUERY,
variables: {
limit: 5,
cursor: this.props.root.users.endCursor,
cursor: this.props.root.flaggedUsers.endCursor,
},
updateQuery: (previous, {fetchMoreResult:{users}}) => {
updateQuery: (previous, {fetchMoreResult:{flaggedUsers}}) => {
const updated = update(previous, {
users: {
flaggedUsers: {
nodes: {
$apply: (nodes) => appendNewNodes(nodes, users.nodes),
$apply: (nodes) => appendNewNodes(nodes, flaggedUsers.nodes),
},
hasNextPage: {$set: users.hasNextPage},
endCursor: {$set: users.endCursor},
hasNextPage: {$set: flaggedUsers.hasNextPage},
endCursor: {$set: flaggedUsers.endCursor},
},
});
return updated;
@@ -61,17 +55,16 @@ class FlaggedAccountsContainer extends Component {
if (this.props.data.loading) {
return <div><Spinner/></div>;
}
return (
<FlaggedAccounts
showBanUserDialog={this.props.showBanUserDialog}
showSuspendUserDialog={this.props.showSuspendUserDialog}
showRejectUsernameDialog={this.props.showRejectUsernameDialog}
viewUserDetail={this.props.viewUserDetail}
approveUser={this.approveUser}
loadMore={this.loadMore}
data={this.props.data}
root={this.props.root}
users={this.props.root.users}
users={this.props.root.flaggedUsers}
me={this.props.root.me}
/>
);
@@ -79,18 +72,25 @@ class FlaggedAccountsContainer extends Component {
}
FlaggedAccountsContainer.propTypes = {
showBanUserDialog: PropTypes.func,
showSuspendUserDialog: PropTypes.func,
showRejectUsernameDialog: PropTypes.func,
viewUserDetail: PropTypes.func,
setUserStatus: PropTypes.func,
approveUsername: PropTypes.func,
data: PropTypes.object,
root: PropTypes.object
root: PropTypes.object,
};
const LOAD_MORE_QUERY = gql`
query TalkAdmin_LoadMoreFlaggedAccounts($limit: Int, $cursor: Cursor) {
users(query:{action_type: FLAG, statuses: [PENDING], limit: $limit, cursor: $cursor}){
flaggedUsers: users(query:{
action_type: FLAG,
state: {
status: {
username: [SET, CHANGED]
}
},
limit: $limit,
cursor: $cursor
}){
hasNextPage
endCursor
nodes {
@@ -104,19 +104,25 @@ const LOAD_MORE_QUERY = gql`
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
showBanUserDialog,
showSuspendUserDialog,
showRejectUsernameDialog,
viewUserDetail,
}, dispatch);
export default compose(
connect(null, mapDispatchToProps),
withSetUserStatus,
withApproveUsername,
withFragments({
root: gql`
fragment TalkAdminCommunity_FlaggedAccounts_root on RootQuery {
users(query:{action_type: FLAG, statuses: [PENDING], limit: 10}){
flaggedUsers: users(query:{
action_type: FLAG,
state: {
status: {
username: [SET, CHANGED]
}
}
limit: 10
}){
hasNextPage
endCursor
nodes {
@@ -17,8 +17,20 @@ export default withFragments({
fragment TalkAdminCommunity_FlaggedUser_user on User {
id
username
status
roles
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
role
actions{
id
created_at
@@ -1,104 +1,230 @@
import React from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose, gql} from 'react-apollo';
import People from '../components/People';
import PropTypes from 'prop-types';
import {withFragments} from 'plugin-api/beta/client/hocs';
import {withUnbanUser, withUnsuspendUser, withSetUserRole} from 'coral-framework/graphql/mutations';
import {showBanUserDialog} from 'actions/banUserDialog';
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
import {viewUserDetail} from '../../../actions/userDetail';
import {
fetchUsers,
updateSorting,
setPage,
hideRejectUsernameDialog,
setCommenterStatus,
setRole,
setSearchValue,
} from '../../../actions/community';
import {appendNewNodes} from 'plugin-api/beta/client/utils';
import update from 'immutability-helper';
import {Spinner} from 'coral-ui';
class PeopleContainer extends React.Component {
timer=null;
timer = null;
fetchUsers = (query = {}) => {
const {community} = this.props;
state = {
searchValue: ''
};
this.props.fetchUsers({
value: community.searchValue,
field: community.fieldPeople,
asc: community.ascPeople,
...query
onSearchChange = (e) => {
const {value} = e.target;
this.setState({searchValue: value}, () => {
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.search(value);
}, 350);
});
}
componentWillMount() {
this.fetchUsers();
search = async (value) => {
return this.props.data.fetchMore({
query: SEARCH_QUERY,
variables: {
value,
limit: 5,
},
updateQuery: (previous, {fetchMoreResult:{users}}) => {
const updated = update(previous, {
users: {
nodes: {
$set: users.nodes,
},
hasNextPage: {$set: users.hasNextPage},
endCursor: {$set: users.endCursor},
},
});
return updated;
},
});
};
setUserRole = async (id, role) => {
await this.props.setUserRole(id, role);
}
onKeyDownHandler = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.fetchUsers();
}
}
onSearchChange = (e) => {
const value = e.target.value;
this.props.setSearchValue(value);
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.fetchUsers({value});
}, 350);
}
onHeaderClickHandler = (sort) => {
this.props.updateSorting(sort);
this.fetchUsers();
}
onPageChange = ({selected}) => {
const page = selected + 1;
this.props.setPage(page);
this.fetchUsers({page});
}
loadMore = () => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
value: this.state.searchValue,
limit: 5,
cursor: this.props.root.users.endCursor,
},
updateQuery: (previous, {fetchMoreResult:{users}}) => {
const updated = update(previous, {
users: {
nodes: {
$apply: (nodes) => appendNewNodes(nodes, users.nodes),
},
hasNextPage: {$set: users.hasNextPage},
endCursor: {$set: users.endCursor},
},
});
return updated;
},
});
};
render() {
if (this.props.data.error) {
return <div>{this.props.data.error.message}</div>;
}
if (this.props.data.loading) {
return <div><Spinner/></div>;
}
return <People
users={this.props.community.users}
searchValue={this.props.community.searchValue}
onSearchChange={this.onSearchChange}
onHeaderClickHandler={this.onHeaderClickHandler}
onPageChange={this.onPageChange}
totalPages={this.props.community.totalPagesPeople}
setCommenterStatus={this.props.setCommenterStatus}
setRole={this.props.setRole}
page={this.props.community.pagePeople}
onSearchChange={this.onSearchChange}
viewUserDetail={this.props.viewUserDetail}
setUserRole={this.setUserRole}
showSuspendUserDialog={this.props.showSuspendUserDialog}
showBanUserDialog={this.props.showBanUserDialog}
unbanUser={this.props.unbanUser}
unsuspendUser={this.props.unsuspendUser}
data={this.props.data}
root={this.props.root}
users={this.props.root.users}
loadMore={this.loadMore}
/>;
}
}
PeopleContainer.propTypes = {
setPage: PropTypes.func,
fetchUsers: PropTypes.func,
updateSorting: PropTypes.func,
setRole: PropTypes.func.isRequired,
setCommenterStatus: PropTypes.func.isRequired,
setSearchValue: PropTypes.func.isRequired,
viewUserDetail: PropTypes.func.isRequired,
community: PropTypes.object,
setUserRole: PropTypes.func.isRequired,
unbanUser: PropTypes.func.isRequired,
unsuspendUser: PropTypes.func.isRequired,
viewUserDetail: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func,
showBanUserDialog: PropTypes.func,
data: PropTypes.object,
root: PropTypes.object,
};
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
setPage,
fetchUsers,
updateSorting,
hideRejectUsernameDialog,
setCommenterStatus,
setRole,
viewUserDetail,
setSearchValue,
showSuspendUserDialog,
showBanUserDialog,
}, dispatch);
export default connect(null, mapDispatchToProps)(PeopleContainer);
const LOAD_MORE_QUERY = gql`
query TalkAdminCommunity_People_LoadMoreUsers($limit: Int, $cursor: Cursor, $value: String) {
users(query: {
value: $value,
limit: $limit,
cursor: $cursor
}){
hasNextPage
endCursor
nodes {
__typename
id
username
role
created_at
profiles {
id
provider
}
state {
status {
banned {
status
}
suspension {
until
}
}
}
}
}
}
`;
const SEARCH_QUERY = gql`
query TalkAdminCommunity_People_SearchUsers($value: String, $limit: Int) {
users(query: {
value: $value,
limit: $limit,
}){
hasNextPage
endCursor
nodes {
__typename
id
username
role
created_at
profiles {
id
provider
}
state {
status {
banned {
status
}
suspension {
until
}
}
}
}
}
}
`;
export default compose(
connect(null, mapDispatchToProps),
withSetUserRole,
withUnsuspendUser,
withUnbanUser,
withFragments({
root: gql`
fragment TalkAdminCommunity_People_root on RootQuery {
users(query: {}){
hasNextPage
endCursor
nodes {
__typename
id
username
role
created_at
profiles {
id
provider
}
state {
status {
banned {
status
}
suspension {
until
}
}
}
}
}
}
`,
}),
)(PeopleContainer);
@@ -5,6 +5,7 @@ import styles from './EmbedLink.css';
import {Button} from 'coral-ui';
import {BASE_URL} from 'coral-framework/constants/url';
import ConfigureCard from 'coral-framework/components/ConfigureCard';
import {stripIndent} from 'common-tags';
class EmbedLink extends Component {
@@ -23,17 +24,16 @@ class EmbedLink extends Component {
}
render () {
const coralJsUrl = join(BASE_URL, '/embed.js');
const nonce = String(Math.random()).slice(2);
const streamElementId = `coral_talk_${nonce}`;
const embedText = `
<div id="${streamElementId}"></div>
<script src="${coralJsUrl}" async onload="
Coral.Talk.render(document.getElementById('${streamElementId}'), {
talk: '${BASE_URL}'
});
"></script>
`.trim();
const coralJsUrl = join(BASE_URL, '/static/embed.js');
const embedText = stripIndent`
<div id="coral_talk_stream"></div>
<script src="${coralJsUrl}" async onload="
Coral.Talk.render(document.getElementById('coral_talk_stream'), {
talk: '${BASE_URL}'
});
"></script>
`;
return (
<ConfigureCard title={'Embed Comment Stream'}>
<p>{t('configure.copy_and_paste')}</p>
@@ -8,8 +8,6 @@ import styles from './Comment.css';
import CommentLabels from 'coral-admin/src/components/CommentLabels';
import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit';
import Slot from 'coral-framework/components/Slot';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
@@ -19,10 +17,9 @@ import RejectButton from 'coral-admin/src/components/RejectButton';
import t, {timeago} from 'coral-framework/services/i18n';
class Comment extends React.Component {
ref = null;
handleRef = (ref) => this.ref = ref;
handleRef = (ref) => (this.ref = ref);
handleFocusOrClick = () => {
if (!this.props.selected) {
@@ -30,40 +27,20 @@ class Comment extends React.Component {
}
};
showSuspendUserDialog = () => {
const {comment, showSuspendUserDialog} = this.props;
return showSuspendUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
};
showBanUserDialog = () => {
const {comment, showBanUserDialog} = this.props;
return showBanUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
};
viewUserDetail = () => {
const {viewUserDetail, comment} = this.props;
return viewUserDetail(comment.user.id);
};
approve = () => (this.props.comment.status === 'ACCEPTED'
? null
: this.props.acceptComment({commentId: this.props.comment.id})
);
approve = () =>
this.props.comment.status === 'ACCEPTED'
? null
: this.props.acceptComment({commentId: this.props.comment.id});
reject = () => (this.props.comment.status === 'REJECTED'
? null
: this.props.rejectComment({commentId: this.props.comment.id})
);
reject = () =>
this.props.comment.status === 'REJECTED'
? null
: this.props.rejectComment({commentId: this.props.comment.id});
componentDidUpdate(prev) {
if (!prev.selected && this.props.selected) {
@@ -79,7 +56,6 @@ class Comment extends React.Component {
data,
root,
root: {settings},
currentUserId,
currentAsset,
clearHeightCache,
dangling,
@@ -91,7 +67,14 @@ class Comment extends React.Component {
return (
<li
tabIndex={0}
className={cn(className, 'mdl-card', selectionStateCSS, styles.root, {[styles.selected]: selected, [styles.dangling]: dangling}, 'talk-admin-moderate-comment')}
className={cn(
className,
'mdl-card',
selectionStateCSS,
styles.root,
{[styles.selected]: selected, [styles.dangling]: dangling},
'talk-admin-moderate-comment'
)}
id={`comment_${comment.id}`}
onClick={this.handleFocusOrClick}
ref={this.handleRef}
@@ -100,38 +83,28 @@ class Comment extends React.Component {
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
{
(
<span className={styles.username} onClick={this.viewUserDetail}>
{comment.user.username}
</span>
)
}
<span
className={cn(
styles.username,
'talk-admin-moderate-comment-username'
)}
onClick={this.viewUserDetail}
>
{comment.user.username}
</span>
<span className={styles.created}>
{timeago(comment.created_at)}
</span>
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
{currentUserId !== comment.user.id &&
<ActionsMenu icon="not_interested" className="talk-admin-moderate-comment-actions-menu">
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={this.showSuspendUserDialog}>
Suspend User</ActionsMenuItem>
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={this.showBanUserDialog}>
Ban User
</ActionsMenuItem>
</ActionsMenu>
}
{comment.editing && comment.editing.edited ? (
<span>
&nbsp;<span className={styles.editedMarker}>
({t('comment.edited')})
</span>
</span>
) : null}
<div className={styles.adminCommentInfoBar}>
<CommentLabels
comment={comment}
/>
<CommentLabels comment={comment} />
<Slot
fill="adminCommentInfoBar"
data={data}
@@ -144,8 +117,11 @@ class Comment extends React.Component {
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
{!currentAsset && (
<Link to={`/admin/moderate/${comment.asset.id}`}>
{t('modqueue.moderate')}
</Link>
)}
</div>
<CommentAnimatedEdit body={comment.body}>
<div className={styles.itemBody}>
@@ -154,8 +130,7 @@ class Comment extends React.Component {
suspectWords={settings.wordlist.suspect}
bannedWords={settings.wordlist.banned}
body={comment.body}
/>
{' '}
/>{' '}
<a
className={styles.external}
href={`${comment.asset.url}?commentId=${comment.id}`}
@@ -216,8 +191,6 @@ Comment.propTypes = {
className: PropTypes.string,
dangling: PropTypes.bool,
currentAsset: PropTypes.object,
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
currentUserId: PropTypes.string.isRequired,
clearHeightCache: PropTypes.func,
comment: PropTypes.shape({
@@ -229,12 +202,12 @@ Comment.propTypes = {
created_at: PropTypes.string.isRequired,
user: PropTypes.shape({
id: PropTypes.string,
status: PropTypes.string
status: PropTypes.string,
}).isRequired,
asset: PropTypes.shape({
title: PropTypes.string,
url: PropTypes.string,
id: PropTypes.string
id: PropTypes.string,
}),
}),
data: PropTypes.object.isRequired,
@@ -12,7 +12,6 @@ import Slot from 'coral-framework/components/Slot';
import ViewOptions from './ViewOptions';
class Moderation extends Component {
state = {
isLoadingMore: false,
};
@@ -27,13 +26,14 @@ class Moderation extends Component {
key('t', () => this.nextQueue());
key('f', () => this.moderate(false));
key('d', () => this.moderate(true));
this.getMenuItems()
.forEach((menuItem, idx) => key(`${idx + 1}`, () => this.selectQueue(menuItem)));
this.getMenuItems().forEach((menuItem, idx) =>
key(`${idx + 1}`, () => this.selectQueue(menuItem))
);
}
onClose = () => {
this.props.toggleModal(false);
}
};
nextQueue = () => {
const activeTab = this.props.activeTab;
@@ -41,38 +41,45 @@ class Moderation extends Component {
const menuItems = this.getMenuItems();
const activeTabIndex = menuItems.findIndex((item) => item === activeTab);
const nextQueueIndex = (activeTabIndex === menuItems.length - 1) ? 0 : activeTabIndex + 1;
const nextQueueIndex =
activeTabIndex === menuItems.length - 1 ? 0 : activeTabIndex + 1;
this.selectQueue(menuItems[nextQueueIndex]);
}
};
selectQueue = (key) => {
const assetId = this.props.data.variables.asset_id;
this.props.router.push(this.props.getModPath(key, assetId));
}
};
getMenuItems = () => Object.keys(this.props.queueConfig);
closeSearch = () => {
const {toggleStorySearch} = this.props;
toggleStorySearch(false);
}
};
openSearch = () => {
this.props.toggleStorySearch(true);
}
};
getActiveTabCount = (props = this.props) => {
return props.root[`${props.activeTab}Count`];
}
};
moderate = (accept) => {
const {acceptComment, rejectComment, moderation: {selectedCommentId}} = this.props;
const {
acceptComment,
rejectComment,
moderation: {selectedCommentId},
} = this.props;
// Accept or reject only if there's a selected comment
if(selectedCommentId != null){
if (selectedCommentId != null) {
const comments = this.getComments();
const commentIdx = comments.findIndex((comment) => comment.id === selectedCommentId);
const commentIdx = comments.findIndex(
(comment) => comment.id === selectedCommentId
);
const comment = comments[commentIdx];
if (accept) {
@@ -81,12 +88,12 @@ class Moderation extends Component {
comment.status !== 'REJECTED' && rejectComment({commentId: comment.id});
}
}
}
};
getComments = (props = this.props) => {
const {root, activeTab} = props;
return root[activeTab].nodes;
}
};
loadMore = async () => {
if (!this.state.isLoadingMore) {
@@ -95,14 +102,13 @@ class Moderation extends Component {
const result = await this.props.loadMore(this.props.activeTab);
this.setState({isLoadingMore: false});
return result;
}
catch (e) {
} catch (e) {
this.setState({isLoadingMore: false});
throw e;
}
}
return false;
}
};
componentWillUnmount() {
key.unbind('s');
@@ -112,12 +118,21 @@ class Moderation extends Component {
key.unbind('t');
key.unbind('f');
key.unbind('d');
this.getMenuItems()
.forEach((menuItem, idx) => key.unbind(`${idx + 1}`));
this.getMenuItems().forEach((menuItem, idx) => key.unbind(`${idx + 1}`));
}
render () {
const {root, data, moderation, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props;
render() {
const {
root,
data,
moderation,
viewUserDetail,
activeTab,
getModPath,
queueConfig,
handleCommentChange,
...props
} = this.props;
const {asset} = root;
const assetId = asset && asset.id;
@@ -128,7 +143,7 @@ class Moderation extends Component {
key: queue,
name: queueConfig[queue].name,
icon: queueConfig[queue].icon,
count: root[`${queue}Count`]
count: root[`${queue}Count`],
}));
return (
@@ -145,7 +160,9 @@ class Moderation extends Component {
items={menuItems}
activeTab={activeTab}
/>
<div className={cn(styles.container, 'talk-admin-moderation-container')}>
<div
className={cn(styles.container, 'talk-admin-moderation-container')}
>
<ViewOptions
selectSort={this.props.setSortOrder}
sort={this.props.moderation.sortOrder}
@@ -159,9 +176,7 @@ class Moderation extends Component {
hasNextPage={comments.hasNextPage}
activeTab={activeTab}
singleView={moderation.singleView}
selectedCommentId={moderation.selectedCommentId}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
selectedCommentId={this.state.selectedCommentId}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
loadMore={this.loadMore}
@@ -192,7 +207,7 @@ class Moderation extends Component {
queryData={{root, asset}}
activeTab={activeTab}
handleCommentChange={handleCommentChange}
fill='adminModeration'
fill="adminModeration"
/>
</div>
);
@@ -213,8 +228,6 @@ Moderation.propTypes = {
commentBelongToQueue: PropTypes.func.isRequired,
handleCommentChange: PropTypes.func.isRequired,
setSortOrder: PropTypes.func.isRequired,
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
loadMore: PropTypes.func.isRequired,
@@ -7,7 +7,12 @@ import EmptyCard from '../../../components/EmptyCard';
import AutoLoadMore from './AutoLoadMore';
import ViewMore from './ViewMore';
import t from 'coral-framework/services/i18n';
import {WindowScroller, CellMeasurer, CellMeasurerCache, List} from 'react-virtualized';
import {
WindowScroller,
CellMeasurer,
CellMeasurerCache,
List,
} from 'react-virtualized';
import throttle from 'lodash/throttle';
import key from 'keymaster';
@@ -49,7 +54,6 @@ function invalidateCursor(invalidated, state, props) {
// getVisibileComments returns a list containing comments
// which comes after the `idCursor`.
function getVisibleComments(comments, idCursor) {
if (!comments) {
return [];
}
@@ -97,8 +101,7 @@ class ModerationQueue extends React.Component {
const view = this.state.view;
if (index < view.length) {
return view[index].id;
}
else if (index === view.length) {
} else if (index === view.length) {
return 'loadMore';
}
throw new Error(`unknown index ${index}`);
@@ -133,7 +136,10 @@ class ModerationQueue extends React.Component {
const {comments: nextComments} = next;
// New comments where added and our cursor list is incomplete.
if (this.state.idCursors.length < 2 && nextComments.length > this.state.idCursors.length) {
if (
this.state.idCursors.length < 2 &&
nextComments.length > this.state.idCursors.length
) {
this.setState(resetCursors(this.state, next));
return;
}
@@ -142,17 +148,24 @@ class ModerationQueue extends React.Component {
// Comments have been removed.
if (
prevComments && nextComments &&
prevComments &&
nextComments &&
nextComments.length < prevComments.length
) {
// Invalidate first cursor if referenced comment was removed.
if (this.state.idCursors[0] && !hasComment(nextComments, this.state.idCursors[0])) {
if (
this.state.idCursors[0] &&
!hasComment(nextComments, this.state.idCursors[0])
) {
idCursors = invalidateCursor(0, this.state, next);
}
// Invalidate second cursor if referenced comment was removed.
if (this.state.idCursors[1] && !hasComment(nextComments, this.state.idCursors[1])) {
if (
this.state.idCursors[1] &&
!hasComment(nextComments, this.state.idCursors[1])
) {
idCursors = invalidateCursor(1, this.state, next);
}
@@ -165,10 +178,12 @@ class ModerationQueue extends React.Component {
let nextSelectedCommentId = null;
// Determine a comment to select.
const prevIndex = view.findIndex((comment) => comment.id === this.props.selectedCommentId);
const prevIndex = view.findIndex(
(comment) => comment.id === this.props.selectedCommentId
);
if (prevIndex !== view.length - 1) {
nextSelectedCommentId = view[prevIndex + 1].id;
} else if(prevIndex > 0) {
} else if (prevIndex > 0) {
nextSelectedCommentId = view[prevIndex - 1].id;
}
this.props.selectCommentId(nextSelectedCommentId);
@@ -182,12 +197,14 @@ class ModerationQueue extends React.Component {
// TODO: removing or adding a comment from the list seems to render incorrect, is this a bug?
// Find first changed comment and perform a reflow.
const index = this.state.view.findIndex((comment, i) => !nextView[i] || nextView[i].id !== comment.id);
const index = this.state.view.findIndex(
(comment, i) => !nextView[i] || nextView[i].id !== comment.id
);
this.reflowList(index);
}
}
componentDidUpdate (prev) {
componentDidUpdate(prev) {
const {commentCount, selectedCommentId} = this.props;
const switchedToMultiMode = prev.singleView && !this.props.singleView;
@@ -213,13 +230,18 @@ class ModerationQueue extends React.Component {
// Returns comment counts without dangling comments.
getCommentCountWithoutDagling(props = this.props) {
return props.comments.filter((comment) => props.commentBelongToQueue(props.activeTab, comment)).length;
return props.comments.filter((comment) =>
props.commentBelongToQueue(props.activeTab, comment)
).length;
}
async selectDown() {
const view = this.state.view;
const index = view.findIndex(({id}) => id === this.props.selectedCommentId);
if (index === view.length - 1 && this.getCommentCountWithoutDagling() !== this.props.commentCount) {
if (
index === view.length - 1 &&
this.getCommentCountWithoutDagling() !== this.props.commentCount
) {
await this.props.loadMore();
this.selectDown();
return;
@@ -268,17 +290,16 @@ class ModerationQueue extends React.Component {
if (index >= 0) {
cache.clear(index);
this.listRef && this.listRef.recomputeRowHeights(index);
}
else {
} else {
cache.clearAll();
this.listRef && this.listRef.recomputeRowHeights();
}
}, 500);
rowRenderer = ({
index, // Index of row within collection
index, // Index of row within collection
parent,
style // Style object to be applied to row (to position it)
style, // Style object to be applied to row (to position it)
}) => {
const view = this.state.view;
const rowCount = view.length + 1;
@@ -291,43 +312,41 @@ class ModerationQueue extends React.Component {
if (index === rowCount - 1) {
key = 'end-of-comment-list';
child = (
<div
style={style}
id={'end-of-comment-list'}
>
{this.props.hasNextPage && <AutoLoadMore
loadMore={this.props.loadMore}
loading={this.props.isLoadingMore}
/>}
<div style={style} id={'end-of-comment-list'}>
{this.props.hasNextPage && (
<AutoLoadMore
loadMore={this.props.loadMore}
loading={this.props.isLoadingMore}
/>
)}
</div>
);
}
else {
} else {
const comment = view[index];
// Use callback cache so not to change the identity of these arrow functions.
// Otherwise shallow compare will fail to optimize.
if (!this.callbackCaches.clearHeightCache[index]) {
this.callbackCaches.clearHeightCache[index] = () => this.reflowList(index);
this.callbackCaches.clearHeightCache[index] = () =>
this.reflowList(index);
}
if (!this.callbackCaches.selectCommentId[comment.id]) {
this.callbackCaches.selectCommentId[comment.id] = () => this.props.selectCommentId(comment.id);
this.callbackCaches.selectCommentId[comment.id] = () =>
this.props.selectCommentId(comment.id);
}
key = comment.id;
child = (
<div
style={style}
>
<div style={style}>
<Comment
data={this.props.data}
root={this.props.root}
comment={comment}
dangling={!this.props.commentBelongToQueue(this.props.activeTab, comment)}
dangling={
!this.props.commentBelongToQueue(this.props.activeTab, comment)
}
selected={comment.id === this.props.selectedCommentId}
viewUserDetail={this.props.viewUserDetail}
showBanUserDialog={this.props.showBanUserDialog}
showSuspendUserDialog={this.props.showSuspendUserDialog}
acceptComment={this.props.acceptComment}
rejectComment={this.props.rejectComment}
currentAsset={this.props.currentAsset}
@@ -352,7 +371,7 @@ class ModerationQueue extends React.Component {
);
};
render () {
render() {
const {
comments,
selectedCommentId,
@@ -370,7 +389,9 @@ class ModerationQueue extends React.Component {
}
if (singleView) {
const index = comments.findIndex((comment) => comment.id === selectedCommentId);
const index = comments.findIndex(
(comment) => comment.id === selectedCommentId
);
const comment = comments[index];
return (
<div className={styles.root}>
@@ -381,8 +402,6 @@ class ModerationQueue extends React.Component {
comment={comment}
selected={true}
viewUserDetail={viewUserDetail}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
currentAsset={props.currentAsset}
@@ -428,18 +447,16 @@ class ModerationQueue extends React.Component {
}
ModerationQueue.propTypes = {
selectCommentId: PropTypes.func.isRequired,
selectedCommentId: PropTypes.string,
viewUserDetail: PropTypes.func.isRequired,
currentAsset: PropTypes.object,
showBanUserDialog: PropTypes.func.isRequired,
selectCommentId: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
commentBelongToQueue: PropTypes.func.isRequired,
cleanUpQueue: PropTypes.func.isRequired,
commentCount: PropTypes.number.isRequired,
loadMore: PropTypes.func.isRequired,
selectedCommentId: PropTypes.string,
singleView: PropTypes.bool,
isLoadingMore: PropTypes.bool,
hasNextPage: PropTypes.bool,
@@ -9,7 +9,7 @@
height: 144px;
min-height: auto;
margin-top: 16px;
z-index: 10;
z-index: 4;
@media (--tablet) {
width: 650px;
@@ -37,7 +37,6 @@ export default withFragments({
user {
id
username
status
}
asset {
id
@@ -11,10 +11,12 @@ import NotFoundAsset from '../components/NotFoundAsset';
import {isPremod, getModPath} from '../../../utils';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {handleCommentChange, commentBelongToQueue, cleanUpQueue} from '../graphql';
import {
handleCommentChange,
commentBelongToQueue,
cleanUpQueue,
} from '../graphql';
import {showBanUserDialog} from 'actions/banUserDialog';
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
import {viewUserDetail} from '../../../actions/userDetail';
import {
toggleModal,
@@ -67,13 +69,15 @@ class ModerationContainer extends Component {
};
get activeTab() {
const {root: {asset, settings}} = this.props;
const id = getAssetId(this.props);
const tab = getTab(this.props);
// Grab premod from asset or from settings if it's defined.
const setting = id && asset && asset.settings ? asset.settings.moderation : settings.moderation;
const setting =
id && asset && asset.settings
? asset.settings.moderation
: settings.moderation;
const queue = isPremod(setting) ? 'premod' : 'new';
const activeTab = tab ? tab : queue;
@@ -86,60 +90,101 @@ class ModerationContainer extends Component {
{
document: COMMENT_ADDED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentAdded: comment}}}) => {
updateQuery: (
prev,
{subscriptionData: {data: {commentAdded: comment}}}
) => {
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_ACCEPTED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body));
updateQuery: (
prev,
{subscriptionData: {data: {commentAccepted: comment}}}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
.assigned_by;
const notifyText =
this.props.auth.user.id === user.id
? ''
: t(
'modqueue.notify_accepted',
user.username,
prepareNotificationText(comment.body)
);
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_REJECTED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body));
updateQuery: (
prev,
{subscriptionData: {data: {commentRejected: comment}}}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
.assigned_by;
const notifyText =
this.props.auth.user.id === user.id
? ''
: t(
'modqueue.notify_rejected',
user.username,
prepareNotificationText(comment.body)
);
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_RESET_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentReset: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_reset', user.username, prepareNotificationText(comment.body));
updateQuery: (
prev,
{subscriptionData: {data: {commentReset: comment}}}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
.assigned_by;
const notifyText =
this.props.auth.user.id === user.id
? ''
: t(
'modqueue.notify_reset',
user.username,
prepareNotificationText(comment.body)
);
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_EDITED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => {
updateQuery: (
prev,
{subscriptionData: {data: {commentEdited: comment}}}
) => {
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_FLAGGED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => {
updateQuery: (
prev,
{subscriptionData: {data: {commentFlagged: comment}}}
) => {
return this.handleCommentChange(prev, comment);
},
},
];
this.subscriptions = parameters.map((param) => this.props.data.subscribeToMoreThrottled(param));
this.subscriptions = parameters.map((param) =>
this.props.data.subscribeToMoreThrottled(param)
);
}
unsubscribe() {
@@ -164,7 +209,9 @@ class ModerationContainer extends Component {
componentWillReceiveProps(nextProps) {
// Resubscribe when we change between assets.
if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) {
if (
this.props.data.variables.asset_id !== nextProps.data.variables.asset_id
) {
this.resubscribe(nextProps.data.variables);
}
}
@@ -172,22 +219,27 @@ class ModerationContainer extends Component {
cleanUpQueue = (queue) => {
if (!this.props.data.loading) {
this.props.data.updateQuery((query) => {
return cleanUpQueue(query, queue, this.props.moderation.sortOrder, this.props.queueConfig);
return cleanUpQueue(
query,
queue,
this.props.moderation.sortOrder,
this.props.queueConfig
);
});
}
}
};
acceptComment = ({commentId}) => {
return this.props.setCommentStatus({commentId, status: 'ACCEPTED'});
}
};
rejectComment = ({commentId}) => {
return this.props.setCommentStatus({commentId, status: 'REJECTED'});
}
};
commentBelongToQueue = (queue, comment) => {
return commentBelongToQueue(queue, comment, this.props.queueConfig);
}
};
loadMore = (tab) => {
const variables = {
@@ -202,7 +254,7 @@ class ModerationContainer extends Component {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables,
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
updateQuery: (prev, {fetchMoreResult: {comments}}) => {
return update(prev, {
[tab]: {
nodes: {$push: comments.nodes},
@@ -210,11 +262,11 @@ class ModerationContainer extends Component {
endCursor: {$set: comments.endCursor},
},
});
}
},
});
};
render () {
render() {
const {root, root: {asset, settings}, data} = this.props;
const assetId = getAssetId(this.props);
@@ -230,14 +282,15 @@ class ModerationContainer extends Component {
}
}
if(data.loading) {
if (data.loading) {
// loading.
return <Spinner />;
}
const premodEnabled = assetId ? isPremod(asset.settings.moderation) :
isPremod(settings.moderation);
const premodEnabled = assetId
? isPremod(asset.settings.moderation)
: isPremod(settings.moderation);
const currentQueueConfig = Object.assign({}, this.props.queueConfig);
@@ -249,19 +302,21 @@ class ModerationContainer extends Component {
delete currentQueueConfig.premod;
}
return <Moderation
{...this.props}
getModPath={getModPath}
loadMore={this.loadMore}
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
activeTab={this.activeTab}
queueConfig={currentQueueConfig}
handleCommentChange={this.handleCommentChange}
selectedCommentId={this.props.selectedCommentId}
commentBelongToQueue={this.commentBelongToQueue}
cleanUpQueue={this.cleanUpQueue}
/>;
return (
<Moderation
{...this.props}
getModPath={getModPath}
loadMore={this.loadMore}
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
activeTab={this.activeTab}
queueConfig={currentQueueConfig}
handleCommentChange={this.handleCommentChange}
selectedCommentId={this.props.selectedCommentId}
commentBelongToQueue={this.commentBelongToQueue}
cleanUpQueue={this.cleanUpQueue}
/>
);
}
}
const COMMENT_ADDED_SUBSCRIPTION = gql`
@@ -368,28 +423,57 @@ const commentConnectionFragment = gql`
${Comment.fragments.comment}
`;
const withModQueueQuery = withQuery(({queueConfig}) => gql`
const withModQueueQuery = withQuery(
({queueConfig}) => gql`
query CoralAdmin_Moderation($asset_id: ID, $sortOrder: SORT_ORDER, $allAssets: Boolean!, $nullStatuses: [COMMENT_STATUS!]) {
${Object.keys(queueConfig).map((queue) => `
${Object.keys(queueConfig).map(
(queue) => `
${queue}: comments(query: {
statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
statuses: ${
queueConfig[queue].statuses
? `[${queueConfig[queue].statuses.join(', ')}],`
: '$nullStatuses'
}
${
queueConfig[queue].tags
? `tags: ["${queueConfig[queue].tags.join('", "')}"],`
: ''
}
${
queueConfig[queue].action_type
? `action_type: ${queueConfig[queue].action_type}`
: ''
}
asset_id: $asset_id,
sortOrder: $sortOrder,
limit: 20,
}) {
...CoralAdmin_Moderation_CommentConnection
}
`)}
${Object.keys(queueConfig).map((queue) => `
`
)}
${Object.keys(queueConfig).map(
(queue) => `
${queue}Count: commentCount(query: {
statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
statuses: ${
queueConfig[queue].statuses
? `[${queueConfig[queue].statuses.join(', ')}],`
: '$nullStatuses'
}
${
queueConfig[queue].tags
? `tags: ["${queueConfig[queue].tags.join('", "')}"],`
: ''
}
${
queueConfig[queue].action_type
? `action_type: ${queueConfig[queue].action_type}`
: ''
}
asset_id: $asset_id,
})
`)}
`
)}
asset(id: $asset_id) @skip(if: $allAssets) {
id
title
@@ -409,20 +493,22 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
}
${Comment.fragments.root}
${commentConnectionFragment}
`, {
options: (props) => {
const id = getAssetId(props);
return {
variables: {
asset_id: id,
sortOrder: props.moderation.sortOrder,
allAssets: id === null,
nullStatuses: null,
},
fetchPolicy: 'network-only'
};
},
});
`,
{
options: (props) => {
const id = getAssetId(props);
return {
variables: {
asset_id: id,
sortOrder: props.moderation.sortOrder,
allAssets: id === null,
nullStatuses: null,
},
fetchPolicy: 'network-only',
};
},
}
);
const mapStateToProps = (state) => ({
moderation: state.moderation,
@@ -430,25 +516,26 @@ const mapStateToProps = (state) => ({
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
toggleModal,
singleView,
showBanUserDialog,
hideShortcutsNote,
toggleStorySearch,
showSuspendUserDialog,
viewUserDetail,
setSortOrder,
storySearchChange,
clearState,
notify,
selectCommentId,
}, dispatch),
...bindActionCreators(
{
toggleModal,
singleView,
hideShortcutsNote,
toggleStorySearch,
viewUserDetail,
setSortOrder,
storySearchChange,
clearState,
notify,
selectCommentId,
},
dispatch
),
});
export default compose(
withQueueConfig(baseQueueConfig),
connect(mapStateToProps, mapDispatchToProps),
withSetCommentStatus,
withModQueueQuery,
withModQueueQuery
)(ModerationContainer);
-1
View File
@@ -11,4 +11,3 @@ export const isPremod = (mod) => mod === 'PRE';
export const getModPath = (type = 'all', assetId) =>
assetId ? `/admin/moderate/${type}/${assetId}` : `/admin/moderate/${type}`;
+16 -33
View File
@@ -2,8 +2,13 @@ import jwtDecode from 'jwt-decode';
import bowser from 'bowser';
import * as actions from '../constants/auth';
import {notify} from 'coral-framework/actions/notification';
import t from 'coral-framework/services/i18n';
import get from 'lodash/get';
export const updateStatus = (status) => ({
type: actions.UPDATE_STATUS,
status,
});
export const showSignInDialog = () => ({
type: actions.SHOW_SIGNIN_DIALOG,
@@ -35,45 +40,19 @@ export const blurSignInDialog = () => ({
type: actions.BLUR_SIGNIN_DIALOG,
});
export const createUsernameRequest = () => ({
type: actions.CREATE_USERNAME_REQUEST
});
export const showCreateUsernameDialog = () => ({
type: actions.SHOW_CREATEUSERNAME_DIALOG
});
export const hideCreateUsernameDialog = () => ({
type: actions.HIDE_CREATEUSERNAME_DIALOG
});
const createUsernameSuccess = () => ({
type: actions.CREATE_USERNAME_SUCCESS
});
const createUsernameFailure = (error) => ({
type: actions.CREATE_USERNAME_FAILURE,
error
});
export const updateUsername = ({username}) => ({
export const updateUsername = (username) => ({
type: actions.UPDATE_USERNAME,
username
});
export const createUsername = (userId, formData) => (dispatch, _, {rest}) => {
dispatch(createUsernameRequest());
rest('/account/username', {method: 'PUT', body: formData})
.then(() => {
dispatch(createUsernameSuccess());
dispatch(hideCreateUsernameDialog());
dispatch(updateUsername(formData));
})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(createUsernameFailure(errorMessage));
});
};
export const changeView = (view) => (dispatch) => {
dispatch({
type: actions.CHANGE_VIEW,
@@ -304,6 +283,8 @@ const checkLoginSuccess = (user, isAdmin) => ({
isAdmin
});
const ErrNotLoggedIn = new Error('Not logged in');
export const checkLogin = () => (dispatch, _, {rest, client, pym, storage}) => {
dispatch(checkLoginRequest());
rest('/auth')
@@ -312,7 +293,7 @@ export const checkLogin = () => (dispatch, _, {rest, client, pym, storage}) => {
if (storage) {
storage.removeItem('token');
}
throw new Error('Not logged in');
throw ErrNotLoggedIn;
}
// Reset the websocket.
@@ -321,13 +302,15 @@ export const checkLogin = () => (dispatch, _, {rest, client, pym, storage}) => {
dispatch(checkLoginSuccess(result.user));
pym.sendMessage('coral-auth-changed', JSON.stringify(result.user));
// Display create username dialog if necessary.
if (result.user.canEditName && result.user.status !== 'BANNED') {
// This is for login via social. Usernames should be set.
if (get(result.user, 'status.username.status') === 'UNSET' && !get(result.user, 'status.banned.status')) {
dispatch(showCreateUsernameDialog());
}
})
.catch((error) => {
console.error(error);
if (error !== ErrNotLoggedIn) {
console.error(error);
}
if (error.status && error.status === 401 && storage) {
// Unauthorized.
@@ -0,0 +1,17 @@
import React, {Component} from 'react';
import t from 'coral-framework/services/i18n';
import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox';
class BannedAccount extends Component {
render () {
return (
<RestrictedMessageBox>
<span>
<b>{t('framework.banned_account_header')}</b><br/> {t('framework.banned_account_body')}
</span>
</RestrictedMessageBox>
);
}
}
export default BannedAccount;
@@ -54,3 +54,4 @@ export const SET_REQUIRE_EMAIL_VERIFICATION = 'SET_REQUIRE_EMAIL_VERIFICATION';
export const SET_REDIRECT_URI = 'SET_REDIRECT_URI';
export const RESET_SIGNIN_DIALOG = 'RESET_SIGNIN_DIALOG';
export const UPDATE_STATUS = 'UPDATE_STATUS';
@@ -21,7 +21,14 @@ import t from 'coral-framework/services/i18n';
import PropTypes from 'prop-types';
import {setActiveTab} from '../actions/embed';
const {logout, checkLogin, focusSignInDialog, blurSignInDialog, hideSignInDialog} = authActions;
const {
logout,
checkLogin,
focusSignInDialog,
blurSignInDialog,
hideSignInDialog,
updateStatus,
} = authActions;
const {fetchAssetSuccess} = assetActions;
class EmbedContainer extends React.Component {
@@ -35,20 +42,23 @@ class EmbedContainer extends React.Component {
if (props.auth.loggedIn) {
const newSubscriptions = [{
document: USER_BANNED_SUBSCRIPTION,
updateQuery: () => {
updateQuery: (_, {subscriptionData: {data: {userBanned: {state}}}}) => {
notify('info', t('your_account_has_been_banned'));
props.updateStatus(state.status);
},
},
{
document: USER_SUSPENDED_SUBSCRIPTION,
updateQuery: () => {
updateQuery: (_, {subscriptionData: {data: {userSuspended: {state}}}}) => {
notify('info', t('your_account_has_been_suspended'));
props.updateStatus(state.status);
},
},
{
document: USERNAME_REJECTED_SUBSCRIPTION,
updateQuery: () => {
updateQuery: (_, {subscriptionData: {data: {usernameRejected: {state}}}}) => {
notify('info', t('your_username_has_been_rejected'));
props.updateStatus(state.status);
},
}];
@@ -116,10 +126,18 @@ const USER_BANNED_SUBSCRIPTION = gql`
subscription UserBanned($user_id: ID!) {
userBanned(user_id: $user_id){
id
status
canEditName
suspension {
until
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
}
}
@@ -129,10 +147,18 @@ const USER_SUSPENDED_SUBSCRIPTION = gql`
subscription UserSuspended($user_id: ID!) {
userSuspended(user_id: $user_id){
id
status
canEditName
suspension {
until
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
}
}
@@ -142,10 +168,18 @@ const USERNAME_REJECTED_SUBSCRIPTION = gql`
subscription UsernameRejected($user_id: ID!) {
usernameRejected(user_id: $user_id){
id
status
canEditName
suspension {
until
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
}
}
@@ -170,7 +204,19 @@ const EMBED_QUERY = gql`
) {
me {
id
status
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
}
asset(id: $assetId, url: $assetUrl) {
...${getDefinitionName(Configure.fragments.asset)}
@@ -224,6 +270,7 @@ const mapDispatchToProps = (dispatch) =>
focusSignInDialog,
blurSignInDialog,
hideSignInDialog,
updateStatus,
},
dispatch
);
+34 -22
View File
@@ -1,7 +1,10 @@
import {gql} from 'react-apollo';
import update from 'immutability-helper';
import uuid from 'uuid/v4';
import {insertCommentIntoEmbedQuery, removeCommentFromEmbedQuery} from './utils';
import {
insertCommentIntoEmbedQuery,
removeCommentFromEmbedQuery,
} from './utils';
import {mapLeaves} from 'coral-framework/utils';
export default {
@@ -45,7 +48,7 @@ export default {
}
}
`,
CreateDontAgreeResponse : gql`
CreateDontAgreeResponse: gql`
fragment CoralEmbedStream_CreateDontAgreeResponse on CreateDontAgreeResponse {
dontagree {
id
@@ -143,13 +146,13 @@ export default {
tag: {
name: tag,
created_at: new Date().toISOString(),
__typename: 'Tag'
__typename: 'Tag',
},
assigned_by: {
id: auth.user.id,
__typename: 'User'
__typename: 'User',
},
__typename: 'TagLink'
__typename: 'TagLink',
})),
},
created_at: new Date().toISOString(),
@@ -159,9 +162,9 @@ export default {
tag: {
name: tag,
created_at: new Date().toISOString(),
__typename: 'Tag'
__typename: 'Tag',
},
__typename: 'TagLink'
__typename: 'TagLink',
})),
status: 'NONE',
replyCount: 0,
@@ -171,9 +174,7 @@ export default {
title: '',
url: '',
},
parent: parent_id
? {__typename: 'Comment', id: parent_id}
: null,
parent: parent_id ? {__typename: 'Comment', id: parent_id} : null,
replies: {
__typename: 'CommentConnection',
nodes: [],
@@ -188,13 +189,17 @@ export default {
},
status_history: [],
id: `pending-${uuid()}`,
}
}
},
},
},
updateQueries: {
CoralEmbedStream_Embed: (prev, {mutationResult: {data: {createComment: {comment}}}}) => {
CoralEmbedStream_Embed: (
prev,
{mutationResult: {data: {createComment: {comment}}}}
) => {
if (
prev.me.roles.indexOf('ADMIN') === -1 && prev.asset.settings.moderation === 'PRE' ||
(prev.me.role !== 'ADMIN' &&
prev.asset.settings.moderation === 'PRE') ||
comment.status === 'PREMOD' ||
comment.status === 'REJECTED' ||
comment.status === 'SYSTEM_WITHHELD'
@@ -203,7 +208,10 @@ export default {
}
return insertCommentIntoEmbedQuery(prev, comment);
},
CoralEmbedStream_Profile: (prev, {mutationResult: {data: {createComment: {comment}}}}) => {
CoralEmbedStream_Profile: (
prev,
{mutationResult: {data: {createComment: {comment}}}}
) => {
return update(prev, {
me: {
comments: {
@@ -212,19 +220,24 @@ export default {
},
});
},
}
},
}),
EditComment: () => ({
updateQueries: {
CoralEmbedStream_Embed: (prev, {mutationResult: {data: {editComment: {comment}}}}) => {
if (!['PREMOD', 'REJECTED', 'SYSTEM_WITHHELD'].includes(comment.status)) {
CoralEmbedStream_Embed: (
prev,
{mutationResult: {data: {editComment: {comment}}}}
) => {
if (
!['PREMOD', 'REJECTED', 'SYSTEM_WITHHELD'].includes(comment.status)
) {
return null;
}
return removeCommentFromEmbedQuery(prev, comment.id);
},
},
}),
UpdateAssetSettings: ({variables: {input}}) => ({
UpdateAssetSettings: ({variables: {input}}) => ({
updateQueries: {
CoralEmbedStream_Embed: (prev) => {
const updated = update(prev, {
@@ -233,9 +246,8 @@ export default {
},
});
return updated;
}
}
},
},
}),
},
};
+11 -29
View File
@@ -1,5 +1,6 @@
import * as actions from '../constants/auth';
import pym from 'coral-framework/services/pym';
import merge from 'lodash/merge';
const initialState = {
isLoading: false,
@@ -198,6 +199,7 @@ export default function auth (state = initialState, action) {
user: {
...state.user,
username: action.username,
lowercaseUsername: action.username.toLowerCase(),
}
};
case actions.VERIFY_EMAIL_FAILURE:
@@ -227,35 +229,15 @@ export default function auth (state = initialState, action) {
...state,
redirectUri: action.uri,
};
case 'APOLLO_SUBSCRIPTION_RESULT':
if (action.operationName === 'UserBanned' && state.getIn(['user', 'id']) === action.variables.user_id) {
return {
...state,
user: {
...state.user,
...action.result.data.userBanned,
},
};
}
if (action.operationName === 'UserSuspended' && state.getIn(['user', 'id']) === action.variables.user_id) {
return {
...state,
user: {
...state.user,
...action.result.data.userSuspended,
},
};
}
if (action.operationName === 'UsernameRejected' && state.getIn(['user', 'id']) === action.variables.user_id) {
return {
...state,
user: {
...state.user,
...action.result.data.usernameRejected,
},
};
}
return state;
case actions.UPDATE_STATUS: {
return {
...state,
user: {
...state.user,
status: merge({}, state.user.status, action.status),
},
};
}
default :
return state;
}
@@ -15,23 +15,6 @@ export default function stream(state = initialState, action) {
activeTab: action.tab,
previousTab: state.activeTab,
};
case 'APOLLO_QUERY_INIT':
if (action.queryString.indexOf('query CoralEmbedStream_Embed(') >= 0) {
return {
...state,
refetching: action.isRefetch ? true : state.refetching,
refetchRequestId: action.isRefetch ? action.requestId : state.refetchRequestId,
};
}
return state;
case 'APOLLO_QUERY_RESULT':
if (action.operationName === 'CoralEmbedStream_Embed') {
return {
...state,
refetching: action.requestId === state.refetchRequestId ? false : state.refetching,
};
}
return state;
default:
return state;
}
@@ -0,0 +1,13 @@
.editNameInput {
margin-top: 10px;
margin-bottom: 10px;
padding: 10px;
border-radius: 3px;
border: solid 1px #d8d8d8;
font-size: 0.9em;
}
.alert {
margin-top: 10px;
color: #B71C1C;
}
@@ -0,0 +1,77 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
import styles from './ChangeUsername.css';
import {Button} from 'coral-ui';
import validate from 'coral-framework/helpers/validate';
import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox';
import {forEachError} from 'plugin-api/beta/client/utils';
class ChangeUsername extends Component {
static propTypes = {
changeUsername: PropTypes.func.isRequired,
user: PropTypes.object.isRequired,
}
state = {
username: '',
alert: '',
}
onSubmitClick = (e) => {
const {changeUsername, user} = this.props;
const {username} = this.state;
e.preventDefault();
if (username === this.props.user.username) {
this.setState({alert: t('error.SAME_USERNAME_PROVIDED')});
}
else if (validate.username(username)) {
changeUsername(user.id, username)
.then(() => location.reload())
.catch((error) => {
let errorMsg = '';
forEachError(error, ({msg}) => errorMsg = errorMsg ? `${errorMsg}, ${msg}` : msg);
this.setState({alert: errorMsg});
});
} else {
this.setState({alert: t('framework.edit_name.error')});
}
}
render () {
const {username, alert} = this.state;
return <RestrictedMessageBox>
<div className="talk-change-username">
<span>
{t('framework.edit_name.msg')}
</span>
<div className={styles.alert}>
{alert}
</div>
<label
htmlFor='username'
className="screen-reader-text">
{t('framework.edit_name.label')}
</label>
<input
type='text'
className={cn(styles.editNameInput, 'talk-change-username-username-input')}
value={username}
placeholder={t('framework.edit_name.label')}
onChange={(e) => this.setState({username: e.target.value})}
/><br/>
<Button
className="talk-change-username-submit-button"
onClick={this.onSubmitClick} >
{t('framework.edit_name.button')}
</Button>
</div>
</RestrictedMessageBox>;
}
}
export default ChangeUsername;
@@ -2,7 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import {StreamError} from './StreamError';
import Comment from '../containers/Comment';
import SuspendedAccount from './SuspendedAccount';
import BannedAccount from '../../../components/BannedAccount';
import ChangeUsername from '../containers/ChangeUsername';
import Slot from 'coral-framework/components/Slot';
import InfoBox from 'talk-plugin-infobox/InfoBox';
import {can} from 'coral-framework/services/perms';
@@ -15,6 +16,7 @@ import QuestionBox from '../../../components/QuestionBox';
import {isCommentActive} from 'coral-framework/utils';
import {Tab, TabCount, TabPane} from 'coral-ui';
import cn from 'classnames';
import get from 'lodash/get';
import {getTopLevelParent, attachCommentToParent} from '../../../graphql/utils';
import AllCommentsPane from './AllCommentsPane';
@@ -225,19 +227,20 @@ class Stream extends React.Component {
notify,
updateItem,
auth: {loggedIn, user},
editName,
} = this.props;
const {keepCommentBox} = this.state;
const open = !asset.isClosed;
const banned = user && user.status === 'BANNED';
const banned = get(user, 'status.banned.status');
const suspensionUntil = get(user, 'status.suspension.until');
const rejectedUsername = get(user, 'status.username.status') === 'REJECTED';
const temporarilySuspended =
user &&
user.suspension.until &&
new Date(user.suspension.until) > new Date();
suspensionUntil &&
new Date(suspensionUntil) > new Date();
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !rejectedUsername && !highlightedComment) || keepCommentBox);
const slotProps = {data};
const slotQueryData = {root, asset};
@@ -270,15 +273,11 @@ class Stream extends React.Component {
{t(
'stream.temporarily_suspended',
root.settings.organizationName,
timeago(user.suspension.until)
timeago(suspensionUntil)
)}
</RestrictedMessageBox>}
{banned &&
<SuspendedAccount
canEditName={user && user.canEditName}
editName={editName}
currentUsername={user.username}
/>}
{!banned && rejectedUsername && <ChangeUsername user={user} />}
{banned && <BannedAccount />}
{showCommentBox &&
<CommentBox
notify={notify}
@@ -0,0 +1,7 @@
import {compose} from 'react-apollo';
import {withChangeUsername} from 'coral-framework/graphql/mutations';
import ChangeUsername from '../components/ChangeUsername';
export default compose(
withChangeUsername,
)(ChangeUsername);
@@ -2,15 +2,25 @@ import React from 'react';
import {gql, compose} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {ADDTL_COMMENTS_ON_LOAD_MORE, THREADING_LEVEL} from '../../../constants/stream';
import {
withPostComment, withPostFlag, withPostDontAgree,
withDeleteAction, withEditComment
ADDTL_COMMENTS_ON_LOAD_MORE,
THREADING_LEVEL,
} from '../../../constants/stream';
import {
withPostComment,
withPostFlag,
withPostDontAgree,
withDeleteAction,
withEditComment,
} from 'coral-framework/graphql/mutations';
import * as authActions from 'coral-embed-stream/src/actions/auth';
import * as notificationActions from 'coral-framework/actions/notification';
import {setActiveReplyBox, setActiveTab, viewAllComments} from '../../../actions/stream';
import {
setActiveReplyBox,
setActiveTab,
viewAllComments,
} from '../../../actions/stream';
import Stream from '../components/Stream';
import Comment from './Comment';
import {withFragments, withEmit} from 'coral-framework/hocs';
@@ -43,7 +53,10 @@ class StreamContainer extends React.Component {
// Ignore mutations from me.
// TODO: need way to detect mutations created by this client, and allow mutations from other clients.
if (this.props.auth.user && commentEdited.user.id === this.props.auth.user.id) {
if (
this.props.auth.user &&
commentEdited.user.id === this.props.auth.user.id
) {
return prev;
}
@@ -52,7 +65,11 @@ class StreamContainer extends React.Component {
return prev;
}
if (['PREMOD', 'REJECTED', 'SYSTEM_WITHHELD'].includes(commentEdited.status)) {
if (
['PREMOD', 'REJECTED', 'SYSTEM_WITHHELD'].includes(
commentEdited.status
)
) {
return removeCommentFromEmbedQuery(prev, commentEdited.id);
}
},
@@ -69,14 +86,20 @@ class StreamContainer extends React.Component {
// Ignore mutations from me.
// TODO: need way to detect mutations created by this client, and allow mutations from other clients.
if (this.props.auth.user && commentAdded.user.id === this.props.auth.user.id) {
if (
this.props.auth.user &&
commentAdded.user.id === this.props.auth.user.id
) {
return prev;
}
// Exit if author is ignored.
if (
this.props.root.me &&
this.props.root.me.ignoredUsers.some(({id}) => id === commentAdded.user.id)) {
this.props.root.me.ignoredUsers.some(
({id}) => id === commentAdded.user.id
)
) {
return prev;
}
@@ -121,11 +144,11 @@ class StreamContainer extends React.Component {
sortOrder: 'ASC',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
updateQuery: (prev, {fetchMoreResult: {comments}}) => {
return insertFetchedCommentsIntoEmbedQuery(prev, comments, parent_id);
},
});
}
};
loadMoreComments = () => {
return this.props.data.fetchMore({
@@ -139,7 +162,7 @@ class StreamContainer extends React.Component {
sortBy: this.props.data.variables.sortBy,
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
updateQuery: (prev, {fetchMoreResult: {comments}}) => {
return insertFetchedCommentsIntoEmbedQuery(prev, comments);
},
});
@@ -168,7 +191,10 @@ class StreamContainer extends React.Component {
}
componentWillReceiveProps(nextProps) {
if (this.props.sortOrder !== nextProps.sortOrder || this.props.sortBy !== nextProps.sortBy) {
if (
this.props.sortOrder !== nextProps.sortOrder ||
this.props.sortBy !== nextProps.sortBy
) {
nextProps.data.refetch();
}
}
@@ -178,23 +204,26 @@ class StreamContainer extends React.Component {
}
render() {
if (!this.props.asset
|| this.props.asset.comment === undefined
&& !this.props.asset.comments
if (
!this.props.asset ||
(this.props.asset.comment === undefined && !this.props.asset.comments)
) {
return <Spinner />;
}
const streamLoading = this.props.refetching || this.props.data.loading;
// @TODO: Detect refetch when we have apollo 2.0.
const streamLoading = this.props.data.loading;
return <Stream
{...this.props}
loadMore={this.loadMore}
loadMoreComments={this.loadMoreComments}
loadNewReplies={this.loadNewReplies}
userIsDegraged={this.userIsDegraged()}
loading={streamLoading}
/>;
return (
<Stream
{...this.props}
loadMore={this.loadMore}
loadMoreComments={this.loadMoreComments}
loadNewReplies={this.loadNewReplies}
userIsDegraged={this.userIsDegraged()}
loading={streamLoading}
/>
);
}
}
@@ -211,8 +240,8 @@ const commentFragment = gql`
`;
const COMMENTS_ADDED_SUBSCRIPTION = gql`
subscription CommentAdded($assetId: ID!, $excludeIgnored: Boolean){
commentAdded(asset_id: $assetId){
subscription CommentAdded($assetId: ID!, $excludeIgnored: Boolean) {
commentAdded(asset_id: $assetId) {
parent {
id
}
@@ -223,8 +252,8 @@ const COMMENTS_ADDED_SUBSCRIPTION = gql`
`;
const COMMENTS_EDITED_SUBSCRIPTION = gql`
subscription CommentEdited($assetId: ID!){
commentEdited(asset_id: $assetId){
subscription CommentEdited($assetId: ID!) {
commentEdited(asset_id: $assetId) {
id
body
status
@@ -281,11 +310,23 @@ const fragments = {
root: gql`
fragment CoralEmbedStream_Stream_root on RootQuery {
me {
status
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
ignoredUsers {
id
}
roles
role
}
settings {
organizationName
@@ -299,12 +340,15 @@ const fragments = {
fragment CoralEmbedStream_Stream_asset on Asset {
comment(id: $commentId) @include(if: $hasComment) {
...CoralEmbedStream_Stream_comment
${nest(`
${nest(
`
parent {
...CoralEmbedStream_Stream_comment
...nest
}
`, THREADING_LEVEL)}
`,
THREADING_LEVEL
)}
}
id
title
@@ -344,7 +388,6 @@ const fragments = {
const mapStateToProps = (state) => ({
auth: state.auth,
refetching: state.embed.refetching,
activeReplyBox: state.stream.activeReplyBox,
commentId: state.stream.commentId,
assetId: state.stream.assetId,
@@ -360,14 +403,17 @@ const mapStateToProps = (state) => ({
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
showSignInDialog,
notify,
setActiveReplyBox,
editName,
viewAllComments,
setActiveStreamTab: setActiveTab,
}, dispatch);
bindActionCreators(
{
showSignInDialog,
notify,
setActiveReplyBox,
editName,
viewAllComments,
setActiveStreamTab: setActiveTab,
},
dispatch
);
export default compose(
withFragments(fragments),
@@ -377,5 +423,5 @@ export default compose(
withPostFlag,
withPostDontAgree,
withDeleteAction,
withEditComment,
withEditComment
)(StreamContainer);
@@ -4,7 +4,8 @@ import {findDOMNode} from 'react-dom';
export default class ClickOutside extends Component {
static propTypes = {
onClickOutside: PropTypes.func.isRequired
onClickOutside: PropTypes.func,
children: PropTypes.node,
};
static contextTypes = {
@@ -16,7 +17,7 @@ export default class ClickOutside extends Component {
handleClick = (e) => {
const {onClickOutside} = this.props;
if (!e || !this.domNode.contains(e.target)) {
onClickOutside(e);
onClickOutside && onClickOutside(e);
}
};
+8 -2
View File
@@ -3,11 +3,17 @@ import {createDefaultResponseFragments} from '../utils';
// fragments defined here are automatically registered.
export default {
...createDefaultResponseFragments(
'SetUserRoleResponse',
'ChangeUsernameResponse',
'SetUsernameResponse',
'BanUsersResponse',
'UnbanUserResponse',
'SetUserSuspensionStatusResponse',
'SetCommentStatusResponse',
'SetUsernameStatusResponse',
'UnsuspendUserResponse',
'SuspendUserResponse',
'RejectUsernameResponse',
'CreateCommentResponse',
'SetUserStatusResponse',
'CreateFlagResponse',
'EditCommentResponse',
'PostFlagResponse',
+151 -14
View File
@@ -177,39 +177,176 @@ export const withSuspendUser = withMutation(
})
});
export const withRejectUsername = withMutation(
export const withUnsuspendUser = withMutation(
gql`
mutation RejectUsername($input: RejectUsernameInput!) {
rejectUsername(input: $input) {
...RejectUsernameResponse
mutation UnsuspendUser($input: UnsuspendUserInput!) {
unsuspendUser(input: $input) {
...UnsuspendUserResponse
}
}
`, {
props: ({mutate}) => ({
rejectUsername: (input) => {
unsuspendUser: (input) => {
return mutate({
variables: {
input,
},
});
}
})
}),
});
export const withSetUserStatus = withMutation(
export const withApproveUsername = withMutation(
gql`
mutation SetUserStatus($userId: ID!, $status: USER_STATUS!) {
setUserStatus(id: $userId, status: $status) {
...SetUserStatusResponse
mutation ApproveUsername($id: ID!) {
approveUsername(id: $id) {
...SetUsernameStatusResponse
}
}
`, {
props: ({mutate}) => ({
setUserStatus: ({userId, status}) => {
approveUsername: (id) => {
return mutate({
variables: {
userId,
status
id,
},
});
}
})
});
export const withRejectUsername = withMutation(
gql`
mutation RejectUsername($id: ID!) {
rejectUsername(id: $id) {
...SetUsernameStatusResponse
}
}
`, {
props: ({mutate}) => ({
rejectUsername: (id) => {
return mutate({
variables: {
id,
},
});
}
})
});
const SetUsernameFragment = gql`
fragment Talk_SetUsername on User {
username
}`;
export const withChangeUsername = withMutation(
gql`
mutation ChangeUsername($id: ID!, $username: String!) {
changeUsername(id: $id, username: $username) {
...ChangeUsernameResponse
}
}
`, {
props: ({mutate}) => ({
changeUsername: (id, username) => {
return mutate({
variables: {
id,
username,
},
update: (proxy) => {
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
username,
};
proxy.writeFragment({fragment: SetUsernameFragment, id: fragmentId, data});
}
});
}
})
});
export const withSetUsername = withMutation(
gql`
mutation SetUsername($id: ID!, $username: String!) {
setUsername(id: $id, username: $username) {
...SetUsernameResponse
}
}
`, {
props: ({mutate}) => ({
setUsername: (id, username) => {
return mutate({
variables: {
id,
username,
},
update: (proxy) => {
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
username,
};
proxy.writeFragment({fragment: SetUsernameFragment, id: fragmentId, data});
}
});
}
})
});
export const withBanUser = withMutation(
gql`
mutation BanUser($input: BanUserInput!) {
banUser(input: $input) {
...BanUsersResponse
}
}
`, {
props: ({mutate}) => ({
banUser: (input) => {
return mutate({
variables: {
input,
},
});
}
}),
});
export const withUnbanUser = withMutation(
gql`
mutation UnbanUser($input: UnbanUserInput!) {
unbanUser(input: $input) {
...UnbanUserResponse
}
}
`, {
props: ({mutate}) => ({
unbanUser: (input) => {
return mutate({
variables: {
input,
},
});
}
}),
});
export const withSetUserRole = withMutation(
gql`
mutation SetUserRole($id: ID!, $role: USER_ROLES!) {
setUserRole(id: $id, role: $role) {
...SetUserRoleResponse
}
}
`, {
props: ({mutate}) => ({
setUserRole: (id, role) => {
return mutate({
variables: {
id,
role,
},
});
}
@@ -228,7 +365,7 @@ export const withPostComment = withMutation(
postComment: (input) => {
return mutate({
variables: {
input
input,
},
});
}
+9
View File
@@ -3,6 +3,12 @@ import has from 'lodash/has';
import get from 'lodash/get';
import merge from 'lodash/merge';
import moment from 'moment';
import 'moment/locale/da';
import 'moment/locale/es';
import 'moment/locale/fr';
import 'moment/locale/pt-br';
import daTA from 'timeago.js/locales/da';
import esTA from 'timeago.js/locales/es';
import frTA from 'timeago.js/locales/fr';
@@ -40,6 +46,9 @@ function init() {
const locale = getLocale();
setLocale(locale);
// Setting moment
moment.locale(locale);
// Extract language key.
lang = locale.split('-')[0];
+35 -12
View File
@@ -1,40 +1,63 @@
import intersection from 'lodash/intersection';
import get from 'lodash/get';
// =========================================================================
// BASIC PERMISSIONS
// =========================================================================
const basicPerms = {
INTERACT_WITH_COMMUNITY: (user) => {
const banned = get(user, 'status.banned.status');
const suspensionUntil = get(user, 'status.suspension.until');
const suspended = suspensionUntil && new Date(suspensionUntil) > new Date();
return !banned && !suspended;
},
EDIT_NAME: (user) => {
const usernameStatus = user.status.username.status;
return usernameStatus === 'UNSET' || usernameStatus === 'REJECTED';
},
};
// =========================================================================
// PERMISSIONS BY ROLE
// =========================================================================
const basicRoles = {
HAS_STAFF_TAG: ['ADMIN', 'MODERATOR', 'STAFF']
HAS_STAFF_TAG: ['ADMIN', 'MODERATOR', 'STAFF'],
};
const queryRoles = {
UPDATE_CONFIG: ['ADMIN'],
ACCESS_ADMIN: ['ADMIN', 'MODERATOR'],
VIEW_USER_EMAILS: ['ADMIN']
VIEW_USER_EMAILS: ['ADMIN'],
};
const mutationRoles = {
CHANGE_ROLES: ['ADMIN'],
MODERATE_COMMENTS: ['ADMIN', 'MODERATOR']
MODERATE_COMMENTS: ['ADMIN', 'MODERATOR'],
};
const roles = {...basicRoles, ...queryRoles, ...mutationRoles};
export const can = (user, ...perms) => {
if (!user) {
return false;
}
const banned = user.status === 'BANNED';
const suspended = user.suspension.until && new Date(user.suspension.until) > new Date();
return perms.every((perm) => {
if (perm === 'INTERACT_WITH_COMMUNITY') {
return !banned && !suspended;
// Basic Permissions
const permAction = basicPerms[perm];
if (typeof permAction !== 'undefined') {
return permAction(user);
}
// Permissions by Role
const role = roles[perm];
if (typeof role === 'undefined') {
throw new Error(`${perm} is not a valid role`);
throw new Error(`${perm} is not a valid role or permission`);
}
return intersection(role, user.roles).length > 0;
return role.includes(user.role);
});
};
+21
View File
@@ -1,3 +1,5 @@
import get from 'lodash/get';
/**
* getReliability
* retrieves reliability value as string
@@ -12,3 +14,22 @@ export const getReliability = (reliabilityValue) => {
return 'unreliable';
}
};
/**
* isSuspended
* retrieves boolean based on the user suspension status
*/
export const isSuspended = (user) => {
const suspensionUntil = get(user, 'state.status.suspension.until');
return user && suspensionUntil && new Date(suspensionUntil) > new Date();
};
/**
* isBanned
* retrieves boolean based on the user ban status
*/
export const isBanned = (user) => {
return get(user, 'state.status.banned.status');
};
-21
View File
@@ -1,21 +0,0 @@
.bio textarea {
width: 100%;
box-sizing: border-box;
border-radius: 2px;
min-height: 100px;
margin: 10px 0;
border: solid 1px #d8d8d8;
}
.bio h1 {
font-size: 16px;
margin: 3px 0;
}
.bio p {
margin: 3px 0;
}
.actions {
text-align: right;
}
-18
View File
@@ -1,18 +0,0 @@
import React from 'react';
import styles from './Bio.css';
import {Button} from '../../coral-ui';
export default ({bio, handleSave, handleInput, handleCancel}) => (
<div className={styles.bio}>
<h1>Bio</h1>
<p>Tell the community about yourself</p>
<form>
<textarea value={bio} onChange={handleInput} />
<div className={styles.actions}>
<Button cStyle='cancel' type="button" onClick={handleCancel} raised>Cancel</Button>
<Button cStyle='success' type="submit" onClick={handleSave}>Save Changes</Button>
</div>
</form>
</div>
);
@@ -1,45 +0,0 @@
import React, {Component} from 'react';
import Bio from '../components/Bio';
export default class BioContainer extends Component {
constructor (props) {
super(props);
this.state = {
bio: props.bio
};
this.handleSave = this.handleSave.bind(this);
this.handleInput = this.handleInput.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
handleInput(e) {
this.setState({
bio: e.target.value
});
}
handleSave (e) {
e.preventDefault();
const {userData, saveBio} = this.props;
const {bio} = this.state;
saveBio(userData.id, {bio});
}
handleCancel () {
this.setState({
bio: this.props.bio
});
}
render () {
return <Bio
bio={this.state.bio}
userData={this.props.userData}
handleSave={this.handleSave}
handleInput={this.handleInput}
handleCancel={this.handleCancel}
/>;
}
}
+1
View File
@@ -150,6 +150,7 @@
box-shadow: none;
color: white;
background-color: #616161;
border-color: transparent;
}
> .icon {
+1 -1
View File
@@ -23,6 +23,7 @@
}
.closeButton {
composes: buttonReset from 'coral-framework/styles/reset.css';
position: absolute;
width: 40px;
height: 40px;
@@ -34,7 +35,6 @@
top: 60px;
box-shadow: -1px 3px 4px 0px rgba(0,0,0,0.15);
text-align: center;
padding-top: 10px;
cursor: pointer;
&:hover {
+7 -4
View File
@@ -1,11 +1,12 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import styles from './Drawer.css';
const Drawer = ({children, onClose}) => {
const Drawer = ({children, onClose, className = ''}) => {
return (
<div className={styles.drawer}>
<div className={styles.closeButton} onClick={onClose}>×</div>
<div className={cn(styles.drawer, className)}>
<button className={cn(styles.closeButton, [className, 'close-button'].join('-'))} onClick={onClose}>×</button>
<div className={styles.content}>
{children}
</div>
@@ -15,7 +16,9 @@ const Drawer = ({children, onClose}) => {
Drawer.propTypes = {
active: PropTypes.bool,
onClose: PropTypes.func.isRequired
onClose: PropTypes.func.isRequired,
children: PropTypes.node,
className: PropTypes.string,
};
export default Drawer;
+6 -6
View File
@@ -1,19 +1,19 @@
.dropdown {
position: relative;
width: 150px;
height: 36px;
height: 34px;
background: #2c2c2c;
box-sizing: border-box;
color: white;
border-radius: 2px;
border-radius: 3px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
line-height: 1.4;
font-size: 13px;
font-size: 0.98em;
border-radius: 3px;
}
.toggle {
padding: 10px 15px;
padding: 8px 15px;
cursor: pointer;
outline: none;
@@ -58,5 +58,5 @@
right: 15px;
font-size: 1.2rem;
vertical-align: middle;
top: 13px;
top: 11px;
}
+2 -2
View File
@@ -17,14 +17,14 @@ class Tab extends React.Component {
getRootClassName({active, className, sub, classNames = {}} = this.props) {
return cn(
'talk-tab',
className,
{
[classNames.root || styles.root]: !sub,
[classNames.rootSub || styles.rootSub]: sub,
[classNames.rootActive || styles.rootActive]: active && !sub,
[classNames.rootSubActive || styles.rootSubActive]: active && sub,
'talk-tab-active': active,
}
},
className,
);
}
+2 -2
View File
@@ -13,11 +13,11 @@ class TabBar extends React.Component {
getRootClassName({className, classNames = {}, sub} = this.props) {
return cn(
'talk-tab-bar',
className,
{
[classNames.root || styles.root]: !sub,
[classNames.rootSub || styles.rootSub]: sub,
}
},
className,
);
}
@@ -7,6 +7,7 @@ import {PopupMenu, Button} from 'coral-ui';
import ClickOutside from 'coral-framework/components/ClickOutside';
import cn from 'classnames';
import styles from './styles.css';
import * as REASONS from '../helpers/flagReasons';
import {getErrorMessages} from 'coral-framework/utils';
@@ -90,10 +91,9 @@ export default class FlagButton extends Component {
let action = {
item_id,
item_type: itemType,
reason: null,
message
};
if (reason === 'COMMENT_NOAGREE') {
if (reason === REASONS.comment.noagree) {
postDontAgree(action)
.then(({data}) => {
if (itemType === 'COMMENTS') {
@@ -122,7 +122,7 @@ export default class FlagButton extends Component {
onPopupOptionClick = (sets) => (e) => {
// If flagging a user, indicate that this is referencing the username rather than the bio
if(sets === 'itemType' && e.target.value === 'users') {
if (sets === 'itemType' && e.target.value === 'users') {
this.setState({field: 'username'});
}

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