mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
562f2350c1 | ||
|
|
ea6330bf94 | ||
|
|
0be3dc2ceb | ||
|
|
eb98b32d21 | ||
|
|
127ee25d7f | ||
|
|
ed4a3c3250 | ||
|
|
7b53048a91 | ||
|
|
28e94d30b0 | ||
|
|
1c2fec29a4 | ||
|
|
56cc4989fd | ||
|
|
b32aebd6f1 | ||
|
|
244a276e1a | ||
|
|
245561bd52 | ||
|
|
d05a63bc2e | ||
|
|
5d118c26d1 | ||
|
|
5ab5b7ca70 | ||
|
|
b79c990ee5 | ||
|
|
1541a618b5 | ||
|
|
30b200d734 | ||
|
|
dc76d89c47 | ||
|
|
3d80df1db6 | ||
|
|
83832abd72 | ||
|
|
47fd522f68 | ||
|
|
1280b16bcd | ||
|
|
3d666111b0 | ||
|
|
919bdff40a | ||
|
|
518b7b5358 | ||
|
|
8b9463f501 | ||
|
|
f5313c0aed | ||
|
|
c59c09e1f4 | ||
|
|
e98dcb6647 | ||
|
|
39db11569f | ||
|
|
c3ad47f6f6 | ||
|
|
b9cb1a783f | ||
|
|
ea51fdf6cc | ||
|
|
324aa6db32 | ||
|
|
9065a64d13 | ||
|
|
9b36fcebe9 | ||
|
|
5d9d01d87f | ||
|
|
7f85129044 | ||
|
|
fc31a2882d | ||
|
|
23cc3548d4 | ||
|
|
574f5b7cea | ||
|
|
b19e050399 | ||
|
|
ef3c82fee1 | ||
|
|
202e7a2b10 | ||
|
|
2588f3946d | ||
|
|
1c2e850a07 | ||
|
|
0e8ecb37f3 | ||
|
|
d1b96f5830 | ||
|
|
a9c6014634 | ||
|
|
125168c7c8 | ||
|
|
eafcadd6a1 | ||
|
|
211f24476a | ||
|
|
f55e4ef895 | ||
|
|
bbf1da88e6 | ||
|
|
d688f70c19 | ||
|
|
da7aa8f6d0 | ||
|
|
b04a477f6a | ||
|
|
0546f741a5 | ||
|
|
a535baeb31 | ||
|
|
8b125eab48 | ||
|
|
053b687959 | ||
|
|
b620ac703b | ||
|
|
3cb0bb052c | ||
|
|
c4985e0007 | ||
|
|
066d9ce0ec | ||
|
|
dc6fa74d22 | ||
|
|
e9e79b4605 | ||
|
|
1d9b627d73 | ||
|
|
40f4419c9d | ||
|
|
8b70dc688d | ||
|
|
b05a6c00ce | ||
|
|
9063b8adf1 | ||
|
|
01fd667eb4 | ||
|
|
73ac8d16ef | ||
|
|
ced449a148 | ||
|
|
ffad851066 | ||
|
|
4281fd590c | ||
|
|
0c167fdfc4 | ||
|
|
5dfb372c42 | ||
|
|
932103c204 | ||
|
|
666225614b | ||
|
|
636f357186 | ||
|
|
be6337f17c | ||
|
|
2c7f5ca089 | ||
|
|
f09550a4ac | ||
|
|
3c3a0523de | ||
|
|
a2dabcac0e | ||
|
|
a686bc75a7 | ||
|
|
8960f67a4d | ||
|
|
e10c1f1a1f | ||
|
|
55e70dfbec | ||
|
|
af46e3a7b1 | ||
|
|
883dfedfab | ||
|
|
ae52b27009 | ||
|
|
34a4a005d1 | ||
|
|
ab72995a2c | ||
|
|
aac47c4686 | ||
|
|
362c072181 | ||
|
|
ef172ae5f8 | ||
|
|
1e4e1cb413 | ||
|
|
e819b96473 | ||
|
|
1e3c1feadf | ||
|
|
83d7e599c7 | ||
|
|
52d7ba8965 | ||
|
|
c3ec772ea2 | ||
|
|
d2b4c2bcac | ||
|
|
f738da4dd5 | ||
|
|
b796bf2607 | ||
|
|
3924ced659 | ||
|
|
23573501bd | ||
|
|
787c00325d | ||
|
|
db371d1c82 | ||
|
|
b744a98a3d | ||
|
|
37439a0896 | ||
|
|
af3cd8aa68 | ||
|
|
7baa551a5c | ||
|
|
b7c4dbd224 | ||
|
|
aad2455654 | ||
|
|
a8e5ad451d | ||
|
|
99425e89ca | ||
|
|
60608af8b2 | ||
|
|
7c753ee518 | ||
|
|
b28902312d | ||
|
|
f32c03f002 | ||
|
|
693879647e | ||
|
|
c01d1f95bc | ||
|
|
65d6d3221b | ||
|
|
15af61de67 | ||
|
|
1adca208dd | ||
|
|
935982cb08 | ||
|
|
4b9f3390d3 | ||
|
|
f94c36e9c9 | ||
|
|
c976ba0ea4 | ||
|
|
6d71bcbee9 | ||
|
|
9be2a520db | ||
|
|
d155615102 | ||
|
|
4d71a5a448 | ||
|
|
cfa7a74742 | ||
|
|
524be1b869 | ||
|
|
31ab54d13c | ||
|
|
c5203e3460 | ||
|
|
79c1bd9bdd | ||
|
|
e2575cb346 | ||
|
|
3a2a4379b8 | ||
|
|
f1899f2f4a | ||
|
|
4d821afa6a | ||
|
|
c16278221c | ||
|
|
a50b39091f | ||
|
|
4c7506e4f5 | ||
|
|
2f06281439 | ||
|
|
fd1f8b24c1 | ||
|
|
270c3b7634 | ||
|
|
e4647cab98 | ||
|
|
60b4909395 | ||
|
|
0973600e68 | ||
|
|
e3ead31964 | ||
|
|
7858ccc45d | ||
|
|
c1807daeaf | ||
|
|
e244d4a286 | ||
|
|
9de99a9ef4 | ||
|
|
01883851ea | ||
|
|
c8d9447033 | ||
|
|
4319bb9da3 | ||
|
|
9f8c8e9c83 | ||
|
|
997dffd326 | ||
|
|
f6d652df0d | ||
|
|
c3ee442775 | ||
|
|
bad869bdfe | ||
|
|
2289c405d1 | ||
|
|
354295c0ae | ||
|
|
737cea6e76 | ||
|
|
867f6966ba | ||
|
|
fbefd7fd20 | ||
|
|
a0a7971866 | ||
|
|
16355169ce | ||
|
|
a7f4dea74a | ||
|
|
8aa0c16769 | ||
|
|
d31044f39e | ||
|
|
a804973990 | ||
|
|
db9c3b45c1 | ||
|
|
ddac5c2c85 | ||
|
|
0312530b86 | ||
|
|
87f8ed401f | ||
|
|
06ebd9ccc5 | ||
|
|
e615624b62 | ||
|
|
9803ccfaf5 | ||
|
|
ab84436a78 | ||
|
|
ccaa744a78 | ||
|
|
d55fc6b601 | ||
|
|
32318567c9 | ||
|
|
6cf8c01d61 | ||
|
|
b92afff4e7 | ||
|
|
089ed0f09b | ||
|
|
2258125789 | ||
|
|
8de4b475a1 | ||
|
|
4691ee07de | ||
|
|
f767b6a998 | ||
|
|
9b26b0a434 | ||
|
|
7817804bae | ||
|
|
b2738afdbe | ||
|
|
e0a32e5a70 | ||
|
|
b7a981e84b | ||
|
|
52431a4c4c | ||
|
|
ca0431054b | ||
|
|
3f03994a51 | ||
|
|
198efd02b4 | ||
|
|
aff486e909 | ||
|
|
55e62b2b22 | ||
|
|
37e6c2c45c | ||
|
|
270491dc0f | ||
|
|
04b562543e | ||
|
|
dd76ac1ed4 | ||
|
|
e03e15fb3b | ||
|
|
f13682c125 | ||
|
|
3695ce5cb7 | ||
|
|
d68169757d | ||
|
|
6d4765b45d | ||
|
|
b94824b8ee | ||
|
|
cb1b9a1463 | ||
|
|
35ccc5070d | ||
|
|
efb18cdb32 | ||
|
|
2338ff1694 | ||
|
|
b134d4099c | ||
|
|
1b40521f88 | ||
|
|
88473a1c71 | ||
|
|
97ab338486 | ||
|
|
8d4e45c0ed | ||
|
|
53b481dc68 | ||
|
|
b67ac375ba | ||
|
|
500d5d582e | ||
|
|
5b9fe5688f | ||
|
|
175dd30d27 | ||
|
|
33990053fb | ||
|
|
3f4747c2ec | ||
|
|
db3187cb56 | ||
|
|
c90c177b7c | ||
|
|
933891b2e3 | ||
|
|
43dd72c715 | ||
|
|
a7f0de6e62 | ||
|
|
53742934f9 | ||
|
|
b8aed80f1c | ||
|
|
4e94727749 | ||
|
|
8412fa5613 | ||
|
|
bee1cc124c | ||
|
|
2ac6ad8eb8 | ||
|
|
fa9a4ecc50 | ||
|
|
b00d2f569e | ||
|
|
5e1450ffb0 | ||
|
|
a14e4859a9 | ||
|
|
2745245564 | ||
|
|
3bb1279acf | ||
|
|
fedca113d2 | ||
|
|
59407e99d7 | ||
|
|
c7c33dcf13 | ||
|
|
0e152797ad | ||
|
|
58d9f294b7 | ||
|
|
fddbf2c00f | ||
|
|
10ae39ba09 | ||
|
|
a025f4ca24 | ||
|
|
eb4d85fb86 | ||
|
|
5e6a295f11 | ||
|
|
b864761ef8 | ||
|
|
01f6bfa979 | ||
|
|
e2a9a42ec6 | ||
|
|
b65c07a8a2 | ||
|
|
a49a53903d | ||
|
|
5984f57682 | ||
|
|
1c844061f4 | ||
|
|
bc878051f8 | ||
|
|
b805451d37 | ||
|
|
4a5424b81d | ||
|
|
18a767b8a4 | ||
|
|
32b6627144 | ||
|
|
e1b3eb9ff7 | ||
|
|
b76578b866 | ||
|
|
c09d1524a9 | ||
|
|
548b56be33 | ||
|
|
ea030af440 | ||
|
|
af59a2e836 | ||
|
|
2ab2b41b55 | ||
|
|
4bb64231e9 | ||
|
|
2800458519 | ||
|
|
90bc9be1b2 | ||
|
|
64a4bf6760 | ||
|
|
6d5b8e73d4 | ||
|
|
87c14dd824 | ||
|
|
e89a15f2d2 | ||
|
|
7d84cff0f7 | ||
|
|
6fa74f84f1 | ||
|
|
10ed5c9a4a | ||
|
|
40b4c0cab4 | ||
|
|
a3059a9fa3 | ||
|
|
d57d05fda9 | ||
|
|
57e0a160a2 | ||
|
|
bb544a92b3 | ||
|
|
010f0cb0d7 | ||
|
|
60dd6a37d7 | ||
|
|
455a034802 | ||
|
|
46e707ba51 | ||
|
|
e7e66f1e6f | ||
|
|
8bb38c5ee5 | ||
|
|
c41b3c27ba | ||
|
|
cfd55907bd | ||
|
|
91907e7647 | ||
|
|
a5048eadb4 | ||
|
|
bde08ea256 | ||
|
|
3de6ba1b24 | ||
|
|
dac9401c44 | ||
|
|
bba4474858 | ||
|
|
259f98a4ec | ||
|
|
f7a8f9bbd1 | ||
|
|
83096dbcce | ||
|
|
555a71fd9b | ||
|
|
2c57ec3c7c | ||
|
|
c0e9e4272b | ||
|
|
a31810ddf4 | ||
|
|
f6b2a25e72 | ||
|
|
bd7443672a | ||
|
|
358ea0d37c | ||
|
|
c0dcfe5f4e | ||
|
|
91b7cd10f8 | ||
|
|
f537b4a772 | ||
|
|
a9cefd8cbd | ||
|
|
f08c2f4152 | ||
|
|
cdcd543fe0 | ||
|
|
66aec74b38 | ||
|
|
85dcc8d0ec | ||
|
|
9d572f6c3b | ||
|
|
012ff926eb | ||
|
|
91fd8ceabd | ||
|
|
4a354f0b9a | ||
|
|
3beb62f837 | ||
|
|
57ecba6f33 | ||
|
|
712a53db24 | ||
|
|
f709057933 | ||
|
|
ac89ddc43f | ||
|
|
b0156fa11f | ||
|
|
16eb93792b | ||
|
|
1b1f2a3608 | ||
|
|
6628e6d08d | ||
|
|
e2f94aa141 | ||
|
|
f20b9819aa | ||
|
|
91fa7b2d8d | ||
|
|
74fff2f1ac | ||
|
|
4b063607ab | ||
|
|
9b26bc9d96 | ||
|
|
38eaff5397 | ||
|
|
420513804c | ||
|
|
b0079c191b | ||
|
|
9815248b8d | ||
|
|
79346738d7 | ||
|
|
e16553419d | ||
|
|
dccf182591 | ||
|
|
33057e197f | ||
|
|
605e759bc6 | ||
|
|
706a12d2e2 | ||
|
|
adeca927a6 | ||
|
|
fc14e9d2fd | ||
|
|
49420e152e | ||
|
|
644c291d7f | ||
|
|
40d7500a75 | ||
|
|
3d27c4538a | ||
|
|
942ea7b916 | ||
|
|
4d53a72767 | ||
|
|
23fcba4ccc | ||
|
|
bee4b4454c | ||
|
|
679c6ce335 | ||
|
|
71d2d0ab10 | ||
|
|
d97fb0a83f | ||
|
|
14311dc298 | ||
|
|
7faa00c463 | ||
|
|
d23d426567 | ||
|
|
fe8f3ae4bb | ||
|
|
646b5be8c8 | ||
|
|
ea12c07cd4 | ||
|
|
bb4cd91b0c | ||
|
|
dca759a5e0 | ||
|
|
018b35e42a | ||
|
|
672e36c66c | ||
|
|
b3db231f09 | ||
|
|
38a53d61f3 | ||
|
|
fc15bcae4e | ||
|
|
6959699ecc | ||
|
|
99bb4b7198 | ||
|
|
12a5654fb8 | ||
|
|
dad6534011 | ||
|
|
c36a0bc9e9 | ||
|
|
30a385e03b | ||
|
|
174088fc62 | ||
|
|
16ef833fd8 | ||
|
|
523f23269b | ||
|
|
8dbc17c8d9 | ||
|
|
b8967dcdb4 | ||
|
|
70276c3358 | ||
|
|
1f2eca300a | ||
|
|
3c99b8e7fb | ||
|
|
073fccebd3 | ||
|
|
13e311de7f | ||
|
|
ba81399707 | ||
|
|
cf93727f73 | ||
|
|
fbbd0fa027 | ||
|
|
332679a7bc | ||
|
|
2a2ca86263 | ||
|
|
393ad8530a | ||
|
|
0cd3921f44 | ||
|
|
f954d2be90 | ||
|
|
2a28306ab2 | ||
|
|
e1bc1476ea | ||
|
|
853c1e407e | ||
|
|
77fddba841 | ||
|
|
17d36c69d0 | ||
|
|
1cf6161b06 | ||
|
|
9ef7544788 | ||
|
|
dfccf962d4 | ||
|
|
6c5d94ec3b | ||
|
|
e7eb300e12 | ||
|
|
614f31d0da | ||
|
|
61591a9716 | ||
|
|
367c570747 | ||
|
|
991bdc6089 | ||
|
|
4ce38d429e | ||
|
|
dcee2a4438 | ||
|
|
4403decbe9 | ||
|
|
e30d5a9c62 | ||
|
|
fe94a5a6d1 | ||
|
|
c7b58957cb | ||
|
|
89cf13b4cf | ||
|
|
f0b6679141 | ||
|
|
26253255ce | ||
|
|
64174b97af | ||
|
|
fe0c581ea9 | ||
|
|
4982d8a9ad | ||
|
|
fd9b14a560 | ||
|
|
6139828844 | ||
|
|
c14c3fbe23 | ||
|
|
f34d546a37 | ||
|
|
6da0028258 | ||
|
|
74fbadcc0f | ||
|
|
30ca7b3d64 | ||
|
|
9573c9a7af | ||
|
|
1dec8bfaab | ||
|
|
2625b372e1 | ||
|
|
5f450adbaf | ||
|
|
d386316f8a | ||
|
|
5604aa8525 | ||
|
|
b518358ea8 | ||
|
|
fe74f4a8d2 | ||
|
|
b78d4f29ea | ||
|
|
ca08efea8a | ||
|
|
5718ba6748 | ||
|
|
0123736275 | ||
|
|
18061e3e1c | ||
|
|
b40df9f10c | ||
|
|
0c7f9cdb1e | ||
|
|
ecf63ffb55 | ||
|
|
ba659b47be | ||
|
|
f73f8d916e | ||
|
|
93c8b115f5 | ||
|
|
685695bf43 | ||
|
|
d7c6d7dfac | ||
|
|
c7cfd80c7f | ||
|
|
928e19ed67 | ||
|
|
5a2ce0f6a7 | ||
|
|
312103098c | ||
|
|
21f95c33c2 | ||
|
|
cbcb84f7ac | ||
|
|
303b3ea0f5 | ||
|
|
5ecdfe94cc | ||
|
|
648b46e5af | ||
|
|
e160dfaa3e | ||
|
|
3658a804b0 | ||
|
|
c902a4b05b | ||
|
|
8a3833ec11 | ||
|
|
d45c396013 | ||
|
|
d22f5215ed | ||
|
|
c7d5ab935f | ||
|
|
aa289ecbfe | ||
|
|
de665b3a0d | ||
|
|
19b6dfaa0a | ||
|
|
b009d7a67a | ||
|
|
06da4d7d26 | ||
|
|
9818b851db |
@@ -12,4 +12,6 @@ plugins/*
|
||||
!plugins/coral-plugin-love
|
||||
!plugins/coral-plugin-viewing-options
|
||||
!plugins/coral-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
!plugins/talk-plugin-featured
|
||||
node_modules
|
||||
|
||||
+2
-1
@@ -53,7 +53,8 @@
|
||||
"no-lonely-if": [2],
|
||||
"curly": [2],
|
||||
"no-unused-vars": ["error", {
|
||||
"argsIgnorePattern": "next"
|
||||
"argsIgnorePattern": "^_|next",
|
||||
"varsIgnorePattern": "^_"
|
||||
}],
|
||||
"no-multiple-empty-lines": ["error", {
|
||||
"max": 1
|
||||
|
||||
@@ -25,5 +25,7 @@ plugins/*
|
||||
!plugins/coral-plugin-love
|
||||
!plugins/coral-plugin-viewing-options
|
||||
!plugins/coral-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
!plugins/talk-plugin-featured
|
||||
|
||||
**/node_modules/*
|
||||
|
||||
+51
-42
@@ -6,16 +6,46 @@ This document is a companion to help you approach contributing. If it does not d
|
||||
|
||||
By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct.html).
|
||||
|
||||
## Product Roadmap
|
||||
## What should I Contribute?
|
||||
|
||||
You can view what the Coral Team is working on next here https://www.pivotaltracker.com/n/projects/1863625.
|
||||
There are at least three ways to contribute to Talk:
|
||||
|
||||
If you'd like to see our longer term roadmap, [get in touch.](https://coralproject.net/contact.html)
|
||||
* Writing Code
|
||||
* Writing Docs
|
||||
* Providing Translations
|
||||
|
||||
## Writing Code
|
||||
|
||||
## Contribute to the documentation
|
||||
Conversation surrounding contributions begins in [issues](https://github.com/coralproject/talk/issues).
|
||||
|
||||
Clear docs are a prerequisite for a successful open source project. We value non-code and code contributions equally.
|
||||
### When should I Create an Issue?
|
||||
|
||||
File an issue as soon as you have an idea of something you'd like to contribute. We would love to hear what you're thinking and help refine the idea to make it into the Talk ecosystem.
|
||||
|
||||
Please file issues if:
|
||||
|
||||
* you would like to contribute to Talk Core.
|
||||
* you are building a Plugin that the current Plugin API doesn't support.
|
||||
* you are building a Plugin and want advice.
|
||||
|
||||
### What should I include?
|
||||
|
||||
Coral has adopted an iterative, agile development philosophy. All contributions that make it into the Talk repo should start with a story or this form:
|
||||
|
||||
`As a [type of person] I'd like to be able to [do something] so that I can [get some result].`
|
||||
|
||||
This exercise does two things:
|
||||
|
||||
* allows us to ground our technical choices in a clear, simple product need.
|
||||
* expresses that product need in a way that doesn't imply a specific technical solution allowing for debate as to the best way to solve the problem.
|
||||
|
||||
Please feel free to provide as much detail as possible when filing the issue but please do keep the initial issue specific to one need and try to avoid including technical or design solutions.
|
||||
|
||||
If you have a specific technical or design solution in mind, please submit it as the first comment on the thread.
|
||||
|
||||
## Contributing Documentation
|
||||
|
||||
Clear docs are a prerequisite for a successful open source project. Contributing to the documentation is often more important than contributing to the code!
|
||||
|
||||
We are looking for _documentarians_ to:
|
||||
|
||||
@@ -23,55 +53,34 @@ We are looking for _documentarians_ to:
|
||||
* create new / missing sections, and
|
||||
* take the lead in making sections, or the over all structure better.
|
||||
|
||||
### But how?
|
||||
Information about how to update docs can be found in our [FAQ](faq.html#how-do-i-contribute-to-these-docs).
|
||||
|
||||
* Our public docs site can be updated [here](https://github.com/coralproject/docs).
|
||||
* [Let us know](https://github.com/coralproject/talk/wiki/Contact-Us) if you'd like permission to update our wiki.
|
||||
* Update any of our .md docs files by following [this guide](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging).
|
||||
* Our API docs need to be kept honest. [Update them here](https://github.com/coralproject/talk/blob/master/docs/swagger.yaml).
|
||||
If you'd like to discuss a contribution, please [file an issue](https://github.com/coralproject/talk/issues) describing the changes you would like to see.
|
||||
|
||||
## Integrate into your environment
|
||||
## Contributing Translations
|
||||
|
||||
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
|
||||
Talk's tranlations are stored in `.yml` files [here](https://github.com/coralproject/talk/tree/master/locales).
|
||||
|
||||
Talk is designed to integrate into existing environments in a variety of ways:
|
||||
Translations can be submitted via pull request. If you do not use github, you can use 'en.yml' as a template and [email](https://coralproject.net/contact.html) the translations to us. We can import it into the repo.
|
||||
|
||||
* [Auth integrations](https://github.com/coralproject/talk/wiki/Security#authentication-strategies)
|
||||
* [Push assets into Talk](https://github.com/coralproject/talk/blob/master/routes/api/assets/index.js)
|
||||
* Monitoring Hooks (coming in 2017)
|
||||
## I want to contribute but I'm not sure what to do!
|
||||
|
||||
If you're considering deploying Talk, [please let us know](https://github.com/coralproject/talk/wiki/Contact-Us)! We are quite literally doing this for you and want to help you succeed any way we can.
|
||||
If you want to contribute but don't have a clear idea of exactly what that may be, here are some resources that may help:
|
||||
|
||||
If you are writing custom integration code in your fork of Talk, please consider keeping it generic and filing a Pull Request to contribute it back to the project! See our [forking and merging guidelines](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging) for more info.
|
||||
### Product Roadmap
|
||||
|
||||
## Write some code
|
||||
Please visit our product roadmap here: https://www.pivotaltracker.com/n/projects/1863625. If you'd like to take on any of our scheduled tasks we'd be forever grateful!
|
||||
|
||||
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
|
||||
### Discussion Forum
|
||||
|
||||
### Build a New Feature / Plugin
|
||||
If you'd like to discuss what we're up to, please visit or [community](https://community.coralproject.net/) or [contact us](https://coralproject.net/contact.html).
|
||||
|
||||
Talk is beginning life as a Commenting Platform, but is architected to support many varieties of community engagement.
|
||||
### Integrations
|
||||
|
||||
Please [contact us](https://github.com/coralproject/talk/wiki/Contact-Us) early and often if you'd like to help. We would love to hear your ideas for features and plugins and help you find a way to productively engage the project.
|
||||
Have a favorite analytics engine? Data science service? CMS? Auth platform? Deployment platform or pipeline? Pet project? Consider building a plugin to integrate them!
|
||||
|
||||
To get an idea of where the Coral Team is going, see:
|
||||
### Favorite Features?
|
||||
|
||||
* our [Slack channel](https://coralprojectslackin.herokuapp.com/), where you can talk directly with the Team and other community members,
|
||||
* our [current stories](https://www.pivotaltracker.com/n/projects/1863625), and
|
||||
* our [issues](https://github.com/coralproject/talk/issues).
|
||||
Do you have a favorite feature of an existing platform that's not yet been done in Talk? Sounds like Talk needs that feature.
|
||||
|
||||
|
||||
Examples:
|
||||
|
||||
* [Add An Emoji Button to Comments](https://github.com/coralproject/talk/wiki/Add-An-Emoji-Button-to-Comments)
|
||||
|
||||
|
||||
### Work on the Core
|
||||
|
||||
There is always more work to be done to make an application more stable, scaleable and secure.
|
||||
|
||||
If you see issues in the code or have ideas on how we may improve Talk, please consider:
|
||||
|
||||
* [contributing a fix](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging),
|
||||
* [filing an issue](https://github.com/coralproject/talk/issues), or
|
||||
* or otherwise [letting us know](https://github.com/coralproject/talk/wiki/Contact-Us).
|
||||
## Thanks!
|
||||
|
||||
+1
-525
@@ -1,527 +1,3 @@
|
||||
# Talk Plugins
|
||||
|
||||
Plugins for Talk can take various forms, currently we are only supporting server
|
||||
side plugins.
|
||||
|
||||
## Plugin Registration
|
||||
|
||||
The parsing order for the plugin registration is as follows:
|
||||
|
||||
- `TALK_PLUGINS_JSON` environment variable
|
||||
- `plugins.json` file
|
||||
- `plugins.default.json` file
|
||||
|
||||
If you need to "disable all plugins", you can simply provide `{}` as the
|
||||
contents of `process.env.TALK_PLUGINS_JSON` or the `plugins.json`.
|
||||
|
||||
The format for this is thus:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": [
|
||||
"people"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Where we have a `server` key with an array of plugins that match the folder
|
||||
name in the `plugins/` folder. For example, the above config would
|
||||
require a plugin from `plugins/people`, which must provide a `index.js` file
|
||||
that returns an object that matches the Plugin Specification.
|
||||
|
||||
If the package is external (available on NPM) you can specify the string for
|
||||
the version by using an object instead, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": [
|
||||
{"people": "^1.2.0"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
External plugins can be resolved by running:
|
||||
|
||||
```bash
|
||||
./bin/cli plugins reconcile
|
||||
```
|
||||
|
||||
This achieves two things:
|
||||
|
||||
1. It will traverse into local plugin folders and install their dependencies.
|
||||
_Note that if the plugin is already installed and available in the node_modules folder, it will not be
|
||||
fetched again unless there is a version mismatch._ This will result in the
|
||||
project `package.json` and `yarn.lock` files to be modified, this is normal as
|
||||
this ensures that repeated deployments (with the same config) will have the
|
||||
same config, these changes should not be committed to source control.
|
||||
2. It will seek out dependencies that are listed in the object notation and try
|
||||
to install them from npm.
|
||||
|
||||
## Plugin Dependencies
|
||||
|
||||
You may also include additional external dependencies in your local packages by
|
||||
specifying a `package.json` at your plugin root which will result in a
|
||||
`node_modules` folder being generated at the plugin root with your specific
|
||||
dependencies.
|
||||
|
||||
## Deployment Solutions
|
||||
|
||||
Plugins can be deployed with a production instance of Talk.
|
||||
|
||||
### Source
|
||||
|
||||
Source deployments can just modify the `plugins.json` file and include any
|
||||
local plugins into the `plugins/` directory. After including the config, you
|
||||
need to reconcile the plugins and build the static assets:
|
||||
|
||||
```bash
|
||||
# get plugin dependancies and remote plugins
|
||||
./bin/cli plugins reconcile
|
||||
|
||||
# build staic assets (including enabled client side plugins)
|
||||
yarn build
|
||||
```
|
||||
|
||||
Then the application can be started as is.
|
||||
|
||||
### Docker
|
||||
|
||||
If you deploy using Docker, you can extend from the `*-onbuild` image, an
|
||||
example `Dockerfile` for your project could be:
|
||||
|
||||
```Dockerfile
|
||||
FROM coralproject/talk:latest-onbuild
|
||||
```
|
||||
|
||||
Where the directory for your instance would contain a `plugins.json` file
|
||||
describing the plugin requirements and a `plugins` directory containing any
|
||||
other local plugins that should be included.
|
||||
|
||||
Onbuild triggers will execute when the image is building with your custom
|
||||
configuration and will ensure that the image is ready to use by building all
|
||||
assets inside the image as well.
|
||||
|
||||
## Server Plugins
|
||||
|
||||
### API
|
||||
|
||||
You can access any API available inside the talk directory in a plugin by simply
|
||||
importing the file relative to the talk project root. An example would be if you
|
||||
wanted to import the `MetadataService`, you would simply write:
|
||||
|
||||
```javascript
|
||||
const MetadataService = require('services/metadata');
|
||||
```
|
||||
|
||||
### Specification
|
||||
|
||||
Each plugin should export a single object with all hooks available on it.
|
||||
|
||||
_**Note: You will have access to the whole core and other plugin's typeDefs,
|
||||
context, loaders, mutators, resolvers, hooks. This is intentional, as it
|
||||
encourages composing plugins to merge functionality, like a Slack plugin which
|
||||
provides a Slack notify context function as well as having the loader for
|
||||
comments.**_
|
||||
|
||||
The following are the hooks available:
|
||||
|
||||
#### Field: `typeDefs`
|
||||
|
||||
```graphql
|
||||
enum COLOUR {
|
||||
RED
|
||||
BLUE
|
||||
}
|
||||
|
||||
type Person {
|
||||
name: String!
|
||||
colour: COLOUR!
|
||||
}
|
||||
|
||||
type RootMutation {
|
||||
createPerson(name: String!): Person
|
||||
}
|
||||
|
||||
type RootQuery {
|
||||
people: [Person!]
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
leader: Person
|
||||
}
|
||||
```
|
||||
|
||||
Thanks to [gql-merge](https://www.npmjs.com/package/gql-merge) the contents of
|
||||
`typeDefs` should be a string that will be _merged_ with the existing type
|
||||
definitions. `enum`'s will be appended to, types will be appended, and new types
|
||||
will be added.
|
||||
|
||||
#### Field: `context`
|
||||
|
||||
```js
|
||||
{
|
||||
Slack: (context) => ({
|
||||
notify: (message) => {
|
||||
// return a promise after we're done sending notifications.
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Any property provided here will be added to the context parameter available
|
||||
inside all resolvers, loaders, mutators, and of course, other context based
|
||||
plugins.
|
||||
|
||||
The top level item must accept a context for the request which it should use to
|
||||
configure the context plugin before it would be mounted at `context.plugins`.
|
||||
This plugin above would mount at: `context.plugins.Slack`, or, if you're using
|
||||
[object destructuring](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), `{plugins: {Slack}}`.
|
||||
|
||||
#### Field: `loaders`
|
||||
|
||||
```js
|
||||
(context) => ({
|
||||
People: {
|
||||
load: () => db.people.find({user: context.user})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Loaders should be provided as a function which returns a map which is used in
|
||||
the resolvers function. These must return a promise or a value.
|
||||
|
||||
#### Field: `mutators`
|
||||
|
||||
```js
|
||||
(context) => ({
|
||||
People: {
|
||||
create: (name) => {
|
||||
return db.people.insert({user: context.user, name});
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Mutators should be provided as a function which returns a map which is used in
|
||||
the resolvers function. These must return a promise or a value.
|
||||
|
||||
#### Field: `resolvers`
|
||||
|
||||
```js
|
||||
{
|
||||
Person: {
|
||||
name(obj, args, context) {
|
||||
return obj.name;
|
||||
},
|
||||
colour(obj, args, context) {
|
||||
// Bill likes the colour red, everyone else likes blue.
|
||||
return obj.name === 'bill' ? 'RED' : 'BLUE';
|
||||
}
|
||||
},
|
||||
RootQuery: {
|
||||
people(obj, args, {loaders: {People}}) {
|
||||
return People.load();
|
||||
}
|
||||
},
|
||||
RootMutation: {
|
||||
createPerson(obj, {name}, {mutators: {People}}) {
|
||||
return People.create(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Should return a resolver map as described in the
|
||||
[Apollo Docs](http://dev.apollodata.com/tools/graphql-tools/resolvers.html#Resolver-map).
|
||||
|
||||
This will merge with the existing resolvers in core and from previous plugins.
|
||||
|
||||
#### Field: `hooks`
|
||||
|
||||
```js
|
||||
{
|
||||
RootMutation: {
|
||||
createPerson: {
|
||||
post: async (obj, args, {plugins: {Slack}}, info, person) {
|
||||
if (!person) {
|
||||
return person;
|
||||
}
|
||||
|
||||
await Slack.notify(`A new person just was created with name ${person.name}`);
|
||||
|
||||
return person;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Hooks here are pretty special, for each resolver field, you can specify a
|
||||
pre/post hook that will execute pre and post field resolution.
|
||||
|
||||
If your post function accepts four parameters, then it can modify the field
|
||||
result. It is *required* that the function resolves a promise (or returns) with
|
||||
the modified value or simply the original if you didn't modify it.
|
||||
|
||||
#### Field: `setupFunctions`
|
||||
|
||||
```js
|
||||
setupFunctions: {
|
||||
leader: (options, args) => ({
|
||||
leader: {
|
||||
filter: (person) => person.place === 1
|
||||
},
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
Setup functions allow you to create filters that control which pubsub.publish() events
|
||||
send data to the client. If the type in question contains args, clients may subscribe using those arguments to further filter their subscription.
|
||||
|
||||
For more information, see the [Apollo Docs](https://github.com/apollographql/graphql-subscriptions).
|
||||
|
||||
#### Field: `tokenUserNotFound`
|
||||
|
||||
```js
|
||||
tokenUserNotFound: async ({jwt, token}) => {
|
||||
let profile = await someExternalService(token);
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let user = await UserModel.findOneAndUpdate({
|
||||
id: profile.id
|
||||
}, {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
lowercaseUsername: profile.username.toLowerCase(),
|
||||
roles: [],
|
||||
profiles: []
|
||||
}, {
|
||||
setDefaultsOnInsert: true,
|
||||
new: true,
|
||||
upsert: true
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
The `tokenUserNotFound` hook allows auth integrations to hook into the event
|
||||
when a valid token is provided but a user can't be found in the database that
|
||||
matches the provided id.
|
||||
|
||||
The function is async, and should return the user object that was created in the
|
||||
database, or null if the user wasn't found. The `jwt` paramenter of the object
|
||||
is the unpacked token, while `token` is the original jwt token string.
|
||||
|
||||
#### Field: `router`
|
||||
|
||||
```js
|
||||
(router) => {
|
||||
router.get('/api/v1/people', (req, res) => {
|
||||
res.json({people: [{name: 'Bob'}]});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The Router hook allows you to create a function that accepts the base express
|
||||
router where you can mount any amount of middleware/routes to do any form of
|
||||
action needed by external applications. We also provide the authorization
|
||||
middleware via:
|
||||
|
||||
```js
|
||||
const authorization = require('middleware/authorization');
|
||||
|
||||
module.exports = {
|
||||
router(router) {
|
||||
router.get('/api/v1/people', authorization.needed('ADMIN'), (req, res) => {
|
||||
res.json({people: [{name: 'SECRET PEOPLE'}]});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Field: `tags`
|
||||
|
||||
The tags hook allows a plugin to define tags that are code controlled (added
|
||||
or enabled by code). Below is an example pulled from the core off topic plugin
|
||||
on how to create a hook for the `OFF_TOPIC` name:
|
||||
|
||||
```js
|
||||
[
|
||||
{
|
||||
name: 'OFF_TOPIC',
|
||||
permissions: {
|
||||
public: true,
|
||||
self: true,
|
||||
roles: []
|
||||
},
|
||||
models: ['COMMENTS'],
|
||||
created_at: new Date()
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
You can refer to `models/schema/tag.js` for the available schema to match when
|
||||
creating models to enable/disable specific features.
|
||||
|
||||
#### Field: `passport`
|
||||
|
||||
```js
|
||||
const FacebookStrategy = require('passport-facebook').Strategy;
|
||||
const UsersService = require('services/users');
|
||||
const {ValidateUserLogin, HandleAuthPopupCallback} = require('services/passport');
|
||||
|
||||
module.exports = {
|
||||
passport(passport) {
|
||||
passport.use(new FacebookStrategy({
|
||||
clientID: process.env.TALK_FACEBOOK_APP_ID,
|
||||
clientSecret: process.env.TALK_FACEBOOK_APP_SECRET,
|
||||
callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`,
|
||||
passReqToCallback: true,
|
||||
profileFields: ['id', 'displayName', 'picture.type(large)']
|
||||
}, async (req, accessToken, refreshToken, profile, done) => {
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await UsersService.findOrCreateExternalUser(profile);
|
||||
} catch (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
return ValidateUserLogin(profile, user, done);
|
||||
}));
|
||||
},
|
||||
router(router) {
|
||||
|
||||
// Note that we have to import the passport instance here, it is
|
||||
// instantiated after all the strategies have been mounted.
|
||||
const {passport} = require('services/passport');
|
||||
|
||||
/**
|
||||
* Facebook auth endpoint, this will redirect the user immediatly to facebook
|
||||
* for authorization.
|
||||
*/
|
||||
router.get('/facebook', passport.authenticate('facebook', {display: 'popup', authType: 'rerequest', scope: ['public_profile']}));
|
||||
|
||||
/**
|
||||
* Facebook callback endpoint, this will send the user a html page designed to
|
||||
* send back the user credentials upon sucesfull login.
|
||||
*/
|
||||
router.get('/facebook/callback', (req, res, next) => {
|
||||
|
||||
// Perform the facebook login flow and pass the data back through the opener.
|
||||
passport.authenticate('facebook', HandleAuthPopupCallback(req, res, next))(req, res, next);
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
This is a full example including the routes hook to add the required components
|
||||
to the application router to support a different auth strategy.
|
||||
|
||||
### Full Example
|
||||
|
||||
Contents of `plugins.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": [
|
||||
"people"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Located in `plugins/people/index.js`:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
typeDefs: `
|
||||
enum COLOUR {
|
||||
RED
|
||||
BLUE
|
||||
}
|
||||
|
||||
type Person {
|
||||
name: String!
|
||||
colour: COLOUR!
|
||||
}
|
||||
|
||||
type RootMutation {
|
||||
createPerson(name: String!): Person
|
||||
}
|
||||
|
||||
type RootQuery {
|
||||
people: [Person!]
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
leader: Person
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
Slack: () => ({
|
||||
notify: (message) => {
|
||||
// return a promise after we're done sending notifications.
|
||||
}
|
||||
})
|
||||
},
|
||||
loaders: ({user}) => ({
|
||||
People: {
|
||||
load: () => db.people.find({user})
|
||||
}
|
||||
}),
|
||||
mutators: ({user}) => ({
|
||||
People: {
|
||||
create: (name) => {
|
||||
return db.people.insert({user, name});
|
||||
}
|
||||
}
|
||||
}),
|
||||
resolvers: {
|
||||
Person: {
|
||||
name(obj, args, context) {
|
||||
return obj.name;
|
||||
},
|
||||
colour(obj, args, context) {
|
||||
// Bill likes the colour red, everyone else likes blue.
|
||||
return obj.name === 'bill' ? 'RED' : 'BLUE';
|
||||
}
|
||||
},
|
||||
RootQuery: {
|
||||
people(obj, args, {loaders: {People}}) {
|
||||
return People.load();
|
||||
}
|
||||
},
|
||||
RootMutation: {
|
||||
createPerson(obj, {name}, {mutators: {People}}) {
|
||||
return People.create(name);
|
||||
}
|
||||
}
|
||||
},
|
||||
hooks: {
|
||||
RootMutation: {
|
||||
createPerson: {
|
||||
post: async (obj, args, {plugins: {Slack}}, info, person) => {
|
||||
if (!person) {
|
||||
return person;
|
||||
}
|
||||
|
||||
await Slack.notify(`A new person just was created with name ${person.name}`);
|
||||
|
||||
return person;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
setupFunctions: {
|
||||
leader: (options, args) => ({
|
||||
leader: {
|
||||
filter: (person) => person.place === 1
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
Our documentation for Talk has moved! Utilize [this hyperlink](https://coralproject.github.io/talk/plugins.html) via click or tap to navigate your browser to the new location!
|
||||
|
||||
@@ -7,7 +7,21 @@ endpoint when the server is running with built assets.
|
||||
|
||||
## Contributing to Talk
|
||||
|
||||
See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md)!
|
||||
See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
### General
|
||||
|
||||
See our [Talk Documentation & Guides](https://coralproject.github.io/talk/index.html).
|
||||
|
||||
### Plugins
|
||||
|
||||
See our guide to using and building [Talk Plugins](https://github.com/coralproject/talk/blob/master/PLUGINS.md).
|
||||
|
||||
### Recipes
|
||||
|
||||
Recipes are plugin templates provided by the Coral Core team. Developers can use these recipes to build their own plugins. You can find all the Talk recipes here: https://github.com/coralproject/talk-recipes/
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -40,6 +54,7 @@ sign and verify tokens via a `HS256` algorithm.
|
||||
- `TALK_RECAPTCHA_SECRET` (*required for reCAPTCHA support*) - server secret used for enabling reCAPTCHA powered logins. If not provided it will instead default to providing only a time based lockout.
|
||||
- `TALK_RECAPTCHA_PUBLIC` (*required for reCAPTCHA support*) - client secret used for enabling reCAPTCHA powered logins. If not provided it will instead default to providing only a time based lockout.
|
||||
- `TALK_PLUGINS_JSON` (_optional_) - used to specify the plugin config via the environment
|
||||
- `TALK_KEEP_ALIVE` (_optional_) - The keepalive timeout that should be used to send keep alive messages through the websocket to keep the socket alive. (Default `30s`)
|
||||
|
||||
Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for
|
||||
alternative methods of loading configuration during development.
|
||||
|
||||
@@ -9,12 +9,17 @@ const program = require('./commander');
|
||||
|
||||
program
|
||||
.command('serve', 'serve the application')
|
||||
.command('settings', 'interact with the application settings')
|
||||
.command('assets', 'interact with assets')
|
||||
.command('setup', 'setup the application')
|
||||
.command('jobs', 'work with the job queues')
|
||||
.command('token', 'work with the access tokens')
|
||||
.command('users', 'work with the application auth')
|
||||
.command('migration', 'provides utilities for migrating the database')
|
||||
.command('plugins', 'provides utilities for interacting with the plugin system')
|
||||
.command(
|
||||
'plugins',
|
||||
'provides utilities for interacting with the plugin system'
|
||||
)
|
||||
.parse(process.argv);
|
||||
|
||||
/**
|
||||
@@ -24,7 +29,10 @@ program
|
||||
* labled with the PID written out by the parent process.
|
||||
*/
|
||||
process.once('exit', () => {
|
||||
if ((program.runningCommand.killed === false) && (program.runningCommand.exitCode === null)) {
|
||||
if (
|
||||
program.runningCommand.killed === false &&
|
||||
program.runningCommand.exitCode === null
|
||||
) {
|
||||
program.runningCommand.kill('SIGINT');
|
||||
}
|
||||
});
|
||||
|
||||
+120
-1
@@ -8,13 +8,14 @@
|
||||
// https://yarnpkg.com/
|
||||
|
||||
const program = require('./commander');
|
||||
const inquirer = require('inquirer');
|
||||
|
||||
// Make things colorful!
|
||||
require('colors');
|
||||
const emoji = require('node-emoji');
|
||||
|
||||
const dir = process.cwd();
|
||||
const fs = require('fs');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const spawn = require('cross-spawn');
|
||||
const semver = require('semver');
|
||||
@@ -272,10 +273,128 @@ async function reconcilePluginDeps({skipLocal, skipRemote, dryRun, upgradeRemote
|
||||
console.log(`✨ Done in ${totalTime}s.`);
|
||||
}
|
||||
|
||||
async function createSeedPlugin() {
|
||||
const pluginsDir = path.join(__dirname, 'plugins');
|
||||
|
||||
function pluginNameExists(pluginName) {
|
||||
const pluginNames = fs.readdirSync(pluginsDir);
|
||||
return !!pluginNames
|
||||
.filter((pn) => pn === pluginName).length;
|
||||
}
|
||||
|
||||
let answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'pluginName',
|
||||
message: 'Plugin Name:',
|
||||
validate: (input) => {
|
||||
|
||||
if (pluginNameExists(input)) {
|
||||
return 'Please, choose another name. That name already exists';
|
||||
}
|
||||
|
||||
if (input && input.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Plugin Name is required.';
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'server',
|
||||
message: 'Is this plugin extending the server capabilities?'
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'client',
|
||||
message: 'Is this plugin extending the client capabilities?'
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'addPluginsJson',
|
||||
message: 'Should we add it to the plugins.json?'
|
||||
}
|
||||
]);
|
||||
|
||||
//==============================================================================
|
||||
// Creating plugin seed
|
||||
//==============================================================================
|
||||
|
||||
const seedPlugin = path.join(__dirname, 'bin/templates/plugin');
|
||||
const newPluginPath = path.join(pluginsDir, answers.pluginName);
|
||||
|
||||
if (fs.existsSync(seedPlugin)) {
|
||||
|
||||
if (answers.server && answers.client) {
|
||||
|
||||
// This is a server-side and client-side plugin!, let's copy the template
|
||||
fs.copySync(seedPlugin, newPluginPath);
|
||||
} else {
|
||||
|
||||
fs.copySync(seedPlugin, newPluginPath, {filter: (p) => {
|
||||
|
||||
// Allowing plugin folder and files with no subfolders
|
||||
const rootRx = /plugin$|plugin\/[^/]*(\.).{2,3}/igm;
|
||||
if (rootRx.test(p) && (fs.lstatSync(p).isDirectory() || fs.lstatSync(p).isFile())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's a client-side plugin, copying client folder
|
||||
if (answers.client) {
|
||||
return /client/.test(p);
|
||||
}
|
||||
|
||||
// If it's a server-side plugin, copying server folder
|
||||
if (answers.server) {
|
||||
return /server/.test(p);
|
||||
}
|
||||
|
||||
}});
|
||||
}
|
||||
|
||||
// Let's add this to the plugins.json
|
||||
if (answers.addPluginsJson) {
|
||||
const pluginsJson = path.join(dir, 'plugins.json');
|
||||
|
||||
fs.readJson(pluginsJson)
|
||||
.then((j) => {
|
||||
|
||||
// This is a client-side plugin, let's push this.
|
||||
if (answers.client) {
|
||||
j.client.push(answers.pluginName);
|
||||
|
||||
const output = JSON.stringify(j, null, 2);
|
||||
fs.writeFileSync(pluginsJson, output);
|
||||
}
|
||||
|
||||
// This is a server-side plugin, let's push this.
|
||||
if (answers.server) {
|
||||
j.server.push(answers.pluginName);
|
||||
|
||||
const output = JSON.stringify(j, null, 2);
|
||||
fs.writeFileSync(pluginsJson, output);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✨ Yay! Plugin created! Find your plugin: ${answers.pluginName} in the ./plugins folder`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.command('create')
|
||||
.description('creates a seed plugin')
|
||||
.action(createSeedPlugin);
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.description('')
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const program = require('./commander');
|
||||
const inquirer = require('inquirer');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const SettingsService = require('../services/settings');
|
||||
const util = require('./util');
|
||||
|
||||
// Register the shutdown criteria.
|
||||
util.onshutdown([() => mongoose.disconnect()]);
|
||||
|
||||
/**
|
||||
* Change the organization name
|
||||
*/
|
||||
async function changeOrgName() {
|
||||
try {
|
||||
let settings = await SettingsService.retrieve();
|
||||
|
||||
let {organizationName} = await inquirer.prompt([
|
||||
{
|
||||
name: 'organizationName',
|
||||
message: 'Organization Name',
|
||||
default: settings.organizationName
|
||||
}
|
||||
]);
|
||||
|
||||
if (settings.organizationName !== organizationName) {
|
||||
settings.organizationName = organizationName;
|
||||
|
||||
await SettingsService.update(settings);
|
||||
|
||||
console.log('Settings were updated.');
|
||||
} else {
|
||||
console.log('No update needed, no change was made.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
}
|
||||
|
||||
util.shutdown();
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.command('change-org-name')
|
||||
.description('change the organization name')
|
||||
.action(changeOrgName);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
// If there is no command listed, output help.
|
||||
if (!process.argv.slice(2).length) {
|
||||
program.outputHelp();
|
||||
util.shutdown();
|
||||
}
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('./commander');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const TokensService = require('../services/tokens');
|
||||
const util = require('./util');
|
||||
const Table = require('cli-table');
|
||||
|
||||
// Register the shutdown criteria.
|
||||
util.onshutdown([
|
||||
() => mongoose.disconnect()
|
||||
]);
|
||||
|
||||
async function listTokens(userID) {
|
||||
try {
|
||||
let tokens = await TokensService.list(userID);
|
||||
|
||||
let table = new Table({
|
||||
head: [
|
||||
'ID',
|
||||
'Name',
|
||||
'Status'
|
||||
]
|
||||
});
|
||||
|
||||
tokens.forEach((token) => {
|
||||
table.push([
|
||||
token.id,
|
||||
token.name,
|
||||
token.active ? 'Active' : 'Revoked'
|
||||
]);
|
||||
});
|
||||
|
||||
console.log(table.toString());
|
||||
|
||||
util.shutdown();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeToken(tokenID) {
|
||||
try {
|
||||
|
||||
await TokensService.revoke(null, tokenID);
|
||||
|
||||
console.log(`Revoked Token[${tokenID}]`);
|
||||
|
||||
util.shutdown();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function createToken(userID, tokenName) {
|
||||
try {
|
||||
|
||||
let {pat: {id}, jwt} = await TokensService.create(userID, tokenName);
|
||||
|
||||
console.log(`Created Token[${id}] for User[${userID}] = ${jwt}`);
|
||||
|
||||
util.shutdown();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.command('list <userID>')
|
||||
.description('list tokens for a user')
|
||||
.action(listTokens);
|
||||
|
||||
program
|
||||
.command('revoke <tokenID>')
|
||||
.description('revokes a token with a given id')
|
||||
.action(revokeToken);
|
||||
|
||||
program
|
||||
.command('create <userID> <tokenName>')
|
||||
.description('create a token for a user with a given name')
|
||||
.action(createToken);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
// If there is no command listed, output help.
|
||||
if (!process.argv.slice(2).length) {
|
||||
program.outputHelp();
|
||||
util.shutdown();
|
||||
}
|
||||
+7
-6
@@ -8,6 +8,7 @@ const program = require('./commander');
|
||||
const inquirer = require('inquirer');
|
||||
const UsersService = require('../services/users');
|
||||
const UserModel = require('../models/user');
|
||||
const USER_ROLES = require('../models/enum/user_roles');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const util = require('./util');
|
||||
const Table = require('cli-table');
|
||||
@@ -36,7 +37,7 @@ function getUserCreateAnswers(options) {
|
||||
roles: []
|
||||
};
|
||||
|
||||
if (options.role && UserModel.USER_ROLES.indexOf(options.role) > -1) {
|
||||
if (options.role && USER_ROLES.indexOf(options.role) > -1) {
|
||||
user.roles = [options.role];
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ function getUserCreateAnswers(options) {
|
||||
name: 'roles',
|
||||
message: 'User Role',
|
||||
type: 'checkbox',
|
||||
choices: UserModel.USER_ROLES
|
||||
choices: USER_ROLES
|
||||
}
|
||||
]);
|
||||
}
|
||||
@@ -291,8 +292,8 @@ function mergeUsers(dstUserID, srcUserID) {
|
||||
*/
|
||||
function addRole(userID, role) {
|
||||
|
||||
if (UserModel.USER_ROLES.indexOf(role) === -1) {
|
||||
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
|
||||
if (USER_ROLES.indexOf(role) === -1) {
|
||||
console.error(`Role '${role}' is not supported. Supported roles are ${USER_ROLES.join(', ')}.`);
|
||||
util.shutdown(1);
|
||||
return;
|
||||
}
|
||||
@@ -316,8 +317,8 @@ function addRole(userID, role) {
|
||||
*/
|
||||
function removeRole(userID, role) {
|
||||
|
||||
if (UserModel.USER_ROLES.indexOf(role) === -1) {
|
||||
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
|
||||
if (USER_ROLES.indexOf(role) === -1) {
|
||||
console.error(`Role '${role}' is not supported. Supported roles are ${USER_ROLES.join(', ')}.`);
|
||||
util.shutdown(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"presets": [
|
||||
"es2015"
|
||||
],
|
||||
"plugins": [
|
||||
"add-module-exports",
|
||||
"transform-class-properties",
|
||||
"transform-decorators-legacy",
|
||||
"transform-object-assign",
|
||||
"transform-object-rest-spread",
|
||||
"transform-async-to-generator",
|
||||
"transform-react-jsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"mocha": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"experimentalObjectRestSpread": true,
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"plugins": [
|
||||
"react"
|
||||
],
|
||||
"rules": {
|
||||
"react/jsx-uses-react": "error",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.myPluginContainer {
|
||||
padding: 10px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #d6d6d6;
|
||||
margin: 10px 0;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: block;
|
||||
animation: spin 2s infinite ease;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #444444;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import {CoralLogo} from 'plugin-api/beta/client/components/ui';
|
||||
import styles from './MyPluginComponent.css';
|
||||
|
||||
class MyPluginComponent extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.myPluginContainer}>
|
||||
<CoralLogo className={styles.logo}/>
|
||||
<div className={styles.description}>
|
||||
<h3>Plugin created by Talk CLI</h3>
|
||||
|
||||
<small>
|
||||
To read more about plugins check{' '}
|
||||
<a href="https://coralproject.github.io/talk/plugins-client.html">
|
||||
our docs and guides!
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MyPluginComponent;
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
/**
|
||||
This is a client index example file and it could look like this:
|
||||
|
||||
```
|
||||
import LoveButton from './components/LoveButton';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
commentReactions: [LoveButton]
|
||||
},
|
||||
reducer,
|
||||
translations
|
||||
};
|
||||
```
|
||||
|
||||
To read more info on how to build client plugins. Please, go to: https://coralproject.github.io/talk/plugins-client.html
|
||||
*/
|
||||
|
||||
import MyPluginComponent from './components/MyPluginComponent';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
stream: [MyPluginComponent]
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
#
|
||||
# This file is for translations and they should look like this:
|
||||
#
|
||||
#
|
||||
# ```
|
||||
# en:
|
||||
# coral-plugin-respect:
|
||||
# respect: Respect
|
||||
# respected: Respected
|
||||
# es:
|
||||
# coral-plugin-respect:
|
||||
# respect: Respetar
|
||||
# respected: Respetado
|
||||
# ```
|
||||
#
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = {};
|
||||
@@ -52,5 +52,6 @@ deployment:
|
||||
|
||||
latest:
|
||||
branch: master
|
||||
owner: coralproject
|
||||
commands:
|
||||
- bash ./scripts/docker.sh deploy
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import {Router, Route, IndexRedirect, browserHistory} from 'react-router';
|
||||
import {Router, Route, IndexRedirect, browserHistory, Redirect} from 'react-router';
|
||||
|
||||
import Configure from 'routes/Configure';
|
||||
import Dashboard from 'routes/Dashboard';
|
||||
@@ -49,8 +49,8 @@ const routes = (
|
||||
<Route path='flagged' components={Moderation}>
|
||||
<Route path=':id' components={Moderation} />
|
||||
</Route>
|
||||
<Route path=':id' components={Moderation} />
|
||||
<IndexRedirect to='premod' />
|
||||
<Redirect from=':id' to='all/:id' />
|
||||
<IndexRedirect to='all' />
|
||||
</Route>
|
||||
</Route>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../constants/assets';
|
||||
|
||||
import coralApi from '../../../coral-framework/helpers/request';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
/**
|
||||
* Action disptacher related to assets
|
||||
@@ -19,12 +20,16 @@ import coralApi from '../../../coral-framework/helpers/request';
|
||||
export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch) => {
|
||||
dispatch({type: FETCH_ASSETS_REQUEST});
|
||||
return coralApi(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`)
|
||||
.then(({result, count}) =>
|
||||
dispatch({type: FETCH_ASSETS_SUCCESS,
|
||||
assets: result,
|
||||
count
|
||||
}))
|
||||
.catch((error) => dispatch({type: FETCH_ASSETS_FAILURE, error}));
|
||||
.then(({result, count}) =>
|
||||
dispatch({type: FETCH_ASSETS_SUCCESS,
|
||||
assets: result,
|
||||
count
|
||||
}))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch({type: FETCH_ASSETS_FAILURE, error: errorMessage});
|
||||
});
|
||||
};
|
||||
|
||||
// Update an asset state
|
||||
@@ -32,9 +37,12 @@ export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filte
|
||||
export const updateAssetState = (id, closedAt) => (dispatch) => {
|
||||
dispatch({type: UPDATE_ASSET_STATE_REQUEST});
|
||||
return coralApi(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}})
|
||||
.then(() =>
|
||||
dispatch({type: UPDATE_ASSET_STATE_SUCCESS}))
|
||||
.catch((error) => dispatch({type: UPDATE_ASSET_STATE_FAILURE, error}));
|
||||
.then(() => dispatch({type: UPDATE_ASSET_STATE_SUCCESS}))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch({type: UPDATE_ASSET_STATE_FAILURE, error: errorMessage});
|
||||
});
|
||||
};
|
||||
|
||||
export const updateAssets = (assets) => (dispatch) => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as actions from '../constants/auth';
|
||||
import coralApi from 'coral-framework/helpers/request';
|
||||
import * as Storage from 'coral-framework/helpers/storage';
|
||||
import {handleAuthToken} from 'coral-framework/actions/auth';
|
||||
import {resetWebsocket} from 'coral-framework/services/client';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
//==============================================================================
|
||||
// SIGN IN
|
||||
@@ -36,16 +38,31 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) =>
|
||||
}
|
||||
|
||||
dispatch(handleAuthToken(token));
|
||||
resetWebsocket();
|
||||
dispatch(checkLoginSuccess(user));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
|
||||
if (error.translation_key === 'NOT_AUTHORIZED') {
|
||||
|
||||
// invalid credentials
|
||||
dispatch({
|
||||
type: actions.LOGIN_FAILURE,
|
||||
message: t('error.email_password')
|
||||
});
|
||||
}
|
||||
else if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') {
|
||||
dispatch({
|
||||
type: actions.LOGIN_MAXIMUM_EXCEEDED,
|
||||
message: error.translation_key
|
||||
message: t(`error.${error.translation_key}`),
|
||||
});
|
||||
} else {
|
||||
dispatch({type: actions.LOGIN_FAILURE, message: error.translation_key});
|
||||
dispatch({
|
||||
type: actions.LOGIN_FAILURE,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -54,25 +71,30 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) =>
|
||||
// FORGOT PASSWORD
|
||||
//==============================================================================
|
||||
|
||||
const forgotPassowordRequest = () => ({
|
||||
const forgotPasswordRequest = () => ({
|
||||
type: actions.FETCH_FORGOT_PASSWORD_REQUEST
|
||||
});
|
||||
|
||||
const forgotPassowordSuccess = () => ({
|
||||
const forgotPasswordSuccess = () => ({
|
||||
type: actions.FETCH_FORGOT_PASSWORD_SUCCESS
|
||||
});
|
||||
|
||||
const forgotPassowordFailure = () => ({
|
||||
type: actions.FETCH_FORGOT_PASSWORD_FAILURE
|
||||
const forgotPasswordFailure = (error) => ({
|
||||
type: actions.FETCH_FORGOT_PASSWORD_FAILURE,
|
||||
error,
|
||||
});
|
||||
|
||||
export const requestPasswordReset = (email) => (dispatch) => {
|
||||
dispatch(forgotPassowordRequest(email));
|
||||
dispatch(forgotPasswordRequest(email));
|
||||
const redirectUri = location.href;
|
||||
|
||||
return coralApi('/account/password/reset', {method: 'POST', body: {email, loc: redirectUri}})
|
||||
.then(() => dispatch(forgotPassowordSuccess()))
|
||||
.catch((error) => dispatch(forgotPassowordFailure(error)));
|
||||
.then(() => dispatch(forgotPasswordSuccess()))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch(forgotPasswordFailure(errorMessage));
|
||||
});
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
@@ -105,10 +127,12 @@ export const checkLogin = () => (dispatch) => {
|
||||
return dispatch(checkLoginFailure('not logged in'));
|
||||
}
|
||||
|
||||
resetWebsocket();
|
||||
dispatch(checkLoginSuccess(user));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
dispatch(checkLoginFailure(`${error.translation_key}`));
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch(checkLoginFailure(errorMessage));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import {SHOW_BAN_USER_DIALOG, HIDE_BAN_USER_DIALOG} from '../constants/banUserDialog';
|
||||
|
||||
export const showBanUserDialog = ({userId, username, commentId, commentStatus}) =>
|
||||
({type: SHOW_BAN_USER_DIALOG, userId, username, commentId, commentStatus});
|
||||
|
||||
export const hideBanUserDialog = () => ({type: HIDE_BAN_USER_DIALOG});
|
||||
|
||||
@@ -10,11 +10,12 @@ import {
|
||||
SET_COMMENTER_STATUS,
|
||||
SHOW_BANUSER_DIALOG,
|
||||
HIDE_BANUSER_DIALOG,
|
||||
SHOW_SUSPENDUSER_DIALOG,
|
||||
HIDE_SUSPENDUSER_DIALOG
|
||||
SHOW_REJECT_USERNAME_DIALOG,
|
||||
HIDE_REJECT_USERNAME_DIALOG
|
||||
} from '../constants/community';
|
||||
|
||||
import coralApi from '../../../coral-framework/helpers/request';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export const fetchAccounts = (query = {}) => (dispatch) => {
|
||||
|
||||
@@ -30,7 +31,11 @@ export const fetchAccounts = (query = {}) => (dispatch) => {
|
||||
totalPages
|
||||
});
|
||||
})
|
||||
.catch((error) => dispatch({type: FETCH_COMMENTERS_FAILURE, error}));
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch({type: FETCH_COMMENTERS_FAILURE, error: errorMessage});
|
||||
});
|
||||
};
|
||||
|
||||
const requestFetchAccounts = () => ({
|
||||
@@ -47,7 +52,6 @@ export const newPage = () => ({
|
||||
});
|
||||
|
||||
export const setRole = (id, role) => (dispatch) => {
|
||||
|
||||
return coralApi(`/users/${id}/role`, {method: 'POST', body: {role}})
|
||||
.then(() => {
|
||||
return dispatch({type: SET_ROLE, id, role});
|
||||
@@ -65,6 +69,6 @@ export const setCommenterStatus = (id, status) => (dispatch) => {
|
||||
export const showBanUserDialog = (user) => ({type: SHOW_BANUSER_DIALOG, user});
|
||||
export const hideBanUserDialog = () => ({type: HIDE_BANUSER_DIALOG});
|
||||
|
||||
// Suspend User Dialog
|
||||
export const showSuspendUserDialog = (user) => ({type: SHOW_SUSPENDUSER_DIALOG, user});
|
||||
export const hideSuspendUserDialog = () => ({type: HIDE_SUSPENDUSER_DIALOG});
|
||||
// Reject Username Dialog
|
||||
export const showRejectUsernameDialog = (user) => ({type: SHOW_REJECT_USERNAME_DIALOG, user});
|
||||
export const hideRejectUsernameDialog = () => ({type: HIDE_REJECT_USERNAME_DIALOG});
|
||||
|
||||
@@ -2,6 +2,7 @@ import coralApi from 'coral-framework/helpers/request';
|
||||
import * as actions from '../constants/install';
|
||||
import validate from 'coral-framework/helpers/validate';
|
||||
import errorMsj from 'coral-framework/helpers/error';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export const nextStep = () => ({type: actions.NEXT_STEP});
|
||||
export const previousStep = () => ({type: actions.PREVIOUS_STEP});
|
||||
@@ -17,7 +18,8 @@ const clearErrors = () => ({type: actions.CLEAR_ERRORS});
|
||||
|
||||
const validation = (formData, dispatch, next) => {
|
||||
if (!(formData != null)) {
|
||||
return dispatch(hasError());
|
||||
dispatch(hasError());
|
||||
return;
|
||||
}
|
||||
|
||||
const validKeys = Object.keys(formData)
|
||||
@@ -40,7 +42,8 @@ const validation = (formData, dispatch, next) => {
|
||||
});
|
||||
|
||||
if (empty.length) {
|
||||
return dispatch(hasError());
|
||||
dispatch(hasError());
|
||||
return;
|
||||
}
|
||||
|
||||
// RegExp Validation
|
||||
@@ -59,7 +62,30 @@ const validation = (formData, dispatch, next) => {
|
||||
});
|
||||
|
||||
if (validation.length) {
|
||||
return dispatch(hasError());
|
||||
dispatch(hasError());
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm Validation
|
||||
const prefixLength = 'confirm'.length;
|
||||
const confirm = validKeys
|
||||
.filter((name) => {
|
||||
if (!name.startsWith('confirm')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that 'confirmX' equals 'X'.
|
||||
const other = name.substr(prefixLength, 1).toLowerCase() + name.substr(prefixLength + 1);
|
||||
const cond = formData[other] !== formData[name];
|
||||
if (cond) {
|
||||
dispatch(addError(name, errorMsj[name]));
|
||||
}
|
||||
return cond;
|
||||
});
|
||||
|
||||
if (confirm.length) {
|
||||
dispatch(hasError());
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(clearErrors());
|
||||
@@ -90,7 +116,8 @@ export const finishInstall = () => (dispatch, getState) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
dispatch(installFailure(`${error.translation_key}`));
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch(installFailure(errorMessage));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -113,6 +140,7 @@ export const checkInstall = (next) => (dispatch) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
dispatch(checkInstallFailure(`${error.translation_key}`));
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch(checkInstallFailure(errorMessage));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,16 +3,6 @@ import * as actions from 'constants/moderation';
|
||||
export const toggleModal = (open) => ({type: actions.TOGGLE_MODAL, open});
|
||||
export const singleView = () => ({type: actions.SINGLE_VIEW});
|
||||
|
||||
// Ban User Dialog
|
||||
export const showBanUserDialog = (user, commentId, commentStatus, showRejectedNote) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId, commentStatus, showRejectedNote});
|
||||
export const hideBanUserDialog = () => ({type: actions.HIDE_BANUSER_DIALOG});
|
||||
|
||||
// Suspend User Dialog
|
||||
export const showSuspendUserDialog = (userId, username, commentId, commentStatus) =>
|
||||
({type: actions.SHOW_SUSPEND_USER_DIALOG, userId, username, commentId, commentStatus});
|
||||
|
||||
export const hideSuspendUserDialog = () => ({type: actions.HIDE_SUSPEND_USER_DIALOG});
|
||||
|
||||
// hide shortcuts note
|
||||
export const hideShortcutsNote = () => {
|
||||
try {
|
||||
@@ -51,3 +41,16 @@ export const toggleSelectCommentInUserDetail = (id, active) => {
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export const toggleStorySearch = (active) => ({
|
||||
type: active ? actions.SHOW_STORY_SEARCH : actions.HIDE_STORY_SEARCH
|
||||
});
|
||||
|
||||
export const storySearchChange = (value) => ({
|
||||
type: actions.STORY_SEARCH_CHANGE_VALUE,
|
||||
value
|
||||
});
|
||||
|
||||
export const clearState = () => ({
|
||||
type: actions.MODERATION_CLEAR_STATE
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import coralApi from '../../../coral-framework/helpers/request';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export const SETTINGS_LOADING = 'SETTINGS_LOADING';
|
||||
export const SETTINGS_RECEIVED = 'SETTINGS_RECEIVED';
|
||||
@@ -20,7 +21,9 @@ export const fetchSettings = () => (dispatch) => {
|
||||
dispatch({type: SETTINGS_RECEIVED, settings});
|
||||
})
|
||||
.catch((error) => {
|
||||
dispatch({type: SETTINGS_FETCH_ERROR, error});
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch({type: SETTINGS_FETCH_ERROR, error: errorMessage});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -49,6 +52,8 @@ export const saveSettingsToServer = () => (dispatch, getState) => {
|
||||
dispatch({type: SAVE_SETTINGS_SUCCESS, settings});
|
||||
})
|
||||
.catch((error) => {
|
||||
dispatch({type: SAVE_SETTINGS_FAILED, error});
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch({type: SAVE_SETTINGS_FAILED, error: errorMessage});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import {SHOW_SUSPEND_USER_DIALOG, HIDE_SUSPEND_USER_DIALOG} from '../constants/suspendUserDialog.js';
|
||||
|
||||
export const showSuspendUserDialog = ({userId, username, commentId, commentStatus}) =>
|
||||
({type: SHOW_SUSPEND_USER_DIALOG, userId, username, commentId, commentStatus});
|
||||
|
||||
export const hideSuspendUserDialog = () => ({type: HIDE_SUSPEND_USER_DIALOG});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import coralApi from '../../../coral-framework/helpers/request';
|
||||
import * as userTypes from '../constants/users';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
/**
|
||||
* Action disptacher related to users
|
||||
@@ -10,7 +11,11 @@ export const userStatusUpdate = (status, userId, commentId) => {
|
||||
dispatch({type: userTypes.UPDATE_STATUS_REQUEST});
|
||||
return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
|
||||
.then((res) => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res}))
|
||||
.catch((error) => dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error}));
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error: errorMessage});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,7 +23,11 @@ export const userStatusUpdate = (status, userId, commentId) => {
|
||||
export const sendNotificationEmail = (userId, subject, body) => {
|
||||
return (dispatch) => {
|
||||
return coralApi(`/users/${userId}/email`, {method: 'POST', body: {subject, body}})
|
||||
.catch((error) => dispatch({type: userTypes.USER_EMAIL_FAILURE, error}));
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch({type: userTypes.USER_EMAIL_FAILURE, error: errorMessage});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,6 +35,10 @@ export const sendNotificationEmail = (userId, subject, body) => {
|
||||
export const enableUsernameEdit = (userId) => {
|
||||
return (dispatch) => {
|
||||
return coralApi(`/users/${userId}/username-enable`, {method: 'POST'})
|
||||
.catch((error) => dispatch({type: userTypes.USERNAME_ENABLE_FAILURE, error}));
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch({type: userTypes.USERNAME_ENABLE_FAILURE, error: errorMessage});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, {PropTypes} from 'react';
|
||||
import Layout from 'coral-admin/src/components/ui/Layout';
|
||||
import styles from './NotFound.css';
|
||||
import {Button, TextField, Alert, Success} from 'coral-ui';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import Recaptcha from 'react-recaptcha';
|
||||
|
||||
class AdminLogin extends React.Component {
|
||||
@@ -35,7 +34,7 @@ class AdminLogin extends React.Component {
|
||||
const {errorMessage, loginMaxExceeded, recaptchaPublic} = this.props;
|
||||
const signInForm = (
|
||||
<form onSubmit={this.handleSignIn}>
|
||||
{errorMessage && <Alert>{t(`error.${errorMessage}`)}</Alert>}
|
||||
{errorMessage && <Alert>{errorMessage}</Alert>}
|
||||
<TextField
|
||||
label='Email Address'
|
||||
value={this.state.email}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Dialog} from 'coral-ui';
|
||||
import styles from './BanUserDialog.css';
|
||||
|
||||
import Button from 'coral-ui/components/Button';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const BanUserDialog = ({open, onCancel, onPerform, username, info}) => (
|
||||
<Dialog
|
||||
className={styles.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>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
BanUserDialog.propTypes = {
|
||||
open: PropTypes.bool,
|
||||
onPerform: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
username: PropTypes.string,
|
||||
info: PropTypes.string,
|
||||
};
|
||||
|
||||
export default BanUserDialog;
|
||||
@@ -16,8 +16,8 @@ const shortcuts = [
|
||||
{
|
||||
title: 'modqueue.actions',
|
||||
shortcuts: {
|
||||
't': 'modqueue.approve',
|
||||
'r': 'modqueue.reject'
|
||||
'd': 'modqueue.approve',
|
||||
'f': 'modqueue.reject'
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -40,8 +40,8 @@ export default class ModerationKeysModal extends React.Component {
|
||||
<p className={styles.ctaHeader}>{t('modqueue.mod_faster')}</p>
|
||||
<p><strong>{t('modqueue.try_these')}:</strong></p>
|
||||
<ul>
|
||||
<li><span>{t('modqueue.approve')}</span> <span className={styles.smallKey}>t</span></li>
|
||||
<li><span>{t('modqueue.reject')}</span> <span className={styles.smallKey}>r</span></li>
|
||||
<li><span>{t('modqueue.approve')}</span> <span className={styles.smallKey}>d</span></li>
|
||||
<li><span>{t('modqueue.reject')}</span> <span className={styles.smallKey}>f</span></li>
|
||||
</ul>
|
||||
<p><span>{t('modqueue.view_more_shortcuts')}</span> <span className={styles.smallKey}>{t('modqueue.shift_key')}</span> + <span className={styles.smallKey}>/</span></p>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,8 @@ import SuspendUserModal from './SuspendUserModal';
|
||||
|
||||
// Each action has different meaning and configuration
|
||||
const menuOptionsMap = {
|
||||
'reject': {status: 'REJECTED', icon: 'close', key: 'r'},
|
||||
'approve': {status: 'ACCEPTED', icon: 'done', key: 't'},
|
||||
'reject': {status: 'REJECTED', icon: 'close', key: 'f'},
|
||||
'approve': {status: 'ACCEPTED', icon: 'done', key: 'd'},
|
||||
'flag': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'},
|
||||
'ban': {status: 'BANNED', icon: 'not interested'}
|
||||
};
|
||||
|
||||
+2
-4
@@ -49,7 +49,6 @@ class SuspendUserDialog extends React.Component {
|
||||
handlePerform = () => {
|
||||
|
||||
this.props.onPerform({
|
||||
id: this.props.userId,
|
||||
message: this.state.message,
|
||||
|
||||
// Add 1 minute more to help `timeago.js` to display the correct duration.
|
||||
@@ -153,11 +152,10 @@ class SuspendUserDialog extends React.Component {
|
||||
|
||||
SuspendUserDialog.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
onPerform: PropTypes.func.isRequired,
|
||||
username: PropTypes.string,
|
||||
userId: PropTypes.string,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
organizationName: PropTypes.string,
|
||||
username: PropTypes.string,
|
||||
};
|
||||
|
||||
export default SuspendUserDialog;
|
||||
@@ -0,0 +1,51 @@
|
||||
.root {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
overflow: hidden;
|
||||
padding-left: 5px;
|
||||
padding-top: 5px;
|
||||
|
||||
}
|
||||
|
||||
.rootFocuesd {
|
||||
border-color: rgb(142, 76, 65);
|
||||
}
|
||||
|
||||
.tag {
|
||||
background-color: rgb(255, 220, 214);
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgb(244, 126, 107);
|
||||
color: rgb(244, 126, 107);
|
||||
display: inline-block;
|
||||
font-family: sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 5px;
|
||||
margin-right: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.tagRemove {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
color: rgb(101, 24, 23);
|
||||
|
||||
&::before {
|
||||
content: " ×";
|
||||
}
|
||||
}
|
||||
|
||||
.input > input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #777;
|
||||
font-family: sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 6px;
|
||||
margin-top: 1px;
|
||||
outline: none;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
import styles from './TagsInput.css';
|
||||
import AutosizeInput from 'react-input-autosize';
|
||||
|
||||
const autosizingRenderInput = ({onChange, value, addTag: _, ...other}) =>
|
||||
<AutosizeInput type='text' onChange={onChange} value={value} {...other} />;
|
||||
|
||||
export default (props) => {
|
||||
return (
|
||||
<TagsInput
|
||||
addOnBlur={true}
|
||||
addOnPaste={true}
|
||||
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
|
||||
className={styles.root}
|
||||
focusedClassName={styles.rootFocus}
|
||||
renderInput={autosizingRenderInput}
|
||||
{...props}
|
||||
|
||||
tagProps={{
|
||||
className: styles.tag,
|
||||
classNameRemove: styles.tagRemove,
|
||||
...props.tagProps,
|
||||
}}
|
||||
inputProps={{
|
||||
className: styles.input,
|
||||
...props.inputProps,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -133,7 +133,7 @@
|
||||
animation-fill-mode: both; }
|
||||
|
||||
.toastify {
|
||||
z-index: 999;
|
||||
z-index: 99999;
|
||||
position: fixed;
|
||||
padding: 4px;
|
||||
width: 350px;
|
||||
@@ -197,7 +197,7 @@
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1), 0 3px 20px 0 rgba(0, 0, 0, 0.05); }
|
||||
.toastify-content--info {
|
||||
background: #2488cb; }
|
||||
background: #404040; }
|
||||
.toastify-content--success {
|
||||
background: #008577; }
|
||||
.toastify-content--warning {
|
||||
@@ -207,6 +207,7 @@
|
||||
|
||||
.toastify__body {
|
||||
color: white;
|
||||
overflow-x: scroll;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@@ -40,12 +40,12 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rightPanel ul {
|
||||
.rightPanel > ul {
|
||||
list-style: none;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.rightPanel li {
|
||||
.rightPanel > ul > li {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
margin-left: 15px;
|
||||
@@ -54,6 +54,12 @@
|
||||
line-height: 33px;
|
||||
}
|
||||
|
||||
.rightPanel a {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.rightPanel .settings {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
@@ -71,12 +71,24 @@ const CoralHeader = ({
|
||||
<IconButton name="settings" id="menu-settings"/>
|
||||
<Menu target="menu-settings" align="right">
|
||||
<MenuItem onClick={() => showShortcuts(true)}>{t('configure.shortcuts')}</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>{t('configure.sign_out')}</MenuItem>
|
||||
<MenuItem>
|
||||
<a href="https://github.com/coralproject/talk/releases" target="_blank">
|
||||
View latest version
|
||||
</a>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<a href="https://coralproject.net/contribute.html#other-ideas-and-bug-reports" target="_blank">
|
||||
Report a bug or give feedback
|
||||
</a>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>
|
||||
{t('configure.sign_out')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
{`v${process.env.VERSION}`}
|
||||
<li>
|
||||
{`v${process.env.VERSION}`}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.layout {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
background-color: #FAFAFA;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const Layout = ({
|
||||
toggleShortcutModal,
|
||||
restricted = false,
|
||||
...props}) => (
|
||||
<LayoutMDL fixedDrawer>
|
||||
<LayoutMDL className={styles.layout} fixedDrawer>
|
||||
<Header
|
||||
handleLogout={handleLogout}
|
||||
showShortcuts={toggleShortcutModal}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const SHOW_BAN_USER_DIALOG = 'SHOW_BAN_USER_DIALOG';
|
||||
export const HIDE_BAN_USER_DIALOG = 'HIDE_BAN_USER_DIALOG';
|
||||
@@ -13,5 +13,5 @@ export const FETCH_FLAGGED_COMMENTERS_FAILURE = 'FETCH_FLAGGED_COMMENTERS_FAILUR
|
||||
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
|
||||
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
|
||||
|
||||
export const SHOW_SUSPENDUSER_DIALOG = 'SHOW_SUSPENDUSER_DIALOG';
|
||||
export const HIDE_SUSPENDUSER_DIALOG = 'HIDE_SUSPENDUSER_DIALOG';
|
||||
export const SHOW_REJECT_USERNAME_DIALOG = 'SHOW_REJECT_USERNAME_DIALOG';
|
||||
export const HIDE_REJECT_USERNAME_DIALOG = 'HIDE_REJECT_USERNAME_DIALOG';
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
export const TOGGLE_MODAL = 'TOGGLE_MODAL';
|
||||
export const SINGLE_VIEW = 'SINGLE_VIEW';
|
||||
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
|
||||
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
|
||||
export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE';
|
||||
export const SHOW_SUSPEND_USER_DIALOG = 'SHOW_SUSPEND_USER_DIALOG';
|
||||
export const HIDE_SUSPEND_USER_DIALOG = 'HIDE_SUSPEND_USER_DIALOG';
|
||||
export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL';
|
||||
export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL';
|
||||
export const SET_SORT_ORDER = 'MODERATION_SET_SORT_ORDER';
|
||||
@@ -12,3 +8,7 @@ export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES';
|
||||
export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT';
|
||||
export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT';
|
||||
export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS';
|
||||
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';
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const SHOW_SUSPEND_USER_DIALOG = 'SHOW_SUSPEND_USER_DIALOG';
|
||||
export const HIDE_SUSPEND_USER_DIALOG = 'HIDE_SUSPEND_USER_DIALOG';
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import BanUserDialog from '../components/BanUserDialog';
|
||||
import {hideBanUserDialog} from '../actions/banUserDialog';
|
||||
import {withSetUserStatus, withSetCommentStatus} from 'coral-framework/graphql/mutations';
|
||||
import {compose} from 'react-apollo';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
class BanUserDialogContainer extends Component {
|
||||
|
||||
banUser = async () => {
|
||||
const {userId, commentId, commentStatus, setUserStatus, setCommentStatus, hideBanUserDialog} = this.props;
|
||||
await setUserStatus({userId, status: 'BANNED'});
|
||||
hideBanUserDialog();
|
||||
if (commentId && commentStatus && commentStatus !== 'REJECTED') {
|
||||
await setCommentStatus({commentId, status: 'REJECTED'});
|
||||
}
|
||||
}
|
||||
|
||||
getInfo() {
|
||||
let note = t('bandialog.note_ban_user');
|
||||
if (this.props.commentStatus && this.props.commentStatus !== 'REJECTED') {
|
||||
note = t('bandialog.note_reject_comment');
|
||||
}
|
||||
return t('bandialog.note', note);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<BanUserDialog
|
||||
open={this.props.open}
|
||||
onPerform={this.banUser}
|
||||
onCancel={this.props.hideBanUserDialog}
|
||||
username={this.props.username}
|
||||
info={this.getInfo()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({banUserDialog: {open, userId, username, commentId, commentStatus}}) => ({
|
||||
open,
|
||||
userId,
|
||||
username,
|
||||
commentId,
|
||||
commentStatus,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
...bindActionCreators({
|
||||
hideBanUserDialog,
|
||||
}, dispatch),
|
||||
});
|
||||
|
||||
export default compose(
|
||||
withSetUserStatus,
|
||||
withSetCommentStatus,
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
),
|
||||
)(BanUserDialogContainer);
|
||||
@@ -5,6 +5,8 @@ import {fetchConfig} from '../actions/config';
|
||||
import AdminLogin from '../components/AdminLogin';
|
||||
import {logout} from 'coral-framework/actions/auth';
|
||||
import {FullLoading} from '../components/FullLoading';
|
||||
import BanUserDialog from './BanUserDialog';
|
||||
import SuspendUserDialog from './SuspendUserDialog';
|
||||
import {toggleModal as toggleShortcutModal} from '../actions/moderation';
|
||||
import {checkLogin, handleLogin, requestPasswordReset} from '../actions/auth';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
@@ -52,7 +54,11 @@ class LayoutContainer extends Component {
|
||||
handleLogout={handleLogout}
|
||||
toggleShortcutModal={toggleShortcutModal}
|
||||
{...this.props}
|
||||
/>
|
||||
>
|
||||
<BanUserDialog />
|
||||
<SuspendUserDialog />
|
||||
{this.props.children}
|
||||
</Layout>
|
||||
);
|
||||
} else if (loggedIn) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import SuspendUserDialog from '../components/SuspendUserDialog';
|
||||
import {hideSuspendUserDialog} from '../actions/suspendUserDialog';
|
||||
import {withSetCommentStatus, withSuspendUser} from 'coral-framework/graphql/mutations';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import * as notification from 'coral-admin/src/services/notification';
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
import get from 'lodash/get';
|
||||
|
||||
class SuspendUserDialogContainer extends Component {
|
||||
|
||||
suspendUser = async ({message, until}) => {
|
||||
const {userId, username, commentStatus, commentId, hideSuspendUserDialog, setCommentStatus, suspendUser} = this.props;
|
||||
hideSuspendUserDialog();
|
||||
try {
|
||||
const result = await suspendUser({id: userId, message, until});
|
||||
if (result.data.suspendUser.errors) {
|
||||
throw result.data.suspendUser.errors;
|
||||
}
|
||||
notification.success(
|
||||
t('suspenduser.notify_suspend_until', username, timeago(until)),
|
||||
);
|
||||
if (commentId && commentStatus && commentStatus !== 'REJECTED') {
|
||||
return setCommentStatus({commentId, status: 'REJECTED'})
|
||||
.then((result) => {
|
||||
if (result.data.setCommentStatus.errors) {
|
||||
throw result.data.setCommentStatus.errors;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
catch(err) {
|
||||
notification.showMutationErrors(err);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SuspendUserDialog
|
||||
open={this.props.open}
|
||||
onPerform={this.suspendUser}
|
||||
onCancel={this.props.hideSuspendUserDialog}
|
||||
organizationName={get(this.props, 'root.settings.organizationName')}
|
||||
username={this.props.username}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const withOrganizationName = withQuery(gql`
|
||||
query CoralAdmin_SuspendUserDialog {
|
||||
settings {
|
||||
organizationName
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const mapStateToProps = ({suspendUserDialog: {open, userId, username, commentId, commentStatus}}) => ({
|
||||
open,
|
||||
userId,
|
||||
username,
|
||||
commentId,
|
||||
commentStatus,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
...bindActionCreators({
|
||||
hideSuspendUserDialog,
|
||||
}, dispatch),
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
),
|
||||
withSuspendUser,
|
||||
withSetCommentStatus,
|
||||
withOrganizationName,
|
||||
)(SuspendUserDialogContainer);
|
||||
@@ -1,6 +1,4 @@
|
||||
import {add} from 'coral-framework/services/graphqlRegistry';
|
||||
import update from 'immutability-helper';
|
||||
const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected'];
|
||||
|
||||
const extension = {
|
||||
mutations: {
|
||||
@@ -10,58 +8,6 @@ const extension = {
|
||||
RejectUsername: () => ({
|
||||
refetchQueries: ['CoralAdmin_Community'],
|
||||
}),
|
||||
SetCommentStatus: ({variables: {commentId, status}}) => ({
|
||||
updateQueries: {
|
||||
CoralAdmin_Moderation: (prev) => {
|
||||
const comment = queues.reduce((comment, queue) => {
|
||||
return comment ? comment : prev[queue].nodes.find((c) => c.id === commentId);
|
||||
}, null);
|
||||
|
||||
let acceptedNodes = prev.accepted.nodes;
|
||||
let acceptedCount = prev.acceptedCount;
|
||||
let rejectedNodes = prev.rejected.nodes;
|
||||
let rejectedCount = prev.rejectedCount;
|
||||
|
||||
if (status !== comment.status) {
|
||||
if (status === 'ACCEPTED') {
|
||||
comment.status = 'ACCEPTED';
|
||||
acceptedCount++;
|
||||
acceptedNodes = [comment, ...acceptedNodes];
|
||||
}
|
||||
else if (status === 'REJECTED') {
|
||||
comment.status = 'REJECTED';
|
||||
rejectedCount++;
|
||||
rejectedNodes = [comment, ...rejectedNodes];
|
||||
}
|
||||
}
|
||||
|
||||
const premodNodes = prev.premod.nodes.filter((c) => c.id !== commentId);
|
||||
const flaggedNodes = prev.flagged.nodes.filter((c) => c.id !== commentId);
|
||||
const premodCount = premodNodes.length < prev.premod.nodes.length ? prev.premodCount - 1 : prev.premodCount;
|
||||
const flaggedCount = flaggedNodes.length < prev.flagged.nodes.length ? prev.flaggedCount - 1 : prev.flaggedCount;
|
||||
|
||||
if (status === 'REJECTED') {
|
||||
acceptedNodes = prev.accepted.nodes.filter((c) => c.id !== commentId);
|
||||
acceptedCount = acceptedNodes.length < prev.accepted.nodes.length ? prev.acceptedCount - 1 : prev.acceptedCount;
|
||||
}
|
||||
else if (status === 'ACCEPTED') {
|
||||
rejectedNodes = prev.rejected.nodes.filter((c) => c.id !== commentId);
|
||||
rejectedCount = rejectedNodes.length < prev.rejected.nodes.length ? prev.rejectedCount - 1 : prev.rejectedCount;
|
||||
}
|
||||
|
||||
return update(prev, {
|
||||
premodCount: {$set: Math.max(0, premodCount)},
|
||||
flaggedCount: {$set: Math.max(0, flaggedCount)},
|
||||
acceptedCount: {$set: Math.max(0, acceptedCount)},
|
||||
rejectedCount: {$set: Math.max(0, rejectedCount)},
|
||||
premod: {nodes: {$set: premodNodes}},
|
||||
flagged: {nodes: {$set: flaggedNodes}},
|
||||
accepted: {nodes: {$set: acceptedNodes}},
|
||||
rejected: {nodes: {$set: rejectedNodes}},
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import update from 'immutability-helper';
|
||||
import * as notification from 'coral-admin/src/services/notification';
|
||||
|
||||
const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected'];
|
||||
const limit = 10;
|
||||
|
||||
const ascending = (a, b) => {
|
||||
const dateA = new Date(a.created_at);
|
||||
const dateB = new Date(b.created_at);
|
||||
if (dateA < dateB) { return -1; }
|
||||
if (dateA > dateB) { return 1; }
|
||||
return 0;
|
||||
};
|
||||
|
||||
const descending = (a, b) => -ascending(a, b);
|
||||
|
||||
function queueHasComment(root, queue, id) {
|
||||
return root[queue].nodes.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
function removeCommentFromQueue(root, queue, id) {
|
||||
if (!queueHasComment(root, queue, id)) {
|
||||
return root;
|
||||
}
|
||||
return update(root, {
|
||||
[`${queue}Count`]: {$set: root[`${queue}Count`] - 1},
|
||||
[queue]: {
|
||||
nodes: {$apply: (nodes) => nodes.filter((c) => c.id !== id)},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function shouldCommentBeAdded(root, queue, comment, sort) {
|
||||
if (root[`${queue}Count`] < limit) {
|
||||
|
||||
// Adding all comments until first limit has reached.
|
||||
return true;
|
||||
}
|
||||
const cursor = new Date(root[queue].endCursor);
|
||||
return sort === 'CHRONOLOGICAL'
|
||||
? new Date(comment.created_at) <= cursor
|
||||
: new Date(comment.created_at) >= cursor;
|
||||
}
|
||||
|
||||
function addCommentToQueue(root, queue, comment, sort) {
|
||||
if (queueHasComment(root, queue, comment.id)) {
|
||||
return root;
|
||||
}
|
||||
|
||||
const sortAlgo = sort === 'CHRONOLOGICAL' ? ascending : descending;
|
||||
const changes = {
|
||||
[`${queue}Count`]: {$set: root[`${queue}Count`] + 1},
|
||||
};
|
||||
|
||||
if (shouldCommentBeAdded(root, queue, comment, sort)) {
|
||||
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},
|
||||
};
|
||||
}
|
||||
|
||||
return update(root, changes);
|
||||
}
|
||||
|
||||
function getCommentQueues(comment) {
|
||||
const queues = ['all'];
|
||||
if (comment.status === 'ACCEPTED') {
|
||||
queues.push('accepted');
|
||||
}
|
||||
else if (comment.status === 'REJECTED') {
|
||||
queues.push('rejected');
|
||||
}
|
||||
else if (comment.status === 'PREMOD') {
|
||||
queues.push('premod');
|
||||
}
|
||||
if (
|
||||
['NONE', 'PREMOD'].indexOf(comment.status) >= 0
|
||||
&& comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction')
|
||||
) {
|
||||
queues.push('flagged');
|
||||
}
|
||||
return queues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assimilate comment changes into current store.
|
||||
* @param {Object} root current state of the store
|
||||
* @param {Object} comment comment that was changed
|
||||
* @param {string} sort current sort order of the queues
|
||||
* @param {Object} [notify] show know notifications if set
|
||||
* @param {string} notify.activeQueue current active queue
|
||||
* @param {string} notify.text notification text to show
|
||||
* @param {bool} notify.anyQueue if true show the notification when the comment is shown
|
||||
* in the current active queue besides the 'all' queue.
|
||||
* @return {Object} next state of the store
|
||||
*/
|
||||
export function handleCommentChange(root, comment, sort, notify) {
|
||||
let next = root;
|
||||
const nextQueues = getCommentQueues(comment);
|
||||
|
||||
let notificationShown = false;
|
||||
const showNotificationOnce = () => {
|
||||
if (notificationShown) {
|
||||
return;
|
||||
}
|
||||
notification.info(notify.text);
|
||||
notificationShown = true;
|
||||
};
|
||||
|
||||
queues.forEach((queue) => {
|
||||
if (nextQueues.indexOf(queue) >= 0) {
|
||||
if (!queueHasComment(next, queue, comment.id)) {
|
||||
next = addCommentToQueue(next, queue, comment, sort);
|
||||
if (notify && notify.activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) {
|
||||
showNotificationOnce(comment);
|
||||
}
|
||||
}
|
||||
} else if(queueHasComment(next, queue, comment.id)){
|
||||
next = removeCommentFromQueue(next, queue, comment.id);
|
||||
if (notify && notify.activeQueue === queue) {
|
||||
showNotificationOnce(comment);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
notify
|
||||
&& (queue === 'all' || notify.anyQueue)
|
||||
&& queueHasComment(next, queue, comment.id)
|
||||
&& notify.activeQueue === queue
|
||||
) {
|
||||
showNotificationOnce(comment);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import {render} from 'react-dom';
|
||||
import {ApolloProvider} from 'react-apollo';
|
||||
import smoothscroll from 'smoothscroll-polyfill';
|
||||
|
||||
import {client} from './services/client';
|
||||
import {getClient} from './services/client';
|
||||
import store from './services/store';
|
||||
|
||||
import App from './components/App';
|
||||
@@ -13,9 +14,10 @@ import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/he
|
||||
|
||||
loadPluginsTranslations();
|
||||
injectPluginsReducers();
|
||||
smoothscroll.polyfill();
|
||||
|
||||
render(
|
||||
<ApolloProvider client={client} store={store}>
|
||||
<ApolloProvider client={getClient()} store={store}>
|
||||
<App />
|
||||
</ApolloProvider>
|
||||
, document.querySelector('#root')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {SHOW_BAN_USER_DIALOG, HIDE_BAN_USER_DIALOG} from '../constants/banUserDialog';
|
||||
|
||||
const initialState = {
|
||||
open: false,
|
||||
userId: null,
|
||||
username: '',
|
||||
commentId: null,
|
||||
commentStatus: '',
|
||||
};
|
||||
|
||||
export default function banUserDialog(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case SHOW_BAN_USER_DIALOG:
|
||||
return {
|
||||
...state,
|
||||
open: true,
|
||||
userId: action.userId,
|
||||
username: action.username,
|
||||
commentId: action.commentId,
|
||||
commentStatus: action.commentStatus,
|
||||
};
|
||||
case HIDE_BAN_USER_DIALOG:
|
||||
return {
|
||||
...state,
|
||||
open: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
SET_COMMENTER_STATUS,
|
||||
SHOW_BANUSER_DIALOG,
|
||||
HIDE_BANUSER_DIALOG,
|
||||
SHOW_SUSPENDUSER_DIALOG,
|
||||
HIDE_SUSPENDUSER_DIALOG
|
||||
SHOW_REJECT_USERNAME_DIALOG,
|
||||
HIDE_REJECT_USERNAME_DIALOG
|
||||
} from '../constants/community';
|
||||
|
||||
const initialState = Map({
|
||||
@@ -24,7 +24,7 @@ const initialState = Map({
|
||||
pagePeople: 0,
|
||||
user: Map({}),
|
||||
banDialog: false,
|
||||
suspendDialog: false
|
||||
rejectUsernameDialog: false
|
||||
});
|
||||
|
||||
export default function community (state = initialState, action) {
|
||||
@@ -79,14 +79,14 @@ export default function community (state = initialState, action) {
|
||||
user: Map(action.user),
|
||||
banDialog: true
|
||||
});
|
||||
case HIDE_SUSPENDUSER_DIALOG:
|
||||
case HIDE_REJECT_USERNAME_DIALOG:
|
||||
return state
|
||||
.set('suspendDialog', false);
|
||||
case SHOW_SUSPENDUSER_DIALOG:
|
||||
.set('rejectUsernameDialog', false);
|
||||
case SHOW_REJECT_USERNAME_DIALOG:
|
||||
return state
|
||||
.merge({
|
||||
user: Map(action.user),
|
||||
suspendDialog: true
|
||||
rejectUsernameDialog: true
|
||||
});
|
||||
default :
|
||||
return state;
|
||||
|
||||
@@ -5,9 +5,13 @@ import community from './community';
|
||||
import moderation from './moderation';
|
||||
import install from './install';
|
||||
import config from './config';
|
||||
import banUserDialog from './banUserDialog';
|
||||
import suspendUserDialog from './suspendUserDialog';
|
||||
|
||||
export default {
|
||||
auth,
|
||||
banUserDialog,
|
||||
suspendUserDialog,
|
||||
assets,
|
||||
settings,
|
||||
community,
|
||||
|
||||
@@ -1,57 +1,23 @@
|
||||
import {fromJS, Map, Set} from 'immutable';
|
||||
import {fromJS, Set} from 'immutable';
|
||||
import * as actions from '../constants/moderation';
|
||||
|
||||
const initialState = fromJS({
|
||||
singleView: false,
|
||||
modalOpen: false,
|
||||
user: {},
|
||||
commentId: null,
|
||||
commentStatus: null,
|
||||
userDetailId: null,
|
||||
userDetailActiveTab: 'all',
|
||||
userDetailStatuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'],
|
||||
userDetailSelectedIds: new Set(),
|
||||
banDialog: false,
|
||||
storySearchVisible: false,
|
||||
storySearchString: '',
|
||||
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
|
||||
sortOrder: 'REVERSE_CHRONOLOGICAL',
|
||||
suspendUserDialog: {
|
||||
show: false,
|
||||
userId: null,
|
||||
username: '',
|
||||
commentId: null,
|
||||
commentStatus: '',
|
||||
},
|
||||
});
|
||||
|
||||
export default function moderation (state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.HIDE_BANUSER_DIALOG:
|
||||
return state
|
||||
.set('banDialog', false)
|
||||
.set('commentStatus', null);
|
||||
case actions.SHOW_BANUSER_DIALOG:
|
||||
return state
|
||||
.merge({
|
||||
user: Map(action.user),
|
||||
commentId: action.commentId,
|
||||
commentStatus: action.commentStatus,
|
||||
showRejectedNote: action.showRejectedNote,
|
||||
banDialog: true
|
||||
});
|
||||
case actions.SHOW_SUSPEND_USER_DIALOG:
|
||||
return state
|
||||
.mergeDeep({
|
||||
suspendUserDialog: {
|
||||
show: true,
|
||||
userId: action.userId,
|
||||
username: action.username,
|
||||
commentId: action.commentId,
|
||||
commentStatus: action.commentStatus,
|
||||
}
|
||||
});
|
||||
case actions.HIDE_SUSPEND_USER_DIALOG:
|
||||
return state
|
||||
.setIn(['suspendUserDialog', 'show'], false);
|
||||
case actions.MODERATION_CLEAR_STATE:
|
||||
return initialState;
|
||||
case actions.SET_ACTIVE_TAB:
|
||||
return state
|
||||
.set('activeTab', action.activeTab);
|
||||
@@ -80,6 +46,12 @@ export default function moderation (state = initialState, action) {
|
||||
return state.update('userDetailSelectedIds', (set) => set.add(action.id));
|
||||
case actions.UNSELECT_USER_DETAIL_COMMENT:
|
||||
return state.update('userDetailSelectedIds', (set) => set.delete(action.id));
|
||||
case actions.SHOW_STORY_SEARCH:
|
||||
return state.set('storySearchVisible', true);
|
||||
case actions.HIDE_STORY_SEARCH:
|
||||
return state.set('storySearchVisible', false);
|
||||
case actions.STORY_SEARCH_CHANGE_VALUE:
|
||||
return state.set('storySearchString', action.value);
|
||||
case actions.SET_SORT_ORDER:
|
||||
return state.set('sortOrder', action.order);
|
||||
default :
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {SHOW_SUSPEND_USER_DIALOG, HIDE_SUSPEND_USER_DIALOG} from '../constants/suspendUserDialog';
|
||||
|
||||
const initialState = {
|
||||
open: false,
|
||||
userId: null,
|
||||
username: '',
|
||||
commentId: null,
|
||||
commentStatus: '',
|
||||
};
|
||||
|
||||
export default function suspendUserDialog(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case SHOW_SUSPEND_USER_DIALOG:
|
||||
return {
|
||||
...state,
|
||||
open: true,
|
||||
userId: action.userId,
|
||||
username: action.username,
|
||||
commentId: action.commentId,
|
||||
commentStatus: action.commentStatus,
|
||||
};
|
||||
case HIDE_SUSPEND_USER_DIALOG:
|
||||
return {
|
||||
...state,
|
||||
open: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,18 @@
|
||||
import React from 'react';
|
||||
import styles from './Community.css';
|
||||
import BanUserButton from './BanUserButton';
|
||||
import {Button} from 'coral-ui';
|
||||
import {menuActionsMap} from '../../Moderation/helpers/moderationQueueActionsMap';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const ActionButton = ({type = '', user, ...props}) => {
|
||||
if (type === 'BAN') {
|
||||
return <BanUserButton icon='not interested' user={user} onClick={() => props.showBanUserDialog(user)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`${type.toLowerCase()} ${styles.actionButton}`}
|
||||
cStyle={type.toLowerCase()}
|
||||
icon={menuActionsMap[type].icon}
|
||||
onClick={() => {
|
||||
type === 'APPROVE' ? props.approveUser({userId: user.id}) : props.showSuspendUserDialog({user: user});
|
||||
type === 'APPROVE' ? props.approveUser({userId: user.id}) : props.showRejectUsernameDialog({user: user});
|
||||
}}
|
||||
>{t(`modqueue.${menuActionsMap[type].text}`)}</Button>
|
||||
);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.banButton {
|
||||
-webkit-transform: scale(.8);
|
||||
transform: scale(.8);
|
||||
margin: 0;
|
||||
|
||||
i {
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button, Icon} from 'coral-ui';
|
||||
import styles from './BanUserButton.css';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const BanUserButton = ({user, ...props}) => (
|
||||
<div className={styles.ban}>
|
||||
<Button cStyle='ban'
|
||||
className={`ban ${styles.banButton}`}
|
||||
disabled={user.status === 'BANNED' ? 'disabled' : ''}
|
||||
onClick={props.onClick}
|
||||
raised>
|
||||
<Icon name='not_interested' />
|
||||
{t('comment.ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
BanUserButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default BanUserButton;
|
||||
@@ -1,164 +0,0 @@
|
||||
.dialog {
|
||||
border: none;
|
||||
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
|
||||
width: 500px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 184px;
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
color: black;
|
||||
font-size: 1.76em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: black;
|
||||
font-size: 1.4em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.textField {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.textField label {
|
||||
font-size: 1.08em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.textField input {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border: none;
|
||||
outline: none;
|
||||
border: 1px solid rgba(0,0,0,.12);
|
||||
padding: 10px 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
margin: 5px auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin: 20px auto 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer span {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #2c69b6;
|
||||
cursor: pointer;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.socialConnections {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.signInButton {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.close {
|
||||
font-size: 20px;
|
||||
line-height: 14px;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
position: absolute;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
color: #363636;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #6b6b6b;
|
||||
}
|
||||
|
||||
input.error{
|
||||
border: solid 2px #f44336;
|
||||
}
|
||||
|
||||
.errorMsg, .hint {
|
||||
color: grey;
|
||||
font-weight: 600;
|
||||
padding: 3px 0 16px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.alert--success {
|
||||
border: solid 1px #1ec00e;
|
||||
background: #cbf1b8;
|
||||
color: #006900;
|
||||
}
|
||||
|
||||
.alert--error {
|
||||
background: #FFEBEE;
|
||||
color: #B71C1C;
|
||||
}
|
||||
|
||||
.userBox a {
|
||||
color: #2c69b6;
|
||||
cursor: pointer;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.attention {
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
background: #B71C1C;
|
||||
color: #FFEBEE;
|
||||
font-weight: bolder;
|
||||
padding: 4px;
|
||||
vertical-align: middle;
|
||||
border-radius: 20px;
|
||||
box-sizing: border-box;
|
||||
font-size: 9px;
|
||||
line-height: 7px;
|
||||
text-align: center;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.passwordRequestSuccess {
|
||||
border: 1px solid green;
|
||||
background-color: lightgreen;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.passwordRequestFailure {
|
||||
border: 1px solid orange;
|
||||
background-color: 1px solid coral;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
margin-right: 10px;
|
||||
width: 47%;
|
||||
}
|
||||
|
||||
.ban {
|
||||
width: 47%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin: 20px 0;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Dialog} from 'coral-ui';
|
||||
import styles from './BanUserDialog.css';
|
||||
|
||||
import Button from 'coral-ui/components/Button';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
id="banuserDialog"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onCancel={handleClose}
|
||||
title={t('community.ban_user')}>
|
||||
<span className={styles.close} onClick={handleClose}>×</span>
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<h2>{t('community.ban_user')}</h2>
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h3>{t('community.are_you_sure', user.username)}</h3>
|
||||
<i>{t('community.note')}</i>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button cStyle="cancel" className={styles.cancel} onClick={handleClose} raised>
|
||||
{t('community.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
cStyle="black" className={styles.ban}
|
||||
onClick={() => {
|
||||
handleBanUser({userId: user.id}).then(() => {
|
||||
handleClose();
|
||||
});
|
||||
}}
|
||||
raised>
|
||||
{t('community.yes_ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
BanUserDialog.propTypes = {
|
||||
handleBanUser: PropTypes.func.isRequired,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
user: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default BanUserDialog;
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, {Component} from 'react';
|
||||
|
||||
import CommunityMenu from './CommunityMenu';
|
||||
import BanUserDialog from './BanUserDialog';
|
||||
import SuspendUserDialog from './SuspendUserDialog';
|
||||
import People from './People';
|
||||
import FlaggedAccounts from './FlaggedAccounts';
|
||||
import RejectUsernameDialog from './RejectUsernameDialog';
|
||||
|
||||
export default class Community extends Component {
|
||||
|
||||
@@ -80,19 +79,15 @@ export default class Community extends Component {
|
||||
<FlaggedAccounts
|
||||
commenters={users}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
showRejectUsernameDialog={props.showRejectUsernameDialog}
|
||||
approveUser={props.approveUser}
|
||||
rejectUsername={props.rejectUsername}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
currentUser={this.props.currentUser}
|
||||
/>
|
||||
<BanUserDialog
|
||||
open={community.banDialog}
|
||||
user={community.user}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
handleBanUser={props.banUser}
|
||||
/>
|
||||
<SuspendUserDialog
|
||||
open={community.suspendDialog}
|
||||
handleClose={props.hideSuspendUserDialog}
|
||||
<RejectUsernameDialog
|
||||
open={community.rejectUsernameDialog}
|
||||
handleClose={props.hideRejectUsernameDialog}
|
||||
user={community.user}
|
||||
rejectUsername={props.rejectUsername}
|
||||
/>
|
||||
|
||||
@@ -9,8 +9,6 @@ const FlaggedAccounts = ({...props}) => {
|
||||
const {commenters} = props;
|
||||
const hasResults = commenters && !!commenters.length;
|
||||
|
||||
// if (commenter.status === 'PENDING' && commenter.actions.length > 0) {
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.mainFlaggedContent}>
|
||||
@@ -24,8 +22,9 @@ const FlaggedAccounts = ({...props}) => {
|
||||
modActionButtons={['APPROVE', 'REJECT']}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
showRejectUsernameDialog={props.showRejectUsernameDialog}
|
||||
approveUser={props.approveUser}
|
||||
suspendUser={props.suspendUser}
|
||||
currentUser={props.currentUser}
|
||||
/>;
|
||||
})
|
||||
: <EmptyCard>{t('community.no_flagged_accounts')}</EmptyCard>
|
||||
|
||||
+17
-17
@@ -1,30 +1,30 @@
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
|
||||
import {Dialog, Button} from 'coral-ui';
|
||||
import styles from './SuspendUserDialog.css';
|
||||
import styles from './RejectUsernameDialog.css';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const stages = [
|
||||
{
|
||||
title: 'suspenduser.title_reject',
|
||||
description: 'suspenduser.description_reject',
|
||||
title: 'reject_username.title_reject',
|
||||
description: 'reject_username.description_reject',
|
||||
options: {
|
||||
'j': 'suspenduser.no_cancel',
|
||||
'k': 'suspenduser.yes_suspend'
|
||||
'j': 'reject_username.no_cancel',
|
||||
'k': 'reject_username.yes_suspend'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'suspenduser.title_notify',
|
||||
description: 'suspenduser.description_notify',
|
||||
title: 'reject_username.title_notify',
|
||||
description: 'reject_username.description_notify',
|
||||
options: {
|
||||
'j': 'bandialog.cancel',
|
||||
'k': 'suspenduser.send'
|
||||
'k': 'reject_username.send'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
class SuspendUserDialog extends Component {
|
||||
class RejectUsernameDialog extends Component {
|
||||
|
||||
state = {email: '', stage: 0}
|
||||
|
||||
@@ -35,7 +35,7 @@ class SuspendUserDialog extends Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({email: t('suspenduser.email_message_reject'), about: t('suspenduser.username')});
|
||||
this.setState({email: t('reject_username.email_message_reject'), about: t('reject_username.username')});
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -72,22 +72,22 @@ class SuspendUserDialog extends Component {
|
||||
|
||||
return <Dialog
|
||||
className={styles.suspendDialog}
|
||||
id="suspendUserDialog"
|
||||
id="rejectUsernameDialog"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onCancel={handleClose}
|
||||
title={t('suspenduser.suspend_user')}>
|
||||
title={t('reject_username.suspend_user')}>
|
||||
<div className={styles.title}>
|
||||
{t(stages[stage].title, t('suspenduser.username'))}
|
||||
{t(stages[stage].title, t('reject_username.username'))}
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.description}>
|
||||
{t(stages[stage].description, t('suspenduser.username'))}
|
||||
{t(stages[stage].description, t('reject_username.username'))}
|
||||
</div>
|
||||
{
|
||||
stage === 1 &&
|
||||
<div className={styles.writeContainer}>
|
||||
<div className={styles.emailMessage}>{t('suspenduser.write_message')}</div>
|
||||
<div className={styles.emailMessage}>{t('reject_username.write_message')}</div>
|
||||
<div className={styles.emailContainer}>
|
||||
<textarea
|
||||
rows={5}
|
||||
@@ -100,7 +100,7 @@ class SuspendUserDialog extends Component {
|
||||
<div className={styles.modalButtons}>
|
||||
{Object.keys(stages[stage].options).map((key, i) => (
|
||||
<Button key={i} onClick={this.onActionClick(stage, i)}>
|
||||
{t(stages[stage].options[key], t('suspenduser.username'))}
|
||||
{t(stages[stage].options[key], t('reject_username.username'))}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -109,4 +109,4 @@ class SuspendUserDialog extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default SuspendUserDialog;
|
||||
export default RejectUsernameDialog;
|
||||
@@ -2,15 +2,18 @@ import React from 'react';
|
||||
import styles from './Community.css';
|
||||
|
||||
import ActionButton from './ActionButton';
|
||||
import {username} from 'coral-plugin-flags/helpers/flagReasons';
|
||||
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
|
||||
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const shortReasons = {
|
||||
'This comment is offensive': t('community.offensive'),
|
||||
'This looks like an ad/marketing': t('community.spam_ads'),
|
||||
'This user is impersonating': t('community.impersonating'),
|
||||
'I don\'t like this username': t('community.dont_like_username'),
|
||||
'Other': t('community.other')
|
||||
[username.other]: t('community.other'),
|
||||
[username.spam]: t('community.spam_ads'),
|
||||
[username.offensive]: t('community.offensive'),
|
||||
[username.nolike]: t('community.dont_like_username'),
|
||||
[username.impersonating]: t('community.impersonating'),
|
||||
};
|
||||
|
||||
// Render a single user for the list
|
||||
@@ -18,6 +21,16 @@ const User = (props) => {
|
||||
const {user, modActionButtons} = props;
|
||||
let userStatus = user.status;
|
||||
|
||||
const showSuspenUserDialog = () => props.showSuspendUserDialog({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
const showBanUserDialog = () => props.showBanUserDialog({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
// Do not display unless the user status is 'pending' or 'banned'.
|
||||
// This means that they have already been reviewed and approved.
|
||||
return (userStatus === 'PENDING' || userStatus === 'BANNED') &&
|
||||
@@ -28,12 +41,19 @@ const User = (props) => {
|
||||
<span>
|
||||
{user.username}
|
||||
</span>
|
||||
<ActionButton
|
||||
className={styles.banButton}
|
||||
type='BAN'
|
||||
user={user}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
/>
|
||||
{props.currentUser.id !== user.id &&
|
||||
<ActionsMenu icon="not_interested">
|
||||
<ActionsMenuItem
|
||||
disabled={user.status === 'BANNED'}
|
||||
onClick={showSuspenUserDialog}>
|
||||
Suspend User</ActionsMenuItem>
|
||||
<ActionsMenuItem
|
||||
disabled={user.status === 'BANNED'}
|
||||
onClick={showBanUserDialog}>
|
||||
Ban User
|
||||
</ActionsMenuItem>
|
||||
</ActionsMenu>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,11 +98,10 @@ const User = (props) => {
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{modActionButtons.map((action, i) =>
|
||||
<ActionButton key={i}
|
||||
type={action.toUpperCase()}
|
||||
user={user}
|
||||
approveUser={props.approveUser}
|
||||
suspendUser={props.suspendUser}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
type={action.toUpperCase()}
|
||||
user={user}
|
||||
approveUser={props.approveUser}
|
||||
showRejectUsernameDialog={props.showRejectUsernameDialog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,7 @@ span {
|
||||
|
||||
.moderateAsset {
|
||||
a {
|
||||
text-align: center;
|
||||
-webkit-box-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
@@ -103,21 +104,9 @@ span {
|
||||
opacity: 1;
|
||||
|
||||
&:hover {
|
||||
opacity: .8;
|
||||
opacity: .8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,15 @@ import {Spinner} from 'coral-ui';
|
||||
|
||||
import {withSetUserStatus, withRejectUsername} from 'coral-framework/graphql/mutations';
|
||||
|
||||
import {showBanUserDialog} from 'actions/banUserDialog';
|
||||
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
|
||||
|
||||
import {
|
||||
fetchAccounts,
|
||||
updateSorting,
|
||||
newPage,
|
||||
showBanUserDialog,
|
||||
hideBanUserDialog,
|
||||
showSuspendUserDialog,
|
||||
hideSuspendUserDialog
|
||||
showRejectUsernameDialog,
|
||||
hideRejectUsernameDialog
|
||||
} from '../../../actions/community';
|
||||
|
||||
import Community from '../components/Community';
|
||||
@@ -85,16 +86,17 @@ export const withCommunityQuery = withQuery(gql`
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
community: state.community.toJS()
|
||||
community: state.community.toJS(),
|
||||
currentUser: state.auth.toJS().user,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({
|
||||
fetchAccounts,
|
||||
showBanUserDialog,
|
||||
hideBanUserDialog,
|
||||
showSuspendUserDialog,
|
||||
hideSuspendUserDialog,
|
||||
showRejectUsernameDialog,
|
||||
hideRejectUsernameDialog,
|
||||
updateSorting,
|
||||
newPage,
|
||||
}, dispatch);
|
||||
|
||||
@@ -136,13 +136,6 @@
|
||||
resize: none;
|
||||
}
|
||||
|
||||
#bannedWordlist, #suspectWordlist {
|
||||
display: block;
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.customCSSInput {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import {Card} from 'coral-ui';
|
||||
import styles from './Configure.css';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
import TagsInput from 'coral-admin/src/components/TagsInput';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const Domainlist = ({domains, onChangeDomainlist}) => {
|
||||
@@ -14,8 +14,6 @@ const Domainlist = ({domains, onChangeDomainlist}) => {
|
||||
<TagsInput
|
||||
value={domains}
|
||||
inputProps={{placeholder: 'URL'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
|
||||
onChange={(tags) => onChangeDomainlist('whitelist', tags)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -165,7 +165,7 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
|
||||
checked={settings.autoCloseStream} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{t('configure.close_after')}
|
||||
<div className={styles.settingsHeader}>{t('configure.close_after')}</div>
|
||||
<br />
|
||||
<Textfield
|
||||
type='number'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
import TagsInput from 'coral-admin/src/components/TagsInput';
|
||||
import styles from './Configure.css';
|
||||
import {Card} from 'coral-ui';
|
||||
|
||||
@@ -13,8 +13,6 @@ const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
|
||||
<TagsInput
|
||||
value={bannedWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
|
||||
onChange={(tags) => onChangeWordlist('banned', tags)}
|
||||
/>
|
||||
</div>
|
||||
@@ -26,8 +24,6 @@ const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
|
||||
<TagsInput
|
||||
value={suspectWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
|
||||
onChange={(tags) => onChangeWordlist('suspect', tags)} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -48,7 +48,7 @@ const InitialStep = (props) => {
|
||||
label={t('install.create.confirm_password')}
|
||||
onChange={handleUserChange}
|
||||
showErrors={install.showErrors}
|
||||
errorMsg={install.errors.confirm_password}
|
||||
errorMsg={install.errors.confirmPassword}
|
||||
/>
|
||||
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import styles from './style.css';
|
||||
import {Button, Card} from 'coral-ui';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
import TagsInput from 'coral-admin/src/components/TagsInput';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
@@ -16,8 +16,6 @@ const PermittedDomainsStep = (props) => {
|
||||
<TagsInput
|
||||
value={domains}
|
||||
inputProps={{placeholder: 'URL'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
|
||||
onChange={(tags) => handleDomainsChange(tags)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Dialog} from 'coral-ui';
|
||||
import styles from './BanUserDialog.css';
|
||||
|
||||
import Button from 'coral-ui/components/Button';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const onBanClick = (userId, commentId, commentStatus, handleBanUser, rejectComment, handleClose) => (e) => {
|
||||
e.preventDefault();
|
||||
handleBanUser({userId})
|
||||
.then(handleClose)
|
||||
.then(() => commentStatus === 'REJECTED' ? null : rejectComment({commentId}));
|
||||
};
|
||||
|
||||
const BanUserDialog = ({open, handleClose, handleBanUser, rejectComment, user, commentId, commentStatus, showRejectedNote}) => (
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
id="banuserDialog"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onCancel={handleClose}
|
||||
title={t('bandialog.ban_user')}>
|
||||
<span className={styles.close} onClick={handleClose}>×</span>
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<h2>{t('bandialog.ban_user')}</h2>
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h3>{t('bandialog.are_you_sure', user.name)}</h3>
|
||||
<i>{showRejectedNote && t('bandialog.note')}</i>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button cStyle="cancel" className={styles.cancel} onClick={handleClose} raised>
|
||||
{t('bandialog.cancel')}
|
||||
</Button>
|
||||
<Button cStyle="black" className={styles.ban} onClick={onBanClick(user.id, commentId, commentStatus, handleBanUser, rejectComment, handleClose)} raised>
|
||||
{t('bandialog.yes_ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
BanUserDialog.propTypes = {
|
||||
handleBanUser: PropTypes.func.isRequired,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
commentId: PropTypes.string,
|
||||
user: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default BanUserDialog;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {withCopyToClipboard} from 'coral-framework/hocs';
|
||||
|
||||
class ButtonCopyToClipboard extends React.Component {
|
||||
render () {
|
||||
return (
|
||||
<Button {...this.props} >
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withCopyToClipboard(ButtonCopyToClipboard);
|
||||
@@ -12,194 +12,239 @@ import {getActionSummary} from 'coral-framework/utils';
|
||||
import ActionButton from 'coral-admin/src/components/ActionButton';
|
||||
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
|
||||
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
|
||||
import cn from 'classnames';
|
||||
import {murmur3} from 'murmurhash-js';
|
||||
import {CSSTransitionGroup} from 'react-transition-group';
|
||||
|
||||
const linkify = new Linkify();
|
||||
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
|
||||
const Comment = ({
|
||||
actions = [],
|
||||
comment,
|
||||
viewUserDetail,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
minimal,
|
||||
selected,
|
||||
toggleSelect,
|
||||
...props
|
||||
}) => {
|
||||
const links = linkify.getMatches(comment.body);
|
||||
const linkText = links ? links.map((link) => link.raw) : [];
|
||||
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
|
||||
const flagActions =
|
||||
comment.actions &&
|
||||
comment.actions.filter((a) => a.__typename === 'FlagAction');
|
||||
let commentType = '';
|
||||
if (comment.status === 'PREMOD') {
|
||||
commentType = 'premod';
|
||||
} else if (flagActions && flagActions.length) {
|
||||
commentType = 'flagged';
|
||||
}
|
||||
class Comment extends React.Component {
|
||||
|
||||
// since words are checked against word boundaries on the backend,
|
||||
// should be the behavior on the front end as well.
|
||||
// currently the highlighter plugin does not support out of the box.
|
||||
const searchWords = [...suspectWords, ...bannedWords]
|
||||
.filter((w) => {
|
||||
return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body);
|
||||
})
|
||||
.concat(linkText);
|
||||
render() {
|
||||
const {
|
||||
actions = [],
|
||||
comment,
|
||||
viewUserDetail,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
minimal,
|
||||
selected,
|
||||
toggleSelect,
|
||||
className,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
let selectionStateCSS;
|
||||
if (minimal) {
|
||||
selectionStateCSS = selected ? styles.minimalSelection : '';
|
||||
} else {
|
||||
selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
|
||||
}
|
||||
const links = linkify.getMatches(comment.body);
|
||||
const linkText = links ? links.map((link) => link.raw) : [];
|
||||
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
|
||||
const flagActions =
|
||||
comment.actions &&
|
||||
comment.actions.filter((a) => a.__typename === 'FlagAction');
|
||||
let commentType = '';
|
||||
if (comment.status === 'PREMOD') {
|
||||
commentType = 'premod';
|
||||
} else if (flagActions && flagActions.length) {
|
||||
commentType = 'flagged';
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
tabIndex={props.index}
|
||||
className={`mdl-card ${selectionStateCSS} ${styles.Comment} ${styles.listItem} ${minimal ? styles.minimal : ''}`}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
{
|
||||
!minimal && (
|
||||
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
|
||||
{comment.user.name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
|
||||
<input
|
||||
className={styles.bulkSelectInput}
|
||||
type='checkbox'
|
||||
value={comment.id}
|
||||
checked={selected}
|
||||
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
|
||||
)
|
||||
}
|
||||
<span className={styles.created}>
|
||||
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
|
||||
</span>
|
||||
{props.currentUserId !== comment.user.id &&
|
||||
<ActionsMenu icon="not_interested">
|
||||
<ActionsMenuItem
|
||||
disabled={comment.user.status === 'BANNED'}
|
||||
onClick={() => props.showSuspendUserDialog(comment.user.id, comment.user.name, comment.id, comment.status)}>
|
||||
Suspend User</ActionsMenuItem>
|
||||
<ActionsMenuItem
|
||||
disabled={comment.user.status === 'BANNED'}
|
||||
onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')}>
|
||||
Ban User
|
||||
</ActionsMenuItem>
|
||||
</ActionsMenu>
|
||||
}
|
||||
<CommentType type={commentType} />
|
||||
</div>
|
||||
{comment.user.status === 'banned'
|
||||
? <span className={styles.banned}>
|
||||
<Icon name="error_outline" />
|
||||
{t('comment.banned_user')}
|
||||
// since words are checked against word boundaries on the backend,
|
||||
// should be the behavior on the front end as well.
|
||||
// currently the highlighter plugin does not support out of the box.
|
||||
const searchWords = [...suspectWords, ...bannedWords]
|
||||
.filter((w) => {
|
||||
return new RegExp(`(^|\\s)${w}(\\s|$)`, 'i').test(comment.body);
|
||||
})
|
||||
.concat(linkText);
|
||||
|
||||
let selectionStateCSS;
|
||||
if (minimal) {
|
||||
selectionStateCSS = selected ? styles.minimalSelection : '';
|
||||
} else {
|
||||
selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
|
||||
}
|
||||
|
||||
const showSuspenUserDialog = () => props.showSuspendUserDialog({
|
||||
userId: comment.user.id,
|
||||
username: comment.user.username,
|
||||
commentId: comment.id,
|
||||
commentStatus: comment.status,
|
||||
});
|
||||
|
||||
const showBanUserDialog = () => props.showBanUserDialog({
|
||||
userId: comment.user.id,
|
||||
username: comment.user.username,
|
||||
commentId: comment.id,
|
||||
commentStatus: comment.status,
|
||||
});
|
||||
|
||||
return (
|
||||
<li
|
||||
tabIndex={props.index}
|
||||
className={cn(className, 'mdl-card', selectionStateCSS, styles.Comment, styles.listItem, {[styles.minimal]: minimal, [styles.selected]: selected})}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
{
|
||||
!minimal && (
|
||||
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
|
||||
{comment.user.username}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
|
||||
<input
|
||||
className={styles.bulkSelectInput}
|
||||
type='checkbox'
|
||||
value={comment.id}
|
||||
checked={selected}
|
||||
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
|
||||
)
|
||||
}
|
||||
<span className={styles.created}>
|
||||
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
|
||||
</span>
|
||||
: null}
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentInfoBar"
|
||||
comment={comment}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.moderateArticle}>
|
||||
Story: {comment.asset.title}
|
||||
{!props.currentAsset &&
|
||||
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
|
||||
</div>
|
||||
<div className={styles.itemBody}>
|
||||
<p className={styles.body}>
|
||||
<Highlighter
|
||||
searchWords={searchWords}
|
||||
textToHighlight={comment.body}
|
||||
/>
|
||||
{' '}
|
||||
<a
|
||||
className={styles.external}
|
||||
href={`${comment.asset.url}#${comment.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon name="open_in_new" /> {t('comment.view_context')}
|
||||
</a>
|
||||
</p>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentContent"
|
||||
comment={comment}
|
||||
/>
|
||||
<div className={styles.sideActions}>
|
||||
{links
|
||||
? <span className={styles.hasLinks}>
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
{props.currentUserId !== comment.user.id &&
|
||||
<ActionsMenu icon="not_interested">
|
||||
<ActionsMenuItem
|
||||
disabled={comment.user.status === 'BANNED'}
|
||||
onClick={showSuspenUserDialog}>
|
||||
Suspend User</ActionsMenuItem>
|
||||
<ActionsMenuItem
|
||||
disabled={comment.user.status === 'BANNED'}
|
||||
onClick={showBanUserDialog}>
|
||||
Ban User
|
||||
</ActionsMenuItem>
|
||||
</ActionsMenu>
|
||||
}
|
||||
<CommentType type={commentType} />
|
||||
</div>
|
||||
{comment.user.status === 'banned'
|
||||
? <span className={styles.banned}>
|
||||
<Icon name="error_outline" />
|
||||
{t('comment.banned_user')}
|
||||
</span>
|
||||
: null}
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{actions.map((action, i) => {
|
||||
const active =
|
||||
(action === 'REJECT' && comment.status === 'REJECTED') ||
|
||||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
|
||||
return (
|
||||
<ActionButton
|
||||
minimal={minimal}
|
||||
key={i}
|
||||
type={action}
|
||||
user={comment.user}
|
||||
status={comment.status}
|
||||
active={active}
|
||||
acceptComment={() =>
|
||||
(comment.status === 'ACCEPTED'
|
||||
? null
|
||||
: props.acceptComment({commentId: comment.id}))}
|
||||
rejectComment={() =>
|
||||
(comment.status === 'REJECTED'
|
||||
? null
|
||||
: props.rejectComment({commentId: comment.id}))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminSideActions"
|
||||
comment={comment}
|
||||
/>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentInfoBar"
|
||||
comment={comment}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.moderateArticle}>
|
||||
Story: {comment.asset.title}
|
||||
{!props.currentAsset &&
|
||||
<Link to={`/admin/moderate/all/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
|
||||
</div>
|
||||
<CSSTransitionGroup
|
||||
component={'div'}
|
||||
style={{position: 'relative'}}
|
||||
transitionName={{
|
||||
enter: styles.bodyEnter,
|
||||
enterActive: styles.bodyEnterActive,
|
||||
leave: styles.bodyLeave,
|
||||
leaveActive: styles.bodyLeaveActive,
|
||||
}}
|
||||
transitionEnter={true}
|
||||
transitionLeave={true}
|
||||
transitionEnterTimeout={3600}
|
||||
transitionLeaveTimeout={2800}
|
||||
>
|
||||
<div className={styles.itemBody} key={murmur3(comment.body)}>
|
||||
<p className={styles.body}>
|
||||
<Highlighter
|
||||
searchWords={searchWords}
|
||||
textToHighlight={comment.body}
|
||||
/>
|
||||
{' '}
|
||||
<a
|
||||
className={styles.external}
|
||||
href={`${comment.asset.url}?commentId=${comment.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon name="open_in_new" /> {t('comment.view_context')}
|
||||
</a>
|
||||
</p>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentContent"
|
||||
comment={comment}
|
||||
/>
|
||||
<div className={styles.sideActions}>
|
||||
{links
|
||||
? <span className={styles.hasLinks}>
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
</span>
|
||||
: null}
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{actions.map((action, i) => {
|
||||
const active =
|
||||
(action === 'REJECT' && comment.status === 'REJECTED') ||
|
||||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
|
||||
return (
|
||||
<ActionButton
|
||||
minimal={minimal}
|
||||
key={i}
|
||||
type={action}
|
||||
user={comment.user}
|
||||
status={comment.status}
|
||||
active={active}
|
||||
acceptComment={() =>
|
||||
(comment.status === 'ACCEPTED'
|
||||
? null
|
||||
: props.acceptComment({commentId: comment.id}))}
|
||||
rejectComment={() =>
|
||||
(comment.status === 'REJECTED'
|
||||
? null
|
||||
: props.rejectComment({commentId: comment.id}))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminSideActions"
|
||||
comment={comment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CSSTransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentDetailArea"
|
||||
comment={comment}
|
||||
/>
|
||||
{flagActions && flagActions.length
|
||||
? <FlagBox
|
||||
actions={flagActions}
|
||||
actionSummaries={flagActionSummaries}
|
||||
/>
|
||||
: null}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentDetailArea"
|
||||
comment={comment}
|
||||
/>
|
||||
{flagActions && flagActions.length
|
||||
? <FlagBox
|
||||
actions={flagActions}
|
||||
actionSummaries={flagActionSummaries}
|
||||
viewUserDetail={() => viewUserDetail(comment.user.id)}
|
||||
/>
|
||||
: null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Comment.propTypes = {
|
||||
minimal: PropTypes.bool,
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
currentAsset: PropTypes.object,
|
||||
|
||||
@@ -67,3 +67,17 @@
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
.username {
|
||||
color: #393B44;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
padding: 2px 5px;
|
||||
border-radius: 2px;
|
||||
margin-left: -5px;
|
||||
transition: background-color 200ms ease;
|
||||
&:hover {
|
||||
background-color: #E0E0E0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class FlagBox extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {actionSummaries, actions} = this.props;
|
||||
const {actionSummaries, actions, viewUserDetail} = this.props;
|
||||
const {showDetail} = this.state;
|
||||
|
||||
return (
|
||||
@@ -59,9 +59,14 @@ class FlagBox extends Component {
|
||||
<li key={i}>
|
||||
{this.reasonMap(summary.reason)} (<strong>{summary.count}</strong>)
|
||||
<ul>
|
||||
{
|
||||
actionList.map((action, j) => <li key={`${i}_${j}`} className={styles.subDetail}><span>{action.user.username}</span> {action.message}</li>)
|
||||
}
|
||||
{actionList.map((action, j) =>
|
||||
<li key={`${i}_${j}`} className={styles.subDetail}>
|
||||
<a className={styles.username} onClick={viewUserDetail}>
|
||||
{action.user.username}
|
||||
</a>
|
||||
{action.message}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -2,18 +2,21 @@ import React, {Component} from 'react';
|
||||
import key from 'keymaster';
|
||||
import styles from './styles.css';
|
||||
|
||||
import BanUserDialog from './BanUserDialog';
|
||||
import SuspendUserDialog from './SuspendUserDialog';
|
||||
import ModerationQueue from './ModerationQueue';
|
||||
import ModerationMenu from './ModerationMenu';
|
||||
import ModerationHeader from './ModerationHeader';
|
||||
import NotFoundAsset from './NotFoundAsset';
|
||||
import ModerationKeysModal from '../../../components/ModerationKeysModal';
|
||||
import UserDetail from '../containers/UserDetail';
|
||||
import StorySearch from '../containers/StorySearch';
|
||||
|
||||
export default class Moderation extends Component {
|
||||
state = {
|
||||
selectedIndex: 0,
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
selectedIndex: 0
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
@@ -24,12 +27,21 @@ export default class Moderation extends Component {
|
||||
key('esc', () => toggleModal(false));
|
||||
key('j', this.select(true));
|
||||
key('k', this.select(false));
|
||||
key('r', this.moderate(false));
|
||||
key('t', this.moderate(true));
|
||||
key('f', this.moderate(false));
|
||||
key('d', this.moderate(true));
|
||||
}
|
||||
|
||||
onClose = () => {
|
||||
this.toggleModal(false);
|
||||
this.props.toggleModal(false);
|
||||
}
|
||||
|
||||
closeSearch = () => {
|
||||
const {toggleStorySearch} = this.props;
|
||||
toggleStorySearch(false);
|
||||
}
|
||||
|
||||
openSearch = () => {
|
||||
this.props.toggleStorySearch(true);
|
||||
}
|
||||
|
||||
moderate = (accept) => () => {
|
||||
@@ -49,24 +61,22 @@ export default class Moderation extends Component {
|
||||
getComments = () => {
|
||||
const {root, route} = this.props;
|
||||
const activeTab = route.path === ':id' ? 'premod' : route.path;
|
||||
return root[activeTab];
|
||||
return root[activeTab].nodes;
|
||||
}
|
||||
|
||||
select = (next) => () => {
|
||||
if (next) {
|
||||
this.setState((prevState) =>
|
||||
({
|
||||
...prevState,
|
||||
selectedIndex: prevState.selectedIndex < this.getComments().length - 1
|
||||
? prevState.selectedIndex + 1 : prevState.selectedIndex
|
||||
}));
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
selectedIndex: state.selectedIndex < this.getComments().length - 1
|
||||
? state.selectedIndex + 1 : state.selectedIndex
|
||||
}));
|
||||
} else {
|
||||
this.setState((prevState) =>
|
||||
({
|
||||
...prevState,
|
||||
selectedIndex: prevState.selectedIndex > 0 ?
|
||||
prevState.selectedIndex - 1 : prevState.selectedIndex
|
||||
}));
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
selectedIndex: state.selectedIndex > 0
|
||||
? state.selectedIndex - 1 : state.selectedIndex
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,8 +86,8 @@ export default class Moderation extends Component {
|
||||
key.unbind('esc');
|
||||
key.unbind('j');
|
||||
key.unbind('k');
|
||||
key.unbind('r');
|
||||
key.unbind('t');
|
||||
key.unbind('f');
|
||||
key.unbind('d');
|
||||
}
|
||||
|
||||
componentDidUpdate(_, prevState) {
|
||||
@@ -92,19 +102,10 @@ export default class Moderation extends Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const {root, moderation, settings, assets, viewUserDetail, hideUserDetail, ...props} = this.props;
|
||||
const providedAssetId = this.props.params.id;
|
||||
const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path;
|
||||
|
||||
let asset;
|
||||
|
||||
if (providedAssetId) {
|
||||
asset = assets.find((asset) => asset.id === this.props.params.id);
|
||||
|
||||
if (!asset) {
|
||||
return <NotFoundAsset assetId={providedAssetId} />;
|
||||
}
|
||||
}
|
||||
const {root, moderation, settings, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props;
|
||||
const assetId = this.props.params.id;
|
||||
const {asset} = root;
|
||||
|
||||
const comments = root[activeTab];
|
||||
let activeTabCount;
|
||||
@@ -128,7 +129,12 @@ export default class Moderation extends Component {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ModerationHeader asset={asset} />
|
||||
<ModerationHeader
|
||||
searchVisible={this.props.moderation.storySearchVisible}
|
||||
openSearch={this.openSearch}
|
||||
closeSearch={this.closeSearch}
|
||||
asset={asset}
|
||||
/>
|
||||
<ModerationMenu
|
||||
asset={asset}
|
||||
allCount={root.allCount}
|
||||
@@ -154,36 +160,19 @@ export default class Moderation extends Component {
|
||||
acceptComment={props.acceptComment}
|
||||
rejectComment={props.rejectComment}
|
||||
loadMore={props.loadMore}
|
||||
assetId={providedAssetId}
|
||||
assetId={assetId}
|
||||
sort={this.props.moderation.sortOrder}
|
||||
commentCount={activeTabCount}
|
||||
currentUserId={this.props.auth.user.id}
|
||||
viewUserDetail={viewUserDetail}
|
||||
hideUserDetail={hideUserDetail}
|
||||
/>
|
||||
<BanUserDialog
|
||||
open={moderation.banDialog}
|
||||
user={moderation.user}
|
||||
commentId={moderation.commentId}
|
||||
commentStatus={moderation.commentStatus}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
handleBanUser={props.banUser}
|
||||
showRejectedNote={moderation.showRejectedNote}
|
||||
rejectComment={props.rejectComment}
|
||||
/>
|
||||
<SuspendUserDialog
|
||||
open={moderation.suspendUserDialog.show}
|
||||
username={moderation.suspendUserDialog.username}
|
||||
userId={moderation.suspendUserDialog.userId}
|
||||
organizationName={root.settings.organizationName}
|
||||
onCancel={props.hideSuspendUserDialog}
|
||||
onPerform={this.props.suspendUser}
|
||||
/>
|
||||
<ModerationKeysModal
|
||||
hideShortcutsNote={props.hideShortcutsNote}
|
||||
shortcutsNoteVisible={moderation.shortcutsNoteVisible}
|
||||
open={moderation.modalOpen}
|
||||
onClose={this.onClose}/>
|
||||
|
||||
{moderation.userDetailId && (
|
||||
<UserDetail
|
||||
id={moderation.userDetailId}
|
||||
@@ -195,6 +184,13 @@ export default class Moderation extends Component {
|
||||
acceptComment={props.acceptComment}
|
||||
rejectComment={props.rejectComment} />
|
||||
)}
|
||||
|
||||
<StorySearch
|
||||
assetId={assetId}
|
||||
moderation={this.props.moderation}
|
||||
closeSearch={this.closeSearch}
|
||||
storySearchChange={this.props.storySearchChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
import React from 'react';
|
||||
import {Link} from 'react-router';
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Icon} from 'coral-ui';
|
||||
import styles from './styles.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const ModerationHeader = (props) => (
|
||||
const ModerationHeader = ({asset, searchVisible, openSearch, closeSearch}) => {
|
||||
|
||||
const trigger = searchVisible ? closeSearch : openSearch;
|
||||
const searchTriggerIcon = <Icon className={styles.searchTrigger} name={searchVisible ? 'arrow_drop_up' : 'arrow_drop_down'} />;
|
||||
|
||||
const title = asset
|
||||
? <a onClick={trigger} className="mdl-tabs__tab">{asset.title} {searchTriggerIcon}</a>
|
||||
: <a onClick={trigger} className="mdl-tabs__tab">{t('modqueue.all_streams')} {searchTriggerIcon}</a>;
|
||||
|
||||
return (
|
||||
<div className=''>
|
||||
<div className={`mdl-tabs ${styles.header}`}>
|
||||
{
|
||||
props.asset ?
|
||||
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
|
||||
<Link className="mdl-tabs__tab" to="/admin/moderate">{t('modqueue.all_streams')}</Link>
|
||||
<a className="mdl-tabs__tab" href={props.asset.url} target="_blank">
|
||||
<span>{props.asset.title}</span>
|
||||
<Icon className={styles.settingsButton} name="open_in_new"/>
|
||||
</a>
|
||||
<Link className="mdl-tabs__tab" to="/admin/stories">Select Stream</Link>
|
||||
</div>
|
||||
:
|
||||
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
|
||||
<a className="mdl-tabs__tab" />
|
||||
<a className="mdl-tabs__tab">{t('modqueue.all_streams')}</a>
|
||||
<Link className="mdl-tabs__tab" to="/admin/stories">{t('modqueue.select_stream')}</Link>
|
||||
</div>
|
||||
}
|
||||
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
ModerationHeader.propTypes = {
|
||||
asset: PropTypes.shape({
|
||||
title: PropTypes.string,
|
||||
id: PropTypes.string
|
||||
}),
|
||||
openSearch: PropTypes.func.isRequired,
|
||||
closeSearch: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ModerationHeader;
|
||||
|
||||
@@ -6,8 +6,10 @@ import EmptyCard from '../../../components/EmptyCard';
|
||||
import {actionsMap} from '../helpers/moderationQueueActionsMap';
|
||||
import LoadMore from './LoadMore';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {CSSTransitionGroup} from 'react-transition-group';
|
||||
|
||||
class ModerationQueue extends React.Component {
|
||||
isLoadingMore = false;
|
||||
|
||||
static propTypes = {
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
@@ -22,7 +24,19 @@ class ModerationQueue extends React.Component {
|
||||
}
|
||||
|
||||
loadMore = () => {
|
||||
this.props.loadMore(this.props.activeTab);
|
||||
if (!this.isLoadingMore) {
|
||||
this.isLoadingMore = true;
|
||||
this.props.loadMore(this.props.activeTab)
|
||||
.then(() => this.isLoadingMore = false)
|
||||
.catch((e) => {
|
||||
this.isLoadingMore = false;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
componentDidUpdate (prev) {
|
||||
@@ -43,20 +57,34 @@ class ModerationQueue extends React.Component {
|
||||
commentCount,
|
||||
singleView,
|
||||
viewUserDetail,
|
||||
activeTab,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div id="moderationList" className={`${styles.list} ${singleView ? styles.singleView : ''}`}>
|
||||
<ul style={{paddingLeft: 0}}>
|
||||
<CSSTransitionGroup
|
||||
key={activeTab}
|
||||
component={'ul'}
|
||||
style={{paddingLeft: 0}}
|
||||
transitionName={{
|
||||
enter: styles.commentEnter,
|
||||
enterActive: styles.commentEnterActive,
|
||||
leave: styles.commentLeave,
|
||||
leaveActive: styles.commentLeaveActive,
|
||||
}}
|
||||
transitionEnter={true}
|
||||
transitionLeave={true}
|
||||
transitionEnterTimeout={1000}
|
||||
transitionLeaveTimeout={1000}
|
||||
>
|
||||
{
|
||||
comments.length
|
||||
? comments.map((comment, i) => {
|
||||
comments.map((comment, i) => {
|
||||
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
|
||||
return <Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
key={i}
|
||||
key={comment.id}
|
||||
index={i}
|
||||
comment={comment}
|
||||
selected={i === selectedIndex}
|
||||
@@ -72,9 +100,14 @@ class ModerationQueue extends React.Component {
|
||||
currentUserId={this.props.currentUserId}
|
||||
/>;
|
||||
})
|
||||
: <EmptyCard>{t('modqueue.empty_queue')}</EmptyCard>
|
||||
}
|
||||
</ul>
|
||||
</CSSTransitionGroup>
|
||||
{comments.length === 0 &&
|
||||
<div className={styles.emptyCardContainer}>
|
||||
<EmptyCard>{t('modqueue.empty_queue')}</EmptyCard>
|
||||
</div>
|
||||
}
|
||||
|
||||
<LoadMore
|
||||
loadMore={this.loadMore}
|
||||
showLoadMore={comments.length < commentCount}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './StorySearch.css';
|
||||
|
||||
const formatDate = (date) => {
|
||||
const d = new Date(date);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
|
||||
};
|
||||
|
||||
const Story = ({author, title, createdAt, open, id, goToStory}) => {
|
||||
return (
|
||||
<li className={styles.story} onClick={() => goToStory(id)}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<div className={styles.meta}>
|
||||
<span className={styles.author}>By {author}</span>
|
||||
<span className={styles.createdAt}>{formatDate(createdAt)}</span>
|
||||
<span className={styles.status}>{open ? 'Open' : 'Closed'}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
Story.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
author: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
createdAt: PropTypes.string.isRequired,
|
||||
open: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default Story;
|
||||
@@ -0,0 +1,147 @@
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 58px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
z-index: 99;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
top: 106px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 50%;
|
||||
box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.15);
|
||||
z-index: 100;
|
||||
max-width: 820px;
|
||||
}
|
||||
|
||||
.positionShim {
|
||||
left: -50%;
|
||||
}
|
||||
|
||||
.headInput {
|
||||
background-color: #efefef;
|
||||
padding: 10px 27px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: calc(100% - 100px);
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
font-size: 16px;
|
||||
margin-right: 5px;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 3px;
|
||||
border: solid 1px #dfdfdf;
|
||||
max-height: 45px;
|
||||
max-width: 600px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.cta > a {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
padding: 12px 30px;
|
||||
letter-spacing: 0.25px;
|
||||
}
|
||||
|
||||
/*.storyList {
|
||||
border-top: 1px solid #ddd;
|
||||
}*/
|
||||
|
||||
.story {
|
||||
padding: 7px 50px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
height: 50px;
|
||||
box-sizing: border-box;
|
||||
transition: background-color 400ms;
|
||||
|
||||
&:hover {
|
||||
background-color: #efefef;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title, .meta {
|
||||
margin: 0;
|
||||
color: black;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.author, .createdAt, .status {
|
||||
font-size: 17px;
|
||||
display: inline-block;
|
||||
font-size: .8em;
|
||||
}
|
||||
|
||||
.createdAt {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.createdAt {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
width: 90px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.searchResults {
|
||||
padding: 7px 27px;
|
||||
background: #F5F5F5;
|
||||
}
|
||||
|
||||
.searchResults i {
|
||||
font-size: 16px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.accessStories {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.headlineRecent {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.25px;
|
||||
vertical-align: middle;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.noResults {
|
||||
padding: 10px 24px 15px 49px;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './StorySearch.css';
|
||||
import {Button, Spinner, Icon} from 'coral-ui';
|
||||
import Story from './Story';
|
||||
|
||||
const StorySearch = (props) => {
|
||||
|
||||
const {
|
||||
root: {
|
||||
assets = []
|
||||
},
|
||||
data: {loading}
|
||||
} = props;
|
||||
|
||||
if (!props.moderation.storySearchVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.container} role='alertdialog' onKeyDown={props.handleEsc}>
|
||||
<div className={styles.positionShim}>
|
||||
<div className={styles.headInput}>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
onChange={props.handleSearchChange}
|
||||
onKeyDown={props.handleEnter}
|
||||
value={props.searchValue}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
cStyle='blue'
|
||||
className={styles.searchButton}
|
||||
onClick={props.search}
|
||||
raised >
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.results}>
|
||||
{props.assetId &&
|
||||
<div className={styles.cta}>
|
||||
<a onClick={props.goToModerateAll}>Moderate comments on All Stories</a>
|
||||
</div>
|
||||
}
|
||||
<div className={styles.storyList}>
|
||||
|
||||
{
|
||||
props.moderation.storySearchString ? (
|
||||
<div className={styles.searchResults}>
|
||||
<Icon name="search" />
|
||||
<span className={styles.headlineRecent}>Search Results</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.searchResults}>
|
||||
<Icon name="access_time" />
|
||||
<span className={styles.headlineRecent}>Most Recent Stories</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
loading
|
||||
? <Spinner />
|
||||
: assets.map((story, i) => {
|
||||
const storyOpen = story.closedAt === null || new Date(story.closedAt) > new Date();
|
||||
return <Story
|
||||
key={i}
|
||||
id={story.id}
|
||||
title={story.title}
|
||||
createdAt={new Date(story.created_at).toISOString()}
|
||||
open={storyOpen}
|
||||
author={story.author}
|
||||
goToStory={props.goToStory}
|
||||
/>;
|
||||
})
|
||||
}
|
||||
|
||||
{assets.length === 0 && <div className={styles.noResults}>No results</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.overlay} onClick={props.clearAndCloseSearch} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
StorySearch.propTypes = {
|
||||
search: PropTypes.func.isRequired,
|
||||
goToStory: PropTypes.func.isRequired,
|
||||
clearAndCloseSearch: PropTypes.func.isRequired,
|
||||
moderation: PropTypes.object.isRequired,
|
||||
handleSearchChange: PropTypes.func.isRequired,
|
||||
assetId: PropTypes.string
|
||||
};
|
||||
|
||||
export default StorySearch;
|
||||
@@ -35,9 +35,8 @@
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
font-size: 16px;
|
||||
position: absolute;
|
||||
width: 90%;
|
||||
outline: none;
|
||||
width: calc(100% - 90px);
|
||||
}
|
||||
|
||||
.commentStatuses {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button, Drawer} from 'coral-ui';
|
||||
import styles from './UserDetail.css';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import Comment from './Comment';
|
||||
import styles from './UserDetail.css';
|
||||
import {Button, Drawer} from 'coral-ui';
|
||||
import {Slot} from 'coral-framework/components';
|
||||
import ButtonCopyToClipboard from './ButtonCopyToClipboard';
|
||||
import {actionsMap} from '../helpers/moderationQueueActionsMap';
|
||||
import ClickOutside from 'coral-framework/components/ClickOutside';
|
||||
|
||||
export default class UserDetail extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
hideUserDetail: PropTypes.func.isRequired,
|
||||
@@ -22,16 +25,6 @@ export default class UserDetail extends React.Component {
|
||||
bulkReject: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
copyPermalink = () => {
|
||||
this.profile.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch (e) {
|
||||
|
||||
/* nothing */
|
||||
}
|
||||
}
|
||||
|
||||
rejectThenReload = (info) => {
|
||||
this.props.rejectComment(info).then(() => {
|
||||
this.props.data.refetch();
|
||||
@@ -88,86 +81,92 @@ export default class UserDetail extends React.Component {
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer handleClickOutside={hideUserDetail}>
|
||||
<h3>{user.username}</h3>
|
||||
<Button className={styles.copyButton} onClick={this.copyPermalink}>Copy</Button>
|
||||
{profile && <input className={styles.profileEmail} readOnly type="text" ref={(ref) => this.profile = ref} value={profile} />}
|
||||
<Slot
|
||||
fill="userProfile"
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
user={user}
|
||||
/>
|
||||
<p className={styles.memberSince}><strong>Member since</strong> {new Date(user.created_at).toLocaleString()}</p>
|
||||
<hr/>
|
||||
<p>
|
||||
<strong>Account summary</strong>
|
||||
<br/><small className={styles.small}>Data represents the last six months of activity</small>
|
||||
</p>
|
||||
<div className={styles.stats}>
|
||||
<div className={styles.stat}>
|
||||
<p>Total Comments</p>
|
||||
<p>{totalComments}</p>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<p>Reject Rate</p>
|
||||
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
selectedIds.length === 0
|
||||
? (
|
||||
<ul className={styles.commentStatuses}>
|
||||
<li className={tab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
|
||||
<li className={tab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
|
||||
</ul>
|
||||
)
|
||||
: (
|
||||
<div className={styles.bulkActionGroup}>
|
||||
<Button
|
||||
onClick={bulkAccept}
|
||||
className={styles.bulkAction}
|
||||
cStyle='approve'
|
||||
icon='done'>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={bulkReject}
|
||||
className={styles.bulkAction}
|
||||
cStyle='reject'
|
||||
icon='close'>
|
||||
</Button>
|
||||
{`${selectedIds.length} comments selected`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<ClickOutside onClickOutside={hideUserDetail}>
|
||||
<Drawer onClose={hideUserDetail}>
|
||||
<h3>{user.username}</h3>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
{profile && <input className={styles.profileEmail} readOnly type="text" ref={(ref) => this.profile = ref} value={profile} />}
|
||||
<ButtonCopyToClipboard className={styles.copyButton} copyText={profile} />
|
||||
</div>
|
||||
|
||||
<Slot
|
||||
fill="userProfile"
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
user={user}
|
||||
/>
|
||||
<p className={styles.memberSince}><strong>Member since</strong> {new Date(user.created_at).toLocaleString()}</p>
|
||||
<hr/>
|
||||
<p>
|
||||
<strong>Account summary</strong>
|
||||
<br/><small className={styles.small}>Data represents the last six months of activity</small>
|
||||
</p>
|
||||
<div className={styles.stats}>
|
||||
<div className={styles.stat}>
|
||||
<p>Total Comments</p>
|
||||
<p>{totalComments}</p>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<p>Reject Rate</p>
|
||||
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
nodes.map((comment, i) => {
|
||||
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
|
||||
const selected = selectedIds.indexOf(comment.id) !== -1;
|
||||
return <Comment
|
||||
key={i}
|
||||
index={i}
|
||||
comment={comment}
|
||||
selected={false}
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
viewUserDetail={() => {}}
|
||||
actions={actionsMap[status]}
|
||||
showBanUserDialog={showBanUserDialog}
|
||||
showSuspendUserDialog={showSuspendUserDialog}
|
||||
acceptComment={this.acceptThenReload}
|
||||
rejectComment={this.rejectThenReload}
|
||||
selected={selected}
|
||||
toggleSelect={toggleSelect}
|
||||
currentAsset={null}
|
||||
currentUserId={this.props.id}
|
||||
minimal={true} />;
|
||||
})
|
||||
selectedIds.length === 0
|
||||
? (
|
||||
<ul className={styles.commentStatuses}>
|
||||
<li className={tab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
|
||||
<li className={tab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
|
||||
</ul>
|
||||
)
|
||||
: (
|
||||
<div className={styles.bulkActionGroup}>
|
||||
<Button
|
||||
onClick={bulkAccept}
|
||||
className={styles.bulkAction}
|
||||
cStyle='approve'
|
||||
icon='done'>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={bulkReject}
|
||||
className={styles.bulkAction}
|
||||
cStyle='reject'
|
||||
icon='close'>
|
||||
</Button>
|
||||
{`${selectedIds.length} comments selected`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<div>
|
||||
{
|
||||
nodes.map((comment, i) => {
|
||||
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
|
||||
const selected = selectedIds.indexOf(comment.id) !== -1;
|
||||
return <Comment
|
||||
key={comment.id}
|
||||
index={i}
|
||||
comment={comment}
|
||||
selected={false}
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
viewUserDetail={() => {}}
|
||||
actions={actionsMap[status]}
|
||||
showBanUserDialog={showBanUserDialog}
|
||||
showSuspendUserDialog={showSuspendUserDialog}
|
||||
acceptComment={this.acceptThenReload}
|
||||
rejectComment={this.rejectThenReload}
|
||||
selected={selected}
|
||||
toggleSelect={toggleSelect}
|
||||
currentAsset={null}
|
||||
currentUserId={this.props.id}
|
||||
minimal={true} />;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</Drawer>
|
||||
</ClickOutside>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,29 +116,20 @@ span {
|
||||
transition: background-color 200ms;
|
||||
opacity: 1;
|
||||
|
||||
&:first-child {
|
||||
text-align: left;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: #212121;
|
||||
}
|
||||
span {
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
max-width: 344px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: #212121;
|
||||
}
|
||||
span {
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
max-width: 344px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,8 +190,6 @@ span {
|
||||
}
|
||||
|
||||
&.selected {
|
||||
max-width: 720px;
|
||||
max-height: 410px;
|
||||
}
|
||||
|
||||
.context {
|
||||
@@ -479,3 +468,63 @@ span {
|
||||
.bulkSelectInput {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.emptyCardContainer {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.commentLeave {
|
||||
opacity: 1.0;
|
||||
transition: opacity 800ms;
|
||||
}
|
||||
|
||||
.commentLeaveActive {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.commentEnter {
|
||||
opacity: 0;
|
||||
transition: opacity 800ms;
|
||||
}
|
||||
|
||||
.commentEnterActive {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.bodyLeave {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
background-color: white;
|
||||
opacity: 1.0;
|
||||
transition: background 400ms, opacity 800ms 1600ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bodyLeaveActive {
|
||||
opacity: 0;
|
||||
background-color: rgba(255,255,0, 0.2);
|
||||
}
|
||||
|
||||
.bodyEnter {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bodyEnterActive {
|
||||
opacity: 1.0;
|
||||
transition: opacity 800ms 2400ms;
|
||||
}
|
||||
|
||||
.editedMarker {
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
line-height: 1px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.searchTrigger {
|
||||
position: relative;
|
||||
top: .3em;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default withFragments({
|
||||
status
|
||||
user {
|
||||
id
|
||||
name: username
|
||||
username
|
||||
status
|
||||
}
|
||||
asset {
|
||||
@@ -49,6 +49,9 @@ export default withFragments({
|
||||
}
|
||||
}
|
||||
}
|
||||
editing {
|
||||
edited
|
||||
}
|
||||
${pluginFragments.spreads('comment')}
|
||||
}
|
||||
${pluginFragments.definitions('comment')}
|
||||
|
||||
@@ -2,75 +2,137 @@ import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
import * as notification from 'coral-admin/src/services/notification';
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import update from 'immutability-helper';
|
||||
import truncate from 'lodash/truncate';
|
||||
import NotFoundAsset from '../components/NotFoundAsset';
|
||||
|
||||
import {withSetUserStatus, withSuspendUser, withSetCommentStatus} from 'coral-framework/graphql/mutations';
|
||||
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
|
||||
import {handleCommentChange} from '../../../graphql/utils';
|
||||
|
||||
import {fetchSettings} from 'actions/settings';
|
||||
import {updateAssets} from 'actions/assets';
|
||||
import {showBanUserDialog} from 'actions/banUserDialog';
|
||||
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
|
||||
import {
|
||||
toggleModal,
|
||||
singleView,
|
||||
showBanUserDialog,
|
||||
hideBanUserDialog,
|
||||
showSuspendUserDialog,
|
||||
hideSuspendUserDialog,
|
||||
hideShortcutsNote,
|
||||
toggleStorySearch,
|
||||
viewUserDetail,
|
||||
hideUserDetail,
|
||||
setSortOrder,
|
||||
storySearchChange,
|
||||
clearState
|
||||
} from 'actions/moderation';
|
||||
|
||||
import {Spinner} from 'coral-ui';
|
||||
import Moderation from '../components/Moderation';
|
||||
import Comment from './Comment';
|
||||
|
||||
function prepareNotificationText(text) {
|
||||
return truncate(text, {length: 50}).replace('\n', ' ');
|
||||
}
|
||||
|
||||
class ModerationContainer extends Component {
|
||||
subscriptions = [];
|
||||
|
||||
get activeTab() { return this.props.route.path; }
|
||||
|
||||
subscribeToUpdates(variables = this.props.data.variables) {
|
||||
const sub1 = this.props.data.subscribeToMore({
|
||||
document: COMMENT_ACCEPTED_SUBSCRIPTION,
|
||||
variables,
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => {
|
||||
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
|
||||
const sort = this.props.moderation.sortOrder;
|
||||
const notify = this.props.auth.user.id === user.id
|
||||
? {}
|
||||
: {
|
||||
activeQueue: this.activeTab,
|
||||
text: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body)),
|
||||
anyQueue: false,
|
||||
};
|
||||
return handleCommentChange(prev, comment, sort, notify);
|
||||
},
|
||||
});
|
||||
|
||||
const sub2 = this.props.data.subscribeToMore({
|
||||
document: COMMENT_REJECTED_SUBSCRIPTION,
|
||||
variables,
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => {
|
||||
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
|
||||
const sort = this.props.moderation.sortOrder;
|
||||
const notify = this.props.auth.user.id === user.id
|
||||
? {}
|
||||
: {
|
||||
activeQueue: this.activeTab,
|
||||
text: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body)),
|
||||
anyQueue: false,
|
||||
};
|
||||
return handleCommentChange(prev, comment, sort, notify);
|
||||
},
|
||||
});
|
||||
|
||||
const sub3 = this.props.data.subscribeToMore({
|
||||
document: COMMENT_EDITED_SUBSCRIPTION,
|
||||
variables,
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => {
|
||||
const sort = this.props.moderation.sortOrder;
|
||||
const notify = {
|
||||
activeQueue: this.activeTab,
|
||||
text: t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body)),
|
||||
anyQueue: false,
|
||||
};
|
||||
return handleCommentChange(prev, comment, sort, notify);
|
||||
},
|
||||
});
|
||||
|
||||
const sub4 = this.props.data.subscribeToMore({
|
||||
document: COMMENT_FLAGGED_SUBSCRIPTION,
|
||||
variables,
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => {
|
||||
const user = comment.actions[comment.actions.length - 1].user;
|
||||
const sort = this.props.moderation.sortOrder;
|
||||
const notify = {
|
||||
activeQueue: this.activeTab,
|
||||
text: t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)),
|
||||
anyQueue: true,
|
||||
};
|
||||
return handleCommentChange(prev, comment, sort, notify);
|
||||
},
|
||||
});
|
||||
|
||||
this.subscriptions.push(sub1, sub2, sub3, sub4);
|
||||
}
|
||||
|
||||
unsubscribe() {
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.subscriptions = [];
|
||||
}
|
||||
|
||||
resubscribe(variables) {
|
||||
this.unsubscribe();
|
||||
this.subscribeToUpdates(variables);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.props.clearState();
|
||||
this.props.fetchSettings();
|
||||
this.subscribeToUpdates();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const {updateAssets} = this.props;
|
||||
if(!isEqual(nextProps.root.assets, this.props.root.assets)) {
|
||||
updateAssets(nextProps.root.assets);
|
||||
}
|
||||
}
|
||||
|
||||
suspendUser = async (args) => {
|
||||
this.props.hideSuspendUserDialog();
|
||||
try {
|
||||
const result = await this.props.suspendUser(args);
|
||||
if (result.data.suspendUser.errors) {
|
||||
throw result.data.suspendUser.errors;
|
||||
}
|
||||
notification.success(
|
||||
t('suspenduser.notify_suspend_until',
|
||||
this.props.moderation.suspendUserDialog.username,
|
||||
timeago(args.until)),
|
||||
);
|
||||
const {commentStatus, commentId} = this.props.moderation.suspendUserDialog;
|
||||
if (commentStatus !== 'REJECTED') {
|
||||
return this.props.rejectComment({commentId})
|
||||
.then((result) => {
|
||||
if (result.data.setCommentStatus.errors) {
|
||||
throw result.data.setCommentStatus.errors;
|
||||
}
|
||||
});
|
||||
}
|
||||
// Resubscribe when we change between assets.
|
||||
if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) {
|
||||
this.resubscribe(nextProps.data.variables);
|
||||
}
|
||||
catch(err) {
|
||||
notification.showMutationErrors(err);
|
||||
}
|
||||
};
|
||||
|
||||
banUser = ({userId}) => {
|
||||
return this.props.setUserStatus({userId, status: 'BANNED'});
|
||||
}
|
||||
|
||||
acceptComment = ({commentId}) => {
|
||||
@@ -113,6 +175,9 @@ class ModerationContainer extends Component {
|
||||
return update(prev, {
|
||||
[tab]: {
|
||||
nodes: {$push: comments.nodes},
|
||||
hasNextPage: {$set: comments.hasNextPage},
|
||||
startCursor: {$set: comments.startCursor},
|
||||
endCursor: {$set: comments.endCursor},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -120,39 +185,100 @@ class ModerationContainer extends Component {
|
||||
};
|
||||
|
||||
render () {
|
||||
const {root, data} = this.props;
|
||||
const {root, root: {asset}, data, params: {id: assetId}} = this.props;
|
||||
|
||||
if (data.error) {
|
||||
return <div>Error</div>;
|
||||
}
|
||||
|
||||
if (!('premodCount' in root)) {
|
||||
return <div><Spinner/></div>;
|
||||
if (assetId) {
|
||||
if (asset === null) {
|
||||
|
||||
// Not found.
|
||||
return <NotFoundAsset assetId={assetId} />;
|
||||
}
|
||||
if (asset === undefined || asset.id !== assetId) {
|
||||
|
||||
// Still loading.
|
||||
return <Spinner />;
|
||||
}
|
||||
} else if(asset !== undefined || !('premodCount' in root)) {
|
||||
|
||||
// loading.
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return <Moderation
|
||||
{...this.props}
|
||||
loadMore={this.loadMore}
|
||||
banUser={this.banUser}
|
||||
acceptComment={this.acceptComment}
|
||||
rejectComment={this.rejectComment}
|
||||
suspendUser={this.suspendUser}
|
||||
activeTab={this.activeTab}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
const COMMENT_EDITED_SUBSCRIPTION = gql`
|
||||
subscription CommentEdited($asset_id: ID){
|
||||
commentEdited(asset_id: $asset_id){
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
}
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const COMMENT_FLAGGED_SUBSCRIPTION = gql`
|
||||
subscription CommentFlagged($asset_id: ID){
|
||||
commentFlagged(asset_id: $asset_id){
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
}
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const COMMENT_ACCEPTED_SUBSCRIPTION = gql`
|
||||
subscription CommentAccepted($asset_id: ID){
|
||||
commentAccepted(asset_id: $asset_id){
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
status_history {
|
||||
type
|
||||
created_at
|
||||
assigned_by {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const COMMENT_REJECTED_SUBSCRIPTION = gql`
|
||||
subscription CommentRejected($asset_id: ID){
|
||||
commentRejected(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: Date, $sort: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
|
||||
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sort: $sort, action_type: $action_type}) {
|
||||
nodes {
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
action_summaries {
|
||||
count
|
||||
... on FlagActionSummary {
|
||||
reason
|
||||
}
|
||||
}
|
||||
}
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
@@ -171,7 +297,7 @@ const commentConnectionFragment = gql`
|
||||
`;
|
||||
|
||||
const withModQueueQuery = withQuery(gql`
|
||||
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER) {
|
||||
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) {
|
||||
all: comments(query: {
|
||||
statuses: [NONE, PREMOD, ACCEPTED, REJECTED],
|
||||
asset_id: $asset_id,
|
||||
@@ -208,7 +334,7 @@ const withModQueueQuery = withQuery(gql`
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
assets: assets {
|
||||
asset(id: $asset_id) @skip(if: $allAssets) {
|
||||
id
|
||||
title
|
||||
url
|
||||
@@ -244,6 +370,7 @@ const withModQueueQuery = withQuery(gql`
|
||||
variables: {
|
||||
asset_id: id,
|
||||
sort: sortOrder,
|
||||
allAssets: id === null
|
||||
}
|
||||
};
|
||||
},
|
||||
@@ -287,31 +414,28 @@ const mapStateToProps = (state) => ({
|
||||
moderation: state.moderation.toJS(),
|
||||
settings: state.settings.toJS(),
|
||||
auth: state.auth.toJS(),
|
||||
assets: state.assets.get('assets')
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
...bindActionCreators({
|
||||
toggleModal,
|
||||
singleView,
|
||||
updateAssets,
|
||||
fetchSettings,
|
||||
showBanUserDialog,
|
||||
hideBanUserDialog,
|
||||
hideShortcutsNote,
|
||||
toggleStorySearch,
|
||||
showSuspendUserDialog,
|
||||
hideSuspendUserDialog,
|
||||
viewUserDetail,
|
||||
hideUserDetail,
|
||||
setSortOrder,
|
||||
storySearchChange,
|
||||
clearState
|
||||
}, dispatch),
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withSetCommentStatus,
|
||||
withSetUserStatus,
|
||||
withSuspendUser,
|
||||
withQueueCountPolling,
|
||||
withModQueueQuery,
|
||||
)(ModerationContainer);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import StorySearch from '../components/StorySearch';
|
||||
import {withRouter} from 'react-router';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
import {isEmpty} from 'lodash';
|
||||
|
||||
class StorySearchContainer extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
searchValue: props.moderation.storySearchString
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.storySearchChange('');
|
||||
}
|
||||
|
||||
clearSearch = () => {
|
||||
this.setState({searchValue: ''}, () => {
|
||||
this.search();
|
||||
});
|
||||
}
|
||||
|
||||
clearAndCloseSearch = () => {
|
||||
if (!isEmpty(this.state.searchValue)) {
|
||||
this.clearSearch();
|
||||
}
|
||||
this.props.closeSearch();
|
||||
}
|
||||
|
||||
handleSearchChange = (e) => {
|
||||
const {value} = e.target;
|
||||
this.setState({
|
||||
searchValue: value
|
||||
});
|
||||
}
|
||||
|
||||
handleEsc = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.clearAndCloseSearch();
|
||||
}
|
||||
}
|
||||
|
||||
handleEnter = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.search();
|
||||
}
|
||||
}
|
||||
|
||||
search = () => {
|
||||
const {searchValue} = this.state;
|
||||
this.props.storySearchChange(searchValue);
|
||||
}
|
||||
|
||||
goToStory = (id) => {
|
||||
const {router} = this.props;
|
||||
router.push(`/admin/moderate/all/${id}`);
|
||||
this.clearAndCloseSearch();
|
||||
}
|
||||
|
||||
goToModerateAll = () => {
|
||||
const {router} = this.props;
|
||||
router.push('/admin/moderate/all');
|
||||
this.clearAndCloseSearch();
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<StorySearch
|
||||
search={this.search}
|
||||
goToStory={this.goToStory}
|
||||
goToModerateAll={this.goToModerateAll}
|
||||
handleEsc={this.handleEsc}
|
||||
handleEnter={this.handleEnter}
|
||||
searchValue={this.state.searchValue}
|
||||
handleSearchChange={this.handleSearchChange}
|
||||
clearAndCloseSearch={this.clearAndCloseSearch}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const withAssetSearchQuery = withQuery(gql`
|
||||
query SearchStories($value: String = "") {
|
||||
assets(query: {value: $value, limit: 10}) {
|
||||
id
|
||||
title
|
||||
url
|
||||
created_at
|
||||
closedAt
|
||||
author
|
||||
}
|
||||
}
|
||||
`, {
|
||||
options: ({moderation: {storySearchString = ''}}) => {
|
||||
return {
|
||||
variables: {
|
||||
value: storySearchString
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export default compose(
|
||||
withRouter,
|
||||
withAssetSearchQuery
|
||||
)(StorySearchContainer);
|
||||
@@ -5,9 +5,9 @@ export const actionsMap = {
|
||||
};
|
||||
|
||||
export const menuActionsMap = {
|
||||
'REJECT': {status: 'REJECTED', text: 'reject', icon: 'close', key: 'r'},
|
||||
'REJECT': {status: 'REJECTED', text: 'reject', icon: 'close', key: 'f'},
|
||||
'REJECTED': {status: 'REJECTED', text: 'rejected', icon: 'close'},
|
||||
'APPROVE': {status: 'ACCEPTED', text: 'approve', icon: 'done', key: 't'},
|
||||
'APPROVE': {status: 'ACCEPTED', text: 'approve', icon: 'done', key: 'd'},
|
||||
'FLAGGED': {status: 'FLAGGED', text: 'flag', icon: 'flag', filter: 'Untouched'},
|
||||
'BAN': {status: 'BANNED', text: 'ban_user', icon: 'not interested'},
|
||||
'': {icon: 'done'}
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import ApolloClient, {addTypename} from 'apollo-client';
|
||||
import {networkInterface} from 'coral-framework/services/transport';
|
||||
import {getClient as getFrameworkClient} from 'coral-framework/services/client';
|
||||
import fragmentMatcher from './fragmentMatcher';
|
||||
|
||||
export const client = new ApolloClient({
|
||||
fragmentMatcher,
|
||||
connectToDevTools: true,
|
||||
addTypename: true,
|
||||
queryTransformer: addTypename,
|
||||
dataIdFromObject: (result) => {
|
||||
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
|
||||
return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle
|
||||
}
|
||||
return null;
|
||||
},
|
||||
networkInterface
|
||||
});
|
||||
|
||||
export default client;
|
||||
|
||||
export function getClient() {
|
||||
return getFrameworkClient({fragmentMatcher});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import mainReducer from '../reducers';
|
||||
import {client} from './client';
|
||||
import {getClient} from './client';
|
||||
|
||||
const middlewares = [
|
||||
applyMiddleware(client.middleware()),
|
||||
applyMiddleware(getClient().middleware()),
|
||||
applyMiddleware(thunk)
|
||||
];
|
||||
|
||||
@@ -16,7 +16,7 @@ if (window.devToolsExtension) {
|
||||
|
||||
const coralReducers = {
|
||||
...mainReducer,
|
||||
apollo: client.reducer()
|
||||
apollo: getClient().reducer()
|
||||
};
|
||||
|
||||
const store = createStore(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {pym} from 'coral-framework';
|
||||
import pym from 'coral-framework/services/pym';
|
||||
import * as actions from '../constants/stream';
|
||||
|
||||
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
|
||||
@@ -47,3 +47,7 @@ export const removeCommentClassName = (idx) => ({
|
||||
type: actions.REMOVE_COMMENT_CLASSNAME,
|
||||
idx
|
||||
});
|
||||
|
||||
export const setActiveTab = (tab) => (dispatch) => {
|
||||
dispatch({type: actions.SET_ACTIVE_TAB, tab});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import React from 'react';
|
||||
|
||||
import LoadMore from './LoadMore';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import NewCount from './NewCount';
|
||||
import {TransitionGroup} from 'react-transition-group';
|
||||
import {forEachError} from 'coral-framework/utils';
|
||||
import Comment from '../components/Comment';
|
||||
|
||||
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.
|
||||
function resetCursors(state, props) {
|
||||
const comments = props.root.asset.comments;
|
||||
if (comments && comments.nodes.length) {
|
||||
const idCursors = [comments.nodes[0].id];
|
||||
if (comments.nodes[1]) {
|
||||
idCursors.push(comments.nodes[1].id);
|
||||
}
|
||||
return {idCursors};
|
||||
}
|
||||
return {idCursors: []};
|
||||
}
|
||||
|
||||
// invalidateCursor is called whenever a comment is removed which is referenced
|
||||
// by one of the 2 id cursors. It returns a new set of id cursors calculated
|
||||
// using the help of the backup cursor.
|
||||
function invalidateCursor(invalidated, state, props) {
|
||||
const alt = invalidated === 1 ? 0 : 1;
|
||||
const comments = props.root.asset.comments;
|
||||
const idCursors = [];
|
||||
if (state.idCursors[alt]) {
|
||||
idCursors.push(state.idCursors[alt]);
|
||||
const index = comments.nodes.findIndex((node) => node.id === idCursors[0]);
|
||||
const nextInLine = comments.nodes[index + 1];
|
||||
if (nextInLine) {
|
||||
idCursors.push(nextInLine.id);
|
||||
}
|
||||
}
|
||||
return {idCursors};
|
||||
}
|
||||
|
||||
class AllCommentsPane extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
...resetCursors(this.state, props),
|
||||
loadingState: '',
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
const {comments: prevComments} = this.props;
|
||||
const {comments: nextComments} = next;
|
||||
|
||||
if (!prevComments && nextComments) {
|
||||
this.setState(resetCursors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
prevComments && nextComments &&
|
||||
nextComments.nodes.length < prevComments.nodes.length
|
||||
) {
|
||||
|
||||
// Invalidate first cursor if referenced comment was removed.
|
||||
if (this.state.idCursors[0] && !hasComment(nextComments.nodes, this.state.idCursors[0])) {
|
||||
this.setState(invalidateCursor(0, this.state, next));
|
||||
}
|
||||
|
||||
// Invalidate second cursor if referenced comment was removed.
|
||||
if (this.state.idCursors[1] && !hasComment(nextComments.nodes, this.state.idCursors[1])) {
|
||||
this.setState(invalidateCursor(1, this.state, next));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMore = () => {
|
||||
this.setState({loadingState: 'loading'});
|
||||
this.props.loadMore()
|
||||
.then(() => {
|
||||
this.setState({loadingState: 'success'});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({loadingState: 'error'});
|
||||
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
|
||||
});
|
||||
}
|
||||
|
||||
viewNewComments = () => {
|
||||
this.setState(resetCursors);
|
||||
};
|
||||
|
||||
// getVisibileComments returns a list containing comments
|
||||
// which were authored by current user or comes after the `idCursor`.
|
||||
getVisibleComments() {
|
||||
const {comments, currentUser: user} = this.props;
|
||||
const idCursor = this.state.idCursors[0];
|
||||
const userId = user ? user.id : null;
|
||||
|
||||
if (!comments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const view = [];
|
||||
let pastCursor = false;
|
||||
comments.nodes.forEach((comment) => {
|
||||
if (comment.id === idCursor) {
|
||||
pastCursor = true;
|
||||
}
|
||||
if (pastCursor || comment.user.id === userId) {
|
||||
view.push(comment);
|
||||
}
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
data,
|
||||
root,
|
||||
comments,
|
||||
commentClassNames,
|
||||
addTag,
|
||||
removeTag,
|
||||
ignoreUser,
|
||||
setActiveReplyBox,
|
||||
activeReplyBox,
|
||||
addNotification,
|
||||
disableReply,
|
||||
postComment,
|
||||
asset,
|
||||
currentUser,
|
||||
postFlag,
|
||||
postDontAgree,
|
||||
loadNewReplies,
|
||||
deleteAction,
|
||||
showSignInDialog,
|
||||
commentIsIgnored,
|
||||
charCountEnable,
|
||||
maxCharCount,
|
||||
editComment,
|
||||
} = this.props;
|
||||
|
||||
const {loadingState} = this.state;
|
||||
const view = this.getVisibleComments();
|
||||
|
||||
return (
|
||||
<div className="talk-stream-comments-container">
|
||||
<NewCount
|
||||
count={comments.nodes.length - view.length}
|
||||
loadMore={this.viewNewComments}
|
||||
/>
|
||||
<TransitionGroup component='div' className="embed__stream">
|
||||
{view.map((comment) => {
|
||||
return commentIsIgnored(comment)
|
||||
? <IgnoredCommentTombstone key={comment.id} />
|
||||
: <Comment
|
||||
commentClassNames={commentClassNames}
|
||||
data={data}
|
||||
root={root}
|
||||
disableReply={disableReply}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postComment={postComment}
|
||||
asset={asset}
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
ignoreUser={ignoreUser}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
loadMore={loadNewReplies}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
editComment={editComment}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
moreComments={asset.comments.hasNextPage}
|
||||
loadMore={this.loadMore}
|
||||
loadingState={loadingState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AllCommentsPane;
|
||||
@@ -1,11 +1,49 @@
|
||||
.Reply {
|
||||
.root {
|
||||
margin-left: 20px;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
margin-bottom: 15px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.Comment {
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
.rootLevel0:first-child {
|
||||
padding-top: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.rootLevel0 {
|
||||
margin-left: 0px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.comment {
|
||||
padding-left: 15px;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
}
|
||||
|
||||
.commentLevel0 {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.commentLevel1 {
|
||||
border-left: 3px #212121 solid;
|
||||
}
|
||||
|
||||
.commentLevel2 {
|
||||
border-left: 2px #6a6a6a solid;
|
||||
}
|
||||
|
||||
.commentLevel3 {
|
||||
border-left: 2px #9e9e9e solid;
|
||||
}
|
||||
|
||||
.commentLevel4 {
|
||||
border-left: 2px #c1c1c1 solid;
|
||||
}
|
||||
|
||||
.highlightedComment {
|
||||
padding-left: 15px;
|
||||
border-left: 3px solid rgb(35,118,216);
|
||||
}
|
||||
|
||||
.pendingComment {
|
||||
@@ -22,6 +60,13 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* element in the top right of the Comment */
|
||||
.topRight {
|
||||
float: right;
|
||||
@@ -111,3 +156,20 @@
|
||||
.enter {
|
||||
animation: enter 1000ms;
|
||||
}
|
||||
|
||||
.commentContainer {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.commentAvatar {
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
.commentAvatar:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timerIcon {
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
|
||||
import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton';
|
||||
import AuthorName from 'coral-plugin-author-name/AuthorName';
|
||||
import TagLabel from 'coral-plugin-tag-label/TagLabel';
|
||||
import PubDate from 'coral-plugin-pubdate/PubDate';
|
||||
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
|
||||
import FlagComment from 'coral-plugin-flags/FlagComment';
|
||||
import {FlagComment} from 'coral-plugin-flags';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
import {TransitionGroup} from 'react-transition-group';
|
||||
import cn from 'classnames';
|
||||
import styles from './Comment.css';
|
||||
import {THREADING_LEVEL} from '../constants/stream';
|
||||
import merge from 'lodash/merge';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
|
||||
import {
|
||||
BestButton,
|
||||
@@ -25,7 +27,8 @@ import CommentContent from './CommentContent';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import {EditableCommentContent} from './EditableCommentContent';
|
||||
import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils';
|
||||
import {getActionSummary, iPerformedThisAction, forEachError} from 'coral-framework/utils';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF');
|
||||
const hasTag = (tags, lookupTag) => !!tags.filter((t) => t.tag.name === lookupTag).length;
|
||||
@@ -75,6 +78,7 @@ const ActionButton = ({children}) => {
|
||||
};
|
||||
|
||||
export default class Comment extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -88,6 +92,7 @@ export default class Comment extends React.Component {
|
||||
isEditing: false,
|
||||
replyBoxVisible: false,
|
||||
animateEnter: false,
|
||||
loadingState: '',
|
||||
...resetCursors({}, props),
|
||||
};
|
||||
}
|
||||
@@ -129,7 +134,6 @@ export default class Comment extends React.Component {
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
reactKey: PropTypes.string.isRequired,
|
||||
|
||||
// id of currently opened ReplyBox. tracked in Stream.js
|
||||
activeReplyBox: PropTypes.string.isRequired,
|
||||
@@ -143,12 +147,8 @@ export default class Comment extends React.Component {
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
depth: PropTypes.number.isRequired,
|
||||
liveUpdates: PropTypes.bool.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
url: PropTypes.string
|
||||
}).isRequired,
|
||||
liveUpdates: PropTypes.bool,
|
||||
asset: PropTypes.object.isRequired,
|
||||
currentUser: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired
|
||||
}),
|
||||
@@ -167,7 +167,7 @@ export default class Comment extends React.Component {
|
||||
replies: PropTypes.object,
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
username: PropTypes.string.isRequired
|
||||
}).isRequired,
|
||||
editing: PropTypes.shape({
|
||||
edited: PropTypes.bool,
|
||||
@@ -193,8 +193,16 @@ export default class Comment extends React.Component {
|
||||
editComment: React.PropTypes.func,
|
||||
}
|
||||
|
||||
editComment = (...args) => {
|
||||
return this.props.editComment(this.props.comment.id, this.props.asset.id, ...args);
|
||||
}
|
||||
|
||||
onClickEdit (e) {
|
||||
e.preventDefault();
|
||||
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
|
||||
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
|
||||
return;
|
||||
}
|
||||
this.setState({isEditing: true});
|
||||
}
|
||||
|
||||
@@ -212,9 +220,18 @@ export default class Comment extends React.Component {
|
||||
loadNewReplies = () => {
|
||||
const {replies, replyCount, id} = this.props.comment;
|
||||
if (replyCount > replies.nodes.length) {
|
||||
this.props.loadMore(id).then(() => {
|
||||
this.setState(resetCursors(this.state, this.props));
|
||||
});
|
||||
this.setState({loadingState: 'loading'});
|
||||
this.props.loadMore(id)
|
||||
.then(() => {
|
||||
this.setState({
|
||||
...resetCursors(this.state, this.props),
|
||||
loadingState: 'success',
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({loadingState: 'error'});
|
||||
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState(resetCursors);
|
||||
@@ -227,7 +244,8 @@ export default class Comment extends React.Component {
|
||||
}
|
||||
if (can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
|
||||
this.props.setActiveReplyBox(this.props.comment.id);
|
||||
return;
|
||||
} else {
|
||||
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -296,6 +314,7 @@ export default class Comment extends React.Component {
|
||||
postDontAgree,
|
||||
setActiveReplyBox,
|
||||
activeReplyBox,
|
||||
loadMore,
|
||||
deleteAction,
|
||||
disableReply,
|
||||
maxCharCount,
|
||||
@@ -310,9 +329,12 @@ export default class Comment extends React.Component {
|
||||
} = this.props;
|
||||
|
||||
const view = this.getVisibileReplies();
|
||||
const {loadingState} = this.state;
|
||||
const isPending = comment.id.indexOf('pending') >= 0;
|
||||
const isHighlighted = highlighted === comment.id;
|
||||
|
||||
const hasMoreComments = comment.replies && (comment.replies.hasNextPage || comment.replies.nodes.length > view.length);
|
||||
const replyCount = this.hasIgnoredReplies() ? '' : comment.replyCount;
|
||||
const moreRepliesCount = this.hasIgnoredReplies() ? -1 : comment.replyCount - view.length;
|
||||
const flagSummary = getActionSummary('FlagActionSummary', comment);
|
||||
const dontAgreeSummary = getActionSummary(
|
||||
'DontAgreeActionSummary',
|
||||
@@ -325,11 +347,6 @@ export default class Comment extends React.Component {
|
||||
myFlag = dontAgreeSummary.find((s) => s.current_user);
|
||||
}
|
||||
|
||||
let commentClass = parentId
|
||||
? `reply ${styles.Reply}`
|
||||
: `comment ${styles.Comment}`;
|
||||
commentClass += comment.id.indexOf('pending') >= 0 ? ` ${styles.pendingComment}` : '';
|
||||
|
||||
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
|
||||
const notifyOnError = (fn, errorToMessage) =>
|
||||
async function(...args) {
|
||||
@@ -349,7 +366,7 @@ export default class Comment extends React.Component {
|
||||
addTag({
|
||||
id: comment.id,
|
||||
name: BEST_TAG,
|
||||
assetId: asset.id
|
||||
assetId: asset.id,
|
||||
}),
|
||||
() => 'Failed to tag comment as best'
|
||||
);
|
||||
@@ -359,13 +376,13 @@ export default class Comment extends React.Component {
|
||||
removeTag({
|
||||
id: comment.id,
|
||||
name: BEST_TAG,
|
||||
assetId: asset.id
|
||||
assetId: asset.id,
|
||||
}),
|
||||
() => 'Failed to remove best comment tag'
|
||||
);
|
||||
|
||||
/**
|
||||
* classNamesToAdd
|
||||
* conditionClassNames
|
||||
* adds classNames based on condition
|
||||
* classnames is an array of objects with key as classnames and value as conditions
|
||||
* i.e:
|
||||
@@ -375,55 +392,48 @@ export default class Comment extends React.Component {
|
||||
*
|
||||
* This will add myClassName to comments tagged with STAFF TAG.
|
||||
* **/
|
||||
|
||||
const classNamesToAdd = commentClassNames.reduce((acc, className) => {
|
||||
let res = [];
|
||||
|
||||
// Adding classNames based on tags
|
||||
Object.keys(className).forEach((cn) => {
|
||||
const condition = className[cn];
|
||||
condition.tags.forEach((tag) => {
|
||||
if (hasTag(comment.tags, tag)) {
|
||||
res = [...res, cn];
|
||||
}
|
||||
});
|
||||
const conditionalClassNames =
|
||||
mapValues(merge({}, ...commentClassNames), (condition) => {
|
||||
if (condition.tags) {
|
||||
return condition.tags.some((tag) => hasTag(comment.tags, tag));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// TODO: Compare rest of the comment obj to the condition if needed
|
||||
const rootClassName = cn(
|
||||
'talk-stream-comment-wrapper',
|
||||
`talk-stream-comment-wrapper-level-${depth}`,
|
||||
styles.root,
|
||||
styles[`rootLevel${depth}`],
|
||||
{
|
||||
...conditionalClassNames,
|
||||
[styles.enter]: this.state.animateEnter,
|
||||
},
|
||||
);
|
||||
|
||||
return [...acc, ...res];
|
||||
}, []);
|
||||
const commentClassName = cn(
|
||||
'talk-stream-comment',
|
||||
`talk-stream-comment-level-${depth}`,
|
||||
styles.comment,
|
||||
styles[`commentLevel${depth}`],
|
||||
{
|
||||
[styles.pendingComment]: isPending,
|
||||
[styles.highlightedComment]: isHighlighted,
|
||||
'talk-stream-pending-comment': isPending,
|
||||
'talk-stream-highlighted-comment': isHighlighted,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(commentClass, 'talk-stream-comment-wrapper', classNamesToAdd, {[styles.enter]: this.state.animateEnter})}
|
||||
className={rootClassName}
|
||||
id={`c_${comment.id}`}
|
||||
style={{marginLeft: depth * 30}}
|
||||
>
|
||||
<hr aria-hidden={true} />
|
||||
<div
|
||||
className={highlighted === comment.id ? 'highlighted-comment' : ''}
|
||||
>
|
||||
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
|
||||
{commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
: null }
|
||||
|
||||
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
|
||||
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>(Edited)</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
<div className={commentClassName}>
|
||||
|
||||
<Slot
|
||||
className={styles.commentInfoBar}
|
||||
fill="commentInfoBar"
|
||||
depth={depth}
|
||||
className={styles.commentAvatar}
|
||||
fill="commentAvatar"
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
data={this.props.data}
|
||||
@@ -431,156 +441,189 @@ export default class Comment extends React.Component {
|
||||
inline
|
||||
/>
|
||||
|
||||
{ (currentUser &&
|
||||
(comment.user.id === currentUser.id))
|
||||
<div className={styles.commentContainer}>
|
||||
|
||||
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
|
||||
{commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
: null }
|
||||
|
||||
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
|
||||
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
|
||||
<Slot
|
||||
className={styles.commentInfoBar}
|
||||
fill="commentInfoBar"
|
||||
depth={depth}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
inline
|
||||
/>
|
||||
|
||||
{ (currentUser && (comment.user.id === currentUser.id)) &&
|
||||
|
||||
/* User can edit/delete their own comment for a short window after posting */
|
||||
? <span className={cn(styles.topRight)}>
|
||||
{
|
||||
commentIsStillEditable(comment) &&
|
||||
<a
|
||||
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
|
||||
onClick={this.onClickEdit}>Edit</a>
|
||||
}
|
||||
</span>
|
||||
<span className={cn(styles.topRight)}>
|
||||
{
|
||||
commentIsStillEditable(comment) &&
|
||||
<a
|
||||
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
|
||||
onClick={this.onClickEdit}>Edit</a>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
{ (currentUser && (comment.user.id !== currentUser.id)) &&
|
||||
|
||||
/* TopRightMenu allows currentUser to ignore other users' comments */
|
||||
: <span className={cn(styles.topRight, styles.topRightMenu)}>
|
||||
/* TopRightMenu allows currentUser to ignore other users' comments */
|
||||
<span className={cn(styles.topRight, styles.topRightMenu)}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification} />
|
||||
</span>
|
||||
}
|
||||
}
|
||||
{
|
||||
this.state.isEditing
|
||||
? <EditableCommentContent
|
||||
editComment={this.editComment}
|
||||
addNotification={addNotification}
|
||||
comment={comment}
|
||||
currentUser={currentUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
parentId={parentId}
|
||||
stopEditing={this.stopEditing}
|
||||
/>
|
||||
: <div>
|
||||
<Slot fill="commentContent" comment={comment} defaultComponent={CommentContent} />
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
this.state.isEditing
|
||||
? <EditableCommentContent
|
||||
editComment={this.props.editComment.bind(null, comment.id, asset.id)}
|
||||
addNotification={addNotification}
|
||||
asset={asset}
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<Slot
|
||||
fill="commentReactions"
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
comment={comment}
|
||||
currentUser={currentUser}
|
||||
maxCharCount={maxCharCount}
|
||||
parentId={parentId}
|
||||
stopEditing={this.stopEditing}
|
||||
/>
|
||||
: <div>
|
||||
<Slot fill="commentContent" comment={comment} defaultComponent={CommentContent} />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<Slot
|
||||
fill="commentReactions"
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
<ActionButton>
|
||||
<IfUserCanModifyBest user={currentUser}>
|
||||
<BestButton
|
||||
isBest={commentIsBest(comment)}
|
||||
addBest={addBestTag}
|
||||
removeBest={removeBestTag}
|
||||
/>
|
||||
</IfUserCanModifyBest>
|
||||
</ActionButton>
|
||||
{!disableReply &&
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
<ActionButton>
|
||||
<ReplyButton
|
||||
onClick={this.showReplyBox}
|
||||
parentCommentId={parentId || comment.id}
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
/>
|
||||
</ActionButton>}
|
||||
<Slot
|
||||
fill="commentActions"
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<ActionButton>
|
||||
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
|
||||
</ActionButton>
|
||||
<ActionButton>
|
||||
<FlagComment
|
||||
flaggedByCurrentUser={!!myFlag}
|
||||
flag={myFlag}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{activeReplyBox === comment.id
|
||||
? <ReplyBox
|
||||
commentPostedHandler={() => {
|
||||
setActiveReplyBox('');
|
||||
}}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
parentId={parentId || comment.id}
|
||||
addNotification={addNotification}
|
||||
authorId={currentUser.id}
|
||||
postComment={postComment}
|
||||
assetId={asset.id}
|
||||
/>
|
||||
: null}
|
||||
|
||||
<TransitionGroup>
|
||||
{view.map((reply) => {
|
||||
return commentIsIgnored(reply)
|
||||
? <IgnoredCommentTombstone key={reply.id} />
|
||||
: <Comment
|
||||
<IfUserCanModifyBest user={currentUser}>
|
||||
<BestButton
|
||||
isBest={commentIsBest(comment)}
|
||||
addBest={addBestTag}
|
||||
removeBest={removeBestTag}
|
||||
/>
|
||||
</IfUserCanModifyBest>
|
||||
</ActionButton>
|
||||
{!disableReply &&
|
||||
<ActionButton>
|
||||
<ReplyButton
|
||||
onClick={this.showReplyBox}
|
||||
parentCommentId={parentId || comment.id}
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
/>
|
||||
</ActionButton>}
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<Slot
|
||||
fill="commentActions"
|
||||
wrapperComponent={ActionButton}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
disableReply={disableReply}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postComment={postComment}
|
||||
editComment={this.props.editComment}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
ignoreUser={ignoreUser}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
<ActionButton>
|
||||
<FlagComment
|
||||
flaggedByCurrentUser={!!myFlag}
|
||||
flag={myFlag}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
addNotification={addNotification}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeReplyBox === comment.id
|
||||
? <ReplyBox
|
||||
commentPostedHandler={() => {
|
||||
setActiveReplyBox('');
|
||||
}}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
showSignInDialog={showSignInDialog}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
liveUpdates={liveUpdates}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<div className="coral-load-more-replies">
|
||||
<LoadMore
|
||||
topLevel={false}
|
||||
replyCount={replyCount}
|
||||
moreComments={hasMoreComments}
|
||||
loadMore={this.loadNewReplies}
|
||||
/>
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
parentId={(depth < THREADING_LEVEL) ? comment.id : parentId}
|
||||
addNotification={addNotification}
|
||||
postComment={postComment}
|
||||
currentUser={currentUser}
|
||||
assetId={asset.id}
|
||||
/>
|
||||
: null}
|
||||
|
||||
<TransitionGroup>
|
||||
{view.map((reply) => {
|
||||
return commentIsIgnored(reply)
|
||||
? <IgnoredCommentTombstone key={reply.id} />
|
||||
: <Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
disableReply={disableReply}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postComment={postComment}
|
||||
editComment={this.props.editComment}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
loadMore={loadMore}
|
||||
ignoreUser={ignoreUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
showSignInDialog={showSignInDialog}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
liveUpdates={liveUpdates}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<div className="talk-load-more-replies">
|
||||
<LoadMore
|
||||
topLevel={false}
|
||||
replyCount={moreRepliesCount}
|
||||
moreComments={hasMoreComments}
|
||||
loadMore={this.loadNewReplies}
|
||||
loadingState={loadingState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user