mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
2d43eef508 | ||
|
|
fa22e45a4d | ||
|
|
abdf5335da | ||
|
|
c24454e234 | ||
|
|
97e2e61a4b | ||
|
|
57ced3c91f | ||
|
|
f7c46b1a42 | ||
|
|
c664438ade | ||
|
|
a5b5043a0d | ||
|
|
b5b6b113d4 | ||
|
|
a13d384cee | ||
|
|
3a0a854317 | ||
|
|
73c3d31fbc | ||
|
|
03a0d904d2 | ||
|
|
315d3babde | ||
|
|
4d6055b9b8 | ||
|
|
d3d1148ee6 | ||
|
|
ecd9219e17 | ||
|
|
ce4ad426a7 | ||
|
|
77c8d966ab | ||
|
|
cabadf467f | ||
|
|
0807acc98f | ||
|
|
3c5b56c599 | ||
|
|
899bba7327 | ||
|
|
41dd3eb180 | ||
|
|
e02537df13 | ||
|
|
81d90cb138 | ||
|
|
e4a359e0b4 | ||
|
|
2882dfbadb | ||
|
|
f2b9db9dbc | ||
|
|
c563a13e7c | ||
|
|
5976595871 | ||
|
|
8a4a7d3589 | ||
|
|
5073e60df5 | ||
|
|
5a71db2568 | ||
|
|
ac0acea22b | ||
|
|
6198c9e730 | ||
|
|
9ac06281e9 | ||
|
|
78e5cb7aa6 | ||
|
|
882bf57bbc | ||
|
|
789fb3dfce | ||
|
|
819b1c597c | ||
|
|
38148ad51c | ||
|
|
8f6ff46244 | ||
|
|
4727c52de4 | ||
|
|
f461d27810 | ||
|
|
fbc5a9d74b | ||
|
|
6ff4994d0d | ||
|
|
c793a840ea | ||
|
|
ebda51dc0f | ||
|
|
b0573749ee | ||
|
|
933903de2f | ||
|
|
4373c382e1 | ||
|
|
af6787bc0f | ||
|
|
dd1fbf30ca | ||
|
|
a5aa59e255 | ||
|
|
19b8d0a6ea | ||
|
|
bcf8370359 | ||
|
|
0da0b1a37e | ||
|
|
31ee8139b6 | ||
|
|
51977dcf54 | ||
|
|
0b18bb06e7 | ||
|
|
9abb3d30da | ||
|
|
f0839c57df | ||
|
|
b2929e9a83 | ||
|
|
ca7b790962 | ||
|
|
42a477ae41 | ||
|
|
53461df287 | ||
|
|
9b18a78ce0 | ||
|
|
da35f39baf | ||
|
|
e885178dfc | ||
|
|
24e8aab0fe | ||
|
|
44d6ad340d | ||
|
|
63b6315435 | ||
|
|
95ca43ff8c | ||
|
|
55e4aed511 | ||
|
|
6d5adc8f54 | ||
|
|
247e05383e | ||
|
|
3988fb9e93 | ||
|
|
7d524efaad | ||
|
|
d7a88cdc2a | ||
|
|
18f5b0cc5e | ||
|
|
2df18f07cb | ||
|
|
786e85686d | ||
|
|
9a651880a2 | ||
|
|
82e01eac00 | ||
|
|
5efdc9865f | ||
|
|
62d01cf2a0 | ||
|
|
81bf63836a | ||
|
|
54ac2985ba | ||
|
|
e9bd5703f1 | ||
|
|
a394ba29b3 | ||
|
|
a2470fa01b | ||
|
|
9b98e3cac9 | ||
|
|
4f89c1e1c3 | ||
|
|
c3e551deb5 | ||
|
|
0678ea0344 | ||
|
|
b09b56bd24 | ||
|
|
2a0f70f084 | ||
|
|
bf2e11ef9a | ||
|
|
0fc810a7b1 | ||
|
|
918ad915b8 | ||
|
|
d51b6b31f6 | ||
|
|
e88445beac | ||
|
|
33be7531a0 | ||
|
|
2131638f48 | ||
|
|
39a980b22f | ||
|
|
ce0b63fe75 | ||
|
|
c0cb641df3 | ||
|
|
cee68ae471 | ||
|
|
33cd783b37 | ||
|
|
82ef16a4ee | ||
|
|
736ab6066f | ||
|
|
3327604a58 | ||
|
|
0c3cfd21a9 | ||
|
|
903e70d641 | ||
|
|
21f6caa942 | ||
|
|
f70ccf641a | ||
|
|
dd4e3d7ec8 | ||
|
|
886a468993 | ||
|
|
de0dad6243 | ||
|
|
7226c475e3 | ||
|
|
173c9262d1 | ||
|
|
5e7ee344ec | ||
|
|
b2d0c58a72 | ||
|
|
b34b033a18 | ||
|
|
4195d8b419 | ||
|
|
bfb2da519f | ||
|
|
8821c0aa5c | ||
|
|
a56031ac47 | ||
|
|
a3eeec34df | ||
|
|
c4ad667788 | ||
|
|
d20666adb9 | ||
|
|
96b44b2b99 | ||
|
|
b92b4027b0 | ||
|
|
f1423abdea | ||
|
|
c31e6347d0 | ||
|
|
48179b413c | ||
|
|
c955fcd29e | ||
|
|
457322e3d0 | ||
|
|
9ba8cbcbe1 | ||
|
|
93363a0650 | ||
|
|
7f442ce91e | ||
|
|
42f0d76d98 | ||
|
|
1578316363 | ||
|
|
5c033dbfdf | ||
|
|
3cdf2a3118 | ||
|
|
4e5e989d22 | ||
|
|
62660ed144 | ||
|
|
2d44f7abf4 | ||
|
|
ea98f0275d | ||
|
|
07e6c15c3a | ||
|
|
05e91129f4 | ||
|
|
350e679abf | ||
|
|
ae07dfb2cd | ||
|
|
88d012755c | ||
|
|
0cdefa0066 | ||
|
|
e4688f9876 | ||
|
|
e091fa552a | ||
|
|
53b1104cf8 | ||
|
|
975fe550e0 | ||
|
|
8adff14263 | ||
|
|
5f07d0256c | ||
|
|
f573504a3c | ||
|
|
8c0118106b | ||
|
|
4ce720e510 | ||
|
|
81c669d63f | ||
|
|
97a8f95b93 | ||
|
|
7bf114c01a | ||
|
|
6460cbd536 | ||
|
|
05b326b91c | ||
|
|
9af206da1a | ||
|
|
0c3a9db443 | ||
|
|
511f4f0975 | ||
|
|
95545a0e7d | ||
|
|
12a71fa117 | ||
|
|
302d30cde1 | ||
|
|
3da4a70442 | ||
|
|
6d7afc7bbc | ||
|
|
b6786f614e | ||
|
|
a6a903c459 | ||
|
|
34b58779e8 | ||
|
|
82a332c787 | ||
|
|
bfe82218ed | ||
|
|
5f5c64870a | ||
|
|
ad4a5595f9 | ||
|
|
b84daf0422 |
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"exceptions": [
|
||||
"https://nodesecurity.io/advisories/531"
|
||||
"https://nodesecurity.io/advisories/531",
|
||||
"https://nodesecurity.io/advisories/532"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
|
||||
either express or implied.
|
||||
|
||||
See the License for the specific language governing permissions and limitations under the License.
|
||||
See the License for the specific language governing permissions
|
||||
and limitations under the License.
|
||||
|
||||
@@ -1,46 +1,37 @@
|
||||
# Talk
|
||||
# Talk · [](https://circleci.com/gh/coralproject/talk) · [](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8) · [](CONTRIBUTING.md#pull-requests)
|
||||
|
||||
[](https://circleci.com/gh/coralproject/talk)
|
||||
[](https://dashboard.heroku.com/new?template=https%3A%2F%2Fgithub.com%2Fcoralproject%2Ftalk&env[TALK_FACEBOOK_APP_ID]=ignore&env[TALK_FACEBOOK_APP_SECRET]=ignore)
|
||||
[](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8)
|
||||
|
||||
Online comments are broken. Our open-source Talk tool rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/products/talk.html).
|
||||
Online comments are broken. Our open-source commenting platform, Talk, rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/products/talk.html).
|
||||
|
||||
Built with <3 by The Coral Project & Mozilla.
|
||||
|
||||
## Getting Started
|
||||
## Try Talk!
|
||||
|
||||
Check out our Docs: https://coralproject.github.io/talk/
|
||||
You're just one click away from trying Talk - all you need is a Heroku account and a few minutes of your time.
|
||||
|
||||
[](https://dashboard.heroku.com/new?template=https%3A%2F%2Fgithub.com%2Fcoralproject%2Ftalk&env[TALK_FACEBOOK_APP_ID]=ignore&env[TALK_FACEBOOK_APP_SECRET]=ignore)
|
||||
|
||||
## Technical Documentation
|
||||
|
||||
From getting up and running, to advanced configuration, to how to scale Talk, our [Talk Technical Docs](https://coralproject.github.io/talk/) have everything you need to know.
|
||||
|
||||
## Product Guide
|
||||
|
||||
Learn more about Talk, including a deep dive into features for commenters and moderators, and FAQs in our [Talk Product Guide](https://coralproject.github.io/talk/how-talk-works).
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- Blog: https://blog.coralproject.net/
|
||||
- Community Forums: https://community.coralproject.net/
|
||||
- Community Guides for Journalism: https://guides.coralproject.net/
|
||||
- Project: https://coralproject.net/
|
||||
- Roadmap: https://www.pivotaltracker.com/n/projects/1863625
|
||||
- [Our Blog](https://blog.coralproject.net/)
|
||||
- [Community Forums](https://community.coralproject.net/)
|
||||
- [Community Guides for Journalism](https://guides.coralproject.net/)
|
||||
- [More About Us](https://coralproject.net/)
|
||||
- [Talk Roadmap](https://www.pivotaltracker.com/n/projects/1863625)
|
||||
|
||||
## End-to-End Testing
|
||||
|
||||
Talk uses [Nightwatch](http://nightwatchjs.org/) to write e2e tests. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at Browserstack.
|
||||
Talk uses [Nightwatch](https://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com).
|
||||
|
||||
|
||||

|
||||
[](https://browserstack.com)
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2017 Mozilla Foundation
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
|
||||
either express or implied.
|
||||
|
||||
See the License for the specific language governing permissions
|
||||
and limitations under the License.
|
||||
Talk is released under the [Apache License, v2.0](/LICENSE).
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
const express = require('express');
|
||||
const bodyParser = require('body-parser');
|
||||
const morgan = require('morgan');
|
||||
const path = require('path');
|
||||
const merge = require('lodash/merge');
|
||||
const helmet = require('helmet');
|
||||
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');
|
||||
|
||||
@@ -36,12 +33,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 +44,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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ test:
|
||||
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml MOCHA_REPORTER=mocha-junit-reporter yarn test
|
||||
# Check dependancies using nsp.
|
||||
- nsp check
|
||||
# - yarn e2e-ci
|
||||
- yarn e2e-ci
|
||||
|
||||
deployment:
|
||||
release:
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
|
||||
@@ -32,8 +32,9 @@ class ActionsMenu extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {className = ''} = this.props;
|
||||
return (
|
||||
<div className={styles.root} onBlur={this.syncOpenState} >
|
||||
<div className={cn(styles.root, className)} onBlur={this.syncOpenState} >
|
||||
<Button
|
||||
cStyle='actions'
|
||||
className={cn(styles.button, {[styles.buttonOpen]: this.state.open})}
|
||||
@@ -59,6 +60,7 @@ class ActionsMenu extends React.Component {
|
||||
ActionsMenu.propTypes = {
|
||||
icon: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ActionsMenu;
|
||||
|
||||
@@ -3,12 +3,14 @@ import cn from 'classnames';
|
||||
import {MenuItem} from 'react-mdl';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './ActionsMenu.css';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
|
||||
const ActionsMenuItem = (props) =>
|
||||
<MenuItem className={cn(styles.menuItem, props.className)} {...props} />;
|
||||
|
||||
<MenuItem className={cn(styles.menuItem, props.className, 'action-menu-item')} {...props} id={camelCase(props.children)}/>;
|
||||
|
||||
ActionsMenuItem.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ActionsMenuItem;
|
||||
|
||||
@@ -7,11 +7,11 @@ import {Icon} from 'coral-ui';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const ApproveButton = ({active, minimal, onClick}) => {
|
||||
const ApproveButton = ({active, minimal, onClick, className}) => {
|
||||
const text = active ? t('modqueue.approved') : t('modqueue.approve');
|
||||
return (
|
||||
<button
|
||||
className={cn(styles.root, {[styles.minimal]: minimal, [styles.active]: active})}
|
||||
className={cn(styles.root, {[styles.minimal]: minimal, [styles.active]: active}, className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon name={'done'} className={styles.icon} />
|
||||
@@ -21,6 +21,7 @@ const ApproveButton = ({active, minimal, onClick}) => {
|
||||
};
|
||||
|
||||
ApproveButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
active: PropTypes.bool,
|
||||
minimal: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Dialog} from 'coral-ui';
|
||||
import styles from './BanUserDialog.css';
|
||||
@@ -8,28 +9,34 @@ import t from 'coral-framework/services/i18n';
|
||||
|
||||
const BanUserDialog = ({open, onCancel, onPerform, username, info}) => (
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
className={cn(styles.dialog, 'talk-ban-user-dialog')}
|
||||
id="banUserDialog"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
title={t('bandialog.ban_user')}>
|
||||
<span className={styles.close} onClick={onCancel}>×</span>
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<h2>{t('bandialog.ban_user')}</h2>
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h3>{t('bandialog.are_you_sure', username)}</h3>
|
||||
<i>{info}</i>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button cStyle="cancel" className={styles.cancel} onClick={onCancel} raised>
|
||||
{t('bandialog.cancel')}
|
||||
</Button>
|
||||
<Button cStyle="black" className={styles.ban} onClick={onPerform} raised>
|
||||
{t('bandialog.yes_ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.header}>
|
||||
<h2>{t('bandialog.ban_user')}</h2>
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h3>{t('bandialog.are_you_sure', username)}</h3>
|
||||
<i>{info}</i>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button
|
||||
className={cn(styles.cancel, 'talk-ban-user-dialog-button-cancel')}
|
||||
cStyle="cancel"
|
||||
onClick={onCancel}
|
||||
raised >
|
||||
{t('bandialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(styles.ban, 'talk-ban-user-dialog-button-confirm')}
|
||||
cStyle="black"
|
||||
onClick={onPerform}
|
||||
raised >
|
||||
{t('bandialog.yes_ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,15 @@ function generateRegExp(phrases) {
|
||||
.join('[\\s"?!.]+')
|
||||
).join('|');
|
||||
|
||||
return new RegExp(`(^|[^\\w])(${inner})(?=[^\\w]|$)`, 'iu');
|
||||
const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
|
||||
try {
|
||||
return new RegExp(pattern, 'iu');
|
||||
}
|
||||
catch (_err) {
|
||||
|
||||
// IE does not support unicode support, so we'll create one without.
|
||||
return new RegExp(pattern, 'i');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
|
||||
|
||||
@@ -6,10 +6,21 @@ import FlagLabel from 'coral-ui/components/FlagLabel';
|
||||
import cn from 'classnames';
|
||||
import styles from './CommentLabels.css';
|
||||
|
||||
const staffRoles = ['ADMIN', 'STAFF', 'MODERATOR'];
|
||||
|
||||
function isUserFlagged(actions) {
|
||||
return actions.some((action) => action.__typename === 'FlagAction' && action.user);
|
||||
}
|
||||
|
||||
function getUserFlaggedType(actions) {
|
||||
return actions
|
||||
.some((action) =>
|
||||
action.__typename === 'FlagAction' &&
|
||||
action.user &&
|
||||
action.user.roles.some((role) => staffRoles.includes(role))
|
||||
) ? 'Staff' : 'User';
|
||||
}
|
||||
|
||||
function hasSuspectedWords(actions) {
|
||||
return actions.some((action) => action.__typename === 'FlagAction' && action.reason === 'Matched suspect word filter');
|
||||
}
|
||||
@@ -24,7 +35,7 @@ const CommentLabels = ({comment, comment: {className, status, actions, hasParent
|
||||
<div className={styles.coreLabels}>
|
||||
{hasParent && <Label iconName="reply" className={styles.replyLabel}>reply</Label>}
|
||||
{status === 'PREMOD' && <Label iconName="query_builder" className={styles.premodLabel}>Pre-Mod</Label>}
|
||||
{isUserFlagged(actions) && <FlagLabel iconName="person">User</FlagLabel>}
|
||||
{isUserFlagged(actions) && <FlagLabel iconName="person">{getUserFlaggedType(actions)}</FlagLabel>}
|
||||
{hasSuspectedWords(actions) && <FlagLabel iconName="sms_failed">Suspect</FlagLabel>}
|
||||
{hasHistoryFlag(actions) && <FlagLabel iconName="sentiment_very_dissatisfied">History</FlagLabel>}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,34 +4,38 @@ import Modal from 'components/Modal';
|
||||
import styles from './ModerationKeysModal.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
title: 'modqueue.navigation',
|
||||
shortcuts: {
|
||||
'j': 'modqueue.next_comment',
|
||||
'k': 'modqueue.prev_comment',
|
||||
'ctrl+f': 'modqueue.toggle_search',
|
||||
't': 'modqueue.next_queue',
|
||||
's': 'modqueue.singleview',
|
||||
'?': 'modqueue.thismenu'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'modqueue.actions',
|
||||
shortcuts: {
|
||||
'd': 'modqueue.approve',
|
||||
'f': 'modqueue.reject'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default class ModerationKeysModal extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
hideShortcutsNote: PropTypes.func.isRequired,
|
||||
shortcutsNoteVisible: PropTypes.string.isRequired
|
||||
shortcutsNoteVisible: PropTypes.string.isRequired,
|
||||
queueCount: PropTypes.number.isRequired
|
||||
}
|
||||
|
||||
buildShortcuts = () => {
|
||||
return [
|
||||
{
|
||||
title: 'modqueue.navigation',
|
||||
shortcuts: {
|
||||
'j': 'modqueue.next_comment',
|
||||
'k': 'modqueue.prev_comment',
|
||||
'ctrl+f': 'modqueue.toggle_search',
|
||||
't': 'modqueue.next_queue',
|
||||
[`1...${this.props.queueCount}`]: 'modqueue.jump_to_queue',
|
||||
's': 'modqueue.singleview',
|
||||
'?': 'modqueue.thismenu'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'modqueue.actions',
|
||||
shortcuts: {
|
||||
'd': 'modqueue.approve',
|
||||
'f': 'modqueue.reject'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
render () {
|
||||
@@ -51,7 +55,7 @@ export default class ModerationKeysModal extends React.Component {
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<h3>{t('modqueue.shortcuts')}</h3>
|
||||
<div className={styles.container}>
|
||||
{shortcuts.map((shortcut, i) => (
|
||||
{this.buildShortcuts().map((shortcut, i) => (
|
||||
<table className={styles.table} key={i}>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -7,11 +7,11 @@ import {Icon} from 'coral-ui';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const RejectButton = ({active, minimal, onClick}) => {
|
||||
const RejectButton = ({active, minimal, onClick, className}) => {
|
||||
const text = active ? t('modqueue.rejected') : t('modqueue.reject');
|
||||
return (
|
||||
<button
|
||||
className={cn(styles.root, {[styles.minimal]: minimal, [styles.active]: active})}
|
||||
className={cn(styles.root, {[styles.minimal]: minimal, [styles.active]: active}, className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon name={'close'} className={styles.icon} />
|
||||
@@ -21,6 +21,7 @@ const RejectButton = ({active, minimal, onClick}) => {
|
||||
};
|
||||
|
||||
RejectButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
active: PropTypes.bool,
|
||||
minimal: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
|
||||
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import {Dialog} from 'coral-ui';
|
||||
import {RadioGroup, Radio} from 'react-mdl';
|
||||
import styles from './SuspendUserDialog.css';
|
||||
import cn from 'classnames';
|
||||
|
||||
import Button from 'coral-ui/components/Button';
|
||||
|
||||
@@ -87,7 +88,7 @@ class SuspendUserDialog extends React.Component {
|
||||
<Button cStyle="white" className={styles.cancel} onClick={onCancel} raised>
|
||||
{t('suspenduser.cancel')}
|
||||
</Button>
|
||||
<Button cStyle="black" className={styles.perform} onClick={this.goToStep1} raised>
|
||||
<Button cStyle="black" className={cn(styles.perform, 'talk-admin-suspend-user-dialog-confirm')} onClick={this.goToStep1} raised>
|
||||
{t('suspenduser.suspend_user')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -120,7 +121,7 @@ class SuspendUserDialog extends React.Component {
|
||||
</Button>
|
||||
<Button
|
||||
cStyle="black"
|
||||
className={styles.perform}
|
||||
className={cn(styles.perform, 'talk-admin-suspend-user-dialog-send')}
|
||||
onClick={this.handlePerform}
|
||||
disabled={this.state.message.length === 0}
|
||||
raised
|
||||
@@ -137,7 +138,7 @@ class SuspendUserDialog extends React.Component {
|
||||
const {step} = this.state;
|
||||
return (
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
className={cn(styles.dialog, 'talk-admin-suspend-user-dialog')}
|
||||
onCancel={onCancel}
|
||||
open={open}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
@@ -76,9 +69,9 @@ const CoralHeader = ({
|
||||
}
|
||||
<div className={styles.rightPanel}>
|
||||
<ul>
|
||||
<li className={styles.settings}>
|
||||
<li className={cn(styles.settings, 'talk-admin-header-settings')}>
|
||||
<div>
|
||||
<IconButton name="settings" id="menu-settings"/>
|
||||
<IconButton name="settings" id="menu-settings" className="talk-admin-header-settings-button"/>
|
||||
<Menu target="menu-settings" align="right">
|
||||
<MenuItem onClick={() => showShortcuts(true)}>{t('configure.shortcuts')}</MenuItem>
|
||||
<MenuItem>
|
||||
@@ -87,11 +80,11 @@ 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>
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<MenuItem onClick={handleLogout} className="talk-admin-header-sign-out">
|
||||
{t('configure.sign_out')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -25,6 +25,7 @@ export default withFragments({
|
||||
}
|
||||
user {
|
||||
id
|
||||
roles
|
||||
}
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'comment')}
|
||||
|
||||
@@ -19,6 +19,6 @@ export default withQuery(gql`
|
||||
}
|
||||
`, {
|
||||
options: {
|
||||
pollInterval: 5000
|
||||
pollInterval: 10000
|
||||
}
|
||||
})(Header);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import SuspendUserDialog from '../components/SuspendUserDialog';
|
||||
@@ -44,6 +45,12 @@ class SuspendUserDialogContainer extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
SuspendUserDialogContainer.propTypes = {
|
||||
open: PropTypes.bool,
|
||||
hideSuspendUserDialog: PropTypes.func,
|
||||
username: PropTypes.string,
|
||||
};
|
||||
|
||||
const withOrganizationName = withQuery(gql`
|
||||
query CoralAdmin_SuspendUserDialog {
|
||||
__typename
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -13,11 +13,11 @@ const CommunityMenu = ({flaggedUsernamesCount = 0}) => {
|
||||
<div className='mdl-tabs'>
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<div>
|
||||
<Link to={flaggedPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
<Link to={flaggedPath} className={`mdl-tabs__tab ${styles.tab} talk-admin-nav-flagged-accounts`} activeClassName={styles.active}>
|
||||
{t('community.flaggedaccounts')}
|
||||
<CountBadge count={flaggedUsernamesCount} />
|
||||
</Link>
|
||||
<Link to={peoplePath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
<Link to={peoplePath} className={`mdl-tabs__tab ${styles.tab} talk-admin-nav-people`} activeClassName={styles.active}>
|
||||
{t('community.people')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import cn from 'classnames';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import EmptyCard from 'coral-admin/src/components/EmptyCard';
|
||||
import LoadMore from '../../../components/LoadMore';
|
||||
@@ -23,7 +24,7 @@ class FlaggedAccounts extends React.Component {
|
||||
const hasResults = users.nodes && !!users.nodes.length;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={cn('talk-adnin-community-flagged-accounts', styles.container)}>
|
||||
<div className={styles.mainFlaggedContent}>
|
||||
{
|
||||
hasResults
|
||||
@@ -70,4 +71,15 @@ class FlaggedAccounts extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
FlaggedAccounts.propTypes = {
|
||||
users: PropTypes.object,
|
||||
loadMore: PropTypes.func,
|
||||
showBanUserDialog: PropTypes.func,
|
||||
showSuspendUserDialog: PropTypes.func,
|
||||
showRejectUsernameDialog: PropTypes.func,
|
||||
approveUser: PropTypes.func,
|
||||
me: PropTypes.object,
|
||||
viewUserDetail: PropTypes.func,
|
||||
};
|
||||
|
||||
export default FlaggedAccounts;
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import React from 'react';
|
||||
import styles from './FlaggedUser.css';
|
||||
|
||||
// TODO: Should not rely on plugin.
|
||||
import PropTypes from 'prop-types';
|
||||
import cn from 'classnames';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {username} from 'talk-plugin-flags/helpers/flagReasons';
|
||||
|
||||
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
|
||||
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
|
||||
import ApproveButton from 'coral-admin/src/components/ApproveButton';
|
||||
import RejectButton from 'coral-admin/src/components/RejectButton';
|
||||
|
||||
import cn from 'classnames';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
// TODO: Should work with custom flags too.
|
||||
const shortReasons = {
|
||||
[username.other]: t('community.other'),
|
||||
[username.spam]: t('community.spam_ads'),
|
||||
@@ -21,7 +17,6 @@ const shortReasons = {
|
||||
[username.impersonating]: t('community.impersonating'),
|
||||
};
|
||||
|
||||
// Render a single user for the list
|
||||
class User extends React.Component {
|
||||
|
||||
showSuspenUserDialog = () => this.props.showSuspendUserDialog({
|
||||
@@ -50,12 +45,10 @@ class User extends React.Component {
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<li
|
||||
tabIndex={0}
|
||||
className={cn(className, styles.root, {[styles.rootSelected]: selected})}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<li tabIndex={0}
|
||||
className={cn(className, styles.root, {[styles.rootSelected]: selected})} >
|
||||
<div className={cn('talk-admin-community-flagged-user', styles.container)}>
|
||||
<div className={cn('talk-admin-community-flagged-user-header', styles.header)}>
|
||||
<div className={styles.author}>
|
||||
<button
|
||||
onClick={this.viewAuthorDetail}
|
||||
@@ -78,8 +71,7 @@ class User extends React.Component {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={cn('talk-admin-community-flagged-user-body', styles.body)}>
|
||||
<div className={styles.flagged}>
|
||||
<div className={styles.flaggedByCount}>
|
||||
<i className={cn('material-icons', styles.flagIcon)}>flag</i>
|
||||
@@ -127,9 +119,11 @@ class User extends React.Component {
|
||||
<div className={styles.sideActions}>
|
||||
<div className={styles.actions}>
|
||||
<ApproveButton
|
||||
className="talk-admin-flagged-user-approve-button"
|
||||
onClick={this.approveUser}
|
||||
/>
|
||||
<RejectButton
|
||||
className="talk-admin-flagged-user-reject-button"
|
||||
onClick={this.showRejectUsernameDialog}
|
||||
/>
|
||||
</div>
|
||||
@@ -141,4 +135,16 @@ class User extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
User.propTypes = {
|
||||
showSuspendUserDialog: PropTypes.func,
|
||||
showBanUserDialog: PropTypes.func,
|
||||
viewUserDetail: PropTypes.func,
|
||||
showRejectUsernameDialog: PropTypes.func,
|
||||
approveUser: PropTypes.func,
|
||||
user: PropTypes.object,
|
||||
className: PropTypes.string,
|
||||
selected: PropTypes.bool,
|
||||
me: PropTypes.object,
|
||||
};
|
||||
|
||||
export default User;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import styles from './styles.css';
|
||||
import Table from './Table';
|
||||
import {Icon} from 'coral-ui';
|
||||
@@ -24,7 +25,7 @@ const People = (props) => {
|
||||
const hasResults = !!users.length;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={cn(styles.container, 'talk-admin-community-people-container')}>
|
||||
<div className={styles.leftColumn}>
|
||||
<div className={styles.searchBox}>
|
||||
<Icon name='search' className={styles.searchIcon}/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import cn from 'classnames';
|
||||
import {Dialog, Button} from 'coral-ui';
|
||||
import styles from './RejectUsernameDialog.css';
|
||||
|
||||
@@ -29,12 +29,6 @@ class RejectUsernameDialog extends Component {
|
||||
|
||||
state = {email: '', stage: 0}
|
||||
|
||||
static propTypes = {
|
||||
stage: PropTypes.number,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
rejectUsername: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({email: t('reject_username.email_message_reject'), about: t('reject_username.username')});
|
||||
}
|
||||
@@ -76,7 +70,7 @@ class RejectUsernameDialog extends Component {
|
||||
const {stage} = this.state;
|
||||
|
||||
return <Dialog
|
||||
className={styles.suspendDialog}
|
||||
className={cn(styles.suspendDialog, 'talk-reject-username-dialog')}
|
||||
id="rejectUsernameDialog"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
@@ -96,15 +90,18 @@ class RejectUsernameDialog extends Component {
|
||||
<div className={styles.emailContainer}>
|
||||
<textarea
|
||||
rows={5}
|
||||
className={styles.emailInput}
|
||||
className={cn(styles.emailInput, 'talk-reject-username-dialog-suspension-message')}
|
||||
value={this.state.email}
|
||||
onChange={this.onEmailChange}/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className={styles.modalButtons}>
|
||||
<div className={cn(styles.modalButtons, 'talk-reject-username-dialog-buttons')}>
|
||||
{Object.keys(stages[stage].options).map((key, i) => (
|
||||
<Button key={i} onClick={this.onActionClick(stage, i)}>
|
||||
<Button
|
||||
key={i}
|
||||
className={cn('talk-reject-username-dialog-button', `talk-reject-username-dialog-button-${key}`)}
|
||||
onClick={this.onActionClick(stage, i)} >
|
||||
{t(stages[stage].options[key], t('reject_username.username'))}
|
||||
</Button>
|
||||
))}
|
||||
@@ -114,4 +111,12 @@ class RejectUsernameDialog extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
RejectUsernameDialog.propTypes = {
|
||||
stage: PropTypes.number,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
rejectUsername: PropTypes.func.isRequired,
|
||||
user: PropTypes.object,
|
||||
open: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default RejectUsernameDialog;
|
||||
|
||||
@@ -42,7 +42,7 @@ const Table = ({users, setRole, onHeaderClickHandler, setCommenterStatus, viewUs
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((row, i)=> (
|
||||
<tr key={i}>
|
||||
<tr key={i} className="talk-admin-community-people-row">
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
<button onClick={() => {viewUserDetail(row.id);}} className={cn(styles.username, styles.button)}>{row.username}</button>
|
||||
<span className={styles.email}>{row.profiles.map(({id}) => id)}</span>
|
||||
@@ -52,6 +52,7 @@ const Table = ({users, setRole, onHeaderClickHandler, setCommenterStatus, viewUs
|
||||
</td>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
<Dropdown
|
||||
containerClassName="talk-admin-community-people-dd-status"
|
||||
value={row.status}
|
||||
placeholder={t('community.status')}
|
||||
onChange={(status) => setCommenterStatus(row.id, status)}>
|
||||
@@ -61,6 +62,7 @@ const Table = ({users, setRole, onHeaderClickHandler, setCommenterStatus, viewUs
|
||||
</td>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
<Dropdown
|
||||
containerClassName="talk-admin-community-people-dd-role"
|
||||
value={row.roles[0] || ''}
|
||||
placeholder={t('community.role')}
|
||||
onChange={(role) => setRole(row.id, role)}>
|
||||
|
||||
@@ -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';
|
||||
@@ -73,7 +73,7 @@ class Comment extends React.Component {
|
||||
return (
|
||||
<li
|
||||
tabIndex={0}
|
||||
className={cn(className, 'mdl-card', selectionStateCSS, styles.root, {[styles.selected]: selected})}
|
||||
className={cn(className, 'mdl-card', selectionStateCSS, styles.root, {[styles.selected]: selected}, 'talk-admin-moderate-comment')}
|
||||
id={`comment_${comment.id}`}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
@@ -95,7 +95,7 @@ class Comment extends React.Component {
|
||||
: null
|
||||
}
|
||||
{currentUserId !== comment.user.id &&
|
||||
<ActionsMenu icon="not_interested">
|
||||
<ActionsMenu icon="not_interested" className="talk-admin-moderate-comment-actions-menu">
|
||||
<ActionsMenuItem
|
||||
disabled={comment.user.status === 'BANNED'}
|
||||
onClick={this.showSuspendUserDialog}>
|
||||
|
||||
@@ -34,6 +34,8 @@ class Moderation extends Component {
|
||||
key('k', () => this.select(false));
|
||||
key('f', () => this.moderate(false));
|
||||
key('d', () => this.moderate(true));
|
||||
this.getMenuItems()
|
||||
.forEach((menuItem, idx) => key(`${idx + 1}`, () => this.selectQueue(menuItem)));
|
||||
}
|
||||
|
||||
onClose = () => {
|
||||
@@ -41,20 +43,23 @@ class Moderation extends Component {
|
||||
}
|
||||
|
||||
nextQueue = () => {
|
||||
const queueConfig = this.props.queueConfig;
|
||||
const activeTab = this.props.activeTab;
|
||||
const assetId = this.props.data.variables.asset_id;
|
||||
|
||||
const menuItems = Object.keys(queueConfig).map((queue) => ({
|
||||
key: queue
|
||||
}));
|
||||
const menuItems = this.getMenuItems();
|
||||
|
||||
const activeTabIndex = menuItems.findIndex((item) => item.key === activeTab);
|
||||
const activeTabIndex = menuItems.findIndex((item) => item === activeTab);
|
||||
const nextQueueIndex = (activeTabIndex === menuItems.length - 1) ? 0 : activeTabIndex + 1;
|
||||
|
||||
this.props.router.push(this.props.getModPath(menuItems[nextQueueIndex].key, assetId));
|
||||
this.selectQueue(menuItems[nextQueueIndex]);
|
||||
}
|
||||
|
||||
selectQueue = (key) => {
|
||||
const assetId = this.props.data.variables.asset_id;
|
||||
this.props.router.push(this.props.getModPath(key, assetId));
|
||||
}
|
||||
|
||||
getMenuItems = () => Object.keys(this.props.queueConfig);
|
||||
|
||||
closeSearch = () => {
|
||||
const {toggleStorySearch} = this.props;
|
||||
toggleStorySearch(false);
|
||||
@@ -175,6 +180,8 @@ class Moderation extends Component {
|
||||
key.unbind('k');
|
||||
key.unbind('f');
|
||||
key.unbind('d');
|
||||
this.getMenuItems()
|
||||
.forEach((menuItem, idx) => key.unbind(`${idx + 1}`));
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
@@ -276,6 +283,7 @@ class Moderation extends Component {
|
||||
shortcutsNoteVisible={moderation.shortcutsNoteVisible}
|
||||
open={moderation.modalOpen}
|
||||
onClose={this.onClose}
|
||||
queueCount={this.getMenuItems().length}
|
||||
/>
|
||||
</div>
|
||||
<StorySearch
|
||||
|
||||
@@ -47,6 +47,9 @@ export default withFragments({
|
||||
editing {
|
||||
edited
|
||||
}
|
||||
status_history {
|
||||
type
|
||||
}
|
||||
hasParent
|
||||
${getSlotFragmentSpreads(slots, 'comment')}
|
||||
...${getDefinitionName(CommentLabels.fragments.comment)}
|
||||
|
||||
@@ -71,10 +71,10 @@ class ModerationContainer extends Component {
|
||||
const id = getAssetId(this.props);
|
||||
const tab = getTab(this.props);
|
||||
|
||||
// Grab premod from asset or from settings
|
||||
const premod = !id ? settings.moderation : asset.settings.moderation;
|
||||
// Grab premod from asset or from settings if it's defined.
|
||||
const setting = id && asset && asset.settings ? asset.settings.moderation : settings.moderation;
|
||||
|
||||
const queue = isPremod(premod) ? 'premod' : 'new';
|
||||
const queue = isPremod(setting) ? 'premod' : 'new';
|
||||
const activeTab = tab ? tab : queue;
|
||||
|
||||
return activeTab;
|
||||
@@ -111,6 +111,17 @@ class ModerationContainer extends Component {
|
||||
return this.handleCommentChange(prev, comment, notifyText);
|
||||
},
|
||||
},
|
||||
{
|
||||
document: COMMENT_RESET_SUBSCRIPTION,
|
||||
variables,
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentReset: comment}}}) => {
|
||||
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
|
||||
const notifyText = this.props.auth.user.id === user.id
|
||||
? ''
|
||||
: t('modqueue.notify_reset', user.username, prepareNotificationText(comment.body));
|
||||
return this.handleCommentChange(prev, comment, notifyText);
|
||||
},
|
||||
},
|
||||
{
|
||||
document: COMMENT_EDITED_SUBSCRIPTION,
|
||||
variables,
|
||||
@@ -127,7 +138,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() {
|
||||
@@ -299,6 +310,23 @@ const COMMENT_REJECTED_SUBSCRIPTION = gql`
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const COMMENT_RESET_SUBSCRIPTION = gql`
|
||||
subscription CommentReset($asset_id: ID){
|
||||
commentReset(asset_id: $asset_id){
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
status_history {
|
||||
type
|
||||
created_at
|
||||
assigned_by {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const LOAD_MORE_QUERY = gql`
|
||||
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $sortOrder: SORT_ORDER, $asset_id: ID, $tags:[String!], $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
|
||||
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sortOrder: $sortOrder, action_type: $action_type, tags: $tags}) {
|
||||
|
||||
@@ -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: {
|
||||
@@ -102,6 +103,9 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
status_history {
|
||||
type
|
||||
}
|
||||
action_summaries {
|
||||
count
|
||||
current_user {
|
||||
@@ -182,6 +186,7 @@ export default {
|
||||
editableUntil: new Date().toISOString(),
|
||||
edited: false,
|
||||
},
|
||||
status_history: [],
|
||||
id: `pending-${uuid()}`,
|
||||
}
|
||||
}
|
||||
@@ -214,6 +219,18 @@ export default {
|
||||
},
|
||||
},
|
||||
}),
|
||||
UpdateAssetSettings: ({variables: {input}}) => ({
|
||||
updateQueries: {
|
||||
CoralEmbedStream_Embed: (prev) => {
|
||||
const updated = update(prev, {
|
||||
asset: {
|
||||
settings: mapLeaves(input, (leaf) => ({$set: leaf})),
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -142,17 +142,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]);
|
||||
+28
-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,12 +151,36 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.footer {
|
||||
+42
-32
@@ -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';
|
||||
@@ -182,6 +182,7 @@ export default class Comment extends React.Component {
|
||||
editableUntil: PropTypes.string,
|
||||
})
|
||||
}).isRequired,
|
||||
setCommentStatus: PropTypes.func.isRequired,
|
||||
|
||||
// edit a comment, passed (id, asset_id, { body })
|
||||
editComment: PropTypes.func,
|
||||
@@ -343,7 +344,12 @@ export default class Comment extends React.Component {
|
||||
} = this.props;
|
||||
|
||||
if (!highlighted && this.commentIsRejected(comment)) {
|
||||
return <CommentTombstone action='reject' />;
|
||||
return <CommentTombstone action='reject' onUndo={() => {
|
||||
this.props.setCommentStatus({
|
||||
commentId: comment.id,
|
||||
status: comment.status_history[comment.status_history.length - 2].type,
|
||||
});
|
||||
}}/>;
|
||||
}
|
||||
|
||||
if (this.commentIsIgnored(comment)) {
|
||||
@@ -446,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}
|
||||
+6
@@ -3,4 +3,10 @@
|
||||
text-align: center;
|
||||
padding: 1em;
|
||||
color: #3E4F71;
|
||||
}
|
||||
|
||||
.undo {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
margin-left: 5px;
|
||||
}
|
||||
+4
@@ -24,6 +24,9 @@ class CommentTombstone extends React.Component {
|
||||
<hr aria-hidden={true} />
|
||||
<p className={styles.commentTombstone}>
|
||||
{this.getCopy()}
|
||||
{this.props.action === 'reject' &&
|
||||
<span className={styles.undo} onClick={this.props.onUndo}>{t('comment.undo_reject')}</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -32,6 +35,7 @@ class CommentTombstone extends React.Component {
|
||||
|
||||
CommentTombstone.propTypes = {
|
||||
action: PropTypes.string,
|
||||
onUndo: PropTypes.func,
|
||||
};
|
||||
|
||||
export default CommentTombstone;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user