mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b60c879599 | ||
|
|
0f93d6ce8a | ||
|
|
b8ffe51812 | ||
|
|
bf298b311a | ||
|
|
b1d9ca0640 | ||
|
|
c0ac5b0799 | ||
|
|
2039cc5b9e | ||
|
|
bb2284909f | ||
|
|
f018d7f597 | ||
|
|
6794827a77 | ||
|
|
6956b4cd31 | ||
|
|
83ded4bda9 | ||
|
|
1fea2f77c1 | ||
|
|
80cfc663e3 | ||
|
|
a3cda1cf1c | ||
|
|
0013c36686 | ||
|
|
d947e103fc | ||
|
|
6d70a7b20e | ||
|
|
b9243938bd | ||
|
|
ca422f0109 | ||
|
|
c62e7328c5 | ||
|
|
df286a31ad | ||
|
|
ca069317e9 | ||
|
|
7c7799d2c0 | ||
|
|
8d30129f47 | ||
|
|
a9ec160f95 | ||
|
|
85836a3965 | ||
|
|
b10ed4e43e | ||
|
|
1e00d75e3c | ||
|
|
66b3d4ab00 | ||
|
|
1ab662efda | ||
|
|
d100128843 | ||
|
|
89e7f54a32 | ||
|
|
e14d380d66 | ||
|
|
fd87ef3b85 | ||
|
|
b260945822 | ||
|
|
1521ec43eb | ||
|
|
5ebdfa8c23 | ||
|
|
6913fe05ae | ||
|
|
41b2af76c4 | ||
|
|
9f1bce1221 | ||
|
|
528c2a9491 | ||
|
|
948d362c40 | ||
|
|
30842362cf | ||
|
|
976bd1e9ad | ||
|
|
7bdd28cb9c | ||
|
|
e16dd14e47 | ||
|
|
c9f2c27a69 | ||
|
|
c4a91d225e | ||
|
|
d1fc5668fb | ||
|
|
aad245fcbf | ||
|
|
49ad48d70f | ||
|
|
800fd63549 | ||
|
|
c08d040d1d | ||
|
|
1e8d2ce3a6 | ||
|
|
a22d0f7536 | ||
|
|
53cac48e3b | ||
|
|
27216cbfdb | ||
|
|
ddadd6c5d3 | ||
|
|
10936d9f41 | ||
|
|
1d5f1e676f | ||
|
|
c2f8f41e59 | ||
|
|
567f8bf94e | ||
|
|
11b2bc265a | ||
|
|
76b125f042 | ||
|
|
d2a2f94531 | ||
|
|
0df4fd1e37 | ||
|
|
5ceda4f2d2 | ||
|
|
0523a1f828 | ||
|
|
12d3273d3d | ||
|
|
e6330cd8c3 | ||
|
|
bc67bc7b78 | ||
|
|
a91a2d3fa3 | ||
|
|
32a8d78eb8 | ||
|
|
203707124c | ||
|
|
a39b7237e6 | ||
|
|
190e93c8d7 | ||
|
|
02a75c590f | ||
|
|
8cdf289b13 | ||
|
|
e435c323ec | ||
|
|
f92754a700 | ||
|
|
5c8130bece | ||
|
|
8ceeb4b275 | ||
|
|
3ec81099f7 | ||
|
|
c9b82008de | ||
|
|
5d2b9439d8 | ||
|
|
b8f24b952b | ||
|
|
b6d284495e | ||
|
|
a527990413 | ||
|
|
325626fecf | ||
|
|
15c7a7cad7 | ||
|
|
8a52290793 | ||
|
|
e6b7fcbd9d | ||
|
|
29209f968b | ||
|
|
2e974ac82f | ||
|
|
948fce9efb | ||
|
|
61690be021 | ||
|
|
da0d0349dd | ||
|
|
b6b7570bcc | ||
|
|
eb02031354 | ||
|
|
94901d40f3 | ||
|
|
145910c4a6 | ||
|
|
4e5f1528f9 | ||
|
|
975a616ce7 | ||
|
|
de7d5e542b | ||
|
|
ddf15df8a6 | ||
|
|
9429fe1afe | ||
|
|
8157a223d0 | ||
|
|
0039c05a78 | ||
|
|
b03223b67b | ||
|
|
c93c7b6ba1 | ||
|
|
4ad055197b | ||
|
|
eac8c63045 | ||
|
|
b972f4305e | ||
|
|
d300025c00 | ||
|
|
fdda5e5bef | ||
|
|
779260c245 | ||
|
|
faa6971961 | ||
|
|
26c70bcee8 | ||
|
|
5266bfa647 | ||
|
|
08a491b3a0 | ||
|
|
1487a454e9 | ||
|
|
c1a0273e84 | ||
|
|
d40242f92f | ||
|
|
5b751b7ba4 | ||
|
|
270d9b3e22 | ||
|
|
a4815ff927 | ||
|
|
fa451a8240 | ||
|
|
8672dea500 | ||
|
|
1a470957ab | ||
|
|
dbeb31f0a7 | ||
|
|
a99ffa1228 | ||
|
|
2ac6a9d829 | ||
|
|
9929046536 | ||
|
|
cb874f8670 | ||
|
|
d2c8019408 | ||
|
|
33a30e4f8d | ||
|
|
59ea17119d | ||
|
|
2a78ce2bf1 | ||
|
|
b1b24d0f9d | ||
|
|
3ae6dff098 | ||
|
|
de97ad59ec | ||
|
|
83762b880f | ||
|
|
57ff4f10f3 | ||
|
|
bb5f245df8 | ||
|
|
b568f1f8a8 | ||
|
|
4d9227840b | ||
|
|
78e74336db | ||
|
|
f3f441eb40 | ||
|
|
ef22909c6c | ||
|
|
a5790ca4df | ||
|
|
109ef0cea4 | ||
|
|
1626721622 | ||
|
|
694669bb85 | ||
|
|
4831d2d6a7 | ||
|
|
d330bf9c25 | ||
|
|
91d9d65372 | ||
|
|
62a559b6fc | ||
|
|
d606f4d094 | ||
|
|
344d1840f0 | ||
|
|
4079a74ab8 | ||
|
|
ba1dc3446b | ||
|
|
3954b7a268 | ||
|
|
31112b3a20 | ||
|
|
337e6cc963 | ||
|
|
665bf5b102 | ||
|
|
70dcf2162f | ||
|
|
84391d9d69 | ||
|
|
dbc8f87683 | ||
|
|
97c75be604 | ||
|
|
7f85b842f0 | ||
|
|
fc3acecf61 | ||
|
|
371348ced5 | ||
|
|
33a75aa4d0 | ||
|
|
1b017daf62 | ||
|
|
4f34827ec8 | ||
|
|
c9df0e5e40 | ||
|
|
ea17c068dd | ||
|
|
08edddf0df | ||
|
|
12e48efa9f | ||
|
|
2217c89aa7 | ||
|
|
b8eacf43a6 | ||
|
|
c1660030cd | ||
|
|
299bb35c9d | ||
|
|
1b583fb945 | ||
|
|
9bfdfc86b9 | ||
|
|
3e6e991609 | ||
|
|
7ada3106d2 | ||
|
|
2af0f2bf65 | ||
|
|
b9583dd55d | ||
|
|
85e478cf3f | ||
|
|
993688837a | ||
|
|
8aa086af02 | ||
|
|
f5369802e6 | ||
|
|
7f38c70e58 | ||
|
|
7dd20d740d | ||
|
|
50c8d3f314 | ||
|
|
0f41165751 | ||
|
|
5ce9c950b8 | ||
|
|
91bc7eaa53 | ||
|
|
58a6333e02 | ||
|
|
4b0eeca08b | ||
|
|
0111e08bc1 | ||
|
|
0767c680fc | ||
|
|
7323c92b26 | ||
|
|
c0ff056354 | ||
|
|
1f3d97fd6b | ||
|
|
4ed68259c6 | ||
|
|
d5b7fd0413 | ||
|
|
389ff0ca0d | ||
|
|
7a9aa331c5 | ||
|
|
673cb0ed04 | ||
|
|
3f8b2b947c | ||
|
|
dd8167a204 | ||
|
|
fba4b23dd0 | ||
|
|
200d41f2a5 | ||
|
|
c9442c7412 | ||
|
|
1994e44e94 | ||
|
|
3c960db93d | ||
|
|
47d37e31d8 | ||
|
|
447c3aa7cc | ||
|
|
b3d1ff8003 | ||
|
|
ce2f8f97e8 | ||
|
|
cf8528dcf1 | ||
|
|
9bd3e6b541 | ||
|
|
f8758bd755 | ||
|
|
46a2699ecb | ||
|
|
ce0479ae5f | ||
|
|
57fb77008a | ||
|
|
433c4fcd03 | ||
|
|
0f84586e1e | ||
|
|
f4b765ccc5 | ||
|
|
0d7e4d6541 | ||
|
|
30b3e91fdb | ||
|
|
a54a240ae6 | ||
|
|
b482cf0a0f | ||
|
|
3a1a5e4e37 | ||
|
|
1a1d28c915 | ||
|
|
1359dfdc0b | ||
|
|
e7bc3002ef | ||
|
|
7eee40368a | ||
|
|
8d59678c8c | ||
|
|
7058e7e7e8 | ||
|
|
0d7acbd44d | ||
|
|
9c425c371d | ||
|
|
8cee74ae06 | ||
|
|
ea16d50516 | ||
|
|
98a638cb4d | ||
|
|
59a1435089 | ||
|
|
4733233cae | ||
|
|
9b37767674 | ||
|
|
22b3a59351 | ||
|
|
fdb3a27616 | ||
|
|
67f1ee1e39 | ||
|
|
13434556fd | ||
|
|
2713254358 | ||
|
|
605cefaeac | ||
|
|
b00435bfe3 | ||
|
|
b1b4e9fc57 | ||
|
|
cd3adb93dd | ||
|
|
0ea3422126 | ||
|
|
e848df0976 | ||
|
|
6a12ab28db | ||
|
|
a91624bf4b | ||
|
|
2dbcb0fe45 | ||
|
|
557bda6444 | ||
|
|
f22350c92e | ||
|
|
772e68f72e | ||
|
|
49943ddac0 | ||
|
|
11a735f82e | ||
|
|
c7515ff126 | ||
|
|
21d19d170d | ||
|
|
608ad26e88 | ||
|
|
17e6064b9e | ||
|
|
8ea8bb521a | ||
|
|
9aa5c1ec46 | ||
|
|
432afd2d8a | ||
|
|
48dadd8571 | ||
|
|
1473c778a5 | ||
|
|
b8c5e56404 | ||
|
|
a7adf3469d | ||
|
|
5e9791af9c | ||
|
|
84a20042f2 | ||
|
|
8ecf55610c | ||
|
|
589f5129c9 | ||
|
|
d4bf5fd423 | ||
|
|
ceb83834a7 | ||
|
|
fefbec032c | ||
|
|
788a35d9bb | ||
|
|
fc1b69681e | ||
|
|
1077bfbec2 | ||
|
|
7ab171a813 | ||
|
|
9b5bbe9971 | ||
|
|
00597378d6 | ||
|
|
3bb0370a5a | ||
|
|
3c2229f3a7 | ||
|
|
fe9d0eb8e2 | ||
|
|
45a2e2a8a3 | ||
|
|
039b620ecf | ||
|
|
d9049ab602 | ||
|
|
ef759e068b | ||
|
|
f10d1a9f0d | ||
|
|
374b99ddc2 | ||
|
|
f60e580f14 | ||
|
|
0c05da9285 | ||
|
|
b4244b8671 | ||
|
|
4d6271d255 | ||
|
|
2e74e2fe62 | ||
|
|
3f304823ca | ||
|
|
b92516ec40 | ||
|
|
0c8def722b | ||
|
|
34e484abf3 | ||
|
|
07114aed1b | ||
|
|
f13e6b0e16 | ||
|
|
c2f6b9aa09 | ||
|
|
fac63099c7 | ||
|
|
3a7353e4fe | ||
|
|
148a87033a | ||
|
|
aeeafb6284 | ||
|
|
c4d79dd215 | ||
|
|
79be92a8bd | ||
|
|
ed3b26b100 | ||
|
|
c3bb399782 | ||
|
|
2af0a2309e | ||
|
|
ef5e70f179 | ||
|
|
b8bd997fed | ||
|
|
6013b4b136 | ||
|
|
712c7e2ad2 | ||
|
|
6c43a8accd | ||
|
|
4b5942dab8 | ||
|
|
9e1d22b988 | ||
|
|
9ffe542234 | ||
|
|
e292475f1e | ||
|
|
c8f3be4bf5 | ||
|
|
3e09d28308 | ||
|
|
85a10ba693 | ||
|
|
be46ac826b | ||
|
|
a036a051cb | ||
|
|
36df7cb784 | ||
|
|
4b74c3312f | ||
|
|
c68cca0e5b | ||
|
|
2ef219cc7c | ||
|
|
f35cce866a | ||
|
|
6383abd445 | ||
|
|
7e917c9611 | ||
|
|
661354998f | ||
|
|
db183124c9 | ||
|
|
ee220ca936 | ||
|
|
907e9bf226 | ||
|
|
25bccde40f | ||
|
|
6343a0d621 | ||
|
|
1db61941ed | ||
|
|
7b1c0dfbaa | ||
|
|
6ea7e9d564 | ||
|
|
92d1fc5759 | ||
|
|
8b582e85f8 | ||
|
|
970fe6fe04 | ||
|
|
f5d4b7216e | ||
|
|
cb95e9a308 | ||
|
|
537311704d | ||
|
|
0f82fa8bc5 | ||
|
|
6e6caaf5a9 | ||
|
|
8261c40c8d | ||
|
|
316b0a8808 | ||
|
|
ebce930b8c | ||
|
|
8c8a2fcee0 | ||
|
|
be63ee86b6 | ||
|
|
10a82f277d | ||
|
|
1733ab8690 | ||
|
|
9624a12e39 | ||
|
|
143050cbd5 | ||
|
|
0fd7d9f020 | ||
|
|
7ac3a325f7 | ||
|
|
149389057d | ||
|
|
3b01218338 | ||
|
|
2d8e36e511 | ||
|
|
9c9f088ac3 | ||
|
|
392d82e30a | ||
|
|
f86d210bbb | ||
|
|
11ea45ef69 | ||
|
|
b0cbddd313 | ||
|
|
1f8f293594 | ||
|
|
7ca7166c7a | ||
|
|
65500eda64 | ||
|
|
0e37f36e02 | ||
|
|
abed842c23 | ||
|
|
1e9fb24f87 | ||
|
|
f17b4a318a | ||
|
|
6ec33a0225 | ||
|
|
eb3a9431f9 | ||
|
|
8d7dc67544 | ||
|
|
e1d1549c93 | ||
|
|
8f820ace5b | ||
|
|
1069498323 | ||
|
|
c34daacd8c | ||
|
|
f7f5b42b71 | ||
|
|
f8a3b84e94 | ||
|
|
44e89bc96c | ||
|
|
2342934f48 | ||
|
|
5328374b73 | ||
|
|
6f64eac5de | ||
|
|
0b884a7664 | ||
|
|
64602f1731 | ||
|
|
3bc26cad80 | ||
|
|
def49d42aa | ||
|
|
3d4c296837 | ||
|
|
64571d3848 | ||
|
|
ad669b8399 | ||
|
|
1c8e009366 | ||
|
|
5d8528fdb5 | ||
|
|
6cab2634bf | ||
|
|
d6adff288c | ||
|
|
ad4b03bfcd | ||
|
|
99b8b4e90b | ||
|
|
3fa475009a | ||
|
|
139802381f | ||
|
|
fd0143da5d | ||
|
|
baa4808364 | ||
|
|
0a4000328b | ||
|
|
673f0cd232 | ||
|
|
fa02d44270 | ||
|
|
84edd8c6c2 | ||
|
|
da2bc69d19 | ||
|
|
b769e95898 | ||
|
|
2b7f46efc7 | ||
|
|
7005175fc0 | ||
|
|
8f64229dee | ||
|
|
47a5b84a74 | ||
|
|
cc1026233f | ||
|
|
6f1b0b55ec | ||
|
|
dcdfbb691a | ||
|
|
55ff0bcf8c | ||
|
|
d61374993a | ||
|
|
980dc2515e | ||
|
|
4511b9ca57 | ||
|
|
52cd7bcce4 | ||
|
|
5fee26da0e | ||
|
|
d99f6d8923 | ||
|
|
f051ee1d58 | ||
|
|
cd1735e1d6 | ||
|
|
3b10aa3945 | ||
|
|
3b0b6bd73c | ||
|
|
760d078705 | ||
|
|
46473142ba | ||
|
|
8a619a219f | ||
|
|
73d15f34d7 | ||
|
|
cf56ca8263 | ||
|
|
add1b67a61 | ||
|
|
1183550e14 | ||
|
|
6652368221 | ||
|
|
9cbcb902c5 | ||
|
|
d488e168c4 | ||
|
|
9990142740 | ||
|
|
33bd2f576a | ||
|
|
19929c97d8 | ||
|
|
99fc67d0d9 | ||
|
|
636d1f4788 | ||
|
|
d45038ccc5 | ||
|
|
9746422b16 | ||
|
|
4ee727c86a | ||
|
|
ad09ce03a1 | ||
|
|
afeeffc262 | ||
|
|
043d5fdcec | ||
|
|
8c7c564a30 | ||
|
|
13cac731b9 | ||
|
|
ab87500303 | ||
|
|
244d421eda | ||
|
|
745c579b82 | ||
|
|
d5dfd89d94 | ||
|
|
d82735b2b5 | ||
|
|
b091cf5221 | ||
|
|
d90f87428c | ||
|
|
41f5297d78 | ||
|
|
0e9d9067f9 | ||
|
|
d2e6a98d46 | ||
|
|
4dd03508ee | ||
|
|
fb2662572a | ||
|
|
0069f31244 | ||
|
|
b235b8334e | ||
|
|
acb7028471 | ||
|
|
9daaa6ffee | ||
|
|
af3489db9c | ||
|
|
012fa3e4aa | ||
|
|
a8467db7aa | ||
|
|
05e2302794 | ||
|
|
c610c319de | ||
|
|
04d330cf5b | ||
|
|
8cbb56af19 | ||
|
|
d4cb07e448 | ||
|
|
71b4413f92 | ||
|
|
9455eea041 | ||
|
|
e8bbd7e4d7 | ||
|
|
94032b69fc | ||
|
|
1f199ad3dd | ||
|
|
eb038b9d49 | ||
|
|
b8d00ec9ce | ||
|
|
b5ce16a33b | ||
|
|
f8a6987476 | ||
|
|
28e589de21 | ||
|
|
1b97811cb0 | ||
|
|
31cfddf93c | ||
|
|
d72df73d6f | ||
|
|
7ba11cdee9 | ||
|
|
8d4c0c6721 | ||
|
|
195592b9ab | ||
|
|
da46d27e5b | ||
|
|
bfbe8d44aa | ||
|
|
da6965122f | ||
|
|
32211c4e37 | ||
|
|
f0bdee415b | ||
|
|
70d1d5e9de | ||
|
|
28010488be | ||
|
|
2a9535f88a | ||
|
|
f9bece468f | ||
|
|
38e8bed68a | ||
|
|
3ca62cab65 | ||
|
|
da6bd80de0 | ||
|
|
774e0cc12e | ||
|
|
dcfbcd287b | ||
|
|
4af9c3bee2 | ||
|
|
ec559f0f23 | ||
|
|
b902695cb7 | ||
|
|
34dbbb0984 | ||
|
|
002dc377a6 | ||
|
|
fbdb044551 | ||
|
|
23ebe9f8e3 | ||
|
|
e3aecd645e | ||
|
|
b8020d5347 | ||
|
|
95e820e9de |
+2
-1
@@ -1,9 +1,10 @@
|
||||
# excluded because we'll likely need to rebuild this.
|
||||
node_modules
|
||||
|
||||
# scripts are used during development and testing, not
|
||||
# most scripts are used during development and testing, not
|
||||
# production.
|
||||
scripts
|
||||
!scripts/generateIntrospectionResult.js
|
||||
|
||||
# documentation should not be visable in production.
|
||||
docs
|
||||
|
||||
+11
-10
@@ -3,15 +3,16 @@ docs
|
||||
client/lib
|
||||
**/*.html
|
||||
plugins/*
|
||||
!plugins/coral-plugin-facebook-auth
|
||||
!plugins/coral-plugin-auth
|
||||
!plugins/coral-plugin-respect
|
||||
!plugins/coral-plugin-offtopic
|
||||
!plugins/coral-plugin-like
|
||||
!plugins/coral-plugin-mod
|
||||
!plugins/coral-plugin-love
|
||||
!plugins/coral-plugin-viewing-options
|
||||
!plugins/coral-plugin-comment-content
|
||||
!plugins/talk-plugin-facebook-auth
|
||||
!plugins/talk-plugin-auth
|
||||
!plugins/talk-plugin-respect
|
||||
!plugins/talk-plugin-offtopic
|
||||
!plugins/talk-plugin-like
|
||||
!plugins/talk-plugin-mod
|
||||
!plugins/talk-plugin-love
|
||||
!plugins/talk-plugin-viewing-options
|
||||
!plugins/talk-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
!plugins/talk-plugin-featured
|
||||
!plugins/talk-plugin-featured-comments
|
||||
|
||||
node_modules
|
||||
|
||||
+13
-10
@@ -5,27 +5,30 @@ dist
|
||||
npm-debug.log*
|
||||
dump.rdb
|
||||
|
||||
client/coral-framework/graphql/introspection.json
|
||||
|
||||
.env
|
||||
*.cfg
|
||||
|
||||
.idea/
|
||||
*.swp
|
||||
*.DS_STORE
|
||||
|
||||
test/e2e/reports
|
||||
coverage/
|
||||
|
||||
plugins.json
|
||||
plugins/*
|
||||
!plugins/coral-plugin-facebook-auth
|
||||
!plugins/coral-plugin-auth
|
||||
!plugins/coral-plugin-respect
|
||||
!plugins/coral-plugin-offtopic
|
||||
!plugins/coral-plugin-like
|
||||
!plugins/coral-plugin-mod
|
||||
!plugins/coral-plugin-love
|
||||
!plugins/coral-plugin-viewing-options
|
||||
!plugins/coral-plugin-comment-content
|
||||
!plugins/talk-plugin-facebook-auth
|
||||
!plugins/talk-plugin-auth
|
||||
!plugins/talk-plugin-respect
|
||||
!plugins/talk-plugin-offtopic
|
||||
!plugins/talk-plugin-like
|
||||
!plugins/talk-plugin-mod
|
||||
!plugins/talk-plugin-love
|
||||
!plugins/talk-plugin-viewing-options
|
||||
!plugins/talk-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
!plugins/talk-plugin-featured
|
||||
!plugins/talk-plugin-featured-comments
|
||||
|
||||
**/node_modules/*
|
||||
|
||||
+1
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:7.8
|
||||
FROM node:8
|
||||
|
||||
# Create app directory
|
||||
RUN mkdir -p /usr/src/app
|
||||
@@ -17,7 +17,6 @@ RUN yarn global add node-gyp && \
|
||||
yarn install --frozen-lockfile && \
|
||||
cli plugins reconcile && \
|
||||
yarn build && \
|
||||
yarn install --production && \
|
||||
yarn cache clean
|
||||
|
||||
# Ensure the runtime of the container is in production mode.
|
||||
|
||||
-275
@@ -1,275 +0,0 @@
|
||||
## Contents
|
||||
|
||||
- [Installation](#installation) - install the application on a machine
|
||||
- [Via Docker](#installation-from-docker)
|
||||
- [Via Source](#installation-from-source)
|
||||
- [Setup](#setup) - setup the application for first use
|
||||
- [Usage](#usage) - connect the application to a website
|
||||
|
||||
# Installation
|
||||
|
||||
## Requirements
|
||||
|
||||
- Any flavour of Linux, OSX or Windows
|
||||
- 1GB memory (minimum)
|
||||
- 5GB storage (minimum)
|
||||
- [MongoDB](https://www.mongodb.com/) v3.4 or later
|
||||
- [Redis](https://redis.io/) v3.2 or later
|
||||
- SSL Certificate
|
||||
- This application assumes that you will be serving this application in a
|
||||
production environment, and therefore requires that you serve it behind a
|
||||
webserver with a valid SSL certificate. This is chosen in order to secure
|
||||
user's sessions.
|
||||
|
||||
## Installation From Docker
|
||||
|
||||
We currently support packaging the Talk application via Docker, which automates
|
||||
the dependency install and asset build process. This is the recommended way to
|
||||
deploy the application when used in production.
|
||||
|
||||
Available as [coralproject/talk](https://hub.docker.com/r/coralproject/talk/) on Docker Hub.
|
||||
|
||||
Images are tagged using the following notation:
|
||||
|
||||
- `x` (where `x` is the major version number): any minor or patch updates will be included in this. If you're ok getting
|
||||
new features occasionally and all the bug fixes, this is the tag for you.
|
||||
- `x.y` (where `y` is the minor version number): any patch updates will be
|
||||
included with this tag. If you like getting fixes and having features change
|
||||
only when you want, this is the tag for you. **(recommended)**
|
||||
- `x.y.z` (where `z` is the patch version): this tag never gets updated, and
|
||||
essentially freezes your version, this should only be used when you are either
|
||||
extending Talk or are sure of a specific version you want to freeze.
|
||||
|
||||
We provide tags with `*-onbuild` that can be used for easy plugin integration and
|
||||
acts as a customization endpoint. Instructions are provided in the `PLUGINS.md`
|
||||
document as to how to use it.
|
||||
|
||||
### Requirements
|
||||
|
||||
There are some runtime requirements for running Talk for Docker:
|
||||
|
||||
- [Docker](https://www.docker.com/) v1.13.0 or later
|
||||
- [Docker Compose](https://docs.docker.com/compose/) v1.10.0 or later
|
||||
|
||||
_Please be sure to check the versions of these requirements. Incorrect versions
|
||||
of these may lead to unexpected errors!_
|
||||
|
||||
### Installing
|
||||
|
||||
An example docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
version: '2'
|
||||
services:
|
||||
talk:
|
||||
image: coralproject/talk:1.5
|
||||
restart: always
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
environment:
|
||||
- TALK_MONGO_URL=mongodb://mongo/talk
|
||||
- TALK_REDIS_URL=redis://redis
|
||||
mongo:
|
||||
image: mongo:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- mongo:/data/db
|
||||
redis:
|
||||
image: redis:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- redis:/data
|
||||
volumes:
|
||||
mongo:
|
||||
external: false
|
||||
redis:
|
||||
external: false
|
||||
```
|
||||
|
||||
At this stage, you should refer to the `README.md` for configuration variables
|
||||
that are specific to your installation. Some pre-defined fields have been filled
|
||||
in the above example which are consistent with Docker Compose naming conventions
|
||||
for [Docker Links](https://docs.docker.com/compose/networking/#links).
|
||||
|
||||
### Scaling
|
||||
|
||||
If you are interested in splitting apart services, you can simply adjust the
|
||||
command being executed in the container to optimize for your use case. An
|
||||
example would be if you wanted to run the API server and the job processor
|
||||
on different machines. You can achieve this easily with docker compose:
|
||||
|
||||
```yaml
|
||||
version: '2'
|
||||
services:
|
||||
talk-api:
|
||||
image: coralproject/talk:1.5
|
||||
command: cli serve
|
||||
restart: always
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
environment:
|
||||
- TALK_MONGO_URL=mongodb://mongo/talk
|
||||
- TALK_REDIS_URL=redis://redis
|
||||
talk-jobs:
|
||||
image: coralproject/talk:1.5
|
||||
command: cli jobs process
|
||||
restart: always
|
||||
ports:
|
||||
- "5001:5000"
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
environment:
|
||||
- TALK_MONGO_URL=mongodb://mongo/talk
|
||||
- TALK_REDIS_URL=redis://redis
|
||||
mongo:
|
||||
image: mongo:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- mongo:/data/db
|
||||
redis:
|
||||
image: redis:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- redis:/data
|
||||
volumes:
|
||||
mongo:
|
||||
external: false
|
||||
redis:
|
||||
external: false
|
||||
```
|
||||
|
||||
Note that the only difference is in the `command` key. From this, you are able
|
||||
to discretely control which modules are running in order to have the maximum
|
||||
flexibility when managing your application.
|
||||
|
||||
### Running
|
||||
|
||||
If you're using docker compose:
|
||||
|
||||
```bash
|
||||
# Start the services using compose
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
If you're using plain docker:
|
||||
|
||||
```bash
|
||||
docker run -d -P coralproject/talk:latest
|
||||
```
|
||||
|
||||
## Installation From Source
|
||||
|
||||
This provides information on how to setup the application from source. Note that
|
||||
this is not recommended for production deploys, but will work for development
|
||||
and testing purposes.
|
||||
|
||||
### Requirements
|
||||
|
||||
There are some runtime requirements for running Talk from source:
|
||||
|
||||
- [Node](https://nodejs.org/) ~7.8
|
||||
- [Yarn](https://yarnpkg.com/) ^0.22.0
|
||||
|
||||
_Please be sure to check the versions of these requirements. Incorrect versions
|
||||
of these may lead to unexpected errors!_
|
||||
|
||||
### Installing
|
||||
|
||||
#### Download
|
||||
|
||||
It is highly recommended that you download a released version as the code
|
||||
available in `master` may not be stable. You can download the latest release
|
||||
from the [releases page](https://github.com/coralproject/talk/releases).
|
||||
|
||||
You can also clone the git repository via:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/coralproject/talk.git
|
||||
```
|
||||
|
||||
#### Building
|
||||
|
||||
We now have to install the dependencies and build the static assets.
|
||||
|
||||
```bash
|
||||
# Install package dependancies
|
||||
yarn
|
||||
|
||||
# Build static files
|
||||
yarn build
|
||||
```
|
||||
|
||||
After you create/modify the `plugins.json` (refer to `PLUGINS.md` for plugin
|
||||
docs) file, you can re-run the following to install their dependencies:
|
||||
|
||||
```bash
|
||||
# Reconcile plugins
|
||||
./bin/cli plugins reconcile
|
||||
|
||||
# Build static files
|
||||
yarn build
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
Refer to the `README.md` file for required configuration variables to add to the
|
||||
environment.
|
||||
|
||||
You can start the server after configuring the server using the command:
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
This will setup the server to serve everything on a single node.js process and
|
||||
is designed to be used in production.
|
||||
|
||||
You can see other scripts we've made available by consulting the `package.json`
|
||||
file under the `scripts` key including:
|
||||
|
||||
- `yarn test` run unit tests
|
||||
- `yarn e2e` run end to end tests
|
||||
- `yarn build-watch` watch for changes to client files and build static assets
|
||||
- `yarn dev-start` watch for changes to server files and reload the server while
|
||||
also sourcing a `.env` file in your local directory for configuration
|
||||
|
||||
# Setup
|
||||
|
||||
Once you've installed Talk (either via Docker or source), you still need to
|
||||
setup the application. If you are unfamiliar with any terminology used in the
|
||||
setup process, refer to the `TERMINOLOGY.md` document.
|
||||
|
||||
## Via Web
|
||||
|
||||
If you want to perform your setup via the web, you can navigate to your
|
||||
installation of Talk at the path `/admin/install`. There you will be asked a
|
||||
series of questions for your installation.
|
||||
|
||||
## Via CLI
|
||||
|
||||
If you want to perform your setup through the terminal, you can simply run:
|
||||
|
||||
```bash
|
||||
cli setup
|
||||
```
|
||||
|
||||
And follow the instructions to perform initial setup and create your first user
|
||||
account.
|
||||
|
||||
|
||||
# Usage
|
||||
|
||||
After setup is complete, you can then refer to the `/admin/configure` path to
|
||||
get the embed code that you can copy/paste onto your blog or website in order to
|
||||
start using Talk.
|
||||
|
||||
_In order for the embed to work correctly, you will need to whitelist the domain
|
||||
that is allowed to embed your site on the `/admin/configure` page, failure to do
|
||||
so will result in the comment stream not loading._
|
||||
@@ -1,3 +0,0 @@
|
||||
# Talk Plugins
|
||||
|
||||
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!
|
||||
@@ -5,105 +5,9 @@ Online comments are broken. Our open-source Talk tool rethinks how moderation, c
|
||||
Third party licenses are available via the `/client/3rdpartylicenses.txt`
|
||||
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).
|
||||
|
||||
## Documentation
|
||||
|
||||
### General
|
||||
|
||||
See our [Talk Documentation & Guides](https://coralproject.github.io/talk/index.html).
|
||||
|
||||
### Plugins
|
||||
|
||||
See our guide to using and building [Talk Plugins](https://github.com/coralproject/talk/blob/master/PLUGINS.md).
|
||||
|
||||
### Recipes
|
||||
|
||||
Recipes are plugin templates provided by the Coral Core team. Developers can use these recipes to build their own plugins. You can find all the Talk recipes here: https://github.com/coralproject/talk-recipes/
|
||||
|
||||
## Usage
|
||||
|
||||
### Installation
|
||||
|
||||
To set up a development environment or build from source, see [INSTALL.md](https://github.com/coralproject/talk/blob/master/INSTALL.md).
|
||||
|
||||
To launch a Talk server of your own from your browser without any need to muck about in a terminal or think about engineering concepts, stay tuned. We will launch [our installer](https://github.com/coralproject/talk-install) shortly!
|
||||
|
||||
### Configuration
|
||||
|
||||
The Talk application looks for the following configuration values either as environment variables:
|
||||
|
||||
- `TALK_MONGO_URL` (*required*) - the database connection string for the MongoDB database.
|
||||
- `TALK_REDIS_URL` (*required*) - the database connection string for the Redis database.
|
||||
- `TALK_ROOT_URL` (*required*) - root url of the installed application externally
|
||||
available in the format: `<scheme>://<host>` without the path.
|
||||
- `TALK_JWT_SECRET` (*required*) - a long and cryptographical secure random string which will be used to
|
||||
sign and verify tokens via a `HS256` algorithm.
|
||||
- `TALK_JWT_EXPIRY` (_optional_) - the expiry duration (`exp`) for the tokens issued for logged in sessions (Default `1 day`)
|
||||
- `TALK_JWT_ISSUER` (_optional_) - the issuer (`iss`) claim for login JWT tokens (Default `process.env.TALK_ROOT_URL`)
|
||||
- `TALK_JWT_AUDIENCE` (_optional_) - the audience (`aud`) claim for login JWT tokens (Default `talk`)
|
||||
- `TALK_SMTP_EMAIL` (*required for email*) - the address to send emails from using the
|
||||
SMTP provider.
|
||||
- `TALK_SMTP_USERNAME` (*required for email*) - username of the SMTP provider you are using.
|
||||
- `TALK_SMTP_PASSWORD` (*required for email*) - password for the SMTP provider you are using.
|
||||
- `TALK_SMTP_HOST` (*required for email*) - SMTP host url with format `smtp.domain.com`.
|
||||
- `TALK_SMTP_PORT` (*required for email*) - SMTP port.
|
||||
- `TALK_INSTALL_LOCK` (_optional for dynamic setup_) - Defaults to `FALSE`. When `TRUE`, disables the dynamic setup endpoint.
|
||||
- `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.
|
||||
|
||||
Our first feature is the notion of Karma. Talk will automatically pre-moderate comments of users who have a negative karma score. You can [see more how karma works here](/services/karma.js).
|
||||
|
||||
## Supported Browsers
|
||||
|
||||
### Web
|
||||
|
||||
- Chrome: latest 2 versions
|
||||
- Firefox: latest 2 versions, and most recent extended support version, if any
|
||||
- Safari: latest 2 versions
|
||||
- Internet Explorer: IE Edge, 11
|
||||
|
||||
### iOS Devices
|
||||
|
||||
- iPad
|
||||
- iPad Pro
|
||||
- iPhone 7 Plus
|
||||
- iPhone 7
|
||||
- iPhone 6 Plus
|
||||
- iPhone 6
|
||||
- iPhone 5
|
||||
|
||||
### iOS Browsers
|
||||
|
||||
- Chrome for iOS: latest version
|
||||
- Firefox for iOS: latest version
|
||||
- Safari for iOS: latest version
|
||||
|
||||
### Android Devices
|
||||
|
||||
- Galaxy S5
|
||||
- Nexus 5X
|
||||
- Nexus 6P
|
||||
|
||||
### Android Browsers
|
||||
|
||||
- Chrome for Android: latest version
|
||||
- Firefox for Android: latest version
|
||||
See our [Talk Documentation & Guides](https://coralproject.github.io/talk/).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# Product's Terminology
|
||||
|
||||
This is a guide to have a common language to talk about "Talk".
|
||||
|
||||
## Definitions
|
||||
|
||||
* Site - a top level site, aka nytimes.com
|
||||
* Section - the section of a site, aka, Politics.
|
||||
* Subsection - the section of a site, aka, Politics.
|
||||
* Asset - An article/video/etc identified by URL.
|
||||
|
||||
* Embed - Things we put on a asset: comment box, ToS, Stream, etc…
|
||||
* Stream - All the activity on a certain asset. Container for Comments, actions, user
|
||||
* Thread - defined by a parent and everything below. All replies to a comment and their replies, etc…
|
||||
* Comment - a kind of user-generated content submitted by a comment author
|
||||
* A parent comment has replies to it
|
||||
* A child comments is a reply to another comment
|
||||
* A comment can be both a parent comment and a child of another comment
|
||||
* A top-level comment is a comment that is not a reply to any other comment
|
||||
* A nth-level comment refers to the number of replies away from the top-level comment
|
||||
|
||||
* User - an item to represent a person using Talk. It could be a moderator, reader, etc.
|
||||
* User Roles:
|
||||
* Active: some who takes action (logged in or not)
|
||||
* Passive: some who just reads, no actions performed
|
||||
* Comment Author: The user who wrote the comment
|
||||
* Staff Member: someone who works for an organization (tagged for leverage in trust)
|
||||
* Moderator: someone with the ability to access the moderation queue and perform moderation actions
|
||||
* Administrator: has the ability to change the setup of their coral space
|
||||
* Public Profile: information about users shown in public
|
||||
* Private Profile: information about users shown only to user about themselves
|
||||
* Protected Profile: information about users that only moderators and admins can see
|
||||
|
||||
* Queue - Group of items based on a query, aka - moderation queue
|
||||
* Target - The item/s on which an action is performed
|
||||
|
||||
## Actions
|
||||
|
||||
Actions are performed by users on items. Actions themselves are items. This requires two relationships: action on item, and user performs action.
|
||||
|
||||
### Flag
|
||||
* A Flagger(user) performs a Flag
|
||||
* A Flag is performed on a Comment or a username or profile content
|
||||
|
||||
|
||||
## Moderation Actions and Status
|
||||
|
||||
Comments contain a field `status`. As moderation actions are peformed, the status changes.
|
||||
|
||||
* Initial status is empty.
|
||||
* When a moderator Approves, the status is set to 'approved'.
|
||||
* When a moderator Rejects, the status is set to 'reject'.
|
||||
|
||||
### Pre and post moderation
|
||||
|
||||
Comments can be set to be premoderated or postmoderated.
|
||||
|
||||
Premoderation means that moderation has to occur _before_ a comment is shown on the site:
|
||||
|
||||
* New comments are shown in the moderator queues immediately.
|
||||
* The are not shown to users until (aka in streams) until they are approved by a moderator.
|
||||
|
||||
Postmoderation means that comments appear on the site _before_ any moderation action is taken.
|
||||
|
||||
* New comments appear in comment streams immediately.
|
||||
* New comments do not appear in moderation queues unless they are flagged by other users.
|
||||
|
||||
### Word lists
|
||||
|
||||
* Banned words - words that the site never allows in a comment
|
||||
* Suspect words - words whose usage needs to be approved by a moderator before being shown in the stream
|
||||
* Approved words - words that are usually Banned or Suspect sitewide, but approved for use in a specific article stream
|
||||
|
||||
@@ -6,6 +6,7 @@ const helmet = require('helmet');
|
||||
const authentication = require('./middleware/authentication');
|
||||
const {passport} = require('./services/passport');
|
||||
const plugins = require('./services/plugins');
|
||||
const pubsub = require('./services/pubsub');
|
||||
const i18n = require('./services/i18n');
|
||||
const enabled = require('debug').enabled;
|
||||
const errors = require('./errors');
|
||||
@@ -14,6 +15,7 @@ const apollo = require('graphql-server-express');
|
||||
const accepts = require('accepts');
|
||||
const compression = require('compression');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const {ROOT_URL} = require('./config');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -73,6 +75,12 @@ app.use('/public', express.static(path.join(__dirname, 'public')));
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
// Set the BASE_URL as the ROOT_URL.
|
||||
app.locals.BASE_URL = ROOT_URL;
|
||||
if (app.locals.BASE_URL[app.locals.BASE_URL.length - 1] !== '/') {
|
||||
app.locals.BASE_URL += '/';
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// PASSPORT MIDDLEWARE
|
||||
//==============================================================================
|
||||
@@ -95,6 +103,19 @@ app.use(passport.initialize());
|
||||
// (if present) the JWT on the request.
|
||||
app.use('/api', authentication);
|
||||
|
||||
const pubsubClient = pubsub.createClientFactory();
|
||||
|
||||
// To handle dependancy injection safer, we inject the pubsub handle onto the
|
||||
// request object.
|
||||
app.use('/api', (req, res, next) => {
|
||||
|
||||
// Attach the pubsub handle to the requests.
|
||||
req.pubsub = pubsubClient();
|
||||
|
||||
// Forward on the request.
|
||||
next();
|
||||
});
|
||||
|
||||
//==============================================================================
|
||||
// GraphQL Router
|
||||
//==============================================================================
|
||||
@@ -159,7 +180,7 @@ app.use('/', (err, req, res, next) => {
|
||||
}
|
||||
|
||||
i18n.init(req);
|
||||
|
||||
|
||||
if (err instanceof errors.APIError) {
|
||||
res.status(err.status);
|
||||
res.render('error', {
|
||||
|
||||
+3
-3
@@ -274,7 +274,7 @@ async function reconcilePluginDeps({skipLocal, skipRemote, dryRun, upgradeRemote
|
||||
}
|
||||
|
||||
async function createSeedPlugin() {
|
||||
const pluginsDir = path.join(__dirname, 'plugins');
|
||||
const pluginsDir = path.resolve(__dirname, '..', 'plugins');
|
||||
|
||||
function pluginNameExists(pluginName) {
|
||||
const pluginNames = fs.readdirSync(pluginsDir);
|
||||
@@ -321,7 +321,7 @@ async function createSeedPlugin() {
|
||||
// Creating plugin seed
|
||||
//==============================================================================
|
||||
|
||||
const seedPlugin = path.join(__dirname, 'bin/templates/plugin');
|
||||
const seedPlugin = path.join(__dirname, 'templates/plugin');
|
||||
const newPluginPath = path.join(pluginsDir, answers.pluginName);
|
||||
|
||||
if (fs.existsSync(seedPlugin)) {
|
||||
@@ -355,7 +355,7 @@ async function createSeedPlugin() {
|
||||
|
||||
// Let's add this to the plugins.json
|
||||
if (answers.addPluginsJson) {
|
||||
const pluginsJson = path.join(dir, 'plugins.json');
|
||||
const pluginsJson = path.resolve(__dirname, '..', 'plugins.json');
|
||||
|
||||
fs.readJson(pluginsJson)
|
||||
.then((j) => {
|
||||
|
||||
+13
-5
@@ -12,6 +12,7 @@ const SetupService = require('../services/setup');
|
||||
const kue = require('../services/kue');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const util = require('./util');
|
||||
const cache = require('../services/cache');
|
||||
const {createSubscriptionManager} = require('../graph/subscriptions');
|
||||
const {
|
||||
PORT
|
||||
@@ -80,12 +81,16 @@ function normalizePort(val) {
|
||||
* Event listener for HTTP server "listening" event.
|
||||
*/
|
||||
|
||||
function onListening() {
|
||||
async function onListening() {
|
||||
|
||||
// Start the cache instance.
|
||||
await cache.init();
|
||||
|
||||
let addr = server.address();
|
||||
let bind = typeof addr === 'string'
|
||||
? `pipe ${addr}`
|
||||
: `port ${addr.port}`;
|
||||
console.log(`API Server Listening on ${bind}`);
|
||||
debug(`API Server Listening on ${bind}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,18 +140,21 @@ async function startApp(program) {
|
||||
/**
|
||||
* Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
server.on('listening', () => {
|
||||
|
||||
});
|
||||
server.listen(port, () => {
|
||||
|
||||
// Mount the websocket server if requested.
|
||||
if (program.websockets) {
|
||||
console.log(`Websocket Server Listening on ${port}`);
|
||||
debug(`Websocket Server Listening on ${port}`);
|
||||
|
||||
// Mount the subscriptions server on the application server.
|
||||
createSubscriptionManager(server);
|
||||
}
|
||||
});
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
|
||||
+32
-6
@@ -94,7 +94,7 @@ const performSetup = async () => {
|
||||
name: 'requireEmailConfirmation',
|
||||
default: settings.requireEmailConfirmation,
|
||||
message: 'Should emails always be confirmed'
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
// Update the settings that were changed.
|
||||
@@ -104,6 +104,32 @@ const performSetup = async () => {
|
||||
}
|
||||
});
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'inputWhitelistedDomains',
|
||||
default: true,
|
||||
message: 'Would you like to specify a whitelisted domain'
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'whitelistedDomain',
|
||||
message: 'Whitelisted Domain',
|
||||
when: ({inputWhitelistedDomains}) => inputWhitelistedDomains,
|
||||
validate: (input) => {
|
||||
if (input && input.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Whitelisted Domain cannot be empty.';
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
if (answers.inputWhitelistedDomains) {
|
||||
settings.domains.whitelist = [answers.whitelistedDomain];
|
||||
}
|
||||
|
||||
console.log('\nWe\'ll ask you some questions about your first admin user.\n');
|
||||
|
||||
let user = await inquirer.prompt([
|
||||
@@ -147,7 +173,11 @@ const performSetup = async () => {
|
||||
name: 'confirmPassword',
|
||||
message: 'Confirm Password',
|
||||
type: 'password',
|
||||
filter: (confirmPassword) => {
|
||||
filter: (confirmPassword, {password}) => {
|
||||
if (password !== confirmPassword) {
|
||||
return Promise.reject(new Error('Passwords do not match'));
|
||||
}
|
||||
|
||||
return UsersService
|
||||
.isValidPassword(confirmPassword)
|
||||
.catch((err) => {
|
||||
@@ -157,10 +187,6 @@ const performSetup = async () => {
|
||||
},
|
||||
]);
|
||||
|
||||
if (user.password !== user.confirmPassword) {
|
||||
return Promise.reject(new Error('Passwords do not match'));
|
||||
}
|
||||
|
||||
let {user: newUser} = await SetupService.setup({
|
||||
settings: settings.toObject(),
|
||||
user: {
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
#
|
||||
# ```
|
||||
# en:
|
||||
# coral-plugin-respect:
|
||||
# talk-plugin-respect:
|
||||
# respect: Respect
|
||||
# respected: Respected
|
||||
# es:
|
||||
# coral-plugin-respect:
|
||||
# talk-plugin-respect:
|
||||
# respect: Respetar
|
||||
# respected: Respetado
|
||||
# ```
|
||||
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
machine:
|
||||
node:
|
||||
version: 7.9
|
||||
version: 8
|
||||
services:
|
||||
- docker
|
||||
- redis
|
||||
@@ -21,9 +21,8 @@ dependencies:
|
||||
|
||||
# Install node dependencies.
|
||||
- yarn --version
|
||||
- yarn global add node-gyp --force
|
||||
- yarn
|
||||
cache_directories:
|
||||
- ~/.cache/yarn
|
||||
post:
|
||||
# Build the static assets.
|
||||
- yarn build
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@
|
||||
"transform-async-to-generator",
|
||||
"transform-react-jsx"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import {Router, Route, IndexRedirect, browserHistory, Redirect} from 'react-router';
|
||||
import {Router, Route, IndexRedirect, IndexRoute} from 'react-router';
|
||||
import {history} from 'coral-framework/helpers/router';
|
||||
|
||||
import Configure from 'routes/Configure';
|
||||
import Dashboard from 'routes/Dashboard';
|
||||
@@ -14,7 +15,7 @@ const routes = (
|
||||
<div>
|
||||
<Route exact path="/admin/install" component={Install}/>
|
||||
<Route path='/admin' component={Layout}>
|
||||
<IndexRedirect to='/admin/moderate/all' />
|
||||
<IndexRedirect to='/admin/moderate' />
|
||||
<Route path='configure' component={Configure} />
|
||||
<Route path='stories' component={Stories} />
|
||||
<Route path='dashboard' component={Dashboard} />
|
||||
@@ -34,28 +35,18 @@ const routes = (
|
||||
{/* Moderation Routes */}
|
||||
|
||||
<Route path='moderate' component={ModerationLayout}>
|
||||
<Route path='all' components={Moderation}>
|
||||
<IndexRoute components={Moderation} />
|
||||
|
||||
<Route path=':tabOrId' components={Moderation} />
|
||||
|
||||
<Route path=':tab' components={Moderation}>
|
||||
<Route path=':id' components={Moderation} />
|
||||
</Route>
|
||||
<Route path='accepted' components={Moderation}>
|
||||
<Route path=':id' components={Moderation} />
|
||||
</Route>
|
||||
<Route path='premod' components={Moderation}>
|
||||
<Route path=':id' components={Moderation} />
|
||||
</Route>
|
||||
<Route path='rejected' components={Moderation}>
|
||||
<Route path=':id' components={Moderation} />
|
||||
</Route>
|
||||
<Route path='flagged' components={Moderation}>
|
||||
<Route path=':id' components={Moderation} />
|
||||
</Route>
|
||||
<Redirect from=':id' to='all/:id' />
|
||||
<IndexRedirect to='all' />
|
||||
</Route>
|
||||
</Route>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AppRouter = () => <Router history={browserHistory} routes={routes} />;
|
||||
const AppRouter = () => <Router history={history} routes={routes}/>;
|
||||
|
||||
export default AppRouter;
|
||||
|
||||
@@ -15,33 +15,11 @@ export const hideShortcutsNote = () => {
|
||||
return {type: actions.HIDE_SHORTCUTS_NOTE};
|
||||
};
|
||||
|
||||
export const viewUserDetail = (userId) => ({type: actions.VIEW_USER_DETAIL, userId});
|
||||
export const hideUserDetail = () => ({type: actions.HIDE_USER_DETAIL});
|
||||
|
||||
export const setSortOrder = (order) => ({
|
||||
type: actions.SET_SORT_ORDER,
|
||||
order
|
||||
});
|
||||
|
||||
export const changeUserDetailStatuses = (tab) => {
|
||||
let statuses;
|
||||
if (tab === 'all') {
|
||||
statuses = ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'];
|
||||
} else if (tab === 'rejected') {
|
||||
statuses = ['REJECTED'];
|
||||
}
|
||||
return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses};
|
||||
};
|
||||
|
||||
export const clearUserDetailSelections = () => ({type: actions.CLEAR_USER_DETAIL_SELECTIONS});
|
||||
|
||||
export const toggleSelectCommentInUserDetail = (id, active) => {
|
||||
return {
|
||||
type: active ? actions.SELECT_USER_DETAIL_COMMENT : actions.UNSELECT_USER_DETAIL_COMMENT,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
export const toggleStorySearch = (active) => ({
|
||||
type: active ? actions.SHOW_STORY_SEARCH : actions.HIDE_STORY_SEARCH
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as actions from 'constants/userDetail';
|
||||
|
||||
export const viewUserDetail = (userId) => ({type: actions.VIEW_USER_DETAIL, userId});
|
||||
export const hideUserDetail = () => ({type: actions.HIDE_USER_DETAIL});
|
||||
|
||||
export const changeUserDetailStatuses = (tab) => {
|
||||
let statuses;
|
||||
if (tab === 'all') {
|
||||
statuses = ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'];
|
||||
} else if (tab === 'rejected') {
|
||||
statuses = ['REJECTED'];
|
||||
}
|
||||
return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses};
|
||||
};
|
||||
|
||||
export const clearUserDetailSelections = () => ({type: actions.CLEAR_USER_DETAIL_SELECTIONS});
|
||||
|
||||
export const toggleSelectCommentInUserDetail = (id, active) => {
|
||||
return {
|
||||
type: active ? actions.SELECT_USER_DETAIL_COMMENT : actions.UNSELECT_USER_DETAIL_COMMENT,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './ModerationList.css';
|
||||
import {Button} from 'coral-ui';
|
||||
import {menuActionsMap} from '../routes/Moderation/helpers/moderationQueueActionsMap';
|
||||
import {menuActionsMap} from '../utils/moderationQueueActionsMap';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import {murmur3} from 'murmurhash-js';
|
||||
import {CSSTransitionGroup} from 'react-transition-group';
|
||||
import styles from './CommentAnimatedEdit.css';
|
||||
|
||||
export default ({children, body}) => {
|
||||
return (
|
||||
<CSSTransitionGroup
|
||||
component={'div'}
|
||||
transitionName={{
|
||||
enter: styles.bodyEnter,
|
||||
enterActive: styles.bodyEnterActive,
|
||||
leave: styles.bodyLeave,
|
||||
leaveActive: styles.bodyLeaveActive,
|
||||
}}
|
||||
transitionEnter={true}
|
||||
transitionLeave={true}
|
||||
transitionEnterTimeout={3600}
|
||||
transitionLeaveTimeout={2800}
|
||||
>
|
||||
{React.cloneElement(React.Children.only(children), {key: murmur3(body)})}
|
||||
</CSSTransitionGroup>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import Linkify from 'react-linkify';
|
||||
const linkify = new Linkify();
|
||||
|
||||
export default ({suspectWords, bannedWords, body, ...rest}) => {
|
||||
|
||||
const links = linkify.getMatches(body);
|
||||
const linkText = links ? links.map((link) => link.raw) : [];
|
||||
|
||||
// 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(body);
|
||||
})
|
||||
.concat(linkText);
|
||||
|
||||
return (
|
||||
<Highlighter
|
||||
{...rest}
|
||||
searchWords={searchWords}
|
||||
textToHighlight={body}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+9
-11
@@ -1,22 +1,20 @@
|
||||
.commentType {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 11px;
|
||||
display: inline-block;
|
||||
color: white;
|
||||
background: grey;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
line-height: 29px;
|
||||
padding: 2px 8px 2px 26px;
|
||||
padding: 0px 5px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
|
||||
i {
|
||||
height: 24px;
|
||||
letter-spacing: 0.4px;
|
||||
margin-bottom: 1px;
|
||||
|
||||
> i {
|
||||
font-size: 14px;
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 8px;
|
||||
vertical-align: text-top;
|
||||
margin: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&.premod {
|
||||
+2
-1
@@ -1,12 +1,13 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './CommentType.css';
|
||||
import {Icon} from 'coral-ui';
|
||||
import cn from 'classnames';
|
||||
|
||||
const CommentType = (props) => {
|
||||
const typeData = getTypeData(props.type);
|
||||
|
||||
return (
|
||||
<span className={`${styles.commentType} ${styles[typeData.className]}`}>
|
||||
<span className={cn(styles.commentType, styles[typeData.className], props.className)}>
|
||||
<Icon name={typeData.icon}/>{typeData.text}
|
||||
</span>
|
||||
);
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
.flagBox {
|
||||
border-top: 1px solid rgba(66, 66, 66, 0.12);
|
||||
|
||||
margin-top: 10px;
|
||||
.container {
|
||||
padding: 0 14px;
|
||||
}
|
||||
+5
-3
@@ -61,9 +61,11 @@ class FlagBox extends Component {
|
||||
<ul>
|
||||
{actionList.map((action, j) =>
|
||||
<li key={`${i}_${j}`} className={styles.subDetail}>
|
||||
<a className={styles.username} onClick={viewUserDetail}>
|
||||
{action.user.username}
|
||||
</a>
|
||||
{action.user &&
|
||||
<a className={styles.username} onClick={() => viewUserDetail(action.user.id)}>
|
||||
{action.user.username}
|
||||
</a>
|
||||
}
|
||||
{action.message}
|
||||
</li>
|
||||
)}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import Linkify from 'react-linkify';
|
||||
const linkify = new Linkify();
|
||||
|
||||
export default ({text, children}) => {
|
||||
const hasLinks = !!linkify.getMatches(text);
|
||||
|
||||
if (!hasLinks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return React.Children.only(children);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
.loadMoreContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #FFF;
|
||||
max-width: 660px;
|
||||
margin-bottom: 30px;
|
||||
background-color: #2376D8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loadMore:hover {
|
||||
background-color: #4399FF;
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -1,9 +1,10 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
import styles from './styles.css';
|
||||
import styles from './LoadMore.css';
|
||||
import cn from 'classnames';
|
||||
|
||||
const LoadMore = ({loadMore, showLoadMore}) =>
|
||||
<div className={styles.loadMoreContainer}>
|
||||
const LoadMore = ({loadMore, showLoadMore, className, ...rest}) =>
|
||||
<div {...rest} className={cn(className, styles.loadMoreContainer)}>
|
||||
{
|
||||
showLoadMore && <Button
|
||||
className={styles.loadMore}
|
||||
@@ -186,13 +186,11 @@
|
||||
.actionButton {
|
||||
transform: scale(.8);
|
||||
margin: 0;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.minimal {
|
||||
width: 45px;
|
||||
min-width: 0;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.approve__active {
|
||||
|
||||
+9
@@ -79,3 +79,12 @@
|
||||
margin-left: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
.loadMore > button {
|
||||
background-color: #696969;
|
||||
|
||||
&:hover {
|
||||
background-color: #404040;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
+44
-28
@@ -1,22 +1,21 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import Comment from './Comment';
|
||||
import Comment from './UserDetailComment';
|
||||
import styles from './UserDetail.css';
|
||||
import {Button, Drawer} from 'coral-ui';
|
||||
import {Button, Drawer, Spinner} from 'coral-ui';
|
||||
import {Slot} from 'coral-framework/components';
|
||||
import ButtonCopyToClipboard from './ButtonCopyToClipboard';
|
||||
import {actionsMap} from '../helpers/moderationQueueActionsMap';
|
||||
import {actionsMap} from '../utils/moderationQueueActionsMap';
|
||||
import ClickOutside from 'coral-framework/components/ClickOutside';
|
||||
import LoadMore from '../components/LoadMore';
|
||||
|
||||
export default class UserDetail extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
userId: PropTypes.string.isRequired,
|
||||
hideUserDetail: PropTypes.func.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
bannedWords: PropTypes.array.isRequired,
|
||||
suspectWords: PropTypes.array.isRequired,
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
changeStatus: PropTypes.func.isRequired,
|
||||
@@ -45,27 +44,36 @@ export default class UserDetail extends React.Component {
|
||||
this.props.changeStatus('rejected');
|
||||
}
|
||||
|
||||
render () {
|
||||
renderLoading() {
|
||||
return (
|
||||
<ClickOutside onClickOutside={this.props.hideUserDetail}>
|
||||
<Drawer onClose={this.props.hideUserDetail}>
|
||||
<Spinner />
|
||||
</Drawer>
|
||||
</ClickOutside>
|
||||
);
|
||||
}
|
||||
|
||||
renderLoaded() {
|
||||
const {
|
||||
root: {
|
||||
user,
|
||||
totalComments,
|
||||
rejectedComments,
|
||||
comments: {nodes}
|
||||
},
|
||||
moderation: {
|
||||
userDetailActiveTab: tab,
|
||||
userDetailSelectedIds: selectedIds
|
||||
comments: {nodes, hasNextPage}
|
||||
},
|
||||
activeTab,
|
||||
selectedCommentIds,
|
||||
bannedWords,
|
||||
suspectWords,
|
||||
toggleSelect,
|
||||
bulkAccept,
|
||||
bulkReject,
|
||||
showBanUserDialog,
|
||||
showSuspendUserDialog,
|
||||
hideUserDetail
|
||||
hideUserDetail,
|
||||
viewUserDetail,
|
||||
loadMore,
|
||||
} = this.props;
|
||||
|
||||
const localProfile = user.profiles.find((p) => p.provider === 'local');
|
||||
|
||||
let profile;
|
||||
@@ -113,11 +121,11 @@ export default class UserDetail extends React.Component {
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
selectedIds.length === 0
|
||||
selectedCommentIds.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>
|
||||
<li className={activeTab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
|
||||
<li className={activeTab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
|
||||
</ul>
|
||||
)
|
||||
: (
|
||||
@@ -134,39 +142,47 @@ export default class UserDetail extends React.Component {
|
||||
cStyle='reject'
|
||||
icon='close'>
|
||||
</Button>
|
||||
{`${selectedIds.length} comments selected`}
|
||||
{`${selectedCommentIds.length} comments selected`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div>
|
||||
{
|
||||
nodes.map((comment, i) => {
|
||||
nodes.map((comment) => {
|
||||
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
|
||||
const selected = selectedIds.indexOf(comment.id) !== -1;
|
||||
const selected = selectedCommentIds.indexOf(comment.id) !== -1;
|
||||
return <Comment
|
||||
key={comment.id}
|
||||
index={i}
|
||||
user={user}
|
||||
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} />;
|
||||
viewUserDetail={viewUserDetail}
|
||||
/>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
<LoadMore
|
||||
className={styles.loadMore}
|
||||
loadMore={loadMore}
|
||||
showLoadMore={hasNextPage}
|
||||
/>
|
||||
</Drawer>
|
||||
</ClickOutside>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
if (this.props.loading) {
|
||||
return this.renderLoading();
|
||||
}
|
||||
return this.renderLoaded();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
.root {
|
||||
display: block;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
width: 100%;
|
||||
transition: all 200ms;
|
||||
padding: 10px 0px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.root:last-child {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.rootSelected {
|
||||
background-color: #ecf4ff;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0px 14px;
|
||||
}
|
||||
|
||||
.story {
|
||||
font-size: 14px;
|
||||
margin: 10px 0;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.story > a {
|
||||
display: inline-block;
|
||||
color: #063b9a;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
letter-spacing: .5px;
|
||||
margin-left: 10px;
|
||||
font-size: 13px;
|
||||
margin-left: 5px;
|
||||
padding-bottom: 0px;
|
||||
border-bottom: solid 1px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.bodyContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-weight: 300;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.commentType {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.created {
|
||||
padding: 5px;
|
||||
color: #262626;
|
||||
font-size: 14px;
|
||||
line-height: 1px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 0px;
|
||||
flex: 1;
|
||||
color: black;
|
||||
max-width: 500px;
|
||||
word-wrap: break-word;
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.sideActions {
|
||||
}
|
||||
|
||||
.editedMarker {
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
line-height: 1px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bulkSelectInput {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.external {
|
||||
font-size: .7em;
|
||||
text-decoration: none;
|
||||
color: #063b9a;
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
margin-left: 10px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
opacity: .9;
|
||||
}
|
||||
|
||||
> i {
|
||||
font-size: 12px;
|
||||
top: 2px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.hasLinks {
|
||||
color: #f00;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Link} from 'react-router';
|
||||
|
||||
import {Icon} from 'coral-ui';
|
||||
import FlagBox from './FlagBox';
|
||||
import styles from './UserDetailComment.css';
|
||||
import CommentType from './CommentType';
|
||||
import {getActionSummary} from 'coral-framework/utils';
|
||||
import ActionButton from 'coral-admin/src/components/ActionButton';
|
||||
import CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter';
|
||||
import IfHasLink from 'coral-admin/src/components/IfHasLink';
|
||||
import cn from 'classnames';
|
||||
import {getCommentType} from 'coral-admin/src/utils/comment';
|
||||
import CommentAnimatedEdit from './CommentAnimatedEdit';
|
||||
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
|
||||
class UserDetailComment extends React.Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
actions = [],
|
||||
comment,
|
||||
viewUserDetail,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
selected,
|
||||
toggleSelect,
|
||||
className,
|
||||
user,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
|
||||
const flagActions = comment.actions && comment.actions.filter((a) => a.__typename === 'FlagAction');
|
||||
const commentType = getCommentType(comment);
|
||||
|
||||
return (
|
||||
<li
|
||||
tabIndex={0}
|
||||
className={cn(className, styles.root, {[styles.rootSelected]: selected})}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<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)}
|
||||
</span>
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
<CommentType type={commentType} className={styles.commentType}/>
|
||||
</div>
|
||||
<div className={styles.story}>
|
||||
Story: {comment.asset.title}
|
||||
{<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
|
||||
</div>
|
||||
<CommentAnimatedEdit body={comment.body}>
|
||||
<div className={styles.bodyContainer}>
|
||||
<p className={styles.body}>
|
||||
<CommentBodyHighlighter
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
body={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>
|
||||
<div className={styles.sideActions}>
|
||||
<IfHasLink text={comment.body}>
|
||||
<span className={styles.hasLinks}>
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
</span>
|
||||
</IfHasLink>
|
||||
<div className={styles.actions}>
|
||||
{actions.map((action, i) => {
|
||||
const active =
|
||||
(action === 'REJECT' && comment.status === 'REJECTED') ||
|
||||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
|
||||
return (
|
||||
<ActionButton
|
||||
minimal={true}
|
||||
key={i}
|
||||
type={action}
|
||||
user={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>
|
||||
</div>
|
||||
</div>
|
||||
</CommentAnimatedEdit>
|
||||
</div>
|
||||
{flagActions && flagActions.length
|
||||
? <FlagBox
|
||||
actions={flagActions}
|
||||
actionSummaries={flagActionSummaries}
|
||||
viewUserDetail={viewUserDetail}
|
||||
/>
|
||||
: null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UserDetailComment.propTypes = {
|
||||
user: PropTypes.object.isRequired,
|
||||
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,
|
||||
toggleSelect: PropTypes.func,
|
||||
comment: PropTypes.shape({
|
||||
body: PropTypes.string.isRequired,
|
||||
action_summaries: PropTypes.array,
|
||||
actions: PropTypes.array,
|
||||
created_at: PropTypes.string.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
title: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
id: PropTypes.string
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
export default UserDetailComment;
|
||||
@@ -10,16 +10,18 @@
|
||||
.logo span {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
font-size: 18px;
|
||||
font-size: 26px;
|
||||
vertical-align: middle;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logo {
|
||||
background: #E5E5E5;
|
||||
background: #696969;
|
||||
height: 100%;
|
||||
width: 128px;
|
||||
z-index: 10;
|
||||
border-right: 1px #757575 solid;
|
||||
}
|
||||
|
||||
.base {
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
export const TOGGLE_MODAL = 'TOGGLE_MODAL';
|
||||
export const SINGLE_VIEW = 'SINGLE_VIEW';
|
||||
export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE';
|
||||
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';
|
||||
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';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL';
|
||||
export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL';
|
||||
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';
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
import UserDetail from 'coral-admin/src/containers/UserDetail';
|
||||
|
||||
class LayoutContainer extends Component {
|
||||
componentWillMount() {
|
||||
@@ -57,6 +58,7 @@ class LayoutContainer extends Component {
|
||||
>
|
||||
<BanUserDialog />
|
||||
<SuspendUserDialog />
|
||||
<UserDetail />
|
||||
{this.props.children}
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import UserDetail from '../components/UserDetail';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
import {
|
||||
viewUserDetail,
|
||||
hideUserDetail,
|
||||
changeUserDetailStatuses,
|
||||
clearUserDetailSelections,
|
||||
toggleSelectCommentInUserDetail,
|
||||
} from 'coral-admin/src/actions/userDetail';
|
||||
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
|
||||
import UserDetailComment from './UserDetailComment';
|
||||
import update from 'immutability-helper';
|
||||
|
||||
const commentConnectionFragment = gql`
|
||||
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
|
||||
nodes {
|
||||
...${getDefinitionName(UserDetailComment.fragments.comment)}
|
||||
}
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
${UserDetailComment.fragments.comment}
|
||||
`;
|
||||
|
||||
const slots = [
|
||||
'userProfile',
|
||||
];
|
||||
|
||||
class UserDetailContainer extends React.Component {
|
||||
isLoadingMore = false;
|
||||
|
||||
// status can be 'ACCEPTED' or 'REJECTED'
|
||||
bulkSetCommentStatus = (status) => {
|
||||
const changes = this.props.selectedCommentIds.map((commentId) => {
|
||||
return this.props.setCommentStatus({commentId, status});
|
||||
});
|
||||
|
||||
Promise.all(changes).then(() => {
|
||||
this.props.clearUserDetailSelections(); // un-select everything
|
||||
});
|
||||
}
|
||||
|
||||
bulkReject = () => {
|
||||
this.bulkSetCommentStatus('REJECTED');
|
||||
}
|
||||
|
||||
bulkAccept = () => {
|
||||
this.bulkSetCommentStatus('ACCEPTED');
|
||||
}
|
||||
|
||||
acceptComment = ({commentId}) => {
|
||||
return this.props.setCommentStatus({commentId, status: 'ACCEPTED'});
|
||||
}
|
||||
|
||||
rejectComment = ({commentId}) => {
|
||||
return this.props.setCommentStatus({commentId, status: 'REJECTED'});
|
||||
}
|
||||
|
||||
loadMore = () => {
|
||||
if (this.isLoadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoadingMore = true;
|
||||
const variables = {
|
||||
limit: 10,
|
||||
cursor: this.props.root.comments.endCursor,
|
||||
author_id: this.props.data.variables.author_id,
|
||||
statuses: this.props.data.variables.statuses,
|
||||
};
|
||||
this.props.data.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
variables,
|
||||
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
|
||||
return update(prev, {
|
||||
comments: {
|
||||
nodes: {$push: comments.nodes},
|
||||
hasNextPage: {$set: comments.hasNextPage},
|
||||
startCursor: {$set: comments.startCursor},
|
||||
endCursor: {$set: comments.endCursor},
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
this.isLoadingMore = false;
|
||||
})
|
||||
.catch((err) => {
|
||||
this.isLoadingMore = false;
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
if (this.props.userId === null && next.userId) {
|
||||
next.data.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
if (!this.props.userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const loading = [1, 2, 4].indexOf(this.props.data.networkStatus) >= 0;
|
||||
|
||||
return <UserDetail
|
||||
bulkReject={this.bulkReject}
|
||||
bulkAccept={this.bulkAccept}
|
||||
changeStatus={this.props.changeUserDetailStatuses}
|
||||
toggleSelect={this.props.toggleSelectCommentInUserDetail}
|
||||
acceptComment={this.acceptComment}
|
||||
rejectComment={this.rejectComment}
|
||||
loading={loading}
|
||||
loadMore={this.loadMore}
|
||||
{...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
const LOAD_MORE_QUERY = gql`
|
||||
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
|
||||
comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
}
|
||||
${commentConnectionFragment}
|
||||
`;
|
||||
|
||||
export const withUserDetailQuery = withQuery(gql`
|
||||
query CoralAdmin_UserDetail($author_id: ID!, $statuses: [COMMENT_STATUS!]) {
|
||||
user(id: $author_id) {
|
||||
id
|
||||
username
|
||||
created_at
|
||||
profiles {
|
||||
id
|
||||
provider
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'user')}
|
||||
}
|
||||
totalComments: commentCount(query: {author_id: $author_id})
|
||||
rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
|
||||
comments: comments(query: {
|
||||
author_id: $author_id,
|
||||
statuses: $statuses
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
${commentConnectionFragment}
|
||||
`, {
|
||||
options: ({userId, statuses}) => {
|
||||
return {
|
||||
variables: {author_id: userId, statuses}
|
||||
};
|
||||
},
|
||||
skip: (ownProps) => !ownProps.userId,
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
userId: state.userDetail.userId,
|
||||
selectedCommentIds: state.userDetail.selectedCommentIds,
|
||||
statuses: state.userDetail.statuses,
|
||||
activeTab: state.userDetail.activeTab,
|
||||
bannedWords: state.settings.toJS().wordlist.banned,
|
||||
suspectWords: state.settings.toJS().wordlist.suspect,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
...bindActionCreators({
|
||||
changeUserDetailStatuses,
|
||||
clearUserDetailSelections,
|
||||
toggleSelectCommentInUserDetail,
|
||||
viewUserDetail,
|
||||
hideUserDetail,
|
||||
}, dispatch)
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withUserDetailQuery,
|
||||
withSetCommentStatus,
|
||||
)(UserDetailContainer);
|
||||
@@ -0,0 +1,39 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import UserDetailComment from '../components/UserDetailComment';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
|
||||
export default withFragments({
|
||||
comment: gql`
|
||||
fragment CoralAdmin_UserDetail_comment on Comment {
|
||||
id
|
||||
body
|
||||
created_at
|
||||
status
|
||||
asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
action_summaries {
|
||||
count
|
||||
... on FlagActionSummary {
|
||||
reason
|
||||
}
|
||||
}
|
||||
actions {
|
||||
... on FlagAction {
|
||||
id
|
||||
reason
|
||||
message
|
||||
user {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
editing {
|
||||
edited
|
||||
}
|
||||
}
|
||||
`
|
||||
})(UserDetailComment);
|
||||
@@ -2,8 +2,10 @@ import React from 'react';
|
||||
import {render} from 'react-dom';
|
||||
import {ApolloProvider} from 'react-apollo';
|
||||
import smoothscroll from 'smoothscroll-polyfill';
|
||||
import EventEmitter from 'eventemitter2';
|
||||
import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider';
|
||||
|
||||
import {getClient} from './services/client';
|
||||
import {getClient} from 'coral-framework/services/client';
|
||||
import store from './services/store';
|
||||
|
||||
import App from './components/App';
|
||||
@@ -12,13 +14,19 @@ import 'react-mdl/extra/material.js';
|
||||
import './graphql';
|
||||
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
|
||||
|
||||
const eventEmitter = new EventEmitter();
|
||||
|
||||
// TODO: pass redux actions through the emitter.
|
||||
|
||||
loadPluginsTranslations();
|
||||
injectPluginsReducers();
|
||||
smoothscroll.polyfill();
|
||||
|
||||
render(
|
||||
<ApolloProvider client={getClient()} store={store}>
|
||||
<App />
|
||||
</ApolloProvider>
|
||||
<EventEmitterProvider eventEmitter={eventEmitter}>
|
||||
<ApolloProvider client={getClient()} store={store}>
|
||||
<App />
|
||||
</ApolloProvider>
|
||||
</EventEmitterProvider>
|
||||
, document.querySelector('#root')
|
||||
);
|
||||
|
||||
@@ -7,11 +7,13 @@ import install from './install';
|
||||
import config from './config';
|
||||
import banUserDialog from './banUserDialog';
|
||||
import suspendUserDialog from './suspendUserDialog';
|
||||
import userDetail from './userDetail';
|
||||
|
||||
export default {
|
||||
auth,
|
||||
banUserDialog,
|
||||
suspendUserDialog,
|
||||
userDetail,
|
||||
assets,
|
||||
settings,
|
||||
community,
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import {fromJS, Set} from 'immutable';
|
||||
import {fromJS} from 'immutable';
|
||||
import * as actions from '../constants/moderation';
|
||||
|
||||
const initialState = fromJS({
|
||||
singleView: false,
|
||||
modalOpen: false,
|
||||
userDetailId: null,
|
||||
userDetailActiveTab: 'all',
|
||||
userDetailStatuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'],
|
||||
userDetailSelectedIds: new Set(),
|
||||
storySearchVisible: false,
|
||||
storySearchString: '',
|
||||
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
|
||||
@@ -18,9 +14,6 @@ export default function moderation (state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.MODERATION_CLEAR_STATE:
|
||||
return initialState;
|
||||
case actions.SET_ACTIVE_TAB:
|
||||
return state
|
||||
.set('activeTab', action.activeTab);
|
||||
case actions.TOGGLE_MODAL:
|
||||
return state
|
||||
.set('modalOpen', action.open);
|
||||
@@ -30,22 +23,6 @@ export default function moderation (state = initialState, action) {
|
||||
case actions.HIDE_SHORTCUTS_NOTE:
|
||||
return state
|
||||
.set('shortcutsNoteVisible', 'hide');
|
||||
case actions.VIEW_USER_DETAIL:
|
||||
return state.set('userDetailId', action.userId);
|
||||
case actions.HIDE_USER_DETAIL:
|
||||
return state
|
||||
.set('userDetailId', null)
|
||||
.update('userDetailSelectedIds', (set) => set.clear());
|
||||
case actions.CLEAR_USER_DETAIL_SELECTIONS:
|
||||
return state.update('userDetailSelectedIds', (set) => set.clear());
|
||||
case actions.CHANGE_USER_DETAIL_STATUSES:
|
||||
return state
|
||||
.set('userDetailActiveTab', action.tab)
|
||||
.set('userDetailStatuses', action.statuses);
|
||||
case actions.SELECT_USER_DETAIL_COMMENT:
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as actions from '../constants/userDetail';
|
||||
|
||||
const initialState = {
|
||||
userId: null,
|
||||
activeTab: 'all',
|
||||
statuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'],
|
||||
selectedCommentIds: [],
|
||||
};
|
||||
|
||||
export default function banUserDialog(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.VIEW_USER_DETAIL:
|
||||
return {
|
||||
...state,
|
||||
userId: action.userId,
|
||||
};
|
||||
case actions.HIDE_USER_DETAIL:
|
||||
return {
|
||||
...state,
|
||||
userId: null,
|
||||
selectedCommentIds: [],
|
||||
};
|
||||
case actions.CLEAR_USER_DETAIL_SELECTIONS:
|
||||
return {
|
||||
...state,
|
||||
selectedCommentIds: [],
|
||||
};
|
||||
case actions.CHANGE_USER_DETAIL_STATUSES:
|
||||
return {
|
||||
...state,
|
||||
activeTab: action.tab,
|
||||
statuses: action.statuses,
|
||||
};
|
||||
case actions.SELECT_USER_DETAIL_COMMENT:
|
||||
return {
|
||||
...state,
|
||||
selectedCommentIds: [...state.selectedCommentIds, action.id],
|
||||
};
|
||||
case actions.UNSELECT_USER_DETAIL_COMMENT:
|
||||
return {
|
||||
...state,
|
||||
selectedCommentIds: state.selectedCommentIds.filter((id) => id !== action.id),
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import styles from './Community.css';
|
||||
import {Button} from 'coral-ui';
|
||||
import {menuActionsMap} from '../../Moderation/helpers/moderationQueueActionsMap';
|
||||
import {menuActionsMap} from '../../../utils/moderationQueueActionsMap';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
|
||||
@@ -52,57 +52,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.email {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dataTable {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
|
||||
th {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
th:nth-child(2), th:nth-child(3) {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.selectField {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
height: 36px;
|
||||
background: #2c2c2c;
|
||||
padding: 10px 15px;
|
||||
box-sizing: border-box;
|
||||
color: white;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
|
||||
> div {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
i {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.7px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: 8px 0;
|
||||
list-style: none;
|
||||
@@ -340,3 +289,11 @@ p.flaggedByReason {
|
||||
margin: 0px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.button {
|
||||
composes: buttonReset from 'coral-framework/styles/reset.css';
|
||||
vertical-align: text-bottom;
|
||||
&:hover {
|
||||
background-color: #E0E0E0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export default class Community extends Component {
|
||||
}
|
||||
|
||||
getTabContent(searchValue, props) {
|
||||
const {community, root: {users}} = props;
|
||||
const {community, root: {users}, viewUserDetail} = props;
|
||||
const activeTab = props.route.path === ':id' ? 'flagged' : props.route.path;
|
||||
|
||||
if (activeTab === 'people') {
|
||||
@@ -84,6 +84,7 @@ export default class Community extends Component {
|
||||
approveUser={props.approveUser}
|
||||
rejectUsername={props.rejectUsername}
|
||||
currentUser={this.props.currentUser}
|
||||
viewUserDetail={viewUserDetail}
|
||||
/>
|
||||
<RejectUsernameDialog
|
||||
open={community.rejectUsernameDialog}
|
||||
|
||||
@@ -5,7 +5,7 @@ import styles from './Community.css';
|
||||
import EmptyCard from 'coral-admin/src/components/EmptyCard';
|
||||
import User from './User';
|
||||
|
||||
const FlaggedAccounts = ({...props}) => {
|
||||
const FlaggedAccounts = (props) => {
|
||||
const {commenters} = props;
|
||||
const hasResults = commenters && !!commenters.length;
|
||||
|
||||
@@ -25,6 +25,7 @@ const FlaggedAccounts = ({...props}) => {
|
||||
showRejectUsernameDialog={props.showRejectUsernameDialog}
|
||||
approveUser={props.approveUser}
|
||||
currentUser={props.currentUser}
|
||||
viewUserDetail={props.viewUserDetail}
|
||||
/>;
|
||||
})
|
||||
: <EmptyCard>{t('community.no_flagged_accounts')}</EmptyCard>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
.dataTable {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
|
||||
th {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
th:nth-child(2), th:nth-child(3) {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
composes: buttonReset from 'coral-framework/styles/reset.css';
|
||||
&:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
}
|
||||
|
||||
.email {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.selectField {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
height: 36px;
|
||||
background: #2c2c2c;
|
||||
padding: 10px 15px;
|
||||
box-sizing: border-box;
|
||||
color: white;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
|
||||
> div {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
i {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.7px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import {SelectField, Option} from 'react-mdl-selectfield';
|
||||
import styles from '../components/Community.css';
|
||||
import styles from '../components/Table.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export default ({headers, commenters, onHeaderClickHandler, onRoleChange, onCommenterStatusChange}) => (
|
||||
export default ({headers, commenters, onHeaderClickHandler, onRoleChange, onCommenterStatusChange, viewUserDetail}) => (
|
||||
<table className={`mdl-data-table ${styles.dataTable}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -21,7 +21,7 @@ export default ({headers, commenters, onHeaderClickHandler, onRoleChange, onComm
|
||||
{commenters.map((row, i)=> (
|
||||
<tr key={i}>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
{row.username}
|
||||
<button onClick={() => {viewUserDetail(row.id);}} className={styles.button}>{row.username}</button>
|
||||
<span className={styles.email}>{row.profiles.map(({id}) => id)}</span>
|
||||
</td>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import styles from './Community.css';
|
||||
|
||||
import ActionButton from './ActionButton';
|
||||
import {username} from 'coral-plugin-flags/helpers/flagReasons';
|
||||
import {username} from 'talk-plugin-flags/helpers/flagReasons';
|
||||
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
|
||||
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
|
||||
|
||||
@@ -38,9 +38,7 @@ const User = (props) => {
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<span>
|
||||
{user.username}
|
||||
</span>
|
||||
<button onClick={() => {props.viewUserDetail(user.id);}} className={styles.button}>{user.username}</button>
|
||||
{props.currentUser.id !== user.id &&
|
||||
<ActionsMenu icon="not_interested">
|
||||
<ActionsMenuItem
|
||||
@@ -82,7 +80,12 @@ const User = (props) => {
|
||||
(action, j) => {
|
||||
if (action.reason === action_sum.reason) {
|
||||
return <p className={styles.flaggedByReason} key={j}>
|
||||
{action.user && action.user.username}: {action.message ? action.message : 'n/a'}
|
||||
{action.user &&
|
||||
<button onClick={() => {props.viewUserDetail(action.user.id);}} className={styles.button}>
|
||||
{action.user.username}
|
||||
</button>
|
||||
}
|
||||
: {action.message ? action.message : 'n/a'}
|
||||
</p>;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
showRejectUsernameDialog,
|
||||
hideRejectUsernameDialog
|
||||
} from '../../../actions/community';
|
||||
import {viewUserDetail} from '../../../actions/userDetail';
|
||||
|
||||
import Community from '../components/Community';
|
||||
|
||||
@@ -99,6 +100,7 @@ const mapDispatchToProps = (dispatch) =>
|
||||
hideRejectUsernameDialog,
|
||||
updateSorting,
|
||||
newPage,
|
||||
viewUserDetail,
|
||||
}, dispatch);
|
||||
|
||||
export default compose(
|
||||
|
||||
@@ -4,6 +4,7 @@ import {bindActionCreators} from 'redux';
|
||||
import {compose} from 'react-apollo';
|
||||
import {setRole, setCommenterStatus} from '../../../actions/community';
|
||||
import Table from '../components/Table';
|
||||
import {viewUserDetail} from '../../../actions/userDetail';
|
||||
|
||||
class TableContainer extends Component {
|
||||
|
||||
@@ -29,6 +30,7 @@ const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({
|
||||
setCommenterStatus,
|
||||
setRole,
|
||||
viewUserDetail,
|
||||
}, dispatch);
|
||||
|
||||
export default compose(
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, {Component} from 'react';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import styles from './Configure.css';
|
||||
import {Button, Card} from 'coral-ui';
|
||||
import {BASE_URL} from 'coral-framework/constants/url';
|
||||
|
||||
class EmbedLink extends Component {
|
||||
|
||||
@@ -24,21 +25,14 @@ class EmbedLink extends Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const location = window.location;
|
||||
const talkBaseUrl = [
|
||||
location.protocol,
|
||||
'//',
|
||||
location.hostname,
|
||||
location.port ? (`:${window.location.port}`) : ''
|
||||
].join('');
|
||||
const coralJsUrl = [talkBaseUrl, '/embed.js'].join('');
|
||||
const coralJsUrl = [BASE_URL, '/embed.js'].join('');
|
||||
const nonce = String(Math.random()).slice(2);
|
||||
const streamElementId = `coral_talk_${nonce}`;
|
||||
const embedText = `
|
||||
<div id="${streamElementId}"></div>
|
||||
<script src="${coralJsUrl}" async onload="
|
||||
Coral.Talk.render(document.getElementById('${streamElementId}'), {
|
||||
talk: '${talkBaseUrl}'
|
||||
talk: '${BASE_URL}'
|
||||
});
|
||||
"></script>
|
||||
`.trim();
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Link} from 'react-router';
|
||||
import Linkify from 'react-linkify';
|
||||
|
||||
import {Icon} from 'coral-ui';
|
||||
import FlagBox from './FlagBox';
|
||||
import FlagBox from 'coral-admin/src/components/FlagBox';
|
||||
import styles from './styles.css';
|
||||
import CommentType from './CommentType';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import CommentType from 'coral-admin/src/components/CommentType';
|
||||
import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
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 CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter';
|
||||
import IfHasLink from 'coral-admin/src/components/IfHasLink';
|
||||
import cn from 'classnames';
|
||||
import {murmur3} from 'murmurhash-js';
|
||||
import {CSSTransitionGroup} from 'react-transition-group';
|
||||
|
||||
const linkify = new Linkify();
|
||||
import {getCommentType} from 'coral-admin/src/utils/comment';
|
||||
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
|
||||
@@ -29,41 +27,16 @@ class Comment extends React.Component {
|
||||
viewUserDetail,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
minimal,
|
||||
selected,
|
||||
toggleSelect,
|
||||
className,
|
||||
...props
|
||||
} = this.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';
|
||||
}
|
||||
const flagActions = comment.actions && comment.actions.filter((a) => a.__typename === 'FlagAction');
|
||||
const commentType = getCommentType(comment);
|
||||
|
||||
// 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';
|
||||
}
|
||||
let selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
|
||||
|
||||
const showSuspenUserDialog = () => props.showSuspendUserDialog({
|
||||
userId: comment.user.id,
|
||||
@@ -81,31 +54,21 @@ class Comment extends React.Component {
|
||||
|
||||
return (
|
||||
<li
|
||||
tabIndex={props.index}
|
||||
className={cn(className, 'mdl-card', selectionStateCSS, styles.Comment, styles.listItem, {[styles.minimal]: minimal, [styles.selected]: selected})}
|
||||
tabIndex={0}
|
||||
className={cn(className, 'mdl-card', selectionStateCSS, styles.Comment, styles.listItem, {[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)}
|
||||
{timeago(comment.created_at)}
|
||||
</span>
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
@@ -125,45 +88,31 @@ class Comment extends React.Component {
|
||||
</ActionsMenuItem>
|
||||
</ActionsMenu>
|
||||
}
|
||||
<CommentType type={commentType} />
|
||||
<div className={styles.adminCommentInfoBar}>
|
||||
<CommentType type={commentType} className={styles.commentType}/>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
comment={comment}
|
||||
asset={comment.asset}
|
||||
fill="adminCommentInfoBar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{comment.user.status === 'banned'
|
||||
? <span className={styles.banned}>
|
||||
<Icon name="error_outline" />
|
||||
{t('comment.banned_user')}
|
||||
</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/all/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
|
||||
<Link to={`/admin/moderate/${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)}>
|
||||
<CommentAnimatedEdit body={comment.body}>
|
||||
<div className={styles.itemBody}>
|
||||
<p className={styles.body}>
|
||||
<Highlighter
|
||||
searchWords={searchWords}
|
||||
textToHighlight={comment.body}
|
||||
<CommentBodyHighlighter
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
body={comment.body}
|
||||
/>
|
||||
{' '}
|
||||
<a
|
||||
@@ -181,11 +130,11 @@ class Comment extends React.Component {
|
||||
comment={comment}
|
||||
/>
|
||||
<div className={styles.sideActions}>
|
||||
{links
|
||||
? <span className={styles.hasLinks}>
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
</span>
|
||||
: null}
|
||||
<IfHasLink text={comment.body}>
|
||||
<span className={styles.hasLinks}>
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
</span>
|
||||
</IfHasLink>
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{actions.map((action, i) => {
|
||||
const active =
|
||||
@@ -193,7 +142,6 @@ class Comment extends React.Component {
|
||||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
|
||||
return (
|
||||
<ActionButton
|
||||
minimal={minimal}
|
||||
key={i}
|
||||
type={action}
|
||||
user={comment.user}
|
||||
@@ -219,7 +167,7 @@ class Comment extends React.Component {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CSSTransitionGroup>
|
||||
</CommentAnimatedEdit>
|
||||
</div>
|
||||
<Slot
|
||||
data={props.data}
|
||||
@@ -231,7 +179,7 @@ class Comment extends React.Component {
|
||||
? <FlagBox
|
||||
actions={flagActions}
|
||||
actionSummaries={flagActionSummaries}
|
||||
viewUserDetail={() => viewUserDetail(comment.user.id)}
|
||||
viewUserDetail={viewUserDetail}
|
||||
/>
|
||||
: null}
|
||||
</li>
|
||||
@@ -240,7 +188,6 @@ class Comment extends React.Component {
|
||||
}
|
||||
|
||||
Comment.propTypes = {
|
||||
minimal: PropTypes.bool,
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
@@ -251,7 +198,6 @@ Comment.propTypes = {
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func.isRequired,
|
||||
currentUserId: PropTypes.string.isRequired,
|
||||
toggleSelect: PropTypes.func,
|
||||
comment: PropTypes.shape({
|
||||
body: PropTypes.string.isRequired,
|
||||
action_summaries: PropTypes.array,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
.count {
|
||||
display: inline-block;
|
||||
background: #989797;
|
||||
background: #616161;
|
||||
margin: 2px;
|
||||
vertical-align: middle;
|
||||
padding: 1px 7px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
margin-left: 2px;
|
||||
line-height: 20px;
|
||||
line-height: 18px;
|
||||
box-sizing: border-box;
|
||||
height: 21px;
|
||||
height: 18px;
|
||||
right: 0;
|
||||
margin-top: -2px;
|
||||
margin-top: 0px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import ModerationQueue from './ModerationQueue';
|
||||
import ModerationMenu from './ModerationMenu';
|
||||
import ModerationHeader from './ModerationHeader';
|
||||
import ModerationKeysModal from '../../../components/ModerationKeysModal';
|
||||
import UserDetail from '../containers/UserDetail';
|
||||
import StorySearch from '../containers/StorySearch';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
|
||||
export default class Moderation extends Component {
|
||||
constructor() {
|
||||
@@ -59,8 +59,7 @@ export default class Moderation extends Component {
|
||||
}
|
||||
|
||||
getComments = () => {
|
||||
const {root, route} = this.props;
|
||||
const activeTab = route.path === ':id' ? 'premod' : route.path;
|
||||
const {root, activeTab} = this.props;
|
||||
return root[activeTab].nodes;
|
||||
}
|
||||
|
||||
@@ -102,30 +101,19 @@ export default class Moderation extends Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
|
||||
const {root, moderation, settings, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props;
|
||||
const assetId = this.props.params.id;
|
||||
const {root, data, moderation, settings, viewUserDetail, hideUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props;
|
||||
const {asset} = root;
|
||||
const assetId = asset && asset.id;
|
||||
|
||||
const comments = root[activeTab];
|
||||
let activeTabCount;
|
||||
switch(activeTab) {
|
||||
case 'all':
|
||||
activeTabCount = root.allCount;
|
||||
break;
|
||||
case 'accepted':
|
||||
activeTabCount = root.acceptedCount;
|
||||
break;
|
||||
case 'premod':
|
||||
activeTabCount = root.premodCount;
|
||||
break;
|
||||
case 'flagged':
|
||||
activeTabCount = root.flaggedCount;
|
||||
break;
|
||||
case 'rejected':
|
||||
activeTabCount = root.rejectedCount;
|
||||
break;
|
||||
}
|
||||
|
||||
const activeTabCount = root[`${activeTab}Count`];
|
||||
const menuItems = Object.keys(queueConfig).map((queue) => ({
|
||||
key: queue,
|
||||
name: queueConfig[queue].name,
|
||||
icon: queueConfig[queue].icon,
|
||||
count: root[`${queue}Count`]
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -137,13 +125,11 @@ export default class Moderation extends Component {
|
||||
/>
|
||||
<ModerationMenu
|
||||
asset={asset}
|
||||
allCount={root.allCount}
|
||||
acceptedCount={root.acceptedCount}
|
||||
premodCount={root.premodCount}
|
||||
rejectedCount={root.rejectedCount}
|
||||
flaggedCount={root.flaggedCount}
|
||||
getModPath={getModPath}
|
||||
items={menuItems}
|
||||
selectSort={this.props.setSortOrder}
|
||||
sort={this.props.moderation.sortOrder}
|
||||
activeTab={activeTab}
|
||||
/>
|
||||
<ModerationQueue
|
||||
data={this.props.data}
|
||||
@@ -173,24 +159,21 @@ export default class Moderation extends Component {
|
||||
open={moderation.modalOpen}
|
||||
onClose={this.onClose}/>
|
||||
|
||||
{moderation.userDetailId && (
|
||||
<UserDetail
|
||||
id={moderation.userDetailId}
|
||||
hideUserDetail={hideUserDetail}
|
||||
bannedWords={settings.wordlist.banned}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
acceptComment={props.acceptComment}
|
||||
rejectComment={props.rejectComment} />
|
||||
)}
|
||||
|
||||
<StorySearch
|
||||
assetId={assetId}
|
||||
moderation={this.props.moderation}
|
||||
closeSearch={this.closeSearch}
|
||||
storySearchChange={this.props.storySearchChange}
|
||||
/>
|
||||
|
||||
<Slot
|
||||
data={data}
|
||||
root={root}
|
||||
assset={asset}
|
||||
activeTab={activeTab}
|
||||
handleCommentChange={handleCommentChange}
|
||||
fill='adminModeration'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,52 +4,32 @@ import styles from './styles.css';
|
||||
import {SelectField, Option} from 'react-mdl-selectfield';
|
||||
import {Icon} from 'coral-ui';
|
||||
import {Link} from 'react-router';
|
||||
import cn from 'classnames';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const ModerationMenu = (
|
||||
{asset, allCount, acceptedCount, premodCount, rejectedCount, flaggedCount, selectSort, sort}
|
||||
) => {
|
||||
|
||||
function getPath (type) {
|
||||
return asset ? `/admin/moderate/${type}/${asset.id}` : `/admin/moderate/${type}`;
|
||||
}
|
||||
|
||||
const ModerationMenu = ({
|
||||
asset = {},
|
||||
items,
|
||||
selectSort,
|
||||
sort,
|
||||
getModPath,
|
||||
activeTab
|
||||
}) => {
|
||||
return (
|
||||
<div className="mdl-tabs">
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<div className={styles.tabBarPadding} />
|
||||
<div>
|
||||
<Link
|
||||
to={getPath('all')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name='question_answer' className={styles.tabIcon} /> {t('modqueue.all')} <CommentCount count={allCount} />
|
||||
</Link>
|
||||
<Link
|
||||
to={getPath('premod')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name='access_time' className={styles.tabIcon} /> {t('modqueue.premod')} <CommentCount count={premodCount} />
|
||||
</Link>
|
||||
<Link
|
||||
to={getPath('flagged')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name='flag' className={styles.tabIcon} /> {t('modqueue.flagged')} <CommentCount count={flaggedCount} />
|
||||
</Link>
|
||||
<Link
|
||||
to={getPath('accepted')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name='check' className={styles.tabIcon} /> {t('modqueue.approved')} <CommentCount count={acceptedCount} />
|
||||
</Link>
|
||||
<Link
|
||||
to={getPath('rejected')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name='close' className={styles.tabIcon} /> {t('modqueue.rejected')} <CommentCount count={rejectedCount} />
|
||||
</Link>
|
||||
{items.map((queue) =>
|
||||
<Link
|
||||
key={queue.key}
|
||||
to={getModPath(queue.key, asset.id)}
|
||||
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === queue.key})}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name={queue.icon} className={styles.tabIcon} /> {queue.name} <CommentCount count={queue.count} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<SelectField
|
||||
className={styles.selectField}
|
||||
@@ -65,10 +45,7 @@ const ModerationMenu = (
|
||||
};
|
||||
|
||||
ModerationMenu.propTypes = {
|
||||
allCount: PropTypes.number.isRequired,
|
||||
premodCount: PropTypes.number.isRequired,
|
||||
rejectedCount: PropTypes.number.isRequired,
|
||||
flaggedCount: PropTypes.number.isRequired,
|
||||
items: PropTypes.array.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
})
|
||||
|
||||
@@ -3,8 +3,8 @@ import React, {PropTypes} from 'react';
|
||||
import Comment from '../containers/Comment';
|
||||
import styles from './styles.css';
|
||||
import EmptyCard from '../../../components/EmptyCard';
|
||||
import {actionsMap} from '../helpers/moderationQueueActionsMap';
|
||||
import LoadMore from './LoadMore';
|
||||
import {actionsMap} from '../../../utils/moderationQueueActionsMap';
|
||||
import LoadMore from '../../../components/LoadMore';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {CSSTransitionGroup} from 'react-transition-group';
|
||||
|
||||
@@ -85,7 +85,6 @@ class ModerationQueue extends React.Component {
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
key={comment.id}
|
||||
index={i}
|
||||
comment={comment}
|
||||
selected={i === selectedIndex}
|
||||
suspectWords={props.suspectWords}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
height: 50px;
|
||||
min-height: 50px;
|
||||
box-sizing: border-box;
|
||||
transition: background-color 400ms;
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ const StorySearch = (props) => {
|
||||
? <Spinner />
|
||||
: assets.map((story, i) => {
|
||||
const storyOpen = story.closedAt === null || new Date(story.closedAt) > new Date();
|
||||
|
||||
return <Story
|
||||
key={i}
|
||||
id={story.id}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
color: #C0C0C0;
|
||||
color: #BDBDBD;
|
||||
text-transform: capitalize;
|
||||
font-weight: 100;
|
||||
font-size: 14px;
|
||||
@@ -29,7 +29,7 @@
|
||||
margin-right: 20px;
|
||||
&:hover {
|
||||
color: white;
|
||||
border-bottom: solid 2px #F36451;
|
||||
/*border-bottom: solid 2px #F36451;*/
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ span {
|
||||
color: white;
|
||||
text-transform: capitalize;
|
||||
font-weight: 400;
|
||||
font-size: 15px;
|
||||
font-size: 20px;
|
||||
letter-spacing: 1px;
|
||||
transition: background-color 200ms;
|
||||
opacity: 1;
|
||||
@@ -173,7 +173,7 @@ span {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
font-size: 18px;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-width: 650px;
|
||||
min-width: 400px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
@@ -213,11 +213,12 @@ span {
|
||||
|
||||
.author {
|
||||
font-weight: 300;
|
||||
min-width: 230px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #262626;
|
||||
font-size: 16px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,26 +397,6 @@ span {
|
||||
}
|
||||
}
|
||||
|
||||
.loadMoreContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
};
|
||||
|
||||
.loadMore {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #FFF;
|
||||
max-width: 660px;
|
||||
margin-bottom: 30px;
|
||||
background-color: #2376D8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loadMore:hover {
|
||||
background-color: #4399FF;
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
@@ -457,18 +438,6 @@ span {
|
||||
}
|
||||
}
|
||||
|
||||
.minimal {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.minimalSelection {
|
||||
background-color: #ecf4ff;
|
||||
}
|
||||
|
||||
.bulkSelectInput {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.emptyCardContainer {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -491,31 +460,6 @@ span {
|
||||
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;
|
||||
@@ -526,5 +470,13 @@ span {
|
||||
|
||||
.searchTrigger {
|
||||
position: relative;
|
||||
top: .3em;
|
||||
top: .2em;
|
||||
}
|
||||
|
||||
.adminCommentInfoBar {
|
||||
min-width: 100px;
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import Comment from '../components/Comment';
|
||||
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import {getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
|
||||
const pluginFragments = getSlotsFragments([
|
||||
const slots = [
|
||||
'adminCommentInfoBar',
|
||||
'adminCommentContent',
|
||||
'adminSideActions',
|
||||
'adminCommentDetailArea',
|
||||
]);
|
||||
];
|
||||
|
||||
export default withFragments({
|
||||
root: gql`
|
||||
fragment CoralAdmin_ModerationComment_root on RootQuery {
|
||||
__typename
|
||||
${pluginFragments.spreads('root')}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
${pluginFragments.definitions('root')}
|
||||
`,
|
||||
comment: gql`
|
||||
fragment CoralAdmin_ModerationComment_comment on Comment {
|
||||
@@ -42,9 +41,11 @@ export default withFragments({
|
||||
}
|
||||
actions {
|
||||
... on FlagAction {
|
||||
id
|
||||
reason
|
||||
message
|
||||
user {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
@@ -52,8 +53,7 @@ export default withFragments({
|
||||
editing {
|
||||
edited
|
||||
}
|
||||
${pluginFragments.spreads('comment')}
|
||||
${getSlotFragmentSpreads(slots, 'comment')}
|
||||
}
|
||||
${pluginFragments.definitions('comment')}
|
||||
`
|
||||
})(Comment);
|
||||
|
||||
@@ -8,20 +8,20 @@ import t from 'coral-framework/services/i18n';
|
||||
import update from 'immutability-helper';
|
||||
import truncate from 'lodash/truncate';
|
||||
import NotFoundAsset from '../components/NotFoundAsset';
|
||||
import {isPremod, getModPath} from '../../../utils';
|
||||
|
||||
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
|
||||
import {handleCommentChange} from '../../../graphql/utils';
|
||||
import {handleCommentChange} from '../graphql';
|
||||
|
||||
import {fetchSettings} from 'actions/settings';
|
||||
import {showBanUserDialog} from 'actions/banUserDialog';
|
||||
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
|
||||
import {viewUserDetail} from '../../../actions/userDetail';
|
||||
import {
|
||||
toggleModal,
|
||||
singleView,
|
||||
hideShortcutsNote,
|
||||
toggleStorySearch,
|
||||
viewUserDetail,
|
||||
hideUserDetail,
|
||||
setSortOrder,
|
||||
storySearchChange,
|
||||
clearState
|
||||
@@ -30,15 +30,47 @@ import {
|
||||
import {Spinner} from 'coral-ui';
|
||||
import Moderation from '../components/Moderation';
|
||||
import Comment from './Comment';
|
||||
import queueConfig from '../queueConfig';
|
||||
|
||||
function prepareNotificationText(text) {
|
||||
return truncate(text, {length: 50}).replace('\n', ' ');
|
||||
}
|
||||
|
||||
function getAssetId(props) {
|
||||
if (props.params.tabOrId && !(props.params.tabOrId in queueConfig)) {
|
||||
return props.params.tabOrId;
|
||||
}
|
||||
return props.params.id || null;
|
||||
}
|
||||
|
||||
function getTab(props) {
|
||||
if (props.params.tabOrId && props.params.tabOrId in queueConfig) {
|
||||
return props.params.tabOrId;
|
||||
}
|
||||
return props.params.tab || null;
|
||||
}
|
||||
|
||||
class ModerationContainer extends Component {
|
||||
subscriptions = [];
|
||||
|
||||
get activeTab() { return this.props.route.path; }
|
||||
handleCommentChange = (root, comment, notify) => {
|
||||
return handleCommentChange(root, comment, this.props.data.variables.sort, notify, queueConfig, this.activeTab);
|
||||
};
|
||||
|
||||
get activeTab() {
|
||||
|
||||
const {root: {asset, settings}} = this.props;
|
||||
const id = getAssetId(this.props);
|
||||
const tab = getTab(this.props);
|
||||
|
||||
// Grab premod from asset or from settings
|
||||
const premod = !id ? settings.moderation : asset.settings.moderation;
|
||||
|
||||
const queue = isPremod(premod) ? 'premod' : 'new';
|
||||
const activeTab = tab ? tab : queue;
|
||||
|
||||
return activeTab;
|
||||
}
|
||||
|
||||
subscribeToUpdates(variables = this.props.data.variables) {
|
||||
const sub1 = this.props.data.subscribeToMore({
|
||||
@@ -46,15 +78,10 @@ class ModerationContainer extends Component {
|
||||
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);
|
||||
? ''
|
||||
: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body));
|
||||
return this.handleCommentChange(prev, comment, notify);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,15 +90,10 @@ class ModerationContainer extends Component {
|
||||
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);
|
||||
? ''
|
||||
: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body));
|
||||
return this.handleCommentChange(prev, comment, notify);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -79,13 +101,8 @@ class ModerationContainer extends Component {
|
||||
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 notify = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body));
|
||||
return this.handleCommentChange(prev, comment, notify);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,13 +111,8 @@ class ModerationContainer extends Component {
|
||||
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);
|
||||
const notify = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body));
|
||||
return this.handleCommentChange(prev, comment, notify);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -149,25 +161,9 @@ class ModerationContainer extends Component {
|
||||
cursor: this.props.root[tab].endCursor,
|
||||
sort: this.props.data.variables.sort,
|
||||
asset_id: this.props.data.variables.asset_id,
|
||||
statuses: queueConfig[tab].statuses,
|
||||
action_type: queueConfig[tab].action_type,
|
||||
};
|
||||
switch(tab) {
|
||||
case 'all':
|
||||
variables.statuses = null;
|
||||
break;
|
||||
case 'accepted':
|
||||
variables.statuses = ['ACCEPTED'];
|
||||
break;
|
||||
case 'premod':
|
||||
variables.statuses = ['PREMOD'];
|
||||
break;
|
||||
case 'flagged':
|
||||
variables.statuses = ['NONE', 'PREMOD'];
|
||||
variables.action_type = 'FLAG';
|
||||
break;
|
||||
case 'rejected':
|
||||
variables.statuses = ['REJECTED'];
|
||||
break;
|
||||
}
|
||||
return this.props.data.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
variables,
|
||||
@@ -185,7 +181,8 @@ class ModerationContainer extends Component {
|
||||
};
|
||||
|
||||
render () {
|
||||
const {root, root: {asset}, data, params: {id: assetId}} = this.props;
|
||||
const {root, root: {asset, settings}, data} = this.props;
|
||||
const assetId = getAssetId(this.props);
|
||||
|
||||
if (data.error) {
|
||||
return <div>Error</div>;
|
||||
@@ -208,12 +205,23 @@ class ModerationContainer extends Component {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
const premodEnabled = assetId ? isPremod(asset.settings.moderation) : isPremod(settings.moderation);
|
||||
const currentQueueConfig = Object.assign({}, queueConfig);
|
||||
if (premodEnabled) {
|
||||
delete currentQueueConfig.new;
|
||||
} else {
|
||||
delete currentQueueConfig.premod;
|
||||
}
|
||||
|
||||
return <Moderation
|
||||
{...this.props}
|
||||
getModPath={getModPath}
|
||||
loadMore={this.loadMore}
|
||||
acceptComment={this.acceptComment}
|
||||
rejectComment={this.rejectComment}
|
||||
activeTab={this.activeTab}
|
||||
queueConfig={currentQueueConfig}
|
||||
handleCommentChange={this.handleCommentChange}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
@@ -298,78 +306,46 @@ const commentConnectionFragment = gql`
|
||||
|
||||
const withModQueueQuery = withQuery(gql`
|
||||
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) {
|
||||
all: comments(query: {
|
||||
statuses: [NONE, PREMOD, ACCEPTED, REJECTED],
|
||||
asset_id: $asset_id,
|
||||
sort: $sort
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
accepted: comments(query: {
|
||||
statuses: [ACCEPTED],
|
||||
asset_id: $asset_id,
|
||||
sort: $sort
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
premod: comments(query: {
|
||||
statuses: [PREMOD],
|
||||
${Object.keys(queueConfig).map((queue) => `
|
||||
${queue}: comments(query: {
|
||||
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
|
||||
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
|
||||
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
|
||||
asset_id: $asset_id,
|
||||
sort: $sort
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
flagged: comments(query: {
|
||||
action_type: FLAG,
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
`)}
|
||||
${Object.keys(queueConfig).map((queue) => `
|
||||
${queue}Count: commentCount(query: {
|
||||
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
|
||||
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
|
||||
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
|
||||
asset_id: $asset_id,
|
||||
statuses: [NONE, PREMOD],
|
||||
sort: $sort
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
rejected: comments(query: {
|
||||
statuses: [REJECTED],
|
||||
asset_id: $asset_id,
|
||||
sort: $sort
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
})
|
||||
`)}
|
||||
asset(id: $asset_id) @skip(if: $allAssets) {
|
||||
id
|
||||
title
|
||||
url
|
||||
settings {
|
||||
moderation
|
||||
}
|
||||
}
|
||||
allCount: commentCount(query: {
|
||||
asset_id: $asset_id
|
||||
})
|
||||
acceptedCount: commentCount(query: {
|
||||
statuses: [ACCEPTED],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
premodCount: commentCount(query: {
|
||||
statuses: [PREMOD],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
rejectedCount: commentCount(query: {
|
||||
statuses: [REJECTED],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
flaggedCount: commentCount(query: {
|
||||
action_type: FLAG,
|
||||
asset_id: $asset_id,
|
||||
statuses: [NONE, PREMOD]
|
||||
})
|
||||
settings {
|
||||
organizationName
|
||||
moderation
|
||||
}
|
||||
}
|
||||
${commentConnectionFragment}
|
||||
`, {
|
||||
options: ({params: {id = null}, moderation: {sortOrder}}) => {
|
||||
options: (props) => {
|
||||
const id = getAssetId(props);
|
||||
return {
|
||||
variables: {
|
||||
asset_id: id,
|
||||
sort: sortOrder,
|
||||
sort: props.moderation.sortOrder,
|
||||
allAssets: id === null
|
||||
}
|
||||
};
|
||||
@@ -378,29 +354,18 @@ const withModQueueQuery = withQuery(gql`
|
||||
|
||||
const withQueueCountPolling = withQuery(gql`
|
||||
query CoralAdmin_ModerationCountPoll($asset_id: ID) {
|
||||
allCount: commentCount(query: {
|
||||
asset_id: $asset_id
|
||||
})
|
||||
acceptedCount: commentCount(query: {
|
||||
statuses: [ACCEPTED],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
premodCount: commentCount(query: {
|
||||
statuses: [PREMOD],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
rejectedCount: commentCount(query: {
|
||||
statuses: [REJECTED],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
flaggedCount: commentCount(query: {
|
||||
action_type: FLAG,
|
||||
asset_id: $asset_id,
|
||||
statuses: [NONE, PREMOD]
|
||||
})
|
||||
${Object.keys(queueConfig).map((queue) => `
|
||||
${queue}Count: commentCount(query: {
|
||||
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
|
||||
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
|
||||
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
|
||||
asset_id: $asset_id,
|
||||
})
|
||||
`)}
|
||||
}
|
||||
`, {
|
||||
options: ({params: {id = null}}) => {
|
||||
options: (props) => {
|
||||
const id = getAssetId(props);
|
||||
return {
|
||||
pollInterval: 5000,
|
||||
variables: {
|
||||
@@ -426,7 +391,6 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
toggleStorySearch,
|
||||
showSuspendUserDialog,
|
||||
viewUserDetail,
|
||||
hideUserDetail,
|
||||
setSortOrder,
|
||||
storySearchChange,
|
||||
clearState
|
||||
|
||||
@@ -59,13 +59,13 @@ class StorySearchContainer extends React.Component {
|
||||
|
||||
goToStory = (id) => {
|
||||
const {router} = this.props;
|
||||
router.push(`/admin/moderate/all/${id}`);
|
||||
router.push(`/admin/moderate/${id}`);
|
||||
this.clearAndCloseSearch();
|
||||
}
|
||||
|
||||
goToModerateAll = () => {
|
||||
const {router} = this.props;
|
||||
router.push('/admin/moderate/all');
|
||||
router.push('/admin/moderate');
|
||||
this.clearAndCloseSearch();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import UserDetail from '../components/UserDetail';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
import {
|
||||
changeUserDetailStatuses,
|
||||
clearUserDetailSelections,
|
||||
toggleSelectCommentInUserDetail
|
||||
} from 'coral-admin/src/actions/moderation';
|
||||
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
|
||||
import Comment from './Comment';
|
||||
|
||||
const commentConnectionFragment = gql`
|
||||
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
|
||||
nodes {
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
}
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const pluginFragments = getSlotsFragments([
|
||||
'userProfile',
|
||||
]);
|
||||
|
||||
class UserDetailContainer extends React.Component {
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
hideUserDetail: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
// status can be 'ACCEPTED' or 'REJECTED'
|
||||
bulkSetCommentStatus = (status) => {
|
||||
const changes = this.props.moderation.userDetailSelectedIds.map((commentId) => {
|
||||
return this.props.setCommentStatus({commentId, status});
|
||||
});
|
||||
|
||||
Promise.all(changes).then(() => {
|
||||
this.props.data.refetch(); // some comments may have moved out of this tab
|
||||
this.props.clearUserDetailSelections(); // un-select everything
|
||||
});
|
||||
}
|
||||
|
||||
bulkReject = () => {
|
||||
this.bulkSetCommentStatus('REJECTED');
|
||||
}
|
||||
|
||||
bulkAccept = () => {
|
||||
this.bulkSetCommentStatus('ACCEPTED');
|
||||
}
|
||||
|
||||
render () {
|
||||
if (!('user' in this.props.root)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <UserDetail
|
||||
bulkReject={this.bulkReject}
|
||||
bulkAccept={this.bulkAccept}
|
||||
changeStatus={this.props.changeUserDetailStatuses}
|
||||
toggleSelect={this.props.toggleSelectCommentInUserDetail}
|
||||
{...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export const withUserDetailQuery = withQuery(gql`
|
||||
query CoralAdmin_UserDetail($author_id: ID!, $statuses: [COMMENT_STATUS!]) {
|
||||
user(id: $author_id) {
|
||||
id
|
||||
username
|
||||
created_at
|
||||
profiles {
|
||||
id
|
||||
provider
|
||||
}
|
||||
${pluginFragments.spreads('user')}
|
||||
}
|
||||
totalComments: commentCount(query: {author_id: $author_id})
|
||||
rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
|
||||
comments: comments(query: {
|
||||
author_id: $author_id,
|
||||
statuses: $statuses
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
${pluginFragments.spreads('root')}
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
${pluginFragments.definitions('user')}
|
||||
${pluginFragments.definitions('root')}
|
||||
${commentConnectionFragment}
|
||||
`, {
|
||||
options: ({id, moderation: {userDetailStatuses: statuses}}) => {
|
||||
return {
|
||||
variables: {author_id: id, statuses}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
moderation: state.moderation.toJS()
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
...bindActionCreators({
|
||||
changeUserDetailStatuses,
|
||||
clearUserDetailSelections,
|
||||
toggleSelectCommentInUserDetail
|
||||
}, dispatch)
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withUserDetailQuery,
|
||||
withSetCommentStatus,
|
||||
)(UserDetailContainer);
|
||||
+30
-26
@@ -1,7 +1,6 @@
|
||||
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) => {
|
||||
@@ -64,23 +63,27 @@ function addCommentToQueue(root, queue, comment, sort) {
|
||||
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');
|
||||
}
|
||||
/**
|
||||
* getCommentQueues determines in which queues a comment should be placed.
|
||||
*/
|
||||
function getCommentQueues(comment, queueConfig) {
|
||||
const queues = [];
|
||||
Object.keys(queueConfig).forEach((key) => {
|
||||
const {action_type, statuses, tags} = queueConfig[key];
|
||||
let addToQueues = false;
|
||||
if (statuses && statuses.indexOf(comment.status) >= 0) {
|
||||
addToQueues = true;
|
||||
}
|
||||
if (tags && comment.tags && comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0)) {
|
||||
addToQueues = true;
|
||||
}
|
||||
if (action_type && comment.actions && comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type}action`)) {
|
||||
addToQueues = true;
|
||||
}
|
||||
if (addToQueues) {
|
||||
queues.push(key);
|
||||
}
|
||||
});
|
||||
return queues;
|
||||
}
|
||||
|
||||
@@ -94,41 +97,42 @@ function getCommentQueues(comment) {
|
||||
* @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.
|
||||
* @param {Object} queueConfig queue configuration
|
||||
* @return {Object} next state of the store
|
||||
*/
|
||||
export function handleCommentChange(root, comment, sort, notify) {
|
||||
export function handleCommentChange(root, comment, sort, notify, queueConfig, activeQueue) {
|
||||
let next = root;
|
||||
const nextQueues = getCommentQueues(comment);
|
||||
|
||||
const nextQueues = getCommentQueues(comment, queueConfig);
|
||||
|
||||
let notificationShown = false;
|
||||
const showNotificationOnce = () => {
|
||||
if (notificationShown) {
|
||||
return;
|
||||
}
|
||||
notification.info(notify.text);
|
||||
notification.info(notify);
|
||||
notificationShown = true;
|
||||
};
|
||||
|
||||
queues.forEach((queue) => {
|
||||
Object.keys(queueConfig).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)) {
|
||||
if (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) {
|
||||
if (notify && activeQueue === queue) {
|
||||
showNotificationOnce(comment);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
notify
|
||||
&& (queue === 'all' || notify.anyQueue)
|
||||
&& queueHasComment(next, queue, comment.id)
|
||||
&& notify.activeQueue === queue
|
||||
&& activeQueue === queue
|
||||
) {
|
||||
showNotificationOnce(comment);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {getModQueueConfigs} from 'coral-framework/helpers/plugins';
|
||||
|
||||
export default {
|
||||
premod: {
|
||||
statuses: ['PREMOD'],
|
||||
icon: 'access_time',
|
||||
name: t('modqueue.premod'),
|
||||
},
|
||||
new: {
|
||||
statuses: ['NONE', 'PREMOD'],
|
||||
icon: 'question_answer',
|
||||
name: t('modqueue.new'),
|
||||
},
|
||||
reported: {
|
||||
action_type: 'FLAG',
|
||||
statuses: ['NONE', 'PREMOD'],
|
||||
icon: 'flag',
|
||||
name: t('modqueue.reported'),
|
||||
},
|
||||
approved: {
|
||||
statuses: ['ACCEPTED'],
|
||||
icon: 'check',
|
||||
name: t('modqueue.approved'),
|
||||
},
|
||||
rejected: {
|
||||
statuses: ['REJECTED'],
|
||||
icon: 'close',
|
||||
name: t('modqueue.rejected'),
|
||||
},
|
||||
all: {
|
||||
statuses: ['NONE', 'PREMOD', 'ACCEPTED', 'REJECTED'],
|
||||
icon: 'question_answer',
|
||||
name: t('modqueue.all'),
|
||||
},
|
||||
...getModQueueConfigs(),
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import {getClient as getFrameworkClient} from 'coral-framework/services/client';
|
||||
import fragmentMatcher from './fragmentMatcher';
|
||||
|
||||
export function getClient() {
|
||||
return getFrameworkClient({fragmentMatcher});
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import {IntrospectionFragmentMatcher} from 'apollo-client';
|
||||
|
||||
// TODO this is a short-term fix
|
||||
// we need to set up something to query the server for the schema before ApolloClient initialization
|
||||
// https://github.com/apollographql/apollo-client/issues/1555#issuecomment-295834774
|
||||
const fm = new IntrospectionFragmentMatcher({
|
||||
introspectionQueryResultData: {
|
||||
__schema: {
|
||||
types: [
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'UserError',
|
||||
possibleTypes: [
|
||||
{name: 'GenericUserError'},
|
||||
{name: 'ValidationUserError'}
|
||||
]
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Response',
|
||||
possibleTypes: [
|
||||
{name: 'CreateCommentResponse'},
|
||||
{name: 'CreateFlagResponse'},
|
||||
{name: 'CreateDontAgreeResponse'},
|
||||
{name: 'DeleteActionResponse'},
|
||||
{name: 'SetUserStatusResponse'},
|
||||
{name: 'SuspendUserResponse'},
|
||||
{name: 'SetCommentStatusResponse'},
|
||||
{name: 'ModifyTagResponse'},
|
||||
{name: 'IgnoreUserResponse'},
|
||||
{name: 'StopIgnoringUserResponse'}
|
||||
]
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Action',
|
||||
possibleTypes: [
|
||||
{name: 'DefaultAction'},
|
||||
{name: 'FlagAction'},
|
||||
{name: 'DontAgreeAction'}
|
||||
]
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'ActionSummary',
|
||||
possibleTypes: [
|
||||
{name: 'DefaultActionSummary'},
|
||||
{name: 'FlagActionSummary'},
|
||||
{name: 'DontAgreeActionSummary'}
|
||||
]
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'AssetActionSummary',
|
||||
possibleTypes: [
|
||||
{name: 'DefaultAssetActionSummary'},
|
||||
{name: 'FlagAssetActionSummary'}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default fm;
|
||||
@@ -1,7 +1,7 @@
|
||||
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import mainReducer from '../reducers';
|
||||
import {getClient} from './client';
|
||||
import {getClient} from 'coral-framework/services/client';
|
||||
|
||||
const middlewares = [
|
||||
applyMiddleware(getClient().middleware()),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function getCommentType(comment) {
|
||||
let commentType = '';
|
||||
if (comment.status === 'PREMOD') {
|
||||
commentType = 'premod';
|
||||
} else if (comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction')) {
|
||||
commentType = 'flagged';
|
||||
}
|
||||
return commentType;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const isPremod = (mod) => mod === 'PRE';
|
||||
|
||||
export const getModPath = (type = 'all', assetId) =>
|
||||
assetId ? `/admin/moderate/${type}/${assetId}` : `/admin/moderate/${type}`;
|
||||
@@ -16,7 +16,7 @@ class ConfigureStreamContainer extends Component {
|
||||
this.state = {
|
||||
changed: false,
|
||||
dirtySettings: props.asset.settings,
|
||||
closedAt: (props.asset.closedAt === null ? 'open' : 'closed')
|
||||
closedAt: !props.asset.isClosed ? 'open' : 'closed'
|
||||
};
|
||||
|
||||
this.toggleStatus = this.toggleStatus.bind(this);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import {Router, Route, browserHistory} from 'react-router';
|
||||
import {Router, Route} from 'react-router';
|
||||
import {history} from 'coral-framework/helpers/router';
|
||||
|
||||
import Embed from './containers/Embed';
|
||||
import {LoginContainer} from 'coral-sign-in/containers/LoginContainer';
|
||||
@@ -11,6 +12,6 @@ const routes = (
|
||||
</div>
|
||||
);
|
||||
|
||||
const AppRouter = () => <Router history={browserHistory} routes={routes} />;
|
||||
const AppRouter = () => <Router history={history} routes={routes} />;
|
||||
|
||||
export default AppRouter;
|
||||
|
||||
@@ -1,35 +1,24 @@
|
||||
import pym from 'coral-framework/services/pym';
|
||||
import * as actions from '../constants/stream';
|
||||
import {buildUrl} from 'coral-framework/utils/url';
|
||||
import queryString from 'query-string';
|
||||
|
||||
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
|
||||
|
||||
function removeParam(key, sourceURL) {
|
||||
let rtn = sourceURL.split('?')[0];
|
||||
let param;
|
||||
let params_arr = [];
|
||||
let queryString = (sourceURL.indexOf('?') !== -1) ? sourceURL.split('?')[1] : '';
|
||||
if (queryString !== '') {
|
||||
params_arr = queryString.split('&');
|
||||
for (let i = params_arr.length - 1; i >= 0; i -= 1) {
|
||||
param = params_arr[i].split('=')[0];
|
||||
if (param === key) {
|
||||
params_arr.splice(i, 1);
|
||||
}
|
||||
}
|
||||
rtn = `${rtn}?${params_arr.join('&')}`;
|
||||
}
|
||||
return rtn;
|
||||
}
|
||||
|
||||
export const viewAllComments = () => {
|
||||
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
comment_id: undefined,
|
||||
});
|
||||
|
||||
// remove the comment_id url param
|
||||
const modifiedUrl = removeParam('comment_id', location.href);
|
||||
const url = buildUrl({...location, search});
|
||||
|
||||
try {
|
||||
|
||||
// "window" here refers to the embedded iframe
|
||||
window.history.replaceState({}, document.title, modifiedUrl);
|
||||
window.history.replaceState({}, document.title, url);
|
||||
|
||||
// also change the parent url
|
||||
pym.sendMessage('coral-view-all-comments');
|
||||
@@ -38,6 +27,28 @@ export const viewAllComments = () => {
|
||||
return {type: actions.VIEW_ALL_COMMENTS};
|
||||
};
|
||||
|
||||
export const viewComment = (id) => {
|
||||
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
comment_id: id,
|
||||
});
|
||||
|
||||
// remove the comment_id url param
|
||||
const url = buildUrl({...location, search});
|
||||
|
||||
try {
|
||||
|
||||
// "window" here refers to the embedded iframe
|
||||
window.history.replaceState({}, document.title, url);
|
||||
|
||||
// also change the parent url
|
||||
pym.sendMessage('coral-view-comment', id);
|
||||
} catch (e) { /* not sure if we're worried about old browsers */ }
|
||||
|
||||
return {type: actions.VIEW_COMMENT, id};
|
||||
};
|
||||
|
||||
export const addCommentClassName = (className) => ({
|
||||
type: actions.ADD_COMMENT_CLASSNAME,
|
||||
className
|
||||
|
||||
@@ -93,6 +93,7 @@ class AllCommentsPane extends React.Component {
|
||||
|
||||
viewNewComments = () => {
|
||||
this.setState(resetCursors);
|
||||
this.props.emit('ui.AllCommentsPane.viewNewComments');
|
||||
};
|
||||
|
||||
// getVisibileComments returns a list containing comments
|
||||
@@ -125,8 +126,6 @@ class AllCommentsPane extends React.Component {
|
||||
root,
|
||||
comments,
|
||||
commentClassNames,
|
||||
addTag,
|
||||
removeTag,
|
||||
ignoreUser,
|
||||
setActiveReplyBox,
|
||||
activeReplyBox,
|
||||
@@ -144,6 +143,7 @@ class AllCommentsPane extends React.Component {
|
||||
charCountEnable,
|
||||
maxCharCount,
|
||||
editComment,
|
||||
emit,
|
||||
} = this.props;
|
||||
|
||||
const {loadingState} = this.state;
|
||||
@@ -173,8 +173,6 @@ class AllCommentsPane extends React.Component {
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
ignoreUser={ignoreUser}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
loadMore={loadNewReplies}
|
||||
@@ -185,6 +183,7 @@ class AllCommentsPane extends React.Component {
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
editComment={editComment}
|
||||
emit={emit}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
}
|
||||
|
||||
.comment {
|
||||
position: relative;
|
||||
padding-left: 15px;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
}
|
||||
|
||||
.commentLevel0 {
|
||||
@@ -67,17 +66,6 @@
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* element in the top right of the Comment */
|
||||
.topRight {
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.topRight > * {
|
||||
text-align: initial;
|
||||
}
|
||||
|
||||
.topRight .popover {
|
||||
margin-top: 1em;
|
||||
right: 0px;
|
||||
@@ -89,11 +77,6 @@
|
||||
border-bottom: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.topRightMenu {
|
||||
cursor: pointer;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.editCommentForm {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -144,7 +127,7 @@
|
||||
}
|
||||
|
||||
.commentInfoBar {
|
||||
float: right;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@keyframes enter {
|
||||
@@ -158,7 +141,6 @@
|
||||
}
|
||||
|
||||
.commentContainer {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.commentAvatar {
|
||||
@@ -173,3 +155,19 @@
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.commentFooter {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
}
|
||||
|
||||
.footer {
|
||||
min-height: 10px;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import AuthorName from 'coral-plugin-author-name/AuthorName';
|
||||
import TagLabel from 'coral-plugin-tag-label/TagLabel';
|
||||
import PubDate from 'coral-plugin-pubdate/PubDate';
|
||||
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
|
||||
import {FlagComment} from 'coral-plugin-flags';
|
||||
import AuthorName from 'talk-plugin-author-name/AuthorName';
|
||||
import TagLabel from 'talk-plugin-tag-label/TagLabel';
|
||||
import PubDate from 'talk-plugin-pubdate/PubDate';
|
||||
import {ReplyBox, ReplyButton} from 'talk-plugin-replies';
|
||||
import {FlagComment} from 'talk-plugin-flags';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
import {TransitionGroup} from 'react-transition-group';
|
||||
import cn from 'classnames';
|
||||
@@ -13,21 +14,15 @@ import {THREADING_LEVEL} from '../constants/stream';
|
||||
import merge from 'lodash/merge';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
|
||||
import {
|
||||
BestButton,
|
||||
IfUserCanModifyBest,
|
||||
BEST_TAG,
|
||||
commentIsBest,
|
||||
BestIndicator
|
||||
} from 'coral-plugin-best/BestButton';
|
||||
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 InactiveCommentLabel from './InactiveCommentLabel';
|
||||
import {EditableCommentContent} from './EditableCommentContent';
|
||||
import {getActionSummary, iPerformedThisAction, forEachError} from 'coral-framework/utils';
|
||||
import {getActionSummary, iPerformedThisAction, forEachError, isCommentActive} from 'coral-framework/utils';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF');
|
||||
@@ -178,19 +173,13 @@ export default class Comment extends React.Component {
|
||||
}).isRequired,
|
||||
|
||||
// given a comment, return whether it should be rendered as ignored
|
||||
commentIsIgnored: React.PropTypes.func,
|
||||
|
||||
// dispatch action to add a tag to a comment
|
||||
addTag: React.PropTypes.func,
|
||||
|
||||
// dispatch action to remove a tag from a comment
|
||||
removeTag: React.PropTypes.func,
|
||||
commentIsIgnored: PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func,
|
||||
ignoreUser: PropTypes.func,
|
||||
|
||||
// edit a comment, passed (id, asset_id, { body })
|
||||
editComment: React.PropTypes.func,
|
||||
editComment: PropTypes.func,
|
||||
}
|
||||
|
||||
editComment = (...args) => {
|
||||
@@ -235,6 +224,7 @@ export default class Comment extends React.Component {
|
||||
return;
|
||||
}
|
||||
this.setState(resetCursors);
|
||||
this.props.emit('ui.Comment.showMoreReplies');
|
||||
};
|
||||
|
||||
showReplyBox = () => {
|
||||
@@ -303,6 +293,8 @@ export default class Comment extends React.Component {
|
||||
render () {
|
||||
const {
|
||||
asset,
|
||||
data,
|
||||
root,
|
||||
depth,
|
||||
comment,
|
||||
postFlag,
|
||||
@@ -321,14 +313,16 @@ export default class Comment extends React.Component {
|
||||
addNotification,
|
||||
charCountEnable,
|
||||
showSignInDialog,
|
||||
addTag,
|
||||
removeTag,
|
||||
liveUpdates,
|
||||
commentIsIgnored,
|
||||
commentClassNames = []
|
||||
} = this.props;
|
||||
|
||||
const view = this.getVisibileReplies();
|
||||
|
||||
// Inactive comments can be viewed by moderators and admins (e.g. using permalinks).
|
||||
const isActive = isCommentActive(comment.status);
|
||||
|
||||
const {loadingState} = this.state;
|
||||
const isPending = comment.id.indexOf('pending') >= 0;
|
||||
const isHighlighted = highlighted === comment.id;
|
||||
@@ -347,40 +341,6 @@ export default class Comment extends React.Component {
|
||||
myFlag = dontAgreeSummary.find((s) => s.current_user);
|
||||
}
|
||||
|
||||
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
|
||||
const notifyOnError = (fn, errorToMessage) =>
|
||||
async function(...args) {
|
||||
if (typeof errorToMessage !== 'function') {
|
||||
errorToMessage = (error) => error.message;
|
||||
}
|
||||
try {
|
||||
return await fn(...args);
|
||||
} catch (error) {
|
||||
addNotification('error', errorToMessage(error));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const addBestTag = notifyOnError(
|
||||
() =>
|
||||
addTag({
|
||||
id: comment.id,
|
||||
name: BEST_TAG,
|
||||
assetId: asset.id,
|
||||
}),
|
||||
() => 'Failed to tag comment as best'
|
||||
);
|
||||
|
||||
const removeBestTag = notifyOnError(
|
||||
() =>
|
||||
removeTag({
|
||||
id: comment.id,
|
||||
name: BEST_TAG,
|
||||
assetId: asset.id,
|
||||
}),
|
||||
() => 'Failed to remove best comment tag'
|
||||
);
|
||||
|
||||
/**
|
||||
* conditionClassNames
|
||||
* adds classNames based on condition
|
||||
@@ -424,6 +384,15 @@ export default class Comment extends React.Component {
|
||||
}
|
||||
);
|
||||
|
||||
// props that are passed down the slots.
|
||||
const slotProps = {
|
||||
data,
|
||||
root,
|
||||
asset,
|
||||
comment,
|
||||
depth,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={rootClassName}
|
||||
@@ -432,198 +401,184 @@ export default class Comment extends React.Component {
|
||||
<div className={commentClassName}>
|
||||
|
||||
<Slot
|
||||
className={styles.commentAvatar}
|
||||
className={`${styles.commentAvatar} talk-stream-comment-avatar`}
|
||||
fill="commentAvatar"
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
{...slotProps}
|
||||
inline
|
||||
/>
|
||||
|
||||
<div className={styles.commentContainer}>
|
||||
|
||||
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
<div className={`${styles.commentContainer} talk-stream-comment-container`}>
|
||||
|
||||
{commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
: null }
|
||||
<div className={styles.header}>
|
||||
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
|
||||
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
|
||||
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
|
||||
<Slot
|
||||
className={styles.commentInfoBar}
|
||||
fill="commentInfoBar"
|
||||
depth={depth}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
inline
|
||||
/>
|
||||
|
||||
{ (currentUser && (comment.user.id === currentUser.id)) &&
|
||||
|
||||
/* User can edit/delete their own comment for a short window after posting */
|
||||
<span className={cn(styles.topRight)}>
|
||||
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
|
||||
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
|
||||
{
|
||||
commentIsStillEditable(comment) &&
|
||||
<a
|
||||
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
|
||||
onClick={this.onClickEdit}>Edit</a>
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
</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} />
|
||||
<Slot
|
||||
className={styles.commentInfoBar}
|
||||
fill="commentInfoBar"
|
||||
{...slotProps}
|
||||
/>
|
||||
|
||||
{ isActive && (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>
|
||||
}
|
||||
{
|
||||
this.state.isEditing
|
||||
? <EditableCommentContent
|
||||
editComment={this.editComment}
|
||||
addNotification={addNotification}
|
||||
comment={comment}
|
||||
currentUser={currentUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
parentId={parentId}
|
||||
stopEditing={this.stopEditing}
|
||||
/>
|
||||
: <div>
|
||||
<Slot fill="commentContent" comment={comment} defaultComponent={CommentContent} />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
{ isActive && (currentUser && (comment.user.id !== currentUser.id)) &&
|
||||
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<Slot
|
||||
fill="commentReactions"
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
<ActionButton>
|
||||
<IfUserCanModifyBest user={currentUser}>
|
||||
<BestButton
|
||||
isBest={commentIsBest(comment)}
|
||||
addBest={addBestTag}
|
||||
removeBest={removeBestTag}
|
||||
/>
|
||||
</IfUserCanModifyBest>
|
||||
</ActionButton>
|
||||
{!disableReply &&
|
||||
<ActionButton>
|
||||
<ReplyButton
|
||||
onClick={this.showReplyBox}
|
||||
parentCommentId={parentId || comment.id}
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
/>
|
||||
</ActionButton>}
|
||||
/* TopRightMenu allows currentUser to ignore other users' comments */
|
||||
<span className={cn(styles.topRight, styles.topRightMenu)}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification} />
|
||||
</span>
|
||||
}
|
||||
{ !isActive &&
|
||||
<InactiveCommentLabel status={comment.status}/>
|
||||
}
|
||||
</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
|
||||
/>
|
||||
<ActionButton>
|
||||
<FlagComment
|
||||
flaggedByCurrentUser={!!myFlag}
|
||||
flag={myFlag}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
addNotification={addNotification}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
</ActionButton>
|
||||
<div className={styles.content}>
|
||||
{
|
||||
this.state.isEditing
|
||||
? <EditableCommentContent
|
||||
editComment={this.editComment}
|
||||
addNotification={addNotification}
|
||||
comment={comment}
|
||||
currentUser={currentUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
parentId={parentId}
|
||||
stopEditing={this.stopEditing}
|
||||
/>
|
||||
: <div>
|
||||
<Slot
|
||||
fill="commentContent"
|
||||
defaultComponent={CommentContent}
|
||||
{...slotProps}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className={cn(styles.footer, 'talk-stream-comment-footer')}>
|
||||
{isActive &&
|
||||
<div className={'talk-stream-comment-actions-container'}>
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<Slot
|
||||
fill="commentReactions"
|
||||
{...slotProps}
|
||||
inline
|
||||
/>
|
||||
{!disableReply &&
|
||||
<ActionButton>
|
||||
<ReplyButton
|
||||
onClick={this.showReplyBox}
|
||||
parentCommentId={parentId || comment.id}
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
/>
|
||||
</ActionButton>}
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<Slot
|
||||
fill="commentActions"
|
||||
wrapperComponent={ActionButton}
|
||||
{...slotProps}
|
||||
inline
|
||||
/>
|
||||
<ActionButton>
|
||||
<FlagComment
|
||||
flaggedByCurrentUser={!!myFlag}
|
||||
flag={myFlag}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
addNotification={addNotification}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeReplyBox === comment.id
|
||||
? <ReplyBox
|
||||
commentPostedHandler={() => {
|
||||
setActiveReplyBox('');
|
||||
}}
|
||||
{activeReplyBox === comment.id
|
||||
? <ReplyBox
|
||||
commentPostedHandler={() => {
|
||||
setActiveReplyBox('');
|
||||
}}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
parentId={(depth < THREADING_LEVEL) ? comment.id : parentId}
|
||||
addNotification={addNotification}
|
||||
postComment={postComment}
|
||||
currentUser={currentUser}
|
||||
assetId={asset.id}
|
||||
/>
|
||||
: null}
|
||||
|
||||
<TransitionGroup>
|
||||
{view.map((reply) => {
|
||||
return commentIsIgnored(reply)
|
||||
? <IgnoredCommentTombstone key={reply.id} />
|
||||
: <Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
disableReply={disableReply}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postComment={postComment}
|
||||
editComment={this.props.editComment}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
loadMore={loadMore}
|
||||
ignoreUser={ignoreUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
parentId={(depth < THREADING_LEVEL) ? comment.id : parentId}
|
||||
addNotification={addNotification}
|
||||
postComment={postComment}
|
||||
currentUser={currentUser}
|
||||
assetId={asset.id}
|
||||
/>
|
||||
: null}
|
||||
|
||||
<TransitionGroup>
|
||||
{view.map((reply) => {
|
||||
return commentIsIgnored(reply)
|
||||
? <IgnoredCommentTombstone key={reply.id} />
|
||||
: <Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
disableReply={disableReply}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postComment={postComment}
|
||||
editComment={this.props.editComment}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
loadMore={loadMore}
|
||||
ignoreUser={ignoreUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
showSignInDialog={showSignInDialog}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
liveUpdates={liveUpdates}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<div className="talk-load-more-replies">
|
||||
<LoadMore
|
||||
topLevel={false}
|
||||
replyCount={moreRepliesCount}
|
||||
moreComments={hasMoreComments}
|
||||
loadMore={this.loadNewReplies}
|
||||
loadingState={loadingState}
|
||||
/>
|
||||
showSignInDialog={showSignInDialog}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
liveUpdates={liveUpdates}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<div className="talk-load-more-replies">
|
||||
<LoadMore
|
||||
topLevel={false}
|
||||
replyCount={moreRepliesCount}
|
||||
moreComments={hasMoreComments}
|
||||
loadMore={this.loadNewReplies}
|
||||
loadingState={loadingState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {notifyForNewCommentStatus} from 'coral-plugin-commentbox/CommentBox';
|
||||
import {CommentForm} from 'coral-plugin-commentbox/CommentForm';
|
||||
import {notifyForNewCommentStatus} from 'talk-plugin-commentbox/CommentBox';
|
||||
import {CommentForm} from 'talk-plugin-commentbox/CommentForm';
|
||||
import styles from './Comment.css';
|
||||
import {CountdownSeconds} from './CountdownSeconds';
|
||||
import {getEditableUntilDate} from './util';
|
||||
|
||||
@@ -6,6 +6,8 @@ import t from 'coral-framework/services/i18n';
|
||||
|
||||
import {TabBar, Tab, TabContent, TabPane} from 'coral-ui';
|
||||
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
|
||||
import Popup from 'coral-framework/components/Popup';
|
||||
import IfSlotIsNotEmpty from 'coral-framework/components/IfSlotIsNotEmpty';
|
||||
import ConfigureStreamContainer
|
||||
from 'coral-configure/containers/ConfigureStreamContainer';
|
||||
import cn from 'classnames';
|
||||
@@ -26,12 +28,24 @@ export default class Embed extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {activeTab, commentId} = this.props;
|
||||
const {activeTab, commentId, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog} = this.props;
|
||||
const {user} = this.props.auth;
|
||||
const hasHighlightedComment = !!commentId;
|
||||
|
||||
return (
|
||||
<div className={cn('talk-embed-stream', {'talk-embed-stream-highlight-comment': hasHighlightedComment})}>
|
||||
<IfSlotIsNotEmpty slot="login">
|
||||
<Popup
|
||||
href='embed/stream/login'
|
||||
title='Login'
|
||||
features='menubar=0,resizable=0,width=500,height=550,top=200,left=500'
|
||||
open={showSignInDialog}
|
||||
focus={signInDialogFocus}
|
||||
onFocus={focusSignInDialog}
|
||||
onBlur={blurSignInDialog}
|
||||
onClose={hideSignInDialog}
|
||||
/>
|
||||
</IfSlotIsNotEmpty>
|
||||
<TabBar
|
||||
onTabClick={this.changeTab}
|
||||
activeTab={activeTab}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.root {
|
||||
display: inline-block;
|
||||
color: white;
|
||||
background: grey;
|
||||
height: 22px;
|
||||
box-sizing: border-box;
|
||||
line-height: 19px;
|
||||
padding: 2px 6px 2px 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 14px;
|
||||
vertical-align: text-top;
|
||||
margin: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.premod {
|
||||
background: #063B9A;
|
||||
}
|
||||
|
||||
.rejected {
|
||||
background: #d03235;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import styles from './InactiveCommentLabel.css';
|
||||
import {Icon} from 'coral-ui';
|
||||
import cn from 'classnames';
|
||||
|
||||
const InactiveCommentLabel = ({status, className, ...rest}) => {
|
||||
let label;
|
||||
let icon;
|
||||
|
||||
switch (status) {
|
||||
case 'PREMOD':
|
||||
label = t('modqueue.premod');
|
||||
icon = 'query_builder';
|
||||
break;
|
||||
case 'REJECTED':
|
||||
label = t('modqueue.rejected');
|
||||
icon = 'close';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown inactive status ${status}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<span {...rest} className={cn(className, styles.root, styles[status.toLowerCase()])}>
|
||||
<Icon name={icon} className={styles.icon}/>
|
||||
<span className={styles.label}>{label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
InactiveCommentLabel.propTypes = {
|
||||
status: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default InactiveCommentLabel;
|
||||
@@ -14,7 +14,7 @@
|
||||
.filterWrapper {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-top: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.highlightedContainer {
|
||||
@@ -26,4 +26,4 @@
|
||||
margin-top: 28px;
|
||||
padding-bottom: 50px;
|
||||
min-height: 600px;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {StreamError} from './StreamError';
|
||||
import Comment from '../components/Comment';
|
||||
import SuspendedAccount from './SuspendedAccount';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import InfoBox from 'coral-plugin-infobox/InfoBox';
|
||||
import InfoBox from 'talk-plugin-infobox/InfoBox';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
import {ModerationLink} from 'coral-plugin-moderation';
|
||||
import {ModerationLink} from 'talk-plugin-moderation';
|
||||
import RestrictedMessageBox
|
||||
from 'coral-framework/components/RestrictedMessageBox';
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
import {getSlotComponents} from 'coral-framework/helpers/plugins';
|
||||
import CommentBox from 'coral-plugin-commentbox/CommentBox';
|
||||
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
|
||||
import CommentBox from 'talk-plugin-commentbox/CommentBox';
|
||||
import QuestionBox from 'talk-plugin-questionbox/QuestionBox';
|
||||
import {isCommentActive} from 'coral-framework/utils';
|
||||
import {Button, TabBar, Tab, TabCount, TabContent, TabPane} from 'coral-ui';
|
||||
import cn from 'classnames';
|
||||
|
||||
import {getTopLevelParent} from '../graphql/utils';
|
||||
import {getTopLevelParent, attachCommentToParent} from '../graphql/utils';
|
||||
import AllCommentsPane from './AllCommentsPane';
|
||||
import AutomaticAssetClosure from '../containers/AutomaticAssetClosure';
|
||||
|
||||
import styles from './Stream.css';
|
||||
|
||||
@@ -35,6 +38,29 @@ class Stream extends React.Component {
|
||||
if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) {
|
||||
this.setState({keepCommentBox: true});
|
||||
}
|
||||
|
||||
this.fallbackAllTab(next);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fallbackAllTab();
|
||||
}
|
||||
|
||||
fallbackAllTab(props = this.props) {
|
||||
if (props.activeStreamTab !== 'all') {
|
||||
const slotPlugins = this.getSlotComponents('streamTabs', props).map((c) => c.talkPluginName);
|
||||
if (slotPlugins.indexOf(props.activeStreamTab) === -1) {
|
||||
props.setActiveStreamTab('all');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSlotProps({data, root, root: {asset}} = this.props) {
|
||||
return {data, root, asset};
|
||||
}
|
||||
|
||||
getSlotComponents(slot, props = this.props) {
|
||||
return getSlotComponents(slot, props.reduxState, this.getSlotProps(props));
|
||||
}
|
||||
|
||||
setActiveReplyBox = (id) => {
|
||||
@@ -57,7 +83,7 @@ class Stream extends React.Component {
|
||||
setActiveReplyBox,
|
||||
appendItemArray,
|
||||
commentClassNames,
|
||||
root: {asset, asset: {comments, totalCommentCount}, comment, me},
|
||||
root: {asset, asset: {comment, comments, totalCommentCount}, me},
|
||||
postComment,
|
||||
addNotification,
|
||||
editComment,
|
||||
@@ -66,7 +92,6 @@ class Stream extends React.Component {
|
||||
deleteAction,
|
||||
showSignInDialog,
|
||||
updateItem,
|
||||
addTag,
|
||||
ignoreUser,
|
||||
activeStreamTab,
|
||||
setActiveStreamTab,
|
||||
@@ -74,14 +99,24 @@ class Stream extends React.Component {
|
||||
loadMoreComments,
|
||||
viewAllComments,
|
||||
auth: {loggedIn, user},
|
||||
removeTag,
|
||||
editName
|
||||
} = this.props;
|
||||
const {keepCommentBox} = this.state;
|
||||
const open = asset.closedAt === null;
|
||||
const open = !asset.isClosed;
|
||||
|
||||
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
|
||||
const highlightedComment = comment && getTopLevelParent(comment);
|
||||
let highlightedComment = comment && getTopLevelParent(comment);
|
||||
if (highlightedComment) {
|
||||
|
||||
// Inactive comments can be viewed by moderators and admins (e.g. using permalinks).
|
||||
const isInactive = !isCommentActive(comment.status);
|
||||
if (comment.parent && isInactive) {
|
||||
|
||||
// the highlighted comment is not active and as such not in the replies, so we
|
||||
// attach it to the right parent.
|
||||
highlightedComment = attachCommentToParent(highlightedComment, comment);
|
||||
}
|
||||
}
|
||||
|
||||
const banned = user && user.status === 'BANNED';
|
||||
const temporarilySuspended =
|
||||
@@ -98,6 +133,7 @@ class Stream extends React.Component {
|
||||
};
|
||||
|
||||
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
|
||||
const slotProps = this.getSlotProps();
|
||||
|
||||
if (!comment && !comments) {
|
||||
console.error('Talk: No comments came back from the graph given that query. Please, check the query params.');
|
||||
@@ -106,6 +142,7 @@ class Stream extends React.Component {
|
||||
|
||||
return (
|
||||
<div id="stream" className={styles.root}>
|
||||
<AutomaticAssetClosure assetId={asset.id} closedAt={asset.closedAt}/>
|
||||
{comment &&
|
||||
<Button
|
||||
cStyle="darkGrey"
|
||||
@@ -156,7 +193,10 @@ class Stream extends React.Component {
|
||||
</div>
|
||||
: <p>{asset.settings.closedMessage}</p>}
|
||||
|
||||
<Slot fill="stream" />
|
||||
<Slot
|
||||
fill="stream"
|
||||
{...slotProps}
|
||||
/>
|
||||
|
||||
{loggedIn && (
|
||||
<ModerationLink
|
||||
@@ -173,8 +213,6 @@ class Stream extends React.Component {
|
||||
data={data}
|
||||
root={root}
|
||||
commentClassNames={commentClassNames}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
ignoreUser={ignoreUser}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
activeReplyBox={activeReplyBox}
|
||||
@@ -204,16 +242,17 @@ class Stream extends React.Component {
|
||||
<div
|
||||
className={cn('talk-stream-filter-wrapper', styles.filterWrapper)}
|
||||
>
|
||||
<Slot fill="streamFilter" />
|
||||
<Slot
|
||||
fill="streamFilter"
|
||||
{...slotProps}
|
||||
/>
|
||||
</div>
|
||||
<TabBar activeTab={activeStreamTab} onTabClick={setActiveStreamTab} sub>
|
||||
{getSlotComponents('streamTabs').map((PluginComponent) => (
|
||||
{this.getSlotComponents('streamTabs').map((PluginComponent) => (
|
||||
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
|
||||
<PluginComponent
|
||||
{...slotProps}
|
||||
active={activeStreamTab === PluginComponent.talkPluginName}
|
||||
data={data}
|
||||
root={root}
|
||||
asset={asset}
|
||||
/>
|
||||
</Tab>
|
||||
))}
|
||||
@@ -222,12 +261,10 @@ class Stream extends React.Component {
|
||||
</Tab>
|
||||
</TabBar>
|
||||
<TabContent activeTab={activeStreamTab} sub>
|
||||
{getSlotComponents('streamTabPanes').map((PluginComponent) => (
|
||||
{this.getSlotComponents('streamTabPanes').map((PluginComponent) => (
|
||||
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
|
||||
<PluginComponent
|
||||
data={data}
|
||||
root={root}
|
||||
asset={asset}
|
||||
{...slotProps}
|
||||
/>
|
||||
</TabPane>
|
||||
))}
|
||||
@@ -237,8 +274,6 @@ class Stream extends React.Component {
|
||||
root={root}
|
||||
comments={comments}
|
||||
commentClassNames={commentClassNames}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
ignoreUser={ignoreUser}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
activeReplyBox={activeReplyBox}
|
||||
@@ -257,6 +292,7 @@ class Stream extends React.Component {
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={editComment}
|
||||
emit={this.props.emit}
|
||||
/>
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
@@ -271,12 +307,6 @@ Stream.propTypes = {
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
|
||||
// dispatch action to add a tag to a comment
|
||||
addTag: PropTypes.func,
|
||||
|
||||
// dispatch action to remove a tag from a comment
|
||||
removeTag: PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func,
|
||||
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/**
|
||||
.toggler {
|
||||
composes: buttonReset from "coral-framework/styles/reset.css";
|
||||
}
|
||||
|
||||
/**
|
||||
* Up/Down Chevrons for the top right menu
|
||||
*/
|
||||
.chevron {
|
||||
}
|
||||
.chevron:before {
|
||||
content: '⌃';
|
||||
display: inline-block;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.25em;
|
||||
top: 0.25em;
|
||||
}
|
||||
|
||||
/* Down Arrow */
|
||||
/* Down Arrow */
|
||||
.chevron.down:before {
|
||||
display: inline-block;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
transform: rotate(180deg);
|
||||
top: 0;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import ClickOutside from 'coral-framework/components/ClickOutside';
|
||||
import styles from './Toggleable.css';
|
||||
import classnames from 'classnames';
|
||||
import cn from 'classnames';
|
||||
|
||||
const upArrow = <span className={classnames(styles.chevron, styles.up)}></span>;
|
||||
const downArrow = <span className={classnames(styles.chevron, styles.down)}></span>;
|
||||
const upArrow = <span className={cn(styles.chevron, styles.up)}></span>;
|
||||
const downArrow = <span className={cn(styles.chevron, styles.down)}></span>;
|
||||
|
||||
export default class Toggleable extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -23,13 +23,12 @@ export default class Toggleable extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {children} = this.props;
|
||||
const {children, className, ...rest} = this.props;
|
||||
const {isOpen} = this.state;
|
||||
return (
|
||||
<ClickOutside onClickOutside={this.close}>
|
||||
<span className={styles.Toggleable} tabIndex="0" >
|
||||
<span className={styles.toggler}
|
||||
onClick={this.toggle}>{isOpen ? upArrow : downArrow}</span>
|
||||
<span {...rest} className={cn(className, styles.Toggleable)} >
|
||||
<button className={styles.toggler} onClick={this.toggle}>{isOpen ? upArrow : downArrow}</button>
|
||||
{isOpen ? children : null}
|
||||
</span>
|
||||
</ClickOutside>
|
||||
|
||||
@@ -45,7 +45,7 @@ export class TopRightMenu extends React.Component {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Toggleable key={this.state.timesReset}>
|
||||
<Toggleable key={this.state.timesReset} className="talk-stream-comment-chevron">
|
||||
<div style={{position: 'absolute', right: 0, zIndex: 1}}>
|
||||
<IgnoreUserWizard
|
||||
user={comment.user}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const SET_ACTIVE_REPLY_BOX = 'SET_ACTIVE_REPLY_BOX';
|
||||
export const ADDTL_COMMENTS_ON_LOAD_MORE = 10;
|
||||
export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS';
|
||||
export const VIEW_COMMENT = 'VIEW_COMMENT';
|
||||
export const ADD_COMMENT_CLASSNAME = 'ADD_COMMENT_CLASSNAME';
|
||||
export const REMOVE_COMMENT_CLASSNAME = 'REMOVE_COMMENT_CLASSNAME';
|
||||
export const THREADING_LEVEL = process.env.TALK_THREADING_LEVEL;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {gql} from 'react-apollo';
|
||||
|
||||
const FRAGMENT = gql`
|
||||
fragment CoralEmbedStream_AutomaticAssetClosure_Fragment on Asset {
|
||||
id
|
||||
isClosed
|
||||
}
|
||||
`;
|
||||
|
||||
function getFragmentId(assetId) {
|
||||
return `Asset_${assetId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* AutomaticAssetClosure updates the graphql state of the provide asset
|
||||
* to `isClosed=true` when passed `closedAt`.
|
||||
*/
|
||||
class AutomaticAssetClosure extends React.Component {
|
||||
static contextTypes = {
|
||||
client: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
timer = null;
|
||||
|
||||
componentWillMount() {
|
||||
this.setupTimer(this.props.assetId, this.props.closedAt);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
if (
|
||||
this.props.assetId !== next.assetId ||
|
||||
this.props.closedAt !== next.closedAt
|
||||
) {
|
||||
this.setupTimer(next.assetId, next.closedAt);
|
||||
}
|
||||
}
|
||||
|
||||
closeAsset(assetId) {
|
||||
this.context.client.writeFragment({
|
||||
fragment: FRAGMENT,
|
||||
id: getFragmentId(assetId),
|
||||
data: {
|
||||
isClosed: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setupTimer(assetId, closedAt) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
|
||||
if (assetId && closedAt) {
|
||||
const asset = this.context.client.readFragment({
|
||||
fragment: FRAGMENT,
|
||||
id: getFragmentId(assetId),
|
||||
});
|
||||
|
||||
if (!asset.isClosed && closedAt) {
|
||||
const diff = (new Date(closedAt) - new Date());
|
||||
if (diff >= 0) {
|
||||
this.timer = setTimeout(() => this.closeAsset(assetId), diff);
|
||||
} else {
|
||||
this.closeAsset(assetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
AutomaticAssetClosure.PropTypes = {
|
||||
assetId: PropTypes.string,
|
||||
closedAt: PropTypes.string,
|
||||
};
|
||||
|
||||
export default AutomaticAssetClosure;
|
||||
@@ -1,9 +1,9 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import Comment from '../components/Comment';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
|
||||
import {getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
|
||||
const pluginFragments = getSlotsFragments([
|
||||
const slots = [
|
||||
'streamQuestionArea',
|
||||
'commentInputArea',
|
||||
'commentInputDetailArea',
|
||||
@@ -12,15 +12,20 @@ const pluginFragments = getSlotsFragments([
|
||||
'commentContent',
|
||||
'commentReactions',
|
||||
'commentAvatar'
|
||||
]);
|
||||
];
|
||||
|
||||
export default withFragments({
|
||||
root: gql`
|
||||
fragment CoralEmbedStream_Comment_root on RootQuery {
|
||||
__typename
|
||||
${pluginFragments.spreads('root')}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
`,
|
||||
asset: gql`
|
||||
fragment CoralEmbedStream_Comment_asset on Asset {
|
||||
__typename
|
||||
${getSlotFragmentSpreads(slots, 'asset')}
|
||||
}
|
||||
${pluginFragments.definitions('root')}
|
||||
`,
|
||||
comment: gql`
|
||||
fragment CoralEmbedStream_Comment_comment on Comment {
|
||||
@@ -28,6 +33,7 @@ export default withFragments({
|
||||
body
|
||||
created_at
|
||||
status
|
||||
replyCount
|
||||
tags {
|
||||
tag {
|
||||
name
|
||||
@@ -48,8 +54,7 @@ export default withFragments({
|
||||
edited
|
||||
editableUntil
|
||||
}
|
||||
${pluginFragments.spreads('comment')}
|
||||
${getSlotFragmentSpreads(slots, 'comment')}
|
||||
}
|
||||
${pluginFragments.definitions('comment')}
|
||||
`
|
||||
})(Comment);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {compose, gql} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import get from 'lodash/get';
|
||||
import branch from 'recompose/branch';
|
||||
import renderComponent from 'recompose/renderComponent';
|
||||
|
||||
@@ -19,7 +20,7 @@ import t from 'coral-framework/services/i18n';
|
||||
|
||||
import {setActiveTab} from '../actions/embed';
|
||||
|
||||
const {logout, checkLogin} = authActions;
|
||||
const {logout, checkLogin, focusSignInDialog, blurSignInDialog, hideSignInDialog} = authActions;
|
||||
const {fetchAssetSuccess} = assetActions;
|
||||
|
||||
class EmbedContainer extends React.Component {
|
||||
@@ -91,10 +92,10 @@ class EmbedContainer extends React.Component {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!prevProps.root.comment && this.props.root.comment) {
|
||||
if (!get(prevProps, 'root.asset.comment') && get(this.props, 'root.asset.comment')) {
|
||||
|
||||
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
|
||||
setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0);
|
||||
setTimeout(() => pym.scrollParentToChildEl('talk-embed-stream-container'), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +186,9 @@ const mapDispatchToProps = (dispatch) =>
|
||||
setActiveTab,
|
||||
fetchAssetSuccess,
|
||||
addNotification,
|
||||
focusSignInDialog,
|
||||
blurSignInDialog,
|
||||
hideSignInDialog,
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@ import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {ADDTL_COMMENTS_ON_LOAD_MORE, THREADING_LEVEL} from '../constants/stream';
|
||||
import {
|
||||
withPostComment, withPostFlag, withPostDontAgree, withDeleteAction,
|
||||
withAddTag, withRemoveTag, withIgnoreUser, withEditComment,
|
||||
withPostComment, withPostFlag, withPostDontAgree,
|
||||
withDeleteAction, withIgnoreUser, withEditComment
|
||||
} from 'coral-framework/graphql/mutations';
|
||||
|
||||
import * as authActions from 'coral-framework/actions/auth';
|
||||
@@ -14,10 +14,9 @@ import {editName} from 'coral-framework/actions/user';
|
||||
import {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/stream';
|
||||
import Stream from '../components/Stream';
|
||||
import Comment from './Comment';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
|
||||
import {withFragments, withEmit} from 'coral-framework/hocs';
|
||||
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
import {Spinner} from 'coral-ui';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
import {
|
||||
findCommentInEmbedQuery,
|
||||
insertCommentIntoEmbedQuery,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
insertFetchedCommentsIntoEmbedQuery,
|
||||
nest,
|
||||
} from '../graphql/utils';
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
const {showSignInDialog} = authActions;
|
||||
const {addNotification} = notificationActions;
|
||||
@@ -159,7 +159,6 @@ const commentFragment = gql`
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
${nest(`
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies(excludeIgnored: $excludeIgnored) {
|
||||
nodes {
|
||||
id
|
||||
@@ -210,7 +209,6 @@ const LOAD_MORE_QUERY = gql`
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
${nest(`
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies(limit: 3, excludeIgnored: $excludeIgnored) {
|
||||
nodes {
|
||||
id
|
||||
@@ -231,28 +229,29 @@ const LOAD_MORE_QUERY = gql`
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const pluginFragments = getSlotsFragments([
|
||||
const slots = [
|
||||
'streamTabs',
|
||||
'streamTabPanes',
|
||||
]);
|
||||
];
|
||||
|
||||
const fragments = {
|
||||
root: gql`
|
||||
fragment CoralEmbedStream_Stream_root on RootQuery {
|
||||
comment(id: $commentId) @include(if: $hasComment) {
|
||||
...CoralEmbedStream_Stream_comment
|
||||
${nest(`
|
||||
parent {
|
||||
...CoralEmbedStream_Stream_comment
|
||||
...nest
|
||||
}
|
||||
`, THREADING_LEVEL)}
|
||||
}
|
||||
asset(id: $assetId, url: $assetUrl) {
|
||||
comment(id: $commentId) @include(if: $hasComment) {
|
||||
...CoralEmbedStream_Stream_comment
|
||||
${nest(`
|
||||
parent {
|
||||
...CoralEmbedStream_Stream_comment
|
||||
...nest
|
||||
}
|
||||
`, THREADING_LEVEL)}
|
||||
}
|
||||
id
|
||||
title
|
||||
url
|
||||
closedAt
|
||||
isClosed
|
||||
created_at
|
||||
settings {
|
||||
moderation
|
||||
@@ -267,8 +266,8 @@ const fragments = {
|
||||
charCount
|
||||
requireEmailConfirmation
|
||||
}
|
||||
commentCount(excludeIgnored: $excludeIgnored)
|
||||
totalCommentCount(excludeIgnored: $excludeIgnored)
|
||||
commentCount(excludeIgnored: $excludeIgnored) @skip(if: $hasComment)
|
||||
totalCommentCount(excludeIgnored: $excludeIgnored) @skip(if: $hasComment)
|
||||
comments(limit: 10, excludeIgnored: $excludeIgnored) @skip(if: $hasComment) {
|
||||
nodes {
|
||||
...CoralEmbedStream_Stream_comment
|
||||
@@ -277,7 +276,8 @@ const fragments = {
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
${pluginFragments.spreads('asset')}
|
||||
${getSlotFragmentSpreads(slots, 'asset')}
|
||||
...${getDefinitionName(Comment.fragments.asset)}
|
||||
}
|
||||
me {
|
||||
status
|
||||
@@ -288,11 +288,10 @@ const fragments = {
|
||||
settings {
|
||||
organizationName
|
||||
}
|
||||
${pluginFragments.spreads('root')}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
...${getDefinitionName(Comment.fragments.root)}
|
||||
}
|
||||
${pluginFragments.definitions('asset')}
|
||||
${pluginFragments.definitions('root')}
|
||||
${Comment.fragments.asset}
|
||||
${Comment.fragments.root}
|
||||
${commentFragment}
|
||||
`,
|
||||
@@ -310,7 +309,9 @@ const mapStateToProps = (state) => ({
|
||||
previousTab: state.embed.previousTab,
|
||||
activeStreamTab: state.stream.activeTab,
|
||||
previousStreamTab: state.stream.previousTab,
|
||||
commentClassNames: state.stream.commentClassNames
|
||||
commentClassNames: state.stream.commentClassNames,
|
||||
pluginConfig: state.config.plugin_config,
|
||||
reduxState: omit(state, 'apollo'),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
@@ -325,12 +326,11 @@ const mapDispatchToProps = (dispatch) =>
|
||||
|
||||
export default compose(
|
||||
withFragments(fragments),
|
||||
withEmit,
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withPostComment,
|
||||
withPostFlag,
|
||||
withPostDontAgree,
|
||||
withAddTag,
|
||||
withRemoveTag,
|
||||
withIgnoreUser,
|
||||
withDeleteAction,
|
||||
withEditComment,
|
||||
|
||||
@@ -73,6 +73,11 @@ const extension = {
|
||||
created_at
|
||||
status
|
||||
replyCount
|
||||
asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
tags {
|
||||
tag {
|
||||
name
|
||||
@@ -84,7 +89,7 @@ const extension = {
|
||||
}
|
||||
user {
|
||||
id
|
||||
name: username
|
||||
username
|
||||
}
|
||||
action_summaries {
|
||||
count
|
||||
@@ -143,7 +148,7 @@ const extension = {
|
||||
user: {
|
||||
__typename: 'User',
|
||||
id: auth.toJS().user.id,
|
||||
name: auth.toJS().user.username
|
||||
username: auth.toJS().user.username
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
body,
|
||||
@@ -162,7 +167,7 @@ const extension = {
|
||||
},
|
||||
__typename: 'TagLink'
|
||||
})),
|
||||
status: null,
|
||||
status: 'NONE',
|
||||
replyCount: 0,
|
||||
parent: parent_id
|
||||
? {id: parent_id}
|
||||
@@ -190,6 +195,15 @@ const extension = {
|
||||
}
|
||||
return insertCommentIntoEmbedQuery(prev, comment);
|
||||
},
|
||||
CoralEmbedStream_Profile: (prev, {mutationResult: {data: {createComment: {comment}}}}) => {
|
||||
return update(prev, {
|
||||
me: {
|
||||
comments: {
|
||||
nodes: {$unshift: [comment]},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}
|
||||
}),
|
||||
EditComment: () => ({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import update from 'immutability-helper';
|
||||
import {insertCommentsSorted} from 'coral-framework/utils';
|
||||
|
||||
function determineCommentDepth(comment) {
|
||||
let depth = 0;
|
||||
let cur = comment;
|
||||
@@ -10,8 +12,8 @@ function determineCommentDepth(comment) {
|
||||
}
|
||||
|
||||
function applyToCommentsOrigin(root, callback) {
|
||||
if (root.comment) {
|
||||
let comment = root.comment;
|
||||
if (root.asset.comment) {
|
||||
let comment = root.asset.comment;
|
||||
for (let depth = 0; depth <= determineCommentDepth(comment); depth++) {
|
||||
let changes = {$apply: (node) => node ? callback(node) : node};
|
||||
for (let i = 0; i < depth; i++) {
|
||||
@@ -22,7 +24,10 @@ function applyToCommentsOrigin(root, callback) {
|
||||
|
||||
return {
|
||||
...root,
|
||||
comment,
|
||||
asset: {
|
||||
...root.asset,
|
||||
comment,
|
||||
},
|
||||
};
|
||||
}
|
||||
return update(root, {
|
||||
@@ -133,8 +138,8 @@ export function findCommentInEmbedQuery(root, callbackOrId) {
|
||||
if (typeof callbackOrId === 'string') {
|
||||
callback = (node) => node.id === callbackOrId;
|
||||
}
|
||||
if (root.comment) {
|
||||
return findComment([getTopLevelParent(root.comment)], callback);
|
||||
if (root.asset.comment) {
|
||||
return findComment([getTopLevelParent(root.asset.comment)], callback);
|
||||
}
|
||||
if (!root.asset.comments) {
|
||||
return false;
|
||||
@@ -142,14 +147,6 @@ export function findCommentInEmbedQuery(root, callbackOrId) {
|
||||
return findComment(root.asset.comments.nodes, callback);
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
function findAndInsertFetchedComments(parent, comments, parent_id) {
|
||||
const isAsset = parent.__typename === 'Asset';
|
||||
const connectionField = isAsset ? 'comments' : 'replies';
|
||||
@@ -162,11 +159,9 @@ function findAndInsertFetchedComments(parent, comments, parent_id) {
|
||||
if (isAsset) {
|
||||
return nodes.concat(comments.nodes);
|
||||
}
|
||||
return nodes
|
||||
.concat(comments.nodes.filter(
|
||||
return insertCommentsSorted(nodes, comments.nodes.filter(
|
||||
(comment) => !nodes.some((node) => node.id === comment.id)
|
||||
))
|
||||
.sort(ascending);
|
||||
));
|
||||
}},
|
||||
},
|
||||
});
|
||||
@@ -190,6 +185,32 @@ export function insertFetchedCommentsIntoEmbedQuery(root, comments, parent_id) {
|
||||
return applyToCommentsOrigin(root, (origin) => findAndInsertFetchedComments(origin, comments, parent_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* attachCommentToParent recurses through the comment tree starting at `topLevelComment`
|
||||
* to find the parent of `comment` and attach it to the replies.
|
||||
*/
|
||||
export function attachCommentToParent(topLevelComment, comment) {
|
||||
if (topLevelComment.id === comment.parent.id) {
|
||||
return update(topLevelComment, {
|
||||
replies: {
|
||||
nodes: {
|
||||
$apply: (nodes) => insertCommentsSorted(nodes, comment),
|
||||
},
|
||||
},
|
||||
replyCount: {
|
||||
$set: (count) => count + 1,
|
||||
}
|
||||
});
|
||||
}
|
||||
return update(topLevelComment, {
|
||||
replies: {
|
||||
nodes: {
|
||||
$apply: (nodes) => nodes.map((node) => attachCommentToParent(node, comment)),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nest a string in itself repeatly until `level` has been reached.
|
||||
*
|
||||
|
||||
@@ -2,18 +2,22 @@ import React from 'react';
|
||||
import {render} from 'react-dom';
|
||||
import {ApolloProvider} from 'react-apollo';
|
||||
|
||||
import {checkLogin} from 'coral-framework/actions/auth';
|
||||
import {checkLogin, handleAuthToken, logout} from 'coral-framework/actions/auth';
|
||||
import './graphql';
|
||||
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
|
||||
import {getStore, injectReducers} from 'coral-framework/services/store';
|
||||
import {getStore, injectReducers, addListener} from 'coral-framework/services/store';
|
||||
import {getClient} from 'coral-framework/services/client';
|
||||
import {createReduxEmitter} from 'coral-framework/services/events';
|
||||
import pym from 'coral-framework/services/pym';
|
||||
import AppRouter from './AppRouter';
|
||||
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
|
||||
import reducers from './reducers';
|
||||
import EventEmitter from 'eventemitter2';
|
||||
import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider';
|
||||
|
||||
const store = getStore();
|
||||
const client = getClient();
|
||||
const eventEmitter = new EventEmitter({wildcard: true});
|
||||
|
||||
loadPluginsTranslations();
|
||||
injectPluginsReducers();
|
||||
@@ -39,14 +43,35 @@ if (!window.opener) {
|
||||
pym.onMessage('config', (config) => {
|
||||
init(JSON.parse(config));
|
||||
});
|
||||
|
||||
pym.onMessage('login', (token) => {
|
||||
if (token) {
|
||||
store.dispatch(handleAuthToken(token));
|
||||
}
|
||||
store.dispatch(checkLogin());
|
||||
});
|
||||
|
||||
pym.onMessage('logout', () => {
|
||||
store.dispatch(logout());
|
||||
});
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// Pass any events through our parent.
|
||||
eventEmitter.onAny((eventName, value) => {
|
||||
pym.sendMessage('event', JSON.stringify({eventName, value}));
|
||||
});
|
||||
|
||||
// Add a redux listener to pass through all actions to our event emitter.
|
||||
addListener(createReduxEmitter(eventEmitter));
|
||||
}
|
||||
|
||||
render(
|
||||
<ApolloProvider client={client} store={store}>
|
||||
<AppRouter />
|
||||
</ApolloProvider>
|
||||
<EventEmitterProvider eventEmitter={eventEmitter}>
|
||||
<ApolloProvider client={client} store={store}>
|
||||
<AppRouter />
|
||||
</ApolloProvider>
|
||||
</EventEmitterProvider>
|
||||
, document.querySelector('#talk-embed-stream-container')
|
||||
);
|
||||
|
||||
@@ -17,11 +17,11 @@ function getQueryVariable(variable) {
|
||||
|
||||
const initialState = {
|
||||
activeReplyBox: '',
|
||||
assetId: getQueryVariable('asset_id'),
|
||||
assetUrl: getQueryVariable('asset_url'),
|
||||
commentId: getQueryVariable('comment_id'),
|
||||
assetId: getQueryVariable('asset_id') || '',
|
||||
assetUrl: getQueryVariable('asset_url') || '',
|
||||
commentId: getQueryVariable('comment_id') || '',
|
||||
commentClassNames: [],
|
||||
activeTab: 'all',
|
||||
activeTab: process.env.TALK_DEFAULT_STREAM_TAB,
|
||||
previousTab: '',
|
||||
};
|
||||
|
||||
@@ -48,6 +48,11 @@ export default function stream(state = initialState, action) {
|
||||
...state,
|
||||
commentId: '',
|
||||
};
|
||||
case actions.VIEW_COMMENT:
|
||||
return {
|
||||
...state,
|
||||
commentId: action.id,
|
||||
};
|
||||
case actions.ADD_COMMENT_CLASSNAME :
|
||||
return {
|
||||
...state,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user