mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f4dbc7a24d | ||
|
|
161e8b4117 | ||
|
|
6f3535984c | ||
|
|
e3557c1f7a | ||
|
|
692b6af09d | ||
|
|
25932e10d6 | ||
|
|
ec13a001a2 | ||
|
|
702a616e6c | ||
|
|
a3dd208fbd | ||
|
|
55c3406990 | ||
|
|
197fbc33e0 | ||
|
|
008a4fd7d5 | ||
|
|
8c5665095f | ||
|
|
647b2ea0c8 | ||
|
|
e61f88b98d | ||
|
|
b024421bbb | ||
|
|
ce95467145 | ||
|
|
1564b380b1 | ||
|
|
593b8959c5 | ||
|
|
3fda3b4b7c | ||
|
|
7e680038d6 | ||
|
|
71b8bb5d60 | ||
|
|
cfbafdeb5c | ||
|
|
c19d4a433a | ||
|
|
13cc80bd35 | ||
|
|
1eea2daeae | ||
|
|
5b29e7d351 | ||
|
|
3af39d99e4 | ||
|
|
1abf988366 | ||
|
|
205f943ab5 | ||
|
|
879683da4f | ||
|
|
974e884432 | ||
|
|
846319f1a9 | ||
|
|
e5237ae805 | ||
|
|
b9d88251e6 | ||
|
|
0d720626d4 | ||
|
|
50b2c6aa88 | ||
|
|
382019d907 | ||
|
|
97103739c2 | ||
|
|
9cf13691ad | ||
|
|
e6107173fb | ||
|
|
1d9ec7a779 | ||
|
|
0c345fe913 | ||
|
|
b6f06f1e89 | ||
|
|
aea3a7449e | ||
|
|
4db73ae73a | ||
|
|
2af7acd3db | ||
|
|
da9847b88f | ||
|
|
5b9f9f1b0d | ||
|
|
5427f295f6 | ||
|
|
620273769f | ||
|
|
dff3d79569 | ||
|
|
0904453e41 | ||
|
|
90eafb557c | ||
|
|
bcb294c625 | ||
|
|
b070a7cc52 | ||
|
|
e48d89a607 | ||
|
|
cb57e5c1e1 | ||
|
|
bf363621d3 | ||
|
|
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 |
@@ -41,9 +41,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
|
||||
|
||||
@@ -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.
|
||||
@@ -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,8 +1,8 @@
|
||||
# Talk · [](https://circleci.com/gh/coralproject/talk) · [](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946) · [](CONTRIBUTING.md#pull-requests)
|
||||
# Talk · [](https://circleci.com/gh/coralproject/talk) · [](CONTRIBUTING.md#pull-requests)
|
||||
|
||||
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 +14,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/)
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import queryString from 'query-string';
|
||||
import queryString from 'querystringify';
|
||||
|
||||
import {
|
||||
FETCH_USERS_REQUEST,
|
||||
@@ -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,
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import queryString from 'query-string';
|
||||
import queryString from 'querystringify';
|
||||
|
||||
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,6 +42,25 @@ 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 }) => {
|
||||
@@ -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,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React from 'react';
|
||||
import styles from './Forbidden.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const Forbidden = () => (
|
||||
<div className={styles.container}>
|
||||
<p className={styles.copy}>
|
||||
This page is for team use only. Please contact an administrator if you
|
||||
want to join this team.
|
||||
</p>
|
||||
<p className={styles.copy}>{t('error.PAGE_NOT_AVAILABLE_ROLE')}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class ForgotPassword extends React.Component {
|
||||
href="#"
|
||||
onClick={this.handleSignInLink}
|
||||
>
|
||||
Sign in
|
||||
{t('login.sign_in')}
|
||||
</a>
|
||||
<Success />
|
||||
</div>
|
||||
@@ -48,7 +48,7 @@ class ForgotPassword extends React.Component {
|
||||
onChange={this.handleEmailChange}
|
||||
/>
|
||||
<Button type="submit" cStyle="black" full>
|
||||
{t('login.reset_password')}
|
||||
{t('login.reset_password_send_button')}
|
||||
</Button>
|
||||
<p className={styles.cta}>
|
||||
{t('login.go_back')}{' '}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -14,9 +14,13 @@ import ApproveButton from './ApproveButton';
|
||||
import RejectButton from 'coral-admin/src/components/RejectButton';
|
||||
import CommentDeletedTombstone from './CommentDeletedTombstone';
|
||||
|
||||
import t, { timeago } from 'coral-framework/services/i18n';
|
||||
import { buildCommentURL } from 'coral-framework/utils/url';
|
||||
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
|
||||
@@ -27,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,
|
||||
@@ -73,14 +100,20 @@ class UserDetailComment extends React.Component {
|
||||
checked={selected}
|
||||
onChange={e => toggleSelect(e.target.value, e.target.checked)}
|
||||
/>
|
||||
<span className={styles.created}>
|
||||
{timeago(comment.created_at)}
|
||||
</span>
|
||||
<TimeAgo className={styles.created} datetime={comment.created_at} />
|
||||
{comment.editing && comment.editing.edited ? (
|
||||
<span>
|
||||
<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}
|
||||
|
||||
@@ -112,7 +145,7 @@ class UserDetailComment extends React.Component {
|
||||
/>
|
||||
<a
|
||||
className={styles.external}
|
||||
href={`${comment.asset.url}?commentId=${comment.id}`}
|
||||
href={buildCommentURL(comment.asset.url, comment.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -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,32 +3,57 @@ 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.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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -42,17 +67,6 @@ export default function assets(state = initialState, action) {
|
||||
},
|
||||
},
|
||||
});
|
||||
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'
|
||||
)}
|
||||
@@ -176,7 +228,7 @@ class People extends React.Component {
|
||||
})
|
||||
}
|
||||
>
|
||||
Suspend User
|
||||
{t('modqueue.suspend')}
|
||||
</ActionsMenuItem>
|
||||
)}
|
||||
|
||||
@@ -195,7 +247,28 @@ class People extends React.Component {
|
||||
})
|
||||
}
|
||||
>
|
||||
Ban User
|
||||
{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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class EmbedLink extends Component {
|
||||
{t('embedlink.copy')}
|
||||
</Button>
|
||||
<div className={styles.copiedText}>
|
||||
{this.state.copied && 'Copied!'}
|
||||
{this.state.copied && t('embedlink.copied')}
|
||||
</div>
|
||||
</div>
|
||||
</ConfigureCard>
|
||||
|
||||
@@ -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,19 +6,23 @@ 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}
|
||||
inputProps={{ placeholder: 'word or phrase' }}
|
||||
inputProps={{
|
||||
placeholder: t('configure.suspect_or_forbidden_words_placeholder'),
|
||||
}}
|
||||
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}
|
||||
inputProps={{ placeholder: 'word or phrase' }}
|
||||
inputProps={{
|
||||
placeholder: t('configure.suspect_or_forbidden_words_placeholder'),
|
||||
}}
|
||||
onChange={tags => onChangeWordlist('suspect', tags)}
|
||||
/>
|
||||
</ConfigureCard>
|
||||
|
||||
@@ -32,6 +32,7 @@ export default compose(
|
||||
settings: gql`
|
||||
fragment TalkAdmin_TechSettings_settings on Settings {
|
||||
customCssUrl
|
||||
customAdminCssUrl
|
||||
domains {
|
||||
whitelist
|
||||
}
|
||||
|
||||
@@ -61,13 +61,20 @@
|
||||
line-height: 1.5;
|
||||
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;
|
||||
@@ -96,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;
|
||||
@@ -104,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;
|
||||
@@ -120,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;
|
||||
@@ -133,7 +140,7 @@
|
||||
white-space: nowrap;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
opacity: .9;
|
||||
opacity: 0.9;
|
||||
}
|
||||
i {
|
||||
font-size: 12px;
|
||||
@@ -188,4 +195,26 @@
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -14,12 +14,16 @@ import cn from 'classnames';
|
||||
import ApproveButton from 'coral-admin/src/components/ApproveButton';
|
||||
import RejectButton from 'coral-admin/src/components/RejectButton';
|
||||
import CommentDeletedTombstone from '../../../components/CommentDeletedTombstone';
|
||||
import TimeAgo from 'coral-framework/components/TimeAgo';
|
||||
|
||||
import t, { timeago } from 'coral-framework/services/i18n';
|
||||
import { buildCommentURL } from 'coral-framework/utils/url';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
class Comment extends React.Component {
|
||||
ref = null;
|
||||
|
||||
state = { showingEditHistory: false };
|
||||
|
||||
handleRef = ref => (this.ref = ref);
|
||||
|
||||
handleFocusOrClick = () => {
|
||||
@@ -43,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();
|
||||
@@ -126,14 +154,23 @@ class Comment extends React.Component {
|
||||
{comment.user.username}
|
||||
</span>
|
||||
|
||||
<span className={styles.created}>
|
||||
{timeago(comment.created_at)}
|
||||
</span>
|
||||
<TimeAgo
|
||||
className={styles.created}
|
||||
datetime={comment.created_at}
|
||||
/>
|
||||
{comment.editing && comment.editing.edited ? (
|
||||
<span>
|
||||
<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}>
|
||||
@@ -168,7 +205,7 @@ class Comment extends React.Component {
|
||||
<div className={styles.commentContentFooter}>
|
||||
<a
|
||||
className={styles.external}
|
||||
href={`${comment.asset.url}?commentId=${comment.id}`}
|
||||
href={buildCommentURL(comment.asset.url, comment.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -180,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}`}>
|
||||
@@ -199,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}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './Story.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const formatDate = date => {
|
||||
const d = new Date(date);
|
||||
@@ -14,7 +15,9 @@ const Story = ({ author, title, createdAt, open, id, goToStory }) => {
|
||||
<div className={styles.meta}>
|
||||
<span className={styles.author}>By {author}</span>
|
||||
<span className={styles.createdAt}>{formatDate(createdAt)}</span>
|
||||
<span className={styles.status}>{open ? 'Open' : 'Closed'}</span>
|
||||
<span className={styles.status}>
|
||||
{open ? t('streams.open') : t('streams.closed')}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -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,41 @@ class StoriesContainer extends Component {
|
||||
const { updateAssetState } = this.props;
|
||||
|
||||
try {
|
||||
updateAssetState(id, closeStream ? Date.now() : null);
|
||||
await updateAssetState(id, closeStream ? Date.now() : null);
|
||||
this.fetchAssets();
|
||||
} 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 +90,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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as actions from '../constants/stream';
|
||||
import { buildUrl } from 'coral-framework/utils/url';
|
||||
import queryString from 'query-string';
|
||||
import queryString from 'querystringify';
|
||||
|
||||
export const setActiveReplyBox = id => ({
|
||||
type: actions.SET_ACTIVE_REPLY_BOX,
|
||||
@@ -14,12 +14,13 @@ export const setSort = ({ sortOrder, sortBy }) => ({
|
||||
});
|
||||
|
||||
export const viewAllComments = () => (dispatch, _, { pym }) => {
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
comment_id: undefined,
|
||||
});
|
||||
const query = queryString.parse(location.search);
|
||||
|
||||
// remove the comment_id url param
|
||||
delete query.comment_id;
|
||||
|
||||
const search = queryString.stringify(query);
|
||||
|
||||
const url = buildUrl({ ...location, search });
|
||||
|
||||
try {
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -9,6 +9,7 @@ import cn from 'classnames';
|
||||
import { getTotalReactionsCount } from 'coral-framework/utils';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import { buildCommentURL } from 'coral-framework/utils/url';
|
||||
|
||||
class Comment extends React.Component {
|
||||
render() {
|
||||
@@ -79,9 +80,10 @@ class Comment extends React.Component {
|
||||
<li>
|
||||
<a
|
||||
className={styles.viewLink}
|
||||
href={`${this.props.comment.asset.url}?commentId=${
|
||||
href={buildCommentURL(
|
||||
this.props.comment.asset.url,
|
||||
this.props.comment.id
|
||||
}`}
|
||||
)}
|
||||
target="_parent"
|
||||
>
|
||||
<Icon name="open_in_new" className={styles.iconView} />
|
||||
|
||||
@@ -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,195 +0,0 @@
|
||||
import queryString from 'query-string';
|
||||
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 search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
commentId: undefined,
|
||||
});
|
||||
|
||||
// Remove the commentId url param.
|
||||
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,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { timeago } from 'coral-framework/services/i18n';
|
||||
import cn from 'classnames';
|
||||
import styles from './CommentTimestamp.css';
|
||||
import TimeAgo from 'coral-framework/components/TimeAgo';
|
||||
|
||||
const CommentTimestamp = ({ className, created_at }) => (
|
||||
<div className={cn(className, styles.timestamp, 'talk-comment-timestamp')}>
|
||||
{timeago(created_at)}
|
||||
</div>
|
||||
<TimeAgo
|
||||
className={cn(className, styles.timestamp, 'talk-comment-timestamp')}
|
||||
datetime={created_at}
|
||||
/>
|
||||
);
|
||||
|
||||
CommentTimestamp.propTypes = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.timeago {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { timeago } from 'coral-framework/services/i18n';
|
||||
import cn from 'classnames';
|
||||
import styles from './TimeAgo.css';
|
||||
|
||||
class TimeAgo extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { timeago: true };
|
||||
}
|
||||
toggleDate = () => this.setState({ timeago: !this.state.timeago });
|
||||
render() {
|
||||
var displayTime = this.state.timeago
|
||||
? timeago(this.props.datetime)
|
||||
: new Date(this.props.datetime).toLocaleString();
|
||||
|
||||
var titleDate = !this.state.timeago
|
||||
? timeago(this.props.datetime)
|
||||
: new Date(this.props.datetime).toLocaleString();
|
||||
return (
|
||||
<span
|
||||
onClick={this.toggleDate}
|
||||
className={cn(
|
||||
this.props.className,
|
||||
styles.timeago,
|
||||
'talk-comment-timeago'
|
||||
)}
|
||||
title={titleDate}
|
||||
>
|
||||
{displayTime}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TimeAgo.propTypes = {
|
||||
datetime: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default TimeAgo;
|
||||
@@ -1,7 +1,7 @@
|
||||
import url from 'url';
|
||||
import parse from 'url-parse';
|
||||
|
||||
const base = document.querySelector('base');
|
||||
const baseUrl = (base && url.parse(base.href)) || {};
|
||||
const baseUrl = (base && parse(base.href)) || {};
|
||||
|
||||
export const BASE_URL = base.href;
|
||||
export const BASE_PATH = baseUrl.pathname;
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
+32
-2
@@ -22,6 +22,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 +47,30 @@ 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 (
|
||||
!bowser.safari &&
|
||||
!bowser.ios &&
|
||||
storage &&
|
||||
storage.getItem('token')
|
||||
) {
|
||||
// Use local storage auth tokens where there's a stable api.
|
||||
return storage.getItem('token');
|
||||
}
|
||||
@@ -135,8 +159,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 +198,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,
|
||||
@@ -75,10 +83,15 @@ let TIMEAGO_INSTANCE;
|
||||
// detectLanguage will try to get the locale from storage if available,
|
||||
// otherwise will try to get it from the navigator, otherwise, it will fallback
|
||||
// to the default language.
|
||||
const detectLanguage = () =>
|
||||
first(
|
||||
const detectLanguage = () => {
|
||||
var browserLanguages = navigator.languages;
|
||||
//IE11 and MS-EDGE do not provide navigator.languages
|
||||
if (!browserLanguages) {
|
||||
browserLanguages = [navigator.language];
|
||||
}
|
||||
return first(
|
||||
negotiateLanguages(
|
||||
navigator.languages,
|
||||
browserLanguages,
|
||||
whitelistedLanguages || supportedLocales,
|
||||
{
|
||||
defaultLocale,
|
||||
@@ -86,6 +99,7 @@ const detectLanguage = () =>
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export function setupTranslations() {
|
||||
// locale
|
||||
@@ -100,10 +114,12 @@ export function setupTranslations() {
|
||||
ta.register('da', daTA);
|
||||
ta.register('de', deTA);
|
||||
ta.register('fr', frTA);
|
||||
ta.register('nl-NL', nlTA);
|
||||
ta.register('pt-BR', pt_BRTA);
|
||||
ta.register('zh-CN', zh_CNTA);
|
||||
ta.register('zh-TW', zh_TWTA);
|
||||
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);
|
||||
ta.register('zh_TW', zh_TWTA);
|
||||
TIMEAGO_INSTANCE = ta();
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -6,6 +6,7 @@ import { capitalize } from 'coral-framework/helpers/strings';
|
||||
import assignWith from 'lodash/assignWith';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
export * from 'coral-framework/helpers/strings';
|
||||
export * from './url';
|
||||
|
||||
export const getTotalActionCount = (type, comment) => {
|
||||
return comment.action_summaries
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import parse from 'url-parse';
|
||||
|
||||
export function buildUrl(
|
||||
{ protocol, hostname, port, pathname, search, hash } = window.location
|
||||
) {
|
||||
@@ -10,3 +12,10 @@ export function buildUrl(
|
||||
port ? `:${port}` : ''
|
||||
}${pathname}${search}${hash}`;
|
||||
}
|
||||
|
||||
export function buildCommentURL(assetURL, commentID) {
|
||||
const u = parse(assetURL, true);
|
||||
u.query.commentId = commentID;
|
||||
u.set('query', u.query);
|
||||
return u.href;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -76,6 +102,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 +243,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 +253,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 +312,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`;
|
||||
|
||||
+11
-5
@@ -76,9 +76,7 @@ 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
|
||||
children:
|
||||
@@ -98,12 +96,16 @@ sidebar:
|
||||
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: Asset Management
|
||||
url: /integrating/asset-management/
|
||||
- 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
|
||||
@@ -118,6 +120,8 @@ sidebar:
|
||||
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
|
||||
@@ -162,6 +166,8 @@ sidebar:
|
||||
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
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"hexo": {
|
||||
"version": "3.7.1"
|
||||
"version": "3.8.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "hexo serve",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/ /talk/
|
||||
/talk/reference/server/ /talk/api/server/
|
||||
/talk/reference/graphql/ /talk/api/graphql/
|
||||
/talk/reference/graphql/ /talk/api/graphql/
|
||||
/talk/integrating/asset-management/ /talk/integrating/cms-integration/
|
||||
|
||||
@@ -5,9 +5,18 @@ permalink: /
|
||||
|
||||
Online comments are broken. Our open-source Talk tool rethinks how moderation,
|
||||
comment display, and conversation function, creating the opportunity for safer,
|
||||
smarter discussions around your work. Read more about our product features and
|
||||
goals [here](https://coralproject.net/talk). The
|
||||
documentation available here is pertaining to the technical details for
|
||||
smarter discussions around your work.
|
||||
|
||||
More than 60 newsrooms use Talk to run their on-site communities, including the Washington Post, the Wall Street Journal, and IGN.
|
||||
|
||||
[Read more about our product features and
|
||||
goals here](https://coralproject.net/talk).
|
||||
|
||||
<div class="callout">
|
||||
We offer hosting and support packages for Talk. [Contact us for more information.](https://coralproject.net/pricing/)
|
||||
</div>
|
||||
|
||||
The documentation available here is pertaining to the technical details for
|
||||
installing, configuring, and deploying Talk.
|
||||
|
||||
Talk is a [Node](https://nodejs.org/) application with
|
||||
@@ -22,13 +31,10 @@ to persist data. The following versions are supported:
|
||||
- MongoDB 3.2+
|
||||
- Redis 3.2.5+
|
||||
|
||||
An optional dependency for Talk is
|
||||
[Docker](https://www.docker.com/community-edition#/download).
|
||||
It is used during development to set up the database and can be
|
||||
used to [install via Docker](#installation-from-docker). We have tested Talk
|
||||
and this documentation with versions 17.06.2+.
|
||||
You can run Talk (and its dependencies) locally or from [Docker](https://www.docker.com/community-edition#/download) containers. Docker is used in the local example below for the database and cache, however it is possible to run Talk without Docker by configuring your own MongoDB and Redis instances. We have tested Talk
|
||||
and this documentation with Docker versions 17.06.2+.
|
||||
|
||||
Another optional dependency for Talk is
|
||||
An optional dependency for Talk is
|
||||
[Docker Compose](https://docs.docker.com/compose/install/). It
|
||||
can be used to setup your environment easily for testing. We have tested Talk
|
||||
and this documentation with versions 1.14.0+.
|
||||
@@ -38,8 +44,9 @@ and this documentation with versions 1.14.0+.
|
||||
### Installation from Docker
|
||||
|
||||
To use Talk without major customization you can run the application using our
|
||||
provided docker image. The following is a `docker-compose.yml` file that can
|
||||
be used to setup Talk:
|
||||
provided docker image.
|
||||
|
||||
Start by making a new directory and create a file called `docker-compose.yml` and copy the following:
|
||||
|
||||
```yml
|
||||
# For details on the syntax of docker-compose.yml files, check out:
|
||||
@@ -48,7 +55,7 @@ be used to setup Talk:
|
||||
version: '2'
|
||||
services:
|
||||
talk:
|
||||
image: coralproject/talk:4.5
|
||||
image: coralproject/talk:4
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -79,7 +86,7 @@ volumes:
|
||||
external: false
|
||||
```
|
||||
|
||||
This is the bare minimum needed to run the demo, for more configuration
|
||||
The environment variables listed above are the bare minimum needed to run the demo, for more configuration
|
||||
variables, check out the [Configuration](/talk/configuration/) section.
|
||||
|
||||
|
||||
@@ -104,8 +111,7 @@ Creating talk_1 ...
|
||||
Creating talk_1 ... done
|
||||
```
|
||||
|
||||
|
||||
And when you run `docker-compose ps`, you should see something like:
|
||||
Once everything has completed, run `docker-compose ps`, and you should see something like:
|
||||
|
||||
```
|
||||
Name Command State Ports
|
||||
@@ -116,8 +122,8 @@ talk_1 yarn start Up 0.0.0.0:3000->3000/tcp
|
||||
```
|
||||
|
||||
|
||||
Continue onto the [Running](#running) section for details on how to complete the
|
||||
installation and get started using Talk.
|
||||
You now have a Talk instance up and running! Continue on to the [Setup](#setup) section for details on how to complete the
|
||||
initial setup and get started using Talk.
|
||||
|
||||
### Installation from Source
|
||||
|
||||
@@ -178,32 +184,37 @@ You can now start the application by running:
|
||||
yarn watch:server
|
||||
```
|
||||
|
||||
Continue onto the [Running](#running) section for details on how to complete the
|
||||
Continue onto the [Setup](#setup) section for details on how to complete the
|
||||
installation and get started using Talk.
|
||||
|
||||
## Running
|
||||
## Setup
|
||||
### Create Admin Account
|
||||
|
||||
You can now navigate to
|
||||
With Talk running, you can now navigate to
|
||||
[http://127.0.0.1:3000/admin/install](http://127.0.0.1:3000/admin/install)
|
||||
and go through the admin installation. There you will be prompted to create your
|
||||
first admin account, and specify the domain whitelist for domains that are
|
||||
allowed to have the comment box on.
|
||||
and walk through the initial setup steps.
|
||||
|
||||
* First, enter your **Organization Name** and **Organization Contact Email**. This will appear in emails when inviting new team members.
|
||||
* Next, create your Admin user. You can specify an **Email Address**, **Username**, and **Password**
|
||||
|
||||
* Finally, enter your list of **Permitted Domains**, read [here](/talk/configuring-talk/#permitted-domains) about whitelisting domains
|
||||
|
||||
|
||||
_During development, ensure you whitelist 127.0.0.1:3000 otherwise the
|
||||
[http://127.0.0.1:3000/](http://127.0.0.1:3000/) page will not
|
||||
load._
|
||||
|
||||
Once you've completed the installation, you can visit
|
||||
[http://127.0.0.1:3000/](http://127.0.0.1:3000/) where you can
|
||||
view our development area where we test out features in Talk where you can write
|
||||
comments and see them in the admin interface where you can do moderation and
|
||||
reconfigure the user experience.
|
||||
Once the setup wizard has been completed you can log into Talk ([http://127.0.0.1:3000/](http://127.0.0.1:3000/)) using the email address and password for the Admin user account that you just created.
|
||||
|
||||
## Demo
|
||||
From here you can test out features in Talk, see comments in the admin interface where you can do moderation, and configure the user experience.
|
||||
In the next step you'll create some user comments to moderate.
|
||||
|
||||
|
||||
### Demo Embedded Comments
|
||||
|
||||
If you've followed the documentation above, you'll now have a running copy of
|
||||
Talk. To demonstrate what your own self-hosted copy of Talk can do, below
|
||||
you'll find a demo that can be used to test the copy that is running now on your
|
||||
Talk. To demonstrate what your own self-hosted copy of Talk can do, we created the demo below
|
||||
that can be used to test the copy that is running now on your
|
||||
machine.
|
||||
|
||||
In order for the demo to work, you must add
|
||||
@@ -220,6 +231,17 @@ Once you have added the domain of these docs, you can click the button below.
|
||||
<div class="mount"></div>
|
||||
</div>
|
||||
|
||||
### Developer Endpoints
|
||||
|
||||
With your local instance of Talk running in development mode (env variable `NODE_ENV=development`) you should now also be able to access the following developer routes:
|
||||
|
||||
* [http://127.0.0.1:3000/dev](http://127.0.0.1:3000/dev]) provides a sample comment stream
|
||||
|
||||
* [http://127.0.0.1:3000/dev/assets](http://127.0.0.1:3000/dev/assets]) provides a list of all stories in Talk and can generate new sample assets
|
||||
|
||||
### Conclusion
|
||||
At this point you've successfully installed, configured, and ran your very own
|
||||
instance of Talk! Continue through this documentation on this site to learn more
|
||||
on how to configure, develop with, and contribute to Talk!
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ be used to setup Talk:
|
||||
version: '2'
|
||||
services:
|
||||
talk:
|
||||
image: coralproject/talk:4.5
|
||||
image: coralproject/talk:4
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -121,7 +121,7 @@ base installation with additional custom plugins. Images can be created with the
|
||||
most basic of `Dockerfile`'s:
|
||||
|
||||
```docker
|
||||
FROM coralproject/talk:4.5-onbuild
|
||||
FROM coralproject/talk:4-onbuild
|
||||
```
|
||||
|
||||
And running the following to build the docker image:
|
||||
@@ -153,7 +153,7 @@ your containerized infrastructure. The versioning of our Docker tags as well
|
||||
lets you do something like:
|
||||
|
||||
```docker
|
||||
FROM coralproject/talk:4.5-onbuild
|
||||
FROM coralproject/talk:4-onbuild
|
||||
```
|
||||
|
||||
Which would pin your image to `4.5.x release's.
|
||||
Which would pin your image to `4.x.x release's.
|
||||
|
||||
@@ -70,5 +70,7 @@ You can now start the application by running:
|
||||
yarn watch:server
|
||||
```
|
||||
|
||||
If you are developing a custom plugin you can use `yarn watch:client` or `yarn watch` to run both client and server.
|
||||
|
||||
At this stage, you should refer to the [configuration](/talk/configuration/) for
|
||||
configuration variables that are specific to your installation.
|
||||
|
||||
@@ -19,3 +19,40 @@ 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
|
||||
|
||||
## How to Scale Talk Components
|
||||
### Scaling the Talk Application
|
||||
|
||||
In addition to scaling by adding additional app servers, Talk as a server component can flex to various roles. Depending on the desired configuration the roles can be split up to ensure high availability, and best matching to underlying host resources.
|
||||
|
||||
There are three components to the Talk application that can be served independently or in combination on a single thread: API Server, WebSockets, and Jobs
|
||||
|
||||

|
||||
|
||||
In the diagram we see the difference between a Typical configuration (which just adds additional Talk application instances) vs the Split configuration where different Talk components could be spread across different machine resources to optimize infrastructure utilization.
|
||||
See [Serving the Application](/talk/configuration-cli-tools/#serving-the-application)
|
||||
|
||||
### Scaling Redis
|
||||
|
||||
Redis serves as a general cache and pub/sub broker. We treat all the data stored in Redis as ephemeral, so using as a cache serves us well for Talk to synchronize expensive query caches. It also serves as our pub/sub broker for use with live updates as propagated through the GraphQL subscription system. For this reason it is not recommended to implement multiple instances of Redis.
|
||||
|
||||
### Scaling MongoDB
|
||||
|
||||
MongoDB is treated as our general store for persisted data. Talk supports the most common strategies for scaling MongoDB instances including replicas and/or sharding. Depending on your specific data use cases, refer to MongoDBs documentation for more information about scaling https://www.mongodb.com/mongodb-scale.
|
||||
|
||||
### Load Balancer
|
||||
While this subject lives outside the Talk ecosystem, it is critical for application delivery. For websockets to work correctly, a load balancer must be selected that can support long lived connections that are required for websockets to work.
|
||||
|
||||
## Running Talk in Production
|
||||
|
||||
When you are ready to launch your production instance of Talk update your NODE_ENV environment variable from `development` to `production` mode.
|
||||
|
||||
Then launch talk with `yarn start` or with the command `NODE_ENV=production ./bin/cli-serve -j -w`
|
||||
|
||||
By default Talk will run on a single thread, but you can also run multiple Talk threads on a single application instance by setting the environment variable `TALK_CONCURRENCY`. [See Advanced Configuration](/talk/advanced-configuration/#talk-concurrency)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ permalink: /pre-launch-checklist/
|
||||
- [ ] Do you have a domain name for Talk?
|
||||
- **Recommended**: You should host Talk on a subdomain on your main site (e.g. if your site is mysitefornews.org, you should serve Talk from a subdomain like talk.mysitefornews.org) to avoid issues with third-party cookie sharing.
|
||||
|
||||
|
||||
- [ ] Do you have a Content Delivery Network (CDN) to improve performance and reduce load on your Talk servers?
|
||||
|
||||
|
||||
- [ ] Do you have a MongoDB instance?
|
||||
- A MongoDB [Docker instance](/talk/installation-from-docker/) hosted alongside Talk?
|
||||
@@ -23,12 +26,16 @@ permalink: /pre-launch-checklist/
|
||||
|
||||
- [ ] Do you need to migrate comments from a legacy system? We currently support Disqus, Livefyre, and Civil Comments.
|
||||
- Use the [Talk Import](https://github.com/coralproject/talk-importer) framework
|
||||
|
||||
|
||||
|
||||
- [ ] Do you want to provide single sign-on (SSO) by integrating with an external auth system?
|
||||
- See [Authenticating with Talk](/talk/integrating/authentication/)
|
||||
|
||||
|
||||
- [ ] Do you want to integrate Talk with your CMS to automate embedding Talk Comment Stream into your site?
|
||||
- See [CMS Integration](/talk/integrating/cms-integration/)
|
||||
|
||||
|
||||
- [ ] Do you want to use Social sign-on?
|
||||
- Facebook
|
||||
- Install [talk-plugin-facebook-auth](/talk/plugin/talk-plugin-facebook-auth/)
|
||||
@@ -56,9 +63,10 @@ permalink: /pre-launch-checklist/
|
||||
- Install [talk-plugin-rich-text](/talk/plugin/talk-plugin-rich-text)
|
||||
|
||||
|
||||
- [ ] Do you want to display comment counts?
|
||||
- Use the GraphQL [CommentCountQuery](https://docs.coralproject.net/talk/api/graphql/#CommentCountQuery)
|
||||
- Install [talk-plugin-deep-reply-count](/talk/plugin/talk-plugin-deep-reply-count) if necessary.
|
||||
- [ ] Do you want to display comment counts on your embed stream or on a homepage with dozens of articles?
|
||||
- Install [talk-plugin-comment-count](https://github.com/coralproject/talk-plugin-comment-count) for summary counts on multiple articles
|
||||
- Install [talk-plugin-deep-reply-count](/talk/plugin/talk-plugin-deep-reply-count) to add counts to the embed stream
|
||||
- Or use the GraphQL [CommentCountQuery](https://docs.coralproject.net/talk/api/graphql/#CommentCountQuery)
|
||||
|
||||
|
||||
- [ ] Do you want to translate Talk to a different language?
|
||||
@@ -69,7 +77,8 @@ permalink: /pre-launch-checklist/
|
||||
- See [our blog for more information](https://coralproject.net/blog/slacking-on/)
|
||||
|
||||
|
||||
- [ ] Has your community team configured Talk to match your community strategy?
|
||||
- [ ] Have you configured Talk’s admin settings and determined your community strategy?
|
||||
- See [our tutorial for more information](https://docs.coralproject.net/talk/when-youve-installed-talk/)
|
||||
- See [Configuring Talk](/talk/configuring-talk/)
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user