mirror of
https://github.com/wassname/talk.git
synced 2026-09-14 11:36:51 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e464c48df | ||
|
|
fd64469c00 | ||
|
|
e488e878d0 | ||
|
|
9f9f5a8a8e | ||
|
|
77e54aab1f | ||
|
|
9109d5ab71 | ||
|
|
104b719dec | ||
|
|
9e69cecb11 | ||
|
|
791f9bdabd | ||
|
|
8d183915fa | ||
|
|
9e0d35e737 | ||
|
|
21f436255e | ||
|
|
6ba7a43cb4 | ||
|
|
331a83d1fc | ||
|
|
8d3de4b082 | ||
|
|
c4801abfbe | ||
|
|
c0861c20d3 | ||
|
|
63545a48c4 | ||
|
|
e1bfe5b608 | ||
|
|
dc096137e3 | ||
|
|
4d41d999fb | ||
|
|
b50d3dc96f | ||
|
|
8c0f55af21 | ||
|
|
ed98b218bb | ||
|
|
64800ffaee | ||
|
|
637605a002 | ||
|
|
5580e14bf3 | ||
|
|
109d9e93f5 | ||
|
|
ff72d79748 | ||
|
|
0645735d2b | ||
|
|
f1a0febd6c | ||
|
|
16d0b39ebc | ||
|
|
66c7430ff1 | ||
|
|
ac06f0d13b | ||
|
|
ca10062498 | ||
|
|
c9381c6367 | ||
|
|
13853ef87e | ||
|
|
fb2f36d0ee | ||
|
|
1a2fa73941 | ||
|
|
d3931db285 | ||
|
|
093202f9e2 | ||
|
|
60be682a5f | ||
|
|
41c9ae32b1 | ||
|
|
4445479237 | ||
|
|
aab44587a6 | ||
|
|
0af28edbd9 | ||
|
|
9db7571fe2 | ||
|
|
683adceec4 | ||
|
|
aa6058e9d1 | ||
|
|
a4c4032b0d | ||
|
|
2ea0d51613 | ||
|
|
f2564f4eda | ||
|
|
73040b1314 | ||
|
|
c3c106c7b0 | ||
|
|
8faf69ce48 | ||
|
|
23f17db0c9 | ||
|
|
1385a0b961 | ||
|
|
7fd01e5845 | ||
|
|
340052cdf0 | ||
|
|
a085e4b6f9 | ||
|
|
1575b15e36 | ||
|
|
7da9126a76 | ||
|
|
9453d207f5 | ||
|
|
869c760a8b | ||
|
|
b283595c97 | ||
|
|
d672af63f9 | ||
|
|
b4ad78fd65 | ||
|
|
713de46c2a | ||
|
|
fc1e51ed62 | ||
|
|
4eef6f4218 | ||
|
|
c8cfd7c0f8 | ||
|
|
0e4cbf5f44 | ||
|
|
448b988281 | ||
|
|
7a688495f2 | ||
|
|
b9cf7084eb | ||
|
|
b99161f0cb | ||
|
|
6faa4862de | ||
|
|
eff3f1dfd8 | ||
|
|
73aa77d7da | ||
|
|
5580ab385f | ||
|
|
40eb0e97ec | ||
|
|
b7e1ce0205 | ||
|
|
bfde9bf8c7 | ||
|
|
54f9b28086 | ||
|
|
03a5eb7f9b | ||
|
|
43e60e6544 | ||
|
|
ebd16666e6 | ||
|
|
f2129082a9 | ||
|
|
1098c068f2 | ||
|
|
f0c95d0044 | ||
|
|
74226aa6fc | ||
|
|
b6ed5b792b | ||
|
|
d31969592c | ||
|
|
794487b1a9 | ||
|
|
39e45c30ba | ||
|
|
ed734ebb08 | ||
|
|
9f059e0c4a | ||
|
|
7d369f2d35 | ||
|
|
5fb5240f89 | ||
|
|
976931135c | ||
|
|
1e3d47eb87 | ||
|
|
2d804d783b | ||
|
|
3404578ecc | ||
|
|
48c5355a5b | ||
|
|
e3a300c643 | ||
|
|
a6653152e4 | ||
|
|
441422542c | ||
|
|
1d1822c086 | ||
|
|
1c2ff7d2ae | ||
|
|
9319bb4e1f | ||
|
|
6611f8b636 | ||
|
|
738542ab99 | ||
|
|
cd4ae47958 | ||
|
|
2ec66007d9 | ||
|
|
ea34c936d0 | ||
|
|
ef1fd9c159 | ||
|
|
c7ccf03132 | ||
|
|
8015098305 | ||
|
|
c85a2703f9 | ||
|
|
9aec6b026c | ||
|
|
b2ca9dba40 | ||
|
|
9186c73fff | ||
|
|
86d55fa2de | ||
|
|
12c4cfbbf6 | ||
|
|
c875a8d9ed | ||
|
|
4d082165aa | ||
|
|
08c01981ac | ||
|
|
cd01aae76c | ||
|
|
53367eeaac | ||
|
|
32962aa1e8 | ||
|
|
049357a09c | ||
|
|
5e7fe7cf5a | ||
|
|
6243fcba91 | ||
|
|
805825e5d2 | ||
|
|
a40e721a68 | ||
|
|
1dd8d0a706 | ||
|
|
da1a8bb539 | ||
|
|
18add4cdea | ||
|
|
de5de26c82 | ||
|
|
934e4b4754 | ||
|
|
049658508c | ||
|
|
f0ead66f68 | ||
|
|
14f1343930 | ||
|
|
b21845b963 | ||
|
|
0ff20bed5e | ||
|
|
0bb2d4cbca | ||
|
|
927a34a579 | ||
|
|
75f2f05534 | ||
|
|
2998db46ad | ||
|
|
8163b52301 | ||
|
|
19b425dc1a | ||
|
|
be81ced2f5 | ||
|
|
f6e5f24722 | ||
|
|
2c1dc9c118 | ||
|
|
657bf388cf | ||
|
|
b3412a46cf | ||
|
|
c6ee54c2f5 | ||
|
|
174a80c377 | ||
|
|
17a05059d9 | ||
|
|
8c53c25a89 | ||
|
|
1928a7b02e | ||
|
|
5173af2a27 | ||
|
|
9077f31872 | ||
|
|
51e8035533 | ||
|
|
94786c2711 | ||
|
|
456abaf862 | ||
|
|
fe9f92d3fc | ||
|
|
a49fbf2d05 | ||
|
|
51bb158446 | ||
|
|
d601bd1466 | ||
|
|
8eb8294987 | ||
|
|
99332323a8 | ||
|
|
1a23cc1fa7 | ||
|
|
798f55ed79 | ||
|
|
6897c0e18d | ||
|
|
9d7b9a91cd | ||
|
|
51a62b8c26 | ||
|
|
f11a51ebfc | ||
|
|
dbdf38af73 | ||
|
|
abc1664ef7 | ||
|
|
c16514502c | ||
|
|
9d3e3ce45d | ||
|
|
f6bfef3fb5 | ||
|
|
9b0173e6bf | ||
|
|
43219eed4b | ||
|
|
9300359ed1 | ||
|
|
80c3367259 | ||
|
|
ef731f719d | ||
|
|
9c09509a08 | ||
|
|
313310a097 | ||
|
|
fa42c028df | ||
|
|
905e43e745 | ||
|
|
6cc0c60ade | ||
|
|
ca44588095 | ||
|
|
9a49044030 | ||
|
|
02963dc5ac | ||
|
|
5ad90e00cb | ||
|
|
1dfc4b83c7 | ||
|
|
d522a72d42 | ||
|
|
d1a22f9dc8 | ||
|
|
5ac3910687 | ||
|
|
d007c2295e | ||
|
|
7ebcc4c793 | ||
|
|
582d45bb21 | ||
|
|
6ef5c3a42f | ||
|
|
00883a3739 | ||
|
|
d7fb0056be | ||
|
|
ad55d715f6 | ||
|
|
0a855cd3d4 | ||
|
|
c02d2b9b4e | ||
|
|
68255bec70 | ||
|
|
28edd98ff1 | ||
|
|
d1b5409e44 | ||
|
|
df5e863cc4 | ||
|
|
acc83e27ce | ||
|
|
ca13fde87d | ||
|
|
f69a9c826d | ||
|
|
76ef0f7d52 | ||
|
|
8f54c1d3c3 | ||
|
|
83379b58a7 | ||
|
|
b0b5807c8b | ||
|
|
c98d227411 | ||
|
|
83819df922 | ||
|
|
db5bf9a676 | ||
|
|
ba31dbbdf9 | ||
|
|
8f633fbe78 | ||
|
|
886222260d | ||
|
|
2c3589cdac | ||
|
|
ed953b5e7e | ||
|
|
79d1f059c2 | ||
|
|
dd0601a801 | ||
|
|
9f7d485871 | ||
|
|
ec816636e6 | ||
|
|
08f579225b | ||
|
|
46987b6303 | ||
|
|
c2d07ec79f | ||
|
|
a98f5ef980 | ||
|
|
d6515e23f8 | ||
|
|
282db6847a | ||
|
|
a3db05845d | ||
|
|
c8fc419cec | ||
|
|
677c8a0828 | ||
|
|
08146fd979 | ||
|
|
ab92fe3fe8 | ||
|
|
95435f0793 | ||
|
|
7eed540671 | ||
|
|
374951bd33 | ||
|
|
42a694f56e | ||
|
|
1c997aea31 | ||
|
|
44830f6cb1 | ||
|
|
22d263c6d2 | ||
|
|
78ba82cc05 | ||
|
|
a6b9cc57ac | ||
|
|
cd9cb56844 | ||
|
|
d331bdee18 | ||
|
|
d6f6fbcd9c | ||
|
|
1ed5df71da | ||
|
|
8b1b61f695 | ||
|
|
f1a3a5ca28 | ||
|
|
8ef44dc6f5 | ||
|
|
362f29f77e | ||
|
|
08d342ea2e | ||
|
|
99777d6b49 | ||
|
|
6f6da1fa25 | ||
|
|
2fac304163 | ||
|
|
5ca7c092db | ||
|
|
cfae6b5374 | ||
|
|
3668a2cffa | ||
|
|
92681f52aa | ||
|
|
847ded5bb4 | ||
|
|
76c959f790 | ||
|
|
4659392ad3 | ||
|
|
e9cec73966 | ||
|
|
bac0ee9c3b | ||
|
|
5ae293af89 | ||
|
|
cce464b82a | ||
|
|
5d32c4ec03 | ||
|
|
50d4e2bb9b | ||
|
|
debdd3eae4 | ||
|
|
286d9baddf | ||
|
|
9c06a63afd | ||
|
|
f38e72c4df | ||
|
|
e2fac4a2dc | ||
|
|
ba0513fd6c | ||
|
|
8e29f0094c | ||
|
|
f60c7e3ca6 | ||
|
|
b3c034d89d | ||
|
|
be2657eb30 | ||
|
|
fd32d2aa24 | ||
|
|
809b39ae2a | ||
|
|
6197981480 | ||
|
|
eb58dd52f8 | ||
|
|
f5c011f44b | ||
|
|
396ccbd6c2 | ||
|
|
4fd61b3731 | ||
|
|
cbd795dc95 | ||
|
|
65a6b484a9 | ||
|
|
45c193b9bf | ||
|
|
ed037f8461 | ||
|
|
7bb0c73ee4 | ||
|
|
f13654b297 | ||
|
|
4a165c0972 | ||
|
|
bbf034a595 | ||
|
|
9f6ddd3801 | ||
|
|
34184d07eb | ||
|
|
057d083341 | ||
|
|
c5f107a168 | ||
|
|
17a801d6d2 | ||
|
|
888a61f895 | ||
|
|
9edf1d7c41 | ||
|
|
b802fdd5eb | ||
|
|
1a7b3fe903 | ||
|
|
cadbbd0b5c | ||
|
|
b6101b880d | ||
|
|
afb9fdcdc1 | ||
|
|
53abb65da1 | ||
|
|
7b5192d40c | ||
|
|
8fdea8734a | ||
|
|
588fbb0eb9 | ||
|
|
a28024dfed | ||
|
|
343eb3e8b6 | ||
|
|
80319f9aca | ||
|
|
33e1e5e729 | ||
|
|
ea2b2bc2e3 | ||
|
|
6ca2f21bc2 | ||
|
|
0d9b795740 | ||
|
|
73482d68ac | ||
|
|
e837dfc3c1 | ||
|
|
2cd9a71d62 | ||
|
|
ad73fcd94b | ||
|
|
66145d6fd5 | ||
|
|
7d246bba76 | ||
|
|
0ce0d57a96 | ||
|
|
792bc713f2 | ||
|
|
399f5ef7ba | ||
|
|
14c0945687 | ||
|
|
52f2f48385 | ||
|
|
e52605e26b | ||
|
|
ae4d6e2424 | ||
|
|
968d20e744 | ||
|
|
49d2416172 | ||
|
|
aac7cae6d6 | ||
|
|
f4fd843efe | ||
|
|
c4de69218c | ||
|
|
94ae029a4a | ||
|
|
1dc1f91b72 | ||
|
|
c98216cf3c | ||
|
|
2c9f8586d3 | ||
|
|
4ce5d97e3b | ||
|
|
3ea2632187 | ||
|
|
c9b709ad43 | ||
|
|
dcd7145510 | ||
|
|
d3896ed97d | ||
|
|
47a3cba5f5 | ||
|
|
0f1cabe126 | ||
|
|
5f62083c2f | ||
|
|
3cabe50a7b | ||
|
|
c0efc33044 | ||
|
|
9344c1bc80 | ||
|
|
1bfe0fbac1 | ||
|
|
b3d704be76 | ||
|
|
3c100160fb | ||
|
|
49c7d2eeda | ||
|
|
cff9b29a36 | ||
|
|
5a1f93975c | ||
|
|
cd6da751a6 | ||
|
|
41610ccf57 | ||
|
|
607a59ad63 | ||
|
|
c0fea4821b | ||
|
|
8432df15a1 | ||
|
|
61486caa9e | ||
|
|
7f45f7e2e2 | ||
|
|
9e511817a8 | ||
|
|
4015e19040 | ||
|
|
c1fcc82b62 | ||
|
|
893c8d32b2 | ||
|
|
2dfadebd42 | ||
|
|
a2d5af1372 | ||
|
|
7a5416a807 | ||
|
|
8c2c632f52 | ||
|
|
fabec2c89b | ||
|
|
7f18603fd5 | ||
|
|
3a69e0cf96 | ||
|
|
7ad3d247b5 | ||
|
|
130dc578b3 | ||
|
|
fd7114ab2a | ||
|
|
5e4bdf1f0c | ||
|
|
71e1b69afe | ||
|
|
6fa18f6c7c | ||
|
|
bda9b55562 | ||
|
|
d843320c4a | ||
|
|
8a88228d9c | ||
|
|
0a9664d464 | ||
|
|
b3749b9332 | ||
|
|
aecb7de019 | ||
|
|
ade3cf6252 | ||
|
|
1837ae4a0f | ||
|
|
334074a7ca | ||
|
|
f93c4adc97 | ||
|
|
bb54009567 | ||
|
|
c126d217ad | ||
|
|
8b2dc3a46d | ||
|
|
d0eca26d5b | ||
|
|
b742923897 | ||
|
|
1781b926d9 | ||
|
|
9222ee57a5 | ||
|
|
67cca7d85d | ||
|
|
7b97a8fca2 | ||
|
|
6bff2de371 | ||
|
|
05283b08f7 | ||
|
|
838687a3ae | ||
|
|
7b0213299a | ||
|
|
63400a455f | ||
|
|
b7d8fb950b | ||
|
|
3915ffc4e0 | ||
|
|
ac9899406a | ||
|
|
13e1b288b3 | ||
|
|
8c8f5d8fee | ||
|
|
35cf694b60 | ||
|
|
fa4dbfe9d0 | ||
|
|
0ea08ccc9c | ||
|
|
fd5bbe0234 | ||
|
|
b43f657c8d | ||
|
|
9456c79bad | ||
|
|
0cd18a458d | ||
|
|
fdcd1404ef | ||
|
|
741d5000dd | ||
|
|
8f413852d9 | ||
|
|
420fc6daa7 | ||
|
|
ad69086888 | ||
|
|
f18c51dc68 | ||
|
|
8e1822af21 | ||
|
|
5d3e8f3fb6 | ||
|
|
71288f543e | ||
|
|
a1890a0bfe | ||
|
|
1f17d71a76 | ||
|
|
fc8b9c3547 | ||
|
|
8ebb90c160 | ||
|
|
b0c230a1df | ||
|
|
be15734c77 | ||
|
|
4478ced205 | ||
|
|
769437c8fb | ||
|
|
5fde3235ad | ||
|
|
2a8975711a | ||
|
|
da56cd5b18 | ||
|
|
90424b4766 | ||
|
|
cf23bdba12 | ||
|
|
0f983109e1 | ||
|
|
8f7edde93f | ||
|
|
1946d023cd | ||
|
|
1e5f5848b2 | ||
|
|
3cbf5fef07 | ||
|
|
4f276ed944 | ||
|
|
e69afc4e76 | ||
|
|
c4b46c28fe | ||
|
|
dac823f9c4 | ||
|
|
65deb1cfbe | ||
|
|
828f200eb6 | ||
|
|
fe5c95c27a | ||
|
|
c7fad00b66 | ||
|
|
5165d06d64 | ||
|
|
e15275666e | ||
|
|
1dc8df4189 | ||
|
|
ffe83452cd | ||
|
|
3c3603ee08 | ||
|
|
f6e94b0cc2 | ||
|
|
c3f92e8c8d | ||
|
|
84a121a1d9 | ||
|
|
f55f1341e1 | ||
|
|
cccac6a8bc | ||
|
|
e775a9f092 | ||
|
|
76237f42da | ||
|
|
6e942a1121 |
+29
-31
@@ -1,4 +1,3 @@
|
||||
|
||||
# job_environment will setup the environment for any job being executed.
|
||||
job_environment: &job_environment
|
||||
NODE_ENV: test
|
||||
@@ -41,9 +40,9 @@ integration_job: &integration_job
|
||||
- attach_workspace:
|
||||
at: ~/coralproject/talk
|
||||
- <<: *create_indexes
|
||||
- run:
|
||||
name: Setup the database with defaults
|
||||
command: ./bin/cli setup --defaults
|
||||
# - run:
|
||||
# name: Setup the database with defaults
|
||||
# command: ./bin/cli setup --defaults
|
||||
- run:
|
||||
name: Run the integration tests
|
||||
command: bash .circleci/e2e.sh
|
||||
@@ -54,7 +53,6 @@ integration_job: &integration_job
|
||||
when: always
|
||||
path: /tmp/circleci-test-results
|
||||
|
||||
|
||||
version: 2
|
||||
jobs:
|
||||
# npm_dependencies will install the dependencies used by all other steps.
|
||||
@@ -69,7 +67,7 @@ jobs:
|
||||
- run:
|
||||
name: Install dependencies
|
||||
command: |
|
||||
yarn global add node-gyp &&
|
||||
yarn global add node-gyp@6.1.0 &&
|
||||
yarn install --frozen-lockfile
|
||||
- save_cache:
|
||||
key: dependency-cache-{{ checksum "yarn.lock" }}
|
||||
@@ -223,20 +221,17 @@ jobs:
|
||||
# only execute on a deploy related job.
|
||||
filter_deploy: &filter_deploy
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- next
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9]+)*/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
|
||||
# filter_develop will add the filters for a development related commit.
|
||||
filter_develop: &filter_develop
|
||||
filters:
|
||||
branches:
|
||||
ignore:
|
||||
- master
|
||||
- next
|
||||
- /release\/.*/
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
@@ -258,10 +253,11 @@ workflows:
|
||||
<<: *filter_develop
|
||||
requires:
|
||||
- npm_dependencies
|
||||
- test_integration_chrome_local:
|
||||
<<: *filter_develop
|
||||
requires:
|
||||
- build_assets
|
||||
# TODO: uncomment when we revisit browserstack
|
||||
# - test_integration_chrome_local:
|
||||
# <<: *filter_develop
|
||||
# requires:
|
||||
# - build_assets
|
||||
# TODO: uncomment when more reliable
|
||||
# - test_integration_firefox_local:
|
||||
# <<: *filter_develop
|
||||
@@ -283,18 +279,19 @@ workflows:
|
||||
<<: *filter_deploy
|
||||
requires:
|
||||
- npm_dependencies
|
||||
- test_integration_chrome:
|
||||
<<: *filter_deploy
|
||||
requires:
|
||||
- build_assets
|
||||
- test_integration_firefox:
|
||||
<<: *filter_deploy
|
||||
requires:
|
||||
- build_assets
|
||||
- test_integration_edge:
|
||||
<<: *filter_deploy
|
||||
requires:
|
||||
- build_assets
|
||||
# TODO: uncomment when we revisit browserstack
|
||||
# - test_integration_chrome:
|
||||
# <<: *filter_deploy
|
||||
# requires:
|
||||
# - build_assets
|
||||
# - test_integration_firefox:
|
||||
# <<: *filter_deploy
|
||||
# requires:
|
||||
# - build_assets
|
||||
# - test_integration_edge:
|
||||
# <<: *filter_deploy
|
||||
# requires:
|
||||
# - build_assets
|
||||
# TODO: uncomment when more reliable
|
||||
# - test_integration_ie:
|
||||
# <<: *filter_deploy
|
||||
@@ -309,9 +306,10 @@ workflows:
|
||||
requires:
|
||||
- lint
|
||||
- test_unit
|
||||
- test_integration_chrome
|
||||
- test_integration_firefox
|
||||
- test_integration_edge
|
||||
# TODO: uncomment when we revisit browserstack
|
||||
# - test_integration_chrome
|
||||
# - test_integration_firefox
|
||||
# - test_integration_edge
|
||||
# TODO: uncomment when more reliable
|
||||
# - test_integration_ie
|
||||
# - test_integration_safari
|
||||
|
||||
@@ -45,6 +45,7 @@ plugins/*
|
||||
!plugins/talk-plugin-moderation-actions
|
||||
!plugins/talk-plugin-notifications
|
||||
!plugins/talk-plugin-notifications-category-featured
|
||||
!plugins/talk-plugin-notifications-category-moderation-actions
|
||||
!plugins/talk-plugin-notifications-category-reply
|
||||
!plugins/talk-plugin-notifications-category-staff
|
||||
!plugins/talk-plugin-notifications-digest-daily
|
||||
|
||||
+5
-4
@@ -1,6 +1,7 @@
|
||||
# Mozilla Community Participation Guidelines
|
||||
# Vox Media Code of Conduct
|
||||
|
||||
## Version 2.3 - Updated May 16, 2017
|
||||
## Introduction
|
||||
|
||||
The heart of Mozilla is people. We put people first and do our best to recognize, appreciate and respect the diversity of our global contributors. The Mozilla Project welcomes contributions from everyone who shares our goals and wants to contribute in a healthy and constructive manner within our community. As such, we have adopted this code of conduct and require all those who participate to agree and adhere to these Community Participation Guidelines in order to help us create a safe and positive community experience for all.
|
||||
https://www.mozilla.org/en-US/about/governance/policies/participation/
|
||||
This code of conduct governs the environment of the Vox Product team. We created it not because we anticipate bad behavior, but because we believe that articulating our values and obligations to one another reinforces the already exceptional level of respect among the team and because having a code provides us with clear avenues to correct our culture should it ever stray from that course. We make this code public in the hopes of contributing to the ongoing conversation about inclusion in the tech, design, and media communities and encourage other teams to fork it and make it their own. To our team, we commit to enforce and evolve this code as our team grows.
|
||||
|
||||
Read the rest here: http://code-of-conduct.voxmedia.com/
|
||||
|
||||
+3
-23
@@ -18,7 +18,7 @@ There are at least three ways to contribute to Talk:
|
||||
|
||||
Conversation surrounding contributions begins in [issues](https://github.com/coralproject/talk/issues).
|
||||
|
||||
### When should I Create an Issue?
|
||||
### When should I create an issue?
|
||||
|
||||
File an issue as soon as you have an idea of something you'd like to contribute. We would love to hear what you're thinking and help refine the idea to make it into the Talk ecosystem.
|
||||
|
||||
@@ -30,9 +30,9 @@ Please file issues if:
|
||||
|
||||
### What should I include?
|
||||
|
||||
Coral has adopted an iterative, agile development philosophy. All contributions that make it into the Talk repository should start with a story or this form:
|
||||
Coral has adopted an iterative, agile development philosophy. All contributions that make it into the Talk repository should start with a user story in this form:
|
||||
|
||||
> As a [type of person] I'd like to be able to [do something] so that I can [get some result].
|
||||
> As a [type of Coral user] I'd like to [do something] so that I can [get some result/value].
|
||||
|
||||
This exercise does two things:
|
||||
|
||||
@@ -71,23 +71,3 @@ If you'd like to discuss a contribution, please [file an issue](https://github.c
|
||||
Talk's translations are stored in `.yml` files [here](https://github.com/coralproject/talk/tree/master/locales).
|
||||
|
||||
Translations can be submitted via pull request. If you do not use github, you can use 'en.yml' as a template and [email](https://coralproject.net/contact) the translations to us. We can import it into the repository.
|
||||
|
||||
## I want to contribute but I'm not sure what to do!
|
||||
|
||||
If you want to contribute but don't have a clear idea of exactly what that may be, here are some resources that may help:
|
||||
|
||||
### Product Roadmap
|
||||
|
||||
Please visit our product roadmap here: https://www.pivotaltracker.com/n/projects/1863625. If you'd like to take on any of our scheduled tasks we'd be forever grateful!
|
||||
|
||||
### Discussion Forum
|
||||
|
||||
If you'd like to discuss what we're up to, please visit or [community](https://community.coralproject.net/) or [contact us](https://coralproject.net/contact).
|
||||
|
||||
### Integrations
|
||||
|
||||
Have a favorite analytics engine? Data science service? CMS? Auth platform? Deployment platform or pipeline? Pet project? Consider building a plugin to integrate them!
|
||||
|
||||
### Favorite Features?
|
||||
|
||||
Do you have a favorite feature of an existing platform that's not yet been done in Talk? Sounds like Talk needs that feature.
|
||||
+2
-1
@@ -20,7 +20,8 @@ ARG REVISION_HASH
|
||||
ENV REVISION_HASH=${REVISION_HASH}
|
||||
|
||||
# Install app dependencies and build static assets.
|
||||
RUN yarn global add node-gyp && \
|
||||
# Pin node-gyp@6.1.0 as newer versions do not build on Node 8.
|
||||
RUN yarn global add node-gyp@6.1.0 && \
|
||||
yarn install --frozen-lockfile && \
|
||||
yarn build && \
|
||||
yarn cache clean
|
||||
|
||||
@@ -4,6 +4,7 @@ FROM coralproject/talk:latest
|
||||
ONBUILD ARG TALK_ADDTL_COMMENTS_ON_LOAD_MORE=10
|
||||
ONBUILD ARG TALK_ASSET_COMMENTS_LOAD_DEPTH=10
|
||||
ONBUILD ARG TALK_REPLY_COMMENTS_LOAD_DEPTH=3
|
||||
ONBUILD ARG TALK_ADDTL_REPLIES_ON_LOAD_MORE=999999
|
||||
ONBUILD ARG TALK_THREADING_LEVEL=3
|
||||
ONBUILD ARG TALK_DEFAULT_STREAM_TAB=all
|
||||
ONBUILD ARG TALK_DISABLE_EMBED_POLYFILL=FALSE
|
||||
@@ -11,6 +12,7 @@ ONBUILD ARG TALK_DEFAULT_LANG=en
|
||||
ONBUILD ARG TALK_WHITELISTED_LANGUAGES
|
||||
ONBUILD ARG TALK_PLUGINS_JSON
|
||||
ONBUILD ARG TALK_WEBPACK_SOURCE_MAP
|
||||
ONBUILD ARG TALK_DEFAULT_LAZY_RENDER
|
||||
|
||||
# Bundle app source
|
||||
ONBUILD COPY . /usr/src/app
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright 2018 Mozilla Foundation
|
||||
Copyright 2019 Vox Media, Inc
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# Talk · [](https://circleci.com/gh/coralproject/talk) · [](CONTRIBUTING.md#pull-requests)
|
||||
|
||||
---
|
||||
|
||||
> :construction: **NOTE:** We're currently building v5 of this platform, check it out in our [next branch](https://github.com/coralproject/talk/tree/next) :construction_worker_man: :construction_worker_woman:
|
||||
|
||||
---
|
||||
|
||||
Online comments are broken. Our open-source commenting platform, Talk, rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/talk).
|
||||
|
||||
Built with <3 by The Coral Project.
|
||||
Built with <3 by The Coral Project, a part of [Vox Media](https://www.voxmedia.com/).
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -14,21 +20,19 @@ Learn more about Talk, including a deep dive into features for commenters and mo
|
||||
|
||||
## Pre-Launch Guide
|
||||
|
||||
You’ve installed Talk on your server, and you’re preparing to launch it on your site. The real community work starts now, before you go live. You have a unique opportunity pre-launch to set your community up for success. Read our [Talk Community Guide](https://blog.coralproject.net/youve-installed-talk-now-what/).
|
||||
You’ve installed Talk on your server, and you’re preparing to launch it on your site. The real community work starts now, before you go live. You have a unique opportunity pre-launch to set your community up for success. Read our [Talk Community Guide](https://coralproject.net/blog/youve-installed-talk-now-what/).
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
For advanced configuration and usage of Talk, check out our [Configuration](https://docs.coralproject.net/talk/advanced-configuration/) and [Integration](https://docs.coralproject.net/talk/integrating/authentication/) how-tos. This covers topics in whih you will need dev support to fully customize and integrate Talk, such as SSO/authentication, creating and managing assets and articles, styling Talk with custom CSS, and setting up Notifications and SMTP support.
|
||||
For advanced configuration and usage of Talk, check out our [Configuration](https://docs.coralproject.net/talk/advanced-configuration/) and [Integration](https://docs.coralproject.net/talk/integrating/authentication/) how-tos. This covers topics in which you will need dev support to fully customize and integrate Talk, such as SSO/authentication, creating and managing assets and articles, styling Talk with custom CSS, and setting up Notifications and SMTP support.
|
||||
|
||||
## Versions & Upgrading
|
||||
|
||||
The current recommended release version is v4.5.0. ^4.5 (and all future even-numbered versions) are considered stable LTS versions. We recommend ^4.5 for use in production environments.
|
||||
Check our Releases page for the latest recommended release version. [Releases](https://github.com/coralproject/talk/releases) All future even-numbered versions are considered stable LTS versions. We recommend the latest verified release for use in production environments.
|
||||
|
||||
## More Resources
|
||||
|
||||
- [Talk Product Roadmap](https://www.pivotaltracker.com/n/projects/1863625)
|
||||
- [Our Blog](https://blog.coralproject.net/)
|
||||
- [Community Forums](https://community.coralproject.net/)
|
||||
- [Our Blog](https://coralproject.net/blog)
|
||||
- [Community Guides for Journalism](https://guides.coralproject.net/)
|
||||
- [More About Us](https://coralproject.net/)
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@ const { HELMET_CONFIGURATION } = require('./config');
|
||||
const { MOUNT_PATH } = require('./url');
|
||||
const routes = require('./routes');
|
||||
const debug = require('debug')('talk:app');
|
||||
const { ENABLE_TRACING, APOLLO_ENGINE_KEY, PORT } = require('./config');
|
||||
const {
|
||||
ENABLE_TRACING,
|
||||
APOLLO_ENGINE_KEY,
|
||||
PORT,
|
||||
TRUST_PROXY,
|
||||
} = require('./config');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -58,7 +63,26 @@ if (ENABLE_TRACING && APOLLO_ENGINE_KEY) {
|
||||
|
||||
// Trust the first proxy in front of us, this will enable us to trust the fact
|
||||
// that SSL was terminated correctly.
|
||||
app.set('trust proxy', 1);
|
||||
app.set(
|
||||
'trust proxy',
|
||||
(function() {
|
||||
if (!TRUST_PROXY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lowercase = TRUST_PROXY.toLowerCase();
|
||||
if (lowercase === 'true' || lowercase === 'false') {
|
||||
return lowercase === 'true';
|
||||
}
|
||||
|
||||
const parsed = Number(TRUST_PROXY);
|
||||
if (!isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return TRUST_PROXY;
|
||||
})()
|
||||
);
|
||||
|
||||
// Enable a suite of security good practices through helmet. We disable
|
||||
// frameguard to allow crossdomain injection of the embed.
|
||||
|
||||
@@ -219,6 +219,28 @@ async function rewrite(search, replace, options) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* debugAssetURL will scrape the given URL and print it's scraped values.
|
||||
*/
|
||||
async function debugAssetURL(url) {
|
||||
try {
|
||||
const meta = await scraper.scrape(url);
|
||||
|
||||
const table = new Table({ head: ['Property', 'Value'] });
|
||||
|
||||
for (const [property, value] of Object.entries(meta)) {
|
||||
table.push([property, value]);
|
||||
}
|
||||
|
||||
console.log(table.toString());
|
||||
|
||||
util.shutdown(0);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
@@ -234,6 +256,13 @@ program
|
||||
.description('list all the assets in the database')
|
||||
.action(listAssets);
|
||||
|
||||
program
|
||||
.command('debug [url]')
|
||||
.description(
|
||||
'prints the scraped metadata that would be added to the given asset'
|
||||
)
|
||||
.action(debugAssetURL);
|
||||
|
||||
program
|
||||
.command('refresh [age]')
|
||||
.description('queues the assets that exceed the age requested')
|
||||
|
||||
+17
-5
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const throng = require('throng');
|
||||
const { CONCURRENCY } = require('../config');
|
||||
const { logger } = require('../services/logging');
|
||||
const util = require('./util');
|
||||
const program = require('commander');
|
||||
const serve = require('../serve');
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
@@ -22,8 +24,18 @@ program
|
||||
)
|
||||
.parse(process.argv);
|
||||
|
||||
// Start serving.
|
||||
serve(program).catch(err => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
throng({
|
||||
workers: CONCURRENCY,
|
||||
start: i => {
|
||||
logger.info({ workerID: i }, 'started worker');
|
||||
|
||||
// Load in the serve.
|
||||
const serve = require('../serve');
|
||||
|
||||
// Start serving.
|
||||
serve(program).catch(err => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+37
-31
@@ -45,11 +45,10 @@ const performSetup = async () => {
|
||||
} catch (err) {
|
||||
// If the error is `not init`, then we're good, otherwise, it's something
|
||||
// else.
|
||||
if (err instanceof ErrSettingsNotInit) {
|
||||
if (!err instanceof ErrSettingsNotInit) {
|
||||
throw err;
|
||||
return;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (program.defaults) {
|
||||
@@ -138,7 +137,7 @@ const performSetup = async () => {
|
||||
|
||||
console.log("\nWe'll ask you some questions about your first admin user.\n");
|
||||
|
||||
let user = await inquirer.prompt([
|
||||
let { username, email } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'username',
|
||||
@@ -161,39 +160,46 @@ const performSetup = async () => {
|
||||
return 'Email is required';
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
message: 'Password',
|
||||
type: 'password',
|
||||
filter: password => {
|
||||
return UsersService.isValidPassword(password).catch(err => {
|
||||
throw err.message;
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'confirmPassword',
|
||||
message: 'Confirm Password',
|
||||
type: 'password',
|
||||
filter: (confirmPassword, { password }) => {
|
||||
if (password !== confirmPassword) {
|
||||
return Promise.reject(new Error('Passwords do not match'));
|
||||
}
|
||||
|
||||
return UsersService.isValidPassword(confirmPassword).catch(err => {
|
||||
throw err.message;
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
let password = '';
|
||||
while (!password) {
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
name: 'password',
|
||||
message: 'Password',
|
||||
type: 'password',
|
||||
filter: password => {
|
||||
try {
|
||||
UsersService.isValidPassword(password);
|
||||
} catch (err) {
|
||||
throw err.message;
|
||||
}
|
||||
|
||||
return password;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'confirmPassword',
|
||||
message: 'Confirm Password',
|
||||
type: 'password',
|
||||
},
|
||||
]);
|
||||
|
||||
if (answers.password !== answers.confirmPassword) {
|
||||
console.error('Passwords do not match');
|
||||
} else {
|
||||
password = answers.password;
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = Context.forSystem();
|
||||
let { user: newUser } = await SetupService.setup(ctx, {
|
||||
settings: settings.toObject(),
|
||||
user: {
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -105,7 +105,8 @@ function printUserAsTable(user) {
|
||||
Suspension: user.suspended
|
||||
? `Until ${user.status.suspension.until}`
|
||||
: false,
|
||||
}
|
||||
},
|
||||
{ 'Always premod comments': user.alwaysPremod }
|
||||
);
|
||||
|
||||
console.log(table.toString());
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
SHOW_ALWAYS_PREMOD_USER_DIALOG,
|
||||
HIDE_ALWAYS_PREMOD_USER_DIALOG,
|
||||
} from '../constants/alwaysPremodUserDialog.js';
|
||||
|
||||
export const showAlwaysPremodUserDialog = ({ userId, username }) => ({
|
||||
type: SHOW_ALWAYS_PREMOD_USER_DIALOG,
|
||||
userId,
|
||||
username,
|
||||
});
|
||||
|
||||
export const hideAlwaysPremodUserDialog = () => ({
|
||||
type: HIDE_ALWAYS_PREMOD_USER_DIALOG,
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
SET_SEARCH_VALUE,
|
||||
SHOW_BANUSER_DIALOG,
|
||||
HIDE_BANUSER_DIALOG,
|
||||
SHOW_ALWAYS_PREMOD_USER_DIALOG,
|
||||
HIDE_ALWAYS_PREMOD_USER_DIALOG,
|
||||
SHOW_REJECT_USERNAME_DIALOG,
|
||||
HIDE_REJECT_USERNAME_DIALOG,
|
||||
SET_INDICATOR_TRACK,
|
||||
@@ -61,6 +63,15 @@ export const setSearchValue = value => ({
|
||||
export const showBanUserDialog = user => ({ type: SHOW_BANUSER_DIALOG, user });
|
||||
export const hideBanUserDialog = () => ({ type: HIDE_BANUSER_DIALOG });
|
||||
|
||||
// Always premod User Dialog
|
||||
export const showAlwaysPremodUserDialog = user => ({
|
||||
type: SHOW_ALWAYS_PREMOD_USER_DIALOG,
|
||||
user,
|
||||
});
|
||||
export const hideAlwaysPremodUserDialog = () => ({
|
||||
type: HIDE_ALWAYS_PREMOD_USER_DIALOG,
|
||||
});
|
||||
|
||||
// Reject Username Dialog
|
||||
export const showRejectUsernameDialog = user => ({
|
||||
type: SHOW_REJECT_USERNAME_DIALOG,
|
||||
|
||||
@@ -4,13 +4,15 @@ import {
|
||||
FETCH_ASSETS_REQUEST,
|
||||
FETCH_ASSETS_SUCCESS,
|
||||
FETCH_ASSETS_FAILURE,
|
||||
LOAD_MORE_ASSETS_REQUEST,
|
||||
LOAD_MORE_ASSETS_SUCCESS,
|
||||
LOAD_MORE_ASSETS_FAILURE,
|
||||
SET_PAGE,
|
||||
SET_SEARCH_VALUE,
|
||||
SET_CRITERIA,
|
||||
UPDATE_ASSET_STATE_REQUEST,
|
||||
UPDATE_ASSET_STATE_SUCCESS,
|
||||
UPDATE_ASSET_STATE_FAILURE,
|
||||
UPDATE_ASSETS,
|
||||
} from '../constants/stories';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
@@ -21,17 +23,14 @@ import t from 'coral-framework/services/i18n';
|
||||
|
||||
// Fetch a page of assets
|
||||
// Get comments to fill each of the three lists on the mod queue
|
||||
export const fetchAssets = (query = {}) => (dispatch, _, { rest }) => {
|
||||
export const fetchAssets = (query = {}) => (dispatch, _, { rest2 }) => {
|
||||
dispatch({ type: FETCH_ASSETS_REQUEST });
|
||||
return rest(`/assets?${queryString.stringify(query)}`)
|
||||
.then(({ result, page, count, limit, totalPages }) =>
|
||||
return rest2(`/stories?${queryString.stringify(query)}`)
|
||||
.then(({ edges, pageInfo }) =>
|
||||
dispatch({
|
||||
type: FETCH_ASSETS_SUCCESS,
|
||||
assets: result,
|
||||
page,
|
||||
count,
|
||||
limit,
|
||||
totalPages,
|
||||
edges,
|
||||
pageInfo,
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
@@ -43,12 +42,31 @@ export const fetchAssets = (query = {}) => (dispatch, _, { rest }) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const loadMoreAssets = (query = {}) => (dispatch, _l, { rest2 }) => {
|
||||
dispatch({ type: LOAD_MORE_ASSETS_REQUEST });
|
||||
return rest2(`/stories?${queryString.stringify(query)}`)
|
||||
.then(({ edges, pageInfo }) =>
|
||||
dispatch({
|
||||
type: LOAD_MORE_ASSETS_SUCCESS,
|
||||
edges,
|
||||
pageInfo,
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key
|
||||
? t(`error.${error.translation_key}`)
|
||||
: error.toString();
|
||||
dispatch({ type: LOAD_MORE_ASSETS_FAILURE, error: errorMessage });
|
||||
});
|
||||
};
|
||||
|
||||
// Update an asset state
|
||||
// Get comments to fill each of the three lists on the mod queue
|
||||
export const updateAssetState = (id, closedAt) => (dispatch, _, { rest }) => {
|
||||
dispatch({ type: UPDATE_ASSET_STATE_REQUEST, id, closedAt });
|
||||
dispatch({ type: UPDATE_ASSET_STATE_REQUEST });
|
||||
return rest(`/assets/${id}/status`, { method: 'PUT', body: { closedAt } })
|
||||
.then(() => dispatch({ type: UPDATE_ASSET_STATE_SUCCESS }))
|
||||
.then(() => dispatch({ type: UPDATE_ASSET_STATE_SUCCESS, id, closedAt }))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key
|
||||
@@ -58,10 +76,6 @@ export const updateAssetState = (id, closedAt) => (dispatch, _, { rest }) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const updateAssets = assets => dispatch => {
|
||||
dispatch({ type: UPDATE_ASSETS, assets });
|
||||
};
|
||||
|
||||
export const setPage = page => ({
|
||||
type: SET_PAGE,
|
||||
page,
|
||||
|
||||
@@ -73,7 +73,7 @@ class AdminLogin extends React.Component {
|
||||
this.setState({ requestPassword: true });
|
||||
}}
|
||||
>
|
||||
{t('login.request_passowrd')}
|
||||
{t('login.request_password')}
|
||||
</a>
|
||||
</p>
|
||||
{loginMaxExceeded && (
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.dialog {
|
||||
border: none;
|
||||
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
|
||||
width: 400px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.header {
|
||||
color: black;
|
||||
font-size: 1.5em;
|
||||
font-weight: 500;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.subheader {
|
||||
color: black;
|
||||
font-size: 1.3em;
|
||||
font-weight: 500;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 6px;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dialog } from 'coral-ui';
|
||||
import styles from './AlwaysPremodUserDialog.css';
|
||||
|
||||
import Button from 'coral-ui/components/Button';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
class AlwaysPremodUserDialog extends React.Component {
|
||||
handlePerform = () => {
|
||||
this.props.onPerform();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { open, onCancel, username, info } = this.props;
|
||||
return (
|
||||
<Dialog
|
||||
className={cn(styles.dialog, 'talk-always-premod-user-dialog')}
|
||||
id="alwaysPremodUserDialog"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
title={t('alwayspremoddialog.always_premod_user')}
|
||||
>
|
||||
<span className={styles.close} onClick={onCancel}>
|
||||
×
|
||||
</span>
|
||||
<section>
|
||||
<h2 className={styles.header}>
|
||||
{t('alwayspremoddialog.always_premod_user')}
|
||||
</h2>
|
||||
<h3 className={styles.subheader}>
|
||||
{t('alwayspremoddialog.are_you_sure', username)}
|
||||
</h3>
|
||||
<p className={styles.description}>{info}</p>
|
||||
<div className={styles.buttons}>
|
||||
<Button
|
||||
className={cn('talk-always-premod-user-dialog-button-cancel')}
|
||||
cStyle="white"
|
||||
onClick={onCancel}
|
||||
raised
|
||||
>
|
||||
{t('alwayspremoddialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className={cn('talk-always-premod-user-dialog-button-confirm')}
|
||||
cStyle="black"
|
||||
onClick={this.handlePerform}
|
||||
raised
|
||||
>
|
||||
{t('alwayspremoddialog.yes_always_premod_user')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AlwaysPremodUserDialog.propTypes = {
|
||||
open: PropTypes.bool,
|
||||
onPerform: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
username: PropTypes.string,
|
||||
info: PropTypes.string,
|
||||
};
|
||||
|
||||
export default AlwaysPremodUserDialog;
|
||||
@@ -7,9 +7,9 @@ import t from 'coral-framework/services/i18n';
|
||||
import { can } from 'coral-framework/services/perms';
|
||||
import cn from 'classnames';
|
||||
|
||||
const CoralDrawer = ({ handleLogout, currentUser }) => (
|
||||
<Drawer className={cn('talk-admin-drawer-nav', styles.drawer)}>
|
||||
{currentUser && can(currentUser, 'ACCESS_ADMIN') ? (
|
||||
const CoralDrawer = ({ handleLogout, currentUser }) =>
|
||||
currentUser && can(currentUser, 'ACCESS_ADMIN') ? (
|
||||
<Drawer className={cn('talk-admin-drawer-nav', styles.drawer)}>
|
||||
<div>
|
||||
<Navigation className={styles.nav}>
|
||||
{can(currentUser, 'MODERATE_COMMENTS') && (
|
||||
@@ -48,9 +48,8 @@ const CoralDrawer = ({ handleLogout, currentUser }) => (
|
||||
<span>{`v${process.env.VERSION}`}</span>
|
||||
</Navigation>
|
||||
</div>
|
||||
) : null}
|
||||
</Drawer>
|
||||
);
|
||||
</Drawer>
|
||||
) : null;
|
||||
|
||||
CoralDrawer.propTypes = {
|
||||
handleLogout: PropTypes.func.isRequired,
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
background-color: #696969;
|
||||
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);
|
||||
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.2),
|
||||
0 1px 5px 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.header > div {
|
||||
@@ -41,7 +42,7 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 170px;
|
||||
width: 200px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -85,7 +86,7 @@
|
||||
background-color: transparent;
|
||||
transition: background-color 200ms;
|
||||
font-weight: 100;
|
||||
letter-spacing: .8;
|
||||
letter-spacing: 0.8;
|
||||
|
||||
&:hover {
|
||||
background-color: #404040;
|
||||
|
||||
@@ -79,7 +79,7 @@ class SignIn extends React.Component {
|
||||
className={styles.forgotPasswordLink}
|
||||
onClick={this.handleForgotPasswordLink}
|
||||
>
|
||||
{t('login.request_passowrd')}
|
||||
{t('login.request_password')}
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@@ -137,6 +137,12 @@
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.actionsMenuAlwaysPremod {
|
||||
background-color: #F0B50B;
|
||||
border-color: #F0B50B;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.actionsMenuSuspended {
|
||||
background-color: #F29336;
|
||||
border-color: #F29336;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isUsernameRejected,
|
||||
isUsernameChanged,
|
||||
isBanned,
|
||||
isAlwaysPremod,
|
||||
getKarma,
|
||||
} from 'coral-framework/utils/user';
|
||||
|
||||
@@ -45,6 +46,7 @@ function getUserStatusArray(user) {
|
||||
const statusMap = {
|
||||
suspended: isSuspended,
|
||||
banned: isBanned,
|
||||
alwaysPremod: isAlwaysPremod,
|
||||
usernameRejected: isUsernameRejected,
|
||||
usernameChanged: isUsernameChanged,
|
||||
};
|
||||
@@ -68,6 +70,12 @@ class UserDetail extends React.Component {
|
||||
username: this.props.root.user.username,
|
||||
});
|
||||
|
||||
showAlwaysPremodUserDialog = () =>
|
||||
this.props.showAlwaysPremodUserDialog({
|
||||
userId: this.props.root.user.id,
|
||||
username: this.props.root.user.username,
|
||||
});
|
||||
|
||||
showRejectUsernameDialog = () =>
|
||||
this.props.showRejectUsernameDialog({
|
||||
userId: this.props.root.user.id,
|
||||
@@ -107,6 +115,8 @@ class UserDetail extends React.Component {
|
||||
return t('user_detail.suspended');
|
||||
case 'banned':
|
||||
return t('user_detail.banned');
|
||||
case 'alwaysPremod':
|
||||
return t('user_detail.always_premoded');
|
||||
case 'usernameRejected':
|
||||
return (
|
||||
<span>
|
||||
@@ -153,6 +163,7 @@ class UserDetail extends React.Component {
|
||||
toggleSelectAll,
|
||||
unbanUser,
|
||||
unsuspendUser,
|
||||
removeAlwaysPremodUser,
|
||||
modal,
|
||||
acceptComment,
|
||||
rejectComment,
|
||||
@@ -169,6 +180,7 @@ class UserDetail extends React.Component {
|
||||
|
||||
const banned = isBanned(user);
|
||||
const suspended = isSuspended(user);
|
||||
const alwaysPremod = isAlwaysPremod(user);
|
||||
const usernameRejected = isUsernameRejected(user);
|
||||
const usernameChanged = isUsernameChanged(user);
|
||||
|
||||
@@ -200,6 +212,7 @@ class UserDetail extends React.Component {
|
||||
{
|
||||
[styles.actionsMenuSuspended]: suspended,
|
||||
[styles.actionsMenuBanned]: banned,
|
||||
[styles.actionsMenuAlwaysPremod]: alwaysPremod,
|
||||
},
|
||||
'talk-admin-user-detail-actions-button'
|
||||
)}
|
||||
@@ -231,6 +244,21 @@ class UserDetail extends React.Component {
|
||||
</ActionsMenuItem>
|
||||
)}
|
||||
|
||||
{alwaysPremod ? (
|
||||
<ActionsMenuItem
|
||||
onClick={() => removeAlwaysPremodUser({ id: user.id })}
|
||||
>
|
||||
{t('user_detail.remove_always_premod')}
|
||||
</ActionsMenuItem>
|
||||
) : (
|
||||
<ActionsMenuItem
|
||||
disabled={me.id === user.id}
|
||||
onClick={this.showAlwaysPremodUserDialog}
|
||||
>
|
||||
{t('user_detail.always_premod')}
|
||||
</ActionsMenuItem>
|
||||
)}
|
||||
|
||||
{usernameChanged && (
|
||||
<ActionsMenuItem onClick={this.goToReportedUsernames}>
|
||||
{t('user_detail.username_needs_approval')}
|
||||
@@ -476,8 +504,10 @@ UserDetail.propTypes = {
|
||||
showRejectUsernameDialog: PropTypes.func,
|
||||
showSuspendUserDialog: PropTypes.func,
|
||||
showBanUserDialog: PropTypes.func,
|
||||
showAlwaysPremodUserDialog: PropTypes.func,
|
||||
unbanUser: PropTypes.func.isRequired,
|
||||
unsuspendUser: PropTypes.func.isRequired,
|
||||
removeAlwaysPremodUser: PropTypes.func.isRequired,
|
||||
modal: PropTypes.bool,
|
||||
rejectUsername: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
color: #063b9a;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
letter-spacing: .5px;
|
||||
letter-spacing: 0.5px;
|
||||
margin-left: 10px;
|
||||
font-size: 13px;
|
||||
margin-left: 5px;
|
||||
@@ -109,7 +109,7 @@
|
||||
}
|
||||
|
||||
.external {
|
||||
font-size: .7em;
|
||||
font-size: 0.7em;
|
||||
text-decoration: none;
|
||||
color: #063b9a;
|
||||
cursor: pointer;
|
||||
@@ -119,7 +119,7 @@
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
opacity: .9;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
> i {
|
||||
@@ -139,3 +139,25 @@
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.bodyHistoryToggle {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
line-height: 1px;
|
||||
font-weight: 300;
|
||||
text-decoration: underline;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.editedCommentBody {
|
||||
padding: 0 5px 5px 5px;
|
||||
}
|
||||
|
||||
.editedComment {
|
||||
margin-top: 6px;
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import TimeAgo from 'coral-framework/components/TimeAgo';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
class UserDetailComment extends React.Component {
|
||||
state = { showingEditHistory: false };
|
||||
|
||||
approve = () =>
|
||||
this.props.comment.status === 'ACCEPTED'
|
||||
? null
|
||||
@@ -29,6 +31,29 @@ class UserDetailComment extends React.Component {
|
||||
? null
|
||||
: this.props.rejectComment({ commentId: this.props.comment.id });
|
||||
|
||||
getBodyHistory = () => {
|
||||
const bodyHistory = [];
|
||||
const comment = this.props.comment;
|
||||
for (let i = 0; i < comment.body_history.length - 1; i++) {
|
||||
bodyHistory.push(
|
||||
<div key={i} className={styles.editedComment}>
|
||||
<div>
|
||||
<TimeAgo className={styles.created} datetime={comment.created_at} />
|
||||
</div>
|
||||
<div className={styles.editedCommentBody}>
|
||||
{comment.body_history[i].body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return bodyHistory;
|
||||
};
|
||||
|
||||
toggleEditHistory = () => {
|
||||
this.setState({ showingEditHistory: !this.state.showingEditHistory });
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
comment,
|
||||
@@ -81,6 +106,14 @@ class UserDetailComment extends React.Component {
|
||||
<span className={styles.editedMarker}>
|
||||
({t('comment.edited')})
|
||||
</span>
|
||||
<span
|
||||
className={styles.bodyHistoryToggle}
|
||||
onClick={this.toggleEditHistory}
|
||||
>
|
||||
{this.state.showingEditHistory
|
||||
? t('comment.hide_edit_history')
|
||||
: t('comment.show_edit_history')}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -123,7 +156,7 @@ class UserDetailComment extends React.Component {
|
||||
<IfHasLink text={comment.body}>
|
||||
<span className={styles.hasLinks}>
|
||||
{/* TODO: translate string */}
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
<Icon name="error_outline" /> {t('common.contains_link')}
|
||||
</span>
|
||||
</IfHasLink>
|
||||
<div className={styles.actions}>
|
||||
@@ -142,6 +175,14 @@ class UserDetailComment extends React.Component {
|
||||
</div>
|
||||
</CommentAnimatedEdit>
|
||||
</div>
|
||||
|
||||
{this.state.showingEditHistory ? (
|
||||
<div className={styles.container}>
|
||||
{t('comment.edit_history')}
|
||||
{this.getBodyHistory()}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CommentDetails root={root} comment={comment} />
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -48,6 +48,10 @@ const buildActionResponse = (typename, created_at, until, status) => {
|
||||
return status
|
||||
? t('user_history.user_banned')
|
||||
: t('user_history.ban_removed');
|
||||
case 'AlwaysPremodStatusHistory':
|
||||
return status
|
||||
? t('user_history.user_always_premoded')
|
||||
: t('user_history.always_premod_removed');
|
||||
case 'SuspensionStatusHistory':
|
||||
return until
|
||||
? t('user_history.suspended', readableDuration(created_at, until))
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const SHOW_ALWAYS_PREMOD_USER_DIALOG = 'SHOW_ALWAYS_PREMOD_USER_DIALOG';
|
||||
export const HIDE_ALWAYS_PREMOD_USER_DIALOG = 'HIDE_ALWAYS_PREMOD_USER_DIALOG';
|
||||
@@ -14,6 +14,9 @@ export const FETCH_FLAGGED_COMMENTERS_FAILURE = `${prefix}_FETCH_FLAGGED_COMMENT
|
||||
export const SHOW_BANUSER_DIALOG = `${prefix}_SHOW_BANUSER_DIALOG`;
|
||||
export const HIDE_BANUSER_DIALOG = `${prefix}_HIDE_BANUSER_DIALOG`;
|
||||
|
||||
export const SHOW_ALWAYS_PREMOD_USER_DIALOG = `${prefix}_SHOW_ALWAYS_PREMOD_USER_DIALOG`;
|
||||
export const HIDE_ALWAYS_PREMOD_USER_DIALOG = `${prefix}_HIDE_ALWAYS_PREMOD_USER_DIALOG`;
|
||||
|
||||
export const SHOW_REJECT_USERNAME_DIALOG = `${prefix}_SHOW_REJECT_USERNAME_DIALOG`;
|
||||
export const HIDE_REJECT_USERNAME_DIALOG = `${prefix}_HIDE_REJECT_USERNAME_DIALOG`;
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ export const FETCH_ASSETS_REQUEST = `${prefix}_FETCH_ASSETS_REQUEST`;
|
||||
export const FETCH_ASSETS_SUCCESS = `${prefix}_FETCH_ASSETS_SUCCESS`;
|
||||
export const FETCH_ASSETS_FAILURE = `${prefix}_FETCH_ASSETS_FAILURE`;
|
||||
|
||||
export const LOAD_MORE_ASSETS_REQUEST = `${prefix}_LOAD_MORE_ASSETS_REQUEST`;
|
||||
export const LOAD_MORE_ASSETS_SUCCESS = `${prefix}_LOAD_MORE_ASSETS_SUCCESS`;
|
||||
export const LOAD_MORE_ASSETS_FAILURE = `${prefix}_LOAD_MORE_ASSETS_FAILURE`;
|
||||
|
||||
export const UPDATE_ASSET_STATE_REQUEST = `${prefix}_UPDATE_ASSET_STATE_REQUEST`;
|
||||
export const UPDATE_ASSET_STATE_SUCCESS = `${prefix}_UPDATE_ASSET_STATE_SUCCESS`;
|
||||
export const UPDATE_ASSET_STATE_FAILURE = `${prefix}_UPDATE_ASSET_STATE_FAILURE`;
|
||||
|
||||
export const UPDATE_ASSETS = `${prefix}_UPDATE_ASSETS`;
|
||||
|
||||
export const SET_PAGE = `${prefix}_SET_PAGE`;
|
||||
export const SET_SEARCH_VALUE = `${prefix}_SET_SEARCH_VALUE`;
|
||||
export const SET_CRITERIA = `${prefix}_SET_CRITERIA`;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import AlwaysPremodUserDialog from '../components/AlwaysPremodUserDialog';
|
||||
import { hideAlwaysPremodUserDialog } from '../actions/alwaysPremodUserDialog';
|
||||
import { withAlwaysPremodUser } from 'coral-framework/graphql/mutations';
|
||||
import { compose } from 'react-apollo';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
class AlwaysPremodUserDialogContainer extends Component {
|
||||
alwaysPremodUser = async () => {
|
||||
const { userId, alwaysPremodUser, hideAlwaysPremodUserDialog } = this.props;
|
||||
await alwaysPremodUser({ id: userId });
|
||||
hideAlwaysPremodUserDialog();
|
||||
};
|
||||
|
||||
getInfo() {
|
||||
let note = t('alwayspremoddialog.note_always_premod_user');
|
||||
return t('alwayspremoddialog.note', note);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<AlwaysPremodUserDialog
|
||||
open={this.props.open}
|
||||
onPerform={this.alwaysPremodUser}
|
||||
onCancel={this.props.hideAlwaysPremodUserDialog}
|
||||
username={this.props.username}
|
||||
info={this.getInfo()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AlwaysPremodUserDialogContainer.propTypes = {
|
||||
alwaysPremodUser: PropTypes.func.isRequired,
|
||||
hideAlwaysPremodUserDialog: PropTypes.func,
|
||||
open: PropTypes.bool,
|
||||
username: PropTypes.string,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({
|
||||
alwaysPremodUserDialog: { open, userId, username },
|
||||
}) => ({
|
||||
open,
|
||||
userId,
|
||||
username,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
...bindActionCreators(
|
||||
{
|
||||
hideAlwaysPremodUserDialog,
|
||||
},
|
||||
dispatch
|
||||
),
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
),
|
||||
withAlwaysPremodUser
|
||||
)(AlwaysPremodUserDialogContainer);
|
||||
@@ -5,6 +5,7 @@ import Layout from '../components/Layout';
|
||||
import Login from '../containers/Login';
|
||||
import { FullLoading } from '../components/FullLoading';
|
||||
import BanUserDialog from './BanUserDialog';
|
||||
import AlwaysPremodUserDialog from './AlwaysPremodUserDialog';
|
||||
import SuspendUserDialog from './SuspendUserDialog';
|
||||
import RejectUsernameDialog from './RejectUsernameDialog';
|
||||
import { toggleModal as toggleShortcutModal } from '../actions/moderation';
|
||||
@@ -40,6 +41,7 @@ class LayoutContainer extends React.Component {
|
||||
toggleShortcutModal={toggleShortcutModal}
|
||||
currentUser={this.props.currentUser}
|
||||
>
|
||||
<AlwaysPremodUserDialog />
|
||||
<BanUserDialog />
|
||||
<SuspendUserDialog />
|
||||
<RejectUsernameDialog />
|
||||
|
||||
@@ -22,12 +22,14 @@ import {
|
||||
withSetCommentStatus,
|
||||
withUnbanUser,
|
||||
withUnsuspendUser,
|
||||
withRemoveAlwaysPremodUser,
|
||||
withRejectUsername,
|
||||
withPostFlag,
|
||||
} from 'coral-framework/graphql/mutations';
|
||||
import UserDetailComment from './UserDetailComment';
|
||||
import update from 'immutability-helper';
|
||||
import { showBanUserDialog } from 'actions/banUserDialog';
|
||||
import { showAlwaysPremodUserDialog } from 'actions/alwaysPremodUserDialog';
|
||||
import { showSuspendUserDialog } from 'actions/suspendUserDialog';
|
||||
import { showRejectUsernameDialog } from 'actions/rejectUsernameDialog';
|
||||
|
||||
@@ -153,6 +155,7 @@ UserDetailContainer.propTypes = {
|
||||
selectedCommentIds: PropTypes.array,
|
||||
unbanUser: PropTypes.func.isRequired,
|
||||
unsuspendUser: PropTypes.func.isRequired,
|
||||
removeAlwaysPremodUser: PropTypes.func.isRequired,
|
||||
rejectUsername: PropTypes.func.isRequired,
|
||||
userId: PropTypes.string,
|
||||
};
|
||||
@@ -218,6 +221,17 @@ export const withUserDetailQuery = withQuery(
|
||||
created_at
|
||||
}
|
||||
}
|
||||
alwaysPremod {
|
||||
status
|
||||
history {
|
||||
status
|
||||
assigned_by {
|
||||
id
|
||||
username
|
||||
}
|
||||
created_at
|
||||
}
|
||||
},
|
||||
username {
|
||||
status
|
||||
history {
|
||||
@@ -280,6 +294,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
...bindActionCreators(
|
||||
{
|
||||
showBanUserDialog,
|
||||
showAlwaysPremodUserDialog,
|
||||
showSuspendUserDialog,
|
||||
showRejectUsernameDialog,
|
||||
changeTab,
|
||||
@@ -302,6 +317,7 @@ export default compose(
|
||||
withSetCommentStatus,
|
||||
withUnbanUser,
|
||||
withUnsuspendUser,
|
||||
withRemoveAlwaysPremodUser,
|
||||
withRejectUsername,
|
||||
withPostFlag,
|
||||
withRouter
|
||||
|
||||
@@ -42,6 +42,10 @@ export default withFragments({
|
||||
status_history {
|
||||
type
|
||||
}
|
||||
body_history {
|
||||
body
|
||||
created_at
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'comment')}
|
||||
...${getDefinitionName(CommentLabels.fragments.comment)}
|
||||
...${getDefinitionName(CommentDetails.fragments.comment)}
|
||||
|
||||
@@ -10,6 +10,9 @@ const userStatusFragment = gql`
|
||||
banned {
|
||||
status
|
||||
}
|
||||
alwaysPremod {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
@@ -125,6 +128,64 @@ export default {
|
||||
});
|
||||
},
|
||||
}),
|
||||
AlwaysPremodUser: ({
|
||||
variables: {
|
||||
input: { id },
|
||||
},
|
||||
}) => ({
|
||||
update: proxy => {
|
||||
const fragmentId = `User_${id}`;
|
||||
const data = proxy.readFragment({
|
||||
fragment: userStatusFragment,
|
||||
id: fragmentId,
|
||||
});
|
||||
|
||||
const updated = update(data, {
|
||||
state: {
|
||||
status: {
|
||||
alwaysPremod: {
|
||||
status: { $set: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
proxy.writeFragment({
|
||||
fragment: userStatusFragment,
|
||||
id: fragmentId,
|
||||
data: updated,
|
||||
});
|
||||
},
|
||||
}),
|
||||
RemoveAlwaysPremodUser: ({
|
||||
variables: {
|
||||
input: { id },
|
||||
},
|
||||
}) => ({
|
||||
update: proxy => {
|
||||
const fragmentId = `User_${id}`;
|
||||
const data = proxy.readFragment({
|
||||
fragment: userStatusFragment,
|
||||
id: fragmentId,
|
||||
});
|
||||
|
||||
const updated = update(data, {
|
||||
state: {
|
||||
status: {
|
||||
alwaysPremod: {
|
||||
status: { $set: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
proxy.writeFragment({
|
||||
fragment: userStatusFragment,
|
||||
id: fragmentId,
|
||||
data: updated,
|
||||
});
|
||||
},
|
||||
}),
|
||||
BanUser: ({
|
||||
variables: {
|
||||
input: { id },
|
||||
@@ -154,6 +215,23 @@ export default {
|
||||
});
|
||||
},
|
||||
}),
|
||||
SetUserAlwaysPremodStatus: ({ variables: { status, id } }) => ({
|
||||
updateQueries: {
|
||||
TalkAdmin_Community: prev => {
|
||||
if (!status) {
|
||||
return prev;
|
||||
}
|
||||
const updated = update(prev, {
|
||||
users: {
|
||||
nodes: {
|
||||
$apply: nodes => nodes.filter(node => node.id !== id),
|
||||
},
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
},
|
||||
}),
|
||||
UnbanUser: ({
|
||||
variables: {
|
||||
input: { id },
|
||||
|
||||
@@ -14,6 +14,11 @@ import { hideShortcutsNote } from './actions/moderation';
|
||||
|
||||
smoothscroll.polyfill();
|
||||
|
||||
if (!NodeList.prototype.forEach) {
|
||||
// Polyfill IE11 missing forEach in NodeList.
|
||||
NodeList.prototype.forEach = Array.prototype.forEach;
|
||||
}
|
||||
|
||||
function init({ store, localStorage }) {
|
||||
const shouldHide = localStorage.getItem('coral:shortcutsNote') === 'hide';
|
||||
if (shouldHide) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
SHOW_ALWAYS_PREMOD_USER_DIALOG,
|
||||
HIDE_ALWAYS_PREMOD_USER_DIALOG,
|
||||
} from '../constants/alwaysPremodUserDialog';
|
||||
|
||||
const initialState = {
|
||||
open: false,
|
||||
userId: null,
|
||||
username: '',
|
||||
};
|
||||
|
||||
export default function alwaysPremodUserDialog(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case SHOW_ALWAYS_PREMOD_USER_DIALOG:
|
||||
return {
|
||||
...state,
|
||||
open: true,
|
||||
userId: action.userId,
|
||||
username: action.username,
|
||||
};
|
||||
case HIDE_ALWAYS_PREMOD_USER_DIALOG:
|
||||
return {
|
||||
...state,
|
||||
open: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import moderation from './moderation';
|
||||
import install from './install';
|
||||
import banUserDialog from './banUserDialog';
|
||||
import suspendUserDialog from './suspendUserDialog';
|
||||
import alwaysPremodUserDialog from './alwaysPremodUserDialog';
|
||||
import rejectUsernameDialog from './rejectUsernameDialog';
|
||||
import userDetail from './userDetail';
|
||||
import ui from './ui';
|
||||
@@ -14,6 +15,7 @@ export default {
|
||||
banUserDialog,
|
||||
configure,
|
||||
suspendUserDialog,
|
||||
alwaysPremodUserDialog,
|
||||
userDetail,
|
||||
stories,
|
||||
community,
|
||||
|
||||
@@ -3,56 +3,79 @@ import update from 'immutability-helper';
|
||||
|
||||
const initialState = {
|
||||
assets: {
|
||||
byId: {},
|
||||
ids: [],
|
||||
assets: [],
|
||||
edges: [],
|
||||
pageInfo: {},
|
||||
},
|
||||
searchValue: '',
|
||||
criteria: {
|
||||
asc: 'false',
|
||||
filter: 'all',
|
||||
},
|
||||
loading: true,
|
||||
loadingMore: false,
|
||||
};
|
||||
|
||||
export default function assets(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.FETCH_ASSETS_SUCCESS: {
|
||||
const assets = action.assets.reduce((prev, curr) => {
|
||||
prev[curr.id] = curr;
|
||||
return prev;
|
||||
}, {});
|
||||
|
||||
case actions.FETCH_ASSETS_REQUEST: {
|
||||
return update(state, {
|
||||
loading: { $set: true },
|
||||
});
|
||||
}
|
||||
case actions.FETCH_ASSETS_FAILURE: {
|
||||
return update(state, {
|
||||
loading: { $set: false },
|
||||
});
|
||||
}
|
||||
case actions.FETCH_ASSETS_SUCCESS: {
|
||||
return update(state, {
|
||||
loading: { $set: false },
|
||||
assets: {
|
||||
totalPages: { $set: action.totalPages },
|
||||
page: { $set: action.page },
|
||||
byId: { $set: assets },
|
||||
count: { $set: action.count },
|
||||
ids: { $set: Object.keys(assets) },
|
||||
edges: { $set: action.edges },
|
||||
pageInfo: { $set: action.pageInfo },
|
||||
},
|
||||
});
|
||||
}
|
||||
case actions.UPDATE_ASSET_STATE_REQUEST:
|
||||
case actions.LOAD_MORE_ASSETS_REQUEST: {
|
||||
return update(state, {
|
||||
loadingMore: { $set: true },
|
||||
});
|
||||
}
|
||||
case actions.LOAD_MORE_ASSETS_FAILURE: {
|
||||
return update(state, {
|
||||
loadingMore: { $set: false },
|
||||
});
|
||||
}
|
||||
case actions.LOAD_MORE_ASSETS_SUCCESS: {
|
||||
return update(state, {
|
||||
loadingMore: { $set: false },
|
||||
assets: {
|
||||
edges: { $push: action.edges },
|
||||
pageInfo: {
|
||||
endCursor: { $set: action.pageInfo.endCursor },
|
||||
hasNextPage: { $set: action.pageInfo.hasNextPage },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
case actions.UPDATE_ASSET_STATE_SUCCESS:
|
||||
const index = state.assets.edges.findIndex(
|
||||
({ node: { id } }) => id === action.id
|
||||
);
|
||||
if (index < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return update(state, {
|
||||
assets: {
|
||||
byId: {
|
||||
[action.id]: {
|
||||
closedAt: { $set: action.closedAt },
|
||||
edges: {
|
||||
[index]: {
|
||||
node: {
|
||||
closedAt: { $set: action.closedAt },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
case actions.UPDATE_ASSETS:
|
||||
return update(state, {
|
||||
assets: {
|
||||
assets: { $set: action.assets },
|
||||
},
|
||||
});
|
||||
case actions.SET_PAGE:
|
||||
return {
|
||||
...state,
|
||||
page: action.page,
|
||||
};
|
||||
case actions.SET_SEARCH_VALUE:
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
SHOW_SUSPEND_USER_DIALOG,
|
||||
HIDE_SUSPEND_USER_DIALOG,
|
||||
} from '../constants/suspendUserDialog';
|
||||
import {
|
||||
SHOW_ALWAYS_PREMOD_USER_DIALOG,
|
||||
HIDE_ALWAYS_PREMOD_USER_DIALOG,
|
||||
} from '../constants/alwaysPremodUserDialog';
|
||||
|
||||
const initialState = {
|
||||
modal: false,
|
||||
@@ -23,6 +27,11 @@ export default function config(state = initialState, action) {
|
||||
...state,
|
||||
modal: true,
|
||||
};
|
||||
case SHOW_ALWAYS_PREMOD_USER_DIALOG:
|
||||
return {
|
||||
...state,
|
||||
modal: true,
|
||||
};
|
||||
case HIDE_BAN_USER_DIALOG:
|
||||
return {
|
||||
...state,
|
||||
@@ -33,6 +42,11 @@ export default function config(state = initialState, action) {
|
||||
...state,
|
||||
modal: false,
|
||||
};
|
||||
case HIDE_ALWAYS_PREMOD_USER_DIALOG:
|
||||
return {
|
||||
...state,
|
||||
modal: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -78,9 +78,10 @@
|
||||
}
|
||||
|
||||
.flaggedByReason {
|
||||
font-size: 1tpx;
|
||||
font-size: 14px;
|
||||
margin: 0px;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.filterHeader {
|
||||
margin-top: 30px;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.filterDetail {
|
||||
margin-top: 15px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
width: calc(100% - 300px);
|
||||
padding: 34px 14px;
|
||||
@@ -115,6 +130,12 @@ th.header:nth-child(2), th.header:nth-child(3) {
|
||||
font-size: 0.98em;
|
||||
}
|
||||
|
||||
.actionsMenuAlwaysPremod {
|
||||
background-color: #F0B50B;
|
||||
border-color: #F0B50B;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.actionsMenuSuspended {
|
||||
background-color: #F29336;
|
||||
border-color: #F29336;
|
||||
|
||||
@@ -8,7 +8,12 @@ import LoadMore from '../../../components/LoadMore';
|
||||
import PropTypes from 'prop-types';
|
||||
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
|
||||
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
|
||||
import { isSuspended, isBanned } from 'coral-framework/utils/user';
|
||||
import {
|
||||
isSuspended,
|
||||
isBanned,
|
||||
isAlwaysPremod,
|
||||
} from 'coral-framework/utils/user';
|
||||
import { RadioGroup, Radio } from 'react-mdl';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
ADMIN,
|
||||
@@ -42,6 +47,8 @@ class People extends React.Component {
|
||||
return 'Banned';
|
||||
} else if (isSuspended(user)) {
|
||||
return 'Suspended';
|
||||
} else if (isAlwaysPremod(user)) {
|
||||
return 'Always premoderated';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@@ -54,6 +61,10 @@ class People extends React.Component {
|
||||
this.props.unbanUser(input);
|
||||
};
|
||||
|
||||
removeAlwaysPremodUser = input => {
|
||||
this.props.removeAlwaysPremodUser(input);
|
||||
};
|
||||
|
||||
showBanUserDialog = input => {
|
||||
this.props.showBanUserDialog(input);
|
||||
};
|
||||
@@ -62,13 +73,18 @@ class People extends React.Component {
|
||||
this.props.showSuspendUserDialog(input);
|
||||
};
|
||||
|
||||
showAlwaysPremodUserDialog = input => {
|
||||
this.props.showAlwaysPremodUserDialog(input);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
onSearchChange,
|
||||
onFilterChange,
|
||||
users = [],
|
||||
setUserRole,
|
||||
viewUserDetail,
|
||||
loadMore,
|
||||
filters,
|
||||
} = this.props;
|
||||
|
||||
const hasResults = !!users.nodes.length;
|
||||
@@ -87,11 +103,44 @@ class People extends React.Component {
|
||||
id="commenters-search"
|
||||
type="text"
|
||||
className={styles.searchBoxInput}
|
||||
defaultValue=""
|
||||
onChange={onSearchChange}
|
||||
value={filters.search}
|
||||
onChange={onFilterChange('search')}
|
||||
placeholder={t('streams.search')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterHeader}>
|
||||
{t('community.filter_users')}
|
||||
</div>
|
||||
<div className={styles.filterDetail}>{t('community.status')}</div>
|
||||
<RadioGroup
|
||||
name="statusFilter"
|
||||
value={filters.status}
|
||||
childContainer="div"
|
||||
onChange={onFilterChange('status')}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
<Radio value="">{t('community.all')}</Radio>
|
||||
<Radio value="active">{t('community.active')}</Radio>
|
||||
<Radio value="suspended">{t('community.suspended')}</Radio>
|
||||
<Radio value="banned">{t('community.banned')}</Radio>
|
||||
<Radio value="alwaysPremod">{t('community.always_premod')}</Radio>
|
||||
</RadioGroup>
|
||||
<div className={styles.filterDetail}>
|
||||
{t('community.filter_role')}
|
||||
</div>
|
||||
<RadioGroup
|
||||
name="roleFilter"
|
||||
value={filters.role}
|
||||
childContainer="div"
|
||||
onChange={onFilterChange('role')}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
<Radio value="">{t('community.all')}</Radio>
|
||||
<Radio value={ADMIN}>{t('community.admin')}</Radio>
|
||||
<Radio value={STAFF}>{t('community.staff')}</Radio>
|
||||
<Radio value={MODERATOR}>{t('community.moderator')}</Radio>
|
||||
<Radio value={COMMENTER}>{t('community.commenter')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
{hasResults ? (
|
||||
@@ -154,6 +203,9 @@ class People extends React.Component {
|
||||
user
|
||||
),
|
||||
[styles.actionsMenuBanned]: isBanned(user),
|
||||
[styles.actionsMenuAlwaysPremod]: isAlwaysPremod(
|
||||
user
|
||||
),
|
||||
},
|
||||
'talk-admin-user-detail-actions-button'
|
||||
)}
|
||||
@@ -198,6 +250,27 @@ class People extends React.Component {
|
||||
{t('modqueue.ban_user_actions')}
|
||||
</ActionsMenuItem>
|
||||
)}
|
||||
|
||||
{isAlwaysPremod(user) ? (
|
||||
<ActionsMenuItem
|
||||
onClick={() =>
|
||||
this.removeAlwaysPremodUser({ id: user.id })
|
||||
}
|
||||
>
|
||||
Remove Always Premoderate
|
||||
</ActionsMenuItem>
|
||||
) : (
|
||||
<ActionsMenuItem
|
||||
onClick={() =>
|
||||
this.showAlwaysPremodUserDialog({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('modqueue.always_premod_user_actions')}
|
||||
</ActionsMenuItem>
|
||||
)}
|
||||
</ActionsMenu>
|
||||
</td>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
@@ -254,13 +327,16 @@ class People extends React.Component {
|
||||
|
||||
People.propTypes = {
|
||||
users: PropTypes.object.isRequired,
|
||||
onSearchChange: PropTypes.func.isRequired,
|
||||
filters: PropTypes.object.isRequired,
|
||||
onFilterChange: PropTypes.func.isRequired,
|
||||
setUserRole: PropTypes.func.isRequired,
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
unbanUser: PropTypes.func.isRequired,
|
||||
unsuspendUser: PropTypes.func.isRequired,
|
||||
removeAlwaysPremodUser: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func,
|
||||
showBanUserDialog: PropTypes.func,
|
||||
showAlwaysPremodUserDialog: PropTypes.func,
|
||||
loadMore: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -8,68 +8,76 @@ import {
|
||||
withUnbanUser,
|
||||
withUnsuspendUser,
|
||||
withSetUserRole,
|
||||
withRemoveAlwaysPremodUser,
|
||||
} from 'coral-framework/graphql/mutations';
|
||||
import { showBanUserDialog } from 'actions/banUserDialog';
|
||||
import { showSuspendUserDialog } from 'actions/suspendUserDialog';
|
||||
import { showAlwaysPremodUserDialog } from 'actions/alwaysPremodUserDialog';
|
||||
import { viewUserDetail } from '../../../actions/userDetail';
|
||||
import { appendNewNodes } from 'plugin-api/beta/client/utils';
|
||||
import update from 'immutability-helper';
|
||||
import { Spinner } from 'coral-ui';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
|
||||
class PeopleContainer extends React.Component {
|
||||
class PeopleContainer extends React.PureComponent {
|
||||
timer = null;
|
||||
|
||||
state = {
|
||||
searchValue: '',
|
||||
search: '',
|
||||
role: '',
|
||||
status: '',
|
||||
};
|
||||
|
||||
onSearchChange = e => {
|
||||
const { value } = e.target;
|
||||
this.setState({ searchValue: value }, () => {
|
||||
statusToQuery = {
|
||||
active: { suspended: false, banned: false },
|
||||
suspended: { suspended: true },
|
||||
banned: { banned: true },
|
||||
alwaysPremod: { alwaysPremod: true },
|
||||
};
|
||||
|
||||
onFilterChange = filter => e =>
|
||||
this.setState({ [filter]: e.target.value }, () => {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.search(value);
|
||||
}, 350);
|
||||
this.timer = setTimeout(this.filter, 350);
|
||||
});
|
||||
|
||||
getFilterState = () => {
|
||||
const { role, status } = this.state;
|
||||
return {
|
||||
status: this.statusToQuery[status] || null,
|
||||
role: role || null,
|
||||
};
|
||||
};
|
||||
|
||||
search = async value => {
|
||||
return this.props.data.fetchMore({
|
||||
query: SEARCH_QUERY,
|
||||
filter = () =>
|
||||
this.props.data.fetchMore({
|
||||
query: FILTER_QUERY,
|
||||
variables: {
|
||||
value,
|
||||
state: this.getFilterState(),
|
||||
value: this.state.search,
|
||||
limit: 10,
|
||||
},
|
||||
updateQuery: (previous, { fetchMoreResult: { users } }) => {
|
||||
const updated = update(previous, {
|
||||
updateQuery: (previous, { fetchMoreResult: { users } }) =>
|
||||
update(previous, {
|
||||
users: {
|
||||
nodes: {
|
||||
$set: users.nodes,
|
||||
},
|
||||
nodes: { $set: users.nodes },
|
||||
hasNextPage: { $set: users.hasNextPage },
|
||||
endCursor: { $set: users.endCursor },
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
setUserRole = async (id, role) => {
|
||||
await this.props.setUserRole(id, role);
|
||||
};
|
||||
|
||||
loadMore = () => {
|
||||
return this.props.data.fetchMore({
|
||||
loadMore = () =>
|
||||
this.props.data.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
variables: {
|
||||
value: this.state.searchValue,
|
||||
limit: 5,
|
||||
cursor: this.props.root.users.endCursor,
|
||||
state: this.getFilterState(),
|
||||
value: this.state.search,
|
||||
limit: 5,
|
||||
},
|
||||
updateQuery: (previous, { fetchMoreResult: { users } }) => {
|
||||
const updated = update(previous, {
|
||||
updateQuery: (previous, { fetchMoreResult: { users } }) =>
|
||||
update(previous, {
|
||||
users: {
|
||||
nodes: {
|
||||
$apply: nodes => appendNewNodes(nodes, users.nodes),
|
||||
@@ -77,11 +85,8 @@ class PeopleContainer extends React.Component {
|
||||
hasNextPage: { $set: users.hasNextPage },
|
||||
endCursor: { $set: users.endCursor },
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.props.data.error) {
|
||||
@@ -98,17 +103,20 @@ class PeopleContainer extends React.Component {
|
||||
|
||||
return (
|
||||
<People
|
||||
onSearchChange={this.onSearchChange}
|
||||
onFilterChange={this.onFilterChange}
|
||||
viewUserDetail={this.props.viewUserDetail}
|
||||
setUserRole={this.setUserRole}
|
||||
setUserRole={this.props.setUserRole}
|
||||
showSuspendUserDialog={this.props.showSuspendUserDialog}
|
||||
showBanUserDialog={this.props.showBanUserDialog}
|
||||
showAlwaysPremodUserDialog={this.props.showAlwaysPremodUserDialog}
|
||||
unbanUser={this.props.unbanUser}
|
||||
unsuspendUser={this.props.unsuspendUser}
|
||||
removeAlwaysPremodUser={this.props.removeAlwaysPremodUser}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
users={this.props.root.users}
|
||||
loadMore={this.loadMore}
|
||||
filters={this.state}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -118,87 +126,103 @@ PeopleContainer.propTypes = {
|
||||
setUserRole: PropTypes.func.isRequired,
|
||||
unbanUser: PropTypes.func.isRequired,
|
||||
unsuspendUser: PropTypes.func.isRequired,
|
||||
removeAlwaysPremodUser: PropTypes.func.isRequired,
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func,
|
||||
showBanUserDialog: PropTypes.func,
|
||||
showAlwaysPremodUserDialog: PropTypes.func,
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
};
|
||||
|
||||
const LOAD_MORE_QUERY = gql`
|
||||
query TalkAdmin_Community_People_LoadMoreUsers(
|
||||
$limit: Int
|
||||
$cursor: Cursor
|
||||
$value: String
|
||||
$state: UserStateInput
|
||||
) {
|
||||
users(
|
||||
query: { value: $value, limit: $limit, cursor: $cursor, state: $state }
|
||||
) {
|
||||
hasNextPage
|
||||
endCursor
|
||||
nodes {
|
||||
__typename
|
||||
id
|
||||
username
|
||||
role
|
||||
created_at
|
||||
profiles {
|
||||
id
|
||||
provider
|
||||
}
|
||||
state {
|
||||
status {
|
||||
banned {
|
||||
status
|
||||
}
|
||||
alwaysPremod {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const FILTER_QUERY = gql`
|
||||
query TalkAdmin_Community_People_FilterUsers(
|
||||
$state: UserStateInput
|
||||
$value: String
|
||||
$limit: Int
|
||||
) {
|
||||
users(query: { state: $state, value: $value, limit: $limit }) {
|
||||
hasNextPage
|
||||
endCursor
|
||||
nodes {
|
||||
__typename
|
||||
id
|
||||
username
|
||||
role
|
||||
created_at
|
||||
profiles {
|
||||
id
|
||||
provider
|
||||
}
|
||||
state {
|
||||
status {
|
||||
banned {
|
||||
status
|
||||
}
|
||||
alwaysPremod {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators(
|
||||
{
|
||||
viewUserDetail,
|
||||
showSuspendUserDialog,
|
||||
showBanUserDialog,
|
||||
showAlwaysPremodUserDialog,
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
const LOAD_MORE_QUERY = gql`
|
||||
query TalkAdmin_Community_People_LoadMoreUsers(
|
||||
$limit: Int
|
||||
$cursor: Cursor
|
||||
$value: String
|
||||
) {
|
||||
users(query: { value: $value, limit: $limit, cursor: $cursor }) {
|
||||
hasNextPage
|
||||
endCursor
|
||||
nodes {
|
||||
__typename
|
||||
id
|
||||
username
|
||||
role
|
||||
created_at
|
||||
profiles {
|
||||
id
|
||||
provider
|
||||
}
|
||||
state {
|
||||
status {
|
||||
banned {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SEARCH_QUERY = gql`
|
||||
query TalkAdmin_Community_People_SearchUsers($value: String, $limit: Int) {
|
||||
users(query: { value: $value, limit: $limit }) {
|
||||
hasNextPage
|
||||
endCursor
|
||||
nodes {
|
||||
__typename
|
||||
id
|
||||
username
|
||||
role
|
||||
created_at
|
||||
profiles {
|
||||
id
|
||||
provider
|
||||
}
|
||||
state {
|
||||
status {
|
||||
banned {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default compose(
|
||||
connect(
|
||||
null,
|
||||
@@ -207,6 +231,7 @@ export default compose(
|
||||
withSetUserRole,
|
||||
withUnsuspendUser,
|
||||
withUnbanUser,
|
||||
withRemoveAlwaysPremodUser,
|
||||
withQuery(
|
||||
gql`
|
||||
query TalkAdmin_Community_People {
|
||||
@@ -228,6 +253,9 @@ export default compose(
|
||||
banned {
|
||||
status
|
||||
}
|
||||
alwaysPremod {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
|
||||
@@ -155,9 +155,19 @@ class StreamSettings extends React.Component {
|
||||
<ConfigureCard
|
||||
checked={settings.infoBoxEnable}
|
||||
onCheckbox={this.updateInfoBoxEnable}
|
||||
title={t('configure.include_comment_stream')}
|
||||
title={t('configure.code_of_conduct_summary')}
|
||||
>
|
||||
<p>{t('configure.include_comment_stream_desc')}</p>
|
||||
<p>
|
||||
{t('configure.code_of_conduct_summary_desc')}
|
||||
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://guides.coralproject.net/create-a-code-of-conduct/"
|
||||
>
|
||||
Code of Conduct Guide.
|
||||
</a>
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
styles.configSettingInfoBox,
|
||||
@@ -238,12 +248,14 @@ class StreamSettings extends React.Component {
|
||||
onChange={this.updateClosedTimeout}
|
||||
value={getTimeoutAmount(settings.closedTimeout)}
|
||||
label={t('configure.closed_comments_label')}
|
||||
disabled={!settings.autoCloseStream}
|
||||
/>
|
||||
<div className={styles.configTimeoutSelect}>
|
||||
<SelectField
|
||||
label="comments closed time window"
|
||||
value={getTimeoutMeasure(settings.closedTimeout)}
|
||||
onChange={this.updateClosedTimeoutMeasure}
|
||||
readOnly={!settings.autoCloseStream}
|
||||
>
|
||||
<Option value={'hours'}>{t('configure.hours')}</Option>
|
||||
<Option value={'days'}>{t('configure.days')}</Option>
|
||||
|
||||
@@ -14,6 +14,11 @@ class TechSettings extends React.Component {
|
||||
this.props.updatePending({ updater });
|
||||
};
|
||||
|
||||
updateCustomAdminCssUrl = event => {
|
||||
const updater = { customAdminCssUrl: { $set: event.target.value } };
|
||||
this.props.updatePending({ updater });
|
||||
};
|
||||
|
||||
updateDomainlist = (listName, list) => {
|
||||
this.props.updatePending({
|
||||
updater: {
|
||||
@@ -50,6 +55,14 @@ class TechSettings extends React.Component {
|
||||
onChange={this.updateCustomCssUrl}
|
||||
/>
|
||||
</ConfigureCard>
|
||||
<ConfigureCard title={t('configure.custom_admin_css_url')}>
|
||||
<p>{t('configure.custom_admin_css_url_desc')}</p>
|
||||
<input
|
||||
className={styles.customCSSInput}
|
||||
value={settings.customAdminCssUrl}
|
||||
onChange={this.updateCustomAdminCssUrl}
|
||||
/>
|
||||
</ConfigureCard>
|
||||
<Slot fill="adminTechSettings" passthrough={slotPassthrough} />
|
||||
</ConfigurePage>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import ConfigureCard from 'coral-framework/components/ConfigureCard';
|
||||
|
||||
const Wordlist = ({ suspectWords, bannedWords, onChangeWordlist }) => (
|
||||
<div>
|
||||
<ConfigureCard title={t('configure.banned_words_title')}>
|
||||
<ConfigureCard collapsible title={t('configure.banned_words_title')}>
|
||||
<p>{t('configure.banned_word_text')}</p>
|
||||
<TagsInput
|
||||
value={bannedWords}
|
||||
@@ -16,7 +16,7 @@ const Wordlist = ({ suspectWords, bannedWords, onChangeWordlist }) => (
|
||||
onChange={tags => onChangeWordlist('banned', tags)}
|
||||
/>
|
||||
</ConfigureCard>
|
||||
<ConfigureCard title={t('configure.suspect_word_title')}>
|
||||
<ConfigureCard collapsible title={t('configure.suspect_word_title')}>
|
||||
<p>{t('configure.suspect_word_text')}</p>
|
||||
<TagsInput
|
||||
value={suspectWords}
|
||||
|
||||
@@ -32,6 +32,7 @@ export default compose(
|
||||
settings: gql`
|
||||
fragment TalkAdmin_TechSettings_settings on Settings {
|
||||
customCssUrl
|
||||
customAdminCssUrl
|
||||
domains {
|
||||
whitelist
|
||||
}
|
||||
|
||||
@@ -62,13 +62,19 @@
|
||||
font-weight: 300;
|
||||
margin-bottom: 8px;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 0px;
|
||||
flex: 1;
|
||||
color: black;
|
||||
max-width: 500px;
|
||||
/*
|
||||
IE11 fix – Next line was supposed to be:
|
||||
max-width: 500px;
|
||||
*/
|
||||
padding-right: 20px;
|
||||
/** IE11 fix end **/
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
overflow-wrap: break-word;
|
||||
@@ -97,7 +103,7 @@
|
||||
color: #063b9a;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
letter-spacing: .5px;
|
||||
letter-spacing: 0.5px;
|
||||
margin-left: 10px;
|
||||
font-size: 13px;
|
||||
margin-left: 5px;
|
||||
@@ -105,14 +111,14 @@
|
||||
border-bottom: solid 1px;
|
||||
line-height: 16px;
|
||||
&:hover {
|
||||
opacity: .9;
|
||||
opacity: 0.9;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.username {
|
||||
color: #393B44;
|
||||
color: #393b44;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
@@ -121,12 +127,12 @@
|
||||
margin-left: -5px;
|
||||
transition: background-color 200ms ease;
|
||||
&:hover {
|
||||
background-color: #E0E0E0;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
.external {
|
||||
font-size: .7em;
|
||||
font-size: 0.7em;
|
||||
text-decoration: none;
|
||||
color: #063b9a;
|
||||
cursor: pointer;
|
||||
@@ -134,7 +140,7 @@
|
||||
white-space: nowrap;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
opacity: .9;
|
||||
opacity: 0.9;
|
||||
}
|
||||
i {
|
||||
font-size: 12px;
|
||||
@@ -190,3 +196,25 @@
|
||||
.commentContentFooter {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bodyHistoryToggle {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
line-height: 1px;
|
||||
font-weight: 300;
|
||||
text-decoration: underline;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.editedCommentBody {
|
||||
padding: 0 5px 5px 5px;
|
||||
}
|
||||
|
||||
.editedComment {
|
||||
margin-top: 6px;
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import t from 'coral-framework/services/i18n';
|
||||
class Comment extends React.Component {
|
||||
ref = null;
|
||||
|
||||
state = { showingEditHistory: false };
|
||||
|
||||
handleRef = ref => (this.ref = ref);
|
||||
|
||||
handleFocusOrClick = () => {
|
||||
@@ -45,6 +47,30 @@ class Comment extends React.Component {
|
||||
? null
|
||||
: this.props.rejectComment({ commentId: this.props.comment.id });
|
||||
|
||||
getBodyHistory = () => {
|
||||
const bodyHistory = [];
|
||||
const comment = this.props.comment;
|
||||
for (let i = 0; i < comment.body_history.length - 1; i++) {
|
||||
bodyHistory.push(
|
||||
<div key={i} className={styles.editedComment}>
|
||||
<div>
|
||||
<TimeAgo className={styles.created} datetime={comment.created_at} />
|
||||
</div>
|
||||
<div className={styles.editedCommentBody}>
|
||||
{comment.body_history[i].body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return bodyHistory;
|
||||
};
|
||||
|
||||
toggleEditHistory = () => {
|
||||
this.setState({ showingEditHistory: !this.state.showingEditHistory });
|
||||
this.props.clearHeightCache && this.props.clearHeightCache();
|
||||
};
|
||||
|
||||
componentDidUpdate(prev) {
|
||||
if (!prev.selected && this.props.selected) {
|
||||
this.ref.focus();
|
||||
@@ -137,6 +163,14 @@ class Comment extends React.Component {
|
||||
<span className={styles.editedMarker}>
|
||||
({t('comment.edited')})
|
||||
</span>
|
||||
<span
|
||||
className={styles.bodyHistoryToggle}
|
||||
onClick={this.toggleEditHistory}
|
||||
>
|
||||
{this.state.showingEditHistory
|
||||
? t('comment.hide_edit_history')
|
||||
: t('comment.show_edit_history')}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<div className={styles.adminCommentInfoBar}>
|
||||
@@ -183,8 +217,7 @@ class Comment extends React.Component {
|
||||
<div className={styles.sideActions}>
|
||||
<IfHasLink text={comment.body}>
|
||||
<span className={styles.hasLinks}>
|
||||
{/* TODO: translate string */}
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
<Icon name="error_outline" /> {t('common.contains_link')}
|
||||
</span>
|
||||
</IfHasLink>
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
@@ -202,6 +235,14 @@ class Comment extends React.Component {
|
||||
</div>
|
||||
</CommentAnimatedEdit>
|
||||
</div>
|
||||
|
||||
{this.state.showingEditHistory ? (
|
||||
<div className={styles.container}>
|
||||
{t('comment.edit_history')}
|
||||
{this.getBodyHistory()}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CommentDetails
|
||||
root={root}
|
||||
comment={comment}
|
||||
|
||||
@@ -52,6 +52,10 @@ export default withFragments({
|
||||
status_history {
|
||||
type
|
||||
}
|
||||
body_history {
|
||||
body
|
||||
created_at
|
||||
}
|
||||
hasParent
|
||||
${getSlotFragmentSpreads(slots, 'comment')}
|
||||
...${getDefinitionName(CommentLabels.fragments.comment)}
|
||||
|
||||
@@ -77,7 +77,6 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -95,3 +94,11 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
margin: 20px auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loadMoreSpinner {
|
||||
margin: 20px auto 50px;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ import React, { Component } from 'react';
|
||||
import cn from 'classnames';
|
||||
import { Link } from 'react-router';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown, Option, Paginate, Icon } from 'coral-ui';
|
||||
import { DataTable, TableHeader, RadioGroup, Radio } from 'react-mdl';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import styles from './Stories.css';
|
||||
import { Dropdown, Option, Icon, Spinner } from 'coral-ui';
|
||||
import EmptyCard from 'coral-admin/src/components/EmptyCard';
|
||||
import LoadMore from 'coral-admin/src/components/LoadMore';
|
||||
|
||||
import styles from './Stories.css';
|
||||
|
||||
class Stories extends Component {
|
||||
renderDate = date => {
|
||||
@@ -39,10 +42,11 @@ class Stories extends Component {
|
||||
filter,
|
||||
onSearchChange,
|
||||
onSettingChange,
|
||||
onPageChange,
|
||||
asc,
|
||||
onLoadMore,
|
||||
loading,
|
||||
loadingMore,
|
||||
} = this.props;
|
||||
const rows = assets.ids.map(id => assets.byId[id]);
|
||||
const rows = assets.edges.map(({ node }) => node);
|
||||
|
||||
return (
|
||||
<div className={cn('talk-admin-stories', styles.container)}>
|
||||
@@ -74,60 +78,58 @@ class Stories extends Component {
|
||||
<Radio value="open">{t('streams.open')}</Radio>
|
||||
<Radio value="closed">{t('streams.closed')}</Radio>
|
||||
</RadioGroup>
|
||||
<div className={styles.optionHeader}>{t('streams.sort_by')}</div>
|
||||
<RadioGroup
|
||||
name="sortBy"
|
||||
value={asc}
|
||||
childContainer="div"
|
||||
onChange={onSettingChange('asc')}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
<Radio value="false">{t('streams.newest')}</Radio>
|
||||
<Radio value="true">{t('streams.oldest')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
{rows.length ? (
|
||||
<div className={styles.mainContent}>
|
||||
<DataTable className={styles.streamsTable} rows={rows}>
|
||||
<TableHeader name="title" cellFormatter={this.renderTitle}>
|
||||
{t('streams.article')}
|
||||
</TableHeader>
|
||||
<TableHeader
|
||||
name="publication_date"
|
||||
cellFormatter={this.renderDate}
|
||||
>
|
||||
{t('streams.pubdate')}
|
||||
</TableHeader>
|
||||
<TableHeader
|
||||
name="closedAt"
|
||||
cellFormatter={this.renderStatus}
|
||||
className={styles.status}
|
||||
>
|
||||
{t('streams.status')}
|
||||
</TableHeader>
|
||||
</DataTable>
|
||||
<Paginate
|
||||
pageCount={assets.totalPages}
|
||||
page={assets.page - 1}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyCard>{t('streams.empty_result')}</EmptyCard>
|
||||
)}
|
||||
<div className={styles.mainContent}>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : rows.length ? (
|
||||
<div>
|
||||
<DataTable className={styles.streamsTable} rows={rows}>
|
||||
<TableHeader name="title" cellFormatter={this.renderTitle}>
|
||||
{t('streams.article')}
|
||||
</TableHeader>
|
||||
<TableHeader
|
||||
name="publication_date"
|
||||
cellFormatter={this.renderDate}
|
||||
>
|
||||
{t('streams.pubdate')}
|
||||
</TableHeader>
|
||||
<TableHeader
|
||||
name="closedAt"
|
||||
cellFormatter={this.renderStatus}
|
||||
className={styles.status}
|
||||
>
|
||||
{t('streams.status')}
|
||||
</TableHeader>
|
||||
</DataTable>
|
||||
{loadingMore ? (
|
||||
<Spinner className={styles.loadMoreSpinner} />
|
||||
) : (
|
||||
<LoadMore
|
||||
showLoadMore={assets.pageInfo.hasNextPage}
|
||||
loadMore={onLoadMore}
|
||||
className={styles.loadMore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyCard>{t('streams.empty_result')}</EmptyCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Stories.propTypes = {
|
||||
loading: PropTypes.bool,
|
||||
loadingMore: PropTypes.bool,
|
||||
assets: PropTypes.object,
|
||||
searchValue: PropTypes.string,
|
||||
asc: PropTypes.string,
|
||||
filter: PropTypes.string,
|
||||
onLoadMore: PropTypes.func.isRequired,
|
||||
onStatusChange: PropTypes.func.isRequired,
|
||||
onSearchChange: PropTypes.func.isRequired,
|
||||
onPageChange: PropTypes.func.isRequired,
|
||||
onSettingChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { bindActionCreators } from 'redux';
|
||||
import {
|
||||
fetchAssets,
|
||||
updateAssetState,
|
||||
setPage,
|
||||
setSearchValue,
|
||||
setCriteria,
|
||||
loadMoreAssets,
|
||||
} from 'coral-admin/src/actions/stories';
|
||||
import Stories from '../components/Stories';
|
||||
|
||||
@@ -35,13 +35,11 @@ class StoriesContainer extends Component {
|
||||
};
|
||||
|
||||
fetchAssets = query => {
|
||||
const { searchValue, asc, filter, limit } = this.props;
|
||||
const { searchValue: value, filter } = this.props;
|
||||
|
||||
this.props.fetchAssets({
|
||||
value: searchValue,
|
||||
asc,
|
||||
value,
|
||||
filter,
|
||||
limit,
|
||||
...query,
|
||||
});
|
||||
};
|
||||
@@ -50,31 +48,40 @@ class StoriesContainer extends Component {
|
||||
const { updateAssetState } = this.props;
|
||||
|
||||
try {
|
||||
updateAssetState(id, closeStream ? Date.now() : null);
|
||||
this.fetchAssets();
|
||||
await updateAssetState(id, closeStream ? Date.now() : null);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
onPageChange = ({ selected }) => {
|
||||
const page = selected + 1;
|
||||
this.props.setPage(page);
|
||||
this.fetchAssets({ page });
|
||||
onLoadMore = async () => {
|
||||
const {
|
||||
searchValue: value,
|
||||
filter,
|
||||
assets: {
|
||||
pageInfo: { endCursor: cursor },
|
||||
},
|
||||
loadMoreAssets,
|
||||
} = this.props;
|
||||
try {
|
||||
await loadMoreAssets({ cursor, value, filter });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Stories
|
||||
loading={this.props.loading}
|
||||
loadingMore={this.props.loadingMore}
|
||||
assets={this.props.assets}
|
||||
searchValue={this.props.searchValue}
|
||||
asc={this.props.asc}
|
||||
filter={this.props.filter}
|
||||
limit={this.props.limit}
|
||||
onPageChange={this.onPageChange}
|
||||
onStatusChange={this.onStatusChange}
|
||||
onSettingChange={this.onSettingChange}
|
||||
onSearchChange={this.onSearchChange}
|
||||
onLoadMore={this.onLoadMore}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -82,34 +89,34 @@ class StoriesContainer extends Component {
|
||||
|
||||
const mapStateToProps = ({ stories }) => ({
|
||||
assets: stories.assets,
|
||||
loading: stories.loading,
|
||||
loadingMore: stories.loadingMore,
|
||||
searchValue: stories.searchValue,
|
||||
asc: stories.criteria.asc,
|
||||
filter: stories.criteria.filter,
|
||||
limit: stories.criteria.limit,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators(
|
||||
{
|
||||
setPage,
|
||||
setCriteria,
|
||||
setSearchValue,
|
||||
fetchAssets,
|
||||
updateAssetState,
|
||||
loadMoreAssets,
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
StoriesContainer.propTypes = {
|
||||
loading: PropTypes.bool,
|
||||
loadingMore: PropTypes.bool,
|
||||
assets: PropTypes.object,
|
||||
searchValue: PropTypes.string,
|
||||
asc: PropTypes.string,
|
||||
filter: PropTypes.string,
|
||||
limit: PropTypes.number,
|
||||
setPage: PropTypes.func.isRequired,
|
||||
setCriteria: PropTypes.func.isRequired,
|
||||
setSearchValue: PropTypes.func.isRequired,
|
||||
fetchAssets: PropTypes.func.isRequired,
|
||||
loadMoreAssets: PropTypes.func.isRequired,
|
||||
updateAssetState: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import LinkifyIt from 'linkify-it';
|
||||
import tlds from 'tlds';
|
||||
|
||||
export function createLinkify() {
|
||||
const linkify = new LinkifyIt();
|
||||
linkify.tlds(tlds);
|
||||
return linkify;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { createPostMessage } from 'coral-framework/services/postMessage';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const staticConfig = getStaticConfiguration();
|
||||
const { STATIC_ORIGIN: origin } = staticConfig;
|
||||
const { BASE_ORIGIN: origin } = staticConfig;
|
||||
const postMessage = createPostMessage(origin);
|
||||
|
||||
// Get the auth element and parse it as JSON by decoding it.
|
||||
|
||||
@@ -5,6 +5,9 @@ const prefix = 'TALK_EMBED_STREAM';
|
||||
export const ADDTL_COMMENTS_ON_LOAD_MORE = parseInt(
|
||||
defaultTo(process.env.TALK_ADDTL_COMMENTS_ON_LOAD_MORE, '10')
|
||||
);
|
||||
export const ADDTL_REPLIES_ON_LOAD_MORE = parseInt(
|
||||
defaultTo(process.env.TALK_ADDTL_REPLIES_ON_LOAD_MORE, '999999')
|
||||
);
|
||||
export const ASSET_COMMENTS_LOAD_DEPTH = parseInt(
|
||||
defaultTo(process.env.TALK_ASSET_COMMENTS_LOAD_DEPTH, '10')
|
||||
);
|
||||
|
||||
@@ -81,7 +81,7 @@ class EmbedContainer extends React.Component {
|
||||
},
|
||||
}
|
||||
) => {
|
||||
notify('info', t('your_username_has_been_rejected'));
|
||||
notify('info', t('your_username_has_been_rejected_not_in_line'));
|
||||
props.updateStatus(state.status);
|
||||
},
|
||||
},
|
||||
@@ -175,6 +175,9 @@ const USER_BANNED_SUBSCRIPTION = gql`
|
||||
banned {
|
||||
status
|
||||
}
|
||||
alwaysPremod {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
@@ -196,6 +199,9 @@ const USER_SUSPENDED_SUBSCRIPTION = gql`
|
||||
banned {
|
||||
status
|
||||
}
|
||||
alwaysPremod {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
@@ -217,6 +223,9 @@ const USERNAME_REJECTED_SUBSCRIPTION = gql`
|
||||
banned {
|
||||
status
|
||||
}
|
||||
alwaysPremod {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
@@ -253,6 +262,9 @@ const EMBED_QUERY = gql`
|
||||
banned {
|
||||
status
|
||||
}
|
||||
alwaysPremod {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import QuestionBox from '../../../components/QuestionBox';
|
||||
import DefaultQuestionBoxIcon from '../../../components/DefaultQuestionBoxIcon';
|
||||
import PropTypes from 'prop-types';
|
||||
import cn from 'classnames';
|
||||
import styles from './QuestionBoxBuilder.css';
|
||||
import { Icon } from 'coral-ui';
|
||||
@@ -12,6 +13,7 @@ const icons = [{ default: DefaultIcon }, 'forum', 'build', 'format_quote'];
|
||||
class QuestionBoxBuilder extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
title,
|
||||
questionBoxIcon,
|
||||
questionBoxContent,
|
||||
onContentChange,
|
||||
@@ -20,7 +22,7 @@ class QuestionBoxBuilder extends React.Component {
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<h4>Include an Icon</h4>
|
||||
<h4>{title}</h4>
|
||||
|
||||
<ul className={styles.iconList}>
|
||||
{icons.map(item => {
|
||||
@@ -53,4 +55,12 @@ class QuestionBoxBuilder extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
QuestionBoxBuilder.propTypes = {
|
||||
title: PropTypes.string,
|
||||
questionBoxIcon: PropTypes.string,
|
||||
questionBoxContent: PropTypes.string,
|
||||
onContentChange: PropTypes.func,
|
||||
onIconChange: PropTypes.func,
|
||||
};
|
||||
|
||||
export default QuestionBoxBuilder;
|
||||
|
||||
@@ -69,6 +69,7 @@ class Settings extends React.Component {
|
||||
{questionBoxEnable && (
|
||||
<div className={styles.questionBoxContainer}>
|
||||
<QuestionBoxBuilder
|
||||
title={t('configure.include_an_icon')}
|
||||
questionBoxIcon={questionBoxIcon}
|
||||
questionBoxContent={questionBoxContent}
|
||||
onIconChange={onQuestionBoxIconChange}
|
||||
|
||||
@@ -48,7 +48,7 @@ class ChangeUsername extends Component {
|
||||
return (
|
||||
<RestrictedMessageBox>
|
||||
<div className="talk-change-username">
|
||||
<span>{t('framework.edit_name.msg')}</span>
|
||||
<span>{t('framework.edit_name.rejected')}</span>
|
||||
<div className={styles.alert}>{alert}</div>
|
||||
<label htmlFor="username" className="screen-reader-text">
|
||||
{t('framework.edit_name.label')}
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
.comment {
|
||||
position: relative;
|
||||
padding-left: 15px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.commentContainer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.commentLevel0 {
|
||||
@@ -138,6 +143,7 @@
|
||||
}
|
||||
|
||||
.commentAvatar {
|
||||
margin-top: 10px;
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
|
||||
@@ -645,7 +645,7 @@ export default class Comment extends React.Component {
|
||||
})}
|
||||
onClick={this.onClickEdit}
|
||||
>
|
||||
Edit
|
||||
{t('framework.edit')}
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -38,5 +38,4 @@
|
||||
position: relative;
|
||||
margin-top: 28px;
|
||||
padding-bottom: 50px;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
@@ -206,15 +206,30 @@ class Stream extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
renderQuestionBox() {
|
||||
const {
|
||||
root,
|
||||
asset,
|
||||
asset: {
|
||||
settings: { questionBoxEnable, questionBoxContent, questionBoxIcon },
|
||||
},
|
||||
} = this.props;
|
||||
const slotPassthrough = { root, asset };
|
||||
if (questionBoxEnable) {
|
||||
return (
|
||||
<QuestionBox content={questionBoxContent} icon={questionBoxIcon}>
|
||||
<Slot fill="streamQuestionArea" passthrough={slotPassthrough} />
|
||||
</QuestionBox>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
root,
|
||||
appendItemArray,
|
||||
asset,
|
||||
asset: {
|
||||
comment: highlightedComment,
|
||||
settings: { questionBoxEnable },
|
||||
},
|
||||
asset: { comment: highlightedComment },
|
||||
postComment,
|
||||
notify,
|
||||
updateItem,
|
||||
@@ -260,14 +275,7 @@ class Stream extends React.Component {
|
||||
content={asset.settings.infoBoxContent}
|
||||
enable={asset.settings.infoBoxEnable}
|
||||
/>
|
||||
{questionBoxEnable && (
|
||||
<QuestionBox
|
||||
content={asset.settings.questionBoxContent}
|
||||
icon={asset.settings.questionBoxIcon}
|
||||
>
|
||||
<Slot fill="streamQuestionArea" passthrough={slotPassthrough} />
|
||||
</QuestionBox>
|
||||
)}
|
||||
{this.renderQuestionBox()}
|
||||
{!banned &&
|
||||
temporarilySuspended && (
|
||||
<RestrictedMessageBox>
|
||||
@@ -305,6 +313,7 @@ class Stream extends React.Component {
|
||||
) : (
|
||||
<Markdown content={asset.settings.disableCommentingMessage} />
|
||||
)}
|
||||
{this.renderQuestionBox()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { bindActionCreators } from 'redux';
|
||||
import {
|
||||
ADDTL_COMMENTS_ON_LOAD_MORE,
|
||||
ASSET_COMMENTS_LOAD_DEPTH,
|
||||
ADDTL_REPLIES_ON_LOAD_MORE,
|
||||
THREADING_LEVEL,
|
||||
} from '../../../constants/stream';
|
||||
import {
|
||||
@@ -152,7 +153,9 @@ class StreamContainer extends React.Component {
|
||||
return this.props.data.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
variables: {
|
||||
limit: parent_id ? 999999 : ADDTL_COMMENTS_ON_LOAD_MORE,
|
||||
limit: parent_id
|
||||
? ADDTL_REPLIES_ON_LOAD_MORE
|
||||
: ADDTL_COMMENTS_ON_LOAD_MORE,
|
||||
cursor: comment.replies.endCursor,
|
||||
parent_id,
|
||||
asset_id: this.props.asset.id,
|
||||
@@ -398,6 +401,9 @@ const fragments = {
|
||||
banned {
|
||||
status
|
||||
}
|
||||
alwaysPremod {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import queryString from 'querystringify';
|
||||
import URLSearchParams from '@ungap/url-search-params';
|
||||
import pym from 'pym.js';
|
||||
import EventEmitter from 'eventemitter2';
|
||||
import { buildUrl } from 'coral-framework/utils/url';
|
||||
|
||||
import SnackBar from './SnackBar';
|
||||
import onIntersect from './onIntersect';
|
||||
import {
|
||||
createStorage,
|
||||
connectStorageToPym,
|
||||
} from 'coral-framework/services/storage';
|
||||
|
||||
// Rebuild the origin if it isn't defined. This is our poor-mans polyfill
|
||||
// for the location APIs.
|
||||
if (!window.location.origin) {
|
||||
window.location.origin = `${window.location.protocol}//${
|
||||
window.location.hostname
|
||||
}${window.location.port ? `:${window.location.port}` : ''}`;
|
||||
}
|
||||
|
||||
const NOTIFICATION_OFFSET = 200;
|
||||
|
||||
// Ensure there is a trailing slash.
|
||||
function ensureEndSlash(p) {
|
||||
return p.match(/\/$/) ? p : `${p}/`;
|
||||
}
|
||||
|
||||
// Build the URL to load in the pym iframe.
|
||||
function buildStreamIframeUrl(talkBaseUrl, query) {
|
||||
let url = talkBaseUrl + 'embed/stream?';
|
||||
|
||||
url += queryString.stringify(query);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// detectAssetURL will try to grab the canonical url from the head, if it isn't
|
||||
// available, source the url from the current one.
|
||||
function detectAssetURL() {
|
||||
try {
|
||||
// Try to get the url from the canonical tag on the page.
|
||||
return document.querySelector('link[rel="canonical"]').href;
|
||||
} catch (e) {
|
||||
window.console.warn(
|
||||
'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.'
|
||||
);
|
||||
|
||||
return window.location.origin + window.location.pathname;
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuery({ asset_id, asset_url }) {
|
||||
// Compose the query to send down to the Talk API so it knows what to load.
|
||||
const query = {};
|
||||
|
||||
// Parse the url parameters to extract some of the information.
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
|
||||
// Pull the Comment ID out of the query string.
|
||||
const commentID = search.get('commentId') || search.get('commentID');
|
||||
if (commentID) {
|
||||
query.comment_id = commentID;
|
||||
}
|
||||
|
||||
// Insert the asset_id into the query.
|
||||
if (asset_id) {
|
||||
query.asset_id = asset_id;
|
||||
}
|
||||
|
||||
// If the asset_url is defined, use it, otherwise, detect it.
|
||||
if (asset_url) {
|
||||
query.asset_url = asset_url;
|
||||
} else {
|
||||
query.asset_url = detectAssetURL();
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
// Get dimensions of viewport.
|
||||
function viewportDimensions() {
|
||||
let target = window;
|
||||
let prefix = 'inner';
|
||||
if (!('innerWidth' in window)) {
|
||||
prefix = 'client';
|
||||
target = document.documentElement || document.body;
|
||||
}
|
||||
|
||||
return {
|
||||
width: target[`${prefix}Width`],
|
||||
height: target[`${prefix}Height`],
|
||||
};
|
||||
}
|
||||
|
||||
function parseAMPHash(opts) {
|
||||
const result = { ...opts };
|
||||
const query = window.location.hash.length && window.location.hash.substr(1);
|
||||
if (query) {
|
||||
const parsed = queryString.parse(query);
|
||||
if (parsed.asset_url && !result.asset_url) {
|
||||
result.asset_url = parsed.asset_url;
|
||||
}
|
||||
if (parsed.asset_id && !result.asset_id) {
|
||||
result.asset_id = parsed.asset_id;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default class Bridge {
|
||||
constructor(
|
||||
element,
|
||||
{
|
||||
// Pull out the URLs used to setup Talk.
|
||||
talk: talkBaseUrl,
|
||||
talkStaticUrl = talkBaseUrl,
|
||||
// Default the following to null.
|
||||
events = null,
|
||||
snackBarStyles = null,
|
||||
onAuthChanged = null,
|
||||
// Determine if we're in lazy mode or not. By default, the build argument
|
||||
// will determine the lazy render status. This default is primarily used
|
||||
// when the embed code cannot be changed, but control of the Talk serving
|
||||
// domain is available.
|
||||
lazy = process.env.TALK_DEFAULT_LAZY_RENDER === 'TRUE',
|
||||
// Any additional options are extracted to be sent to the embed via the
|
||||
// pym bridge.
|
||||
amp,
|
||||
...opts
|
||||
}
|
||||
) {
|
||||
this.pym = null;
|
||||
this.element = element;
|
||||
this.amp = amp;
|
||||
this.lazy = !amp && lazy;
|
||||
|
||||
// Parse amp hash.
|
||||
this.opts = amp ? parseAMPHash(opts) : opts;
|
||||
this.query = buildQuery(this.opts);
|
||||
this.emitter = new EventEmitter({ wildcard: true });
|
||||
this.snackBar = amp ? null : new SnackBar(snackBarStyles || {});
|
||||
this.onAuthChanged = onAuthChanged;
|
||||
this.talkBaseUrl = ensureEndSlash(talkBaseUrl);
|
||||
this.talkStaticUrl = ensureEndSlash(talkStaticUrl);
|
||||
|
||||
// Store queued operations in a queue that can be processed once the stream
|
||||
// is rendered.
|
||||
this.queued = [];
|
||||
|
||||
// Attach to the events emitted by the pym parent.
|
||||
if (events) {
|
||||
events(this.emitter);
|
||||
}
|
||||
|
||||
// Start the embed loading process.
|
||||
if (this.lazy) {
|
||||
// When the dom element containing the talk embed container is in view,
|
||||
// render the stream with force turned on so that it skips this portion.
|
||||
onIntersect(this.element, () => this.load());
|
||||
} else {
|
||||
// We aren't being lazy, load it now!
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
ensureRendered() {
|
||||
// Check to see if the pym bridge is created, and the embed is loaded.
|
||||
if (this.pym === null) {
|
||||
throw new Error('Stream Embed must be rendered first');
|
||||
}
|
||||
}
|
||||
|
||||
queueWhenRendered(callback) {
|
||||
// Check to see if the queue is alive, if it isn't, run the callback now,
|
||||
// otherwise, push the callback to be processed when the stream has loaded.
|
||||
if (this.queued !== null) {
|
||||
this.queued.push(callback);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
setupPym() {
|
||||
const url = buildStreamIframeUrl(this.talkBaseUrl, this.query);
|
||||
this.pym = new pym.Parent(this.element.id, url, {
|
||||
title: this.opts.title,
|
||||
id: `${this.element.id}_iframe`,
|
||||
name: `${this.element.id}_iframe`,
|
||||
});
|
||||
|
||||
// NOTE: Workaround for iOS Safari which ignores `width` but respects `min-width` value.
|
||||
this.pym.el.firstChild.style.width = '1px';
|
||||
this.pym.el.firstChild.style.minWidth = '100%';
|
||||
|
||||
// Resize parent iframe height when child height changes
|
||||
let cachedHeight;
|
||||
this.pym.onMessage('height', height => {
|
||||
if (height !== cachedHeight) {
|
||||
this.pym.el.firstChild.style.height = `${height}px`;
|
||||
cachedHeight = height;
|
||||
if (this.amp) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
sentinel: 'amp',
|
||||
type: 'embed-size',
|
||||
height,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send the config back over the pym bridge if requested.
|
||||
this.pym.onMessage('getConfig', () => {
|
||||
this.pym.sendMessage('config', JSON.stringify(this.opts));
|
||||
});
|
||||
|
||||
// If the auth changes, and someone is listening for it, then re-emit it.
|
||||
if (this.onAuthChanged) {
|
||||
this.pym.onMessage('coral-auth-changed', message => {
|
||||
this.onAuthChanged(message ? JSON.parse(message) : null);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove the permalink comment id from the search.
|
||||
this.pym.onMessage('coral-view-all-comments', () => {
|
||||
const query = queryString.parse(location.search);
|
||||
|
||||
// Remove the commentId/commentID url param.
|
||||
delete query.commentId;
|
||||
delete query.commentID;
|
||||
|
||||
// Rebuild the search field without the commentId in it.
|
||||
const search = queryString.stringify(query);
|
||||
|
||||
// Change the url.
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
buildUrl({ ...location, search })
|
||||
);
|
||||
});
|
||||
|
||||
// Remove the permalink comment id from the hash.
|
||||
this.pym.onMessage('coral-view-comment', id => {
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
commentId: id,
|
||||
});
|
||||
|
||||
// Change the url to the permalink url.
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
buildUrl({ ...location, search })
|
||||
);
|
||||
});
|
||||
|
||||
// Helps child show notifications at the right scrollTop.
|
||||
this.pym.onMessage('getPosition', () => {
|
||||
const { height } = viewportDimensions();
|
||||
let position = height + document.body.scrollTop;
|
||||
|
||||
if (position > NOTIFICATION_OFFSET) {
|
||||
position = position - NOTIFICATION_OFFSET;
|
||||
}
|
||||
|
||||
this.pym.sendMessage('position', position);
|
||||
});
|
||||
|
||||
// When end-user clicks link in iframe, open it in parent context
|
||||
this.pym.onMessage('navigate', url => {
|
||||
// Open the new window, detach the opener, and focus on it.
|
||||
const w = window.open(url, '_blank');
|
||||
w.opener = null;
|
||||
w.focus();
|
||||
});
|
||||
|
||||
// Pass events from iframe to the event emitter.
|
||||
this.pym.onMessage('event', raw => {
|
||||
const { eventName, value } = JSON.parse(raw);
|
||||
this.emitter.emit(eventName, value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* load will configure the pym parent if it hasn't already been, and setup the
|
||||
* snackBar. If there are any queued operations, it will run them first.
|
||||
*/
|
||||
load() {
|
||||
if (this.pym !== null) {
|
||||
throw new Error('Stream Embed already rendered');
|
||||
}
|
||||
|
||||
// Setup Pym.
|
||||
this.setupPym();
|
||||
|
||||
if (this.snackBar) {
|
||||
// Attach the snackBar to the pym parent and to the body of the page.
|
||||
this.snackBar.attach(window.document.body, this.pym);
|
||||
}
|
||||
|
||||
// If the user clicks outside the embed, then tell the embed.
|
||||
document.addEventListener('click', this.handleClick.bind(this), true);
|
||||
|
||||
// Listens to ${name}Storage requests on pym and relay it to
|
||||
// ${name}Storage.
|
||||
['local', 'session'].forEach(name => {
|
||||
connectStorageToPym(
|
||||
createStorage(`${name}Storage`),
|
||||
this.pym,
|
||||
`${name}Storage`
|
||||
);
|
||||
});
|
||||
|
||||
// Process any queued operations.
|
||||
const queued = this.queued;
|
||||
this.queued = null;
|
||||
queued.forEach(callback => callback());
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
this.pym.sendMessage('click');
|
||||
}
|
||||
|
||||
enablePluginsDebug() {
|
||||
this.pym.sendMessage('enablePluginsDebug');
|
||||
}
|
||||
|
||||
disablePluginsDebug() {
|
||||
this.pym.sendMessage('disablePluginsDebug');
|
||||
}
|
||||
|
||||
login(token) {
|
||||
this.pym.sendMessage('login', token);
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.pym.sendMessage('logout');
|
||||
}
|
||||
|
||||
remove() {
|
||||
// Remove the event listeners.
|
||||
document.removeEventListener('click', this.handleClick.bind(this));
|
||||
this.emitter.removeAllListeners();
|
||||
|
||||
// Remove the snackbar.
|
||||
if (this.snackBar) {
|
||||
this.snackBar.remove();
|
||||
this.snackBar = null;
|
||||
}
|
||||
|
||||
// Remove the pym parent.
|
||||
this.pym.remove();
|
||||
this.pym = null;
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,25 @@ const DEFAULT_STYLE = {
|
||||
willChange: 'transform, opacity',
|
||||
transition: 'transform .35s cubic-bezier(.55,0,.1,1), opacity .35s',
|
||||
pointerEvents: 'none',
|
||||
padding: '12px 18px',
|
||||
padding: '12px 18px 0',
|
||||
color: '#fff',
|
||||
borderRadius: '3px 3px 0 0',
|
||||
textAlign: 'center',
|
||||
borderRadius: '4px',
|
||||
maxWidth: '400px',
|
||||
left: '50%',
|
||||
opacity: 0,
|
||||
transform: 'translate(-50%, 20px)',
|
||||
bottom: 0,
|
||||
bottom: '20px',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'Helvetica, "Helvetica Neue", Verdana, sans-serif',
|
||||
display: 'flex',
|
||||
};
|
||||
|
||||
const CLOSE_STYLE = {
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
marginLeft: '10px',
|
||||
position: 'relative',
|
||||
top: '-4px',
|
||||
};
|
||||
|
||||
export default class Snackbar {
|
||||
@@ -26,6 +34,20 @@ export default class Snackbar {
|
||||
this.el = document.createElement('div');
|
||||
this.el.id = 'coral-notif';
|
||||
|
||||
const closeButton = document.createElement('div');
|
||||
closeButton.className = 'coral-notif-close';
|
||||
for (let key in CLOSE_STYLE) {
|
||||
closeButton.style[key] = CLOSE_STYLE[key];
|
||||
}
|
||||
closeButton.textContent = '×';
|
||||
closeButton.onclick = () => this.clear();
|
||||
|
||||
this.snackbarText = document.createElement('div');
|
||||
this.snackbarText.className = 'coral-notif-text';
|
||||
this.snackbarText.style.paddingBottom = '12px';
|
||||
this.el.appendChild(this.snackbarText);
|
||||
this.el.appendChild(closeButton);
|
||||
|
||||
// Apply custom styles to the snackbar.
|
||||
const style = Object.assign({}, DEFAULT_STYLE, customStyle);
|
||||
for (let key in style) {
|
||||
@@ -35,6 +57,7 @@ export default class Snackbar {
|
||||
|
||||
clear() {
|
||||
this.el.style.opacity = 0;
|
||||
this.el.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
alert(message) {
|
||||
@@ -42,7 +65,7 @@ export default class Snackbar {
|
||||
this.el.style.transform = 'translate(-50%, 20px)';
|
||||
this.el.style.opacity = 0;
|
||||
this.el.className = `coral-notif-${type}`;
|
||||
this.el.textContent = text;
|
||||
this.snackbarText.textContent = text;
|
||||
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
@@ -51,10 +74,12 @@ export default class Snackbar {
|
||||
this.timeout = setTimeout(() => {
|
||||
this.el.style.transform = 'translate(-50%, 0)';
|
||||
this.el.style.opacity = 1;
|
||||
this.el.style.pointerEvents = 'auto';
|
||||
|
||||
this.timeout = setTimeout(() => {
|
||||
this.el.style.opacity = 0;
|
||||
}, 7000);
|
||||
this.el.style.pointerEvents = 'none';
|
||||
}, 15000);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import queryString from 'querystringify';
|
||||
import pym from 'pym.js';
|
||||
import EventEmitter from 'eventemitter2';
|
||||
import { buildUrl } from 'coral-framework/utils/url';
|
||||
import Snackbar from './Snackbar';
|
||||
import {
|
||||
createStorage,
|
||||
connectStorageToPym,
|
||||
} from 'coral-framework/services/storage';
|
||||
|
||||
const NOTIFICATION_OFFSET = 200;
|
||||
|
||||
// Build the URL to load in the pym iframe.
|
||||
function buildStreamIframeUrl(talkBaseUrl, query) {
|
||||
let url = [
|
||||
talkBaseUrl,
|
||||
talkBaseUrl.match(/\/$/) ? '' : '/', // make sure no double-'/' if opts.talk already ends with '/'
|
||||
'embed/stream?',
|
||||
].join('');
|
||||
|
||||
url += queryString.stringify(query);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// Get dimensions of viewport.
|
||||
function viewportDimensions() {
|
||||
let e = window,
|
||||
a = 'inner';
|
||||
if (!('innerWidth' in window)) {
|
||||
a = 'client';
|
||||
e = document.documentElement || document.body;
|
||||
}
|
||||
|
||||
return {
|
||||
width: e[`${a}Width`],
|
||||
height: e[`${a}Height`],
|
||||
};
|
||||
}
|
||||
|
||||
export default class Stream {
|
||||
constructor(el, talkBaseUrl, query, config) {
|
||||
this.query = query;
|
||||
|
||||
// Extract the non-opts opts from the object.
|
||||
const {
|
||||
events = null,
|
||||
snackBarStyles = null,
|
||||
onAuthChanged = null,
|
||||
...opts
|
||||
} = config;
|
||||
|
||||
this.opts = opts;
|
||||
|
||||
this.emitter = new EventEmitter({ wildcard: true });
|
||||
this.pym = new pym.Parent(el.id, buildStreamIframeUrl(talkBaseUrl, query), {
|
||||
title: opts.title,
|
||||
id: `${el.id}_iframe`,
|
||||
name: `${el.id}_iframe`,
|
||||
});
|
||||
this.snackBar = new Snackbar(snackBarStyles || {});
|
||||
|
||||
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
|
||||
this.pym.el.firstChild.style.width = '1px';
|
||||
this.pym.el.firstChild.style.minWidth = '100%';
|
||||
|
||||
// Resize parent iframe height when child height changes
|
||||
let cachedHeight;
|
||||
this.pym.onMessage('height', height => {
|
||||
if (height !== cachedHeight) {
|
||||
this.pym.el.firstChild.style.height = `${height}px`;
|
||||
cachedHeight = height;
|
||||
}
|
||||
});
|
||||
|
||||
// Attach to the events emitted by the pym parent.
|
||||
if (events) {
|
||||
events(this.emitter);
|
||||
}
|
||||
|
||||
this.pym.onMessage('getConfig', () => {
|
||||
this.pym.sendMessage('config', JSON.stringify(opts));
|
||||
});
|
||||
|
||||
// If the auth changes, and someone is listening for it, then re-emit it.
|
||||
if (onAuthChanged) {
|
||||
this.pym.onMessage('coral-auth-changed', message => {
|
||||
onAuthChanged(message ? JSON.parse(message) : null);
|
||||
});
|
||||
}
|
||||
|
||||
// Attach the snackbar to the pym parent and to the body of the page.
|
||||
this.snackBar.attach(window.document.body, this.pym);
|
||||
|
||||
// Remove the permalink comment id from the hash.
|
||||
this.pym.onMessage('coral-view-all-comments', () => {
|
||||
const query = queryString.parse(location.search);
|
||||
|
||||
// Remove the commentId url param.
|
||||
delete query.commentId;
|
||||
|
||||
const search = queryString.stringify(query);
|
||||
|
||||
const url = buildUrl({ ...location, search });
|
||||
|
||||
// Change the url.
|
||||
window.history.replaceState({}, document.title, url);
|
||||
});
|
||||
|
||||
// Remove the permalink comment id from the hash.
|
||||
this.pym.onMessage('coral-view-comment', id => {
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
commentId: id,
|
||||
});
|
||||
|
||||
// Remove the commentId url param.
|
||||
const url = buildUrl({ ...location, search });
|
||||
|
||||
// Change the url.
|
||||
window.history.replaceState({}, document.title, url);
|
||||
});
|
||||
|
||||
// Helps child show notifications at the right scrollTop.
|
||||
this.pym.onMessage('getPosition', () => {
|
||||
const { height } = viewportDimensions();
|
||||
let position = height + document.body.scrollTop;
|
||||
|
||||
if (position > NOTIFICATION_OFFSET) {
|
||||
position = position - NOTIFICATION_OFFSET;
|
||||
}
|
||||
|
||||
this.pym.sendMessage('position', position);
|
||||
});
|
||||
|
||||
// When end-user clicks link in iframe, open it in parent context
|
||||
this.pym.onMessage('navigate', url => {
|
||||
window.open(url, '_blank').focus();
|
||||
});
|
||||
|
||||
// Pass events from iframe to the event emitter.
|
||||
this.pym.onMessage('event', raw => {
|
||||
const { eventName, value } = JSON.parse(raw);
|
||||
this.emitter.emit(eventName, value);
|
||||
});
|
||||
|
||||
// If the user clicks outside the embed, then tell the embed.
|
||||
document.addEventListener('click', this.handleClick.bind(this), true);
|
||||
|
||||
// Listens to local storage requests on pym and relay it to local storage.
|
||||
connectStorageToPym(
|
||||
createStorage('localStorage'),
|
||||
this.pym,
|
||||
'localStorage'
|
||||
);
|
||||
|
||||
// Listens to session storage requests on pym and relay it to session storage.
|
||||
connectStorageToPym(
|
||||
createStorage('sessionStorage'),
|
||||
this.pym,
|
||||
'sessionStorage'
|
||||
);
|
||||
}
|
||||
|
||||
enablePluginsDebug() {
|
||||
this.pym.sendMessage('enablePluginsDebug');
|
||||
}
|
||||
|
||||
disablePluginsDebug() {
|
||||
this.pym.sendMessage('disablePluginsDebug');
|
||||
}
|
||||
|
||||
login(token) {
|
||||
this.pym.sendMessage('login', token);
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.pym.sendMessage('logout');
|
||||
}
|
||||
|
||||
remove() {
|
||||
// Remove the event listeners.
|
||||
document.removeEventListener('click', this.handleClick.bind(this));
|
||||
this.emitter.removeAllListeners();
|
||||
|
||||
// Remove the snackbar.
|
||||
this.snackBar.remove();
|
||||
|
||||
// Remove the pym parent.
|
||||
this.pym.remove();
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
this.pym.sendMessage('click');
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
export default class StreamInterface {
|
||||
constructor(stream) {
|
||||
this._stream = stream;
|
||||
}
|
||||
|
||||
on(eventName, callback) {
|
||||
return this._stream.emitter.on(eventName, callback);
|
||||
}
|
||||
|
||||
off(eventName, callback) {
|
||||
return this._stream.emitter.off(eventName, callback);
|
||||
}
|
||||
|
||||
login(token) {
|
||||
return this._stream.login(token);
|
||||
}
|
||||
|
||||
logout() {
|
||||
return this._stream.logout();
|
||||
}
|
||||
|
||||
remove() {
|
||||
return this._stream.remove();
|
||||
}
|
||||
|
||||
enablePluginsDebug() {
|
||||
return this._stream.enablePluginsDebug();
|
||||
}
|
||||
|
||||
disablePluginsDebug() {
|
||||
return this._stream.disablePluginsDebug();
|
||||
}
|
||||
}
|
||||
@@ -1,106 +1,72 @@
|
||||
import URLSearchParams from 'url-search-params';
|
||||
import Stream from './Stream';
|
||||
import StreamInterface from './StreamInterface';
|
||||
// Polyfill IntersectionObserver always, the alternative is that we have to also
|
||||
// polyfill for Promise, which itself adds 1KB gziped, which means that the
|
||||
// 4KB that the intersection observer really doesn't take up that much in terms
|
||||
// of size.
|
||||
import 'intersection-observer';
|
||||
|
||||
// Rebuild the origin if it isn't defined. This is our poor-mans polyfill
|
||||
// for the location API's.
|
||||
if (!window.location.origin) {
|
||||
window.location.origin = `${window.location.protocol}//${
|
||||
window.location.hostname
|
||||
}${window.location.port ? `:${window.location.port}` : ''}`;
|
||||
}
|
||||
import Bridge from './Bridge';
|
||||
import wrapBridge from './wrapBridge';
|
||||
|
||||
// parses the Asset URL from the config variable
|
||||
function parseAssetURL() {
|
||||
try {
|
||||
// Try to get the url from the canonical tag on the page.
|
||||
return document.querySelector('link[rel="canonical"]').href;
|
||||
} catch (e) {
|
||||
window.console.warn(
|
||||
'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.'
|
||||
/**
|
||||
* validateElement will throw an error when the element is not configured
|
||||
* correctly or is not an HTMLElement at all.
|
||||
*
|
||||
* @param {HTMLElement} element the HTMLElement where the stream will be rendered
|
||||
*/
|
||||
function validateElement(element) {
|
||||
if (!element) {
|
||||
throw new Error(
|
||||
'Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'
|
||||
);
|
||||
}
|
||||
|
||||
return window.location.origin + window.location.pathname;
|
||||
if (typeof element !== 'object') {
|
||||
throw new Error(
|
||||
`Coral.Talk.render() expected HTMLElement but got ${element} (${typeof element})`
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure element has an id, as pym can't directly accept the HTMLElement.
|
||||
if (!element.id) {
|
||||
element.id = `_${Math.random()}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class Talk {
|
||||
/**
|
||||
* validateConfig is the configuration validation tool.
|
||||
*
|
||||
* @param {Object} config the configuration that will be used to setup Talk.
|
||||
*/
|
||||
function validateConfig(config) {
|
||||
if (!config || typeof config !== 'object' || !config.talk) {
|
||||
throw new Error(
|
||||
'Coral.Talk.render() expected configuration with at least opts.talk as the Talk Base URL, none found'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const Talk = {
|
||||
/**
|
||||
* Render a Talk stream
|
||||
* @param {HTMLElement} element - Element to render the stream in
|
||||
* @param {Object} config - Configuration options for talk
|
||||
* @param {String} config.talk - Talk base URL
|
||||
* @param {String} [config.title] - Title of Stream (rendered in iframe)
|
||||
* @param {String} [config.asset_url] - Asset URL
|
||||
* @param {String} [config.asset_id] - Asset ID
|
||||
* @param {String} config.talk - URL to the Talk installation
|
||||
* @param {String} [config.asset_id] - (optional) ID for the Asset
|
||||
* @param {String} [config.asset_url] - (optional) URL where the Asset is located
|
||||
* @param {String} [config.auth_token] - (optional) A jwt representing the session
|
||||
* @param {String} [config.lazy] - (optional) If set the stream will only render lazily
|
||||
* @param {String} [config.talkStaticUrl] - (optional) Static URL used to serve Talk
|
||||
* @param {Boolean} [config.amp] - (optional) Run Talk in AMP mode
|
||||
* @return {Object}
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* const embed = Talk.render(document.getElementById('talkStreamEmbed'), config);
|
||||
*
|
||||
* // trigger a login with optional token.
|
||||
* embed.login(token);
|
||||
*
|
||||
* // trigger a logout.
|
||||
* embed.logout();
|
||||
*
|
||||
* // listen to events (in this case all events).
|
||||
* embed.on('**', function(value) {
|
||||
* console.log(this.event, value);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
static render(element, config) {
|
||||
if (!element) {
|
||||
throw new Error(
|
||||
'Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'
|
||||
);
|
||||
}
|
||||
if (typeof element !== 'object') {
|
||||
throw new Error(
|
||||
`Coral.Talk.render() expected HTMLElement but got ${element} (${typeof element})`
|
||||
);
|
||||
}
|
||||
if (!config || typeof config !== 'object' || !config.talk) {
|
||||
throw new Error(
|
||||
'Coral.Talk.render() expected configuration with at least opts.talk as the Talk Base URL, none found'
|
||||
);
|
||||
}
|
||||
render: (element, config) => {
|
||||
// Validate the element.
|
||||
validateElement(element);
|
||||
|
||||
// Ensure el has an id, as pym can't directly accept the HTMLElement.
|
||||
if (!element.id) {
|
||||
element.id = `_${Math.random()}`;
|
||||
}
|
||||
// Validate the configuration.
|
||||
validateConfig(config);
|
||||
|
||||
// Compose the query to send down to the Talk API so it knows what to load.
|
||||
const query = {};
|
||||
|
||||
// Parse the url parameters to extract some of the information.
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
|
||||
// Pull the commentID out from the query params.
|
||||
const commentID = search.get('commentId') || search.get('commentID');
|
||||
if (commentID) {
|
||||
query.comment_id = commentID;
|
||||
}
|
||||
|
||||
// Extract the asset id from the options.
|
||||
if (config.asset_id) {
|
||||
query.asset_id = config.asset_id;
|
||||
}
|
||||
|
||||
// Parse the Asset URL.
|
||||
query.asset_url = config.asset_url;
|
||||
if (!query.asset_url) {
|
||||
query.asset_url = parseAssetURL();
|
||||
}
|
||||
|
||||
// Create the new Stream.
|
||||
const stream = new Stream(element, config.talk, query, config);
|
||||
|
||||
// Return the public interface for the stream.
|
||||
return new StreamInterface(stream);
|
||||
}
|
||||
}
|
||||
// Create the new Bridge, and wrap it up.
|
||||
return wrapBridge(new Bridge(element, config));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export default function onIntersect(el, callback) {
|
||||
// Ensure that the intersection observer is available.
|
||||
if (!IntersectionObserver) {
|
||||
// tslint:disable-next-line:no-console
|
||||
window.console.warn('IntersectionObserver not available, rendering now');
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the Intersection Observer that will wait till the embed is within
|
||||
// view and will then call the callback.
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting) {
|
||||
// Stop receiving intersection events.
|
||||
observer.disconnect();
|
||||
|
||||
// Fire the callback.
|
||||
callback();
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '100px',
|
||||
threshold: 1.0,
|
||||
}
|
||||
);
|
||||
|
||||
// Start observing the element for visibility.
|
||||
observer.observe(el);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export default bridge => {
|
||||
// whenRendered will call the callback when the stream has rendered (or now if
|
||||
// the stream is already rendered).
|
||||
const whenRendered = callback => bridge.queueWhenRendered(callback);
|
||||
|
||||
// onlyWhenRendered will guard the callback unless the stream is rendered.
|
||||
const onlyWhenRendered = callback => {
|
||||
bridge.ensureRendered();
|
||||
callback();
|
||||
};
|
||||
|
||||
// Return the limited stream interface.
|
||||
return {
|
||||
// Directly map the on/off from the event emitter to the stream.
|
||||
on: (eventName, callback) => bridge.emitter.on(eventName, callback),
|
||||
off: (eventName, callback) => bridge.emitter.off(eventName, callback),
|
||||
|
||||
// Queue up the login operation until the stream has been rendered.
|
||||
login: token => whenRendered(() => bridge.login(token)),
|
||||
|
||||
// Queue up the logout operation until the stream has been rendered.
|
||||
logout: () => whenRendered(() => bridge.logout()),
|
||||
|
||||
// Remove the stream if it's already been rendered.
|
||||
remove: () => onlyWhenRendered(() => bridge.remove()),
|
||||
|
||||
// Queue up the plugin config until the embed has rendered.
|
||||
enablePluginsDebug: () => whenRendered(() => bridge.enablePluginsDebug()),
|
||||
disablePluginsDebug: () => whenRendered(() => bridge.disablePluginsDebug()),
|
||||
};
|
||||
};
|
||||
@@ -1,10 +1,5 @@
|
||||
import * as actions from '../constants/auth';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
|
||||
function cleanAuthData(localStorage) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('exp');
|
||||
}
|
||||
import { setStorageAuthToken, clearStorageAuthToken } from '../services/auth';
|
||||
|
||||
export const checkLogin = () => (
|
||||
dispatch,
|
||||
@@ -15,7 +10,7 @@ export const checkLogin = () => (
|
||||
rest('/auth')
|
||||
.then(result => {
|
||||
if (!result.user) {
|
||||
cleanAuthData(localStorage);
|
||||
clearStorageAuthToken(localStorage);
|
||||
dispatch(checkLoginSuccess(null));
|
||||
client.resetWebsocket();
|
||||
return;
|
||||
@@ -23,12 +18,16 @@ export const checkLogin = () => (
|
||||
|
||||
dispatch(checkLoginSuccess(result.user));
|
||||
pym.sendMessage('coral-auth-changed', JSON.stringify(result.user));
|
||||
client.resetWebsocket();
|
||||
|
||||
// We don't need to reset the websocket here because if the request
|
||||
// returned that there was a user (which is the case here), then the
|
||||
// original request has already succeeded, or a previous call to a token
|
||||
// set handler has already reset it.
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.status && error.status === 401 && localStorage) {
|
||||
// Unauthorized.
|
||||
cleanAuthData(localStorage);
|
||||
clearStorageAuthToken(localStorage);
|
||||
client.resetWebsocket();
|
||||
} else {
|
||||
console.error(error);
|
||||
@@ -49,14 +48,20 @@ const checkLoginSuccess = user => ({
|
||||
user,
|
||||
});
|
||||
|
||||
export const setAuthToken = token => (dispatch, _, { localStorage }) => {
|
||||
localStorage.setItem('exp', jwtDecode(token).exp);
|
||||
localStorage.setItem('token', token);
|
||||
export const setAuthToken = token => (
|
||||
dispatch,
|
||||
_,
|
||||
{ localStorage, client }
|
||||
) => {
|
||||
setStorageAuthToken(localStorage, token);
|
||||
|
||||
// Dispatch the set auth token action. For some browsers and situations, we
|
||||
// may not be able to persist the auth token any other way. Keep it in redux!
|
||||
dispatch({ type: actions.SET_AUTH_TOKEN, token });
|
||||
|
||||
// Now that we set a token, let's reset the subscriptions.
|
||||
client.resetWebsocket();
|
||||
|
||||
dispatch(checkLogin());
|
||||
};
|
||||
|
||||
@@ -65,9 +70,7 @@ export const handleSuccessfulLogin = (user, token) => (
|
||||
_,
|
||||
{ client, localStorage, postMessage }
|
||||
) => {
|
||||
const { exp } = jwtDecode(token);
|
||||
localStorage.setItem('exp', exp);
|
||||
localStorage.setItem('token', token);
|
||||
setStorageAuthToken(localStorage, token);
|
||||
|
||||
// Send the message via the messages service to the window.opener if it
|
||||
// exists.
|
||||
@@ -79,6 +82,7 @@ export const handleSuccessfulLogin = (user, token) => (
|
||||
);
|
||||
}
|
||||
|
||||
// Now that we just set a token, set the token!
|
||||
client.resetWebsocket();
|
||||
|
||||
dispatch({
|
||||
@@ -105,7 +109,7 @@ export const logout = () => async (
|
||||
}
|
||||
|
||||
// Clear the auth data persisted to localStorage.
|
||||
cleanAuthData(localStorage);
|
||||
clearStorageAuthToken(localStorage);
|
||||
|
||||
// Reset the websocket.
|
||||
client.resetWebsocket();
|
||||
|
||||
@@ -1,50 +1,48 @@
|
||||
.card {
|
||||
margin-bottom: 20px;
|
||||
align-items: flex-start;
|
||||
min-height: 100px;
|
||||
max-width: 600px;
|
||||
min-height: 100px;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.collapsibleCard {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.enabledCard {
|
||||
border-left: 7px solid #00796b;
|
||||
}
|
||||
|
||||
.action {
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-top: 3px;
|
||||
margin-bottom: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
.body {
|
||||
margin-top: 7px;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
padding: 0px 30px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.enabledSetting {
|
||||
border-left-color: #00796b;
|
||||
border-left-style: solid;
|
||||
border-left-width: 7px;
|
||||
}
|
||||
|
||||
.disabledSetting {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.disabledSettingText {
|
||||
.disabledBody {
|
||||
color: #ccc;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +1,69 @@
|
||||
import React from 'react';
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './ConfigureCard.css';
|
||||
import classnames from 'classnames/bind';
|
||||
import { Card } from 'coral-ui';
|
||||
import { Checkbox } from 'react-mdl';
|
||||
import cn from 'classnames';
|
||||
import { Checkbox, IconButton } from 'react-mdl';
|
||||
|
||||
const ConfigureCard = ({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
onCheckbox,
|
||||
checked,
|
||||
...rest
|
||||
}) => (
|
||||
<Card
|
||||
{...rest}
|
||||
className={cn(styles.card, className, {
|
||||
[styles.enabledSetting]: checked === true,
|
||||
[styles.disabledSetting]: checked === false,
|
||||
})}
|
||||
>
|
||||
{checked !== undefined && (
|
||||
<div className={styles.action}>
|
||||
<Checkbox onChange={onCheckbox} checked={checked} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(styles.wrapper, {
|
||||
[styles.content]: checked !== undefined,
|
||||
})}
|
||||
>
|
||||
<div className={styles.header}>{title}</div>
|
||||
<div
|
||||
className={cn({
|
||||
[styles.disabledSettingText]: checked === false,
|
||||
import styles from './ConfigureCard.css';
|
||||
|
||||
const cn = classnames.bind(styles);
|
||||
|
||||
class ConfigureCard extends PureComponent {
|
||||
state = {
|
||||
isOpen: !this.props.collapsible,
|
||||
};
|
||||
|
||||
toggle = () => this.setState({ isOpen: !this.state.isOpen });
|
||||
|
||||
render() {
|
||||
const {
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
onCheckbox,
|
||||
checked,
|
||||
collapsible,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const { isOpen } = this.state;
|
||||
|
||||
const iconName = isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down';
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...rest}
|
||||
className={cn(styles.card, className, {
|
||||
enabledCard: checked === true,
|
||||
collapsibleCard: collapsible,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
{checked !== undefined && (
|
||||
<div className={styles.action}>
|
||||
<Checkbox onChange={onCheckbox} checked={checked} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
{collapsible && (
|
||||
<IconButton onClick={this.toggle} name={iconName} ripple />
|
||||
)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(styles.body, {
|
||||
disabledBody: checked === false,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ConfigureCard.propTypes = {
|
||||
title: PropTypes.string,
|
||||
@@ -48,6 +71,7 @@ ConfigureCard.propTypes = {
|
||||
onCheckbox: PropTypes.func,
|
||||
checked: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
collapsible: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ConfigureCard;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createDefaultResponseFragments } from '../utils';
|
||||
// fragments defined here are automatically registered.
|
||||
export default {
|
||||
...createDefaultResponseFragments(
|
||||
'AlwaysPremodUserResponse',
|
||||
'BanUsersResponse',
|
||||
'ChangeUsernameResponse',
|
||||
'CloseAssetResponse',
|
||||
@@ -14,6 +15,7 @@ export default {
|
||||
'IgnoreUserResponse',
|
||||
'ModifyTagResponse',
|
||||
'PostFlagResponse',
|
||||
'RemoveAlwaysPremodUserResponse',
|
||||
'SetCommentStatusResponse',
|
||||
'SetUsernameResponse',
|
||||
'SetUsernameStatusResponse',
|
||||
|
||||
@@ -412,6 +412,48 @@ export const withSetUsername = withMutation(
|
||||
}
|
||||
);
|
||||
|
||||
export const withAlwaysPremodUser = withMutation(
|
||||
gql`
|
||||
mutation AlwaysPremodUser($input: AlwaysPremodUserInput!) {
|
||||
alwaysPremodUser(input: $input) {
|
||||
...AlwaysPremodUserResponse
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
props: ({ mutate }) => ({
|
||||
alwaysPremodUser: input => {
|
||||
return mutate({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
export const withRemoveAlwaysPremodUser = withMutation(
|
||||
gql`
|
||||
mutation RemoveAlwaysPremodUser($input: RemoveAlwaysPremodUserInput!) {
|
||||
removeAlwaysPremodUser(input: $input) {
|
||||
...RemoveAlwaysPremodUserResponse
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
props: ({ mutate }) => ({
|
||||
removeAlwaysPremodUser: input => {
|
||||
return mutate({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
export const withBanUser = withMutation(
|
||||
gql`
|
||||
mutation BanUser($input: BanUserInput!) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import jwtDecode from 'jwt-decode';
|
||||
|
||||
export const setStorageAuthToken = (storage, token) => {
|
||||
storage.setItem('exp', jwtDecode(token).exp);
|
||||
storage.setItem('token', token);
|
||||
};
|
||||
|
||||
export const clearStorageAuthToken = storage => {
|
||||
storage.removeItem('token');
|
||||
storage.removeItem('exp');
|
||||
};
|
||||
+27
-3
@@ -7,7 +7,6 @@ import { createReduxEmitter } from './events';
|
||||
import { createRestClient } from './rest';
|
||||
import thunk from 'redux-thunk';
|
||||
import { loadTranslations } from './i18n';
|
||||
import bowser from 'bowser';
|
||||
import noop from 'lodash/noop';
|
||||
import { BASE_PATH } from 'coral-framework/constants/url';
|
||||
import { createPluginsService } from './plugins';
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
} from 'coral-framework/services/storage';
|
||||
import { createHistory } from 'coral-framework/services/history';
|
||||
import { createIntrospection } from 'coral-framework/services/introspection';
|
||||
import { setStorageAuthToken } from 'coral-framework/services/auth';
|
||||
import introspectionData from 'coral-framework/graphql/introspection.json';
|
||||
import coreReducers from '../reducers';
|
||||
import { checkLogin as checkLoginAction } from '../actions/auth';
|
||||
@@ -46,7 +46,25 @@ const getAuthToken = (store, storage) => {
|
||||
// capable of storing the token in localStorage, then we would have
|
||||
// persisted it to the redux state.
|
||||
return state.config.auth_token || state.auth.token;
|
||||
} else if (!bowser.safari && !bowser.ios && storage) {
|
||||
} else if (location.hash && location.hash.startsWith('#access_token=')) {
|
||||
// Check to see if the access token is living in the URL as a hash.
|
||||
const token = location.hash.substring(14);
|
||||
|
||||
history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname + window.location.search
|
||||
);
|
||||
|
||||
// Once we clear the hash above, this login method will not persist across
|
||||
// refreshes. We will need to persist the token to storage if it's
|
||||
// available.
|
||||
if (storage) {
|
||||
setStorageAuthToken(storage, token);
|
||||
}
|
||||
|
||||
return token;
|
||||
} else if (storage && storage.getItem('token')) {
|
||||
// Use local storage auth tokens where there's a stable api.
|
||||
return storage.getItem('token');
|
||||
}
|
||||
@@ -135,8 +153,13 @@ export async function createContext({
|
||||
token,
|
||||
});
|
||||
|
||||
const rest2 = createRestClient({
|
||||
uri: `${BASE_PATH}api/v2`,
|
||||
token,
|
||||
});
|
||||
|
||||
const staticConfig = getStaticConfiguration();
|
||||
let { LIVE_URI: liveUri, STATIC_ORIGIN: origin } = staticConfig;
|
||||
let { LIVE_URI: liveUri, BASE_ORIGIN: origin } = staticConfig;
|
||||
if (liveUri == null) {
|
||||
// The protocol must match the origin protocol, secure/insecure.
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
@@ -169,6 +192,7 @@ export async function createContext({
|
||||
plugins,
|
||||
eventEmitter,
|
||||
rest,
|
||||
rest2,
|
||||
graphql,
|
||||
notification,
|
||||
localStorage,
|
||||
|
||||
@@ -88,21 +88,23 @@ export function createClient(options = {}) {
|
||||
});
|
||||
|
||||
client.resetWebsocket = () => {
|
||||
// Close socket connection which will also unregister subscriptions on the server-side.
|
||||
wsClient.close();
|
||||
if (wsClient.client) {
|
||||
// Close socket connection which will also unregister subscriptions on the server-side.
|
||||
wsClient.close(true);
|
||||
|
||||
// Reconnect to the server.
|
||||
wsClient.connect();
|
||||
// Reconnect to the server.
|
||||
wsClient.connect();
|
||||
|
||||
// Reregister all subscriptions (uses non public api).
|
||||
// See: https://github.com/apollographql/subscriptions-transport-ws/issues/171
|
||||
Object.keys(wsClient.operations).forEach(id => {
|
||||
wsClient.sendMessage(
|
||||
id,
|
||||
MessageTypes.GQL_START,
|
||||
wsClient.operations[id].options
|
||||
);
|
||||
});
|
||||
// Re-register all subscriptions (uses non public api).
|
||||
// See: https://github.com/apollographql/subscriptions-transport-ws/issues/171
|
||||
Object.keys(wsClient.operations).forEach(id => {
|
||||
wsClient.sendMessage(
|
||||
id,
|
||||
MessageTypes.GQL_START,
|
||||
wsClient.operations[id].options
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return client;
|
||||
|
||||
@@ -12,6 +12,8 @@ import 'moment/locale/da';
|
||||
import 'moment/locale/de';
|
||||
import 'moment/locale/es';
|
||||
import 'moment/locale/fr';
|
||||
import 'moment/locale/he';
|
||||
import 'moment/locale/it';
|
||||
import 'moment/locale/nl';
|
||||
import 'moment/locale/pt-br';
|
||||
|
||||
@@ -22,6 +24,8 @@ import daTA from 'timeago.js/locales/da';
|
||||
import deTA from 'timeago.js/locales/de';
|
||||
import esTA from 'timeago.js/locales/es';
|
||||
import frTA from 'timeago.js/locales/fr';
|
||||
import heTA from 'timeago.js/locales/he';
|
||||
import itTA from 'timeago.js/locales/it';
|
||||
import nlTA from 'timeago.js/locales/nl';
|
||||
import pt_BRTA from 'timeago.js/locales/pt_BR';
|
||||
import zh_CNTA from 'timeago.js/locales/zh_CN';
|
||||
@@ -34,6 +38,8 @@ import da from '../../../locales/da.yml';
|
||||
import de from '../../../locales/de.yml';
|
||||
import es from '../../../locales/es.yml';
|
||||
import fr from '../../../locales/fr.yml';
|
||||
import he from '../../../locales/he.yml';
|
||||
import it from '../../../locales/it.yml';
|
||||
import nl_NL from '../../../locales/nl_NL.yml';
|
||||
import pt_BR from '../../../locales/pt_BR.yml';
|
||||
import zh_CN from '../../../locales/zh_CN.yml';
|
||||
@@ -61,6 +67,8 @@ export const translations = {
|
||||
...de,
|
||||
...es,
|
||||
...fr,
|
||||
...he,
|
||||
...it,
|
||||
...nl_NL,
|
||||
...pt_BR,
|
||||
...zh_CN,
|
||||
@@ -106,6 +114,8 @@ export function setupTranslations() {
|
||||
ta.register('da', daTA);
|
||||
ta.register('de', deTA);
|
||||
ta.register('fr', frTA);
|
||||
ta.register('he', heTA);
|
||||
ta.register('it', itTA);
|
||||
ta.register('nl_NL', nlTA);
|
||||
ta.register('pt_BR', pt_BRTA);
|
||||
ta.register('zh_CN', zh_CNTA);
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
export function createStore(reducers, middlewares = []) {
|
||||
const enhancers = [applyMiddleware(...middlewares)];
|
||||
|
||||
if (window.devToolsExtension) {
|
||||
if (window.__REDUX_DEVTOOLS_EXTENSION__) {
|
||||
// we can't have the last argument of compose() be undefined
|
||||
enhancers.push(window.devToolsExtension());
|
||||
enhancers.push(window.__REDUX_DEVTOOLS_EXTENSION__());
|
||||
}
|
||||
|
||||
return reduxCreateStore(combineReducers(reducers), {}, compose(...enhancers));
|
||||
|
||||
@@ -35,6 +35,15 @@ export const isBanned = user => {
|
||||
return get(user, 'state.status.banned.status');
|
||||
};
|
||||
|
||||
/**
|
||||
* isAlwaysPremod
|
||||
* retrieves boolean based on the user premod status
|
||||
*/
|
||||
|
||||
export const isAlwaysPremod = user => {
|
||||
return get(user, 'state.status.alwaysPremod.status');
|
||||
};
|
||||
|
||||
/**
|
||||
* isUsernameRejected
|
||||
* retrieves boolean based on the username status
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
line-height: 30px;
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
padding: 5px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -24,7 +25,14 @@
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.active {
|
||||
.page:hover {
|
||||
background-color: #ababab;
|
||||
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);
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
.active, .active:hover {
|
||||
background-color: #696969;
|
||||
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);
|
||||
a {
|
||||
|
||||
@@ -30,7 +30,7 @@ const CONFIG = {
|
||||
ENABLE_TRACING: Boolean(process.env.APOLLO_ENGINE_KEY),
|
||||
|
||||
// EMAIL_SUBJECT_PREFIX is the string before emails in the subject.
|
||||
EMAIL_SUBJECT_PREFIX: process.env.TALK_EMAIL_SUBJECT_PREFIX || '[Talk]',
|
||||
EMAIL_SUBJECT_PREFIX: process.env.TALK_EMAIL_SUBJECT_PREFIX,
|
||||
|
||||
// DEFAULT_LANG is the default language used for server sent emails and
|
||||
// rendered text.
|
||||
@@ -43,6 +43,32 @@ const CONFIG = {
|
||||
process.env.TALK_WHITELISTED_LANGUAGES &&
|
||||
process.env.TALK_WHITELISTED_LANGUAGES.split(',').map(l => l.trim()),
|
||||
|
||||
// USERNAME_CAST_REGEXP defiles the regex expression that will be used to
|
||||
// strip characters from a username during a username cast operation.
|
||||
USERNAME_CAST_REGEXP: new RegExp(
|
||||
process.env.USERNAME_CAST_REGEXP || '[^a-zA-Z_]',
|
||||
'g'
|
||||
),
|
||||
|
||||
// USERNAME_REPLACEMENT_CAST_REGEXP defiles the regex expression that will be
|
||||
// used to replace characters with the replacement character during a username
|
||||
// cast operation. First duplicates will be replaced, then
|
||||
USERNAME_REPLACEMENT_CAST_REGEXP: new RegExp(
|
||||
process.env.USERNAME_REPLACEMENT_CAST_REGEXP || ' +',
|
||||
'g'
|
||||
),
|
||||
|
||||
// USERNAME_REPLACEMENT_CHARACTER is the character used to replace other
|
||||
// characters matching the USERNAME_REPLACEMENT_CAST_REGEXP.
|
||||
USERNAME_REPLACEMENT_CHARACTER:
|
||||
process.env.USERNAME_REPLACEMENT_CHARACTER || '_',
|
||||
|
||||
// USERNAME_VALIDATION_REGEX defines the allowed characters for a username in
|
||||
// Talk.
|
||||
USERNAME_VALIDATION_REGEX: new RegExp(
|
||||
process.env.USERNAME_VALIDATION_REGEX || '^[A-Za-z0-9_]+$'
|
||||
),
|
||||
|
||||
// When TRUE, it ensures that database indexes created in core will not add
|
||||
// indexes.
|
||||
CREATE_MONGO_INDEXES: process.env.DISABLE_CREATE_MONGO_INDEXES !== 'TRUE',
|
||||
@@ -59,6 +85,9 @@ const CONFIG = {
|
||||
// as report CSP violations.
|
||||
ENABLE_STRICT_CSP: process.env.TALK_ENABLE_STRICT_CSP === 'TRUE',
|
||||
|
||||
// TRUST_PROXY allows control over the `trust proxy` configuration on express.
|
||||
TRUST_PROXY: process.env.TALK_TRUST_PROXY || '1',
|
||||
|
||||
// LOGGING_LEVEL specifies the logging level used by the bunyan logger.
|
||||
LOGGING_LEVEL: ['fatal', 'error', 'warn', 'info', 'debug', 'trace'].includes(
|
||||
process.env.TALK_LOGGING_LEVEL
|
||||
@@ -76,6 +105,10 @@ const CONFIG = {
|
||||
// on the scraper when it makes requests.
|
||||
SCRAPER_HEADERS: process.env.TALK_SCRAPER_HEADERS || '{}',
|
||||
|
||||
// HTTP_X_REQUEST_ID is a string which represents the request header where we
|
||||
// should source the request ID from, otherwise, a new one will be generated.
|
||||
HTTP_X_REQUEST_ID: process.env.TALK_HTTP_X_REQUEST_ID || null,
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// JWT based configuration
|
||||
//------------------------------------------------------------------------------
|
||||
@@ -213,6 +246,9 @@ const CONFIG = {
|
||||
// messages through the websocket to keep the socket alive.
|
||||
KEEP_ALIVE: process.env.TALK_KEEP_ALIVE || '30s',
|
||||
|
||||
// CONCURRENCY is the number of workers that will serve traffic.
|
||||
CONCURRENCY: parseInt(process.env.TALK_CONCURRENCY || '1'),
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Cache configuration
|
||||
//------------------------------------------------------------------------------
|
||||
@@ -220,6 +256,14 @@ const CONFIG = {
|
||||
CACHE_EXPIRY_COMMENT_COUNT:
|
||||
process.env.TALK_CACHE_EXPIRY_COMMENT_COUNT || '1hr',
|
||||
|
||||
// EMBED_EXPIRY_TIME is the time that the embed will be cacheable for, sent as
|
||||
// the max-age= directive on the Cache-Control header.
|
||||
EMBED_EXPIRY_TIME: ms(process.env.TALK_EMBED_EXPIRY || '24hr'),
|
||||
|
||||
// EMBED_EXPIRY_TIME is the time that the rest of the static files will be
|
||||
// cacheable for, sent as the max-age= directive on the Cache-Control header.
|
||||
STATIC_EXPIRY_TIME: ms(process.env.TALK_STATIC_EXPIRY || '1w'),
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Recaptcha configuration
|
||||
//------------------------------------------------------------------------------
|
||||
@@ -271,6 +315,10 @@ const CONFIG = {
|
||||
// CONFIG VALIDATION
|
||||
//==============================================================================
|
||||
|
||||
if (typeof CONFIG.EMAIL_SUBJECT_PREFIX === 'undefined') {
|
||||
CONFIG.EMAIL_SUBJECT_PREFIX = '[Talk]';
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
if (!CONFIG.ROOT_URL) {
|
||||
CONFIG.ROOT_URL = `http://${localAddress}:3001`;
|
||||
|
||||
+150
-95
@@ -6,7 +6,7 @@
|
||||
title: Talk Documentation
|
||||
subtitle:
|
||||
description: >
|
||||
Documentation and guides for Talk from The Coral Project.
|
||||
Documentation and guides for Coral by Vox Media
|
||||
google_analytics: UA-73335347-5
|
||||
author:
|
||||
language: en
|
||||
@@ -47,7 +47,7 @@ highlight:
|
||||
# per_page: Posts displayed per page. (0 = disable pagination)
|
||||
# order_by: Posts order. (Order by date descending by default)
|
||||
index_generator:
|
||||
path: ''
|
||||
path: ""
|
||||
|
||||
# Category & Tag
|
||||
default_category: uncategorized
|
||||
@@ -76,106 +76,161 @@ sidebar:
|
||||
- title: GitHub
|
||||
url: https://github.com/coralproject/
|
||||
- title: Docker
|
||||
url: https://hub.docker.com/r/coralproject/
|
||||
- title: Roadmap
|
||||
url: https://www.pivotaltracker.com/n/projects/1863625
|
||||
url: https://hub.docker.com/r/coralproject/talk/
|
||||
side:
|
||||
- title: Installation
|
||||
- title: Version 5+
|
||||
children:
|
||||
- title: Talk Quickstart
|
||||
url: /
|
||||
- title: Installation from Docker
|
||||
url: /installation-from-docker/
|
||||
- title: Installation from Source
|
||||
url: /installation-from-source/
|
||||
- title: Planning your Talk Architecture
|
||||
url: /planning-architecture/
|
||||
- title: Pre-Launch Checklist
|
||||
url: /pre-launch-checklist/
|
||||
- title: Configuration
|
||||
- title: Getting started
|
||||
children:
|
||||
- title: Installing
|
||||
url: /
|
||||
- title: Configuration
|
||||
url: /v5/configuration/
|
||||
- title: Admin settings
|
||||
url: /v5/administration/
|
||||
- title: Developing
|
||||
url: /v5/developing/
|
||||
- title: CLI Tools
|
||||
url: /v5/configuration-cli-tools/
|
||||
- title: Integration
|
||||
children:
|
||||
- title: CMS integration
|
||||
url: /v5/integrating/cms/
|
||||
- title: SSO
|
||||
url: /v5/integrating/sso/
|
||||
- title: Other auth options
|
||||
url: /v5/integration/auth/
|
||||
- title: Custom CSS styles
|
||||
url: /v5/css/
|
||||
- title: Emails & notifications
|
||||
url: /v5/notifications/
|
||||
- title: GDPR
|
||||
url: /v5/integrating/gdpr/
|
||||
- title: Comment count
|
||||
url: /v5/integrating/counts/
|
||||
- title: Moderating via Slack
|
||||
url: /v5/integrating/slack/
|
||||
- title: Event tracking
|
||||
url: /v5/integrating/events/
|
||||
- title: SEO & iframes
|
||||
url: /v5/seo/
|
||||
- title: API
|
||||
children:
|
||||
- title: GraphQL Overview
|
||||
url: /v5/api/overview/
|
||||
- title: Version 4
|
||||
children:
|
||||
- title: Required Configuration
|
||||
url: /configuration/
|
||||
- title: Advanced Configuration
|
||||
url: /advanced-configuration/
|
||||
- title: Integrating
|
||||
children:
|
||||
- title: Authentication
|
||||
url: /integrating/authentication/
|
||||
- title: Asset Management
|
||||
url: /integrating/asset-management/
|
||||
- title: Configuring the Comment Stream
|
||||
url: /integrating/configuring-comment-stream/
|
||||
- title: Configuring the Admin
|
||||
url: /integrating/configuring-admin/
|
||||
- title: Notifications
|
||||
url: /integrating/notifications/
|
||||
- title: Event Tracking and Metrics
|
||||
url: /integrating/event-tracking-metrics/
|
||||
- title: Styling and CSS
|
||||
url: /integrating/styling-css/
|
||||
- title: Translations and i18n
|
||||
url: /integrating/translations-i18n/
|
||||
- title: GDPR Compliance
|
||||
url: /integrating/gdpr/
|
||||
- title: Product Guide
|
||||
children:
|
||||
- title: How Talk Works
|
||||
url: /how-talk-works/
|
||||
- title: Commenter Features
|
||||
url: /commenter-features/
|
||||
- title: Moderator Features
|
||||
url: /moderator-features/
|
||||
- title: User Roles in Talk
|
||||
url: /roles/
|
||||
- title: Trust
|
||||
url: /trust/
|
||||
- title: Toxic Comments
|
||||
url: /toxic-comments/
|
||||
- title: Configuring Talk
|
||||
url: /configuring-talk/
|
||||
- title: Plugins
|
||||
children:
|
||||
- title: Plugins Overview
|
||||
url: /plugins/
|
||||
- title: Plugin Directory
|
||||
url: /plugins-directory/
|
||||
- title: Plugin Recipes
|
||||
url: /plugin-recipes/
|
||||
- title: API
|
||||
children:
|
||||
- title: GraphQL Overview
|
||||
url: /api/overview/
|
||||
- title: GraphQL Reference
|
||||
url: /api/graphql/
|
||||
- title: Server Plugin API
|
||||
url: /api/server/
|
||||
- title: Client Plugin API
|
||||
url: /api/client/
|
||||
- title: Plugin Slots API
|
||||
url: /api/slots/
|
||||
- title: Tutorials
|
||||
children:
|
||||
- title: Creating a Basic Plugin
|
||||
url: /building-basic-plugin/
|
||||
- title: Customizing Plugins with Coral UI
|
||||
url: /customizing-plugins-coral-ui/
|
||||
- title: When You've Installed Talk
|
||||
url: /when-youve-installed-talk/
|
||||
- title: Installation
|
||||
children:
|
||||
- title: Talk Quickstart
|
||||
url: /installation-quickstart
|
||||
- title: Installation from Docker
|
||||
url: /installation-from-docker/
|
||||
- title: Installation from Source
|
||||
url: /installation-from-source/
|
||||
- title: Planning your Talk Architecture
|
||||
url: /planning-architecture/
|
||||
- title: Pre-Launch Checklist
|
||||
url: /pre-launch-checklist/
|
||||
- title: Configuration
|
||||
children:
|
||||
- title: Required Configuration
|
||||
url: /configuration/
|
||||
- title: Advanced Configuration
|
||||
url: /advanced-configuration/
|
||||
- title: Configuration with CLI tools
|
||||
url: /configuration-cli-tools/
|
||||
- title: Integrating
|
||||
children:
|
||||
- title: Authentication
|
||||
url: /integrating/authentication/
|
||||
- title: CMS Integration
|
||||
url: /integrating/cms-integration/
|
||||
- title: Asset Scraping
|
||||
url: /integrating/asset-scraping/
|
||||
- title: Configuring the Comment Stream
|
||||
url: /integrating/configuring-comment-stream/
|
||||
- title: Configuring the Admin
|
||||
url: /integrating/configuring-admin/
|
||||
- title: Notifications
|
||||
url: /integrating/notifications/
|
||||
- title: Event Tracking and Metrics
|
||||
url: /integrating/event-tracking-metrics/
|
||||
- title: Styling and CSS
|
||||
url: /integrating/styling-css/
|
||||
- title: Translations and i18n
|
||||
url: /integrating/translations-i18n/
|
||||
- title: GDPR Compliance
|
||||
url: /integrating/gdpr/
|
||||
- title: Accelerated Mobile Page
|
||||
url: /integrating/amp/
|
||||
- title: Product Guide
|
||||
children:
|
||||
- title: How Talk Works
|
||||
url: /how-talk-works/
|
||||
- title: Commenter Features
|
||||
url: /commenter-features/
|
||||
- title: Moderator Features
|
||||
url: /moderator-features/
|
||||
- title: User Roles in Talk
|
||||
url: /roles/
|
||||
- title: Trust
|
||||
url: /trust/
|
||||
- title: Toxic Comments
|
||||
url: /toxic-comments/
|
||||
- title: Configuring Talk
|
||||
url: /configuring-talk/
|
||||
- title: Plugins
|
||||
children:
|
||||
- title: Plugins Overview
|
||||
url: /plugins/
|
||||
- title: Plugin Directory
|
||||
url: /plugins-directory/
|
||||
- title: Plugin Recipes
|
||||
url: /plugin-recipes/
|
||||
- title: API
|
||||
children:
|
||||
- title: GraphQL Overview
|
||||
url: /api/overview/
|
||||
- title: GraphQL Reference
|
||||
url: /api/graphql/
|
||||
- title: Server Plugin API
|
||||
url: /api/server/
|
||||
- title: Client Plugin API
|
||||
url: /api/client/
|
||||
- title: Plugin Slots API
|
||||
url: /api/slots/
|
||||
- title: Tutorials
|
||||
children:
|
||||
- title: Creating a Basic Plugin
|
||||
url: /building-basic-plugin/
|
||||
- title: Customizing Plugins with Coral UI
|
||||
url: /customizing-plugins-coral-ui/
|
||||
- title: When You've Installed Talk
|
||||
url: /when-youve-installed-talk/
|
||||
- title: Embedding Talk in Native Mobile Apps
|
||||
url: /embedding-in-native-mobile-apps/
|
||||
- title: Migrating
|
||||
children:
|
||||
- title: Migrating from v3.x.x
|
||||
url: /migration/3/
|
||||
- title: Migrating to v4.0.0
|
||||
url: /migration/4/
|
||||
- title: Migrating to v4.1.0
|
||||
url: /migration/4.1/
|
||||
- title:
|
||||
children:
|
||||
- title: Migrating from v3.x.x
|
||||
url: /migration/3/
|
||||
- title: Migrating to v4.0.0
|
||||
url: /migration/4/
|
||||
- title: Migrating to v4.1.0
|
||||
url: /migration/4.1/
|
||||
- title: Migrating to v5.0.0
|
||||
url: /migration/5.0/
|
||||
- title: Migrating to v6.0.0
|
||||
url: /migration/6.0/
|
||||
- title: FAQ & Troubleshooting
|
||||
children:
|
||||
- title: FAQ
|
||||
url: /faq/
|
||||
- title: Troubleshooting Tips
|
||||
url: /troubleshooting-tips/
|
||||
- children:
|
||||
- title: FAQ
|
||||
url: /faq/
|
||||
- title: Troubleshooting Tips
|
||||
url: /troubleshooting-tips/
|
||||
- title: Contact
|
||||
url: /contact/
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
/ /talk/
|
||||
/talk/reference/server/ /talk/api/server/
|
||||
/talk/reference/graphql/ /talk/api/graphql/
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: Installing Coral
|
||||
permalink: /
|
||||
---
|
||||
|
||||
Online comments are broken. Our open-source commenting platform, Coral, reimagines
|
||||
moderation, comment display, and conversation. Use Coral to add smarter, safer discussions to your site without giving away your data.
|
||||
|
||||
More than 130 newsrooms in 16 countries trust Coral to power their on-site communities, including The Washington Post, The Wall Street Journal, and Der Spiegel. [Read more about Coral here](https://coralproject.net/).
|
||||
|
||||
<div class="callout">
|
||||
We offer hosting and support packages for Coral, as well as exclusive, customer-only features. [Contact us for more information.](https://coralproject.net/pricing/)
|
||||
</div>
|
||||
|
||||
Built with ❤️ by Coral by [Vox Media](https://product.voxmedia.com/).
|
||||
|
||||
Try a test version of Coral by running it via a Heroku App:
|
||||
|
||||
[](https://heroku.com/deploy?template=https://github.com/coralproject/talk)
|
||||
|
||||
## Requirements
|
||||
|
||||
- MongoDB >=4.2
|
||||
- Redis >=3.2
|
||||
- NodeJS >=12
|
||||
- NPM >=6.7
|
||||
|
||||
## Running
|
||||
|
||||
You can install Coral using Docker or via Source. We recommend Docker, as it
|
||||
provides the easiest deployment solution going forward, as all the dependencies
|
||||
are baked and shipped with the provided
|
||||
[coralproject/talk:6](https://hub.docker.com/r/coralproject/talk) image.
|
||||
|
||||
### Docker
|
||||
|
||||
The easiest way to get started with Coral is through our published Docker image
|
||||
and provided example `docker-compose.yml` file. The following assumes that you
|
||||
have Docker and Docker Compose installed on your local machine:
|
||||
|
||||
- Install Docker: https://docs.docker.com/install/
|
||||
- Install Docker Compose: https://docs.docker.com/compose/install/ (this is typically included in the Docker Desktop editions already)
|
||||
|
||||
```bash
|
||||
# Create directories to persist the data in MongoDB and Redis.
|
||||
mkdir -p data/{mongo,redis}
|
||||
|
||||
# Create the `docker-compose.yml` file to get started.
|
||||
cat > docker-compose.yml <<EOF
|
||||
version: "2"
|
||||
services:
|
||||
talk:
|
||||
image: coralproject/talk:6
|
||||
restart: always
|
||||
ports:
|
||||
- "127.0.0.1:3000:5000"
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
environment:
|
||||
- MONGODB_URI=mongodb://mongo:27017/coral
|
||||
- REDIS_URI=redis://redis:6379
|
||||
- SIGNING_SECRET=<replace me with something secret>
|
||||
mongo:
|
||||
image: mongo:4.2
|
||||
volumes:
|
||||
- ./data/mongo:/data/db
|
||||
redis:
|
||||
image: redis:3.2
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
EOF
|
||||
|
||||
# Start up Coral using Docker.
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Then head on over to http://localhost:3000 to install Coral!
|
||||
|
||||
### Source
|
||||
|
||||
Coral requires NodeJS >=12, we recommend using `nvm` to help manage node
|
||||
versions: https://github.com/creationix/nvm.
|
||||
|
||||
```bash
|
||||
# Clone and cd into the Coral directory.
|
||||
git clone https://github.com/coralproject/talk.git
|
||||
cd talk
|
||||
|
||||
# Install dependencies.
|
||||
npm install
|
||||
|
||||
# Build the application dependencies.
|
||||
# This might take a while.
|
||||
npm run build
|
||||
```
|
||||
|
||||
This should output all the compiled application code to `./dist`.
|
||||
|
||||
Running Coral with default settings assumes that you have:
|
||||
|
||||
- MongoDB >=4.2 running on `127.0.0.1:27017`
|
||||
- Redis >=3.2 running on `127.0.0.1:6379`
|
||||
|
||||
If you don't already have these databases running, you can execute the following
|
||||
assuming you have Docker installed on your local machine:
|
||||
|
||||
```bash
|
||||
docker run -d -p 27017:27017 --restart always --name mongo mongo:4.2
|
||||
docker run -d -p 6379:6379 --restart always --name redis redis:3.2
|
||||
```
|
||||
|
||||
Then start Coral with:
|
||||
|
||||
```bash
|
||||
# Start the server in development mode.
|
||||
npm run start:development
|
||||
```
|
||||
|
||||
Then head on over to http://localhost:3000 to install Coral!
|
||||
|
||||
Note that if you want to run Coral in production from source, you'll need to
|
||||
create a secret for signing that differs from the unsafe default. You can do
|
||||
this by using something like `openssl rand -base64 32`. You can then place this
|
||||
secret in a `.env` file in your `talk` directory as such:
|
||||
|
||||
```bash
|
||||
SIGNING_SECRET=<replace me with something secret>
|
||||
```
|
||||
|
||||
You can then run Coral with the production command instead:
|
||||
|
||||
```bash
|
||||
# Start the server in production mode.
|
||||
npm run start
|
||||
```
|
||||
@@ -0,0 +1,193 @@
|
||||
---
|
||||
title: Configuring Version 5+
|
||||
permalink: /v5/configuration/
|
||||
toc: true
|
||||
---
|
||||
|
||||
The following environment variables can be set to configure the Coral Server. You
|
||||
can expose them in your shell via `export NODE_ENV=development` or by placing
|
||||
the variables in a `.env` file in the root of the project in a simple
|
||||
`NODE_ENV=development` format delimited by newlines.
|
||||
|
||||
## Required Configuration Variables
|
||||
|
||||
### `PORT`
|
||||
|
||||
The port to listen for HTTP and Websocket requests. (Default `3000`)
|
||||
|
||||
### `MONGODB_URI`
|
||||
|
||||
The MongoDB database URI to connect to. (Default `mongodb://127.0.0.1:27017/coral`)
|
||||
|
||||
### `REDIS_URI`
|
||||
|
||||
The Redis database URI to connect to. (Default `redis://127.0.0.1:6379`)
|
||||
|
||||
### `SIGNING_SECRET`
|
||||
|
||||
The shared secret to use to sign JSON Web Tokens (JWT) with the selected signing
|
||||
algorithm. (Default: `keyboard cat`)
|
||||
|
||||
Note: While there is a default for this so development can be simplified, Coral
|
||||
will throw a runtime error in the event it's started with `NODE_ENV=production`
|
||||
and the `SIGNING_SECRET="keyboard cat"` to prevent insecure installations. This
|
||||
_must_ be set in production to something long and secure. You can use `openssl`
|
||||
to help with that:
|
||||
|
||||
```sh
|
||||
openssl rand -base64 45
|
||||
```
|
||||
|
||||
## Advanced Configuration Variables
|
||||
|
||||
### `NODE_ENV`
|
||||
|
||||
Can be one of `production` or `development`. All production deployments should
|
||||
use `production`. Defaults to `production` when ran with `npm run start`, or
|
||||
with Docker deployments. Defaults to `development` when run with
|
||||
`npm run start:development`.
|
||||
|
||||
### `REDIS_OPTIONS`
|
||||
|
||||
A JSON string with optional configuration options to be used when connecting to
|
||||
Redis as specified in the [ioredis](https://github.com/luin/ioredis/blob/1dac50a63753c2afc969315cfe38faf0edc50bc5/API.md#new_Redis_new) documentation. (Default: `{}`)
|
||||
|
||||
### `SIGNING_ALGORITHM`
|
||||
|
||||
The signing algorithm to use for signing tokens. (Default `HS256`).
|
||||
|
||||
Supported algorithms are:
|
||||
|
||||
- `HS256`
|
||||
- `HS384`
|
||||
- `HS512`
|
||||
- `RS256`
|
||||
- `RS384`
|
||||
- `RS512`
|
||||
- `ES256`
|
||||
- `ES384`
|
||||
- `ES512`
|
||||
|
||||
### `LOCALE`
|
||||
|
||||
Specify the default locale to use for all requests without a locale specified. (Default `en-US`)
|
||||
|
||||
### `LOGGING_LEVEL`
|
||||
|
||||
The logging level that can be set to one of `fatal`,
|
||||
`error`, `warn`, `info`, `debug`, or `trace`. (Default `info`)
|
||||
|
||||
### `FORCE_SSL`
|
||||
|
||||
Forces SSL in production by redirecting all HTTP requests to HTTPS, and sending
|
||||
[HSTS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security) headers. (Default `false`)
|
||||
|
||||
Coral does not provide or manage HTTPS certificates. If you want to enable
|
||||
HTTPS, you must configure a proxy in front of Coral such as
|
||||
[Caddy](https://caddyserver.com/).
|
||||
|
||||
**Troubleshooting**: If you are seeing redirect loops when trying to access
|
||||
pages like the admin, you may need to configure [`TRUST_PROXY`](#trust-proxy) to
|
||||
tell Coral which upstream proxies to trust.
|
||||
|
||||
**Warning**: When `FORCE_SSL=true`, Coral will send [HSTS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security)
|
||||
headers that will force web browsers to connect via HTTPS for the next 60 days.
|
||||
By forcing SSL use you'll need to provide a secure connection to your Coral
|
||||
instance for at least the next 60 days.
|
||||
|
||||
### `DISABLE_LIVE_UPDATES`
|
||||
|
||||
When `true`, the comment stream will not create a websocket connection to get
|
||||
live comment updates. This applies across all tenants on the installation, and
|
||||
cannot be turned back on via the interface. (Default `false`)
|
||||
|
||||
### `DISABLE_LIVE_UPDATES_TIMEOUT`
|
||||
|
||||
Stories that have not received a comment within this time frame will pause live
|
||||
updates automatically. Once a single comment is received on these stories, live
|
||||
updates will be re-enabled until the story sits idle for the timeout value,
|
||||
parsed by [ms](https://www.npmjs.com/package/ms). (Default `2 weeks`)
|
||||
|
||||
### `DISABLE_TENANT_CACHING`
|
||||
|
||||
When `true`, all tenants will be loaded from the database when needed rather than keeping a in-memory copy in sync via published events on Redis. (Default `false`)
|
||||
|
||||
### `ENABLE_GRAPHIQL`
|
||||
|
||||
When `true`, it will enable the interactive GraphQL developer environment at the
|
||||
`/graphiql` route. This will also disable persisted (Default `false`)
|
||||
|
||||
Note: We do not recommend using this in production environments as it disables
|
||||
many safety features used by the application to provide it.
|
||||
|
||||
### `METRICS_USERNAME`
|
||||
|
||||
The username for _Basic Authentication_ at the `/metrics` route. If not
|
||||
provided with `METRICS_PASSWORD`, no authentication will be added to this route.
|
||||
|
||||
### `METRICS_PASSWORD`
|
||||
|
||||
The password for _Basic Authentication_ at the `/metrics` route. If not
|
||||
provided with `METRICS_USERNAME`, no authentication will be added to this route.
|
||||
|
||||
### `METRICS_PORT`
|
||||
|
||||
[Prometheus](https://prometheus.io/docs/introduction/overview/) metrics are
|
||||
provided at this port under `/metrics` route. (Default `9000`)
|
||||
|
||||
### `SCRAPE_TIMEOUT`
|
||||
|
||||
The request timeout for scraping operations, parsed by [ms](https://www.npmjs.com/package/ms).
|
||||
(Default `10 seconds`)
|
||||
|
||||
### `SCRAPE_MAX_RESPONSE_SIZE`
|
||||
|
||||
The maximum size (in bytes) to allow for scraping responses. (Default `10e6`)
|
||||
|
||||
### `STATIC_URI`
|
||||
|
||||
The URI that static assets can be accessed from. This URI can be to a proxy that
|
||||
uses this Coral server on `PORT` as the upstream. Disabled by default.
|
||||
|
||||
### `TRUST_PROXY`
|
||||
|
||||
When provided, it configures the "trust proxy" settings for Express. If you are
|
||||
encountering issues where urls in the administration are showing with a `http`
|
||||
instead of `https`, you may need to set the `TRUST_PROXY` setting. Refer to
|
||||
https://expressjs.com/en/guide/behind-proxies.html for possible values of this
|
||||
configuration variable as it pertains to your setup.
|
||||
|
||||
### `WEBSOCKET_KEEP_ALIVE_TIMEOUT`
|
||||
|
||||
The interval that should be used to send keep alive messages over websocket to
|
||||
keep the socket open, parsed by [ms](https://www.npmjs.com/package/ms).
|
||||
(Default `30s`)
|
||||
|
||||
### `WORD_LIST_TIMEOUT`
|
||||
|
||||
The length of time that a given request to test a comment against a given word
|
||||
list, parsed by [ms](https://www.npmjs.com/package/ms). (Default `100ms`)
|
||||
|
||||
### `PERSPECTIVE_TIMEOUT`
|
||||
|
||||
The length of time that a given request should wait for a response when
|
||||
interacting with the Perspective API, parsed by [ms](https://www.npmjs.com/package/ms).
|
||||
(Default `800ms`)
|
||||
|
||||
## Development Configuration Variables
|
||||
|
||||
The following configuration variables are only enabled when the server has been
|
||||
started in development mode (where `NODE_ENV=development`).
|
||||
|
||||
### `DEV_PORT`
|
||||
|
||||
The port where the Webpack Development server is running on. (Default `8080`)
|
||||
|
||||
### `DISABLE_CLIENT_ROUTES`
|
||||
|
||||
Disables mounting of client routes for developing with Webpack Dev Server.
|
||||
(Default `false`)
|
||||
|
||||
### `DISABLE_RATE_LIMITERS`
|
||||
|
||||
Used to disable the rate limiters used in Coral. (Default `false`)
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
title: Admin Settings
|
||||
permalink: /v5/administration/
|
||||
---
|
||||
|
||||
Upon completing the installation setup wizard, you will be able to login to Coral with the ADMIN user you created during setup.
|
||||
|
||||
## Moderation queues
|
||||
All moderation queues are sorted by Most Recent at the top.
|
||||
|
||||
### Reported
|
||||
This is the most urgent queue – these are comments that are still on the page, might be a problem, and you haven't made a moderation decision about them yet. These comments have either been Reported by a user, or reported by the system because they contain suspect words or phrases.
|
||||
|
||||
### Pending
|
||||
These are comments that have been held back by the system for some reason. These are the reasons that comments are held back in Pending:
|
||||
|
||||
* The article or site is set to pre-moderate all comments (except for Staff/Moderator/Admin/Expert comments)
|
||||
* The commenter has been manually set to 'Always Pre-moderate' by a moderator
|
||||
* The comment has been ruled as 'Likely to be toxic' by the AI (optional)
|
||||
* The commenter's recent behavior has included a higher percentage of rejected comment than the threshhold you set in Configure ('Recent History') (optional)
|
||||
* The commenter has entered an identical comment twice in a row, anywhere on the site
|
||||
* The site is set to hold back for review the first comments by new commenters (optional)
|
||||
* The comment is marked as likely to spam by our spam detection system (optional)
|
||||
|
||||
Users who login with the ADMIN role are able to access the Configure tab, and from here change settings that control the functionality of your Coral instance.
|
||||
|
||||
### Unmoderated
|
||||
These are comments of any kind - Reported, Published, Pending - that a moderator hasn't yet accepted or rejected.
|
||||
|
||||
### Approved
|
||||
These are comments that a moderator has already approved. The name of the moderator is included on the card.
|
||||
|
||||
### Rejected
|
||||
These are comments that a moderator has already rejected. The name of the moderator is included on the card.
|
||||
|
||||
There is also a clock icon in the top right of the page. When you click the icon, you will see a list of the comments that you have recently moderated, with links to review your decision.
|
||||
|
||||
## Configure Menu Settings
|
||||
|
||||
### Language
|
||||
You can set the default language Coral uses in **Admin** > **Configure** > **General**.
|
||||
|
||||
You can see what languages Coral currently supports here: https://github.com/coralproject/talk/tree/master/src/locales
|
||||
|
||||
### Comment Stream Settings
|
||||
|
||||
Also located in **Admin** > **Configure** > **General** are the setting to control the comment stream. These settings are site-wide and will affect all of your comment streams.
|
||||
|
||||
#### **Community guidelines summary**
|
||||
This will appear above the comments sitewide. You can format the text using Markdown.
|
||||
|
||||
#### **Sitewide closed comments message**
|
||||
A message that will be displayed when comment streams are closed sitewide.
|
||||
|
||||
#### **Comment length**
|
||||
Set minimum and maximum comment length requirements. Blank spaces at the beginning and the end of a comment will be trimmed. Some newsrooms we’ve worked with prefer a limit between 2000 and 5000
|
||||
characters. Commenters will be alerted that they have gone over that number and won’t be able to submit their comment until they’ve edited it. This can be a useful tool to ensure commenters are concise with their comments.
|
||||
|
||||
#### **Comment editing**
|
||||
Set a limit on how long commenters have to edit their comments sitewide. Edited comments are marked as (Edited) on the comment stream and the moderation panel.
|
||||
|
||||
#### **Closing comment streams**
|
||||
Set comment streams to close after a defined period of time after a story’s publication. When this feature is enabled, existing stories older than the defined period that have not been manually opened or closed will also be updated and set as closed. Manually opening or closing a story will permantenly override the auto-close functionality.
|
||||
|
||||
#### **Closed comment stream message**
|
||||
A message to appear when a story is closed for commenting.
|
||||
|
||||
### Reactions
|
||||
Coral comes with a `Respect` button out of the box. Why a “Respect” button, you
|
||||
ask?
|
||||
[Read more here](https://mediaengagement.org/research/engagement-buttons/).
|
||||
|
||||
You can also create your own custom reaction
|
||||
buttons by modifying the Reaction label.
|
||||
|
||||
### Badges & Staff Member Badge
|
||||
|
||||
Badges differentiate users on the comment stream. By default, the `Staff` user badge displays when a commenter has an Admin, Moderator, or Staff role.
|
||||
|
||||
You can customize the badge label used for Staff roles by providing your own custom badge text. *i.e.: "NewsSite Team"*
|
||||
|
||||
Custom user badges are also available with SSO integrations. See [Single Sign On](/talk/v5/integrating/sso/)
|
||||
|
||||
|
||||
## Moderation
|
||||
These options can be found under **Admin** > **Configure** > **Moderation**.
|
||||
|
||||
### Pre-moderation
|
||||
When pre-moderation is turned on, comments will not be published unless approved by a moderator.
|
||||
|
||||
### Recent History
|
||||
Prevents repeat offenders from publishing comments without approval. When a commenter's rejection rate is above the threshold, their comments are sent to the Pending queue for moderation. This does not apply to Staff comments.
|
||||
|
||||
Rejection rate time period determines the look back period of commenter activity that will be considered in performing the calculation.
|
||||
|
||||
Rejection rate threshold is calculated by dividing
|
||||
number of Rejected comments by (rejected comments + published comments) over the time period specified, and is displayed as a percentage. It does not include comments pending for toxicity, spam or pre-moderation, only moderated comments are considred.
|
||||
|
||||
### Toxic Comment Filter
|
||||
|
||||
Using Google's Perspective API, the Toxic Comment Filter warns users when comments exceed the predefined toxicity threshold. You can read more about Google’s Perspective API and/or request an API key here: http://perspectiveapi.com/.
|
||||
|
||||
Comments with a toxicity score above the threshold will not be published and are placed in the Pending Queue for review by a moderator. Only if approved by a moderator, then the comment will be published.
|
||||
|
||||
If a comment exceeds the threshold, the commenter is warned that their comment may be toxic, and are given the chance to modify their comment before posting. If the revised comment is below the Toxicity Threshold, it is posted and displayed normally; however if the revised comment still exceeds the Toxicity Threshold, it is not displayed on the stream and instead is sent to the Pending queue for moderation.
|
||||
|
||||
Perspective API uses machine learning based on existing databases of accepted/rejected comments to guess the probability that a comment is abusive and/or toxic. English is the default language, but experimental models are available in multiple languages. Sites can also work with Jigsaw to create an individualized data set specifically trained on their own comment history. Read more about Coral’s take on toxicity [on our blog](https://coralproject.net/blog/toxic-avenging/).
|
||||
|
||||
### Spam Detection Filter
|
||||
|
||||
Enables spam detection from [Akismet](https://akismet.com/). Comments will be passed to the Akismet API for spam detection. If a comment is determined to be spam, it will prompt the user, indicating that the comment might be considered spam. If the user continues after this point with the still spam-like comment, the comment will be reported as containing spam, and sent for moderator approval.
|
||||
|
||||
*Note: Akismet is a premium service, charges may apply.*
|
||||
|
||||
## Banned & Suspect Words and Phrases
|
||||
Located in **Admin** > **Configure** > **Banned words and phrases**.
|
||||
|
||||
**Banned words** are a list of words or phrases that will trigger a comment to be automatically **Rejected**.
|
||||
|
||||
Comments containing a word or phrase in the **Suspect Words** List are placed into the **Reported** queue for moderator review and are published (if comments are not pre-moderated).
|
||||
|
||||
Lists of words/phrases are not case sensitive, and are separated by new lines.
|
||||
|
||||
|
||||
## Email SMTP Settings
|
||||
These settings can be found under **Admin** > **Configure** > **Email**.
|
||||
|
||||
You can configure a **From Name** as well as a **From Email Address** to appear on all outgoing emails.
|
||||
|
||||
Coral will use your SMTP provider for all outgoing mail including user invites, email verifications, password resets, and notifications. Specify your SMTP provider's settings for Host, Port, and authentication to send emails from Coral.
|
||||
|
||||
The `Invite` (users) button on the Community tab will only be available if Email settings have been configured.
|
||||
|
||||
## Advanced
|
||||
Located in **Admin** > **Configure** > **Advanced**.
|
||||
|
||||
### Embed Code
|
||||
This is the unique script that is used to embed Coral comment streams on your website. For more information about using your Embed code see [CMS Integration](/talk/v5/integrating/cms/).
|
||||
|
||||
### Custom CSS
|
||||
The link to your custom stylesheet. This will override any default styles, so you can style the comment embed to match your site.
|
||||
|
||||
### Live Updates
|
||||
When enabled, there will be real-time loading and updating of comments via subscriptions
|
||||
(specifically GraphQL Subscriptions). When disabled, users will have to refresh the page to see new comments.
|
||||
|
||||
Coral enables this via “Load More” buttons for both top-level comments (this
|
||||
button appears at the top of the stream), and within conversation threads (this
|
||||
button appears in situ for replies).
|
||||
|
||||
We’ve decided to go this route in order to make the viewing experience as smooth
|
||||
as possible, so that the feed of comments doesn’t change as you’re reading just
|
||||
because new comments are coming in. This could be especially disruptive on
|
||||
breaking news and/or controversial stories with very active discussions.
|
||||
|
||||
This option can be disabled by setting the environment variable: [DISABLE_LIVE_UPDATES](/talk/v5/configuration/#disable-live-updates)
|
||||
|
||||
|
||||
### Permitted Domains
|
||||
|
||||
List of domains where your Coral instance is allowed to be embedded. List each domain and subdomain in use separately and include the scheme for example: `http://localhost:3000`, `https://staging.yourdomain.com`, `http://yourdomain.com`, `https://yourdomain.com`, etc.
|
||||
|
||||
### Story Creation Settings
|
||||
Advanced settings for how stories are created within Coral; such as if stories are to be automatically created when they are published from your CMS.
|
||||
|
||||
See CMS Integration secions [Story Creation](/talk/v5/integrating/cms/#story-creation) and [Story Scraping](/talk/v5/integrating/cms/#story-scraping) for more details.
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: Developing
|
||||
permalink: /v5/developing/
|
||||
---
|
||||
|
||||
Running Coral for development is very similar to installing Coral via Source as
|
||||
described above.
|
||||
|
||||
Coral requires NodeJS >=12, we recommend using `nvm` to help manage node
|
||||
versions: https://github.com/creationix/nvm.
|
||||
|
||||
```bash
|
||||
# Clone and cd into the Coral directory.
|
||||
git clone https://github.com/coralproject/talk.git
|
||||
cd talk
|
||||
|
||||
# Install dependencies.
|
||||
npm install
|
||||
```
|
||||
|
||||
Running Coral with default settings assumes that you have:
|
||||
|
||||
- MongoDB >=4.2 running on `127.0.0.1:27017`
|
||||
- Redis >=3.2 running on `127.0.0.1:6379`
|
||||
|
||||
If you don't already have these databases running, you can execute the following
|
||||
assuming you have Docker installed on your local machine:
|
||||
|
||||
```bash
|
||||
docker run -d -p 27017:27017 --restart always --name mongo mongo:4.2
|
||||
docker run -d -p 6379:6379 --restart always --name redis redis:3.2
|
||||
```
|
||||
|
||||
We recommend installing [watchman](https://facebook.github.io/watchman/docs/install.html) for better watch
|
||||
performance.
|
||||
|
||||
```bash
|
||||
# On macOS, you can run the following with Homebrew.
|
||||
brew update
|
||||
brew install watchman
|
||||
```
|
||||
|
||||
Then start Coral with:
|
||||
|
||||
```bash
|
||||
# Run the server in development mode in order to facilitate auto-restarting and
|
||||
# rebuilding when file changes are detected. This might take a while to fully run.
|
||||
npm run watch
|
||||
```
|
||||
|
||||
When the client code has been built, navigate to http://localhost:8080/install
|
||||
to start the installation wizard. **Note: Ensure `localhost:8080` is used in the permitted domains list.**
|
||||
|
||||
To see the comment stream goto http://localhost:8080/.
|
||||
|
||||
To run linting and tests use the following commands:
|
||||
|
||||
```bash
|
||||
# Run the linters.
|
||||
npm run lint
|
||||
|
||||
# Run our unit and integration tests.
|
||||
npm run test
|
||||
```
|
||||
|
||||
## Email
|
||||
|
||||
To test out the email sending functionality, you can run [inbucket](https://www.inbucket.org/)
|
||||
which provides a test SMTP server that can visualize emails in the browser:
|
||||
|
||||
```bash
|
||||
docker run -d --name inbucket --restart always -p 2500:2500 -p 9000:9000 inbucket/inbucket
|
||||
```
|
||||
|
||||
You can then configure the email server on Coral
|
||||
by setting the email settings in
|
||||
`Configure -> Email` in the admin:
|
||||
|
||||
| Field | Value |
|
||||
| -------------- | -------------------- |
|
||||
| From Address | `community@test.com` |
|
||||
| Secure | `No` |
|
||||
| Host | `localhost` |
|
||||
| Port | `2500` |
|
||||
| Authentication | `No` |
|
||||
|
||||
Navigate to http://localhost:9000, click the "Monitor" tab. New emails received
|
||||
on this screen.
|
||||
|
||||
## Design Language System (UI Components)
|
||||
|
||||
We use [docz](https://docz.site) to document and develop our Design Language System. To start docz run:
|
||||
|
||||
```bash
|
||||
# Make sure CSS types are generated.
|
||||
# This is not required when `npm run watch` is already running.
|
||||
npm run generate:css-types
|
||||
|
||||
# Run docz in development.
|
||||
npm run docz -- dev
|
||||
```
|
||||
|
||||
After compilation has finished you can access docz at http://localhost:3030/.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions from the open source community!
|
||||
|
||||
To get started contributing, check out our [Contribution Guidelines](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md).
|
||||
|
||||
### Contributing a Translation
|
||||
|
||||
We’re so proud to have received submissions from a lot of 3rd party contributors translating Coral into their own languages.
|
||||
|
||||
You can see what languages Coral currently supports here: https://github.com/coralproject/talk/tree/master/src/locales
|
||||
|
||||
You can set the default language Coral uses in **Admin** > **Configure** > **General**.
|
||||
|
||||
We use the fluent library and store our translations in FTL files in `src/locales/` and `src/core/server/locales/`. To add a new Coral translation, strings are added or removed from localization bundles in the translation files as needed.
|
||||
|
||||
Strings MUST NOT be changed after they've been committed and pushed to master. Changing a string requires creating a new ID with a new name (preferably descriptive instead of incremented) and deletion of the obsolete ID. It's often useful to add a comment above the string with info about how and where the string is used.
|
||||
|
||||
If you are a developer contributing a new language, you’ll need to add the required i18n support in the i18n files (or you can leave that to us if you like). If you’re a non-developer, you can submit the translation via GitHub if you feel comfortable doing that, or feel free to email it to us via our Support: support@coralproject.net
|
||||
|
||||
If you want to suggest a new language or put a placeholder for a translation you’re working on, feel free to create a GitHub issue: https://github.com/coralproject/talk/issues/new
|
||||
|
||||
## Compatibility with IE11
|
||||
|
||||
We strive to make the Comment Stream Embed usable in IE11 without being pixel-perfect. If you develop on the Comment Stream you need to be aware of the following:
|
||||
|
||||
### CSS Variables
|
||||
|
||||
In order to support IE11 on the Comment Stream, every time new CSS is loaded, you need to call `polyfillCSSVarsForIE11()`.
|
||||
|
||||
```ts
|
||||
import { polyfillCSSVarsForIE11 } from "coral-framework/helpers";
|
||||
|
||||
const loadProfileContainer = () =>
|
||||
import("./ProfileContainer" /* webpackChunkName: "profile" */).then(x => {
|
||||
// New css is loaded, take care of polyfilling those css vars for IE11.
|
||||
polyfillCSSVarsForIE11();
|
||||
return x;
|
||||
});
|
||||
```
|
||||
|
||||
### CSS Calc
|
||||
|
||||
Various bugs exists around `calc` support in IE11 (see https://caniuse.com/#feat=calc). We work around most of them by pre-transforming `calc` values using `postcss-calc-function`. Some css attributes might have an issue if you use `css-variables` inside `calc`.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
title: Planning your Talk Architecture
|
||||
permalink: /planning-architecture/
|
||||
---
|
||||
|
||||
Talk is architected to be able to run on as little as 500MB of RAM. To do this however you will need to use the pre-compiled Docker container, as compiling the code and dependencies will cause a memory spike.
|
||||
|
||||
For the average small blog or newsroom, these are our recommended machines:
|
||||
|
||||
- **Digital Ocean**: ~$5/month for their 1GB droplet
|
||||
- **Google Cloud**: ~$14/month for a g1 small
|
||||
- **AWS**: ~$16/month for a t2small
|
||||
|
||||
From there, you’re free to separate app servers and DB servers, and scale up as much as you need.
|
||||
|
||||
One larger newsroom’s setup, as an example of Talk performing at scale, is:
|
||||
|
||||
Application servers: c4.xlarge (16 VM nginx + Talk VM machine pairs)
|
||||
Mongo nodes: 3x c3.medium (large db cluster, 1 master, 2 read replicas)
|
||||
|
||||
If you need help with Talk performance or want custom scaling help or recommendations, let us know by logging a ticket and one of our engineers will get in touch with you: https://support.coralproject.net
|
||||
@@ -0,0 +1,230 @@
|
||||
---
|
||||
title: Configuration with CLI tools in Version 5
|
||||
permalink: /v5/configuration-cli-tools/
|
||||
toc: true
|
||||
---
|
||||
|
||||
Coral’s CLI tooling has been reinvented in version 5. We’ve replaced the former `./bin/cli` tools with a completely new CLI application which is available on GitHub at: https://github.com/coralproject/coral-cli
|
||||
|
||||
The new CLI was built on `oclif` ([View on GitHub](https://github.com/oclif)), the open source CLI Framework originally built by Heroku, and offers an extensible platform for interacting with Coral via CLI.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
To get started with the new CLI run:
|
||||
```
|
||||
npm install -g @coralproject/coral-cli
|
||||
```
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
Once installed, run:
|
||||
```
|
||||
coral-cli [COMMAND]
|
||||
```
|
||||
Below is a list of commands available:
|
||||
```
|
||||
CLI to interact with Coral
|
||||
|
||||
VERSION
|
||||
@coralproject/coral-cli/0.2.0 darwin-x64 node-v11.9.0
|
||||
|
||||
USAGE
|
||||
$ coral-cli [COMMAND]
|
||||
|
||||
COMMANDS
|
||||
help display help for coral-cli
|
||||
login grabs a token for interacting with Coral
|
||||
logout removes credentials for logging in with Coral
|
||||
plugins list installed plugins
|
||||
story manage stories on Coral
|
||||
tenant creates a new tenant
|
||||
token creates tokens on the current user
|
||||
```
|
||||
|
||||
### `coral-cli help [COMMAND]`
|
||||
Displays help for `coral-cli`, or for the command specified.
|
||||
```
|
||||
USAGE
|
||||
$ coral-cli help [COMMAND]
|
||||
|
||||
ARGUMENTS
|
||||
COMMAND command to show help for
|
||||
|
||||
OPTIONS
|
||||
--all see all commands in CLI
|
||||
```
|
||||
|
||||
### `coral-cli login`
|
||||
Grabs a session token for interacting with a specific Coral installation. Can be used to create a session prior to running other commands.
|
||||
```
|
||||
USAGE
|
||||
$ coral-cli login
|
||||
|
||||
OPTIONS
|
||||
-d, --domain=domain (required) domain for tenant to run command against
|
||||
```
|
||||
|
||||
### `coral-cli logout`
|
||||
Removes credentials for logging in with Coral and terminates current login session.
|
||||
```
|
||||
USAGE
|
||||
$ coral-cli logout
|
||||
|
||||
OPTIONS
|
||||
-d, --domain=domain (required) domain for tenant to run command against
|
||||
```
|
||||
|
||||
### `coral-cli plugins`
|
||||
Not to be confused with "Talk Plugins" that were available in prior versions of Coral, `coral-cli plugins` are extensions to the `coral-cli` application. See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins). Use this command to the list installed plugins.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ coral-cli plugins
|
||||
|
||||
OPTIONS
|
||||
--core show core plugins
|
||||
|
||||
EXAMPLE
|
||||
$ coral-cli plugins
|
||||
```
|
||||
|
||||
### `coral-cli plugins:install [PLUGIN]`
|
||||
Installs a plugin into the CLI. Can be from npm or a git url. See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins)
|
||||
|
||||
*NOTE: Installation of a user-installed plugin will override a core plugin. e.g. If you have a core plugin that has a `hello` command, installing a user-installed plugin with a `hello` command will override the core plugin implementation.*
|
||||
```bash
|
||||
USAGE
|
||||
$ coral-cli plugins:install PLUGIN
|
||||
|
||||
ARGUMENTS
|
||||
PLUGIN plugin to install
|
||||
|
||||
OPTIONS
|
||||
-f, --force yarn install with force flag
|
||||
-h, --help show CLI help
|
||||
-v, --verbose
|
||||
|
||||
ALIASES
|
||||
$ coral-cli plugins:add
|
||||
|
||||
EXAMPLES
|
||||
$ coral-cli plugins:install myplugin
|
||||
$ coral-cli plugins:install https://github.com/someuser/someplugin
|
||||
$ coral-cli plugins:install someuser/someplugin
|
||||
```
|
||||
|
||||
### `coral-cli plugins:link PLUGIN`
|
||||
Links a plugin into the CLI for development.
|
||||
See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins)
|
||||
```
|
||||
USAGE
|
||||
$ coral-cli plugins:link PLUGIN
|
||||
|
||||
ARGUMENTS
|
||||
PATH [default: .] path to plugin
|
||||
|
||||
OPTIONS
|
||||
-h, --help show CLI help
|
||||
-v, --verbose
|
||||
|
||||
EXAMPLE
|
||||
$ coral-cli plugins:link myplugin
|
||||
```
|
||||
|
||||
### `coral-cli plugins:uninstall PLUGIN`
|
||||
Removes a plugin from the CLI. See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins)
|
||||
```
|
||||
USAGE
|
||||
$ coral-cli plugins:uninstall PLUGIN
|
||||
|
||||
ARGUMENTS
|
||||
PLUGIN plugin to uninstall
|
||||
|
||||
OPTIONS
|
||||
-h, --help show CLI help
|
||||
-v, --verbose
|
||||
|
||||
ALIASES
|
||||
$ coral-cli plugins:unlink
|
||||
$ coral-cli plugins:remove
|
||||
```
|
||||
|
||||
### `coral-cli plugins:update`
|
||||
Update installed plugins. See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins).
|
||||
```
|
||||
USAGE
|
||||
$ coral-cli plugins:update
|
||||
|
||||
OPTIONS
|
||||
-h, --help show CLI help
|
||||
-v, --verbose
|
||||
```
|
||||
|
||||
### `coral-cli story:get`
|
||||
Fetches a story by `id` or `url`, useful to view Story metadata obtained by the scraper.
|
||||
```USAGE
|
||||
$ coral-cli story:get
|
||||
|
||||
OPTIONS
|
||||
-d, --domain=domain (required) domain for tenant to run command against
|
||||
--id=id find a story by ID
|
||||
--url=url find a story by URL
|
||||
```
|
||||
|
||||
### `coral-cli story:merge`
|
||||
Merge duplicate stories and their comments into a single story. Story metadata & url are maintained on destination.
|
||||
```
|
||||
USAGE
|
||||
$ coral-cli story:merge
|
||||
|
||||
OPTIONS
|
||||
-d, --domain=domain (required) domain for tenant to run command against
|
||||
--from=from (required) source Story ID that will be merged from
|
||||
--into=into (required) destination Story ID that will be merged into
|
||||
```
|
||||
|
||||
### `coral-cli story:update`
|
||||
Target a story by `id` to update its `url`.
|
||||
```
|
||||
USAGE
|
||||
$ coral-cli story:update
|
||||
|
||||
OPTIONS
|
||||
-d, --domain=domain (required) domain for tenant to run command against
|
||||
--id=id (required) the ID of the story to update
|
||||
--url=url (required) the new URL to update the story to
|
||||
```
|
||||
|
||||
### `coral-cli token:create`
|
||||
Creates a persistent token for the current user (based on running `coral-cli login` above). The token generated can be used to authenticate API requests as the associated user.
|
||||
```
|
||||
USAGE
|
||||
$ coral-cli token:create
|
||||
|
||||
OPTIONS
|
||||
-d, --domain=domain (required) domain for tenant to run command against
|
||||
--name=name (required) name of the token
|
||||
```
|
||||
|
||||
### `coral-cli token:list`
|
||||
Lists tokens on the current user (based on running `coral-cli login` above).
|
||||
```
|
||||
USAGE
|
||||
$ coral-cli token:list
|
||||
|
||||
OPTIONS
|
||||
-d, --domain=domain (required) domain for tenant to run command against
|
||||
```
|
||||
|
||||
### `coral-cli token:revoke`
|
||||
Revokes token by `id`. Token must be on the current user (based on running `coral-cli login` above).
|
||||
```
|
||||
USAGE
|
||||
$ coral-cli token:revoke
|
||||
|
||||
OPTIONS
|
||||
-d, --domain=domain (required) domain for tenant to run command against
|
||||
--id=id (required) id of the token to revoke
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: Integrating on your site
|
||||
permalink: /v5/integrating/cms/
|
||||
---
|
||||
|
||||
With Coral setup and running locally you can find your **Embed code** under **Configure** > **Organization** > **Site Details** (when logged in as an ADMIN). It should look something like this, but with your domain in place of `CORAL_DOMAIN_NAME`. You can test placing the comment stream embed on your page with this sample embed script:
|
||||
|
||||
```
|
||||
<div id="coral_thread"></div>
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
var d = document, s = d.createElement('script');
|
||||
var url = '{{ CORAL_DOMAIN_NAME }}';
|
||||
s.src = '//' + url + '/assets/js/embed.js';
|
||||
s.async = false;
|
||||
s.defer = true;
|
||||
s.onload = function() {
|
||||
Coral.createStreamEmbed({
|
||||
id: "coral_thread",
|
||||
autoRender: true,
|
||||
rootURL: '//' + url,
|
||||
});
|
||||
};
|
||||
(d.head || d.body).appendChild(s);
|
||||
})();
|
||||
</script>`;
|
||||
```
|
||||
|
||||
> **NOTE:** Replace the value of `{% raw %}{{ CORAL_DOMAIN_NAME }}{% endraw %}` with the location of your running instance of Coral.
|
||||
|
||||
|
||||
## Story Creation
|
||||
|
||||
Lazy `Story` Creation enables stories to be automatically created when they are published from your CMS. Triggering the embed script above renders the comment stream iFrame on your page. By default that script dynamically generates `Stories` in Coral for seamless integration.
|
||||
|
||||
### storyURL
|
||||
|
||||
If you do not specify a `storyURL` when rendering the embed, the `storyURL` is first inferred from the Canonical link element, which takes the form of a <link> element in your <head> of the page:
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="canonical" href="https://example.com/page" />
|
||||
</head>
|
||||
<body>
|
||||
...
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
The url must reference an existing Permitted Domain. If your articles/stories always have unique urls, then you will not need to modify the default behavior.
|
||||
|
||||
If this tag is not present, or if the canonical URL references a different url than your site such as a wire service, you can specify the `storyURL` parameter in the render function.
|
||||
|
||||
The url will be used by Coral to build user facing links, and should reference the location where you would direct a user back to this particular story or article.
|
||||
|
||||
### storyID
|
||||
|
||||
To more tightly couple Coral with your CMS you can provide your CMS's unique identifier to Coral by including a `storyID` parameter in the render function. Doing so will allow you to target the `Story` for later updates via Coral's Graphql API, such as updating the URL if it changes.
|
||||
|
||||
## Integration via API
|
||||
|
||||
Story creation can also be controlled by direct calls to Coral's API. When Lazy Story Creation is disabled embed streams can only be created by data migration or API POST request.
|
||||
|
||||
See [GraphQL API Overview](/talk/v5/api/overview/) for help with the API.
|
||||
|
||||
## Story Scraping
|
||||
|
||||
By default, stories have their metadata scraped when they are loaded. This provides the easiest way for newsrooms to integrate their CMS’s into Coral in a simple way. We use the following meta tags on the target pages that allow us to extract some properties.
|
||||
|
||||
Metadata scraping is performed by the `scraper` job which is enabled by default.
|
||||
|
||||
If your production site is behind a paywall or otherwise prevents scraping, you might need to confiugre a **Scraper Proxy URL**. When specified it allows scraping requests to use the provided proxy. All requests are then passed through the appropriote proxy as parsed by the npm proxy-agent package.
|
||||
|
||||
| Asset Property | Selector |
|
||||
|--------------------|----------|
|
||||
| `title` | See [`metascraper-title`](https://github.com/microlinkhq/metascraper/blob/dc664c37ea1b238b1e3e9d5342edfacc9027892c/packages/metascraper-title/index.js) |
|
||||
| `description` | See [`metascraper-description`](https://github.com/microlinkhq/metascraper/blob/dc664c37ea1b238b1e3e9d5342edfacc9027892c/packages/metascraper-description/index.js) |
|
||||
| `image` | See [`metascraper-image`](https://github.com/microlinkhq/metascraper/blob/dc664c37ea1b238b1e3e9d5342edfacc9027892c/packages/metascraper-image/index.js) |
|
||||
| `author` | See [`metascraper-author`](https://github.com/microlinkhq/metascraper/blob/dc664c37ea1b238b1e3e9d5342edfacc9027892c/packages/metascraper-author/index.js) |
|
||||
| `publication_date` | See [`metascraper-date`](https://github.com/microlinkhq/metascraper/blob/dc664c37ea1b238b1e3e9d5342edfacc9027892c/packages/metascraper-date/index.js) |
|
||||
| `modified_date` | `meta[property="article:modified"]` |
|
||||
| `section` | `meta[property="article:section"]` |
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
title: Single Sign On
|
||||
permalink: /v5/integrating/sso/
|
||||
---
|
||||
|
||||
In order to allow seamless connection to an existing authentication system,
|
||||
Coral utilizes the industry standard [JWT Token](https://jwt.io/) to connect. To
|
||||
learn more about how to create a JWT token, see [this introduction](https://jwt.io/introduction/).
|
||||
|
||||
1. Visit: `https://{% raw %}{{ CORAL_DOMAIN_NAME }}{% endraw %} /admin/configure/auth`
|
||||
2. Scroll to the `Login with Single Sign On` section
|
||||
3. Enable the Single Sign On Authentication Integration
|
||||
4. Enable `Allow Registration`
|
||||
5. Copy the string in the `Secret` box
|
||||
6. Click Save
|
||||
|
||||
> **NOTE:** Replace the value of `{% raw %}{{ CORAL_DOMAIN_NAME }}{% endraw %}` with the location of your running instance of Coral.
|
||||
|
||||
You will then have to generate a JWT with the following claims:
|
||||
|
||||
- `jti` _(optional)_ - A unique ID for this particular JWT token. We recommend
|
||||
using a [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier)
|
||||
for this value. This claim controls the logout functionality on the embed stream. To disable the “Sign Out” links in the embed, remove this claim, and disable “Login with email authentication” on the Comment Stream. You can then call `logout()` on the embed, or expire the token when the SSO user should be signed out of Coral.
|
||||
- `exp` _(optional)_ - When the given SSO token should expire. This is
|
||||
specified as a unix time stamp in seconds. Once the token has expired, a new
|
||||
token should be generated and passed into Coral. Without this parameter you will need to call `logout()` on the embed itself.
|
||||
- `iat` _(optional)_ - When the given SSO token was issued. This is required to
|
||||
utilize the automatic user detail update system. If this time is newer than
|
||||
the time we received the last update, the contents of the token will be used
|
||||
to update the user. Any claims not present on the jwt will be ignored on update, and will not overwrite existing values.
|
||||
- `user.id` **(required)** - the unique ID of the user from your authentication system.
|
||||
This is required to connect the user in your system to allow a seamless
|
||||
connection to Coral.
|
||||
- `user.email` **(required)** - the unique email address of the user from your
|
||||
authentication system. This is required to facilitate notification email's
|
||||
about status changes on a user account such as bans or suspensions.
|
||||
- `user.username` **(required)** - the username that should be used when being
|
||||
presented inside Coral to moderators and other users. There are no username validations or restrictions enforced by Coral when you're using SSO.
|
||||
- `user.badges` _(optional)_ - array of strings to be displayed as badges beside
|
||||
username inside Coral, visible to other users and moderators. For example, to indicate
|
||||
a user's subscription status. If you include the claim, but you are not passing a badge value, then use an empty array instead of null.
|
||||
- `user.role` _(optional)_ - one of "COMMENTER", "STAFF", "MODERATOR", "ADMIN". Will create/update
|
||||
Coral user with this permission level. When users have both an assigned role greather than COMMENTER and a badge, both will be displayed.
|
||||
- `user.url` _(optional)_ - url for user account management, where a user will
|
||||
be able to perform account management tasks such as changing password or
|
||||
deleting data. If provided, user will be able to access this URL by clicking
|
||||
on the "account" tab from the stream.
|
||||
|
||||
An example of the claims for this token would be:
|
||||
|
||||
```json
|
||||
{
|
||||
"jti": "151c19fc-ad15-4f80-a49c-09f137789fbb",
|
||||
"exp": 1572172094,
|
||||
"iat": 1562172094,
|
||||
"user": {
|
||||
"id": "628bdc61-6616-4add-bfec-dd79156715d4",
|
||||
"email": "bob@example.com",
|
||||
"username": "bob"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With the claims provided, you can sign them with the `Secret` obtained from the
|
||||
Coral administration panel in the previous steps with a `HS256` algorithm. This
|
||||
token can be provided in the above mentioned embed code by adding it to the
|
||||
`createStreamEmbed` function:
|
||||
|
||||
```js
|
||||
Coral.createStreamEmbed({
|
||||
// Don't forget to include the parameters from the
|
||||
// "Embed On Your Site" section.
|
||||
accessToken: "{{ SSO_TOKEN }}"
|
||||
});
|
||||
```
|
||||
|
||||
Or by calling the `login/logout` method on the embed object:
|
||||
|
||||
```js
|
||||
var embed = Coral.createStreamEmbed({
|
||||
// Don't forget to include the parameters from the
|
||||
// "Embed On Your Site" section.
|
||||
});
|
||||
|
||||
// Login the current embed with the generated SSO token.
|
||||
embed.login("{{ SSO_TOKEN }}");
|
||||
|
||||
// Logout the user.
|
||||
embed.logout();
|
||||
```
|
||||
|
||||
## External Integrations
|
||||
|
||||
You can integrate directly with the Coral GraphQL API in order to facilitate
|
||||
account updates for your users when using Coral SSO. The relevant mutations are
|
||||
as follows:
|
||||
|
||||
- `updateUserUsername` lets you update a given user with a new username using
|
||||
an admin token.
|
||||
- `updateUserEmail` lets you update a given user with a new email address
|
||||
using an admin token.
|
||||
- `deleteUser` lets you delete a given account using an admin token.
|
||||
Note that even with an admin token, you may not delete yourself via
|
||||
this method, and instead must use the `requestAccountDeletion`
|
||||
mutation instead. This differs from the `requestAccountDeletion` as
|
||||
it does the operation immediately instead of scheduling it as
|
||||
`requestAccountDeletion` does.
|
||||
- `requestUserCommentsDownload` lets you retrieve a given account's comments download. This mutation will provide you with a `archiveURL` that can be used to download a ZIP file containing the user's comment export.
|
||||
|
||||
If you're unsure on how to call GraphQL API's, refer to the section here on [Making your first GraphQL request](/talk/v5/api/overview/#making-your-first-request).
|
||||
|
||||
## Login Prompts
|
||||
|
||||
In order to handle login prompts (e.g. a user clicks on the sign in button) you can listen to the `loginPrompt` event.
|
||||
|
||||
```js
|
||||
var embed = Coral.createStreamEmbed({
|
||||
// Don't forget to include the parameters from the
|
||||
// "Embed On Your Site" section.
|
||||
events: function(events) {
|
||||
events.on("loginPrompt", function() {
|
||||
// Redirect user to a login page.
|
||||
location.href = "http://example.com/login";
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
## Troubleshooting JWT Validation Errors
|
||||
|
||||
In addition to the uniqueness constraints on User `id` and `email` values, each `user.id`/`user.email` combination must also be unique inside Coral. This is true for both Single Sign On users created by JWT tokens, as well as users that register/login using Coral’s built in “Login with email authentication”. You can not share authentication strategies for a single user; thus if a user logs in with SSO, they can not also login with email and vice versa. When you attempt to authenticate a JWT token with an email address that already exists in Coral with a different `user.id` than was passed on the token Coral will throw a Duplicate User error.
|
||||
|
||||
Any JWT validation errors thrown can be found in Coral's server logs.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: Social and Email Authentication
|
||||
permalink: /v5/integration/auth/
|
||||
---
|
||||
|
||||
## Authentication Strategies
|
||||
|
||||
There are several strategies to choose from when it comes to user authentication. Login methods can be enabled or disabled for both the **Coral Admin** and the **Comment Stream** separately thus allowing you to use different methods for different user groups.
|
||||
|
||||
You can also use "Allow Registration" to restrict or allow users that have not signed up before to register and create new user accounts.
|
||||
|
||||
To configure authentication for your instance of Coral, go to **Configure** > **Authentication**.
|
||||
|
||||
## Login with email authentication
|
||||
Best when you want Coral to manage all user registration, creation and authentication. Requires the least amount of setup and configuration. Users register by creating a Username (must be unique across users, no spaces or special chars), Email, and Password. All user details are stored in Coral's database.
|
||||
|
||||
By Default - users are sent a verification email and are prompted to verify email addresss, however completing the email verification step is not required to submit comments.
|
||||
|
||||
|
||||
## Login with OpenID Connect
|
||||
|
||||
Allows integration with your existing OpenID Connect authentication provider. When enabled this strategy adds a "Login with ___", button to Coral's login modal with the value you've specified for `Provider Name`.
|
||||
|
||||
With OpenID Connect authentication is handled by passing an access token parameter to the comment stream embed when placing the call to render it. To learn more please visit: https://openid.net/connect/
|
||||
|
||||
## Login with Single Sign On
|
||||
|
||||
When you want users to authenticate via your existing identity system, and be automatically registered/logged into Coral. Users register, login, and manage their user details on your existing site, your user database maintains the source of truth for all user information. Users are created and updated in Coral via jwt tokens passed from your auth service to Coral.
|
||||
|
||||
Requires advanced configuration, please see: [Single Sign On](/talk/v5/integrating/sso/)
|
||||
|
||||
## Login with Google & Facebook
|
||||
Allows users to use existing social media account to sign in. Requires you to setup API keys with social sign on provider, and configure your API keys in Coral.
|
||||
|
||||
Users register and login via a *“Sign in with ….”*, and are not required to set a password if Email based authentication is not enabled. Users with social accounts are prompted to associate an email address with their social profile to enable notifications.
|
||||
|
||||
|
||||
## Commenter Account Management Features
|
||||
|
||||
Optional features you can enable or disable to allows users to:
|
||||
* change their usernames (once every 14 days)
|
||||
* download their comments
|
||||
* delete their account details.
|
||||
|
||||
## Session settings
|
||||
|
||||
Determines length of user sessions when not using SSO or when `exp` claim is not provided on jwt. Default: `90 Days`.
|
||||
|
||||
## Username Restrictions
|
||||
|
||||
Coral usernames are subject to the following validations:
|
||||
* USERNAME_REGEX =
|
||||
`(/^[a-zA-Z0-9_.]+$/)`
|
||||
* USERNAME_MAX_LENGTH = `30`
|
||||
* USERNAME_MIN_LENGTH = `3`
|
||||
|
||||
*NOTE: SSO users are not subject to username restricitons*
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: Customizing Styles with CSS
|
||||
permalink: /v5/css/
|
||||
---
|
||||
|
||||
You can add your own stylesheet in **Admin** > **Configure** > **Advanced** > **Custom CSS**.
|
||||
|
||||
### Use CSS Variables
|
||||
|
||||
We recommend using CSS Variables to broadly apply changes to certain styling aspects like color or fonts.
|
||||
|
||||
Here is a CSS example that modifies some of the CSS Variables.
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Change primary font */
|
||||
--font-family-primary: 'Verdana';
|
||||
|
||||
/* Remove round corners */
|
||||
--round-corners: 0px;
|
||||
}
|
||||
```
|
||||
|
||||
You can find all CSS Variables using the Web Inspector or navigate to https://github.com/coralproject/talk/blob/master/CSS_VARIABLES.md for a comprehensive list and additional information.
|
||||
|
||||
> **NOTE:** Before 6.3.0 Coral uses a different set of CSS Variables. Navigate to the link above to get information on upgrading.
|
||||
|
||||
### Use stable CSS Class Names
|
||||
|
||||
If you would like to change the styling of any elements of the comment embed, we provide global classnames. Most elements will be tagged with either `.coral` or `.coral-stream`.
|
||||
|
||||
The easiest way to find the classname for the element you're looking for is to use the web inspector, and then update your stylesheet accordingly.
|
||||
|
||||
You can also navigate to https://github.com/coralproject/talk/blob/master/src/core/client/stream/classes.ts to see available stable class names.
|
||||
|
||||
### Custom body class for theming
|
||||
|
||||
You can set the class name of the `<body>` tag inside the embed by using the `bodyClassName` parameter when calling `Coral.createStreamEmbed`:
|
||||
|
||||
```js
|
||||
Coral.createStreamEmbed({
|
||||
bodyClassName: "pink"
|
||||
});
|
||||
```
|
||||
|
||||
This will allow your styles to include variations:
|
||||
|
||||
```css
|
||||
.pink button.coral {
|
||||
background: pink;
|
||||
}
|
||||
```
|
||||
|
||||
### Reaction styling
|
||||
|
||||
As of Coral 6.3.0, Coral has support for styling based on the number of
|
||||
reactions that a given comment has received. It does so via the:
|
||||
|
||||
```sh
|
||||
.coral-reacted-{{ n }}
|
||||
```
|
||||
|
||||
Where `{{ n }}` is the number of reactions the comment has received. You can
|
||||
invert this when creating CSS to allow you to highlight comments that have at
|
||||
least `{{ n }}` reactions. For example, if you wanted to add a coral color to
|
||||
comments with at least 3 reactions, you could write:
|
||||
|
||||
```css
|
||||
.coral-comment .coral-indent {
|
||||
background-color: coral;
|
||||
}
|
||||
|
||||
.coral-reacted-0 .coral-indent,
|
||||
.coral-reacted-1 .coral-indent,
|
||||
.coral-reacted-2 .coral-indent {
|
||||
background-color: transparent;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: Emails & Notifications
|
||||
permalink: /v5/notifications/
|
||||
---
|
||||
|
||||
There are several email notifications included with Coral by default that will be sent to users based on user activity.
|
||||
|
||||
_NOTE: Notifications are only supported for users that have a valid email address! If you are using authenticate via Facebook, Google, or OpenIDConnect, user emails are not automatically required._
|
||||
|
||||
### Configuring SMTP
|
||||
|
||||
You must setup SMTP to send email notifications.
|
||||
See our documentation on [Email SMTP Settings](/talk/v5/administration/#email-smtp-settings) for reference.
|
||||
|
||||
When running in production we recommend using a 3rd party mail service provider like SendGrid or MailGun.
|
||||
|
||||
|
||||
### Notification Types
|
||||
|
||||
In addition to the comment activity notifications, Coral also provides users with several transactional notifications about their user profile.
|
||||
|
||||
Comment activity notifications are set to OFF for individual users by default, and commenters must opt-in to enable activity notifications. Commenters cannot enable notifications until they have verified their email, and are not able to opt-out of transactional notifications such as when a user has been Banned or Suspended.
|
||||
|
||||
|
||||
#### Type: User Profile Notifications
|
||||
|
||||
* When a new Staff, Moderator, or Admin user is invited, sends invitation
|
||||
* When any user registers, sends confirmation and email verification link
|
||||
* When user requests password reset, sends reset link
|
||||
* When user changes username, sends change confirmation
|
||||
* When user changes their email address, sends change confirmation and email verification link
|
||||
* When a user is banned or suspended, sends notice
|
||||
* When comment history is ready for download (only one link can be generated every 7 days, and the link is valid for 24 hours), sends download link
|
||||
* When user requests account deletion, cancels account deletion, or account deletion has been completed, sends confirmation
|
||||
|
||||
_NOTE: Users can not opt-out of User Profile Notifications_
|
||||
|
||||
|
||||
#### Type: Comment Activity Notifications
|
||||
|
||||
Commenter users can subscribe to the following comment activity notificaitons:
|
||||
|
||||
* When one of my comments receives a reply
|
||||
* When a Staff, Moderator, or Admin user replies to one of my comments
|
||||
* When a Moderator Features one of my comments
|
||||
* When one of my pending comments has been APPROVED or REJECTED
|
||||
|
||||
|
||||
Commenters have 3 options for activity notification frequency:
|
||||
* Immediately
|
||||
* Hourly
|
||||
* Daily
|
||||
|
||||
Immediately will send one email notification per occurance, versus sending an hourly or daily summary with all occurances listed.
|
||||
|
||||
### Email Templates
|
||||
|
||||
Email templates are text based and support translations. Many of the transactional emails can be modified by Moderators at the time of sending to provide additional context to users.
|
||||
|
||||
Coral does not currently support customizing email templates beyond the default templates.
|
||||
|
||||
To view the current email text strings for US English visit: https://github.com/coralproject/talk/blob/master/src/core/server/locales/en-US/email.ftl
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: GDPR Compliance
|
||||
permalink: /v5/integrating/gdpr/
|
||||
---
|
||||
|
||||
In order to facilitate compliance with the
|
||||
[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), Coral
|
||||
provides features so your users can change and manage their own data.
|
||||
|
||||
Even if GDPR will not apply to you, it is recommended to enable these
|
||||
features as a best practice to provide your users with control over their own
|
||||
data.
|
||||
|
||||
## GDPR Feature Overview
|
||||
|
||||
Integrating our GDPR tools will give your users and organizations the following benefits:
|
||||
|
||||
- **Download my comment data**: Users can request a download of their comments. An email with a link is emailed to them to download a CSV with each comment they've made, what story it was made on, and the comment's ID and timestamp.
|
||||
- **Delete my account**: Users can request deletion of their account. Deleted account requests are pending for 24 hours to allow the user to download their comments, or to change their mind and reactivate their account before the expiry. Account deletions remove all of their comments from the site, all their comments and actions from the database, and their account info from our system.
|
||||
- **Add an email to an OAuth/external account**: Users are prompted to add an email to their non-Coral account (Facebook, Google, external, etc) so that they can take part in GDPR and other features requiring email communication.
|
||||
- **Change my username**: Users can update their username. This is capped at once every 2 weeks.
|
||||
- **Change my email**: Users can change their email.
|
||||
- **Change my password**: Users can change their password.
|
||||
|
||||
## GDPR with SSO
|
||||
|
||||
As many newsrooms often implement their own [SSO solutions](/talk/v5/integrating/sso/),
|
||||
we also provide API support to manage GDPR features directly from your own Account or My Profile page.
|
||||
|
||||
We provide the following GraphQL mutations designed to allow you to integrate it into your existing user
|
||||
interfaces or exports.
|
||||
|
||||
- `requestUserCommentsDownload` - lets you grab the direct link to download a users
|
||||
account in a zip format. From there, you can integrate it into your existing
|
||||
data export or simply proxy it to the user to allow them to download it
|
||||
elsewhere in your UI.
|
||||
- `deleteUserAccount` - lets you delete the specified user
|
||||
|
||||
**Note: These mutations require an administrative token**
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user