mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60b4909395 | ||
|
|
0973600e68 | ||
|
|
e3ead31964 | ||
|
|
7858ccc45d | ||
|
|
c1807daeaf | ||
|
|
e244d4a286 | ||
|
|
9de99a9ef4 | ||
|
|
01883851ea | ||
|
|
c8d9447033 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
0bfd4f8797 | ||
|
|
a088827568 | ||
|
|
5ecdfe94cc | ||
|
|
648b46e5af | ||
|
|
e160dfaa3e | ||
|
|
fb910efbc0 | ||
|
|
8dd8544154 | ||
|
|
b053be8e4d | ||
|
|
035c00ee09 | ||
|
|
69ab46d32a | ||
|
|
c30d05fd6c | ||
|
|
3658a804b0 | ||
|
|
b40d592819 | ||
|
|
25376bd00f | ||
|
|
787ba75ede | ||
|
|
60905bb99d | ||
|
|
fd875031dd | ||
|
|
687b135adf | ||
|
|
82acab2726 | ||
|
|
987d4a5610 | ||
|
|
4536d218d2 | ||
|
|
5717f0bf3d | ||
|
|
d17144d436 | ||
|
|
7d67566f00 | ||
|
|
5633dd27fc | ||
|
|
fd2fcb4ed9 | ||
|
|
b8c5461b90 | ||
|
|
cdfa8379ff | ||
|
|
99745723fb | ||
|
|
b6237892d7 | ||
|
|
7d8f2aa876 | ||
|
|
aed265efcc | ||
|
|
00d1b0e6f5 | ||
|
|
65f7ee2ab3 | ||
|
|
c902a4b05b | ||
|
|
8a3833ec11 | ||
|
|
d45c396013 | ||
|
|
d84ddfe1ec | ||
|
|
efc4db56d0 | ||
|
|
32da779ac1 | ||
|
|
09d74e8fd6 | ||
|
|
9fc139964f | ||
|
|
681e25ebdb | ||
|
|
e2fdba4898 | ||
|
|
ce76335fda | ||
|
|
70177d3a2c | ||
|
|
d628c26693 | ||
|
|
2a283a9a5a | ||
|
|
74ad3c3a35 | ||
|
|
c7d5ab935f | ||
|
|
cc4c943a21 | ||
|
|
aa289ecbfe | ||
|
|
dfd580bf7c | ||
|
|
de665b3a0d | ||
|
|
f929059daf | ||
|
|
f2f5ddd131 | ||
|
|
1fbeacdc36 | ||
|
|
3c58888129 | ||
|
|
b5065bad3e | ||
|
|
f6012526b3 | ||
|
|
452b2c3cdb | ||
|
|
fc64a98cb7 | ||
|
|
8d24dcd58d | ||
|
|
5d59f2923e | ||
|
|
291d3b583b | ||
|
|
c7ca3c5e4a | ||
|
|
66c9e0e232 | ||
|
|
1d3cc906eb | ||
|
|
3bf35bdc7f | ||
|
|
99174565fa | ||
|
|
8d2c1b7247 | ||
|
|
399f1e7458 | ||
|
|
140f980c14 | ||
|
|
32708e9098 | ||
|
|
d0b3845d0f | ||
|
|
dbb0a68766 | ||
|
|
3545bf01cd | ||
|
|
4e5680577e | ||
|
|
428246cbe2 | ||
|
|
a9a24b96c0 | ||
|
|
1153c52c2c | ||
|
|
995dd9fd60 | ||
|
|
007b9604af | ||
|
|
57747e8f13 | ||
|
|
27445f7b19 | ||
|
|
b06a58ebe1 | ||
|
|
b95e20e493 | ||
|
|
5a89ca51f5 | ||
|
|
80900a98fd | ||
|
|
d55f22b09c | ||
|
|
9b839b76f5 | ||
|
|
8eb8010a33 | ||
|
|
40709c5b79 | ||
|
|
6bc5793e57 | ||
|
|
490a5d3263 | ||
|
|
1c23f72807 | ||
|
|
8c1eaf09bc | ||
|
|
7e5adcd477 | ||
|
|
283c13fae8 | ||
|
|
6e82c180f7 | ||
|
|
8d5085b7a3 | ||
|
|
ebbbe72d15 | ||
|
|
f6798af74d | ||
|
|
1f261f5216 | ||
|
|
81f3cc808c | ||
|
|
480bab3c1a | ||
|
|
3a285d92a5 | ||
|
|
177edf8ed8 | ||
|
|
55928dff08 | ||
|
|
82a72e506f | ||
|
|
9695db0678 | ||
|
|
f3dcfd23f3 | ||
|
|
655e53a35c | ||
|
|
15b5dc2236 | ||
|
|
38077cf3cf | ||
|
|
ef43ba3a35 | ||
|
|
d61ede2396 | ||
|
|
fb39446c6a | ||
|
|
5d28f611b2 | ||
|
|
a40dd04c1b | ||
|
|
06da4d7d26 | ||
|
|
9818b851db | ||
|
|
63a35cbceb | ||
|
|
4cbc8b2226 | ||
|
|
6827ff2f1f | ||
|
|
cf82f5b707 | ||
|
|
1e904584e7 | ||
|
|
0007773b4d | ||
|
|
ec03c6b48c | ||
|
|
8f219c47a7 | ||
|
|
132b0a854d | ||
|
|
17e7b28688 | ||
|
|
b795368674 | ||
|
|
f57fa7cb7b | ||
|
|
7a256bdd2b | ||
|
|
6664f73d2d | ||
|
|
fb0e89485c | ||
|
|
b2f1707f17 | ||
|
|
9db77360b4 | ||
|
|
9dc0693530 | ||
|
|
54eb078a13 | ||
|
|
793f634959 | ||
|
|
c59346eb0a | ||
|
|
d02412a734 | ||
|
|
c3d02a14a0 | ||
|
|
820ffb14c1 | ||
|
|
3eea5e1a8a | ||
|
|
ff6a9cf0d5 | ||
|
|
b60ed166d0 | ||
|
|
0a8bfe8a38 | ||
|
|
e6b2ff19b8 | ||
|
|
e2f6d5989a | ||
|
|
0d957bcfb9 | ||
|
|
899805b956 | ||
|
|
aeaf809a0f | ||
|
|
d815d9dc02 | ||
|
|
f1e4f98055 | ||
|
|
0d47ee895c | ||
|
|
2e1bc8d75a | ||
|
|
b8e530ca1f | ||
|
|
da0cebbda7 | ||
|
|
7c09902294 | ||
|
|
96cd5197e3 | ||
|
|
743a96cd21 | ||
|
|
c4c0ee79cd | ||
|
|
6310528804 | ||
|
|
a90e1e132b | ||
|
|
d262663e3f | ||
|
|
85fae3104e | ||
|
|
607ab6c0d6 | ||
|
|
c72cb2428e | ||
|
|
d7e72bb138 | ||
|
|
cb09c50820 | ||
|
|
28920eb357 | ||
|
|
3fcf21f949 | ||
|
|
058aa3f907 | ||
|
|
c4ffe58f58 | ||
|
|
0a4fd15fa2 | ||
|
|
b52325f8e9 | ||
|
|
91af8c2ece | ||
|
|
962a9bd6c9 | ||
|
|
183dab9826 | ||
|
|
8458a9aecc | ||
|
|
bd72bfc6ec | ||
|
|
1137fca3b4 |
@@ -11,4 +11,6 @@ plugins/*
|
||||
!plugins/coral-plugin-mod
|
||||
!plugins/coral-plugin-love
|
||||
!plugins/coral-plugin-viewing-options
|
||||
!plugins/coral-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
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
|
||||
|
||||
@@ -24,5 +24,7 @@ plugins/*
|
||||
!plugins/coral-plugin-mod
|
||||
!plugins/coral-plugin-love
|
||||
!plugins/coral-plugin-viewing-options
|
||||
!plugins/coral-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
|
||||
**/node_modules/*
|
||||
|
||||
+1
-501
@@ -1,503 +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: `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,17 @@ 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).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -40,10 +50,15 @@ 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.
|
||||
|
||||
### Running Migrations
|
||||
|
||||
We have a migration tool that can be run using `bin/cli migration run`. This will detect new migrations available and prompt you to backup your database before proceeding with the migration. Migrations are required with major version releases.
|
||||
|
||||
### Using Trust
|
||||
|
||||
Talk ships with core components we call "Trust". This allows Talk to automate certain actions based on previous user behavior.
|
||||
|
||||
@@ -12,7 +12,9 @@ program
|
||||
.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')
|
||||
.parse(process.argv);
|
||||
|
||||
|
||||
+108
-48
@@ -8,9 +8,12 @@ const program = require('./commander');
|
||||
const parseDuration = require('ms');
|
||||
const Table = require('cli-table');
|
||||
const AssetModel = require('../models/asset');
|
||||
const CommentModel = require('../models/comment');
|
||||
const AssetsService = require('../services/assets');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const scraper = require('../services/scraper');
|
||||
const util = require('./util');
|
||||
const inquirer = require('inquirer');
|
||||
|
||||
// Register the shutdown criteria.
|
||||
util.onshutdown([
|
||||
@@ -20,65 +23,112 @@ util.onshutdown([
|
||||
/**
|
||||
* Lists all the assets registered in the database.
|
||||
*/
|
||||
function listAssets() {
|
||||
AssetModel
|
||||
.find({})
|
||||
.sort({'created_at': 1})
|
||||
.then((asset) => {
|
||||
let table = new Table({
|
||||
head: [
|
||||
'ID',
|
||||
'Title',
|
||||
'URL'
|
||||
]
|
||||
});
|
||||
async function listAssets() {
|
||||
try {
|
||||
let assets = await AssetModel.find({}).sort({'created_at': 1});
|
||||
|
||||
asset.forEach((asset) => {
|
||||
table.push([
|
||||
asset.id,
|
||||
asset.title ? asset.title : '',
|
||||
asset.url ? asset.url : ''
|
||||
]);
|
||||
});
|
||||
|
||||
console.log(table.toString());
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
let table = new Table({
|
||||
head: [
|
||||
'ID',
|
||||
'Title',
|
||||
'URL'
|
||||
]
|
||||
});
|
||||
|
||||
assets.forEach((asset) => {
|
||||
table.push([
|
||||
asset.id,
|
||||
asset.title ? asset.title : '',
|
||||
asset.url ? asset.url : ''
|
||||
]);
|
||||
});
|
||||
|
||||
console.log(table.toString());
|
||||
util.shutdown();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAssets(ageString) {
|
||||
const now = new Date().getTime();
|
||||
const ageMs = parseDuration(ageString);
|
||||
const age = new Date(now - ageMs);
|
||||
async function refreshAssets(ageString) {
|
||||
try {
|
||||
const now = new Date().getTime();
|
||||
const ageMs = parseDuration(ageString);
|
||||
const age = new Date(now - ageMs);
|
||||
|
||||
AssetModel.find({
|
||||
$or: [
|
||||
{
|
||||
scraped: {
|
||||
$lte: age
|
||||
let assets = await AssetModel.find({
|
||||
$or: [
|
||||
{
|
||||
scraped: {
|
||||
$lte: age
|
||||
}
|
||||
},
|
||||
{
|
||||
scraped: null
|
||||
}
|
||||
},
|
||||
{
|
||||
scraped: null
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Queue all the assets for scraping.
|
||||
.then((assets) => Promise.all(assets.map(scraper.create)))
|
||||
// Queue all the assets for scraping.
|
||||
await Promise.all(assets.map(scraper.create));
|
||||
|
||||
.then(() => {
|
||||
console.log('Assets were queued to be scraped');
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
util.shutdown(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function updateURL(assetID, assetURL) {
|
||||
try {
|
||||
await AssetsService.updateURL(assetID, assetURL);
|
||||
|
||||
console.log(`Asset ${assetID} was updated to have url ${assetURL}.`);
|
||||
util.shutdown();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function merge(srcID, dstID) {
|
||||
try {
|
||||
|
||||
// Grab the assets...
|
||||
let [srcAsset, dstAsset] = await AssetsService.findByIDs([srcID, dstID]);
|
||||
if (!srcAsset || !dstAsset) {
|
||||
throw new Error('Not all assets indicated by id exist, cannot merge');
|
||||
}
|
||||
|
||||
// Count the affected resources...
|
||||
let srcCommentCount = await CommentModel.find({asset_id: srcID}).count();
|
||||
|
||||
console.log(`Now going to update ${srcCommentCount} comments and delete the source Asset[${srcID}].`);
|
||||
|
||||
let {confirm} = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: 'Proceed with merge',
|
||||
default: false
|
||||
}
|
||||
]);
|
||||
|
||||
if (confirm) {
|
||||
|
||||
// Perform the merge!
|
||||
await AssetsService.merge(srcID, dstID);
|
||||
} else {
|
||||
console.warn('Aborting merge');
|
||||
}
|
||||
|
||||
util.shutdown(0);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
@@ -95,6 +145,16 @@ program
|
||||
.description('queues the assets that exceed the age requested')
|
||||
.action(refreshAssets);
|
||||
|
||||
program
|
||||
.command('update-url <assetID> <url>')
|
||||
.description('update the URL of an asset')
|
||||
.action(updateURL);
|
||||
|
||||
program
|
||||
.command('merge <srcID> <dstID>')
|
||||
.description('merges two assets together by moving comments from src to dst and deleting the src asset')
|
||||
.action(merge);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
// If there is no command listed, output help.
|
||||
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('./commander');
|
||||
const util = require('./util');
|
||||
const inquirer = require('inquirer');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const MigrationService = require('../services/migration');
|
||||
|
||||
// Register shutdown hooks.
|
||||
util.onshutdown([
|
||||
() => mongoose.disconnect()
|
||||
]);
|
||||
|
||||
async function createMigration(name) {
|
||||
try {
|
||||
|
||||
// Create the migration.
|
||||
await MigrationService.create(name);
|
||||
|
||||
util.shutdown();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function runMigrations() {
|
||||
|
||||
try {
|
||||
|
||||
let {backedUp} = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'backedUp',
|
||||
message: 'Did you perform a database backup',
|
||||
default: false
|
||||
}
|
||||
]);
|
||||
|
||||
if (!backedUp) {
|
||||
throw new Error('Please backup your databases prior to migrations occuring');
|
||||
}
|
||||
|
||||
// Get the migrations to run.
|
||||
let migrations = await MigrationService.listPending();
|
||||
|
||||
console.log('Now going to run the following migrations:\n');
|
||||
|
||||
for (let {filename} of migrations) {
|
||||
console.log(`\tmigrations/${filename}`);
|
||||
}
|
||||
|
||||
let {confirm} = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: 'Proceed with migrations',
|
||||
default: false
|
||||
}
|
||||
]);
|
||||
|
||||
if (confirm) {
|
||||
|
||||
// Run the migrations.
|
||||
await MigrationService.run(migrations);
|
||||
} else {
|
||||
console.warn('Skipping migrations');
|
||||
}
|
||||
|
||||
util.shutdown();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.command('create <name>')
|
||||
.description('creates a new migration')
|
||||
.action(createMigration);
|
||||
|
||||
program
|
||||
.command('run')
|
||||
.description('runs all pending migrations')
|
||||
.action(runMigrations);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
// If there is no command listed, output help.
|
||||
if (process.argv.length <= 2) {
|
||||
program.outputHelp();
|
||||
util.shutdown();
|
||||
}
|
||||
+44
-1
@@ -2,9 +2,13 @@
|
||||
|
||||
const program = require('./commander');
|
||||
const app = require('../app');
|
||||
const debug = require('debug')('talk:cli:serve');
|
||||
const errors = require('../errors');
|
||||
const {createServer} = require('http');
|
||||
const scraper = require('../services/scraper');
|
||||
const mailer = require('../services/mailer');
|
||||
const MigrationService = require('../services/migration');
|
||||
const SetupService = require('../services/setup');
|
||||
const kue = require('../services/kue');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const util = require('./util');
|
||||
@@ -87,7 +91,46 @@ function onListening() {
|
||||
/**
|
||||
* Start the app.
|
||||
*/
|
||||
function startApp(program) {
|
||||
async function startApp(program) {
|
||||
|
||||
try {
|
||||
|
||||
// Check to see if the application is installed. If the application
|
||||
// has been installed, then it will throw errors.ErrSettingsNotInit, this
|
||||
// just means we don't have to check that the migrations have run.
|
||||
await SetupService.isAvailable();
|
||||
|
||||
debug('setup is currently available, migrations not being checked');
|
||||
|
||||
} catch (e) {
|
||||
|
||||
// Check the error.
|
||||
switch (e) {
|
||||
case errors.ErrInstallLock, errors.ErrSettingsInit:
|
||||
|
||||
debug('setup is not currently available, migrations now being checked');
|
||||
|
||||
// The error was expected, just continue.
|
||||
break;
|
||||
default:
|
||||
|
||||
// The error was not expected, throw the error!
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Now try and check the migration status.
|
||||
try {
|
||||
|
||||
// Verify that the minimum migration version is met.
|
||||
await MigrationService.verify();
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
debug('migrations do not have to be run');
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen on provided port, on all network interfaces.
|
||||
|
||||
+139
-149
@@ -8,6 +8,7 @@ const program = require('./commander');
|
||||
const inquirer = require('inquirer');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const SettingModel = require('../models/setting');
|
||||
const MODERATION_OPTIONS = require('../models/enum/moderation_options');
|
||||
const SettingsService = require('../services/settings');
|
||||
const SetupService = require('../services/setup');
|
||||
const UsersService = require('../services/users');
|
||||
@@ -32,166 +33,155 @@ program
|
||||
// Setup the application
|
||||
//==============================================================================
|
||||
|
||||
const performSetup = () => {
|
||||
|
||||
if (program.defaults) {
|
||||
return SettingsService
|
||||
.init()
|
||||
.then(() => {
|
||||
console.log('Settings created.');
|
||||
console.log('\nTalk is now installed!');
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
});
|
||||
}
|
||||
const performSetup = async () => {
|
||||
|
||||
// Get the current settings, we are expecing an error here.
|
||||
return SettingsService
|
||||
.retrieve()
|
||||
.then(() => {
|
||||
try {
|
||||
|
||||
// We should NOT have gotten a settings object, this means that the
|
||||
// application is already setup. Error out here.
|
||||
throw errors.ErrSettingsInit;
|
||||
// Try to get the settings.
|
||||
await SettingsService.retrieve();
|
||||
|
||||
})
|
||||
.catch((err) => {
|
||||
// We should NOT have gotten a settings object, this means that the
|
||||
// application is already setup. Error out here.
|
||||
throw errors.ErrSettingsInit;
|
||||
|
||||
// If the error is `not init`, then we're good, otherwise, it's something
|
||||
// else.
|
||||
if (err !== errors.ErrSettingsNotInit) {
|
||||
throw err;
|
||||
} catch (e) {
|
||||
|
||||
// If the error is `not init`, then we're good, otherwise, it's something
|
||||
// else.
|
||||
if (e !== errors.ErrSettingsNotInit) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (program.defaults) {
|
||||
await SettingsService.init();
|
||||
|
||||
console.log('Settings created.');
|
||||
console.log('\nTalk is now installed!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the base settings model.
|
||||
let settings = new SettingModel();
|
||||
|
||||
console.log('\nWe\'ll ask you some questions in order to setup your installation of Talk.\n');
|
||||
|
||||
let answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'organizationName',
|
||||
message: 'Organization Name',
|
||||
default: settings.organizationName,
|
||||
validate: (input) => {
|
||||
if (input && input.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Organization Name is required.';
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
choices: MODERATION_OPTIONS,
|
||||
name: 'moderation',
|
||||
default: settings.moderation,
|
||||
message: 'Select a moderation mode'
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'requireEmailConfirmation',
|
||||
default: settings.requireEmailConfirmation,
|
||||
message: 'Should emails always be confirmed'
|
||||
}
|
||||
]);
|
||||
|
||||
})
|
||||
.then(() => {
|
||||
// Update the settings that were changed.
|
||||
Object.keys(answers).forEach((key) => {
|
||||
if (answers[key] !== undefined) {
|
||||
settings[key] = answers[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Create the base settings model.
|
||||
let settings = new SettingModel();
|
||||
console.log('\nWe\'ll ask you some questions about your first admin user.\n');
|
||||
|
||||
console.log('We\'ll ask you some questions in order to setup your installation of Talk.\n');
|
||||
|
||||
return inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'organizationName',
|
||||
message: 'Organization Name',
|
||||
default: settings.organizationName,
|
||||
validate: (input) => {
|
||||
if (input && input.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Organization Name is required.';
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
choices: SettingModel.MODERATION_OPTIONS,
|
||||
name: 'moderation',
|
||||
default: settings.moderation,
|
||||
message: 'Select a moderation mode'
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'requireEmailConfirmation',
|
||||
default: settings.requireEmailConfirmation,
|
||||
message: 'Should emails always be confirmed'
|
||||
}
|
||||
])
|
||||
.then((answers) => {
|
||||
|
||||
// Update the settings that were changed.
|
||||
Object.keys(answers).forEach((key) => {
|
||||
if (answers[key] !== undefined) {
|
||||
settings[key] = answers[key];
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nWe\'ll ask you some questions about your first admin user.\n');
|
||||
|
||||
return inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'username',
|
||||
message: 'Username',
|
||||
filter: (username) => {
|
||||
return UsersService
|
||||
.isValidUsername(username, false)
|
||||
.catch((err) => {
|
||||
throw err.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
message: 'Email',
|
||||
format: 'email',
|
||||
validate: (value) => {
|
||||
if (value && value.length >= 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Email is required';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
message: 'Password',
|
||||
type: 'password',
|
||||
filter: (password) => {
|
||||
return UsersService
|
||||
.isValidPassword(password)
|
||||
.catch((err) => {
|
||||
throw err.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'confirmPassword',
|
||||
message: 'Confirm Password',
|
||||
type: 'password',
|
||||
filter: (confirmPassword) => {
|
||||
return UsersService
|
||||
.isValidPassword(confirmPassword)
|
||||
.catch((err) => {
|
||||
throw err.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
||||
})
|
||||
.then((user) => {
|
||||
|
||||
if (user.password !== user.confirmPassword) {
|
||||
return Promise.reject(new Error('Passwords do not match'));
|
||||
let user = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'username',
|
||||
message: 'Username',
|
||||
filter: (username) => {
|
||||
return UsersService
|
||||
.isValidUsername(username, false)
|
||||
.catch((err) => {
|
||||
throw err.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
message: 'Email',
|
||||
format: 'email',
|
||||
validate: (value) => {
|
||||
if (value && value.length >= 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return SetupService.setup({
|
||||
settings: settings.toObject(),
|
||||
user: {
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
password: user.password
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(({user}) => {
|
||||
console.log('Settings created.');
|
||||
console.log(`User ${user.id} created.`);
|
||||
console.log('\nTalk is now installed!');
|
||||
console.log('\nWe recommend adding TALK_INSTALL_LOCK=TRUE to your environment to turn off the dynamic setup.');
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
});
|
||||
return 'Email is required';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
message: 'Password',
|
||||
type: 'password',
|
||||
filter: (password) => {
|
||||
return UsersService
|
||||
.isValidPassword(password)
|
||||
.catch((err) => {
|
||||
throw err.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'confirmPassword',
|
||||
message: 'Confirm Password',
|
||||
type: 'password',
|
||||
filter: (confirmPassword) => {
|
||||
return UsersService
|
||||
.isValidPassword(confirmPassword)
|
||||
.catch((err) => {
|
||||
throw err.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
if (user.password !== user.confirmPassword) {
|
||||
return Promise.reject(new Error('Passwords do not match'));
|
||||
}
|
||||
|
||||
let {user: newUser} = await SetupService.setup({
|
||||
settings: settings.toObject(),
|
||||
user: {
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
password: user.password
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Settings created.');
|
||||
console.log(`User ${newUser.id} created.`);
|
||||
console.log('\nTalk is now installed!');
|
||||
console.log('\nWe recommend adding TALK_INSTALL_LOCK=TRUE to your environment to turn off the dynamic setup.');
|
||||
};
|
||||
|
||||
// Start tthe setup process.
|
||||
performSetup();
|
||||
performSetup()
|
||||
.then(() => {
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
util.shutdown(1);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -77,7 +83,7 @@
|
||||
letter-spacing: .8;
|
||||
|
||||
&:hover {
|
||||
background-color: #232323;
|
||||
background-color: #404040;
|
||||
}
|
||||
|
||||
&.active {
|
||||
|
||||
@@ -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;
|
||||
@@ -175,7 +175,7 @@
|
||||
right: 0;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
padding: 40px 18px;
|
||||
padding: 50px 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -310,24 +310,33 @@
|
||||
.flaggedByCount {
|
||||
display: block;
|
||||
text-align: left;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.flaggedByCount i {
|
||||
font-size: 14px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.flaggedBy {
|
||||
display: inline;
|
||||
padding: 3px;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.flaggedByLabel {
|
||||
font-weight: bold;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flaggedReasons {
|
||||
padding-top: 15px;
|
||||
margin-left: 24px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.flaggedByReason {
|
||||
p.flaggedByReason {
|
||||
font-size: 1tpx;
|
||||
margin: 0px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -52,6 +52,10 @@ span {
|
||||
}
|
||||
}
|
||||
|
||||
.approve {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.notFound {
|
||||
position: relative;
|
||||
margin: 20px auto;
|
||||
@@ -87,6 +91,7 @@ span {
|
||||
|
||||
.moderateAsset {
|
||||
a {
|
||||
text-align: center;
|
||||
-webkit-box-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
@@ -99,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,7 +186,7 @@ span {
|
||||
top: 0;
|
||||
padding: 40px 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.itemHeader {
|
||||
display: flex;
|
||||
@@ -255,6 +248,8 @@ span {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.empty {
|
||||
color: #444;
|
||||
margin-top: 50px;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
.heading {
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
.widgetTable {
|
||||
@@ -32,7 +33,8 @@
|
||||
}
|
||||
|
||||
.widgetHead p {
|
||||
color: rgb(35, 102, 223);
|
||||
color: #2c2c2c;
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
text-transform: capitalize;
|
||||
@@ -47,11 +49,11 @@
|
||||
}
|
||||
|
||||
.rowLinkify {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid lightgrey;
|
||||
color: #555;
|
||||
height: var(--row-height);
|
||||
padding: 10px;
|
||||
transition: background-color 200ms;
|
||||
}
|
||||
|
||||
.rowLinkify:last-child {
|
||||
@@ -60,6 +62,7 @@
|
||||
|
||||
.rowLinkify:hover {
|
||||
background-color: #f8f8f8;
|
||||
pointer: default;
|
||||
}
|
||||
|
||||
.linkToAsset {
|
||||
@@ -68,16 +71,17 @@
|
||||
}
|
||||
|
||||
.linkToModerate {
|
||||
background-color: #e0e0e0;
|
||||
background-color: #BDBDBD;
|
||||
padding: 10px 14px;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
float: right;
|
||||
margin-left: 15px;
|
||||
transition: background-color 200ms;
|
||||
}
|
||||
|
||||
.linkToModerate:hover {
|
||||
background-color: #ccc;
|
||||
background-color: #9E9E9E;
|
||||
}
|
||||
|
||||
.lede {
|
||||
@@ -89,14 +93,10 @@
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
font-size: 1.2em;
|
||||
font-weight: normal;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.assetTitle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.widgetCount {
|
||||
color: #555;
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,4 +12,5 @@
|
||||
right: 0;
|
||||
margin-top: -2px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -15,7 +15,7 @@
|
||||
display: flex;
|
||||
|
||||
.stat {
|
||||
margin: 0 4px 12px;
|
||||
margin: 0 4px 10px 0px;
|
||||
}
|
||||
|
||||
.stat:last-child {
|
||||
@@ -35,9 +35,8 @@
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
font-size: 16px;
|
||||
position: absolute;
|
||||
width: 90%;
|
||||
outline: none;
|
||||
width: calc(100% - 90px);
|
||||
}
|
||||
|
||||
.commentStatuses {
|
||||
@@ -49,7 +48,7 @@
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,20 @@
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
color: #EEEEEE;
|
||||
color: #C0C0C0;
|
||||
text-transform: capitalize;
|
||||
font-weight: 100;
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
letter-spacing: 1px;
|
||||
transition: border-bottom 200ms;
|
||||
padding: 0px 5px;
|
||||
margin-right: 30px;
|
||||
transition: color 200ms;
|
||||
padding: 0px 10px;
|
||||
margin-right: 20px;
|
||||
&:hover {
|
||||
color: white;
|
||||
border-bottom: solid 2px #F36451;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
@@ -33,6 +39,10 @@
|
||||
box-sizing: border-box;
|
||||
border-bottom: solid 4px #F36451;
|
||||
font-weight: 400;
|
||||
&:hover {
|
||||
border-bottom: solid 4px #F36451;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.active > span {
|
||||
@@ -103,33 +113,23 @@ span {
|
||||
font-weight: 400;
|
||||
font-size: 15px;
|
||||
letter-spacing: 1px;
|
||||
transition: opacity 200ms;
|
||||
transition: background-color 200ms;
|
||||
opacity: 1;
|
||||
|
||||
&:hover {
|
||||
opacity: .8;
|
||||
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;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,8 +190,6 @@ span {
|
||||
}
|
||||
|
||||
&.selected {
|
||||
max-width: 720px;
|
||||
max-height: 410px;
|
||||
}
|
||||
|
||||
.context {
|
||||
@@ -245,7 +243,6 @@ span {
|
||||
padding: 5px;
|
||||
color: #262626;
|
||||
font-size: 14px;
|
||||
margin-left: 15px;
|
||||
line-height: 1px;
|
||||
font-weight: 300;
|
||||
}
|
||||
@@ -421,17 +418,21 @@ span {
|
||||
|
||||
.tabIcon {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
top: 3px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: blue;
|
||||
text-decoration: underline;
|
||||
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: rgba(255, 0, 0, .1);
|
||||
background-color: #E0E0E0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,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});
|
||||
}
|
||||
|
||||
@@ -26,8 +26,7 @@ const fm = new IntrospectionFragmentMatcher({
|
||||
{name: 'SetUserStatusResponse'},
|
||||
{name: 'SuspendUserResponse'},
|
||||
{name: 'SetCommentStatusResponse'},
|
||||
{name: 'AddCommentTagResponse'},
|
||||
{name: 'RemoveCommentTagResponse'},
|
||||
{name: 'ModifyTagResponse'},
|
||||
{name: 'IgnoreUserResponse'},
|
||||
{name: 'StopIgnoringUserResponse'}
|
||||
]
|
||||
|
||||
@@ -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,14 +1,14 @@
|
||||
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 Content from 'coral-plugin-commentcontent/CommentContent';
|
||||
import PubDate from 'coral-plugin-pubdate/PubDate';
|
||||
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
|
||||
import FlagComment from 'coral-plugin-flags/FlagComment';
|
||||
import {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 {
|
||||
BestButton,
|
||||
@@ -17,17 +17,18 @@ import {
|
||||
commentIsBest,
|
||||
BestIndicator
|
||||
} from 'coral-plugin-best/BestButton';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import LoadMore from './LoadMore';
|
||||
import {getEditableUntilDate} from './util';
|
||||
import {TopRightMenu} from './TopRightMenu';
|
||||
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 {getEditableUntilDate} from './util';
|
||||
import styles from './Comment.css';
|
||||
import {getActionSummary, iPerformedThisAction, forEachError} from 'coral-framework/utils';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF');
|
||||
const hasTag = (tags, lookupTag) => !!tags.filter((tag) => tag.name === lookupTag).length;
|
||||
const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF');
|
||||
const hasTag = (tags, lookupTag) => !!tags.filter((t) => t.tag.name === lookupTag).length;
|
||||
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
|
||||
|
||||
// resetCursors will return the id cursors of the first and second newest comment in
|
||||
@@ -74,6 +75,7 @@ const ActionButton = ({children}) => {
|
||||
};
|
||||
|
||||
export default class Comment extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -87,6 +89,7 @@ export default class Comment extends React.Component {
|
||||
isEditing: false,
|
||||
replyBoxVisible: false,
|
||||
animateEnter: false,
|
||||
loadingState: '',
|
||||
...resetCursors({}, props),
|
||||
};
|
||||
}
|
||||
@@ -166,7 +169,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,
|
||||
@@ -180,10 +183,10 @@ export default class Comment extends React.Component {
|
||||
commentIsIgnored: React.PropTypes.func,
|
||||
|
||||
// dispatch action to add a tag to a comment
|
||||
addCommentTag: React.PropTypes.func,
|
||||
addTag: React.PropTypes.func,
|
||||
|
||||
// dispatch action to remove a tag from a comment
|
||||
removeCommentTag: React.PropTypes.func,
|
||||
removeTag: React.PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func,
|
||||
@@ -192,8 +195,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});
|
||||
}
|
||||
|
||||
@@ -211,14 +222,36 @@ 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);
|
||||
};
|
||||
|
||||
showReplyBox = () => {
|
||||
if (!this.props.currentUser) {
|
||||
this.props.showSignInDialog();
|
||||
return;
|
||||
}
|
||||
if (can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
|
||||
this.props.setActiveReplyBox(this.props.comment.id);
|
||||
} else {
|
||||
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// getVisibileReplies returns a list containing comments
|
||||
// which were authored by current user or comes before the `idCursor`.
|
||||
getVisibileReplies() {
|
||||
@@ -286,17 +319,18 @@ export default class Comment extends React.Component {
|
||||
deleteAction,
|
||||
disableReply,
|
||||
maxCharCount,
|
||||
addCommentTag,
|
||||
addNotification,
|
||||
charCountEnable,
|
||||
showSignInDialog,
|
||||
removeCommentTag,
|
||||
addTag,
|
||||
removeTag,
|
||||
liveUpdates,
|
||||
commentIsIgnored,
|
||||
commentClassNames = []
|
||||
} = this.props;
|
||||
|
||||
const view = this.getVisibileReplies();
|
||||
const {loadingState} = this.state;
|
||||
|
||||
const hasMoreComments = comment.replies && (comment.replies.hasNextPage || comment.replies.nodes.length > view.length);
|
||||
const replyCount = this.hasIgnoredReplies() ? '' : comment.replyCount;
|
||||
@@ -333,18 +367,20 @@ export default class Comment extends React.Component {
|
||||
|
||||
const addBestTag = notifyOnError(
|
||||
() =>
|
||||
addCommentTag({
|
||||
addTag({
|
||||
id: comment.id,
|
||||
tag: BEST_TAG
|
||||
name: BEST_TAG,
|
||||
assetId: asset.id
|
||||
}),
|
||||
() => 'Failed to tag comment as best'
|
||||
);
|
||||
|
||||
const removeBestTag = notifyOnError(
|
||||
() =>
|
||||
removeCommentTag({
|
||||
removeTag({
|
||||
id: comment.id,
|
||||
tag: BEST_TAG
|
||||
name: BEST_TAG,
|
||||
assetId: asset.id
|
||||
}),
|
||||
() => 'Failed to remove best comment tag'
|
||||
);
|
||||
@@ -400,7 +436,7 @@ export default class Comment extends React.Component {
|
||||
<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>
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
@@ -416,32 +452,32 @@ export default class Comment extends React.Component {
|
||||
inline
|
||||
/>
|
||||
|
||||
{ (currentUser &&
|
||||
(comment.user.id === currentUser.id))
|
||||
{ (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>
|
||||
/* 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>
|
||||
}
|
||||
{ (currentUser && (comment.user.id !== currentUser.id)) &&
|
||||
|
||||
/* TopRightMenu allows currentUser to ignore other users' comments */
|
||||
: <span className={cn(styles.topRight, styles.topRightMenu)}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification} />
|
||||
</span>
|
||||
<span className={cn(styles.topRight, styles.topRightMenu)}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification} />
|
||||
</span>
|
||||
}
|
||||
|
||||
{
|
||||
this.state.isEditing
|
||||
? <EditableCommentContent
|
||||
editComment={this.props.editComment.bind(null, comment.id, asset.id)}
|
||||
editComment={this.editComment}
|
||||
addNotification={addNotification}
|
||||
asset={asset}
|
||||
comment={comment}
|
||||
@@ -451,8 +487,7 @@ export default class Comment extends React.Component {
|
||||
stopEditing={this.stopEditing}
|
||||
/>
|
||||
: <div>
|
||||
<Content body={comment.body} />
|
||||
<Slot fill="commentContent" />
|
||||
<Slot fill="commentContent" comment={comment} defaultComponent={CommentContent} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -477,25 +512,23 @@ export default class Comment extends React.Component {
|
||||
{!disableReply &&
|
||||
<ActionButton>
|
||||
<ReplyButton
|
||||
onClick={() => setActiveReplyBox(comment.id)}
|
||||
onClick={this.showReplyBox}
|
||||
parentCommentId={parentId || comment.id}
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
banned={false}
|
||||
/>
|
||||
</ActionButton>}
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<Slot
|
||||
fill="commentActions"
|
||||
wrapperComponent={ActionButton}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
asset={asset}
|
||||
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}
|
||||
@@ -503,6 +536,7 @@ export default class Comment extends React.Component {
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
addNotification={addNotification}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
@@ -521,8 +555,8 @@ export default class Comment extends React.Component {
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
parentId={parentId || comment.id}
|
||||
addNotification={addNotification}
|
||||
authorId={currentUser.id}
|
||||
postComment={postComment}
|
||||
currentUser={currentUser}
|
||||
assetId={asset.id}
|
||||
/>
|
||||
: null}
|
||||
@@ -547,8 +581,8 @@ export default class Comment extends React.Component {
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
addCommentTag={addCommentTag}
|
||||
removeCommentTag={removeCommentTag}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
ignoreUser={ignoreUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
@@ -561,12 +595,13 @@ export default class Comment extends React.Component {
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<div className="coral-load-more-replies">
|
||||
<div className="talk-load-more-replies">
|
||||
<LoadMore
|
||||
topLevel={false}
|
||||
replyCount={replyCount}
|
||||
moreComments={hasMoreComments}
|
||||
loadMore={this.loadNewReplies}
|
||||
loadingState={loadingState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
const CommentContent = ({comment}) => {
|
||||
const textbreaks = comment.body.split('\n');
|
||||
return <div className={`${name}-text`}>
|
||||
{
|
||||
textbreaks.map((line, i) => {
|
||||
return (
|
||||
<span key={i} className={`${name}-line`}>
|
||||
{line}
|
||||
<br className={`${name}-linebreak`}/>
|
||||
</span>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default CommentContent;
|
||||
@@ -4,6 +4,8 @@ import {CommentForm} from 'coral-plugin-commentbox/CommentForm';
|
||||
import styles from './Comment.css';
|
||||
import {CountdownSeconds} from './CountdownSeconds';
|
||||
import {getEditableUntilDate} from './util';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
import {forEachError} from 'coral-framework/utils';
|
||||
|
||||
import {Icon} from 'coral-ui';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
@@ -45,10 +47,14 @@ export class EditableCommentContent extends React.Component {
|
||||
// called when editing should be stopped
|
||||
stopEditing: React.PropTypes.func,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.editComment = this.editComment.bind(this);
|
||||
this.editWindowExpiryTimeout = null;
|
||||
this.state = {
|
||||
body: props.comment.body,
|
||||
loadingState: '',
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
const editableUntil = getEditableUntilDate(this.props.comment);
|
||||
@@ -65,66 +71,75 @@ export class EditableCommentContent extends React.Component {
|
||||
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
|
||||
}
|
||||
}
|
||||
async editComment(edit) {
|
||||
|
||||
handleBodyChange = (body) => {
|
||||
this.setState({body});
|
||||
}
|
||||
|
||||
handleSubmit = async () => {
|
||||
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
|
||||
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({loadingState: 'loading'});
|
||||
|
||||
const {editComment, addNotification, stopEditing} = this.props;
|
||||
if (typeof editComment !== 'function') {return;}
|
||||
let response;
|
||||
let successfullyEdited = false;
|
||||
try {
|
||||
response = await editComment(edit);
|
||||
const errors = (response && response.data && response.data.editComment)
|
||||
? response.data.editComment.errors
|
||||
: null;
|
||||
if (errors && (errors.length === 1)) {
|
||||
throw errors[0];
|
||||
}
|
||||
successfullyEdited = true;
|
||||
} catch (error) {
|
||||
if (error.translation_key) {
|
||||
addNotification('error', t(`error.${error.translation_key}`));
|
||||
} else if (error.networkError) {
|
||||
addNotification('error', t('error.network_error'));
|
||||
} else {
|
||||
addNotification('error', t('edit_comment.unexpected_error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (successfullyEdited) {
|
||||
response = await editComment({body: this.state.body});
|
||||
this.setState({loadingState: 'success'});
|
||||
const status = response.data.editComment.comment.status;
|
||||
notifyForNewCommentStatus(this.props.addNotification, status);
|
||||
}
|
||||
if (successfullyEdited && typeof stopEditing === 'function') {
|
||||
stopEditing();
|
||||
if (typeof stopEditing === 'function') {
|
||||
stopEditing();
|
||||
}
|
||||
} catch (error) {
|
||||
this.setState({loadingState: 'error'});
|
||||
forEachError(error, ({msg}) => addNotification('error', msg));
|
||||
}
|
||||
}
|
||||
|
||||
getEditableUntil = (props = this.props) => {
|
||||
return getEditableUntilDate(props.comment);
|
||||
}
|
||||
|
||||
isEditWindowExpired = (props = this.props) => {
|
||||
return (this.getEditableUntil(props) - new Date()) < 0;
|
||||
}
|
||||
|
||||
isSubmitEnabled = (comment) => {
|
||||
|
||||
// should be disabled if user hasn't actually changed their
|
||||
// original comment
|
||||
return (comment.body !== this.props.comment.body) && !this.isEditWindowExpired();
|
||||
}
|
||||
|
||||
render() {
|
||||
const originalBody = this.props.comment.body;
|
||||
const editableUntil = getEditableUntilDate(this.props.comment);
|
||||
const editWindowExpired = (editableUntil - new Date()) < 0;
|
||||
return (
|
||||
<div className={styles.editCommentForm}>
|
||||
<CommentForm
|
||||
defaultValue={this.props.comment.body}
|
||||
charCountEnable={this.props.asset.settings.charCountEnable}
|
||||
maxCharCount={this.props.maxCharCount}
|
||||
saveCommentEnabled={(comment) => {
|
||||
|
||||
// should be disabled if user hasn't actually changed their
|
||||
// original comment
|
||||
return (comment.body !== originalBody) && !editWindowExpired;
|
||||
}}
|
||||
saveComment={this.editComment}
|
||||
submitEnabled={this.isSubmitEnabled}
|
||||
body={this.state.body}
|
||||
onBodyChange={this.handleBodyChange}
|
||||
onSubmit={this.handleSubmit}
|
||||
bodyLabel={t('edit_comment.body_input_label')}
|
||||
bodyPlaceholder=""
|
||||
submitText={<span>{t('edit_comment.save_button')}</span>}
|
||||
saveButtonCStyle="green"
|
||||
cancelButtonClicked={this.props.stopEditing}
|
||||
buttonClass={styles.button}
|
||||
submitButtonCStyle="green"
|
||||
onCancel={this.props.stopEditing}
|
||||
submitButtonClassName={styles.button}
|
||||
cancelButtonClassName={styles.button}
|
||||
loadingState={this.state.loadingState}
|
||||
buttonContainerStart={
|
||||
<div className={styles.buttonContainerLeft}>
|
||||
<span className={styles.editWindowRemaining}>
|
||||
{
|
||||
editWindowExpired
|
||||
this.isEditWindowExpired()
|
||||
? <span>
|
||||
{t('edit_comment.edit_window_expired')}
|
||||
{
|
||||
@@ -136,7 +151,7 @@ export class EditableCommentContent extends React.Component {
|
||||
: <span>
|
||||
<Icon name="timer"/> {t('edit_comment.edit_window_timer_prefix')}
|
||||
<CountdownSeconds
|
||||
until={editableUntil}
|
||||
until={this.getEditableUntil()}
|
||||
classNameForMsRemaining={(remainingMs) => (remainingMs <= 10 * 1000) ? styles.editWindowAlmostOver : '' }
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -9,7 +9,7 @@ export class IgnoreUserWizard extends React.Component {
|
||||
// comment on which this menu appears
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
username: PropTypes.string.isRequired
|
||||
}).isRequired,
|
||||
cancel: PropTypes.func.isRequired,
|
||||
|
||||
@@ -47,7 +47,7 @@ export class IgnoreUserWizard extends React.Component {
|
||||
const step2Confirmation = (
|
||||
<div>
|
||||
<header>Ignore User</header>
|
||||
<p>Are you sure you want to ignore { user.name }?</p>
|
||||
<p>Are you sure you want to ignore { user.username }?</p>
|
||||
<div className={styles.textAlignRight}>
|
||||
<Button cStyle='cancel' onClick={this.onClickCancel}>Cancel</Button>
|
||||
<Button onClick={onClickIgnoreUser}>Ignore user</Button>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Button} from 'coral-ui';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import cn from 'classnames';
|
||||
|
||||
class LoadMore extends React.Component {
|
||||
|
||||
componentDidMount () {
|
||||
this.initialState = true;
|
||||
}
|
||||
initialState = true;
|
||||
|
||||
replyCountFormat = (count) => {
|
||||
if (!count) {
|
||||
@@ -23,15 +22,22 @@ class LoadMore extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (['success', 'error'].indexOf(nextProps.loadingState) >= 0) {
|
||||
this.initialState = false;
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const {topLevel, moreComments, loadMore, replyCount} = this.props;
|
||||
const {topLevel, moreComments, replyCount, loadingState, loadMore} = this.props;
|
||||
const disabled = loadingState === 'loading';
|
||||
return moreComments
|
||||
? <div className='coral-load-more'>
|
||||
? <div className='talk-load-more'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.initialState = false;
|
||||
loadMore();
|
||||
}}>
|
||||
onClick={loadMore}
|
||||
className={cn('talk-load-more-button', {[`talk-load-more-button-${loadingState}`]: loadingState})}
|
||||
disabled={disabled}
|
||||
>
|
||||
{topLevel ? t('framework.view_more_comments') : this.replyCountFormat(replyCount)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -42,7 +48,9 @@ class LoadMore extends React.Component {
|
||||
LoadMore.propTypes = {
|
||||
replyCount: PropTypes.number,
|
||||
topLevel: PropTypes.bool.isRequired,
|
||||
loadMore: PropTypes.func.isRequired
|
||||
loadMore: PropTypes.func.isRequired,
|
||||
moreComments: PropTypes.bool,
|
||||
loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']),
|
||||
};
|
||||
|
||||
export default LoadMore;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import LoadMore from './LoadMore';
|
||||
import {StreamError} from './StreamError';
|
||||
import Comment from '../components/Comment';
|
||||
import SuspendedAccount from './SuspendedAccount';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
@@ -14,6 +15,7 @@ import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import NewCount from './NewCount';
|
||||
import {TransitionGroup} from 'react-transition-group';
|
||||
import {forEachError} from 'coral-framework/utils';
|
||||
|
||||
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
|
||||
|
||||
@@ -55,7 +57,11 @@ class Stream extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = resetCursors(this.state, props);
|
||||
this.state = {
|
||||
...resetCursors(this.state, props),
|
||||
keepCommentBox: false,
|
||||
loadingState: '',
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
@@ -66,6 +72,12 @@ class Stream extends React.Component {
|
||||
this.setState(resetCursors);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep comment box when user was live suspended, banned, ...
|
||||
if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) {
|
||||
this.setState({keepCommentBox: true});
|
||||
}
|
||||
|
||||
if (
|
||||
prevComments && nextComments &&
|
||||
nextComments.nodes.length < prevComments.nodes.length
|
||||
@@ -95,6 +107,18 @@ class Stream extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
loadMoreComments = () => {
|
||||
this.setState({loadingState: 'loading'});
|
||||
this.props.loadMoreComments()
|
||||
.then(() => {
|
||||
this.setState({loadingState: 'success'});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({loadingState: 'error'});
|
||||
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
|
||||
});
|
||||
}
|
||||
|
||||
// getVisibileComments returns a list containing comments
|
||||
// which were authored by current user or comes after the `idCursor`.
|
||||
getVisibleComments() {
|
||||
@@ -119,6 +143,10 @@ class Stream extends React.Component {
|
||||
return view;
|
||||
}
|
||||
|
||||
userIsDegraged({auth: {user}} = this.props) {
|
||||
return !can(user, 'INTERACT_WITH_COMMUNITY');
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
commentClassNames,
|
||||
@@ -129,13 +157,14 @@ class Stream extends React.Component {
|
||||
postDontAgree,
|
||||
deleteAction,
|
||||
showSignInDialog,
|
||||
addCommentTag,
|
||||
addTag,
|
||||
ignoreUser,
|
||||
auth: {loggedIn, user},
|
||||
removeCommentTag,
|
||||
removeTag,
|
||||
pluginProps,
|
||||
editName
|
||||
} = this.props;
|
||||
const {keepCommentBox, loadingState} = this.state;
|
||||
const view = this.getVisibleComments();
|
||||
const open = asset.closedAt === null;
|
||||
|
||||
@@ -157,9 +186,17 @@ class Stream extends React.Component {
|
||||
me.ignoredUsers.find((u) => u.id === comment.user.id)
|
||||
);
|
||||
};
|
||||
|
||||
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
|
||||
|
||||
if (!comment && !comments) {
|
||||
console.error('Talk: No comments came back from the graph given that query. Please, check the query params.');
|
||||
return <StreamError />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="stream">
|
||||
<Slot fill="stream" />
|
||||
|
||||
{open
|
||||
? <div id="commentBox">
|
||||
<InfoBox
|
||||
@@ -183,11 +220,9 @@ class Stream extends React.Component {
|
||||
<SuspendedAccount
|
||||
canEditName={user && user.canEditName}
|
||||
editName={editName}
|
||||
currentUsername={user.username}
|
||||
/>}
|
||||
{loggedIn &&
|
||||
!banned &&
|
||||
!temporarilySuspended &&
|
||||
!highlightedComment &&
|
||||
{showCommentBox &&
|
||||
<CommentBox
|
||||
addNotification={this.props.addNotification}
|
||||
postComment={this.props.postComment}
|
||||
@@ -196,13 +231,15 @@ class Stream extends React.Component {
|
||||
assetId={asset.id}
|
||||
premod={asset.settings.moderation}
|
||||
isReply={false}
|
||||
authorId={user.id}
|
||||
currentUser={user}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
/>}
|
||||
</div>
|
||||
: <p>{asset.settings.closedMessage}</p>}
|
||||
|
||||
<Slot fill="stream" />
|
||||
|
||||
{loggedIn && (
|
||||
<ModerationLink
|
||||
assetId={asset.id}
|
||||
@@ -223,6 +260,7 @@ class Stream extends React.Component {
|
||||
activeReplyBox={this.props.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
disableReply={!open}
|
||||
postComment={this.props.postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
@@ -264,8 +302,8 @@ class Stream extends React.Component {
|
||||
currentUser={user}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
addCommentTag={addCommentTag}
|
||||
removeCommentTag={removeCommentTag}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
ignoreUser={ignoreUser}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
loadMore={this.props.loadNewReplies}
|
||||
@@ -285,7 +323,8 @@ class Stream extends React.Component {
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
moreComments={asset.comments.hasNextPage}
|
||||
loadMore={this.props.loadMoreComments}
|
||||
loadMore={this.loadMoreComments}
|
||||
loadingState={loadingState}
|
||||
/>
|
||||
</div>}
|
||||
</div>
|
||||
@@ -298,10 +337,10 @@ Stream.propTypes = {
|
||||
postComment: PropTypes.func.isRequired,
|
||||
|
||||
// dispatch action to add a tag to a comment
|
||||
addCommentTag: PropTypes.func,
|
||||
addTag: PropTypes.func,
|
||||
|
||||
// dispatch action to remove a tag from a comment
|
||||
removeCommentTag: PropTypes.func,
|
||||
removeTag: PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user