Compare commits

...
159 Commits
Author SHA1 Message Date
Kim Gardner f738da4dd5 Merge pull request #744 from coralproject/redis-flush
Flush Redis before each test
2017-07-07 16:19:52 +01:00
Kim Gardner b796bf2607 Merge branch 'master' into redis-flush 2017-07-07 16:06:22 +01:00
Kim Gardner 3924ced659 Merge pull request #743 from coralproject/change-org-name
Added new cli-settings
2017-07-07 16:01:55 +01:00
Wyatt Johnson 23573501bd Configured redis to flush before each test and changed default test
redis database
2017-07-07 08:57:56 -06:00
Wyatt Johnson 787c00325d Added new cli-settings 2017-07-07 08:40:18 -06:00
Kim Gardner db371d1c82 Merge pull request #742 from coralproject/bump-version-2-3-0
Update version number
2017-07-07 15:25:36 +01:00
David Erwin b744a98a3d Merge branch 'master' into bump-version-2-3-0 2017-07-07 10:20:44 -04:00
Kim Gardner 37439a0896 Merge pull request #741 from coralproject/rm-commonjs-framework
Remove CommonJS style exports from framework
2017-07-07 15:19:57 +01:00
Kim Gardner af3cd8aa68 Update version number 2017-07-07 15:09:25 +01:00
Kiwi 7baa551a5c Merge branch 'master' into rm-commonjs-framework 2017-07-07 12:39:00 +02:00
Chi Vinh Le b7c4dbd224 Remove CommonJS style exports in framework 2017-07-07 17:27:05 +07:00
Belén Curcio b28902312d Merge pull request #740 from coralproject/threading-cleanup
Cleanup
2017-07-05 12:41:28 -03:00
Kiwi f32c03f002 Merge branch 'master' into threading-cleanup 2017-07-05 17:13:43 +02:00
Chi Vinh Le 693879647e Cleanup 2017-07-05 22:11:55 +07:00
Kim Gardner c01d1f95bc Merge pull request #735 from coralproject/french-translation
French translations
2017-07-05 15:59:36 +01:00
Kim Gardner 65d6d3221b Merge branch 'master' into french-translation 2017-07-05 15:36:14 +01:00
Kim Gardner 15af61de67 Merge pull request #738 from coralproject/threading
Deep Threading Support
2017-07-05 15:35:43 +01:00
Kim Gardner 1adca208dd Merge branch 'master' into french-translation 2017-07-05 15:26:39 +01:00
Kim Gardner 935982cb08 Merge branch 'master' into threading 2017-07-05 15:23:45 +01:00
Kim Gardner 4b9f3390d3 Merge pull request #739 from coralproject/bump-version-2-2-1
Update version 2.2.1
2017-07-05 11:57:27 +01:00
Kim Gardner f94c36e9c9 Update version 2.2.1 2017-07-05 11:43:18 +01:00
Kim Gardner c976ba0ea4 Merge branch 'master' into french-translation 2017-07-05 09:17:36 +01:00
Chi Vinh Le 6d71bcbee9 Fix styling issues 2017-07-04 22:56:58 +07:00
Chi Vinh Le 9be2a520db Merge branch 'master' into threading 2017-07-04 22:42:49 +07:00
Chi Vinh Le d155615102 Add pending comment 2017-07-04 22:41:38 +07:00
Kim Gardner 4d71a5a448 Merge pull request #737 from coralproject/fix-plugin-slot-logic
Fix isSlotEmpty logic and handle empty plugin_config
2017-07-04 16:35:05 +01:00
Chi Vinh Le cfa7a74742 Fix isSlotEmpty logic and handle empty plugin_config 2017-07-04 22:22:52 +07:00
Chi Vinh Le 524be1b869 Allow customizing thread levels via TALK_THREADING_LEVEL env 2017-07-04 22:10:40 +07:00
Chi Vinh Le 31ab54d13c First round styling 2017-07-04 21:32:33 +07:00
David Erwin c5203e3460 Merge pull request #734 from coralproject/dont-show-popup-when-empty
Don't show popup when empty
2017-07-03 14:40:31 -04:00
David Erwin 79c1bd9bdd Merge branch 'master' into dont-show-popup-when-empty 2017-07-03 14:35:44 -04:00
David Erwin e2575cb346 Merge pull request #733 from coralproject/disable-plugin-components-via-config
Disable plugin components via plugin_config
2017-07-03 14:28:54 -04:00
Chi Vinh Le 3a2a4379b8 Change to 4 levels 2017-07-04 01:09:27 +07:00
Chi Vinh Le f1899f2f4a Threading support 2017-07-04 01:09:12 +07:00
Belén Curcio 4d821afa6a Merge branch 'master' into disable-plugin-components-via-config 2017-07-03 15:08:36 -03:00
David Erwin c16278221c Merge pull request #719 from coralproject/docs-contribution-guide
Rewrite contributor's guide
2017-07-03 10:24:28 -04:00
David Erwin a50b39091f ABC Always Be Clarifying 2017-07-03 10:13:09 -04:00
David Erwin 4c7506e4f5 Merge branch 'docs-contribution-guide' of https://github.com/coralproject/talk into docs-contribution-guide 2017-07-03 10:06:56 -04:00
David Erwin 2f06281439 Move contributor's guide 2017-07-03 10:03:51 -04:00
Chi Vinh Le fd1f8b24c1 Don't show popup when empty 2017-07-03 19:40:26 +07:00
Chi Vinh Le 270c3b7634 Disable plugin components via plugin_config 2017-07-03 19:01:17 +07:00
Kim Gardner e4647cab98 Merge branch 'master' into docs-contribution-guide 2017-07-03 12:12:56 +01:00
Kim Gardner 60b4909395 Merge pull request #732 from coralproject/update-lockfile
Update yarn.lock
2017-06-30 17:53:57 +01:00
Kim Gardner 0973600e68 Update yarn.lock 2017-06-30 17:45:45 +01:00
Kim Gardner e3ead31964 Merge pull request #731 from coralproject/button-states
Support post, reply, edit, load and view more loading states via global css classnames
2017-06-30 17:32:47 +01:00
Chi Vinh Le 7858ccc45d Switch to prop-types 2017-06-30 23:25:11 +07:00
Chi Vinh Le c1807daeaf Correct spanish translation key 2017-06-30 21:10:19 +07:00
Chi Vinh Le e244d4a286 Merge branch 'master' into button-states
Conflicts:
	client/coral-embed-stream/src/components/Stream.js
