Compare commits

...
212 Commits
Author SHA1 Message Date
Kim Gardner e2a9a42ec6 Merge pull request #708 from coralproject/bugs-fix
BUG: Wrong label in Admin Configure
2017-06-23 18:37:46 +01:00
Belén Curcio b65c07a8a2 Merge branch 'master' into bugs-fix 2017-06-23 14:32:54 -03:00
Belen Curcio 5984f57682 Close after, wrong label 2017-06-23 14:29:22 -03:00
Kiwi 1c844061f4 Merge pull request #705 from Goyapa/issue-676-set-domain-whitelist
Issue-676 add addOnBlur preventing loss of domain-whitelist settings
2017-06-24 00:29:19 +07:00
Kiwi bc878051f8 Merge branch 'master' into issue-676-set-domain-whitelist 2017-06-24 00:22:34 +07:00
Kiwi b805451d37 Merge pull request #700 from coralproject/refactor-ban-suspend-actions
Refactor and reuse "Actions" in "Moderate" and "Community"
2017-06-23 22:43:38 +07:00
Chi Vinh Le 4a5424b81d Translation Querido/a 2017-06-23 22:38:30 +07:00
Kiwi 18a767b8a4 Merge branch 'master' into refactor-ban-suspend-actions 2017-06-23 22:24:01 +07:00
Kim Gardner 32b6627144 Merge pull request #696 from coralproject/bugs-fix
Bug Fixes
2017-06-23 16:22:06 +01:00
Kim Gardner e1b3eb9ff7 Merge branch 'master' into issue-676-set-domain-whitelist 2017-06-23 16:19:46 +01:00
Kim Gardner b76578b866 Merge branch 'master' into refactor-ban-suspend-actions 2017-06-23 16:17:26 +01:00
Kim Gardner c09d1524a9 Merge branch 'master' into bugs-fix 2017-06-23 16:17:09 +01:00
Kim Gardner 548b56be33 Merge pull request #707 from coralproject/redirect-to-all
If not specified redirect to 'all' tab
2017-06-23 16:16:57 +01:00
Belen Curcio ea030af440 missing keys 2017-06-23 09:59:05 -03:00
Belen Curcio af59a2e836 Merge branch 'bugs-fix' of github.com:coralproject/talk into bugs-fix 2017-06-23 09:56:43 -03:00
Belen Curcio 2ab2b41b55 unbind 2017-06-23 09:54:21 -03:00
Belén Curcio 4bb64231e9 Merge branch 'master' into issue-676-set-domain-whitelist 2017-06-23 09:50:29 -03:00
Belén Curcio 2800458519 Merge branch 'master' into bugs-fix 2017-06-23 09:49:54 -03:00
Kiwi 90bc9be1b2 Merge branch 'master' into redirect-to-all 2017-06-23 18:29:52 +07:00
Kiwi 64a4bf6760 Merge pull request #703 from ALawliet/fix-moderate-link
Fix Moderate → link by setting to "All" for that story
2017-06-23 18:29:38 +07:00
Kiwi 6d5b8e73d4 Merge branch 'master' into redirect-to-all 2017-06-23 18:18:18 +07:00
Kiwi 87c14dd824 Merge branch 'master' into fix-moderate-link 2017-06-23 18:15:36 +07:00
Chi Vinh Le e89a15f2d2 If not specified redirect to 'all' tab 2017-06-23 18:11:54 +07:00
Kim Gardner 7d84cff0f7 Merge pull request #698 from ALawliet/master
Search dropdown: clear search when closed
2017-06-23 12:10:10 +01:00
Kim Gardner 6fa74f84f1 Merge branch 'master' into fix-moderate-link 2017-06-23 09:53:35 +01:00
Kim Gardner 10ed5c9a4a Merge branch 'master' into master 2017-06-23 09:53:23 +01:00
Michael Macherey 40b4c0cab4 Issue-676 add addOnBlur preventing loss of domain-whitelist settings
Whithout addOnBlur set true, typed domain-whitelist settings like localhost:3000 get lost and will not be set in configure->Tech Settings->Permitted Domains.
This address issue-676.
2017-06-23 00:35:49 +02:00
Belén Curcio bb544a92b3 Merge pull request #704 from coralproject/keep-alive
Added support for keep alive websocket frames
2017-06-22 17:45:02 -03:00
Wyatt Johnson 010f0cb0d7 Added support for keep alive websocket frames 2017-06-22 14:32:49 -06:00
A Lawliet 60dd6a37d7 Fix Moderate → link by setting to "All" for that story 2017-06-22 13:44:48 -04:00
Chi Vinh Le 455a034802 Don't show dropdown on current logged in user 2017-06-22 22:13:09 +07:00
Chi Vinh Le 46e707ba51 Use snake case for new translations 2017-06-22 22:04:11 +07:00
Kiwi e7e66f1e6f Merge branch 'master' into refactor-ban-suspend-actions 2017-06-22 21:57:30 +07:00
Chi Vinh Le 8bb38c5ee5 Refactor and reuse "Actions" in "Moderate" and "Community" 2017-06-22 21:50:19 +07:00
A Lawliet c41b3c27ba Prevent unnecessary render - no need to clear search if already empty, just close dropdown 2017-06-22 10:29:41 -04:00
Kim Gardner cfd55907bd Merge branch 'master' into bugs-fix 2017-06-22 14:34:04 +01:00
Kim Gardner 91907e7647 Merge branch 'master' into master 2017-06-22 14:28:01 +01:00
Kim Gardner a5048eadb4 Merge pull request #699 from coralproject/update-circle-config
Limit circle deploys off only our master branch
2017-06-22 14:27:42 +01:00
Kim Gardner bde08ea256 Merge branch 'master' into update-circle-config 2017-06-22 14:07:53 +01:00
Kim Gardner 3de6ba1b24 Limit circle deploys off only our master branch 2017-06-22 14:05:50 +01:00
Kim Gardner dac9401c44 Merge branch 'master' into bugs-fix 2017-06-22 12:30:49 +01:00
A Lawliet bba4474858 Search dropdown: clear search when closed 2017-06-22 01:51:06 -04:00
Kiwi 259f98a4ec Merge pull request #697 from coralproject/bug-sweep
Bug sweep
2017-06-22 01:05:11 +07:00
Chi Vinh Le f7a8f9bbd1 Oops this comment needs a rewrite 2017-06-22 00:59:04 +07:00
Kiwi 83096dbcce Merge branch 'master' into bug-sweep 2017-06-22 00:52:30 +07:00
Kiwi 555a71fd9b Merge pull request #695 from coralproject/bug-killing-spree
Bug killing spree
2017-06-22 00:52:00 +07:00
Kiwi 2c57ec3c7c Merge branch 'master' into bug-killing-spree 2017-06-22 00:47:27 +07:00
Chi Vinh Le c0e9e4272b Merge branch 'master' into bug-killing-spree
Conflicts:
	client/coral-admin/src/routes/Moderation/components/UserDetail.js
2017-06-22 00:46:17 +07:00
Belén Curcio a31810ddf4 Merge branch 'master' into bug-sweep 2017-06-21 14:43:32 -03:00
Belén Curcio f6b2a25e72 Merge pull request #687 from coralproject/pat
Tokens
2017-06-21 14:43:05 -03:00
Wyatt Johnson bd7443672a Merge branch 'master' into pat 2017-06-21 11:36:47 -06:00
Belén Curcio 358ea0d37c Merge branch 'master' into bug-sweep 2017-06-21 14:35:50 -03:00
Kiwi c0dcfe5f4e Merge pull request #694 from coralproject/copy
withCopyToClipboard & ButtonCopyToClipboard
2017-06-22 00:35:25 +07:00
Wyatt Johnson 91b7cd10f8 fixing all the 👻 2017-06-21 11:35:00 -06:00
Wyatt Johnson f537b4a772 Merge branch 'master' into pat 2017-06-21 11:34:18 -06:00
Belen Curcio a9cefd8cbd deleting old ref 2017-06-21 14:23:51 -03:00
Belen Curcio f08c2f4152 Merge branch 'copy' of github.com:coralproject/talk into copy 2017-06-21 13:31:53 -03:00
Belen Curcio cdcd543fe0 good looking export 2017-06-21 13:30:09 -03:00
Belen Curcio 66aec74b38 good looking export 2017-06-21 13:29:44 -03:00
Belen Curcio 85dcc8d0ec Merge branch 'master' of github.com:coralproject/talk into copy 2017-06-21 13:28:33 -03:00
Chi Vinh Le 9d572f6c3b Add proptype 2017-06-21 22:27:13 +07:00
Chi Vinh Le 012ff926eb Use router for 'moderate all' link and only show when necessary 2017-06-21 22:21:42 +07:00
Kiwi 91fd8ceabd Merge branch 'master' into copy 2017-06-21 21:52:51 +07:00
Kiwi 4a354f0b9a Merge branch 'master' into bug-sweep 2017-06-21 21:48:35 +07:00
Chi Vinh Le 3beb62f837 Merge branch 'master' into bug-killing-spree
Conflicts:
	client/coral-admin/src/routes/Moderation/components/UserDetail.js
2017-06-21 21:47:01 +07:00
Chi Vinh Le 57ecba6f33 Remove unessecary binds 2017-06-21 21:36:27 +07:00
Kim Gardner 712a53db24 Merge pull request #693 from coralproject/show-version-in-nav
Show Talk version number in nav
2017-06-21 15:35:52 +01:00
Chi Vinh Le f709057933 Require username to be different when editing it 2017-06-21 21:18:28 +07:00
Belen Curcio ac89ddc43f Show user details from flag history 2017-06-21 11:04:45 -03:00
Belen Curcio b0156fa11f missing header css 2017-06-21 10:53:12 -03:00
Belen Curcio 16eb93792b Merge branch 'master' of github.com:coralproject/talk into bugs-fix 2017-06-21 10:43:28 -03:00
Belen Curcio 1b1f2a3608 Changing moderation keys: d approve and f reject 2017-06-21 10:42:01 -03:00
Chi Vinh Le 6628e6d08d Correctly handle login with mixed case email 2017-06-21 20:34:33 +07:00
Kim Gardner e2f94aa141 Merge branch 'master' into show-version-in-nav 2017-06-21 14:29:49 +01:00
Kim Gardner f20b9819aa Merge branch 'master' into copy 2017-06-21 14:29:36 +01:00
Chi Vinh Le 91fa7b2d8d Show correct login error messages in admin 2017-06-21 20:24:54 +07:00
Kim Gardner 74fff2f1ac Add all stories link to story search dropdown 2017-06-21 14:14:33 +01:00
Chi Vinh Le 4b063607ab Return the returns 2017-06-21 20:04:38 +07:00
Chi Vinh Le 9b26bc9d96 Fix Typo 2017-06-21 19:53:00 +07:00
Chi Vinh Le 38eaff5397 Don't render title when it's null 2017-06-21 19:41:22 +07:00
Chi Vinh Le 420513804c Show correct notification message when edit fails 2017-06-21 19:37:19 +07:00
Chi Vinh Le b0079c191b Remove non existant 'truthy' tests 2017-06-21 19:31:55 +07:00
Chi Vinh Le 9815248b8d Prevent errors being swallowed 2017-06-21 19:17:48 +07:00
Kim Gardner 79346738d7 Have Moderate this Stream link also default to All 2017-06-21 13:01:23 +01:00
Kim Gardner e16553419d Merge pull request #686 from coralproject/subscribe-mod-actions
Mod Queue Live Updates
2017-06-21 12:24:32 +01:00
Kim Gardner dccf182591 Merge branch 'master' into copy 2017-06-21 12:23:50 +01:00
Kim Gardner 33057e197f Merge branch 'master' into show-version-in-nav 2017-06-21 12:23:20 +01:00
Kim Gardner 605e759bc6 Merge branch 'master' into pat 2017-06-21 12:22:28 +01:00
Chi Vinh Le 706a12d2e2 No MoreMore 2017-06-21 02:48:59 +07:00
Kiwi adeca927a6 Merge branch 'master' into bug-killing-spree 2017-06-21 01:37:46 +07:00
Chi Vinh Le fc14e9d2fd Prevent double execution of fetchMore 2017-06-21 01:29:10 +07:00
Chi Vinh Le 49420e152e Click outside closes TopRightMenu, detect click outside iframe 2017-06-21 01:29:10 +07:00
Chi Vinh Le 644c291d7f Don't cut off long comment 2017-06-21 01:29:07 +07:00
Kiwi 40d7500a75 Merge branch 'master' into subscribe-mod-actions 2017-06-20 21:12:09 +07:00
Kim Gardner 3d27c4538a Merge pull request #691 from coralproject/bump-version-2-1-0
Bump version 2.1.0
2017-06-20 14:51:36 +01:00
Belen Curcio 942ea7b916 ButtonCopyToClipboard 2017-06-20 10:37:10 -03:00
Chi Vinh Le 4d53a72767 Always default to all queue 2017-06-20 20:00:00 +07:00
Chi Vinh Le 23fcba4ccc Don't show caret when logged out 2017-06-20 19:55:53 +07:00
Belén Curcio bee4b4454c Merge branch 'master' into show-version-in-nav 2017-06-20 09:52:50 -03:00
Belén Curcio 679c6ce335 Merge branch 'master' into bump-version-2-1-0 2017-06-20 09:52:26 -03:00
Belén Curcio 71d2d0ab10 Merge pull request #692 from coralproject/update-readme
Update README
2017-06-20 09:52:14 -03:00
Belen Curcio d97fb0a83f Refs by ReactDOMNode 2017-06-20 09:47:37 -03:00
Chi Vinh Le 14311dc298 Merge branch 'master' into subscribe-mod-actions
Conflicts:
	client/coral-admin/src/routes/Moderation/components/Comment.js
2017-06-20 19:34:56 +07:00
Belen Curcio 7faa00c463 withCopyToClipboard 2017-06-20 09:05:01 -03:00
Belen Curcio d23d426567 Adding the translations 2017-06-20 08:37:53 -03:00
Belen Curcio fe8f3ae4bb Moving Copy component 2017-06-20 08:32:08 -03:00
Kim Gardner 646b5be8c8 Show version in nav 2017-06-20 10:48:01 +01:00
Kim Gardner ea12c07cd4 Update README 2017-06-20 10:29:39 +01:00
Kim Gardner bb4cd91b0c Merge branch 'master' into pat 2017-06-20 10:12:47 +01:00
Kim Gardner dca759a5e0 Bump version 2.1.0 2017-06-20 10:02:10 +01:00
Kim Gardner 018b35e42a Merge pull request #688 from coralproject/fixes
Bug-Fixes
2017-06-20 09:59:00 +01:00
Kim Gardner 672e36c66c Merge branch 'master' into fixes 2017-06-20 09:54:38 +01:00
Kim Gardner b3db231f09 Merge pull request #689 from coralproject/keyboard-shortcuts
Keyboard shortcuts
2017-06-20 09:53:49 +01:00
Kim Gardner 38a53d61f3 Merge branch 'master' into fixes 2017-06-20 09:53:24 +01:00
Kim Gardner fc15bcae4e Merge branch 'master' into keyboard-shortcuts 2017-06-20 09:46:04 +01:00
Kim Gardner 6959699ecc Merge pull request #690 from coralproject/admin-links
Settings Cog Links
2017-06-20 09:45:44 +01:00
Belen Curcio 99bb4b7198 New Links 2017-06-19 18:09:38 -03:00
Belen Curcio 12a5654fb8 Keyboard shorcuts working 2017-06-19 17:48:20 -03:00
Belen Curcio dad6534011 Fix close modal 2017-06-19 17:26:57 -03:00
Belen Curcio c36a0bc9e9 d approve and f reject 2017-06-19 17:23:57 -03:00
Belen Curcio 30a385e03b Linting 2017-06-19 17:09:49 -03:00
Wyatt Johnson 174088fc62 Merge branch 'master' into pat 2017-06-19 13:51:35 -06:00
Belen Curcio 16ef833fd8 Ádding Copy component, and UserDetail fic 2017-06-19 16:17:50 -03:00
Chi Vinh Le 523f23269b Translate, pep up notifications 2017-06-20 01:38:38 +07:00
Kiwi 8dbc17c8d9 Merge branch 'master' into subscribe-mod-actions 2017-06-20 01:12:54 +07:00
Chi Vinh Le b8967dcdb4 More accurate loading detection 2017-06-20 01:09:37 +07:00
Chi Vinh Le 70276c3358 Fix subscription issues 2017-06-20 01:01:39 +07:00
Belen Curcio 1f2eca300a Report vbox 2017-06-19 14:55:58 -03:00
Belen Curcio 3c99b8e7fb Sign In Button Position 2017-06-19 14:35:26 -03:00
Belen Curcio 073fccebd3 Show likes 2017-06-19 14:30:56 -03:00
Belén Curcio 13e311de7f Merge pull request #684 from coralproject/action-focus-states
Action focus states
2017-06-19 14:09:22 -03:00
Kim Gardner ba81399707 Merge branch 'master' into pat 2017-06-19 18:03:23 +01:00
Belen Curcio cf93727f73 Merge branch 'master' of github.com:coralproject/talk into action-focus-states 2017-06-19 14:03:07 -03:00
Chi Vinh Le fbbd0fa027 Merge branch 'master' into subscribe-mod-actions
Conflicts:
	client/coral-admin/src/routes/Moderation/components/Moderation.js
	client/coral-admin/src/routes/Moderation/components/styles.css
	client/coral-admin/src/routes/Moderation/containers/Moderation.js
