mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
519464ce57 | ||
|
|
f27de815e6 | ||
|
|
e8553c5035 | ||
|
|
349341400d | ||
|
|
7b92e58ff3 | ||
|
|
f2f7742709 | ||
|
|
689fb4baf0 | ||
|
|
650f98a080 | ||
|
|
88d3a53165 | ||
|
|
6e88976294 | ||
|
|
f25cc56ddb | ||
|
|
4134b2edb2 | ||
|
|
5b886b6eea | ||
|
|
6c80de2981 | ||
|
|
b9b3355317 | ||
|
|
88e9faea00 | ||
|
|
b1c9fbedd8 | ||
|
|
c9b7eb3494 | ||
|
|
66287f5b7d | ||
|
|
b61eeeba74 | ||
|
|
2179632c94 | ||
|
|
4342161a70 | ||
|
|
a48688ad35 | ||
|
|
1b4f783abd | ||
|
|
e2d8562868 | ||
|
|
91602b9d34 | ||
|
|
eba530eda0 | ||
|
|
8e1cfc3a29 | ||
|
|
c78bb598e7 | ||
|
|
f12abbf39e | ||
|
|
7dcb1dfbaa | ||
|
|
1fce2451c7 | ||
|
|
e4f1f5d08b | ||
|
|
d06ff8c93c | ||
|
|
9d9056ef86 | ||
|
|
1b61ca8abf | ||
|
|
f75a743090 | ||
|
|
82e617b19b | ||
|
|
7a4b87f743 | ||
|
|
71f0887a82 | ||
|
|
4116e206a3 | ||
|
|
e78f17460f | ||
|
|
70bfd91359 | ||
|
|
b3432a5e0d | ||
|
|
382cb42dcf | ||
|
|
dbd106d523 | ||
|
|
e8f94d4d4a | ||
|
|
3ea141d4b0 | ||
|
|
f783c8423d | ||
|
|
9d4a9861f2 | ||
|
|
6d69286df6 | ||
|
|
3121ffb55a | ||
|
|
c0af51fbc0 | ||
|
|
ad85b0fcc8 | ||
|
|
5ad73b1c14 | ||
|
|
bf83f462e0 | ||
|
|
8dd79a19c3 | ||
|
|
a8368eb78d | ||
|
|
e88bac6e0f | ||
|
|
281d1f65f1 | ||
|
|
2704d71b09 | ||
|
|
583ab0ff74 | ||
|
|
ad5c0444f7 | ||
|
|
2a74e75506 | ||
|
|
fb7ad76b8b | ||
|
|
eddb471366 | ||
|
|
35c88ffab4 | ||
|
|
1cdef8f864 | ||
|
|
82fd56dc67 | ||
|
|
0e01d40aa7 | ||
|
|
f7d1543aa3 | ||
|
|
d2ebc81e25 | ||
|
|
278e1cec1d | ||
|
|
31436e627d | ||
|
|
4e50865927 | ||
|
|
6ad218782e | ||
|
|
8f1f875d76 | ||
|
|
9fb8e01cd2 | ||
|
|
950a35310b | ||
|
|
12884cce01 | ||
|
|
f8d153cf95 | ||
|
|
c871f1c797 | ||
|
|
9af7ae0357 | ||
|
|
073eea2847 | ||
|
|
9cb6ff3847 | ||
|
|
c699e044fd | ||
|
|
7d3ec7da1e | ||
|
|
5d288da69d | ||
|
|
4ef7abc132 | ||
|
|
9e486f3a9d | ||
|
|
fbcd390220 | ||
|
|
338c23dc3d | ||
|
|
4d6f38a3d9 | ||
|
|
5b8f335b2a | ||
|
|
f0330ed21c | ||
|
|
02da749c65 | ||
|
|
5a1393c039 | ||
|
|
d7a5e613fa | ||
|
|
75a944eba8 | ||
|
|
9a0fb95d06 | ||
|
|
6850dd1a53 | ||
|
|
ecb01224fd | ||
|
|
fdd5cbb0d7 | ||
|
|
eb9827a3ae | ||
|
|
d207b240f2 | ||
|
|
d9654b52cf | ||
|
|
d3965924aa | ||
|
|
36d535546c | ||
|
|
19568afde5 | ||
|
|
2b312dbf8e | ||
|
|
5446abe3a9 | ||
|
|
c66c100dcb | ||
|
|
d4051f6828 | ||
|
|
33781c3d16 | ||
|
|
defc4049cb | ||
|
|
243d0c1493 | ||
|
|
a448c69a2a | ||
|
|
0b774402cf | ||
|
|
0bda44e97f | ||
|
|
60f6c63c8a | ||
|
|
62c2d551f8 | ||
|
|
f32bb5ce8f | ||
|
|
5e6d22f1af | ||
|
|
34289e3340 | ||
|
|
98ef60a48e | ||
|
|
8323344d7d | ||
|
|
fbecc363cf | ||
|
|
2779734ec0 | ||
|
|
5548deb6b5 | ||
|
|
df925f5a1a | ||
|
|
30073b88f1 | ||
|
|
51bd109f13 | ||
|
|
b12d5f6bd7 | ||
|
|
f32e4523e8 | ||
|
|
ed959ca07a | ||
|
|
27ecbd1bdd | ||
|
|
4477c0981f | ||
|
|
2c9eae9264 | ||
|
|
812a4ee704 | ||
|
|
d3caf350ac | ||
|
|
864e3f1a1f | ||
|
|
61d16fbe08 | ||
|
|
000e26e3e1 | ||
|
|
71f0200557 | ||
|
|
d79edcd412 | ||
|
|
485b9d036f | ||
|
|
041ba95a5b | ||
|
|
d0d047f641 | ||
|
|
b057131d34 | ||
|
|
ba572fbf48 | ||
|
|
e3c1f8c231 | ||
|
|
07ddb94327 | ||
|
|
0addb938ae | ||
|
|
c0d01e55e1 | ||
|
|
3ac301142e | ||
|
|
7072aa013b | ||
|
|
091be25f36 | ||
|
|
41a3639d31 | ||
|
|
f96146bfd9 | ||
|
|
2eec87aa80 | ||
|
|
aabd40d53a | ||
|
|
9bce69a7cd | ||
|
|
7afa6c018b | ||
|
|
94b56b547f | ||
|
|
6329e9f618 | ||
|
|
ea7266595d | ||
|
|
4f2d97cd82 | ||
|
|
6a00d3d249 | ||
|
|
d3040804a2 | ||
|
|
24596a48bd | ||
|
|
04ae844a1a | ||
|
|
a8a8455f40 | ||
|
|
d7302e4b3c | ||
|
|
f09ca6d060 | ||
|
|
73f24fd75b | ||
|
|
001beb7a5b | ||
|
|
0b04a011c4 | ||
|
|
00fb2e277b | ||
|
|
a79dd76f4b | ||
|
|
0d91396b1c | ||
|
|
e1324a168b | ||
|
|
af9180f114 | ||
|
|
8f5e54520b | ||
|
|
2061a7b97d | ||
|
|
3b4e69f06e | ||
|
|
f71be09bca | ||
|
|
87ef0f1cb9 | ||
|
|
0640be2a77 | ||
|
|
fdf11fa277 | ||
|
|
8b71175c46 | ||
|
|
6da4439b94 | ||
|
|
7731ec42de | ||
|
|
67a4d63a23 | ||
|
|
ce9ee0a5b0 | ||
|
|
2e8f5f2e69 | ||
|
|
099186d030 | ||
|
|
99daa0b2c9 | ||
|
|
c2b488fa98 | ||
|
|
612d2596f9 | ||
|
|
835d30d362 | ||
|
|
015e3d3ed9 | ||
|
|
4c7c852af7 | ||
|
|
72123cabe7 | ||
|
|
f17b296e2e | ||
|
|
2c72975137 | ||
|
|
5c1314165c | ||
|
|
fd83fed7bb | ||
|
|
dff89ec6cd | ||
|
|
ef200a85cb | ||
|
|
9c0e6ccb36 | ||
|
|
64276cd157 | ||
|
|
214de045fb | ||
|
|
92a9a535c6 | ||
|
|
3db7439d76 | ||
|
|
65be2b89a4 | ||
|
|
c9f6ec327a | ||
|
|
06ddb30d77 | ||
|
|
af3542aec5 | ||
|
|
016eb472eb | ||
|
|
ab772839c5 | ||
|
|
1b9c2404db | ||
|
|
cf96d07a7a | ||
|
|
1c01a3320f | ||
|
|
8d018df0d1 | ||
|
|
b2a2b135a4 | ||
|
|
ccd22ea334 | ||
|
|
83910f7fce | ||
|
|
28873ed961 | ||
|
|
e3febf4a0c | ||
|
|
c3b25d2d0a | ||
|
|
96a375e38a | ||
|
|
417a7a3c36 | ||
|
|
c903d8c0f9 | ||
|
|
24e895460b | ||
|
|
d6a83201ad | ||
|
|
bbdde50858 | ||
|
|
8730ada87c | ||
|
|
506b938536 | ||
|
|
043e722d95 | ||
|
|
fb1dc23fa9 | ||
|
|
e882b51ed0 | ||
|
|
e52a6fd9c1 | ||
|
|
24af7b2812 | ||
|
|
24b5a58584 | ||
|
|
a868cc9c67 | ||
|
|
af83d32de1 | ||
|
|
fa82824d03 | ||
|
|
f0033a7912 | ||
|
|
0924531677 | ||
|
|
44b77597ed | ||
|
|
ec2bf3cb7f | ||
|
|
37f3c11104 | ||
|
|
9359bfc322 | ||
|
|
111ebd67ba | ||
|
|
31f87dc192 | ||
|
|
c9dad30c71 | ||
|
|
7f53375c67 | ||
|
|
df0c016e37 | ||
|
|
bf3fe2ab8e | ||
|
|
9629ba2f83 | ||
|
|
ac7b4d56a1 | ||
|
|
c29bb02b1c | ||
|
|
8c72e32a25 | ||
|
|
4a31bb7ba2 | ||
|
|
3be1c94f8f | ||
|
|
7fba0428bb | ||
|
|
c7d433deb5 | ||
|
|
a665dbd88e | ||
|
|
c990a812a4 | ||
|
|
3f26a8d7ad | ||
|
|
4a3e5942b0 | ||
|
|
5360bdc8ec | ||
|
|
548a204665 | ||
|
|
a8e8b78441 | ||
|
|
258788fd88 | ||
|
|
8cc3d19819 | ||
|
|
0de5a328ab | ||
|
|
4830aec138 | ||
|
|
4269fd1eb9 | ||
|
|
12e61bbc66 | ||
|
|
511e71f63a | ||
|
|
e675d716cb | ||
|
|
2d6143af62 | ||
|
|
873d1915c3 | ||
|
|
67f3f8cdef | ||
|
|
8f0eef7cb5 | ||
|
|
4c7c3c9e42 | ||
|
|
8f9724ed90 | ||
|
|
9fb7aa2657 | ||
|
|
d714586279 | ||
|
|
3e4c9dcb48 | ||
|
|
285ec98d54 | ||
|
|
853b0ca6dd | ||
|
|
c58d9068e3 | ||
|
|
78e0fb6cc6 | ||
|
|
dfbb9ad96f | ||
|
|
f7c46b1a42 | ||
|
|
b5b6b113d4 | ||
|
|
a13d384cee | ||
|
|
3a0a854317 | ||
|
|
73c3d31fbc | ||
|
|
d3d1148ee6 | ||
|
|
ecd9219e17 | ||
|
|
ce4ad426a7 | ||
|
|
e02537df13 | ||
|
|
e4a359e0b4 |
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"exceptions": [
|
||||
"https://nodesecurity.io/advisories/531"
|
||||
"https://nodesecurity.io/advisories/531",
|
||||
"https://nodesecurity.io/advisories/532"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
const express = require('express');
|
||||
const bodyParser = require('body-parser');
|
||||
const morgan = require('morgan');
|
||||
const path = require('path');
|
||||
const uuid = require('uuid');
|
||||
const merge = require('lodash/merge');
|
||||
const helmet = require('helmet');
|
||||
const plugins = require('./services/plugins');
|
||||
const compression = require('compression');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const {HELMET_CONFIGURATION} = require('./config');
|
||||
const {MOUNT_PATH} = require('./url');
|
||||
const {applyLocals} = require('./services/locals');
|
||||
const routes = require('./routes');
|
||||
const debug = require('debug')('talk:app');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Request Identity Middleware
|
||||
app.use((req, res, next) => {
|
||||
req.id = uuid.v4();
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
//==============================================================================
|
||||
// PLUGIN PRE APPLICATION MIDDLEWARE
|
||||
//==============================================================================
|
||||
|
||||
// Inject server route plugins.
|
||||
plugins.get('server', 'app').forEach(({plugin, app: callback}) => {
|
||||
debug(`added plugin '${plugin.name}'`);
|
||||
|
||||
// Pass the app to the plugin to mount it's routes.
|
||||
callback(app);
|
||||
});
|
||||
|
||||
//==============================================================================
|
||||
// APPLICATION WIDE MIDDLEWARE
|
||||
//==============================================================================
|
||||
@@ -36,12 +54,6 @@ app.use(helmet(merge(HELMET_CONFIGURATION, {
|
||||
// Compress the responses if appropriate.
|
||||
app.use(compression());
|
||||
|
||||
// Parse the cookies on the request.
|
||||
app.use(cookieParser());
|
||||
|
||||
// Parse the body json if it's there.
|
||||
app.use(bodyParser.json());
|
||||
|
||||
//==============================================================================
|
||||
// VIEW CONFIGURATION
|
||||
//==============================================================================
|
||||
@@ -53,9 +65,6 @@ app.set('view engine', 'ejs');
|
||||
// ROUTES
|
||||
//==============================================================================
|
||||
|
||||
// Add the locals to the app renderer.
|
||||
applyLocals(app.locals);
|
||||
|
||||
debug(`mounting routes on the ${MOUNT_PATH} path`);
|
||||
|
||||
// Actually apply the routes.
|
||||
|
||||
+44
-23
@@ -12,7 +12,7 @@ const mongoose = require('../services/mongoose');
|
||||
const kue = require('../services/kue');
|
||||
|
||||
util.onshutdown([
|
||||
() => mongoose.disconnect()
|
||||
() => mongoose.disconnect(),
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -20,17 +20,17 @@ util.onshutdown([
|
||||
*/
|
||||
function processJobs() {
|
||||
|
||||
// Start the scraper processor.
|
||||
scraper.process();
|
||||
|
||||
// Start the mail processor.
|
||||
mailer.process();
|
||||
|
||||
// The scraper only needs to shutdown when the scraper has actually been
|
||||
// started.
|
||||
util.onshutdown([
|
||||
() => kue.Task.shutdown()
|
||||
]);
|
||||
|
||||
// Start the scraper processor.
|
||||
scraper.process();
|
||||
|
||||
// Start the mail processor.
|
||||
mailer.process();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,22 +48,13 @@ function removeJob(job) {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the jobs passed in and returns a promise.
|
||||
* @param {Array} jobs array of jobs
|
||||
* @return {Promise}
|
||||
*/
|
||||
function removeJobs(jobs) {
|
||||
return Promise.all(jobs.map(removeJob));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the top n jobs with a specific state.
|
||||
* @param {String} [state='complete'] state to list jobs by
|
||||
* @param {Number} limit limit of jobs to load
|
||||
* @return {Promise}
|
||||
*/
|
||||
function rangeJobsByState(state = 'complete', limit) {
|
||||
function rangeJobsByState(state, limit) {
|
||||
return new Promise((resolve, reject) => {
|
||||
kue.Job.rangeByState(state, 0, limit, 'asc', (err, jobs) => {
|
||||
if (err) {
|
||||
@@ -75,22 +66,52 @@ function rangeJobsByState(state = 'complete', limit) {
|
||||
});
|
||||
}
|
||||
|
||||
async function getJobBatch(n, includeStuck) {
|
||||
let jobs = [];
|
||||
|
||||
jobs = await rangeJobsByState('complete', n);
|
||||
|
||||
if (includeStuck) {
|
||||
jobs = jobs.concat(await rangeJobsByState('failed', n));
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the jobs that are in the queue.
|
||||
*/
|
||||
async function cleanupJobs(options) {
|
||||
|
||||
// The scraper only needs to shutdown when the scraper has actually been
|
||||
// started.
|
||||
util.onshutdown([
|
||||
() => kue.Task.shutdown()
|
||||
]);
|
||||
|
||||
const n = 100;
|
||||
|
||||
try {
|
||||
const joblists = await Promise.all([
|
||||
rangeJobsByState('complete', n),
|
||||
options.stuck ? rangeJobsByState('failed', n) : false
|
||||
]);
|
||||
|
||||
await joblists.filter((jobs) => jobs).map(removeJobs);
|
||||
// Connect to redis by establishing a queue.
|
||||
kue.Task.connect();
|
||||
|
||||
let jobCount = 0;
|
||||
let jobs = await getJobBatch(n, options.stuck);
|
||||
|
||||
while (jobs.length > 0) {
|
||||
|
||||
// Remove all the jobs.
|
||||
await Promise.all(jobs.map((job) => removeJob(job)));
|
||||
|
||||
jobCount += jobs.length;
|
||||
|
||||
// Get the next batch of jobs.
|
||||
jobs = await getJobBatch(n, options.stuck);
|
||||
}
|
||||
|
||||
util.shutdown();
|
||||
console.log('Removed old jobs');
|
||||
console.log(`Removed ${jobCount} jobs`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
|
||||
+5
-1
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const program = require('./commander');
|
||||
const util = require('./util');
|
||||
const serve = require('../serve');
|
||||
|
||||
//==============================================================================
|
||||
@@ -13,5 +14,8 @@ program
|
||||
.parse(process.argv);
|
||||
|
||||
// Start serving.
|
||||
serve({jobs: program.jobs, websockets: program.websockets});
|
||||
serve({jobs: program.jobs, websockets: program.websockets}).catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
});
|
||||
|
||||
|
||||
+39
-14
@@ -8,10 +8,13 @@ const program = require('./commander');
|
||||
const inquirer = require('inquirer');
|
||||
const UsersService = require('../services/users');
|
||||
const UserModel = require('../models/user');
|
||||
const CommentModel = require('../models/comment');
|
||||
const ActionModel = require('../models/action');
|
||||
const USER_ROLES = require('../models/enum/user_roles');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const util = require('./util');
|
||||
const Table = require('cli-table');
|
||||
const databaseVerifications = require('./verifications/database');
|
||||
|
||||
const validateRequired = (msg = 'Field is required', len = 1) => (input) => {
|
||||
if (input && input.length >= len) {
|
||||
@@ -122,26 +125,48 @@ async function createUser(options) {
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
util.shutdown();
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user.
|
||||
*/
|
||||
function deleteUser(userID) {
|
||||
UserModel
|
||||
.findOneAndRemove({
|
||||
id: userID
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Deleted user');
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown();
|
||||
});
|
||||
async function deleteUser(userID) {
|
||||
|
||||
try {
|
||||
|
||||
// Find the user we're removing.
|
||||
const user = await UserModel.findOne({id: userID});
|
||||
if (!user) {
|
||||
throw new Error(`user with id ${userID} not found`);
|
||||
}
|
||||
|
||||
// Remove all the user's actions.
|
||||
await ActionModel
|
||||
.where({user_id: user.id})
|
||||
.setOptions({multi: true})
|
||||
.remove();
|
||||
|
||||
// Remove all the user's comments.
|
||||
await CommentModel
|
||||
.where({author_id: user.id})
|
||||
.setOptions({multi: true})
|
||||
.remove();
|
||||
|
||||
// Update the counts that might have changed.
|
||||
for (const verification of databaseVerifications) {
|
||||
await verification({fix: true, limit: Infinity, batch: 1000});
|
||||
}
|
||||
|
||||
// Remove the user.
|
||||
await user.remove();
|
||||
|
||||
util.shutdown();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
|
||||
import {Router, Route, IndexRedirect, IndexRoute} from 'react-router';
|
||||
|
||||
import Configure from 'routes/Configure';
|
||||
import Dashboard from 'routes/Dashboard';
|
||||
import Install from 'routes/Install';
|
||||
import Stories from 'routes/Stories';
|
||||
import Community from 'routes/Community/containers/Community';
|
||||
@@ -18,7 +17,6 @@ const routes = (
|
||||
<IndexRedirect to='/admin/moderate' />
|
||||
<Route path='configure' component={Configure} />
|
||||
<Route path='stories' component={Stories} />
|
||||
<Route path='dashboard' component={Dashboard} />
|
||||
|
||||
{/* Community Routes */}
|
||||
|
||||
|
||||
@@ -34,3 +34,8 @@ export const storySearchChange = (value) => ({
|
||||
export const clearState = () => ({
|
||||
type: actions.MODERATION_CLEAR_STATE
|
||||
});
|
||||
|
||||
export const selectCommentId = (id) => ({
|
||||
type: actions.MODERATION_SELECT_COMMENT,
|
||||
id,
|
||||
});
|
||||
|
||||
@@ -21,10 +21,11 @@ class CommentDetails extends Component {
|
||||
this.setState((state) => ({
|
||||
showDetail: !state.showDetail
|
||||
}));
|
||||
this.props.clearHeightCache && this.props.clearHeightCache();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {data, root, comment} = this.props;
|
||||
const {data, root, comment, clearHeightCache} = this.props;
|
||||
const {showDetail} = this.state;
|
||||
const queryData = {
|
||||
root,
|
||||
@@ -44,12 +45,14 @@ class CommentDetails extends Component {
|
||||
<Slot
|
||||
fill="adminCommentDetailArea"
|
||||
data={data}
|
||||
clearHeightCache={clearHeightCache}
|
||||
queryData={queryData}
|
||||
more={showDetail}
|
||||
/>
|
||||
{showDetail && <Slot
|
||||
fill="adminCommentMoreDetails"
|
||||
data={data}
|
||||
clearHeightCache={clearHeightCache}
|
||||
queryData={queryData}
|
||||
/>}
|
||||
</div>
|
||||
@@ -61,6 +64,7 @@ CommentDetails.propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
comment: PropTypes.object.isRequired,
|
||||
clearHeightCache: PropTypes.func,
|
||||
};
|
||||
|
||||
export default CommentDetails;
|
||||
|
||||
@@ -12,20 +12,14 @@ const CoralDrawer = ({handleLogout, auth = {}}) => (
|
||||
{ auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
|
||||
<div>
|
||||
<Navigation className={styles.nav}>
|
||||
<IndexLink
|
||||
className={cn('talk-admin-nav-dashboard', styles.navLink)}
|
||||
to="/admin/dashboard"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.dashboard')}
|
||||
</IndexLink>
|
||||
{
|
||||
can(auth.user, 'MODERATE_COMMENTS') && (
|
||||
<Link
|
||||
<IndexLink
|
||||
className={cn('talk-admin-nav-moderate', styles.navLink)}
|
||||
to="/admin/moderate"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.moderate')}
|
||||
</Link>
|
||||
</IndexLink>
|
||||
)
|
||||
}
|
||||
<Link
|
||||
|
||||
@@ -23,23 +23,16 @@ const CoralHeader = ({
|
||||
{
|
||||
auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
|
||||
<Navigation className={styles.nav}>
|
||||
<IndexLink
|
||||
id='dashboardNav'
|
||||
className={cn('talk-admin-nav-dashboard', styles.navLink)}
|
||||
to="/admin/dashboard"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.dashboard')}
|
||||
</IndexLink>
|
||||
{
|
||||
can(auth.user, 'MODERATE_COMMENTS') && (
|
||||
<Link
|
||||
<IndexLink
|
||||
id='moderateNav'
|
||||
className={cn('talk-admin-nav-moderate', styles.navLink)}
|
||||
to="/admin/moderate"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.moderate')}
|
||||
{(root.premodCount !== 0 || root.reportedCount !== 0) && <Indicator />}
|
||||
</Link>
|
||||
</IndexLink>
|
||||
)
|
||||
}
|
||||
<Link
|
||||
@@ -87,7 +80,7 @@ const CoralHeader = ({
|
||||
</a>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<a href="https://coralproject.net/contribute.html#other-ideas-and-bug-reports" target="_blank" rel="noopener noreferrer">
|
||||
<a href="https://support.coralproject.net" target="_blank" rel="noopener noreferrer">
|
||||
Report a bug or give feedback
|
||||
</a>
|
||||
</MenuItem>
|
||||
|
||||
@@ -6,3 +6,4 @@ export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH';
|
||||
export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH';
|
||||
export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE';
|
||||
export const MODERATION_CLEAR_STATE = 'MODERATION_CLEAR_STATE';
|
||||
export const MODERATION_SELECT_COMMENT = 'MODERATION_SELECT_COMMENT';
|
||||
|
||||
@@ -19,6 +19,6 @@ export default withQuery(gql`
|
||||
}
|
||||
`, {
|
||||
options: {
|
||||
pollInterval: 5000
|
||||
pollInterval: 10000
|
||||
}
|
||||
})(Header);
|
||||
|
||||
@@ -19,7 +19,7 @@ import update from 'immutability-helper';
|
||||
import {notify} from 'coral-framework/actions/notification';
|
||||
|
||||
const commentConnectionFragment = gql`
|
||||
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
|
||||
fragment CoralAdmin_UserDetail_CommentConnection on CommentConnection {
|
||||
nodes {
|
||||
...${getDefinitionName(UserDetailComment.fragments.comment)}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ class UserDetailContainer extends React.Component {
|
||||
const LOAD_MORE_QUERY = gql`
|
||||
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
|
||||
comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
...CoralAdmin_UserDetail_CommentConnection
|
||||
}
|
||||
}
|
||||
${commentConnectionFragment}
|
||||
@@ -155,7 +155,7 @@ export const withUserDetailQuery = withQuery(gql`
|
||||
author_id: $author_id,
|
||||
statuses: $statuses
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
...CoralAdmin_UserDetail_CommentConnection
|
||||
}
|
||||
...${getDefinitionName(UserDetailComment.fragments.root)}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import update from 'immutability-helper';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
|
||||
// Map nested object leaves. Array objects are considered leaves.
|
||||
function mapLeaves(o, mapper) {
|
||||
return mapValues(o, (val) => {
|
||||
if (typeof val === 'object' && !Array.isArray(val)) {
|
||||
return mapLeaves(val, mapper);
|
||||
}
|
||||
return mapper(val);
|
||||
});
|
||||
}
|
||||
import {mapLeaves} from 'coral-framework/utils';
|
||||
|
||||
export default {
|
||||
mutations: {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// this is initialized here because
|
||||
// currently you have to reload the dashboard to get new stats
|
||||
// cleaner updates are planned in the future.
|
||||
const DASHBOARD_WINDOW_MINUTES = 5;
|
||||
let then = new Date();
|
||||
then.setMinutes(then.getMinutes() - DASHBOARD_WINDOW_MINUTES);
|
||||
|
||||
const initialState = {
|
||||
windowStart: then.toISOString(),
|
||||
windowEnd: new Date().toISOString(),
|
||||
};
|
||||
|
||||
export default function dashboard (state = initialState, _action) {
|
||||
return state;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import auth from './auth';
|
||||
import stories from './stories';
|
||||
import dashboard from './dashboard';
|
||||
import configure from './configure';
|
||||
import community from './community';
|
||||
import moderation from './moderation';
|
||||
@@ -13,7 +12,6 @@ import userDetail from './userDetail';
|
||||
export default {
|
||||
auth,
|
||||
banUserDialog,
|
||||
dashboard,
|
||||
configure,
|
||||
suspendUserDialog,
|
||||
userDetail,
|
||||
|
||||
@@ -7,6 +7,7 @@ const initialState = {
|
||||
storySearchString: '',
|
||||
shortcutsNoteVisible: 'show',
|
||||
sortOrder: 'DESC',
|
||||
selectedCommentId: '',
|
||||
};
|
||||
|
||||
export default function moderation (state = initialState, action) {
|
||||
@@ -51,6 +52,11 @@ export default function moderation (state = initialState, action) {
|
||||
...state,
|
||||
sortOrder: action.order,
|
||||
};
|
||||
case actions.MODERATION_SELECT_COMMENT:
|
||||
return {
|
||||
...state,
|
||||
selectedCommentId: action.id,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class ModerationSettings extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {settings, data, root} = this.props;
|
||||
const {settings, data, root, updatePending, errors} = this.props;
|
||||
|
||||
return (
|
||||
<ConfigurePage
|
||||
@@ -74,6 +74,8 @@ class ModerationSettings extends React.Component {
|
||||
fill="adminModerationSettings"
|
||||
data={data}
|
||||
queryData={{root, settings}}
|
||||
updatePending={updatePending}
|
||||
errors={errors}
|
||||
/>
|
||||
</ConfigurePage>
|
||||
);
|
||||
@@ -82,6 +84,7 @@ class ModerationSettings extends React.Component {
|
||||
|
||||
ModerationSettings.propTypes = {
|
||||
updatePending: PropTypes.func.isRequired,
|
||||
errors: PropTypes.object.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
|
||||
@@ -53,3 +53,6 @@
|
||||
|
||||
|
||||
|
||||
.autoCloseWrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ class StreamSettings extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {settings, data, root, errors} = this.props;
|
||||
const {settings, data, root, errors, updatePending} = this.props;
|
||||
|
||||
return (
|
||||
<ConfigurePage
|
||||
@@ -180,22 +180,24 @@ class StreamSettings extends React.Component {
|
||||
onCheckbox={this.updateAutoClose}
|
||||
title={t('configure.close_after')}
|
||||
>
|
||||
<Textfield
|
||||
type='number'
|
||||
pattern='[0-9]+'
|
||||
style={{width: 50}}
|
||||
onChange={this.updateClosedTimeout}
|
||||
value={getTimeoutAmount(settings.closedTimeout)}
|
||||
label={t('configure.closed_comments_label')} />
|
||||
<div className={styles.configTimeoutSelect}>
|
||||
<SelectField
|
||||
label="comments closed time window"
|
||||
value={getTimeoutMeasure(settings.closedTimeout)}
|
||||
onChange={this.updateClosedTimeoutMeasure}>
|
||||
<Option value={'hours'}>{t('configure.hours')}</Option>
|
||||
<Option value={'days'}>{t('configure.days')}</Option>
|
||||
<Option value={'weeks'}>{t('configure.weeks')}</Option>
|
||||
</SelectField>
|
||||
<div className={styles.autoCloseWrapper}>
|
||||
<Textfield
|
||||
type='number'
|
||||
pattern='[0-9]+'
|
||||
style={{width: 50}}
|
||||
onChange={this.updateClosedTimeout}
|
||||
value={getTimeoutAmount(settings.closedTimeout)}
|
||||
label={t('configure.closed_comments_label')} />
|
||||
<div className={styles.configTimeoutSelect}>
|
||||
<SelectField
|
||||
label="comments closed time window"
|
||||
value={getTimeoutMeasure(settings.closedTimeout)}
|
||||
onChange={this.updateClosedTimeoutMeasure}>
|
||||
<Option value={'hours'}>{t('configure.hours')}</Option>
|
||||
<Option value={'days'}>{t('configure.days')}</Option>
|
||||
<Option value={'weeks'}>{t('configure.weeks')}</Option>
|
||||
</SelectField>
|
||||
</div>
|
||||
</div>
|
||||
</ConfigureCard>
|
||||
{/* the above card should be the last one if at all possible because of z-index issues with the selects */}
|
||||
@@ -203,6 +205,8 @@ class StreamSettings extends React.Component {
|
||||
fill="adminStreamSettings"
|
||||
data={data}
|
||||
queryData={{root, settings}}
|
||||
updatePending={updatePending}
|
||||
errors={errors}
|
||||
/>
|
||||
</ConfigurePage>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ class TechSettings extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {settings, data, root} = this.props;
|
||||
const {settings, data, root, errors, updatePending} = this.props;
|
||||
return (
|
||||
<ConfigurePage
|
||||
title={t('configure.tech_settings')}
|
||||
@@ -51,6 +51,8 @@ class TechSettings extends React.Component {
|
||||
fill="adminTechSettings"
|
||||
data={data}
|
||||
queryData={{root, settings}}
|
||||
updatePending={updatePending}
|
||||
errors={errors}
|
||||
/>
|
||||
</ConfigurePage>
|
||||
);
|
||||
@@ -59,6 +61,7 @@ class TechSettings extends React.Component {
|
||||
|
||||
TechSettings.propTypes = {
|
||||
updatePending: PropTypes.func.isRequired,
|
||||
errors: PropTypes.object.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
|
||||
@@ -2,38 +2,20 @@ import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
import {withQuery, withMergedSettings} from 'coral-framework/hocs';
|
||||
import {Spinner} from 'coral-ui';
|
||||
import {notify} from 'coral-framework/actions/notification';
|
||||
import PropTypes from 'prop-types';
|
||||
import assignWith from 'lodash/assignWith';
|
||||
import {withUpdateSettings} from 'coral-framework/graphql/mutations';
|
||||
import {getErrorMessages, getDefinitionName} from 'coral-framework/utils';
|
||||
import StreamSettings from './StreamSettings';
|
||||
import TechSettings from './TechSettings';
|
||||
import ModerationSettings from './ModerationSettings';
|
||||
import {clearPending, setActiveSection} from '../../../actions/configure';
|
||||
|
||||
import Configure from '../components/Configure';
|
||||
|
||||
// Like lodash merge but does not recurse into arrays.
|
||||
const mergeExcludingArrays = (objValue, srcValue) => {
|
||||
if (typeof srcValue === 'object' && !Array.isArray(srcValue)) {
|
||||
return assignWith({}, objValue, srcValue, mergeExcludingArrays);
|
||||
}
|
||||
return srcValue;
|
||||
};
|
||||
|
||||
class ConfigureContainer extends Component {
|
||||
|
||||
// Merge current settings with pending settings.
|
||||
getMergedSettings = (props = this.props) => {
|
||||
return assignWith({}, props.root.settings, props.pending, mergeExcludingArrays);
|
||||
}
|
||||
|
||||
// Cached merged settings.
|
||||
mergedSettings = this.getMergedSettings();
|
||||
|
||||
savePending = async () => {
|
||||
try {
|
||||
await this.props.updateSettings(this.props.pending);
|
||||
@@ -44,14 +26,6 @@ class ConfigureContainer extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
|
||||
// Recalculate merged settings when necessary.
|
||||
if (this.props.root.settings !== nextProps.root.settings || this.props.pending !== nextProps.pending) {
|
||||
this.mergedSettings = this.getMergedSettings(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
if(this.props.data.loading) {
|
||||
return <Spinner/>;
|
||||
@@ -62,7 +36,7 @@ class ConfigureContainer extends Component {
|
||||
auth={this.props.auth}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
settings={this.mergedSettings}
|
||||
settings={this.props.mergedSettings}
|
||||
canSave={this.props.canSave}
|
||||
savePending={this.savePending}
|
||||
setActiveSection={this.props.setActiveSection}
|
||||
@@ -112,6 +86,7 @@ export default compose(
|
||||
withUpdateSettings,
|
||||
withConfigureQuery,
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withMergedSettings('root.settings', 'pending', 'mergedSettings'),
|
||||
)(ConfigureContainer);
|
||||
|
||||
ConfigureContainer.propTypes = {
|
||||
@@ -124,5 +99,6 @@ ConfigureContainer.propTypes = {
|
||||
root: PropTypes.object.isRequired,
|
||||
canSave: PropTypes.bool.isRequired,
|
||||
pending: PropTypes.object.isRequired,
|
||||
mergedSettings: PropTypes.object.isRequired,
|
||||
activeSection: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,10 @@ const slots = [
|
||||
'adminModerationSettings',
|
||||
];
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
errors: state.configure.errors,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({
|
||||
updatePending,
|
||||
@@ -36,5 +40,5 @@ export default compose(
|
||||
}
|
||||
`
|
||||
}),
|
||||
connect(null, mapDispatchToProps),
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
)(ModerationSettings);
|
||||
|
||||
@@ -10,6 +10,10 @@ const slots = [
|
||||
'adminTechSettings',
|
||||
];
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
errors: state.configure.errors,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({
|
||||
updatePending,
|
||||
@@ -33,5 +37,5 @@ export default compose(
|
||||
}
|
||||
`
|
||||
}),
|
||||
connect(null, mapDispatchToProps),
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
)(TechSettings);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Link} from 'react-router';
|
||||
import styles from './Widget.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const ActivityWidget = ({assets}) => {
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>{t('dashboard.most_conversations')}</h2>
|
||||
<div className={styles.widgetHead}>
|
||||
<p>{t('streams.article')}</p>
|
||||
<p>{t('dashboard.comment_count')}</p>
|
||||
</div>
|
||||
<div className={styles.widgetTable}>
|
||||
{
|
||||
assets.length
|
||||
? assets.map((asset) => {
|
||||
return (
|
||||
<div className={styles.rowLinkify} key={asset.id}>
|
||||
<Link className={styles.linkToModerate} to={`/admin/moderate/${asset.id}`}>Moderate</Link>
|
||||
<p className={styles.widgetCount}>{asset.commentCount}</p>
|
||||
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
|
||||
<p className={styles.assetTitle}>{asset.title}</p>
|
||||
</a>
|
||||
<p className={styles.lede}>{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: <div className={styles.rowLinkify}>{t('dashboard.no_activity')}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ActivityWidget.propTypes = {
|
||||
assets: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
commentCount: PropTypes.number,
|
||||
author: PropTypes.string,
|
||||
created_at: PropTypes.string
|
||||
})).isRequired
|
||||
};
|
||||
|
||||
export default ActivityWidget;
|
||||
@@ -1,93 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './Dashboard.css';
|
||||
import {Icon} from 'coral-ui';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
const refreshIntervalSeconds = 60 * 5;
|
||||
|
||||
// TODO: refactor out storage code into redux.
|
||||
|
||||
class CountdownTimer extends React.Component {
|
||||
|
||||
static contextTypes = {
|
||||
storage: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
handleTimeout: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
const {storage} = context;
|
||||
try {
|
||||
if (storage && storage.getItem('coral:dashboardNote') === null) {
|
||||
storage.setItem('coral:dashboardNote', 'show');
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
// above will fail in Private Mode in some browsers.
|
||||
}
|
||||
|
||||
this.state = {
|
||||
secondsUntilRefresh: refreshIntervalSeconds,
|
||||
dashboardNote: (storage && storage.getItem('coral:dashboardNote')) || 'show'
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount () {
|
||||
this.interval = setInterval(() => { // the countdown timer
|
||||
let nextCount = this.state.secondsUntilRefresh - 1;
|
||||
if (nextCount < 0) {
|
||||
nextCount = refreshIntervalSeconds;
|
||||
return this.props.handleTimeout();
|
||||
}
|
||||
this.setState({secondsUntilRefresh: nextCount});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.clearInterval(this.interval);
|
||||
}
|
||||
|
||||
formatTime = () => {
|
||||
const minutes = Math.floor(this.state.secondsUntilRefresh / 60);
|
||||
let seconds = (this.state.secondsUntilRefresh % 60).toString();
|
||||
if (seconds.length < 2) {
|
||||
seconds = `0${seconds}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
dismissNote = () => {
|
||||
const {storage} = this.context;
|
||||
try {
|
||||
if (storage) {
|
||||
storage.setItem('coral:dashboardNote', 'hide');
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
// when setItem fails in Safari Private mode
|
||||
this.setState({dashboardNote: 'hide'});
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const {storage} = this.context;
|
||||
const hideReloadNote = (storage && storage.getItem('coral:dashboardNote') === 'hide') ||
|
||||
this.state.dashboardNote === 'hide'; // for Safari Incognito
|
||||
return (
|
||||
<p
|
||||
style={{display: hideReloadNote ? 'none' : 'block'}}
|
||||
className={styles.autoUpdate}
|
||||
onClick={this.dismissNote}>
|
||||
<b>×</b>
|
||||
<Icon name='timer' /> <strong>{t('dashboard.next_update', this.formatTime())}</strong> {t('dashboard.auto_update')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CountdownTimer;
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* @TODO: deprecated as this file contains styles from multiple components. Please refactor.
|
||||
*/
|
||||
|
||||
.Dashboard {
|
||||
display: flex;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.autoUpdate {
|
||||
background-color: #d5d5d5;
|
||||
padding: 3px 10px 10px 10px;
|
||||
margin-bottom: 0;
|
||||
|
||||
i {
|
||||
position: relative;
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
b {
|
||||
float: right;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
background-color: #c0c0c0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
text-align: center;
|
||||
top: 4px;
|
||||
position: relative;
|
||||
line-height: 1.7em;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import FlagWidget from './FlagWidget';
|
||||
import ActivityWidget from './ActivityWidget';
|
||||
import CountdownTimer from './CountdownTimer';
|
||||
import styles from './Dashboard.css';
|
||||
|
||||
export default ({root: {assetsByActivity, assetsByFlag}, reloadData}) => (
|
||||
<div>
|
||||
<CountdownTimer handleTimeout={reloadData} />
|
||||
<div className={styles.Dashboard}>
|
||||
<FlagWidget assets={assetsByFlag} />
|
||||
<ActivityWidget assets={assetsByActivity} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Link} from 'react-router';
|
||||
import styles from './Widget.css';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const FlagWidget = ({assets}) => {
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>{t('dashboard.most_flags')}</h2>
|
||||
<div className={styles.widgetHead}>
|
||||
<p>{t('streams.article')}</p>
|
||||
<p>{t('dashboard.flags')}</p>
|
||||
</div>
|
||||
<div className={styles.widgetTable}>
|
||||
{
|
||||
assets.length
|
||||
? assets.map((asset) => {
|
||||
let flagSummary = null;
|
||||
if (asset.action_summaries) {
|
||||
flagSummary = asset.action_summaries.find((s) => s.__typename === 'FlagAssetActionSummary');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.rowLinkify} key={asset.id}>
|
||||
<Link className={styles.linkToModerate} to={`/admin/moderate/reported/${asset.id}`}>Moderate</Link>
|
||||
<p className={styles.widgetCount}>{flagSummary ? flagSummary.actionCount : 0}</p>
|
||||
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
|
||||
<p className={styles.assetTitle}>{asset.title}</p>
|
||||
</a>
|
||||
<p className={styles.lede}>{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: <div className={styles.rowLinkify}>{t('dashboard.no_flags')}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FlagWidget.propTypes = {
|
||||
assets: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
action_summaries: PropTypes.array,
|
||||
author: PropTypes.string,
|
||||
created_at: PropTypes.string
|
||||
})).isRequired
|
||||
};
|
||||
|
||||
export default FlagWidget;
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Link} from 'react-router';
|
||||
import styles from './Widget.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const LikeWidget = ({assets}) => {
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>Articles with the most likes</h2>
|
||||
<div className={styles.widgetHead}>
|
||||
<p>{t('streams.article')}</p>
|
||||
<p>{t('modqueue.likes')}</p>
|
||||
</div>
|
||||
<div className={styles.widgetTable}>
|
||||
{
|
||||
assets.length
|
||||
? assets.map((asset) => {
|
||||
const likeSummary = asset.action_summaries.find((s) => s.type === 'LikeAssetActionSummary');
|
||||
return (
|
||||
<div className={styles.rowLinkify} key={asset.id}>
|
||||
<Link className={styles.linkToModerate} to={`/admin/moderate/${asset.id}`}>Moderate</Link>
|
||||
<p className={styles.widgetCount}>{likeSummary ? likeSummary.actionCount : 0}</p>
|
||||
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
|
||||
<p className={styles.assetTitle}>{asset.title}</p>
|
||||
</a>
|
||||
<p className={styles.lede}>{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: <div className={styles.rowLinkify}>{t('dashboard.no_likes')}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LikeWidget.propTypes = {
|
||||
assets: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
action_summaries: PropTypes.array,
|
||||
author: PropTypes.string,
|
||||
created_at: PropTypes.string
|
||||
})).isRequired
|
||||
};
|
||||
|
||||
export default LikeWidget;
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import ModerationQueue from 'coral-admin/src/containers/ModerationQueue/ModerationQueue';
|
||||
import styles from './Widget.css';
|
||||
import BanUserDialog from 'coral-admin/src/components/BanUserDialog';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const MostLikedCommentsWidget = (props) => {
|
||||
const {
|
||||
comments,
|
||||
moderation,
|
||||
settings,
|
||||
handleBanUser,
|
||||
showBanUserDialog,
|
||||
hideBanUserDialog,
|
||||
acceptComment,
|
||||
rejectComment
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>{t('most_liked_comments')}</h2>
|
||||
<ModerationQueue
|
||||
comments={comments}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
showBanUserDialog={showBanUserDialog}
|
||||
acceptComment={acceptComment}
|
||||
rejectComment={rejectComment} />
|
||||
<BanUserDialog
|
||||
open={moderation.banDialog}
|
||||
user={moderation.user}
|
||||
handleClose={hideBanUserDialog}
|
||||
handleBanUser={handleBanUser} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MostLikedCommentsWidget;
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* @TODO: deprecated as this file contains styles from multiple components. Please refactor.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--row-height: 60px;
|
||||
}
|
||||
|
||||
.widget {
|
||||
margin: 10px 5px 5px 5px;
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2);
|
||||
flex: 1;
|
||||
background-color: white;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.widget * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
.widgetTable {
|
||||
height: calc(var(--row-height) * 10);
|
||||
}
|
||||
|
||||
.widgetTable + div:after {
|
||||
content: '';
|
||||
clear: both;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.widgetHead p {
|
||||
color: #2c2c2c;
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
text-transform: capitalize;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.widgetHead p:last-child {
|
||||
float: right;
|
||||
margin-right: 100px;
|
||||
}
|
||||
|
||||
.rowLinkify {
|
||||
border-bottom: 1px solid lightgrey;
|
||||
color: #555;
|
||||
height: var(--row-height);
|
||||
padding: 10px;
|
||||
transition: background-color 200ms;
|
||||
}
|
||||
|
||||
.rowLinkify:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rowLinkify:hover {
|
||||
background-color: #f8f8f8;
|
||||
pointer: default;
|
||||
}
|
||||
|
||||
.linkToAsset {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.linkToModerate {
|
||||
background-color: #BDBDBD;
|
||||
padding: 10px 14px;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
float: right;
|
||||
margin-left: 15px;
|
||||
transition: background-color 200ms;
|
||||
}
|
||||
|
||||
.linkToModerate:hover {
|
||||
background-color: #9E9E9E;
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: 0.9em;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.assetTitle {
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
font-size: 1.2em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.widgetCount {
|
||||
color: #555;
|
||||
font-size: 1.3em;
|
||||
font-weight: 400;
|
||||
float: right;
|
||||
margin-top: 7px;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import Dashboard from '../components/Dashboard';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
|
||||
import {Spinner} from 'coral-ui';
|
||||
|
||||
class DashboardContainer extends React.Component {
|
||||
|
||||
reloadData = () => {
|
||||
this.props.data.refetch();
|
||||
}
|
||||
|
||||
render () {
|
||||
if (this.props.data.loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
return <Dashboard {...this.props} reloadData={this.reloadData} />;
|
||||
}
|
||||
}
|
||||
|
||||
export const witDashboardQuery = withQuery(gql`
|
||||
query CoralAdmin_Dashboard($from: Date!, $to: Date!) {
|
||||
assetsByFlag: assetMetrics(from: $from, to: $to, sortBy: FLAG) {
|
||||
...CoralAdmin_Metrics
|
||||
}
|
||||
assetsByActivity: assetMetrics(from: $from, to: $to, sortBy: ACTIVITY) {
|
||||
...CoralAdmin_Metrics
|
||||
}
|
||||
}
|
||||
fragment CoralAdmin_Metrics on Asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
author
|
||||
created_at
|
||||
commentCount
|
||||
action_summaries {
|
||||
actionCount
|
||||
actionableItemCount
|
||||
}
|
||||
}
|
||||
`, {
|
||||
options: ({windowStart, windowEnd}) => {
|
||||
return {
|
||||
variables: {
|
||||
from: windowStart,
|
||||
to: windowEnd,
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
windowStart: state.dashboard.windowStart,
|
||||
windowEnd: state.dashboard.windowEnd,
|
||||
};
|
||||
};
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps),
|
||||
witDashboardQuery,
|
||||
)(DashboardContainer);
|
||||
@@ -1 +0,0 @@
|
||||
export {default} from './containers/Dashboard.js';
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import {Spinner} from 'coral-ui';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* AutoLoadMore with call `loadMore` the moment it is rendered and shows a Spinner.
|
||||
*/
|
||||
class AutoLoadMore extends React.Component {
|
||||
componentDidMount() {
|
||||
if(!this.props.loading) {
|
||||
this.props.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Spinner />;
|
||||
}
|
||||
}
|
||||
|
||||
AutoLoadMore.propTypes = {
|
||||
loading: PropTypes.bool.isRequired,
|
||||
loadMore: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AutoLoadMore;
|
||||
@@ -10,7 +10,9 @@
|
||||
position: relative;
|
||||
transition: all 200ms;
|
||||
padding: 10px 0;
|
||||
margin-top: 13px;
|
||||
min-height: 0;
|
||||
outline: 0;
|
||||
|
||||
/*
|
||||
Fix rendering issues in Safari by promoting this
|
||||
@@ -25,6 +27,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dangling {
|
||||
background-color: #efefef;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@ import t, {timeago} from 'coral-framework/services/i18n';
|
||||
|
||||
class Comment extends React.Component {
|
||||
|
||||
ref = null;
|
||||
|
||||
handleRef = (ref) => this.ref = ref;
|
||||
|
||||
handleFocusOrClick = () => {
|
||||
if (!this.props.selected) {
|
||||
this.props.selectComment();
|
||||
}
|
||||
};
|
||||
|
||||
showSuspendUserDialog = () => {
|
||||
const {comment, showSuspendUserDialog} = this.props;
|
||||
return showSuspendUserDialog({
|
||||
@@ -55,6 +65,12 @@ class Comment extends React.Component {
|
||||
: this.props.rejectComment({commentId: this.props.comment.id})
|
||||
);
|
||||
|
||||
componentDidUpdate(prev) {
|
||||
if (!prev.selected && this.props.selected) {
|
||||
this.ref.focus();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
comment,
|
||||
@@ -65,6 +81,8 @@ class Comment extends React.Component {
|
||||
root: {settings},
|
||||
currentUserId,
|
||||
currentAsset,
|
||||
clearHeightCache,
|
||||
dangling,
|
||||
} = this.props;
|
||||
|
||||
const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
|
||||
@@ -73,8 +91,11 @@ class Comment extends React.Component {
|
||||
return (
|
||||
<li
|
||||
tabIndex={0}
|
||||
className={cn(className, 'mdl-card', selectionStateCSS, styles.root, {[styles.selected]: selected}, 'talk-admin-moderate-comment')}
|
||||
className={cn(className, 'mdl-card', selectionStateCSS, styles.root, {[styles.selected]: selected, [styles.dangling]: dangling}, 'talk-admin-moderate-comment')}
|
||||
id={`comment_${comment.id}`}
|
||||
onClick={this.handleFocusOrClick}
|
||||
ref={this.handleRef}
|
||||
onFocus={this.handleFocusOrClick}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
@@ -114,6 +135,7 @@ class Comment extends React.Component {
|
||||
<Slot
|
||||
fill="adminCommentInfoBar"
|
||||
data={data}
|
||||
clearHeightCache={clearHeightCache}
|
||||
queryData={queryData}
|
||||
/>
|
||||
</div>
|
||||
@@ -145,6 +167,7 @@ class Comment extends React.Component {
|
||||
<Slot
|
||||
fill="adminCommentContent"
|
||||
data={data}
|
||||
clearHeightCache={clearHeightCache}
|
||||
queryData={queryData}
|
||||
/>
|
||||
<div className={styles.sideActions}>
|
||||
@@ -166,6 +189,7 @@ class Comment extends React.Component {
|
||||
<Slot
|
||||
fill="adminSideActions"
|
||||
data={data}
|
||||
clearHeightCache={clearHeightCache}
|
||||
queryData={queryData}
|
||||
/>
|
||||
</div>
|
||||
@@ -176,6 +200,7 @@ class Comment extends React.Component {
|
||||
data={data}
|
||||
root={root}
|
||||
comment={comment}
|
||||
clearHeightCache={clearHeightCache}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
@@ -185,12 +210,16 @@ class Comment extends React.Component {
|
||||
Comment.propTypes = {
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
selectComment: PropTypes.func,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
dangling: PropTypes.bool,
|
||||
currentAsset: PropTypes.object,
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func.isRequired,
|
||||
currentUserId: PropTypes.string.isRequired,
|
||||
clearHeightCache: PropTypes.func,
|
||||
comment: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
|
||||
@@ -12,15 +12,10 @@ import Slot from 'coral-framework/components/Slot';
|
||||
import ViewOptions from './ViewOptions';
|
||||
|
||||
class Moderation extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const comments = this.getComments(props);
|
||||
|
||||
this.state = {
|
||||
selectedCommentId: comments[0] ? comments[0].id : null,
|
||||
};
|
||||
|
||||
}
|
||||
state = {
|
||||
isLoadingMore: false,
|
||||
};
|
||||
|
||||
componentWillMount() {
|
||||
const {toggleModal, singleView} = this.props;
|
||||
@@ -30,8 +25,6 @@ class Moderation extends Component {
|
||||
key('esc', () => toggleModal(false));
|
||||
key('ctrl+f', () => this.openSearch());
|
||||
key('t', () => this.nextQueue());
|
||||
key('j', () => this.select(true));
|
||||
key('k', () => this.select(false));
|
||||
key('f', () => this.moderate(false));
|
||||
key('d', () => this.moderate(true));
|
||||
this.getMenuItems()
|
||||
@@ -74,8 +67,7 @@ class Moderation extends Component {
|
||||
}
|
||||
|
||||
moderate = (accept) => {
|
||||
const {acceptComment, rejectComment} = this.props;
|
||||
const {selectedCommentId} = this.state;
|
||||
const {acceptComment, rejectComment, moderation: {selectedCommentId}} = this.props;
|
||||
|
||||
// Accept or reject only if there's a selected comment
|
||||
if(selectedCommentId != null){
|
||||
@@ -96,74 +88,16 @@ class Moderation extends Component {
|
||||
return root[activeTab].nodes;
|
||||
}
|
||||
|
||||
scrollTo = (toId, smooth = true) =>
|
||||
document.querySelector(`#comment_${toId}`).scrollIntoView(smooth ? {behavior: 'smooth'} : {});
|
||||
|
||||
select = async (next, props = this.props, selectedCommentId = this.state.selectedCommentId) => {
|
||||
const comments = this.getComments(props);
|
||||
|
||||
// No comments to be selected.
|
||||
if (comments.length === 0){
|
||||
return;
|
||||
}
|
||||
|
||||
// Find current index if we have a selected comment.
|
||||
const index = selectedCommentId
|
||||
? comments.findIndex((comment) => comment.id === selectedCommentId)
|
||||
: null;
|
||||
|
||||
if (next) {
|
||||
|
||||
// Grab first one if we don't have a selected comment yet.
|
||||
if (!selectedCommentId) {
|
||||
this.setState({selectedCommentId: comments[0].id}, () => this.scrollTo(comments[0].id));
|
||||
return;
|
||||
}
|
||||
|
||||
// Select next one when we still have more comments left.
|
||||
if (index < comments.length - 1) {
|
||||
this.setState({selectedCommentId: comments[index + 1].id}, () => this.scrollTo(comments[index + 1].id));
|
||||
return;
|
||||
} else {
|
||||
|
||||
// We hit the end of the list, load more comments if we have.
|
||||
if (comments.length < this.getActiveTabCount()) {
|
||||
const res = await this.loadMore();
|
||||
|
||||
// If `loadMore` was already in progress, res would be false.
|
||||
if (res) {
|
||||
|
||||
// Select next comment after loading has completed.
|
||||
this.select(true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
|
||||
// We have no selected comment, so just skip it.
|
||||
if (!selectedCommentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we still have previous comments take the one before.
|
||||
if (index > 0) {
|
||||
this.setState({selectedCommentId: comments[index - 1].id}, () => this.scrollTo(comments[index - 1].id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMore = async () => {
|
||||
if (!this.isLoadingMore) {
|
||||
this.isLoadingMore = true;
|
||||
if (!this.state.isLoadingMore) {
|
||||
this.setState({isLoadingMore: true});
|
||||
try {
|
||||
const result = await this.props.loadMore(this.props.activeTab);
|
||||
this.isLoadingMore = false;
|
||||
this.setState({isLoadingMore: false});
|
||||
return result;
|
||||
}
|
||||
catch (e) {
|
||||
this.isLoadingMore = false;
|
||||
this.setState({isLoadingMore: false});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -176,56 +110,12 @@ class Moderation extends Component {
|
||||
key.unbind('esc');
|
||||
key.unbind('ctrl+f');
|
||||
key.unbind('t');
|
||||
key.unbind('j');
|
||||
key.unbind('k');
|
||||
key.unbind('f');
|
||||
key.unbind('d');
|
||||
this.getMenuItems()
|
||||
.forEach((menuItem, idx) => key.unbind(`${idx + 1}`));
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
|
||||
if (this.props.activeTab !== nextProps.activeTab) {
|
||||
|
||||
// Reset selection when changing tabs.
|
||||
this.select(true, nextProps, null);
|
||||
} else {
|
||||
|
||||
// Detect if comment has left the queue and find next or prev selected comment to set it
|
||||
// as the new selectedCommentId.
|
||||
const prevComments = this.getComments(this.props);
|
||||
const nextComments = this.getComments(nextProps);
|
||||
if (nextComments.length < prevComments.length) {
|
||||
|
||||
// Comments have changed, now check if our selected comment has left the queue.
|
||||
if (
|
||||
this.state.selectedCommentId &&
|
||||
!nextComments.some((comment) => comment.id === this.state.selectedCommentId)
|
||||
) {
|
||||
|
||||
// Determine a comment to select.
|
||||
const prevIndex = prevComments.findIndex((comment) => comment.id === this.state.selectedCommentId);
|
||||
if (prevIndex !== prevComments.length - 1) {
|
||||
this.setState({selectedCommentId: prevComments[prevIndex + 1].id});
|
||||
} else if(prevIndex > 0) {
|
||||
this.setState({selectedCommentId: prevComments[prevIndex - 1].id});
|
||||
} else {
|
||||
this.setState({selectedCommentId: null});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
|
||||
// Scroll to comment when changing from single wiew to normal view.
|
||||
if (prevProps.moderation.singleView !== this.props.moderation.singleView && this.state.selectedCommentId) {
|
||||
this.scrollTo(this.state.selectedCommentId, false);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const {root, data, moderation, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props;
|
||||
const {asset} = root;
|
||||
@@ -266,17 +156,22 @@ class Moderation extends Component {
|
||||
root={this.props.root}
|
||||
currentAsset={asset}
|
||||
comments={comments.nodes}
|
||||
hasNextPage={comments.hasNextPage}
|
||||
activeTab={activeTab}
|
||||
singleView={moderation.singleView}
|
||||
selectedCommentId={this.state.selectedCommentId}
|
||||
selectedCommentId={moderation.selectedCommentId}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
acceptComment={props.acceptComment}
|
||||
rejectComment={props.rejectComment}
|
||||
loadMore={this.loadMore}
|
||||
commentBelongToQueue={this.props.commentBelongToQueue}
|
||||
isLoadingMore={this.state.isLoadingMore}
|
||||
commentCount={activeTabCount}
|
||||
currentUserId={this.props.auth.user.id}
|
||||
viewUserDetail={viewUserDetail}
|
||||
selectCommentId={props.selectCommentId}
|
||||
cleanUpQueue={props.cleanUpQueue}
|
||||
/>
|
||||
<ModerationKeysModal
|
||||
hideShortcutsNote={props.hideShortcutsNote}
|
||||
@@ -307,12 +202,15 @@ class Moderation extends Component {
|
||||
Moderation.propTypes = {
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
toggleModal: PropTypes.func.isRequired,
|
||||
selectedCommentId: PropTypes.string,
|
||||
toggleStorySearch: PropTypes.func.isRequired,
|
||||
getModPath: PropTypes.func.isRequired,
|
||||
cleanUpQueue: PropTypes.func.isRequired,
|
||||
storySearchChange: PropTypes.func.isRequired,
|
||||
moderation: PropTypes.object.isRequired,
|
||||
auth: PropTypes.object.isRequired,
|
||||
queueConfig: PropTypes.object.isRequired,
|
||||
commentBelongToQueue: PropTypes.func.isRequired,
|
||||
handleCommentChange: PropTypes.func.isRequired,
|
||||
setSortOrder: PropTypes.func.isRequired,
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -2,29 +2,15 @@
|
||||
padding: 8px 0;
|
||||
list-style: none;
|
||||
display: block;
|
||||
min-height: 650px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:global(html) {
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.commentLeave {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.commentLeaveActive {
|
||||
opacity: 0;
|
||||
transition: opacity 800ms;
|
||||
}
|
||||
|
||||
.commentEnter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.commentEnterActive {
|
||||
opacity: 1.0;
|
||||
transition: opacity 800ms;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,26 +4,29 @@ import PropTypes from 'prop-types';
|
||||
import Comment from '../containers/Comment';
|
||||
import styles from './ModerationQueue.css';
|
||||
import EmptyCard from '../../../components/EmptyCard';
|
||||
import LoadMore from '../../../components/LoadMore';
|
||||
import AutoLoadMore from './AutoLoadMore';
|
||||
import ViewMore from './ViewMore';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {CSSTransitionGroup} from 'react-transition-group';
|
||||
import {WindowScroller, CellMeasurer, CellMeasurerCache, List} from 'react-virtualized';
|
||||
import throttle from 'lodash/throttle';
|
||||
import key from 'keymaster';
|
||||
|
||||
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
|
||||
|
||||
// resetCursors will return the id cursors of the first and second comment of
|
||||
// the current comment list. The cursors are used to dertermine which
|
||||
// comments to show. The spare cursor functions as a backup in case one
|
||||
// of the comments gets deleted.
|
||||
// the current comment The spare cursor functions as a backup in case one
|
||||
// of the comments gets deleted. Additionally the new view based on the new
|
||||
// cursors are also returned.
|
||||
function resetCursors(state, props) {
|
||||
let idCursors = [];
|
||||
if (props.comments && props.comments.length) {
|
||||
const idCursors = [props.comments[0].id];
|
||||
idCursors.push(props.comments[0].id);
|
||||
if (props.comments[1]) {
|
||||
idCursors.push(props.comments[1].id);
|
||||
}
|
||||
return {idCursors};
|
||||
}
|
||||
return {idCursors: []};
|
||||
const view = getVisibleComments(props.comments, idCursors[0]);
|
||||
return {idCursors, view};
|
||||
}
|
||||
|
||||
// invalidateCursor is called whenever a comment is removed which is referenced
|
||||
@@ -40,91 +43,306 @@ function invalidateCursor(invalidated, state, props) {
|
||||
idCursors.push(nextInLine.id);
|
||||
}
|
||||
}
|
||||
return {idCursors};
|
||||
return idCursors;
|
||||
}
|
||||
|
||||
// getVisibileComments returns a list containing comments
|
||||
// which comes after the `idCursor`.
|
||||
function getVisibleComments(comments, idCursor) {
|
||||
|
||||
if (!comments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const view = [];
|
||||
let pastCursor = false;
|
||||
comments.forEach((comment) => {
|
||||
if (comment.id === idCursor) {
|
||||
pastCursor = true;
|
||||
}
|
||||
if (pastCursor) {
|
||||
view.push(comment);
|
||||
}
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
// Current keymapper to use for the CellMeasurer Cache.
|
||||
let keyMapper = null;
|
||||
|
||||
// CellMeasurerCache is used to measure the size of the elements
|
||||
// of the virtual list. We use a global one with a keyMapper that
|
||||
// should resolve to a comment id, which is then used to cache the height.
|
||||
const cache = new CellMeasurerCache({
|
||||
fixedWidth: true,
|
||||
defaultHeight: 250,
|
||||
keyMapper: (index) => keyMapper(index),
|
||||
});
|
||||
|
||||
class ModerationQueue extends React.Component {
|
||||
isLoadingMore = false;
|
||||
listRef = null;
|
||||
callbackCaches = {
|
||||
clearHeightCache: {},
|
||||
selectCommentId: {},
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
...resetCursors(this.state, props),
|
||||
};
|
||||
|
||||
// Set keyMapper to map to comment ids.
|
||||
keyMapper = (index) => {
|
||||
const view = this.state.view;
|
||||
if (index < view.length) {
|
||||
return view[index].id;
|
||||
}
|
||||
else if (index === view.length) {
|
||||
return 'loadMore';
|
||||
}
|
||||
throw new Error(`unknown index ${index}`);
|
||||
};
|
||||
|
||||
// Select first comment.
|
||||
if (this.state.view.length) {
|
||||
props.selectCommentId(this.state.view[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prev) {
|
||||
const {comments, commentCount} = this.props;
|
||||
componentDidMount() {
|
||||
key('j', () => this.selectDown());
|
||||
key('k', () => this.selectUp());
|
||||
|
||||
// if the user just moderated the last (visible) comment
|
||||
// AND there are more comments available on the server,
|
||||
// go ahead and load more comments
|
||||
if (prev.comments.length > 0 && comments.length === 0 && commentCount > 0) {
|
||||
this.props.loadMore();
|
||||
}
|
||||
// TODO: Workaround for issue https://github.com/bvaughn/react-virtualized/issues/866
|
||||
this.reflowList();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
key.unbind('j');
|
||||
key.unbind('k');
|
||||
|
||||
// When switching queues, clean it up first.
|
||||
// Removes dangling comments and reduce overly large
|
||||
// lists and restore chronological order.
|
||||
this.props.cleanUpQueue(this.props.activeTab);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
const {comments: prevComments} = this.props;
|
||||
const {comments: nextComments} = next;
|
||||
|
||||
if (!prevComments && nextComments) {
|
||||
this.setState(resetCursors);
|
||||
// New comments where added and our cursor list is incomplete.
|
||||
if (this.state.idCursors.length < 2 && nextComments.length > this.state.idCursors.length) {
|
||||
this.setState(resetCursors(this.state, next));
|
||||
return;
|
||||
}
|
||||
|
||||
let idCursors = this.state.idCursors;
|
||||
|
||||
// Comments have been removed.
|
||||
if (
|
||||
prevComments && nextComments &&
|
||||
nextComments.length < prevComments.length
|
||||
nextComments.length < prevComments.length
|
||||
) {
|
||||
|
||||
// Invalidate first cursor if referenced comment was removed.
|
||||
if (this.state.idCursors[0] && !hasComment(nextComments, this.state.idCursors[0])) {
|
||||
this.setState(invalidateCursor(0, this.state, next));
|
||||
idCursors = invalidateCursor(0, this.state, next);
|
||||
}
|
||||
|
||||
// Invalidate second cursor if referenced comment was removed.
|
||||
if (this.state.idCursors[1] && !hasComment(nextComments, this.state.idCursors[1])) {
|
||||
this.setState(invalidateCursor(1, this.state, next));
|
||||
idCursors = invalidateCursor(1, this.state, next);
|
||||
}
|
||||
|
||||
// Selected comment was removed, determine and set next selected comment.
|
||||
if (
|
||||
this.props.selectedCommentId &&
|
||||
!hasComment(nextComments, this.props.selectedCommentId)
|
||||
) {
|
||||
const view = this.state.view;
|
||||
let nextSelectedCommentId = null;
|
||||
|
||||
// Determine a comment to select.
|
||||
const prevIndex = view.findIndex((comment) => comment.id === this.props.selectedCommentId);
|
||||
if (prevIndex !== view.length - 1) {
|
||||
nextSelectedCommentId = view[prevIndex + 1].id;
|
||||
} else if(prevIndex > 0) {
|
||||
nextSelectedCommentId = view[prevIndex - 1].id;
|
||||
}
|
||||
this.props.selectCommentId(nextSelectedCommentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Comments changed.
|
||||
if (prevComments !== nextComments) {
|
||||
const nextView = getVisibleComments(nextComments, idCursors[0]);
|
||||
this.setState({idCursors, view: nextView});
|
||||
|
||||
// TODO: removing or adding a comment from the list seems to render incorrect, is this a bug?
|
||||
// Find first changed comment and perform a reflow.
|
||||
const index = this.state.view.findIndex((comment, i) => !nextView[i] || nextView[i].id !== comment.id);
|
||||
this.reflowList(index);
|
||||
}
|
||||
}
|
||||
|
||||
viewNewComments = () => {
|
||||
this.setState(resetCursors);
|
||||
componentDidUpdate (prev) {
|
||||
const {commentCount, selectedCommentId} = this.props;
|
||||
|
||||
// If the user just moderated the last (visible) comment
|
||||
// AND there are more comments available on the server,
|
||||
// go ahead and load more comments
|
||||
if (prev.comments.length > 0 && this.getCommentCountWithoutDagling() === 0 && commentCount > 0) {
|
||||
this.props.loadMore();
|
||||
}
|
||||
|
||||
// Scroll to selected comment.
|
||||
if (prev.selectedCommentId !== selectedCommentId && this.listRef) {
|
||||
|
||||
const view = this.state.view;
|
||||
const index = view.findIndex(({id}) => id === selectedCommentId);
|
||||
|
||||
this.listRef.scrollToRow(index);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns comment counts without dangling comments.
|
||||
getCommentCountWithoutDagling(props = this.props) {
|
||||
return props.comments.filter((comment) => props.commentBelongToQueue(props.activeTab, comment)).length;
|
||||
}
|
||||
|
||||
async selectDown() {
|
||||
const view = this.state.view;
|
||||
const index = view.findIndex(({id}) => id === this.props.selectedCommentId);
|
||||
if (index === view.length - 1 && this.getCommentCountWithoutDagling() !== this.props.commentCount) {
|
||||
await this.props.loadMore();
|
||||
this.selectDown();
|
||||
return;
|
||||
}
|
||||
if (index < view.length - 1) {
|
||||
this.props.selectCommentId(view[index + 1].id);
|
||||
}
|
||||
}
|
||||
|
||||
selectUp() {
|
||||
const view = this.state.view;
|
||||
const index = view.findIndex(({id}) => id === this.props.selectedCommentId);
|
||||
|
||||
if (index === 0 && view.length < this.props.comments.length) {
|
||||
this.viewNewComments(() => this.selectUp());
|
||||
return;
|
||||
}
|
||||
if (index > 0) {
|
||||
this.props.selectCommentId(view[index - 1].id);
|
||||
}
|
||||
}
|
||||
|
||||
handleListRef = (list) => {
|
||||
this.listRef = list;
|
||||
};
|
||||
|
||||
// getVisibileComments returns a list containing comments
|
||||
// which comes after the `idCursor`.
|
||||
getVisibleComments() {
|
||||
const {comments} = this.props;
|
||||
const idCursor = this.state.idCursors[0];
|
||||
viewNewComments = (callback) => {
|
||||
this.setState(resetCursors, () => {
|
||||
this.reflowList();
|
||||
callback && callback();
|
||||
});
|
||||
};
|
||||
|
||||
if (!comments) {
|
||||
return [];
|
||||
reflowList = throttle((index) => {
|
||||
if (index >= 0) {
|
||||
cache.clear(index);
|
||||
this.listRef && this.listRef.recomputeRowHeights(index);
|
||||
}
|
||||
else {
|
||||
cache.clearAll();
|
||||
this.listRef && this.listRef.recomputeRowHeights();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
rowRenderer = ({
|
||||
index, // Index of row within collection
|
||||
parent,
|
||||
style // Style object to be applied to row (to position it)
|
||||
}) => {
|
||||
const view = this.state.view;
|
||||
const rowCount = view.length + 1;
|
||||
|
||||
let child = null;
|
||||
let key = null;
|
||||
|
||||
// Last element of list is our AutoLoadMore component and contains an
|
||||
// id indicating that this is the last element in list.
|
||||
if (index === rowCount - 1) {
|
||||
key = 'end-of-comment-list';
|
||||
child = (
|
||||
<div
|
||||
style={style}
|
||||
id={'end-of-comment-list'}
|
||||
>
|
||||
{this.props.hasNextPage && <AutoLoadMore
|
||||
loadMore={this.props.loadMore}
|
||||
loading={this.props.isLoadingMore}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
else {
|
||||
const comment = view[index];
|
||||
|
||||
// Use callback cache so not to change the identity of these arrow functions.
|
||||
// Otherwise shallow compare will fail to optimize.
|
||||
if (!this.callbackCaches.clearHeightCache[index]) {
|
||||
this.callbackCaches.clearHeightCache[index] = () => this.reflowList(index);
|
||||
}
|
||||
if (!this.callbackCaches.selectCommentId[comment.id]) {
|
||||
this.callbackCaches.selectCommentId[comment.id] = () => this.props.selectCommentId(comment.id);
|
||||
}
|
||||
|
||||
key = comment.id;
|
||||
child = (
|
||||
<div
|
||||
style={style}
|
||||
>
|
||||
<Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
comment={comment}
|
||||
dangling={!this.props.commentBelongToQueue(this.props.activeTab, comment)}
|
||||
selected={comment.id === this.props.selectedCommentId}
|
||||
viewUserDetail={this.props.viewUserDetail}
|
||||
showBanUserDialog={this.props.showBanUserDialog}
|
||||
showSuspendUserDialog={this.props.showSuspendUserDialog}
|
||||
acceptComment={this.props.acceptComment}
|
||||
rejectComment={this.props.rejectComment}
|
||||
currentAsset={this.props.currentAsset}
|
||||
currentUserId={this.props.currentUserId}
|
||||
clearHeightCache={this.callbackCaches.clearHeightCache[index]}
|
||||
selectComment={this.callbackCaches.selectCommentId[comment.id]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const view = [];
|
||||
let pastCursor = false;
|
||||
comments.forEach((comment) => {
|
||||
if (comment.id === idCursor) {
|
||||
pastCursor = true;
|
||||
}
|
||||
if (pastCursor) {
|
||||
view.push(comment);
|
||||
}
|
||||
});
|
||||
return view;
|
||||
}
|
||||
return (
|
||||
<CellMeasurer
|
||||
cache={cache}
|
||||
columnIndex={0}
|
||||
key={key}
|
||||
parent={parent}
|
||||
rowIndex={index}
|
||||
>
|
||||
{child}
|
||||
</CellMeasurer>
|
||||
);
|
||||
};
|
||||
|
||||
render () {
|
||||
const {
|
||||
comments,
|
||||
selectedCommentId,
|
||||
commentCount,
|
||||
singleView,
|
||||
viewUserDetail,
|
||||
activeTab,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
@@ -159,54 +377,35 @@ class ModerationQueue extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
const view = this.getVisibleComments();
|
||||
const view = this.state.view;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<ViewMore
|
||||
viewMore={this.viewNewComments}
|
||||
viewMore={() => this.viewNewComments()}
|
||||
count={comments.length - view.length}
|
||||
/>
|
||||
<CSSTransitionGroup
|
||||
key={activeTab}
|
||||
component={'ul'}
|
||||
className={styles.list}
|
||||
transitionName={{
|
||||
enter: styles.commentEnter,
|
||||
enterActive: styles.commentEnterActive,
|
||||
leave: styles.commentLeave,
|
||||
leaveActive: styles.commentLeaveActive,
|
||||
}}
|
||||
transitionEnter={true}
|
||||
transitionLeave={true}
|
||||
transitionEnterTimeout={1000}
|
||||
transitionLeaveTimeout={1000}
|
||||
>
|
||||
{
|
||||
view
|
||||
.map((comment) => {
|
||||
return <Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
selected={comment.id === selectedCommentId}
|
||||
viewUserDetail={viewUserDetail}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
acceptComment={props.acceptComment}
|
||||
rejectComment={props.rejectComment}
|
||||
currentAsset={props.currentAsset}
|
||||
currentUserId={this.props.currentUserId}
|
||||
/>;
|
||||
})
|
||||
}
|
||||
</CSSTransitionGroup>
|
||||
|
||||
<LoadMore
|
||||
loadMore={this.props.loadMore}
|
||||
showLoadMore={comments.length < commentCount}
|
||||
/>
|
||||
<WindowScroller onResize={this.reflowList}>
|
||||
{({height, isScrolling, onChildScroll, scrollTop}) => (
|
||||
<List
|
||||
ref={this.handleListRef}
|
||||
autoHeight
|
||||
className={styles.list}
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
height={height}
|
||||
width={1280}
|
||||
scrollTop={scrollTop}
|
||||
isScrolling={isScrolling}
|
||||
onScroll={onChildScroll}
|
||||
rowCount={view.length + 1}
|
||||
deferredMeasurementCache={cache}
|
||||
rowRenderer={this.rowRenderer}
|
||||
rowHeight={cache.rowHeight}
|
||||
/>
|
||||
)}
|
||||
</WindowScroller>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -216,14 +415,19 @@ ModerationQueue.propTypes = {
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
currentAsset: PropTypes.object,
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
selectCommentId: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
comments: PropTypes.array.isRequired,
|
||||
commentBelongToQueue: PropTypes.func.isRequired,
|
||||
cleanUpQueue: PropTypes.func.isRequired,
|
||||
commentCount: PropTypes.number.isRequired,
|
||||
loadMore: PropTypes.func.isRequired,
|
||||
selectedCommentId: PropTypes.string,
|
||||
singleView: PropTypes.bool,
|
||||
isLoadingMore: PropTypes.bool,
|
||||
hasNextPage: PropTypes.bool,
|
||||
comments: PropTypes.array,
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
background-color: #2376D8;
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
margin: 0;
|
||||
margin-top: -18px;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
|
||||
.viewMore:hover {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
overflow: visible;
|
||||
height: 144px;
|
||||
min-height: auto;
|
||||
margin-top: 16px;
|
||||
z-index: 10;
|
||||
|
||||
@media (--tablet) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import NotFoundAsset from '../components/NotFoundAsset';
|
||||
import {isPremod, getModPath} from '../../../utils';
|
||||
|
||||
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
|
||||
import {handleCommentChange} from '../graphql';
|
||||
import {handleCommentChange, commentBelongToQueue, cleanUpQueue} from '../graphql';
|
||||
|
||||
import {showBanUserDialog} from 'actions/banUserDialog';
|
||||
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
toggleStorySearch,
|
||||
setSortOrder,
|
||||
storySearchChange,
|
||||
clearState
|
||||
clearState,
|
||||
selectCommentId,
|
||||
} from 'actions/moderation';
|
||||
import withQueueConfig from '../hoc/withQueueConfig';
|
||||
import {notify} from 'coral-framework/actions/notification';
|
||||
@@ -138,7 +139,7 @@ class ModerationContainer extends Component {
|
||||
},
|
||||
];
|
||||
|
||||
this.subscriptions = parameters.map((param) => this.props.data.subscribeToMore(param));
|
||||
this.subscriptions = parameters.map((param) => this.props.data.subscribeToMoreThrottled(param));
|
||||
}
|
||||
|
||||
unsubscribe() {
|
||||
@@ -168,6 +169,14 @@ class ModerationContainer extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
cleanUpQueue = (queue) => {
|
||||
if (!this.props.data.loading) {
|
||||
this.props.data.updateQuery((query) => {
|
||||
return cleanUpQueue(query, queue, this.props.moderation.sortOrder, this.props.queueConfig);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
acceptComment = ({commentId}) => {
|
||||
return this.props.setCommentStatus({commentId, status: 'ACCEPTED'});
|
||||
}
|
||||
@@ -176,9 +185,13 @@ class ModerationContainer extends Component {
|
||||
return this.props.setCommentStatus({commentId, status: 'REJECTED'});
|
||||
}
|
||||
|
||||
commentBelongToQueue = (queue, comment) => {
|
||||
return commentBelongToQueue(queue, comment, this.props.queueConfig);
|
||||
}
|
||||
|
||||
loadMore = (tab) => {
|
||||
const variables = {
|
||||
limit: 10,
|
||||
limit: 20,
|
||||
cursor: this.props.root[tab].endCursor,
|
||||
sortOrder: this.props.data.variables.sortOrder,
|
||||
asset_id: this.props.data.variables.asset_id,
|
||||
@@ -194,7 +207,6 @@ class ModerationContainer extends Component {
|
||||
[tab]: {
|
||||
nodes: {$push: comments.nodes},
|
||||
hasNextPage: {$set: comments.hasNextPage},
|
||||
startCursor: {$set: comments.startCursor},
|
||||
endCursor: {$set: comments.endCursor},
|
||||
},
|
||||
});
|
||||
@@ -246,6 +258,9 @@ class ModerationContainer extends Component {
|
||||
activeTab={this.activeTab}
|
||||
queueConfig={currentQueueConfig}
|
||||
handleCommentChange={this.handleCommentChange}
|
||||
selectedCommentId={this.props.selectedCommentId}
|
||||
commentBelongToQueue={this.commentBelongToQueue}
|
||||
cleanUpQueue={this.cleanUpQueue}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
@@ -361,7 +376,8 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
|
||||
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
|
||||
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
|
||||
asset_id: $asset_id,
|
||||
sortOrder: $sortOrder
|
||||
sortOrder: $sortOrder,
|
||||
limit: 20,
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
@@ -386,6 +402,9 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
|
||||
organizationName
|
||||
moderation
|
||||
}
|
||||
me {
|
||||
id
|
||||
}
|
||||
...${getDefinitionName(Comment.fragments.root)}
|
||||
}
|
||||
${Comment.fragments.root}
|
||||
@@ -423,6 +442,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
storySearchChange,
|
||||
clearState,
|
||||
notify,
|
||||
selectCommentId,
|
||||
}, dispatch),
|
||||
});
|
||||
|
||||
|
||||
@@ -16,16 +16,21 @@ function queueHasComment(root, queue, id) {
|
||||
return root[queue].nodes.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
function removeCommentFromQueue(root, queue, id) {
|
||||
function removeCommentFromQueue(root, queue, id, dangling = false) {
|
||||
if (!queueHasComment(root, queue, id)) {
|
||||
return root;
|
||||
}
|
||||
return update(root, {
|
||||
const changes = {
|
||||
[`${queue}Count`]: {$set: root[`${queue}Count`] - 1},
|
||||
[queue]: {
|
||||
};
|
||||
|
||||
if (!dangling) {
|
||||
changes[queue] = {
|
||||
nodes: {$apply: (nodes) => nodes.filter((c) => c.id !== id)},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return update(root, changes);
|
||||
}
|
||||
|
||||
function shouldCommentBeAdded(root, queue, comment, sortOrder) {
|
||||
@@ -40,26 +45,46 @@ function shouldCommentBeAdded(root, queue, comment, sortOrder) {
|
||||
: new Date(comment.created_at) >= cursor;
|
||||
}
|
||||
|
||||
function addCommentToQueue(root, queue, comment, sortOrder) {
|
||||
function addCommentToQueue(root, queue, comment, sortOrder, cleanup) {
|
||||
if (queueHasComment(root, queue, comment.id)) {
|
||||
return root;
|
||||
}
|
||||
|
||||
const sortAlgo = sortOrder === 'ASC' ? ascending : descending;
|
||||
const changes = {
|
||||
[`${queue}Count`]: {$set: root[`${queue}Count`] + 1},
|
||||
};
|
||||
|
||||
if (shouldCommentBeAdded(root, queue, comment, sortOrder)) {
|
||||
const nodes = root[queue].nodes.concat(comment).sort(sortAlgo);
|
||||
changes[queue] = {
|
||||
nodes: {$set: nodes},
|
||||
startCursor: {$set: nodes[0].created_at},
|
||||
endCursor: {$set: nodes[nodes.length - 1].created_at},
|
||||
};
|
||||
if (!shouldCommentBeAdded(root, queue, comment, sortOrder)) {
|
||||
return update(root, changes);
|
||||
}
|
||||
|
||||
return update(root, changes);
|
||||
const cursor = new Date(root[queue].startCursor);
|
||||
const date = new Date(comment.created_at);
|
||||
|
||||
let append = sortOrder === 'ASC'
|
||||
? date >= cursor
|
||||
: date <= cursor;
|
||||
|
||||
const nodes = append
|
||||
? root[queue].nodes.concat(comment)
|
||||
: [comment].concat(...root[queue].nodes);
|
||||
|
||||
changes[queue] = {
|
||||
nodes: {$set: nodes},
|
||||
};
|
||||
|
||||
const next = update(root, changes);
|
||||
|
||||
if (!cleanup) {
|
||||
return next;
|
||||
}
|
||||
|
||||
return cleanUpQueue(next, queue, sortOrder);
|
||||
}
|
||||
|
||||
function sortComments(nodes, sortOrder) {
|
||||
const sortAlgo = sortOrder === 'ASC' ? ascending : descending;
|
||||
return nodes.sort(sortAlgo);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,24 +93,92 @@ function addCommentToQueue(root, queue, comment, sortOrder) {
|
||||
function getCommentQueues(comment, queueConfig) {
|
||||
const queues = [];
|
||||
Object.keys(queueConfig).forEach((key) => {
|
||||
const {action_type, statuses, tags} = queueConfig[key];
|
||||
let addToQueues = true;
|
||||
if (statuses && statuses.indexOf(comment.status) === -1) {
|
||||
addToQueues = false;
|
||||
}
|
||||
if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) {
|
||||
addToQueues = false;
|
||||
}
|
||||
if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) {
|
||||
addToQueues = false;
|
||||
}
|
||||
if (addToQueues) {
|
||||
if (commentBelongToQueue(key, comment, queueConfig)) {
|
||||
queues.push(key);
|
||||
}
|
||||
});
|
||||
return queues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether or not the comment belongs to the queue.
|
||||
*/
|
||||
export function commentBelongToQueue(queue, comment, queueConfig) {
|
||||
const {action_type, statuses, tags} = queueConfig[queue];
|
||||
let belong = true;
|
||||
if (statuses && statuses.indexOf(comment.status) === -1) {
|
||||
belong = false;
|
||||
}
|
||||
if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) {
|
||||
belong = false;
|
||||
}
|
||||
if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) {
|
||||
belong = false;
|
||||
}
|
||||
return belong;
|
||||
}
|
||||
|
||||
function isVisible(id) {
|
||||
return !!document.getElementById(`comment_${id}`);
|
||||
}
|
||||
|
||||
function isEndOfListVisible(root, queue) {
|
||||
return root[queue].nodes.length === 0 || !!document.getElementById('end-of-comment-list');
|
||||
}
|
||||
|
||||
function applyCommentChanges(root, comment, queueConfig) {
|
||||
const queues = Object.keys(queueConfig);
|
||||
for (let i = 0; i < queues.length; i++) {
|
||||
const queue = queues[i];
|
||||
const index = root[queue].nodes.findIndex(({id}) => id === comment.id);
|
||||
if (index > -1) {
|
||||
return update(root, {
|
||||
[queue]: {
|
||||
nodes: {
|
||||
[index]: {$merge: comment},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dangling comments, sort and resize queues.
|
||||
* If queueConfig is omitted, dangling comments are not removed.
|
||||
*/
|
||||
export function cleanUpQueue(root, queue, sortOrder, queueConfig) {
|
||||
let nodes = root[queue].nodes;
|
||||
let hasNextPage = root[queue].hasNextPage;
|
||||
|
||||
if (!nodes.length) {
|
||||
return root;
|
||||
}
|
||||
|
||||
if (queueConfig) {
|
||||
nodes = root[queue].nodes.filter((comment) => commentBelongToQueue(queue, comment, queueConfig));
|
||||
}
|
||||
|
||||
nodes = sortComments(
|
||||
nodes,
|
||||
sortOrder,
|
||||
);
|
||||
|
||||
if (nodes.length > 100) {
|
||||
nodes = nodes.slice(0, 100);
|
||||
hasNextPage = true;
|
||||
}
|
||||
|
||||
return update(root, {
|
||||
[queue]: {
|
||||
nodes: {$set: nodes},
|
||||
endCursor: {$set: nodes[nodes.length - 1].created_at},
|
||||
hasNextPage: {$set: hasNextPage},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assimilate comment changes into current store.
|
||||
* @param {Object} root current state of the store
|
||||
@@ -113,25 +206,27 @@ export function handleCommentChange(root, comment, sortOrder, notify, queueConfi
|
||||
Object.keys(queueConfig).forEach((queue) => {
|
||||
if (nextQueues.indexOf(queue) >= 0) {
|
||||
if (!queueHasComment(next, queue, comment.id)) {
|
||||
next = addCommentToQueue(next, queue, comment, sortOrder);
|
||||
if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sortOrder)) {
|
||||
next = addCommentToQueue(next, queue, comment, sortOrder, activeQueue !== queue);
|
||||
if (notify && activeQueue === queue && isEndOfListVisible(root, queue)) {
|
||||
showNotificationOnce();
|
||||
}
|
||||
}
|
||||
} else if(queueHasComment(next, queue, comment.id)){
|
||||
next = removeCommentFromQueue(next, queue, comment.id);
|
||||
if (notify && activeQueue === queue) {
|
||||
const dangling = activeQueue === queue && comment.status_history[comment.status_history.length - 1].assigned_by.id !== root.me.id;
|
||||
next = removeCommentFromQueue(next, queue, comment.id, dangling);
|
||||
if (notify && isVisible(comment.id)) {
|
||||
showNotificationOnce();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
notify
|
||||
&& queueHasComment(next, queue, comment.id)
|
||||
&& activeQueue === queue
|
||||
) {
|
||||
if (notify && isVisible(comment.id)) {
|
||||
showNotificationOnce();
|
||||
}
|
||||
|
||||
// We need to apply every comment change, because we use
|
||||
// batched subscription handler which bypasses apollo that would
|
||||
// have done that for us.
|
||||
next = applyCommentChanges(next, comment, queueConfig);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ class Stories extends Component {
|
||||
<div className={styles.optionHeader}>{t('streams.filter_streams')}</div>
|
||||
<div className={styles.optionDetail}>{t('streams.stream_status')}</div>
|
||||
<RadioGroup
|
||||
name='status filter'
|
||||
name='statusFilter'
|
||||
value={filter}
|
||||
childContainer='div'
|
||||
onChange={onSettingChange('filter')}
|
||||
@@ -60,7 +60,7 @@ class Stories extends Component {
|
||||
</RadioGroup>
|
||||
<div className={styles.optionHeader}>{t('streams.sort_by')}</div>
|
||||
<RadioGroup
|
||||
name='sort by'
|
||||
name='sortBy'
|
||||
value={asc}
|
||||
childContainer='div'
|
||||
onChange={onSettingChange('asc')}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
import PropTypes from 'prop-types';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const CloseCommentsInfo = ({status, onClick}) => (
|
||||
status === 'open' ? (
|
||||
<div className="close-comments-intro-wrapper">
|
||||
<p>
|
||||
{t('configure.open_stream_configuration')}
|
||||
</p>
|
||||
<Button onClick={onClick}>{t('configure.close_stream')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="close-comments-intro-wrapper">
|
||||
<p>
|
||||
{t('configure.close_stream_configuration')}
|
||||
</p>
|
||||
<Button onClick={onClick}>{t('configure.open_stream')}</Button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
CloseCommentsInfo.propTypes = {
|
||||
status: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default CloseCommentsInfo;
|
||||
@@ -1,71 +0,0 @@
|
||||
import React from 'react';
|
||||
import {Button, Checkbox} from 'coral-ui';
|
||||
import QuestionBoxBuilder from './QuestionBoxBuilder';
|
||||
import cn from 'classnames';
|
||||
|
||||
import styles from './ConfigureCommentStream.css';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export default ({handleChange, handleApply, changed, ...props}) => (
|
||||
<form onSubmit={handleApply}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.container}>
|
||||
<h3>{t('configure.title')}</h3>
|
||||
<Button
|
||||
type="submit"
|
||||
className={cn(styles.apply, 'talk-embed-stream-configuration-submit-button')}
|
||||
onChange={handleChange}
|
||||
cStyle={changed ? 'green' : 'darkGrey'} >
|
||||
{t('configure.apply')}
|
||||
</Button>
|
||||
<p>{t('configure.description')}</p>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
cStyle={changed ? 'green' : 'darkGrey'}
|
||||
name="premod"
|
||||
onChange={handleChange}
|
||||
defaultChecked={props.premod}
|
||||
info={{
|
||||
title: t('configure.enable_premod'),
|
||||
description: t('configure.enable_premod_description')
|
||||
}} />
|
||||
</li>
|
||||
<li>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
cStyle={changed ? 'green' : 'darkGrey'}
|
||||
name="plinksenable"
|
||||
onChange={handleChange}
|
||||
defaultChecked={props.premodLinksEnable}
|
||||
info={{
|
||||
title: t('configure.enable_premod_links'),
|
||||
description: t('configure.enable_premod_links_description')
|
||||
}} />
|
||||
</li>
|
||||
<li>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
cStyle={changed ? 'green' : 'darkGrey'}
|
||||
name="qboxenable"
|
||||
onChange={handleChange}
|
||||
defaultChecked={props.questionBoxEnable}
|
||||
info={{
|
||||
title: t('configure.enable_questionbox'),
|
||||
description: t('configure.enable_questionbox_description')
|
||||
}} />
|
||||
{
|
||||
props.questionBoxEnable && <QuestionBoxBuilder
|
||||
questionBoxIcon={props.questionBoxIcon}
|
||||
questionBoxContent={props.questionBoxContent}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
@@ -1,24 +0,0 @@
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import marked from 'marked';
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
|
||||
// Set link target to `_parent` to work properly in an embed.
|
||||
renderer.link = (href, title, text) =>
|
||||
`<a target="_parent" href="${href}" ${title ? `title="${title}"` : ''}>${text}</a>`;
|
||||
|
||||
marked.setOptions({renderer});
|
||||
|
||||
export default class Markdown extends PureComponent {
|
||||
render() {
|
||||
const {content, ...rest} = this.props;
|
||||
const __html = marked(content);
|
||||
return <div {...rest} dangerouslySetInnerHTML={{__html}} />;
|
||||
}
|
||||
}
|
||||
|
||||
Markdown.propTypes = {
|
||||
content: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
.qbBuilder {
|
||||
margin-left: 50px;
|
||||
}
|
||||
|
||||
.qbItemIconList {
|
||||
padding: 0;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.qbItemIcon {
|
||||
background: #F0F0F0;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
line-height: 45px;
|
||||
color: #252525;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
margin-right: 10px;
|
||||
position: relative;
|
||||
border: solid 2px #F0F0F0;
|
||||
transition: border 0.3s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
|
||||
.qbItemIcon:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qbItemIconActive {
|
||||
border: solid 2px #00796B;
|
||||
}
|
||||
|
||||
.defaultIcon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.qb {
|
||||
margin: 10px 0;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import React from 'react';
|
||||
import QuestionBox from 'talk-plugin-questionbox/QuestionBox';
|
||||
import {Icon, Spinner} from 'coral-ui';
|
||||
import DefaultIcon from './DefaultIcon';
|
||||
import cn from 'classnames';
|
||||
import styles from './QuestionBoxBuilder.css';
|
||||
|
||||
class QuestionBoxBuilder extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
loading: true
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.loadEditor();
|
||||
}
|
||||
|
||||
async loadEditor() {
|
||||
const {default: MarkdownEditor} = await import('coral-framework/components/MarkdownEditor');
|
||||
|
||||
return this.setState({
|
||||
loading : false,
|
||||
MarkdownEditor
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {handleChange, questionBoxIcon, questionBoxContent} = this.props;
|
||||
const {loading, MarkdownEditor} = this.state;
|
||||
|
||||
if (loading) {
|
||||
return <Spinner/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.qbBuilder}>
|
||||
<h4>Include an Icon</h4>
|
||||
|
||||
<ul className={styles.qbItemIconList}>
|
||||
<li className={cn(
|
||||
styles.qbItemIcon,
|
||||
{[styles.qbItemIconActive]: questionBoxIcon === 'default'}
|
||||
)}
|
||||
id="qboxicon"
|
||||
onClick={handleChange}
|
||||
data-icon="default" >
|
||||
<DefaultIcon className={styles.defaultIcon} />
|
||||
</li>
|
||||
<li className={cn(
|
||||
styles.qbItemIcon,
|
||||
{[styles.qbItemIconActive]: questionBoxIcon === 'forum'}
|
||||
)}
|
||||
id="qboxicon"
|
||||
onClick={handleChange}
|
||||
data-icon="forum" >
|
||||
<Icon name="forum" />
|
||||
</li>
|
||||
<li className={cn(
|
||||
styles.qbItemIcon,
|
||||
{[styles.qbItemIconActive]: questionBoxIcon === 'build'}
|
||||
)}
|
||||
id="qboxicon"
|
||||
onClick={handleChange}
|
||||
data-icon="build" >
|
||||
<Icon name="build" />
|
||||
</li>
|
||||
<li className={cn(
|
||||
styles.qbItemIcon,
|
||||
{[styles.qbItemIconActive]: questionBoxIcon === 'format_quote'}
|
||||
)}
|
||||
id="qboxicon"
|
||||
onClick={handleChange}
|
||||
data-icon="format_quote" >
|
||||
<Icon name="format_quote" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<QuestionBox
|
||||
className={styles.qb}
|
||||
enable={true}
|
||||
icon={questionBoxIcon}
|
||||
content={questionBoxContent}
|
||||
/>
|
||||
|
||||
<MarkdownEditor
|
||||
value={questionBoxContent}
|
||||
onChange={(value) => handleChange({}, {questionBoxContent: value})}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default QuestionBoxBuilder;
|
||||
@@ -1,145 +0,0 @@
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {compose} from 'react-apollo';
|
||||
import PropTypes from 'prop-types';
|
||||
import {updateOpenStatus, updateConfiguration} from 'coral-embed-stream/src/actions/asset';
|
||||
import CloseCommentsInfo from '../components/CloseCommentsInfo';
|
||||
import ConfigureCommentStream from '../components/ConfigureCommentStream';
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
|
||||
class ConfigureStreamContainer extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
changed: false,
|
||||
dirtySettings: {...props.asset.settings},
|
||||
closedAt: !props.asset.isClosed ? 'open' : 'closed'
|
||||
};
|
||||
|
||||
this.toggleStatus = this.toggleStatus.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleApply = this.handleApply.bind(this);
|
||||
}
|
||||
|
||||
handleApply (e) {
|
||||
e.preventDefault();
|
||||
const {elements} = e.target;
|
||||
const {questionBoxIcon, questionBoxContent} = this.state.dirtySettings;
|
||||
const premod = elements.premod.checked;
|
||||
const questionBoxEnable = elements.qboxenable.checked;
|
||||
const premodLinksEnable = elements.plinksenable.checked;
|
||||
const {changed} = this.state;
|
||||
|
||||
const newConfig = {
|
||||
moderation: premod ? 'PRE' : 'POST',
|
||||
questionBoxEnable,
|
||||
questionBoxContent,
|
||||
questionBoxIcon,
|
||||
premodLinksEnable
|
||||
};
|
||||
|
||||
if (changed) {
|
||||
this.props.updateConfiguration(newConfig);
|
||||
setTimeout(() => {
|
||||
this.setState({
|
||||
changed: false
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
handleChange (e, newChanges) {
|
||||
let changes = {};
|
||||
|
||||
if (changes) {
|
||||
changes = {...newChanges};
|
||||
}
|
||||
|
||||
if (e.target && e.target.id === 'qboxenable') {
|
||||
changes.questionBoxEnable = e.target.checked;
|
||||
}
|
||||
if (e.currentTarget && e.currentTarget.id === 'qboxicon') {
|
||||
changes.questionBoxIcon = e.currentTarget.dataset.icon;
|
||||
}
|
||||
if (e.target && e.target.id === 'plinksenable') {
|
||||
changes.premodLinksEnable = e.target.value;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
changed: true,
|
||||
dirtySettings: {
|
||||
...this.state.dirtySettings,
|
||||
...changes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleStatus () {
|
||||
|
||||
// update the closedAt status for the asset
|
||||
this.props.updateStatus(
|
||||
this.state.closedAt === 'open' ? 'closed' : 'open'
|
||||
);
|
||||
this.setState({
|
||||
closedAt: (this.state.closedAt === 'open' ? 'closed' : 'open')
|
||||
});
|
||||
}
|
||||
|
||||
getClosedIn () {
|
||||
const {closedTimeout} = this.props.asset.settings;
|
||||
const {created_at} = this.props.asset;
|
||||
|
||||
return timeago(new Date(created_at).getTime() + (1000 * closedTimeout));
|
||||
}
|
||||
|
||||
render () {
|
||||
const {dirtySettings} = this.state;
|
||||
const premod = dirtySettings.moderation === 'PRE';
|
||||
const {closedAt} = this.state;
|
||||
const closedTimeout = dirtySettings.closedTimeout;
|
||||
|
||||
return (
|
||||
<div className='talk-embed-stream-configuration-container'>
|
||||
<ConfigureCommentStream
|
||||
handleChange={this.handleChange}
|
||||
handleApply={this.handleApply}
|
||||
changed={this.state.changed}
|
||||
premodLinksEnable={dirtySettings.premodLinksEnable}
|
||||
premod={premod}
|
||||
questionBoxIcon={dirtySettings.questionBoxIcon}
|
||||
questionBoxEnable={dirtySettings.questionBoxEnable}
|
||||
questionBoxContent={dirtySettings.questionBoxContent}
|
||||
/>
|
||||
<hr />
|
||||
<h3>{closedAt === 'open' ? t('configure.close') : t('configure.open')} {t('configure.comment_stream')}</h3>
|
||||
{(closedAt === 'open' && closedTimeout) ? <p>{t('configure.comment_stream_will_close')} {this.getClosedIn()}.</p> : ''}
|
||||
<CloseCommentsInfo
|
||||
onClick={this.toggleStatus}
|
||||
status={closedAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
asset: state.asset
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
updateStatus: (status) => dispatch(updateOpenStatus(status)),
|
||||
updateConfiguration: (newConfig) => dispatch(updateConfiguration(newConfig)),
|
||||
});
|
||||
|
||||
ConfigureStreamContainer.propTypes = {
|
||||
updateStatus: PropTypes.func,
|
||||
closedTimeout: PropTypes.string,
|
||||
created_at: PropTypes.string,
|
||||
updateConfiguration: PropTypes.func,
|
||||
asset: PropTypes.object,
|
||||
};
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps)
|
||||
)(ConfigureStreamContainer);
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as actions from '../constants/configure';
|
||||
|
||||
export const updatePending = ({updater, errorUpdater}) => {
|
||||
return {type: actions.UPDATE_PENDING, updater, errorUpdater};
|
||||
};
|
||||
|
||||
export const clearPending = () => {
|
||||
return {type: actions.CLEAR_PENDING};
|
||||
};
|
||||
+2
-9
@@ -16,12 +16,5 @@
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.qbIconContainer {
|
||||
position: relative;
|
||||
border: 0;
|
||||
color: white;
|
||||
display: inline-block;
|
||||
padding: 5px 20px;
|
||||
vertical-align: middle;
|
||||
width: 10px;
|
||||
}
|
||||
.root {
|
||||
}
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import styles from './DefaultIcon.css';
|
||||
import styles from './DefaultQuestionBoxIcon.css';
|
||||
import {Icon} from 'coral-ui';
|
||||
|
||||
const DefaultIcon = ({className}) => (
|
||||
<div className={cn(styles.qbIconContainer, className)}>
|
||||
const DefaultQuestionBoxIcon = ({className}) => (
|
||||
<div className={cn(styles.root, className)}>
|
||||
<Icon name="chat_bubble" className={cn(styles.iconBubble)} />
|
||||
<Icon name="person" className={cn(styles.iconPerson)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default DefaultIcon;
|
||||
export default DefaultQuestionBoxIcon;
|
||||
@@ -1,16 +1,17 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Stream from '../containers/Stream';
|
||||
import Stream from '../tabs/stream/containers/Stream';
|
||||
import Configure from '../tabs/configure/containers/Configure';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import AutomaticAssetClosure from '../containers/AutomaticAssetClosure';
|
||||
|
||||
import {TabBar, Tab, TabContent, TabPane} from 'coral-ui';
|
||||
import ExtendableTabPanel from '../containers/ExtendableTabPanel';
|
||||
import {Tab, TabPane} from 'coral-ui';
|
||||
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
|
||||
import Popup from 'coral-framework/components/Popup';
|
||||
import IfSlotIsNotEmpty from 'coral-framework/components/IfSlotIsNotEmpty';
|
||||
import ConfigureStreamContainer
|
||||
from 'coral-configure/containers/ConfigureStreamContainer';
|
||||
import cn from 'classnames';
|
||||
|
||||
export default class Embed extends React.Component {
|
||||
@@ -21,20 +22,37 @@ export default class Embed extends React.Component {
|
||||
case 'profile':
|
||||
this.props.data.refetch();
|
||||
break;
|
||||
case 'config':
|
||||
this.props.data.refetch();
|
||||
break;
|
||||
}
|
||||
this.props.setActiveTab(tab);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {activeTab, commentId, root, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog, router: {location: {query: {parentUrl}}}} = this.props;
|
||||
getTabs() {
|
||||
const {user} = this.props.auth;
|
||||
const tabs = [
|
||||
<Tab key='stream' tabId='stream' className='talk-embed-stream-comments-tab'>
|
||||
{t('embed_comments_tab')}
|
||||
</Tab>,
|
||||
<Tab key='profile' tabId='profile' className='talk-embed-stream-profile-tab'>
|
||||
{t('framework.my_profile')}
|
||||
</Tab>,
|
||||
];
|
||||
if (can(user, 'UPDATE_CONFIG')) {
|
||||
tabs.push(
|
||||
<Tab key='config' tabId='config'>
|
||||
{t('framework.configure_stream')}
|
||||
</Tab>
|
||||
);
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {activeTab, commentId, root, root: {asset}, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog, router: {location: {query: {parentUrl}}}} = this.props;
|
||||
const hasHighlightedComment = !!commentId;
|
||||
|
||||
return (
|
||||
<div className={cn('talk-embed-stream', {'talk-embed-stream-highlight-comment': hasHighlightedComment})}>
|
||||
<AutomaticAssetClosure asset={asset} />
|
||||
<IfSlotIsNotEmpty slot="login">
|
||||
<Popup
|
||||
href={`embed/stream/login?parentUrl=${encodeURIComponent(parentUrl)}`}
|
||||
@@ -47,44 +65,36 @@ export default class Embed extends React.Component {
|
||||
onClose={hideSignInDialog}
|
||||
/>
|
||||
</IfSlotIsNotEmpty>
|
||||
<TabBar
|
||||
onTabClick={this.changeTab}
|
||||
activeTab={activeTab}
|
||||
className='talk-embed-stream-tab-bar'
|
||||
aria-controls='talk-embed-stream-tab-content'
|
||||
>
|
||||
<Tab tabId={'stream'} className={'talk-embed-stream-comments-tab'}>
|
||||
{t('embed_comments_tab')}
|
||||
</Tab>
|
||||
<Tab tabId={'profile'} className={'talk-embed-stream-profile-tab'}>
|
||||
{t('framework.my_profile')}
|
||||
</Tab>
|
||||
{can(user, 'UPDATE_CONFIG') &&
|
||||
<Tab tabId={'config'} className={'talk-embed-stream-configuration-tab'}>
|
||||
{t('framework.configure_stream')}
|
||||
</Tab>
|
||||
}
|
||||
</TabBar>
|
||||
|
||||
<Slot
|
||||
data={data}
|
||||
queryData={{root}}
|
||||
fill="embed"
|
||||
/>
|
||||
|
||||
<TabContent
|
||||
<ExtendableTabPanel
|
||||
className='talk-embed-stream-tab-bar'
|
||||
activeTab={activeTab}
|
||||
id='talk-embed-stream-tab-content'
|
||||
>
|
||||
<TabPane tabId={'stream'} className={'talk-embed-stream-comments-tab-pane'}>
|
||||
<Stream data={data} root={root} />
|
||||
</TabPane>
|
||||
<TabPane tabId={'profile'} className={'talk-embed-stream-profile-tab-pane'}>
|
||||
<ProfileContainer />
|
||||
</TabPane>
|
||||
<TabPane tabId={'config'} className={'talk-embed-stream-configuration-tab-pane'}>
|
||||
<ConfigureStreamContainer />
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
setActiveTab={this.changeTab}
|
||||
fallbackTab='stream'
|
||||
tabSlot='embedStreamTabs'
|
||||
tabSlotPrepend='embedStreamTabsPrepend'
|
||||
tabPaneSlot='embedStreamTabPanes'
|
||||
slotProps={{data}}
|
||||
queryData={{root}}
|
||||
tabs={this.getTabs()}
|
||||
tabPanes={[
|
||||
<TabPane key='stream' tabId='stream' className='talk-embed-stream-comments-tab-pane'>
|
||||
<Stream data={data} root={root} asset={root.asset} />
|
||||
</TabPane>,
|
||||
<TabPane key='profile' tabId='profile' className='talk-embed-stream-profile-tab-pane'>
|
||||
<ProfileContainer />
|
||||
</TabPane>,
|
||||
<TabPane key='config' tabId='config' className='talk-embed-stream-configuration-tab-pane'>
|
||||
<Configure data={data} root={root} asset={root.asset} />
|
||||
</TabPane>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import {Tab} from 'coral-ui';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* ExtendableTab adds a hover property to its children, because
|
||||
* Tab is rendered as a button and under Firefox its children do
|
||||
* not support mouse events.
|
||||
*/
|
||||
class ExtendableTab extends React.Component {
|
||||
state = {
|
||||
hover: false,
|
||||
}
|
||||
|
||||
handleMouseEnter = () => this.setState({hover: true});
|
||||
handleMouseLeave = () => this.setState({hover: false});
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Tab
|
||||
{...this.props}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
>
|
||||
{React.cloneElement(this.props.children, {hover: this.state.hover})}
|
||||
</Tab>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ExtendableTab.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default ExtendableTab;
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import {Spinner, TabBar, TabContent} from 'coral-ui';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './StreamTabPanel.css';
|
||||
import styles from './ExtendableTabPanel.css';
|
||||
|
||||
class StreamTabPanel extends React.Component {
|
||||
class ExtendableTabPanel extends React.Component {
|
||||
|
||||
render() {
|
||||
const {activeTab, setActiveTab, tabs, tabPanes, sub, loading, ...rest} = this.props;
|
||||
@@ -23,7 +23,7 @@ class StreamTabPanel extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
StreamTabPanel.propTypes = {
|
||||
ExtendableTabPanel.propTypes = {
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
setActiveTab: PropTypes.func.isRequired,
|
||||
loading: PropTypes.bool,
|
||||
@@ -39,4 +39,4 @@ StreamTabPanel.propTypes = {
|
||||
sub: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default StreamTabPanel;
|
||||
export default ExtendableTabPanel;
|
||||
+2
-5
@@ -13,6 +13,8 @@
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
border-radius: 3px;
|
||||
padding-right: 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -63,8 +65,3 @@
|
||||
justify-content: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
display: none;
|
||||
}
|
||||
+5
-8
@@ -3,16 +3,14 @@ import cn from 'classnames';
|
||||
import styles from './QuestionBox.css';
|
||||
import {Icon} from 'coral-ui';
|
||||
import Markdown from 'coral-framework/components/Markdown';
|
||||
import DefaultQuestionBoxIcon from './DefaultQuestionBoxIcon';
|
||||
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
|
||||
const QuestionBox = ({content, enable, icon = '', className = ''}) => (
|
||||
<div className={cn(styles.qbInfo, {[styles.hidden]: !enable}, 'questionbox-info', className)}>
|
||||
const QuestionBox = ({content, icon, className, children}) => (
|
||||
<div className={cn(styles.qbInfo, 'questionbox-info', className)}>
|
||||
{
|
||||
icon === 'default' ? (
|
||||
<div className={cn(styles.qbIconContainer)}>
|
||||
<Icon name="chat_bubble" className={cn(styles.iconBubble)} />
|
||||
<Icon name="person" className={cn(styles.iconPerson)} />
|
||||
<DefaultQuestionBoxIcon />
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(styles.qbIconContainer)}>
|
||||
@@ -23,8 +21,7 @@ const QuestionBox = ({content, enable, icon = '', className = ''}) => (
|
||||
<div className={cn(styles.qbContent, 'questionbox-content')}>
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
|
||||
<Slot fill="streamQuestionArea" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
const prefix = 'TALK_EMBED_STREAM_CONFIGURE';
|
||||
|
||||
export const UPDATE_PENDING = `${prefix}_UPDATE_PENDING`;
|
||||
export const CLEAR_PENDING = `${prefix}_CLEAR_PENDING`;
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {gql} from 'react-apollo';
|
||||
import {gql, compose} from 'react-apollo';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
|
||||
const FRAGMENT = gql`
|
||||
fragment CoralEmbedStream_AutomaticAssetClosure_Fragment on Asset {
|
||||
id
|
||||
isClosed
|
||||
}
|
||||
`;
|
||||
@@ -26,15 +26,15 @@ class AutomaticAssetClosure extends React.Component {
|
||||
timer = null;
|
||||
|
||||
componentWillMount() {
|
||||
this.setupTimer(this.props.assetId, this.props.closedAt);
|
||||
this.setupTimer(this.props.asset.id, this.props.asset.closedAt);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
if (
|
||||
this.props.assetId !== next.assetId ||
|
||||
this.props.closedAt !== next.closedAt
|
||||
this.props.asset.id !== next.asset.id ||
|
||||
this.props.asset.closedAt !== next.asset.closedAt
|
||||
) {
|
||||
this.setupTimer(next.assetId, next.closedAt);
|
||||
this.setupTimer(next.asset.id, next.asset.closedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ class AutomaticAssetClosure extends React.Component {
|
||||
fragment: FRAGMENT,
|
||||
id: getFragmentId(assetId),
|
||||
data: {
|
||||
__typename: 'Asset',
|
||||
isClosed: true,
|
||||
},
|
||||
});
|
||||
@@ -74,9 +75,21 @@ class AutomaticAssetClosure extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
AutomaticAssetClosure.PropTypes = {
|
||||
assetId: PropTypes.string,
|
||||
closedAt: PropTypes.string,
|
||||
AutomaticAssetClosure.propTypes = {
|
||||
asset: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default AutomaticAssetClosure;
|
||||
const withAutomaticAssetClosureFragments = withFragments({
|
||||
asset: gql`
|
||||
fragment CoralEmbedStream_AutomaticAssetClosure_asset on Asset {
|
||||
id
|
||||
closedAt
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const enhance = compose(
|
||||
withAutomaticAssetClosureFragments,
|
||||
);
|
||||
|
||||
export default enhance(AutomaticAssetClosure);
|
||||
|
||||
@@ -13,7 +13,9 @@ import * as assetActions from '../actions/asset';
|
||||
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
import {withQuery} from 'coral-framework/hocs';
|
||||
import Embed from '../components/Embed';
|
||||
import Stream from './Stream';
|
||||
import Stream from '../tabs/stream/containers/Stream';
|
||||
import AutomaticAssetClosure from './AutomaticAssetClosure';
|
||||
import Configure from '../tabs/configure/containers/Configure';
|
||||
import {notify} from 'coral-framework/actions/notification';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
@@ -151,6 +153,9 @@ const USERNAME_REJECTED_SUBSCRIPTION = gql`
|
||||
|
||||
const slots = [
|
||||
'embed',
|
||||
'embedStreamTabs',
|
||||
'embedStreamTabsPrepend',
|
||||
'embedStreamTabPanes',
|
||||
];
|
||||
|
||||
const EMBED_QUERY = gql`
|
||||
@@ -167,10 +172,20 @@ const EMBED_QUERY = gql`
|
||||
id
|
||||
status
|
||||
}
|
||||
asset(id: $assetId, url: $assetUrl) {
|
||||
...${getDefinitionName(Configure.fragments.asset)}
|
||||
...${getDefinitionName(Stream.fragments.asset)}
|
||||
...${getDefinitionName(AutomaticAssetClosure.fragments.asset)}
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
...${getDefinitionName(Stream.fragments.root)}
|
||||
...${getDefinitionName(Configure.fragments.root)}
|
||||
}
|
||||
${Stream.fragments.root}
|
||||
${Stream.fragments.asset}
|
||||
${Configure.fragments.root}
|
||||
${Configure.fragments.asset}
|
||||
${AutomaticAssetClosure.fragments.asset}
|
||||
`;
|
||||
|
||||
export const withEmbedQuery = withQuery(EMBED_QUERY, {
|
||||
|
||||
+44
-23
@@ -1,23 +1,23 @@
|
||||
import React from 'react';
|
||||
import StreamTabPanel from '../components/StreamTabPanel';
|
||||
import ExtendableTabPanel from '../components/ExtendableTabPanel';
|
||||
import {connect} from 'react-redux';
|
||||
import omit from 'lodash/omit';
|
||||
import {Tab, TabPane} from 'coral-ui';
|
||||
import {TabPane} from 'coral-ui';
|
||||
import ExtendableTab from '../components/ExtendableTab';
|
||||
import {getShallowChanges} from 'coral-framework/utils';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class StreamTabPanelContainer extends React.Component {
|
||||
class ExtendableTabPanelContainer extends React.Component {
|
||||
static contextTypes = {
|
||||
plugins: PropTypes.object,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.fallbackAllTab();
|
||||
this.handleFallback();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
this.fallbackAllTab(next);
|
||||
this.handleFallback(next);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(next) {
|
||||
@@ -35,30 +35,50 @@ class StreamTabPanelContainer extends React.Component {
|
||||
return changes.length !== 0;
|
||||
}
|
||||
|
||||
fallbackAllTab(props = this.props) {
|
||||
if (props.activeTab !== props.fallbackTab) {
|
||||
const slotPlugins = this.getSlotElements(props.tabSlot, props).map((el) => el.type.talkPluginName);
|
||||
if (slotPlugins.indexOf(props.activeTab) === -1) {
|
||||
props.setActiveTab(props.fallbackTab);
|
||||
}
|
||||
handleFallback(props = this.props) {
|
||||
if (this.getTabNames(props).indexOf(props.activeTab) === -1) {
|
||||
props.setActiveTab(props.fallbackTab);
|
||||
}
|
||||
}
|
||||
|
||||
getTabNames(props = this.props) {
|
||||
return this.getTabElements(props).map((el) => el.props.tabId);
|
||||
}
|
||||
|
||||
getSlotElements(slot, props = this.props) {
|
||||
const {plugins} = this.context;
|
||||
return plugins.getSlotElements(slot, props.reduxState, props.slotProps, props.queryData);
|
||||
}
|
||||
|
||||
getPluginTabElements(props = this.props) {
|
||||
return this.getSlotElements(props.tabSlot).map((el) => {
|
||||
return this.getSlotTabElements(props.tabSlot);
|
||||
}
|
||||
|
||||
getPluginTabElementsPrepend(props = this.props) {
|
||||
return this.getSlotTabElements(props.tabSlotPrepend);
|
||||
}
|
||||
|
||||
getSlotTabElements(slot) {
|
||||
return this.getSlotElements(slot).map((el) => {
|
||||
return (
|
||||
<Tab tabId={el.type.talkPluginName} key={el.type.talkPluginName}>
|
||||
<ExtendableTab tabId={el.type.talkPluginName} key={el.type.talkPluginName}>
|
||||
{React.cloneElement(el, {active: this.props.activeTab === el.type.talkPluginName})}
|
||||
</Tab>
|
||||
</ExtendableTab>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
getTabElements(props = this.props) {
|
||||
const elements = [...this.getPluginTabElementsPrepend(props)];
|
||||
if (Array.isArray(props.tabs)) {
|
||||
elements.push(...props.tabs);
|
||||
} else {
|
||||
elements.push(props.tabs);
|
||||
}
|
||||
elements.push(...this.getPluginTabElements(props));
|
||||
return elements;
|
||||
}
|
||||
|
||||
getPluginTabPaneElements(props = this.props) {
|
||||
return this.getSlotElements(props.tabPaneSlot).map((el) => {
|
||||
return (
|
||||
@@ -71,12 +91,12 @@ class StreamTabPanelContainer extends React.Component {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<StreamTabPanel
|
||||
<ExtendableTabPanel
|
||||
className={this.props.className}
|
||||
activeTab={this.props.activeTab}
|
||||
setActiveTab={this.props.setActiveTab}
|
||||
tabs={this.getPluginTabElements().concat(this.props.appendTabs)}
|
||||
tabPanes={this.getPluginTabPaneElements().concat(this.props.appendTabPanes)}
|
||||
tabs={this.getTabElements()}
|
||||
tabPanes={this.getPluginTabPaneElements().concat(this.props.tabPanes)}
|
||||
loading={this.props.loading}
|
||||
sub={this.props.sub}
|
||||
/>
|
||||
@@ -84,19 +104,20 @@ class StreamTabPanelContainer extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
StreamTabPanelContainer.propTypes = {
|
||||
ExtendableTabPanelContainer.propTypes = {
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
setActiveTab: PropTypes.func.isRequired,
|
||||
appendTabs: PropTypes.oneOfType([
|
||||
tabs: PropTypes.oneOfType([
|
||||
PropTypes.element,
|
||||
PropTypes.arrayOf(PropTypes.element)
|
||||
]),
|
||||
appendTabPanes: PropTypes.oneOfType([
|
||||
tabPanes: PropTypes.oneOfType([
|
||||
PropTypes.element,
|
||||
PropTypes.arrayOf(PropTypes.element)
|
||||
]),
|
||||
fallbackTab: PropTypes.string.isRequired,
|
||||
tabSlot: PropTypes.string.isRequired,
|
||||
tabSlotPrepend: PropTypes.string.isRequired,
|
||||
tabPaneSlot: PropTypes.string.isRequired,
|
||||
slotProps: PropTypes.object.isRequired,
|
||||
queryData: PropTypes.object,
|
||||
@@ -106,7 +127,7 @@ StreamTabPanelContainer.propTypes = {
|
||||
};
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
reduxState: omit(state, 'apollo'),
|
||||
reduxState: state,
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(StreamTabPanelContainer);
|
||||
export default connect(mapStateToProps, null)(ExtendableTabPanelContainer);
|
||||
@@ -2,6 +2,7 @@ import {gql} from 'react-apollo';
|
||||
import update from 'immutability-helper';
|
||||
import uuid from 'uuid/v4';
|
||||
import {insertCommentIntoEmbedQuery, removeCommentFromEmbedQuery} from './utils';
|
||||
import {mapLeaves} from 'coral-framework/utils';
|
||||
|
||||
export default {
|
||||
fragments: {
|
||||
@@ -192,7 +193,12 @@ export default {
|
||||
},
|
||||
updateQueries: {
|
||||
CoralEmbedStream_Embed: (prev, {mutationResult: {data: {createComment: {comment}}}}) => {
|
||||
if (prev.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED' || comment.status === 'SYSTEM_WITHHELD') {
|
||||
if (
|
||||
prev.me.roles.indexOf('ADMIN') === -1 && prev.asset.settings.moderation === 'PRE' ||
|
||||
comment.status === 'PREMOD' ||
|
||||
comment.status === 'REJECTED' ||
|
||||
comment.status === 'SYSTEM_WITHHELD'
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return insertCommentIntoEmbedQuery(prev, comment);
|
||||
@@ -218,6 +224,18 @@ export default {
|
||||
},
|
||||
},
|
||||
}),
|
||||
UpdateAssetSettings: ({variables: {input}}) => ({
|
||||
updateQueries: {
|
||||
CoralEmbedStream_Embed: (prev) => {
|
||||
const updated = update(prev, {
|
||||
asset: {
|
||||
settings: mapLeaves(input, (leaf) => ({$set: leaf})),
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ function applyToCommentsOrigin(root, callback) {
|
||||
function findAndInsertComment(parent, comment) {
|
||||
const isAsset = parent.__typename === 'Asset';
|
||||
const [connectionField, countField, action] = isAsset
|
||||
? ['comments', 'commentCount', '$unshift']
|
||||
? ['comments', 'totalCommentCount', '$unshift']
|
||||
: ['replies', 'replyCount', '$push'];
|
||||
|
||||
if (
|
||||
@@ -67,19 +67,12 @@ function findAndInsertComment(parent, comment) {
|
||||
}
|
||||
|
||||
export function insertCommentIntoEmbedQuery(root, comment) {
|
||||
|
||||
// Increase total comment count by one.
|
||||
root = update(root, {
|
||||
asset: {
|
||||
totalCommentCount: {$apply: (c) => c + 1},
|
||||
},
|
||||
});
|
||||
return applyToCommentsOrigin(root, (origin) => findAndInsertComment(origin, comment));
|
||||
}
|
||||
|
||||
function findAndRemoveComment(parent, id) {
|
||||
const [connectionField, countField] = parent.__typename === 'Asset'
|
||||
? ['comments', 'commentCount']
|
||||
? ['comments', 'totalCommentCount']
|
||||
: ['replies', 'replyCount'];
|
||||
|
||||
const connection = parent[connectionField];
|
||||
@@ -104,13 +97,6 @@ function findAndRemoveComment(parent, id) {
|
||||
}
|
||||
|
||||
export function removeCommentFromEmbedQuery(root, id) {
|
||||
|
||||
// Decrease total comment by one.
|
||||
root = update(root, {
|
||||
asset: {
|
||||
totalCommentCount: {$apply: (c) => c - 1},
|
||||
},
|
||||
});
|
||||
return applyToCommentsOrigin(root, (origin) => findAndRemoveComment(origin, id));
|
||||
}
|
||||
|
||||
@@ -142,17 +128,21 @@ export function findCommentWithId(nodes, id) {
|
||||
}
|
||||
|
||||
export function findCommentInEmbedQuery(root, callbackOrId) {
|
||||
return findCommentInAsset(root.asset, callbackOrId);
|
||||
}
|
||||
|
||||
export function findCommentInAsset(asset, callbackOrId) {
|
||||
let callback = callbackOrId;
|
||||
if (typeof callbackOrId === 'string') {
|
||||
callback = (node) => node.id === callbackOrId;
|
||||
}
|
||||
if (root.asset.comment) {
|
||||
return findComment([getTopLevelParent(root.asset.comment)], callback);
|
||||
if (asset.comment) {
|
||||
return findComment([getTopLevelParent(asset.comment)], callback);
|
||||
}
|
||||
if (!root.asset.comments) {
|
||||
if (!asset.comments) {
|
||||
return false;
|
||||
}
|
||||
return findComment(root.asset.comments.nodes, callback);
|
||||
return findComment(asset.comments.nodes, callback);
|
||||
}
|
||||
|
||||
function findAndInsertFetchedComments(parent, comments, parent_id) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as actions from '../constants/configure';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import update from 'immutability-helper';
|
||||
|
||||
const initialState = {
|
||||
canSave: false,
|
||||
pending: {},
|
||||
errors: {},
|
||||
};
|
||||
|
||||
export default function config(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.UPDATE_PENDING: {
|
||||
let next = state;
|
||||
if (action.updater) {
|
||||
next = update(next, {
|
||||
pending: action.updater,
|
||||
});
|
||||
}
|
||||
if (action.errorUpdater) {
|
||||
next = update(next, {
|
||||
errors: action.errorUpdater,
|
||||
});
|
||||
}
|
||||
const noErrors = Object.keys(next.errors).reduce((res, error) => res && !next.errors[error], true);
|
||||
const canSave = !isEmpty(next.pending) && noErrors;
|
||||
next = update(next, {
|
||||
canSave: {$set: canSave},
|
||||
});
|
||||
|
||||
return next;
|
||||
}
|
||||
case actions.CLEAR_PENDING:
|
||||
return {
|
||||
...state,
|
||||
pending: {},
|
||||
canSave: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import auth from './auth';
|
||||
import asset from './asset';
|
||||
import embed from './embed';
|
||||
import config from './config';
|
||||
import configure from './configure';
|
||||
import stream from './stream';
|
||||
import {reducer as commentBox} from '../../../talk-plugin-commentbox';
|
||||
|
||||
@@ -11,5 +12,6 @@ export default {
|
||||
commentBox,
|
||||
embed,
|
||||
config,
|
||||
configure,
|
||||
stream,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 300px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
import PropTypes from 'prop-types';
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
import cn from 'classnames';
|
||||
import styles from './AssetStatusInfo.css';
|
||||
|
||||
class AssetStatusInfo extends React.Component {
|
||||
|
||||
timer = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.setupTimer(props);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.setupTimer(nextProps);
|
||||
}
|
||||
|
||||
// Rerendering interval. If remaining time > 1min, rerender every minute, otherwise evey second.
|
||||
interval(closedAt) {
|
||||
const diff = new Date(closedAt).getTime() - new Date().getTime();
|
||||
return diff > 60000 ? 60000 : 1000;
|
||||
}
|
||||
|
||||
// Timer that counts down the remaining time.
|
||||
setupTimer({closedAt, isClosed} = this.props) {
|
||||
if (this.timer && (isClosed || !closedAt)) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
if (isClosed || !closedAt) {
|
||||
this.timer = null;
|
||||
return;
|
||||
}
|
||||
if (!this.timer) {
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.forceUpdate();
|
||||
this.setupTimer();
|
||||
}, this.interval(closedAt));
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {isClosed, closedAt, onClose, onOpen} = this.props;
|
||||
return (
|
||||
<div>
|
||||
<h3>{!isClosed ? t('configure.close') : t('configure.open')} {t('configure.comment_stream')}</h3>
|
||||
{(!isClosed && closedAt) ? <p>{t('configure.comment_stream_will_close')} {timeago(new Date(closedAt))}.</p> : ''}
|
||||
<div className={cn('close-comments-intro-wrapper', styles.wrapper)}>
|
||||
<p>
|
||||
{!isClosed ? t('configure.open_stream_configuration') : t('configure.close_stream_configuration')}
|
||||
</p>
|
||||
<Button className={styles.button} onClick={!isClosed ? onClose : onOpen}>
|
||||
{!isClosed ? t('configure.close_stream') : t('configure.open_stream')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AssetStatusInfo.propTypes = {
|
||||
isClosed: PropTypes.bool.isRequired,
|
||||
closedAt: PropTypes.string,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onOpen: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AssetStatusInfo;
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import AssetStatusInfo from '../containers/AssetStatusInfo';
|
||||
import Settings from '../containers/Settings';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class Configure extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className='talk-embed-stream-configuration-container'>
|
||||
<Settings
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
asset={this.props.asset}
|
||||
/>
|
||||
<hr />
|
||||
<AssetStatusInfo
|
||||
asset={this.props.asset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Configure.propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
asset: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default Configure;
|
||||
@@ -0,0 +1,59 @@
|
||||
.root {
|
||||
}
|
||||
|
||||
.iconList {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
composes: buttonReset from "coral-framework/styles/reset.css";
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: #F0F0F0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
line-height: 48px;
|
||||
color: #252525;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-right: 10px;
|
||||
position: relative;
|
||||
border: solid 2px #F0F0F0;
|
||||
outline: 0;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
border: solid 2px #00c96B;
|
||||
}
|
||||
|
||||
.buttonActive {
|
||||
border: solid 2px #00796B;
|
||||
}
|
||||
|
||||
.defaultIcon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.questionBox {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import QuestionBox from '../../../components/QuestionBox';
|
||||
import {Icon, Spinner} from 'coral-ui';
|
||||
import DefaultQuestionBoxIcon from '../../../components/DefaultQuestionBoxIcon';
|
||||
import cn from 'classnames';
|
||||
import styles from './QuestionBoxBuilder.css';
|
||||
|
||||
const DefaultIcon = <DefaultQuestionBoxIcon className={styles.defaultIcon} />;
|
||||
|
||||
const icons = [
|
||||
{'default': DefaultIcon},
|
||||
'forum',
|
||||
'build',
|
||||
'format_quote',
|
||||
];
|
||||
|
||||
class QuestionBoxBuilder extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
loading: true
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.loadEditor();
|
||||
}
|
||||
|
||||
async loadEditor() {
|
||||
const {default: MarkdownEditor} = await import('coral-framework/components/MarkdownEditor');
|
||||
|
||||
return this.setState({
|
||||
loading : false,
|
||||
MarkdownEditor
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {questionBoxIcon, questionBoxContent, onContentChange, onIconChange} = this.props;
|
||||
const {loading, MarkdownEditor} = this.state;
|
||||
|
||||
if (loading) {
|
||||
return <Spinner/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<h4>Include an Icon</h4>
|
||||
|
||||
<ul className={styles.iconList}>
|
||||
{icons.map((item) => {
|
||||
const name = typeof item === 'object' ? Object.keys(item)[0] : item;
|
||||
const icon = typeof item === 'object' ? item[name] : item;
|
||||
return (
|
||||
<li
|
||||
className={styles.item}
|
||||
key={name}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
styles.button,
|
||||
{[styles.buttonActive]: questionBoxIcon === name},
|
||||
)}
|
||||
onClick={() => onIconChange(name)}
|
||||
>
|
||||
{typeof icon === 'string'
|
||||
? <Icon name={icon} />
|
||||
: icon
|
||||
}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<QuestionBox
|
||||
className={styles.questionBox}
|
||||
icon={questionBoxIcon}
|
||||
content={questionBoxContent}
|
||||
/>
|
||||
|
||||
<MarkdownEditor
|
||||
value={questionBoxContent}
|
||||
onChange={onContentChange}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default QuestionBoxBuilder;
|
||||
+7
-13
@@ -7,9 +7,8 @@
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.wrapper ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
.description {
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
@@ -17,19 +16,14 @@
|
||||
margin: 12px 12px 12px 0;
|
||||
}
|
||||
|
||||
.wrapper h4 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.wrapper p {
|
||||
max-width: 380px;
|
||||
.list {
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.questionBoxContainer {
|
||||
margin-top: 24px;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
import PropTypes from 'prop-types';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import cn from 'classnames';
|
||||
import styles from './Settings.css';
|
||||
import Configuration from 'coral-framework/components/StreamConfiguration';
|
||||
import QuestionBoxBuilder from './QuestionBoxBuilder';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
|
||||
class Settings extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
settings: {
|
||||
moderation,
|
||||
premodLinksEnable,
|
||||
questionBoxEnable,
|
||||
questionBoxContent,
|
||||
questionBoxIcon,
|
||||
},
|
||||
onToggleModeration,
|
||||
onTogglePremodLinks,
|
||||
onToggleQuestionBox,
|
||||
onQuestionBoxIconChange,
|
||||
onQuestionBoxContentChange,
|
||||
canSave,
|
||||
onApply,
|
||||
slotProps,
|
||||
queryData,
|
||||
} = this.props;
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.container}>
|
||||
<h3>{t('configure.title')}</h3>
|
||||
<Button
|
||||
type="submit"
|
||||
className={cn(styles.apply, 'talk-embed-stream-configuration-submit-button')}
|
||||
checked={canSave}
|
||||
cStyle={canSave ? 'green' : 'darkGrey'}
|
||||
onClick={onApply}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{t('configure.apply')}
|
||||
</Button>
|
||||
<p className={styles.description}>{t('configure.description')}</p>
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
<Configuration
|
||||
checked={moderation === 'PRE'}
|
||||
title={t('configure.enable_premod')}
|
||||
description={t('configure.enable_premod_description')}
|
||||
onCheckbox={onToggleModeration}
|
||||
/>
|
||||
<Configuration
|
||||
checked={premodLinksEnable}
|
||||
title={t('configure.enable_premod_links')}
|
||||
description={t('configure.enable_premod_description')}
|
||||
onCheckbox={onTogglePremodLinks}
|
||||
/>
|
||||
<Configuration
|
||||
checked={questionBoxEnable}
|
||||
title={t('configure.enable_questionbox')}
|
||||
description={t('configure.enable_questionbox_description')}
|
||||
onCheckbox={onToggleQuestionBox}
|
||||
>
|
||||
{
|
||||
questionBoxEnable &&
|
||||
<div className={styles.questionBoxContainer}>
|
||||
<QuestionBoxBuilder
|
||||
questionBoxIcon={questionBoxIcon}
|
||||
questionBoxContent={questionBoxContent}
|
||||
onIconChange={onQuestionBoxIconChange}
|
||||
onContentChange={onQuestionBoxContentChange}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</Configuration>
|
||||
<Slot
|
||||
fill="streamSettings"
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Settings.propTypes = {
|
||||
queryData: PropTypes.object.isRequired,
|
||||
slotProps: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
canSave: PropTypes.bool.isRequired,
|
||||
onToggleModeration: PropTypes.func.isRequired,
|
||||
onTogglePremodLinks: PropTypes.func.isRequired,
|
||||
onToggleQuestionBox: PropTypes.func.isRequired,
|
||||
onQuestionBoxContentChange: PropTypes.func.isRequired,
|
||||
onQuestionBoxIconChange: PropTypes.func.isRequired,
|
||||
onApply: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import {gql, compose} from 'react-apollo';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
import AssetStatusInfo from '../components/AssetStatusInfo';
|
||||
import PropTypes from 'prop-types';
|
||||
import {withUpdateAssetStatus} from 'coral-framework/graphql/mutations';
|
||||
|
||||
class AssetStatusInfoContainer extends React.Component {
|
||||
|
||||
openAsset = () => this.props.updateAssetStatus(this.props.asset.id, {closedAt: null});
|
||||
closeAsset = () => this.props.updateAssetStatus(this.props.asset.id, {closedAt: new Date().toISOString()});
|
||||
|
||||
render() {
|
||||
return <AssetStatusInfo
|
||||
settings={this.props.asset.settings}
|
||||
isClosed={this.props.asset.isClosed}
|
||||
closedAt={this.props.asset.closedAt}
|
||||
onOpen={this.openAsset}
|
||||
onClose={this.closeAsset}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
AssetStatusInfoContainer.propTypes = {
|
||||
asset: PropTypes.object.isRequired,
|
||||
updateAssetStatus: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const withAssetStatusInfoFragments = withFragments({
|
||||
asset: gql`
|
||||
fragment CoralEmbedStream_AssetStatusInfo_asset on Asset {
|
||||
id
|
||||
closedAt
|
||||
isClosed
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const enhance = compose(
|
||||
withAssetStatusInfoFragments,
|
||||
withUpdateAssetStatus,
|
||||
);
|
||||
|
||||
export default enhance(AssetStatusInfoContainer);
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import {gql, compose} from 'react-apollo';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
import Configure from '../components/Configure';
|
||||
import AssetStatusInfo from './AssetStatusInfo';
|
||||
import Settings from './Settings';
|
||||
import PropTypes from 'prop-types';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
|
||||
class ConfigureContainer extends React.Component {
|
||||
|
||||
render() {
|
||||
return <Configure
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
asset={this.props.asset}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
ConfigureContainer.propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
asset: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
const withConfigureFragments = withFragments({
|
||||
root: gql`
|
||||
fragment CoralEmbedStream_Configure_root on RootQuery {
|
||||
__typename
|
||||
...${getDefinitionName(Settings.fragments.root)}
|
||||
}
|
||||
${Settings.fragments.root}
|
||||
`,
|
||||
asset: gql`
|
||||
fragment CoralEmbedStream_Configure_asset on Asset {
|
||||
__typename
|
||||
...${getDefinitionName(AssetStatusInfo.fragments.asset)}
|
||||
...${getDefinitionName(Settings.fragments.asset)}
|
||||
}
|
||||
${AssetStatusInfo.fragments.asset}
|
||||
${Settings.fragments.asset}
|
||||
`,
|
||||
});
|
||||
|
||||
const enhance = compose(
|
||||
withConfigureFragments,
|
||||
);
|
||||
|
||||
export default enhance(ConfigureContainer);
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import {gql, compose} from 'react-apollo';
|
||||
import {withFragments, withMergedSettings} from 'coral-framework/hocs';
|
||||
import {getErrorMessages, getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
import Settings from '../components/Settings.js';
|
||||
import PropTypes from 'prop-types';
|
||||
import {withUpdateAssetSettings} from 'coral-framework/graphql/mutations';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {notify} from 'coral-framework/actions/notification';
|
||||
import {clearPending, updatePending} from '../../../actions/configure';
|
||||
|
||||
const slots = [
|
||||
'streamSettings',
|
||||
];
|
||||
|
||||
class SettingsContainer extends React.Component {
|
||||
|
||||
toggleModeration = () => {
|
||||
const updater = {moderation: {$set: this.props.mergedSettings.moderation === 'PRE' ? 'POST' : 'PRE'}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
togglePremodLinks = () => {
|
||||
const updater = {premodLinksEnable: {$set: !this.props.mergedSettings.premodLinksEnable}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
toggleQuestionBox = () => {
|
||||
const updater = {questionBoxEnable: {$set: !this.props.mergedSettings.questionBoxEnable}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
setQuestionBoxIcon = (icon) => {
|
||||
const updater = {questionBoxIcon: {$set: icon}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
setQuestionBoxContent = (content) => {
|
||||
const updater = {questionBoxContent: {$set: content}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
savePending = async () => {
|
||||
try {
|
||||
await this.props.updateAssetSettings(this.props.asset.id, this.props.pending);
|
||||
this.props.clearPending();
|
||||
}
|
||||
catch(err) {
|
||||
this.props.notify('error', getErrorMessages(err));
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {mergedSettings, canSave, data, root, asset, errors, updatePending} = this.props;
|
||||
return <Settings
|
||||
settings={mergedSettings}
|
||||
queryData={{root, asset, settings: mergedSettings}}
|
||||
slotProps={{data, updatePending, errors}}
|
||||
savePending={this.savePending}
|
||||
onToggleModeration={this.toggleModeration}
|
||||
onTogglePremodLinks={this.togglePremodLinks}
|
||||
onToggleQuestionBox={this.toggleQuestionBox}
|
||||
onQuestionBoxIconChange={this.setQuestionBoxIcon}
|
||||
onQuestionBoxContentChange={this.setQuestionBoxContent}
|
||||
onApply={this.savePending}
|
||||
canSave={canSave}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
SettingsContainer.propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
asset: PropTypes.object.isRequired,
|
||||
pending: PropTypes.object.isRequired,
|
||||
errors: PropTypes.object.isRequired,
|
||||
mergedSettings: PropTypes.object.isRequired,
|
||||
updateAssetSettings: PropTypes.func.isRequired,
|
||||
clearPending: PropTypes.func.isRequired,
|
||||
notify: PropTypes.func.isRequired,
|
||||
updatePending: PropTypes.func.isRequired,
|
||||
canSave: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
const withSettingsFragments = withFragments({
|
||||
root: gql`
|
||||
fragment CoralEmbedStream_Settings_root on RootQuery {
|
||||
__typename
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
`,
|
||||
asset: gql`
|
||||
fragment CoralEmbedStream_Settings_asset on Asset {
|
||||
id
|
||||
settings {
|
||||
moderation
|
||||
premodLinksEnable
|
||||
questionBoxEnable
|
||||
questionBoxIcon
|
||||
questionBoxContent
|
||||
${getSlotFragmentSpreads(slots, 'settings')}
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'asset')}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
pending: state.configure.pending,
|
||||
canSave: state.configure.canSave,
|
||||
errors: state.configure.errors,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({
|
||||
notify,
|
||||
clearPending,
|
||||
updatePending,
|
||||
}, dispatch);
|
||||
|
||||
const enhance = compose(
|
||||
withSettingsFragments,
|
||||
withUpdateAssetSettings,
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withMergedSettings('asset.settings', 'pending', 'mergedSettings'),
|
||||
);
|
||||
|
||||
export default enhance(SettingsContainer);
|
||||
+2
-2
@@ -14,7 +14,7 @@ const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
|
||||
// comments to show. The spare cursor functions as a backup in case one
|
||||
// of the comments gets deleted.
|
||||
function resetCursors(state, props) {
|
||||
const comments = props.root.asset.comments;
|
||||
const comments = props.asset.comments;
|
||||
if (comments && comments.nodes.length) {
|
||||
const idCursors = [comments.nodes[0].id];
|
||||
if (comments.nodes[1]) {
|
||||
@@ -30,7 +30,7 @@ function resetCursors(state, props) {
|
||||
// using the help of the backup cursor.
|
||||
function invalidateCursor(invalidated, state, props) {
|
||||
const alt = invalidated === 1 ? 0 : 1;
|
||||
const comments = props.root.asset.comments;
|
||||
const comments = props.asset.comments;
|
||||
const idCursors = [];
|
||||
if (state.idCursors[alt]) {
|
||||
idCursors.push(state.idCursors[alt]);
|
||||
+27
-5
@@ -119,6 +119,8 @@
|
||||
|
||||
.commentInfoBar {
|
||||
margin-left: auto;
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@keyframes enter {
|
||||
@@ -131,9 +133,6 @@
|
||||
animation: enter 1000ms;
|
||||
}
|
||||
|
||||
.commentContainer {
|
||||
}
|
||||
|
||||
.commentAvatar {
|
||||
max-width: 60px;
|
||||
}
|
||||
@@ -152,9 +151,32 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
@media (min-width: 480px) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.tagsContainer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tagsContainer > * {
|
||||
margin: 3px;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
+35
-31
@@ -9,13 +9,13 @@ import {can} from 'coral-framework/services/perms';
|
||||
import {TransitionGroup} from 'react-transition-group';
|
||||
import cn from 'classnames';
|
||||
import styles from './Comment.css';
|
||||
import {THREADING_LEVEL} from '../constants/stream';
|
||||
import {THREADING_LEVEL} from '../../../constants/stream';
|
||||
import merge from 'lodash/merge';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
|
||||
import LoadMore from './LoadMore';
|
||||
import {getEditableUntilDate} from './util';
|
||||
import {findCommentWithId} from '../graphql/utils';
|
||||
import {findCommentWithId} from '../../../graphql/utils';
|
||||
import CommentContent from 'coral-framework/components/CommentContent';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import CommentTombstone from './CommentTombstone';
|
||||
@@ -452,39 +452,43 @@ export default class Comment extends React.Component {
|
||||
<div className={cn(styles.commentContainer, 'talk-stream-comment-container')}>
|
||||
<div className={cn(styles.header, 'talk-stream-comment-header')}>
|
||||
|
||||
<Slot
|
||||
className={cn(styles.username, 'talk-stream-comment-user-name')}
|
||||
fill="commentAuthorName"
|
||||
defaultComponent={CommentAuthorName}
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
/>
|
||||
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
|
||||
<Slot
|
||||
className={cn('talk-stream-comment-author-tags')}
|
||||
fill="commentAuthorTags"
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
inline
|
||||
/>
|
||||
|
||||
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
|
||||
<div className={cn(styles.headerContainer, 'talk-stream-comment-header-container')}>
|
||||
<Slot
|
||||
fill="commentTimestamp"
|
||||
defaultComponent={CommentTimestamp}
|
||||
className={'talk-stream-comment-published-date'}
|
||||
created_at={comment.created_at}
|
||||
className={cn(styles.username, 'talk-stream-comment-user-name')}
|
||||
fill="commentAuthorName"
|
||||
defaultComponent={CommentAuthorName}
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
/>
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
|
||||
<div className={cn(styles.tagsContainer, 'talk-stream-comment-header-tags-container')}>
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
|
||||
<Slot
|
||||
className={cn(styles.commentAuthorTagsSlot, 'talk-stream-comment-author-tags')}
|
||||
fill="commentAuthorTags"
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
|
||||
<Slot
|
||||
fill="commentTimestamp"
|
||||
defaultComponent={CommentTimestamp}
|
||||
className={'talk-stream-comment-published-date'}
|
||||
created_at={comment.created_at}
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
/>
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Slot
|
||||
className={styles.commentInfoBar}
|
||||
+16
-3
@@ -2,9 +2,22 @@
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.viewAllButton {
|
||||
.viewAllButtonContainer {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: -11px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.viewAllButton {
|
||||
composes: buttonReset from "coral-framework/styles/reset.css";
|
||||
|
||||
background-color: #4D8FCC;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tabPanel {
|
||||
@@ -26,4 +39,4 @@
|
||||
margin-top: 28px;
|
||||
padding-bottom: 50px;
|
||||
min-height: 600px;
|
||||
}
|
||||
}
|
||||
+49
-38
@@ -11,15 +11,14 @@ import RestrictedMessageBox
|
||||
from 'coral-framework/components/RestrictedMessageBox';
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
import CommentBox from 'talk-plugin-commentbox/CommentBox';
|
||||
import QuestionBox from 'talk-plugin-questionbox/QuestionBox';
|
||||
import QuestionBox from '../../../components/QuestionBox';
|
||||
import {isCommentActive} from 'coral-framework/utils';
|
||||
import {Button, Tab, TabCount, TabPane} from 'coral-ui';
|
||||
import {Tab, TabCount, TabPane} from 'coral-ui';
|
||||
import cn from 'classnames';
|
||||
|
||||
import {getTopLevelParent, attachCommentToParent} from '../graphql/utils';
|
||||
import {getTopLevelParent, attachCommentToParent} from '../../../graphql/utils';
|
||||
import AllCommentsPane from './AllCommentsPane';
|
||||
import AutomaticAssetClosure from '../containers/AutomaticAssetClosure';
|
||||
import StreamTabPanel from '../containers/StreamTabPanel';
|
||||
import ExtendableTabPanel from '../../../containers/ExtendableTabPanel';
|
||||
|
||||
import styles from './Stream.css';
|
||||
|
||||
@@ -47,7 +46,8 @@ class Stream extends React.Component {
|
||||
activeReplyBox,
|
||||
setActiveReplyBox,
|
||||
commentClassNames,
|
||||
root: {asset, asset: {comment}},
|
||||
asset,
|
||||
asset: {comment},
|
||||
postComment,
|
||||
notify,
|
||||
editComment,
|
||||
@@ -58,6 +58,7 @@ class Stream extends React.Component {
|
||||
loadNewReplies,
|
||||
auth: {user},
|
||||
emit,
|
||||
viewAllComments,
|
||||
} = this.props;
|
||||
|
||||
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
|
||||
@@ -76,6 +77,14 @@ class Stream extends React.Component {
|
||||
|
||||
return (
|
||||
<div className={cn('talk-stream-highlighted-container', styles.highlightedContainer)}>
|
||||
<div className={cn('talk-stream-show-all-comments-button-container', styles.viewAllButtonContainer)}>
|
||||
<button
|
||||
className={cn('talk-stream-show-all-comments-button', styles.viewAllButton)}
|
||||
onClick={viewAllComments}
|
||||
>
|
||||
{t('framework.show_all_comments')}
|
||||
</button>
|
||||
</div>
|
||||
<Comment
|
||||
data={data}
|
||||
root={root}
|
||||
@@ -106,14 +115,15 @@ class Stream extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
renderTabPanel() {
|
||||
renderExtendableTabPanel() {
|
||||
const {
|
||||
data,
|
||||
root,
|
||||
activeReplyBox,
|
||||
setActiveReplyBox,
|
||||
commentClassNames,
|
||||
root: {asset, asset: {comments, totalCommentCount}},
|
||||
asset,
|
||||
asset: {comments, totalCommentCount},
|
||||
postComment,
|
||||
notify,
|
||||
editComment,
|
||||
@@ -135,7 +145,7 @@ class Stream extends React.Component {
|
||||
const slotProps = {data};
|
||||
const slotQueryData = {root, asset};
|
||||
|
||||
// `key` of `StreamTabPanel` depends on sorting so that we always reset
|
||||
// `key` of `ExtendableTabPanel` depends on sorting so that we always reset
|
||||
// the state when changing sorting.
|
||||
return (
|
||||
<div className={cn('talk-stream-tab-container', styles.tabContainer)}>
|
||||
@@ -148,26 +158,28 @@ class Stream extends React.Component {
|
||||
{...slotProps}
|
||||
/>
|
||||
</div>
|
||||
<StreamTabPanel
|
||||
<ExtendableTabPanel
|
||||
key={`${sortBy}_${sortOrder}`}
|
||||
activeTab={activeStreamTab}
|
||||
setActiveTab={setActiveStreamTab}
|
||||
fallbackTab={'all'}
|
||||
tabSlot={'streamTabs'}
|
||||
tabPaneSlot={'streamTabPanes'}
|
||||
fallbackTab='all'
|
||||
tabSlot='streamTabs'
|
||||
tabSlotPrepend='streamTabsPrepend'
|
||||
tabPaneSlot='streamTabPanes'
|
||||
slotProps={slotProps}
|
||||
queryData={slotQueryData}
|
||||
loading={loading}
|
||||
appendTabs={
|
||||
tabs={
|
||||
<Tab tabId={'all'} key='all'>
|
||||
{t('stream.all_comments')} <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
|
||||
</Tab>
|
||||
}
|
||||
appendTabPanes={
|
||||
<TabPane tabId={'all'} key='all'>
|
||||
tabPanes={
|
||||
<TabPane tabId='all' key='all'>
|
||||
<AllCommentsPane
|
||||
data={data}
|
||||
root={root}
|
||||
asset={asset}
|
||||
comments={comments}
|
||||
commentClassNames={commentClassNames}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
@@ -175,7 +187,6 @@ class Stream extends React.Component {
|
||||
notify={notify}
|
||||
disableReply={!open}
|
||||
postComment={postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
@@ -201,11 +212,16 @@ class Stream extends React.Component {
|
||||
data,
|
||||
root,
|
||||
appendItemArray,
|
||||
root: {asset, asset: {comment: highlightedComment}},
|
||||
asset,
|
||||
asset: {
|
||||
comment: highlightedComment,
|
||||
settings: {
|
||||
questionBoxEnable,
|
||||
}
|
||||
},
|
||||
postComment,
|
||||
notify,
|
||||
updateItem,
|
||||
viewAllComments,
|
||||
auth: {loggedIn, user},
|
||||
editName,
|
||||
} = this.props;
|
||||
@@ -213,14 +229,13 @@ class Stream extends React.Component {
|
||||
const open = !asset.isClosed;
|
||||
|
||||
const banned = user && user.status === 'BANNED';
|
||||
const pending = user && user.status === 'PENDING';
|
||||
|
||||
const temporarilySuspended =
|
||||
user &&
|
||||
user.suspension.until &&
|
||||
new Date(user.suspension.until) > new Date();
|
||||
|
||||
const showCommentBox = loggedIn && ((!banned && !pending & !temporarilySuspended && !highlightedComment) || keepCommentBox);
|
||||
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
|
||||
const slotProps = {data};
|
||||
const slotQueryData = {root, asset};
|
||||
|
||||
@@ -230,27 +245,23 @@ class Stream extends React.Component {
|
||||
|
||||
return (
|
||||
<div id="stream" className={styles.root}>
|
||||
<AutomaticAssetClosure assetId={asset.id} closedAt={asset.closedAt}/>
|
||||
{highlightedComment &&
|
||||
<Button
|
||||
cStyle="darkGrey"
|
||||
className={cn('talk-stream-show-all-comments-button', styles.viewAllButton)}
|
||||
onClick={viewAllComments}
|
||||
>
|
||||
{t('framework.show_all_comments')}
|
||||
</Button>}
|
||||
|
||||
{open
|
||||
? <div id="commentBox">
|
||||
<InfoBox
|
||||
content={asset.settings.infoBoxContent}
|
||||
enable={asset.settings.infoBoxEnable}
|
||||
/>
|
||||
<QuestionBox
|
||||
content={asset.settings.questionBoxContent}
|
||||
enable={asset.settings.questionBoxEnable}
|
||||
icon={asset.settings.questionBoxIcon}
|
||||
/>
|
||||
{questionBoxEnable && (
|
||||
<QuestionBox
|
||||
content={asset.settings.questionBoxContent}
|
||||
icon={asset.settings.questionBoxIcon}>
|
||||
<Slot
|
||||
fill="streamQuestionArea"
|
||||
queryData={slotQueryData}
|
||||
{...slotProps}
|
||||
/>
|
||||
</QuestionBox>
|
||||
)}
|
||||
{!banned &&
|
||||
temporarilySuspended &&
|
||||
<RestrictedMessageBox>
|
||||
@@ -297,7 +308,7 @@ class Stream extends React.Component {
|
||||
|
||||
{highlightedComment
|
||||
? this.renderHighlightedComment()
|
||||
: this.renderTabPanel()
|
||||
: this.renderExtendableTabPanel()
|
||||
}
|
||||
</div>
|
||||
);
|
||||
@@ -325,7 +336,7 @@ Stream.propTypes = {
|
||||
loading: PropTypes.bool,
|
||||
editName: PropTypes.func,
|
||||
appendItemArray: PropTypes.func,
|
||||
updateItem: PropTypes.func,
|
||||
updateItem: PropTypes.func,
|
||||
viewAllComments: PropTypes.func,
|
||||
notify: PropTypes.func.isRequired,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
+1
-1
@@ -2,7 +2,7 @@ import React, {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import cn from 'classnames';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import styles from './SuspendAccount.css';
|
||||
import styles from './SuspendedAccount.css';
|
||||
import {Button} from 'coral-ui';
|
||||
import validate from 'coral-framework/helpers/validate';
|
||||
import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user