2017-06-30 17:46:17 +07:00
Chi Vinh Le 9de99a9ef4 Support post, reply, edit, load and view more loading states via global css classnames 2017-06-30 17:00:42 +07:00
Belén Curcio 01883851ea Merge pull request #729 from coralproject/permalink-id
Permalink by Query Param instead of Hash
2017-06-29 14:05:07 -03:00
Belen Curcio c8d9447033 Adding the polifill to the package.json 2017-06-29 13:44:40 -03:00
Kim Gardner 4319bb9da3 Merge branch 'master' into docs-contribution-guide 2017-06-29 17:31:33 +01:00
Belen Curcio 9f8c8e9c83 Merge branch 'permalink-id' of github.com:coralproject/talk into permalink-id 2017-06-29 13:30:49 -03:00
Kim Gardner 997dffd326 Merge branch 'master' into permalink-id 2017-06-29 17:30:38 +01:00
Kim Gardner f6d652df0d Merge pull request #730 from coralproject/fix-build
Update dependencies to fix build
2017-06-29 17:30:20 +01:00
Belen Curcio c3ee442775 Replacing moderation comment and history hashes 2017-06-29 13:30:12 -03:00
Belen Curcio bad869bdfe Merge branch 'permalink-id' of github.com:coralproject/talk into permalink-id 2017-06-29 13:27:08 -03:00
Belen Curcio 2289c405d1 Translatios 2017-06-29 13:25:50 -03:00
Belen Curcio 354295c0ae Adding polyfill 2017-06-29 13:20:27 -03:00
Kim Gardner 737cea6e76 Reinstall modules 2017-06-29 16:59:00 +01:00
Kim Gardner 867f6966ba Update dependencies to fix build 2017-06-29 16:52:01 +01:00
Belén Curcio fbefd7fd20 Merge branch 'master' into permalink-id 2017-06-29 12:35:06 -03:00
Kim Gardner a0a7971866 Merge pull request #725 from StephanieDClark/add-tab-classes
Updates to tab classes
2017-06-29 16:22:52 +01:00
Kim Gardner 16355169ce Merge branch 'master' into add-tab-classes 2017-06-29 16:16:24 +01:00
Kim Gardner a7f4dea74a Merge branch 'master' into permalink-id 2017-06-29 15:58:23 +01:00
Kim Gardner 8aa0c16769 Merge pull request #728 from coralproject/bump-version-2-2-0
Bump version to 2.2.0
2017-06-29 15:57:20 +01:00
Belen Curcio d31044f39e linting 2017-06-29 11:25:52 -03:00
Belén Curcio a804973990 Merge branch 'master' into permalink-id 2017-06-29 11:22:28 -03:00
Belen Curcio db9c3b45c1 Stream Error 2017-06-29 11:06:52 -03:00
Belen Curcio ddac5c2c85 Avoid Error due to invalid queries 2017-06-29 10:48:29 -03:00
Belen Curcio 0312530b86 Modifying permalink plugin 2017-06-29 10:20:33 -03:00
Belen Curcio 87f8ed401f Using CommentId by queryParam 2017-06-29 10:18:40 -03:00
Kim Gardner 06ebd9ccc5 Bump version to 2.2.0 2017-06-29 12:15:00 +01:00
Kim Gardner e615624b62 Merge branch 'master' into add-tab-classes 2017-06-29 11:06:33 +01:00
Kim Gardner 9803ccfaf5 Merge pull request #724 from coralproject/permalink-patch
Permalink plugin now enabled by default
2017-06-29 10:35:11 +01:00
Wyatt Johnson ab84436a78 Update plugins.default.json 2017-06-28 15:32:53 -06:00
StephanieDClark ccaa744a78 Updates to tab classes 2017-06-28 14:26:41 -07:00
Belén Curcio d55fc6b601 Merge branch 'master' into docs-contribution-guide 2017-06-28 12:08:40 -03:00
Kim Gardner 32318567c9 Merge pull request #723 from coralproject/dont-alias-username-2
Don't alias username 2
2017-06-28 15:22:55 +01:00
Kiwi 6cf8c01d61 Merge branch 'master' into dont-alias-username-2 2017-06-28 21:17:41 +07:00
Chi Vinh Le b92afff4e7 Don't alias username 2 2017-06-28 21:14:34 +07:00
Kim Gardner 089ed0f09b Merge pull request #722 from coralproject/edit-name-fix
Allow editing to the same username in the "create username" flow
2017-06-28 14:43:17 +01:00
Belén Curcio 2258125789 Merge branch 'master' into edit-name-fix 2017-06-28 10:22:31 -03:00
Belén Curcio 8de4b475a1 Merge pull request #721 from coralproject/smooth-scroll
Smooth Scroll on Moderation
2017-06-28 10:21:56 -03:00
Belen Curcio 4691ee07de Merge branch 'smooth-scroll' of github.com:coralproject/talk into smooth-scroll 2017-06-28 09:07:19 -03:00
Belen Curcio f767b6a998 Removing old 2017-06-28 09:06:46 -03:00
Kiwi 9b26b0a434 Merge branch 'master' into edit-name-fix 2017-06-28 18:56:34 +07:00
Kim Gardner 7817804bae Merge branch 'master' into smooth-scroll 2017-06-28 12:51:43 +01:00
Kim Gardner b2738afdbe Merge pull request #718 from coralproject/no-username-alias
Don't alias username
2017-06-28 12:51:27 +01:00
Kim Gardner e0a32e5a70 Merge branch 'master' into smooth-scroll 2017-06-28 12:27:40 +01:00
Kiwi b7a981e84b Merge branch 'master' into no-username-alias 2017-06-28 16:37:01 +07:00
Kiwi 52431a4c4c Merge branch 'master' into edit-name-fix 2017-06-28 16:36:20 +07:00
Kim Gardner ca0431054b Merge pull request #720 from coralproject/flag-reason
BUG: Flag Username - Offensive, doesn't show up in the Community Admin
2017-06-28 10:33:52 +01:00
Kim Gardner 3f03994a51 Merge branch 'master' into flag-reason 2017-06-28 10:27:48 +01:00
Kim Gardner 198efd02b4 Merge branch 'master' into smooth-scroll 2017-06-28 10:27:00 +01:00
Kiwi aff486e909 Merge branch 'master' into no-username-alias 2017-06-28 16:22:12 +07:00
Kiwi 55e62b2b22 Merge branch 'master' into edit-name-fix 2017-06-28 16:20:01 +07:00
Kim Gardner 37e6c2c45c Merge pull request #717 from coralproject/live-suspension
Live update on stream when banned user, suspended user or rejected username
2017-06-28 10:16:18 +01:00
Chi Vinh Le 270491dc0f Allow editing to the same username in the "create username" flow 2017-06-28 16:08:46 +07:00
Belén Curcio 04b562543e Merge branch 'master' into flag-reason 2017-06-27 16:53:56 -03:00
Belen Curcio dd76ac1ed4 Now the smooth flag works 2017-06-27 16:51:00 -03:00
Belen Curcio e03e15fb3b renaming 2017-06-27 16:20:08 -03:00
Belen Curcio f13682c125 flagMap added to the admin side 2017-06-27 16:12:13 -03:00
David Erwin 3695ce5cb7 Update links 2017-06-27 15:08:21 -04:00
David Erwin d68169757d Rewrite contributor's guide 2017-06-27 15:04:00 -04:00
Belén Curcio 6d4765b45d Merge branch 'master' into no-username-alias 2017-06-27 16:03:03 -03:00
David Erwin b94824b8ee Merge branch 'master' into live-suspension 2017-06-27 14:14:21 -04:00
Belén Curcio cb1b9a1463 Merge pull request #714 from coralproject/unify-taglists
Refactor TagsInput and improve UX
2017-06-27 14:54:57 -03:00
Belen Curcio 35ccc5070d Flag refactor 2017-06-27 14:52:12 -03:00
Kiwi efb18cdb32 Merge branch 'master' into unify-taglists 2017-06-28 00:28:32 +07:00
Kiwi 2338ff1694 Merge branch 'master' into no-username-alias 2017-06-28 00:15:14 +07:00
Chi Vinh Le b134d4099c More concise and functional subscribing 2017-06-28 00:13:47 +07:00
Kim Gardner 1b40521f88 Merge branch 'master' into live-suspension 2017-06-27 17:53:58 +01:00
Kim Gardner 88473a1c71 Merge pull request #716 from coralproject/coral-plugin-permalink
Talk Plugin Permalink
2017-06-27 17:53:38 +01:00
Chi Vinh Le 97ab338486 Don't alias username 2017-06-27 23:51:49 +07:00
Kiwi 8d4e45c0ed Merge branch 'master' into coral-plugin-permalink 2017-06-27 23:46:55 +07:00
Kim Gardner 53b481dc68 Merge branch 'master' into unify-taglists 2017-06-27 16:25:00 +01:00
Chi Vinh Le b67ac375ba Pass current user to CommentBox 2017-06-27 22:14:06 +07:00
Chi Vinh Le 500d5d582e Fix edit 2017-06-27 21:51:19 +07:00
Kiwi 5b9fe5688f Merge branch 'master' into live-suspension 2017-06-27 21:44:26 +07:00
Chi Vinh Le 175dd30d27 Keep comment box when user is degraded (live) 2017-06-27 21:40:48 +07:00
David Erwin 33990053fb Merge pull request #709 from coralproject/docs-plugins-quickstart
Docs plugins quickstart
2017-06-27 10:19:53 -04:00
Kim Gardner 3f4747c2ec Fix typo 2017-06-27 14:11:04 +01:00
Chi Vinh Le db3187cb56 Merge branch 'master' into live-suspension 2017-06-27 20:08:53 +07:00
Chi Vinh Le c90c177b7c Live update on stream when banned user, suspended user or rejected username 2017-06-27 20:06:27 +07:00
Kim Gardner 933891b2e3 Merge branch 'master' into docs-plugins-quickstart 2017-06-27 13:41:16 +01:00
Kim Gardner 43dd72c715 Merge branch 'master' into unify-taglists 2017-06-27 13:40:24 +01:00
Kim Gardner a7f0de6e62 Merge branch 'master' into coral-plugin-permalink 2017-06-27 13:40:15 +01:00
Kim Gardner 53742934f9 Merge pull request #715 from coralproject/commentCount
Comment Count by Asset URL
2017-06-27 13:01:47 +01:00
Belen Curcio b8aed80f1c Merge branch 'coral-plugin-permalink' of github.com:coralproject/talk into coral-plugin-permalink 2017-06-26 17:35:07 -03:00
Belen Curcio 4e94727749 Merge branch 'master' of github.com:coralproject/talk into coral-plugin-permalink 2017-06-26 17:34:53 -03:00
Belén Curcio 8412fa5613 Merge branch 'master' into coral-plugin-permalink 2017-06-26 16:26:46 -03:00
Belen Curcio bee1cc124c renamed to talk 2017-06-26 16:26:05 -03:00
Belen Curcio 2ac6ad8eb8 Using the plugin api 2017-06-26 16:16:40 -03:00
Belen Curcio fa9a4ecc50 Clearing the timeout 2017-06-26 16:08:32 -03:00
Belen Curcio b00d2f569e Removing styles from default.css 2017-06-26 16:01:07 -03:00
Belen Curcio 5e1450ffb0 Renaming to talk 2017-06-26 15:46:53 -03:00
Belen Curcio a14e4859a9 refactor 2017-06-26 14:49:24 -03:00
Belen Curcio 2745245564 updated 2017-06-26 14:39:10 -03:00
Kim Gardner 3bb1279acf Merge branch 'master' into docs-plugins-quickstart 2017-06-26 16:15:59 +01:00
Belen Curcio fedca113d2 Adding plugin 2017-06-26 11:50:51 -03:00
Belen Curcio 59407e99d7 commentCount by asset_url 2017-06-26 09:59:28 -03:00
Kiwi c7c33dcf13 Merge branch 'master' into unify-taglists 2017-06-26 19:08:38 +07:00
Kim Gardner 0e152797ad Merge pull request #710 from ALawliet/fix-highlight-suspect-banned-words-regex
Make highlight suspect/banned words regex case insensitive
2017-06-26 12:48:11 +01:00
Kim Gardner 58d9f294b7 Merge branch 'master' into fix-highlight-suspect-banned-words-regex 2017-06-26 12:25:30 +01:00
Kim Gardner fddbf2c00f Merge pull request #713 from coralproject/validate-confirm-password
Validate `confirmPassword` in the web installer
2017-06-26 12:25:16 +01:00
Chi Vinh Le 10ae39ba09 Merge branch 'master' into unify-taglists 2017-06-26 17:39:06 +07:00
Chi Vinh Le a025f4ca24 Validate confirmPassword in the web installer 2017-06-26 17:35:45 +07:00
Chi Vinh Le eb4d85fb86 Refactor TagsInput and Improve UX 2017-06-26 17:34:15 +07:00
A Lawliet 5e6a295f11 Make highlight suspect/banned words regex case insensitive 2017-06-24 15:50:44 -04:00
David Erwin b864761ef8 Merge branch 'master' into docs-plugins-quickstart 2017-06-23 16:22:19 -04:00
David Erwin 01f6bfa979 Clean up the quickstart 2017-06-23 16:18:29 -04:00
David Erwin a49a53903d Finish quickstart first draft 2017-06-23 13:29:35 -04:00
David Erwin a3059a9fa3 Update docs menu 2017-06-22 17:42:42 -04:00
David Erwin d57d05fda9 Move plugin docs into new docs 2017-06-22 17:33:16 -04:00
David Erwin 57e0a160a2 Add plugins overview and quickstart 2017-06-22 17:25:01 -04:00
gaba d22f5215ed Adding french translation to the auth plugin. 2017-06-09 16:09:13 -07:00
gaba 19b6dfaa0a Adding a new language is not so straight forward. It needs to change at some point. 2017-06-08 11:39:51 -07:00
gaba b009d7a67a Adding french translations. 2017-06-08 10:47:17 -07:00
119 changed files with 2519 additions and 1366 deletions
+1
View File
@@ -12,4 +12,5 @@ plugins/*
!plugins/coral-plugin-love
!plugins/coral-plugin-viewing-options
!plugins/coral-plugin-comment-content
!plugins/talk-plugin-permalink
node_modules
+1
View File
@@ -25,5 +25,6 @@ plugins/*
!plugins/coral-plugin-love
!plugins/coral-plugin-viewing-options
!plugins/coral-plugin-comment-content
!plugins/talk-plugin-permalink
**/node_modules/*
+51 -42
View File
@@ -6,16 +6,46 @@ This document is a companion to help you approach contributing. If it does not d
By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct.html).
## Product Roadmap
## What should I Contribute?
You can view what the Coral Team is working on next here https://www.pivotaltracker.com/n/projects/1863625.
There are at least three ways to contribute to Talk:
If you'd like to see our longer term roadmap, [get in touch.](https://coralproject.net/contact.html)
* Writing Code
* Writing Docs
* Providing Translations
## Writing Code
## Contribute to the documentation
Conversation surrounding contributions begins in [issues](https://github.com/coralproject/talk/issues).
Clear docs are a prerequisite for a successful open source project. We value non-code and code contributions equally.
### 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.
Please file issues if:
* you would like to contribute to Talk Core.
* you are building a Plugin that the current Plugin API doesn't support.
* you are building a Plugin and want advice.
### What should I include?
Coral has adopted an iterative, agile development philosophy. All contributions that make it into the Talk repo should start with a story or this form:
`As a [type of person] I'd like to be able to [do something] so that I can [get some result].`
This exercise does two things:
* allows us to ground our technical choices in a clear, simple product need.
* expresses that product need in a way that doesn't imply a specific technical solution allowing for debate as to the best way to solve the problem.
Please feel free to provide as much detail as possible when filing the issue but please do keep the initial issue specific to one need and try to avoid including technical or design solutions.
If you have a specific technical or design solution in mind, please submit it as the first comment on the thread.
## Contributing Documentation
Clear docs are a prerequisite for a successful open source project. Contributing to the documentation is often more important than contributing to the code!
We are looking for _documentarians_ to:
@@ -23,55 +53,34 @@ We are looking for _documentarians_ to:
* create new / missing sections, and
* take the lead in making sections, or the over all structure better.
### But how?
Information about how to update docs can be found in our [FAQ](faq.html#how-do-i-contribute-to-these-docs).
* Our public docs site can be updated [here](https://github.com/coralproject/docs).
* [Let us know](https://github.com/coralproject/talk/wiki/Contact-Us) if you'd like permission to update our wiki.
* Update any of our .md docs files by following [this guide](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging).
* Our API docs need to be kept honest. [Update them here](https://github.com/coralproject/talk/blob/master/docs/swagger.yaml).
If you'd like to discuss a contribution, please [file an issue](https://github.com/coralproject/talk/issues) describing the changes you would like to see.
## Integrate into your environment
## Contributing Translations
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
Talk's tranlations are stored in `.yml` files [here](https://github.com/coralproject/talk/tree/master/locales).
Talk is designed to integrate into existing environments in a variety of ways:
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.html) the translations to us. We can import it into the repo.
* [Auth integrations](https://github.com/coralproject/talk/wiki/Security#authentication-strategies)
* [Push assets into Talk](https://github.com/coralproject/talk/blob/master/routes/api/assets/index.js)
* Monitoring Hooks (coming in 2017)
## I want to contribute but I'm not sure what to do!
If you're considering deploying Talk, [please let us know](https://github.com/coralproject/talk/wiki/Contact-Us)! We are quite literally doing this for you and want to help you succeed any way we can.
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:
If you are writing custom integration code in your fork of Talk, please consider keeping it generic and filing a Pull Request to contribute it back to the project! See our [forking and merging guidelines](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging) for more info.
### Product Roadmap
## Write some code
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!
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
### Discussion Forum
### Build a New Feature / Plugin
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.html).
Talk is beginning life as a Commenting Platform, but is architected to support many varieties of community engagement.
### Integrations
Please [contact us](https://github.com/coralproject/talk/wiki/Contact-Us) early and often if you'd like to help. We would love to hear your ideas for features and plugins and help you find a way to productively engage the project.
Have a favorite analytics engine? Data science service? CMS? Auth platform? Deployment platform or pipeline? Pet project? Consider building a plugin to integrate them!
To get an idea of where the Coral Team is going, see:
### Favorite Features?
* our [Slack channel](https://coralprojectslackin.herokuapp.com/), where you can talk directly with the Team and other community members,
* our [current stories](https://www.pivotaltracker.com/n/projects/1863625), and
* our [issues](https://github.com/coralproject/talk/issues).
Do you have a favorite feature of an existing platform that's not yet been done in Talk? Sounds like Talk needs that feature.
Examples:
* [Add An Emoji Button to Comments](https://github.com/coralproject/talk/wiki/Add-An-Emoji-Button-to-Comments)
### Work on the Core
There is always more work to be done to make an application more stable, scaleable and secure.
If you see issues in the code or have ideas on how we may improve Talk, please consider:
* [contributing a fix](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging),
* [filing an issue](https://github.com/coralproject/talk/issues), or
* or otherwise [letting us know](https://github.com/coralproject/talk/wiki/Contact-Us).
## Thanks!
+1 -525
View File
@@ -1,527 +1,3 @@
# Talk Plugins
Plugins for Talk can take various forms, currently we are only supporting server
side plugins.
## Plugin Registration
The parsing order for the plugin registration is as follows:
- `TALK_PLUGINS_JSON` environment variable
- `plugins.json` file
- `plugins.default.json` file
If you need to "disable all plugins", you can simply provide `{}` as the
contents of `process.env.TALK_PLUGINS_JSON` or the `plugins.json`.
The format for this is thus:
```json
{
"server": [
"people"
]
}
```
Where we have a `server` key with an array of plugins that match the folder
name in the `plugins/` folder. For example, the above config would
require a plugin from `plugins/people`, which must provide a `index.js` file
that returns an object that matches the Plugin Specification.
If the package is external (available on NPM) you can specify the string for
the version by using an object instead, for example:
```json
{
"server": [
{"people": "^1.2.0"}
]
}
```
External plugins can be resolved by running:
```bash
./bin/cli plugins reconcile
```
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 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` 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.
2. It will seek out dependencies that are listed in the object notation and try
to install them from npm.
## Plugin Dependencies
You may also include additional external dependencies in your local packages by
specifying a `package.json` at your plugin root which will result in a
`node_modules` folder being generated at the plugin root with your specific
dependencies.
## Deployment Solutions
Plugins can be deployed with a production instance of Talk.
### Source
Source deployments can just modify the `plugins.json` file and include any
local plugins into the `plugins/` directory. After including the config, you
need to reconcile the plugins and build the static assets:
```bash
# get plugin dependancies and remote plugins
./bin/cli plugins reconcile
# build staic assets (including enabled client side plugins)
yarn build
```
Then the application can be started as is.
### Docker
If you deploy using Docker, you can extend from the `*-onbuild` image, an
example `Dockerfile` for your project could be:
```Dockerfile
FROM coralproject/talk:latest-onbuild
```
Where the directory for your instance would contain a `plugins.json` file
describing the plugin requirements and a `plugins` directory containing any
other local plugins that should be included.
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.
## Server Plugins
### API
You can access any API available inside the talk directory in a plugin by simply
importing the file relative to the talk project root. An example would be if you
wanted to import the `MetadataService`, you would simply write:
```javascript
const MetadataService = require('services/metadata');
```
### Specification
Each plugin should export a single object with all hooks available on it.
_**Note: You will have access to the whole core and other plugin's typeDefs,
context, loaders, mutators, resolvers, hooks. This is intentional, as it
encourages composing plugins to merge functionality, like a Slack plugin which
provides a Slack notify context function as well as having the loader for
comments.**_
The following are the hooks available:
#### Field: `typeDefs`
```graphql
enum COLOUR {
RED
BLUE
}
type Person {
name: String!
colour: COLOUR!
}
type RootMutation {
createPerson(name: String!): Person
}
type RootQuery {
people: [Person!]
}
type Subscription {
leader: Person
}
```
Thanks to [gql-merge](https://www.npmjs.com/package/gql-merge) the contents of
`typeDefs` should be a string that will be _merged_ with the existing type
definitions. `enum`'s will be appended to, types will be appended, and new types
will be added.
#### Field: `context`
```js
{
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
inside all resolvers, loaders, mutators, and of course, other context based
plugins.
The top level item must accept a context for the request which it should use to
configure the context plugin before it would be mounted at `context.plugins`.
This plugin above would mount at: `context.plugins.Slack`, or, if you're using
[object destructuring](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), `{plugins: {Slack}}`.
#### Field: `loaders`
```js
(context) => ({
People: {
load: () => db.people.find({user: context.user})
}
})
```
Loaders should be provided as a function which returns a map which is used in
the resolvers function. These must return a promise or a value.
#### Field: `mutators`
```js
(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
the resolvers function. These must return a promise or a value.
#### Field: `resolvers`
```js
{
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';
}
},
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
[Apollo Docs](http://dev.apollodata.com/tools/graphql-tools/resolvers.html#Resolver-map).
This will merge with the existing resolvers in core and from previous plugins.
#### Field: `hooks`
```js
{
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;
}
}
}
}
```
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
the modified value or simply the original if you didn't modify it.
#### Field: `setupFunctions`
```js
setupFunctions: {
leader: (options, args) => ({
leader: {
filter: (person) => person.place === 1
},
}),
}
```
Setup functions allow you to create filters that control which pubsub.publish() events
send data to the client. If the type in question contains args, clients may subscribe using those arguments to further filter their subscription.
For more information, see the [Apollo Docs](https://github.com/apollographql/graphql-subscriptions).
#### Field: `tokenUserNotFound`
```js
tokenUserNotFound: async ({jwt, token}) => {
let profile = await someExternalService(token);
if (!profile) {
return null;
}
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
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` paramenter of the object
is the unpacked token, while `token` is the original jwt token string.
#### Field: `router`
```js
(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
router where you can mount any amount of middleware/routes to do any form of
action needed by external applications. We also provide the authorization
middleware via:
```js
const authorization = require('middleware/authorization');
module.exports = {
router(router) {
router.get('/api/v1/people', authorization.needed('ADMIN'), (req, res) => {
res.json({people: [{name: 'SECRET PEOPLE'}]});
});
}
}
```
#### Field: `tags`
The tags hook allows a plugin to define tags that are code controlled (added
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()
}
]
```
You can refer to `models/schema/tag.js` for the available schema to match when
creating models to enable/disable specific features.
#### Field: `passport`
```js
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) => {
let user;
try {
user = await UsersService.findOrCreateExternalUser(profile);
} catch (err) {
return done(err);
}
return ValidateUserLogin(profile, user, done);
}));
},
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');
/**
* Facebook auth endpoint, this will redirect the user immediatly to facebook
* for authorization.
*/
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 sucesfull login.
*/
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);
});
}
};
```
This is a full example including the routes hook to add the required components
to the application router to support a different auth strategy.
### Full Example
Contents of `plugins.json`:
```json
{
"server": [
"people"
]
}
```
Located in `plugins/people/index.js`:
```js
module.exports = {
typeDefs: `
enum COLOUR {
RED
BLUE
}
type Person {
name: String!
colour: COLOUR!
}
type RootMutation {
createPerson(name: String!): Person
}
type RootQuery {
people: [Person!]
}
type Subscription {
leader: Person
}
`,
context: {
Slack: () => ({
notify: (message) => {
// return a promise after we're done sending notifications.
}
})
},
loaders: ({user}) => ({
People: {
load: () => db.people.find({user})
}
}),
mutators: ({user}) => ({
People: {
create: (name) => {
return db.people.insert({user, name});
}
}
}),
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';
}
},
RootQuery: {
people(obj, args, {loaders: {People}}) {
return People.load();
}
},
RootMutation: {
createPerson(obj, {name}, {mutators: {People}}) {
return People.create(name);
}
}
},
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;
}
}
}
},
setupFunctions: {
leader: (options, args) => ({
leader: {
filter: (person) => person.place === 1
}
}
}
};
```
Our documentation for Talk has moved! Utilize [this hyperlink](https://coralproject.github.io/talk/plugins.html) via click or tap to navigate your browser to the new location!
+9 -2
View File
@@ -9,13 +9,17 @@ const program = require('./commander');
program
.command('serve', 'serve the application')
.command('settings', 'interact with the application settings')
.command('assets', 'interact with assets')
.command('setup', 'setup the application')
.command('jobs', 'work with the job queues')
.command('token', 'work with the access tokens')
.command('users', 'work with the application auth')
.command('migration', 'provides utilities for migrating the database')
.command('plugins', 'provides utilities for interacting with the plugin system')
.command(
'plugins',
'provides utilities for interacting with the plugin system'
)
.parse(process.argv);
/**
@@ -25,7 +29,10 @@ program
* labled with the PID written out by the parent process.
*/
process.once('exit', () => {
if ((program.runningCommand.killed === false) && (program.runningCommand.exitCode === null)) {
if (
program.runningCommand.killed === false &&
program.runningCommand.exitCode === null
) {
program.runningCommand.kill('SIGINT');
}
});
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env node
const program = require('./commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingsService = require('../services/settings');
const util = require('./util');
// Register the shutdown criteria.
util.onshutdown([() => mongoose.disconnect()]);
/**
* Change the organization name
*/
async function changeOrgName() {
try {
let settings = await SettingsService.retrieve();
let {organizationName} = await inquirer.prompt([
{
name: 'organizationName',
message: 'Organization Name',
default: settings.organizationName
}
]);
if (settings.organizationName !== organizationName) {
settings.organizationName = organizationName;
await SettingsService.update(settings);
console.log('Settings were updated.');
} else {
console.log('No update needed, no change was made.');
}
} catch (err) {
console.error(err);
util.shutdown(1);
}
util.shutdown();
}
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.command('change-org-name')
.description('change the organization name')
.action(changeOrgName);
program.parse(process.argv);
// If there is no command listed, output help.
if (!process.argv.slice(2).length) {
program.outputHelp();
util.shutdown();
}
+22
View File
@@ -66,6 +66,28 @@ const validation = (formData, dispatch, next) => {
return;
}
// Confirm Validation
const prefixLength = 'confirm'.length;
const confirm = validKeys
.filter((name) => {
if (!name.startsWith('confirm')) {
return false;
}
// Check that 'confirmX' equals 'X'.
const other = name.substr(prefixLength, 1).toLowerCase() + name.substr(prefixLength + 1);
const cond = formData[other] !== formData[name];
if (cond) {
dispatch(addError(name, errorMsj[name]));
}
return cond;
});
if (confirm.length) {
dispatch(hasError());
return;
}
dispatch(clearErrors());
next();
};
@@ -0,0 +1,51 @@
.root {
background-color: #fff;
border: 1px solid #ccc;
overflow: hidden;
padding-left: 5px;
padding-top: 5px;
}
.rootFocuesd {
border-color: rgb(142, 76, 65);
}
.tag {
background-color: rgb(255, 220, 214);
border-radius: 2px;
border: 1px solid rgb(244, 126, 107);
color: rgb(244, 126, 107);
display: inline-block;
font-family: sans-serif;
font-size: 13px;
font-weight: 400;
margin-bottom: 5px;
margin-right: 5px;
padding: 5px;
}
.tagRemove {
cursor: pointer;
font-weight: bold;
color: rgb(101, 24, 23);
&::before {
content: " ×";
}
}
.input > input {
background: transparent;
border: 0;
color: #777;
font-family: sans-serif;
font-size: 13px;
font-weight: 400;
margin-bottom: 6px;
margin-top: 1px;
outline: none;
padding: 5px;
}
@@ -0,0 +1,31 @@
import React from 'react';
import TagsInput from 'react-tagsinput';
import styles from './TagsInput.css';
import AutosizeInput from 'react-input-autosize';
const autosizingRenderInput = ({onChange, value, addTag: _, ...other}) =>
<AutosizeInput type='text' onChange={onChange} value={value} {...other} />;
export default (props) => {
return (
<TagsInput
addOnBlur={true}
addOnPaste={true}
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
className={styles.root}
focusedClassName={styles.rootFocus}
renderInput={autosizingRenderInput}
{...props}
tagProps={{
className: styles.tag,
classNameRemove: styles.tagRemove,
...props.tagProps,
}}
inputProps={{
className: styles.input,
...props.inputProps,
}}
/>
);
};
+2
View File
@@ -1,6 +1,7 @@
import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import smoothscroll from 'smoothscroll-polyfill';
import {getClient} from './services/client';
import store from './services/store';
@@ -13,6 +14,7 @@ import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/he
loadPluginsTranslations();
injectPluginsReducers();
smoothscroll.polyfill();
render(
<ApolloProvider client={getClient()} store={store}>
@@ -2,17 +2,18 @@ import React from 'react';
import styles from './Community.css';
import ActionButton from './ActionButton';
import {username} from 'coral-plugin-flags/helpers/flagReasons';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import t from 'coral-framework/services/i18n';
const shortReasons = {
'This comment is offensive': t('community.offensive'),
'This looks like an ad/marketing': t('community.spam_ads'),
'This user is impersonating': t('community.impersonating'),
'I don\'t like this username': t('community.dont_like_username'),
'Other': t('community.other')
[username.other]: t('community.other'),
[username.spam]: t('community.spam_ads'),
[username.offensive]: t('community.offensive'),
[username.nolike]: t('community.dont_like_username'),
[username.impersonating]: t('community.impersonating'),
};
// Render a single user for the list
@@ -136,13 +136,6 @@
resize: none;
}
#bannedWordlist, #suspectWordlist {
display: block;
input {
width: 100%;
}
}
.customCSSInput {
width: 100%;
font-size: 14px;
@@ -1,7 +1,7 @@
import React from 'react';
import {Card} from 'coral-ui';
import styles from './Configure.css';
import TagsInput from 'react-tagsinput';
import TagsInput from 'coral-admin/src/components/TagsInput';
import t from 'coral-framework/services/i18n';
const Domainlist = ({domains, onChangeDomainlist}) => {
@@ -14,8 +14,6 @@ const Domainlist = ({domains, onChangeDomainlist}) => {
<TagsInput
value={domains}
inputProps={{placeholder: 'URL'}}
addOnPaste={true}
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
onChange={(tags) => onChangeDomainlist('whitelist', tags)}
/>
</div>
@@ -1,6 +1,6 @@
import React from 'react';
import t from 'coral-framework/services/i18n';
import TagsInput from 'react-tagsinput';
import TagsInput from 'coral-admin/src/components/TagsInput';
import styles from './Configure.css';
import {Card} from 'coral-ui';
@@ -13,8 +13,6 @@ const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
<TagsInput
value={bannedWords}
inputProps={{placeholder: 'word or phrase'}}
addOnPaste={true}
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
onChange={(tags) => onChangeWordlist('banned', tags)}
/>
</div>
@@ -26,8 +24,6 @@ const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
<TagsInput
value={suspectWords}
inputProps={{placeholder: 'word or phrase'}}
addOnPaste={true}
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
onChange={(tags) => onChangeWordlist('suspect', tags)} />
</div>
</Card>
@@ -48,7 +48,7 @@ const InitialStep = (props) => {
label={t('install.create.confirm_password')}
onChange={handleUserChange}
showErrors={install.showErrors}
errorMsg={install.errors.confirm_password}
errorMsg={install.errors.confirmPassword}
/>
{
@@ -1,7 +1,7 @@
import React from 'react';
import styles from './style.css';
import {Button, Card} from 'coral-ui';
import TagsInput from 'react-tagsinput';
import TagsInput from 'coral-admin/src/components/TagsInput';
import t from 'coral-framework/services/i18n';
@@ -16,9 +16,6 @@ const PermittedDomainsStep = (props) => {
<TagsInput
value={domains}
inputProps={{placeholder: 'URL'}}
addOnBlur={true}
addOnPaste={true}
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
onChange={(tags) => handleDomainsChange(tags)}
/>
</Card>
@@ -54,7 +54,7 @@ class Comment extends React.Component {
// currently the highlighter plugin does not support out of the box.
const searchWords = [...suspectWords, ...bannedWords]
.filter((w) => {
return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body);
return new RegExp(`(^|\\s)${w}(\\s|$)`, 'i').test(comment.body);
})
.concat(linkText);
@@ -168,7 +168,7 @@ class Comment extends React.Component {
{' '}
<a
className={styles.external}
href={`${comment.asset.url}#${comment.id}`}
href={`${comment.asset.url}?commentId=${comment.id}`}
target="_blank"
>
<Icon name="open_in_new" /> {t('comment.view_context')}
@@ -1,4 +1,4 @@
import {pym} from 'coral-framework';
import pym from 'coral-framework/services/pym';
import * as actions from '../constants/stream';
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
@@ -1,11 +1,41 @@
.Reply {
position: relative;
.root {
margin-top: 16px;
margin-left: 20px;
margin-bottom: 15px;
position: relative;
}
.Comment {
margin-bottom: 15px;
position: relative;
.rootLevel0 {
margin-left: 0px;
}
.comment {
padding-left: 20px;
}
.commentLevel0 {
padding-left: 0px;
}
.commentLevel1 {
border-left: 3px #212121 solid;
}
.commentLevel2 {
border-left: 2px #6a6a6a solid;
}
.commentLevel3 {
border-left: 2px #9e9e9e solid;
}
.commentLevel4 {
border-left: 2px #c1c1c1 solid;
}
.highlightedComment {
padding-left: 20px;
border-left: 3px solid rgb(35,118,216);
}
.pendingComment {
@@ -1,15 +1,17 @@
import React, {PropTypes} from 'react';
import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton';
import AuthorName from 'coral-plugin-author-name/AuthorName';
import TagLabel from 'coral-plugin-tag-label/TagLabel';
import PubDate from 'coral-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
import FlagComment from 'coral-plugin-flags/FlagComment';
import {FlagComment} from 'coral-plugin-flags';
import {can} from 'coral-framework/services/perms';
import {TransitionGroup} from 'react-transition-group';
import cn from 'classnames';
import styles from './Comment.css';
import {THREADING_LEVEL} from '../constants/stream';
import merge from 'lodash/merge';
import mapValues from 'lodash/mapValues';
import {
BestButton,
@@ -25,7 +27,7 @@ import CommentContent from './CommentContent';
import Slot from 'coral-framework/components/Slot';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import {EditableCommentContent} from './EditableCommentContent';
import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils';
import {getActionSummary, iPerformedThisAction, forEachError} from 'coral-framework/utils';
import t from 'coral-framework/services/i18n';
const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF');
@@ -76,7 +78,6 @@ const ActionButton = ({children}) => {
};
export default class Comment extends React.Component {
isLoadingReplies = false;
constructor(props) {
super(props);
@@ -91,6 +92,7 @@ export default class Comment extends React.Component {
isEditing: false,
replyBoxVisible: false,
animateEnter: false,
loadingState: '',
...resetCursors({}, props),
};
}
@@ -170,7 +172,7 @@ export default class Comment extends React.Component {
replies: PropTypes.object,
user: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
username: PropTypes.string.isRequired
}).isRequired,
editing: PropTypes.shape({
edited: PropTypes.bool,
@@ -196,8 +198,16 @@ export default class Comment extends React.Component {
editComment: React.PropTypes.func,
}
editComment = (...args) => {
return this.props.editComment(this.props.comment.id, this.props.asset.id, ...args);
}
onClickEdit (e) {
e.preventDefault();
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
return;
}
this.setState({isEditing: true});
}
@@ -213,24 +223,23 @@ export default class Comment extends React.Component {
}
loadNewReplies = () => {
if (!this.isLoadingReplies) {
this.isLoadingReplies = true;
const {replies, replyCount, id} = this.props.comment;
if (replyCount > replies.nodes.length) {
this.props.loadMore(id)
.then(() => {
this.setState(resetCursors(this.state, this.props));
this.isLoadingReplies = false;
})
.catch((e) => {
this.isLoadingReplies = false;
throw e;
const {replies, replyCount, id} = this.props.comment;
if (replyCount > replies.nodes.length) {
this.setState({loadingState: 'loading'});
this.props.loadMore(id)
.then(() => {
this.setState({
...resetCursors(this.state, this.props),
loadingState: 'success',
});
return;
}
this.setState(resetCursors);
this.isLoadingReplies = false;
})
.catch((error) => {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
});
return;
}
this.setState(resetCursors);
};
showReplyBox = () => {
@@ -240,7 +249,8 @@ export default class Comment extends React.Component {
}
if (can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.setActiveReplyBox(this.props.comment.id);
return;
} else {
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
}
return;
}
@@ -309,6 +319,7 @@ export default class Comment extends React.Component {
postDontAgree,
setActiveReplyBox,
activeReplyBox,
loadMore,
deleteAction,
disableReply,
maxCharCount,
@@ -323,9 +334,13 @@ export default class Comment extends React.Component {
} = this.props;
const view = this.getVisibileReplies();
const {loadingState} = this.state;
const isReply = !!parentId;
const isPending = comment.id.indexOf('pending') >= 0;
const isHighlighted = highlighted === comment.id;
const hasMoreComments = comment.replies && (comment.replies.hasNextPage || comment.replies.nodes.length > view.length);
const replyCount = this.hasIgnoredReplies() ? '' : comment.replyCount;
const moreRepliesCount = this.hasIgnoredReplies() ? -1 : comment.replyCount - view.length;
const flagSummary = getActionSummary('FlagActionSummary', comment);
const dontAgreeSummary = getActionSummary(
'DontAgreeActionSummary',
@@ -338,11 +353,6 @@ export default class Comment extends React.Component {
myFlag = dontAgreeSummary.find((s) => s.current_user);
}
let commentClass = parentId
? `reply ${styles.Reply}`
: `comment ${styles.Comment}`;
commentClass += comment.id.indexOf('pending') >= 0 ? ` ${styles.pendingComment}` : '';
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
const notifyOnError = (fn, errorToMessage) =>
async function(...args) {
@@ -378,7 +388,7 @@ export default class Comment extends React.Component {
);
/**
* classNamesToAdd
* conditionClassNames
* adds classNames based on condition
* classnames is an array of objects with key as classnames and value as conditions
* i.e:
@@ -388,35 +398,45 @@ export default class Comment extends React.Component {
*
* This will add myClassName to comments tagged with STAFF TAG.
* **/
const classNamesToAdd = commentClassNames.reduce((acc, className) => {
let res = [];
// Adding classNames based on tags
Object.keys(className).forEach((cn) => {
const condition = className[cn];
condition.tags.forEach((tag) => {
if (hasTag(comment.tags, tag)) {
res = [...res, cn];
}
});
const conditionalClassNames =
mapValues(merge({}, ...commentClassNames), (condition) => {
if (condition.tags) {
return condition.tags.some((tag) => hasTag(comment.tags, tag));
}
return false;
});
// TODO: Compare rest of the comment obj to the condition if needed
const rootClassName = cn(
'talk-stream-comment-wrapper',
`talk-stream-comment-wrapper-level-${depth}`,
styles.root,
styles[`rootLevel${depth}`],
{
...conditionalClassNames,
[styles.enter]: this.state.animateEnter,
},
);
return [...acc, ...res];
}, []);
const commentClassName = cn(
'talk-stream-comment',
`talk-stream-comment-level-${depth}`,
styles.comment,
styles[`commentLevel${depth}`],
{
[styles.pendingComment]: isPending,
[styles.highlightedComment]: isHighlighted,
'talk-stream-pending-comment': isPending,
'talk-stream-highlighted-comment': isHighlighted,
}
);
return (
<div
className={cn(commentClass, 'talk-stream-comment-wrapper', classNamesToAdd, {[styles.enter]: this.state.animateEnter})}
className={rootClassName}
id={`c_${comment.id}`}
style={{marginLeft: depth * 30}}
>
<hr aria-hidden={true} />
<div
className={highlighted === comment.id ? 'highlighted-comment' : ''}
>
{!isReply && <hr aria-hidden={true} />}
<div className={commentClassName}>
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
@@ -469,7 +489,7 @@ export default class Comment extends React.Component {
{
this.state.isEditing
? <EditableCommentContent
editComment={this.props.editComment.bind(null, comment.id, asset.id)}
editComment={this.editComment}
addNotification={addNotification}
asset={asset}
comment={comment}
@@ -509,19 +529,18 @@ export default class Comment extends React.Component {
currentUserId={currentUser && currentUser.id}
/>
</ActionButton>}
</div>
<div className="commentActionsRight comment__action-container">
<Slot
fill="commentActions"
wrapperComponent={ActionButton}
data={this.props.data}
root={this.props.root}
asset={asset}
comment={comment}
commentId={comment.id}
inline
/>
</div>
<div className="commentActionsRight comment__action-container">
<ActionButton>
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
</ActionButton>
<ActionButton>
<FlagComment
flaggedByCurrentUser={!!myFlag}
@@ -529,6 +548,7 @@ export default class Comment extends React.Component {
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
addNotification={addNotification}
postDontAgree={postDontAgree}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
@@ -545,10 +565,10 @@ export default class Comment extends React.Component {
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
setActiveReplyBox={setActiveReplyBox}
parentId={parentId || comment.id}
parentId={(depth < THREADING_LEVEL) ? comment.id : parentId}
addNotification={addNotification}
authorId={currentUser.id}
postComment={postComment}
currentUser={currentUser}
assetId={asset.id}
/>
: null}
@@ -575,6 +595,7 @@ export default class Comment extends React.Component {
deleteAction={deleteAction}
addTag={addTag}
removeTag={removeTag}
loadMore={loadMore}
ignoreUser={ignoreUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
@@ -587,12 +608,13 @@ export default class Comment extends React.Component {
/>;
})}
</TransitionGroup>
<div className="coral-load-more-replies">
<div className="talk-load-more-replies">
<LoadMore
topLevel={false}
replyCount={replyCount}
replyCount={moreRepliesCount}
moreComments={hasMoreComments}
loadMore={this.loadNewReplies}
loadingState={loadingState}
/>
</div>
</div>
@@ -4,6 +4,8 @@ import {CommentForm} from 'coral-plugin-commentbox/CommentForm';
import styles from './Comment.css';
import {CountdownSeconds} from './CountdownSeconds';
import {getEditableUntilDate} from './util';
import {can} from 'coral-framework/services/perms';
import {forEachError} from 'coral-framework/utils';
import {Icon} from 'coral-ui';
import t from 'coral-framework/services/i18n';
@@ -45,10 +47,14 @@ export class EditableCommentContent extends React.Component {
// called when editing should be stopped
stopEditing: React.PropTypes.func,
}
constructor(props) {
super(props);
this.editComment = this.editComment.bind(this);
this.editWindowExpiryTimeout = null;
this.state = {
body: props.comment.body,
loadingState: '',
};
}
componentDidMount() {
const editableUntil = getEditableUntilDate(this.props.comment);
@@ -65,69 +71,75 @@ export class EditableCommentContent extends React.Component {
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
}
}
async editComment(edit) {
handleBodyChange = (body) => {
this.setState({body});
}
handleSubmit = async () => {
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
return;
}
this.setState({loadingState: 'loading'});
const {editComment, addNotification, stopEditing} = this.props;
if (typeof editComment !== 'function') {return;}
let response;
let successfullyEdited = false;
try {
response = await editComment(edit);
const errors = (response && response.data && response.data.editComment)
? response.data.editComment.errors
: null;
if (errors && (errors.length === 1)) {
throw errors[0];
}
successfullyEdited = true;
} catch (error) {
const errors = error.errors || [error];
errors.forEach((e) => {
if (e.translation_key) {
addNotification('error', t(`error.${e.translation_key}`));
} else if (error.networkError) {
addNotification('error', t('error.network_error'));
} else {
addNotification('error', t('edit_comment.unexpected_error'));
console.error(e);
}
});
}
if (successfullyEdited) {
response = await editComment({body: this.state.body});
this.setState({loadingState: 'success'});
const status = response.data.editComment.comment.status;
notifyForNewCommentStatus(this.props.addNotification, status);
}
if (successfullyEdited && typeof stopEditing === 'function') {
stopEditing();
if (typeof stopEditing === 'function') {
stopEditing();
}
} catch (error) {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => addNotification('error', msg));
}
}
getEditableUntil = (props = this.props) => {
return getEditableUntilDate(props.comment);
}
isEditWindowExpired = (props = this.props) => {
return (this.getEditableUntil(props) - new Date()) < 0;
}
isSubmitEnabled = (comment) => {
// should be disabled if user hasn't actually changed their
// original comment
return (comment.body !== this.props.comment.body) && !this.isEditWindowExpired();
}
render() {
const originalBody = this.props.comment.body;
const editableUntil = getEditableUntilDate(this.props.comment);
const editWindowExpired = (editableUntil - new Date()) < 0;
return (
<div className={styles.editCommentForm}>
<CommentForm
defaultValue={this.props.comment.body}
charCountEnable={this.props.asset.settings.charCountEnable}
maxCharCount={this.props.maxCharCount}
saveCommentEnabled={(comment) => {
// should be disabled if user hasn't actually changed their
// original comment
return (comment.body !== originalBody) && !editWindowExpired;
}}
saveComment={this.editComment}
submitEnabled={this.isSubmitEnabled}
body={this.state.body}
onBodyChange={this.handleBodyChange}
onSubmit={this.handleSubmit}
bodyLabel={t('edit_comment.body_input_label')}
bodyPlaceholder=""
submitText={<span>{t('edit_comment.save_button')}</span>}
saveButtonCStyle="green"
cancelButtonClicked={this.props.stopEditing}
buttonClass={styles.button}
submitButtonCStyle="green"
onCancel={this.props.stopEditing}
submitButtonClassName={styles.button}
cancelButtonClassName={styles.button}
loadingState={this.state.loadingState}
buttonContainerStart={
<div className={styles.buttonContainerLeft}>
<span className={styles.editWindowRemaining}>
{
editWindowExpired
this.isEditWindowExpired()
? <span>
{t('edit_comment.edit_window_expired')}
{
@@ -139,7 +151,7 @@ export class EditableCommentContent extends React.Component {
: <span>
<Icon name="timer"/> {t('edit_comment.edit_window_timer_prefix')}
<CountdownSeconds
until={editableUntil}
until={this.getEditableUntil()}
classNameForMsRemaining={(remainingMs) => (remainingMs <= 10 * 1000) ? styles.editWindowAlmostOver : '' }
/>
</span>
@@ -9,7 +9,7 @@ export class IgnoreUserWizard extends React.Component {
// comment on which this menu appears
user: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
username: PropTypes.string.isRequired
}).isRequired,
cancel: PropTypes.func.isRequired,
@@ -47,7 +47,7 @@ export class IgnoreUserWizard extends React.Component {
const step2Confirmation = (
<div>
<header>Ignore User</header>
<p>Are you sure you want to ignore { user.name }?</p>
<p>Are you sure you want to ignore { user.username }?</p>
<div className={styles.textAlignRight}>
<Button cStyle='cancel' onClick={this.onClickCancel}>Cancel</Button>
<Button onClick={onClickIgnoreUser}>Ignore user</Button>
@@ -1,39 +1,31 @@
import React, {PropTypes} from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import {Button} from 'coral-ui';
import t from 'coral-framework/services/i18n';
import cn from 'classnames';
class LoadMore extends React.Component {
componentDidMount () {
this.initialState = true;
}
replyCountFormat = (count) => {
if (!count) {
return t('framework.view_all_replies_unknown_number');
return t('framework.show_all_replies');
}
if (count === 1) {
return t('framework.view_reply');
return t('framework.show_1_more_reply');
}
if (this.initialState) {
return t('framework.view_all_replies_initial', count);
} else {
return t('framework.view_all_replies', count);
}
}
loadMore = () => {
this.initialState = false;
this.props.loadMore();
return t('framework.show_x_more_replies', count);
}
render () {
const {topLevel, moreComments, replyCount} = this.props;
const {topLevel, moreComments, replyCount, loadingState, loadMore} = this.props;
const disabled = loadingState === 'loading';
return moreComments
? <div className='coral-load-more'>
? <div className='talk-load-more'>
<Button
onClick={this.loadMore}>
onClick={loadMore}
className={cn('talk-load-more-button', {[`talk-load-more-button-${loadingState}`]: loadingState})}
disabled={disabled}
>
{topLevel ? t('framework.view_more_comments') : this.replyCountFormat(replyCount)}
</Button>
</div>
@@ -44,7 +36,9 @@ class LoadMore extends React.Component {
LoadMore.propTypes = {
replyCount: PropTypes.number,
topLevel: PropTypes.bool.isRequired,
loadMore: PropTypes.func.isRequired
loadMore: PropTypes.func.isRequired,
moreComments: PropTypes.bool,
loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']),
};
export default LoadMore;
@@ -3,7 +3,7 @@ import React, {PropTypes} from 'react';
import t from 'coral-framework/services/i18n';
const NewCount = ({count, loadMore}) => {
return <div className='coral-new-comments coral-load-more'>
return <div className='talk-new-comments talk-load-more'>
{
count ?
<button onClick={loadMore}>
@@ -1,5 +1,6 @@
import React, {PropTypes} from 'react';
import LoadMore from './LoadMore';
import {StreamError} from './StreamError';
import Comment from '../components/Comment';
import SuspendedAccount from './SuspendedAccount';
import Slot from 'coral-framework/components/Slot';
@@ -14,6 +15,8 @@ import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import NewCount from './NewCount';
import {TransitionGroup} from 'react-transition-group';
import {forEachError} from 'coral-framework/utils';
import {getTopLevelParent} from '../graphql/utils';
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
@@ -53,11 +56,13 @@ function invalidateCursor(invalidated, state, props) {
class Stream extends React.Component {
isLoadingMore = false;
constructor(props) {
super(props);
this.state = resetCursors(this.state, props);
this.state = {
...resetCursors(this.state, props),
keepCommentBox: false,
loadingState: '',
};
}
componentWillReceiveProps(next) {
@@ -68,6 +73,12 @@ class Stream extends React.Component {
this.setState(resetCursors);
return;
}
// Keep comment box when user was live suspended, banned, ...
if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) {
this.setState({keepCommentBox: true});
}
if (
prevComments && nextComments &&
nextComments.nodes.length < prevComments.nodes.length
@@ -98,15 +109,15 @@ class Stream extends React.Component {
};
loadMoreComments = () => {
if (!this.isLoadingMore) {
this.isLoadingMore = true;
this.props.loadMoreComments()
.then(() => this.isLoadingMore = false)
.catch((e) => {
this.isLoadingMore = false;
throw e;
});
}
this.setState({loadingState: 'loading'});
this.props.loadMoreComments()
.then(() => {
this.setState({loadingState: 'success'});
})
.catch((error) => {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
});
}
// getVisibileComments returns a list containing comments
@@ -133,6 +144,10 @@ class Stream extends React.Component {
return view;
}
userIsDegraged({auth: {user}} = this.props) {
return !can(user, 'INTERACT_WITH_COMMUNITY');
}
render() {
const {
commentClassNames,
@@ -150,13 +165,12 @@ class Stream extends React.Component {
pluginProps,
editName
} = this.props;
const {keepCommentBox, loadingState} = this.state;
const view = this.getVisibleComments();
const open = asset.closedAt === null;
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
const highlightedComment = comment && comment.parent
? comment.parent
: comment;
const highlightedComment = comment && getTopLevelParent(comment);
const banned = user && user.status === 'BANNED';
const temporarilySuspended =
@@ -171,6 +185,14 @@ class Stream extends React.Component {
me.ignoredUsers.find((u) => u.id === comment.user.id)
);
};
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
if (!comment && !comments) {
console.error('Talk: No comments came back from the graph given that query. Please, check the query params.');
return <StreamError />;
}
return (
<div id="stream">
@@ -199,10 +221,7 @@ class Stream extends React.Component {
editName={editName}
currentUsername={user.username}
/>}
{loggedIn &&
!banned &&
!temporarilySuspended &&
!highlightedComment &&
{showCommentBox &&
<CommentBox
addNotification={this.props.addNotification}
postComment={this.props.postComment}
@@ -211,7 +230,7 @@ class Stream extends React.Component {
assetId={asset.id}
premod={asset.settings.moderation}
isReply={false}
authorId={user.id}
currentUser={user}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
/>}
@@ -304,6 +323,7 @@ class Stream extends React.Component {
topLevel={true}
moreComments={asset.comments.hasNextPage}
loadMore={this.loadMoreComments}
loadingState={loadingState}
/>
</div>}
</div>
@@ -0,0 +1,3 @@
.streamError {
padding: 10px 0;
}
@@ -0,0 +1,9 @@
import React from 'react';
import styles from './StreamError.css';
import t from 'coral-framework/services/i18n';
export const StreamError = () => (
<div className={styles.streamError}>
{t('common.error')}
</div>
);
@@ -12,7 +12,7 @@ export class TopRightMenu extends React.Component {
comment: PropTypes.shape({
user: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
username: PropTypes.string.isRequired
}).isRequired
}).isRequired,
ignoreUser: PropTypes.func,
@@ -3,3 +3,4 @@ export const ADDTL_COMMENTS_ON_LOAD_MORE = 10;
export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS';
export const ADD_COMMENT_CLASSNAME = 'ADD_COMMENT_CLASSNAME';
export const REMOVE_COMMENT_CLASSNAME = 'REMOVE_COMMENT_CLASSNAME';
export const THREADING_LEVEL = process.env.TALK_THREADING_LEVEL;
@@ -34,7 +34,7 @@ export default withFragments({
}
user {
id
name: username
username
}
action_summaries {
__typename
@@ -7,11 +7,15 @@ import branch from 'recompose/branch';
import renderComponent from 'recompose/renderComponent';
import {Spinner} from 'coral-ui';
import {authActions, assetActions, pym} from 'coral-framework';
import * as authActions from 'coral-framework/actions/auth';
import * as assetActions from 'coral-framework/actions/asset';
import pym from 'coral-framework/services/pym';
import {getDefinitionName} from 'coral-framework/utils';
import {withQuery} from 'coral-framework/hocs';
import Embed from '../components/Embed';
import Stream from './Stream';
import {addNotification} from 'coral-framework/actions/notification';
import t from 'coral-framework/services/i18n';
import {setActiveTab} from '../actions/embed';
import {viewAllComments} from '../actions/stream';
@@ -20,12 +24,63 @@ const {logout, checkLogin} = authActions;
const {fetchAssetSuccess} = assetActions;
class EmbedContainer extends React.Component {
subscriptions = [];
subscribeToUpdates(props = this.props) {
if (props.auth.loggedIn) {
const newSubscriptions = [{
document: USER_BANNED_SUBSCRIPTION,
updateQuery: () => {
addNotification('info', t('your_account_has_been_banned'));
},
},
{
document: USER_SUSPENDED_SUBSCRIPTION,
updateQuery: () => {
addNotification('info', t('your_account_has_been_suspended'));
},
},
{
document: USERNAME_REJECTED_SUBSCRIPTION,
updateQuery: () => {
addNotification('info', t('your_username_has_been_rejected'));
},
}];
this.subscriptions = newSubscriptions.map((s) => props.data.subscribeToMore({
document: s.document,
variables: {
user_id: props.auth.user.id,
},
updateQuery: s.updateQuery,
}));
}
}
unsubscribe() {
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.subscriptions = [];
}
resubscribe(props) {
this.unsubscribe();
this.subscribeToUpdates(props);
}
componentDidMount() {
this.subscribeToUpdates();
}
componentWillUnmount() {
this.unsubscribe();
}
componentWillReceiveProps(nextProps) {
if (this.props.auth.loggedIn !== nextProps.auth.loggedIn) {
// Refetch after login/logout.
this.props.data.refetch();
this.resubscribe(nextProps);
}
const {fetchAssetSuccess} = this.props;
@@ -52,6 +107,45 @@ class EmbedContainer extends React.Component {
}
}
const USER_BANNED_SUBSCRIPTION = gql`
subscription UserBanned($user_id: ID!) {
userBanned(user_id: $user_id){
id
status
canEditName
suspension {
until
}
}
}
`;
const USER_SUSPENDED_SUBSCRIPTION = gql`
subscription UserSuspended($user_id: ID!) {
userSuspended(user_id: $user_id){
id
status
canEditName
suspension {
until
}
}
}
`;
const USERNAME_REJECTED_SUBSCRIPTION = gql`
subscription UsernameRejected($user_id: ID!) {
usernameRejected(user_id: $user_id){
id
status
canEditName
suspension {
until
}
}
}
`;
const EMBED_QUERY = gql`
query CoralEmbedStream_Embed($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) {
asset(id: $assetId, url: $assetUrl) {
@@ -95,6 +189,7 @@ const mapDispatchToProps = (dispatch) =>
setActiveTab,
viewAllComments,
fetchAssetSuccess,
addNotification,
},
dispatch
);
@@ -2,13 +2,14 @@ import React from 'react';
import {gql, compose} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream';
import {ADDTL_COMMENTS_ON_LOAD_MORE, THREADING_LEVEL} from '../constants/stream';
import {
withPostComment, withPostFlag, withPostDontAgree, withDeleteAction,
withAddTag, withRemoveTag, withIgnoreUser, withEditComment,
} from 'coral-framework/graphql/mutations';
import {notificationActions, authActions} from 'coral-framework';
import * as authActions from 'coral-framework/actions/auth';
import * as notificationActions from 'coral-framework/actions/notification';
import {editName} from 'coral-framework/actions/user';
import {setActiveReplyBox} from '../actions/stream';
import Stream from '../components/Stream';
@@ -21,6 +22,7 @@ import {
insertCommentIntoEmbedQuery,
removeCommentFromEmbedQuery,
insertFetchedCommentsIntoEmbedQuery,
nest,
} from '../graphql/utils';
const {showSignInDialog} = authActions;
@@ -30,11 +32,8 @@ class StreamContainer extends React.Component {
subscriptions = [];
subscribeToUpdates() {
const sub1 = this.props.data.subscribeToMore({
const newSubscriptions = [{
document: COMMENTS_EDITED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => {
// Ignore mutations from me.
@@ -52,13 +51,9 @@ class StreamContainer extends React.Component {
return removeCommentFromEmbedQuery(prev, commentEdited.id);
}
},
});
const sub2 = this.props.data.subscribeToMore({
},
{
document: COMMENTS_ADDED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => {
// Ignore mutations from me.
@@ -81,9 +76,15 @@ class StreamContainer extends React.Component {
return insertCommentIntoEmbedQuery(prev, commentAdded);
}
});
}];
this.subscriptions.push(sub1, sub2);
this.subscriptions = newSubscriptions.map((s) => this.props.data.subscribeToMore({
document: s.document,
variables: {
assetId: this.props.root.asset.id,
},
updateQuery: s.updateQuery,
}));
}
unsubscribe() {
@@ -92,9 +93,7 @@ class StreamContainer extends React.Component {
}
loadNewReplies = (parent_id) => {
const comment = this.props.root.comment
? this.props.root.comment.parent || this.props.root.comment // highlighted comment.
: this.props.root.asset.comments.nodes.filter((comment) => comment.id === parent_id)[0];
const comment = findCommentInEmbedQuery(this.props.root, parent_id);
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
@@ -158,22 +157,25 @@ const commentFragment = gql`
fragment CoralEmbedStream_Stream_comment on Comment {
id
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies(excludeIgnored: $excludeIgnored) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
${nest(`
replyCount(excludeIgnored: $excludeIgnored)
replies(excludeIgnored: $excludeIgnored) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
...nest
}
hasNextPage
startCursor
endCursor
}
hasNextPage
startCursor
endCursor
}
`, THREADING_LEVEL)}
}
${Comment.fragments.comment}
`;
const COMMENTS_ADDED_SUBSCRIPTION = gql`
subscription onCommentAdded($assetId: ID!, $excludeIgnored: Boolean){
subscription CommentAdded($assetId: ID!, $excludeIgnored: Boolean){
commentAdded(asset_id: $assetId){
parent {
id
@@ -185,7 +187,7 @@ const COMMENTS_ADDED_SUBSCRIPTION = gql`
`;
const COMMENTS_EDITED_SUBSCRIPTION = gql`
subscription onCommentEdited($assetId: ID!){
subscription CommentEdited($assetId: ID!){
commentEdited(asset_id: $assetId){
id
body
@@ -206,16 +208,19 @@ const LOAD_MORE_QUERY = gql`
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies(limit: 3, excludeIgnored: $excludeIgnored) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
${nest(`
replyCount(excludeIgnored: $excludeIgnored)
replies(limit: 3, excludeIgnored: $excludeIgnored) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
...nest
}
hasNextPage
startCursor
endCursor
}
hasNextPage
startCursor
endCursor
}
`, THREADING_LEVEL)}
}
hasNextPage
startCursor
@@ -230,9 +235,12 @@ const fragments = {
fragment CoralEmbedStream_Stream_root on RootQuery {
comment(id: $commentId) @include(if: $hasComment) {
...CoralEmbedStream_Stream_comment
parent {
...CoralEmbedStream_Stream_comment
}
${nest(`
parent {
...CoralEmbedStream_Stream_comment
...nest
}
`, THREADING_LEVEL)}
}
asset(id: $assetId, url: $assetUrl) {
id
+51 -19
View File
@@ -1,21 +1,21 @@
import update from 'immutability-helper';
import {THREADING_LEVEL} from '../constants/stream';
function applyToCommentsOrigin(root, callback) {
if (root.comment) {
if (root.comment.parent) {
return update(root, {
comment: {
parent: {
$apply: (node) => callback(node),
},
},
});
let comment = root.comment;
for (let depth = 0; depth <= THREADING_LEVEL; depth++) {
let changes = {$apply: (node) => node ? callback(node) : node};
for (let i = 0; i < depth; i++) {
changes = {parent: changes};
}
comment = update(comment, changes);
}
return update(root, {
comment: {
$apply: (node) => callback(node),
},
});
return {
...root,
comment,
};
}
return update(root, {
asset: {$apply: (asset) => callback(asset)},
@@ -97,6 +97,13 @@ export function removeCommentFromEmbedQuery(root, id) {
return applyToCommentsOrigin(root, (origin) => findAndRemoveComment(origin, id));
}
export function getTopLevelParent(comment) {
if (comment.parent) {
return getTopLevelParent(comment.parent);
}
return comment;
}
function findComment(nodes, callback) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
@@ -119,12 +126,7 @@ export function findCommentInEmbedQuery(root, callbackOrId) {
callback = (node) => node.id === callbackOrId;
}
if (root.comment) {
if (callback(root.comment)) {
return root.comment;
}
if (root.comment.parent && callback(root.comment.parent)) {
return root.comment.parent;
}
return findComment([getTopLevelParent(root.comment)], callback);
}
if (!root.asset.comments) {
return false;
@@ -179,3 +181,33 @@ function findAndInsertFetchedComments(parent, comments, parent_id) {
export function insertFetchedCommentsIntoEmbedQuery(root, comments, parent_id) {
return applyToCommentsOrigin(root, (origin) => findAndInsertFetchedComments(origin, comments, parent_id));
}
/**
* Nest a string in itself repeatly until `level` has been reached.
*
* Example:
* nest(`
* a
* ...nest
* b
* `, 2)
*
* Output:
* `
* a
* a
* b
* b
* `
*/
export function nest(document, level) {
let result = '';
for (let x = 0; x < level; x++) {
if (x === 0) {
result += document;
continue;
}
result = result.replace('...nest', document);
}
return result.replace('...nest', '');
}
+1 -1
View File
@@ -7,8 +7,8 @@ import './graphql';
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
import {getStore, injectReducers} from 'coral-framework/services/store';
import {getClient} from 'coral-framework/services/client';
import pym from 'coral-framework/services/pym';
import AppRouter from './AppRouter';
import {pym} from 'coral-framework';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
import reducers from './reducers';
+35 -56
View File
@@ -26,7 +26,7 @@ body {
}
button {
margin: 5px 10px 5px 0px;
margin: 5px 0px 5px 0px;
background: none;
padding: 0px;
border: none;
@@ -197,12 +197,12 @@ hr {
}
/* Comment Box Styles */
.coral-plugin-commentbox-container {
.talk-plugin-commentbox-container {
display: flex;
width: 100%;
}
.coral-plugin-commentbox-textarea {
.talk-plugin-commentbox-textarea {
color: #262626;
flex: 1;
padding: 1em;
@@ -212,13 +212,13 @@ hr {
border: 1px solid #9E9E9E;
}
.coral-plugin-commentbox-button-container {
.talk-plugin-commentbox-button-container {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.coral-plugin-commentbox-button {
.talk-plugin-commentbox-button {
float: right;
margin-top: 10px;
padding: 5px 10px;
@@ -228,18 +228,18 @@ hr {
border-radius: 2px;
}
.coral-plugin-commentbox-username {
.talk-plugin-commentbox-username {
width: 50%;
padding-left: 5px;
margin-bottom: 5px;
}
.coral-plugin-commentbox-char-count {
.talk-plugin-commentbox-char-count {
color: #ccc;
text-align: right;
}
.coral-plugin-commentbox-char-max {
.talk-plugin-commentbox-char-max {
color: #d50000;
}
@@ -266,11 +266,6 @@ hr {
float: right;
}
.highlighted-comment {
padding-left: 10px;
border-left: 3px solid rgb(35,118,216);
}
/* Tag Labels */
.coral-plugin-tag-label {
@@ -282,26 +277,16 @@ hr {
border-radius: 2px;
}
/* Reply styles */
.comment .reply {
margin: 0px 0px 10px 20px;
}
/* Comment Action Styles */
.commentActionsRight, .replyActionsRight {
display: flex;
justify-content: flex-end;
width: 30%;
}
.commentActionsLeft, .replyActionsLeft {
display: flex;
justify-content: flex-start;
float: left;
width: 70%;
}
.comment__action-container .material-icons {
@@ -335,33 +320,6 @@ button.comment__action-button[disabled],
display: inline-block;
}
.coral-plugin-permalinks-container {
/*position: relative;*/
z-index: 2;
}
.coral-plugin-permalinks-popover {
display: none;
background-color: white;
border: 1px solid black;
position: absolute;
padding: 5px;
}
.coral-plugin-permalinks-popover.active {
display: block;
}
.coral-plugin-permalinks-copy-field {
display: block;
width: calc(100% - 5px);
}
.coral-plugin-permalinks-copied-text {
float: right;
margin: 8px;
}
/* Flag Styles */
.coral-plugin-flags-popup-form {
@@ -444,14 +402,16 @@ button.comment__action-button[disabled],
/* Load More */
.coral-load-more {
.talk-load-more {
text-align: center;
}
.coral-load-more button {
.talk-load-more button {
width: 100%;
text-align: center;
color: #FFF;
background-color: #2376D8;
border-radius: 2px;
cursor: pointer;
padding: 10px;
border-radius: 2px;
@@ -460,27 +420,46 @@ button.comment__action-button[disabled],
display: inline-block;
}
.coral-load-more:hover button {
.talk-load-more:hover button {
background-color: #4399FF;
}
.coral-load-more-replies, .coral-new-comments {
.talk-new-comments {
width: 100%;
display: flex;
justify-content: center;
cursor: pointer;
}
.coral-new-comments {
.talk-load-more-replies {
width: 100%;
padding-left: 20px;
box-sizing: border-box;
}
.talk-new-comments {
position: relative;
top: 1.8em;
z-index: 100;
}
.coral-load-more-replies button.coral-load-more, .coral-new-comments button.coral-load-more{
.talk-load-more-replies .talk-load-more-button {
background-color: transparent;
color: #979797;
border: #979797 solid 1px;
border-radius: 2px;
}
.talk-load-more-replies .talk-load-more:hover button {
background-color: #979797;
color: white;
}
.talk-new-comments button.talk-load-more{
width: initial;
}
@media (min-device-width : 300px) and (max-device-width : 420px) {
.commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button {
visibility: collapse;
+12 -6
View File
@@ -1,4 +1,5 @@
import pym from 'pym.js';
import URLSearchParams from 'url-search-params';
import {stringify} from 'querystring';
@@ -28,6 +29,7 @@ const snackbarStyles = {
// This function should return value of window.Coral
const Coral = {};
const Talk = (Coral.Talk = {});
let notificationTimeout = null;
// build the URL to load in the pym iframe
function buildStreamIframeUrl(talkBaseUrl, query) {
@@ -110,14 +112,15 @@ function configurePymParent(pymParent, opts) {
snackbar.className = `coral-notif-${type}`;
snackbar.textContent = text;
setTimeout(() => {
clearTimeout(notificationTimeout);
notificationTimeout = setTimeout(() => {
snackbar.style.transform = 'translate(-50%, 0)';
snackbar.style.opacity = 1;
}, 0);
setTimeout(() => {
snackbar.style.opacity = 0;
}, 5000);
notificationTimeout = setTimeout(() => {
snackbar.style.opacity = 0;
}, 7000);
}, 0);
});
// Helps child show notifications at the right scrollTop
@@ -188,7 +191,10 @@ Talk.render = function(el, opts) {
// Compose the query to send down to the Talk API so it knows what to load.
let query = {};
query.comment_id = window.location.hash.slice(1);
let urlParams = new URLSearchParams(window.location.search);
query.comment_id = urlParams.get('commentId');
query.asset_id = opts.asset_id;
query.asset_url = opts.asset_url;
+5 -1
View File
@@ -3,12 +3,16 @@ import bowser from 'bowser';
import * as actions from '../constants/auth';
import * as Storage from '../helpers/storage';
import coralApi, {base} from '../helpers/request';
import pym from '../services/PymConnection';
import pym from '../services/pym';
import {resetWebsocket} from 'coral-framework/services/client';
import t from 'coral-framework/services/i18n';
import {isSlotEmpty} from 'plugin-api/beta/client/services';
export const showSignInDialog = () => (dispatch, getState) => {
if (isSlotEmpty('login')) {
return;
}
const signInPopUp = window.open(
'/embed/stream/login',
'Login',
-9
View File
@@ -1,9 +0,0 @@
import * as authActions from './auth';
import * as assetActions from './asset';
import * as notificationActions from './notification';
export default {
authActions,
assetActions,
notificationActions,
};
@@ -1,4 +1,4 @@
import pym from '../services/PymConnection';
import pym from '../services/pym';
import * as actions from '../constants/notification';
export const addNotification = (notifType, text) => {
@@ -1,7 +1,7 @@
import {Component, cloneElement, Children} from 'react';
import PropTypes from 'prop-types';
import {findDOMNode} from 'react-dom';
import {pym} from 'coral-framework';
import pym from 'coral-framework/services/pym';
export default class ClickOutside extends Component {
static propTypes = {
+19 -5
View File
@@ -7,17 +7,31 @@ import flatten from 'lodash/flatten';
import flattenDeep from 'lodash/flattenDeep';
import {getDefinitionName, mergeDocuments} from 'coral-framework/utils';
import {loadTranslations} from 'coral-framework/services/i18n';
import {injectReducers} from 'coral-framework/services/store';
import {injectReducers, getStore} from 'coral-framework/services/store';
import camelize from './camelize';
function getSlotComponents(slot) {
const pluginConfig = getStore().getState().config.plugin_config;
// Filter out components that have been disabled in `plugin_config`
return flatten(plugins
// Filter out components that have been disabled in `plugin_config`
.filter((o) => !pluginConfig || !pluginConfig[o.plugin] || !pluginConfig[o.plugin].disable_components)
.filter((o) => o.module.slots[slot])
.map((o) => o.module.slots[slot]));
}
export function isSlotEmpty(slot) {
return getSlotComponents(slot).length === 0;
}
/**
* Returns React Elements for given slot.
*/
export function getSlotElements(slot, props = {}) {
const components = flatten(plugins
.filter((o) => o.module.slots[slot])
.map((o) => o.module.slots[slot]));
return components
return getSlotComponents(slot)
.map((component, i) => React.createElement(component, {key: i, ...props}));
}
-10
View File
@@ -1,10 +0,0 @@
import pym from './services/PymConnection';
import actions from './actions';
// TODO (bc): Deprecate old actions. Spreading actions is now needed.
export default {
pym,
actions,
...actions
};
+15 -1
View File
@@ -1,6 +1,6 @@
import {Map, fromJS} from 'immutable';
import * as actions from '../constants/auth';
import {pym} from 'coral-framework';
import pym from 'coral-framework/services/pym';
const initialState = Map({
isLoading: false,
@@ -151,6 +151,20 @@ export default function auth (state = initialState, action) {
case actions.SET_REDIRECT_URI:
return state
.set('redirectUri', action.uri);
case 'APOLLO_SUBSCRIPTION_RESULT':
if (action.operationName === 'UserBanned' && state.getIn(['user', 'id']) === action.variables.user_id) {
return state
.mergeIn(['user'], action.result.data.userBanned);
}
if (action.operationName === 'UserSuspended' && state.getIn(['user', 'id']) === action.variables.user_id) {
return state
.mergeIn(['user'], action.result.data.userSuspended);
}
if (action.operationName === 'UsernameRejected' && state.getIn(['user', 'id']) === action.variables.user_id) {
return state
.mergeIn(['user'], action.result.data.usernameRejected);
}
return state;
default :
return state;
}
+4 -1
View File
@@ -4,13 +4,15 @@ import get from 'lodash/get';
import merge from 'lodash/merge';
import esTA from '../../../node_modules/timeago.js/locales/es';
import frTA from '../../../node_modules/timeago.js/locales/fr';
import en from '../../../locales/en.yml';
import es from '../../../locales/es.yml';
import fr from '../../../locales/fr.yml';
// Translations are happening at https://translate.lingohub.com/the-coral-project/dashboard
const defaultLanguage = 'en';
const translations = {...en, ...es};
const translations = {...en, ...es, ...fr};
let lang;
let timeagoInstance;
@@ -40,6 +42,7 @@ function init() {
}
ta.register('es', esTA);
ta.register('fr', frTA);
timeagoInstance = ta();
}
@@ -1,4 +1,4 @@
import Pym from '../../../node_modules/pym.js';
import Pym from 'pym.js';
const pym = new Pym.Child({polling: 100});
export default pym;
+18
View File
@@ -1,4 +1,5 @@
import {gql} from 'react-apollo';
import t from 'coral-framework/services/i18n';
export const getTotalActionCount = (type, comment) => {
return comment.action_summaries
@@ -126,3 +127,20 @@ export function createDefaultResponseFragments(...names) {
});
return result;
}
export function forEachError(error, callback) {
const errors = error.errors || [error];
errors.forEach((e) => {
console.error(e);
let msg = '';
if (e.translation_key) {
msg = t(`error.${e.translation_key}`);
} else if (error.networkError) {
msg = t('error.network_error');
} else {
msg = t('error.unexpected');
}
callback({error: e, msg});
});
}
@@ -24,7 +24,7 @@ export default class AuthorName extends Component {
return (
<div
className={`${packagename}-text`}>
{author && author.name}
{author && author.username}
</div>
);
}
+36 -20
View File
@@ -1,12 +1,14 @@
import React, {PropTypes} from 'react';
import t from 'coral-framework/services/i18n';
import {can} from 'coral-framework/services/perms';
import {forEachError} from 'coral-framework/utils';
import Slot from 'coral-framework/components/Slot';
import {connect} from 'react-redux';
import {CommentForm} from './CommentForm';
export const name = 'coral-plugin-commentbox';
export const name = 'talk-plugin-commentbox';
// Given a newly posted comment's status, show a notification to the user
// if needed
@@ -27,36 +29,45 @@ class CommentBox extends React.Component {
this.state = {
username: '',
body: '',
loadingState: '',
// incremented on successful post to clear form
postedCount: 0,
hooks: {
preSubmit: [],
postSubmit: []
}
};
}
postComment = ({body}) => {
handleSubmit = () => {
const {
commentPostedHandler,
postComment,
assetId,
parentId,
addNotification,
currentUser,
} = this.props;
if (!can(currentUser, 'INTERACT_WITH_COMMUNITY')) {
addNotification('error', t('error.NOT_AUTHORIZED'));
return;
}
let comment = {
asset_id: assetId,
parent_id: parentId,
body,
body: this.state.body,
...this.props.commentBox
};
// Execute preSubmit Hooks
this.state.hooks.preSubmit.forEach((hook) => hook());
this.setState({loadingState: 'loading'});
postComment(comment, 'comments')
.then(({data}) => {
this.setState({loadingState: 'success'});
const postedComment = data.createComment.comment;
// Execute postSubmit Hooks
@@ -67,12 +78,17 @@ class CommentBox extends React.Component {
if (commentPostedHandler) {
commentPostedHandler();
}
this.setState({body: ''});
})
.catch((err) => {
console.error(err);
this.setState({loadingState: 'error'});
forEachError(err, ({msg}) => addNotification('error', msg));
});
}
this.setState({postedCount: this.state.postedCount + 1});
handleBodyChange = (body) => {
this.setState({body});
}
registerHook = (hookType = '', hook = () => {}) => {
@@ -123,21 +139,17 @@ class CommentBox extends React.Component {
});
}
handleChange = (e) => this.setState({body: e.target.value});
render () {
const {styles, isReply, authorId, maxCharCount} = this.props;
let {cancelButtonClicked} = this.props;
const {isReply, maxCharCount} = this.props;
let {onCancel} = this.props;
if (isReply && typeof cancelButtonClicked !== 'function') {
console.warn('the CommentBox component should have a cancelButtonClicked callback defined if it lives in a Reply');
cancelButtonClicked = () => {};
if (isReply && typeof onCancel !== 'function') {
console.warn('the CommentBox component should have a onCancel callback defined if it lives in a Reply');
onCancel = () => {};
}
return <div>
<CommentForm
styles={styles}
key={this.state.postedCount}
defaultValue={this.props.defaultValue}
bodyInputId={isReply ? 'replyText' : 'commentText'}
bodyLabel={isReply ? t('comment_box.reply') : t('comment.comment')}
@@ -145,7 +157,7 @@ class CommentBox extends React.Component {
charCountEnable={this.props.charCountEnable}
bodyPlaceholder={t('comment.comment')}
bodyInputId={isReply ? 'replyText' : 'commentText'}
saveComment={authorId && this.postComment}
body={this.state.body}
buttonContainerStart={<Slot
fill="commentInputDetailArea"
registerHook={this.registerHook}
@@ -153,7 +165,10 @@ class CommentBox extends React.Component {
isReply={isReply}
inline
/>}
cancelButtonClicked={cancelButtonClicked}
onBodyChange={this.handleBodyChange}
loadingState={this.state.loadingState}
onCancel={onCancel}
onSubmit={this.handleSubmit}
/>
</div>;
}
@@ -167,12 +182,13 @@ CommentBox.propTypes = {
maxCharCount: PropTypes.number,
commentPostedHandler: PropTypes.func,
postComment: PropTypes.func.isRequired,
cancelButtonClicked: PropTypes.func,
onCancel: PropTypes.func,
assetId: PropTypes.string.isRequired,
parentId: PropTypes.string,
authorId: PropTypes.string.isRequired,
currentUser: PropTypes.object.isRequired,
isReply: PropTypes.bool.isRequired,
canPost: PropTypes.bool,
addNotification: PropTypes.func.isRequired,
};
const mapStateToProps = ({commentBox}) => ({commentBox});
+56 -49
View File
@@ -1,9 +1,10 @@
import React, {PropTypes} from 'react';
import {Button} from 'coral-ui';
import classnames from 'classnames';
import cn from 'classnames';
import Slot from 'coral-framework/components/Slot';
import {name} from './CommentBox';
import styles from './styles.css';
import t from 'coral-framework/services/i18n';
@@ -13,15 +14,8 @@ import t from 'coral-framework/services/i18n';
export class CommentForm extends React.Component {
static propTypes = {
// Initial value for underlying comment body textarea
defaultValue: PropTypes.string,
charCountEnable: PropTypes.bool.isRequired,
maxCharCount: PropTypes.number,
cancelButtonClicked: PropTypes.func,
// Save the comment in the form.
// Will be passed { body: String }
saveComment: PropTypes.func.isRequired,
// DOM ID for form input that edits comment body
bodyInputId: PropTypes.string,
@@ -38,53 +32,63 @@ export class CommentForm extends React.Component {
// render inside submit button
submitText: PropTypes.node,
styles: PropTypes.shape({
textarea: PropTypes.string
}),
// cStyle for enabled submit <coral-ui/Button>
submitButtonCStyle: PropTypes.string,
// cStyle for enabled save <coral-ui/Button>
saveButtonCStyle: PropTypes.string,
// return whether the save button should be enabled for the provided
// return whether the submit button should be enabled for the provided
// comment ({ body }) (for reasons other than charCount)
saveCommentEnabled: PropTypes.func,
submitEnabled: PropTypes.func,
// className to add to buttons
buttonClass: PropTypes.string,
submitButtonClassName: PropTypes.string,
cancelButtonClassName: PropTypes.string,
body: PropTypes.string.isRequired,
onBodyChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func,
state: PropTypes.string,
loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']),
}
static get defaultProps() {
return {
bodyLabel: t('comment_box.comment'),
bodyPlaceholder: t('comment_box.comment'),
submitText: t('comment_box.post'),
saveButtonCStyle: 'darkGrey',
saveCommentEnabled: () => true,
submitButtonCStyle: 'darkGrey',
submitEnabled: () => true,
};
}
constructor(props) {
super(props);
this.onBodyChange = this.onBodyChange.bind(this);
this.onClickSubmit = this.onClickSubmit.bind(this);
this.state = {
body: props.defaultValue || ''
};
}
onBodyChange(e) {
this.setState({body: e.target.value});
}
onClickSubmit(e) {
e.preventDefault();
const {saveComment} = this.props;
const {body} = this.state;
saveComment({body});
}
render() {
const {maxCharCount, styles, saveCommentEnabled, buttonClass, charCountEnable} = this.props;
const body = this.state.body;
onBodyChange = (e) => {
this.props.onBodyChange(e.target.value);
}
onClickSubmit = () => {
this.props.onSubmit();
}
getButtonClassName = () => {
switch (this.props.loadingState) {
case 'loading':
return cn(`${name}-button-loading`, styles.buttonLoading);
case 'success':
return cn(`${name}-button-success`, styles.buttonSuccess);
case 'error':
return cn(`${name}-button-error`, styles.buttonError);
default:
return '';
}
}
render() {
const {maxCharCount, submitEnabled, cancelButtonClassName, submitButtonClassName, charCountEnable, body, loadingState} = this.props;
const length = body.length;
const isRespectingMaxCount = (length) => charCountEnable && maxCharCount && length > maxCharCount;
const disablePostComment = !length || isRespectingMaxCount(length) || !saveCommentEnabled({body});
const disableSubmitButton = !length || isRespectingMaxCount(length) || !submitEnabled({body}) || loadingState === 'loading';
const disableCancelButton = loadingState === 'loading';
const disableTextArea = loadingState === 'loading';
return <div>
<div className={`${name}-container`}>
@@ -95,13 +99,14 @@ export class CommentForm extends React.Component {
{this.props.bodyLabel}
</label>
<textarea
style={styles && styles.textarea}
className={`${name}-textarea`}
value={this.state.body}
value={body}
placeholder={this.props.bodyPlaceholder}
id={this.props.bodyInputId}
onChange={this.onBodyChange}
rows={3}/>
rows={3}
disabled={disableTextArea}
/>
<Slot fill='commentInputArea' />
</div>
{
@@ -113,20 +118,22 @@ export class CommentForm extends React.Component {
<div className={`${name}-button-container`}>
{ this.props.buttonContainerStart }
{
typeof this.props.cancelButtonClicked === 'function' && (
typeof this.props.onCancel === 'function' && (
<Button
cStyle='darkGrey'
className={classnames(`${name}-cancel-button`, buttonClass)}
onClick={this.props.cancelButtonClicked}>
className={cn(`${name}-cancel-button`, cancelButtonClassName)}
onClick={this.props.onCancel}
disabled={disableCancelButton}
>
{t('comment_box.cancel')}
</Button>
)
}
<Button
cStyle={disablePostComment ? 'lightGrey' : this.props.saveButtonCStyle}
className={classnames(`${name}-button`, buttonClass)}
cStyle={disableSubmitButton ? 'lightGrey' : this.props.submitButtonCStyle}
className={cn(`${name}-button`, submitButtonClassName, this.getButtonClassName())}
onClick={this.onClickSubmit}
disabled={disablePostComment ? 'disabled' : ''}>
disabled={disableSubmitButton ? 'disabled' : ''}>
{this.props.submitText}
</Button>
</div>
+89 -5
View File
@@ -1,6 +1,90 @@
.slot {
display: inline-block;
div {
display: inline-block;
}
/**
* Example loading state animations on the button.
*/
/*
@-webkit-keyframes sk-scaleout {
0% { -webkit-transform: scale(0) }
100% {
-webkit-transform: scale(1.0);
opacity: 0;
}
}
@keyframes sk-scaleout {
0% {
-webkit-transform: scale(0);
transform: scale(0);
} 100% {
-webkit-transform: scale(1.0);
transform: scale(1.0);
opacity: 0;
}
}
@keyframes comeAndGo {
0% {
opacity: 0;
}
50% {
opacity: 1.0;
}
100% {
opacity: 0;
}
}
.buttonLoading, .buttonLoading:disabled, .buttonLoading:hover {
transition: none;
font-weight: normal;
}
.buttonSuccess, .buttonSuccess:disabled, .buttonSuccess:hover {
}
.buttonSuccess::before {
content: '✓';
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 3px 0px;
animation: comeAndGo 2s forwards;
background: rgb(51, 204, 51);
color: white;
}
.buttonLoading::before {
content: '';
display: inline-block;
width: 40px;
height: 40px;
position: absolute;
top: 0px;
background-color: #333;
border-radius: 100%;
-webkit-animation: sk-scaleout 1.0s infinite ease-in-out;
animation: sk-scaleout 1.0s infinite ease-in-out;
}
.buttonError, .buttonError:disabled, .buttonError:hover {
}
.buttonError::before {
content: '×';
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 3px 0px;
animation: comeAndGo 2s forwards;
background: rgb(250, 100, 100);
color: white;
}
*/
@@ -5,6 +5,8 @@ import {can} from 'coral-framework/services/perms';
import {PopupMenu, Button} from 'coral-ui';
import ClickOutside from 'coral-framework/components/ClickOutside';
import cn from 'classnames';
import styles from './styles.css';
const name = 'coral-plugin-flags';
@@ -39,6 +41,8 @@ export default class FlagButton extends Component {
} else {
this.setState({showMenu: true});
}
} else {
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
}
}
@@ -144,14 +148,17 @@ export default class FlagButton extends Component {
<button
ref={(ref) => this.flagButton = ref}
onClick={!this.props.banned && !flaggedByCurrentUser && !localPost ? this.onReportClick : null}
className={`${name}-button`}>
className={cn(`${name}-button`, styles.button)}>
{
flagged
? <span className={`${name}-button-text`}>{t('reported')}</span>
: <span className={`${name}-button-text`}>{t('report')}</span>
}
<i className={`${name}-icon material-icons ${flagged && 'flaggedIcon'}`}
style={flagged ? styles.flaggedIcon : {}}
<i className={
cn(`${name}-icon`, 'material-icons', {
flaggedIcon: flagged,
[styles.flaggedIcon]: flagged,
})}
aria-hidden={true}>flag</i>
</button>
{
@@ -212,12 +219,3 @@ export default class FlagButton extends Component {
);
}
}
const styles = {
flaggedIcon: {
color: '#F00'
},
unflaggedIcon: {
color: 'inherit'
}
};
@@ -2,6 +2,7 @@ import React from 'react';
import FlagButton from './FlagButton';
import t from 'coral-framework/services/i18n';
import {username, comment} from '../helpers/flagReasons';
const FlagComment = (props) => <FlagButton {...props} getPopupMenu={getPopupMenu} />;
@@ -20,17 +21,17 @@ const getPopupMenu = [
(itemType) => {
const options = itemType === 'COMMENTS' ?
[
{val: 'This comment is offensive', text: t('comment_offensive')},
{val: 'This looks like an ad/marketing', text: t('marketing')},
{val: 'I don\'t agree with this comment', text: t('no_agree_comment')},
{val: 'Other', text: t('other')}
{val: comment.offensive, text: t('comment_offensive')},
{val: comment.spam, text: t('marketing')},
{val: comment.noagree, text: t('no_agree_comment')},
{val: comment.other, text: t('other')}
]
: [
{val: 'This username is offensive', text: t('username_offensive')},
{val: 'I don\'t like this username', text: t('no_like_username')},
{val: 'This user is impersonating', text: t('user_impersonating')},
{val: 'This looks like an ad/marketing', text: t('marketing')},
{val: 'Other', text: t('other')}
{val: username.offensive, text: t('username_offensive')},
{val: username.nolike, text: t('no_like_username')},
{val: username.impersonating, text: t('user_impersonating')},
{val: username.spam, text: t('marketing')},
{val: username.other, text: t('other')}
];
return {
header: t('step_2_header'),
@@ -0,0 +1,7 @@
.button {
margin: 5px 0px 5px 10px;
}
.flaggedIcon {
color: #f00
}
@@ -0,0 +1,15 @@
export default {
username: {
offensive: 'USERNAME_OFFENSIVE',
nolike: 'USERNAME_NOLIKE',
impersonating: 'USERNAME_IMPERSONATING',
spam: 'USERNAME_SPAM',
other: 'USERNAME_OTHER'
},
comment: {
offensive: 'COMMENT_OFFENSIVE',
spam: 'COMMENT_SPAM',
noagree: 'COMMENT_NOAGREE',
other: 'COMMENT_OTHER'
}
};
+1
View File
@@ -0,0 +1 @@
export {default as FlagComment} from './components/FlagComment';
+1 -1
View File
@@ -29,7 +29,7 @@ const Comment = (props) => {
<div className={styles.sidebar}>
<ul>
<li>
<a onClick={props.link(`${props.asset.url}#${props.comment.id}`)}>
<a onClick={props.link(`${props.asset.url}?commentId=${props.comment.id}`)}>
<Icon name="open_in_new" />{t('view_conversation')}
</a>
</li>
@@ -1,81 +0,0 @@
import React, {PropTypes} from 'react';
const name = 'coral-plugin-permalinks';
import {Button} from 'coral-ui';
import styles from './styles.css';
import ClickOutside from 'coral-framework/components/ClickOutside';
import t from 'coral-framework/services/i18n';
export default class PermalinkButton extends React.Component {
static propTypes = {
articleURL: PropTypes.string.isRequired,
commentId: PropTypes.string.isRequired
}
constructor (props) {
super(props);
this.state = {popoverOpen: false, copySuccessful: null, copyFailure: null};
this.toggle = this.toggle.bind(this);
this.copyPermalink = this.copyPermalink.bind(this);
}
toggle () {
// I wish I could position this with a stylesheet, but top-level comments with
// nested replies throws everything off, as well as very long comments
this.popover.style.top = `${this.linkButton.offsetTop - 80}px`;
this.setState({popoverOpen: !this.state.popoverOpen});
}
handleClickOutside = () => {
this.setState({popoverOpen: false});
}
copyPermalink () {
this.permalinkInput.select();
try {
document.execCommand('copy');
this.setState({copySuccessful: true, copyFailure: null});
} catch (err) {
this.setState({copyFailure: true, copySuccessful: null});
}
setTimeout(() => {
this.setState({copyFailure: null, copySuccessful: null});
}, 3000);
}
render () {
const {copySuccessful, copyFailure} = this.state;
return (
<ClickOutside onClickOutside={this.handleClickOutside}>
<div className={`${name}-container`}>
<button
ref={(ref) => this.linkButton = ref}
onClick={this.toggle}
className={`${name}-button`}>
{t('permalink')}
<i className={`${name}-icon material-icons`} aria-hidden={true}>link</i>
</button>
<div
ref={(ref) => this.popover = ref}
className={`${name}-popover ${styles.container} ${this.state.popoverOpen ? 'active' : ''}`}>
<input
className={`${name}-copy-field`}
type='text'
ref={(input) => this.permalinkInput = input}
value={`${this.props.articleURL}#${this.props.commentId}`}
onChange={() => {}} />
<Button className={`${name}-copy-button ${copySuccessful ? styles.success : ''} ${copyFailure ? styles.failure : ''}`}
onClick={this.copyPermalink} >
{!copyFailure && !copySuccessful && 'Copy'}
{copySuccessful && 'Copied'}
{copyFailure && 'Not supported'}
</Button>
</div>
</div>
</ClickOutside>
);
}
}
-67
View File
@@ -1,67 +0,0 @@
.container {
border-radius: 3px;
padding: 15px 10px;
box-sizing: border-box;
/* box-shadow: 3px 3px 5px 0 rgba(0, 0, 0, 0.3); */
border: solid 1px rgba(153, 153, 153, 0.33);
left: 0;
width: 100%;
&::before {
content: '';
border: 10px solid transparent;
border-top-color: white;
position: absolute;
right: 7em;
bottom: -20px;
z-index: 2;
}
&::after{
content: '';
border: 10px solid transparent;
border-top-color: rgba(153, 153, 153, 0.33);
position: absolute;
right: 7em;
bottom: -21px;
z-index: 1;
}
input {
display: inline-block;
width: calc(100% - 78px);
padding: 8px;
border-radius: 3px;
border: solid 1px #e0e0e0;
height: 32px;
box-sizing: border-box;
font-size: 1em;
}
button {
display: inline-block;
float: right;
box-sizing: border-box;
margin: 0;
background-color: #e0e0e0;
font-size: 1em;
width: auto;
height: auto;
padding: 2px;
transition: background-color 0.4s ease;
&:hover{
color: black;
}
&.success {
background-color: #00897B;
color: white;
}
&.failure {
background-color: #FF5252;
color: white;
}
}
}
+3 -4
View File
@@ -18,7 +18,7 @@ class ReplyBox extends Component {
styles,
postComment,
assetId,
authorId,
currentUser,
addNotification,
parentId,
commentPostedHandler,
@@ -31,9 +31,9 @@ class ReplyBox extends Component {
charCountEnable={charCountEnable}
commentPostedHandler={commentPostedHandler}
parentId={parentId}
cancelButtonClicked={this.cancelReply}
onCancel={this.cancelReply}
addNotification={addNotification}
authorId={authorId}
currentUser={currentUser}
assetId={assetId}
postComment={postComment}
isReply={true} />
@@ -48,7 +48,6 @@ ReplyBox.propTypes = {
commentPostedHandler: PropTypes.func,
parentId: PropTypes.string,
addNotification: PropTypes.func.isRequired,
authorId: PropTypes.string.isRequired,
postComment: PropTypes.func.isRequired,
assetId: PropTypes.string.isRequired
};
@@ -5,7 +5,7 @@ import {bindActionCreators} from 'redux';
import {withStopIgnoringUser} from 'coral-framework/graphql/mutations';
import {link} from 'coral-framework/services/PymConnection';
import {link} from 'coral-framework/services/pym';
import NotLoggedIn from '../components/NotLoggedIn';
import IgnoredUsers from '../components/IgnoredUsers';
import {Spinner} from 'coral-ui';
+8 -6
View File
@@ -24,12 +24,6 @@
margin: 2px;
letter-spacing: 0.7px;
font-weight: 400;
&:disabled {
background: #E0E0E0;
color: #4f5c67;
font-weight: bold;
}
}
.icon {
@@ -224,3 +218,11 @@
.raised {
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);
}
.button:disabled {
background: #E0E0E0;
color: #4f5c67;
font-weight: bold;
cursor: default;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import styles from './Tab.css';
export default ({children, tabId, active, onTabClick, cStyle = 'base', ...props}) => (
<li
key={tabId}
className={`${active ? `${styles[`${cStyle}--active`]} coral-tab-active` : ''} coral-tab ${props.className}`}
className={`${active ? `${styles[`${cStyle}--active`]} talk-tab-active` : ''} talk-tab ${props.className}`}
onClick={() => onTabClick(tabId)}
>
{children}
+1 -1
View File
@@ -17,7 +17,7 @@ class TabBar extends React.Component {
const {children, activeTab, cStyle = 'base'} = this.props;
return (
<div>
<ul className={`${styles.base} ${cStyle ? styles[cStyle] : ''} ${this.props.className}`}>
<ul className={`${styles.base} ${cStyle ? styles[cStyle] : ''} talk-tab-bar ${this.props.className}`}>
{React.Children.toArray(children)
.filter((child) => !child.props.restricted)
.map((child, tabId) =>
+20 -7
View File
@@ -29,7 +29,7 @@ const CONFIG = {
// JWT_ISSUER is the value for the issuer for the tokens that will be verified
// when decoding. If `JWT_ISSUER` is not in the environment, then it will try
// `TALK_ROOT_URL`, otherwise, it will be undefined.
JWT_ISSUER: process.env.TALK_JWT_ISSUER || process.env.TALK_ROOT_URL || undefined,
JWT_ISSUER: process.env.TALK_JWT_ISSUER || process.env.TALK_ROOT_URL,
// JWT_EXPIRY is the time for which a given token is valid for.
JWT_EXPIRY: process.env.TALK_JWT_EXPIRY || '1 day',
@@ -91,7 +91,9 @@ const CONFIG = {
if (process.env.NODE_ENV === 'test' && !CONFIG.JWT_SECRET) {
CONFIG.JWT_SECRET = 'keyboard cat';
} else if (!CONFIG.JWT_SECRET) {
throw new Error('TALK_JWT_SECRET must be provided in the environment to sign/verify tokens');
throw new Error(
'TALK_JWT_SECRET must be provided in the environment to sign/verify tokens'
);
}
//------------------------------------------------------------------------------
@@ -108,7 +110,7 @@ if (process.env.NODE_ENV === 'test' && !CONFIG.MONGO_URL) {
// Reset the redis url in the event it hasn't been overrided and we are in a
// testing environment.
if (process.env.NODE_ENV === 'test' && !CONFIG.REDIS_URL) {
CONFIG.REDIS_URL = 'redis://localhost';
CONFIG.REDIS_URL = 'redis://localhost/1';
}
//------------------------------------------------------------------------------
@@ -119,10 +121,15 @@ if (process.env.NODE_ENV === 'test' && !CONFIG.REDIS_URL) {
* This is true when the recaptcha secret is provided and the Recaptcha feature
* is to be enabled.
*/
CONFIG.RECAPTCHA_ENABLED = CONFIG.RECAPTCHA_SECRET && CONFIG.RECAPTCHA_SECRET.length > 0 &&
CONFIG.RECAPTCHA_PUBLIC && CONFIG.RECAPTCHA_PUBLIC.length > 0;
CONFIG.RECAPTCHA_ENABLED =
CONFIG.RECAPTCHA_SECRET &&
CONFIG.RECAPTCHA_SECRET.length > 0 &&
CONFIG.RECAPTCHA_PUBLIC &&
CONFIG.RECAPTCHA_PUBLIC.length > 0;
if (!CONFIG.RECAPTCHA_ENABLED) {
console.warn('Recaptcha is not enabled for login/signup abuse prevention, set TALK_RECAPTCHA_SECRET and TALK_RECAPTCHA_PUBLIC to enable Recaptcha.');
console.warn(
'Recaptcha is not enabled for login/signup abuse prevention, set TALK_RECAPTCHA_SECRET and TALK_RECAPTCHA_PUBLIC to enable Recaptcha.'
);
}
//------------------------------------------------------------------------------
@@ -138,7 +145,13 @@ if (!CONFIG.RECAPTCHA_ENABLED) {
];
if (requiredProps.some((prop) => !CONFIG[prop])) {
console.warn(`${requiredProps.map((v) => `TALK_${v}`).join(', ')} should be defined in the environment if you would like to send password reset emails from Talk`);
console.warn(
`${requiredProps
.map((v) => `TALK_${v}`)
.join(
', '
)} should be defined in the environment if you would like to send password reset emails from Talk`
);
}
}
+12
View File
@@ -11,6 +11,9 @@ entries:
- title: About
url: /index.html
output: web
- title: Contribute
url: /contribute.html
output: web
- title: FAQ
url: /faq.html
output: web
@@ -18,6 +21,9 @@ entries:
- title: Installation
output: web
folderitems:
- title: Getting Started
output: web
url: /install.html
- title: Configuration
output: web
url: /configuration.html
@@ -34,6 +40,12 @@ entries:
- title: Plugins
output: web
folderitems:
- title: Overview
url: /plugins.html
output: web
- title: Quickstart
url: /plugins-quickstart.html
output: web
- title: Client API
url: /plugins-client.html
output: web
+9
View File
@@ -0,0 +1,9 @@
---
title: Contributor's Guide
keywords: homepage
sidebar: talk_sidebar
permalink: contribute.html
summary:
---
Our Contributor's Guide can be found [here](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md).
+1 -1
View File
@@ -52,7 +52,7 @@ We strive for clear inline documentation across our codebase, but have gaps. Con
If you are considering changing a core component (aka, one that is not in a plugin), you are entering the realm of a core developer. We strongly ask that you reach out the coral team before forking and changing core code. We are glad to help talk through your product need and come up with a strategy for implementing as a plugin, or working with you to extend the plugin API for your use case.
### How do I contribue to these docs?
### How do I contribute to these docs?
Contributions to the docs are much appreciated and a great way to get involved in the project.
+22
View File
@@ -0,0 +1,22 @@
---
title: Installation
sidebar: talk_sidebar
permalink: install.html
summary:
---
## Requirements
Talk requires MongoDB and Redis.
- MongoDB ^3.2 - [Docs](https://docs.mongodb.com/manual/installation/)
- Redis ^3.2.5 - [Docs](https://redis.io/topics/quickstart)
## Installation method
While Talk can be installed in many ways, we support two install paths:
* [Install from Source](install-source.html) (development)
* [Install via Docker](install-docker.html) (deployment)
If you have success installing Talk in another way, please consider [contributing to this documentation](faq.html#how-do-i-contribute-to-these-docs)!
+244
View File
@@ -0,0 +1,244 @@
---
title: Plugins Quickstart
keywords: plugins
sidebar: talk_sidebar
permalink: plugins-quickstart.html
summary:
---
This tutorial walks through the mechanics of creating and publishing a Talk plugin. Along the way I call out some particular habits and techniques that I employ. If you have other practices that you find valuable, please don't hesitate to [contribute!](faq.html#how-do-i-contribute-to-these-docs)
We will create a plugin that exposes a route that allows assets to be created or updated.
## Setup the environment
Before I begin working on the plugin, I've installed [Talk from source](/install-source.html).
### Watch the Server
In a terminal, I run `yarn dev-start`. This:
* starts my server, showing plugin and configuration information
* restarts it when I save files
* shows my temporary `console.log()` statements
* shows real time access logs
* shows verbose debug output if enabled (more on this later)
### Watch the Client build process
In a separate terminal I run `yarn build-watch`. This:
* builds the client side javascript bundles
* watches relevant files and rebuilds the bundle on change
* displays _compile time_ errors, including (the many) syntax errors I cause
If you need to run `yarn install`, you will see missing module error messages here.
### Watch from the Browser
I open up `http://localhost:3000` in a web browser and see the default comment stream. I then open the dev tools console, which:
* shows any _run time_ errors/warnings generated on the front end.
* shows any temporary `console.log()` statements I add during development.
I also often toggle to the Network Tab to see:
* which files are being loaded
* requests sent from my front end code, including headers, the payload/queries sent and the data returned
I strongly recommend taking the time to fully explore all the features of your browser's dev tools!
## Create a home for my new plugin
My goals for this tutorial are to:
* build this plugin locally
* use source control and publish for collaboration
* publish the plugin as an npm library
### Create a repo
I create a new repo called `talk-plugin-asset-manager`. (I use github, but this you could store this anywhere, bitbucket, svn, etc...)
_make sure to respect the naming convention `talk-plugin-*`. This will allow for easy identification of the repo and, eventually, easy searching on npm._
### Set up a local file structure
In a blatant rip off from the Golang community, I create an environment var to hold the path to the root of my Coral directory. This allows absolute pathing.
```
export CORALPATH=/path/to/my/coral/root/dir
```
I like to put my plugins in a directory next to talk, but you could put this anywhere.
```
cd $CORALPATH
git clone https://github.com/jde/talk-plugin-asset-manager.git
```
### Register your plugin
Add the plugin to the plugins.json file:
```
{
"server": [
...
"talk-plugin-asset-manager"
],
"client": [
...
// no client side components so I won't add it here
]
}
```
But wait! Talk looks in `talk/plugins/[plugin-name]` for plugin code. Why couldn't we just add that plugin there?
We could have.
This would make it _a little_ easier to register, but _a lot_ harder to cleanly manage in version control. In order to avoid it being sucked into your Talk repo, you would have to manually `.gitignore` it or use [sub modules]() or something similar.
As a user of a Linux_y_ os, I prefer to create a symbolic link.
```
cd $CORALPATH/talk/plugins
ln -s $CORALPATH/talk-plugin-asset-manager
```
Now, as far as Talk knows, our plugin is right there in the folder. Git is wise, however, and will not include it in the Talk repo. Best of all, our `yarn dev-start` based watch statement follows symbolic links and will restart our sever each time a file is saved.
### Create the initial index file
All plugins contain server and/or client index files, which export all plugin functionality.
```
// talk-plugin-asset-manager/index.js
module.exports = {};
```
## Build the feature!
Now that the plugin is set up I can get down to writing the feature. My goal is to allow my CMS to push new assets as they are created into Talk. To accomplish this, I will create a POST endpoint using Talk's [route api](plugins-server.html#field-router).
### Create a route
When designing my api, I want to be careful to avoid conflicts with not only the existing Talk api, but other plugins in the open source ecosystem that may be creating routes. To do this, I'll follow the golden rule of creating universals with plugins:
_Always namespace all universals with your plugin's unique name._
To ensure everything is hooked up, I'll log the request body (POST payload in this case) to the console and echo it as the response:
```
// talk-plugin-asset-manager/index.js
module.exports = {
router(router) {
router.post('/api/v1/asset-manager', (req, res) => {
console.log(req.body);
res.json(req.body);
});
}
}
```
When I save this file, I reflexively check my console to be sure that the server restarts.
To test that this works, I can:
```
$ curl -H "Content-Type: application/json" -X POST -d '{"url":"http://localhost:3000/my-article.html","title":"My Article"}' http://localhost:3000/api/v1/asset-manager
{"url":"http://localhost:3000/my-article.html","title":"My Article"}
```
After hitting the endpoint, I can also look at the terminal running `yarn dev-start` and see my `console.log()` and the access log:
```
{ url: 'http://localhost:3000/my-article.html',
title: 'My Article' }
POST /api/v1/asset-manager 200 1.379 ms - 68
```
### Save the asset
When I save this asset, I will use Talk's [asset model](https://github.com/coralproject/talk/blob/master/models/asset.js).
Mongo has a handy method [findOneAndUpdate](https://docs.mongodb.com/v3.2/reference/method/db.collection.findOneAndUpdate/) that will take care determining whether or not this asset exists, then either updating or inserting it. Whenever possible, we recommend using these atomic patterns that prevent multiple queries to the db and the efficiency problems and race conditions that they cause.
```
// talk-plugin-asset-manager/index.js
const AssetModel = require('models/asset');
module.exports = {
router(router) {
router.post('/api/v1/asset-manager', (req, res) => {
const asset = req.body;
const update = {$setOnInsert: {url: asset.url}};
AssetModel.findOneAndUpdate(asset, update, {
new: true,
upsert: true,
setDefaultsOnInsert: true
})
.then((asset) => res.json(asset));
});
}
}
```
I can now run the `curl` command as before and see that the response contains a complete asset document!
In addition, I can change the title in the json payload and verify that the id is the same, indicating that the record was updated!
Lastly, I can see the asset in the admin panel at `http://localhost:3000/admin` as well as in my database.
We have an alpha version of our plugin!
### More work to be done
The purpose of this tutorial is to follow the full lifecycle of a plugin, from conception through publication into deployment. With that in mind we'll move forward with this alpha version.
Some things to make this production ready:
* refactoring to separate concerns
* commenting
* adding tests
* validating data
* [adding security](https://github.com/coralproject/talk/blob/b805451d376d2892c81c58d8822a85563e469b88/routes/api/users/index.js#L14)
* incorporating [domain whitelisting](https://github.com/coralproject/talk/blob/b805451d376d2892c81c58d8822a85563e469b88/services/assets.js#L60).
It is important to realize that when you're writing a Talk plugin you are writing a program that may be touched by other devs and could grow in size and complexity. Bring your best engineering sensibilities to bear.
## Publishing the plugin
### Publish to npm
In order to [register](http://localhost:4000/plugins.html#plugin-registration) your _published_ plugin, you will need to [publish it to npm](https://docs.npmjs.com/getting-started/publishing-npm-packages).
Once the package is published, update `plugins.json` to use the published plugin:
```
{
"server": [
...
{"talk-plugin-asset-manager": "^0.1"}
],
...
}
```
Finally, run the `reconcile` script to install the plugin from npm.
```
$ bin/cli plugins reconcile
```
Once you've taken this step, anyone can register your plugin into their Talk server! Thank you for contributing to the open source community!
### Publish to version control
This plugin is open source, so I'm also going to [publish it to github](https://github.com/jde/talk-plugin-asset-manager/commit/66b626caa85cb8030b3ddaa7c1a4821bf01e350a) and [cut a release](https://github.com/jde/talk-plugin-asset-manager/releases/tag/v0.1) that mirrors the npm relese.
## Done!
+59
View File
@@ -189,6 +189,41 @@ send data to the client. If the type in question contains args, clients may subs
For more information, see the [Apollo Docs](https://github.com/apollographql/graphql-subscriptions).
#### Field: `tokenUserNotFound`
```js
tokenUserNotFound: async ({jwt, token}) => {
let profile = await someExternalService(token);
if (!profile) {
return null;
}
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
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` paramenter of the object
is the unpacked token, while `token` is the original jwt token string.
### Routes
#### Field: `router`
@@ -205,6 +240,30 @@ The Router hook allows you to create a function that accepts the base express
router where you can mount any amount of middleware/routes to do any form of
action needed by external applications.
#### Field: `tags`
The tags hook allows a plugin to define tags that are code controlled (added
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()
}
]
```
You can refer to `models/schema/tag.js` for the available schema to match when
creating models to enable/disable specific features.
### Authorization middleware
The following example creates the requisite callback route and passport
+105
View File
@@ -0,0 +1,105 @@
---
title: Plugins Overview
keywords: plugins
sidebar: talk_sidebar
permalink: plugins.html
summary:
---
## Plugin Registration
In order for a plugin to be active in a Talk install, it must be _registered_. The parsing order for the plugin registration is as follows:
- `TALK_PLUGINS_JSON` environment variable
- `plugins.json` file
- `plugins.default.json` file
If you need to "disable all plugins", you can simply provide `{}` as the
contents of `process.env.TALK_PLUGINS_JSON` or the `plugins.json`.
The format for this is thus:
```json
{
"server": [
"people"
]
}
```
Where we have a `server` key with an array of plugins that match the folder
name in the `plugins/` folder. For example, the above config would
require a plugin from `plugins/people`, which must provide a `index.js` file
that returns an object that matches the Plugin Specification.
If the package is external (available on NPM) you can specify the string for
the version by using an object instead, for example:
```json
{
"server": [
{"people": "^1.2.0"}
]
}
```
External plugins can be resolved by running:
```bash
./bin/cli plugins reconcile
```
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 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` 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.
2. It will seek out dependencies that are listed in the object notation and try
to install them from npm.
## Plugin Dependencies
You may also include additional external dependencies in your local packages by
specifying a `package.json` at your plugin root which will result in a
`node_modules` folder being generated at the plugin root with your specific
dependencies.
## Deployment Solutions
Plugins can be deployed with a production instance of Talk.
### Source
Source deployments can just modify the `plugins.json` file and include any
local plugins into the `plugins/` directory. After including the config, you
need to reconcile the plugins and build the static assets:
```bash
# get plugin dependancies and remote plugins
./bin/cli plugins reconcile
# build staic assets (including enabled client side plugins)
yarn build
```
Then the application can be started as is.
### Docker
If you deploy using Docker, you can extend from the `*-onbuild` image, an
example `Dockerfile` for your project could be:
```Dockerfile
FROM coralproject/talk:latest-onbuild
```
Where the directory for your instance would contain a `plugins.json` file
describing the plugin requirements and a `plugins` directory containing any
other local plugins that should be included.
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.
+2 -1
View File
@@ -3,6 +3,7 @@ const mutators = require('./mutators');
const uuid = require('uuid');
const plugins = require('../services/plugins');
const pubsub = require('../services/pubsub');
const debug = require('debug')('talk:graph:context');
/**
@@ -32,7 +33,7 @@ const decorateContextPlugins = (context, contextPlugins) => contextPlugins.reduc
* Stores the request context.
*/
class Context {
constructor({user = null}, pubsub) {
constructor({user = null}) {
// Generate a new context id for the request.
this.id = uuid.v4();
+1 -2
View File
@@ -1,6 +1,5 @@
const schema = require('./schema');
const Context = require('./context');
const pubsub = require('./pubsub');
const {createSubscriptionManager} = require('./subscriptions');
module.exports = {
@@ -11,7 +10,7 @@ module.exports = {
// Load in the new context here, this'll create the loaders + mutators for
// the lifespan of this request.
context: new Context(req, pubsub)
context: new Context(req)
}),
createSubscriptionManager
};
+14 -1
View File
@@ -63,18 +63,31 @@ const getAssetsForMetrics = async ({loaders: {Actions, Comments}}) => {
.then((connection) => connection.nodes);
};
const findByUrl = async (context, asset_url) => {
// Verify that the asset_url is parsable.
let parsed_asset_url = url.parse(asset_url);
if (!parsed_asset_url.protocol) {
throw errors.ErrInvalidAssetURL;
}
return AssetsService.findByUrl(asset_url);
};
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
* @return {Object} object of loaders
*/
module.exports = (context) => ({
Assets: {
// TODO: decide whether we want to move these to mutators or not, as in fact
// this operation create a new asset if one isn't found.
getByURL: (url) => findOrCreateAssetByURL(context, url),
findByUrl: (url) => findByUrl(context, url),
search: (query) => getAssetsByQuery(context, query),
getByID: new DataLoader((ids) => genAssetsByID(context, ids)),
getForMetrics: () => getAssetsForMetrics(context),
+2 -2
View File
@@ -16,7 +16,7 @@ const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants');
const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id, item_type, action_type, group_id, metadata = {}}) => {
let comment;
if (pubsub && item_type === 'COMMENTS') {
if (item_type === 'COMMENTS') {
comment = await Comments.get.load(item_id);
if (!comment) {
throw new Error('Comment not found');
@@ -38,7 +38,7 @@ const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id,
await UsersService.setStatus(item_id, 'PENDING');
}
if (pubsub && comment) {
if (comment) {
pubsub.publish('commentFlagged', comment);
}
+10 -18
View File
@@ -177,11 +177,8 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu
}
Comments.countByAssetID.incr(asset_id);
if (pubsub) {
// Publish the newly added comment via the subscription.
pubsub.publish('commentAdded', comment);
}
// Publish the newly added comment via the subscription.
pubsub.publish('commentAdded', comment);
}
return comment;
@@ -346,17 +343,14 @@ const setStatus = async ({user, loaders: {Comments}, pubsub}, {id, status}) => {
// adjust the affected user's karma in the next tick.
process.nextTick(adjustKarma(Comments, id, status));
if (pubsub) {
if (status === 'ACCEPTED') {
if (status === 'ACCEPTED') {
// Publish the comment status change via the subscription.
pubsub.publish('commentAccepted', comment);
} else if (status === 'REJECTED') {
// Publish the comment status change via the subscription.
pubsub.publish('commentAccepted', comment);
} else if (status === 'REJECTED') {
// Publish the comment status change via the subscription.
pubsub.publish('commentRejected', comment);
}
// Publish the comment status change via the subscription.
pubsub.publish('commentRejected', comment);
}
return comment;
@@ -379,11 +373,9 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
// Execute the edit.
const comment = await CommentsService.edit(id, context.user.id, {body, status});
if (context.pubsub) {
// Publish the edited comment via the subscription.
context.pubsub.publish('commentEdited', comment);
// Publish the edited comment via the subscription.
context.pubsub.publish('commentEdited', comment);
}
return comment;
};
+18 -6
View File
@@ -2,16 +2,28 @@ const errors = require('../../errors');
const UsersService = require('../../services/users');
const {SET_USER_STATUS, SUSPEND_USER, REJECT_USERNAME} = require('../../perms/constants');
const setUserStatus = ({user}, {id, status}) => {
return UsersService.setStatus(id, status);
const setUserStatus = async ({user, pubsub}, {id, status}) => {
const result = await UsersService.setStatus(id, status);
if (result && result.status === 'BANNED') {
pubsub.publish('userBanned', result);
}
return result;
};
const suspendUser = ({user}, {id, message, until}) => {
return UsersService.suspendUser(id, message, until);
const suspendUser = async ({user, pubsub}, {id, message, until}) => {
const result = await UsersService.suspendUser(id, message, until);
if (result) {
pubsub.publish('userSuspended', result);
}
return result;
};
const rejectUsername = ({user}, {id, message}) => {
return UsersService.rejectUsername(id, message);
const rejectUsername = async ({user, pubsub}, {id, message}) => {
const result = await UsersService.rejectUsername(id, message);
if (result) {
pubsub.publish('usernameRejected', result);
}
return result;
};
const ignoreUser = ({user}, userToIgnore) => {
+1 -1
View File
@@ -14,7 +14,7 @@ const Asset = {
});
},
commentCount({id, commentCount}, {excludeIgnored}, {user, loaders: {Comments}}) {
// TODO: remove
if (user && excludeIgnored) {
return Comments.parentCountByAssetIDPersonalized({assetId: id, excludeIgnored});
+11 -3
View File
@@ -40,17 +40,25 @@ const RootQuery = {
return Comments.get.load(id);
},
async commentCount(_, {query}, {user, loaders: {Actions, Comments}}) {
async commentCount(_, {query}, {user, loaders: {Actions, Comments, Assets}}) {
if (user == null || !user.can(SEARCH_OTHERS_COMMENTS)) {
return null;
}
const {action_type} = query;
const {action_type, asset_url} = query;
if (action_type) {
query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
}
if (asset_url) {
let asset = await Assets.findByUrl(asset_url);
if (asset) {
query.asset_id = asset.id;
}
}
return Comments.getCountByQuery(query);
},
+9
View File
@@ -14,6 +14,15 @@ const Subscription = {
commentFlagged(comment) {
return comment;
},
userBanned(user) {
return user;
},
userSuspended(user) {
return user;
},
usernameRejected(user) {
return user;
},
};
module.exports = Subscription;
+8
View File
@@ -6,6 +6,7 @@ const {
SEARCH_OTHERS_COMMENTS,
UPDATE_USER_ROLES,
SEARCH_COMMENT_METRICS,
VIEW_SUSPENSION_INFO,
LIST_OWN_TOKENS
} = require('../../perms/constants');
@@ -84,6 +85,13 @@ const User = {
if (requestingUser && requestingUser.can(SEARCH_COMMENT_METRICS)) {
return KarmaService.model(user);
}
},
suspension({id, suspension}, _, {user}) {
if (user.id !== id && !user.can(VIEW_SUSPENSION_INFO)) {
return null;
}
return suspension;
}
};
+45 -3
View File
@@ -3,7 +3,7 @@ const {SubscriptionServer} = require('subscriptions-transport-ws');
const _ = require('lodash');
const debug = require('debug')('talk:graph:subscriptions');
const pubsub = require('./pubsub');
const pubsub = require('../services/pubsub');
const schema = require('./schema');
const Context = require('./context');
const plugins = require('../services/plugins');
@@ -21,6 +21,9 @@ const {
SUBSCRIBE_COMMENT_FLAGGED,
SUBSCRIBE_ALL_COMMENT_EDITED,
SUBSCRIBE_ALL_COMMENT_ADDED,
SUBSCRIBE_ALL_USER_SUSPENDED,
SUBSCRIBE_ALL_USER_BANNED,
SUBSCRIBE_ALL_USERNAME_REJECTED,
} = require('../perms/constants');
/**
@@ -83,6 +86,45 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu
}
},
}),
userSuspended: (options, args) => ({
userSuspended: {
filter: (user, context) => {
if (
!context.user
|| args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USER_SUSPENDED)
) {
return false;
}
return !args.user_id || user.id === args.user_id;
}
},
}),
userBanned: (options, args) => ({
userBanned: {
filter: (user, context) => {
if (
!context.user
|| args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USER_BANNED)
) {
return false;
}
return !args.user_id || user.id === args.user_id;
}
},
}),
usernameRejected: (options, args) => ({
usernameRejected: {
filter: (user, context) => {
if (
!context.user
|| args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USERNAME_REJECTED)
) {
return false;
}
return !args.user_id || user.id === args.user_id;
}
},
}),
});
/**
@@ -117,10 +159,10 @@ const createSubscriptionManager = (server) => new SubscriptionServer({
} catch (e) {
console.error(e);
return new Context({}, pubsub);
return new Context({});
}
return new Context(req, pubsub);
return new Context(req);
};
return baseParams;
+26
View File
@@ -61,6 +61,10 @@ type UserProfile {
provider: String!
}
type SuspensionInfo {
until: Date
}
# Any person who can author comments, create actions, and view comments on a
# stream.
type User {
@@ -108,6 +112,10 @@ type User {
# returns user status
status: USER_STATUS
# returns suspension info. Only available to Admins and Moderators
# or on own logged in User.
suspension: SuspensionInfo
}
# UsersQuery allows the ability to query users by a specific fields.
@@ -255,6 +263,9 @@ input CommentCountQuery {
# Asset that a comment is on.
asset_id: ID
# The URL that the asset is located on.
asset_url: String
# the parent of the comment that we want to retrieve.
parent_id: ID
@@ -1031,6 +1042,21 @@ type Subscription {
# Get an update whenever a comment has been rejected.
# Requires the `ADMIN` or `MODERATOR` role.
commentRejected(asset_id: ID): Comment
# Get an update whenever a user has been suspended.
# `user_id` must match id of current user except for
# users with the `ADMIN` or `MODERATOR` role.
userSuspended(user_id: ID): User
# Get an update whenever a user has been banned.
# `user_id` must match id of current user except for
# users with the `ADMIN` or `MODERATOR` role.
userBanned(user_id: ID): User
# Get an update whenever a username has been rejected.
# `user_id` must match id of current user except for
# users with the `ADMIN` or `MODERATOR` role.
usernameRejected(user_id: ID): User
}
################################################################################
+8 -4
View File
@@ -1,4 +1,7 @@
en:
your_account_has_been_suspended: Your account has been temporarily suspended.
your_account_has_been_banned: Your account has been banned.
your_username_has_been_rejected: Your account has been suspended because your username has been deemed inappropriate. To restore your account please enter a new username.
bandialog:
are_you_sure: "Are you sure you would like to ban {0}?"
ban_user: "Ban User?"
@@ -37,6 +40,7 @@ en:
comment_post_notif_premod: "Thank you for posting. Our moderation team will review your comment shortly."
common:
copy: 'Copy'
error: 'An error has occurred.'
community:
account_creation_date: "Account Creation Date"
active: Active
@@ -163,7 +167,6 @@ en:
edit_window_timer_prefix: "Edit Window: "
second: "second"
seconds_plural: "seconds"
unexpected_error: "Unexpected error while saving changes. Sorry!"
email:
confirm:
has_been_requested: "A email confirmation has been requested for the following account:"
@@ -204,6 +207,7 @@ en:
organization_name: "Organization name must only contain letters or numbers."
password: "Password must be at least 8 characters"
username: "Usernames can contain letters numbers and _ only"
unexpected: "Unexpected error occurred. Sorry!"
flag_comment: "Report comment"
flag_reason: "Reason for reporting (Optional)"
flag_username: "Report username"
@@ -231,9 +235,9 @@ en:
success_bio_update: "Your biography has been updated"
success_name_update: "Your username has been updated"
success_update_settings: "The changes you have made have been applied to the comment stream on this article"
view_all_replies_unknown_number: "view all replies"
view_all_replies: "view {0} replies"
view_all_replies_initial: "view all {0} replies"
show_all_replies: Show all replies
show_1_more_reply: Show 1 more reply
show_x_more_replies: Show {0} more replies
view_more_comments: "view more comments"
view_reply: "view reply"
from_settings_page: "From the Profile Page you can see your comment history."
+8 -4
View File
@@ -1,4 +1,7 @@
es:
your_account_has_been_suspended: Su cuenta ha sido temporalmente suspendida.
your_account_has_been_banned: Su cuenta ha sido suspendida.
your_username_has_been_rejected: Su cuenta ha sido suspendida porque tu nombre de usuario ha sido considerado no apropiado para el espacio. Para recuperar la cuenta, por favor ingresar un nuevo nombre de usuario.
bandialog:
are_you_sure: "¿Estás segura que quieres suspender a {0}?"
ban_user: "¿Quieres suspender el Usuario?"
@@ -37,6 +40,7 @@ es:
comment_post_notif_premod: "Gracias por el comentario. Nuestro equipo de moderación va a revisarlo muy pronto."
common:
copy: 'Copiar'
error: 'Ha ocurrido un error.'
community:
account_creation_date: "Fecha de creación de la cuenta"
active: Activa
@@ -162,7 +166,6 @@ es:
edit_window_timer_prefix: "Ventana Edición:"
second: "segundo"
seconds_plural: "segundos"
unexpected_error: "Lo siento. Ha habido un error no previsto al guardar los cambios."
email:
confirm:
has_been_requested: "Un correo de confirmación ha sido pedido para la siguiente cuenta:"
@@ -189,6 +192,7 @@ es:
email_required: "Se requiere un correo"
email_username_in_use: "Correo o nombre en uso."
INVALID_ASSET_URL: "La URL del articulo no es valida"
unexpected: "Lo siento. Ha habido un error no previsto."
login_maximum_exceeded: "Ha realizado demasiados intentos fallidos de usar la contraseña. Por favor espere."
network_error: "Error al conectar con el servidor. Compruebe su conexión a Internet y vuelva a intentarlo."
NOT_AUTHORIZED: "Acción no autorizada."
@@ -228,9 +232,9 @@ es:
success_bio_update: "Tu biografia fue actualizada"
success_name_update: "Tu nombre de usuario ha sido actualizado"
success_update_settings: "La configuración de este articulo fue actualizada"
view_all_replies_unknown_number: "ver todas las respuestas"
view_all_replies: "ver {0} respuestas"
view_all_replies_initial: "ver todas las {0} respuestas"
show_all_replies: "Mostrar todas las respuestas"
show_1_more_reply: "Mostrar 1 respuestas"
show_x_more_replies: "Mostrar {0} respuestas más"
view_more_comments: "Ver más comentarios"
view_reply: "ver respuesta"
from_settings_page: "Desde la página de configuración puedes ver tu historia de comentarios."
+355
View File
@@ -0,0 +1,355 @@
fr:
bandialog:
are_you_sure: "Êtes-vous sûr de vouloir bannir {0}?"
ban_user: "Bannir l'utilisateur ?"
banned_user: "Utilisateur banni"
cancel: Annuler
note: "Remarque: bannir cet utilisateur rejettera également ce commentaire."
yes_ban_user: "Oui, bannir cet utilisateur"
bio_offensive: "Cette biographie est offensante"
cancel: Annuler
characters_remaining: "caractères restants"
comment:
anon: Anonyme
ban_user: "Bannir utilisateur"
comment: "Publier un commentaire"
flagged: signalé
view_context: "Afficher le contexte"
comment_box:
post: "Publier"
cancel: "Annuler"
reply: "Répondre"
comment: "Publier un commentaire"
name: "Nom"
comment_post_notif: "Votre commentaire a été publié."
comment_post_notif_premod: "Merci d'avoir envoyé un commentaire. Notre équipe de modération passera en revue votre commentaire sous peu."
comment_post_banned_word: "Votre commentaire contient un ou plusieurs mots qui ne sont pas autorisés, il ne sera pas publié. Si vous pensez qu'il y a une erreur, contactez notre équipe de modération."
characters_remaining: "caractères restants"
comment_is_best: "Ce commentaire est l'un des meilleurs"
comment_offensive: "Ce commentaire est offensant"
comment_plural: Commentaires
comment_post_banned_word: "Votre commentaire contient un ou plusieurs mots qui ne sont pas autorisés, il ne sera pas publié. Si vous pensez qu'il y a une erreur, contactez notre équipe de modération."
comment_post_notif: "Votre commentaire a été publié."
comment_post_notif_premod: "Merci d'avoir envoyé un commentaire. Notre équipe de modération passera en revue votre commentaire sous peu."
community:
account_creation_date: "Date de création du compte"
active: Actif
admin: Administrateur
ads_marketing: "Cela ressemble à du marketing / un message publicitaire"
are_you_sure: "Êtes-vous sûr de vouloir bannir {0} ?"
ban_user: "Bannir l'utilisateur ?"
banned: Banni
banned_user: "Utilisateur banni"
cancel: Signalé
dont_like_username: "Je n'aime pas ce nom d'utilisateur"
flaggedaccounts: "Noms d'utilisateurs signalés"
flags: Signalements
impersonating: "Cet utilisateur se fait passer pour quelqu'un d'autre"
loading: "Chargement des résultats"
moderator: Modérateur
newsroom_role: "Rôle de la salle de presse"
no_flagged_accounts: "La liste des comptes signalés est vide."
no_results: "Aucun utilisateur n'a été trouvé avec ce nom d'utilisateur ou cette adresse de messagerie. Ils se cachent !"
note: "Remarque: bannir cet utilisateur ne lui permettra pas de modifier les commentaires ou de supprimer quoi que ce soit."
offensive: "Ce commentaire est offensant"
other: Autre
people: Gens
role: "Sélectionnez le rôle ..."
select_status: "Sélectionnez l'état ..."
spam_ads: "Spam / Annonces"
staff: "Équipe"
status: Statut
username_and_email: "Nom d'utilisateur et e-mail"
yes_ban_user: "Oui bannir l'utilisateur"
configure:
apply: Appliquer
banned_word_text: "Les commentaires qui contiennent ces mots ou ces expressions (non sensibles à la casse) seront automatiquement supprimés du flux de commentaires. Tapez un mot et appuyez sur Entrée ou Tabulation pour ajouter. En option, envoyez une liste séparée par des virgules."
banned_words_title: "Liste des mots interdits"
close: "Fermer"
close_after: "Fermer les commentaires après"
close_stream: "Fermer le fil"
close_stream_configuration: "Ce fil de commentaires est actuellement fermé. En ouvrant ce fil commentaire, de nouveaux commentaires peuvent être soumis et publiés."
closed_comments_desc: "Écrivez un message à afficher lorsque votre fil de commentaires est fermé et n'accepte plus de nouveaux commentaires."
closed_comments_label: "Écrire un message..."
closed_stream_settings: "Message de fil de commentaires fermé"
comment_count_error: "S'il vous plait, entrez un nombre valide."
comment_count_header: "Limiter la longueur d'un commentaire"
comment_count_text_post: caractères
comment_count_text_pre: "Les commentaires seront limités à"
comment_settings: Paramètres
comment_stream: "Fil de commentaires"
comment_stream_will_close: "Le fil de commentaires va se fermer dans"
community: Communauté
configure: Configurer
copy_and_paste: "Copiez et collez le code ci-dessous dans votre CMS pour intégrer votre boîte de commentaires dans vos articles."
custom_css_url: "URL CSS personnalisée"
custom_css_url_desc: "URL d'une feuille de style CSS qui remplacera les styles par défaut d'intégration des commentaires. Peut être interne ou externe."
dashboard: Tableau de bord
days: Journées
description: "En tant qu'administrateur, vous pouvez personnaliser les paramètres du fil de commentaires pour cet élément."
domain_list_text: "Entrez les domaines que vous souhaitez autoriser pour Talk, par exemple vos environnements locaux de production et de production (ex : localhost: 3000 staging.domain.com domain.com)."
domain_list_title: "Domaines autorisés"
edit_comment_timeframe_heading: "Modifier l'horodatage des commentaires"
edit_comment_timeframe_text_pre: "Les commentateurs auront"
edit_comment_timeframe_text_post: "secondes pour modifier leurs commentaires."
embed_comment_stream: "Intégrer le fil"
enable_premod_links_text: "Les modérateurs doivent approuver tout commentaire contenant un lien avant sa publication."
enable_pre_moderation: "Activer modération a priori"
enable_pre_moderation_text: "Les modérateurs doivent approuver tout commentaire avant qu'il ne soit publié."
enable_premod_links: "Modérer les commentaires contenant des liens"
enable_premod: "Activer la modération a priori"
enable_premod_description: "Les modérateurs doivent approuver tout commentaire avant sa publication."
enable_premod_links_description: "Les modérateurs doivent approuver tout commentaire contenant un lien avant sa publication."
enable_questionbox: "Posez une question aux lecteurs."
enable_questionbox_description: "Cette question apparaîtra en haut de ce fil de commentaires. Demandez aux lecteurs de s'exprimer sur un sujet évoqué dans l'article ou posez les termes de la discussion, etc."
hours: Heures
include_comment_stream: "Inclure la description du fil de commentaires pour les lecteurs"
include_comment_stream_desc: "Écrivez un message à ajouter au haut de votre flux de commentaires. Publiez un sujet de discussion, avec des suggestion sur la tenue des débats, etc."
include_text: "Entrez votre texte ici."
include_question_here: "Écrivez votre question ici."
moderate: Modérer
moderation_settings: "Paramètres de la modération"
open: "Ouvert"
open_stream: "Ouvrir le fil"
open_stream_configuration: "Ce fil de commentaires est actuellement ouvert. En fermant ce fil de commentaires, aucun nouveau commentaire ne pourra être envoyé, les commentaires précédents seront toujours affichés."
require_email_verification: "Exiger une vérification de l'e-mail"
require_email_verification_text: "Les nouveaux utilisateurs doivent vérifier leur adresse e-mail avant de commenter."
save_changes: "Sauvegarder les changements"
shortcuts: Raccourcis
sign_out: "Se Déconnecter"
stories: Histoires
stream_settings: "Paramètres du fil"
suspect_word_title: "Liste des mots suspects"
suspect_word_text: "Les commentaires contenant ces mots ou expressions (non sensibles à la casse) seront mis en évidence dans le flux de commentaires. Tapez un mot et appuyez sur Entrée ou Tabulation pour ajouter. En option, entrez une liste séparée par des virgules."
tech_settings: "Paramètres techniques"
title: "Configurer le fil de commentaires"
weeks: Semaines
wordlist: "Mots interdits"
continue: Continuer
dashboard:
auto_update: "Les données sont automatiquement mises à jour toutes les cinq minutes ou lorsque vous rechargez."
comment_count: commentaires
flags: signalements
most_flags: "Articles avec le plus de signalements"
most_conversations: "Articles avec le plus de conversations"
next_update: "{0} minutes jusqu'à la prochaine mise à jour."
no_activity: "Il n'y a eu aucun commentaire nulle part dans les cinq dernières minutes."
no_flags: "Il n'y a pas eu aucun signalement au cours des 5 dernières minutes! Hooray !"
no_likes: "Il n'y a pas eu de \"J'aime\" au cours des 5 dernières minutes. Tout est calme."
done: Terminé
edit_comment:
body_input_label: "Modifier ce commentaire"
save_button: "Sauvegarder les changements"
edit_window_expired: "Vous ne pouvez plus modifier ce commentaire. La fenêtre de temps pour le faire a expiré. Pourquoi ne pas en publier un autre ?"
edit_window_expired_close: "Fermer"
edit_window_timer_prefix: "Fenêtre d'édition :"
second: "seconde"
seconds_plural: "secondes"
unexpected_error: "Erreur inattendue lors de l'enregistrement des modifications. Désolé !"
embedlink:
copy: "Copier dans le presse-papier"
error:
COMMENT_TOO_SHORT: "Votre commentaire doit contenir quelque chose"
NOT_AUTHORIZED: "Vous n'êtes pas autorisé à effectuer cette action."
NO_SPECIAL_CHARACTERS: "Les noms d'utilisateur ne peuvent contenir que des lettres, des chiffres et \"_\" seulement"
PASSWORD_LENGTH: "Le mot de passe est trop court"
PROFANITY_ERROR: "Les noms d'utilisateur ne doivent pas contenir de mots offensants. Veuillez contacter l'administrateur si vous pensez qu'il y a une erreur."
USERNAME_IN_USE: "Ce nom d'utilisateur est déjà pris"
USERNAME_REQUIRED: "Doit entrer un nom d'utilisateur"
EDIT_WINDOW_ENDED: "Vous ne pouvez plus modifier ce commentaire. La fenêtre de temps pour le faire a expiré."
EDIT_USERNAME_NOT_AUTHORIZED: "Vous n'avez pas la permission de mettre à jour votre nom d'utilisateur."
EMAIL_IN_USE: "Adresse e-mail déjà utilisée"
EMAIL_REQUIRED: "Une adresse email est requise"
LOGIN_MAXIMUM_EXCEEDED: "Vous avez effectué trop de tentatives infructueuses pour entrer votre mot de passe. S'il vous plaît, attendez."
PASSWORD_REQUIRED: "Doit entrer un mot de passe"
COMMENTING_CLOSED: "Les commentaires sont déjà fermés"
NOT_FOUND: "Ressource introuvable"
INVALID_ASSET_URL: "L'URL est invalide"
email: "Pas une adresse e-mail valide"
confirm_password: "Les mots de passe ne correspondent pas. Vérifiez à nouveau"
network_error: "Échec de connexion au serveur. Vérifiez votre connexion Internet et réessayez."
email_not_verified: "L'adresse e-mail {0} n'est pas vérifiée."
email_password: "Combinaison e-mail / mot de passe incorrecte."
organization_name: "Le nom de l'organisation ne peut contenir que des lettres ou des chiffres."
password: "Le mot de passe doit être d'au moins 8 caractères"
username: "Les noms d'utilisateur ne peuvent contenir que des chiffres, des lettres et \"_\""
flag_comment: "Signaler un commentaire"
flag_reason: "Motif du signalement (facultatif)"
flag_username: "Signaler un nom d'utilisateur"
framework:
banned_account_msg: "Votre compte est actuellement suspendu. Cela signifie que vous ne pouvez pas Aimer Signaler ou Écrire des commentaires. Contactez-nous si vous avez des questions."
comment: commentaire
comment_is_ignored: "Ce commentaire est caché car vous avez ignoré cet utilisateur."
comments: commentaires
configure_stream: "Configure le fil"
content_not_available: "Ce contenu n'est pas disponible"
edit_name:
button: Soumettre
error: "Les noms d'utilisateur ne peuvent contenir que des chiffres, des lettres et \"_\""
label: "Nouveau nom d'utilisateur"
msg: "Votre compte est actuellement suspendu car votre nom d'utilisateur a été jugé inapproprié. Pour restaurer votre compte, entrez un nouveau nom d'utilisateur. Contactez-nous si vous avez des questions."
ignored_users: "Utilisateurs ignorés"
my_comments: "Mes commentaires"
my_profile: "Mon profil"
new_count: "Voir {0} nouveau {1}"
profile: Profil
show_all_comments: "Afficher tous les commentaires"
success_bio_update: "Votre biographie a été mise à jour"
success_name_update: "Votre nom d'utilisateur a été mis à jour"
success_update_settings: "Les modifications que vous avez apportées ont été appliqué es au fil de commentaires sur cet article"
view_all_replies: "Voir {0} réponses"
view_all_replies_initial: "Voir toutes les {0} réponses"
view_more_comments: "Voir plus de commentaires"
view_reply: "Voir la réponse"
from_settings_page: "Dans la page Profil, vous pouvez voir l'historique de vos commentaires."
like: "J'aime"
loading_results: "Chargement des résultats"
marketing: "Cela ressemble à du marketing / une publicité"
moderate_this_stream: "Modifiez ce fil"
modqueue:
account: "Signalements du compte"
actions: Actions
all: tous
all_streams: "Tous les fils"
approve: "Approuver"
approved: "Approuvé"
ban_user: "Bannir"
billion: B
close: Fermer
dont_like_username: "N'aime pas le nom d'utilisateur"
empty_queue: "Plus de commentaires à modérer ! Vous avez terminé, allez prendre un ☕️"
flagged: signalé
impersonating: "problème d'identité"
less_detail: "Moins de détails"
likes: "J'aime"
million: M
mod_faster: "Modérer plus rapidement avec les raccourcis clavier"
moderate: "Modérer →"
more_detail: "Plus de détails"
newest_first: "Le plus récent d'abord"
navigation: Navigation
next_comment: "Aller au prochain commentaire"
offensive: Offensant
oldest_first: "Le plus ancien d'abord"
other: Autre
premod: Pre-modérer
prev_comment: "Aller au commentaire précédent"
reject: "Rejeter"
rejected: "Rejeté"
select_stream: "Sélectionnez le fil"
shift_key:
shortcuts: Raccourcis
show_shortcuts: "Afficher les raccourcis"
singleview: "Passer en mode d'édition de commentaire unique"
spam_ads: Spam / Publicités
thismenu: "Ouvrir ce menu"
thousand: k
try_these: "Essayez ces"
view_more_shortcuts: "Afficher plus de raccourcis"
my_comment_history: "Mon historique de commentaires"
name: Nom
no_agree_comment: "Je ne suis pas d'accord avec ce commentaire"
no_like_bio: "Je n'aime pas cette biographie"
no_like_username: "Je n'aime pas ce nom d'utilisateur"
other: Autre
permalink: Lien
personal_info: "Ce commentaire révèle des informations personnelles identifiables"
post: Publier
profile: Profil
profile_settings: "Paramètres du profil"
reply: Répondre
report: Signaler
report_notif: "Merci de signaler ce commentaire. Notre équipe de modération a é té informée."
report_notif_remove: "Votre signalement a été supprimé."
reported: Signalé
set_best: "Sélectionner comme le meilleur"
settings:
all_comments: "Tous les commentaires"
from_settings_page: "Dans la page Profil, vous pouvez voir l'historique de vos commentaires."
my_comment_history: "Mon historique de commentaires"
profile: Profil
profile_settings: "Paramètres du profil"
sign_in: "Se connecter"
to_access: "Accéder au profil"
user_no_comment: "Vous n'avez jamais laissé de commentaire. Rejoignez la conversation!"
stream:
temporarily_suspended: "Conformément à la charte d'utilisation des commentaires de {0}, votre compte a été temporairement suspendu. Merci de revenir dans la conversation {1}."
step_1_header: "Signaler un problème"
step_2_header: "Aidez-nous à comprendre"
step_3_header: "Merci pour votre participation"
streams:
all: Tous
article: Récit
closed: Fermé
empty_result: "Aucun contenu ne correspond à cette recherche. Peut-être essayer d'élargir votre recherche ?"
filter_streams: "Filtrer les flux"
newest: Le plus récent
oldest: Le plus ancien
open: Ouvrir
pubdate: "Date de publication"
search: Recherche
sort_by: "Trier par"
status: "Statut du fil"
stream_status: "Statut du fil"
suspenduser:
bio: Bio
cancel: "Annuler"
days: "{0} jours"
description_0: "Voulez-vous interdire temporairement cet utilisateur en raison de leur {0} ? Cela dissimulera temporairement leurs commentaires jusqu'à ce qu'ils réécrivent leurs {0}."
description_1: "Suspendre cet utilisateur désactivera temporairement son compte et cachera tous ses commentaires sur le site."
description_notify: "Suspendre cet utilisateur désactivera temporairement son compte et cachera tous ses commentaires sur le site."
description_reject: "Voulez-vous interdire temporairement cet utilisateur en raison de leur {0}? Cela dissimulera temporairement leurs commentaires jusqu'à ce qu'ils réécrivent leurs {0}."
description_suspend: "Vous suspendez {0}. Ce commentaire sera rejeté, et {0} ne sera pas autorisé à aimer, à signaler, à répondre ou à publier jusqu'à ce que la suspension soit levée."
email: "Un autre membre de la communauté a récemment signalé votre nom d'utilisateur pour examen. En raison de son contenu, votre utilisateur a été rejeté. Cela signifie que vous ne pouvez plus commenter ou signaler du contenu jusqu'à ce que vous changiez votre nom d'utilisateur. Veuillez nous envoyer un e-mail si vous avez des questions ou des préoccupations."
email_subject: "Votre compte a été suspendu"
email_message_reject: "Un autre membre de la communauté a récemment signalé votre nom d'utilisateur pour examen. En raison de son contenu, votre nom d'utilisateur a été rejeté. Cela signifie que vous ne pouvez plus commenter, ni signaler du contenu jusqu'à ce que vous changiez votre nom d'utilisateur. Veuillez nous envoyer un e-mail si vous avez des questions ou des préoccupations."
email_message_suspend: "Cher {0},\n\nConformément à la charte des commentaires de {1}, votre compte a été temporairement suspendu. Pendant cette période, vous ne pourrez pas commenter, signaler ou participer à d'autres commentaires. \n\nMerci de revenir dans la conversation {2}."
error_email_message_empty: "Vous devez spécifier un message dans l'e-mail."
hours: "{0} heures"
no_cancel: "Pas d'annulation"
notify_suspend_until: "L'utilisateur {0} a été temporairement suspendu. Cette suspension se terminera automatiquement {1}."
one_hour: "1 heure"
send: Envoyer
select_duration: "Sélectionnez la durée de la suspension"
suspend_user: "Suspendre l'utilisateur"
title: "Suspendre un utilisateur"
title_0: "Nous avons remarqué que vous avez rejeté un nom d'utilisateur"
title_1: "Aviser l'utilisateur de sa suspension temporaire"
title_notify: "Aviser l'utilisateur de sa suspension temporaire"
title_reject: "Nous avons remarqué que vous avez rejeté un nom d'utilisateur"
title_suspend: "Suspendre l'utilisateur"
username: "Nom d'utilisateur"
write_message: "Écrire un message"
yes_suspend: "Oui suspendre"
thank_you: "Nous apprécions vos commentaires. Un modérateur passera en revue votre signalement. Merci pour votre aide."
unset_best: "Déselectionner comme le meilleur"
user:
bio_flags: "Signaler pour cette biographie"
user_bio: "Bio de l'utilisateur"
username_flags: "Signaler pour ce nom d'utilisateur"
user_impersonating: "Cet utilisateur se fait passer pour quelqu'un d'autre"
user_no_comment: "Vous n'avez jamais laissé de commentaire. Rejoignez la conversation !"
username_offensive: "Ce nom d'utilisateur est offensant"
view_conversation: "Afficher la conversation"
install:
initial:
description: "Configurez votre communauté Talk en quelques petites étapes."
submit: "Commencer"
add_organization:
description: "Veuillez nous indiquer le nom de votre organisation. Cela apparaîtra dans les e-mails lors de l'invitation de nouveaux membres dans l'équipe."
label: "Nom de l'organisation"
save: "Sauvegarder"
create:
email: "Adresse e-mail"
username: "Nom d'utilisateur"
password: "Mot de passe"
confirm_password: "Confirmez Le mot de passe"
save: "Sauvegarder"
permitted_domains:
title: "Domaines autorisés"
description: "Entrez les domaines que vous souhaitez autoriser pour Talk, par ex. vos environnements locaux, de déploiement et de production (ex localhost:3000, staging.domain.com, domain.com)."
submit: "Terminer l'installation"
final:
description: "Merci d'avoir installé Talk ! Nous avons envoyé un e-mail pour vérifier votre adresse électronique. Pendant que vous terminez la configuration, vous pouvez commencer à engager vos lecteurs dès maintenant."
launch: "Lancer Talk"
close: "Fermez cet installateur"
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "2.1.0",
"version": "2.3.0",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"scripts": {
@@ -57,6 +57,7 @@
"dependencies": {
"accepts": "^1.3.3",
"app-module-path": "^2.2.0",
"async": "^2.5.0",
"bcrypt": "^1.0.2",
"body-parser": "^1.17.1",
"bowser": "^1.7.0",
@@ -109,6 +110,7 @@
"passport-local": "^1.0.0",
"prop-types": "^15.5.10",
"react-apollo": "^1.1.0",
"react-input-autosize": "^1.1.4",
"react-recaptcha": "^2.2.6",
"react-toastify": "^1.5.0",
"react-transition-group": "^1.1.3",
@@ -117,9 +119,11 @@
"resolve": "^1.3.2",
"semver": "^5.3.0",
"simplemde": "^1.11.2",
"smoothscroll-polyfill": "^0.3.5",
"snake-case": "^2.1.0",
"subscriptions-transport-ws": "^0.7.2",
"timekeeper": "^1.0.0",
"url-search-params": "^0.9.0",
"uuid": "^3.0.1",
"yaml-loader": "^0.4.0",
"yamljs": "^0.2.10"
@@ -195,7 +199,7 @@
"react-mdl-selectfield": "^0.2.0",
"react-redux": "^4.4.5",
"react-router": "^3.0.0",
"react-tagsinput": "^3.14.0",
"react-tagsinput": "^3.17.0",
"redux": "^3.6.0",
"redux-mock-store": "^1.2.1",
"redux-thunk": "^2.1.0",
+4
View File
@@ -26,6 +26,7 @@ module.exports = {
SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS',
LIST_OWN_TOKENS: 'LIST_OWN_TOKENS',
SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY',
VIEW_SUSPENSION_INFO: 'VIEW_SUSPENSION_INFO',
// subscriptions
SUBSCRIBE_COMMENT_ACCEPTED: 'SUBSCRIBE_COMMENT_ACCEPTED',
@@ -33,4 +34,7 @@ module.exports = {
SUBSCRIBE_COMMENT_FLAGGED: 'SUBSCRIBE_COMMENT_FLAGGED',
SUBSCRIBE_ALL_COMMENT_ADDED: 'SUBSCRIBE_ALL_COMMENT_ADDED',
SUBSCRIBE_ALL_COMMENT_EDITED: 'SUBSCRIBE_ALL_COMMENT_EDITED',
SUBSCRIBE_ALL_USER_SUSPENDED: 'SUBSCRIBE_ALL_USER_SUSPENDED',
SUBSCRIBE_ALL_USER_BANNED: 'SUBSCRIBE_ALL_USER_BANNED',
SUBSCRIBE_ALL_USERNAME_REJECTED: 'SUBSCRIBE_ALL_USERNAME_REJECTED',
};
+2
View File
@@ -19,6 +19,8 @@ module.exports = (user, perm) => {
return check(user, ['ADMIN']);
case types.SEARCH_COMMENT_STATUS_HISTORY:
return check(user, ['ADMIN', 'MODERATOR']);
case types.VIEW_SUSPENSION_INFO:
return check(user, ['ADMIN', 'MODERATOR']);
default:
break;
}
+6
View File
@@ -13,6 +13,12 @@ module.exports = (user, perm) => {
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_COMMENT_ADDED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_USER_SUSPENDED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_USER_BANNED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_USERNAME_REJECTED:
return check(user, ['ADMIN', 'MODERATOR']);
default:
break;
}
@@ -1 +1,2 @@
export {Slot} from 'coral-framework/components';
export {default as ClickOutside} from 'coral-framework/components/ClickOutside';
+3 -1
View File
@@ -8,6 +8,7 @@ import {compose, gql} from 'react-apollo';
import withFragments from 'coral-framework/hocs/withFragments';
import withMutation from 'coral-framework/hocs/withMutation';
import {showSignInDialog} from 'coral-framework/actions/auth';
import {addNotification} from 'coral-framework/actions/notification';
import {capitalize} from 'coral-framework/helpers/strings';
import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils';
import * as PropTypes from 'prop-types';
@@ -248,6 +249,7 @@ export default (reaction) => (WrappedComponent) => {
return <WrappedComponent
showSignInDialog={this.props.showSignInDialog}
addNotification={this.props.addNotification}
user={this.props.user}
comment={comment}
reactionSummary={reactionSummary}
@@ -350,7 +352,7 @@ export default (reaction) => (WrappedComponent) => {
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({showSignInDialog}, dispatch);
bindActionCreators({showSignInDialog, addNotification}, dispatch);
const enhance = compose(
withFragments({
+1
View File
@@ -1,2 +1,3 @@
export {t} from 'coral-framework/services/i18n';
export {can} from 'coral-framework/services/perms';
export {isSlotEmpty} from 'coral-framework/helpers/plugins';
+2 -1
View File
@@ -12,6 +12,7 @@
"coral-plugin-auth",
"coral-plugin-offtopic",
"coral-plugin-viewing-options",
"coral-plugin-comment-content"
"coral-plugin-comment-content",
"talk-plugin-permalink"
]
}
@@ -89,3 +89,47 @@ es:
username: Nombre
write_your_username: "Edita tu nombre"
your_username: "Tu nombre aparece en cada comentario que publiques."
fr:
sign_in:
email_verify_cta: "Merci de vérifier votre adresse e-mail."
request_new_verify_email: "Demander un nouvel envoi d'e-mail :"
verify_email: "Merci d'avoir créé un compte ! Nous avons envoyé un courrier électronique à l'adresse que vous avez fournie pour vérifier votre adresse e-mail."
verify_email2: "Vous devez vérifier votre adresse e-mail avant de vous engager auprès de la communauté."
not_you: "Pas vous ?"
logged_in_as: "Connecté en tant que"
facebook_sign_in: "Connectez-vous avec Facebook"
facebook_sign_up: "Inscrivez-vous avec Facebook"
logout: "Se déconnecter"
sign_in: "Se connecter"
sign_in_to_join: "Connectez-vous pour participer à la conversation"
or: "Ou"
email: "Adresse e-mail"
password: "Mot de passe"
forgot_your_pass: "Mot de passe oublié ?"
need_an_account: "Besoin d'un compte ?"
register: "Inscription"
sign_up: "S'inscrire"
confirm_password: "Confirmez Le mot de passe"
username: "Nom d'utilisateur"
already_have_an_account: "Vous avez déjà un compte ?"
recover_password: "Récupérer le mot de passe"
email_in_use: "L'adresse e-mail est déjà utilisée"
email_or_username_in_use: "Adresse e-mail ou nom d'utilisateur déjà utilisé"
required_field: "Ce champ est obligatoire"
passwords_dont_match: "Les mots de passe ne correspondent pas."
special_characters: "Les noms d'utilisateur ne peuvent contenir que des lettres, des chiffres et \"_\" seulement"
sign_in_to_comment: "Connectez-vous pour commenter"
check_the_form: "Formulaire non valide. Veuillez vérifier les champs"
createdisplay:
check_the_form: "Formulaire non valide. Veuillez vérifier les champs"
continue: "Continuez avec le même nom d'utilisateur que sur Facebook"
error_create: "Erreur lors de la modification du nom d'utilisateur"
fake_comment_body: "Ceci est un exemple de commentaire. Les lecteurs peuvent partager leurs pensées et opinions avec les rédactions dans la section des commentaires."
fake_comment_date: "Il y a 1 minute"
if_you_dont_change_your_name: "Si vous ne modifiez pas votre nom d'utilisateur à cette étape, votre nom d'affichage Facebook apparaîtra à côté de tous vos commentaires."
required_field: "champs requis"
save: Sauvegarder
special_characters: "Les noms d'utilisateur ne peuvent contenir que des chiffres, des lettres et \"_\""
username: "Nom d'utilisateur"
write_your_username: "Modifier votre nom d'utilisateur"
your_username: "Votre nom d'utilisateur apparaît sur chaque commentaire que vous publiez."
@@ -13,6 +13,7 @@ class LikeButton extends React.Component {
postReaction,
deleteReaction,
showSignInDialog,
addNotification,
alreadyReacted,
user,
} = this.props;
@@ -25,6 +26,7 @@ class LikeButton extends React.Component {
// If the current user is suspended, do nothing.
if (!can(user, 'INTERACT_WITH_COMMUNITY')) {
addNotification('error', t('error.NOT_AUTHORIZED'));
return;
}
@@ -13,6 +13,7 @@ class LoveButton extends React.Component {
postReaction,
deleteReaction,
showSignInDialog,
addNotification,
alreadyReacted,
user,
} = this.props;
@@ -25,6 +26,7 @@ class LoveButton extends React.Component {
// If the current user is suspended, do nothing.
if (!can(user, 'INTERACT_WITH_COMMUNITY')) {
addNotification('error', t('error.NOT_AUTHORIZED'));
return;
}
@@ -14,6 +14,7 @@ class RespectButton extends React.Component {
deleteReaction,
showSignInDialog,
alreadyReacted,
addNotification,
user,
} = this.props;
@@ -25,6 +26,7 @@ class RespectButton extends React.Component {
// If the current user is suspended, do nothing.
if (!can(user, 'INTERACT_WITH_COMMUNITY')) {
addNotification('error', t('error.NOT_AUTHORIZED'));
return;
}
@@ -0,0 +1,14 @@
{
"presets": [
"es2015"
],
"plugins": [
"add-module-exports",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
"transform-object-rest-spread",
"transform-async-to-generator",
"transform-react-jsx"
]
}
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}

Some files were not shown because too many files have changed in this diff Show More