mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72b661c93 | ||
|
|
0af41a5b6b | ||
|
|
864413b5d6 | ||
|
|
91cd2e9e86 | ||
|
|
e6ef4b615f | ||
|
|
b75557912f | ||
|
|
887fe08819 | ||
|
|
1070dfd5ae | ||
|
|
04cd7312a6 | ||
|
|
b674ff45c0 | ||
|
|
1da61784b4 | ||
|
|
f0bb9db892 | ||
|
|
9d15a0448a | ||
|
|
f09d9d0a8b | ||
|
|
801d18639b | ||
|
|
fcf22ef4f9 | ||
|
|
2e6c7f7b09 | ||
|
|
d829d2541f | ||
|
|
ebc928c6f2 | ||
|
|
55a8b4984a | ||
|
|
087b1a7bd7 | ||
|
|
0096de6e79 | ||
|
|
88d718e7c9 | ||
|
|
c8c160a551 | ||
|
|
5d1fd61425 | ||
|
|
9fbde9c023 | ||
|
|
744fce4563 | ||
|
|
c071484357 | ||
|
|
0a80205095 | ||
|
|
fbc5a6660a | ||
|
|
71647d5233 | ||
|
|
e7587d9a41 | ||
|
|
02ece80227 | ||
|
|
74d78b76a5 | ||
|
|
d4c9bdb6fd | ||
|
|
b8ff2b6763 | ||
|
|
fa985acd14 | ||
|
|
f06edec6a2 | ||
|
|
b6d8a90084 | ||
|
|
3d08d1f54f | ||
|
|
b35336d1ed | ||
|
|
f42b8f324a | ||
|
|
30583cd93f | ||
|
|
2086bd7b72 | ||
|
|
fe62c1a739 | ||
|
|
06331d3c07 | ||
|
|
2ad27ef6f9 | ||
|
|
3cbe8bb7ac | ||
|
|
963fc1f091 | ||
|
|
2e34f726eb | ||
|
|
afb246b173 | ||
|
|
48cc82b039 | ||
|
|
88169526c3 | ||
|
|
4a54c571ed | ||
|
|
853981e21f | ||
|
|
74bef18098 | ||
|
|
64ed10845b | ||
|
|
0b73f18eb3 | ||
|
|
1a065ffa51 | ||
|
|
c4256fd27b | ||
|
|
aa83aea849 | ||
|
|
31abe57cb2 | ||
|
|
000f2c64bb | ||
|
|
34ca36822f | ||
|
|
0035037583 | ||
|
|
693e92a627 | ||
|
|
b4abb9724e | ||
|
|
9764631a37 | ||
|
|
e320a9dd20 | ||
|
|
1f1b1d43ae | ||
|
|
29e69a66bb | ||
|
|
9598c55466 | ||
|
|
ec0dd34d40 | ||
|
|
ad9684acd9 | ||
|
|
91402a3e8b | ||
|
|
edf9ced454 | ||
|
|
7c132600f0 | ||
|
|
b3eb3e3c43 | ||
|
|
d19d2ebe48 | ||
|
|
30b6f8d112 | ||
|
|
14483a39a0 | ||
|
|
c1d3cdce77 | ||
|
|
cebc5f2f45 | ||
|
|
b1848c28e4 | ||
|
|
e993330467 | ||
|
|
e3b183c565 | ||
|
|
373f119ac2 | ||
|
|
eb469df573 | ||
|
|
26b7d48f1f | ||
|
|
79b366f257 | ||
|
|
b6d917a22b | ||
|
|
622afcc80f | ||
|
|
a09c208f58 | ||
|
|
2698045e82 | ||
|
|
5a2d8c5201 | ||
|
|
9616c50d49 | ||
|
|
66b0babdde | ||
|
|
9435b627b1 | ||
|
|
da7349343e | ||
|
|
62ae297887 | ||
|
|
d2acffc19c | ||
|
|
054baa0530 | ||
|
|
fb791a9df9 | ||
|
|
ecfb365c24 | ||
|
|
fa300b1c24 | ||
|
|
b0acdff34d | ||
|
|
6f44b9cba0 | ||
|
|
7c3f566202 | ||
|
|
9edf99730d | ||
|
|
050e98f157 | ||
|
|
5566856542 | ||
|
|
53dd34f406 | ||
|
|
9d7b727f08 | ||
|
|
603854c0be | ||
|
|
ce73118f53 | ||
|
|
2ffecbeeff | ||
|
|
0a5a23c1f3 | ||
|
|
ee9674cd23 | ||
|
|
9dc57c82d1 | ||
|
|
5750758cf1 | ||
|
|
88ed6ffe76 | ||
|
|
aa1f06d1ee | ||
|
|
cda768d694 | ||
|
|
33ec0798e9 | ||
|
|
eeaefd9656 | ||
|
|
3a59a785f8 | ||
|
|
5e9f8eb624 | ||
|
|
d921a59858 | ||
|
|
74ced3b2fd | ||
|
|
6e9757ae91 | ||
|
|
8b8bef4ea7 | ||
|
|
4b33199f2f | ||
|
|
368606befe | ||
|
|
71f219c0c4 | ||
|
|
0e27984518 | ||
|
|
98b3d299e5 | ||
|
|
d977c33cf5 | ||
|
|
a7e9c0c776 | ||
|
|
0994023864 | ||
|
|
8cd929bb1b | ||
|
|
840e0d0ef6 | ||
|
|
90c58885aa | ||
|
|
0d7b482dc1 | ||
|
|
c1fea3151c | ||
|
|
a409fcbab7 | ||
|
|
4e8306bcc9 | ||
|
|
c4e1c1ae44 | ||
|
|
58e50b4242 | ||
|
|
7fd075a7a6 | ||
|
|
75bcf29154 | ||
|
|
33d274386a | ||
|
|
365f60c428 | ||
|
|
f375ff48c9 | ||
|
|
765edb48b9 | ||
|
|
9fbd1d3a77 | ||
|
|
9fd184f638 | ||
|
|
c3f238b0b2 | ||
|
|
fa4c6e8ec5 | ||
|
|
6282c39f3a | ||
|
|
9e22c100f0 | ||
|
|
01f905b79d | ||
|
|
adf4729d89 | ||
|
|
44058b1718 | ||
|
|
da761468b6 | ||
|
|
a8227bd4ad | ||
|
|
d1621ad424 | ||
|
|
b2d9688c89 | ||
|
|
f023d1de08 | ||
|
|
6563f29ccf | ||
|
|
fbd0bf52fe | ||
|
|
0ec2c11cf7 | ||
|
|
32d23214f1 | ||
|
|
0dcc912fd0 | ||
|
|
dfc073ab10 | ||
|
|
d724351fb6 | ||
|
|
9c40ee1245 | ||
|
|
450d74848e | ||
|
|
39bf092ebb | ||
|
|
b98b71189f | ||
|
|
f813a77194 | ||
|
|
715faf321f | ||
|
|
2615db8575 | ||
|
|
ae29d65c75 | ||
|
|
d89f41a041 | ||
|
|
3c540b9daa | ||
|
|
8f95e29315 | ||
|
|
f0f692fc26 | ||
|
|
d14fa33077 | ||
|
|
c16f3b8fbe | ||
|
|
7a5aa0a80c | ||
|
|
cc740a4a5e | ||
|
|
0f15c21cc9 | ||
|
|
89c1fd19b7 | ||
|
|
07b2f9c2e5 | ||
|
|
f286263f1f | ||
|
|
fec18b50d1 | ||
|
|
1db1908778 | ||
|
|
8bad6f7a73 | ||
|
|
e9846c654c | ||
|
|
672d1ee19e | ||
|
|
840c91274d | ||
|
|
03a5acc736 | ||
|
|
d683b09b1d | ||
|
|
b172e30e5b | ||
|
|
2d414a5ab7 | ||
|
|
d34d511ce6 | ||
|
|
a40971e376 | ||
|
|
44f3f99b47 | ||
|
|
d3c215a18d | ||
|
|
bbfd017998 | ||
|
|
58e7abbe0c | ||
|
|
89a188255f | ||
|
|
5f63af8140 | ||
|
|
347fe8bf2b | ||
|
|
e36ecdfb33 | ||
|
|
5b30fd89c8 | ||
|
|
52ed18a9b8 | ||
|
|
f8e55ddc8b | ||
|
|
074630e628 | ||
|
|
b59512d2b8 | ||
|
|
17849898ae | ||
|
|
dc63cc6bce | ||
|
|
0131dd6b13 | ||
|
|
1432cfba4a | ||
|
|
b957e75d68 | ||
|
|
61bc87bafc | ||
|
|
30c7539cde | ||
|
|
e8dc319a9a | ||
|
|
98a6ba5f1f | ||
|
|
9d7810f48c | ||
|
|
f957e3c8ce | ||
|
|
8fa7561e20 | ||
|
|
01d9fc313e | ||
|
|
8601562418 | ||
|
|
a2704e3099 | ||
|
|
9809403701 | ||
|
|
b8fc2c9cc7 | ||
|
|
0f24ab6b28 | ||
|
|
ec020df488 | ||
|
|
d5a82f04c2 | ||
|
|
9e651a9292 | ||
|
|
565e34e09d | ||
|
|
e510f99754 | ||
|
|
a468fe4c28 | ||
|
|
890ab810f3 | ||
|
|
9ba0176b3c | ||
|
|
7a239c02ab | ||
|
|
216b21251d | ||
|
|
c1e3580ef7 | ||
|
|
0a064c0e11 | ||
|
|
3fa0460f34 | ||
|
|
f792aeccd2 | ||
|
|
b517adab56 | ||
|
|
5c5a7f5ba5 | ||
|
|
dd7bdb8a26 | ||
|
|
800382132d | ||
|
|
dff8413404 | ||
|
|
20505acfbe | ||
|
|
845d274b9b |
@@ -1 +1,2 @@
|
||||
node_modules
|
||||
test
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
"no-template-curly-in-string": [1],
|
||||
"no-unsafe-negation": [1],
|
||||
"array-callback-return": [1],
|
||||
"eqeqeq": [2],
|
||||
"eqeqeq": [2, "smart"],
|
||||
"no-eval": [2],
|
||||
"no-global-assign": [2],
|
||||
"no-implied-eval": [2],
|
||||
|
||||
+2
-2
@@ -3,13 +3,13 @@ npm-debug.log*
|
||||
dist
|
||||
!dist/coral-admin
|
||||
dist/coral-admin/bundle.js
|
||||
tests/e2e/reports
|
||||
test/e2e/reports
|
||||
.DS_Store
|
||||
*.iml
|
||||
*.swp
|
||||
dump.rdb
|
||||
.env
|
||||
gaba.cfg
|
||||
*.cfg
|
||||
.idea/
|
||||
coverage/
|
||||
yarn.lock
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"verbose": true,
|
||||
"ignore": ["tests/*", "client/*", "dist/*"]
|
||||
"ignore": ["test/*", "client/*", "dist/*"],
|
||||
"ext": "js,json,graphql"
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- 4
|
||||
@@ -10,6 +10,8 @@ const RedisStore = require('connect-redis')(session);
|
||||
const redis = require('./services/redis');
|
||||
const csrf = require('csurf');
|
||||
const errors = require('./errors');
|
||||
const graph = require('./graph');
|
||||
const apollo = require('graphql-server-express');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -49,7 +51,7 @@ const session_opts = {
|
||||
name: 'talk.sid',
|
||||
cookie: {
|
||||
secure: false,
|
||||
maxAge: 18000000, // 30 minutes for expiry.
|
||||
maxAge: 36000000, // 1 hour for expiry.
|
||||
},
|
||||
store: new RedisStore({
|
||||
ttl: 1800,
|
||||
@@ -77,6 +79,23 @@ app.use(session(session_opts));
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
//==============================================================================
|
||||
// GraphQL Router
|
||||
//==============================================================================
|
||||
|
||||
// GraphQL endpoint.
|
||||
app.use('/api/v1/graph/ql', apollo.graphqlExpress(graph.createGraphOptions));
|
||||
|
||||
// Only include the graphiql tool if we aren't in production mode.
|
||||
if (app.get('env') !== 'production') {
|
||||
|
||||
// Interactive graphiql interface.
|
||||
app.use('/api/v1/graph/iql', apollo.graphiqlExpress({
|
||||
endpointURL: '/api/v1/graph/ql'
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// CSRF MIDDLEWARE
|
||||
//==============================================================================
|
||||
|
||||
@@ -4,38 +4,13 @@
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('commander');
|
||||
const pkg = require('../package.json');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.version(pkg.version)
|
||||
.option('-c, --config [path]', 'Specify the configuration file to load')
|
||||
.parse(process.argv);
|
||||
|
||||
if (program.config) {
|
||||
let r = dotenv.config({
|
||||
path: program.config
|
||||
});
|
||||
|
||||
if (r.error) {
|
||||
throw r.error;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform rewrites to the runtime environment variables based on the contents
|
||||
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
|
||||
// entrypoint for the entire application.
|
||||
require('env-rewrite').rewrite();
|
||||
const util = require('../util');
|
||||
const program = require('./commander');
|
||||
|
||||
program
|
||||
.command('serve', 'serve the application')
|
||||
.command('assets', 'interact with assets')
|
||||
.command('settings', 'work with the application settings')
|
||||
.command('setup', 'setup the application')
|
||||
.command('jobs', 'work with the job queues')
|
||||
.command('users', 'work with the application auth')
|
||||
.parse(process.argv);
|
||||
@@ -43,4 +18,15 @@ program
|
||||
// If there is no command listed, output help.
|
||||
if (!process.argv.slice(2).length) {
|
||||
program.outputHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// The ensures that the child process that is created here is always cleaned up
|
||||
// properly when the parent process dies.
|
||||
util.onshutdown([
|
||||
(signal) => {
|
||||
if ((program.runningCommand.killed === false) && (program.runningCommand.exitCode === null)) {
|
||||
program.runningCommand.kill(signal);
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
+4
-8
@@ -4,11 +4,10 @@
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('commander');
|
||||
const pkg = require('../package.json');
|
||||
const program = require('./commander');
|
||||
const parseDuration = require('parse-duration');
|
||||
const Table = require('cli-table');
|
||||
const Asset = require('../models/asset');
|
||||
const AssetModel = require('../models/asset');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const scraper = require('../services/scraper');
|
||||
const util = require('../util');
|
||||
@@ -22,7 +21,7 @@ util.onshutdown([
|
||||
* Lists all the assets registered in the database.
|
||||
*/
|
||||
function listAssets() {
|
||||
Asset
|
||||
AssetModel
|
||||
.find({})
|
||||
.sort({'created_at': 1})
|
||||
.then((asset) => {
|
||||
@@ -56,7 +55,7 @@ function refreshAssets(ageString) {
|
||||
const ageMs = parseDuration(ageString);
|
||||
const age = new Date(now - ageMs);
|
||||
|
||||
Asset.find({
|
||||
AssetModel.find({
|
||||
$or: [
|
||||
{
|
||||
scraped: {
|
||||
@@ -86,9 +85,6 @@ function refreshAssets(ageString) {
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.version(pkg.version);
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.description('list all the assets in the database')
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('commander');
|
||||
const program = require('./commander');
|
||||
const scraper = require('../services/scraper');
|
||||
const mailer = require('../services/mailer');
|
||||
const util = require('../util');
|
||||
|
||||
+7
-19
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const app = require('../app');
|
||||
const program = require('./commander');
|
||||
const debug = require('debug')('talk:server');
|
||||
const http = require('http');
|
||||
const init = require('../init');
|
||||
const scraper = require('../services/scraper');
|
||||
const mailer = require('../services/mailer');
|
||||
const kue = require('../services/kue');
|
||||
@@ -86,27 +86,15 @@ function onListening() {
|
||||
* Start the app.
|
||||
*/
|
||||
function startApp() {
|
||||
init().then(() => {
|
||||
|
||||
/**
|
||||
* Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
server.listen(port);
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
});
|
||||
/**
|
||||
* Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
server.listen(port);
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
}
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('commander');
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('commander');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const Setting = require('../models/setting');
|
||||
const util = require('../util');
|
||||
|
||||
// Register the shutdown criteria.
|
||||
util.onshutdown([
|
||||
() => mongoose.disconnect()
|
||||
]);
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.command('init')
|
||||
.description('initilizes the talk settings')
|
||||
.action(() => {
|
||||
const defaults = {id: '1', moderation: 'pre'};
|
||||
|
||||
Setting
|
||||
.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true})
|
||||
.then(() => {
|
||||
console.log('Created settings object.');
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`failed to create the settings object ${JSON.stringify(err)}`);
|
||||
util.shutdown(1);
|
||||
});
|
||||
});
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
// If there is no command listed, output help.
|
||||
if (!process.argv.slice(2).length) {
|
||||
program.outputHelp();
|
||||
util.shutdown();
|
||||
}
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('./commander');
|
||||
const inquirer = require('inquirer');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const SettingModel = require('../models/setting');
|
||||
const SettingsService = require('../services/settings');
|
||||
const util = require('../util');
|
||||
|
||||
// Register the shutdown criteria.
|
||||
util.onshutdown([
|
||||
() => mongoose.disconnect()
|
||||
]);
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.description('runs the setup wizard to setup the application')
|
||||
.option('--defaults', 'apply defaults for config instead of prompting')
|
||||
.parse(process.argv);
|
||||
|
||||
//==============================================================================
|
||||
// Setup the application
|
||||
//==============================================================================
|
||||
|
||||
SettingsService
|
||||
.init()
|
||||
.then((settings) => {
|
||||
if (program.defaults) {
|
||||
return settings.save();
|
||||
}
|
||||
|
||||
console.log('We\'ll ask you some questions in order to setup your installation of Talk.\n');
|
||||
|
||||
return inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'organizationName',
|
||||
message: 'Organization Name',
|
||||
default: settings.organizationName,
|
||||
validate: (input) => {
|
||||
if (input && input.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Organization Name is required.';
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
choices: SettingModel.MODERATION_OPTIONS,
|
||||
name: 'moderation',
|
||||
default: settings.moderation,
|
||||
message: 'Select a moderation mode'
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'requireEmailConfirmation',
|
||||
default: settings.requireEmailConfirmation,
|
||||
message: 'Should emails always be confirmed'
|
||||
}
|
||||
])
|
||||
.then((answers) => {
|
||||
|
||||
// Update the settings that were changed.
|
||||
Object.keys(answers).forEach((key) => {
|
||||
if (answers[key] !== undefined) {
|
||||
settings[key] = answers[key];
|
||||
}
|
||||
});
|
||||
|
||||
return settings.save();
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Talk is now installed!');
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
});
|
||||
+152
-117
@@ -4,105 +4,139 @@
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('commander');
|
||||
const pkg = require('../package.json');
|
||||
const prompt = require('prompt');
|
||||
const User = require('../models/user');
|
||||
const program = require('./commander');
|
||||
const inquirer = require('inquirer');
|
||||
const UsersService = require('../services/users');
|
||||
const UserModel = require('../models/user');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const util = require('../util');
|
||||
const Table = require('cli-table');
|
||||
|
||||
const validateRequired = (msg = 'Field is required', len = 1) => (input) => {
|
||||
if (input && input.length >= len) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return msg;
|
||||
};
|
||||
|
||||
// Regeister 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,
|
||||
displayName: options.name,
|
||||
roles: []
|
||||
};
|
||||
|
||||
if (options.role && UserModel.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: 'displayName',
|
||||
message: 'Display Name',
|
||||
filter: (displayName) => {
|
||||
return UsersService
|
||||
.isValidDisplayName(displayName)
|
||||
.catch((err) => {
|
||||
throw err.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'roles',
|
||||
message: 'User Role',
|
||||
type: 'checkbox',
|
||||
choices: UserModel.USER_ROLES
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts for input and registers a user based on those.
|
||||
*/
|
||||
function createUser(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
if (options.flag_mode) {
|
||||
return resolve({
|
||||
email: options.email,
|
||||
password: options.password,
|
||||
displayName: options.name,
|
||||
role: options.role
|
||||
});
|
||||
}
|
||||
|
||||
prompt.start();
|
||||
|
||||
prompt.get([
|
||||
{
|
||||
name: 'email',
|
||||
description: 'Email',
|
||||
format: 'email',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
description: 'Password',
|
||||
hidden: true,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'confirmPassword',
|
||||
description: 'Confirm Password',
|
||||
hidden: true,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'displayName',
|
||||
description: 'Display Name',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
description: 'User Role',
|
||||
required: false
|
||||
}
|
||||
], (err, result) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
getUserCreateAnswers(options)
|
||||
.then((answers) => {
|
||||
if (answers.password !== answers.confirmPassword) {
|
||||
return Promise.reject(new Error('Passwords do not match'));
|
||||
}
|
||||
|
||||
if (result.password !== result.confirmPassword) {
|
||||
return reject(new Error('Passwords do not match'));
|
||||
}
|
||||
return answers;
|
||||
})
|
||||
.then((answers) => {
|
||||
return UsersService
|
||||
.createLocalUser(answers.email.trim(), answers.password.trim(), answers.displayName.trim())
|
||||
.then((user) => {
|
||||
console.log(`Created user ${user.id}.`);
|
||||
|
||||
resolve(result);
|
||||
});
|
||||
})
|
||||
.then((result) => {
|
||||
return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim())
|
||||
.then((user) => {
|
||||
console.log(`Created user ${user.id}.`);
|
||||
|
||||
if (result.role && result.role.length > 0) {
|
||||
return User
|
||||
.addRoleToUser(user.id, result.role.trim())
|
||||
.then(() => {
|
||||
console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`);
|
||||
util.shutdown();
|
||||
});
|
||||
} else {
|
||||
util.shutdown();
|
||||
}
|
||||
if (answers.roles.length > 0) {
|
||||
return Promise.all(answers.roles.map((role) => {
|
||||
return UsersService
|
||||
.addRoleToUser(user.id, role)
|
||||
.then(() => {
|
||||
console.log(`Added the role ${role} to User ${user.id}.`);
|
||||
});
|
||||
}));
|
||||
}
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user.
|
||||
*/
|
||||
function deleteUser(userID) {
|
||||
User
|
||||
UserModel
|
||||
.findOneAndRemove({
|
||||
id: userID
|
||||
})
|
||||
@@ -120,44 +154,34 @@ function deleteUser(userID) {
|
||||
* Changes the password for a user.
|
||||
*/
|
||||
function passwd(userID) {
|
||||
prompt.start();
|
||||
|
||||
prompt.get([
|
||||
inquirer.prompt([
|
||||
{
|
||||
name: 'password',
|
||||
description: 'Password',
|
||||
hidden: true,
|
||||
required: true
|
||||
message: 'Password',
|
||||
type: 'password',
|
||||
validate: validateRequired('Password is required')
|
||||
},
|
||||
{
|
||||
name: 'confirmPassword',
|
||||
description: 'Confirm Password',
|
||||
hidden: true,
|
||||
required: true
|
||||
message: 'Confirm Password',
|
||||
type: 'password',
|
||||
validate: validateRequired('Confirm Password is required')
|
||||
}
|
||||
], (err, result) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
util.shutdown();
|
||||
return;
|
||||
])
|
||||
.then((answers) => {
|
||||
if (answers.password !== answers.confirmPassword) {
|
||||
return Promise.reject(new Error('Password mismatch'));
|
||||
}
|
||||
|
||||
if (result.password !== result.confirmPassword) {
|
||||
console.error(new Error('Password mismatch'));
|
||||
util.shutdown(1);
|
||||
return;
|
||||
}
|
||||
|
||||
User
|
||||
.changePassword(userID, result.password)
|
||||
.then(() => {
|
||||
console.log('Password changed.');
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
});
|
||||
return UsersService.changePassword(userID, answers.password);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Password changed.');
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,7 +192,7 @@ function updateUser(userID, options) {
|
||||
const updates = [];
|
||||
|
||||
if (options.email && typeof options.email === 'string' && options.email.length > 0) {
|
||||
let q = User.update({
|
||||
let q = UserModel.update({
|
||||
'id': userID,
|
||||
'profiles.provider': 'local'
|
||||
}, {
|
||||
@@ -181,7 +205,7 @@ function updateUser(userID, options) {
|
||||
}
|
||||
|
||||
if (options.name && typeof options.name === 'string' && options.name.length > 0) {
|
||||
let q = User.update({
|
||||
let q = UserModel.update({
|
||||
'id': userID
|
||||
}, {
|
||||
$set: {
|
||||
@@ -208,7 +232,7 @@ function updateUser(userID, options) {
|
||||
* Lists all the users registered in the database.
|
||||
*/
|
||||
function listUsers() {
|
||||
User
|
||||
UsersService
|
||||
.all()
|
||||
.then((users) => {
|
||||
let table = new Table({
|
||||
@@ -248,7 +272,7 @@ function listUsers() {
|
||||
* @param {String} srcUserID id of the user to which is the source of the merge
|
||||
*/
|
||||
function mergeUsers(dstUserID, srcUserID) {
|
||||
User
|
||||
UsersService
|
||||
.mergeUsers(dstUserID, srcUserID)
|
||||
.then(() => {
|
||||
console.log(`User ${srcUserID} was merged into user ${dstUserID}.`);
|
||||
@@ -266,7 +290,14 @@ function mergeUsers(dstUserID, srcUserID) {
|
||||
* @param {String} role the role to add
|
||||
*/
|
||||
function addRole(userID, role) {
|
||||
User
|
||||
|
||||
if (UserModel.USER_ROLES.indexOf(role) === -1) {
|
||||
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
|
||||
util.shutdown(1);
|
||||
return;
|
||||
}
|
||||
|
||||
UsersService
|
||||
.addRoleToUser(userID, role)
|
||||
.then(() => {
|
||||
console.log(`Added the ${role} role to User ${userID}.`);
|
||||
@@ -284,7 +315,14 @@ function addRole(userID, role) {
|
||||
* @param {String} role the role to remove
|
||||
*/
|
||||
function removeRole(userID, role) {
|
||||
User
|
||||
|
||||
if (UserModel.USER_ROLES.indexOf(role) === -1) {
|
||||
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
|
||||
util.shutdown(1);
|
||||
return;
|
||||
}
|
||||
|
||||
UsersService
|
||||
.removeRoleFromUser(userID, role)
|
||||
.then(() => {
|
||||
console.log(`Removed the ${role} role from User ${userID}.`);
|
||||
@@ -301,8 +339,8 @@ function removeRole(userID, role) {
|
||||
* @param {String} userID id of the user to ban
|
||||
*/
|
||||
function ban(userID) {
|
||||
User
|
||||
.setStatus(userID, 'banned', '')
|
||||
UsersService
|
||||
.setStatus(userID, 'BANNED')
|
||||
.then(() => {
|
||||
console.log(`Banned the User ${userID}.`);
|
||||
util.shutdown();
|
||||
@@ -318,8 +356,8 @@ function ban(userID) {
|
||||
* @param {String} userUD id of the user to remove the role from
|
||||
*/
|
||||
function unban(userID) {
|
||||
User
|
||||
.setStatus(userID, 'active', '')
|
||||
UsersService
|
||||
.setStatus(userID, 'ACTIVE')
|
||||
.then(() => {
|
||||
console.log(`Unban the User ${userID}.`);
|
||||
util.shutdown();
|
||||
@@ -335,7 +373,7 @@ function unban(userID) {
|
||||
* @param {String} userID the ID of a user to disable
|
||||
*/
|
||||
function disableUser(userID) {
|
||||
User
|
||||
UsersService
|
||||
.disableUser(userID)
|
||||
.then(() => {
|
||||
console.log(`User ${userID} was disabled.`);
|
||||
@@ -352,7 +390,7 @@ function disableUser(userID) {
|
||||
* @param {String} userID the ID of a user to enable
|
||||
*/
|
||||
function enableUser(userID) {
|
||||
User
|
||||
UsersService
|
||||
.enableUser(userID)
|
||||
.then(() => {
|
||||
console.log(`User ${userID} was enabled.`);
|
||||
@@ -368,9 +406,6 @@ function enableUser(userID) {
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.version(pkg.version);
|
||||
|
||||
program
|
||||
.command('create')
|
||||
.option('--email [email]', 'Email to use')
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const pkg = require('../package.json');
|
||||
const dotenv = require('dotenv');
|
||||
const fs = require('fs');
|
||||
const program = require('commander');
|
||||
const util = require('../util');
|
||||
|
||||
// Perform rewrites to the runtime environment variables based on the contents
|
||||
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
|
||||
// entrypoint for the entire application.
|
||||
require('env-rewrite').rewrite();
|
||||
|
||||
//==============================================================================
|
||||
// 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 (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 used, put the current pid there.
|
||||
if (parseArgs.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');
|
||||
+17
-3
@@ -3,12 +3,26 @@ machine:
|
||||
version: 7
|
||||
services:
|
||||
- docker
|
||||
- redis
|
||||
|
||||
dependencies:
|
||||
post:
|
||||
# Lint the project here, before tests are ran.
|
||||
- npm run lint
|
||||
|
||||
database:
|
||||
post:
|
||||
# Initialize the settings in the database, this will create indicies for the
|
||||
# database.
|
||||
- ./bin/cli setup --defaults
|
||||
- sleep 2
|
||||
|
||||
test:
|
||||
override:
|
||||
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml ./node_modules/.bin/mocha tests --reporter mocha-junit-reporter
|
||||
- npm run lint
|
||||
- npm run build
|
||||
# Run the tests using the junit reporter.
|
||||
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml NPM_PACKAGE_CONFIG_MOCHA_REPORTER=mocha-junit-reporter npm run test
|
||||
# Run the e2e test suite
|
||||
- E2E_REPORT_PATH=$CIRCLE_TEST_REPORTS/e2e npm run e2e
|
||||
|
||||
deployment:
|
||||
release:
|
||||
|
||||
@@ -11,10 +11,13 @@ export const checkLogin = () => dispatch => {
|
||||
dispatch(checkLoginRequest());
|
||||
coralApi('/auth')
|
||||
.then(result => {
|
||||
const isAdmin = !!result.user.roles.filter(i => i === 'admin').length;
|
||||
const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length;
|
||||
dispatch(checkLoginSuccess(result.user, isAdmin));
|
||||
})
|
||||
.catch(error => dispatch(checkLoginFailure(error)));
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
dispatch(checkLoginFailure(`${error.message}`));
|
||||
});
|
||||
};
|
||||
|
||||
// LogOut Actions
|
||||
|
||||
@@ -15,17 +15,18 @@ export const fetchModerationQueueComments = () => {
|
||||
|
||||
return Promise.all([
|
||||
coralApi('/queue/comments/pending'),
|
||||
coralApi('/queue/users/pending'),
|
||||
coralApi('/queue/comments/rejected'),
|
||||
coralApi('/queue/comments/flagged')
|
||||
])
|
||||
.then(([pending, rejected, flagged]) => {
|
||||
.then(([pendingComments, pendingUsers, rejected, flagged]) => {
|
||||
|
||||
/* Combine seperate calls into a single object */
|
||||
flagged.comments.forEach(comment => comment.flagged = true);
|
||||
return {
|
||||
comments: [...pending.comments, ...rejected.comments, ...flagged.comments],
|
||||
users: [...pending.users, ...rejected.users, ...flagged.users],
|
||||
actions: [...pending.actions, ...rejected.actions, ...flagged.actions]
|
||||
comments: [...pendingComments.comments, ...rejected.comments, ...flagged.comments],
|
||||
users: [...pendingComments.users, ...pendingUsers.users, ...rejected.users, ...flagged.users],
|
||||
actions: [...pendingComments.actions, ...pendingUsers.actions, ...rejected.actions, ...flagged.actions]
|
||||
};
|
||||
})
|
||||
.then(addUsersCommentsActions.bind(this, dispatch));
|
||||
@@ -41,6 +42,15 @@ export const fetchPendingQueue = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchPendingUsersQueue = () => {
|
||||
return dispatch => {
|
||||
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
|
||||
|
||||
return coralApi('/queue/users/pending')
|
||||
.then(addUsersCommentsActions.bind(this, dispatch));
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchRejectedQueue = () => {
|
||||
return dispatch => {
|
||||
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import coralApi from '../../../coral-framework/helpers/response';
|
||||
import * as actions from '../constants/user';
|
||||
import * as userTypes from '../constants/users';
|
||||
|
||||
/**
|
||||
* Action disptacher related to users
|
||||
@@ -7,9 +7,17 @@ import * as actions from '../constants/user';
|
||||
// change status of a user
|
||||
export const userStatusUpdate = (status, userId, commentId) => {
|
||||
return (dispatch) => {
|
||||
dispatch({type: actions.UPDATE_STATUS_REQUEST});
|
||||
dispatch({type: userTypes.UPDATE_STATUS_REQUEST});
|
||||
return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
|
||||
.then(res => dispatch({type: actions.UPDATE_STATUS_SUCCESS, res}))
|
||||
.catch(error => dispatch({type: actions.UPDATE_STATUS_FAILURE, error}));
|
||||
.then(res => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res}))
|
||||
.catch(error => dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error}));
|
||||
};
|
||||
};
|
||||
|
||||
// change status of a user
|
||||
export const sendNotificationEmail = (userId, subject, body) => {
|
||||
return (dispatch) => {
|
||||
return coralApi(`/users/${userId}/email`, {method: 'POST', body: {subject, body}})
|
||||
.catch(error => dispatch({type: userTypes.USER_EMAIL_FAILURE, error}));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import styles from './ModerationList.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
import {FabButton, Button, Icon} from 'coral-ui';
|
||||
|
||||
const ActionButton = ({option, type, comment = {}, user, menuOptionsMap, onClickAction, onClickShowBanDialog}) =>
|
||||
{
|
||||
const banned = user.status === 'BANNED';
|
||||
|
||||
if (option === 'flag' && (type === 'USERS' || comment.status || comment.flagged === true)) {
|
||||
return null;
|
||||
}
|
||||
if (option === 'ban') {
|
||||
return (
|
||||
<div className={styles.ban}>
|
||||
<Button
|
||||
className={`ban ${styles.banButton}`}
|
||||
cStyle='darkGrey'
|
||||
disabled={banned ? 'disabled' : ''}
|
||||
onClick={() => onClickShowBanDialog(user.id, user.displayName, comment.id)
|
||||
}
|
||||
raised
|
||||
>
|
||||
<Icon name='not_interested' className={styles.banIcon} />
|
||||
{lang.t('comment.ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const menuOption = menuOptionsMap[option];
|
||||
const action = {
|
||||
item_type: type,
|
||||
item_id: type === 'COMMENTS' ? comment.id : user.id
|
||||
};
|
||||
return (
|
||||
<FabButton
|
||||
className={`${option} ${styles.actionButton}`}
|
||||
cStyle={option}
|
||||
icon={menuOption.icon}
|
||||
onClick={() => onClickAction(menuOption.status, type === 'COMMENTS' ? comment : user, action)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionButton;
|
||||
|
||||
const lang = new I18n(translations);
|
||||
@@ -35,7 +35,7 @@ const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => (
|
||||
<Button cStyle="cancel" className={styles.cancel} onClick={() => handleClose()} raised>
|
||||
{lang.t('bandialog.cancel')}
|
||||
</Button>
|
||||
<Button cStyle="black" className={styles.ban} onClick={() => onClickBanUser(user.userId, user.commentId)} raised>
|
||||
<Button cStyle="black" className={styles.ban} onClick={() => onClickBanUser('BANNED', user.userId, user.commentId)} raised>
|
||||
{lang.t('bandialog.yes_ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,18 +2,19 @@ import React from 'react';
|
||||
import timeago from 'timeago.js';
|
||||
import Linkify from 'react-linkify';
|
||||
|
||||
import styles from './CommentList.css';
|
||||
import styles from './ModerationList.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import {FabButton, Button, Icon} from 'coral-ui';
|
||||
import {Icon} from 'coral-ui';
|
||||
import ActionButton from './ActionButton';
|
||||
|
||||
const linkify = new Linkify();
|
||||
|
||||
// Render a single comment for the list
|
||||
export default props => {
|
||||
const Comment = props => {
|
||||
const {comment, author} = props;
|
||||
let authorStatus = author.status;
|
||||
const links = linkify.getMatches(comment.body);
|
||||
@@ -30,7 +31,18 @@ export default props => {
|
||||
{links ?
|
||||
<span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{props.modActions.map((action, i) => getActionButton(action, i, props))}
|
||||
{props.modActions.map(
|
||||
(action, i) =>
|
||||
<ActionButton
|
||||
option={action}
|
||||
key={i}
|
||||
type='COMMENTS'
|
||||
comment={comment}
|
||||
user={author}
|
||||
menuOptionsMap={props.menuOptionsMap}
|
||||
onClickAction={props.onClickAction}
|
||||
onClickShowBanDialog={props.onClickShowBanDialog}/>
|
||||
)}
|
||||
</div>
|
||||
{authorStatus === 'banned' ?
|
||||
<span className={styles.banned}><Icon name='error_outline'/> {lang.t('comment.banned_user')}</span> : null}
|
||||
@@ -49,43 +61,7 @@ export default props => {
|
||||
);
|
||||
};
|
||||
|
||||
// Get the button of the action performed over a comment if any
|
||||
const getActionButton = (action, i, props) => {
|
||||
const {comment, author} = props;
|
||||
const status = comment.status;
|
||||
const flagged = comment.flagged;
|
||||
const banned = (author.status === 'banned');
|
||||
|
||||
if (action === 'flag' && (status || flagged === true)) {
|
||||
return null;
|
||||
}
|
||||
if (action === 'ban') {
|
||||
return (
|
||||
<div className={styles.ban}>
|
||||
<Button
|
||||
className={`ban ${styles.banButton}`}
|
||||
cStyle='darkGrey'
|
||||
disabled={banned ? 'disabled' : ''}
|
||||
onClick={() => props.onClickShowBanDialog(author.id, author.displayName, comment.id)}
|
||||
key={i}
|
||||
raised
|
||||
>
|
||||
<Icon name='not_interested' className={styles.banIcon} />
|
||||
{lang.t('comment.ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FabButton
|
||||
className={`${action} ${styles.actionButton}`}
|
||||
cStyle={action}
|
||||
icon={props.actionsMap[action].icon}
|
||||
key={i}
|
||||
onClick={() => props.onClickAction(props.actionsMap[action].status, comment)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default Comment;
|
||||
|
||||
const linkStyles = {
|
||||
backgroundColor: 'rgb(255, 219, 135)',
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './CommentList.css';
|
||||
import key from 'keymaster';
|
||||
import Hammer from 'hammerjs';
|
||||
import Comment from 'components/Comment';
|
||||
|
||||
// Each action has different meaning and configuration
|
||||
const modActions = {
|
||||
'reject': {status: 'rejected', icon: 'close', key: 'r'},
|
||||
'approve': {status: 'accepted', icon: 'done', key: 't'},
|
||||
'flag': {status: 'flagged', icon: 'flag', filter: 'Untouched'},
|
||||
'ban': {status: 'banned', icon: 'not interested'}
|
||||
};
|
||||
|
||||
// Renders a comment list and allow performing actions
|
||||
export default class CommentList extends React.Component {
|
||||
static propTypes = {
|
||||
isActive: PropTypes.bool,
|
||||
singleView: PropTypes.bool,
|
||||
commentIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
comments: PropTypes.object.isRequired,
|
||||
users: PropTypes.object.isRequired,
|
||||
onClickAction: PropTypes.func,
|
||||
|
||||
// list of actions (flags, etc) associated with the comments
|
||||
modActions: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
loading: PropTypes.bool,
|
||||
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.state = {active: null};
|
||||
this.onClickAction = this.onClickAction.bind(this);
|
||||
this.onClickShowBanDialog = this.onClickShowBanDialog.bind(this);
|
||||
}
|
||||
|
||||
// remove key handlers before leaving
|
||||
componentWillUnmount () {
|
||||
this.unbindKeyHandlers();
|
||||
}
|
||||
|
||||
// add key handlers and gestures
|
||||
componentDidMount () {
|
||||
this.bindKeyHandlers();
|
||||
|
||||
// this.bindGestures() // need to check whether we're on a mobile device or this throws an Error
|
||||
}
|
||||
|
||||
// If entering to singleview and no active, active is the first eleement
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.singleView && !this.state.active) {
|
||||
this.setState({active: nextProps.commentIds[0]});
|
||||
}
|
||||
}
|
||||
|
||||
// Add swipe to approve or reject
|
||||
bindGestures () {
|
||||
const {modActions} = this.props;
|
||||
this._hammer = new Hammer(this.base);
|
||||
this._hammer.get('swipe').set({direction: Hammer.DIRECTION_HORIZONTAL});
|
||||
|
||||
if (modActions.indexOf('reject') !== -1) {
|
||||
this._hammer.on('swipeleft', () => this.props.singleView && this.actionKeyHandler('Rejected'));
|
||||
}
|
||||
if (modActions.indexOf('approve') !== -1) {
|
||||
this._hammer.on('swiperight', () => this.props.singleView && this.actionKeyHandler('Approved'));
|
||||
}
|
||||
}
|
||||
|
||||
// Add key handlers. Each action has one and added j/k for moving around
|
||||
bindKeyHandlers () {
|
||||
this.props.modActions.filter(action => modActions[action].key).forEach(action => {
|
||||
key(modActions[action].key, 'commentList', () => this.props.isActive && this.actionKeyHandler(modActions[action].status));
|
||||
});
|
||||
key('j', 'commentList', () => this.props.isActive && this.moveKeyHandler('down'));
|
||||
key('k', 'commentList', () => this.props.isActive && this.moveKeyHandler('up'));
|
||||
key.setScope('commentList');
|
||||
}
|
||||
|
||||
// Perform an action using the keys only if the comment is active
|
||||
actionKeyHandler (action) {
|
||||
if (this.props.isActive && this.state.active) {
|
||||
this.onClickAction(action, this.state.active);
|
||||
}
|
||||
}
|
||||
|
||||
// move around with j/k
|
||||
moveKeyHandler (direction) {
|
||||
if (!this.props.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {commentIds} = this.props;
|
||||
const {active} = this.state;
|
||||
|
||||
// check boundaries
|
||||
if (active === null || !commentIds.length) {
|
||||
this.setState({active: commentIds[0]});
|
||||
} else if (direction === 'up' && active !== commentIds[0]) {
|
||||
this.setState({active: commentIds[commentIds.indexOf(active) - 1]});
|
||||
} else if (direction === 'down' && active !== commentIds[commentIds.length - 1]) {
|
||||
this.setState({active: commentIds[commentIds.indexOf(active) + 1]});
|
||||
}
|
||||
|
||||
// scroll to the position
|
||||
const index = Math.max(commentIds.indexOf(this.state.active), 0);
|
||||
this.base.childNodes[index] && this.base.childNodes[index].focus();
|
||||
}
|
||||
|
||||
unbindKeyHandlers () {
|
||||
key.deleteScope('commentList');
|
||||
}
|
||||
|
||||
// If we are performing an action over a comment (aka removing from the list) we need to select a new active.
|
||||
// TODO: In the future this can be improved and look at the actual state to
|
||||
// resolve since the content of the list could change externally. For now it works as expected
|
||||
onClickAction (action, id, author_id) {
|
||||
|
||||
// activate the next comment
|
||||
if (id === this.state.active) {
|
||||
const {commentIds} = this.props;
|
||||
if (commentIds[commentIds.length - 1] === this.state.active) {
|
||||
this.setState({active: commentIds[commentIds.length - 2]});
|
||||
} else {
|
||||
this.setState({active: commentIds[Math.min(commentIds.indexOf(this.state.active) + 1, commentIds.length - 1)]});
|
||||
}
|
||||
}
|
||||
this.props.onClickAction(action, id, author_id);
|
||||
}
|
||||
|
||||
onClickShowBanDialog(userId, userName, commentId) {
|
||||
this.props.onClickShowBanDialog(userId, userName, commentId);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {singleView, commentIds, comments, users, hideActive, key, suspectWords} = this.props;
|
||||
const {active} = this.state;
|
||||
|
||||
return (
|
||||
<ul
|
||||
className={`${styles.list} ${singleView ? styles.singleView : ''}`} {...key}
|
||||
id='commentList'
|
||||
>
|
||||
{commentIds.map((commentId, index) => {
|
||||
const comment = comments[commentId];
|
||||
const author = users[comment.author_id];
|
||||
return <Comment
|
||||
suspectWords={suspectWords}
|
||||
comment={comment}
|
||||
author={author}
|
||||
key={index}
|
||||
index={index}
|
||||
onClickAction={this.onClickAction}
|
||||
onClickShowBanDialog={this.onClickShowBanDialog}
|
||||
modActions={this.props.modActions}
|
||||
actionsMap={modActions}
|
||||
isActive={commentId === active}
|
||||
hideActive={hideActive} />;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
.inner {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: #fff;
|
||||
box-shadow: 2px 2px 5px 0px rgba(153,153,153,1);
|
||||
padding: 20px;
|
||||
@@ -37,7 +36,6 @@
|
||||
@media (--big-viewport) {
|
||||
.inner {
|
||||
width: 600px;
|
||||
height: 50vh;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -69,6 +69,7 @@
|
||||
justify-content: space-between;
|
||||
|
||||
.author {
|
||||
min-width: 230px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -112,6 +113,12 @@
|
||||
padding-top: 15px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.flagCount{
|
||||
font-size: 12px;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -0,0 +1,227 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './ModerationList.css';
|
||||
import key from 'keymaster';
|
||||
import Hammer from 'hammerjs';
|
||||
import Comment from './Comment';
|
||||
import UserAction from './UserAction';
|
||||
import SuspendUserModal from './SuspendUserModal';
|
||||
|
||||
// Each action has different meaning and configuration
|
||||
const menuOptionsMap = {
|
||||
'reject': {status: 'REJECTED', icon: 'close', key: 'r'},
|
||||
'approve': {status: 'ACCEPTED', icon: 'done', key: 't'},
|
||||
'flag': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'},
|
||||
'ban': {status: 'BANNED', icon: 'not interested'}
|
||||
};
|
||||
|
||||
// Renders a comment list and allow performing actions
|
||||
export default class ModerationList extends React.Component {
|
||||
static propTypes = {
|
||||
isActive: PropTypes.bool,
|
||||
singleView: PropTypes.bool,
|
||||
commentIds: PropTypes.arrayOf(PropTypes.string),
|
||||
actionIds: PropTypes.arrayOf(PropTypes.string),
|
||||
comments: PropTypes.object,
|
||||
users: PropTypes.object.isRequired,
|
||||
actions: PropTypes.object,
|
||||
userStatusUpdate: PropTypes.func.isRequired,
|
||||
suspendUser: PropTypes.func.isRequired,
|
||||
|
||||
// list of actions (flags, etc) associated with the comments
|
||||
modActions: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
loading: PropTypes.bool,
|
||||
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired
|
||||
}
|
||||
|
||||
state = {active: null, suspendUserModal: null, email: null};
|
||||
|
||||
// remove key handlers before leaving
|
||||
componentWillUnmount () {
|
||||
this.unbindKeyHandlers();
|
||||
}
|
||||
|
||||
// add key handlers and gestures
|
||||
componentDidMount () {
|
||||
this.bindKeyHandlers();
|
||||
|
||||
// this.bindGestures() // need to check whether we're on a mobile device or this throws an Error
|
||||
}
|
||||
|
||||
// If entering to singleview and no active, active is the first eleement
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.singleView && !this.state.active) {
|
||||
this.setState({active: nextProps.commentIds[0]});
|
||||
}
|
||||
}
|
||||
|
||||
// Add swipe to approve or reject
|
||||
bindGestures () {
|
||||
const {modActions} = this.props;
|
||||
this._hammer = new Hammer(this.base);
|
||||
this._hammer.get('swipe').set({direction: Hammer.DIRECTION_HORIZONTAL});
|
||||
|
||||
if (modActions.indexOf('reject') !== -1) {
|
||||
this._hammer.on('swipeleft', () => this.props.singleView && this.actionKeyHandler('Rejected'));
|
||||
}
|
||||
if (modActions.indexOf('approve') !== -1) {
|
||||
this._hammer.on('swiperight', () => this.props.singleView && this.actionKeyHandler('Approved'));
|
||||
}
|
||||
}
|
||||
|
||||
// Add key handlers. Each action has one and added j/k for moving around
|
||||
bindKeyHandlers () {
|
||||
const {modActions, isActive} = this.props;
|
||||
modActions.filter(action => menuOptionsMap[action].key).forEach(action => {
|
||||
key(menuOptionsMap[action].key, 'moderationList', () => isActive && this.actionKeyHandler(menuOptionsMap[action].status));
|
||||
});
|
||||
key('j', 'moderationList', () => isActive && this.moveKeyHandler('down'));
|
||||
key('k', 'moderationList', () => isActive && this.moveKeyHandler('up'));
|
||||
key.setScope('moderationList');
|
||||
}
|
||||
|
||||
// Perform an action using the keys only if the comment is active
|
||||
actionKeyHandler (action) {
|
||||
if (this.props.isActive && this.state.active) {
|
||||
this.onClickAction(action, this.state.active);
|
||||
}
|
||||
}
|
||||
|
||||
// move around with j/k
|
||||
moveKeyHandler (direction) {
|
||||
if (!this.props.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {commentIds} = this.props;
|
||||
const {active} = this.state;
|
||||
|
||||
// check boundaries
|
||||
if (active === null || !commentIds.length) {
|
||||
this.setState({active: commentIds[0]});
|
||||
} else if (direction === 'up' && active !== commentIds[0]) {
|
||||
this.setState({active: commentIds[commentIds.indexOf(active) - 1]});
|
||||
} else if (direction === 'down' && active !== commentIds[commentIds.length - 1]) {
|
||||
this.setState({active: commentIds[commentIds.indexOf(active) + 1]});
|
||||
}
|
||||
|
||||
// scroll to the position
|
||||
const index = Math.max(commentIds.indexOf(this.state.active), 0);
|
||||
this.base.childNodes[index] && this.base.childNodes[index].focus();
|
||||
}
|
||||
|
||||
unbindKeyHandlers () {
|
||||
key.deleteScope('moderationList');
|
||||
}
|
||||
|
||||
// If we are performing an action over a comment (aka removing from the list) we need to select a new active.
|
||||
// TODO: In the future this can be improved and look at the actual state to
|
||||
// resolve since the content of the list could change externally. For now it works as expected
|
||||
onClickAction = (menuOption, id, action) => {
|
||||
|
||||
// activate the next comment
|
||||
if (id === this.state.active) {
|
||||
const moderationIds = this.getModerationIds();
|
||||
if (moderationIds[moderationIds.length - 1] === this.state.active) {
|
||||
this.setState({active: moderationIds[moderationIds.length - 2]});
|
||||
} else {
|
||||
this.setState({active: moderationIds[Math.min(moderationIds.indexOf(this.state.active) + 1, moderationIds.length - 1)]});
|
||||
}
|
||||
}
|
||||
|
||||
// Update the status right away if this is a comment
|
||||
if (action.item_type === 'COMMENTS') {
|
||||
this.props.updateCommentStatus(menuOption, id);
|
||||
} else if (action.item_type === 'USERS') {
|
||||
|
||||
// If a user bio or name is rejected, bring up a dialog before suspending them.
|
||||
if (menuOption === 'REJECTED') {
|
||||
this.setState({suspendUserModal: action});
|
||||
} else if (menuOption === 'ACCEPTED') {
|
||||
this.props.userStatusUpdate('ACTIVE', action.item_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClickShowBanDialog = (userId, userName, commentId) => {
|
||||
this.props.onClickShowBanDialog(userId, userName, commentId);
|
||||
}
|
||||
|
||||
mapModItems = (itemId, index) => {
|
||||
|
||||
const {comments = {}, users, actions = {}, modActions, suspectWords, hideActive} = this.props;
|
||||
const {active} = this.state;
|
||||
|
||||
// Because ids are unique, the id will either appear as an action or as a comment.
|
||||
|
||||
const item = comments[itemId] || actions[itemId];
|
||||
let modItem;
|
||||
|
||||
if (item.body) {
|
||||
|
||||
// If the item is a comment...
|
||||
const author = users[item.author_id];
|
||||
modItem = <Comment
|
||||
suspectWords={suspectWords}
|
||||
comment={item}
|
||||
author={author}
|
||||
key={index}
|
||||
index={index}
|
||||
onClickAction={this.onClickAction}
|
||||
onClickShowBanDialog={this.onClickShowBanDialog}
|
||||
modActions={modActions}
|
||||
menuOptionsMap={menuOptionsMap}
|
||||
isActive={itemId === active}
|
||||
hideActive={hideActive} />;
|
||||
} else {
|
||||
|
||||
// If the item is an action...
|
||||
const user = users[item.item_id];
|
||||
modItem = user && <UserAction
|
||||
suspectWords={suspectWords}
|
||||
action={item}
|
||||
user={user}
|
||||
key={index}
|
||||
index={index}
|
||||
onClickAction={this.onClickAction}
|
||||
onClickShowBanDialog={this.onClickShowBanDialog}
|
||||
modActions={modActions}
|
||||
menuOptionsMap={menuOptionsMap}
|
||||
isActive={itemId === active}
|
||||
hideActive={hideActive} />;
|
||||
}
|
||||
return modItem;
|
||||
}
|
||||
|
||||
getModerationIds = () => {
|
||||
const {commentIds = [], actionIds = [], comments, actions} = this.props;
|
||||
if (comments && actions) {
|
||||
return [ ...commentIds, ...actionIds ].sort((a, b) => {
|
||||
const itemA = comments[a] || actions[a];
|
||||
const itemB = comments[b] || actions[b];
|
||||
return itemB.updated_at - itemA.updated_at;
|
||||
});
|
||||
} else {
|
||||
return comments ? commentIds : actionIds;
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const {singleView, key, suspendUser} = this.props;
|
||||
|
||||
// Combine moderations and actions into a single stream and sort by most recently updated.
|
||||
const moderationIds = this.getModerationIds();
|
||||
|
||||
return (
|
||||
<ul
|
||||
className={`${styles.list} ${singleView ? styles.singleView : ''}`} {...key}
|
||||
id='moderationList'>
|
||||
{moderationIds.map(this.mapModItems)}
|
||||
<SuspendUserModal
|
||||
action = {this.state.suspendUserModal}
|
||||
onClose={() => this.setState({suspendUserModal:null})}
|
||||
suspendUser={suspendUser} />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
.modalButtons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.emailInput {
|
||||
border-radius: 3px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.writeContainer {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.emailContainer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.emailMessage {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import Modal from 'components/Modal';
|
||||
import styles from './SuspendUserModal.css';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
const stages = [
|
||||
{
|
||||
title: 'suspenduser.title_0',
|
||||
description: 'suspenduser.description_0',
|
||||
options: {
|
||||
'j': 'suspenduser.no_cancel',
|
||||
'k': 'suspenduser.yes_suspend'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'suspenduser.title_1',
|
||||
description: 'suspenduser.description_1',
|
||||
options: {
|
||||
'j': 'bandialog.cancel',
|
||||
'k': 'suspenduser.send'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
class SuspendUserModal extends Component {
|
||||
|
||||
state = {email: '', stage: 0}
|
||||
|
||||
static propTypes = {
|
||||
stage: PropTypes.number,
|
||||
actionType: PropTypes.string,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
suspendUser: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const about = this.props.actionType === 'flag_bio' ? lang.t('suspenduser.bio') : lang.t('suspenduser.username');
|
||||
this.setState({email: lang.t('suspenduser.email', about)});
|
||||
}
|
||||
|
||||
/*
|
||||
* When an admin clicks to suspend a user a dialog is shown, this function
|
||||
* handles the possible actions for that dialog.
|
||||
*/
|
||||
onActionClick = (stage, menuOption) => () => {
|
||||
const {suspendUser, action} = this.props;
|
||||
const {stage, email} = this.state;
|
||||
const cancel = this.props.onClose;
|
||||
const next = () => this.setState({stage: stage + 1});
|
||||
const suspend = () => suspendUser(action.item_id, lang.t('suspenduser.email_subject'), email)
|
||||
.then(this.props.onClose);
|
||||
const suspendModalActions = [
|
||||
[ cancel, next ],
|
||||
[ cancel, suspend ]
|
||||
];
|
||||
return suspendModalActions[stage][menuOption]();
|
||||
}
|
||||
|
||||
onEmailChange = (e) => this.setState({email: e.target.value})
|
||||
|
||||
render () {
|
||||
const {action, onClose} = this.props;
|
||||
|
||||
if (!action) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {stage} = this.state;
|
||||
const actionType = action.actionType;
|
||||
const about = actionType === 'flag_bio' ? lang.t('suspenduser.bio') : lang.t('suspenduser.username');
|
||||
return <Modal open={true} onClose={onClose}>
|
||||
<div className={styles.title}>{lang.t(stages[stage].title, about)}</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.description}>
|
||||
{lang.t(stages[stage].description, about)}
|
||||
</div>
|
||||
{
|
||||
stage === 1 &&
|
||||
<div className={styles.writeContainer}>
|
||||
<div className={styles.emailMessage}>{lang.t('suspenduser.write_message')}</div>
|
||||
<div className={styles.emailContainer}>
|
||||
<textarea
|
||||
rows={5}
|
||||
className={styles.emailInput}
|
||||
value={this.state.email}
|
||||
onChange={this.onEmailChange}/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className={styles.modalButtons}>
|
||||
{Object.keys(stages[stage].options).map((key, i) => (
|
||||
<Button key={i} onClick={this.onActionClick(stage, i)}>
|
||||
{lang.t(stages[stage].options[key], about)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
}
|
||||
|
||||
export default SuspendUserModal;
|
||||
|
||||
const lang = new I18n(translations);
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import Linkify from 'react-linkify';
|
||||
|
||||
import styles from './ModerationList.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
|
||||
import {Icon} from 'react-mdl';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import ActionButton from './ActionButton';
|
||||
|
||||
const linkify = new Linkify();
|
||||
|
||||
// Render a single comment for the list
|
||||
const UserAction = props => {
|
||||
const {action, user} = props;
|
||||
let userStatus = user.status;
|
||||
const links = user.settings.bio ? linkify.getMatches(user.settings.bio) : [];
|
||||
|
||||
// Do not display unless the user status is 'pending' or 'banned'.
|
||||
// This means that they have already been reviewed and approved.
|
||||
return (userStatus === 'PENDING' || userStatus === 'BANNED') &&
|
||||
<li tabIndex={props.index} className={`mdl-card mdl-shadow--2dp ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<span>{user.displayName}</span>
|
||||
</div>
|
||||
<div className={styles.sideActions}>
|
||||
{links ?
|
||||
<span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{props.modActions.map(
|
||||
(action, i) =>
|
||||
<ActionButton
|
||||
option={action}
|
||||
key={i}
|
||||
type='USERS'
|
||||
user={user}
|
||||
menuOptionsMap={props.menuOptionsMap}
|
||||
onClickAction={props.onClickAction}
|
||||
onClickShowBanDialog={props.onClickShowBanDialog}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{userStatus === 'banned' ?
|
||||
<span className={styles.banned}><Icon name='error_outline'/> {lang.t('comment.banned_user')}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
user.settings.bio &&
|
||||
<div>
|
||||
<div className={styles.itemBody}>
|
||||
<div>{lang.t('user.user_bio')}:</div>
|
||||
<span className={styles.body}>
|
||||
<Linkify component='span' properties={{style: linkStyles}}>
|
||||
<Highlighter
|
||||
searchWords={props.suspectWords}
|
||||
textToHighlight={user.settings.bio} />
|
||||
</Linkify>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className={styles.flagCount}>
|
||||
{`${action.count} ${action.action_type === 'flag_bio' ? lang.t('user.bio_flags') : lang.t('user.username_flags')}`}
|
||||
</div>
|
||||
</li>;
|
||||
};
|
||||
|
||||
export default UserAction;
|
||||
|
||||
const linkStyles = {
|
||||
backgroundColor: 'rgb(255, 219, 135)',
|
||||
padding: '1px 2px'
|
||||
};
|
||||
|
||||
const lang = new I18n(translations);
|
||||
@@ -1,3 +1,5 @@
|
||||
export const UPDATE_STATUS_REQUEST = 'UPDATE_STATUS_REQUEST';
|
||||
export const UPDATE_STATUS_SUCCESS = 'UPDATE_STATUS_SUCCESS';
|
||||
export const UPDATE_STATUS_FAILURE = 'UPDATE_STATUS_FAILURE';
|
||||
export const USER_EMAIL_FAILURE = 'USER_EMAIL_FAILURE';
|
||||
export const USERS_MODERATION_QUEUE_FETCH_SUCCESS = 'USERS_MODERATION_QUEUE_FETCH_SUCCESS';
|
||||
@@ -3,11 +3,11 @@ import styles from './CommentStream.css';
|
||||
import {Snackbar} from 'react-mdl';
|
||||
import {connect} from 'react-redux';
|
||||
import {createComment, flagComment} from 'actions/comments';
|
||||
import CommentList from 'components/CommentList';
|
||||
import ModerationList from 'components/ModerationList';
|
||||
import CommentBox from 'components/CommentBox';
|
||||
|
||||
/**
|
||||
* Renders a comment stream using a CommentList component
|
||||
* Renders a comment stream using a ModerationList component
|
||||
* and adds a box for adding a new comment
|
||||
*/
|
||||
|
||||
@@ -39,12 +39,12 @@ class CommentStream extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Render the comment box along with the CommentList
|
||||
// Render the comment box along with the ModerationList
|
||||
render ({comments, users}, {snackbar, snackbarMsg}) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<CommentBox onSubmit={this.onSubmit}/>
|
||||
<CommentList isActive hideActive
|
||||
<CommentBox onSubmit={this.onSubmit} />
|
||||
<ModerationList isActive hideActive
|
||||
singleView={false}
|
||||
commentIds={comments.ids}
|
||||
comments={comments.byId}
|
||||
|
||||
@@ -55,8 +55,8 @@ class Table extends Component {
|
||||
className={styles.selectField}
|
||||
label={lang.t('community.status')}
|
||||
onChange={status => this.onCommenterStatusChange(row.id, status)}>
|
||||
<Option value={'active'}>{lang.t('community.active')}</Option>
|
||||
<Option value={'banned'}>{lang.t('community.banned')}</Option>
|
||||
<Option value={'ACTIVE'}>{lang.t('community.active')}</Option>
|
||||
<Option value={'BANNED'}>{lang.t('community.banned')}</Option>
|
||||
</SelectField>
|
||||
</td>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
@@ -65,8 +65,8 @@ class Table extends Component {
|
||||
label={lang.t('community.role')}
|
||||
onChange={role => this.onRoleChange(row.id, role)}>
|
||||
<Option value={''}>.</Option>
|
||||
<Option value={'moderator'}>{lang.t('community.moderator')}</Option>
|
||||
<Option value={'admin'}>{lang.t('community.admin')}</Option>
|
||||
<Option value={'MODERATOR'}>{lang.t('community.moderator')}</Option>
|
||||
<Option value={'ADMIN'}>{lang.t('community.admin')}</Option>
|
||||
</SelectField>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -28,7 +28,7 @@ const updateCharCount = (updateSettings, settingsError) => (event) => {
|
||||
};
|
||||
|
||||
const updateModeration = (updateSettings, mod) => () => {
|
||||
const moderation = mod === 'pre' ? 'post' : 'pre';
|
||||
const moderation = mod === 'PRE' ? 'POST' : 'PRE';
|
||||
updateSettings({moderation});
|
||||
};
|
||||
|
||||
@@ -70,15 +70,15 @@ const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError
|
||||
return (
|
||||
<div className={styles.commentSettingsSection}>
|
||||
<h3>{title}</h3>
|
||||
<Card className={`${styles.configSetting} ${settings.moderation === 'pre' ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<Card className={`${styles.configSetting} ${settings.moderation === 'PRE' ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateModeration(updateSettings, settings.moderation)}
|
||||
checked={settings.moderation === 'pre'} />
|
||||
checked={settings.moderation === 'PRE'} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.enable-pre-moderation')}</div>
|
||||
<p className={settings.moderation === 'pre' ? '' : styles.disabledSettingText}>
|
||||
<p className={settings.moderation === 'PRE' ? '' : styles.disabledSettingText}>
|
||||
{lang.t('configure.enable-pre-moderation-text')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -32,4 +32,3 @@ export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(LayoutContainer);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
fetchFlaggedQueue,
|
||||
fetchModerationQueueComments,
|
||||
} from 'actions/comments';
|
||||
import {userStatusUpdate} from 'actions/users';
|
||||
import {userStatusUpdate, sendNotificationEmail} from 'actions/users';
|
||||
import {fetchSettings} from 'actions/settings';
|
||||
|
||||
import ModerationQueue from './ModerationQueue';
|
||||
@@ -22,7 +22,7 @@ class ModerationContainer extends React.Component {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
activeTab: 'pending',
|
||||
activeTab: 'all',
|
||||
singleView: false,
|
||||
modalOpen: false
|
||||
};
|
||||
@@ -74,20 +74,22 @@ class ModerationContainer extends React.Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const {comments} = this.props;
|
||||
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'premod');
|
||||
const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'rejected');
|
||||
const {comments, actions} = this.props;
|
||||
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'PREMOD');
|
||||
const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'REJECTED');
|
||||
const flaggedIds = comments.ids.filter(id =>
|
||||
comments.byId[id].flagged === true &&
|
||||
comments.byId[id].status !== 'rejected' &&
|
||||
comments.byId[id].status !== 'accepted'
|
||||
comments.byId[id].status !== 'REJECTED' &&
|
||||
comments.byId[id].status !== 'ACCEPTED'
|
||||
);
|
||||
const userActionIds = actions.ids.filter(id => actions.byId[id].item_type === 'USERS');
|
||||
|
||||
return (
|
||||
<ModerationQueue
|
||||
onTabClick={this.onTabClick}
|
||||
onClose={this.onClose}
|
||||
premodIds={premodIds}
|
||||
userActionIds={userActionIds}
|
||||
rejectedIds={rejectedIds}
|
||||
flaggedIds={flaggedIds}
|
||||
{...this.props}
|
||||
@@ -100,7 +102,8 @@ class ModerationContainer extends React.Component {
|
||||
const mapStateToProps = state => ({
|
||||
comments: state.comments.toJS(),
|
||||
settings: state.settings.toJS(),
|
||||
users: state.users.toJS()
|
||||
users: state.users.toJS(),
|
||||
actions: state.actions.toJS(),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
@@ -112,9 +115,13 @@ const mapDispatchToProps = dispatch => {
|
||||
fetchFlaggedQueue: () => dispatch(fetchFlaggedQueue()),
|
||||
showBanUserDialog: (userId, userName, commentId) => dispatch(showBanUserDialog(userId, userName, commentId)),
|
||||
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
|
||||
banUser: (userId, commentId) => dispatch(userStatusUpdate('banned', userId, commentId)).then(() => {
|
||||
userStatusUpdate: (status, userId, commentId) => dispatch(userStatusUpdate(status, userId, commentId)).then(() => {
|
||||
dispatch(fetchModerationQueueComments());
|
||||
}),
|
||||
suspendUser: (userId, subject, text) => dispatch(userStatusUpdate('suspended', userId))
|
||||
.then(() => dispatch(sendNotificationEmail(userId, subject, text)))
|
||||
.then(() => dispatch(fetchModerationQueueComments()))
|
||||
,
|
||||
updateStatus: (action, comment) => dispatch(updateStatus(action, comment))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import styles from './ModerationQueue.css';
|
||||
|
||||
import ModerationKeysModal from 'components/ModerationKeysModal';
|
||||
import CommentList from 'components/CommentList';
|
||||
import ModerationList from 'components/ModerationList';
|
||||
import BanUserDialog from 'components/BanUserDialog';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
@@ -10,23 +10,40 @@ import translations from '../../translations.json';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
export default ({onTabClick, ...props}) => (
|
||||
export default (props) => (
|
||||
<div>
|
||||
<div className='mdl-tabs'>
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<a href='#all'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
props.onTabClick('all');
|
||||
}}
|
||||
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'all' ? styles.active : ''}`}
|
||||
>
|
||||
{lang.t('modqueue.all')}
|
||||
</a>
|
||||
<a href='#pending'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onTabClick('pending');
|
||||
props.onTabClick('pending');
|
||||
}}
|
||||
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'pending' ? styles.active : ''}`}
|
||||
>
|
||||
{lang.t('modqueue.pending')}
|
||||
</a>
|
||||
<a href='#account'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
props.onTabClick('account');
|
||||
}}
|
||||
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'account' ? styles.active : ''}`}>
|
||||
{lang.t('modqueue.account')}
|
||||
</a>
|
||||
<a href='#rejected'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onTabClick('rejected');
|
||||
props.onTabClick('rejected');
|
||||
}}
|
||||
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'rejected' ? styles.active : ''}`}
|
||||
>
|
||||
@@ -35,69 +52,146 @@ export default ({onTabClick, ...props}) => (
|
||||
<a href='#flagged'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onTabClick('flagged');
|
||||
props.onTabClick('flagged');
|
||||
}}
|
||||
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'flagged' ? styles.active : ''}`}
|
||||
>
|
||||
{lang.t('modqueue.flagged')}
|
||||
</a>
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
|
||||
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='all'>
|
||||
{
|
||||
props.activeTab === 'pending'
|
||||
? <div>
|
||||
<CommentList
|
||||
suspectWords={props.settings.settings.wordlist.suspect}
|
||||
isActive={props.activeTab === 'pending'}
|
||||
singleView={props.singleView}
|
||||
commentIds={props.premodIds}
|
||||
comments={props.comments.byId}
|
||||
users={props.users.byId}
|
||||
onClickAction={props.updateStatus}
|
||||
onClickShowBanDialog={props.showBanUserDialog}
|
||||
modActions={['reject', 'approve', 'ban']}
|
||||
loading={props.comments.loading} />
|
||||
<BanUserDialog
|
||||
open={props.comments.showBanUserDialog}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
onClickBanUser={props.banUser}
|
||||
user={props.comments.banUser} />
|
||||
</div>
|
||||
: null
|
||||
props.activeTab === 'all' &&
|
||||
<div>
|
||||
<ModerationList
|
||||
suspectWords={props.settings.settings.wordlist.suspect}
|
||||
isActive={props.activeTab === 'all'}
|
||||
singleView={props.singleView}
|
||||
commentIds={[...props.premodIds, ...props.flaggedIds]}
|
||||
comments={props.comments.byId}
|
||||
users={props.users.byId}
|
||||
actionIds={props.userActionIds}
|
||||
actions={props.actions.byId}
|
||||
userStatusUpdate={props.userStatusUpdate}
|
||||
suspendUser={props.suspendUser}
|
||||
updateCommentStatus={props.updateStatus}
|
||||
onClickShowBanDialog={props.showBanUserDialog}
|
||||
modActions={['reject', 'approve', 'ban']}
|
||||
loading={props.comments.loading}/>
|
||||
<BanUserDialog
|
||||
open={props.comments.showBanUserDialog}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
onClickBanUser={props.userStatusUpdate}
|
||||
user={props.comments.banUser}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
|
||||
{
|
||||
props.activeTab === 'rejected'
|
||||
? <CommentList
|
||||
suspectWords={props.settings.settings.wordlist.suspect}
|
||||
isActive={props.activeTab === 'rejected'}
|
||||
singleView={props.singleView}
|
||||
commentIds={props.rejectedIds}
|
||||
comments={props.comments.byId}
|
||||
users={props.users.byId}
|
||||
onClickAction={props.updateStatus}
|
||||
modActions={['approve']}
|
||||
loading={props.comments.loading} />
|
||||
: null
|
||||
}
|
||||
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
|
||||
{
|
||||
props.activeTab === 'pending' &&
|
||||
<div>
|
||||
<ModerationList
|
||||
suspectWords={props.settings.settings.wordlist.suspect}
|
||||
isActive={props.activeTab === 'pending'}
|
||||
singleView={props.singleView}
|
||||
commentIds={props.premodIds}
|
||||
comments={props.comments.byId}
|
||||
users={props.users.byId}
|
||||
actions={props.actions.byId}
|
||||
userStatusUpdate={props.userStatusUpdate}
|
||||
suspendUser={props.suspendUser}
|
||||
updateCommentStatus={props.updateStatus}
|
||||
onClickShowBanDialog={props.showBanUserDialog}
|
||||
modActions={['reject', 'approve', 'ban']}
|
||||
loading={props.comments.loading}/>
|
||||
<BanUserDialog
|
||||
open={props.comments.showBanUserDialog}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
onClickBanUser={props.userStatusUpdate}
|
||||
user={props.comments.banUser}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='account'>
|
||||
{
|
||||
props.activeTab === 'account' &&
|
||||
<div>
|
||||
<ModerationList
|
||||
suspectWords={props.settings.settings.wordlist.suspect}
|
||||
isActive={props.activeTab === 'account'}
|
||||
singleView={props.singleView}
|
||||
users={props.users.byId}
|
||||
actionIds={props.userActionIds}
|
||||
actions={props.actions.byId}
|
||||
userStatusUpdate={props.userStatusUpdate}
|
||||
suspendUser={props.suspendUser}
|
||||
updateCommentStatus={props.updateStatus}
|
||||
onClickShowBanDialog={props.showBanUserDialog}
|
||||
modActions={['reject', 'approve', 'ban']}
|
||||
loading={props.comments.loading}/>
|
||||
<BanUserDialog
|
||||
open={props.comments.showBanUserDialog}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
onClickBanUser={props.userStatusUpdate}
|
||||
user={props.comments.banUser}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
|
||||
{
|
||||
props.activeTab === 'flagged'
|
||||
? <CommentList
|
||||
props.activeTab === 'flagged' &&
|
||||
<div>
|
||||
<ModerationList
|
||||
suspectWords={props.settings.settings.wordlist.suspect}
|
||||
isActive={props.activeTab === 'flagged'}
|
||||
singleView={props.singleView}
|
||||
commentIds={props.flaggedIds}
|
||||
userStatusUpdate={props.userStatusUpdate}
|
||||
suspendUser={props.suspendUser}
|
||||
comments={props.comments.byId}
|
||||
users={props.users.byId}
|
||||
onClickAction={props.updateStatus}
|
||||
updateCommentStatus={props.updateStatus}
|
||||
modActions={['reject', 'approve']}
|
||||
loading={props.comments.loading} />
|
||||
: null
|
||||
}
|
||||
loading={props.comments.loading}/>
|
||||
<BanUserDialog
|
||||
open={props.comments.showBanUserDialog}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
onClickBanUser={props.userStatusUpdate}
|
||||
user={props.comments.banUser}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
|
||||
{
|
||||
props.activeTab === 'rejected' &&
|
||||
<div>
|
||||
<ModerationList
|
||||
suspectWords={props.settings.settings.wordlist.suspect}
|
||||
isActive={props.activeTab === 'rejected'}
|
||||
singleView={props.singleView}
|
||||
commentIds={props.rejectedIds}
|
||||
userStatusUpdate={props.userStatusUpdate}
|
||||
suspendUser={props.suspendUser}
|
||||
comments={props.comments.byId}
|
||||
users={props.users.byId}
|
||||
updateCommentStatus={props.updateStatus}
|
||||
modActions={['approve']}
|
||||
loading={props.comments.loading}
|
||||
/>
|
||||
<BanUserDialog
|
||||
open={props.comments.showBanUserDialog}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
onClickBanUser={props.userStatusUpdate}
|
||||
user={props.comments.banUser}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ModerationKeysModal open={props.modalOpen} onClose={props.closeModal} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {Map, Set} from 'immutable';
|
||||
import {Map, Set, fromJS} from 'immutable';
|
||||
import * as types from '../constants/actions';
|
||||
|
||||
const initialState = Map({
|
||||
ids: Set()
|
||||
ids: Set(),
|
||||
byId: Map()
|
||||
});
|
||||
|
||||
export default (state = initialState, action) => {
|
||||
@@ -14,11 +15,13 @@ export default (state = initialState, action) => {
|
||||
};
|
||||
|
||||
const addActions = (state, action) => {
|
||||
const ids = action.actions.map(action => action.item_id);
|
||||
|
||||
// Make ids that are unique by item_id and by action type
|
||||
const typeId = (action) => `${action.action_type}_${action.item_id}`;
|
||||
const ids = action.actions.map(action => typeId(action));
|
||||
const map = action.actions.reduce((memo, action) => {
|
||||
memo[action.item_id] = action;
|
||||
memo[typeId(action)] = action;
|
||||
return memo;
|
||||
}, {});
|
||||
const newIds = state.get('ids').concat(ids);
|
||||
return state.merge(map).set('ids', newIds);
|
||||
return state.set('byId', fromJS(map)).set('ids', new Set(ids));
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as actions from '../constants/comments';
|
||||
import * as userActions from '../constants/user';
|
||||
import * as userActions from '../constants/users';
|
||||
import {Map, List, fromJS} from 'immutable';
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
"loading": "Loading results"
|
||||
},
|
||||
"modqueue": {
|
||||
"all": "all",
|
||||
"pending": "pending",
|
||||
"rejected": "rejected",
|
||||
"flagged": "flagged",
|
||||
"account": "account flags",
|
||||
"shortcuts": "Shortcuts",
|
||||
"close": "Close",
|
||||
"actions": "Actions",
|
||||
@@ -37,6 +39,11 @@
|
||||
"ban_user": "Ban User",
|
||||
"banned_user": "Banned User"
|
||||
},
|
||||
"user": {
|
||||
"user_bio": "User Bio",
|
||||
"bio_flags": "flags for this bio",
|
||||
"username_flags": "flags for this username"
|
||||
},
|
||||
"embedlink": {
|
||||
"copy": "Copy to Clipboard"
|
||||
},
|
||||
@@ -79,6 +86,20 @@
|
||||
"cancel": "Cancel",
|
||||
"yes_ban_user": "Yes, Ban User"
|
||||
},
|
||||
"suspenduser": {
|
||||
"title_0": "We noticed you rejected a {0}",
|
||||
"description_0": "Would you like to temporarily ban this user becuase of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}.",
|
||||
"title_1": "Notify the user of their temporary suspension",
|
||||
"description_1": "Suspending this user will temporarily disable their account and hide all of their comments on the site.",
|
||||
"no_cancel": "No, cancel",
|
||||
"yes_suspend": "Yes, suspend",
|
||||
"send": "Send",
|
||||
"bio": "bio",
|
||||
"username": "username",
|
||||
"email_subject": "Your account has been suspended",
|
||||
"email": "Another member of the community recently flagged your {0} for review. Because of its content your {0} was rejected. This means you can no longer comment, like, or flag content until you rewrite your {0}. Please e-mail moderator@newsorg.com if you have any questions or concerns.",
|
||||
"write_message": "Write a message"
|
||||
},
|
||||
"streams": {
|
||||
"search": "Search",
|
||||
"filter-streams": "Filter Streams",
|
||||
@@ -126,6 +147,11 @@
|
||||
"ban_user": "Suspender Usuario",
|
||||
"banned_user": "Usuario Suspendido"
|
||||
},
|
||||
"user": {
|
||||
"user_bio": "",
|
||||
"bio_flags": "",
|
||||
"username_flags": ""
|
||||
},
|
||||
"configure": {
|
||||
"enable-pre-moderation": "Habilitar pre-moderación",
|
||||
"enable-pre-moderation-text": "Los moderadores deben aprobar cada comentario antes de que sea publicado.",
|
||||
|
||||
@@ -7,47 +7,50 @@ import translations from '../translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
export default ({handleChange, handleApply, changed, ...props}) => (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.container}>
|
||||
<h3>{lang.t('configureCommentStream.title')}</h3>
|
||||
<p>{lang.t('configureCommentStream.description')}</p>
|
||||
<Button
|
||||
className={styles.apply}
|
||||
cStyle={changed ? 'green' : 'darkGrey'}
|
||||
onClick={handleApply}
|
||||
>
|
||||
{lang.t('configureCommentStream.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
cStyle={changed ? 'green' : 'darkGrey'}
|
||||
name="premod"
|
||||
<form onSubmit={handleApply}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.container}>
|
||||
<h3>{lang.t('configureCommentStream.title')}</h3>
|
||||
<p>{lang.t('configureCommentStream.description')}</p>
|
||||
<Button
|
||||
type="submit"
|
||||
className={styles.apply}
|
||||
onChange={handleChange}
|
||||
checked={props.premod}
|
||||
info={{
|
||||
title: lang.t('configureCommentStream.enablePremod'),
|
||||
description: lang.t('configureCommentStream.enablePremodDescription')
|
||||
}}
|
||||
/>
|
||||
<ul>
|
||||
<li>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
cStyle={changed ? 'green' : 'darkGrey'}
|
||||
name="premodLinks"
|
||||
onChange={handleChange}
|
||||
checked={props.premodLinks}
|
||||
info={{
|
||||
title: lang.t('configureCommentStream.enablePremodLinks'),
|
||||
description: lang.t('configureCommentStream.enablePremodDescription')
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
cStyle={changed ? 'green' : 'darkGrey'} >
|
||||
{lang.t('configureCommentStream.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
cStyle={changed ? 'green' : 'darkGrey'}
|
||||
name="premod"
|
||||
onChange={handleChange}
|
||||
defaultChecked={props.premod}
|
||||
info={{
|
||||
title: lang.t('configureCommentStream.enablePremod'),
|
||||
description: lang.t('configureCommentStream.enablePremodDescription')
|
||||
}} />
|
||||
{/* To be implimented
|
||||
<ul>
|
||||
<li>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
cStyle={changed ? 'green' : 'darkGrey'}
|
||||
name="premodLinks"
|
||||
onChange={handleChange}
|
||||
defaultChecked={props.premodLinks}
|
||||
info={{
|
||||
title: lang.t('configureCommentStream.enablePremodLinks'),
|
||||
description: lang.t('configureCommentStream.enablePremodDescription')
|
||||
}} />
|
||||
</li>
|
||||
</ul>
|
||||
*/}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {compose} from 'react-apollo';
|
||||
|
||||
import {I18n} from '../../coral-framework';
|
||||
import {updateOpenStatus, updateConfiguration} from '../../coral-framework/actions/config';
|
||||
import {I18n} from 'coral-framework';
|
||||
import {updateOpenStatus, updateConfiguration} from 'coral-framework/actions/asset';
|
||||
|
||||
import CloseCommentsInfo from '../components/CloseCommentsInfo';
|
||||
import ConfigureCommentStream from '../components/ConfigureCommentStream';
|
||||
@@ -14,8 +15,7 @@ class ConfigureStreamContainer extends Component {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
premod: props.config.moderation === 'pre',
|
||||
premodLinks: false
|
||||
changed: false
|
||||
};
|
||||
|
||||
this.toggleStatus = this.toggleStatus.bind(this);
|
||||
@@ -23,11 +23,18 @@ class ConfigureStreamContainer extends Component {
|
||||
this.handleApply = this.handleApply.bind(this);
|
||||
}
|
||||
|
||||
handleApply () {
|
||||
const {premod, changed} = this.state;
|
||||
handleApply (e) {
|
||||
e.preventDefault();
|
||||
const {elements} = e.target;
|
||||
const premod = elements.premod.checked;
|
||||
|
||||
// const premodLinks = elements.premodLinks.checked;
|
||||
const {changed} = this.state;
|
||||
|
||||
const newConfig = {
|
||||
moderation: premod ? 'pre' : 'post'
|
||||
moderation: premod ? 'PRE' : 'POST'
|
||||
};
|
||||
|
||||
if (changed) {
|
||||
this.props.updateConfiguration(newConfig);
|
||||
setTimeout(() => {
|
||||
@@ -38,26 +45,27 @@ class ConfigureStreamContainer extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleChange (e) {
|
||||
const {name, checked} = e.target;
|
||||
handleChange () {
|
||||
this.setState({
|
||||
[name]: checked,
|
||||
changed: true
|
||||
});
|
||||
}
|
||||
|
||||
toggleStatus () {
|
||||
this.props.updateStatus(this.props.config.status === 'open' ? 'closed' : 'open');
|
||||
this.props.updateStatus(
|
||||
this.props.asset.closedAt === null ? 'closed' : 'open'
|
||||
);
|
||||
}
|
||||
|
||||
getClosedIn () {
|
||||
const {closedTimeout} = this.props.config;
|
||||
const {closedTimeout} = this.props.asset.settings;
|
||||
const {created_at} = this.props.asset;
|
||||
return lang.timeago(new Date(created_at).getTime() + (1000 * closedTimeout));
|
||||
}
|
||||
|
||||
render () {
|
||||
const {status} = this.props;
|
||||
const status = this.props.asset.closedAt === null ? 'open' : 'closed';
|
||||
const premod = this.props.asset.settings.moderation === 'PRE';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -65,7 +73,8 @@ class ConfigureStreamContainer extends Component {
|
||||
handleChange={this.handleChange}
|
||||
handleApply={this.handleApply}
|
||||
changed={this.state.changed}
|
||||
{...this.state}
|
||||
premodLinks={false}
|
||||
premod={premod}
|
||||
/>
|
||||
<hr />
|
||||
<h3>{status === 'open' ? 'Close' : 'Open'} Comment Stream</h3>
|
||||
@@ -80,11 +89,7 @@ class ConfigureStreamContainer extends Component {
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
config: state.config.toJS(),
|
||||
asset: state.items
|
||||
.get('assets')
|
||||
.first()
|
||||
.toJS()
|
||||
asset: state.asset.toJS()
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
@@ -92,7 +97,6 @@ const mapDispatchToProps = dispatch => ({
|
||||
updateConfiguration: newConfig => dispatch(updateConfiguration(newConfig))
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps)
|
||||
)(ConfigureStreamContainer);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// this component will
|
||||
// render its children
|
||||
// render a like button
|
||||
// render a permalink button
|
||||
// render a reply button
|
||||
// render a flag button
|
||||
// translate things?
|
||||
|
||||
import React, {PropTypes} from 'react';
|
||||
import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton';
|
||||
|
||||
import AuthorName from 'coral-plugin-author-name/AuthorName';
|
||||
import Content from 'coral-plugin-commentcontent/CommentContent';
|
||||
import PubDate from 'coral-plugin-pubdate/PubDate';
|
||||
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
|
||||
import FlagComment from 'coral-plugin-flags/FlagComment';
|
||||
import LikeButton from 'coral-plugin-likes/LikeButton';
|
||||
|
||||
const getAction = (type, comment) => comment.actions.filter((a) => a.type === type)[0];
|
||||
|
||||
class Comment extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {replyBoxVisible: false};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
reactKey: PropTypes.string.isRequired,
|
||||
|
||||
// id of currently opened ReplyBox. tracked in Stream.js
|
||||
activeReplyBox: PropTypes.string.isRequired,
|
||||
setActiveReplyBox: PropTypes.func.isRequired,
|
||||
refetch: PropTypes.func.isRequired,
|
||||
showSignInDialog: PropTypes.func.isRequired,
|
||||
postAction: PropTypes.func.isRequired,
|
||||
deleteAction: PropTypes.func.isRequired,
|
||||
parentId: PropTypes.string,
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
postItem: PropTypes.func.isRequired,
|
||||
depth: PropTypes.number.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
url: PropTypes.string
|
||||
}).isRequired,
|
||||
currentUser: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired
|
||||
}),
|
||||
comment: PropTypes.shape({
|
||||
depth: PropTypes.number,
|
||||
actions: PropTypes.array.isRequired,
|
||||
body: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
replies: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
body: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired
|
||||
})
|
||||
),
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
}).isRequired
|
||||
}).isRequired
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
comment,
|
||||
parentId,
|
||||
currentUser,
|
||||
asset,
|
||||
depth,
|
||||
postItem,
|
||||
refetch,
|
||||
addNotification,
|
||||
showSignInDialog,
|
||||
postAction,
|
||||
setActiveReplyBox,
|
||||
activeReplyBox,
|
||||
deleteAction
|
||||
} = this.props;
|
||||
|
||||
const like = getAction('LIKE', comment);
|
||||
const flag = getAction('FLAG', comment);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={parentId ? 'reply' : 'comment'}
|
||||
id={`c_${comment.id}`}
|
||||
style={{marginLeft: depth * 30}}>
|
||||
<hr aria-hidden={true} />
|
||||
<AuthorName
|
||||
author={comment.user}
|
||||
addNotification={this.props.addNotification}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postAction={this.props.postAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
deleteAction={this.props.deleteAction}
|
||||
currentUser={currentUser}/>
|
||||
<PubDate created_at={comment.created_at} />
|
||||
<Content body={comment.body} />
|
||||
<div className="commentActionsLeft">
|
||||
<ReplyButton
|
||||
onClick={() => setActiveReplyBox(comment.id)}
|
||||
parentCommentId={parentId || comment.id}
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
banned={false} />
|
||||
<LikeButton
|
||||
like={like}
|
||||
id={comment.id}
|
||||
postAction={postAction}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser} />
|
||||
</div>
|
||||
<div className="commentActionsRight">
|
||||
<FlagComment
|
||||
flag={flag}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postAction={postAction}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser} />
|
||||
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
|
||||
</div>
|
||||
{
|
||||
activeReplyBox === comment.id
|
||||
? <ReplyBox
|
||||
commentPostedHandler={() => {
|
||||
setActiveReplyBox('');
|
||||
refetch();
|
||||
}}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
parentId={parentId || comment.id}
|
||||
addNotification={addNotification}
|
||||
authorId={currentUser.id}
|
||||
postItem={postItem}
|
||||
assetId={asset.id} />
|
||||
: null
|
||||
}
|
||||
{
|
||||
comment.replies &&
|
||||
comment.replies.map(reply => {
|
||||
return <Comment
|
||||
refetch={refetch}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postItem={postItem}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
currentUser={currentUser}
|
||||
postAction={postAction}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply} />;
|
||||
})
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Comment;
|
||||
@@ -1,331 +0,0 @@
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import Pym from 'pym.js';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {
|
||||
itemActions,
|
||||
Notification,
|
||||
notificationActions,
|
||||
authActions
|
||||
} from '../../coral-framework';
|
||||
|
||||
import CommentBox from '../../coral-plugin-commentbox/CommentBox';
|
||||
import InfoBox from '../../coral-plugin-infobox/InfoBox';
|
||||
import Content from '../../coral-plugin-commentcontent/CommentContent';
|
||||
import PubDate from '../../coral-plugin-pubdate/PubDate';
|
||||
import Count from '../../coral-plugin-comment-count/CommentCount';
|
||||
import AuthorName from '../../coral-plugin-author-name/AuthorName';
|
||||
import {ReplyBox, ReplyButton} from '../../coral-plugin-replies';
|
||||
import FlagComment from '../../coral-plugin-flags/FlagComment';
|
||||
import LikeButton from '../../coral-plugin-likes/LikeButton';
|
||||
import PermalinkButton from '../../coral-plugin-permalinks/PermalinkButton';
|
||||
import SignInContainer from '../../coral-sign-in/containers/SignInContainer';
|
||||
import UserBox from '../../coral-sign-in/components/UserBox';
|
||||
|
||||
import {TabBar, Tab, TabContent, Spinner} from '../../coral-ui';
|
||||
import SettingsContainer from '../../coral-settings/containers/SettingsContainer';
|
||||
import RestrictedContent from '../../coral-framework/components/RestrictedContent';
|
||||
import SuspendedAccount from '../../coral-framework/components/SuspendedAccount';
|
||||
|
||||
import ConfigureStreamContainer from '../../coral-configure/containers/ConfigureStreamContainer';
|
||||
|
||||
const {addItem, updateItem, postItem, getStream, postAction, deleteAction, appendItemArray} = itemActions;
|
||||
const {addNotification, clearNotification} = notificationActions;
|
||||
const {logout, showSignInDialog} = authActions;
|
||||
|
||||
class CommentStream extends Component {
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
activeTab: 0
|
||||
};
|
||||
|
||||
this.changeTab = this.changeTab.bind(this);
|
||||
}
|
||||
|
||||
changeTab (tab) {
|
||||
this.setState({
|
||||
activeTab: tab
|
||||
});
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
items: PropTypes.object.isRequired,
|
||||
addItem: PropTypes.func.isRequired,
|
||||
updateItem: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
|
||||
// Set up messaging between embedded Iframe an parent component
|
||||
this.pym = new Pym.Child({polling: 100});
|
||||
|
||||
let path = this.pym.parentUrl.split('#')[0];
|
||||
|
||||
if (!path) {
|
||||
path = window.location.href.split('#')[0];
|
||||
}
|
||||
|
||||
this.props.getStream(path || window.location);
|
||||
this.path = path;
|
||||
|
||||
this.pym.sendMessage('childReady');
|
||||
|
||||
this.pym.onMessage('DOMContentLoaded', hash => {
|
||||
const commentId = hash.replace('#', 'c_');
|
||||
let count = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (document.getElementById(commentId)) {
|
||||
window.clearInterval(interval);
|
||||
this.pym.scrollParentToChildEl(commentId);
|
||||
}
|
||||
|
||||
if (++count > 100) { // ~10 seconds
|
||||
// give up waiting for the comments to load.
|
||||
// it would be weird for the page to jump after that long.
|
||||
window.clearInterval(interval);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
render () {
|
||||
const rootItemId = this.props.items.assets && Object.keys(this.props.items.assets)[0];
|
||||
const rootItem = this.props.items.assets && this.props.items.assets[rootItemId];
|
||||
const {actions, users, comments} = this.props.items;
|
||||
const {status, moderation, closedMessage, charCount, charCountEnable} = this.props.config;
|
||||
const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth;
|
||||
const {activeTab} = this.state;
|
||||
const banned = (this.props.userData.status === 'banned');
|
||||
|
||||
const expandForLogin = showSignInDialog ? {
|
||||
minHeight: document.body.scrollHeight + 150
|
||||
} : {};
|
||||
return <div style={expandForLogin}>
|
||||
{
|
||||
rootItem
|
||||
? <div className="commentStream">
|
||||
<TabBar onChange={this.changeTab} activeTab={activeTab}>
|
||||
<Tab><Count id={rootItemId} items={this.props.items}/></Tab>
|
||||
<Tab>Settings</Tab>
|
||||
<Tab restricted={!isAdmin}>Configure Stream</Tab>
|
||||
</TabBar>
|
||||
{loggedIn && <UserBox user={user} logout={this.props.logout} />}
|
||||
<TabContent show={activeTab === 0}>
|
||||
{
|
||||
status === 'open'
|
||||
? <div id="commentBox">
|
||||
<InfoBox
|
||||
content={this.props.config.infoBoxContent}
|
||||
enable={this.props.config.infoBoxEnable}
|
||||
/>
|
||||
<RestrictedContent restricted={banned} restrictedComp={<SuspendedAccount />}>
|
||||
<CommentBox
|
||||
addNotification={this.props.addNotification}
|
||||
postItem={this.props.postItem}
|
||||
appendItemArray={this.props.appendItemArray}
|
||||
updateItem={this.props.updateItem}
|
||||
id={rootItemId}
|
||||
premod={moderation}
|
||||
reply={false}
|
||||
currentUser={this.props.auth.user}
|
||||
banned={banned}
|
||||
author={user}
|
||||
charCount={charCountEnable && charCount}/>
|
||||
</RestrictedContent>
|
||||
</div>
|
||||
: <p>{closedMessage}</p>
|
||||
}
|
||||
{!loggedIn && <SignInContainer offset={signInOffset}/>}
|
||||
{
|
||||
rootItem.comments && rootItem.comments.map((commentId) => {
|
||||
const comment = comments[commentId];
|
||||
return <div className="comment" key={commentId} id={`c_${commentId}`}>
|
||||
<hr aria-hidden={true}/>
|
||||
<AuthorName
|
||||
author={users[comment.author_id]}
|
||||
addNotification={this.props.addNotification}
|
||||
id={commentId}
|
||||
author_id={comment.author_id}
|
||||
postAction={this.props.postAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
deleteAction={this.props.deleteAction}
|
||||
addItem={this.props.addItem}
|
||||
updateItem={this.props.updateItem}
|
||||
currentUser={this.props.auth.user}/>
|
||||
<PubDate created_at={comment.created_at}/>
|
||||
<Content body={comment.body}/>
|
||||
<div className="commentActionsLeft">
|
||||
<ReplyButton
|
||||
updateItem={this.props.updateItem}
|
||||
id={commentId}
|
||||
currentUser={this.props.auth.user}
|
||||
showReply={comment.showReply}
|
||||
banned={banned}/>
|
||||
<LikeButton
|
||||
addNotification={this.props.addNotification}
|
||||
id={commentId}
|
||||
like={actions[comment.like]}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
postAction={this.props.postAction}
|
||||
deleteAction={this.props.deleteAction}
|
||||
addItem={this.props.addItem}
|
||||
updateItem={this.props.updateItem}
|
||||
currentUser={this.props.auth.user}
|
||||
banned={banned}/>
|
||||
</div>
|
||||
<div className="commentActionsRight">
|
||||
<FlagComment
|
||||
addNotification={this.props.addNotification}
|
||||
id={commentId}
|
||||
author_id={comment.author_id}
|
||||
flag={actions[comment.flag]}
|
||||
postAction={this.props.postAction}
|
||||
deleteAction={this.props.deleteAction}
|
||||
addItem={this.props.addItem}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
updateItem={this.props.updateItem}
|
||||
banned={banned}
|
||||
currentUser={this.props.auth.user}/>
|
||||
<PermalinkButton
|
||||
commentId={commentId}
|
||||
articleURL={this.path}/>
|
||||
</div>
|
||||
<ReplyBox
|
||||
addNotification={this.props.addNotification}
|
||||
postItem={this.props.postItem}
|
||||
appendItemArray={this.props.appendItemArray}
|
||||
updateItem={this.props.updateItem}
|
||||
id={rootItemId}
|
||||
author={user}
|
||||
parent_id={commentId}
|
||||
premod={moderation}
|
||||
currentUser={user}
|
||||
charCount={charCountEnable && charCount}
|
||||
showReply={comment.showReply}/>
|
||||
{
|
||||
comment.children &&
|
||||
comment.children.map((replyId) => {
|
||||
let reply = this.props.items.comments[replyId];
|
||||
return <div className="reply" key={replyId} id={`c_${replyId}`}>
|
||||
<hr aria-hidden={true}/>
|
||||
<AuthorName author={users[reply.author_id]}/>
|
||||
<PubDate created_at={reply.created_at}/>
|
||||
<Content body={reply.body}/>
|
||||
<div className="replyActionsLeft">
|
||||
<ReplyButton
|
||||
updateItem={this.props.updateItem}
|
||||
id={replyId}
|
||||
banned={banned}
|
||||
currentUser={this.props.auth.user}
|
||||
showReply={reply.showReply}/>
|
||||
<LikeButton
|
||||
addNotification={this.props.addNotification}
|
||||
id={replyId}
|
||||
like={this.props.items.actions[reply.like]}
|
||||
postAction={this.props.postAction}
|
||||
deleteAction={this.props.deleteAction}
|
||||
addItem={this.props.addItem}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
updateItem={this.props.updateItem}
|
||||
currentUser={this.props.auth.user}
|
||||
banned={banned}/>
|
||||
</div>
|
||||
<div className="replyActionsRight">
|
||||
<FlagComment
|
||||
addNotification={this.props.addNotification}
|
||||
id={replyId}
|
||||
author_id={comment.author_id}
|
||||
flag={actions[reply.flag]}
|
||||
postAction={this.props.postAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
deleteAction={this.props.deleteAction}
|
||||
addItem={this.props.addItem}
|
||||
updateItem={this.props.updateItem}
|
||||
banned={banned}
|
||||
currentUser={this.props.auth.user}/>
|
||||
<PermalinkButton
|
||||
commentId={reply.parent_id}
|
||||
articleURL={this.path}
|
||||
/>
|
||||
</div>
|
||||
<ReplyBox
|
||||
addNotification={this.props.addNotification}
|
||||
postItem={this.props.postItem}
|
||||
appendItemArray={this.props.appendItemArray}
|
||||
updateItem={this.props.updateItem}
|
||||
id={rootItemId}
|
||||
author={user}
|
||||
parent_id={commentId}
|
||||
child_id={replyId}
|
||||
premod={moderation}
|
||||
banned={banned}
|
||||
currentUser={user}
|
||||
charCount={charCountEnable && charCount}
|
||||
showReply={reply.showReply}/>
|
||||
</div>;
|
||||
})
|
||||
}
|
||||
</div>;
|
||||
})
|
||||
}
|
||||
<Notification
|
||||
notifLength={4500}
|
||||
clearNotification={this.props.clearNotification}
|
||||
notification={this.props.notification}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 1}>
|
||||
<SettingsContainer
|
||||
loggedIn={loggedIn}
|
||||
userData={this.props.userData}
|
||||
showSignInDialog={this.props.handleSignInDialog}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 2}>
|
||||
<RestrictedContent restricted={!loggedIn}>
|
||||
<ConfigureStreamContainer
|
||||
status={status}
|
||||
onClick={this.toggleStatus}
|
||||
/>
|
||||
</RestrictedContent>
|
||||
</TabContent>
|
||||
<Notification
|
||||
notifLength={4500}
|
||||
clearNotification={this.props.clearNotification}
|
||||
notification={this.props.notification}
|
||||
/>
|
||||
</div>
|
||||
:
|
||||
<Spinner/>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
config: state.config.toJS(),
|
||||
items: state.items.toJS(),
|
||||
notification: state.notification.toJS(),
|
||||
auth: state.auth.toJS(),
|
||||
userData: state.user.toJS()
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
addItem: (item, item_id) => dispatch(addItem(item, item_id)),
|
||||
updateItem: (id, property, value, itemType) => dispatch(updateItem(id, property, value, itemType)),
|
||||
postItem: (data, type, id) => dispatch(postItem(data, type, id)),
|
||||
getStream: (rootId) => dispatch(getStream(rootId)),
|
||||
addNotification: (type, text) => dispatch(addNotification(type, text)),
|
||||
clearNotification: () => dispatch(clearNotification()),
|
||||
postAction: (item, itemType, action) => dispatch(postAction(item, itemType, action)),
|
||||
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
|
||||
deleteAction: (item, action, user, itemType) => dispatch(deleteAction(item, action, user, itemType)),
|
||||
appendItemArray: (item, property, value, addToFront, itemType) => dispatch(appendItemArray(item, property, value, addToFront, itemType)),
|
||||
handleSignInDialog: () => dispatch(authActions.showSignInDialog()),
|
||||
logout: () => dispatch(logout())
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(CommentStream);
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, {Component} from 'react';
|
||||
import {compose} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
import {TabBar, Tab, TabContent, Spinner} from '../../coral-ui';
|
||||
|
||||
const {logout, showSignInDialog} = authActions;
|
||||
const {addNotification, clearNotification} = notificationActions;
|
||||
const {fetchAssetSuccess} = assetActions;
|
||||
|
||||
import {queryStream} from 'coral-framework/graphql/queries';
|
||||
import {postComment, postAction, deleteAction} from 'coral-framework/graphql/mutations';
|
||||
import {Notification, notificationActions, authActions, assetActions, pym} from 'coral-framework';
|
||||
|
||||
import Stream from './Stream';
|
||||
import InfoBox from 'coral-plugin-infobox/InfoBox';
|
||||
import Count from 'coral-plugin-comment-count/CommentCount';
|
||||
import CommentBox from 'coral-plugin-commentbox/CommentBox';
|
||||
import UserBox from '../../coral-sign-in/components/UserBox';
|
||||
import SignInContainer from '../../coral-sign-in/containers/SignInContainer';
|
||||
import SuspendedAccount from '../../coral-framework/components/SuspendedAccount';
|
||||
import SettingsContainer from '../../coral-settings/containers/SettingsContainer';
|
||||
import RestrictedContent from '../../coral-framework/components/RestrictedContent';
|
||||
import ConfigureStreamContainer from '../../coral-configure/containers/ConfigureStreamContainer';
|
||||
|
||||
class Embed extends Component {
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
activeTab: 0,
|
||||
showSignInDialog: false
|
||||
};
|
||||
|
||||
this.changeTab = this.changeTab.bind(this);
|
||||
}
|
||||
|
||||
changeTab (tab) {
|
||||
this.setState({
|
||||
activeTab: tab
|
||||
});
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
data: React.PropTypes.shape({
|
||||
loading: React.PropTypes.bool,
|
||||
error: React.PropTypes.object
|
||||
}).isRequired
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
|
||||
// stream id, logged in user, settings
|
||||
|
||||
// Set up messaging between embedded Iframe an parent component
|
||||
|
||||
// this.props.getStream(path || window.location);
|
||||
// this.path = window.location.href.split('#')[0];
|
||||
//
|
||||
|
||||
pym.sendMessage('childReady');
|
||||
|
||||
pym.onMessage('DOMContentLoaded', hash => {
|
||||
const commentId = hash.replace('#', 'c_');
|
||||
let count = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (document.getElementById(commentId)) {
|
||||
window.clearInterval(interval);
|
||||
pym.scrollParentToChildEl(commentId);
|
||||
}
|
||||
|
||||
if (++count > 100) { // ~10 seconds
|
||||
// give up waiting for the comments to load.
|
||||
// it would be weird for the page to jump after that long.
|
||||
window.clearInterval(interval);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const {loadAsset} = this.props;
|
||||
if(!isEqual(nextProps.data.asset, this.props.data.asset)) {
|
||||
loadAsset(nextProps.data.asset);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const {activeTab} = this.state;
|
||||
const {closedAt} = this.props.asset;
|
||||
const {loading, asset, refetch} = this.props.data;
|
||||
const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth;
|
||||
|
||||
const openStream = closedAt === null;
|
||||
|
||||
const expandForLogin = showSignInDialog ? {
|
||||
minHeight: document.body.scrollHeight + 200
|
||||
} : {};
|
||||
|
||||
return <div style={expandForLogin}>
|
||||
{
|
||||
loading ? <Spinner/>
|
||||
: <div className="commentStream">
|
||||
<TabBar onChange={this.changeTab} activeTab={activeTab}>
|
||||
<Tab><Count count={asset.commentCount}/></Tab>
|
||||
<Tab>Settings</Tab>
|
||||
<Tab restricted={!isAdmin}>Configure Stream</Tab>
|
||||
</TabBar>
|
||||
{loggedIn && <UserBox user={user} logout={this.props.logout} />}
|
||||
<TabContent show={activeTab === 0}>
|
||||
{
|
||||
openStream
|
||||
? <div id="commentBox">
|
||||
<InfoBox
|
||||
content={asset.settings.infoBoxContent}
|
||||
enable={asset.settings.infoBoxEnable}
|
||||
/>
|
||||
<RestrictedContent restricted={false} restrictedComp={<SuspendedAccount />}>
|
||||
{
|
||||
user
|
||||
? <CommentBox
|
||||
commentPostedHandler={refetch}
|
||||
addNotification={this.props.addNotification}
|
||||
postItem={this.props.postItem}
|
||||
appendItemArray={this.props.appendItemArray}
|
||||
updateItem={this.props.updateItem}
|
||||
assetId={asset.id}
|
||||
premod={asset.settings.moderation}
|
||||
isReply={false}
|
||||
currentUser={this.props.auth.user}
|
||||
banned={false}
|
||||
authorId={user.id}
|
||||
charCount={asset.settings.charCountEnable && asset.settings.charCount} />
|
||||
: null
|
||||
}
|
||||
</RestrictedContent>
|
||||
</div>
|
||||
: <p>{asset.settings.closedMessage}</p>
|
||||
}
|
||||
{!loggedIn && <SignInContainer offset={signInOffset}/>}
|
||||
<Stream
|
||||
refetch={refetch}
|
||||
addNotification={this.props.addNotification}
|
||||
postItem={this.props.postItem}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
postAction={this.props.postAction}
|
||||
deleteAction={this.props.deleteAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
comments={asset.comments} />
|
||||
<Notification
|
||||
notifLength={4500}
|
||||
clearNotification={this.props.clearNotification}
|
||||
notification={{text: null}}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 1}>
|
||||
<SettingsContainer
|
||||
loggedIn={loggedIn}
|
||||
userData={this.props.userData}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 2}>
|
||||
<RestrictedContent restricted={!loggedIn}>
|
||||
<ConfigureStreamContainer
|
||||
status={status}
|
||||
onClick={this.toggleStatus}
|
||||
/>
|
||||
</RestrictedContent>
|
||||
</TabContent>
|
||||
<Notification
|
||||
notifLength={4500}
|
||||
clearNotification={this.props.clearNotification}
|
||||
notification={this.props.notification}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
notification: state.notification.toJS(),
|
||||
auth: state.auth.toJS(),
|
||||
userData: state.user.toJS(),
|
||||
asset: state.asset.toJS()
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
loadAsset: (asset) => dispatch(fetchAssetSuccess(asset)),
|
||||
addNotification: (type, text) => dispatch(addNotification(type, text)),
|
||||
clearNotification: () => dispatch(clearNotification()),
|
||||
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
|
||||
logout: () => dispatch(logout()),
|
||||
dispatch: d => dispatch(d)
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
postComment,
|
||||
postAction,
|
||||
deleteAction,
|
||||
queryStream
|
||||
)(Embed);
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import Comment from './Comment';
|
||||
|
||||
class Stream extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
refetch: PropTypes.func.isRequired,
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
postItem: PropTypes.func.isRequired,
|
||||
asset: PropTypes.object.isRequired,
|
||||
comments: PropTypes.array.isRequired,
|
||||
currentUser: PropTypes.shape({
|
||||
displayName: PropTypes.string,
|
||||
id: PropTypes.string
|
||||
})
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {activeReplyBox: ''};
|
||||
this.setActiveReplyBox = this.setActiveReplyBox.bind(this);
|
||||
}
|
||||
|
||||
setActiveReplyBox (reactKey) {
|
||||
if (!this.props.currentUser) {
|
||||
const offset = document.getElementById(`c_${reactKey}`).getBoundingClientRect().top - 75;
|
||||
this.props.showSignInDialog(offset);
|
||||
} else {
|
||||
this.setState({activeReplyBox: reactKey});
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
comments,
|
||||
currentUser,
|
||||
asset,
|
||||
postItem,
|
||||
addNotification,
|
||||
postAction,
|
||||
deleteAction,
|
||||
showSignInDialog,
|
||||
refetch
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
comments.map(comment => {
|
||||
return <Comment
|
||||
refetch={refetch}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.state.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postItem={postItem}
|
||||
asset={asset}
|
||||
currentUser={currentUser}
|
||||
postAction={postAction}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
key={comment.id}
|
||||
reactKey={comment.id}
|
||||
comment={comment} />;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Stream;
|
||||
@@ -1,11 +1,15 @@
|
||||
import React from 'react';
|
||||
import {render} from 'react-dom';
|
||||
import CommentStream from './CommentStream';
|
||||
import {Provider} from 'react-redux';
|
||||
import {store} from '../../coral-framework';
|
||||
import {ApolloProvider} from 'react-apollo';
|
||||
|
||||
import {client} from 'coral-framework/client';
|
||||
import store from 'coral-framework/store';
|
||||
|
||||
import Embed from './Embed';
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<CommentStream />
|
||||
</Provider>
|
||||
, document.querySelector('#coralStream'));
|
||||
<ApolloProvider client={client} store={store}>
|
||||
<Embed />
|
||||
</ApolloProvider>
|
||||
, document.querySelector('#coralStream')
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import Pym from '../../node_modules/pym.js';
|
||||
|
||||
const pym = new Pym.Child({polling: 100});
|
||||
export default pym;
|
||||
|
||||
export const link = (url) => (e) => {
|
||||
e.preventDefault();
|
||||
pym.sendMessage('navigate', url);
|
||||
};
|
||||
+16
-19
@@ -1,42 +1,39 @@
|
||||
import * as actions from '../constants/asset';
|
||||
import coralApi from '../helpers/response';
|
||||
import * as actions from '../constants/config';
|
||||
import {addNotification} from '../actions/notification';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from './../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const updateConfigRequest = () => ({type: actions.UPDATE_CONFIG_REQUEST});
|
||||
const updateConfigSuccess = config => ({type: actions.UPDATE_CONFIG_SUCCESS, config});
|
||||
const updateConfigFailure = () => ({type: actions.UPDATE_CONFIG_FAILURE});
|
||||
export const fetchAssetRequest = () => ({type: actions.FETCH_ASSET_REQUEST});
|
||||
export const fetchAssetSuccess = asset => ({type: actions.FETCH_ASSET_SUCCESS, asset});
|
||||
export const fetchAssetFailure = error => ({type: actions.FETCH_ASSET_FAILURE, error});
|
||||
|
||||
const updateAssetSettingsRequest = () => ({type: actions.UPDATE_ASSET_SETTINGS_REQUEST});
|
||||
const updateAssetSettingsSuccess = settings => ({type: actions.UPDATE_ASSET_SETTINGS_SUCCESS, settings});
|
||||
const updateAssetSettingsFailure = () => ({type: actions.UPDATE_ASSET_SETTINGS_FAILURE});
|
||||
|
||||
export const updateConfiguration = newConfig => (dispatch, getState) => {
|
||||
const assetId = getState().items.get('assets')
|
||||
.keySeq()
|
||||
.toArray()[0];
|
||||
|
||||
dispatch(updateConfigRequest());
|
||||
const assetId = getState().asset.toJS().id;
|
||||
dispatch(updateAssetSettingsRequest());
|
||||
coralApi(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig})
|
||||
.then(() => {
|
||||
dispatch(addNotification('success', lang.t('successUpdateSettings')));
|
||||
dispatch(updateConfigSuccess(newConfig));
|
||||
dispatch(updateAssetSettingsSuccess(newConfig));
|
||||
})
|
||||
.catch(error => dispatch(updateConfigFailure(error)));
|
||||
.catch(error => dispatch(updateAssetSettingsFailure(error)));
|
||||
};
|
||||
|
||||
export const updateOpenStream = closedBody => (dispatch, getState) => {
|
||||
const assetId = getState().items.get('assets')
|
||||
.keySeq()
|
||||
.toArray()[0];
|
||||
|
||||
dispatch(updateConfigRequest());
|
||||
|
||||
const assetId = getState().asset.toJS().id;
|
||||
dispatch(fetchAssetRequest());
|
||||
coralApi(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody})
|
||||
.then(() => {
|
||||
dispatch(addNotification('success', lang.t('successUpdateSettings')));
|
||||
dispatch(updateConfigSuccess(closedBody));
|
||||
dispatch(fetchAssetSuccess(closedBody));
|
||||
})
|
||||
.catch(error => dispatch(updateConfigFailure(error)));
|
||||
.catch(error => dispatch(fetchAssetFailure(error)));
|
||||
};
|
||||
|
||||
const openStream = () => ({type: actions.OPEN_COMMENTS});
|
||||
@@ -3,7 +3,6 @@ import translations from './../translations';
|
||||
const lang = new I18n(translations);
|
||||
import * as actions from '../constants/auth';
|
||||
import coralApi, {base} from '../helpers/response';
|
||||
import {addItem} from './items';
|
||||
|
||||
// Dialog Actions
|
||||
export const showSignInDialog = (offset = 0) => ({type: actions.SHOW_SIGNIN_DIALOG, offset});
|
||||
@@ -27,10 +26,9 @@ export const fetchSignIn = (formData) => (dispatch) => {
|
||||
dispatch(signInRequest());
|
||||
coralApi('/auth/local', {method: 'POST', body: formData})
|
||||
.then(({user}) => {
|
||||
const isAdmin = !!user.roles.filter(i => i === 'admin').length;
|
||||
const isAdmin = !!user.roles.filter(i => i === 'ADMIN').length;
|
||||
dispatch(signInSuccess(user, isAdmin));
|
||||
dispatch(hideSignInDialog());
|
||||
dispatch(addItem(user, 'users'));
|
||||
})
|
||||
.catch(() => dispatch(signInFailure(lang.t('error.emailPasswordError'))));
|
||||
};
|
||||
@@ -59,7 +57,6 @@ export const facebookCallback = (err, data) => dispatch => {
|
||||
const user = JSON.parse(data);
|
||||
dispatch(signInFacebookSuccess(user));
|
||||
dispatch(hideSignInDialog());
|
||||
dispatch(addItem(user, 'users'));
|
||||
} catch (err) {
|
||||
dispatch(signInFacebookFailure(err));
|
||||
return;
|
||||
@@ -132,8 +129,11 @@ export const checkLogin = () => dispatch => {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
|
||||
const isAdmin = !!result.user.roles.filter(i => i === 'admin').length;
|
||||
const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length;
|
||||
dispatch(checkLoginSuccess(result.user, isAdmin));
|
||||
})
|
||||
.catch(error => dispatch(checkLoginFailure(error)));
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
dispatch(checkLoginFailure(`${error.message}`));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
import coralApi from '../helpers/response';
|
||||
import {fromJS} from 'immutable';
|
||||
import {UPDATE_CONFIG} from '../constants/config';
|
||||
|
||||
/**
|
||||
* Action name constants
|
||||
*/
|
||||
|
||||
export const ADD_ITEM = 'ADD_ITEM';
|
||||
export const UPDATE_ITEM = 'UPDATE_ITEM';
|
||||
export const APPEND_ITEM_ARRAY = 'APPEND_ITEM_ARRAY';
|
||||
|
||||
/**
|
||||
* Action creators
|
||||
*/
|
||||
|
||||
/*
|
||||
* Adds an item to the local store without posting it to the server
|
||||
* Useful for optimistic posting, etc.
|
||||
*
|
||||
* @params
|
||||
* item - the item to be posted
|
||||
*
|
||||
*/
|
||||
|
||||
export const addItem = (item, item_type) => {
|
||||
if (!item.id) {
|
||||
console.warn('addItem called without an item id.');
|
||||
}
|
||||
return {
|
||||
type: ADD_ITEM,
|
||||
item,
|
||||
item_type,
|
||||
id: item.id
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
* Updates an item in the local store without posting it to the server
|
||||
* Useful for item-level toggles, etc.
|
||||
*
|
||||
* @params
|
||||
* id - the id of the item to be posted
|
||||
* property - the property to be updated
|
||||
* value - the value that the property should be set to
|
||||
* item_type - the type of the item being updated (users, comments, etc)
|
||||
*
|
||||
*/
|
||||
export const updateItem = (id, property, value, item_type) => {
|
||||
return {
|
||||
type: UPDATE_ITEM,
|
||||
id,
|
||||
property,
|
||||
value,
|
||||
item_type
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
* Appends data to an array in an item in the local store without posting it to the server
|
||||
* Useful for adding a recently posted reply to a comment, etc.
|
||||
*
|
||||
* @params
|
||||
* id - the id of the item to be posted
|
||||
* property - the property to be updated (should be an array)
|
||||
* value - the value that should be added to the array
|
||||
* add_to_front - boolean that defines whether value is added at the beginning (unshift) or end (push)
|
||||
* item_type - the type of the item being updated (users, comments, etc)
|
||||
*
|
||||
*/
|
||||
export const appendItemArray = (id, property, value, add_to_front, item_type) => {
|
||||
return {
|
||||
type: APPEND_ITEM_ARRAY,
|
||||
id,
|
||||
property,
|
||||
value,
|
||||
add_to_front,
|
||||
item_type
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
* Get Items from Query
|
||||
* Gets a set of items from a predefined query
|
||||
*
|
||||
* @params
|
||||
* Query - a predefiend query for retreiving items
|
||||
*
|
||||
* @returns
|
||||
* A promise resolving to a set of items
|
||||
*
|
||||
* @dispatches
|
||||
* A set of items to the item store
|
||||
*/
|
||||
export function getStream (assetUrl) {
|
||||
return (dispatch) => {
|
||||
return coralApi(`/stream?asset_url=${encodeURIComponent(assetUrl)}`)
|
||||
.then((json) => {
|
||||
|
||||
/* Add items to the store */
|
||||
Object.keys(json).forEach(type => {
|
||||
if (type === 'actions') {
|
||||
json[type].forEach(action => {
|
||||
action.id = `${action.action_type}_${action.item_id}`;
|
||||
dispatch(addItem(action, 'actions'));
|
||||
});
|
||||
} else if (type === 'settings') {
|
||||
dispatch({type: UPDATE_CONFIG, config: fromJS(json[type])});
|
||||
} else {
|
||||
json[type].forEach(item => {
|
||||
dispatch(addItem(item, type));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const assetId = json.assets[0].id;
|
||||
|
||||
/* Sort comments by date*/
|
||||
json.comments.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
const rels = json.comments.reduce((h, item) => {
|
||||
|
||||
/* Check for root and child comments. */
|
||||
if (
|
||||
item.asset_id === assetId &&
|
||||
!item.parent_id) {
|
||||
h.rootComments.push(item.id);
|
||||
} else if (
|
||||
item.asset_id === assetId
|
||||
) {
|
||||
let children = h.childComments[item.parent_id] || [];
|
||||
h.childComments[item.parent_id] = children.concat(item.id);
|
||||
}
|
||||
return h;
|
||||
}, {rootComments: [], childComments: {}});
|
||||
|
||||
dispatch(updateItem(assetId, 'comments', rels.rootComments, 'assets'));
|
||||
|
||||
Object.keys(rels.childComments).forEach(key => {
|
||||
dispatch(updateItem(key, 'children', rels.childComments[key].reverse(), 'comments'));
|
||||
});
|
||||
|
||||
/* Hydrate actions on comments */
|
||||
json.actions.forEach(action => {
|
||||
dispatch(updateItem(action.item_id, action.action_type, action.id, 'comments'));
|
||||
});
|
||||
|
||||
return (json);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Get Items Array
|
||||
* Gets a set of items from an array of item ids
|
||||
*
|
||||
* @params
|
||||
* Query - a predefiend query for retreiving items
|
||||
*
|
||||
* @returns
|
||||
* A promise resolving to a set of items
|
||||
*
|
||||
* @dispatches
|
||||
* A set of items to the item store
|
||||
*/
|
||||
|
||||
export function getItemsArray (ids) {
|
||||
return (dispatch) => {
|
||||
return coralApi(`/item/${ids}`)
|
||||
.then((json) => {
|
||||
for (let i = 0; i < json.items.length; i++) {
|
||||
dispatch(addItem(json.items[i]));
|
||||
}
|
||||
return json.items;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* PutItem
|
||||
* Puts an item
|
||||
*
|
||||
* @params
|
||||
* Item - the item to be put
|
||||
*
|
||||
* @returns
|
||||
* A promise resolving to an item is
|
||||
*
|
||||
* @dispatches
|
||||
* The newly put item to the item store
|
||||
*/
|
||||
|
||||
export function postItem (item, type, id) {
|
||||
return (dispatch) => {
|
||||
if (id) {
|
||||
item.id = id;
|
||||
}
|
||||
return coralApi(`/${type}`, {method: 'POST', body: item})
|
||||
.then((json) => {
|
||||
dispatch(addItem({...item, id:json.id}, type));
|
||||
return json;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* PostAction
|
||||
* Posts an action to an item
|
||||
*
|
||||
* @params
|
||||
* id - the id of the item on which the action is taking place
|
||||
* action - the action object.
|
||||
* Must include an 'action_type' string.
|
||||
* May optionally include a `metadata` object with arbitrary action data.
|
||||
* user - the user performing the action
|
||||
* host - the coral host
|
||||
*
|
||||
* @returns
|
||||
* A promise resolving to null or an error
|
||||
*
|
||||
*/
|
||||
|
||||
export function postAction (item_id, item_type, action) {
|
||||
return (dispatch) => {
|
||||
return coralApi(`/${item_type}/${item_id}/actions`, {method: 'POST', body: action})
|
||||
.then((json) => {
|
||||
dispatch(updateItem(action.item_id, action.action_type, action.id, item_type));
|
||||
return json;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* DeleteAction
|
||||
* Deletes an action to an item
|
||||
*
|
||||
* @params
|
||||
* id - the id of the item on which the action is taking place
|
||||
* action - the name of the action
|
||||
* user - the user performing the action
|
||||
* host - the coral host
|
||||
*
|
||||
* @returns
|
||||
* A promise resolving to null or an error
|
||||
*
|
||||
*/
|
||||
|
||||
export function deleteAction (action_id) {
|
||||
return () => {
|
||||
return coralApi(`/actions/${action_id}`, {method: 'DELETE'});
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import * as actions from '../constants/user';
|
||||
import * as assetActions from '../constants/assets';
|
||||
import {addNotification} from '../actions/notification';
|
||||
import {addItem} from '../actions/items';
|
||||
import coralApi from '../helpers/response';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
@@ -21,36 +19,3 @@ export const saveBio = (user_id, formData) => dispatch => {
|
||||
})
|
||||
.catch(error => dispatch(saveBioFailure(error)));
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* Get a list of comments by a single user
|
||||
*
|
||||
* @param {string} user_id
|
||||
* @returns Promise
|
||||
*/
|
||||
export const fetchCommentsByUserId = userId => {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({type: actions.COMMENTS_BY_USER_REQUEST});
|
||||
return coralApi(`/comments?user_id=${userId}`)
|
||||
.then(({comments, assets}) => {
|
||||
const state = getState();
|
||||
comments.forEach(comment => dispatch(addItem(comment, 'comments')));
|
||||
assets.forEach(asset => {
|
||||
const prevAsset = state.items.getIn(['assets', asset.id]);
|
||||
|
||||
if (prevAsset) {
|
||||
|
||||
// Include data such as hydrated comments from assets already in the system.
|
||||
dispatch(addItem({...prevAsset.toJS(), ...asset}, 'assets'));
|
||||
} else {
|
||||
dispatch(addItem(asset, 'assets'));
|
||||
}
|
||||
});
|
||||
|
||||
dispatch({type: actions.COMMENTS_BY_USER_SUCCESS, comments: comments.map(comment => comment.id)});
|
||||
dispatch({type: assetActions.MULTIPLE_ASSETS_SUCCESS, assets: assets.map(asset => asset.id)});
|
||||
})
|
||||
.catch(error => dispatch({type: actions.COMMENTS_BY_USER_FAILURE, error}));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import ApolloClient, {addTypename} from 'apollo-client';
|
||||
import getNetworkInterface from './transport';
|
||||
|
||||
export const client = new ApolloClient({
|
||||
connectToDevTools: true,
|
||||
queryTransformer: addTypename,
|
||||
dataIdFromObject: (result) => {
|
||||
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
|
||||
return result.__typename + result.id; // eslint-disable-line no-underscore-dangle
|
||||
}
|
||||
return null;
|
||||
},
|
||||
networkInterface: getNetworkInterface()
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export const FETCH_ASSET_REQUEST = 'FETCH_ASSET_REQUEST';
|
||||
export const FETCH_ASSET_FAILURE = 'FETCH_ASSET_FAILURE';
|
||||
export const FETCH_ASSET_SUCCESS = 'FETCH_ASSET_SUCCESS';
|
||||
|
||||
export const UPDATE_ASSET_SETTINGS_REQUEST = 'UPDATE_ASSET_SETTINGS_REQUEST';
|
||||
export const UPDATE_ASSET_SETTINGS_SUCCESS = 'UPDATE_ASSET_SETTINGS_SUCCESS';
|
||||
export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE';
|
||||
|
||||
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
|
||||
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
|
||||
@@ -0,0 +1,21 @@
|
||||
fragment commentView on Comment {
|
||||
id
|
||||
body
|
||||
created_at
|
||||
status
|
||||
user {
|
||||
id
|
||||
name: displayName
|
||||
settings {
|
||||
bio
|
||||
}
|
||||
}
|
||||
actions {
|
||||
type: action_type
|
||||
count
|
||||
current: current_user {
|
||||
id
|
||||
created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation deleteAction ($id: ID!) {
|
||||
deleteAction(id:$id)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {graphql} from 'react-apollo';
|
||||
import POST_COMMENT from './postComment.graphql';
|
||||
import POST_ACTION from './postAction.graphql';
|
||||
import DELETE_ACTION from './deleteAction.graphql';
|
||||
|
||||
import commentView from '../fragments/commentView.graphql';
|
||||
|
||||
export const postComment = graphql(POST_COMMENT, {
|
||||
options: () => ({
|
||||
fragments: commentView
|
||||
}),
|
||||
props: ({mutate}) => ({
|
||||
postItem: ({asset_id, body, parent_id} /* , type */ ) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
asset_id,
|
||||
body,
|
||||
parent_id
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const postAction = graphql(POST_ACTION, {
|
||||
props: ({mutate}) => ({
|
||||
postAction: (action) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
action
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const deleteAction = graphql(DELETE_ACTION, {
|
||||
props: ({mutate}) => ({
|
||||
deleteAction: (id) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation CreateAction ($action: CreateActionInput!) {
|
||||
createAction(action:$action) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#import "../fragments/commentView.graphql"
|
||||
|
||||
mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) {
|
||||
createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) {
|
||||
...commentView
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {graphql} from 'react-apollo';
|
||||
import STREAM_QUERY from './streamQuery.graphql';
|
||||
import MY_COMMENT_HISTORY from './myCommentHistory.graphql';
|
||||
|
||||
function getQueryVariable(variable) {
|
||||
let query = window.location.search.substring(1);
|
||||
let vars = query.split('&');
|
||||
for (let i = 0; i < vars.length; i++) {
|
||||
let pair = vars[i].split('=');
|
||||
if (decodeURIComponent(pair[0]) === variable) {
|
||||
return decodeURIComponent(pair[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const queryStream = graphql(STREAM_QUERY, {
|
||||
options: () => ({
|
||||
variables: {
|
||||
asset_url: getQueryVariable('asset_url')
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export const myCommentHistory = graphql(MY_COMMENT_HISTORY, {});
|
||||
@@ -0,0 +1,9 @@
|
||||
query myCommentHistory {
|
||||
me {
|
||||
comments {
|
||||
id
|
||||
body
|
||||
created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#import "../fragments/commentView.graphql"
|
||||
|
||||
query AssetQuery($asset_url: String!) {
|
||||
asset(url: $asset_url) {
|
||||
id
|
||||
title
|
||||
url
|
||||
closedAt
|
||||
created_at
|
||||
settings {
|
||||
moderation
|
||||
infoBoxEnable
|
||||
infoBoxContent
|
||||
closeTimeout
|
||||
closedMessage
|
||||
charCountEnable
|
||||
charCount
|
||||
requireEmailConfirmation
|
||||
}
|
||||
commentCount
|
||||
comments {
|
||||
...commentView
|
||||
replies {
|
||||
...commentView
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export default {
|
||||
email: email => (/^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)),
|
||||
email: email => (/^([A-Za-z0-9_\-\.\+])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)),
|
||||
password: pass => (/^(?=.{8,}).*$/.test(pass)),
|
||||
confirmPassword: () => true,
|
||||
displayName: displayName => (/^[a-zA-Z0-9_]+$/.test(displayName))
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import Notification from './modules/notification/Notification';
|
||||
import store from './store';
|
||||
import * as itemActions from './actions/items';
|
||||
import pym from './PymConnection';
|
||||
import I18n from './modules/i18n/i18n';
|
||||
import * as notificationActions from './actions/notification';
|
||||
import * as authActions from './actions/auth';
|
||||
import * as configActions from './actions/config';
|
||||
import * as assetActions from './actions/asset';
|
||||
import * as notificationActions from './actions/notification';
|
||||
import Notification from './modules/notification/Notification';
|
||||
|
||||
export {
|
||||
Notification,
|
||||
store,
|
||||
itemActions,
|
||||
pym,
|
||||
I18n,
|
||||
notificationActions,
|
||||
store,
|
||||
authActions,
|
||||
configActions
|
||||
assetActions,
|
||||
Notification,
|
||||
notificationActions
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {Map} from 'immutable';
|
||||
import * as actions from '../constants/asset';
|
||||
|
||||
const initialState = Map({
|
||||
closedAt: null,
|
||||
settings: null,
|
||||
title: null,
|
||||
url: null,
|
||||
features: Map({}),
|
||||
status: 'open',
|
||||
moderation: null
|
||||
});
|
||||
|
||||
export default function asset (state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.FETCH_ASSET_SUCCESS:
|
||||
return state
|
||||
.merge(action.asset);
|
||||
case actions.UPDATE_ASSET_SETTINGS_SUCCESS:
|
||||
return state
|
||||
.setIn(['settings'], action.settings);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import {Map} from 'immutable';
|
||||
import * as actions from '../constants/config';
|
||||
|
||||
const initialState = Map({
|
||||
features: Map({}),
|
||||
status: 'open',
|
||||
moderation: null
|
||||
});
|
||||
|
||||
export default (state = initialState, action) => {
|
||||
switch(action.type) {
|
||||
case actions.UPDATE_CONFIG:
|
||||
return state
|
||||
.merge(Map(action.config));
|
||||
case actions.UPDATE_CONFIG_SUCCESS:
|
||||
return state
|
||||
.merge(Map(action.config));
|
||||
case actions.OPEN_COMMENTS:
|
||||
return state
|
||||
.set('status', 'open');
|
||||
case actions.CLOSE_COMMENTS:
|
||||
return state
|
||||
.set('status', 'closed');
|
||||
case actions.ADD_ITEM:
|
||||
return action.item_type === 'assets' ? state.set('status', (action.item && action.item.closedAt && new Date(action.item.closedAt).getTime() <= new Date().getTime()) ? 'closed' : 'open') : state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
@@ -1,20 +1,11 @@
|
||||
/* @flow */
|
||||
|
||||
import {combineReducers} from 'redux';
|
||||
import config from './config';
|
||||
import items from './items';
|
||||
import notification from './notification';
|
||||
import auth from './auth';
|
||||
import user from './user';
|
||||
import asset from './asset';
|
||||
import notification from './notification';
|
||||
|
||||
/**
|
||||
* Expose the combined main reducer
|
||||
*/
|
||||
|
||||
export default combineReducers({
|
||||
config,
|
||||
items,
|
||||
notification,
|
||||
export default {
|
||||
auth,
|
||||
user
|
||||
});
|
||||
user,
|
||||
asset,
|
||||
notification
|
||||
};
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/* Items Reducer */
|
||||
|
||||
import {fromJS} from 'immutable';
|
||||
import * as actions from '../actions/items';
|
||||
|
||||
const initialState = fromJS({
|
||||
comments: {},
|
||||
users: {},
|
||||
assets: {},
|
||||
actions: {}
|
||||
});
|
||||
|
||||
export default (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case actions.ADD_ITEM:
|
||||
return state.setIn([action.item_type, action.id], fromJS(action.item));
|
||||
case actions.UPDATE_ITEM:
|
||||
return state.setIn([action.item_type, action.id, action.property], fromJS(action.value));
|
||||
case actions.APPEND_ITEM_ARRAY:
|
||||
return state.updateIn([action.item_type, action.id, action.property], (prop) => {
|
||||
if (action.add_to_front) {
|
||||
return prop ? prop.unshift(fromJS(action.value)) : fromJS([action.value]);
|
||||
} else {
|
||||
return prop ? prop.push(fromJS(action.value)) : fromJS([action.value]);
|
||||
}
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,24 @@
|
||||
import {createStore, applyMiddleware} from 'redux';
|
||||
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import mainReducer from './reducers';
|
||||
import {client} from './client';
|
||||
|
||||
const middlewares = [
|
||||
applyMiddleware(client.middleware()),
|
||||
applyMiddleware(thunk)
|
||||
];
|
||||
|
||||
if (window.devToolsExtension) {
|
||||
|
||||
// we can't have the last argument of compose() be undefined
|
||||
middlewares.push(window.devToolsExtension());
|
||||
}
|
||||
|
||||
export default createStore(
|
||||
mainReducer,
|
||||
window.devToolsExtension && window.devToolsExtension(),
|
||||
applyMiddleware(thunk)
|
||||
combineReducers({
|
||||
...mainReducer,
|
||||
apollo: client.reducer()
|
||||
}),
|
||||
{},
|
||||
compose(...middlewares)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import {print} from 'graphql-tag/printer';
|
||||
|
||||
// quick way to add the subscribe and unsubscribe functions to the network interface
|
||||
const addGraphQLSubscriptions = (networkInterface, wsClient) => Object.assign(networkInterface, {
|
||||
subscribe: (request, handler) => wsClient.subscribe({
|
||||
query: print(request.query),
|
||||
variables: request.variables,
|
||||
}, handler),
|
||||
unsubscribe: (id) => {
|
||||
wsClient.unsubscribe(id);
|
||||
},
|
||||
});
|
||||
|
||||
export default addGraphQLSubscriptions;
|
||||
@@ -0,0 +1,11 @@
|
||||
import {createNetworkInterface} from 'apollo-client';
|
||||
|
||||
export default function getNetworkInterface(apiUrl = '/api/v1/graph/ql', headers = {}) {
|
||||
return new createNetworkInterface({
|
||||
uri: apiUrl,
|
||||
opts: {
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -36,8 +36,8 @@ export default class AuthorName extends Component {
|
||||
onMouseOver={this.handleMouseOver}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
>
|
||||
{author && author.displayName}
|
||||
{ showTooltip && <Tooltip>
|
||||
{author && author.name}
|
||||
{ showTooltip && author.settings.bio && <Tooltip>
|
||||
<div className={`${packagename}-bio`}>
|
||||
{author.settings.bio}
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
import React from 'react';
|
||||
import React, {PropTypes} from 'react';
|
||||
import {I18n} from '../coral-framework';
|
||||
import translations from './translations.json';
|
||||
import has from 'lodash/has';
|
||||
import reduce from 'lodash/reduce';
|
||||
const name = 'coral-plugin-comment-count';
|
||||
|
||||
const CommentCount = ({items, id}) => {
|
||||
let count = 0;
|
||||
if (has(items, `assets.${id}.comments`)) {
|
||||
count += items.assets[id].comments.length;
|
||||
}
|
||||
|
||||
// lodash reduce works on {}
|
||||
count += reduce(items.comments, (accum, comment) => {
|
||||
if (comment.children) {
|
||||
accum += comment.children.length;
|
||||
}
|
||||
return accum;
|
||||
}, 0);
|
||||
|
||||
const CommentCount = ({count}) => {
|
||||
return <div className={`${name}-text`}>
|
||||
{`${count} ${count === 1 ? lang.t('comment') : lang.t('comment-plural')}`}
|
||||
</div>;
|
||||
};
|
||||
|
||||
CommentCount.propTypes = {
|
||||
count: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default CommentCount;
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
@@ -8,11 +8,16 @@ const name = 'coral-plugin-commentbox';
|
||||
class CommentBox extends Component {
|
||||
|
||||
static propTypes = {
|
||||
postItem: PropTypes.func,
|
||||
updateItem: PropTypes.func,
|
||||
id: PropTypes.string,
|
||||
comments: PropTypes.array,
|
||||
reply: PropTypes.bool,
|
||||
|
||||
// updateItem: PropTypes.func,
|
||||
// comments: PropTypes.array,
|
||||
commentPostedHandler: PropTypes.func,
|
||||
postItem: PropTypes.func.isRequired,
|
||||
cancelButtonClicked: PropTypes.func,
|
||||
assetId: PropTypes.string.isRequired,
|
||||
parentId: PropTypes.string,
|
||||
authorId: PropTypes.string.isRequired,
|
||||
isReply: PropTypes.bool.isRequired,
|
||||
canPost: PropTypes.bool,
|
||||
currentUser: PropTypes.object
|
||||
}
|
||||
@@ -24,73 +29,88 @@ class CommentBox extends Component {
|
||||
|
||||
postComment = () => {
|
||||
const {
|
||||
|
||||
// child_id,
|
||||
// updateItem,
|
||||
// appendItemArray,
|
||||
commentPostedHandler,
|
||||
postItem,
|
||||
updateItem,
|
||||
id,
|
||||
parent_id,
|
||||
child_id,
|
||||
assetId,
|
||||
parentId,
|
||||
addNotification,
|
||||
appendItemArray,
|
||||
premod,
|
||||
author
|
||||
authorId
|
||||
} = this.props;
|
||||
|
||||
let comment = {
|
||||
body: this.state.body,
|
||||
asset_id: id,
|
||||
author_id: author.id
|
||||
asset_id: assetId,
|
||||
author_id: authorId,
|
||||
parent_id: parentId
|
||||
};
|
||||
let related;
|
||||
let parent_type;
|
||||
if (parent_id) {
|
||||
comment.parent_id = parent_id;
|
||||
related = 'children';
|
||||
parent_type = 'comments';
|
||||
} else {
|
||||
related = 'comments';
|
||||
parent_type = 'assets';
|
||||
}
|
||||
if (child_id || parent_id) {
|
||||
updateItem(child_id || parent_id, 'showReply', false, 'comments');
|
||||
}
|
||||
|
||||
// let related;
|
||||
// let parent_type;
|
||||
// if (parent_id) {
|
||||
// comment.parent_id = parent_id;
|
||||
// related = 'children';
|
||||
// parent_type = 'comments';
|
||||
// } else {
|
||||
// related = 'comments';
|
||||
// parent_type = 'assets';
|
||||
// }
|
||||
// if (child_id || parent_id) {
|
||||
// updateItem(child_id || parent_id, 'showReply', false, 'comments');
|
||||
// }
|
||||
|
||||
if (this.props.charCount && this.state.body.length > this.props.charCount) {
|
||||
return;
|
||||
}
|
||||
postItem(comment, 'comments')
|
||||
.then((postedComment) => {
|
||||
const commentId = postedComment.id;
|
||||
if (postedComment.status === 'rejected') {
|
||||
.then(({data}) => {
|
||||
const postedComment = data.createComment;
|
||||
|
||||
// const commentId = postedComment.id;
|
||||
if (postedComment.status === 'REJECTED') {
|
||||
addNotification('error', lang.t('comment-post-banned-word'));
|
||||
} else if (premod === 'pre') {
|
||||
} else if (postedComment.status === 'PREMOD') {
|
||||
addNotification('success', lang.t('comment-post-notif-premod'));
|
||||
} else {
|
||||
appendItemArray(parent_id || id, related, commentId, !parent_id, parent_type);
|
||||
addNotification('success', 'Your comment has been posted.');
|
||||
}
|
||||
|
||||
if (commentPostedHandler) {
|
||||
commentPostedHandler();
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
this.setState({body: ''});
|
||||
}
|
||||
|
||||
render () {
|
||||
const {styles, reply, author, charCount} = this.props;
|
||||
const {styles, isReply, authorId, charCount} = this.props;
|
||||
let {cancelButtonClicked} = this.props;
|
||||
const length = this.state.body.length;
|
||||
|
||||
if (isReply && typeof cancelButtonClicked !== 'function') {
|
||||
console.warn('the CommentBox component should have a cancelButtonClicked callback defined if it lives in a Reply');
|
||||
cancelButtonClicked = () => {};
|
||||
}
|
||||
|
||||
return <div>
|
||||
<div
|
||||
className={`${name}-container`}>
|
||||
<label
|
||||
htmlFor={ reply ? 'replyText' : 'commentText'}
|
||||
htmlFor={ isReply ? 'replyText' : 'commentText'}
|
||||
className="screen-reader-text"
|
||||
aria-hidden={true}>
|
||||
{reply ? lang.t('reply') : lang.t('comment')}
|
||||
{isReply ? lang.t('reply') : lang.t('comment')}
|
||||
</label>
|
||||
<textarea
|
||||
className={`${name}-textarea`}
|
||||
style={styles && styles.textarea}
|
||||
value={this.state.body}
|
||||
placeholder={lang.t('comment')}
|
||||
id={reply ? 'replyText' : 'commentText'}
|
||||
id={isReply ? 'replyText' : 'commentText'}
|
||||
onChange={(e) => this.setState({body: e.target.value})}
|
||||
rows={3}/>
|
||||
</div>
|
||||
@@ -101,7 +121,20 @@ class CommentBox extends Component {
|
||||
}
|
||||
</div>
|
||||
<div className={`${name}-button-container`}>
|
||||
{ author && (
|
||||
{
|
||||
isReply && (
|
||||
<Button
|
||||
cStyle='darkGrey'
|
||||
className={`${name}-cancel-button`}
|
||||
onClick={() => {
|
||||
console.log('cancel button in comment box');
|
||||
cancelButtonClicked('');
|
||||
}}>
|
||||
{lang.t('cancel')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{ authorId && (
|
||||
<Button
|
||||
cStyle={!length || (charCount && length > charCount) ? 'lightGrey' : 'darkGrey'}
|
||||
className={`${name}-button`}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"en": {
|
||||
"post": "Post",
|
||||
"cancel": "Cancel",
|
||||
"reply": "Reply",
|
||||
"comment": "Comment",
|
||||
"name": "Name",
|
||||
@@ -11,6 +12,7 @@
|
||||
},
|
||||
"es": {
|
||||
"post": "Publicar",
|
||||
"cancel": "Cancelar",
|
||||
"reply": "Respuesta",
|
||||
"comment": "Comentario",
|
||||
"name": "Nombre",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
const name = 'coral-plugin-replies';
|
||||
const name = 'coral-plugin-content';
|
||||
|
||||
const Content = ({body, styles}) => {
|
||||
const textbreaks = body.split('\n');
|
||||
|
||||
@@ -9,7 +9,7 @@ const getPopupMenu = [
|
||||
() => {
|
||||
return {
|
||||
header: lang.t('step-2-header'),
|
||||
itemType: 'user',
|
||||
itemType: 'USERS',
|
||||
field: 'bio',
|
||||
options: [
|
||||
{val: 'This bio is offensive', text: lang.t('bio-offensive')},
|
||||
|
||||
@@ -14,22 +14,32 @@ class FlagButton extends Component {
|
||||
reason: '',
|
||||
note: '',
|
||||
step: 0,
|
||||
posted: false
|
||||
posted: false,
|
||||
localPost: null,
|
||||
localDelete: false
|
||||
}
|
||||
|
||||
// When the "report" button is clicked expand the menu
|
||||
onReportClick = () => {
|
||||
if (!this.props.currentUser) {
|
||||
const {currentUser, flag, deleteAction} = this.props;
|
||||
const {localPost, localDelete} = this.state;
|
||||
const flagged = (flag && flag.current && !localDelete) || localPost;
|
||||
if (!currentUser) {
|
||||
const offset = document.getElementById(`c_${this.props.id}`).getBoundingClientRect().top - 75;
|
||||
this.props.showSignInDialog(offset);
|
||||
return;
|
||||
}
|
||||
this.setState({showMenu: !this.state.showMenu});
|
||||
if (flagged) {
|
||||
this.setState((prev) => prev.localPost ? {...prev, localPost: null, step: 0} : {...prev, localDelete: true});
|
||||
deleteAction(localPost || flag.current.id);
|
||||
} else {
|
||||
this.setState({showMenu: !this.state.showMenu});
|
||||
}
|
||||
}
|
||||
|
||||
onPopupContinue = () => {
|
||||
const {postAction, addItem, updateItem, flag, id, author_id} = this.props;
|
||||
const {itemType, field, reason, step, note, posted} = this.state;
|
||||
const {postAction, id, author_id} = this.props;
|
||||
const {itemType, reason, step, posted} = this.state;
|
||||
|
||||
// Proceed to the next step or close the menu if we've reached the end
|
||||
if (step + 1 >= this.props.getPopupMenu.length) {
|
||||
@@ -40,32 +50,32 @@ class FlagButton extends Component {
|
||||
|
||||
// If itemType and reason are both set, post the action
|
||||
if (itemType && reason && !posted) {
|
||||
this.setState({posted: true});
|
||||
|
||||
// Set the text from the "other" field if it exists.
|
||||
let item_id;
|
||||
switch(itemType) {
|
||||
case 'comments':
|
||||
case 'COMMENTS':
|
||||
item_id = id;
|
||||
break;
|
||||
case 'users':
|
||||
case 'USERS':
|
||||
item_id = author_id;
|
||||
break;
|
||||
}
|
||||
const action = {
|
||||
action_type: 'flag',
|
||||
metadata: {
|
||||
field,
|
||||
reason,
|
||||
note
|
||||
|
||||
// Note: Action metadata has been temporarily removed.
|
||||
if (itemType === 'COMMENTS') {
|
||||
this.setState({localPost: 'temp'});
|
||||
}
|
||||
postAction({
|
||||
item_id,
|
||||
item_type: itemType,
|
||||
action_type: 'FLAG'
|
||||
}).then(({data}) => {
|
||||
if (itemType === 'COMMENTS') {
|
||||
this.setState({localPost: data.createAction.id});
|
||||
}
|
||||
};
|
||||
postAction(item_id, itemType, action)
|
||||
.then((action) => {
|
||||
let id = `${action.action_type}_${action.item_id}`;
|
||||
addItem({id, current_user: action, count: flag ? flag.count + 1 : 1}, 'actions');
|
||||
updateItem(action.item_id, action.action_type, id, action.item_type);
|
||||
this.setState({posted: true});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +108,8 @@ class FlagButton extends Component {
|
||||
|
||||
render () {
|
||||
const {flag, getPopupMenu} = this.props;
|
||||
const flagged = flag && flag.current_user;
|
||||
const {localPost, localDelete} = this.state;
|
||||
const flagged = (flag && flag.current && !localDelete) || localPost;
|
||||
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
|
||||
|
||||
return <div className={`${name}-container`}>
|
||||
@@ -106,7 +117,7 @@ class FlagButton extends Component {
|
||||
{
|
||||
flagged
|
||||
? <span className={`${name}-button-text`}>{lang.t('reported')}</span>
|
||||
: <span className={`${name}-button-text`}>{lang.t('report')}</span>
|
||||
: <span className={`${name}-button-text`}>{lang.t('report')}</span>
|
||||
}
|
||||
<i className={`${name}-icon material-icons ${flagged && 'flaggedIcon'}`}
|
||||
style={flagged ? styles.flaggedIcon : {}}
|
||||
|
||||
@@ -10,15 +10,15 @@ const getPopupMenu = [
|
||||
return {
|
||||
header: lang.t('step-1-header'),
|
||||
options: [
|
||||
{val: 'users', text: lang.t('flag-username')},
|
||||
{val: 'comments', text: lang.t('flag-comment')}
|
||||
{val: 'USERS', text: lang.t('flag-username')},
|
||||
{val: 'COMMENTS', text: lang.t('flag-comment')}
|
||||
],
|
||||
button: lang.t('continue'),
|
||||
sets: 'itemType'
|
||||
};
|
||||
},
|
||||
(itemType) => {
|
||||
const options = itemType === 'comments' ?
|
||||
const options = itemType === 'COMMENTS' ?
|
||||
[
|
||||
{val: 'I don\'t agree with this comment', text: lang.t('no-agree-comment')},
|
||||
{val: 'This comment is offensive', text: lang.t('comment-offensive')},
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
|
||||
import styles from './Comment.css';
|
||||
|
||||
const Comment = props => {
|
||||
return (
|
||||
<div className={styles.myComment}>
|
||||
<p className="myCommentAsset">
|
||||
<a className={`${styles.assetURL} myCommentAnchor`} href={`${props.asset.url}#${props.comment.id}`}>{props.asset.url}</a>
|
||||
<a className={`${styles.assetURL} myCommentAnchor`} href='#' onClick={props.link(`${props.asset.url}#${props.comment.id}`)}>{props.asset.url}</a>
|
||||
</p>
|
||||
<p className={`${styles.commentBody} myCommentBody`}>{props.comment.body}</p>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ const CommentHistory = props => {
|
||||
<div className={`${styles.header} commentHistory`}>
|
||||
<div className="commentHistory__list">
|
||||
{props.comments.map((comment, i) => {
|
||||
const asset = props.assets.find(asset => asset.id === comment.asset_id);
|
||||
return <Comment
|
||||
key={i}
|
||||
comment={comment}
|
||||
asset={asset} />;
|
||||
link={props.link}
|
||||
asset={props.asset} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,8 +19,8 @@ const CommentHistory = props => {
|
||||
};
|
||||
|
||||
CommentHistory.propTypes = {
|
||||
comments: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
assets: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
comments: PropTypes.array.isRequired,
|
||||
asset: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default CommentHistory;
|
||||
|
||||
@@ -1,52 +1,76 @@
|
||||
import React from 'react';
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import {I18n} from '../coral-framework';
|
||||
import translations from './translations.json';
|
||||
|
||||
const name = 'coral-plugin-likes';
|
||||
|
||||
const LikeButton = ({like, id, postAction, deleteAction, addItem, showSignInDialog, updateItem, currentUser, banned}) => {
|
||||
const liked = like && like.current_user;
|
||||
const onLikeClick = () => {
|
||||
if (!currentUser) {
|
||||
const offset = document.getElementById(`c_${id}`).getBoundingClientRect().top - 75;
|
||||
showSignInDialog(offset);
|
||||
return;
|
||||
}
|
||||
if (banned) {
|
||||
return;
|
||||
}
|
||||
if (!liked) {
|
||||
const action = {
|
||||
action_type: 'like'
|
||||
};
|
||||
postAction(id, 'comments', action)
|
||||
.then((action) => {
|
||||
let id = `${action.action_type}_${action.item_id}`;
|
||||
addItem({id, current_user: action, count: like ? like.count + 1 : 1}, 'actions');
|
||||
updateItem(action.item_id, action.action_type, id, 'comments');
|
||||
});
|
||||
} else {
|
||||
deleteAction(liked.id)
|
||||
.then(() => {
|
||||
updateItem(like.id, 'count', like.count - 1, 'actions');
|
||||
updateItem(like.id, 'current_user', false, 'actions');
|
||||
});
|
||||
}
|
||||
};
|
||||
class LikeButton extends Component {
|
||||
|
||||
return <div className={`${name}-container`}>
|
||||
<button onClick={onLikeClick} className={`${name}-button ${liked && 'likedButton'}`}>
|
||||
{
|
||||
liked
|
||||
? <span className={`${name}-button-text`}>{lang.t('liked')}</span>
|
||||
: <span className={`${name}-button-text`}>{lang.t('like')}</span>
|
||||
static propTypes = {
|
||||
like: PropTypes.shape({
|
||||
current: PropTypes.obect,
|
||||
count: PropTypes.number
|
||||
}),
|
||||
id: PropTypes.string,
|
||||
postAction: PropTypes.func.isRequired,
|
||||
deleteAction: PropTypes.func.isRequired,
|
||||
showSignInDialog: PropTypes.func.isRequired,
|
||||
currentUser: PropTypes.shape({
|
||||
banned: PropTypes.boolean
|
||||
}),
|
||||
}
|
||||
|
||||
state = {
|
||||
localPost: null, // Set to the ID of an action if one is posted
|
||||
localDelete: false // Set to true is the user deletes an action, unless localPost is already set.
|
||||
}
|
||||
|
||||
render() {
|
||||
const {like, id, postAction, deleteAction, showSignInDialog, currentUser} = this.props;
|
||||
const {localPost, localDelete} = this.state;
|
||||
const liked = (like && like.current && !localDelete) || localPost;
|
||||
let count = like ? like.count : 0;
|
||||
if (localPost) {count += 1;}
|
||||
if (localDelete) {count -= 1;}
|
||||
|
||||
const onLikeClick = () => {
|
||||
if (!currentUser) {
|
||||
const offset = document.getElementById(`c_${id}`).getBoundingClientRect().top - 75;
|
||||
showSignInDialog(offset);
|
||||
return;
|
||||
}
|
||||
<i className={`${name}-icon material-icons`}
|
||||
aria-hidden={true}>thumb_up</i>
|
||||
<span className={`${name}-like-count`}>{like && like.count > 0 && like.count}</span>
|
||||
</button>
|
||||
</div>;
|
||||
};
|
||||
if (currentUser.banned) {
|
||||
return;
|
||||
}
|
||||
if (!liked) {
|
||||
this.setState({localPost: 'temp', localDelete: false});
|
||||
postAction({
|
||||
item_id: id,
|
||||
item_type: 'COMMENTS',
|
||||
action_type: 'LIKE'
|
||||
}).then(({data}) => {
|
||||
this.setState({localPost: data.createAction.id});
|
||||
});
|
||||
} else {
|
||||
this.setState((prev) => prev.localPost ? {...prev, localPost: null} : {...prev, localDelete: true});
|
||||
deleteAction(localPost || like.current.id);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className={`${name}-container`}>
|
||||
<button onClick={onLikeClick} className={`${name}-button ${liked && 'likedButton'}`}>
|
||||
{
|
||||
liked
|
||||
? <span className={`${name}-button-text`}>{lang.t('liked')}</span>
|
||||
: <span className={`${name}-button-text`}>{lang.t('like')}</span>
|
||||
}
|
||||
<i className={`${name}-icon material-icons`}
|
||||
aria-hidden={true}>thumb_up</i>
|
||||
<span className={`${name}-like-count`}>{count > 0 && count}</span>
|
||||
</button>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export default LikeButton;
|
||||
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import React from 'react';
|
||||
import React, {PropTypes} from 'react';
|
||||
import CommentBox from '../coral-plugin-commentbox/CommentBox';
|
||||
|
||||
const name = 'coral-plugin-replies';
|
||||
|
||||
const ReplyBox = (props) => <div
|
||||
className={`${name}-textarea`}
|
||||
style={props.styles && props.styles.container}>
|
||||
{
|
||||
props.showReply && <CommentBox
|
||||
{...props}
|
||||
comments = {props.child}
|
||||
reply = {true}/>
|
||||
}
|
||||
</div>;
|
||||
const ReplyBox = ({styles, postItem, assetId, authorId, addNotification, parentId, commentPostedHandler, setActiveReplyBox}) => (
|
||||
<div className={`${name}-textarea`} style={styles && styles.container}>
|
||||
<CommentBox
|
||||
commentPostedHandler={commentPostedHandler}
|
||||
parentId={parentId}
|
||||
cancelButtonClicked={setActiveReplyBox}
|
||||
addNotification={addNotification}
|
||||
authorId={authorId}
|
||||
assetId={assetId}
|
||||
postItem={postItem}
|
||||
isReply={true} />
|
||||
</div>
|
||||
);
|
||||
|
||||
ReplyBox.propTypes = {
|
||||
setActiveReplyBox: PropTypes.func.isRequired,
|
||||
commentPostedHandler: PropTypes.func,
|
||||
parentId: PropTypes.string,
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
authorId: PropTypes.string.isRequired,
|
||||
postItem: PropTypes.func.isRequired,
|
||||
assetId: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default ReplyBox;
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import React from 'react';
|
||||
import React, {PropTypes} from 'react';
|
||||
import {I18n} from '../coral-framework';
|
||||
import translations from './translations.json';
|
||||
|
||||
const name = 'coral-plugin-replies';
|
||||
|
||||
const ReplyButton = (props) => <button
|
||||
className={`${name}-reply-button`}
|
||||
onClick={() => {
|
||||
if (props.banned) {
|
||||
return;
|
||||
}
|
||||
props.updateItem(props.id, 'showReply', !props.showReply, 'comments');
|
||||
}}>
|
||||
{lang.t('reply')}
|
||||
<i className={`${name}-icon material-icons`}
|
||||
aria-hidden={true}>reply</i>
|
||||
</button>;
|
||||
const ReplyButton = ({banned, onClick}) => {
|
||||
return (
|
||||
<button
|
||||
className={`${name}-reply-button`}
|
||||
onClick={onClick}>
|
||||
{lang.t('reply')}
|
||||
<i className={`${name}-icon material-icons`}
|
||||
aria-hidden={true}>{banned ? 'BANNED' : 'reply'}</i>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ReplyButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
banned: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default ReplyButton;
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, {Component} from 'react';
|
||||
import {graphql} from 'react-apollo';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export class RileysAwesomeCommentBox extends Component {
|
||||
|
||||
postComment() {
|
||||
console.log(this.props);
|
||||
console.log('postComment', this.props.asset_id);
|
||||
this.props.mutate({
|
||||
variables: {
|
||||
asset_id: this.props.asset_id,
|
||||
body: this.textarea.value,
|
||||
parent_id: null
|
||||
}
|
||||
}).then(({data}) => {
|
||||
console.log('it workt');
|
||||
console.log(data);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>
|
||||
<textarea ref={textarea => this.textarea = textarea}></textarea>
|
||||
<button onClick={this.postComment.bind(this)}>POST</button>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const postComment = gql`
|
||||
fragment commentView on Comment {
|
||||
id
|
||||
body
|
||||
user {
|
||||
name: displayName
|
||||
}
|
||||
actions {
|
||||
type: action_type
|
||||
count
|
||||
current: current_user {
|
||||
id
|
||||
created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) {
|
||||
createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) {
|
||||
...commentView
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const RileysAwesomeCommentBoxWithData = graphql(
|
||||
postComment
|
||||
)(RileysAwesomeCommentBox);
|
||||
|
||||
export default RileysAwesomeCommentBoxWithData;
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, {Component} from 'react';
|
||||
import {graphql} from 'react-apollo';
|
||||
import gql from 'graphql-tag';
|
||||
import {fetchSignIn} from 'coral-framework/actions/auth';
|
||||
import RileysAwesomeCommentBox from 'coral-plugin-stream/RileysAwesomeCommentBox';
|
||||
|
||||
const assetID = '6187a94b-0b6d-4a96-ac6b-62b529cd8410';
|
||||
|
||||
// MyComponent is a "presentational" or apollo-unaware component,
|
||||
// It could be a simple React class:
|
||||
class Stream extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
logMeIn() {
|
||||
fetchSignIn({email: 'your@example.com', password: 'dfasidfaisdufoiausdfoiuaspdoifas'})(() => {});
|
||||
}
|
||||
|
||||
render() {
|
||||
console.log(this.props);
|
||||
const {data} = this.props;
|
||||
return <div>
|
||||
<button onClick={this.logMeIn.bind(this)}>Login or whatever</button>
|
||||
{
|
||||
data.loading
|
||||
? 'loading!'
|
||||
: <div>
|
||||
<RileysAwesomeCommentBox asset_id={data.asset.id} />
|
||||
<p>Asset ID: {data.asset.id}</p>
|
||||
<ul>
|
||||
{
|
||||
data.asset.comments.map(comment => {
|
||||
return <li key={comment.id}>
|
||||
{comment.body} [{comment.id}]
|
||||
<ul>
|
||||
{
|
||||
comment.replies.map(reply => {
|
||||
return <li key={reply.id}>{reply.body}</li>;
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</li>;
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize GraphQL queries or mutations with the `gql` tag
|
||||
const StreamQuery = gql`fragment commentView on Comment {
|
||||
id
|
||||
body
|
||||
user {
|
||||
name: displayName
|
||||
}
|
||||
actions {
|
||||
type: action_type
|
||||
count
|
||||
current: current_user {
|
||||
id
|
||||
created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query AssetQuery($asset_id: ID!) {
|
||||
asset(id: $asset_id) {
|
||||
id
|
||||
title
|
||||
url
|
||||
commentCount
|
||||
comments {
|
||||
...commentView
|
||||
replies {
|
||||
...commentView
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
// We then can use `graphql` to pass the query results returned by MyQuery
|
||||
// to MyComponent as a prop (and update them as the results change)
|
||||
const StreamWithData = graphql(
|
||||
StreamQuery, {
|
||||
options: {
|
||||
variables: {
|
||||
asset_id: assetID
|
||||
}
|
||||
}
|
||||
}
|
||||
)(Stream);
|
||||
|
||||
export default StreamWithData;
|
||||
@@ -1,10 +1,15 @@
|
||||
import React from 'react';
|
||||
import styles from './NotLoggedIn.css';
|
||||
import SignInContainer from '../../coral-sign-in/containers/SignInContainer';
|
||||
|
||||
export default ({showSignInDialog}) => (
|
||||
<div className={styles.message}>
|
||||
<SignInContainer noButton={true}/>
|
||||
<div>
|
||||
<a onClick={showSignInDialog}>Sign In</a> to access Settings
|
||||
<a onClick={() => {
|
||||
console.log('Signin click');
|
||||
showSignInDialog();
|
||||
}}>Sign In</a> to access Settings
|
||||
</div>
|
||||
<div>
|
||||
From the Settings Page you can
|
||||
|
||||
@@ -4,7 +4,11 @@ import styles from './SettingsHeader.css';
|
||||
export default ({userData}) => (
|
||||
<div className={styles.header}>
|
||||
<h1>{userData.displayName}</h1>
|
||||
<h2>{userData.profiles.map(profile => profile.id)}</h2>
|
||||
|
||||
{
|
||||
|
||||
// Hiding display of users ID unless there's a use case for it.
|
||||
// <h2>{userData.profiles.map(profile => profile.id)}</h2>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {compose} from 'react-apollo';
|
||||
import React, {Component} from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
|
||||
import {saveBio, fetchCommentsByUserId} from 'coral-framework/actions/user';
|
||||
import {myCommentHistory} from 'coral-framework/graphql/queries';
|
||||
import {saveBio} from 'coral-framework/actions/user';
|
||||
|
||||
import BioContainer from './BioContainer';
|
||||
import {link} from 'coral-framework/PymConnection';
|
||||
import NotLoggedIn from '../components/NotLoggedIn';
|
||||
import {TabBar, Tab, TabContent} from '../../coral-ui';
|
||||
import CommentHistory from 'coral-plugin-history/CommentHistory';
|
||||
import {TabBar, Tab, TabContent, Spinner} from 'coral-ui';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import RestrictedContent from 'coral-framework/components/RestrictedContent';
|
||||
import CommentHistory from 'coral-plugin-history/CommentHistory';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
@@ -24,12 +26,6 @@ class SettingsContainer extends Component {
|
||||
this.handleTabChange = this.handleTabChange.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount () {
|
||||
|
||||
// Fetch commentHistory
|
||||
this.props.fetchCommentsByUserId(this.props.userData.id);
|
||||
}
|
||||
|
||||
handleTabChange(tab) {
|
||||
this.setState({
|
||||
activeTab: tab
|
||||
@@ -37,54 +33,56 @@ class SettingsContainer extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {loggedIn, userData, showSignInDialog, items, user} = this.props;
|
||||
const {loggedIn, userData, asset, showSignInDialog, data, user} = this.props;
|
||||
const {activeTab} = this.state;
|
||||
const {me} = this.props.data;
|
||||
|
||||
const commentsMostRecentFirst = user
|
||||
.myComments.map(id => items.comments[id])
|
||||
.sort(({created_at:a}, {created_at:b}) => {
|
||||
if (!loggedIn || !me) {
|
||||
return <NotLoggedIn showSignInDialog={showSignInDialog}/>;
|
||||
}
|
||||
|
||||
// descending order, created_at
|
||||
// js date strings can be sorted lexigraphically.
|
||||
const aLessThanB = a < b ? 1 : 0;
|
||||
return a > b ? -1 : aLessThanB;
|
||||
});
|
||||
if (data.loading) {
|
||||
return <Spinner/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<RestrictedContent restricted={!loggedIn} restrictedComp={<NotLoggedIn showSignInDialog={showSignInDialog} />}>
|
||||
<div>
|
||||
<SettingsHeader {...this.props} />
|
||||
<TabBar onChange={this.handleTabChange} activeTab={activeTab} cStyle='material'>
|
||||
<Tab>All Comments ({user.myComments.length})</Tab>
|
||||
<Tab>Profile Settings</Tab>
|
||||
<Tab>{lang.t('allComments')} ({user.myComments.length})</Tab>
|
||||
<Tab>{lang.t('profileSettings')}</Tab>
|
||||
</TabBar>
|
||||
<TabContent show={activeTab === 0}>
|
||||
{
|
||||
user.myComments.length && user.myAssets.length
|
||||
? <CommentHistory
|
||||
comments={commentsMostRecentFirst}
|
||||
assets={user.myAssets.map(id => items.assets[id])} />
|
||||
: <p>{lang.t('user-no-comment')}</p>
|
||||
me.comments.length ?
|
||||
<CommentHistory
|
||||
comments={me.comments}
|
||||
asset={asset}
|
||||
link={link}
|
||||
/>
|
||||
:
|
||||
<p>{lang.t('userNoComment')}</p>
|
||||
}
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 1}>
|
||||
<BioContainer bio={userData.settings.bio} handleSave={this.handleSave} {...this.props} />
|
||||
</TabContent>
|
||||
</RestrictedContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
items: state.items.toJS(),
|
||||
user: state.user.toJS()
|
||||
user: state.user.toJS(),
|
||||
asset: state.asset.toJS(),
|
||||
auth: state.auth.toJS()
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
saveBio: (user_id, formData) => dispatch(saveBio(user_id, formData)),
|
||||
fetchCommentsByUserId: userId => dispatch(fetchCommentsByUserId(userId))
|
||||
saveBio: (user_id, formData) => dispatch(saveBio(user_id, formData))
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
myCommentHistory
|
||||
)(SettingsContainer);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"en":{
|
||||
"user-no-comment": "This user has not yet left a comment."
|
||||
"userNoComment": "This user has not yet left a comment.",
|
||||
"allComments": "All Comments",
|
||||
"profileSettings": "Profile Settings",
|
||||
"myCommentHistory": "My comment History"
|
||||
},
|
||||
"es":{
|
||||
"user-no-comment": "Aún no ha escrito ningún comentario."
|
||||
"userNoComment": "Aún no ha escrito ningún comentario.",
|
||||
"allComments": "Todos los comentarios",
|
||||
"profileSettings": "Configuración del perfil",
|
||||
"myCommentHistory": "Mi historial de comentarios"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const SignDialog = ({open, view, handleClose, offset, ...props}) => (
|
||||
id="signInDialog"
|
||||
open={open}
|
||||
style={{
|
||||
position: 'relative',
|
||||
position: 'fixed',
|
||||
top: offset !== 0 && offset
|
||||
}}>
|
||||
<span className={styles.close} onClick={handleClose}>×</span>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
invalidForm,
|
||||
validForm,
|
||||
checkLogin
|
||||
} from '../../coral-framework/actions/auth';
|
||||
} from 'coral-framework/actions/auth';
|
||||
|
||||
class SignInContainer extends Component {
|
||||
initialState = {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import styles from './Checkbox.css';
|
||||
|
||||
export default ({name, cStyle = 'base', onChange, label, className, info, checked = 'false'}) => (
|
||||
export default ({name, cStyle = 'base', onChange, label, className, info, ...props}) => (
|
||||
<label className={`${styles.label} ${styles[`type--${cStyle}`]} ${className}`} htmlFor={name}>
|
||||
<input type="checkbox" id={name} name={name} onChange={onChange} checked={checked} />
|
||||
<input type="checkbox" id={name} name={name} onChange={onChange} {...props} />
|
||||
<span className={styles.checkbox}></span>
|
||||
{label && <span>{label}</span>}
|
||||
{info && (
|
||||
|
||||
+9
-1
@@ -821,6 +821,14 @@ definitions:
|
||||
current_user:
|
||||
type: object
|
||||
description: Will include the action performed by the currently logged in user if that user has taken an action on this item. Otherwise will return null.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Oldest created_at of actions in this set.
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Newest updated_at of actions in this set.
|
||||
Action:
|
||||
type: object
|
||||
description: A single action taken by a user.
|
||||
@@ -916,7 +924,7 @@ definitions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Roles occupied by the user (e.g. 'admin', 'moderator', etc.)
|
||||
description: Roles occupied by the user (e.g. 'ADMIN', 'MODERATOR', etc.)
|
||||
status:
|
||||
type: string
|
||||
description: The current status of the user in the system.
|
||||
|
||||
@@ -110,10 +110,15 @@ const ErrNotAuthorized = new APIError('not authorized', {
|
||||
status: 401
|
||||
});
|
||||
|
||||
// ErrSettingsNotInit is returned when the settings are required but not
|
||||
// initialized.
|
||||
const ErrSettingsNotInit = new Error('settings not initialized, run `./bin/cli setup` to setup the application first');
|
||||
|
||||
module.exports = {
|
||||
ExtendableError,
|
||||
APIError,
|
||||
ErrPasswordTooShort,
|
||||
ErrSettingsNotInit,
|
||||
ErrMissingEmail,
|
||||
ErrMissingPassword,
|
||||
ErrMissingToken,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const loaders = require('./loaders');
|
||||
const mutators = require('./mutators');
|
||||
|
||||
/**
|
||||
* Stores the request context.
|
||||
*/
|
||||
class Context {
|
||||
constructor({user = null}) {
|
||||
|
||||
// Load the current logged in user to `user`, otherwise this'll be null.
|
||||
if (user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
// Create the loaders.
|
||||
this.loaders = loaders(this);
|
||||
|
||||
// Create the mutators.
|
||||
this.mutators = mutators(this);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Context;
|
||||
@@ -0,0 +1,14 @@
|
||||
const schema = require('./schema');
|
||||
const Context = require('./context');
|
||||
|
||||
module.exports = {
|
||||
createGraphOptions: (req) => ({
|
||||
|
||||
// Schema is created already, so just include it.
|
||||
schema,
|
||||
|
||||
// Load in the new context here, this'll create the loaders + mutators for
|
||||
// the lifespan of this request.
|
||||
context: new Context(req)
|
||||
})
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
const DataLoader = require('dataloader');
|
||||
|
||||
const util = require('./util');
|
||||
|
||||
const ActionsService = require('../../services/actions');
|
||||
const ActionModel = require('../../models/action');
|
||||
|
||||
/**
|
||||
* Looks up actions based on the requested id's all bounded by the user.
|
||||
* @param {Object} context the context of the request
|
||||
* @param {Array} ids array of id's to get
|
||||
* @return {Promise} resolves to the promises of the requested actions
|
||||
*/
|
||||
const genActionSummariessByItemID = ({user = {}}, item_ids) => {
|
||||
return ActionsService
|
||||
.getActionSummaries(item_ids, user.id)
|
||||
.then(util.arrayJoinBy(item_ids, 'item_id'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Search for actions based on their action_type and item_type and ensures that
|
||||
* the actions returned have unique item id's.
|
||||
* @param {String} action_type the action to search by
|
||||
* @param {String} item_type the item id to search by
|
||||
* @return {Promise} resolves to distinct items actions
|
||||
*/
|
||||
const getItemIdsByActionTypeAndItemType = (_, action_type, item_type) => {
|
||||
return ActionModel.distinct('item_id', {action_type, item_type});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a set of loaders based on a GraphQL context.
|
||||
* @param {Object} context the context of the GraphQL request
|
||||
* @return {Object} object of loaders
|
||||
*/
|
||||
module.exports = (context) => ({
|
||||
Actions: {
|
||||
getSummariesByItemID: new DataLoader((ids) => genActionSummariessByItemID(context, ids)),
|
||||
getByTypes: ({action_type, item_type}) => getItemIdsByActionTypeAndItemType(context, action_type, item_type)
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
const DataLoader = require('dataloader');
|
||||
const url = require('url');
|
||||
|
||||
const errors = require('../../errors');
|
||||
const scraper = require('../../services/scraper');
|
||||
const util = require('./util');
|
||||
|
||||
const AssetModel = require('../../models/asset');
|
||||
const AssetsService = require('../../services/assets');
|
||||
|
||||
/**
|
||||
* Retrieves assets by an array of ids.
|
||||
* @param {Object} context the context of the request
|
||||
* @param {Array} ids array of ids to lookup
|
||||
*/
|
||||
const genAssetsByID = (context, ids) => AssetModel.find({
|
||||
id: {
|
||||
$in: ids
|
||||
}
|
||||
}).then(util.singleJoinBy(ids, 'id'));
|
||||
|
||||
/**
|
||||
* This endpoint find or creates an asset at the given url when it is loaded.
|
||||
* @param {Object} context the context of the request
|
||||
* @param {String} asset_url the url passed in from the query
|
||||
* @returns {Promise} resolves to the asset
|
||||
*/
|
||||
const findOrCreateAssetByURL = (context, asset_url) => {
|
||||
|
||||
// Verify that the asset_url is parsable.
|
||||
let parsed_asset_url = url.parse(asset_url);
|
||||
if (!parsed_asset_url.protocol) {
|
||||
return Promise.reject(errors.ErrInvalidAssetURL);
|
||||
}
|
||||
|
||||
return AssetsService.findOrCreateByUrl(asset_url)
|
||||
.then((asset) => {
|
||||
|
||||
// If the asset wasn't scraped before, scrape it! Otherwise just return
|
||||
// the asset.
|
||||
if (!asset.scraped) {
|
||||
return scraper.create(asset).then(() => asset);
|
||||
}
|
||||
|
||||
return asset;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a set of loaders based on a GraphQL context.
|
||||
* @param {Object} context the context of the GraphQL request
|
||||
* @return {Object} object of loaders
|
||||
*/
|
||||
module.exports = (context) => ({
|
||||
Assets: {
|
||||
|
||||
// TODO: decide whether we want to move these to mutators or not, as in fact
|
||||
// this operation create a new asset if one isn't found.
|
||||
getByURL: (url) => findOrCreateAssetByURL(context, url),
|
||||
|
||||
getByID: new DataLoader((ids) => genAssetsByID(context, ids)),
|
||||
getAll: new util.SingletonResolver(() => AssetModel.find({}))
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
const util = require('./util');
|
||||
|
||||
const CommentModel = require('../../models/comment');
|
||||
|
||||
/**
|
||||
* Returns the comment count for all comments that are public based on their
|
||||
* asset ids.
|
||||
* @param {Object} context graph context
|
||||
* @param {Array<String>} asset_ids the ids of assets for which there are
|
||||
* comments that we want to get
|
||||
*/
|
||||
const getCountsByAssetID = (context, asset_ids) => {
|
||||
return CommentModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
asset_id: {
|
||||
$in: asset_ids
|
||||
},
|
||||
status: {
|
||||
$in: [null, 'ACCEPTED']
|
||||
},
|
||||
parent_id: null
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$asset_id',
|
||||
count: {
|
||||
$sum: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
.then(util.singleJoinBy(asset_ids, '_id'))
|
||||
.then((results) => results.map((result) => result ? result.count : 0));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the comment count for all comments that are public based on their
|
||||
* parent ids.
|
||||
* @param {Object} context graph context
|
||||
* @param {Array<String>} parent_ids the ids of parents for which there are
|
||||
* comments that we want to get
|
||||
*/
|
||||
const getCountsByParentID = (context, parent_ids) => {
|
||||
return CommentModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
parent_id: {
|
||||
$in: parent_ids
|
||||
},
|
||||
status: {
|
||||
$in: [null, 'ACCEPTED']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$parent_id',
|
||||
count: {
|
||||
$sum: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
.then(util.singleJoinBy(parent_ids, '_id'))
|
||||
.then((results) => results.map((result) => result ? result.count : 0));
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves comments based on the passed in query that is filtered by the
|
||||
* current used passed in via the context.
|
||||
* @param {Object} context graph context
|
||||
* @param {Object} query query terms to apply to the comments query
|
||||
*/
|
||||
const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, limit, cursor, sort}) => {
|
||||
let comments = CommentModel.find();
|
||||
|
||||
// Only administrators can search for comments with statuses that are not
|
||||
// `null`, or `'ACCEPTED'`.
|
||||
if (user != null && user.hasRoles('ADMIN') && statuses) {
|
||||
comments = comments.where({
|
||||
status: {
|
||||
$in: statuses
|
||||
}
|
||||
});
|
||||
} else {
|
||||
comments = comments.where({
|
||||
status: {
|
||||
$in: [null, 'ACCEPTED']
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (ids) {
|
||||
comments = comments.find({
|
||||
id: {
|
||||
$in: ids
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (asset_id) {
|
||||
comments = comments.where({asset_id});
|
||||
}
|
||||
|
||||
// We perform the undefined check because, null, is a valid state for the
|
||||
// search to be with, which indicates that it is at depth 0.
|
||||
if (parent_id !== undefined) {
|
||||
comments = comments.where({parent_id});
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
if (sort === 'REVERSE_CHRONOLOGICAL') {
|
||||
comments = comments.where({
|
||||
created_at: {
|
||||
$lt: cursor
|
||||
}
|
||||
});
|
||||
} else {
|
||||
comments = comments.where({
|
||||
created_at: {
|
||||
$gt: cursor
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return comments
|
||||
.sort({created_at: sort === 'REVERSE_CHRONOLOGICAL' ? -1 : 1})
|
||||
.limit(limit);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a set of loaders based on a GraphQL context.
|
||||
* @param {Object} context the context of the GraphQL request
|
||||
* @return {Object} object of loaders
|
||||
*/
|
||||
module.exports = (context) => ({
|
||||
Comments: {
|
||||
getByQuery: (query) => getCommentsByQuery(context, query),
|
||||
countByAssetID: new util.SharedCacheDataLoader('Comments.countByAssetID', 3600, (ids) => getCountsByAssetID(context, ids)),
|
||||
countByParentID: new util.SharedCacheDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids))
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
const _ = require('lodash');
|
||||
|
||||
const Actions = require('./actions');
|
||||
const Assets = require('./assets');
|
||||
const Comments = require('./comments');
|
||||
const Settings = require('./settings');
|
||||
const Users = require('./users');
|
||||
|
||||
/**
|
||||
* Creates a set of loaders based on a GraphQL context.
|
||||
* @param {Object} context the context of the GraphQL request
|
||||
* @return {Object} object of loaders
|
||||
*/
|
||||
module.exports = (context) => {
|
||||
|
||||
// We need to return an object to be accessed.
|
||||
return _.merge(...[
|
||||
Actions,
|
||||
Assets,
|
||||
Comments,
|
||||
Settings,
|
||||
Users
|
||||
].map((loaders) => {
|
||||
|
||||
// Each loader is a function which takes the context.
|
||||
return loaders(context);
|
||||
}));
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user