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