2017-06-19 23:52:10 +07:00
Kim Gardner 332679a7bc Merge pull request #680 from coralproject/search-stories
Search stories
2017-06-19 17:44:16 +01:00
Wyatt Johnson 2a2ca86263 Merge branch 'master' into pat 2017-06-19 09:40:25 -06:00
Wyatt Johnson 393ad8530a added tests, fixed some services 2017-06-19 08:55:08 -06:00
Chi Vinh Le 0cd3921f44 Simplification 2017-06-19 21:29:33 +07:00
Chi Vinh Le f954d2be90 Better loading detection 2017-06-19 21:23:30 +07:00
Chi Vinh Le 2a28306ab2 Merge branch 'master' into subscribe-mod-actions 2017-06-19 21:17:33 +07:00
Chi Vinh Le e1bc1476ea Remove the need for querying all assets 2017-06-19 21:08:12 +07:00
Chi Vinh Le 853c1e407e Simplify args 2017-06-19 21:08:12 +07:00
Belen Curcio 77fddba841 removing overflow hidden 2017-06-19 11:05:16 -03:00
David Erwin 17d36c69d0 Merge branch 'master' into action-focus-states 2017-06-19 09:23:40 -04:00
Belén Curcio 1cf6161b06 Merge branch 'master' into search-stories 2017-06-18 20:14:35 -03:00
Andrew Losowsky 9ef7544788 Merge pull request #685 from coralproject/bump-version-2-0-1
Update version number
2017-06-16 17:41:51 -04:00
Kim Gardner dfccf962d4 Update version number 2017-06-16 17:30:09 -04:00
IAmSamHankins 6c5d94ec3b adjustments to letter spacing, and container positioning. 2017-06-16 16:40:17 -04:00
IAmSamHankins e7eb300e12 search input box styling 2017-06-16 15:47:45 -04:00
IAmSamHankins 614f31d0da styling of search results bar 2017-06-16 15:39:24 -04:00
Belen Curcio 61591a9716 Removing extra log 2017-06-16 16:24:00 -03:00
Belen Curcio 367c570747 ModQueue query 2017-06-16 16:21:39 -03:00
Belen Curcio 991bdc6089 Updated BE 2017-06-16 16:18:02 -03:00
Samantha Hankins 4ce38d429e Merge branch 'master' into action-focus-states 2017-06-16 15:06:11 -04:00
IAmSamHankins dcee2a4438 adjust comment stream sizing to allow for chrome focus state 2017-06-16 15:00:42 -04:00
Belen Curcio 4403decbe9 Handling dropdown on Esc <3 2017-06-16 15:50:58 -03:00
Belen Curcio e30d5a9c62 Sizing 2017-06-16 15:41:32 -03:00
Belen Curcio fe94a5a6d1 Limit 10 2017-06-16 15:08:10 -03:00
Belen Curcio c7b58957cb Merge branch 'search-stories' of github.com:coralproject/talk into search-stories 2017-06-16 15:07:47 -03:00
Belen Curcio 89cf13b4cf Updates 2017-06-16 15:05:22 -03:00
Belén Curcio f0b6679141 Merge branch 'master' into search-stories 2017-06-16 14:50:05 -03:00
Kiwi 26253255ce Merge pull request #658 from alleyinteractive/TCP-7
Pass onAuthChange callback into embed
2017-06-17 00:33:44 +07:00
Max Eddy 64174b97af Cleanup 2017-06-16 13:04:27 -04:00
Max Eddy fe0c581ea9 Note logout action as well for onAuthChanged event. 2017-06-16 13:04:27 -04:00
Max Eddy 4982d8a9ad Use REST API result for check login message. 2017-06-16 13:04:27 -04:00
Max Eddy fd9b14a560 cb option for auth change event 2017-06-16 13:04:27 -04:00
Max Eddy 6139828844 Send post message with auth state to parent window. 2017-06-16 13:04:27 -04:00
Chi Vinh Le c14c3fbe23 Streamline perm names 2017-06-17 00:03:33 +07:00
Kim Gardner f34d546a37 Merge branch 'master' into search-stories 2017-06-16 12:58:12 -04:00
Chi Vinh Le 6da0028258 Reset websocket after login change 2017-06-16 23:00:40 +07:00
Chi Vinh Le 74fbadcc0f Refactor subscriptions 2017-06-16 22:55:22 +07:00
Kim Gardner 30ca7b3d64 Merge pull request #683 from coralproject/cli-fix
User CLI Fix
2017-06-16 09:49:53 -04:00
Chi Vinh Le 9573c9a7af Refactor & docs 2017-06-16 20:49:17 +07:00
Wyatt Johnson 1dec8bfaab small fix 2017-06-16 09:45:26 -04:00
Chi Vinh Le 2625b372e1 Handle comment flags 2017-06-16 20:06:13 +07:00
Chi Vinh Le 5f450adbaf Simplify subscription 2017-06-16 19:19:59 +07:00
Belen Curcio d386316f8a Styles, copy, funtionality, state changes and clicks. Ready to be tested. 2017-06-16 08:19:22 -03:00
Belen Curcio 5604aa8525 Overlay and Container 2017-06-16 07:33:22 -03:00
Belen Curcio b518358ea8 Lintin 2017-06-15 22:51:15 -03:00
Belen Curcio fe74f4a8d2 Search on enter, removed unused links 2017-06-15 20:24:22 -03:00
Wyatt Johnson b78d4f29ea Added PAT support 2017-06-15 18:12:53 -04:00
Belen Curcio ca08efea8a Adding proptypes as a package, and linting 2017-06-15 18:49:05 -03:00
Belen Curcio 5718ba6748 Merge branch 'search-stories' of github.com:coralproject/talk into search-stories 2017-06-15 17:42:43 -03:00
Belen Curcio 0123736275 Deleting unnecesary connects 2017-06-15 17:42:22 -03:00
Belen Curcio 18061e3e1c new func and style 2017-06-15 17:32:14 -03:00
Kim Gardner b40df9f10c Merge branch 'master' into search-stories 2017-06-15 15:18:57 -04:00
Andrew Losowsky 0c7f9cdb1e Merge pull request #682 from coralproject/fix-config-stream-typos
Fix typos
2017-06-15 14:50:57 -04:00
Chi Vinh Le ecf63ffb55 More accurate edit notification 2017-06-16 01:49:35 +07:00
Kim Gardner ba659b47be Fix typos 2017-06-15 14:44:06 -04:00
Chi Vinh Le f73f8d916e Only show notification once 2017-06-16 00:50:44 +07:00
Chi Vinh Le 93c8b115f5 Fix some queries 2017-06-16 00:43:48 +07:00
Belen Curcio 685695bf43 Clear search value 2017-06-15 14:32:50 -03:00
Belen Curcio d7c6d7dfac Merge branch 'search-stories' of github.com:coralproject/talk into search-stories 2017-06-15 14:32:27 -03:00
Belén Curcio c7cfd80c7f Merge branch 'master' into search-stories 2017-06-15 14:15:34 -03:00
Belen Curcio 928e19ed67 Merge branch 'master' of github.com:coralproject/talk into search-stories 2017-06-15 14:14:13 -03:00
Chi Vinh Le 5a2ce0f6a7 No div in p 2017-06-15 23:31:30 +07:00
Chi Vinh Le 312103098c More mod queue fixes 2017-06-15 23:27:31 +07:00
Chi Vinh Le 21f95c33c2 Use correct comment id in UserDetail 2017-06-15 22:54:36 +07:00
Chi Vinh Le cbcb84f7ac No need for editableUntil 2017-06-15 22:54:29 +07:00
Chi Vinh Le 303b3ea0f5 Support live comment edit update 2017-06-15 22:46:38 +07:00
Chi Vinh Le 5ecdfe94cc Refactor state updater 2017-06-15 18:28:08 +07:00
Belén Curcio 648b46e5af Merge branch 'master' into search-stories 2017-06-14 18:44:36 -03:00
Belen Curcio e160dfaa3e Style and functionality 2017-06-14 18:40:44 -03:00
Chi Vinh Le 3658a804b0 Implement live updates for mod actions 2017-06-15 01:08:25 +07:00
Belen Curcio c902a4b05b Merge branch 'search-stories' of github.com:coralproject/talk into search-stories 2017-06-12 16:31:14 -03:00
Belen Curcio 8a3833ec11 Merge issues 2017-06-12 16:30:59 -03:00
Belén Curcio d45c396013 Merge branch 'master' into search-stories 2017-06-12 16:27:39 -03:00
riley c7d5ab935f merge master 2017-06-09 15:27:44 -06:00
Riley Davis aa289ecbfe less terrible looking 2017-06-09 14:32:12 -06:00
Riley Davis de665b3a0d show stories in dropdown 2017-06-09 14:10:21 -06:00
Riley Davis 06da4d7d26 toggle story search 2017-06-07 16:36:27 -06:00
Riley Davis 9818b851db clean up the ModerationHeader component 2017-06-07 15:40:36 -06:00
142 changed files with 3081 additions and 1383 deletions
+2 -1
View File
@@ -53,7 +53,8 @@
"no-lonely-if": [2],
"curly": [2],
"no-unused-vars": ["error", {
"argsIgnorePattern": "next"
"argsIgnorePattern": "^_|next",
"varsIgnorePattern": "^_"
}],
"no-multiple-empty-lines": ["error", {
"max": 1
+12 -1
View File
@@ -7,7 +7,17 @@ endpoint when the server is running with built assets.
## Contributing to Talk
See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md)!
See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md).
## Documentation
### General
See our [Talk Documentation & Guides](https://coralproject.github.io/talk/index.html).
### Plugins
See our guide to using and building [Talk Plugins](https://github.com/coralproject/talk/blob/master/PLUGINS.md).
## Usage
@@ -40,6 +50,7 @@ sign and verify tokens via a `HS256` algorithm.
- `TALK_RECAPTCHA_SECRET` (*required for reCAPTCHA support*) - server secret used for enabling reCAPTCHA powered logins. If not provided it will instead default to providing only a time based lockout.
- `TALK_RECAPTCHA_PUBLIC` (*required for reCAPTCHA support*) - client secret used for enabling reCAPTCHA powered logins. If not provided it will instead default to providing only a time based lockout.
- `TALK_PLUGINS_JSON` (_optional_) - used to specify the plugin config via the environment
- `TALK_KEEP_ALIVE` (_optional_) - The keepalive timeout that should be used to send keep alive messages through the websocket to keep the socket alive. (Default `30s`)
Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for
alternative methods of loading configuration during development.
+1
View File
@@ -12,6 +12,7 @@ program
.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')
Executable
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const program = require('./commander');
const mongoose = require('../services/mongoose');
const TokensService = require('../services/tokens');
const util = require('./util');
const Table = require('cli-table');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
async function listTokens(userID) {
try {
let tokens = await TokensService.list(userID);
let table = new Table({
head: [
'ID',
'Name',
'Status'
]
});
tokens.forEach((token) => {
table.push([
token.id,
token.name,
token.active ? 'Active' : 'Revoked'
]);
});
console.log(table.toString());
util.shutdown();
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
async function revokeToken(tokenID) {
try {
await TokensService.revoke(null, tokenID);
console.log(`Revoked Token[${tokenID}]`);
util.shutdown();
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
async function createToken(userID, tokenName) {
try {
let {pat: {id}, jwt} = await TokensService.create(userID, tokenName);
console.log(`Created Token[${id}] for User[${userID}] = ${jwt}`);
util.shutdown();
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.command('list <userID>')
.description('list tokens for a user')
.action(listTokens);
program
.command('revoke <tokenID>')
.description('revokes a token with a given id')
.action(revokeToken);
program
.command('create <userID> <tokenName>')
.description('create a token for a user with a given name')
.action(createToken);
program.parse(process.argv);
// If there is no command listed, output help.
if (!process.argv.slice(2).length) {
program.outputHelp();
util.shutdown();
}
+7 -6
View File
@@ -8,6 +8,7 @@ const program = require('./commander');
const inquirer = require('inquirer');
const UsersService = require('../services/users');
const UserModel = require('../models/user');
const USER_ROLES = require('../models/enum/user_roles');
const mongoose = require('../services/mongoose');
const util = require('./util');
const Table = require('cli-table');
@@ -36,7 +37,7 @@ function getUserCreateAnswers(options) {
roles: []
};
if (options.role && UserModel.USER_ROLES.indexOf(options.role) > -1) {
if (options.role && USER_ROLES.indexOf(options.role) > -1) {
user.roles = [options.role];
}
@@ -89,7 +90,7 @@ function getUserCreateAnswers(options) {
name: 'roles',
message: 'User Role',
type: 'checkbox',
choices: UserModel.USER_ROLES
choices: USER_ROLES
}
]);
}
@@ -291,8 +292,8 @@ function mergeUsers(dstUserID, srcUserID) {
*/
function addRole(userID, role) {
if (UserModel.USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
if (USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${USER_ROLES.join(', ')}.`);
util.shutdown(1);
return;
}
@@ -316,8 +317,8 @@ function addRole(userID, role) {
*/
function removeRole(userID, role) {
if (UserModel.USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
if (USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${USER_ROLES.join(', ')}.`);
util.shutdown(1);
return;
}
+1
View File
@@ -52,5 +52,6 @@ deployment:
latest:
branch: master
owner: coralproject
commands:
- bash ./scripts/docker.sh deploy
+3 -3
View File
@@ -1,5 +1,5 @@
import React from 'react';
import {Router, Route, IndexRedirect, browserHistory} from 'react-router';
import {Router, Route, IndexRedirect, browserHistory, Redirect} from 'react-router';
import Configure from 'routes/Configure';
import Dashboard from 'routes/Dashboard';
@@ -49,8 +49,8 @@ const routes = (
<Route path='flagged' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path=':id' components={Moderation} />
<IndexRedirect to='premod' />
<Redirect from=':id' to='all/:id' />
<IndexRedirect to='all' />
</Route>
</Route>
</div>
+17 -9
View File
@@ -9,6 +9,7 @@ import {
} from '../constants/assets';
import coralApi from '../../../coral-framework/helpers/request';
import t from 'coral-framework/services/i18n';
/**
* Action disptacher related to assets
@@ -19,12 +20,16 @@ import coralApi from '../../../coral-framework/helpers/request';
export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch) => {
dispatch({type: FETCH_ASSETS_REQUEST});
return coralApi(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`)
.then(({result, count}) =>
dispatch({type: FETCH_ASSETS_SUCCESS,
assets: result,
count
}))
.catch((error) => dispatch({type: FETCH_ASSETS_FAILURE, error}));
.then(({result, count}) =>
dispatch({type: FETCH_ASSETS_SUCCESS,
assets: result,
count
}))
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: FETCH_ASSETS_FAILURE, error: errorMessage});
});
};
// Update an asset state
@@ -32,9 +37,12 @@ export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filte
export const updateAssetState = (id, closedAt) => (dispatch) => {
dispatch({type: UPDATE_ASSET_STATE_REQUEST});
return coralApi(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}})
.then(() =>
dispatch({type: UPDATE_ASSET_STATE_SUCCESS}))
.catch((error) => dispatch({type: UPDATE_ASSET_STATE_FAILURE, error}));
.then(() => dispatch({type: UPDATE_ASSET_STATE_SUCCESS}))
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: UPDATE_ASSET_STATE_FAILURE, error: errorMessage});
});
};
export const updateAssets = (assets) => (dispatch) => {
+35 -11
View File
@@ -3,6 +3,8 @@ import * as actions from '../constants/auth';
import coralApi from 'coral-framework/helpers/request';
import * as Storage from 'coral-framework/helpers/storage';
import {handleAuthToken} from 'coral-framework/actions/auth';
import {resetWebsocket} from 'coral-framework/services/client';
import t from 'coral-framework/services/i18n';
//==============================================================================
// SIGN IN
@@ -36,16 +38,31 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) =>
}
dispatch(handleAuthToken(token));
resetWebsocket();
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
if (error.translation_key === 'NOT_AUTHORIZED') {
// invalid credentials
dispatch({
type: actions.LOGIN_FAILURE,
message: t('error.email_password')
});
}
else if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') {
dispatch({
type: actions.LOGIN_MAXIMUM_EXCEEDED,
message: error.translation_key
message: t(`error.${error.translation_key}`),
});
} else {
dispatch({type: actions.LOGIN_FAILURE, message: error.translation_key});
dispatch({
type: actions.LOGIN_FAILURE,
message: errorMessage,
});
}
});
};
@@ -54,25 +71,30 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) =>
// FORGOT PASSWORD
//==============================================================================
const forgotPassowordRequest = () => ({
const forgotPasswordRequest = () => ({
type: actions.FETCH_FORGOT_PASSWORD_REQUEST
});
const forgotPassowordSuccess = () => ({
const forgotPasswordSuccess = () => ({
type: actions.FETCH_FORGOT_PASSWORD_SUCCESS
});
const forgotPassowordFailure = () => ({
type: actions.FETCH_FORGOT_PASSWORD_FAILURE
const forgotPasswordFailure = (error) => ({
type: actions.FETCH_FORGOT_PASSWORD_FAILURE,
error,
});
export const requestPasswordReset = (email) => (dispatch) => {
dispatch(forgotPassowordRequest(email));
dispatch(forgotPasswordRequest(email));
const redirectUri = location.href;
return coralApi('/account/password/reset', {method: 'POST', body: {email, loc: redirectUri}})
.then(() => dispatch(forgotPassowordSuccess()))
.catch((error) => dispatch(forgotPassowordFailure(error)));
.then(() => dispatch(forgotPasswordSuccess()))
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(forgotPasswordFailure(errorMessage));
});
};
//==============================================================================
@@ -105,10 +127,12 @@ export const checkLogin = () => (dispatch) => {
return dispatch(checkLoginFailure('not logged in'));
}
resetWebsocket();
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
console.error(error);
dispatch(checkLoginFailure(`${error.translation_key}`));
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(checkLoginFailure(errorMessage));
});
};
@@ -0,0 +1,7 @@
import {SHOW_BAN_USER_DIALOG, HIDE_BAN_USER_DIALOG} from '../constants/banUserDialog';
export const showBanUserDialog = ({userId, username, commentId, commentStatus}) =>
({type: SHOW_BAN_USER_DIALOG, userId, username, commentId, commentStatus});
export const hideBanUserDialog = () => ({type: HIDE_BAN_USER_DIALOG});
+11 -7
View File
@@ -10,11 +10,12 @@ import {
SET_COMMENTER_STATUS,
SHOW_BANUSER_DIALOG,
HIDE_BANUSER_DIALOG,
SHOW_SUSPENDUSER_DIALOG,
HIDE_SUSPENDUSER_DIALOG
SHOW_REJECT_USERNAME_DIALOG,
HIDE_REJECT_USERNAME_DIALOG
} from '../constants/community';
import coralApi from '../../../coral-framework/helpers/request';
import t from 'coral-framework/services/i18n';
export const fetchAccounts = (query = {}) => (dispatch) => {
@@ -30,7 +31,11 @@ export const fetchAccounts = (query = {}) => (dispatch) => {
totalPages
});
})
.catch((error) => dispatch({type: FETCH_COMMENTERS_FAILURE, error}));
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: FETCH_COMMENTERS_FAILURE, error: errorMessage});
});
};
const requestFetchAccounts = () => ({
@@ -47,7 +52,6 @@ export const newPage = () => ({
});
export const setRole = (id, role) => (dispatch) => {
return coralApi(`/users/${id}/role`, {method: 'POST', body: {role}})
.then(() => {
return dispatch({type: SET_ROLE, id, role});
@@ -65,6 +69,6 @@ export const setCommenterStatus = (id, status) => (dispatch) => {
export const showBanUserDialog = (user) => ({type: SHOW_BANUSER_DIALOG, user});
export const hideBanUserDialog = () => ({type: HIDE_BANUSER_DIALOG});
// Suspend User Dialog
export const showSuspendUserDialog = (user) => ({type: SHOW_SUSPENDUSER_DIALOG, user});
export const hideSuspendUserDialog = () => ({type: HIDE_SUSPENDUSER_DIALOG});
// Reject Username Dialog
export const showRejectUsernameDialog = (user) => ({type: SHOW_REJECT_USERNAME_DIALOG, user});
export const hideRejectUsernameDialog = () => ({type: HIDE_REJECT_USERNAME_DIALOG});
+11 -5
View File
@@ -2,6 +2,7 @@ import coralApi from 'coral-framework/helpers/request';
import * as actions from '../constants/install';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
import t from 'coral-framework/services/i18n';
export const nextStep = () => ({type: actions.NEXT_STEP});
export const previousStep = () => ({type: actions.PREVIOUS_STEP});
@@ -17,7 +18,8 @@ const clearErrors = () => ({type: actions.CLEAR_ERRORS});
const validation = (formData, dispatch, next) => {
if (!(formData != null)) {
return dispatch(hasError());
dispatch(hasError());
return;
}
const validKeys = Object.keys(formData)
@@ -40,7 +42,8 @@ const validation = (formData, dispatch, next) => {
});
if (empty.length) {
return dispatch(hasError());
dispatch(hasError());
return;
}
// RegExp Validation
@@ -59,7 +62,8 @@ const validation = (formData, dispatch, next) => {
});
if (validation.length) {
return dispatch(hasError());
dispatch(hasError());
return;
}
dispatch(clearErrors());
@@ -90,7 +94,8 @@ export const finishInstall = () => (dispatch, getState) => {
})
.catch((error) => {
console.error(error);
dispatch(installFailure(`${error.translation_key}`));
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(installFailure(errorMessage));
});
};
@@ -113,6 +118,7 @@ export const checkInstall = (next) => (dispatch) => {
})
.catch((error) => {
console.error(error);
dispatch(checkInstallFailure(`${error.translation_key}`));
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(checkInstallFailure(errorMessage));
});
};
+13 -10
View File
@@ -3,16 +3,6 @@ import * as actions from 'constants/moderation';
export const toggleModal = (open) => ({type: actions.TOGGLE_MODAL, open});
export const singleView = () => ({type: actions.SINGLE_VIEW});
// Ban User Dialog
export const showBanUserDialog = (user, commentId, commentStatus, showRejectedNote) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId, commentStatus, showRejectedNote});
export const hideBanUserDialog = () => ({type: actions.HIDE_BANUSER_DIALOG});
// Suspend User Dialog
export const showSuspendUserDialog = (userId, username, commentId, commentStatus) =>
({type: actions.SHOW_SUSPEND_USER_DIALOG, userId, username, commentId, commentStatus});
export const hideSuspendUserDialog = () => ({type: actions.HIDE_SUSPEND_USER_DIALOG});
// hide shortcuts note
export const hideShortcutsNote = () => {
try {
@@ -51,3 +41,16 @@ export const toggleSelectCommentInUserDetail = (id, active) => {
id
};
};
export const toggleStorySearch = (active) => ({
type: active ? actions.SHOW_STORY_SEARCH : actions.HIDE_STORY_SEARCH
});
export const storySearchChange = (value) => ({
type: actions.STORY_SEARCH_CHANGE_VALUE,
value
});
export const clearState = () => ({
type: actions.MODERATION_CLEAR_STATE
});
+7 -2
View File
@@ -1,4 +1,5 @@
import coralApi from '../../../coral-framework/helpers/request';
import t from 'coral-framework/services/i18n';
export const SETTINGS_LOADING = 'SETTINGS_LOADING';
export const SETTINGS_RECEIVED = 'SETTINGS_RECEIVED';
@@ -20,7 +21,9 @@ export const fetchSettings = () => (dispatch) => {
dispatch({type: SETTINGS_RECEIVED, settings});
})
.catch((error) => {
dispatch({type: SETTINGS_FETCH_ERROR, error});
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: SETTINGS_FETCH_ERROR, error: errorMessage});
});
};
@@ -49,6 +52,8 @@ export const saveSettingsToServer = () => (dispatch, getState) => {
dispatch({type: SAVE_SETTINGS_SUCCESS, settings});
})
.catch((error) => {
dispatch({type: SAVE_SETTINGS_FAILED, error});
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: SAVE_SETTINGS_FAILED, error: errorMessage});
});
};
@@ -0,0 +1,7 @@
import {SHOW_SUSPEND_USER_DIALOG, HIDE_SUSPEND_USER_DIALOG} from '../constants/suspendUserDialog.js';
export const showSuspendUserDialog = ({userId, username, commentId, commentStatus}) =>
({type: SHOW_SUSPEND_USER_DIALOG, userId, username, commentId, commentStatus});
export const hideSuspendUserDialog = () => ({type: HIDE_SUSPEND_USER_DIALOG});
+16 -3
View File
@@ -1,5 +1,6 @@
import coralApi from '../../../coral-framework/helpers/request';
import * as userTypes from '../constants/users';
import t from 'coral-framework/services/i18n';
/**
* Action disptacher related to users
@@ -10,7 +11,11 @@ export const userStatusUpdate = (status, userId, commentId) => {
dispatch({type: userTypes.UPDATE_STATUS_REQUEST});
return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
.then((res) => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res}))
.catch((error) => dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error}));
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error: errorMessage});
});
};
};
@@ -18,7 +23,11 @@ export const userStatusUpdate = (status, userId, commentId) => {
export const sendNotificationEmail = (userId, subject, body) => {
return (dispatch) => {
return coralApi(`/users/${userId}/email`, {method: 'POST', body: {subject, body}})
.catch((error) => dispatch({type: userTypes.USER_EMAIL_FAILURE, error}));
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: userTypes.USER_EMAIL_FAILURE, error: errorMessage});
});
};
};
@@ -26,6 +35,10 @@ export const sendNotificationEmail = (userId, subject, body) => {
export const enableUsernameEdit = (userId) => {
return (dispatch) => {
return coralApi(`/users/${userId}/username-enable`, {method: 'POST'})
.catch((error) => dispatch({type: userTypes.USERNAME_ENABLE_FAILURE, error}));
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: userTypes.USERNAME_ENABLE_FAILURE, error: errorMessage});
});
};
};
@@ -2,7 +2,6 @@ import React, {PropTypes} from 'react';
import Layout from 'coral-admin/src/components/ui/Layout';
import styles from './NotFound.css';
import {Button, TextField, Alert, Success} from 'coral-ui';
import t from 'coral-framework/services/i18n';
import Recaptcha from 'react-recaptcha';
class AdminLogin extends React.Component {
@@ -35,7 +34,7 @@ class AdminLogin extends React.Component {
const {errorMessage, loginMaxExceeded, recaptchaPublic} = this.props;
const signInForm = (
<form onSubmit={this.handleSignIn}>
{errorMessage && <Alert>{t(`error.${errorMessage}`)}</Alert>}
{errorMessage && <Alert>{errorMessage}</Alert>}
<TextField
label='Email Address'
value={this.state.email}
@@ -0,0 +1,44 @@
import React, {PropTypes} from 'react';
import {Dialog} from 'coral-ui';
import styles from './BanUserDialog.css';
import Button from 'coral-ui/components/Button';
import t from 'coral-framework/services/i18n';
const BanUserDialog = ({open, onCancel, onPerform, username, info}) => (
<Dialog
className={styles.dialog}
id="banUserDialog"
open={open}
onCancel={onCancel}
title={t('bandialog.ban_user')}>
<span className={styles.close} onClick={onCancel}>×</span>
<div>
<div className={styles.header}>
<h2>{t('bandialog.ban_user')}</h2>
</div>
<div className={styles.separator}>
<h3>{t('bandialog.are_you_sure', username)}</h3>
<i>{info}</i>
</div>
<div className={styles.buttons}>
<Button cStyle="cancel" className={styles.cancel} onClick={onCancel} raised>
{t('bandialog.cancel')}
</Button>
<Button cStyle="black" className={styles.ban} onClick={onPerform} raised>
{t('bandialog.yes_ban_user')}
</Button>
</div>
</div>
</Dialog>
);
BanUserDialog.propTypes = {
open: PropTypes.bool,
onPerform: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
username: PropTypes.string,
info: PropTypes.string,
};
export default BanUserDialog;
@@ -16,8 +16,8 @@ const shortcuts = [
{
title: 'modqueue.actions',
shortcuts: {
't': 'modqueue.approve',
'r': 'modqueue.reject'
'd': 'modqueue.approve',
'f': 'modqueue.reject'
}
}
];
@@ -40,8 +40,8 @@ export default class ModerationKeysModal extends React.Component {
<p className={styles.ctaHeader}>{t('modqueue.mod_faster')}</p>
<p><strong>{t('modqueue.try_these')}:</strong></p>
<ul>
<li><span>{t('modqueue.approve')}</span> <span className={styles.smallKey}>t</span></li>
<li><span>{t('modqueue.reject')}</span> <span className={styles.smallKey}>r</span></li>
<li><span>{t('modqueue.approve')}</span> <span className={styles.smallKey}>d</span></li>
<li><span>{t('modqueue.reject')}</span> <span className={styles.smallKey}>f</span></li>
</ul>
<p><span>{t('modqueue.view_more_shortcuts')}</span> <span className={styles.smallKey}>{t('modqueue.shift_key')}</span> + <span className={styles.smallKey}>/</span></p>
</div>
@@ -8,8 +8,8 @@ import SuspendUserModal from './SuspendUserModal';
// Each action has different meaning and configuration
const menuOptionsMap = {
'reject': {status: 'REJECTED', icon: 'close', key: 'r'},
'approve': {status: 'ACCEPTED', icon: 'done', key: 't'},
'reject': {status: 'REJECTED', icon: 'close', key: 'f'},
'approve': {status: 'ACCEPTED', icon: 'done', key: 'd'},
'flag': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'},
'ban': {status: 'BANNED', icon: 'not interested'}
};
@@ -49,7 +49,6 @@ class SuspendUserDialog extends React.Component {
handlePerform = () => {
this.props.onPerform({
id: this.props.userId,
message: this.state.message,
// Add 1 minute more to help `timeago.js` to display the correct duration.
@@ -153,11 +152,10 @@ class SuspendUserDialog extends React.Component {
SuspendUserDialog.propTypes = {
open: PropTypes.bool.isRequired,
onCancel: PropTypes.func.isRequired,
onPerform: PropTypes.func.isRequired,
username: PropTypes.string,
userId: PropTypes.string,
onCancel: PropTypes.func.isRequired,
organizationName: PropTypes.string,
username: PropTypes.string,
};
export default SuspendUserDialog;
@@ -133,7 +133,7 @@
animation-fill-mode: both; }
.toastify {
z-index: 999;
z-index: 99999;
position: fixed;
padding: 4px;
width: 350px;
@@ -197,7 +197,7 @@
border-radius: 2px;
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1), 0 3px 20px 0 rgba(0, 0, 0, 0.05); }
.toastify-content--info {
background: #2488cb; }
background: #404040; }
.toastify-content--success {
background: #008577; }
.toastify-content--warning {
@@ -207,6 +207,7 @@
.toastify__body {
color: white;
overflow-x: scroll;
font-size: 15px;
font-weight: 400;
}
@@ -40,12 +40,12 @@
height: 100%;
}
.rightPanel ul {
.rightPanel > ul {
list-style: none;
margin-right: 20px;
}
.rightPanel li {
.rightPanel > ul > li {
display: inline-block;
float: right;
margin-left: 15px;
@@ -54,6 +54,12 @@
line-height: 33px;
}
.rightPanel a {
color: black;
text-decoration: none;
font-weight: 400;
}
.rightPanel .settings {
line-height: 0;
}
+15 -3
View File
@@ -71,12 +71,24 @@ const CoralHeader = ({
<IconButton name="settings" id="menu-settings"/>
<Menu target="menu-settings" align="right">
<MenuItem onClick={() => showShortcuts(true)}>{t('configure.shortcuts')}</MenuItem>
<MenuItem onClick={handleLogout}>{t('configure.sign_out')}</MenuItem>
<MenuItem>
<a href="https://github.com/coralproject/talk/releases" target="_blank">
View latest version
</a>
</MenuItem>
<MenuItem>
<a href="https://coralproject.net/contribute.html#other-ideas-and-bug-reports" target="_blank">
Report a bug or give feedback
</a>
</MenuItem>
<MenuItem onClick={handleLogout}>
{t('configure.sign_out')}
</MenuItem>
</Menu>
</div>
</li>
<li>
{`v${process.env.VERSION}`}
<li>
{`v${process.env.VERSION}`}
</li>
</ul>
</div>
@@ -1,6 +1,5 @@
.layout {
max-width: 1280px;
margin: 0 auto;
overflow: hidden;
background-color: #FAFAFA;
}
@@ -10,7 +10,7 @@ const Layout = ({
toggleShortcutModal,
restricted = false,
...props}) => (
<LayoutMDL fixedDrawer>
<LayoutMDL className={styles.layout} fixedDrawer>
<Header
handleLogout={handleLogout}
showShortcuts={toggleShortcutModal}
@@ -0,0 +1,2 @@
export const SHOW_BAN_USER_DIALOG = 'SHOW_BAN_USER_DIALOG';
export const HIDE_BAN_USER_DIALOG = 'HIDE_BAN_USER_DIALOG';
@@ -13,5 +13,5 @@ export const FETCH_FLAGGED_COMMENTERS_FAILURE = 'FETCH_FLAGGED_COMMENTERS_FAILUR
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
export const SHOW_SUSPENDUSER_DIALOG = 'SHOW_SUSPENDUSER_DIALOG';
export const HIDE_SUSPENDUSER_DIALOG = 'HIDE_SUSPENDUSER_DIALOG';
export const SHOW_REJECT_USERNAME_DIALOG = 'SHOW_REJECT_USERNAME_DIALOG';
export const HIDE_REJECT_USERNAME_DIALOG = 'HIDE_REJECT_USERNAME_DIALOG';
@@ -1,10 +1,6 @@
export const TOGGLE_MODAL = 'TOGGLE_MODAL';
export const SINGLE_VIEW = 'SINGLE_VIEW';
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE';
export const SHOW_SUSPEND_USER_DIALOG = 'SHOW_SUSPEND_USER_DIALOG';
export const HIDE_SUSPEND_USER_DIALOG = 'HIDE_SUSPEND_USER_DIALOG';
export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL';
export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL';
export const SET_SORT_ORDER = 'MODERATION_SET_SORT_ORDER';
@@ -12,3 +8,7 @@ export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES';
export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT';
export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT';
export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS';
export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH';
export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH';
export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE';
export const MODERATION_CLEAR_STATE = 'MODERATION_CLEAR_STATE';
@@ -0,0 +1,2 @@
export const SHOW_SUSPEND_USER_DIALOG = 'SHOW_SUSPEND_USER_DIALOG';
export const HIDE_SUSPEND_USER_DIALOG = 'HIDE_SUSPEND_USER_DIALOG';
@@ -0,0 +1,63 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import BanUserDialog from '../components/BanUserDialog';
import {hideBanUserDialog} from '../actions/banUserDialog';
import {withSetUserStatus, withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {compose} from 'react-apollo';
import t from 'coral-framework/services/i18n';
class BanUserDialogContainer extends Component {
banUser = async () => {
const {userId, commentId, commentStatus, setUserStatus, setCommentStatus, hideBanUserDialog} = this.props;
await setUserStatus({userId, status: 'BANNED'});
hideBanUserDialog();
if (commentId && commentStatus && commentStatus !== 'REJECTED') {
await setCommentStatus({commentId, status: 'REJECTED'});
}
}
getInfo() {
let note = t('bandialog.note_ban_user');
if (this.props.commentStatus && this.props.commentStatus !== 'REJECTED') {
note = t('bandialog.note_reject_comment');
}
return t('bandialog.note', note);
}
render() {
return (
<BanUserDialog
open={this.props.open}
onPerform={this.banUser}
onCancel={this.props.hideBanUserDialog}
username={this.props.username}
info={this.getInfo()}
/>
);
}
}
const mapStateToProps = ({banUserDialog: {open, userId, username, commentId, commentStatus}}) => ({
open,
userId,
username,
commentId,
commentStatus,
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
hideBanUserDialog,
}, dispatch),
});
export default compose(
withSetUserStatus,
withSetCommentStatus,
connect(
mapStateToProps,
mapDispatchToProps,
),
)(BanUserDialogContainer);
+7 -1
View File
@@ -5,6 +5,8 @@ import {fetchConfig} from '../actions/config';
import AdminLogin from '../components/AdminLogin';
import {logout} from 'coral-framework/actions/auth';
import {FullLoading} from '../components/FullLoading';
import BanUserDialog from './BanUserDialog';
import SuspendUserDialog from './SuspendUserDialog';
import {toggleModal as toggleShortcutModal} from '../actions/moderation';
import {checkLogin, handleLogin, requestPasswordReset} from '../actions/auth';
import {can} from 'coral-framework/services/perms';
@@ -52,7 +54,11 @@ class LayoutContainer extends Component {
handleLogout={handleLogout}
toggleShortcutModal={toggleShortcutModal}
{...this.props}
/>
>
<BanUserDialog />
<SuspendUserDialog />
{this.props.children}
</Layout>
);
} else if (loggedIn) {
return (
@@ -0,0 +1,83 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import SuspendUserDialog from '../components/SuspendUserDialog';
import {hideSuspendUserDialog} from '../actions/suspendUserDialog';
import {withSetCommentStatus, withSuspendUser} from 'coral-framework/graphql/mutations';
import {compose, gql} from 'react-apollo';
import * as notification from 'coral-admin/src/services/notification';
import t, {timeago} from 'coral-framework/services/i18n';
import withQuery from 'coral-framework/hocs/withQuery';
import get from 'lodash/get';
class SuspendUserDialogContainer extends Component {
suspendUser = async ({message, until}) => {
const {userId, username, commentStatus, commentId, hideSuspendUserDialog, setCommentStatus, suspendUser} = this.props;
hideSuspendUserDialog();
try {
const result = await suspendUser({id: userId, message, until});
if (result.data.suspendUser.errors) {
throw result.data.suspendUser.errors;
}
notification.success(
t('suspenduser.notify_suspend_until', username, timeago(until)),
);
if (commentId && commentStatus && commentStatus !== 'REJECTED') {
return setCommentStatus({commentId, status: 'REJECTED'})
.then((result) => {
if (result.data.setCommentStatus.errors) {
throw result.data.setCommentStatus.errors;
}
});
}
}
catch(err) {
notification.showMutationErrors(err);
}
};
render() {
return (
<SuspendUserDialog
open={this.props.open}
onPerform={this.suspendUser}
onCancel={this.props.hideSuspendUserDialog}
organizationName={get(this.props, 'root.settings.organizationName')}
username={this.props.username}
/>
);
}
}
const withOrganizationName = withQuery(gql`
query CoralAdmin_SuspendUserDialog {
settings {
organizationName
}
}
`);
const mapStateToProps = ({suspendUserDialog: {open, userId, username, commentId, commentStatus}}) => ({
open,
userId,
username,
commentId,
commentStatus,
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
hideSuspendUserDialog,
}, dispatch),
});
export default compose(
connect(
mapStateToProps,
mapDispatchToProps,
),
withSuspendUser,
withSetCommentStatus,
withOrganizationName,
)(SuspendUserDialogContainer);
-54
View File
@@ -1,6 +1,4 @@
import {add} from 'coral-framework/services/graphqlRegistry';
import update from 'immutability-helper';
const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected'];
const extension = {
mutations: {
@@ -10,58 +8,6 @@ const extension = {
RejectUsername: () => ({
refetchQueries: ['CoralAdmin_Community'],
}),
SetCommentStatus: ({variables: {commentId, status}}) => ({
updateQueries: {
CoralAdmin_Moderation: (prev) => {
const comment = queues.reduce((comment, queue) => {
return comment ? comment : prev[queue].nodes.find((c) => c.id === commentId);
}, null);
let acceptedNodes = prev.accepted.nodes;
let acceptedCount = prev.acceptedCount;
let rejectedNodes = prev.rejected.nodes;
let rejectedCount = prev.rejectedCount;
if (status !== comment.status) {
if (status === 'ACCEPTED') {
comment.status = 'ACCEPTED';
acceptedCount++;
acceptedNodes = [comment, ...acceptedNodes];
}
else if (status === 'REJECTED') {
comment.status = 'REJECTED';
rejectedCount++;
rejectedNodes = [comment, ...rejectedNodes];
}
}
const premodNodes = prev.premod.nodes.filter((c) => c.id !== commentId);
const flaggedNodes = prev.flagged.nodes.filter((c) => c.id !== commentId);
const premodCount = premodNodes.length < prev.premod.nodes.length ? prev.premodCount - 1 : prev.premodCount;
const flaggedCount = flaggedNodes.length < prev.flagged.nodes.length ? prev.flaggedCount - 1 : prev.flaggedCount;
if (status === 'REJECTED') {
acceptedNodes = prev.accepted.nodes.filter((c) => c.id !== commentId);
acceptedCount = acceptedNodes.length < prev.accepted.nodes.length ? prev.acceptedCount - 1 : prev.acceptedCount;
}
else if (status === 'ACCEPTED') {
rejectedNodes = prev.rejected.nodes.filter((c) => c.id !== commentId);
rejectedCount = rejectedNodes.length < prev.rejected.nodes.length ? prev.rejectedCount - 1 : prev.rejectedCount;
}
return update(prev, {
premodCount: {$set: Math.max(0, premodCount)},
flaggedCount: {$set: Math.max(0, flaggedCount)},
acceptedCount: {$set: Math.max(0, acceptedCount)},
rejectedCount: {$set: Math.max(0, rejectedCount)},
premod: {nodes: {$set: premodNodes}},
flagged: {nodes: {$set: flaggedNodes}},
accepted: {nodes: {$set: acceptedNodes}},
rejected: {nodes: {$set: rejectedNodes}},
});
}
}
}),
},
};
+137
View File
@@ -0,0 +1,137 @@
import update from 'immutability-helper';
import * as notification from 'coral-admin/src/services/notification';
const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected'];
const limit = 10;
const ascending = (a, b) => {
const dateA = new Date(a.created_at);
const dateB = new Date(b.created_at);
if (dateA < dateB) { return -1; }
if (dateA > dateB) { return 1; }
return 0;
};
const descending = (a, b) => -ascending(a, b);
function queueHasComment(root, queue, id) {
return root[queue].nodes.find((c) => c.id === id);
}
function removeCommentFromQueue(root, queue, id) {
if (!queueHasComment(root, queue, id)) {
return root;
}
return update(root, {
[`${queue}Count`]: {$set: root[`${queue}Count`] - 1},
[queue]: {
nodes: {$apply: (nodes) => nodes.filter((c) => c.id !== id)},
},
});
}
function shouldCommentBeAdded(root, queue, comment, sort) {
if (root[`${queue}Count`] < limit) {
// Adding all comments until first limit has reached.
return true;
}
const cursor = new Date(root[queue].endCursor);
return sort === 'CHRONOLOGICAL'
? new Date(comment.created_at) <= cursor
: new Date(comment.created_at) >= cursor;
}
function addCommentToQueue(root, queue, comment, sort) {
if (queueHasComment(root, queue, comment.id)) {
return root;
}
const sortAlgo = sort === 'CHRONOLOGICAL' ? ascending : descending;
const changes = {
[`${queue}Count`]: {$set: root[`${queue}Count`] + 1},
};
if (shouldCommentBeAdded(root, queue, comment, sort)) {
const nodes = root[queue].nodes.concat(comment).sort(sortAlgo);
changes[queue] = {
nodes: {$set: nodes},
startCursor: {$set: nodes[0].created_at},
endCursor: {$set: nodes[nodes.length - 1].created_at},
};
}
return update(root, changes);
}
function getCommentQueues(comment) {
const queues = ['all'];
if (comment.status === 'ACCEPTED') {
queues.push('accepted');
}
else if (comment.status === 'REJECTED') {
queues.push('rejected');
}
else if (comment.status === 'PREMOD') {
queues.push('premod');
}
if (
['NONE', 'PREMOD'].indexOf(comment.status) >= 0
&& comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction')
) {
queues.push('flagged');
}
return queues;
}
/**
* Assimilate comment changes into current store.
* @param {Object} root current state of the store
* @param {Object} comment comment that was changed
* @param {string} sort current sort order of the queues
* @param {Object} [notify] show know notifications if set
* @param {string} notify.activeQueue current active queue
* @param {string} notify.text notification text to show
* @param {bool} notify.anyQueue if true show the notification when the comment is shown
* in the current active queue besides the 'all' queue.
* @return {Object} next state of the store
*/
export function handleCommentChange(root, comment, sort, notify) {
let next = root;
const nextQueues = getCommentQueues(comment);
let notificationShown = false;
const showNotificationOnce = () => {
if (notificationShown) {
return;
}
notification.info(notify.text);
notificationShown = true;
};
queues.forEach((queue) => {
if (nextQueues.indexOf(queue) >= 0) {
if (!queueHasComment(next, queue, comment.id)) {
next = addCommentToQueue(next, queue, comment, sort);
if (notify && notify.activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) {
showNotificationOnce(comment);
}
}
} else if(queueHasComment(next, queue, comment.id)){
next = removeCommentFromQueue(next, queue, comment.id);
if (notify && notify.activeQueue === queue) {
showNotificationOnce(comment);
}
}
if (
notify
&& (queue === 'all' || notify.anyQueue)
&& queueHasComment(next, queue, comment.id)
&& notify.activeQueue === queue
) {
showNotificationOnce(comment);
}
});
return next;
}
+2 -2
View File
@@ -2,7 +2,7 @@ import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import {client} from './services/client';
import {getClient} from './services/client';
import store from './services/store';
import App from './components/App';
@@ -15,7 +15,7 @@ loadPluginsTranslations();
injectPluginsReducers();
render(
<ApolloProvider client={client} store={store}>
<ApolloProvider client={getClient()} store={store}>
<App />
</ApolloProvider>
, document.querySelector('#root')
@@ -0,0 +1,30 @@
import {SHOW_BAN_USER_DIALOG, HIDE_BAN_USER_DIALOG} from '../constants/banUserDialog';
const initialState = {
open: false,
userId: null,
username: '',
commentId: null,
commentStatus: '',
};
export default function banUserDialog(state = initialState, action) {
switch (action.type) {
case SHOW_BAN_USER_DIALOG:
return {
...state,
open: true,
userId: action.userId,
username: action.username,
commentId: action.commentId,
commentStatus: action.commentStatus,
};
case HIDE_BAN_USER_DIALOG:
return {
...state,
open: false,
};
default:
return state;
}
}
+7 -7
View File
@@ -9,8 +9,8 @@ import {
SET_COMMENTER_STATUS,
SHOW_BANUSER_DIALOG,
HIDE_BANUSER_DIALOG,
SHOW_SUSPENDUSER_DIALOG,
HIDE_SUSPENDUSER_DIALOG
SHOW_REJECT_USERNAME_DIALOG,
HIDE_REJECT_USERNAME_DIALOG
} from '../constants/community';
const initialState = Map({
@@ -24,7 +24,7 @@ const initialState = Map({
pagePeople: 0,
user: Map({}),
banDialog: false,
suspendDialog: false
rejectUsernameDialog: false
});
export default function community (state = initialState, action) {
@@ -79,14 +79,14 @@ export default function community (state = initialState, action) {
user: Map(action.user),
banDialog: true
});
case HIDE_SUSPENDUSER_DIALOG:
case HIDE_REJECT_USERNAME_DIALOG:
return state
.set('suspendDialog', false);
case SHOW_SUSPENDUSER_DIALOG:
.set('rejectUsernameDialog', false);
case SHOW_REJECT_USERNAME_DIALOG:
return state
.merge({
user: Map(action.user),
suspendDialog: true
rejectUsernameDialog: true
});
default :
return state;
+4
View File
@@ -5,9 +5,13 @@ import community from './community';
import moderation from './moderation';
import install from './install';
import config from './config';
import banUserDialog from './banUserDialog';
import suspendUserDialog from './suspendUserDialog';
export default {
auth,
banUserDialog,
suspendUserDialog,
assets,
settings,
community,
+11 -39
View File
@@ -1,57 +1,23 @@
import {fromJS, Map, Set} from 'immutable';
import {fromJS, Set} from 'immutable';
import * as actions from '../constants/moderation';
const initialState = fromJS({
singleView: false,
modalOpen: false,
user: {},
commentId: null,
commentStatus: null,
userDetailId: null,
userDetailActiveTab: 'all',
userDetailStatuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'],
userDetailSelectedIds: new Set(),
banDialog: false,
storySearchVisible: false,
storySearchString: '',
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
sortOrder: 'REVERSE_CHRONOLOGICAL',
suspendUserDialog: {
show: false,
userId: null,
username: '',
commentId: null,
commentStatus: '',
},
});
export default function moderation (state = initialState, action) {
switch (action.type) {
case actions.HIDE_BANUSER_DIALOG:
return state
.set('banDialog', false)
.set('commentStatus', null);
case actions.SHOW_BANUSER_DIALOG:
return state
.merge({
user: Map(action.user),
commentId: action.commentId,
commentStatus: action.commentStatus,
showRejectedNote: action.showRejectedNote,
banDialog: true
});
case actions.SHOW_SUSPEND_USER_DIALOG:
return state
.mergeDeep({
suspendUserDialog: {
show: true,
userId: action.userId,
username: action.username,
commentId: action.commentId,
commentStatus: action.commentStatus,
}
});
case actions.HIDE_SUSPEND_USER_DIALOG:
return state
.setIn(['suspendUserDialog', 'show'], false);
case actions.MODERATION_CLEAR_STATE:
return initialState;
case actions.SET_ACTIVE_TAB:
return state
.set('activeTab', action.activeTab);
@@ -80,6 +46,12 @@ export default function moderation (state = initialState, action) {
return state.update('userDetailSelectedIds', (set) => set.add(action.id));
case actions.UNSELECT_USER_DETAIL_COMMENT:
return state.update('userDetailSelectedIds', (set) => set.delete(action.id));
case actions.SHOW_STORY_SEARCH:
return state.set('storySearchVisible', true);
case actions.HIDE_STORY_SEARCH:
return state.set('storySearchVisible', false);
case actions.STORY_SEARCH_CHANGE_VALUE:
return state.set('storySearchString', action.value);
case actions.SET_SORT_ORDER:
return state.set('sortOrder', action.order);
default :
@@ -0,0 +1,30 @@
import {SHOW_SUSPEND_USER_DIALOG, HIDE_SUSPEND_USER_DIALOG} from '../constants/suspendUserDialog';
const initialState = {
open: false,
userId: null,
username: '',
commentId: null,
commentStatus: '',
};
export default function suspendUserDialog(state = initialState, action) {
switch (action.type) {
case SHOW_SUSPEND_USER_DIALOG:
return {
...state,
open: true,
userId: action.userId,
username: action.username,
commentId: action.commentId,
commentStatus: action.commentStatus,
};
case HIDE_SUSPEND_USER_DIALOG:
return {
...state,
open: false,
};
default:
return state;
}
}
@@ -1,23 +1,18 @@
import React from 'react';
import styles from './Community.css';
import BanUserButton from './BanUserButton';
import {Button} from 'coral-ui';
import {menuActionsMap} from '../../Moderation/helpers/moderationQueueActionsMap';
import t from 'coral-framework/services/i18n';
const ActionButton = ({type = '', user, ...props}) => {
if (type === 'BAN') {
return <BanUserButton icon='not interested' user={user} onClick={() => props.showBanUserDialog(user)} />;
}
return (
<Button
className={`${type.toLowerCase()} ${styles.actionButton}`}
cStyle={type.toLowerCase()}
icon={menuActionsMap[type].icon}
onClick={() => {
type === 'APPROVE' ? props.approveUser({userId: user.id}) : props.showSuspendUserDialog({user: user});
type === 'APPROVE' ? props.approveUser({userId: user.id}) : props.showRejectUsernameDialog({user: user});
}}
>{t(`modqueue.${menuActionsMap[type].text}`)}</Button>
);
@@ -1,11 +0,0 @@
.banButton {
-webkit-transform: scale(.8);
transform: scale(.8);
margin: 0;
i {
vertical-align: middle;
margin-right: 5px;
font-size: 14px;
}
}
@@ -1,24 +0,0 @@
import React, {PropTypes} from 'react';
import {Button, Icon} from 'coral-ui';
import styles from './BanUserButton.css';
import t from 'coral-framework/services/i18n';
const BanUserButton = ({user, ...props}) => (
<div className={styles.ban}>
<Button cStyle='ban'
className={`ban ${styles.banButton}`}
disabled={user.status === 'BANNED' ? 'disabled' : ''}
onClick={props.onClick}
raised>
<Icon name='not_interested' />
{t('comment.ban_user')}
</Button>
</div>
);
BanUserButton.propTypes = {
onClick: PropTypes.func.isRequired
};
export default BanUserButton;
@@ -1,164 +0,0 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 500px;
top: 50%;
transform: translateY(-50%);
height: 184px;
padding: 20px;
h2 {
color: black;
font-size: 1.76em;
font-weight: 500;
margin: 0;
}
h3 {
color: black;
font-size: 1.4em;
font-weight: 500;
margin: 0;
}
}
.textField {
margin-top: 15px;
}
.textField label {
font-size: 1.08em;
font-weight: bold;
margin-bottom: 5px;
}
.textField input {
width: 100%;
display: block;
border: none;
outline: none;
border: 1px solid rgba(0,0,0,.12);
padding: 10px 6px;
box-sizing: border-box;
border-radius: 2px;
margin: 5px auto;
}
.footer {
margin: 20px auto 10px;
text-align: center;
}
.footer span {
display: block;
margin-bottom: 5px;
}
.footer a {
color: #2c69b6;
cursor: pointer;
margin: 0 5px;
}
.socialConnections {
margin-bottom: 20px;
}
.signInButton {
margin-top: 10px;
}
.close {
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
}
.close:hover {
color: #6b6b6b;
}
input.error{
border: solid 2px #f44336;
}
.errorMsg, .hint {
color: grey;
font-weight: 600;
padding: 3px 0 16px;
}
.alert {
padding: 10px;
margin-bottom: 20px;
border-radius: 2px;
}
.alert--success {
border: solid 1px #1ec00e;
background: #cbf1b8;
color: #006900;
}
.alert--error {
background: #FFEBEE;
color: #B71C1C;
}
.userBox a {
color: #2c69b6;
cursor: pointer;
margin: 0px;
}
.attention {
display: inline-block;
width: 15px;
height: 15px;
background: #B71C1C;
color: #FFEBEE;
font-weight: bolder;
padding: 4px;
vertical-align: middle;
border-radius: 20px;
box-sizing: border-box;
font-size: 9px;
line-height: 7px;
text-align: center;
margin-right: 5px;
}
.action {
margin-top: 15px;
}
.passwordRequestSuccess {
border: 1px solid green;
background-color: lightgreen;
padding: 10px;
}
.passwordRequestFailure {
border: 1px solid orange;
background-color: 1px solid coral;
padding: 10px;
}
.cancel {
margin-right: 10px;
width: 47%;
}
.ban {
width: 47%;
}
.buttons {
margin: 20px 0;
}
@@ -1,51 +0,0 @@
import React, {PropTypes} from 'react';
import {Dialog} from 'coral-ui';
import styles from './BanUserDialog.css';
import Button from 'coral-ui/components/Button';
import t from 'coral-framework/services/i18n';
const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
<Dialog
className={styles.dialog}
id="banuserDialog"
open={open}
onClose={handleClose}
onCancel={handleClose}
title={t('community.ban_user')}>
<span className={styles.close} onClick={handleClose}>×</span>
<div>
<div className={styles.header}>
<h2>{t('community.ban_user')}</h2>
</div>
<div className={styles.separator}>
<h3>{t('community.are_you_sure', user.username)}</h3>
<i>{t('community.note')}</i>
</div>
<div className={styles.buttons}>
<Button cStyle="cancel" className={styles.cancel} onClick={handleClose} raised>
{t('community.cancel')}
</Button>
<Button
cStyle="black" className={styles.ban}
onClick={() => {
handleBanUser({userId: user.id}).then(() => {
handleClose();
});
}}
raised>
{t('community.yes_ban_user')}
</Button>
</div>
</div>
</Dialog>
);
BanUserDialog.propTypes = {
handleBanUser: PropTypes.func.isRequired,
handleClose: PropTypes.func.isRequired,
user: PropTypes.object.isRequired,
};
export default BanUserDialog;
@@ -1,10 +1,9 @@
import React, {Component} from 'react';
import CommunityMenu from './CommunityMenu';
import BanUserDialog from './BanUserDialog';
import SuspendUserDialog from './SuspendUserDialog';
import People from './People';
import FlaggedAccounts from './FlaggedAccounts';
import RejectUsernameDialog from './RejectUsernameDialog';
export default class Community extends Component {
@@ -80,19 +79,15 @@ export default class Community extends Component {
<FlaggedAccounts
commenters={users}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
showRejectUsernameDialog={props.showRejectUsernameDialog}
approveUser={props.approveUser}
rejectUsername={props.rejectUsername}
showSuspendUserDialog={props.showSuspendUserDialog}
currentUser={this.props.currentUser}
/>
<BanUserDialog
open={community.banDialog}
user={community.user}
handleClose={props.hideBanUserDialog}
handleBanUser={props.banUser}
/>
<SuspendUserDialog
open={community.suspendDialog}
handleClose={props.hideSuspendUserDialog}
<RejectUsernameDialog
open={community.rejectUsernameDialog}
handleClose={props.hideRejectUsernameDialog}
user={community.user}
rejectUsername={props.rejectUsername}
/>
@@ -9,8 +9,6 @@ const FlaggedAccounts = ({...props}) => {
const {commenters} = props;
const hasResults = commenters && !!commenters.length;
// if (commenter.status === 'PENDING' && commenter.actions.length > 0) {
return (
<div className={styles.container}>
<div className={styles.mainFlaggedContent}>
@@ -24,8 +22,9 @@ const FlaggedAccounts = ({...props}) => {
modActionButtons={['APPROVE', 'REJECT']}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
showRejectUsernameDialog={props.showRejectUsernameDialog}
approveUser={props.approveUser}
suspendUser={props.suspendUser}
currentUser={props.currentUser}
/>;
})
: <EmptyCard>{t('community.no_flagged_accounts')}</EmptyCard>
@@ -1,30 +1,30 @@
import React, {Component, PropTypes} from 'react';
import {Dialog, Button} from 'coral-ui';
import styles from './SuspendUserDialog.css';
import styles from './RejectUsernameDialog.css';
import t from 'coral-framework/services/i18n';
const stages = [
{
title: 'suspenduser.title_reject',
description: 'suspenduser.description_reject',
title: 'reject_username.title_reject',
description: 'reject_username.description_reject',
options: {
'j': 'suspenduser.no_cancel',
'k': 'suspenduser.yes_suspend'
'j': 'reject_username.no_cancel',
'k': 'reject_username.yes_suspend'
}
},
{
title: 'suspenduser.title_notify',
description: 'suspenduser.description_notify',
title: 'reject_username.title_notify',
description: 'reject_username.description_notify',
options: {
'j': 'bandialog.cancel',
'k': 'suspenduser.send'
'k': 'reject_username.send'
}
}
];
class SuspendUserDialog extends Component {
class RejectUsernameDialog extends Component {
state = {email: '', stage: 0}
@@ -35,7 +35,7 @@ class SuspendUserDialog extends Component {
}
componentDidMount() {
this.setState({email: t('suspenduser.email_message_reject'), about: t('suspenduser.username')});
this.setState({email: t('reject_username.email_message_reject'), about: t('reject_username.username')});
}
/*
@@ -72,22 +72,22 @@ class SuspendUserDialog extends Component {
return <Dialog
className={styles.suspendDialog}
id="suspendUserDialog"
id="rejectUsernameDialog"
open={open}
onClose={handleClose}
onCancel={handleClose}
title={t('suspenduser.suspend_user')}>
title={t('reject_username.suspend_user')}>
<div className={styles.title}>
{t(stages[stage].title, t('suspenduser.username'))}
{t(stages[stage].title, t('reject_username.username'))}
</div>
<div className={styles.container}>
<div className={styles.description}>
{t(stages[stage].description, t('suspenduser.username'))}
{t(stages[stage].description, t('reject_username.username'))}
</div>
{
stage === 1 &&
<div className={styles.writeContainer}>
<div className={styles.emailMessage}>{t('suspenduser.write_message')}</div>
<div className={styles.emailMessage}>{t('reject_username.write_message')}</div>
<div className={styles.emailContainer}>
<textarea
rows={5}
@@ -100,7 +100,7 @@ class SuspendUserDialog extends Component {
<div className={styles.modalButtons}>
{Object.keys(stages[stage].options).map((key, i) => (
<Button key={i} onClick={this.onActionClick(stage, i)}>
{t(stages[stage].options[key], t('suspenduser.username'))}
{t(stages[stage].options[key], t('reject_username.username'))}
</Button>
))}
</div>
@@ -109,4 +109,4 @@ class SuspendUserDialog extends Component {
}
}
export default SuspendUserDialog;
export default RejectUsernameDialog;
@@ -2,6 +2,8 @@ import React from 'react';
import styles from './Community.css';
import ActionButton from './ActionButton';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import t from 'coral-framework/services/i18n';
@@ -18,6 +20,16 @@ const User = (props) => {
const {user, modActionButtons} = props;
let userStatus = user.status;
const showSuspenUserDialog = () => props.showSuspendUserDialog({
userId: user.id,
username: user.username,
});
const showBanUserDialog = () => props.showBanUserDialog({
userId: user.id,
username: user.username,
});
// Do not display unless the user status is 'pending' or 'banned'.
// This means that they have already been reviewed and approved.
return (userStatus === 'PENDING' || userStatus === 'BANNED') &&
@@ -28,12 +40,19 @@ const User = (props) => {
<span>
{user.username}
</span>
<ActionButton
className={styles.banButton}
type='BAN'
user={user}
showBanUserDialog={props.showBanUserDialog}
/>
{props.currentUser.id !== user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
disabled={user.status === 'BANNED'}
onClick={showSuspenUserDialog}>
Suspend User</ActionsMenuItem>
<ActionsMenuItem
disabled={user.status === 'BANNED'}
onClick={showBanUserDialog}>
Ban User
</ActionsMenuItem>
</ActionsMenu>
}
</div>
</div>
@@ -78,11 +97,10 @@ const User = (props) => {
<div className={`actions ${styles.actions}`}>
{modActionButtons.map((action, i) =>
<ActionButton key={i}
type={action.toUpperCase()}
user={user}
approveUser={props.approveUser}
suspendUser={props.suspendUser}
showSuspendUserDialog={props.showSuspendUserDialog}
type={action.toUpperCase()}
user={user}
approveUser={props.approveUser}
showRejectUsernameDialog={props.showRejectUsernameDialog}
/>
)}
</div>
@@ -91,6 +91,7 @@ span {
.moderateAsset {
a {
text-align: center;
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
@@ -103,21 +104,9 @@ span {
opacity: 1;
&:hover {
opacity: .8;
opacity: .8;
cursor: pointer;
}
&:first-child {
text-align: left;
}
&:nth-child(2) {
text-align: center;
}
&:last-child {
text-align: right;
}
}
}
}
@@ -7,14 +7,15 @@ import {Spinner} from 'coral-ui';
import {withSetUserStatus, withRejectUsername} from 'coral-framework/graphql/mutations';
import {showBanUserDialog} from 'actions/banUserDialog';
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
import {
fetchAccounts,
updateSorting,
newPage,
showBanUserDialog,
hideBanUserDialog,
showSuspendUserDialog,
hideSuspendUserDialog
showRejectUsernameDialog,
hideRejectUsernameDialog
} from '../../../actions/community';
import Community from '../components/Community';
@@ -85,16 +86,17 @@ export const withCommunityQuery = withQuery(gql`
});
const mapStateToProps = (state) => ({
community: state.community.toJS()
community: state.community.toJS(),
currentUser: state.auth.toJS().user,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
fetchAccounts,
showBanUserDialog,
hideBanUserDialog,
showSuspendUserDialog,
hideSuspendUserDialog,
showRejectUsernameDialog,
hideRejectUsernameDialog,
updateSorting,
newPage,
}, dispatch);
@@ -165,7 +165,7 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
checked={settings.autoCloseStream} />
</div>
<div className={styles.content}>
{t('configure.close_after')}
<div className={styles.settingsHeader}>{t('configure.close_after')}</div>
<br />
<Textfield
type='number'
@@ -16,6 +16,7 @@ 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)}
@@ -1,52 +0,0 @@
import React, {PropTypes} from 'react';
import {Dialog} from 'coral-ui';
import styles from './BanUserDialog.css';
import Button from 'coral-ui/components/Button';
import t from 'coral-framework/services/i18n';
const onBanClick = (userId, commentId, commentStatus, handleBanUser, rejectComment, handleClose) => (e) => {
e.preventDefault();
handleBanUser({userId})
.then(handleClose)
.then(() => commentStatus === 'REJECTED' ? null : rejectComment({commentId}));
};
const BanUserDialog = ({open, handleClose, handleBanUser, rejectComment, user, commentId, commentStatus, showRejectedNote}) => (
<Dialog
className={styles.dialog}
id="banuserDialog"
open={open}
onClose={handleClose}
onCancel={handleClose}
title={t('bandialog.ban_user')}>
<span className={styles.close} onClick={handleClose}>×</span>
<div>
<div className={styles.header}>
<h2>{t('bandialog.ban_user')}</h2>
</div>
<div className={styles.separator}>
<h3>{t('bandialog.are_you_sure', user.name)}</h3>
<i>{showRejectedNote && t('bandialog.note')}</i>
</div>
<div className={styles.buttons}>
<Button cStyle="cancel" className={styles.cancel} onClick={handleClose} raised>
{t('bandialog.cancel')}
</Button>
<Button cStyle="black" className={styles.ban} onClick={onBanClick(user.id, commentId, commentStatus, handleBanUser, rejectComment, handleClose)} raised>
{t('bandialog.yes_ban_user')}
</Button>
</div>
</div>
</Dialog>
);
BanUserDialog.propTypes = {
handleBanUser: PropTypes.func.isRequired,
handleClose: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
commentId: PropTypes.string,
user: PropTypes.object.isRequired,
};
export default BanUserDialog;
@@ -0,0 +1,16 @@
import React from 'react';
import {Button} from 'coral-ui';
import t from 'coral-framework/services/i18n';
import {withCopyToClipboard} from 'coral-framework/hocs';
class ButtonCopyToClipboard extends React.Component {
render () {
return (
<Button {...this.props} >
{t('common.copy')}
</Button>
);
}
}
export default withCopyToClipboard(ButtonCopyToClipboard);
@@ -12,194 +12,239 @@ import {getActionSummary} from 'coral-framework/utils';
import ActionButton from 'coral-admin/src/components/ActionButton';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import cn from 'classnames';
import {murmur3} from 'murmurhash-js';
import {CSSTransitionGroup} from 'react-transition-group';
const linkify = new Linkify();
import t, {timeago} from 'coral-framework/services/i18n';
const Comment = ({
actions = [],
comment,
viewUserDetail,
suspectWords,
bannedWords,
minimal,
selected,
toggleSelect,
...props
}) => {
const links = linkify.getMatches(comment.body);
const linkText = links ? links.map((link) => link.raw) : [];
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions =
comment.actions &&
comment.actions.filter((a) => a.__typename === 'FlagAction');
let commentType = '';
if (comment.status === 'PREMOD') {
commentType = 'premod';
} else if (flagActions && flagActions.length) {
commentType = 'flagged';
}
class Comment extends React.Component {
// since words are checked against word boundaries on the backend,
// should be the behavior on the front end as well.
// 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);
})
.concat(linkText);
render() {
const {
actions = [],
comment,
viewUserDetail,
suspectWords,
bannedWords,
minimal,
selected,
toggleSelect,
className,
...props
} = this.props;
let selectionStateCSS;
if (minimal) {
selectionStateCSS = selected ? styles.minimalSelection : '';
} else {
selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
}
const links = linkify.getMatches(comment.body);
const linkText = links ? links.map((link) => link.raw) : [];
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions =
comment.actions &&
comment.actions.filter((a) => a.__typename === 'FlagAction');
let commentType = '';
if (comment.status === 'PREMOD') {
commentType = 'premod';
} else if (flagActions && flagActions.length) {
commentType = 'flagged';
}
return (
<li
tabIndex={props.index}
className={`mdl-card ${selectionStateCSS} ${styles.Comment} ${styles.listItem} ${minimal ? styles.minimal : ''}`}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
{
!minimal && (
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
{comment.user.name}
</span>
)
}
{
minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
<input
className={styles.bulkSelectInput}
type='checkbox'
value={comment.id}
checked={selected}
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
)
}
<span className={styles.created}>
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
</span>
{props.currentUserId !== comment.user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={() => props.showSuspendUserDialog(comment.user.id, comment.user.name, comment.id, comment.status)}>
Suspend User</ActionsMenuItem>
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')}>
Ban User
</ActionsMenuItem>
</ActionsMenu>
}
<CommentType type={commentType} />
</div>
{comment.user.status === 'banned'
? <span className={styles.banned}>
<Icon name="error_outline" />
{t('comment.banned_user')}
// since words are checked against word boundaries on the backend,
// should be the behavior on the front end as well.
// 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);
})
.concat(linkText);
let selectionStateCSS;
if (minimal) {
selectionStateCSS = selected ? styles.minimalSelection : '';
} else {
selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
}
const showSuspenUserDialog = () => props.showSuspendUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
const showBanUserDialog = () => props.showBanUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
return (
<li
tabIndex={props.index}
className={cn(className, 'mdl-card', selectionStateCSS, styles.Comment, styles.listItem, {[styles.minimal]: minimal, [styles.selected]: selected})}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
{
!minimal && (
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
{comment.user.username}
</span>
)
}
{
minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
<input
className={styles.bulkSelectInput}
type='checkbox'
value={comment.id}
checked={selected}
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
)
}
<span className={styles.created}>
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
</span>
: null}
<Slot
data={props.data}
root={props.root}
fill="adminCommentInfoBar"
comment={comment}
/>
</div>
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!props.currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
</div>
<div className={styles.itemBody}>
<p className={styles.body}>
<Highlighter
searchWords={searchWords}
textToHighlight={comment.body}
/>
{' '}
<a
className={styles.external}
href={`${comment.asset.url}#${comment.id}`}
target="_blank"
>
<Icon name="open_in_new" /> {t('comment.view_context')}
</a>
</p>
<Slot
data={props.data}
root={props.root}
fill="adminCommentContent"
comment={comment}
/>
<div className={styles.sideActions}>
{links
? <span className={styles.hasLinks}>
<Icon name="error_outline" /> Contains Link
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
{props.currentUserId !== comment.user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={showSuspenUserDialog}>
Suspend User</ActionsMenuItem>
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={showBanUserDialog}>
Ban User
</ActionsMenuItem>
</ActionsMenu>
}
<CommentType type={commentType} />
</div>
{comment.user.status === 'banned'
? <span className={styles.banned}>
<Icon name="error_outline" />
{t('comment.banned_user')}
</span>
: null}
<div className={`actions ${styles.actions}`}>
{actions.map((action, i) => {
const active =
(action === 'REJECT' && comment.status === 'REJECTED') ||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
return (
<ActionButton
minimal={minimal}
key={i}
type={action}
user={comment.user}
status={comment.status}
active={active}
acceptComment={() =>
(comment.status === 'ACCEPTED'
? null
: props.acceptComment({commentId: comment.id}))}
rejectComment={() =>
(comment.status === 'REJECTED'
? null
: props.rejectComment({commentId: comment.id}))}
/>
);
})}
</div>
<Slot
data={props.data}
root={props.root}
fill="adminSideActions"
comment={comment}
/>
<Slot
data={props.data}
root={props.root}
fill="adminCommentInfoBar"
comment={comment}
/>
</div>
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!props.currentAsset &&
<Link to={`/admin/moderate/all/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
</div>
<CSSTransitionGroup
component={'div'}
style={{position: 'relative'}}
transitionName={{
enter: styles.bodyEnter,
enterActive: styles.bodyEnterActive,
leave: styles.bodyLeave,
leaveActive: styles.bodyLeaveActive,
}}
transitionEnter={true}
transitionLeave={true}
transitionEnterTimeout={3600}
transitionLeaveTimeout={2800}
>
<div className={styles.itemBody} key={murmur3(comment.body)}>
<p className={styles.body}>
<Highlighter
searchWords={searchWords}
textToHighlight={comment.body}
/>
{' '}
<a
className={styles.external}
href={`${comment.asset.url}#${comment.id}`}
target="_blank"
>
<Icon name="open_in_new" /> {t('comment.view_context')}
</a>
</p>
<Slot
data={props.data}
root={props.root}
fill="adminCommentContent"
comment={comment}
/>
<div className={styles.sideActions}>
{links
? <span className={styles.hasLinks}>
<Icon name="error_outline" /> Contains Link
</span>
: null}
<div className={`actions ${styles.actions}`}>
{actions.map((action, i) => {
const active =
(action === 'REJECT' && comment.status === 'REJECTED') ||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
return (
<ActionButton
minimal={minimal}
key={i}
type={action}
user={comment.user}
status={comment.status}
active={active}
acceptComment={() =>
(comment.status === 'ACCEPTED'
? null
: props.acceptComment({commentId: comment.id}))}
rejectComment={() =>
(comment.status === 'REJECTED'
? null
: props.rejectComment({commentId: comment.id}))}
/>
);
})}
</div>
<Slot
data={props.data}
root={props.root}
fill="adminSideActions"
comment={comment}
/>
</div>
</div>
</CSSTransitionGroup>
</div>
</div>
<Slot
data={props.data}
root={props.root}
fill="adminCommentDetailArea"
comment={comment}
/>
{flagActions && flagActions.length
? <FlagBox
actions={flagActions}
actionSummaries={flagActionSummaries}
/>
: null}
</li>
);
};
<Slot
data={props.data}
root={props.root}
fill="adminCommentDetailArea"
comment={comment}
/>
{flagActions && flagActions.length
? <FlagBox
actions={flagActions}
actionSummaries={flagActionSummaries}
viewUserDetail={() => viewUserDetail(comment.user.id)}
/>
: null}
</li>
);
}
}
Comment.propTypes = {
minimal: PropTypes.bool,
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
className: PropTypes.string,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
currentAsset: PropTypes.object,
@@ -67,3 +67,17 @@
color: black;
}
}
.username {
color: #393B44;
text-decoration: none;
cursor: pointer;
font-weight: 600;
padding: 2px 5px;
border-radius: 2px;
margin-left: -5px;
transition: background-color 200ms ease;
&:hover {
background-color: #E0E0E0;
}
}
@@ -33,7 +33,7 @@ class FlagBox extends Component {
}
render() {
const {actionSummaries, actions} = this.props;
const {actionSummaries, actions, viewUserDetail} = this.props;
const {showDetail} = this.state;
return (
@@ -59,9 +59,14 @@ class FlagBox extends Component {
<li key={i}>
{this.reasonMap(summary.reason)} (<strong>{summary.count}</strong>)
<ul>
{
actionList.map((action, j) => <li key={`${i}_${j}`} className={styles.subDetail}><span>{action.user.username}</span> {action.message}</li>)
}
{actionList.map((action, j) =>
<li key={`${i}_${j}`} className={styles.subDetail}>
<a className={styles.username} onClick={viewUserDetail}>
{action.user.username}
</a>
{action.message}
</li>
)}
</ul>
</li>
);
@@ -2,18 +2,21 @@ import React, {Component} from 'react';
import key from 'keymaster';
import styles from './styles.css';
import BanUserDialog from './BanUserDialog';
import SuspendUserDialog from './SuspendUserDialog';
import ModerationQueue from './ModerationQueue';
import ModerationMenu from './ModerationMenu';
import ModerationHeader from './ModerationHeader';
import NotFoundAsset from './NotFoundAsset';
import ModerationKeysModal from '../../../components/ModerationKeysModal';
import UserDetail from '../containers/UserDetail';
import StorySearch from '../containers/StorySearch';
export default class Moderation extends Component {
state = {
selectedIndex: 0,
constructor() {
super();
this.state = {
selectedIndex: 0
};
}
componentWillMount() {
@@ -24,12 +27,21 @@ export default class Moderation extends Component {
key('esc', () => toggleModal(false));
key('j', this.select(true));
key('k', this.select(false));
key('r', this.moderate(false));
key('t', this.moderate(true));
key('f', this.moderate(false));
key('d', this.moderate(true));
}
onClose = () => {
this.toggleModal(false);
this.props.toggleModal(false);
}
closeSearch = () => {
const {toggleStorySearch} = this.props;
toggleStorySearch(false);
}
openSearch = () => {
this.props.toggleStorySearch(true);
}
moderate = (accept) => () => {
@@ -49,24 +61,22 @@ export default class Moderation extends Component {
getComments = () => {
const {root, route} = this.props;
const activeTab = route.path === ':id' ? 'premod' : route.path;
return root[activeTab];
return root[activeTab].nodes;
}
select = (next) => () => {
if (next) {
this.setState((prevState) =>
({
...prevState,
selectedIndex: prevState.selectedIndex < this.getComments().length - 1
? prevState.selectedIndex + 1 : prevState.selectedIndex
}));
this.setState((state) => ({
...state,
selectedIndex: state.selectedIndex < this.getComments().length - 1
? state.selectedIndex + 1 : state.selectedIndex
}));
} else {
this.setState((prevState) =>
({
...prevState,
selectedIndex: prevState.selectedIndex > 0 ?
prevState.selectedIndex - 1 : prevState.selectedIndex
}));
this.setState((state) => ({
...state,
selectedIndex: state.selectedIndex > 0
? state.selectedIndex - 1 : state.selectedIndex
}));
}
}
@@ -76,8 +86,8 @@ export default class Moderation extends Component {
key.unbind('esc');
key.unbind('j');
key.unbind('k');
key.unbind('r');
key.unbind('t');
key.unbind('f');
key.unbind('d');
}
componentDidUpdate(_, prevState) {
@@ -92,19 +102,10 @@ export default class Moderation extends Component {
}
render () {
const {root, moderation, settings, assets, viewUserDetail, hideUserDetail, ...props} = this.props;
const providedAssetId = this.props.params.id;
const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path;
let asset;
if (providedAssetId) {
asset = assets.find((asset) => asset.id === this.props.params.id);
if (!asset) {
return <NotFoundAsset assetId={providedAssetId} />;
}
}
const {root, moderation, settings, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props;
const assetId = this.props.params.id;
const {asset} = root;
const comments = root[activeTab];
let activeTabCount;
@@ -128,7 +129,12 @@ export default class Moderation extends Component {
return (
<div>
<ModerationHeader asset={asset} />
<ModerationHeader
searchVisible={this.props.moderation.storySearchVisible}
openSearch={this.openSearch}
closeSearch={this.closeSearch}
asset={asset}
/>
<ModerationMenu
asset={asset}
allCount={root.allCount}
@@ -154,36 +160,19 @@ export default class Moderation extends Component {
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
loadMore={props.loadMore}
assetId={providedAssetId}
assetId={assetId}
sort={this.props.moderation.sortOrder}
commentCount={activeTabCount}
currentUserId={this.props.auth.user.id}
viewUserDetail={viewUserDetail}
hideUserDetail={hideUserDetail}
/>
<BanUserDialog
open={moderation.banDialog}
user={moderation.user}
commentId={moderation.commentId}
commentStatus={moderation.commentStatus}
handleClose={props.hideBanUserDialog}
handleBanUser={props.banUser}
showRejectedNote={moderation.showRejectedNote}
rejectComment={props.rejectComment}
/>
<SuspendUserDialog
open={moderation.suspendUserDialog.show}
username={moderation.suspendUserDialog.username}
userId={moderation.suspendUserDialog.userId}
organizationName={root.settings.organizationName}
onCancel={props.hideSuspendUserDialog}
onPerform={this.props.suspendUser}
/>
<ModerationKeysModal
hideShortcutsNote={props.hideShortcutsNote}
shortcutsNoteVisible={moderation.shortcutsNoteVisible}
open={moderation.modalOpen}
onClose={this.onClose}/>
{moderation.userDetailId && (
<UserDetail
id={moderation.userDetailId}
@@ -195,6 +184,13 @@ export default class Moderation extends Component {
acceptComment={props.acceptComment}
rejectComment={props.rejectComment} />
)}
<StorySearch
assetId={assetId}
moderation={this.props.moderation}
closeSearch={this.closeSearch}
storySearchChange={this.props.storySearchChange}
/>
</div>
);
}
@@ -1,30 +1,35 @@
import React from 'react';
import {Link} from 'react-router';
import React, {PropTypes} from 'react';
import {Icon} from 'coral-ui';
import styles from './styles.css';
import t from 'coral-framework/services/i18n';
const ModerationHeader = (props) => (
const ModerationHeader = ({asset, searchVisible, openSearch, closeSearch}) => {
const trigger = searchVisible ? closeSearch : openSearch;
const searchTriggerIcon = <Icon className={styles.searchTrigger} name={searchVisible ? 'arrow_drop_up' : 'arrow_drop_down'} />;
const title = asset
? <a onClick={trigger} className="mdl-tabs__tab">{asset.title} {searchTriggerIcon}</a>
: <a onClick={trigger} className="mdl-tabs__tab">{t('modqueue.all_streams')} {searchTriggerIcon}</a>;
return (
<div className=''>
<div className={`mdl-tabs ${styles.header}`}>
{
props.asset ?
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
<Link className="mdl-tabs__tab" to="/admin/moderate">{t('modqueue.all_streams')}</Link>
<a className="mdl-tabs__tab" href={props.asset.url} target="_blank">
<span>{props.asset.title}</span>
<Icon className={styles.settingsButton} name="open_in_new"/>
</a>
<Link className="mdl-tabs__tab" to="/admin/stories">Select Stream</Link>
</div>
:
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
<a className="mdl-tabs__tab" />
<a className="mdl-tabs__tab">{t('modqueue.all_streams')}</a>
<Link className="mdl-tabs__tab" to="/admin/stories">{t('modqueue.select_stream')}</Link>
</div>
}
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
{title}
</div>
</div>
</div>
);
);
};
ModerationHeader.propTypes = {
asset: PropTypes.shape({
title: PropTypes.string,
id: PropTypes.string
}),
openSearch: PropTypes.func.isRequired,
closeSearch: PropTypes.func.isRequired
};
export default ModerationHeader;
@@ -6,8 +6,10 @@ import EmptyCard from '../../../components/EmptyCard';
import {actionsMap} from '../helpers/moderationQueueActionsMap';
import LoadMore from './LoadMore';
import t from 'coral-framework/services/i18n';
import {CSSTransitionGroup} from 'react-transition-group';
class ModerationQueue extends React.Component {
isLoadingMore = false;
static propTypes = {
viewUserDetail: PropTypes.func.isRequired,
@@ -22,7 +24,19 @@ class ModerationQueue extends React.Component {
}
loadMore = () => {
this.props.loadMore(this.props.activeTab);
if (!this.isLoadingMore) {
this.isLoadingMore = true;
this.props.loadMore(this.props.activeTab)
.then(() => this.isLoadingMore = false)
.catch((e) => {
this.isLoadingMore = false;
throw e;
});
}
}
constructor(props) {
super(props);
}
componentDidUpdate (prev) {
@@ -43,20 +57,34 @@ class ModerationQueue extends React.Component {
commentCount,
singleView,
viewUserDetail,
activeTab,
...props
} = this.props;
return (
<div id="moderationList" className={`${styles.list} ${singleView ? styles.singleView : ''}`}>
<ul style={{paddingLeft: 0}}>
<CSSTransitionGroup
key={activeTab}
component={'ul'}
style={{paddingLeft: 0}}
transitionName={{
enter: styles.commentEnter,
enterActive: styles.commentEnterActive,
leave: styles.commentLeave,
leaveActive: styles.commentLeaveActive,
}}
transitionEnter={true}
transitionLeave={true}
transitionEnterTimeout={1000}
transitionLeaveTimeout={1000}
>
{
comments.length
? comments.map((comment, i) => {
comments.map((comment, i) => {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
return <Comment
data={this.props.data}
root={this.props.root}
key={i}
key={comment.id}
index={i}
comment={comment}
selected={i === selectedIndex}
@@ -72,9 +100,14 @@ class ModerationQueue extends React.Component {
currentUserId={this.props.currentUserId}
/>;
})
: <EmptyCard>{t('modqueue.empty_queue')}</EmptyCard>
}
</ul>
</CSSTransitionGroup>
{comments.length === 0 &&
<div className={styles.emptyCardContainer}>
<EmptyCard>{t('modqueue.empty_queue')}</EmptyCard>
</div>
}
<LoadMore
loadMore={this.loadMore}
showLoadMore={comments.length < commentCount}
@@ -0,0 +1,30 @@
import React, {PropTypes} from 'react';
import styles from './StorySearch.css';
const formatDate = (date) => {
const d = new Date(date);
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
};
const Story = ({author, title, createdAt, open, id, goToStory}) => {
return (
<li className={styles.story} onClick={() => goToStory(id)}>
<span className={styles.title}>{title}</span>
<div className={styles.meta}>
<span className={styles.author}>By {author}</span>
<span className={styles.createdAt}>{formatDate(createdAt)}</span>
<span className={styles.status}>{open ? 'Open' : 'Closed'}</span>
</div>
</li>
);
};
Story.propTypes = {
id: PropTypes.string.isRequired,
author: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
open: PropTypes.bool.isRequired
};
export default Story;
@@ -0,0 +1,147 @@
.overlay {
position: absolute;
top: 58px;
left: 0;
right: 0;
bottom: 0;
width: 100%;
z-index: 99;
opacity: 0;
}
.container {
position: absolute;
background-color: white;
top: 106px;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
width: 50%;
box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.15);
z-index: 100;
max-width: 820px;
}
.positionShim {
left: -50%;
}
.headInput {
background-color: #efefef;
padding: 10px 27px;
box-sizing: border-box;
}
.searchInput {
width: calc(100% - 100px);
padding: 8px;
height: 100%;
font-size: 16px;
margin-right: 5px;
position: relative;
top: 3px;
box-sizing: border-box;
border-radius: 3px;
border: solid 1px #dfdfdf;
max-height: 45px;
max-width: 600px;
font-weight: 300;
}
.cta > a {
display: inline-block;
cursor: pointer;
color: #000;
text-decoration: none;
font-weight: bold;
font-size: 15px;
margin: 0;
box-sizing: border-box;
font-size: 15px;
font-weight: 500;
padding: 12px 30px;
letter-spacing: 0.25px;
}
/*.storyList {
border-top: 1px solid #ddd;
}*/
.story {
padding: 7px 50px;
border-bottom: 1px solid #ddd;
cursor: pointer;
display: block;
text-decoration: none;
height: 50px;
box-sizing: border-box;
transition: background-color 400ms;
&:hover {
background-color: #efefef;
}
&:last-child {
border-bottom: none;
}
}
.title, .meta {
margin: 0;
color: black;
font-size: 15px;
}
.author, .createdAt, .status {
font-size: 17px;
display: inline-block;
font-size: .8em;
}
.createdAt {
text-align: center;
}
.author {
display: inline-block;
width: 200px;
color: #aaa;
}
.createdAt {
display: inline-block;
width: 200px;
color: #aaa;
}
.searchButton {
width: 90px;
height: 35px;
}
.searchResults {
padding: 7px 27px;
background: #F5F5F5;
}
.searchResults i {
font-size: 16px;
vertical-align: middle;
}
.accessStories {
font-size: 18px;
}
.headlineRecent {
font-size: 15px;
font-weight: 500;
letter-spacing: 0.25px;
vertical-align: middle;
margin-left: 8px;
}
.noResults {
padding: 10px 24px 15px 49px;
}
@@ -0,0 +1,98 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './StorySearch.css';
import {Button, Spinner, Icon} from 'coral-ui';
import Story from './Story';
const StorySearch = (props) => {
const {
root: {
assets = []
},
data: {loading}
} = props;
if (!props.moderation.storySearchVisible) {
return null;
}
return (
<div>
<div className={styles.container} role='alertdialog' onKeyDown={props.handleEsc}>
<div className={styles.positionShim}>
<div className={styles.headInput}>
<input
className={styles.searchInput}
onChange={props.handleSearchChange}
onKeyDown={props.handleEnter}
value={props.searchValue}
autoFocus
/>
<Button
cStyle='blue'
className={styles.searchButton}
onClick={props.search}
raised >
Search
</Button>
</div>
<div className={styles.results}>
{props.assetId &&
<div className={styles.cta}>
<a onClick={props.goToModerateAll}>Moderate comments on All Stories</a>
</div>
}
<div className={styles.storyList}>
{
props.moderation.storySearchString ? (
<div className={styles.searchResults}>
<Icon name="search" />
<span className={styles.headlineRecent}>Search Results</span>
</div>
) : (
<div className={styles.searchResults}>
<Icon name="access_time" />
<span className={styles.headlineRecent}>Most Recent Stories</span>
</div>
)
}
{
loading
? <Spinner />
: assets.map((story, i) => {
const storyOpen = story.closedAt === null || new Date(story.closedAt) > new Date();
return <Story
key={i}
id={story.id}
title={story.title}
createdAt={new Date(story.created_at).toISOString()}
open={storyOpen}
author={story.author}
goToStory={props.goToStory}
/>;
})
}
{assets.length === 0 && <div className={styles.noResults}>No results</div>}
</div>
</div>
</div>
</div>
<div className={styles.overlay} onClick={props.clearAndCloseSearch} />
</div>
);
};
StorySearch.propTypes = {
search: PropTypes.func.isRequired,
goToStory: PropTypes.func.isRequired,
clearAndCloseSearch: PropTypes.func.isRequired,
moderation: PropTypes.object.isRequired,
handleSearchChange: PropTypes.func.isRequired,
assetId: PropTypes.string
};
export default StorySearch;
@@ -35,9 +35,8 @@
border: none;
background-color: transparent;
font-size: 16px;
position: absolute;
width: 90%;
outline: none;
width: calc(100% - 90px);
}
.commentStatuses {
@@ -1,11 +1,14 @@
import React, {PropTypes} from 'react';
import {Button, Drawer} from 'coral-ui';
import styles from './UserDetail.css';
import Slot from 'coral-framework/components/Slot';
import Comment from './Comment';
import styles from './UserDetail.css';
import {Button, Drawer} from 'coral-ui';
import {Slot} from 'coral-framework/components';
import ButtonCopyToClipboard from './ButtonCopyToClipboard';
import {actionsMap} from '../helpers/moderationQueueActionsMap';
import ClickOutside from 'coral-framework/components/ClickOutside';
export default class UserDetail extends React.Component {
static propTypes = {
id: PropTypes.string.isRequired,
hideUserDetail: PropTypes.func.isRequired,
@@ -22,16 +25,6 @@ export default class UserDetail extends React.Component {
bulkReject: PropTypes.func.isRequired,
}
copyPermalink = () => {
this.profile.select();
try {
document.execCommand('copy');
} catch (e) {
/* nothing */
}
}
rejectThenReload = (info) => {
this.props.rejectComment(info).then(() => {
this.props.data.refetch();
@@ -88,86 +81,92 @@ export default class UserDetail extends React.Component {
}
return (
<Drawer handleClickOutside={hideUserDetail}>
<h3>{user.username}</h3>
<Button className={styles.copyButton} onClick={this.copyPermalink}>Copy</Button>
{profile && <input className={styles.profileEmail} readOnly type="text" ref={(ref) => this.profile = ref} value={profile} />}
<Slot
fill="userProfile"
data={this.props.data}
root={this.props.root}
user={user}
/>
<p className={styles.memberSince}><strong>Member since</strong> {new Date(user.created_at).toLocaleString()}</p>
<hr/>
<p>
<strong>Account summary</strong>
<br/><small className={styles.small}>Data represents the last six months of activity</small>
</p>
<div className={styles.stats}>
<div className={styles.stat}>
<p>Total Comments</p>
<p>{totalComments}</p>
</div>
<div className={styles.stat}>
<p>Reject Rate</p>
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
</div>
</div>
{
selectedIds.length === 0
? (
<ul className={styles.commentStatuses}>
<li className={tab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
<li className={tab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
</ul>
)
: (
<div className={styles.bulkActionGroup}>
<Button
onClick={bulkAccept}
className={styles.bulkAction}
cStyle='approve'
icon='done'>
</Button>
<Button
onClick={bulkReject}
className={styles.bulkAction}
cStyle='reject'
icon='close'>
</Button>
{`${selectedIds.length} comments selected`}
</div>
)
}
<ClickOutside onClickOutside={hideUserDetail}>
<Drawer onClose={hideUserDetail}>
<h3>{user.username}</h3>
<div>
<div>
{profile && <input className={styles.profileEmail} readOnly type="text" ref={(ref) => this.profile = ref} value={profile} />}
<ButtonCopyToClipboard className={styles.copyButton} copyText={profile} />
</div>
<Slot
fill="userProfile"
data={this.props.data}
root={this.props.root}
user={user}
/>
<p className={styles.memberSince}><strong>Member since</strong> {new Date(user.created_at).toLocaleString()}</p>
<hr/>
<p>
<strong>Account summary</strong>
<br/><small className={styles.small}>Data represents the last six months of activity</small>
</p>
<div className={styles.stats}>
<div className={styles.stat}>
<p>Total Comments</p>
<p>{totalComments}</p>
</div>
<div className={styles.stat}>
<p>Reject Rate</p>
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
</div>
</div>
{
nodes.map((comment, i) => {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
const selected = selectedIds.indexOf(comment.id) !== -1;
return <Comment
key={i}
index={i}
comment={comment}
selected={false}
suspectWords={suspectWords}
bannedWords={bannedWords}
viewUserDetail={() => {}}
actions={actionsMap[status]}
showBanUserDialog={showBanUserDialog}
showSuspendUserDialog={showSuspendUserDialog}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
selected={selected}
toggleSelect={toggleSelect}
currentAsset={null}
currentUserId={this.props.id}
minimal={true} />;
})
selectedIds.length === 0
? (
<ul className={styles.commentStatuses}>
<li className={tab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
<li className={tab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
</ul>
)
: (
<div className={styles.bulkActionGroup}>
<Button
onClick={bulkAccept}
className={styles.bulkAction}
cStyle='approve'
icon='done'>
</Button>
<Button
onClick={bulkReject}
className={styles.bulkAction}
cStyle='reject'
icon='close'>
</Button>
{`${selectedIds.length} comments selected`}
</div>
)
}
</div>
</Drawer>
<div>
{
nodes.map((comment, i) => {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
const selected = selectedIds.indexOf(comment.id) !== -1;
return <Comment
key={comment.id}
index={i}
comment={comment}
selected={false}
suspectWords={suspectWords}
bannedWords={bannedWords}
viewUserDetail={() => {}}
actions={actionsMap[status]}
showBanUserDialog={showBanUserDialog}
showSuspendUserDialog={showSuspendUserDialog}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
selected={selected}
toggleSelect={toggleSelect}
currentAsset={null}
currentUserId={this.props.id}
minimal={true} />;
})
}
</div>
</Drawer>
</ClickOutside>
);
}
}
@@ -116,29 +116,20 @@ span {
transition: background-color 200ms;
opacity: 1;
&:first-child {
text-align: left;
&:hover {
cursor: pointer;
background-color: #212121;
}
span {
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 344px;
display: inline-block;
vertical-align: top;
}
&:nth-child(2) {
&:hover {
cursor: pointer;
background-color: #212121;
}
span {
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 344px;
display: inline-block;
vertical-align: top;
}
}
&:last-child {
text-align: right;
}
}
}
}
@@ -199,8 +190,6 @@ span {
}
&.selected {
max-width: 720px;
max-height: 410px;
}
.context {
@@ -479,3 +468,63 @@ span {
.bulkSelectInput {
cursor: pointer;
}
.emptyCardContainer {
margin-top: 16px;
}
.commentLeave {
opacity: 1.0;
transition: opacity 800ms;
}
.commentLeaveActive {
opacity: 0;
}
.commentEnter {
opacity: 0;
transition: opacity 800ms;
}
.commentEnterActive {
opacity: 1.0;
}
.bodyLeave {
position: absolute;
width: 100%;
top: 0;
background-color: white;
opacity: 1.0;
transition: background 400ms, opacity 800ms 1600ms;
pointer-events: none;
}
.bodyLeaveActive {
opacity: 0;
background-color: rgba(255,255,0, 0.2);
}
.bodyEnter {
opacity: 0;
pointer-events: none;
}
.bodyEnterActive {
opacity: 1.0;
transition: opacity 800ms 2400ms;
}
.editedMarker {
font-style: italic;
color: #666;
font-size: 12px;
line-height: 1px;
font-weight: 300;
}
.searchTrigger {
position: relative;
top: .3em;
}
@@ -26,7 +26,7 @@ export default withFragments({
status
user {
id
name: username
username
status
}
asset {
@@ -49,6 +49,9 @@ export default withFragments({
}
}
}
editing {
edited
}
${pluginFragments.spreads('comment')}
}
${pluginFragments.definitions('comment')}
@@ -2,75 +2,137 @@ import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose, gql} from 'react-apollo';
import isEqual from 'lodash/isEqual';
import withQuery from 'coral-framework/hocs/withQuery';
import {getDefinitionName} from 'coral-framework/utils';
import * as notification from 'coral-admin/src/services/notification';
import t, {timeago} from 'coral-framework/services/i18n';
import t from 'coral-framework/services/i18n';
import update from 'immutability-helper';
import truncate from 'lodash/truncate';
import NotFoundAsset from '../components/NotFoundAsset';
import {withSetUserStatus, withSuspendUser, withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {handleCommentChange} from '../../../graphql/utils';
import {fetchSettings} from 'actions/settings';
import {updateAssets} from 'actions/assets';
import {showBanUserDialog} from 'actions/banUserDialog';
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
import {
toggleModal,
singleView,
showBanUserDialog,
hideBanUserDialog,
showSuspendUserDialog,
hideSuspendUserDialog,
hideShortcutsNote,
toggleStorySearch,
viewUserDetail,
hideUserDetail,
setSortOrder,
storySearchChange,
clearState
} from 'actions/moderation';
import {Spinner} from 'coral-ui';
import Moderation from '../components/Moderation';
import Comment from './Comment';
function prepareNotificationText(text) {
return truncate(text, {length: 50}).replace('\n', ' ');
}
class ModerationContainer extends Component {
subscriptions = [];
get activeTab() { return this.props.route.path; }
subscribeToUpdates(variables = this.props.data.variables) {
const sub1 = this.props.data.subscribeToMore({
document: COMMENT_ACCEPTED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const sort = this.props.moderation.sortOrder;
const notify = this.props.auth.user.id === user.id
? {}
: {
activeQueue: this.activeTab,
text: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body)),
anyQueue: false,
};
return handleCommentChange(prev, comment, sort, notify);
},
});
const sub2 = this.props.data.subscribeToMore({
document: COMMENT_REJECTED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const sort = this.props.moderation.sortOrder;
const notify = this.props.auth.user.id === user.id
? {}
: {
activeQueue: this.activeTab,
text: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body)),
anyQueue: false,
};
return handleCommentChange(prev, comment, sort, notify);
},
});
const sub3 = this.props.data.subscribeToMore({
document: COMMENT_EDITED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => {
const sort = this.props.moderation.sortOrder;
const notify = {
activeQueue: this.activeTab,
text: t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body)),
anyQueue: false,
};
return handleCommentChange(prev, comment, sort, notify);
},
});
const sub4 = this.props.data.subscribeToMore({
document: COMMENT_FLAGGED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => {
const user = comment.actions[comment.actions.length - 1].user;
const sort = this.props.moderation.sortOrder;
const notify = {
activeQueue: this.activeTab,
text: t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)),
anyQueue: true,
};
return handleCommentChange(prev, comment, sort, notify);
},
});
this.subscriptions.push(sub1, sub2, sub3, sub4);
}
unsubscribe() {
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.subscriptions = [];
}
resubscribe(variables) {
this.unsubscribe();
this.subscribeToUpdates(variables);
}
componentWillMount() {
this.props.clearState();
this.props.fetchSettings();
this.subscribeToUpdates();
}
componentWillUnmount() {
this.unsubscribe();
}
componentWillReceiveProps(nextProps) {
const {updateAssets} = this.props;
if(!isEqual(nextProps.root.assets, this.props.root.assets)) {
updateAssets(nextProps.root.assets);
}
}
suspendUser = async (args) => {
this.props.hideSuspendUserDialog();
try {
const result = await this.props.suspendUser(args);
if (result.data.suspendUser.errors) {
throw result.data.suspendUser.errors;
}
notification.success(
t('suspenduser.notify_suspend_until',
this.props.moderation.suspendUserDialog.username,
timeago(args.until)),
);
const {commentStatus, commentId} = this.props.moderation.suspendUserDialog;
if (commentStatus !== 'REJECTED') {
return this.props.rejectComment({commentId})
.then((result) => {
if (result.data.setCommentStatus.errors) {
throw result.data.setCommentStatus.errors;
}
});
}
// Resubscribe when we change between assets.
if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) {
this.resubscribe(nextProps.data.variables);
}
catch(err) {
notification.showMutationErrors(err);
}
};
banUser = ({userId}) => {
return this.props.setUserStatus({userId, status: 'BANNED'});
}
acceptComment = ({commentId}) => {
@@ -113,6 +175,9 @@ class ModerationContainer extends Component {
return update(prev, {
[tab]: {
nodes: {$push: comments.nodes},
hasNextPage: {$set: comments.hasNextPage},
startCursor: {$set: comments.startCursor},
endCursor: {$set: comments.endCursor},
},
});
}
@@ -120,39 +185,100 @@ class ModerationContainer extends Component {
};
render () {
const {root, data} = this.props;
const {root, root: {asset}, data, params: {id: assetId}} = this.props;
if (data.error) {
return <div>Error</div>;
}
if (!('premodCount' in root)) {
return <div><Spinner/></div>;
if (assetId) {
if (asset === null) {
// Not found.
return <NotFoundAsset assetId={assetId} />;
}
if (asset === undefined || asset.id !== assetId) {
// Still loading.
return <Spinner />;
}
} else if(asset !== undefined || !('premodCount' in root)) {
// loading.
return <Spinner />;
}
return <Moderation
{...this.props}
loadMore={this.loadMore}
banUser={this.banUser}
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
suspendUser={this.suspendUser}
activeTab={this.activeTab}
/>;
}
}
const COMMENT_EDITED_SUBSCRIPTION = gql`
subscription CommentEdited($asset_id: ID){
commentEdited(asset_id: $asset_id){
...${getDefinitionName(Comment.fragments.comment)}
}
}
${Comment.fragments.comment}
`;
const COMMENT_FLAGGED_SUBSCRIPTION = gql`
subscription CommentFlagged($asset_id: ID){
commentFlagged(asset_id: $asset_id){
...${getDefinitionName(Comment.fragments.comment)}
}
}
${Comment.fragments.comment}
`;
const COMMENT_ACCEPTED_SUBSCRIPTION = gql`
subscription CommentAccepted($asset_id: ID){
commentAccepted(asset_id: $asset_id){
...${getDefinitionName(Comment.fragments.comment)}
status_history {
type
created_at
assigned_by {
id
username
}
}
}
}
${Comment.fragments.comment}
`;
const COMMENT_REJECTED_SUBSCRIPTION = gql`
subscription CommentRejected($asset_id: ID){
commentRejected(asset_id: $asset_id){
...${getDefinitionName(Comment.fragments.comment)}
status_history {
type
created_at
assigned_by {
id
username
}
}
}
}
${Comment.fragments.comment}
`;
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $sort: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sort: $sort, action_type: $action_type}) {
nodes {
...${getDefinitionName(Comment.fragments.comment)}
action_summaries {
count
... on FlagActionSummary {
reason
}
}
}
hasNextPage
startCursor
endCursor
}
}
${Comment.fragments.comment}
@@ -171,7 +297,7 @@ const commentConnectionFragment = gql`
`;
const withModQueueQuery = withQuery(gql`
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER) {
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) {
all: comments(query: {
statuses: [NONE, PREMOD, ACCEPTED, REJECTED],
asset_id: $asset_id,
@@ -208,7 +334,7 @@ const withModQueueQuery = withQuery(gql`
}) {
...CoralAdmin_Moderation_CommentConnection
}
assets: assets {
asset(id: $asset_id) @skip(if: $allAssets) {
id
title
url
@@ -244,6 +370,7 @@ const withModQueueQuery = withQuery(gql`
variables: {
asset_id: id,
sort: sortOrder,
allAssets: id === null
}
};
},
@@ -287,31 +414,28 @@ const mapStateToProps = (state) => ({
moderation: state.moderation.toJS(),
settings: state.settings.toJS(),
auth: state.auth.toJS(),
assets: state.assets.get('assets')
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
toggleModal,
singleView,
updateAssets,
fetchSettings,
showBanUserDialog,
hideBanUserDialog,
hideShortcutsNote,
toggleStorySearch,
showSuspendUserDialog,
hideSuspendUserDialog,
viewUserDetail,
hideUserDetail,
setSortOrder,
storySearchChange,
clearState
}, dispatch),
});
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withSetCommentStatus,
withSetUserStatus,
withSuspendUser,
withQueueCountPolling,
withModQueueQuery,
)(ModerationContainer);
@@ -0,0 +1,113 @@
import React from 'react';
import {compose, gql} from 'react-apollo';
import StorySearch from '../components/StorySearch';
import {withRouter} from 'react-router';
import withQuery from 'coral-framework/hocs/withQuery';
import {isEmpty} from 'lodash';
class StorySearchContainer extends React.Component {
constructor(props) {
super(props);
this.state = {
searchValue: props.moderation.storySearchString
};
}
componentWillUnmount() {
this.props.storySearchChange('');
}
clearSearch = () => {
this.setState({searchValue: ''}, () => {
this.search();
});
}
clearAndCloseSearch = () => {
if (!isEmpty(this.state.searchValue)) {
this.clearSearch();
}
this.props.closeSearch();
}
handleSearchChange = (e) => {
const {value} = e.target;
this.setState({
searchValue: value
});
}
handleEsc = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.clearAndCloseSearch();
}
}
handleEnter = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.search();
}
}
search = () => {
const {searchValue} = this.state;
this.props.storySearchChange(searchValue);
}
goToStory = (id) => {
const {router} = this.props;
router.push(`/admin/moderate/all/${id}`);
this.clearAndCloseSearch();
}
goToModerateAll = () => {
const {router} = this.props;
router.push('/admin/moderate/all');
this.clearAndCloseSearch();
}
render () {
return (
<StorySearch
search={this.search}
goToStory={this.goToStory}
goToModerateAll={this.goToModerateAll}
handleEsc={this.handleEsc}
handleEnter={this.handleEnter}
searchValue={this.state.searchValue}
handleSearchChange={this.handleSearchChange}
clearAndCloseSearch={this.clearAndCloseSearch}
{...this.props}
/>
);
}
}
export const withAssetSearchQuery = withQuery(gql`
query SearchStories($value: String = "") {
assets(query: {value: $value, limit: 10}) {
id
title
url
created_at
closedAt
author
}
}
`, {
options: ({moderation: {storySearchString = ''}}) => {
return {
variables: {
value: storySearchString
}
};
}
});
export default compose(
withRouter,
withAssetSearchQuery
)(StorySearchContainer);
@@ -5,9 +5,9 @@ export const actionsMap = {
};
export const menuActionsMap = {
'REJECT': {status: 'REJECTED', text: 'reject', icon: 'close', key: 'r'},
'REJECT': {status: 'REJECTED', text: 'reject', icon: 'close', key: 'f'},
'REJECTED': {status: 'REJECTED', text: 'rejected', icon: 'close'},
'APPROVE': {status: 'ACCEPTED', text: 'approve', icon: 'done', key: 't'},
'APPROVE': {status: 'ACCEPTED', text: 'approve', icon: 'done', key: 'd'},
'FLAGGED': {status: 'FLAGGED', text: 'flag', icon: 'flag', filter: 'Untouched'},
'BAN': {status: 'BANNED', text: 'ban_user', icon: 'not interested'},
'': {icon: 'done'}
+4 -18
View File
@@ -1,20 +1,6 @@
import ApolloClient, {addTypename} from 'apollo-client';
import {networkInterface} from 'coral-framework/services/transport';
import {getClient as getFrameworkClient} from 'coral-framework/services/client';
import fragmentMatcher from './fragmentMatcher';
export const client = new ApolloClient({
fragmentMatcher,
connectToDevTools: true,
addTypename: true,
queryTransformer: addTypename,
dataIdFromObject: (result) => {
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle
}
return null;
},
networkInterface
});
export default client;
export function getClient() {
return getFrameworkClient({fragmentMatcher});
}
+3 -3
View File
@@ -1,10 +1,10 @@
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from '../reducers';
import {client} from './client';
import {getClient} from './client';
const middlewares = [
applyMiddleware(client.middleware()),
applyMiddleware(getClient().middleware()),
applyMiddleware(thunk)
];
@@ -16,7 +16,7 @@ if (window.devToolsExtension) {
const coralReducers = {
...mainReducer,
apollo: client.reducer()
apollo: getClient().reducer()
};
const store = createStore(
@@ -26,6 +26,7 @@ import Slot from 'coral-framework/components/Slot';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import {EditableCommentContent} from './EditableCommentContent';
import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils';
import t from 'coral-framework/services/i18n';
const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF');
const hasTag = (tags, lookupTag) => !!tags.filter((t) => t.tag.name === lookupTag).length;
@@ -75,6 +76,8 @@ const ActionButton = ({children}) => {
};
export default class Comment extends React.Component {
isLoadingReplies = false;
constructor(props) {
super(props);
@@ -210,14 +213,24 @@ export default class Comment extends React.Component {
}
loadNewReplies = () => {
const {replies, replyCount, id} = this.props.comment;
if (replyCount > replies.nodes.length) {
this.props.loadMore(id).then(() => {
this.setState(resetCursors(this.state, this.props));
});
return;
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;
});
return;
}
this.setState(resetCursors);
this.isLoadingReplies = false;
}
this.setState(resetCursors);
};
showReplyBox = () => {
@@ -415,7 +428,7 @@ export default class Comment extends React.Component {
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>(Edited)</span></span>
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
</span>
@@ -431,28 +444,28 @@ export default class Comment extends React.Component {
inline
/>
{ (currentUser &&
(comment.user.id === currentUser.id))
{ (currentUser && (comment.user.id === currentUser.id)) &&
/* User can edit/delete their own comment for a short window after posting */
? <span className={cn(styles.topRight)}>
{
commentIsStillEditable(comment) &&
<a
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
onClick={this.onClickEdit}>Edit</a>
}
</span>
/* User can edit/delete their own comment for a short window after posting */
<span className={cn(styles.topRight)}>
{
commentIsStillEditable(comment) &&
<a
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
onClick={this.onClickEdit}>Edit</a>
}
</span>
}
{ (currentUser && (comment.user.id !== currentUser.id)) &&
/* TopRightMenu allows currentUser to ignore other users' comments */
: <span className={cn(styles.topRight, styles.topRightMenu)}>
<TopRightMenu
comment={comment}
ignoreUser={ignoreUser}
addNotification={addNotification} />
</span>
<span className={cn(styles.topRight, styles.topRightMenu)}>
<TopRightMenu
comment={comment}
ignoreUser={ignoreUser}
addNotification={addNotification} />
</span>
}
{
this.state.isEditing
? <EditableCommentContent
@@ -80,14 +80,17 @@ export class EditableCommentContent extends React.Component {
}
successfullyEdited = true;
} catch (error) {
if (error.translation_key) {
addNotification('error', t(`error.${error.translation_key}`));
} else if (error.networkError) {
addNotification('error', t('error.network_error'));
} else {
addNotification('error', t('edit_comment.unexpected_error'));
throw 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) {
const status = response.data.editComment.comment.status;
@@ -23,15 +23,17 @@ class LoadMore extends React.Component {
}
}
loadMore = () => {
this.initialState = false;
this.props.loadMore();
}
render () {
const {topLevel, moreComments, loadMore, replyCount} = this.props;
const {topLevel, moreComments, replyCount} = this.props;
return moreComments
? <div className='coral-load-more'>
<Button
onClick={() => {
this.initialState = false;
loadMore();
}}>
onClick={this.loadMore}>
{topLevel ? t('framework.view_more_comments') : this.replyCountFormat(replyCount)}
</Button>
</div>
@@ -53,6 +53,8 @@ function invalidateCursor(invalidated, state, props) {
class Stream extends React.Component {
isLoadingMore = false;
constructor(props) {
super(props);
this.state = resetCursors(this.state, props);
@@ -95,6 +97,18 @@ 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;
});
}
}
// getVisibileComments returns a list containing comments
// which were authored by current user or comes after the `idCursor`.
getVisibleComments() {
@@ -159,7 +173,7 @@ class Stream extends React.Component {
};
return (
<div id="stream">
<Slot fill="stream" />
{open
? <div id="commentBox">
<InfoBox
@@ -183,6 +197,7 @@ class Stream extends React.Component {
<SuspendedAccount
canEditName={user && user.canEditName}
editName={editName}
currentUsername={user.username}
/>}
{loggedIn &&
!banned &&
@@ -203,6 +218,8 @@ class Stream extends React.Component {
</div>
: <p>{asset.settings.closedMessage}</p>}
<Slot fill="stream" />
{loggedIn && (
<ModerationLink
assetId={asset.id}
@@ -286,7 +303,7 @@ class Stream extends React.Component {
<LoadMore
topLevel={true}
moreComments={asset.comments.hasNextPage}
loadMore={this.props.loadMoreComments}
loadMore={this.loadMoreComments}
/>
</div>}
</div>
@@ -9,7 +9,8 @@ class SuspendedAccount extends Component {
static propTypes = {
canEditName: PropTypes.bool,
editName: PropTypes.func.isRequired
editName: PropTypes.func.isRequired,
currentUsername: PropTypes.string.isRequired,
}
state = {
@@ -21,7 +22,11 @@ class SuspendedAccount extends Component {
const {editName} = this.props;
const {username} = this.state;
e.preventDefault();
if (validate.username(username)) {
if (username === this.props.currentUsername) {
this.setState({alert: t('error.SAME_USERNAME_PROVIDED')});
}
else if (validate.username(username)) {
editName(username)
.then(() => location.reload())
.catch((error) => {
@@ -0,0 +1,39 @@
import React from 'react';
import ClickOutside from 'coral-framework/components/ClickOutside';
import styles from './Toggleable.css';
import classnames from 'classnames';
const upArrow = <span className={classnames(styles.chevron, styles.up)}></span>;
const downArrow = <span className={classnames(styles.chevron, styles.down)}></span>;
export default class Toggleable extends React.Component {
constructor(props) {
super(props);
this.state = {
isOpen: false
};
}
toggle = () => {
this.setState({isOpen: !this.state.isOpen});
}
close = () => {
this.setState({isOpen: false});
}
render() {
const {children} = this.props;
const {isOpen} = this.state;
return (
<ClickOutside onClickOutside={this.close}>
<span className={styles.Toggleable} tabIndex="0" >
<span className={styles.toggler}
onClick={this.toggle}>{isOpen ? upArrow : downArrow}</span>
{isOpen ? children : null}
</span>
</ClickOutside>
);
}
}
@@ -1,8 +1,6 @@
import React, {PropTypes} from 'react';
import classnames from 'classnames';
import {IgnoreUserWizard} from './IgnoreUserWizard';
import styles from './TopRightMenu.css';
import Toggleable from './Toggleable';
// TopRightMenu appears as a dropdown in the top right of the comment.
// when you click the down cehvron, it expands and shows IgnoreUserWizard
@@ -56,38 +54,6 @@ export class TopRightMenu extends React.Component {
/>
</div>
</Toggleable>
);
}
}
const upArrow = <span className={classnames(styles.chevron, styles.up)}></span>;
const downArrow = <span className={classnames(styles.chevron, styles.down)}></span>;
class Toggleable extends React.Component {
constructor(props) {
super(props);
this.toggle = this.toggle.bind(this);
this.close = this.close.bind(this);
this.state = {
isOpen: false
};
}
toggle() {
this.setState({isOpen: !this.state.isOpen});
}
close() {
this.setState({isOpen: false});
}
render() {
const {children} = this.props;
const {isOpen} = this.state;
return (
// /*onBlur={ this.close } */
<span className={styles.Toggleable} tabIndex="0" >
<span className={styles.toggler}
onClick={this.toggle}>{isOpen ? upArrow : downArrow}</span>
{isOpen ? children : null}
</span>
);
}
}
+4 -6
View File
@@ -17,6 +17,10 @@ body {
height: auto !important;
}
#coralStream {
padding: 4px;
}
.expandForSignin {
min-height: 600px;
}
@@ -183,7 +187,6 @@ hr {
.talk-stream-comments-container {
position: relative;
z-index: 0;
}
.commentStream {
@@ -479,11 +482,6 @@ button.comment__action-button[disabled],
}
@media (min-device-width : 300px) and (max-device-width : 420px) {
.commentActionsLeft.comment__action-container .coral-plugin-likes-button-text,
.commentActionsLeft.comment__action-container > div span {
display: none;
}
.commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button {
visibility: collapse;
margin-left: -30px;
+11
View File
@@ -54,6 +54,12 @@ function configurePymParent(pymParent, opts) {
pymParent.sendMessage('config', JSON.stringify(config));
}
pymParent.onMessage('coral-auth-changed', function(message) {
if (opts.onAuthChanged) {
opts.onAuthChanged(message ? JSON.parse(message) : null);
}
});
// Sends config to the child
pymParent.onMessage('getConfig', function() {
sendConfig(opts || {});
@@ -67,6 +73,11 @@ function configurePymParent(pymParent, opts) {
window.document.body.appendChild(snackbar);
// Notify embed that there was a click outside.
document.addEventListener('click', () => {
pymParent.sendMessage('click');
}, true);
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
pymParent.el.firstChild.style.width = '1px';
pymParent.el.firstChild.style.minWidth = '100%';
+9 -3
View File
@@ -10,7 +10,7 @@ export const fetchAssetFailure = (error) => ({type: actions.FETCH_ASSET_FAILURE,
const updateAssetSettingsRequest = () => ({type: actions.UPDATE_ASSET_SETTINGS_REQUEST});
const updateAssetSettingsSuccess = (settings) => ({type: actions.UPDATE_ASSET_SETTINGS_SUCCESS, settings});
const updateAssetSettingsFailure = () => ({type: actions.UPDATE_ASSET_SETTINGS_FAILURE});
const updateAssetSettingsFailure = (error) => ({type: actions.UPDATE_ASSET_SETTINGS_FAILURE, error});
export const updateConfiguration = (newConfig) => (dispatch, getState) => {
const assetId = getState().asset.toJS().id;
@@ -20,7 +20,10 @@ export const updateConfiguration = (newConfig) => (dispatch, getState) => {
dispatch(addNotification('success', t('framework.success_update_settings')));
dispatch(updateAssetSettingsSuccess(newConfig));
})
.catch((error) => dispatch(updateAssetSettingsFailure(error)));
.catch((error) => {
console.error(error);
dispatch(updateAssetSettingsFailure(error));
});
};
export const updateOpenStream = (closedBody) => (dispatch, getState) => {
@@ -31,7 +34,10 @@ export const updateOpenStream = (closedBody) => (dispatch, getState) => {
dispatch(addNotification('success', t('framework.success_update_settings')));
dispatch(fetchAssetSuccess(closedBody));
})
.catch((error) => dispatch(fetchAssetFailure(error)));
.catch((error) => {
console.error(error);
dispatch(fetchAssetFailure(error));
});
};
const openStream = () => ({type: actions.OPEN_COMMENTS});
+28 -18
View File
@@ -3,6 +3,7 @@ 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 {resetWebsocket} from 'coral-framework/services/client';
import t from 'coral-framework/services/i18n';
@@ -74,7 +75,9 @@ export const createUsername = (userId, formData) => (dispatch) => {
dispatch(updateUsername(formData));
})
.catch((error) => {
dispatch(createUsernameFailure(t(`error.${error.translation_key}`)));
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(createUsernameFailure(errorMessage));
});
};
@@ -138,16 +141,20 @@ export const fetchSignIn = (formData) => {
dispatch(hideSignInDialog());
})
.catch((error) => {
console.error(error);
if (error.metadata) {
// the user might not have a valid email. prompt the user user re-request the confirmation email
dispatch(
signInFailure(t('error.email_not_verified', error.metadata))
);
} else {
} else if (error.translation_key === 'NOT_AUTHORIZED') {
// invalid credentials
dispatch(signInFailure(t('error.email_password')));
dispatch(signInFailure(t('error.email_password'), error.metadata));
} else {
const str = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(signInFailure(str));
}
});
};
@@ -233,12 +240,8 @@ export const fetchSignUp = (formData) => (dispatch, getState) => {
dispatch(signUpSuccess(user));
})
.catch((error) => {
let errorMessage = t(`error.${error.message}`);
// if there is no translation defined, just show the error string
if (errorMessage === `error.${error.message}`) {
errorMessage = error.message;
}
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(signUpFailure(errorMessage));
});
};
@@ -255,8 +258,9 @@ const forgotPasswordSuccess = () => ({
type: actions.FETCH_FORGOT_PASSWORD_SUCCESS
});
const forgotPasswordFailure = () => ({
type: actions.FETCH_FORGOT_PASSWORD_FAILURE
const forgotPasswordFailure = (error) => ({
type: actions.FETCH_FORGOT_PASSWORD_FAILURE,
error,
});
export const fetchForgotPassword = (email) => (dispatch, getState) => {
@@ -267,7 +271,11 @@ export const fetchForgotPassword = (email) => (dispatch, getState) => {
body: {email, loc: redirectUri}
})
.then(() => dispatch(forgotPasswordSuccess()))
.catch((error) => dispatch(forgotPasswordFailure(error)));
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(forgotPasswordFailure(errorMessage));
});
};
//==============================================================================
@@ -284,6 +292,7 @@ export const logout = () => (dispatch) => {
resetWebsocket();
dispatch({type: actions.LOGOUT});
pym.sendMessage('coral-auth-changed');
});
};
@@ -315,6 +324,7 @@ export const checkLogin = () => (dispatch) => {
resetWebsocket();
dispatch(checkLoginSuccess(result.user));
pym.sendMessage('coral-auth-changed', JSON.stringify(result.user));
// Display create username dialog if necessary.
if (result.user.canEditName && result.user.status !== 'BANNED') {
@@ -323,7 +333,8 @@ export const checkLogin = () => (dispatch) => {
})
.catch((error) => {
console.error(error);
dispatch(checkLoginFailure(`${error.translation_key}`));
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(checkLoginFailure(errorMessage));
});
};
@@ -357,11 +368,10 @@ export const requestConfirmEmail = (email) => (dispatch, getState) => {
.then(() => {
dispatch(verifyEmailSuccess());
})
.catch((err) => {
// email might have already been verifyed
dispatch(verifyEmailFailure(err));
throw err;
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(verifyEmailFailure(errorMessage));
});
};
+3 -1
View File
@@ -14,6 +14,8 @@ export const editName = (username) => (dispatch) => {
dispatch(addNotification('success', t('framework.success_name_update')));
})
.catch((error) => {
dispatch(editUsernameFailure(t(`error.${error.translation_key}`)));
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(editUsernameFailure(errorMessage));
});
};
@@ -0,0 +1,35 @@
import {Component, cloneElement, Children} from 'react';
import PropTypes from 'prop-types';
import {findDOMNode} from 'react-dom';
import {pym} from 'coral-framework';
export default class ClickOutside extends Component {
static propTypes = {
onClickOutside: PropTypes.func.isRequired
};
domNode = null;
handleClick = (e) => {
const {onClickOutside} = this.props;
if (!e || !this.domNode.contains(e.target)) {
onClickOutside(e);
}
};
componentDidMount() {
this.domNode = findDOMNode(this);
document.addEventListener('click', this.handleClick, true);
pym.onMessage('click', this.handleClick);
}
componentWillUnmount() {
document.removeEventListener('click', this.handleClick, true);
pym.messageHandlers.click = pym.messageHandlers.click.filter((h) => h !== this.handleClick);
}
render() {
const {children, onClickOutside: _, ...rest} = this.props;
return cloneElement(Children.only(children), rest);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
export {default as withFragments} from './withFragments';
export {default as withMutation} from './withMutation';
export {default as withQuery} from './withQuery';
export {default as withCopyToClipboard} from './withCopyToClipboard';
@@ -0,0 +1,29 @@
import React from 'react';
import ReactDOM from 'react-dom';
import Clipboard from 'clipboard';
export default (WrappedComponent) => {
class WithCopyToClipboard extends React.Component {
componentDidMount() {
const clipboard = new Clipboard(ReactDOM.findDOMNode(this));
clipboard.on('success', (e) => {
e.clearSelection();
});
}
render() {
const {copyTarget = '', copyText = '', className = '', ...rest} = this.props;
return <WrappedComponent
className={className}
data-clipboard-action="copy"
data-clipboard-text={copyText}
data-clipboard-target={copyTarget}
{...rest}
/>;
}
}
return WithCopyToClipboard;
};
+1 -1
View File
@@ -30,7 +30,7 @@ class ResponseError {
* Exports a HOC with the same signature as `graphql`, that will
* apply mutation options registered in the graphRegistry.
*/
export default (document, config) => (WrappedComponent) => {
export default (document, config = {}) => (WrappedComponent) => {
config = {
...config,
options: config.options || {},
+1 -1
View File
@@ -14,7 +14,7 @@ const withSkipOnErrors = (reducer) => (prev, action, ...rest) => {
* Exports a HOC with the same signature as `graphql`, that will
* apply query options registered in the graphRegistry.
*/
export default (document, config) => (WrappedComponent) => {
export default (document, config = {}) => (WrappedComponent) => {
config = {
...config,
options: config.options || {},
+2 -1
View File
@@ -26,7 +26,7 @@ export function resetWebsocket() {
});
}
export function getClient() {
export function getClient(options = {}) {
if (client) {
return client;
}
@@ -56,6 +56,7 @@ export function getClient() {
);
client = new ApolloClient({
...options,
connectToDevTools: true,
addTypename: true,
queryTransformer: addTypename,
@@ -20,7 +20,7 @@ describe('CommentBox', () => {
});
it('should render the CommentBox appropriately', () => {
expect(render.contains('<div class="CommentBox"')).to.be.truthy;
expect(render.contains('<button class="postCommentButton"')).to.be.truthy;
expect(render.contains('<div class="CommentBox"')).to.be.true;
expect(render.contains('<button class="postCommentButton"')).to.be.true;
});
});
+67 -65
View File
@@ -4,11 +4,11 @@ import t from 'coral-framework/services/i18n';
import {can} from 'coral-framework/services/perms';
import {PopupMenu, Button} from 'coral-ui';
import onClickOutside from 'react-onclickoutside';
import ClickOutside from 'coral-framework/components/ClickOutside';
const name = 'coral-plugin-flags';
class FlagButton extends Component {
export default class FlagButton extends Component {
state = {
showMenu: false,
@@ -128,7 +128,7 @@ class FlagButton extends Component {
this.setState({message: e.target.value});
}
handleClickOutside () {
handleClickOutside = () => {
this.closeMenu();
}
@@ -138,79 +138,81 @@ class FlagButton extends Component {
const flagged = flaggedByCurrentUser || localPost;
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
return <div className={`${name}-container`}>
<button
ref={(ref) => this.flagButton = ref}
onClick={!this.props.banned && !flaggedByCurrentUser && !localPost ? this.onReportClick : null}
className={`${name}-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 : {}}
aria-hidden={true}>flag</i>
</button>
{
this.state.showMenu &&
<div className={`${name}-popup`} ref={(ref) => this.popup = ref}>
<PopupMenu>
<div className={`${name}-popup-header`}>{popupMenu.header}</div>
return (
<ClickOutside onClickOutside={this.handleClickOutside}>
<div className={`${name}-container`}>
<button
ref={(ref) => this.flagButton = ref}
onClick={!this.props.banned && !flaggedByCurrentUser && !localPost ? this.onReportClick : null}
className={`${name}-button`}>
{
popupMenu.text &&
<div className={`${name}-popup-text`}>{popupMenu.text}</div>
flagged
? <span className={`${name}-button-text`}>{t('reported')}</span>
: <span className={`${name}-button-text`}>{t('report')}</span>
}
{
popupMenu.options && <form className={`${name}-popup-form`}>
<i className={`${name}-icon material-icons ${flagged && 'flaggedIcon'}`}
style={flagged ? styles.flaggedIcon : {}}
aria-hidden={true}>flag</i>
</button>
{
this.state.showMenu &&
<div className={`${name}-popup`} ref={(ref) => this.popup = ref}>
<PopupMenu>
<div className={`${name}-popup-header`}>{popupMenu.header}</div>
{
popupMenu.options.map((option) =>
<div key={option.val}>
<input
className={`${name}-popup-radio`}
type="radio"
id={option.val}
checked={this.state[popupMenu.sets] === option.val}
onClick={this.onPopupOptionClick(popupMenu.sets)}
value={option.val}/>
<label htmlFor={option.val} className={`${name}-popup-radio-label`}>{option.text}</label><br/>
</div>
)
popupMenu.text &&
<div className={`${name}-popup-text`}>{popupMenu.text}</div>
}
{
this.state.reason && <div>
<label htmlFor={'message'} className={`${name}-popup-radio-label`}>
{t('flag_reason')}
</label><br/>
<textarea
className={`${name}-reason-text`}
id="message"
rows={4}
onChange={this.onNoteTextChange}
value={this.state.message}/>
</div>
popupMenu.options && <form className={`${name}-popup-form`}>
{
popupMenu.options.map((option) =>
<div key={option.val}>
<input
className={`${name}-popup-radio`}
type="radio"
id={option.val}
checked={this.state[popupMenu.sets] === option.val}
onClick={this.onPopupOptionClick(popupMenu.sets)}
value={option.val}/>
<label htmlFor={option.val} className={`${name}-popup-radio-label`}>{option.text}</label><br/>
</div>
)
}
{
this.state.reason && <div>
<label htmlFor={'message'} className={`${name}-popup-radio-label`}>
{t('flag_reason')}
</label><br/>
<textarea
className={`${name}-reason-text`}
id="message"
rows={4}
onChange={this.onNoteTextChange}
value={this.state.message}/>
</div>
}
</form>
}
</form>
}
<div className={`${name}-popup-counter`}>
{this.state.step + 1} of {getPopupMenu.length}
<div className={`${name}-popup-counter`}>
{this.state.step + 1} of {getPopupMenu.length}
</div>
{
popupMenu.button && <Button
className={`${name}-popup-button`}
onClick={this.onPopupContinue}>
{popupMenu.button}
</Button>
}
</PopupMenu>
</div>
{
popupMenu.button && <Button
className={`${name}-popup-button`}
onClick={this.onPopupContinue}>
{popupMenu.button}
</Button>
}
</PopupMenu>
}
</div>
}
</div>;
</ClickOutside>
);
}
}
export default onClickOutside(FlagButton);
const styles = {
flaggedIcon: {
color: '#F00'
+1 -1
View File
@@ -5,7 +5,7 @@ const renderer = new marked.Renderer();
// Set link target to `_parent` to work properly in an embed.
renderer.link = (href, title, text) =>
`<a target="_parent" href="${href}" title="${title}">${text}</a>`;
`<a target="_parent" href="${href}" ${title ? `title="${title}"` : ''}>${text}</a>`;
marked.setOptions({renderer});
@@ -5,7 +5,7 @@ import t from 'coral-framework/services/i18n';
const ModerationLink = (props) => props.isAdmin ? (
<div className={styles.moderationLink}>
<a href={`/admin/moderate/${props.assetId}`} target="_blank">
<a href={`/admin/moderate/all/${props.assetId}`} target="_blank">
{t('moderate_this_stream')}
</a>
</div>

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