mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1098c068f2 | ||
|
|
f0c95d0044 | ||
|
|
d31969592c | ||
|
|
794487b1a9 | ||
|
|
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 | ||
|
|
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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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,4 +1,4 @@
|
||||
import queryString from 'query-string';
|
||||
import queryString from 'querystringify';
|
||||
|
||||
import {
|
||||
FETCH_ASSETS_REQUEST,
|
||||
|
||||
@@ -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')}{' '}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,9 @@ 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 {
|
||||
approve = () =>
|
||||
@@ -73,9 +75,7 @@ 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}>
|
||||
@@ -112,7 +112,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 +123,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}>
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,6 +11,7 @@ const initialState = {
|
||||
criteria: {
|
||||
asc: 'false',
|
||||
filter: 'all',
|
||||
limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -188,4 +195,4 @@
|
||||
|
||||
.commentContentFooter {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ 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;
|
||||
@@ -126,9 +128,10 @@ 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}>
|
||||
@@ -168,7 +171,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 +183,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}`}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ 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);
|
||||
|
||||
@@ -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,324 @@
|
||||
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`],
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
...opts
|
||||
}
|
||||
) {
|
||||
this.pym = null;
|
||||
this.element = element;
|
||||
this.opts = opts;
|
||||
this.query = buildQuery(this.opts);
|
||||
this.emitter = new EventEmitter({ wildcard: true });
|
||||
this.snackBar = new SnackBar(snackBarStyles || {});
|
||||
this.onAuthChanged = onAuthChanged;
|
||||
this.talkBaseUrl = ensureEndSlash(talkBaseUrl);
|
||||
this.talkStaticUrl = ensureEndSlash(talkStaticUrl);
|
||||
this.lazy = lazy;
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
|
||||
// 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.
|
||||
this.snackBar.remove();
|
||||
|
||||
// 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,71 @@
|
||||
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
|
||||
* @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');
|
||||
};
|
||||
+26
-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');
|
||||
}
|
||||
@@ -136,7 +160,7 @@ export async function createContext({
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
@@ -88,21 +88,23 @@ export function createClient(options = {}) {
|
||||
});
|
||||
|
||||
client.resetWebsocket = () => {
|
||||
// Close socket connection which will also unregister subscriptions on the server-side.
|
||||
wsClient.close();
|
||||
if (wsClient.client) {
|
||||
// Close socket connection which will also unregister subscriptions on the server-side.
|
||||
wsClient.close(true);
|
||||
|
||||
// Reconnect to the server.
|
||||
wsClient.connect();
|
||||
// Reconnect to the server.
|
||||
wsClient.connect();
|
||||
|
||||
// Reregister all subscriptions (uses non public api).
|
||||
// See: https://github.com/apollographql/subscriptions-transport-ws/issues/171
|
||||
Object.keys(wsClient.operations).forEach(id => {
|
||||
wsClient.sendMessage(
|
||||
id,
|
||||
MessageTypes.GQL_START,
|
||||
wsClient.operations[id].options
|
||||
);
|
||||
});
|
||||
// Re-register all subscriptions (uses non public api).
|
||||
// See: https://github.com/apollographql/subscriptions-transport-ws/issues/171
|
||||
Object.keys(wsClient.operations).forEach(id => {
|
||||
wsClient.sendMessage(
|
||||
id,
|
||||
MessageTypes.GQL_START,
|
||||
wsClient.operations[id].options
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return client;
|
||||
|
||||
@@ -12,6 +12,8 @@ import 'moment/locale/da';
|
||||
import 'moment/locale/de';
|
||||
import 'moment/locale/es';
|
||||
import 'moment/locale/fr';
|
||||
import 'moment/locale/he';
|
||||
import 'moment/locale/it';
|
||||
import 'moment/locale/nl';
|
||||
import 'moment/locale/pt-br';
|
||||
|
||||
@@ -22,6 +24,8 @@ import daTA from 'timeago.js/locales/da';
|
||||
import deTA from 'timeago.js/locales/de';
|
||||
import esTA from 'timeago.js/locales/es';
|
||||
import frTA from 'timeago.js/locales/fr';
|
||||
import heTA from 'timeago.js/locales/he';
|
||||
import itTA from 'timeago.js/locales/it';
|
||||
import nlTA from 'timeago.js/locales/nl';
|
||||
import pt_BRTA from 'timeago.js/locales/pt_BR';
|
||||
import zh_CNTA from 'timeago.js/locales/zh_CN';
|
||||
@@ -34,6 +38,8 @@ import da from '../../../locales/da.yml';
|
||||
import de from '../../../locales/de.yml';
|
||||
import es from '../../../locales/es.yml';
|
||||
import fr from '../../../locales/fr.yml';
|
||||
import he from '../../../locales/he.yml';
|
||||
import it from '../../../locales/it.yml';
|
||||
import nl_NL from '../../../locales/nl_NL.yml';
|
||||
import pt_BR from '../../../locales/pt_BR.yml';
|
||||
import zh_CN from '../../../locales/zh_CN.yml';
|
||||
@@ -61,6 +67,8 @@ export const translations = {
|
||||
...de,
|
||||
...es,
|
||||
...fr,
|
||||
...he,
|
||||
...it,
|
||||
...nl_NL,
|
||||
...pt_BR,
|
||||
...zh_CN,
|
||||
@@ -106,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`;
|
||||
|
||||
+9
-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
|
||||
@@ -162,6 +164,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,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?
|
||||
|
||||
@@ -23,6 +23,10 @@ otherwise the application will fail to start.
|
||||
Configure the duration for which comment counts are cached for, parsed by
|
||||
[ms](https://www.npmjs.com/package/ms). (Default `1hr`)
|
||||
|
||||
## TALK_CONCURRENCY
|
||||
|
||||
This environment variable allows multiple worker processes to be spawned to handle traffic. (Default `1`)
|
||||
|
||||
## TALK_DEFAULT_LANG
|
||||
|
||||
This is a **Build Variable** and must be consumed during build. If using the
|
||||
@@ -246,6 +250,21 @@ Refer to the documentation for [TALK_JWT_ALG](#talk-jwt-alg) for other signing
|
||||
methods and other forms of the `TALK_JWT_SECRET`. If you are interested in using
|
||||
multiple keys, then refer to [TALK_JWT_SECRETS](#talk-jwt-secrets).
|
||||
|
||||
You can also encode your secret as a base64 string (if you are using a symmetric
|
||||
algorithm) as long as you prefix it with `base64:`. For example:
|
||||
|
||||
```plain
|
||||
TALK_JWT_SECRET={"secret": "base64:dGVzdA=="}
|
||||
```
|
||||
|
||||
Would be the same as:
|
||||
|
||||
```plain
|
||||
TALK_JWT_SECRET={"secret": "test"}
|
||||
```
|
||||
|
||||
As `dGVzdA==` is just `test` encoded using base64.
|
||||
|
||||
## TALK_JWT_SECRETS
|
||||
|
||||
Used when specifying multiple secrets used for key rotations. This is a JSON
|
||||
@@ -271,7 +290,6 @@ Note that the secret is stored in a JSON object, keyed by `secret`. This is only
|
||||
needed when specifying in the multiple secrets for `TALK_JWT_SECRETS`, but may
|
||||
be used to specify the single [TALK_JWT_SECRET](#talk-jwt-secret).
|
||||
|
||||
|
||||
When the value of [TALK_JWT_ALG](#talk-jwt-alg) is **not** a `HS*` value, then
|
||||
the value of the `TALK_JWT_SECRETS` should take the form:
|
||||
|
||||
@@ -282,7 +300,6 @@ TALK_JWT_SECRETS=[{"kid": "1", "private": "<my private key>", "public": "<my pub
|
||||
Refer to the documentation on the [TALK_JWT_ALG](#talk-jwt-alg) for more
|
||||
information on what to store in these parameters.
|
||||
|
||||
|
||||
## TALK_JWT_SIGNING_COOKIE_NAME
|
||||
|
||||
The default cookie name that is use to set a cookie containing a JWT that was
|
||||
@@ -399,13 +416,19 @@ cannot. You would use this option in the latter situation.
|
||||
|
||||
## TALK_SMTP_FROM_ADDRESS
|
||||
|
||||
The email address to send emails from using the SMTP provider in the format:
|
||||
The email address to send emails from using the SMTP provider. You can include the name and email address or only provide the email.
|
||||
|
||||
```plain
|
||||
TALK_SMTP_FROM_ADDRESS="The Coral Project" <support@coralproject.net>
|
||||
TALK_SMTP_FROM_ADDRESS="The Coral Project" support@coralproject.net
|
||||
|
||||
```
|
||||
or
|
||||
|
||||
```plain
|
||||
TALK_SMTP_FROM_ADDRESS=support@coralproject.net
|
||||
|
||||
```
|
||||
|
||||
Including the name and email address.
|
||||
|
||||
## TALK_SMTP_HOST
|
||||
|
||||
@@ -482,8 +505,9 @@ Could be read as:
|
||||
pre-moderated once in order to post freely again. If they instead get rejected
|
||||
again, then they must have two of their comments approved in order to get
|
||||
added back to the queue.
|
||||
- At the moment of writing, behavior is not attached to the flagging
|
||||
reliability, but it is recorded.
|
||||
- At the moment of writing, behavior is not attached to reliable commenters
|
||||
as well as the flagging reliability and unreliability. Regardless,
|
||||
all of these scores are recorded for future implementation.
|
||||
|
||||
## TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF
|
||||
|
||||
@@ -576,3 +600,13 @@ can be used to set an authorization header, or change the user agent. (Default
|
||||
## TALK_SCRAPER_PROXY_URL
|
||||
|
||||
Sets a specific HTTP/S proxy to be used by the Asset Scraper using [https-proxy-agent](https://www.npmjs.com/package/https-proxy-agent). (Default `null`)
|
||||
|
||||
## TALK_ADDTL_REPLIES_ON_LOAD_MORE
|
||||
|
||||
This is a **Build Variable** and must be consumed during build. If using the
|
||||
[Docker-onbuild](/talk/installation-from-docker/#onbuild)
|
||||
image you can specify it with `--build-arg TALK_ADDTL_REPLIES_ON_LOAD_MORE=3`.
|
||||
|
||||
Specifies the number of replies to load for a comment when clicking "Load More".
|
||||
It is defaulted quite high to sort of support "loading the rest of the replies".
|
||||
(Default `999999`)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
---
|
||||
title: Configuration with CLI tools
|
||||
permalink: /configuration-cli-tools/
|
||||
class: configuration
|
||||
toc: true
|
||||
---
|
||||
|
||||
Talk can be configured using CLI tools. The CLI tools are available through `./bin/cli`. Below is a list of commands available:
|
||||
```
|
||||
Usage: cli [options] [command]
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help output usage information
|
||||
|
||||
Commands:
|
||||
|
||||
serve serve the application
|
||||
db run database commands
|
||||
settings interact with the application settings
|
||||
assets interact with assets
|
||||
setup setup the application
|
||||
jobs work with the job queues
|
||||
token work with the access tokens
|
||||
users work with the application auth
|
||||
migration provides utilities for migrating the database
|
||||
plugins provides utilities for interacting with the plugin system
|
||||
help [cmd] display help for [cmd]
|
||||
```
|
||||
|
||||
## Serving the application
|
||||
To serve the application, run `./bin/cli serve`. Please check that you have installed the Talk before doing this step. Below is a list of additional options available for this command:
|
||||
```
|
||||
Usage: cli-serve [options]
|
||||
|
||||
Options:
|
||||
|
||||
-j, --jobs enable job processing on this thread
|
||||
--disabled-jobs <jobs> disable jobs specified if the -j option is passed, specified as a comma separated list (default: )
|
||||
-w, --websockets enable the websocket (subscriptions) handler on this thread
|
||||
-h, --help output usage information
|
||||
```
|
||||
|
||||
## Database commands
|
||||
You can also run database commands using `./bin/cli db`. Below is a list of additional options available for this command:
|
||||
```
|
||||
Usage: cli-db [options] [command]
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help output usage information
|
||||
|
||||
Commands:
|
||||
|
||||
createIndexes creates the database indexes and waits until they are created
|
||||
```
|
||||
|
||||
## Application settings
|
||||
You can also change application settings using `./bin/cli settings`. Below is a list of additional options available for this command:
|
||||
```
|
||||
Usage: cli-settings [options] [command]
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help output usage information
|
||||
|
||||
Commands:
|
||||
|
||||
change-org-name change the organization name
|
||||
```
|
||||
|
||||
## Interaction with assets
|
||||
You can also interact with your application's assets using `./bin/cli assets`. Below is a list of additional options available for this command:
|
||||
```
|
||||
Usage: cli-assets [options] [command]
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help output usage information
|
||||
|
||||
Commands:
|
||||
|
||||
list [options] list all the assets in the database
|
||||
refresh [age] queues the assets that exceed the age requested
|
||||
update-url <assetID> <url> update the URL of an asset
|
||||
merge <srcID> <dstID> merges two assets together by moving comments from src to dst and deleting the src asset
|
||||
rewrite [options] <search> <replace> rewrites asset url's using the provided regex replacement pattern
|
||||
```
|
||||
|
||||
## Setting up the application
|
||||
You can also run a setup wizard to setup the wizard using `./bin/cli setup`. Below is a list of additional options available for this command:
|
||||
```
|
||||
Usage: cli-setup [options]
|
||||
|
||||
runs the setup wizard to setup the application
|
||||
|
||||
Options:
|
||||
|
||||
--defaults apply defaults for config instead of prompting
|
||||
-h, --help output usage information
|
||||
```
|
||||
|
||||
## Working with job queues
|
||||
You can also work with job queues using `./bin/cli jobs`. Below is a list of additional options available for this command:
|
||||
```
|
||||
Usage: cli-jobs [options] [command]
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help output usage information
|
||||
|
||||
Commands:
|
||||
|
||||
process starts job processing
|
||||
cleanup [options] cleans up inactive jobs
|
||||
```
|
||||
|
||||
## Working with access tokens
|
||||
You can also work with access tokens using `./bin/cli token`. Below is a list of additional options available for this command:
|
||||
```
|
||||
Usage: cli-token [options] [command]
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help output usage information
|
||||
|
||||
Commands:
|
||||
|
||||
list <userID> list tokens for a user
|
||||
revoke <tokenID> revokes a token with a given id
|
||||
create <userID> <tokenName> create a token for a user with a given name
|
||||
```
|
||||
|
||||
## Working with your application's authentication
|
||||
You can also work with your application's authentication using `./bin/cli users`. Below is a list of additional options available for this command:
|
||||
```
|
||||
Usage: cli-users [options] [command]
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help output usage information
|
||||
|
||||
Commands:
|
||||
|
||||
create creates a local user
|
||||
delete <userID> delete a user
|
||||
list searches for a user based on their stored username and email
|
||||
set-role <userID> sets the role on a user
|
||||
verify <userID> <email> verifies the given user's email address
|
||||
```
|
||||
|
||||
## Migrating your database
|
||||
Talk provides utilities for migrating your database using `./bin/cli migration`. Below is a list of additional options available for this command:
|
||||
```
|
||||
Usage: cli-migration [options] [command]
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help output usage information
|
||||
|
||||
Commands:
|
||||
|
||||
create <name> creates a new migration
|
||||
run [options] runs all pending migrations
|
||||
```
|
||||
|
||||
## Interacting with the plugin system
|
||||
Talk provides utilities to interact with the plugin system using `./bin/cli plugins`. Below is a list of additional options available for this command:
|
||||
```
|
||||
Usage: cli-plugins [options] [command]
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help output usage information
|
||||
|
||||
Commands:
|
||||
|
||||
create creates a seed plugin
|
||||
list
|
||||
reconcile [options] reconciles dependencies by downloading external plugins
|
||||
```
|
||||
@@ -5,13 +5,15 @@ permalink: /commenter-features/
|
||||
|
||||
## Signing up for Talk
|
||||
|
||||
There are 2 ways that newsrooms can support signup/login functionality with Talk:
|
||||
There are 3 ways that newsrooms can support signup/login functionality with Talk:
|
||||
|
||||
* Use Talk’s auth plugin out of the box (supports account registration with username and password, as well as features like forgot password)
|
||||
|
||||
* Use 3rd party authentication provider such as FaceBook or Google. We provide plugins that support logging in with either [Facebook](/talk/plugin/talk-plugin-facebook-auth/)
|
||||
or [Google](/talk/plugin/talk-plugin-google-auth/). (Note: you must provide your own Facebook App ID and Secret, which you can read more about here: [https://developers.facebook.com](https://developers.facebook.com))
|
||||
|
||||
* Create their own auth plugin to integrate with your own auth systems
|
||||
|
||||
We also provide a Facebook auth plugin that supports logging in with Facebook (you must provide your own Facebook App ID and Secret, which you can read more about here: [https://developers.facebook.com](https://developers.facebook.com))
|
||||
|
||||
## Comments and Replies
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ permalink: /moderator-features/
|
||||
|
||||
## The Talk Admin
|
||||
|
||||
The Admin is your moderators will moderate your comments, and your Admins will
|
||||
The Admin is where your moderators will moderate your comments, and your Admins will
|
||||
configure and manage the different parts of Talk.
|
||||
|
||||
### Moderate
|
||||
@@ -18,57 +18,99 @@ site-wide.
|
||||
|
||||
**New**
|
||||
|
||||
The New queue contains all comments that have not been moderated yet.
|
||||
>_Conditions:_ Pre-Mod disabled
|
||||
|
||||
**Reported**
|
||||
Comments have been published? _**Yes**_
|
||||
|
||||
The Reported queue contains all comments that need moderator attention.
|
||||
The New queue contains all comments that have not been moderated yet. This queue contains comments that haven’t yet been Accepted or Rejected by a moderator
|
||||
|
||||
**Approved**
|
||||
**Pre-mod**
|
||||
|
||||
The Approved queue contains all approved comments.
|
||||
>_Conditions:_ Pre-Mod enabled
|
||||
|
||||
Comments have been published? _**No**_
|
||||
|
||||
This queue contains comments that haven’t yet been Accepted or Rejected by a moderator
|
||||
|
||||
**Accepted**
|
||||
|
||||
Comments have been published? _**Yes**_
|
||||
|
||||
Comments that have been approved by a moderator, written by a Staff member (and so auto approved), or Featured (and so auto approved). Tags for reports still appear on comments in this queue, but the comments will not go back into Reported.
|
||||
|
||||
**Rejected**
|
||||
|
||||
The Rejected queue contains all comments that have been rejected, either
|
||||
manually by moderators or automatically, e.g. they have used a banned word.
|
||||
Comments have been published? _**No**_
|
||||
|
||||
Comments that have been rejected by a moderator, or rejected because they contained a banned word/phrase. Tags for reports still appear on comments in this queue, but the comments will not go back into Reported.
|
||||
|
||||
|
||||
**Reported**
|
||||
|
||||
>_Conditions:_ Pre-Mod disabled
|
||||
|
||||
Comments have been published? _**depends**_
|
||||
|
||||
This queue contains comments that have been reported for moderator review, either by users or automatically by Talk, and have not yet been Approved or Rejected. If there are comments to review in this queue, a red dot will be displayed in the top menu.
|
||||
|
||||
**All**
|
||||
|
||||
The All queue contains all comments that have been submitted either article or
|
||||
site-wide.
|
||||
|
||||
#### Moderation Badges
|
||||
#### Moderation Tags that appear in Reported/other queues
|
||||
|
||||
**Pre-mod**
|
||||
|
||||
The Pre-mod badge signifies comments that are being pre-modded.
|
||||
_Comment published? No_
|
||||
|
||||
The Pre-mod tag signifies comments that are being pre-modded.
|
||||
|
||||
**User**
|
||||
|
||||
The User badge signifies comments that have been reported by another user.
|
||||
_Comment published? Yes_
|
||||
|
||||
**History**
|
||||
Comment has been reported by a user. The ‘More Details’ box contains the name/s of the users who reported the comment, and any additional information they provided.
|
||||
|
||||
**Staff**
|
||||
|
||||
_Comment published? Yes_
|
||||
|
||||
Comment has been reported by a staff member. The ‘More Details’ box contains the name/s of the staff member who reported the comment, and any additional information they provided.
|
||||
|
||||
**Suspect Word**
|
||||
|
||||
_Comment published? Yes_
|
||||
|
||||
Comment contains a word or phrase that is on the ’Suspect words’ list in the Configure > Moderation tab (administrators only). The word/phrase will be highlighted in yellow.
|
||||
|
||||
**Karma**
|
||||
|
||||
_Comment published? No_
|
||||
|
||||
The History tag signifies comments that have been flagged because the user’s history indicates their Accepted:Rejected ratio has reached below the karma threshold. [Read more about karma here.](/talk/trust/#user-karma-score)
|
||||
|
||||
**Spam**
|
||||
|
||||
_Comment published? No_
|
||||
|
||||
If you are using the Akismet anti-spam plugin, this tag means that Akismet has flagged the comment as spam. You must Approve it for it to appear in the stream. **Requires:** [talk-plugin-akismet](/talk/plugin/talk-plugin-akismet/)
|
||||
|
||||
The History badge signifies comments that have been flagged because of a user’s
|
||||
history.
|
||||
|
||||
**Toxic**
|
||||
|
||||
The Toxic badge signifies comments that are above the set Toxicity Probability
|
||||
Threshold. Note you must have [talk-plugin-toxic-comments](/talk/additional-plugins/#talk-plugin-toxic-comments) enabled.
|
||||
[Read more about Toxic Comments here](/talk/toxic-comments/).
|
||||
_Comment published? No_
|
||||
|
||||
**Suspect**
|
||||
If you are using the Toxic Comments plugin, this tag means that Perspective API has flagged the comment as likely to be toxic, above the threshold you have set (default: 80%). You must Approve it for it to appear in the stream. **Requires:** [talk-plugin-toxic-comments](/talk/additional-plugins/#talk-plugin-toxic-comments).
|
||||
Read more about [Toxic Comments here](/talk/toxic-comments/).
|
||||
|
||||
The Suspect badge signifies comments that contain a Suspect Word.
|
||||
|
||||
**Contains Link**
|
||||
|
||||
The Contains Link badge signifies a comment that contains a link, which can
|
||||
The Contains Link tag signifies a comment that contains a link, which can
|
||||
sometimes mean it is a spam or ad comment.
|
||||
|
||||
**Flag Details View**
|
||||
**More Details View**
|
||||
|
||||
At the bottom of each comment in the moderation queues, you can see more
|
||||
information about a comment’s flags by clicking on More Detail.
|
||||
@@ -88,9 +130,35 @@ Rejecting a comment removes the comment from the stream.
|
||||
Featuring a comment adds that comment to the Featured Comments tab on the
|
||||
stream.
|
||||
|
||||
#### Keyboard Shortcuts
|
||||
|
||||
Talk also supports a number of keyboard shortcuts that moderators can leverage
|
||||
to moderate queues quickly:
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------- | -------------------------- |
|
||||
| `j` | Go to the next comment |
|
||||
| `k` | Go to the previous comment |
|
||||
| `ctrl+f` | Open search |
|
||||
| `1-5` | Switch queues |
|
||||
| `z` | Zen mode |
|
||||
| `?` | Open shortcuts menu |
|
||||
| `d` | Approve |
|
||||
| `f` | Reject |
|
||||
|
||||
Note: "Zen mode" allows a moderator to view and action only one comment at a time. Enjoy the silence!
|
||||
|
||||
#### The User Drawer
|
||||
|
||||
In order to get an idea of what sort of a commenter someone is, moderators can
|
||||
click on the commenter's username in any moderation queue/elsewhere in the admin (eg the Community page) to see details about
|
||||
their comments, and to take actions.
|
||||
|
||||
#### User Drawer Actions
|
||||
|
||||
**Suspend User**
|
||||
|
||||
Suspending a user allows a moderator to give a commenter a “time-out”; during
|
||||
Suspending a user allows a moderator to give a commenter a “time-out” for a set time chosen from four options; during
|
||||
that time they won’t be allowed to post comments or react to comments.
|
||||
|
||||
**Ban User**
|
||||
@@ -99,15 +167,13 @@ Banning a user allows a moderator to permanently disallow a commenter to
|
||||
interact with their community. The commenters previous comments will remain on
|
||||
the site. This action can only be un-done manually by a moderator.
|
||||
|
||||
#### Viewing a User’s Comment History
|
||||
**Reject Username**
|
||||
|
||||
In order to get an idea of what sort of a commenter someone is, moderators can
|
||||
click on the commenters username in any moderation queue and see details about
|
||||
their history.
|
||||
If a username is violating your guidelines but isn't bad enough to lead to a ban (eg. they are impersonating a famous person), you can reject their username. This will prevent them from publishing comments until they submit a new username. The new name will appear on the Community page for moderator approval before they can publish comments again.
|
||||
|
||||
**Username, Email and Member Since Date**
|
||||
|
||||
This shows the basic details about a commenter.
|
||||
This shows the basic details about a commenter's account
|
||||
|
||||
**Total Comments**
|
||||
|
||||
@@ -119,32 +185,17 @@ display on the site.
|
||||
This shows the % of comments a commenter has had rejected by moderators, or
|
||||
automatically.
|
||||
|
||||
**Reports**
|
||||
**Karma score**
|
||||
|
||||
This shows if a commenter is a reliable flagger, an unreliable flagger, or a
|
||||
neutral flagger. [Read more about reliable and unreliable flaggers here](/talk/trust/#reliable-and-unreliable-flaggers).
|
||||
The user's current score. To see the default threshhold, click on the 'i' next to the word Karma. If the user's score is equal to or below the default threshhold, the number will be highlighted red. [Read more about karma scores here.](/talk/trust/#user-karma-score)
|
||||
|
||||
**User History**
|
||||
|
||||
This shows the history of the account - if a user has changed their username, or been suspended or banned. The name of the moderator who suspended/banned them/lifted the suspection or ban is displayed here as well.
|
||||
|
||||
**Moderating from this View**
|
||||
|
||||
Talk also allows you to moderate a commenters recent comments from this view.
|
||||
|
||||
#### Keyboard Shortcuts
|
||||
|
||||
Talk also supports a number of keyboard shortcuts that moderators can leverage
|
||||
to moderate quickly:
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------- | -------------------------- |
|
||||
| `j` | Go to the next comment |
|
||||
| `k` | Go to the previous comment |
|
||||
| `ctrl+f` | Open search |
|
||||
| `t` | Switch queues |
|
||||
| `z` | Zen mode |
|
||||
| `?` | Open this menu |
|
||||
| `d` | Approve |
|
||||
| `f` | Reject |
|
||||
|
||||
Note: "Zen mode" allows a moderator to view and action only one comment at a time. Enjoy the silence!
|
||||
Talk also allows you to moderate a commenters recent comments from this view. You can 'Select all' to select up to ten previous comments, and take bulk accept/reject actions.
|
||||
|
||||
### Stories
|
||||
|
||||
|
||||
@@ -88,8 +88,9 @@ Comments with suspect words will display until a moderator takes action on them.
|
||||
|
||||
#### Permitted Domains
|
||||
|
||||
A list of domains where your Talk instance is allowed to be embedded. Typical
|
||||
use is `localhost`, `staging.yourdomain.com`, `yourdomain.com`, etc.
|
||||
A list of domains where your Talk instance is allowed to be embedded. List each domain and subdomain in use
|
||||
separately, for example: `localhost`, `staging.yourdomain.com`, `yourdomain.com`, `www.yourdomain.com`, etc.
|
||||
|
||||
|
||||
#### Embed Script
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Trust is a set of components within Talk that incorporate basic automated modera
|
||||
|
||||
## User Karma Score
|
||||
|
||||
Using Trust’s calculations, Talk will automatically hold back, move to the Reported queue, and tag with a 'History' marker, any comments by users who have an Unreliable karma score. (This is for sites who practice post-moderation. If you set pre-moderation of all comments sitewide, this feature has limited use.)
|
||||
Using Trust’s calculations, Talk will automatically hold back, move to the Reported queue, and tag with a 'Karma' marker, any comments by users who have an Unreliable karma score. (This is for sites who practice post-moderation. If you set pre-moderation of all comments sitewide, this feature has limited use.)
|
||||
|
||||
All users start out with a Neutral karma score (`0`). If they have a comment approved by a moderator, their score increases by `1`; if they have a comment rejected by a moderator, it decreases by `1`. When a commenter's score is labeled as Unreliable, their comments must be approved from the Reported queue before they are posted. Commenters are shown a message stating that a moderator will review their comment shortly.
|
||||
|
||||
@@ -27,29 +27,3 @@ We strongly recommend not telling your community how this system works, or where
|
||||
|
||||
If you see that a high proportion of first-time commenters on your site are abusive, you might want to change the threshhold to `0`, at least temporarily. You can configure your own Trust thresholds by using [TRUST_THRESHOLDS](/talk/advanced-configuration/#trust-thresholds) in your site configuration.
|
||||
|
||||
|
||||
## Reliable and Unreliable Flaggers
|
||||
|
||||
Trust also calculates how reliable users are in terms of the comments they
|
||||
report. This information is displayed to moderators in the User History drawer,
|
||||
which is accessed by clicking on a user’s name in the Admin. Currently, no other action is taken based on this score.
|
||||
|
||||
If a user's reports mostly match what moderators reject, their Report status
|
||||
will display to moderators as Reliable in the user information drawer. If a
|
||||
user's reports mostly differ from what moderators reject, their Report status
|
||||
will show as Unreliable.
|
||||
|
||||
If Talk doesn't have enough reports to make a call, or the reports even out, their
|
||||
status is Neutral.
|
||||
|
||||
Here are the default thresholds:
|
||||
|
||||
```text
|
||||
-1 and lower: Unreliable
|
||||
0 to +1: Neutral
|
||||
+2 and higher: Reliable
|
||||
```
|
||||
You can configure your own Trust thresholds by using [TRUST_THRESHOLDS](/talk/advanced-configuration/#trust-thresholds) in your
|
||||
configuration.
|
||||
|
||||
Note: Report Karma doesn't include reports of "I don't agree with this comment".
|
||||
|
||||
@@ -15,7 +15,7 @@ Even if GDPR will not apply to you, it is recommended to enable these
|
||||
features as a best practice to provide your users with control over their own
|
||||
data.
|
||||
|
||||
## GPDR Feature Overview
|
||||
## GDPR Feature Overview
|
||||
|
||||
Integrating our GDPR tools will give your users and organizations the following benefits:
|
||||
|
||||
|
||||
@@ -47,3 +47,9 @@ Source: [talk-recipes/tree/master/plugins/author-name](https://github.com/coralp
|
||||
Enables the ability to hover over a commenter’s name and add plugin
|
||||
functionality there. The Member Since plugin that is provided in this recipe is
|
||||
an example of this.
|
||||
|
||||
## recipe-custom-auth
|
||||
|
||||
Source: [talk-recipes/tree/master/plugins/custom-auth](https://github.com/coralproject/talk-recipes/tree/master/plugins/custom-auth)
|
||||
|
||||
Example code for creating, and updating Users in Talk from JWT data provided by your custom SSO authentication service.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Embedding Talk in Native Mobile Apps
|
||||
permalink: /embedding-in-native-mobile-apps/
|
||||
---
|
||||
|
||||
When it comes mobile devices, the Talk client embed is already responsive and mobile friendly. We do not currently have plans to provide mobile SDKs, however, you can do this yourself.
|
||||
|
||||
One organization that has taken on the challenge of creating their own iOS native Talk client is [IGN](http://www.ign.com/). They were kind enough to share how they implemented Talk natively on their apps, which can be found [here](http://coralproject.net/wp-content/uploads/2019/01/IGNNativeComments.pdf).
|
||||
|
||||
If you implement Talk on your mobile apps and would like to contribute your process for others, you can add those here in our docs via a PR or post an issue on Github and we can help you.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user