mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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
|
||||
|
||||
@@ -18,17 +18,15 @@ You’ve installed Talk on your server, and you’re preparing to launch it on y
|
||||
|
||||
## 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,
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
@@ -198,6 +250,27 @@ class People extends React.Component {
|
||||
{t('modqueue.ban_user_actions')}
|
||||
</ActionsMenuItem>
|
||||
)}
|
||||
|
||||
{isAlwaysPremod(user) ? (
|
||||
<ActionsMenuItem
|
||||
onClick={() =>
|
||||
this.removeAlwaysPremodUser({ id: user.id })
|
||||
}
|
||||
>
|
||||
Remove Always Premoderate
|
||||
</ActionsMenuItem>
|
||||
) : (
|
||||
<ActionsMenuItem
|
||||
onClick={() =>
|
||||
this.showAlwaysPremodUserDialog({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('modqueue.always_premod_user_actions')}
|
||||
</ActionsMenuItem>
|
||||
)}
|
||||
</ActionsMenu>
|
||||
</td>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
@@ -254,13 +327,16 @@ class People extends React.Component {
|
||||
|
||||
People.propTypes = {
|
||||
users: PropTypes.object.isRequired,
|
||||
onSearchChange: PropTypes.func.isRequired,
|
||||
filters: PropTypes.object.isRequired,
|
||||
onFilterChange: PropTypes.func.isRequired,
|
||||
setUserRole: PropTypes.func.isRequired,
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
unbanUser: PropTypes.func.isRequired,
|
||||
unsuspendUser: PropTypes.func.isRequired,
|
||||
removeAlwaysPremodUser: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func,
|
||||
showBanUserDialog: PropTypes.func,
|
||||
showAlwaysPremodUserDialog: PropTypes.func,
|
||||
loadMore: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -8,68 +8,76 @@ import {
|
||||
withUnbanUser,
|
||||
withUnsuspendUser,
|
||||
withSetUserRole,
|
||||
withRemoveAlwaysPremodUser,
|
||||
} from 'coral-framework/graphql/mutations';
|
||||
import { showBanUserDialog } from 'actions/banUserDialog';
|
||||
import { showSuspendUserDialog } from 'actions/suspendUserDialog';
|
||||
import { showAlwaysPremodUserDialog } from 'actions/alwaysPremodUserDialog';
|
||||
import { viewUserDetail } from '../../../actions/userDetail';
|
||||
import { appendNewNodes } from 'plugin-api/beta/client/utils';
|
||||
import update from 'immutability-helper';
|
||||
import { Spinner } from 'coral-ui';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
|
||||
class PeopleContainer extends React.Component {
|
||||
class PeopleContainer extends React.PureComponent {
|
||||
timer = null;
|
||||
|
||||
state = {
|
||||
searchValue: '',
|
||||
search: '',
|
||||
role: '',
|
||||
status: '',
|
||||
};
|
||||
|
||||
onSearchChange = e => {
|
||||
const { value } = e.target;
|
||||
this.setState({ searchValue: value }, () => {
|
||||
statusToQuery = {
|
||||
active: { suspended: false, banned: false },
|
||||
suspended: { suspended: true },
|
||||
banned: { banned: true },
|
||||
alwaysPremod: { alwaysPremod: true },
|
||||
};
|
||||
|
||||
onFilterChange = filter => e =>
|
||||
this.setState({ [filter]: e.target.value }, () => {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.search(value);
|
||||
}, 350);
|
||||
this.timer = setTimeout(this.filter, 350);
|
||||
});
|
||||
|
||||
getFilterState = () => {
|
||||
const { role, status } = this.state;
|
||||
return {
|
||||
status: this.statusToQuery[status] || null,
|
||||
role: role || null,
|
||||
};
|
||||
};
|
||||
|
||||
search = async value => {
|
||||
return this.props.data.fetchMore({
|
||||
query: SEARCH_QUERY,
|
||||
filter = () =>
|
||||
this.props.data.fetchMore({
|
||||
query: FILTER_QUERY,
|
||||
variables: {
|
||||
value,
|
||||
state: this.getFilterState(),
|
||||
value: this.state.search,
|
||||
limit: 10,
|
||||
},
|
||||
updateQuery: (previous, { fetchMoreResult: { users } }) => {
|
||||
const updated = update(previous, {
|
||||
updateQuery: (previous, { fetchMoreResult: { users } }) =>
|
||||
update(previous, {
|
||||
users: {
|
||||
nodes: {
|
||||
$set: users.nodes,
|
||||
},
|
||||
nodes: { $set: users.nodes },
|
||||
hasNextPage: { $set: users.hasNextPage },
|
||||
endCursor: { $set: users.endCursor },
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
setUserRole = async (id, role) => {
|
||||
await this.props.setUserRole(id, role);
|
||||
};
|
||||
|
||||
loadMore = () => {
|
||||
return this.props.data.fetchMore({
|
||||
loadMore = () =>
|
||||
this.props.data.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
variables: {
|
||||
value: this.state.searchValue,
|
||||
limit: 5,
|
||||
cursor: this.props.root.users.endCursor,
|
||||
state: this.getFilterState(),
|
||||
value: this.state.search,
|
||||
limit: 5,
|
||||
},
|
||||
updateQuery: (previous, { fetchMoreResult: { users } }) => {
|
||||
const updated = update(previous, {
|
||||
updateQuery: (previous, { fetchMoreResult: { users } }) =>
|
||||
update(previous, {
|
||||
users: {
|
||||
nodes: {
|
||||
$apply: nodes => appendNewNodes(nodes, users.nodes),
|
||||
@@ -77,11 +85,8 @@ class PeopleContainer extends React.Component {
|
||||
hasNextPage: { $set: users.hasNextPage },
|
||||
endCursor: { $set: users.endCursor },
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.props.data.error) {
|
||||
@@ -98,17 +103,20 @@ class PeopleContainer extends React.Component {
|
||||
|
||||
return (
|
||||
<People
|
||||
onSearchChange={this.onSearchChange}
|
||||
onFilterChange={this.onFilterChange}
|
||||
viewUserDetail={this.props.viewUserDetail}
|
||||
setUserRole={this.setUserRole}
|
||||
setUserRole={this.props.setUserRole}
|
||||
showSuspendUserDialog={this.props.showSuspendUserDialog}
|
||||
showBanUserDialog={this.props.showBanUserDialog}
|
||||
showAlwaysPremodUserDialog={this.props.showAlwaysPremodUserDialog}
|
||||
unbanUser={this.props.unbanUser}
|
||||
unsuspendUser={this.props.unsuspendUser}
|
||||
removeAlwaysPremodUser={this.props.removeAlwaysPremodUser}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
users={this.props.root.users}
|
||||
loadMore={this.loadMore}
|
||||
filters={this.state}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -118,87 +126,103 @@ PeopleContainer.propTypes = {
|
||||
setUserRole: PropTypes.func.isRequired,
|
||||
unbanUser: PropTypes.func.isRequired,
|
||||
unsuspendUser: PropTypes.func.isRequired,
|
||||
removeAlwaysPremodUser: PropTypes.func.isRequired,
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func,
|
||||
showBanUserDialog: PropTypes.func,
|
||||
showAlwaysPremodUserDialog: PropTypes.func,
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
};
|
||||
|
||||
const LOAD_MORE_QUERY = gql`
|
||||
query TalkAdmin_Community_People_LoadMoreUsers(
|
||||
$limit: Int
|
||||
$cursor: Cursor
|
||||
$value: String
|
||||
$state: UserStateInput
|
||||
) {
|
||||
users(
|
||||
query: { value: $value, limit: $limit, cursor: $cursor, state: $state }
|
||||
) {
|
||||
hasNextPage
|
||||
endCursor
|
||||
nodes {
|
||||
__typename
|
||||
id
|
||||
username
|
||||
role
|
||||
created_at
|
||||
profiles {
|
||||
id
|
||||
provider
|
||||
}
|
||||
state {
|
||||
status {
|
||||
banned {
|
||||
status
|
||||
}
|
||||
alwaysPremod {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const FILTER_QUERY = gql`
|
||||
query TalkAdmin_Community_People_FilterUsers(
|
||||
$state: UserStateInput
|
||||
$value: String
|
||||
$limit: Int
|
||||
) {
|
||||
users(query: { state: $state, value: $value, limit: $limit }) {
|
||||
hasNextPage
|
||||
endCursor
|
||||
nodes {
|
||||
__typename
|
||||
id
|
||||
username
|
||||
role
|
||||
created_at
|
||||
profiles {
|
||||
id
|
||||
provider
|
||||
}
|
||||
state {
|
||||
status {
|
||||
banned {
|
||||
status
|
||||
}
|
||||
alwaysPremod {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators(
|
||||
{
|
||||
viewUserDetail,
|
||||
showSuspendUserDialog,
|
||||
showBanUserDialog,
|
||||
showAlwaysPremodUserDialog,
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
const LOAD_MORE_QUERY = gql`
|
||||
query TalkAdmin_Community_People_LoadMoreUsers(
|
||||
$limit: Int
|
||||
$cursor: Cursor
|
||||
$value: String
|
||||
) {
|
||||
users(query: { value: $value, limit: $limit, cursor: $cursor }) {
|
||||
hasNextPage
|
||||
endCursor
|
||||
nodes {
|
||||
__typename
|
||||
id
|
||||
username
|
||||
role
|
||||
created_at
|
||||
profiles {
|
||||
id
|
||||
provider
|
||||
}
|
||||
state {
|
||||
status {
|
||||
banned {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SEARCH_QUERY = gql`
|
||||
query TalkAdmin_Community_People_SearchUsers($value: String, $limit: Int) {
|
||||
users(query: { value: $value, limit: $limit }) {
|
||||
hasNextPage
|
||||
endCursor
|
||||
nodes {
|
||||
__typename
|
||||
id
|
||||
username
|
||||
role
|
||||
created_at
|
||||
profiles {
|
||||
id
|
||||
provider
|
||||
}
|
||||
state {
|
||||
status {
|
||||
banned {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default compose(
|
||||
connect(
|
||||
null,
|
||||
@@ -207,6 +231,7 @@ export default compose(
|
||||
withSetUserRole,
|
||||
withUnsuspendUser,
|
||||
withUnbanUser,
|
||||
withRemoveAlwaysPremodUser,
|
||||
withQuery(
|
||||
gql`
|
||||
query TalkAdmin_Community_People {
|
||||
@@ -228,6 +253,9 @@ export default compose(
|
||||
banned {
|
||||
status
|
||||
}
|
||||
alwaysPremod {
|
||||
status
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
|
||||
@@ -155,9 +155,19 @@ class StreamSettings extends React.Component {
|
||||
<ConfigureCard
|
||||
checked={settings.infoBoxEnable}
|
||||
onCheckbox={this.updateInfoBoxEnable}
|
||||
title={t('configure.include_comment_stream')}
|
||||
title={t('configure.code_of_conduct_summary')}
|
||||
>
|
||||
<p>{t('configure.include_comment_stream_desc')}</p>
|
||||
<p>
|
||||
{t('configure.code_of_conduct_summary_desc')}
|
||||
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://guides.coralproject.net/create-a-code-of-conduct/"
|
||||
>
|
||||
Code of Conduct Guide.
|
||||
</a>
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
styles.configSettingInfoBox,
|
||||
@@ -238,12 +248,14 @@ class StreamSettings extends React.Component {
|
||||
onChange={this.updateClosedTimeout}
|
||||
value={getTimeoutAmount(settings.closedTimeout)}
|
||||
label={t('configure.closed_comments_label')}
|
||||
disabled={!settings.autoCloseStream}
|
||||
/>
|
||||
<div className={styles.configTimeoutSelect}>
|
||||
<SelectField
|
||||
label="comments closed time window"
|
||||
value={getTimeoutMeasure(settings.closedTimeout)}
|
||||
onChange={this.updateClosedTimeoutMeasure}
|
||||
readOnly={!settings.autoCloseStream}
|
||||
>
|
||||
<Option value={'hours'}>{t('configure.hours')}</Option>
|
||||
<Option value={'days'}>{t('configure.days')}</Option>
|
||||
|
||||
@@ -6,7 +6,7 @@ import ConfigureCard from 'coral-framework/components/ConfigureCard';
|
||||
|
||||
const Wordlist = ({ suspectWords, bannedWords, onChangeWordlist }) => (
|
||||
<div>
|
||||
<ConfigureCard title={t('configure.banned_words_title')}>
|
||||
<ConfigureCard collapsible title={t('configure.banned_words_title')}>
|
||||
<p>{t('configure.banned_word_text')}</p>
|
||||
<TagsInput
|
||||
value={bannedWords}
|
||||
@@ -16,7 +16,7 @@ const Wordlist = ({ suspectWords, bannedWords, onChangeWordlist }) => (
|
||||
onChange={tags => onChangeWordlist('banned', tags)}
|
||||
/>
|
||||
</ConfigureCard>
|
||||
<ConfigureCard title={t('configure.suspect_word_title')}>
|
||||
<ConfigureCard collapsible title={t('configure.suspect_word_title')}>
|
||||
<p>{t('configure.suspect_word_text')}</p>
|
||||
<TagsInput
|
||||
value={suspectWords}
|
||||
|
||||
@@ -62,13 +62,19 @@
|
||||
font-weight: 300;
|
||||
margin-bottom: 8px;
|
||||
overflow-wrap: break-word;
|
||||
word-break:break-word;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 0px;
|
||||
flex: 1;
|
||||
color: black;
|
||||
max-width: 500px;
|
||||
/*
|
||||
IE11 fix – Next line was supposed to be:
|
||||
max-width: 500px;
|
||||
*/
|
||||
padding-right: 20px;
|
||||
/** IE11 fix end **/
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
@@ -183,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}`}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,196 +0,0 @@
|
||||
import queryString from 'querystringify';
|
||||
import pym from 'pym.js';
|
||||
import EventEmitter from 'eventemitter2';
|
||||
import { buildUrl } from 'coral-framework/utils/url';
|
||||
import Snackbar from './Snackbar';
|
||||
import {
|
||||
createStorage,
|
||||
connectStorageToPym,
|
||||
} from 'coral-framework/services/storage';
|
||||
|
||||
const NOTIFICATION_OFFSET = 200;
|
||||
|
||||
// Build the URL to load in the pym iframe.
|
||||
function buildStreamIframeUrl(talkBaseUrl, query) {
|
||||
let url = [
|
||||
talkBaseUrl,
|
||||
talkBaseUrl.match(/\/$/) ? '' : '/', // make sure no double-'/' if opts.talk already ends with '/'
|
||||
'embed/stream?',
|
||||
].join('');
|
||||
|
||||
url += queryString.stringify(query);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// Get dimensions of viewport.
|
||||
function viewportDimensions() {
|
||||
let e = window,
|
||||
a = 'inner';
|
||||
if (!('innerWidth' in window)) {
|
||||
a = 'client';
|
||||
e = document.documentElement || document.body;
|
||||
}
|
||||
|
||||
return {
|
||||
width: e[`${a}Width`],
|
||||
height: e[`${a}Height`],
|
||||
};
|
||||
}
|
||||
|
||||
export default class Stream {
|
||||
constructor(el, talkBaseUrl, query, config) {
|
||||
this.query = query;
|
||||
|
||||
// Extract the non-opts opts from the object.
|
||||
const {
|
||||
events = null,
|
||||
snackBarStyles = null,
|
||||
onAuthChanged = null,
|
||||
...opts
|
||||
} = config;
|
||||
|
||||
this.opts = opts;
|
||||
|
||||
this.emitter = new EventEmitter({ wildcard: true });
|
||||
this.pym = new pym.Parent(el.id, buildStreamIframeUrl(talkBaseUrl, query), {
|
||||
title: opts.title,
|
||||
id: `${el.id}_iframe`,
|
||||
name: `${el.id}_iframe`,
|
||||
});
|
||||
this.snackBar = new Snackbar(snackBarStyles || {});
|
||||
|
||||
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
|
||||
this.pym.el.firstChild.style.width = '1px';
|
||||
this.pym.el.firstChild.style.minWidth = '100%';
|
||||
|
||||
// Resize parent iframe height when child height changes
|
||||
let cachedHeight;
|
||||
this.pym.onMessage('height', height => {
|
||||
if (height !== cachedHeight) {
|
||||
this.pym.el.firstChild.style.height = `${height}px`;
|
||||
cachedHeight = height;
|
||||
}
|
||||
});
|
||||
|
||||
// Attach to the events emitted by the pym parent.
|
||||
if (events) {
|
||||
events(this.emitter);
|
||||
}
|
||||
|
||||
this.pym.onMessage('getConfig', () => {
|
||||
this.pym.sendMessage('config', JSON.stringify(opts));
|
||||
});
|
||||
|
||||
// If the auth changes, and someone is listening for it, then re-emit it.
|
||||
if (onAuthChanged) {
|
||||
this.pym.onMessage('coral-auth-changed', message => {
|
||||
onAuthChanged(message ? JSON.parse(message) : null);
|
||||
});
|
||||
}
|
||||
|
||||
// Attach the snackbar to the pym parent and to the body of the page.
|
||||
this.snackBar.attach(window.document.body, this.pym);
|
||||
|
||||
// Remove the permalink comment id from the hash.
|
||||
this.pym.onMessage('coral-view-all-comments', () => {
|
||||
const query = queryString.parse(location.search);
|
||||
|
||||
// Remove the commentId url param.
|
||||
delete query.commentId;
|
||||
|
||||
const search = queryString.stringify(query);
|
||||
|
||||
const url = buildUrl({ ...location, search });
|
||||
|
||||
// Change the url.
|
||||
window.history.replaceState({}, document.title, url);
|
||||
});
|
||||
|
||||
// Remove the permalink comment id from the hash.
|
||||
this.pym.onMessage('coral-view-comment', id => {
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
commentId: id,
|
||||
});
|
||||
|
||||
// Remove the commentId url param.
|
||||
const url = buildUrl({ ...location, search });
|
||||
|
||||
// Change the url.
|
||||
window.history.replaceState({}, document.title, url);
|
||||
});
|
||||
|
||||
// Helps child show notifications at the right scrollTop.
|
||||
this.pym.onMessage('getPosition', () => {
|
||||
const { height } = viewportDimensions();
|
||||
let position = height + document.body.scrollTop;
|
||||
|
||||
if (position > NOTIFICATION_OFFSET) {
|
||||
position = position - NOTIFICATION_OFFSET;
|
||||
}
|
||||
|
||||
this.pym.sendMessage('position', position);
|
||||
});
|
||||
|
||||
// When end-user clicks link in iframe, open it in parent context
|
||||
this.pym.onMessage('navigate', url => {
|
||||
window.open(url, '_blank').focus();
|
||||
});
|
||||
|
||||
// Pass events from iframe to the event emitter.
|
||||
this.pym.onMessage('event', raw => {
|
||||
const { eventName, value } = JSON.parse(raw);
|
||||
this.emitter.emit(eventName, value);
|
||||
});
|
||||
|
||||
// If the user clicks outside the embed, then tell the embed.
|
||||
document.addEventListener('click', this.handleClick.bind(this), true);
|
||||
|
||||
// Listens to local storage requests on pym and relay it to local storage.
|
||||
connectStorageToPym(
|
||||
createStorage('localStorage'),
|
||||
this.pym,
|
||||
'localStorage'
|
||||
);
|
||||
|
||||
// Listens to session storage requests on pym and relay it to session storage.
|
||||
connectStorageToPym(
|
||||
createStorage('sessionStorage'),
|
||||
this.pym,
|
||||
'sessionStorage'
|
||||
);
|
||||
}
|
||||
|
||||
enablePluginsDebug() {
|
||||
this.pym.sendMessage('enablePluginsDebug');
|
||||
}
|
||||
|
||||
disablePluginsDebug() {
|
||||
this.pym.sendMessage('disablePluginsDebug');
|
||||
}
|
||||
|
||||
login(token) {
|
||||
this.pym.sendMessage('login', token);
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.pym.sendMessage('logout');
|
||||
}
|
||||
|
||||
remove() {
|
||||
// Remove the event listeners.
|
||||
document.removeEventListener('click', this.handleClick.bind(this));
|
||||
this.emitter.removeAllListeners();
|
||||
|
||||
// Remove the snackbar.
|
||||
this.snackBar.remove();
|
||||
|
||||
// Remove the pym parent.
|
||||
this.pym.remove();
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
this.pym.sendMessage('click');
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
export default class StreamInterface {
|
||||
constructor(stream) {
|
||||
this._stream = stream;
|
||||
}
|
||||
|
||||
on(eventName, callback) {
|
||||
return this._stream.emitter.on(eventName, callback);
|
||||
}
|
||||
|
||||
off(eventName, callback) {
|
||||
return this._stream.emitter.off(eventName, callback);
|
||||
}
|
||||
|
||||
login(token) {
|
||||
return this._stream.login(token);
|
||||
}
|
||||
|
||||
logout() {
|
||||
return this._stream.logout();
|
||||
}
|
||||
|
||||
remove() {
|
||||
return this._stream.remove();
|
||||
}
|
||||
|
||||
enablePluginsDebug() {
|
||||
return this._stream.enablePluginsDebug();
|
||||
}
|
||||
|
||||
disablePluginsDebug() {
|
||||
return this._stream.disablePluginsDebug();
|
||||
}
|
||||
}
|
||||
@@ -1,106 +1,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,50 +1,48 @@
|
||||
.card {
|
||||
margin-bottom: 20px;
|
||||
align-items: flex-start;
|
||||
min-height: 100px;
|
||||
max-width: 600px;
|
||||
min-height: 100px;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.collapsibleCard {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.enabledCard {
|
||||
border-left: 7px solid #00796b;
|
||||
}
|
||||
|
||||
.action {
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-top: 3px;
|
||||
margin-bottom: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
.body {
|
||||
margin-top: 7px;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
padding: 0px 30px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.enabledSetting {
|
||||
border-left-color: #00796b;
|
||||
border-left-style: solid;
|
||||
border-left-width: 7px;
|
||||
}
|
||||
|
||||
.disabledSetting {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.disabledSettingText {
|
||||
.disabledBody {
|
||||
color: #ccc;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +1,69 @@
|
||||
import React from 'react';
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './ConfigureCard.css';
|
||||
import classnames from 'classnames/bind';
|
||||
import { Card } from 'coral-ui';
|
||||
import { Checkbox } from 'react-mdl';
|
||||
import cn from 'classnames';
|
||||
import { Checkbox, IconButton } from 'react-mdl';
|
||||
|
||||
const ConfigureCard = ({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
onCheckbox,
|
||||
checked,
|
||||
...rest
|
||||
}) => (
|
||||
<Card
|
||||
{...rest}
|
||||
className={cn(styles.card, className, {
|
||||
[styles.enabledSetting]: checked === true,
|
||||
[styles.disabledSetting]: checked === false,
|
||||
})}
|
||||
>
|
||||
{checked !== undefined && (
|
||||
<div className={styles.action}>
|
||||
<Checkbox onChange={onCheckbox} checked={checked} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(styles.wrapper, {
|
||||
[styles.content]: checked !== undefined,
|
||||
})}
|
||||
>
|
||||
<div className={styles.header}>{title}</div>
|
||||
<div
|
||||
className={cn({
|
||||
[styles.disabledSettingText]: checked === false,
|
||||
import styles from './ConfigureCard.css';
|
||||
|
||||
const cn = classnames.bind(styles);
|
||||
|
||||
class ConfigureCard extends PureComponent {
|
||||
state = {
|
||||
isOpen: !this.props.collapsible,
|
||||
};
|
||||
|
||||
toggle = () => this.setState({ isOpen: !this.state.isOpen });
|
||||
|
||||
render() {
|
||||
const {
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
onCheckbox,
|
||||
checked,
|
||||
collapsible,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const { isOpen } = this.state;
|
||||
|
||||
const iconName = isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down';
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...rest}
|
||||
className={cn(styles.card, className, {
|
||||
enabledCard: checked === true,
|
||||
collapsibleCard: collapsible,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
{checked !== undefined && (
|
||||
<div className={styles.action}>
|
||||
<Checkbox onChange={onCheckbox} checked={checked} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
{collapsible && (
|
||||
<IconButton onClick={this.toggle} name={iconName} ripple />
|
||||
)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(styles.body, {
|
||||
disabledBody: checked === false,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ConfigureCard.propTypes = {
|
||||
title: PropTypes.string,
|
||||
@@ -48,6 +71,7 @@ ConfigureCard.propTypes = {
|
||||
onCheckbox: PropTypes.func,
|
||||
checked: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
collapsible: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ConfigureCard;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createDefaultResponseFragments } from '../utils';
|
||||
// fragments defined here are automatically registered.
|
||||
export default {
|
||||
...createDefaultResponseFragments(
|
||||
'AlwaysPremodUserResponse',
|
||||
'BanUsersResponse',
|
||||
'ChangeUsernameResponse',
|
||||
'CloseAssetResponse',
|
||||
@@ -14,6 +15,7 @@ export default {
|
||||
'IgnoreUserResponse',
|
||||
'ModifyTagResponse',
|
||||
'PostFlagResponse',
|
||||
'RemoveAlwaysPremodUserResponse',
|
||||
'SetCommentStatusResponse',
|
||||
'SetUsernameResponse',
|
||||
'SetUsernameStatusResponse',
|
||||
|
||||
@@ -412,6 +412,48 @@ export const withSetUsername = withMutation(
|
||||
}
|
||||
);
|
||||
|
||||
export const withAlwaysPremodUser = withMutation(
|
||||
gql`
|
||||
mutation AlwaysPremodUser($input: AlwaysPremodUserInput!) {
|
||||
alwaysPremodUser(input: $input) {
|
||||
...AlwaysPremodUserResponse
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
props: ({ mutate }) => ({
|
||||
alwaysPremodUser: input => {
|
||||
return mutate({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
export const withRemoveAlwaysPremodUser = withMutation(
|
||||
gql`
|
||||
mutation RemoveAlwaysPremodUser($input: RemoveAlwaysPremodUserInput!) {
|
||||
removeAlwaysPremodUser(input: $input) {
|
||||
...RemoveAlwaysPremodUserResponse
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
props: ({ mutate }) => ({
|
||||
removeAlwaysPremodUser: input => {
|
||||
return mutate({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
export const withBanUser = withMutation(
|
||||
gql`
|
||||
mutation BanUser($input: BanUserInput!) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import jwtDecode from 'jwt-decode';
|
||||
|
||||
export const setStorageAuthToken = (storage, token) => {
|
||||
storage.setItem('exp', jwtDecode(token).exp);
|
||||
storage.setItem('token', token);
|
||||
};
|
||||
|
||||
export const clearStorageAuthToken = storage => {
|
||||
storage.removeItem('token');
|
||||
storage.removeItem('exp');
|
||||
};
|
||||
+25
-1
@@ -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');
|
||||
}
|
||||
|
||||
@@ -88,21 +88,23 @@ export function createClient(options = {}) {
|
||||
});
|
||||
|
||||
client.resetWebsocket = () => {
|
||||
// Close socket connection which will also unregister subscriptions on the server-side.
|
||||
wsClient.close();
|
||||
if (wsClient.client) {
|
||||
// Close socket connection which will also unregister subscriptions on the server-side.
|
||||
wsClient.close(true);
|
||||
|
||||
// Reconnect to the server.
|
||||
wsClient.connect();
|
||||
// Reconnect to the server.
|
||||
wsClient.connect();
|
||||
|
||||
// Reregister all subscriptions (uses non public api).
|
||||
// See: https://github.com/apollographql/subscriptions-transport-ws/issues/171
|
||||
Object.keys(wsClient.operations).forEach(id => {
|
||||
wsClient.sendMessage(
|
||||
id,
|
||||
MessageTypes.GQL_START,
|
||||
wsClient.operations[id].options
|
||||
);
|
||||
});
|
||||
// Re-register all subscriptions (uses non public api).
|
||||
// See: https://github.com/apollographql/subscriptions-transport-ws/issues/171
|
||||
Object.keys(wsClient.operations).forEach(id => {
|
||||
wsClient.sendMessage(
|
||||
id,
|
||||
MessageTypes.GQL_START,
|
||||
wsClient.operations[id].options
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return client;
|
||||
|
||||
@@ -12,6 +12,8 @@ import 'moment/locale/da';
|
||||
import 'moment/locale/de';
|
||||
import 'moment/locale/es';
|
||||
import 'moment/locale/fr';
|
||||
import 'moment/locale/he';
|
||||
import 'moment/locale/it';
|
||||
import 'moment/locale/nl';
|
||||
import 'moment/locale/pt-br';
|
||||
|
||||
@@ -22,6 +24,8 @@ import daTA from 'timeago.js/locales/da';
|
||||
import deTA from 'timeago.js/locales/de';
|
||||
import esTA from 'timeago.js/locales/es';
|
||||
import frTA from 'timeago.js/locales/fr';
|
||||
import heTA from 'timeago.js/locales/he';
|
||||
import itTA from 'timeago.js/locales/it';
|
||||
import nlTA from 'timeago.js/locales/nl';
|
||||
import pt_BRTA from 'timeago.js/locales/pt_BR';
|
||||
import zh_CNTA from 'timeago.js/locales/zh_CN';
|
||||
@@ -34,6 +38,8 @@ import da from '../../../locales/da.yml';
|
||||
import de from '../../../locales/de.yml';
|
||||
import es from '../../../locales/es.yml';
|
||||
import fr from '../../../locales/fr.yml';
|
||||
import he from '../../../locales/he.yml';
|
||||
import it from '../../../locales/it.yml';
|
||||
import nl_NL from '../../../locales/nl_NL.yml';
|
||||
import pt_BR from '../../../locales/pt_BR.yml';
|
||||
import zh_CN from '../../../locales/zh_CN.yml';
|
||||
@@ -61,6 +67,8 @@ export const translations = {
|
||||
...de,
|
||||
...es,
|
||||
...fr,
|
||||
...he,
|
||||
...it,
|
||||
...nl_NL,
|
||||
...pt_BR,
|
||||
...zh_CN,
|
||||
@@ -106,6 +114,8 @@ export function setupTranslations() {
|
||||
ta.register('da', daTA);
|
||||
ta.register('de', deTA);
|
||||
ta.register('fr', frTA);
|
||||
ta.register('he', heTA);
|
||||
ta.register('it', itTA);
|
||||
ta.register('nl_NL', nlTA);
|
||||
ta.register('pt_BR', pt_BRTA);
|
||||
ta.register('zh_CN', zh_CNTA);
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
export function createStore(reducers, middlewares = []) {
|
||||
const enhancers = [applyMiddleware(...middlewares)];
|
||||
|
||||
if (window.devToolsExtension) {
|
||||
if (window.__REDUX_DEVTOOLS_EXTENSION__) {
|
||||
// we can't have the last argument of compose() be undefined
|
||||
enhancers.push(window.devToolsExtension());
|
||||
enhancers.push(window.__REDUX_DEVTOOLS_EXTENSION__());
|
||||
}
|
||||
|
||||
return reduxCreateStore(combineReducers(reducers), {}, compose(...enhancers));
|
||||
|
||||
@@ -35,6 +35,15 @@ export const isBanned = user => {
|
||||
return get(user, 'state.status.banned.status');
|
||||
};
|
||||
|
||||
/**
|
||||
* isAlwaysPremod
|
||||
* retrieves boolean based on the user premod status
|
||||
*/
|
||||
|
||||
export const isAlwaysPremod = user => {
|
||||
return get(user, 'state.status.alwaysPremod.status');
|
||||
};
|
||||
|
||||
/**
|
||||
* isUsernameRejected
|
||||
* retrieves boolean based on the username status
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
line-height: 30px;
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
padding: 5px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -24,7 +25,14 @@
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.active {
|
||||
.page:hover {
|
||||
background-color: #ababab;
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
.active, .active:hover {
|
||||
background-color: #696969;
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
a {
|
||||
|
||||
@@ -30,7 +30,7 @@ const CONFIG = {
|
||||
ENABLE_TRACING: Boolean(process.env.APOLLO_ENGINE_KEY),
|
||||
|
||||
// EMAIL_SUBJECT_PREFIX is the string before emails in the subject.
|
||||
EMAIL_SUBJECT_PREFIX: process.env.TALK_EMAIL_SUBJECT_PREFIX || '[Talk]',
|
||||
EMAIL_SUBJECT_PREFIX: process.env.TALK_EMAIL_SUBJECT_PREFIX,
|
||||
|
||||
// DEFAULT_LANG is the default language used for server sent emails and
|
||||
// rendered text.
|
||||
@@ -76,6 +76,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 +217,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 +227,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 +286,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`;
|
||||
|
||||
+8
-2
@@ -98,12 +98,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 +166,8 @@ sidebar:
|
||||
url: /customizing-plugins-coral-ui/
|
||||
- title: When You've Installed Talk
|
||||
url: /when-youve-installed-talk/
|
||||
- title: Embedding Talk in Native Mobile Apps
|
||||
url: /embedding-in-native-mobile-apps/
|
||||
- title: Migrating
|
||||
children:
|
||||
- title: Migrating from v3.x.x
|
||||
|
||||
@@ -1,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/
|
||||
|
||||
@@ -187,8 +187,10 @@ 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._
|
||||
@@ -220,6 +222,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!
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -11,8 +11,6 @@ To log a bug or request a feature, submit a Support ticket ([support@coralprojec
|
||||
|
||||
You can also request help on Github by [submitting an issue](https://github.com/coralproject/talk/issues). This also increases your chances of having someone from the community respond to help.
|
||||
|
||||
And you can also search our [Coral Community](https://community.coralproject.net) to see if your issue has been solved, or to get tips from the community.
|
||||
|
||||
## How can our dev team contribute to Talk?
|
||||
|
||||
We are lucky to work with newsroom dev teams and individual contributors who span the world, and come from newsrooms of all sizes. You can read our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md) to get started, but feel free to reach out to us via Github too.
|
||||
@@ -35,24 +33,24 @@ Our team is small, so it's difficult for us to provide support packages. However
|
||||
|
||||
## Is there a hosted version I can purchase by monthly subscription?
|
||||
|
||||
Yes! We are happy to announce that as of July 2018, we provide a SaaS version of Talk, called the Coral Cloud. For a monthly subscription, you get your own hosted Talk instance to embed on your news and blog articles. [Reach out to us](mailto:support@coralproject.net) if you're interested in this option.
|
||||
Yes! [Visit our hosting page](https://coralproject.net/pricing/) to submit information and receive a quote.
|
||||
|
||||
## Where is our data when we use Talk?
|
||||
|
||||
If you are hosting Talk on your own:
|
||||
|
||||
* Your data is stored in a MongoDB database that you provide
|
||||
* The Coral Team doesn’t have any access to your data
|
||||
* The Coral team doesn’t have any access to your data
|
||||
|
||||
If you are using Coral Cloud Hosting for Talk:
|
||||
If you are using our Hosted SaaS version of Talk:
|
||||
|
||||
* Your data is stored in a dedicated MongDB database that is provisioned for your in the Cloud
|
||||
* Your data is completely isolated from other customer’s data
|
||||
* The Coral Team and its third party database hosting providers use strict access controls and auditing to protected your data from unauthorized access by team members
|
||||
* Your data is stored in a dedicated MongoDB database that is provisioned for you
|
||||
* Your data is completely isolated from other customers' data
|
||||
* The Coral Team and its third-party database hosting providers use strict access controls and auditing to protected your data from unauthorized access
|
||||
|
||||
## Does Talk have any automated moderation features to protect against spam and trolling?
|
||||
|
||||
Talk features a couple of plugins that provide advanced moderation:
|
||||
As well as basic word/phrase filtering and user reputation scores, Talk offers optional advanced features via third-party services:
|
||||
|
||||
* The [Toxic Comments plugin](/talk/plugin/talk-plugin-toxic-comments) integrates with the [Perspective API from Google](https://www.perspectiveapi.com/) to detect the likelihood of toxicity of comments in real-time
|
||||
* The [Akismet plugin](/talk/plugin/talk-plugin-akismet) detects and blocks spam comments
|
||||
@@ -66,4 +64,4 @@ Talk features a couple of plugins that provide advanced moderation:
|
||||
|
||||
* The Talk software is freely available under the Apache 2.0 open source license
|
||||
* Associated costs are those for the infrastructure required to run Talk (i.e. cloud hosting fee or bare-metal server costs)
|
||||
* The Coral Project offers a SaaS version of Talk. Please [get in touch with us](mailto:support@coralproject.net) to discuss pricing for your custom requirements.
|
||||
* The Coral Project offers a SaaS/hosted version of Talk. Please [get in touch with us](https://coralproject.net/pricing/) to discuss pricing for your requirements.
|
||||
|
||||
@@ -38,3 +38,6 @@ If you're using `talk-plugin-auth`:
|
||||
* See if you can isolate if it's a particular group of users that are experiencing this issue, e.g. mods, admins, subscribers? Confirm they have the appropriate permissions to comment.
|
||||
* Note if this is a new issue that happened after an upgrade - did you read the [migration docs](/talk/migration/3/) and the [release notes](https://github.com/coralproject/talk/releases)? This might help you resolve the issue.
|
||||
* If you're still experiencing issues, log a [support ticket](mailto:support@coralproject.net) so we can help diagnose the issue
|
||||
|
||||
If a user has been locked out due to too many failed login attempts:
|
||||
* How long does the user have to wait before they will be allowed to login? 10 mins
|
||||
@@ -146,3 +146,8 @@
|
||||
tags:
|
||||
- default
|
||||
- sorting
|
||||
- name: talk-plugin-comment-count
|
||||
description: This plugin enables adding comment counts to Talk’s embed or in other locations on your site.
|
||||
link: https://github.com/coralproject/talk-plugin-comment-count
|
||||
tags:
|
||||
- counts
|
||||
|
||||
+245
-168
@@ -56,16 +56,32 @@ Thanks to [gql-merge](https://www.npmjs.com/package/gql-merge) the contents of
|
||||
definitions. `enum`'s will be appended to, types will be appended, and new types
|
||||
will be added.
|
||||
|
||||
This can be included as a plugin via:
|
||||
|
||||
```js
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
module.exports = {
|
||||
typeDefs: fs.readFileSync(path.join(__dirname, "typeDefs.graphql"), "utf8")
|
||||
};
|
||||
```
|
||||
|
||||
Assuming the above GraphQL schema is located in the plugin folder at
|
||||
`typeDefs.graphql`.
|
||||
|
||||
### context
|
||||
|
||||
```js
|
||||
{
|
||||
Slack: (context) => ({
|
||||
notify: (message) => {
|
||||
// return a promise after we're done sending notifications.
|
||||
}
|
||||
})
|
||||
}
|
||||
module.exports = {
|
||||
context: {
|
||||
Slack: context => ({
|
||||
notify: message => {
|
||||
// return a promise after we're done sending notifications.
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Any property provided here will be added to the context parameter available
|
||||
@@ -83,41 +99,50 @@ A special context hook, `Sort` will allow plugin authors to provide new
|
||||
methods to sort data. An example is as follows:
|
||||
|
||||
```js
|
||||
{
|
||||
Sort: () => ({
|
||||
Comments: { // <-- (1)
|
||||
likes: { // <-- (2)
|
||||
startCursor(ctx, nodes, {cursor}) { // <-- (3)
|
||||
return cursor != null ? cursor : 0;
|
||||
},
|
||||
endCursor(ctx, nodes, {cursor}) { // <-- (4)
|
||||
return nodes.length ? (cursor != null ? cursor : 0) + nodes.length : null;
|
||||
},
|
||||
sort(ctx, query, {cursor, sort}) { // <-- (5)
|
||||
if (cursor) {
|
||||
query = query.skip(cursor);
|
||||
}
|
||||
module.exports = {
|
||||
context: {
|
||||
Sort: () => ({
|
||||
Comments: {
|
||||
// <-- (1)
|
||||
likes: {
|
||||
// <-- (2)
|
||||
startCursor(ctx, nodes, { cursor }) {
|
||||
// <-- (3)
|
||||
return cursor != null ? cursor : 0;
|
||||
},
|
||||
endCursor(ctx, nodes, { cursor }) {
|
||||
// <-- (4)
|
||||
return nodes.length
|
||||
? (cursor != null ? cursor : 0) + nodes.length
|
||||
: null;
|
||||
},
|
||||
sort(ctx, query, { cursor, sort }) {
|
||||
// <-- (5)
|
||||
if (cursor) {
|
||||
query = query.skip(cursor);
|
||||
}
|
||||
|
||||
return query.sort({
|
||||
'action_counts.like': sort === 'DESC' ? -1 : 1,
|
||||
created_at: sort === 'DESC' ? -1 : 1,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
return query.sort({
|
||||
"action_counts.like": sort === "DESC" ? -1 : 1,
|
||||
created_at: sort === "DESC" ? -1 : 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
This has a bunch of special features:
|
||||
|
||||
1. `Comments` is the name of the type being sorted, this is pluralized and
|
||||
capitalized.
|
||||
capitalized.
|
||||
2. `likes` is the `sortBy` field in lowercase.
|
||||
3. `startCursor` will retrieve the start cursor based on the current set of
|
||||
nodes and the current cursor.
|
||||
nodes and the current cursor.
|
||||
4. `endCursor` will retrieve the end cursor based on the current set of nodes
|
||||
and the current cursor.
|
||||
and the current cursor.
|
||||
5. `sort` will mutate the `query` to apply the sort operations.
|
||||
|
||||
All the `startCursor`, `endCursor`, and `sort` functions must be provided in
|
||||
@@ -126,11 +151,13 @@ order for the sorting to apply properly.
|
||||
### loaders
|
||||
|
||||
```js
|
||||
(context) => ({
|
||||
People: {
|
||||
load: () => db.people.find({user: context.user})
|
||||
}
|
||||
})
|
||||
module.exports = {
|
||||
context: context => ({
|
||||
People: {
|
||||
load: () => db.people.find({ user: context.user })
|
||||
}
|
||||
})
|
||||
};
|
||||
```
|
||||
|
||||
Loaders should be provided as a function which returns a map which is used in
|
||||
@@ -139,13 +166,15 @@ the resolvers function. These must return a promise or a value.
|
||||
### mutators
|
||||
|
||||
```js
|
||||
(context) => ({
|
||||
People: {
|
||||
create: (name) => {
|
||||
return db.people.insert({user: context.user, name});
|
||||
module.exports = {
|
||||
context: context => ({
|
||||
People: {
|
||||
create: name => {
|
||||
return db.people.insert({ user: context.user, name });
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
```
|
||||
|
||||
Mutators should be provided as a function which returns a map which is used in
|
||||
@@ -154,27 +183,41 @@ the resolvers function. These must return a promise or a value.
|
||||
### resolvers
|
||||
|
||||
```js
|
||||
{
|
||||
Person: {
|
||||
name(obj, args, context) {
|
||||
return obj.name;
|
||||
module.exports = {
|
||||
resolvers: {
|
||||
Person: {
|
||||
name(obj, args, context) {
|
||||
return obj.name;
|
||||
},
|
||||
colour(obj, args, context) {
|
||||
// Bill likes the colour red, everyone else likes blue.
|
||||
return obj.name === "bill" ? "RED" : "BLUE";
|
||||
}
|
||||
},
|
||||
colour(obj, args, context) {
|
||||
// Bill likes the colour red, everyone else likes blue.
|
||||
return obj.name === 'bill' ? 'RED' : 'BLUE';
|
||||
}
|
||||
},
|
||||
RootQuery: {
|
||||
people(obj, args, {loaders: {People}}) {
|
||||
return People.load();
|
||||
}
|
||||
},
|
||||
RootMutation: {
|
||||
createPerson(obj, {name}, {mutators: {People}}) {
|
||||
return People.create(name);
|
||||
RootQuery: {
|
||||
people(
|
||||
obj,
|
||||
args,
|
||||
{
|
||||
loaders: { People }
|
||||
}
|
||||
) {
|
||||
return People.load();
|
||||
}
|
||||
},
|
||||
RootMutation: {
|
||||
createPerson(
|
||||
obj,
|
||||
{ name },
|
||||
{
|
||||
mutators: { People }
|
||||
}
|
||||
) {
|
||||
return People.create(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Should return a resolver map as described in the
|
||||
@@ -185,40 +228,44 @@ This will merge with the existing resolvers in core and from previous plugins.
|
||||
### hooks
|
||||
|
||||
```js
|
||||
{
|
||||
RootMutation: {
|
||||
createPerson: {
|
||||
post: async (obj, args, {plugins: {Slack}}, info, person) {
|
||||
if (!person) {
|
||||
module.exports = {
|
||||
hooks: {
|
||||
RootMutation: {
|
||||
createPerson: {
|
||||
post: async (obj, args, {plugins: {Slack}}, info, person) {
|
||||
if (!person) {
|
||||
return person;
|
||||
}
|
||||
|
||||
await Slack.notify(`A new person just was created with name ${person.name}`);
|
||||
|
||||
return person;
|
||||
}
|
||||
|
||||
await Slack.notify(`A new person just was created with name ${person.name}`);
|
||||
|
||||
return person;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Hooks here are pretty special, for each resolver field, you can specify a
|
||||
pre/post hook that will execute pre and post field resolution.
|
||||
|
||||
If your post function accepts four parameters, then it can modify the field
|
||||
result. It is *required* that the function resolves a promise (or returns) with
|
||||
result. It is _required_ that the function resolves a promise (or returns) with
|
||||
the modified value or simply the original if you didn't modify it.
|
||||
|
||||
### setupFunctions
|
||||
|
||||
```js
|
||||
setupFunctions: {
|
||||
leader: (options, args) => ({
|
||||
leader: {
|
||||
filter: (person) => person.place === 1
|
||||
},
|
||||
}),
|
||||
}
|
||||
module.exports = {
|
||||
setupFunctions: {
|
||||
leader: (options, args) => ({
|
||||
leader: {
|
||||
filter: person => person.place === 1
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Setup functions allow you to create filters that control which pubsub.publish() events
|
||||
@@ -229,28 +276,32 @@ For more information, see the [Apollo Docs](https://github.com/apollographql/gra
|
||||
### tokenUserNotFound
|
||||
|
||||
```js
|
||||
tokenUserNotFound: async ({jwt, token}) => {
|
||||
let profile = await someExternalService(token);
|
||||
if (!profile) {
|
||||
return null;
|
||||
const Users = require("services/users");
|
||||
|
||||
module.exports = {
|
||||
tokenUserNotFound: async ({ jwt }) => {
|
||||
const user = await Users.upsertExternalUser(
|
||||
null,
|
||||
jwt.sub,
|
||||
jwt.iss,
|
||||
jwt.username
|
||||
);
|
||||
|
||||
const email = jwt.email.toLowerCase();
|
||||
|
||||
//upsertExternalUser will also create a profile with provider:jwt.iss, id:jwt.sub
|
||||
//add a "local" profile to persist email on the user
|
||||
user.profiles.push({
|
||||
provider: "local",
|
||||
id: email
|
||||
});
|
||||
user.created_at = new Date(jwt.memberSince * 1000);
|
||||
|
||||
await user.save();
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
let user = await UserModel.findOneAndUpdate({
|
||||
id: profile.id
|
||||
}, {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
lowercaseUsername: profile.username.toLowerCase(),
|
||||
roles: [],
|
||||
profiles: []
|
||||
}, {
|
||||
setDefaultsOnInsert: true,
|
||||
new: true,
|
||||
upsert: true
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
The `tokenUserNotFound` hook allows auth integrations to hook into the event
|
||||
@@ -258,8 +309,15 @@ when a valid token is provided but a user can't be found in the database that
|
||||
matches the provided id.
|
||||
|
||||
The function is async, and should return the user object that was created in the
|
||||
database, or null if the user wasn't found. The `jwt` parameter of the object
|
||||
is the unpacked token, while `token` is the original jwt token string.
|
||||
database. The `jwt` parameter of the object is the unpacked token, which has already been validated.
|
||||
|
||||
More details about the `upsertExternalUser` method can be found at:
|
||||
https://github.com/coralproject/talk/blob/32962aa1e84c09d87141440a1f04cbd1659b3336/services/users.js/#L592
|
||||
|
||||
In this example, a unix timestamp was included on the jwt with a date that is being persisted on the user. This is optional, but can be used to handle any extra claims you want to include on the jwt. You can use the metadata object on the user to store custom values.
|
||||
|
||||
Having trouble implementing your own `tokenUserNotFound` hook?
|
||||
Submit a Support ticket ([support@coralproject.net](mailto:support@coralproject.net))
|
||||
|
||||
### tags
|
||||
|
||||
@@ -268,18 +326,20 @@ or enabled by code). Below is an example pulled from the core off topic plugin
|
||||
on how to create a hook for the `OFF_TOPIC` name:
|
||||
|
||||
```js
|
||||
[
|
||||
{
|
||||
name: 'OFF_TOPIC',
|
||||
permissions: {
|
||||
public: true,
|
||||
self: true,
|
||||
roles: []
|
||||
},
|
||||
models: ['COMMENTS'],
|
||||
created_at: new Date()
|
||||
}
|
||||
]
|
||||
module.exports = {
|
||||
tags: [
|
||||
{
|
||||
name: "OFF_TOPIC",
|
||||
permissions: {
|
||||
public: true,
|
||||
self: true,
|
||||
roles: []
|
||||
},
|
||||
models: ["COMMENTS"],
|
||||
created_at: new Date()
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
You can refer to `models/schema/tag.js` for the available schema to match when
|
||||
@@ -288,11 +348,13 @@ creating models to enable/disable specific features.
|
||||
### router
|
||||
|
||||
```js
|
||||
(router) => {
|
||||
router.get('/api/v1/people', (req, res) => {
|
||||
res.json({people: [{name: 'Bob'}]});
|
||||
});
|
||||
}
|
||||
module.exports = {
|
||||
router: router => {
|
||||
router.get("/api/v1/people", (req, res) => {
|
||||
res.json({ people: [{ name: "Bob" }] });
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
The Router hook allows you to create a function that accepts the base express
|
||||
@@ -302,56 +364,73 @@ action needed by external applications.
|
||||
### passport
|
||||
|
||||
```js
|
||||
const FacebookStrategy = require('passport-facebook').Strategy;
|
||||
const UsersService = require('services/users');
|
||||
const {ValidateUserLogin, HandleAuthPopupCallback} = require('services/passport');
|
||||
const FacebookStrategy = require("passport-facebook").Strategy;
|
||||
const UsersService = require("services/users");
|
||||
const {
|
||||
ValidateUserLogin,
|
||||
HandleAuthPopupCallback
|
||||
} = require("services/passport");
|
||||
|
||||
module.exports = {
|
||||
passport(passport) {
|
||||
passport.use(new FacebookStrategy({
|
||||
clientID: process.env.TALK_FACEBOOK_APP_ID,
|
||||
clientSecret: process.env.TALK_FACEBOOK_APP_SECRET,
|
||||
callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`,
|
||||
passReqToCallback: true,
|
||||
profileFields: ['id', 'displayName', 'picture.type(large)']
|
||||
}, async (req, accessToken, refreshToken, profile, done) => {
|
||||
passport: passport => {
|
||||
passport.use(
|
||||
new FacebookStrategy(
|
||||
{
|
||||
clientID: process.env.TALK_FACEBOOK_APP_ID,
|
||||
clientSecret: process.env.TALK_FACEBOOK_APP_SECRET,
|
||||
callbackURL: `${
|
||||
process.env.TALK_ROOT_URL
|
||||
}/api/v1/auth/facebook/callback`,
|
||||
passReqToCallback: true,
|
||||
profileFields: ["id", "displayName", "picture.type(large)"]
|
||||
},
|
||||
async (req, accessToken, refreshToken, profile, done) => {
|
||||
let user;
|
||||
try {
|
||||
const { id, provider, displayName } = profile;
|
||||
user = await UsersService.upsertSocialUser(
|
||||
req.context,
|
||||
id,
|
||||
provider,
|
||||
displayName
|
||||
);
|
||||
} catch (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
const { id, provider, displayName } = profile;
|
||||
user = await UsersService.upsertSocialUser(
|
||||
req.context,
|
||||
id,
|
||||
provider,
|
||||
displayName
|
||||
);
|
||||
} catch (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
return ValidateUserLogin(profile, user, done);
|
||||
}));
|
||||
return ValidateUserLogin(profile, user, done);
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
router(router) {
|
||||
|
||||
router: router => {
|
||||
// Note that we have to import the passport instance here, it is
|
||||
// instantiated after all the strategies have been mounted.
|
||||
const {passport} = require('services/passport');
|
||||
const { passport } = require("services/passport");
|
||||
|
||||
/**
|
||||
* Facebook auth endpoint, this will redirect the user immediately to facebook
|
||||
* for authorization.
|
||||
*/
|
||||
router.get('/facebook', passport.authenticate('facebook', {display: 'popup', authType: 'rerequest', scope: ['public_profile']}));
|
||||
router.get(
|
||||
"/facebook",
|
||||
passport.authenticate("facebook", {
|
||||
display: "popup",
|
||||
authType: "rerequest",
|
||||
scope: ["public_profile"]
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Facebook callback endpoint, this will send the user a html page designed to
|
||||
* send back the user credentials upon successful login.
|
||||
*/
|
||||
router.get('/facebook/callback', (req, res, next) => {
|
||||
|
||||
router.get("/facebook/callback", (req, res, next) => {
|
||||
// Perform the facebook login flow and pass the data back through the opener.
|
||||
passport.authenticate('facebook', HandleAuthPopupCallback(req, res, next))(req, res, next);
|
||||
passport.authenticate(
|
||||
"facebook",
|
||||
HandleAuthPopupCallback(req, res, next)
|
||||
)(req, res, next);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -360,10 +439,10 @@ module.exports = {
|
||||
### translations
|
||||
|
||||
```js
|
||||
const path = require('path');
|
||||
const path = require("path");
|
||||
|
||||
module.exports = {
|
||||
translations: path.join(__dirname, 'translations.yml'),
|
||||
translations: path.join(__dirname, "translations.yml")
|
||||
};
|
||||
```
|
||||
|
||||
@@ -377,11 +456,11 @@ en:
|
||||
|
||||
Which overrides the copy for the `embedlink.copy` template. You can
|
||||
also provide other languages as well by using the correct language
|
||||
prefix.
|
||||
prefix.
|
||||
|
||||
When creating a plugin using this `translations` hook to override copy
|
||||
When creating a plugin using this `translations` hook to override copy
|
||||
from another plugin, be sure to list it after the plugin it's overriding
|
||||
in the `plugins.json` file.
|
||||
in the `plugins.json` file.
|
||||
|
||||
### websockets
|
||||
|
||||
@@ -392,12 +471,12 @@ module.exports = {
|
||||
// Do something with the connection params or connection, like
|
||||
// logging it out, or incrementing a metric.
|
||||
},
|
||||
onDisconnect: (connection) => {
|
||||
onDisconnect: connection => {
|
||||
// Do something with the connection params or connection, like
|
||||
// logging it out, or decrementing a metric.
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
This `websockets` hook can be used to attach methods to the
|
||||
@@ -411,12 +490,13 @@ connections.
|
||||
module.exports = {
|
||||
schemaLevelResolveFunction: (root, args, ctx, info) => {
|
||||
// The GraphQL Operation Name. Example: CoralEmbedStream_Embed
|
||||
const name = info.operation.name !== null ? info.operation.name.value : null;
|
||||
const name =
|
||||
info.operation.name !== null ? info.operation.name.value : null;
|
||||
// Maybe increment a metric based on the operation name...
|
||||
|
||||
// You must _always_ return the root.
|
||||
return root;
|
||||
},
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
@@ -431,9 +511,7 @@ Contents of `plugins.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": [
|
||||
"people"
|
||||
]
|
||||
"server": ["people"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -527,5 +605,4 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Asset Scraping
|
||||
permalink: /integrating/asset-scraping/
|
||||
---
|
||||
|
||||
By default, Assets in Talk have their metadata scraped when they are loaded.
|
||||
This provides the easiest way for newsrooms to integrate their CMS's into Talk
|
||||
in a simple way. We use the following
|
||||
[meta tags](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta) on
|
||||
the target pages that allow us to extract some properties.
|
||||
|
||||
| Asset Property | Selector |
|
||||
|--------------------|----------|
|
||||
| `title` | See [`metascraper-title`](https://github.com/microlinkhq/metascraper/blob/dc664c37ea1b238b1e3e9d5342edfacc9027892c/packages/metascraper-title/index.js) |
|
||||
| `description` | See [`metascraper-description`](https://github.com/microlinkhq/metascraper/blob/dc664c37ea1b238b1e3e9d5342edfacc9027892c/packages/metascraper-description/index.js) |
|
||||
| `image` | See [`metascraper-image`](https://github.com/microlinkhq/metascraper/blob/dc664c37ea1b238b1e3e9d5342edfacc9027892c/packages/metascraper-image/index.js) |
|
||||
| `author` | See [`metascraper-author`](https://github.com/microlinkhq/metascraper/blob/dc664c37ea1b238b1e3e9d5342edfacc9027892c/packages/metascraper-author/index.js) |
|
||||
| `publication_date` | See [`metascraper-date`](https://github.com/microlinkhq/metascraper/blob/dc664c37ea1b238b1e3e9d5342edfacc9027892c/packages/metascraper-date/index.js) |
|
||||
| `modified_date` | `meta[property="article:modified"]` |
|
||||
| `section` | `meta[property="article:section"]` |
|
||||
|
||||
You can use the `./bin/cli assets debug <url>` to print the scraped metadata
|
||||
from that URL. For example:
|
||||
|
||||
```bash
|
||||
$ ./bin/cli assets debug https://www.washingtonpost.com/technology/2018/10/30/apple-event-october-ipad-pro-macbook-air/
|
||||
┌──────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Property │ Value │
|
||||
├──────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ title │ Apple redesigns the iPad Pro, breathes new life in the MacBook Air │
|
||||
├──────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ description │ Apple is unveiling new iPads and MacBooks at an event in New York starting at 10 a.m. Fowler is there and will report in with the news and hands-on analysis throughout the day. │
|
||||
├──────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ image │ https://www.washingtonpost.com/resizer/JAwNQE2alL2JjiWrbXeJ46wZHqA=/1484x0/arc-anglerfish-washpost-prod-washpost.s3.amazonaws.com/public/G5TWBFW4LAI6RC5MX7QB7TODUY.jpg │
|
||||
├──────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ author │ Geoffrey A. Fowler │
|
||||
├──────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ publication_date │ 2018-10-30T10:40:00.000Z │
|
||||
├──────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ modified_date │ │
|
||||
├──────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ section │ │
|
||||
└──────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -3,32 +3,39 @@ title: Authenticating with Talk
|
||||
permalink: /integrating/authentication/
|
||||
---
|
||||
|
||||
You can integrate Talk with any external authentication service that will enable
|
||||
seamless single sign-on for users within your organization. There are a few
|
||||
methods of doing so:
|
||||
### Authentication Strategies
|
||||
|
||||
1. Passport Middleware
|
||||
2. Custom Token Integration
|
||||
Talk comes with built in user management features like account registration with username/email and password, as well as features like resetting a forgotten password.
|
||||
|
||||
Both methods work, but there are product decisions that will affect the overall
|
||||
choice.
|
||||
There are four strategies to choose from when it comes to user authentication:
|
||||
|
||||
## Passport Middleware
|
||||
#### Talk Email Authentication
|
||||
Best when you want Talk to manage all user registration, creation and authentication. Requires the least amount of setup and configuration. Users register by creating a Username (must be unique across users, no spaces or special chars), Email, and Password and are stored in Talk’s database. Optionally - you can require an email verification step at time of account creation. *Plugins required: [talk-plugin-local-auth](/talk/plugin/talk-plugin-local-auth), [talk-plugin-auth](/talk/plugin/talk-plugin-auth)*
|
||||
|
||||
You would choose the **Passport Middleware** route when you are OK using an auth
|
||||
that is triggered from inside Talk that is not connected to an external auth
|
||||
state (you don't use the auth anywhere else now). A great example of this is our
|
||||
[talk-plugin-facebook-auth](/talk/plugin/talk-plugin-facebook-auth/) plugin.
|
||||
#### Social Sign-in (FaceBook or Google via Passport Middleware)
|
||||
|
||||
## Custom Token Integration
|
||||
Allows users to use existing social media account to sign in to Talk. Requires you to setup API keys with social sign on provider, and configure your API keys in Talk. Users register and login via a *“Sign in with ….”*, but are not required to set user details such as password or email address. *Plugins required: [talk-plugin-auth](/talk/plugin/talk-plugin-auth), plus at least one of the following:*
|
||||
|
||||
* [Facebook](/talk/plugin/talk-plugin-facebook-auth/)
|
||||
* [Google](/talk/plugin/talk-plugin-google-auth/)
|
||||
|
||||
_FAQ: Can I create a Twitter auth plugin?_
|
||||
This is currently not possible because Talk uses passport.js which does not support Twitter's oAuth2 requirements.
|
||||
|
||||
#### Social Sign-in with Email Association
|
||||
This strategy is actually a combination of the first two. Talk’s built in Email Authentication is augmented by also allowing registration with a social account. Users with social accounts are prompted to associate an email address with their social profile to enable notifications. *Plugins required: [talk-plugin-auth](/talk/plugin/talk-plugin-auth), [talk-plugin-local-auth](/talk/plugin/talk-plugin-local-auth), [talk-plugin-facebook-auth](/talk/plugin/talk-plugin-facebook-auth/), and or [talk-plugin-google-auth](/talk/plugin/talk-plugin-google-auth/)*
|
||||
|
||||
#### Custom SSO Token Integration
|
||||
When you want users to authenticate via your existing identity system, and be automatically registered/logged into Talk. Users register, login, and manage their user details on your existing site, your user database maintains the source of truth for all user information. Users are created and updated in Talk via jwt tokens passed from your auth service to Talk. *Required Plugins: requires custom plugin development, as well as disabling talk-plugin-auth, and talk-plugin-local-auth*
|
||||
|
||||
## Setting Up Custom SSO Token Integration
|
||||
|
||||
You can integrate Talk with any authentication service to enable single sign-on
|
||||
for users. The steps to do that are:
|
||||
|
||||
1. Create a service that generates [JWT tokens](https://jwt.io/introduction/).
|
||||
2. Push the token into the embed.
|
||||
3. Implement the [`tokenUserNotFound`](#implement-tokenusernotfound) hook to
|
||||
process the token.
|
||||
2. Include the JWT token in your Talk embed script
|
||||
3. Create a custom plugin to generate and authenticate Talk users from the jwt token
|
||||
|
||||
### Create JWT Token
|
||||
|
||||
@@ -55,7 +62,10 @@ The generated JWT must contain the following claims:
|
||||
- [`iss`](https://tools.ietf.org/html/rfc7519#section-4.1.1): the issuer for the token must match the value of `TALK_JWT_ISSUER`
|
||||
- [`aud`](https://tools.ietf.org/html/rfc7519#section-4.1.3): the audience for the token must match the value of `TALK_JWT_AUDIENCE`
|
||||
|
||||
### Push token into embed
|
||||
### Generate a key to sign the JWT
|
||||
Optionally you can use https://github.com/coralproject/coralcert to generate a key with which to sign the JWTs and specify the secret as an environment variable.
|
||||
|
||||
### Include token in embed
|
||||
|
||||
We're assuming that your CMS is capable of authenticating a user account, or
|
||||
at least having the user's details available to send off to the token creation
|
||||
@@ -71,15 +81,17 @@ Coral.Talk.render(document.getElementById('coralStreamEmbed'), {
|
||||
});
|
||||
```
|
||||
|
||||
Which will pass down the token to Talk and will fire the next steps
|
||||
Which will pass down the token to Talk and will fire the
|
||||
`tokenUserNotFound` hook to complete the auth flow.
|
||||
|
||||
### Implement `tokenUserNotFound`
|
||||
### Create a Custom Plugin to Generate Talk Users
|
||||
|
||||
This is the only piece of code you'll have to write that lives inside Talk.
|
||||
The role of this code is to live as a plugin and provide Talk with a way of
|
||||
taking the token that you gave it, and turning into a user.
|
||||
|
||||
Your custom plugin will need to include a `tokenUserNotFound` function that will handle creating a user in Talk from the claims provided on the JWT, refer to [`tokenUserNotFound`](/talk/api/server/#tokenusernotfound) for reference.
|
||||
|
||||
Using the example application we were working with in the JWT issuing step
|
||||
above, we'll need to ensure that the configuration is consistent in-between both
|
||||
Talk and the JWT issuer. Namely, the following environment variables from our
|
||||
@@ -96,5 +108,4 @@ example issuer and Talk must match:
|
||||
reference, the basic takeaway is that the secret used to sign the tokens issued
|
||||
by the issuer must be able to be verified by Talk.
|
||||
|
||||
For an example of implementing the plugin, refer to [`tokenUserNotFound`](/talk/api/server/#tokenusernotfound)
|
||||
reference.
|
||||
For an example of implementing the plugin, check out our [SSO Custom Auth Recipe](https://github.com/coralproject/talk-recipes/tree/master/plugins/custom-auth)
|
||||
|
||||
+73
-11
@@ -1,22 +1,81 @@
|
||||
---
|
||||
title: Asset Management
|
||||
permalink: /integrating/asset-management/
|
||||
title: CMS Integration
|
||||
permalink: /integrating/cms-integration/
|
||||
---
|
||||
|
||||
## Embedding Comments on Your Site
|
||||
Talk provides an embed script that you can drop into your site where you want a comments section to appear. By default that script dynamically generate Assets
|
||||
in Talk in order to make it easier for lighter installations.
|
||||
|
||||
You can find the embed script inside talk under `Configure > Tech Settings > Embed Script`. It should look something like this, but with your domain in place of `<TALK_ROOT_URL>`:
|
||||
```
|
||||
<div id="coral_talk_stream"></div>
|
||||
<script src="<TALK_ROOT_URL>/static/embed.js" async onload="
|
||||
Coral.Talk.render(document.getElementById('coral_talk_stream'), {
|
||||
talk: '<TALK_ROOT_URL>'
|
||||
});
|
||||
"></script>
|
||||
```
|
||||
|
||||
## Triggering the Comments Section (client side, i.e. Your site)
|
||||
|
||||
When the embed script is triggered on your page load several things are initiated inside Talk, including fetching all comments for the specified article, establishing websocket connections for this user, and checking user’s session for SSO/authentication.
|
||||
|
||||
Instead of greedily triggering the embed to render on _EVERY PAGE LOAD_, we highly recommend implementing a _“lazy”_ rendering strategy to only render the comments section if a user wants to interact with it. This will greatly improve your initial page load performance, and will be critical to managing server resources if you’re running Talk on a heavy-traffic production site.
|
||||
|
||||
We recommend using one of these _“lazy”_ loading strategies:
|
||||
|
||||
#### Scroll to Comments Section
|
||||
Wait for user to scroll to the comment section before triggering the embed to render.
|
||||
|
||||
You can pass lazy: true to the render options, like so:
|
||||
```
|
||||
Coral.Talk.render(document.getElementById('container'), {
|
||||
talk: 'https://my-talk-installation.com',
|
||||
lazy: true,
|
||||
});
|
||||
```
|
||||
|
||||
Or you can enable lazy rendering by default on all assets using ENV variable `TALK_DEFAULT_LAZY_RENDER=TRUE`
|
||||
|
||||
_*Note: This feature requires Talk version 4.6.8 or greater_
|
||||
|
||||
#### Show Comments Button
|
||||
You can hide the comments section until a user clicks button, then trigger the embed to render
|
||||
|
||||
This example uses jQuery to render the embed on the button's click event
|
||||
```
|
||||
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
|
||||
|
||||
<div id="coral_talk_stream">
|
||||
<button>Show Comments</button>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
$('#coral_talk_stream button').on('click', function() {
|
||||
$.getScript('<TALK_ROOT_URL>/static/embed.js', function() {
|
||||
Coral.Talk.render(document.getElementById('coral_talk_stream'), {
|
||||
talk: '<TALK_ROOT_URL>',
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Creating Assets in Talk (server side, i.e. What you need to send to Talk)
|
||||
One of the most frequent questions that we get asked by organizations trying to
|
||||
integrate Talk is: _How do we hook our CMS up to Talk so that articles are in
|
||||
sync?_
|
||||
|
||||
This guide is designed to explain the steps to take your base installation of
|
||||
Talk and configure it to allow only assets pushed into it from your CMS, and
|
||||
keep your URL/title in sync. We won't cover here how to install the plugin, as
|
||||
it is covered in our [Plugins Overview](/talk/plugins/).
|
||||
### “Lazy Asset Creation”
|
||||
Talk’s provided embed script by default dynamically generates Assets in Talk based on each unique url that triggers it. The url must reference an existing Permitted Domain. If your articles/stories always have unique urls, then you will not need to modify the default behavior.
|
||||
|
||||
## Why do we need to create a plugin?
|
||||
Assets created in this way will then be scraped to load metadata, see [Asset Scraping](/talk/integrating/asset-scraping/)
|
||||
|
||||
By default, Talk will use "Lazy Asset Creation" to dynamically generate Assets
|
||||
in Talk in order to make it easier for lighter installations. In order to have
|
||||
more strict control over this flow, we will create a plugin that will:
|
||||
## Customizing the Integration with a Plugin
|
||||
In order to have more strict control over the asset creation flow to allow only assets pushed into it from your CMS, and keep your URL/title in sync we will create a plugin.
|
||||
|
||||
We will create a plugin that will:
|
||||
|
||||
1. Disable "Lazy Asset Creation" by [Overriding a Resolver](#overriding-a-resolver).
|
||||
2. Create Assets from our CMS by [Creating a New Asset Route](#creating-a-new-asset-route).
|
||||
@@ -24,6 +83,9 @@ more strict control over this flow, we will create a plugin that will:
|
||||
|
||||
We will then modify our embed so that we can [Target the Asset](#target-the-asset).
|
||||
|
||||
|
||||
This guide is designed to explain the steps to take your base installation of Talk and configure it. We won't cover here how to install the plugin, as it is covered in our [Plugins Overview](/talk/plugins/).
|
||||
|
||||
But first we should grab our basic plugin structure:
|
||||
|
||||
```sh
|
||||
@@ -248,7 +310,7 @@ module.exports = router => {
|
||||
};
|
||||
```
|
||||
|
||||
As you can see from the previous step of [Creating a New Asset Route](#creating-a-New-Asset-Route)
|
||||
As you can see from the previous step of [Creating a New Asset Route](#Creating%20a%20New%20Asset%20Route)
|
||||
, we have added the new `PUT` route to the router. This is a simple addition
|
||||
that allows your CMS to call into Talk when the asset has updated it's title,
|
||||
it's url (or really anything in the [AssetSchema](https://github.com/coralproject/talk/blob/master/models/asset.js)) to keep the Talk Admin and links up to date.
|
||||
@@ -3,9 +3,19 @@ title: Tracking Talk Events and Metrics
|
||||
permalink: /integrating/event-tracking-metrics/
|
||||
---
|
||||
|
||||
Talk supports event emitting via Redux, Apollo and GraphQL. This means that common actions taken within Talk, such as successfully posting a comment, posting a reaction, or changing a setting, are automatically emitted from Talk. To send these events to your analytics tool of choice, however, will require some integration on your part.
|
||||
Talk supports event emitting. This means that common actions taken within Talk, such as successfully posting a comment, posting a reaction, or changing a setting, are automatically emitted from Talk. To send these events to your analytics tool of choice, you’ll need to configure your Talk embed to process them.
|
||||
|
||||
First, we want to uncomment the tracking code in `article.ejs` (https://github.com/coralproject/talk/blob/93bda87ad061a2dc5eb8dc5b65a579a20efb76f7/views/article.ejs#L34). This will enable events to be sent via the Talk embed that is on your article pages. This will start a stream of events to the browser console, so that you can see which events are available.
|
||||
The following example demonstrates how the event emitter works on a sample article page generated by Talk. When you’re ready to capture and handle events from your actual site, add the events property along with your event handling functions to your Talk embed script.
|
||||
|
||||
## Example Using Local Development Instance of Talk
|
||||
If you have Talk running in a local development environment you can follow these steps to first view all of the events emitted by Talk, and then capture the `PostComment.success` event and send it to a third party analytics provider.
|
||||
|
||||
|
||||
First, with Talk running, ensure that you can access the `/dev/assets` endpoint and create a random article (i.e.: `localhost:3000/dev/assets`).
|
||||
|
||||
|
||||
Then in Talk, uncomment the events code in `/views/dev/article.njk`
|
||||
(https://github.com/coralproject/talk/blob/master/views/dev/article.njk#L32). This will enable events to be sent via the Talk embed that is on the sample article page. This will start a stream of events to the browser console, so that you can see which events are available.
|
||||
|
||||
```
|
||||
events: function(events) {
|
||||
@@ -16,7 +26,10 @@ First, we want to uncomment the tracking code in `article.ejs` (https://github.c
|
||||
},
|
||||
```
|
||||
|
||||
Now, we want to add our code that sends the events to our analytics system. In this case, we're sending the `PostComment.success` event. The particular way you send this will depend on what tool you're using. Refer to your tool's API and docs to determine this.
|
||||
Save your changes to `article.njk`, restart Talk, and try refreshing your article in a browser with development console open. You should see the console log the list of Talk’s events.
|
||||
|
||||
|
||||
Next, we want to add our code that sends the events to our analytics system. In this example, we're sending the `PostComment.success` event. The particular way you send this will depend on what tool you're using. Refer to your tool's API and docs to determine this.
|
||||
|
||||
```
|
||||
events: function(events) {
|
||||
@@ -30,4 +43,24 @@ Now, we want to add our code that sends the events to our analytics system. In t
|
||||
```
|
||||
You can continue this process for any specific events you'd like to track. You can also remove the `console.log` to stop events being emitted to the browser and instead only send the events to your analytics tool.
|
||||
|
||||
PR for Reference: https://github.com/coralproject/talk/pull/785
|
||||
## Metrics List
|
||||
|
||||
Note: This is not an exhaustive list, but should get you started.
|
||||
|
||||
```
|
||||
mutation.CreateRespectAction.success
|
||||
mutation.CreateLikeAction.success
|
||||
mutation.PostComment.success
|
||||
mutation.PostFlag.success
|
||||
mutation.EditComment.success
|
||||
|
||||
action.CORAL_STREAM_SET_ACTIVE_TAB
|
||||
action.SHOW_SIGNIN_DIALOG
|
||||
action.FOCUS_SIGNIN_DIALOG
|
||||
action.TALK_FRAMEWORK_HANDLE_SUCCESSFUL_LOGIN
|
||||
action.HIDE_SIGNIN_DIALOG
|
||||
action.TALK_VIEWING_OPTIONS_OPEN_MENU
|
||||
action.TALK_VIEWING_OPTIONS_CLOSE_MENU
|
||||
action.CORAL_STREAM_SET_SORT
|
||||
action.SET_ACTIVE_REPLY_BOX
|
||||
```
|
||||
|
||||
@@ -3,22 +3,13 @@ title: Notifications
|
||||
permalink: /integrating/notifications/
|
||||
---
|
||||
|
||||
Talk currently supports 3 types of email notifications.
|
||||
There are several plugins included with Talk that once enabled will control how and when Talk sends emails to users based on user activity. Only basic user profile notifications are enabled by default.
|
||||
|
||||
|
||||
1. When someone replies to my comment
|
||||
2. When a staff member replies to my comment
|
||||
3. When my comment gets featured
|
||||
|
||||
Talk support 3 options for notification frequency: immediately, hourly or daily. Commenters can also opt-out of email notifications. Notifications are set to OFF by default.
|
||||
|
||||
Commenters cannot enable notifications until they have verified their email.
|
||||
|
||||
Note: Notifications are not currently supported for users that sign-up via Facebook or Google auth, or don't have an email attached to their account for any other reason.
|
||||
_NOTE: Notifications are only supported for users that have an email address in Talk! If you are using authenticate via Facebook, Google, or SSO and you want to persist the user’s email, you will also need to enable `talk-plugin-local-auth` ([See Authentication](/talk/integrating/authentication/))_
|
||||
|
||||
### Configuring SMTP
|
||||
|
||||
You must setup SMTP to use notifications. The following ENV variables must be set:
|
||||
You must setup SMTP to send email notifications. The following ENV variables must be set:
|
||||
|
||||
```
|
||||
TALK_SMTP_FROM_ADDRESS=email@email.com
|
||||
@@ -28,28 +19,74 @@ TALK_SMTP_HOST=smtp.domain.net
|
||||
TALK_SMTP_PORT=2525
|
||||
```
|
||||
|
||||
### Enabling Notifications
|
||||
See our documentation on [setting env variables](/talk/advanced-configuration/#talk-smtp-from-address) for reference.
|
||||
|
||||
Enabling the `talk-plugin-notifications` creates a NotificationManager that creates and manages events send from the event emitter that is linked to the Graph API PubSub system.
|
||||
When running Talk in production we recommend using a 3rd party mail service provider like SendGrid or MailGun.
|
||||
|
||||
Adding the `talk-plugin-notifications` plugin will also enable the `notifications` plugin hook. Any plugin that registers before the `talk-plugin-notifications` plugin will get picked up by.
|
||||
If you are having difficulty with your SMTP settings you can add `DEBUG=talk:jobs:mailer` to your .env to see additional logs from the mailer service.
|
||||
|
||||
See https://github.com/coralproject/talk/blob/8b669a31c551a042f0f079d8cfc16825673eb8f0/plugins/talk-plugin-notifications-reply/index.js for an example.
|
||||
All notifications require SMTP settings to be correctly configured first, then enable the corresponding plugin, and configure any settings in the Talk Admin. If SMTP is not enabled, it will log to the server console that some variables were not provided.
|
||||
|
||||
### Notification Categories
|
||||
### Notification Types & Plugins
|
||||
|
||||
Talk currently supports the following Notifications options out of the box:
|
||||
In addition to the core notifications included with Talk, some plugins add features that are supported by and will add additional types of email notifications. Commenters can opt-out of most email notifications, and comment activity notifications are set to OFF by default. Commenters cannot enable notifications until they have verified their email.
|
||||
|
||||
`talk-plugin-notifications-category-reply`
|
||||
`talk-plugin-notifications-category-staff-reply`
|
||||
`talk-plugin-notifications-category-featured`
|
||||
You can see a list of all the plugins related to notifications by visiting:
|
||||
https://docs.coralproject.net/talk/plugins-directory/?q=notifications
|
||||
|
||||
#### Type: User Profile Notifications (Included by Talk core)
|
||||
|
||||
* When user registers, sends email confirmation and verification
|
||||
* When user requests password reset
|
||||
* When user changes username, sends confirmation
|
||||
* When a user is banned or suspended
|
||||
|
||||
_NOTE: Users can not opt-out of User Profile Notifications_
|
||||
|
||||
#### Type: GDPR Notifications (optional)
|
||||
`talk-plugin-profile-data:`
|
||||
* When my comment history is ready for download (only one link can be generated every 7 days, and the link is valid for 24 hours)
|
||||
|
||||
`talk-plugin-local-auth:`
|
||||
* When user changes their email address
|
||||
* When a user adds a new email address
|
||||
|
||||
#### Type: Comment Activity Notifications (optional)
|
||||
Talk support 3 options for notification frequency for this type of notification:
|
||||
* immediately
|
||||
* hourly
|
||||
* daily
|
||||
|
||||
`talk-plugin-notifications:`
|
||||
* Enables notifications configuration (required for all comment activity notifications below)
|
||||
|
||||
`talk-plugin-notifications-category-reply:`
|
||||
* When someone replies to my comment
|
||||
|
||||
`talk-plugin-notifications-category-staff-reply:`
|
||||
* When a staff member replies to my comment
|
||||
|
||||
`talk-plugin-notifications-category-featured:`
|
||||
* When my comment gets featured
|
||||
|
||||
### Notification Digests
|
||||
|
||||
Notification digests are enabled by enabling the corresponding plugin, otherwise comment activity notifications are sent immediately.
|
||||
* `talk-plugin-notifications-digest-daily`
|
||||
* `talk-plugin-notifications-digest-hourly`
|
||||
|
||||
Talk supports hourly and daily digests out the box, if you would like to create your own, refer to the below:
|
||||
|
||||
https://github.com/coralproject/talk/blob/9cc9969320dca47bb0f8f81e8d944ae4d19e548b/plugins/talk-plugin-notifications/server/connect.js#L69-L102
|
||||
|
||||
|
||||
### Customizing Notifications
|
||||
|
||||
Enabling the `talk-plugin-notifications` creates a NotificationManager that creates and manages events send from the event emitter that is linked to the Graph API PubSub system. This allows the instance that received the mutation to also fire off a notification job that can be handled. It also enabels the `notifications` plugin hook. Any plugin that registers after the `talk-plugin-notifications` can export the notifications plugin to reference it.
|
||||
|
||||
See https://github.com/coralproject/talk/blob/8b669a31c551a042f0f079d8cfc16825673eb8f0/plugins/talk-plugin-notifications-reply/index.js for an example.
|
||||
|
||||
|
||||
### Connect API
|
||||
|
||||
This exposes the `graph/connectors.js` via the `connect` hook.
|
||||
@@ -67,3 +104,12 @@ See https://github.com/coralproject/talk/blob/90290cfa2de88e62f687e1ed0235ba6dfe
|
||||
### Email Templates
|
||||
|
||||
Email templates are text based and support translations. If you would like to create a new email template, you can register it via the Connect API, see https://github.com/coralproject/talk/blob/8b669a31c551a042f0f079d8cfc16825673eb8f0/plugins/talk-plugin-notifications/server/connect.js#L12-L28###
|
||||
|
||||
Any email template registered with the same name as another template will replace the existing template for that type of email. _NOTE: that also means that the template data sent to each email template will __not__ change, so bear that in mind when designing templates that you may not have rich access to data._
|
||||
|
||||
An example of this is with the `talk-plugin-notifications-category-featured` plugin:
|
||||
|
||||
Notification translation is located: https://github.com/coralproject/talk/blob/dd0601a80132d2849c53ef7eaf12ff382f3920b9/plugins/talk-plugin-notifications-category-featured/translations.yml#L13
|
||||
|
||||
Where the template content is provided: https://github.com/coralproject/talk/blob/dd0601a80132d2849c53ef7eaf12ff382f3920b9/plugins/talk-plugin-notifications-category-featured/index.js#L79
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: talk-plugin-notifications-category-moderation-actions
|
||||
permalink: /plugin/talk-plugin-notifications-category-moderation-actions/
|
||||
layout: plugin
|
||||
plugin:
|
||||
name: talk-plugin-notifications-moderation-actions
|
||||
default: true
|
||||
depends:
|
||||
- name: talk-plugin-notifications
|
||||
provides:
|
||||
- Server
|
||||
- Client
|
||||
---
|
||||
|
||||
When a comment that is initially withheld from publication and is then approved or rejected, the user will receive a notification email.
|
||||
|
||||
Configuration:
|
||||
`TALK_MODERATION_NOTIFICATION_TYPES`. This plugin requires values to be set. Available options: `APPROVED`, `REJECTED` as a single string (comma separated).
|
||||
@@ -67,9 +67,9 @@ External plugins can be resolved by running:
|
||||
This achieves two things:
|
||||
|
||||
1. It will traverse into local plugin folders and install their dependencies.
|
||||
_Note that if the plugin is already installed and available in the
|
||||
__Note that if the plugin is already installed and available in the
|
||||
node_modules folder, it will not be fetched again unless there is a version
|
||||
mismatch._ This will result in the project `package.json` and `yarn.lock`
|
||||
mismatch.__ This will result in the project `package.json` and `yarn.lock`
|
||||
files to be modified, this is normal as this ensures that repeated deployments
|
||||
(with the same config) will have the same config, these changes should not be
|
||||
committed to source control.
|
||||
@@ -105,6 +105,8 @@ Then the application can be started as is.
|
||||
|
||||
### Docker
|
||||
|
||||
To deploy customized plugins to a production instance of Talk, we recommend using the Docker onbuild strategy outlined below.
|
||||
|
||||
If you deploy using Docker, you can extend from the `*-onbuild` image, an
|
||||
example `Dockerfile` for your project could be:
|
||||
|
||||
@@ -112,14 +114,22 @@ example `Dockerfile` for your project could be:
|
||||
FROM coralproject/talk:4.5-onbuild
|
||||
```
|
||||
|
||||
Where the directory for your instance would contain a `plugins.json` file
|
||||
describing the plugin requirements and a `plugins` directory containing any
|
||||
Establish a private repository for your instance that includes the following:
|
||||
|
||||
* a `plugins.json` file
|
||||
listing the plugin requirements
|
||||
* a `plugins` directory containing any
|
||||
other local plugins that should be included.
|
||||
* a Dockerfile as outlined above
|
||||
|
||||
Git submodules can also be used to point to plugin directories that might be outside your primary repository.
|
||||
|
||||
Onbuild triggers will execute when the image is building with your custom
|
||||
configuration and will ensure that the image is ready to use by building all
|
||||
assets inside the image as well.
|
||||
|
||||
Once built, you can deploy the docker image to your architecture by tagging the image and including it in your docker-compose.yml.
|
||||
|
||||
For more information on the onbuild image, refer to the
|
||||
[Installation from Docker](/talk/installation-from-docker/) documentation.
|
||||
|
||||
|
||||
+3
-4
@@ -28,9 +28,8 @@
|
||||
|
||||
|
||||
<div class="notice">
|
||||
<p>Don't see what you're looking for? We're upgrading the docs, check out our <a href="https://github.com/coralproject/talk/tree/ef49d9a3d2acc4d2fc03b00e0c872dfbc57f005a/docs/_docs" target="_blank" class="coral-link-invert">old docs</a>.</p>
|
||||
<p><a href="https://coralproject.net/" class="coral-link-invert" target="_blank">The Coral Project</a> from Mozilla</p>
|
||||
<a href="https://www.mozilla.org/" target="_blank" title="Mozilla"><figure class="mozilla" alt="Mozilla">{{ image_tag('images/moz-logo-bw-rgb.svg') }}</figure></a>
|
||||
<p>Made by <a href="https://coralproject.net/" class="coral-link-invert" target="_blank">The Coral Project</a> by <a href="https://www.voxmedia.com/" class="coral-link-invert" target="_blank" title="Vox Media">Vox Media</a>.</p>
|
||||
<a href="https://www.voxmedia.com/" target="_blank" title="Vox Media"><figure class="voxmedia" alt="Vox Media">{{ image_tag('images/coralproject.png') }}</figure></a>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="sidebar__backdrop"></div>
|
||||
<div class="sidebar__backdrop"></div>
|
||||
|
||||
+6
-2
@@ -344,9 +344,13 @@ a.brand {
|
||||
padding: $content_gutter $content_gutter 60px;
|
||||
}
|
||||
|
||||
.mozilla {
|
||||
width: 50%;
|
||||
.voxmedia {
|
||||
// width: 50%;
|
||||
margin: 0 0 $sidebar_gutter;
|
||||
|
||||
> img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 20.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 360 102.8" style="enable-background:new 0 0 360 102.8;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g>
|
||||
<rect y="-0.2" width="360" height="102.9"/>
|
||||
<path class="st0" d="M343.8,73.7c-0.7,0.2-1.3,0.3-1.9,0.3c-2.1,0-3.1-0.9-3.1-3.5V51c0-10.3-8.2-15.3-17.9-15.3
|
||||
c-7.4,0-11.4,0.9-19.3,4.1L300,50.2l10.3,1.1l1.5-5.1c2.1-1.1,4.2-1.3,6.9-1.3c7.3,0,7.4,5.5,7.4,10.1v1.5
|
||||
c-2.3-0.3-4.9-0.4-7.4-0.4c-10.3,0-21,2.6-21,13.7c0,9.4,7.4,12.9,13.9,12.9c7.3,0,11.9-4.4,14.5-9c0.6,5.5,3.9,9,9.9,9
|
||||
c2.8,0,5.7-0.8,8.1-2.1L343.8,73.7z M316.2,73.4c-3.9,0-5.3-2.3-5.3-5.2c0-4.9,4-6.2,8.6-6.2c2.1,0,4.4,0.3,6.5,0.6
|
||||
C325.7,70.1,320.8,73.4,316.2,73.4z M298.9,12.9l-19.5,68.7h-12.7l19.5-68.7H298.9z M272.7,12.9l-19.5,68.7h-12.7L260,12.9H272.7z
|
||||
M218.7,36.6h13.5v16.2h-13.5V36.6z M218.7,65.5h13.5v16.2h-13.5V65.5z M199,64.9l10.2,1l-2.8,15.8h-39.3l-1.3-6.8l24.8-28.5h-14.1
|
||||
l-2,7l-9.3-1l1.6-15.8h39.5l1,6.8l-25,28.5h14.6L199,64.9z M134.5,35.7c-16.2,0-24.2,10.9-24.2,24.1c0,14.4,9.6,22.9,23.5,22.9
|
||||
c14.4,0,24.8-9.1,24.8-23.5C158.6,46.6,150.7,35.7,134.5,35.7z M134.2,72.7c-7,0-10.6-6-10.6-13.8c0-8.5,4.1-13.4,10.7-13.4
|
||||
c6.1,0,11,4.1,11,13.2C145.3,67.4,140.9,72.7,134.2,72.7z M98.7,71.8h6v9.8H85.9V56.3c0-7.8-2.6-10.8-7.7-10.8
|
||||
c-6.2,0-8.7,4.4-8.7,10.7v15.7h6v9.8H56.6V56.3c0-7.8-2.6-10.8-7.7-10.8c-6.2,0-8.7,4.4-8.7,10.7v15.7h8.6v9.8H21.4v-9.8h6V46.4h-6
|
||||
v-9.8h18.9v6.8c2.7-4.8,7.4-7.7,13.7-7.7c6.5,0,12.5,3.1,14.7,9.7c2.5-6,7.6-9.7,14.7-9.7c8.1,0,15.5,4.9,15.5,15.6V71.8z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user