Compare commits

..
343 Commits
Author SHA1 Message Date
Kim Gardner 0bfd4f8797 Merge pull request #681 from coralproject/fix-viewing-options
Use new import path for coral-ui
2017-06-15 07:46:16 -04:00
Chi Vinh Le a088827568 Use new import path for coral-ui 2017-06-15 18:31:53 +07:00
Kim Gardner fb910efbc0 Merge pull request #667 from coralproject/bump-version-2-0-0
Prep to release 2.0.0
2017-06-14 15:13:19 -04:00
Kim Gardner 8dd8544154 Merge branch 'master' into bump-version-2-0-0 2017-06-14 15:06:45 -04:00
Kim Gardner b053be8e4d Merge pull request #675 from coralproject/comment-link
Highlighting Links and New plugin: coral-plugin-comment-content
2017-06-14 15:06:31 -04:00
Chi Vinh Le 035c00ee09 Only create Slot elements once, support Class Components as defaultComponent 2017-06-15 01:28:06 +07:00
Kim Gardner 69ab46d32a Merge branch 'master' into bump-version-2-0-0 2017-06-14 14:26:57 -04:00
Chi Vinh Le c30d05fd6c Switch all plugins to components/ui 2017-06-15 01:21:12 +07:00
Belen Curcio b40d592819 import lint 2017-06-14 13:27:06 -03:00
Belen Curcio 25376bd00f replacing coral-ui in plugins 2017-06-14 13:22:03 -03:00
Belen Curcio 787ba75ede Merge branch 'comment-link' of github.com:coralproject/talk into comment-link 2017-06-14 13:12:07 -03:00
Belen Curcio 60905bb99d Default Components for Slots 2017-06-14 13:02:51 -03:00
David Erwin fd875031dd Merge branch 'master' into comment-link 2017-06-13 17:35:19 -04:00
David Erwin 687b135adf Merge pull request #679 from coralproject/flagged-usernames-styling
style updates to flagged user names queue and updated translations
2017-06-13 17:32:39 -04:00
David Erwin 82acab2726 Merge branch 'master' into flagged-usernames-styling 2017-06-13 17:26:24 -04:00
David Erwin 987d4a5610 Merge pull request #672 from coralproject/asset-improvements
Added asset merging + url updates
2017-06-13 17:23:09 -04:00
David Erwin 4536d218d2 Merge branch 'master' into asset-improvements 2017-06-13 17:18:47 -04:00
David Erwin 5717f0bf3d Merge pull request #677 from coralproject/mod-panel-design-tweaks
hover states, link styling, alignment fixes
2017-06-13 17:10:24 -04:00
David Erwin d17144d436 Merge branch 'master' into mod-panel-design-tweaks 2017-06-13 17:06:02 -04:00
David Erwin 7d67566f00 Merge pull request #678 from coralproject/robust-live-reactions
Robust live reactions
2017-06-13 17:05:42 -04:00
Belen Curcio 5633dd27fc Link should be target blank 2017-06-13 15:53:59 -03:00
Belen Curcio fd2fcb4ed9 Parent target 2017-06-13 15:28:18 -03:00
Belen Curcio b8c5461b90 Adding react-linkify 2017-06-13 15:13:26 -03:00
Chi Vinh Le cdfa8379ff Change text and status of ErrAlreadyExists 2017-06-14 00:38:34 +07:00
IAmSamHankins 99745723fb style updates to flagged user names queue and updated translations 2017-06-13 12:40:07 -04:00
Samantha Hankins b6237892d7 Merge branch 'master' into mod-panel-design-tweaks 2017-06-13 11:04:13 -04:00
Chi Vinh Le 7d8f2aa876 More robust reactions 2017-06-13 22:02:54 +07:00
Chi Vinh Le aed265efcc Use correct fragment name 2017-06-13 22:01:58 +07:00
Belen Curcio 00d1b0e6f5 Ádding to plugins 2017-06-12 19:11:53 -03:00
Belen Curcio 65f7ee2ab3 coral-plugin-commnet-content 2017-06-12 19:09:42 -03:00
Belén Curcio d84ddfe1ec Merge branch 'master' into asset-improvements 2017-06-12 16:19:23 -03:00
Belén Curcio efc4db56d0 Merge pull request #674 from coralproject/live-reactions
Live reactions
2017-06-12 16:19:02 -03:00
Belén Curcio 32da779ac1 Merge branch 'master' into live-reactions 2017-06-12 14:46:26 -03:00
Belén Curcio 09d74e8fd6 Merge branch 'master' into asset-improvements 2017-06-12 14:34:20 -03:00
Belén Curcio 9fc139964f Merge pull request #670 from coralproject/client-targets
Webpack Plugin Targets
2017-06-12 14:32:45 -03:00
Belén Curcio 681e25ebdb Merge branch 'master' into client-targets 2017-06-12 14:09:09 -03:00
Chi Vinh Le e2fdba4898 Upgrade subscriptions-transport-ws 2017-06-13 00:00:40 +07:00
Chi Vinh Le ce76335fda Add some comments 2017-06-12 23:50:09 +07:00
Chi Vinh Le 70177d3a2c Cleanup 2017-06-12 23:45:46 +07:00
Chi Vinh Le d628c26693 Wrap mutations into framework 2017-06-12 23:25:58 +07:00
Chi Vinh Le 2a283a9a5a Support live updates 2017-06-12 23:21:27 +07:00
Chi Vinh Le 74ad3c3a35 Refactor all reactions and encapsulate server code 2017-06-12 17:02:14 +07:00
IAmSamHankins cc4c943a21 hover states, link styling, alignment fixes 2017-06-09 17:27:35 -04:00
Kim Gardner dfd580bf7c Merge branch 'master' into bump-version-2-0-0 2017-06-09 16:26:48 -04:00
Wyatt Johnson f929059daf added asset merging + url updates 2017-06-09 13:23:16 -06:00
Kim Gardner f2f5ddd131 Merge pull request #663 from coralproject/port-perm-checks
Port perm checks to the new framework
2017-06-09 13:24:43 -04:00
Belén Curcio 1fbeacdc36 Merge branch 'master' into port-perm-checks 2017-06-09 14:08:07 -03:00
Kim Gardner 3c58888129 Merge branch 'master' into client-targets 2017-06-09 13:00:00 -04:00
Kim Gardner b5065bad3e Merge branch 'master' into bump-version-2-0-0 2017-06-09 12:59:43 -04:00
Kim Gardner f6012526b3 Merge pull request #668 from coralproject/update-readme
Update README
2017-06-09 12:24:31 -04:00
Kim Gardner 452b2c3cdb Merge branch 'master' into bump-version-2-0-0 2017-06-09 12:17:42 -04:00
Kim Gardner fc64a98cb7 Merge branch 'master' into update-readme 2017-06-09 12:17:28 -04:00
Kiwi 8d24dcd58d Merge branch 'master' into port-perm-checks 2017-06-09 17:49:18 +07:00
Kiwi 5d59f2923e Merge branch 'master' into client-targets 2017-06-09 17:49:11 +07:00
Kiwi 291d3b583b Merge pull request #669 from coralproject/subscription-fixes
Subscription fixes
2017-06-09 17:45:31 +07:00
Chi Vinh Le c7ca3c5e4a Reset websocket after successful login 2017-06-09 17:26:42 +07:00
Kiwi 66c9e0e232 Merge branch 'master' into port-perm-checks 2017-06-09 16:59:48 +07:00
Wyatt Johnson 1d3cc906eb small fix 2017-06-08 17:34:01 -06:00
Wyatt Johnson 3bf35bdc7f Merge branch 'master' into client-targets 2017-06-08 17:32:51 -06:00
Wyatt Johnson 99174565fa fixes to webpack config (uncommenting) 2017-06-08 17:32:01 -06:00
Wyatt Johnson 8d2c1b7247 changed bundle format 2017-06-08 17:25:27 -06:00
Wyatt Johnson 399f1e7458 simplified webpack file 2017-06-08 16:53:22 -06:00
Wyatt Johnson 140f980c14 Merge branch 'master' into subscription-fixes 2017-06-08 15:09:37 -06:00
Wyatt Johnson 32708e9098 submscription fixes 2017-06-08 15:08:18 -06:00
Kim Gardner d0b3845d0f Update README 2017-06-08 15:55:41 -04:00
Kim Gardner dbb0a68766 Prep to release 2.0.0 2017-06-08 15:53:12 -04:00
Kim Gardner 3545bf01cd Merge pull request #522 from coralproject/142993479-tags
Get tags into a separate structure that can be applied to other models
2017-06-08 15:16:33 -04:00
Chi Vinh Le 4e5680577e Include public comments 2017-06-09 01:17:27 +07:00
Chi Vinh Le 428246cbe2 Make it work without iframe 2017-06-09 01:16:39 +07:00
Wyatt Johnson a9a24b96c0 cleaned up plugins in webpack 2017-06-08 11:49:36 -06:00
Chi Vinh Le 1153c52c2c Correct optimistic response 2017-06-09 00:31:29 +07:00
Chi Vinh Le 995dd9fd60 Correct fragment names 2017-06-09 00:31:19 +07:00
Chi Vinh Le 007b9604af Merge branch '142993479-tags' of ssh://github.com/coralproject/talk into 142993479-tags 2017-06-09 00:24:48 +07:00
Chi Vinh Le 57747e8f13 Make mutations extendable 2017-06-09 00:22:58 +07:00
Wyatt Johnson 27445f7b19 Merge branch '142993479-tags' of github.com:coralproject/talk into 142993479-tags 2017-06-08 11:21:55 -06:00
Wyatt Johnson b06a58ebe1 adjusted flow 2017-06-08 11:21:44 -06:00
Wyatt Johnson b95e20e493 removed explicit migration step 2017-06-08 11:21:09 -06:00
Chi Vinh Le 5a89ca51f5 Merge branch '142993479-tags' of ssh://github.com/coralproject/talk into 142993479-tags 2017-06-09 00:20:19 +07:00
Wyatt Johnson 80900a98fd perform migrations during web install 2017-06-08 11:19:43 -06:00
Chi Vinh Le d55f22b09c Merge branch '142993479-tags' of ssh://github.com/coralproject/talk into 142993479-tags 2017-06-09 00:19:37 +07:00
Chi Vinh Le 9b839b76f5 Don't assume response name pattern 2017-06-08 23:55:37 +07:00
Chi Vinh Le 8eb8010a33 Implement Response interface 2017-06-08 23:54:08 +07:00
Belen Curcio 40709c5b79 Merge branch '142993479-tags' of github.com:coralproject/talk into 142993479-tags 2017-06-08 12:58:01 -03:00
Belen Curcio 6bc5793e57 Merge branch 'master' of github.com:coralproject/talk into 142993479-tags 2017-06-08 12:56:33 -03:00
Kim Gardner 490a5d3263 Merge branch 'master' into 142993479-tags 2017-06-08 11:56:14 -04:00
Kim Gardner 1c23f72807 Merge pull request #666 from coralproject/bump-version-1-9-1
Bump version 1.9.1
2017-06-08 11:55:08 -04:00
Kim Gardner 8c1eaf09bc Bump version 1.9.1 2017-06-08 11:51:16 -04:00
Kim Gardner 7e5adcd477 Merge branch 'master' into 142993479-tags 2017-06-08 11:50:18 -04:00
Belen Curcio 283c13fae8 Ádding typenames to update the store 2017-06-08 12:43:02 -03:00
Wyatt Johnson 6e82c180f7 Merge pull request #665 from coralproject/turn-on-off-topic
Turn on off-topic plugin by default
2017-06-08 09:42:08 -06:00
Belen Curcio 8d5085b7a3 Merge branch 'master' of github.com:coralproject/talk into 142993479-tags 2017-06-08 12:39:10 -03:00
Belen Curcio ebbbe72d15 Merge branch '142993479-tags' of github.com:coralproject/talk into 142993479-tags 2017-06-08 12:37:50 -03:00
Belen Curcio f6798af74d Lint 2017-06-08 12:37:37 -03:00
Belen Curcio 1f261f5216 Fully working 2017-06-08 12:35:42 -03:00
Kim Gardner 81f3cc808c Turn on off-topic plugin by default 2017-06-08 11:33:06 -04:00
Wyatt Johnson 480bab3c1a changed some text around 2017-06-08 09:12:08 -06:00
Wyatt Johnson 3a285d92a5 Added more warnings 2017-06-08 09:10:53 -06:00
Kim Gardner 177edf8ed8 Merge branch 'master' into port-perm-checks 2017-06-08 11:10:05 -04:00
Kim Gardner 55928dff08 Merge pull request #664 from coralproject/fix-reply-bugs
Fix bugs related to reply box
2017-06-08 11:09:10 -04:00
Belen Curcio 82a72e506f Off-topic and Staff working 2017-06-08 12:04:33 -03:00
Kim Gardner 9695db0678 Merge branch 'master' into fix-reply-bugs 2017-06-08 11:04:04 -04:00
Kim Gardner f3dcfd23f3 Merge branch 'master' into 142993479-tags 2017-06-08 11:01:57 -04:00
Kim Gardner 655e53a35c Merge pull request #662 from coralproject/bump-version
Update version number
2017-06-08 11:01:09 -04:00
Belen Curcio 15b5dc2236 Linting 2017-06-08 11:58:38 -03:00
Belen Curcio 38077cf3cf Missing fragment 2017-06-08 11:56:38 -03:00
Belen Curcio ef43ba3a35 new merge conflicts to master 2017-06-08 11:55:06 -03:00
Chi Vinh Le d61ede2396 Fix bugs related to reply box 2017-06-08 18:44:07 +07:00
Chi Vinh Le fb39446c6a Port perm checks to the new framework 2017-06-08 18:28:46 +07:00
Wyatt Johnson 5d28f611b2 support for targeting new targets 2017-06-07 20:26:48 -06:00
Kim Gardner a40dd04c1b Update version number 2017-06-07 19:10:13 -04:00
Kim Gardner 9d25a4c14a Merge pull request #661 from coralproject/comment-stream-cleanup
Refinement of design for banned and suspend user messaging on comment stream.
2017-06-07 17:12:25 -04:00
Kim Gardner 801d0a92af Merge branch 'master' into comment-stream-cleanup 2017-06-07 17:07:39 -04:00
David Erwin 8bf86f34ee Merge pull request #654 from coralproject/off-topic-features
Off Topic Features
2017-06-07 15:43:03 -04:00
Belen Curcio 63a35cbceb Merge issues 2017-06-07 16:38:36 -03:00
Kim Gardner 14a35033da Merge branch 'master' into comment-stream-cleanup 2017-06-07 14:33:29 -04:00
Chi Vinh Le 05da69d097 Remove falsely commited css styles 2017-06-08 01:23:22 +07:00
Chi Vinh Le ffce78ef60 Move Icon and translation service to plugin-api 2017-06-08 01:18:02 +07:00
Chi Vinh Le 6561f733b7 Use correct prefix 2017-06-08 01:17:36 +07:00
Chi Vinh Le b9758ba942 Fix overlap not clickable 2017-06-08 00:59:44 +07:00
Chi Vinh Le 4e56406c37 Fix tabsize in css 2017-06-08 00:49:18 +07:00
Chi Vinh Le 06858bf071 Move Slot and withReaction to beta 2017-06-08 00:42:21 +07:00
Chi Vinh Le 66e4f02a1a Selectors, resolving merge errors, better class names, removing comment reducer 2017-06-08 00:29:05 +07:00
Wyatt Johnson 4cbc8b2226 Updates to setup process 2017-06-07 11:08:39 -06:00
IAmSamHankins c3d76c6a4f messaging style updates, and update to translation files. 2017-06-07 12:55:40 -04:00
Belen Curcio c8534a91a6 Merge branch 'off-topic-features' of github.com:coralproject/talk into off-topic-features 2017-06-07 13:53:54 -03:00
Belen Curcio 87ddfea689 indexes css 2017-06-07 13:53:33 -03:00
Wyatt Johnson 6827ff2f1f Added support for version enforcing 2017-06-07 10:27:42 -06:00
Belén Curcio 830441c531 Merge branch 'master' into off-topic-features 2017-06-07 13:24:58 -03:00
Riley Davis 7417d51973 Merge pull request #660 from coralproject/math-is-hard
percents have to be multiplied by 100
2017-06-07 10:22:45 -06:00
Belen Curcio 847a30ff92 Adding correct classNames 2017-06-07 13:19:36 -03:00
Riley Davis 9f4df35c3a percents have to be multiplied by 100 2017-06-07 10:15:50 -06:00
Belen Curcio f6ad9c9373 Adding camelcase for plugin reducers 🎉 <3 2017-06-07 13:14:52 -03:00
Belen Curcio 8872248d63 Addiing Stable API Architecture 2017-06-07 13:10:17 -03:00
Belen Curcio d780eea357 Merge branch 'off-topic-features' of github.com:coralproject/talk into off-topic-features 2017-06-07 12:38:31 -03:00
Belen Curcio 20aba92ad2 Linting 2017-06-07 12:37:59 -03:00
Chi Vinh Le 7a2fd95eca Merge branch 'master' into off-topic-features
Conflicts:
	client/coral-embed-stream/src/components/Comment.css
	client/coral-embed-stream/src/components/Comment.js
	client/coral-embed-stream/src/components/Stream.js
	client/coral-embed-stream/src/reducers/index.js
	client/coral-framework/helpers/plugins.js
2017-06-07 22:37:06 +07:00
Belen Curcio d7dfae89a3 Global classNames, and more 2017-06-07 12:05:53 -03:00
David Erwin fa33fe2ed2 Merge pull request #657 from coralproject/better-refetch-detection
Better refetch detection
2017-06-07 09:29:19 -04:00
Belen Curcio 456eedfd31 Clearing tags and naming convention 2017-06-07 10:04:52 -03:00
Belen Curcio 4725f07757 Viewing Options state and name conventions 2017-06-07 09:53:53 -03:00
Belen Curcio e24f6ca9f1 Renaming to commentClassNames 2017-06-07 09:28:12 -03:00
Chi Vinh Le ceb99cf205 Better refetch detection 2017-06-07 17:45:33 +07:00
Kiwi e47d3b085e Merge pull request #656 from coralproject/service-fix
Resolve facebook login issue
2017-06-07 16:57:19 +07:00
Belén Curcio 5e1fcc48e1 Merge branch 'master' into service-fix 2017-06-06 20:45:06 -03:00
Belen Curcio b5c9e92cc6 Adding streamBox slot and coral-plugin-viewing-options 🎉 2017-06-06 20:42:42 -03:00
Wyatt Johnson cf82f5b707 fixed enum 2017-06-06 17:32:52 -06:00
Wyatt Johnson 1e904584e7 Added model migrations 2017-06-06 17:31:52 -06:00
Wyatt Johnson 71ff1c31f8 moved plugin to lazy load 2017-06-06 16:49:51 -06:00
IAmSamHankins cc76517ba8 minor copy changes in Configure tab on stream, and style updates to banned user message on stream 2017-06-06 17:37:14 -04:00
Belen Curcio 6f909c3451 Containers and Components structure 2017-06-06 18:21:03 -03:00
Belen Curcio 2202c8ae5b Fully working filter 2017-06-06 18:08:13 -03:00
Kim Gardner 6d8133b1b5 Merge pull request #655 from coralproject/move-config
move edit comment config
2017-06-06 16:57:48 -04:00
Riley Davis 2384ae4baa move edit comment config 2017-06-06 14:49:07 -06:00
Belen Curcio 1c7ad9a8ac Plugin reducers working again with reducer name 2017-06-06 17:29:56 -03:00
Belen Curcio c3360aa020 Plugin reducers working again 2017-06-06 17:23:35 -03:00
Belen Curcio 4aa7900769 Not connecting comment Component to the Store 2017-06-06 17:09:13 -03:00
Riley Davis 0dc61f33f5 Merge pull request #646 from coralproject/bulk-actions
bulk operations on User Detail comments
2017-06-06 13:26:28 -06:00
Riley Davis 8af0d929c2 Merge branch 'master' into bulk-actions 2017-06-06 13:15:04 -06:00
Riley Davis 4e19d5eecf don't have functions re-created every render 2017-06-06 13:11:31 -06:00
Riley Davis 5465563856 reload and clear selections 2017-06-06 12:52:26 -06:00
Belen Curcio b5a64bd2f9 Filter State 2017-06-06 15:45:56 -03:00
Kim Gardner b02782d532 Merge pull request #653 from coralproject/support-wss
Use wss when connection is secure
2017-06-06 14:39:35 -04:00
Chi Vinh Le 7cb05cebef Use wss when connection is secure 2017-06-07 01:32:37 +07:00
Kim Gardner 80d4ffa5bf Merge branch 'master' into bulk-actions 2017-06-06 14:13:47 -04:00
Riley Davis 9963f6d337 Merge pull request #651 from ebirving/active-tab-classnames
add semantic classname to active tab
2017-06-06 12:11:44 -06:00
Kim Gardner eff923424c Merge branch 'master' into bulk-actions 2017-06-06 14:11:16 -04:00
Erica Irving c3e32f37dc add coral- prefix as requested 2017-06-06 13:43:45 -04:00
Riley Davis 18f11a84cf Merge branch 'master' into active-tab-classnames 2017-06-06 11:14:47 -06:00
Riley Davis b0bdfff785 Merge pull request #649 from ebirving/respect-plugin-styles
add classnames to RespectButton component
2017-06-06 11:14:32 -06:00
Riley Davis dc55febac8 Merge branch 'master' into respect-plugin-styles 2017-06-06 11:09:36 -06:00
David Erwin 4d6e02228c Merge pull request #643 from coralproject/subscribe-to-comments
Subscribe to comment updates
2017-06-06 13:00:58 -04:00
Chi Vinh Le c50fe9f91d Merge branch 'master' into subscribe-to-comments
Conflicts:
	client/coral-embed-stream/src/components/Comment.js
2017-06-06 23:44:27 +07:00
Riley Davis 7d5a73bc95 Merge branch 'master' into respect-plugin-styles 2017-06-06 10:40:33 -06:00
Riley Davis 5f3220dba9 Merge pull request #652 from coralproject/css-override-classes
Css override classes + tab bugfix
2017-06-06 10:40:21 -06:00
Kim Gardner 700ab38cb0 Merge branch 'master' into css-override-classes 2017-06-06 12:34:13 -04:00
Riley Davis 6fcd003e76 remove console.log 2017-06-06 10:32:14 -06:00
Kiwi 0069d17672 Merge branch 'master' into subscribe-to-comments 2017-06-06 23:19:22 +07:00
Chi Vinh Le afdcdff51e Handle ignored user case 2017-06-06 23:18:37 +07:00
Erica Irving 3d8478dfb2 add semantic classname to active tab 2017-06-06 12:17:32 -04:00
Kim Gardner 71d2f44d38 Merge pull request #650 from coralproject/copy-updates
Copy changes
2017-06-06 12:07:40 -04:00
Kim Gardner 124ea19b52 Update en.yml 2017-06-06 12:03:12 -04:00
Erica Irving 512a463589 add classnames to RespectButton component 2017-06-06 11:28:28 -04:00
Belen Curcio e9cb61a534 Arrow for filtering 2017-06-06 12:13:26 -03:00
Belen Curcio a6db60096c Fully working, needs docs 2017-06-06 11:58:51 -03:00
Belen Curcio 9364272ffc Ádd classnames based on tags 2017-06-06 11:53:19 -03:00
Chi Vinh Le 1ceb72ca5c Fix error with tombstones 2017-06-06 21:26:43 +07:00
Belen Curcio 9e001f5ce9 addClassNames, removeClassNames, reducers and actions for Comment Component 2017-06-06 11:15:35 -03:00
Chi Vinh Le 0fcdd09f9f Lazily create apollo client 2017-06-06 20:50:07 +07:00
Belen Curcio 3ad3b3af1c Adding filters 2017-06-06 09:43:34 -03:00
Chi Vinh Le d3f7e6210c Simplify transition code 2017-06-06 19:06:42 +07:00
Chi Vinh Le 447ec7e524 Deeply pass commentIsIgnored 2017-06-06 19:05:18 +07:00
Chi Vinh Le f8ca14d3d3 Reset stream after refetching 2017-06-06 18:45:45 +07:00
Belen Curcio 5faa92e8b3 Label style, top level comments, no offtopic replies 2017-06-06 08:09:34 -03:00
Kim Gardner 17783c91c4 Merge branch 'master' into bulk-actions 2017-06-05 23:06:03 -04:00
Riley Davis ad4c4a0a5b fix for active tab not showing. user box classNames 2017-06-05 16:46:58 -06:00
Wyatt Johnson 7956c193c5 Merge branch 'master' into subscribe-to-comments 2017-06-05 16:38:28 -06:00
Wyatt Johnson 2aa3b7a5a0 Merge pull request #641 from coralproject/safari-cookie-time
Use same lifespan for cookies as for tokens
2017-06-05 16:38:13 -06:00
Wyatt Johnson 2f77842072 Merge branch 'master' into safari-cookie-time 2017-06-05 16:32:29 -06:00
Wyatt Johnson a595e94394 Removed parse-duration in favour of ms 2017-06-05 16:31:06 -06:00
riley d03647067d Merge branch 'master' into css-override-classes 2017-06-05 16:19:46 -06:00
Riley Davis fb537c9990 Merge pull request #648 from coralproject/StephanieDClark-master
Stephanie's changes to add classNames to Tab and TabBar
2017-06-05 16:18:19 -06:00
Riley Davis 8ea7ba9b69 Merge branch 'master' into css-override-classes 2017-06-05 16:13:45 -06:00
David Erwin 15297cd674 Update css naming convention 2017-06-05 18:12:38 -04:00
Riley Davis e46802910c Merge branch 'master' into StephanieDClark-master 2017-06-05 16:09:12 -06:00
riley 19b90910ba better tab classNames 2017-06-05 16:08:53 -06:00
David Erwin 291cee3af4 Merge pull request #647 from coralproject/auth-token-hooks
Added new tokenUserNotFound plugin hook
2017-06-05 18:05:56 -04:00
Wyatt Johnson 069c4c5e50 Merge branch 'master' into auth-token-hooks 2017-06-05 16:00:23 -06:00
Riley Davis 7d54fb60a8 use existing className attr instead of custom property 2017-06-05 15:57:34 -06:00
Wyatt Johnson e8143aea14 Added new tokenUserNotFound plugin hook 2017-06-05 15:52:32 -06:00
StephanieDClark 9e1d010db3 Adjusting tab classes 2017-06-05 14:48:50 -07:00
David Erwin 728d523b9e Begin adding css override classes 2017-06-05 17:25:27 -04:00
StephanieDClark 5838f5d486 fix lint errors 2017-06-05 14:00:21 -07:00
Riley Davis 1c8e1cc46c Merge branch 'master' into bulk-actions 2017-06-05 14:42:07 -06:00
Riley Davis c4a41af33b clear selected ids when panel is closed 2017-06-05 14:40:03 -06:00
StephanieDClark 5e463c00fd Merge branch 'master' into master 2017-06-05 13:35:36 -07:00
Kim Gardner 933a9371f3 Merge branch 'master' into safari-cookie-time 2017-06-05 16:35:23 -04:00
Kim Gardner 0be77fa5fd Merge pull request #645 from coralproject/fix-typos
Fix typos
2017-06-05 16:28:08 -04:00
Kim Gardner 49eecaa65b Fix typos 2017-06-05 16:22:17 -04:00
Riley Davis 39d8a5d01b bulk operations on User Detail comments 2017-06-05 14:20:34 -06:00
StephanieDClark 414e5d78bd Fix yarn lint error 2017-06-05 12:58:04 -07:00
Kiwi e9a6c3dfe3 Merge branch 'master' into subscribe-to-comments 2017-06-06 02:42:35 +07:00
StephanieDClark 3b7603ee31 add supplied classes to tabbar 2017-06-05 12:39:35 -07:00
StephanieDClark 8c89296007 add supplied classes to tab 2017-06-05 12:38:36 -07:00
StephanieDClark 32fe291847 Add classes to tabbar, tab 2017-06-05 12:37:13 -07:00
Chi Vinh Le 056ddc7010 Meaningful comment 2017-06-06 02:25:42 +07:00
Chi Vinh Le 2d0eec15c1 Readd falsely deleted part 2017-06-06 02:25:32 +07:00
Chi Vinh Le 2be73d1df2 Unsubscribe on unmount 2017-06-06 02:10:56 +07:00
Chi Vinh Le 3ad17f008f Refactor fetchMore updaters 2017-06-06 01:44:17 +07:00
David Erwin 547547d04c Merge pull request #638 from coralproject/auth-token-via-config
Use config auth_token if present on http headers
2017-06-05 14:22:48 -04:00
David Erwin 90d7e3706c Improve auth token logic. 2017-06-05 13:58:13 -04:00
Chi Vinh Le 9993350b7e Animate flash for new comments 2017-06-06 00:54:12 +07:00
David Erwin 7c66d846c2 Add auth token comment 2017-06-05 13:38:06 -04:00
Chi Vinh Le 6b0cdae183 Subscribe to comments 2017-06-05 20:51:08 +07:00
Chi Vinh Le c20df65ebc Use same lifespan for cookies as for tokens 2017-06-05 18:21:40 +07:00
Kiwi 7efdccb683 Merge branch 'master' into auth-token-via-config 2017-06-05 17:57:13 +07:00
Chi Vinh Le a9796d47cd Check login after loading config 2017-06-05 17:51:06 +07:00
Chi Vinh Le 7c145bbb3c Refactor import order and framework initialization 2017-06-05 17:49:43 +07:00
Chi Vinh Le ac9e70bc67 Add same getAuthToken logic to apollo transport 2017-06-05 17:48:09 +07:00
Chi Vinh Le defd28b20f Remove double login checks 2017-06-05 17:46:57 +07:00
Chi Vinh Le b05b54a991 Add auth_token default config 2017-06-05 17:46:30 +07:00
Kim Gardner 3c96b7eb1e Merge pull request #640 from coralproject/user-comment-history
user detail design pass
2017-06-02 17:27:13 -04:00
Kim Gardner 6e483842f1 Merge branch 'master' into user-comment-history 2017-06-02 17:22:48 -04:00
Kim Gardner 0276c00828 Merge pull request #639 from coralproject/key-trans-missing
Key translation missing
2017-06-02 17:08:48 -04:00
gaba c6efc586ae Merge branch 'master' into key-trans-missing 2017-06-02 14:03:33 -07:00
gaba 6de9c69668 Missing key translation. 2017-06-02 14:02:45 -07:00
Kim Gardner 396abaae22 Merge pull request #637 from coralproject/i18n-emails
I18n emails
2017-06-02 16:59:32 -04:00
gaba e98b9ff9bc Some translations missing and a folder that should not be there. 2017-06-02 13:50:48 -07:00
Gabriela Rodríguez Berón cbf83206b8 Merge branch 'master' into i18n-emails 2017-06-02 13:35:09 -07:00
Kim Gardner 664ace170b Merge pull request #618 from coralproject/user-comment-history
show comments on the UserDetail drawer
2017-06-02 16:10:22 -04:00
Kim Gardner bd60eca6f9 Merge branch 'master' into user-comment-history 2017-06-02 15:58:54 -04:00
Riley Davis e097b8eb21 design pass 2017-06-02 13:50:00 -06:00
Kim Gardner 058cab970b Merge pull request #636 from coralproject/story-146187883
UX Updates
2017-06-02 15:25:40 -04:00
Riley Davis 22c2843ae5 remove moderate link per designs 2017-06-02 13:12:59 -06:00
Riley Davis d8cb560755 add a tab for All/Rejected user detail comments 2017-06-02 13:02:22 -06:00
David Erwin ea3d8e141d Use config auth_token if present on http headers 2017-06-02 14:22:06 -04:00
gaba cf50a0e85c Translations missing. 2017-06-02 08:30:36 -07:00
gaba b9b62e5764 Use accepts library. 2017-06-02 08:24:21 -07:00
gaba eadbb03c15 Fix typo. 2017-06-02 06:54:01 -07:00
gaba b6dbb5975f Adds missing translations. 2017-06-01 17:26:49 -07:00
gaba fd1cf8121e Move start to the left. 2017-06-01 17:21:55 -07:00
gaba dcbd0fc167 Change signed in for logged in. 2017-06-01 17:13:55 -07:00
gaba 44d82f8b13 Merge branch 'master' into i18n-emails 2017-06-01 16:26:44 -07:00
gaba 27e6372fe2 Get language from the header. 2017-06-01 16:24:15 -07:00
gaba 686dc96c5f An attempt to guess language. In process. 2017-06-01 15:44:44 -07:00
Riley Davis 8d25b766ff Merge branch 'master' into user-comment-history 2017-06-01 15:47:54 -06:00
Riley Davis 555d54425a implement kiwi's pagination changes 2017-06-01 15:34:05 -06:00
Kim Gardner f3f7fad68f Merge pull request #635 from StephanieDClark/master
Add isReply to commentInputDetailArea api
2017-06-01 14:38:36 -04:00
Riley Davis 9483922781 Merge branch 'master' into master 2017-06-01 12:31:48 -06:00
Kim Gardner 91d415b434 Merge pull request #632 from coralproject/losowsky-patch-2
Updated description
2017-06-01 14:25:55 -04:00
Kim Gardner 1943b38544 Merge branch 'master' into losowsky-patch-2 2017-06-01 14:20:45 -04:00
Kim Gardner 9344490514 Merge pull request #631 from coralproject/losowsky-patch-1
Added iPhone 7
2017-06-01 14:20:28 -04:00
StephanieDClark 1bf2053cb8 Add isReply to commentInputDetailArea api 2017-06-01 11:01:57 -07:00
Andrew Losowsky 9fe7b961ca Updated description
Added mention of Plugins
2017-06-01 13:43:30 -04:00
Andrew Losowsky ac0db69c27 Added iPhone 7
What about the Android devices? It says Galaxy S5 but that's up to S7, same with Nexus.
2017-06-01 13:41:08 -04:00
Riley Davis d981b5a85b Merge branch 'master' into user-comment-history 2017-06-01 10:52:24 -06:00
gaba 276b2c7591 Merge branch 'master' into i18n-emails 2017-05-31 15:31:14 -07:00
gaba a126c94aa4 Adds service for translations in the backend. Clean yaml. 2017-05-31 13:55:27 -07:00
Riley Davis 576cbed8e9 no more errors 2017-05-31 13:44:38 -06:00
Kim Gardner bf15354aa2 Merge branch 'master' into user-comment-history 2017-05-31 15:17:00 -04:00
riley 6e458e0149 resolve missing files 2017-05-31 10:54:34 -06:00
Riley Davis 259ce9c9fb merge master 2017-05-31 10:36:01 -06:00
Riley Davis 6599ed2b4c Merge branch 'master' into user-comment-history 2017-05-30 15:53:36 -06:00
Riley Davis 29753a958b show comments on the UserDetail drawer 2017-05-30 15:50:56 -06:00
gaba 77bfd55af3 Merge branch 'i18n-refactor' into i18n-emails 2017-05-25 14:35:49 -04:00
gaba 09bd0a6735 Move templates to a folder for translations. 2017-05-25 10:43:34 -04:00
Kim Gardner 0007773b4d Merge branch 'master' into 142993479-tags 2017-05-23 13:02:29 -04:00
riley ec03c6b48c merge master 2017-05-22 13:10:43 -06:00
Wyatt Johnson 8f219c47a7 Tag server impl 2017-05-19 12:23:53 -06:00
Belen Curcio 132b0a854d linting 2017-05-18 00:22:39 -03:00
Belen Curcio 17e7b28688 Fully working best tag 2017-05-18 00:21:08 -03:00
Belen Curcio b795368674 Merge branch '142993479-tags' of github.com:coralproject/talk into 142993479-tags 2017-05-17 23:54:38 -03:00
Belen Curcio f57fa7cb7b Update Cache 2017-05-17 23:53:07 -03:00
Wyatt Johnson 7a256bdd2b Merge branch 'master' into 142993479-tags 2017-05-17 10:01:58 -06:00
Belen Curcio 6664f73d2d Best tag working, only Optimistic Missing 2017-05-17 11:57:51 -03:00
Belen Curcio fb0e89485c Merge branch '142993479-tags' of github.com:coralproject/talk into 142993479-tags 2017-05-17 11:20:00 -03:00
Belen Curcio b2f1707f17 Fully working offtopic 2017-05-17 11:19:06 -03:00
Wyatt Johnson 9db77360b4 removed unused errors 2017-05-17 08:16:48 -06:00
Belen Curcio 9dc0693530 Linting 2017-05-17 09:43:04 -03:00
Belen Curcio 54eb078a13 Working offtopic tag 2017-05-17 09:37:26 -03:00
Belen Curcio 793f634959 Working tags 2017-05-17 09:01:41 -03:00
Belen Curcio c59346eb0a tags, and assignedby nul 2017-05-15 18:13:45 -03:00
Belen Curcio d02412a734 Merge branch '142993479-tags' of github.com:coralproject/talk into 142993479-tags 2017-05-15 18:11:30 -03:00
Belen Curcio c3d02a14a0 Updated queries and hasRoles 2017-05-15 18:11:14 -03:00
Belén Curcio 820ffb14c1 Merge branch 'master' into 142993479-tags 2017-05-15 16:55:47 -03:00
Belén Curcio 3eea5e1a8a Merge branch 'master' into 142993479-tags 2017-05-15 14:53:54 -03:00
David Erwin ff6a9cf0d5 Merge branch 'master' into 142993479-tags 2017-05-12 15:44:38 -04:00
David Erwin b60ed166d0 Allow MODERATORs to access tags 2017-05-12 15:38:38 -04:00
Wyatt Johnson 0a8bfe8a38 Merge branch 'master' into 142993479-tags 2017-05-12 07:54:46 -06:00
Wyatt Johnson e6b2ff19b8 fixing graph merge error 2017-05-12 07:51:53 -06:00
Wyatt Johnson e2f6d5989a Merge branch 'master' into 142993479-tags 2017-05-12 07:49:02 -06:00
Wyatt Johnson 0d957bcfb9 Code cleanup related to old tag services 2017-05-11 14:56:36 -06:00
Wyatt Johnson 899805b956 Merge branch '142993479-tags' of github.com:coralproject/talk into 142993479-tags 2017-05-11 14:42:37 -06:00
Wyatt Johnson aeaf809a0f Made queries more generic related to tags 2017-05-11 14:42:06 -06:00
Gabriela Rodríguez Berón d815d9dc02 Merge branch 'master' into 142993479-tags 2017-05-11 08:30:51 -07:00
Gabriela Rodríguez Berón f1e4f98055 Merge branch 'master' into 142993479-tags 2017-05-10 08:50:28 -07:00
Wyatt Johnson 0d47ee895c removed unused service 2017-05-09 15:02:31 -06:00
Wyatt Johnson 2e1bc8d75a Adjusted query/tag flow 2017-05-09 15:01:01 -06:00
Gabriela Rodríguez Berón b8e530ca1f Merge branch 'master' into 142993479-tags 2017-05-08 21:08:28 -07:00
gaba da0cebbda7 Fix the plugin TagLabel to show the staff tag. 2017-05-08 17:06:49 -07:00
gaba 7c09902294 The name for the tag is called ID. 2017-05-08 16:55:11 -07:00
gaba 96cd5197e3 Test was failing as I was not returning an error. 2017-05-08 16:05:40 -07:00
gaba 743a96cd21 Removes debug statements. 2017-05-08 13:12:24 -07:00
gaba c4c0ee79cd Fix tests. 2017-05-08 13:12:24 -07:00
gaba 6310528804 Merge branch 'master' into 142993479-tags 2017-05-08 13:12:23 -07:00
Kim Gardner a90e1e132b Merge pull request #569 from coralproject/ui-overlaps
Apply button overlapped text
2017-05-08 13:12:22 -07:00
Wyatt Johnson d262663e3f Merge pull request #568 from coralproject/user-fix
Added privacy fix
2017-05-08 13:12:21 -07:00
Riley Davis 85fae3104e float: right; in css is just fine. 2017-05-08 13:12:21 -07:00
Wyatt Johnson 607ab6c0d6 Added privacy fix 2017-05-08 13:12:20 -07:00
gaba c72cb2428e Fixed errors on tags creation 2017-05-08 13:12:19 -07:00
gaba d7e72bb138 Fix tests. 2017-05-08 13:12:18 -07:00
gaba cb09c50820 Error on duplicate. 2017-05-08 13:11:48 -07:00
Gabriela Rodríguez Berón 28920eb357 Merge branch 'master' into 142993479-tags 2017-05-05 08:44:09 -07:00
Kim Gardner 3fcf21f949 Merge branch 'master' into 142993479-tags 2017-05-02 13:41:39 -04:00
Kim Gardner 058aa3f907 Merge branch 'master' into 142993479-tags 2017-04-27 11:59:48 -04:00
gaba c4ffe58f58 Adds privacy type to the methods to add tags. 2017-04-21 14:30:41 -07:00
gaba 0a4fd15fa2 Merge branch 'master' into 142993479-tags 2017-04-21 12:51:05 -07:00
gaba b52325f8e9 Merge branch 'master' into 142993479-tags 2017-04-21 10:36:28 -07:00
gaba 91af8c2ece Index on Tag for name, item id, item type. 2017-04-21 10:34:57 -07:00
gaba 962a9bd6c9 That was so so so silly... 2017-04-20 14:42:57 -07:00
gaba 183dab9826 Merge branch 'master' into 142993479-tags 2017-04-20 12:34:32 -07:00
gaba 8458a9aecc fixed lint error. 2017-04-20 12:29:21 -07:00
gaba bd72bfc6ec Fix tests related to tags on comments. 2017-04-20 12:24:08 -07:00
gaba 1137fca3b4 Adds Tag Model that will be apply to different models. 2017-04-18 14:03:26 -07:00
247 changed files with 5474 additions and 2926 deletions
+2
View File
@@ -10,4 +10,6 @@ plugins/*
!plugins/coral-plugin-like
!plugins/coral-plugin-mod
!plugins/coral-plugin-love
!plugins/coral-plugin-viewing-options
!plugins/coral-plugin-comment-content
node_modules
+2
View File
@@ -23,5 +23,7 @@ plugins/*
!plugins/coral-plugin-like
!plugins/coral-plugin-mod
!plugins/coral-plugin-love
!plugins/coral-plugin-viewing-options
!plugins/coral-plugin-comment-content
**/node_modules/*
+59
View File
@@ -280,6 +280,41 @@ send data to the client. If the type in question contains args, clients may subs
For more information, see the [Apollo Docs](https://github.com/apollographql/graphql-subscriptions).
#### Field: `tokenUserNotFound`
```js
tokenUserNotFound: async ({jwt, token}) => {
let profile = await someExternalService(token);
if (!profile) {
return null;
}
let user = await UserModel.findOneAndUpdate({
id: profile.id
}, {
id: profile.id,
username: profile.username,
lowercaseUsername: profile.username.toLowerCase(),
roles: [],
profiles: []
}, {
setDefaultsOnInsert: true,
new: true,
upsert: true
});
return user;
}
```
The `tokenUserNotFound` hook allows auth integrations to hook into the event
when a valid token is provided but a user can't be found in the database that
matches the provided id.
The function is async, and should return the user object that was created in the
database, or null if the user wasn't found. The `jwt` paramenter of the object
is the unpacked token, while `token` is the original jwt token string.
#### Field: `router`
```js
@@ -307,6 +342,30 @@ module.exports = {
}
```
#### Field: `tags`
The tags hook allows a plugin to define tags that are code controlled (added
or enabled by code). Below is an example pulled from the core off topic plugin
on how to create a hook for the `OFF_TOPIC` name:
```js
[
{
name: 'OFF_TOPIC',
permissions: {
public: true,
self: true,
roles: []
},
models: ['COMMENTS'],
created_at: new Date()
}
]
```
You can refer to `models/schema/tag.js` for the available schema to match when
creating models to enable/disable specific features.
#### Field: `passport`
```js
+4
View File
@@ -44,6 +44,10 @@ sign and verify tokens via a `HS256` algorithm.
Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for
alternative methods of loading configuration during development.
### Running Migrations
We have a migration tool that can be run using `bin/cli migration run`. This will detect new migrations available and prompt you to backup your database before proceeding with the migration. Migrations are required with major version releases.
### Using Trust
Talk ships with core components we call "Trust". This allows Talk to automate certain actions based on previous user behavior.
+3
View File
@@ -6,6 +6,7 @@ const helmet = require('helmet');
const authentication = require('./middleware/authentication');
const {passport} = require('./services/passport');
const plugins = require('./services/plugins');
const i18n = require('./services/i18n');
const enabled = require('debug').enabled;
const errors = require('./errors');
const {createGraphOptions} = require('./graph');
@@ -157,6 +158,8 @@ app.use('/', (err, req, res, next) => {
console.error(err);
}
i18n.init(req);
if (err instanceof errors.APIError) {
res.status(err.status);
res.render('error', {
+1
View File
@@ -13,6 +13,7 @@ program
.command('setup', 'setup the application')
.command('jobs', 'work with the job queues')
.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')
.parse(process.argv);
+109 -49
View File
@@ -5,12 +5,15 @@
*/
const program = require('./commander');
const parseDuration = require('parse-duration');
const parseDuration = require('ms');
const Table = require('cli-table');
const AssetModel = require('../models/asset');
const CommentModel = require('../models/comment');
const AssetsService = require('../services/assets');
const mongoose = require('../services/mongoose');
const scraper = require('../services/scraper');
const util = require('./util');
const inquirer = require('inquirer');
// Register the shutdown criteria.
util.onshutdown([
@@ -20,65 +23,112 @@ util.onshutdown([
/**
* Lists all the assets registered in the database.
*/
function listAssets() {
AssetModel
.find({})
.sort({'created_at': 1})
.then((asset) => {
let table = new Table({
head: [
'ID',
'Title',
'URL'
]
});
async function listAssets() {
try {
let assets = await AssetModel.find({}).sort({'created_at': 1});
asset.forEach((asset) => {
table.push([
asset.id,
asset.title ? asset.title : '',
asset.url ? asset.url : ''
]);
});
console.log(table.toString());
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
let table = new Table({
head: [
'ID',
'Title',
'URL'
]
});
assets.forEach((asset) => {
table.push([
asset.id,
asset.title ? asset.title : '',
asset.url ? asset.url : ''
]);
});
console.log(table.toString());
util.shutdown();
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
function refreshAssets(ageString) {
const now = new Date().getTime();
const ageMs = parseDuration(ageString);
const age = new Date(now - ageMs);
async function refreshAssets(ageString) {
try {
const now = new Date().getTime();
const ageMs = parseDuration(ageString);
const age = new Date(now - ageMs);
AssetModel.find({
$or: [
{
scraped: {
$lte: age
let assets = await AssetModel.find({
$or: [
{
scraped: {
$lte: age
}
},
{
scraped: null
}
},
{
scraped: null
}
]
})
]
});
// Queue all the assets for scraping.
.then((assets) => Promise.all(assets.map(scraper.create)))
// Queue all the assets for scraping.
await Promise.all(assets.map(scraper.create));
.then(() => {
console.log('Assets were queued to be scraped');
util.shutdown();
})
.catch((err) => {
console.error(err);
} catch (e) {
console.error(e);
util.shutdown(1);
});
}
}
async function updateURL(assetID, assetURL) {
try {
await AssetsService.updateURL(assetID, assetURL);
console.log(`Asset ${assetID} was updated to have url ${assetURL}.`);
util.shutdown();
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
async function merge(srcID, dstID) {
try {
// Grab the assets...
let [srcAsset, dstAsset] = await AssetsService.findByIDs([srcID, dstID]);
if (!srcAsset || !dstAsset) {
throw new Error('Not all assets indicated by id exist, cannot merge');
}
// Count the affected resources...
let srcCommentCount = await CommentModel.find({asset_id: srcID}).count();
console.log(`Now going to update ${srcCommentCount} comments and delete the source Asset[${srcID}].`);
let {confirm} = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Proceed with merge',
default: false
}
]);
if (confirm) {
// Perform the merge!
await AssetsService.merge(srcID, dstID);
} else {
console.warn('Aborting merge');
}
util.shutdown(0);
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
//==============================================================================
@@ -95,6 +145,16 @@ program
.description('queues the assets that exceed the age requested')
.action(refreshAssets);
program
.command('update-url <assetID> <url>')
.description('update the URL of an asset')
.action(updateURL);
program
.command('merge <srcID> <dstID>')
.description('merges two assets together by moving comments from src to dst and deleting the src asset')
.action(merge);
program.parse(process.argv);
// If there is no command listed, output help.
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const program = require('./commander');
const util = require('./util');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const MigrationService = require('../services/migration');
// Register shutdown hooks.
util.onshutdown([
() => mongoose.disconnect()
]);
async function createMigration(name) {
try {
// Create the migration.
await MigrationService.create(name);
util.shutdown();
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
async function runMigrations() {
try {
let {backedUp} = await inquirer.prompt([
{
type: 'confirm',
name: 'backedUp',
message: 'Did you perform a database backup',
default: false
}
]);
if (!backedUp) {
throw new Error('Please backup your databases prior to migrations occuring');
}
// Get the migrations to run.
let migrations = await MigrationService.listPending();
console.log('Now going to run the following migrations:\n');
for (let {filename} of migrations) {
console.log(`\tmigrations/${filename}`);
}
let {confirm} = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Proceed with migrations',
default: false
}
]);
if (confirm) {
// Run the migrations.
await MigrationService.run(migrations);
} else {
console.warn('Skipping migrations');
}
util.shutdown();
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.command('create <name>')
.description('creates a new migration')
.action(createMigration);
program
.command('run')
.description('runs all pending migrations')
.action(runMigrations);
program.parse(process.argv);
// If there is no command listed, output help.
if (process.argv.length <= 2) {
program.outputHelp();
util.shutdown();
}
+44 -1
View File
@@ -2,9 +2,13 @@
const program = require('./commander');
const app = require('../app');
const debug = require('debug')('talk:cli:serve');
const errors = require('../errors');
const {createServer} = require('http');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const MigrationService = require('../services/migration');
const SetupService = require('../services/setup');
const kue = require('../services/kue');
const mongoose = require('../services/mongoose');
const util = require('./util');
@@ -87,7 +91,46 @@ function onListening() {
/**
* Start the app.
*/
function startApp(program) {
async function startApp(program) {
try {
// Check to see if the application is installed. If the application
// has been installed, then it will throw errors.ErrSettingsNotInit, this
// just means we don't have to check that the migrations have run.
await SetupService.isAvailable();
debug('setup is currently available, migrations not being checked');
} catch (e) {
// Check the error.
switch (e) {
case errors.ErrInstallLock, errors.ErrSettingsInit:
debug('setup is not currently available, migrations now being checked');
// The error was expected, just continue.
break;
default:
// The error was not expected, throw the error!
throw e;
}
// Now try and check the migration status.
try {
// Verify that the minimum migration version is met.
await MigrationService.verify();
} catch (e) {
console.error(e);
process.exit(1);
}
debug('migrations do not have to be run');
}
/**
* Listen on provided port, on all network interfaces.
+139 -149
View File
@@ -8,6 +8,7 @@ const program = require('./commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingModel = require('../models/setting');
const MODERATION_OPTIONS = require('../models/enum/moderation_options');
const SettingsService = require('../services/settings');
const SetupService = require('../services/setup');
const UsersService = require('../services/users');
@@ -32,166 +33,155 @@ program
// Setup the application
//==============================================================================
const performSetup = () => {
if (program.defaults) {
return SettingsService
.init()
.then(() => {
console.log('Settings created.');
console.log('\nTalk is now installed!');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
const performSetup = async () => {
// Get the current settings, we are expecing an error here.
return SettingsService
.retrieve()
.then(() => {
try {
// We should NOT have gotten a settings object, this means that the
// application is already setup. Error out here.
throw errors.ErrSettingsInit;
// Try to get the settings.
await SettingsService.retrieve();
})
.catch((err) => {
// We should NOT have gotten a settings object, this means that the
// application is already setup. Error out here.
throw errors.ErrSettingsInit;
// If the error is `not init`, then we're good, otherwise, it's something
// else.
if (err !== errors.ErrSettingsNotInit) {
throw err;
} catch (e) {
// If the error is `not init`, then we're good, otherwise, it's something
// else.
if (e !== errors.ErrSettingsNotInit) {
throw e;
}
}
if (program.defaults) {
await SettingsService.init();
console.log('Settings created.');
console.log('\nTalk is now installed!');
return;
}
// Create the base settings model.
let settings = new SettingModel();
console.log('\nWe\'ll ask you some questions in order to setup your installation of Talk.\n');
let answers = await inquirer.prompt([
{
type: 'input',
name: 'organizationName',
message: 'Organization Name',
default: settings.organizationName,
validate: (input) => {
if (input && input.length > 0) {
return true;
}
return 'Organization Name is required.';
}
},
{
type: 'list',
choices: MODERATION_OPTIONS,
name: 'moderation',
default: settings.moderation,
message: 'Select a moderation mode'
},
{
type: 'confirm',
name: 'requireEmailConfirmation',
default: settings.requireEmailConfirmation,
message: 'Should emails always be confirmed'
}
]);
})
.then(() => {
// Update the settings that were changed.
Object.keys(answers).forEach((key) => {
if (answers[key] !== undefined) {
settings[key] = answers[key];
}
});
// Create the base settings model.
let settings = new SettingModel();
console.log('\nWe\'ll ask you some questions about your first admin user.\n');
console.log('We\'ll ask you some questions in order to setup your installation of Talk.\n');
return inquirer.prompt([
{
type: 'input',
name: 'organizationName',
message: 'Organization Name',
default: settings.organizationName,
validate: (input) => {
if (input && input.length > 0) {
return true;
}
return 'Organization Name is required.';
}
},
{
type: 'list',
choices: SettingModel.MODERATION_OPTIONS,
name: 'moderation',
default: settings.moderation,
message: 'Select a moderation mode'
},
{
type: 'confirm',
name: 'requireEmailConfirmation',
default: settings.requireEmailConfirmation,
message: 'Should emails always be confirmed'
}
])
.then((answers) => {
// Update the settings that were changed.
Object.keys(answers).forEach((key) => {
if (answers[key] !== undefined) {
settings[key] = answers[key];
}
});
console.log('\nWe\'ll ask you some questions about your first admin user.\n');
return inquirer.prompt([
{
type: 'input',
name: 'username',
message: 'Username',
filter: (username) => {
return UsersService
.isValidUsername(username, false)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'email',
message: 'Email',
format: 'email',
validate: (value) => {
if (value && value.length >= 3) {
return true;
}
return 'Email is required';
}
},
{
name: 'password',
message: 'Password',
type: 'password',
filter: (password) => {
return UsersService
.isValidPassword(password)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
filter: (confirmPassword) => {
return UsersService
.isValidPassword(confirmPassword)
.catch((err) => {
throw err.message;
});
}
},
]);
})
.then((user) => {
if (user.password !== user.confirmPassword) {
return Promise.reject(new Error('Passwords do not match'));
let user = await inquirer.prompt([
{
type: 'input',
name: 'username',
message: 'Username',
filter: (username) => {
return UsersService
.isValidUsername(username, false)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'email',
message: 'Email',
format: 'email',
validate: (value) => {
if (value && value.length >= 3) {
return true;
}
return SetupService.setup({
settings: settings.toObject(),
user: {
email: user.email,
username: user.username,
password: user.password
}
});
});
})
.then(({user}) => {
console.log('Settings created.');
console.log(`User ${user.id} created.`);
console.log('\nTalk is now installed!');
console.log('\nWe recommend adding TALK_INSTALL_LOCK=TRUE to your environment to turn off the dynamic setup.');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
return 'Email is required';
}
},
{
name: 'password',
message: 'Password',
type: 'password',
filter: (password) => {
return UsersService
.isValidPassword(password)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
filter: (confirmPassword) => {
return UsersService
.isValidPassword(confirmPassword)
.catch((err) => {
throw err.message;
});
}
},
]);
if (user.password !== user.confirmPassword) {
return Promise.reject(new Error('Passwords do not match'));
}
let {user: newUser} = await SetupService.setup({
settings: settings.toObject(),
user: {
email: user.email,
username: user.username,
password: user.password
}
});
console.log('Settings created.');
console.log(`User ${newUser.id} created.`);
console.log('\nTalk is now installed!');
console.log('\nWe recommend adding TALK_INSTALL_LOCK=TRUE to your environment to turn off the dynamic setup.');
};
// Start tthe setup process.
performSetup();
performSetup()
.then(() => {
util.shutdown();
})
.catch((e) => {
console.error(e);
util.shutdown(1);
});
+5 -6
View File
@@ -4,21 +4,20 @@
"es6": true,
"mocha": true
},
"extends": "../.eslintrc.json",
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
},
"sourceType": "module"
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
@@ -33,3 +33,21 @@ export const setSortOrder = (order) => ({
order
});
export const changeUserDetailStatuses = (tab) => {
let statuses;
if (tab === 'all') {
statuses = ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'];
} else if (tab === 'rejected') {
statuses = ['REJECTED'];
}
return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses};
};
export const clearUserDetailSelections = () => ({type: actions.CLEAR_USER_DETAIL_SELECTIONS});
export const toggleSelectCommentInUserDetail = (id, active) => {
return {
type: active ? actions.SELECT_USER_DETAIL_COMMENT : actions.UNSELECT_USER_DETAIL_COMMENT,
id
};
};
@@ -17,11 +17,11 @@ const ActionButton = ({type = '', active, ...props}) => {
return (
<Button
className={`${typeName} ${styles.actionButton} ${active ? styles[`${typeName}__active`] : ''}`}
className={`${typeName} ${styles.actionButton} ${props.minimal ? styles.minimal : ''} ${active ? styles[`${typeName}__active`] : ''}`}
cStyle={typeName}
icon={menuActionsMap[type].icon}
onClick={type === 'APPROVE' ? props.acceptComment : props.rejectComment}
>{t(`modqueue.${text}`)}</Button>
>{props.minimal ? '' : t(`modqueue.${text}`)}</Button>
);
};
@@ -189,6 +189,12 @@
width: 140px;
}
.minimal {
width: 45px;
min-width: 0;
float: left;
}
.approve__active {
box-shadow: none;
color: white;
@@ -77,7 +77,7 @@
letter-spacing: .8;
&:hover {
background-color: #232323;
background-color: #404040;
}
&.active {
@@ -8,3 +8,7 @@ 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';
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';
+2 -1
View File
@@ -9,9 +9,10 @@ import App from './components/App';
import 'react-mdl/extra/material.js';
import './graphql';
import {loadPluginsTranslations} from 'coral-framework/helpers/plugins';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
loadPluginsTranslations();
injectPluginsReducers();
render(
<ApolloProvider client={client} store={store}>
+1 -1
View File
@@ -13,5 +13,5 @@ export default {
community,
moderation,
install,
config
config,
};
+17 -2
View File
@@ -1,4 +1,4 @@
import {fromJS, Map} from 'immutable';
import {fromJS, Map, Set} from 'immutable';
import * as actions from '../constants/moderation';
const initialState = fromJS({
@@ -8,6 +8,9 @@ const initialState = fromJS({
commentId: null,
commentStatus: null,
userDetailId: null,
userDetailActiveTab: 'all',
userDetailStatuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'],
userDetailSelectedIds: new Set(),
banDialog: false,
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
sortOrder: 'REVERSE_CHRONOLOGICAL',
@@ -64,7 +67,19 @@ export default function moderation (state = initialState, action) {
case actions.VIEW_USER_DETAIL:
return state.set('userDetailId', action.userId);
case actions.HIDE_USER_DETAIL:
return state.set('userDetailId', null);
return state
.set('userDetailId', null)
.update('userDetailSelectedIds', (set) => set.clear());
case actions.CLEAR_USER_DETAIL_SELECTIONS:
return state.update('userDetailSelectedIds', (set) => set.clear());
case actions.CHANGE_USER_DETAIL_STATUSES:
return state
.set('userDetailActiveTab', action.tab)
.set('userDetailStatuses', action.statuses);
case actions.SELECT_USER_DETAIL_COMMENT:
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.SET_SORT_ORDER:
return state.set('sortOrder', action.order);
default :
@@ -175,7 +175,7 @@
right: 0;
height: 100%;
top: 0;
padding: 40px 18px;
padding: 50px 18px;
box-sizing: border-box;
}
@@ -310,24 +310,33 @@
.flaggedByCount {
display: block;
text-align: left;
margin-top: 5px;
}
.flaggedByCount i {
font-size: 14px;
margin-right: 10px;
}
.flaggedBy {
display: inline;
padding: 3px;
font-size: 16px;
font-size: 14px;
margin-left: 5px;
}
.flaggedByLabel {
font-weight: bold;
font-weight: 600;
font-size: 14px;
}
.flaggedReasons {
padding-top: 15px;
margin-left: 24px;
margin-top: 10px;
}
.flaggedByReason {
p.flaggedByReason {
font-size: 1tpx;
margin: 0px;
line-height: 1.4;
}
@@ -52,6 +52,10 @@ span {
}
}
.approve {
margin-top: 10px;
}
.notFound {
position: relative;
margin: 20px auto;
@@ -193,7 +197,7 @@ span {
top: 0;
padding: 40px 18px;
box-sizing: border-box;
}
}
.itemHeader {
display: flex;
@@ -255,6 +259,8 @@ span {
}
.empty {
color: #444;
margin-top: 50px;
@@ -99,7 +99,7 @@
.inlineTextfield {
border-color: #ccc;
border-style: solid;
border-width: 0px 0px 1px 0px;
border-width: 0px 0px 1px 0px;
text-align: center;
font-size: inherit;
}
@@ -108,7 +108,7 @@
outline: none;
}
.charCountTexfield {
.charCountTexfield, .editCommentTimeframeTextfield {
width: 4em;
padding: 0px;
}
@@ -25,12 +25,6 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => {
const on = styles.enabledSetting;
const off = styles.disabledSetting;
const onChangeEditCommentWindowLength = (e) => {
const value = e.target.value;
const valueAsNumber = parseFloat(value);
const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000);
updateSettings({editCommentWindowLength: milliseconds || value});
};
return (
<div className={styles.Configure}>
<Card className={`${styles.configSetting} ${settings.requireEmailConfirmation ? on : off}`}>
@@ -76,27 +70,6 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => {
bannedWords={settings.wordlist.banned}
suspectWords={settings.wordlist.suspect}
onChangeWordlist={onChangeWordlist} />
{/* Edit Comment Timeframe */}
<Card className={styles.configSetting}>
<div className={styles.settingsHeader}>{t('configure.edit_comment_timeframe_heading')}</div>
<p>
{t('configure.edit_comment_timeframe_text_pre')}
&nbsp;
<input
style={{width: '3em'}}
className={styles.inlineTextfield}
type="number"
min="0"
onChange={onChangeEditCommentWindowLength}
placeholder="30"
defaultValue={(settings.editCommentWindowLength / 1000) /* saved as ms, rendered as seconds */}
pattern='[0-9]+([\.][0-9]*)?'
/>
&nbsp;
{t('configure.edit_comment_timeframe_text_post')}
</p>
</Card>
</div>
);
};
@@ -61,6 +61,13 @@ const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => {
}
};
const updateEditCommentWindowLength = (updateSettings) => (e) => {
const value = e.target.value;
const valueAsNumber = parseFloat(value);
const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000);
updateSettings({editCommentWindowLength: milliseconds || value});
};
const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
// just putting this here for shorthand below
@@ -132,6 +139,25 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
</div>
</div>
</Card>
{/* Edit Comment Timeframe */}
<Card className={styles.configSetting}>
<div className={styles.settingsHeader}>{t('configure.edit_comment_timeframe_heading')}</div>
<p>
{t('configure.edit_comment_timeframe_text_pre')}
&nbsp;
<input
className={`${styles.inlineTextfield} ${styles.editCommentTimeframeTextfield}`}
type="number"
min="0"
onChange={updateEditCommentWindowLength(updateSettings)}
placeholder="30"
defaultValue={(settings.editCommentWindowLength / 1000) /* saved as ms, rendered as seconds */}
pattern='[0-9]+([\.][0-9]*)?'
/>
&nbsp;
{t('configure.edit_comment_timeframe_text_post')}
</p>
</Card>
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox}`}>
<div className={styles.action}>
<Checkbox
@@ -17,8 +17,9 @@
.heading {
margin: 0;
padding-left: 10px;
font-size: 1.5rem;
font-weight: bold;
font-size: 1.3rem;
font-weight: 600;
color: #2c2c2c;
}
.widgetTable {
@@ -32,7 +33,8 @@
}
.widgetHead p {
color: rgb(35, 102, 223);
color: #2c2c2c;
font-weight: 500;
padding: 10px;
text-align: left;
text-transform: capitalize;
@@ -47,11 +49,11 @@
}
.rowLinkify {
cursor: pointer;
border-bottom: 1px solid lightgrey;
color: #555;
height: var(--row-height);
padding: 10px;
transition: background-color 200ms;
}
.rowLinkify:last-child {
@@ -60,6 +62,7 @@
.rowLinkify:hover {
background-color: #f8f8f8;
pointer: default;
}
.linkToAsset {
@@ -68,16 +71,17 @@
}
.linkToModerate {
background-color: #e0e0e0;
background-color: #BDBDBD;
padding: 10px 14px;
text-decoration: none;
color: black;
float: right;
margin-left: 15px;
transition: background-color 200ms;
}
.linkToModerate:hover {
background-color: #ccc;
background-color: #9E9E9E;
}
.lede {
@@ -89,14 +93,10 @@
color: #555;
text-decoration: none;
font-size: 1.2em;
font-weight: normal;
font-weight: 500;
margin: 0;
}
.assetTitle:hover {
text-decoration: underline;
}
.widgetCount {
color: #555;
font-size: 1.3em;
@@ -23,6 +23,9 @@ const Comment = ({
viewUserDetail,
suspectWords,
bannedWords,
minimal,
selected,
toggleSelect,
...props
}) => {
const links = linkify.getMatches(comment.body);
@@ -47,17 +50,38 @@ const Comment = ({
})
.concat(linkText);
let selectionStateCSS;
if (minimal) {
selectionStateCSS = selected ? styles.minimalSelection : '';
} else {
selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
}
return (
<li
tabIndex={props.index}
className={`mdl-card ${props.selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem} ${props.selected ? styles.selected : ''}`}
className={`mdl-card ${selectionStateCSS} ${styles.Comment} ${styles.listItem} ${minimal ? styles.minimal : ''}`}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
{comment.user.name}
</span>
{
!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>
@@ -128,6 +152,7 @@ const Comment = ({
(action === 'APPROVE' && comment.status === 'ACCEPTED');
return (
<ActionButton
minimal={minimal}
key={i}
type={action}
user={comment.user}
@@ -154,14 +179,12 @@ const Comment = ({
</div>
</div>
</div>
<div>
<Slot
data={props.data}
root={props.root}
fill="adminCommentDetailArea"
comment={comment}
/>
</div>
<Slot
data={props.data}
root={props.root}
fill="adminCommentDetailArea"
comment={comment}
/>
{flagActions && flagActions.length
? <FlagBox
actions={flagActions}
@@ -173,6 +196,7 @@ const Comment = ({
};
Comment.propTypes = {
minimal: PropTypes.bool,
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
@@ -182,6 +206,7 @@ Comment.propTypes = {
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
currentUserId: PropTypes.string.isRequired,
toggleSelect: PropTypes.func,
comment: PropTypes.shape({
body: PropTypes.string.isRequired,
action_summaries: PropTypes.array,
@@ -12,4 +12,5 @@
right: 0;
margin-top: -2px;
font-size: 12px;
color: white;
}
@@ -187,10 +187,15 @@ export default class Moderation extends Component {
{moderation.userDetailId && (
<UserDetail
id={moderation.userDetailId}
hideUserDetail={hideUserDetail} />
hideUserDetail={hideUserDetail}
bannedWords={settings.wordlist.banned}
suspectWords={settings.wordlist.suspect}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment} />
)}
</div>
);
}
}
@@ -15,7 +15,7 @@
display: flex;
.stat {
margin: 0 4px 12px;
margin: 0 4px 10px 0px;
}
.stat:last-child {
@@ -36,6 +36,47 @@
background-color: transparent;
font-size: 16px;
position: absolute;
width: 100%;
width: 90%;
outline: none;
}
.commentStatuses {
padding: 10px 0 0 0;
margin: 0;
height: 52px;
list-style: none;
box-sizing: border-box;
li {
display: inline-block;
margin-right: 10px;
cursor: pointer;
padding: 0 10px;
}
}
.active {
font-weight: bold;
border-bottom: 3px solid #F36451;
}
.bulkActionGroup {
height: 52px;
background-color: #efefef;
i {
margin-right: 0;
}
.bulkAction {
display: inline-block;
width: 48px;
height: 48px;
transform: scale(.7);
min-width: 0;
}
.bulkAction:last-child {
margin-left: -10px;
}
}
@@ -2,12 +2,24 @@ 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 {actionsMap} from '../helpers/moderationQueueActionsMap';
export default class UserDetail extends React.Component {
static propTypes = {
id: PropTypes.string.isRequired,
hideUserDetail: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
bannedWords: PropTypes.array.isRequired,
suspectWords: PropTypes.array.isRequired,
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
changeStatus: PropTypes.func.isRequired,
toggleSelect: PropTypes.func.isRequired,
bulkAccept: PropTypes.func.isRequired,
bulkReject: PropTypes.func.isRequired,
}
copyPermalink = () => {
@@ -20,15 +32,55 @@ export default class UserDetail extends React.Component {
}
}
rejectThenReload = (info) => {
this.props.rejectComment(info).then(() => {
this.props.data.refetch();
});
}
acceptThenReload = (info) => {
this.props.acceptComment(info).then(() => {
this.props.data.refetch();
});
}
showAll = () => {
this.props.changeStatus('all');
}
showRejected = () => {
this.props.changeStatus('rejected');
}
render () {
const {root: {user, totalComments, rejectedComments}, hideUserDetail} = this.props;
const {
root: {
user,
totalComments,
rejectedComments,
comments: {nodes}
},
moderation: {
userDetailActiveTab: tab,
userDetailSelectedIds: selectedIds
},
bannedWords,
suspectWords,
toggleSelect,
bulkAccept,
bulkReject,
showBanUserDialog,
showSuspendUserDialog,
hideUserDetail
} = this.props;
const localProfile = user.profiles.find((p) => p.provider === 'local');
let profile;
if (localProfile) {
profile = localProfile.id;
}
let rejectedPercent = rejectedComments / totalComments;
let rejectedPercent = (rejectedComments / totalComments) * 100;
if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
// if totalComments is 0, you're dividing by zero, which is naughty
@@ -62,8 +114,60 @@ export default class UserDetail extends React.Component {
<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>
)
}
<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} />;
})
}
</div>
</Drawer>
);
}
}
@@ -18,14 +18,20 @@
.tab {
flex: 1;
color: #EEEEEE;
color: #C0C0C0;
text-transform: capitalize;
font-weight: 100;
font-size: 15px;
font-size: 14px;
letter-spacing: 1px;
transition: border-bottom 200ms;
padding: 0px 5px;
margin-right: 30px;
transition: color 200ms;
padding: 0px 10px;
margin-right: 20px;
&:hover {
color: white;
border-bottom: solid 2px #F36451;
box-sizing: border-box;
}
}
.active {
@@ -33,6 +39,10 @@
box-sizing: border-box;
border-bottom: solid 4px #F36451;
font-weight: 400;
&:hover {
border-bottom: solid 4px #F36451;
font-weight: 400;
}
}
.active > span {
@@ -103,19 +113,18 @@ span {
font-weight: 400;
font-size: 15px;
letter-spacing: 1px;
transition: opacity 200ms;
transition: background-color 200ms;
opacity: 1;
&:hover {
opacity: .8;
cursor: pointer;
}
&:first-child {
text-align: left;
}
&:nth-child(2) {
&:hover {
cursor: pointer;
background-color: #212121;
}
span {
text-align: center;
text-overflow: ellipsis;
@@ -178,16 +187,11 @@ span {
margin: 0 auto;
position: relative;
transition: all 200ms;
padding: 10px 0 0;
min-height: 220px;
padding: 10px 0;
min-height: 0;
.container {
padding: 0 14px;
min-height: 180px;
}
&:hover {
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
}
&:last-child {
@@ -247,9 +251,9 @@ span {
}
.created {
padding: 5px;
color: #262626;
font-size: 14px;
margin-left: 15px;
line-height: 1px;
font-weight: 300;
}
@@ -291,7 +295,6 @@ span {
@media (--big-viewport) {
.listItem {
border: 1px solid #e0e0e0;
margin-bottom: 30px;
&:last-child {
@@ -426,18 +429,21 @@ span {
.tabIcon {
position: relative;
top: 2px;
top: 3px;
font-size: 16px;
}
.username {
color: blue;
text-decoration: underline;
padding: 5px;
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: rgba(255, 0, 0, .1);
background-color: #E0E0E0;
}
}
@@ -461,3 +467,15 @@ span {
position: relative;
}
}
.minimal {
margin: 0;
}
.minimalSelection {
background-color: #ecf4ff;
}
.bulkSelectInput {
cursor: pointer;
}
@@ -1,8 +1,30 @@
import React, {PropTypes} from 'react';
import {compose, gql} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import UserDetail from '../components/UserDetail';
import withQuery from 'coral-framework/hocs/withQuery';
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
import {getDefinitionName} from 'coral-framework/utils';
import {
changeUserDetailStatuses,
clearUserDetailSelections,
toggleSelectCommentInUserDetail
} from 'coral-admin/src/actions/moderation';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import Comment from './Comment';
const commentConnectionFragment = gql`
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
nodes {
...${getDefinitionName(Comment.fragments.comment)}
}
hasNextPage
startCursor
endCursor
}
${Comment.fragments.comment}
`;
const pluginFragments = getSlotsFragments([
'userProfile',
@@ -14,17 +36,42 @@ class UserDetailContainer extends React.Component {
hideUserDetail: PropTypes.func.isRequired
}
// status can be 'ACCEPTED' or 'REJECTED'
bulkSetCommentStatus = (status) => {
const changes = this.props.moderation.userDetailSelectedIds.map((commentId) => {
return this.props.setCommentStatus({commentId, status});
});
Promise.all(changes).then(() => {
this.props.data.refetch(); // some comments may have moved out of this tab
this.props.clearUserDetailSelections(); // un-select everything
});
}
bulkReject = () => {
this.bulkSetCommentStatus('REJECTED');
}
bulkAccept = () => {
this.bulkSetCommentStatus('ACCEPTED');
}
render () {
if (!('user' in this.props.root)) {
return null;
}
return <UserDetail {...this.props}/>;
return <UserDetail
bulkReject={this.bulkReject}
bulkAccept={this.bulkAccept}
changeStatus={this.props.changeUserDetailStatuses}
toggleSelect={this.props.toggleSelectCommentInUserDetail}
{...this.props} />;
}
}
export const withUserDetailQuery = withQuery(gql`
query CoralAdmin_UserDetail($author_id: ID!) {
query CoralAdmin_UserDetail($author_id: ID!, $statuses: [COMMENT_STATUS!]) {
user(id: $author_id) {
id
username
@@ -37,18 +84,40 @@ export const withUserDetailQuery = withQuery(gql`
}
totalComments: commentCount(query: {author_id: $author_id})
rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
comments: comments(query: {
author_id: $author_id,
statuses: $statuses
}) {
...CoralAdmin_Moderation_CommentConnection
}
${pluginFragments.spreads('root')}
}
${Comment.fragments.comment}
${pluginFragments.definitions('user')}
${pluginFragments.definitions('root')}
${commentConnectionFragment}
`, {
options: ({id}) => {
options: ({id, moderation: {userDetailStatuses: statuses}}) => {
return {
variables: {author_id: id}
variables: {author_id: id, statuses}
};
}
});
const mapStateToProps = (state) => ({
moderation: state.moderation.toJS()
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
changeUserDetailStatuses,
clearUserDetailSelections,
toggleSelectCommentInUserDetail
}, dispatch)
});
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withUserDetailQuery,
withSetCommentStatus,
)(UserDetailContainer);
@@ -26,8 +26,7 @@ const fm = new IntrospectionFragmentMatcher({
{name: 'SetUserStatusResponse'},
{name: 'SuspendUserResponse'},
{name: 'SetCommentStatusResponse'},
{name: 'AddCommentTagResponse'},
{name: 'RemoveCommentTagResponse'},
{name: 'ModifyTagResponse'},
{name: 'IgnoreUserResponse'},
{name: 'StopIgnoringUserResponse'}
]
+8 -4
View File
@@ -14,14 +14,18 @@ if (window.devToolsExtension) {
middlewares.push(window.devToolsExtension());
}
const coralReducers = {
...mainReducer,
apollo: client.reducer()
};
const store = createStore(
combineReducers({
...mainReducer,
apollo: client.reducer()
}),
combineReducers(coralReducers),
{},
compose(...middlewares)
);
store.coralReducers = coralReducers;
window.coralStore = store;
export default store;
@@ -2,7 +2,6 @@ import {pym} from 'coral-framework';
import * as actions from '../constants/stream';
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
export const setCommentCountCache = (amount) => ({type: actions.SET_COMMENT_COUNT_CACHE, amount});
function removeParam(key, sourceURL) {
let rtn = sourceURL.split('?')[0];
@@ -38,3 +37,13 @@ export const viewAllComments = () => {
return {type: actions.VIEW_ALL_COMMENTS};
};
export const addCommentClassName = (className) => ({
type: actions.ADD_COMMENT_CLASSNAME,
className
});
export const removeCommentClassName = (idx) => ({
type: actions.REMOVE_COMMENT_CLASSNAME,
idx
});
@@ -97,3 +97,17 @@
.Wizard .textAlignRight {
text-align: right;
}
.commentInfoBar {
float: right;
}
@keyframes enter {
0% {background-color: rgba(0, 0, 0, 0);}
50% {background-color: rgba(255,255,0, 0.2);}
100% {background-color: rgba(0, 0, 0, 0);}
}
.enter {
animation: enter 1000ms;
}
@@ -1,13 +1,16 @@
import React, {PropTypes} from 'react';
import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton';
import AuthorName from 'coral-plugin-author-name/AuthorName';
import TagLabel from 'coral-plugin-tag-label/TagLabel';
import Content from 'coral-plugin-commentcontent/CommentContent';
import PubDate from 'coral-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
import FlagComment from 'coral-plugin-flags/FlagComment';
import {can} from 'coral-framework/services/perms';
import {TransitionGroup} from 'react-transition-group';
import cn from 'classnames';
import styles from './Comment.css';
import {
BestButton,
IfUserCanModifyBest,
@@ -15,17 +18,52 @@ import {
commentIsBest,
BestIndicator
} from 'coral-plugin-best/BestButton';
import Slot from 'coral-framework/components/Slot';
import LoadMore from './LoadMore';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import {getEditableUntilDate} from './util';
import {TopRightMenu} from './TopRightMenu';
import classnames from 'classnames';
import CommentContent from './CommentContent';
import Slot from 'coral-framework/components/Slot';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import {EditableCommentContent} from './EditableCommentContent';
import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils';
import {getEditableUntilDate} from './util';
import styles from './Comment.css';
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF');
const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF');
const hasTag = (tags, lookupTag) => !!tags.filter((t) => t.tag.name === lookupTag).length;
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
// resetCursors will return the id cursors of the first and second newest comment in
// the current reply list. The cursors are used to dertermine which
// comments to show. The spare cursor functions as a backup in case one
// of the comments gets deleted.
function resetCursors(state, props) {
const replies = props.comment.replies;
if (replies && replies.nodes.length) {
const idCursors = [replies.nodes[replies.nodes.length - 1].id];
if (replies.nodes.length >= 2) {
idCursors.push(replies.nodes[replies.nodes.length - 2].id);
}
return {idCursors};
}
return {idCursors: []};
}
// invalidateCursor is called whenever a comment is removed which is referenced
// by one of the 2 id cursors. It returns a new set of id cursors calculated
// using the help of the backup cursor.
function invalidateCursor(invalidated, state, props) {
const alt = invalidated === 1 ? 0 : 1;
const replies = props.comment.replies;
const idCursors = [];
if (state.idCursors[alt]) {
idCursors.push(state.idCursors[alt]);
const index = replies.nodes.findIndex((node) => node.id === idCursors[0]);
const prevInLine = replies.nodes[index - 1];
if (prevInLine) {
idCursors.push(prevInLine.id);
}
}
return {idCursors};
}
// hold actions links (e.g. Reply) along the comment footer
const ActionButton = ({children}) => {
@@ -36,7 +74,7 @@ const ActionButton = ({children}) => {
);
};
class Comment extends React.Component {
export default class Comment extends React.Component {
constructor(props) {
super(props);
@@ -49,9 +87,47 @@ class Comment extends React.Component {
// Whether the comment should be editable (e.g. after a commenter clicking the 'Edit' button on their own comment)
isEditing: false,
replyBoxVisible: false,
animateEnter: false,
...resetCursors({}, props),
};
}
componentWillReceiveProps(next) {
const {comment: {replies: prevReplies}} = this.props;
const {comment: {replies: nextReplies}} = next;
if (
prevReplies && nextReplies &&
nextReplies.nodes.length < prevReplies.nodes.length
) {
// Invalidate first cursor if referenced comment was removed.
if (this.state.idCursors[0] && !hasComment(nextReplies.nodes, this.state.idCursors[0])) {
this.setState(invalidateCursor(0, this.state, next));
}
// Invalidate second cursor if referenced comment was removed.
if (this.state.idCursors[1] && !hasComment(nextReplies.nodes, this.state.idCursors[1])) {
this.setState(invalidateCursor(1, this.state, next));
}
}
}
componentWillEnter(callback) {
callback();
const userId = this.props.currentUser ? this.props.currentUser.id : null;
if (this.props.comment.id.indexOf('pending') >= 0) {
return;
}
if (userId && this.props.comment.user.id === userId) {
// This comment was just added by currentUser.
if (Date.now() - Number(new Date(this.props.comment.created_at)) < 30 * 1000) {
return;
}
}
this.setState({animateEnter: true});
}
static propTypes = {
reactKey: PropTypes.string.isRequired,
@@ -67,6 +143,7 @@ class Comment extends React.Component {
addNotification: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
depth: PropTypes.number.isRequired,
liveUpdates: PropTypes.bool.isRequired,
asset: PropTypes.shape({
id: PropTypes.string,
title: PropTypes.string,
@@ -104,10 +181,10 @@ class Comment extends React.Component {
commentIsIgnored: React.PropTypes.func,
// dispatch action to add a tag to a comment
addCommentTag: React.PropTypes.func,
addTag: React.PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: React.PropTypes.func,
removeTag: React.PropTypes.func,
// dispatch action to ignore another user
ignoreUser: React.PropTypes.func,
@@ -127,6 +204,62 @@ class Comment extends React.Component {
}
}
hasIgnoredReplies() {
return this.props.comment.replies &&
this.props.comment.replies.nodes.some((reply) => this.props.commentIsIgnored(reply));
}
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;
}
this.setState(resetCursors);
};
showReplyBox = () => {
if (!this.props.currentUser) {
this.props.showSignInDialog();
return;
}
if (can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.setActiveReplyBox(this.props.comment.id);
return;
}
return;
}
// getVisibileReplies returns a list containing comments
// which were authored by current user or comes before the `idCursor`.
getVisibileReplies() {
const {comment: {replies}, currentUser, liveUpdates} = this.props;
const idCursor = this.state.idCursors[0];
const userId = currentUser ? currentUser.id : null;
if (!replies) {
return [];
}
if (liveUpdates) {
return replies.nodes;
}
const view = [];
let pastCursor = false;
replies.nodes.forEach((comment) => {
if (idCursor && !pastCursor || comment.user.id === userId) {
view.push(comment);
}
if (comment.id === idCursor) {
pastCursor = true;
}
});
return view;
}
componentDidMount() {
this._isMounted = true;
if (this.editWindowExpiryTimeout) {
@@ -151,30 +284,35 @@ class Comment extends React.Component {
}
render () {
const {
comment,
parentId,
currentUser,
asset,
depth,
postComment,
addNotification,
showSignInDialog,
highlighted,
comment,
postFlag,
parentId,
ignoreUser,
highlighted,
postComment,
currentUser,
postDontAgree,
loadMore,
setActiveReplyBox,
activeReplyBox,
deleteAction,
addCommentTag,
removeCommentTag,
ignoreUser,
disableReply,
commentIsIgnored,
maxCharCount,
charCountEnable
addNotification,
charCountEnable,
showSignInDialog,
addTag,
removeTag,
liveUpdates,
commentIsIgnored,
commentClassNames = []
} = this.props;
const view = this.getVisibileReplies();
const hasMoreComments = comment.replies && (comment.replies.hasNextPage || comment.replies.nodes.length > view.length);
const replyCount = this.hasIgnoredReplies() ? '' : comment.replyCount;
const flagSummary = getActionSummary('FlagActionSummary', comment);
const dontAgreeSummary = getActionSummary(
'DontAgreeActionSummary',
@@ -208,25 +346,57 @@ class Comment extends React.Component {
const addBestTag = notifyOnError(
() =>
addCommentTag({
addTag({
id: comment.id,
tag: BEST_TAG
name: BEST_TAG,
assetId: asset.id
}),
() => 'Failed to tag comment as best'
);
const removeBestTag = notifyOnError(
() =>
removeCommentTag({
removeTag({
id: comment.id,
tag: BEST_TAG
name: BEST_TAG,
assetId: asset.id
}),
() => 'Failed to remove best comment tag'
);
/**
* classNamesToAdd
* adds classNames based on condition
* classnames is an array of objects with key as classnames and value as conditions
* i.e:
* {
* 'myClassName': { tags: ['STAFF']}
* }
*
* This will add myClassName to comments tagged with STAFF TAG.
* **/
const classNamesToAdd = commentClassNames.reduce((acc, className) => {
let res = [];
// Adding classNames based on tags
Object.keys(className).forEach((cn) => {
const condition = className[cn];
condition.tags.forEach((tag) => {
if (hasTag(comment.tags, tag)) {
res = [...res, cn];
}
});
});
// TODO: Compare rest of the comment obj to the condition if needed
return [...acc, ...res];
}, []);
return (
<div
className={commentClass}
className={cn(commentClass, 'talk-stream-comment-wrapper', classNamesToAdd, {[styles.enter]: this.state.animateEnter})}
id={`c_${comment.id}`}
style={{marginLeft: depth * 30}}
>
@@ -234,15 +404,15 @@ class Comment extends React.Component {
<div
className={highlighted === comment.id ? 'highlighted-comment' : ''}
>
<AuthorName author={comment.user} />
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
{commentIsBest(comment)
? <TagLabel><BestIndicator /></TagLabel>
: null }
<span className={styles.bylineSecondary}>
<PubDate created_at={comment.created_at} />
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
<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>
@@ -251,11 +421,13 @@ class Comment extends React.Component {
</span>
<Slot
className={styles.commentInfoBar}
fill="commentInfoBar"
data={this.props.data}
root={this.props.root}
depth={depth}
comment={comment}
commentId={comment.id}
data={this.props.data}
root={this.props.root}
inline
/>
@@ -263,17 +435,17 @@ class Comment extends React.Component {
(comment.user.id === currentUser.id))
/* User can edit/delete their own comment for a short window after posting */
? <span className={classnames(styles.topRight)}>
? <span className={cn(styles.topRight)}>
{
commentIsStillEditable(comment) &&
<a
className={classnames(styles.link, {[styles.active]: this.state.isEditing})}
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
onClick={this.onClickEdit}>Edit</a>
}
</span>
/* TopRightMenu allows currentUser to ignore other users' comments */
: <span className={classnames(styles.topRight, styles.topRightMenu)}>
: <span className={cn(styles.topRight, styles.topRightMenu)}>
<TopRightMenu
comment={comment}
ignoreUser={ignoreUser}
@@ -294,8 +466,7 @@ class Comment extends React.Component {
stopEditing={this.stopEditing}
/>
: <div>
<Content body={comment.body} />
<Slot fill="commentContent" />
<Slot fill="commentContent" comment={comment} defaultComponent={CommentContent} />
</div>
}
@@ -308,15 +479,6 @@ class Comment extends React.Component {
commentId={comment.id}
inline
/>
{!disableReply &&
<ActionButton>
<ReplyButton
onClick={() => setActiveReplyBox(comment.id)}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
banned={false}
/>
</ActionButton>}
<ActionButton>
<IfUserCanModifyBest user={currentUser}>
<BestButton
@@ -326,6 +488,14 @@ class Comment extends React.Component {
/>
</IfUserCanModifyBest>
</ActionButton>
{!disableReply &&
<ActionButton>
<ReplyButton
onClick={this.showReplyBox}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
/>
</ActionButton>}
<Slot
fill="commentActions"
data={this.props.data}
@@ -369,53 +539,54 @@ class Comment extends React.Component {
assetId={asset.id}
/>
: null}
{comment.replies &&
comment.replies.nodes.map((reply) => {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
parentId={comment.id}
postComment={postComment}
editComment={this.props.editComment}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
currentUser={currentUser}
postFlag={postFlag}
deleteAction={deleteAction}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
ignoreUser={ignoreUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
showSignInDialog={showSignInDialog}
reactKey={reply.id}
key={reply.id}
comment={reply}
/>;
})}
{comment.replies &&
<div className="coral-load-more-replies">
<LoadMore
topLevel={false}
replyCount={comment.replyCount}
moreComments={comment.replyCount > comment.replies.nodes.length}
loadMore={() => loadMore(comment.id)}
/>
</div>}
<TransitionGroup>
{view.map((reply) => {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
parentId={comment.id}
postComment={postComment}
editComment={this.props.editComment}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
currentUser={currentUser}
postFlag={postFlag}
deleteAction={deleteAction}
addTag={addTag}
removeTag={removeTag}
ignoreUser={ignoreUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
showSignInDialog={showSignInDialog}
commentIsIgnored={commentIsIgnored}
liveUpdates={liveUpdates}
reactKey={reply.id}
key={reply.id}
comment={reply}
/>;
})}
</TransitionGroup>
<div className="coral-load-more-replies">
<LoadMore
topLevel={false}
replyCount={replyCount}
moreComments={hasMoreComments}
loadMore={this.loadNewReplies}
/>
</div>
</div>
);
}
}
export default Comment;
// return whether the comment is editable
function commentIsStillEditable (comment) {
const editing = comment && comment.editing;
@@ -0,0 +1,19 @@
import React from 'react';
const CommentContent = ({comment}) => {
const textbreaks = comment.body.split('\n');
return <div className={`${name}-text`}>
{
textbreaks.map((line, i) => {
return (
<span key={i} className={`${name}-line`}>
{line}
<br className={`${name}-linebreak`}/>
</span>
);
})
}
</div>;
};
export default CommentContent;
@@ -41,10 +41,10 @@ export default class Embed extends React.Component {
return (
<div>
<div className="commentStream">
<TabBar onChange={this.changeTab} activeTab={activeTab}>
<Tab><Count count={totalCommentCount} /></Tab>
<Tab>{t('framework.my_profile')}</Tab>
<Tab restricted={!can(user, 'UPDATE_CONFIG')}>{t('framework.configure_stream')}</Tab>
<TabBar onChange={this.changeTab} activeTab={activeTab} className='talk-stream-tabbar'>
<Tab className={'talk-stream-comment-count-tab'} id='stream'><Count count={totalCommentCount}/></Tab>
<Tab className={'talk-stream-profile-tab'} id='profile'>{t('framework.my_profile')}</Tab>
<Tab className={'talk-stream-configuration-tab'} id='config' restricted={!can(user, 'UPDATE_CONFIG')}>{t('framework.configure_stream')}</Tab>
</TabBar>
{commentId &&
<Button
@@ -3,18 +3,22 @@ import React from 'react';
import t from 'coral-framework/services/i18n';
// Render in place of a Comment when the author of the comment is ignored
const IgnoredCommentTombstone = () => (
<div>
<hr aria-hidden={true} />
<p style={{
backgroundColor: '#F0F0F0',
textAlign: 'center',
padding: '1em',
color: '#3E4F71',
}}>
{t('framework.comment_is_ignored')}
</p>
</div>
);
class IgnoredCommentTombstone extends React.Component {
render() {
return (
<div>
<hr aria-hidden={true} />
<p style={{
backgroundColor: '#F0F0F0',
textAlign: 'center',
padding: '1em',
color: '#3E4F71',
}}>
{t('framework.comment_is_ignored')}
</p>
</div>
);
}
}
export default IgnoredCommentTombstone;
@@ -9,6 +9,9 @@ class LoadMore extends React.Component {
}
replyCountFormat = (count) => {
if (!count) {
return t('framework.view_all_replies_unknown_number');
}
if (count === 1) {
return t('framework.view_reply');
}
@@ -2,22 +2,14 @@ import React, {PropTypes} from 'react';
import t from 'coral-framework/services/i18n';
const onLoadMoreClick = ({loadMore, commentCount, setCommentCountCache}) => (e) => {
e.preventDefault();
setCommentCountCache(commentCount);
loadMore();
};
const NewCount = (props) => {
const newComments = props.commentCount - props.commentCountCache;
const NewCount = ({count, loadMore}) => {
return <div className='coral-new-comments coral-load-more'>
{
props.commentCountCache && newComments > 0 ?
<button onClick={onLoadMoreClick(props)}>
{newComments === 1
? t('framework.new_count', newComments, t('framework.comment'))
: t('framework.new_count', newComments, t('framework.comments'))}
count ?
<button onClick={loadMore}>
{count === 1
? t('framework.new_count', count, t('framework.comment'))
: t('framework.new_count', count, t('framework.comments'))}
</button>
: null
}
@@ -25,8 +17,7 @@ const NewCount = (props) => {
};
NewCount.propTypes = {
commentCount: PropTypes.number.isRequired,
commentCountCache: PropTypes.number,
count: PropTypes.number.isRequired,
loadMore: PropTypes.func.isRequired,
};
@@ -1,21 +1,92 @@
import React, {PropTypes} from 'react';
import LoadMore from './LoadMore';
import NewCount from './NewCount';
import Comment from '../containers/Comment';
import Comment from '../components/Comment';
import SuspendedAccount from './SuspendedAccount';
import RestrictedMessageBox
from 'coral-framework/components/RestrictedMessageBox';
import Slot from 'coral-framework/components/Slot';
import InfoBox from 'coral-plugin-infobox/InfoBox';
import {can} from 'coral-framework/services/perms';
import {ModerationLink} from 'coral-plugin-moderation';
import RestrictedMessageBox
from 'coral-framework/components/RestrictedMessageBox';
import t, {timeago} from 'coral-framework/services/i18n';
import CommentBox from 'coral-plugin-commentbox/CommentBox';
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import t, {timeago} from 'coral-framework/services/i18n';
import NewCount from './NewCount';
import {TransitionGroup} from 'react-transition-group';
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
// resetCursors will return the id cursors of the first and second comment of
// the current comment list. The cursors are used to dertermine which
// comments to show. The spare cursor functions as a backup in case one
// of the comments gets deleted.
function resetCursors(state, props) {
const comments = props.root.asset.comments;
if (comments && comments.nodes.length) {
const idCursors = [comments.nodes[0].id];
if (comments.nodes[1]) {
idCursors.push(comments.nodes[1].id);
}
return {idCursors};
}
return {idCursors: []};
}
// invalidateCursor is called whenever a comment is removed which is referenced
// by one of the 2 id cursors. It returns a new set of id cursors calculated
// using the help of the backup cursor.
function invalidateCursor(invalidated, state, props) {
const alt = invalidated === 1 ? 0 : 1;
const comments = props.root.asset.comments;
const idCursors = [];
if (state.idCursors[alt]) {
idCursors.push(state.idCursors[alt]);
const index = comments.nodes.findIndex((node) => node.id === idCursors[0]);
const nextInLine = comments.nodes[index + 1];
if (nextInLine) {
idCursors.push(nextInLine.id);
}
}
return {idCursors};
}
class Stream extends React.Component {
constructor(props) {
super(props);
this.state = resetCursors(this.state, props);
}
componentWillReceiveProps(next) {
const {root: {asset: {comments: prevComments}}} = this.props;
const {root: {asset: {comments: nextComments}}} = next;
if (!prevComments && nextComments) {
this.setState(resetCursors);
return;
}
if (
prevComments && nextComments &&
nextComments.nodes.length < prevComments.nodes.length
) {
// Invalidate first cursor if referenced comment was removed.
if (this.state.idCursors[0] && !hasComment(nextComments.nodes, this.state.idCursors[0])) {
this.setState(invalidateCursor(0, this.state, next));
}
// Invalidate second cursor if referenced comment was removed.
if (this.state.idCursors[1] && !hasComment(nextComments.nodes, this.state.idCursors[1])) {
this.setState(invalidateCursor(1, this.state, next));
}
}
}
viewNewComments = () => {
this.setState(resetCursors);
};
setActiveReplyBox = (reactKey) => {
if (!this.props.auth.user) {
this.props.showSignInDialog();
@@ -24,8 +95,33 @@ class Stream extends React.Component {
}
};
// getVisibileComments returns a list containing comments
// which were authored by current user or comes after the `idCursor`.
getVisibleComments() {
const {root: {asset: {comments}}, auth: {user}} = this.props;
const idCursor = this.state.idCursors[0];
const userId = user ? user.id : null;
if (!comments) {
return [];
}
const view = [];
let pastCursor = false;
comments.nodes.forEach((comment) => {
if (comment.id === idCursor) {
pastCursor = true;
}
if (pastCursor || comment.user.id === userId) {
view.push(comment);
}
});
return view;
}
render() {
const {
commentClassNames,
root: {asset, asset: {comments}, comment, me},
postComment,
addNotification,
@@ -33,14 +129,14 @@ class Stream extends React.Component {
postDontAgree,
deleteAction,
showSignInDialog,
addCommentTag,
removeCommentTag,
pluginProps,
addTag,
ignoreUser,
auth: {loggedIn, user},
commentCountCache,
removeTag,
pluginProps,
editName
} = this.props;
const view = this.getVisibleComments();
const open = asset.closedAt === null;
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
@@ -97,8 +193,6 @@ class Stream extends React.Component {
postComment={this.props.postComment}
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
setCommentCountCache={this.props.setCommentCountCache}
commentCountCache={commentCountCache}
assetId={asset.id}
premod={asset.settings.moderation}
isReply={false}
@@ -108,11 +202,17 @@ class Stream extends React.Component {
/>}
</div>
: <p>{asset.settings.closedMessage}</p>}
{loggedIn &&
{loggedIn && (
<ModerationLink
assetId={asset.id}
isAdmin={can(user, 'MODERATE_COMMENTS')}
/>}
/>
)}
<div className="talk-stream-wrapper-box">
<Slot fill="streamBox" />
</div>
{/* the highlightedComment is isolated after the user followed a permalink */}
{highlightedComment
@@ -123,6 +223,7 @@ class Stream extends React.Component {
activeReplyBox={this.props.activeReplyBox}
addNotification={addNotification}
depth={0}
disableReply={!open}
postComment={this.props.postComment}
asset={asset}
currentUser={user}
@@ -139,19 +240,19 @@ class Stream extends React.Component {
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={this.props.editComment}
liveUpdates={true}
/>
: <div>
: <div className="talk-stream-comments-container">
<NewCount
commentCount={asset.commentCount}
commentCountCache={commentCountCache}
setCommentCountCache={this.props.setCommentCountCache}
loadMore={this.props.loadNewComments}
count={comments.nodes.length - view.length}
loadMore={this.viewNewComments}
/>
<div className="embed__stream">
{comments && comments.nodes.map((comment) => {
<TransitionGroup component='div' className="embed__stream">
{view.map((comment) => {
return commentIsIgnored(comment)
? <IgnoredCommentTombstone key={comment.id} />
: <Comment
commentClassNames={commentClassNames}
data={this.props.data}
root={this.props.root}
disableReply={!open}
@@ -164,8 +265,8 @@ class Stream extends React.Component {
currentUser={user}
postFlag={postFlag}
postDontAgree={postDontAgree}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
addTag={addTag}
removeTag={removeTag}
ignoreUser={ignoreUser}
commentIsIgnored={commentIsIgnored}
loadMore={this.props.loadNewReplies}
@@ -178,9 +279,10 @@ class Stream extends React.Component {
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={this.props.editComment}
liveUpdates={false}
/>;
})}
</div>
</TransitionGroup>
<LoadMore
topLevel={true}
moreComments={asset.comments.hasNextPage}
@@ -197,10 +299,10 @@ Stream.propTypes = {
postComment: PropTypes.func.isRequired,
// dispatch action to add a tag to a comment
addCommentTag: PropTypes.func,
addTag: PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: PropTypes.func,
removeTag: PropTypes.func,
// dispatch action to ignore another user
ignoreUser: React.PropTypes.func,
@@ -41,7 +41,10 @@ class SuspendedAccount extends Component {
<span>{
canEditName ?
t('framework.edit_name.msg')
: t('framework.banned_account_msg')
:
<span>
<b>{t('framework.banned_account_header')}</b><br/> {t('framework.banned_account_body')}
</span>
}</span>
{
canEditName ?
@@ -1,5 +1,5 @@
export const SET_ACTIVE_REPLY_BOX = 'SET_ACTIVE_REPLY_BOX';
export const SET_COMMENT_COUNT_CACHE = 'SET_COMMENT_COUNT_CACHE';
export const ADDTL_COMMENTS_ON_LOAD_MORE = 10;
export const NEW_COMMENT_COUNT_POLL_INTERVAL = 20000;
export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS';
export const ADD_COMMENT_CLASSNAME = 'ADD_COMMENT_CLASSNAME';
export const REMOVE_COMMENT_CLASSNAME = 'REMOVE_COMMENT_CLASSNAME';
@@ -28,7 +28,9 @@ export default withFragments({
created_at
status
tags {
name
tag {
name
}
}
user {
id
@@ -14,7 +14,7 @@ import Embed from '../components/Embed';
import Stream from './Stream';
import {setActiveTab} from '../actions/embed';
import {setCommentCountCache, viewAllComments} from '../actions/stream';
import {viewAllComments} from '../actions/stream';
const {logout, checkLogin} = authActions;
const {fetchAssetSuccess} = assetActions;
@@ -33,18 +33,11 @@ class EmbedContainer extends React.Component {
// TODO: remove asset data from redux store.
fetchAssetSuccess(nextProps.root.asset);
const {setCommentCountCache, commentCountCache} = this.props;
const {asset} = nextProps.root;
if (commentCountCache === -1) {
setCommentCountCache(asset.commentCount);
}
}
}
componentDidUpdate(prevProps) {
if (!isEqual(prevProps.root.comment, this.props.root.comment)) {
if (!prevProps.root.comment && this.props.root.comment) {
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0);
@@ -87,7 +80,6 @@ export const withEmbedQuery = withQuery(EMBED_QUERY, {
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
commentCountCache: state.stream.commentCountCache,
commentId: state.stream.commentId,
assetId: state.stream.assetId,
assetUrl: state.stream.assetUrl,
@@ -103,7 +95,6 @@ const mapDispatchToProps = (dispatch) =>
setActiveTab,
viewAllComments,
fetchAssetSuccess,
setCommentCountCache
},
dispatch
);
+118 -152
View File
@@ -2,34 +2,94 @@ import React from 'react';
import {gql, compose} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {NEW_COMMENT_COUNT_POLL_INTERVAL, ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream';
import {ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream';
import {
withPostComment, withPostFlag, withPostDontAgree, withDeleteAction,
withAddCommentTag, withRemoveCommentTag, withIgnoreUser, withEditComment,
withAddTag, withRemoveTag, withIgnoreUser, withEditComment,
} from 'coral-framework/graphql/mutations';
import update from 'immutability-helper';
import {notificationActions, authActions} from 'coral-framework';
import {editName} from 'coral-framework/actions/user';
import {setCommentCountCache, setActiveReplyBox} from '../actions/stream';
import {setActiveReplyBox} from '../actions/stream';
import Stream from '../components/Stream';
import Comment from './Comment';
import {withFragments} from 'coral-framework/hocs';
import {Spinner} from 'coral-ui';
import {getDefinitionName} from 'coral-framework/utils';
import {
findCommentInEmbedQuery,
insertCommentIntoEmbedQuery,
removeCommentFromEmbedQuery,
insertFetchedCommentsIntoEmbedQuery,
} from '../graphql/utils';
const {showSignInDialog} = authActions;
const {addNotification} = notificationActions;
class StreamContainer extends React.Component {
getCounts = (variables) => {
return this.props.data.fetchMore({
query: LOAD_COMMENT_COUNTS_QUERY,
variables,
subscriptions = [];
// Apollo requires this, even though we don't use it...
updateQuery: (data) => data,
subscribeToUpdates() {
const sub1 = this.props.data.subscribeToMore({
document: COMMENTS_EDITED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => {
// Ignore mutations from me.
// TODO: need way to detect mutations created by this client, and allow mutations from other clients.
if (this.props.auth.user && commentEdited.user.id === this.props.auth.user.id) {
return prev;
}
// Exit when comment is not in the query.
if (!findCommentInEmbedQuery(prev, commentEdited.id)) {
return prev;
}
if (['PREMOD', 'REJECTED'].includes(commentEdited.status)) {
return removeCommentFromEmbedQuery(prev, commentEdited.id);
}
},
});
};
const sub2 = this.props.data.subscribeToMore({
document: COMMENTS_ADDED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => {
// Ignore mutations from me.
// TODO: need way to detect mutations created by this client, and allow mutations from other clients.
if (this.props.auth.user && commentAdded.user.id === this.props.auth.user.id) {
return prev;
}
// Exit if author is ignored.
if (
this.props.root.me &&
this.props.root.me.ignoredUsers.some(({id}) => id === commentAdded.user.id)) {
return prev;
}
// Exit when comment is already in the query.
if (findCommentInEmbedQuery(prev, commentAdded.id)) {
return prev;
}
return insertCommentIntoEmbedQuery(prev, commentAdded);
}
});
this.subscriptions.push(sub1, sub2);
}
unsubscribe() {
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.subscriptions = [];
}
loadNewReplies = (parent_id) => {
const comment = this.props.root.comment
@@ -47,86 +107,11 @@ class StreamContainer extends React.Component {
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
if (!comments.nodes.length) {
return prev;
}
const updateNode = (node) =>
update(node, {
replies: {
endCursor: {$set: comments.endCursor},
nodes: {$apply: (nodes) => nodes
.concat(comments.nodes.filter(
(comment) => !nodes.some((node) => node.id === comment.id)
))
.sort(ascending)
},
},
});
// highlighted comment.
if (prev.comment) {
if (prev.comment.parent) {
return update(prev, {
comment: {
parent: {$apply: (comment) => updateNode(comment)},
}
});
}
return update(prev, {
comment: {$apply: (comment) => updateNode(comment)},
});
}
return update(prev, {
asset: {
comments: {
nodes: {
$apply: (nodes) => nodes.map(
(node) => node.id !== parent_id
? node
: updateNode(node)
)
},
},
},
});
return insertFetchedCommentsIntoEmbedQuery(prev, comments, parent_id);
},
});
}
loadNewComments = () => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
limit: ADDTL_COMMENTS_ON_LOAD_MORE,
cursor: this.props.root.asset.comments.startCursor,
parent_id: null,
asset_id: this.props.root.asset.id,
sort: 'CHRONOLOGICAL',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
if (!comments.nodes.length) {
return prev;
}
return update(prev, {
asset: {
comments: {
startCursor: {$set: comments.endCursor},
nodes: {$apply: (nodes) => comments.nodes.filter(
(comment) => !nodes.some((node) => node.id === comment.id)
)
.concat(nodes)
.sort(descending)
},
},
},
});
},
});
};
loadMoreComments = () => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
@@ -139,78 +124,77 @@ class StreamContainer extends React.Component {
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
if (!comments.nodes.length) {
return prev;
}
return update(prev, {
asset: {
comments: {
hasNextPage: {$set: comments.hasNextPage},
endCursor: {$set: comments.endCursor},
nodes: {$push: comments.nodes},
},
},
});
return insertFetchedCommentsIntoEmbedQuery(prev, comments);
},
});
};
componentDidMount() {
if (this.props.previousTab) {
this.props.data.refetch()
.then(({data: {asset: {commentCount}}}) => {
return this.props.setCommentCountCache(commentCount);
});
this.props.data.refetch();
}
this.countPoll = setInterval(() => {
this.getCounts(this.props.data.variables);
}, NEW_COMMENT_COUNT_POLL_INTERVAL);
this.subscribeToUpdates();
}
componentWillUnmount() {
this.unsubscribe();
clearInterval(this.countPoll);
}
render() {
if (this.props.refetching) {
return <Spinner />;
}
return <Stream
{...this.props}
loadMore={this.loadMore}
loadMoreComments={this.loadMoreComments}
loadNewComments={this.loadNewComments}
loadNewReplies={this.loadNewReplies}
/>;
}
}
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 commentFragment = gql`
fragment CoralEmbedStream_Stream_comment on Comment {
id
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies(excludeIgnored: $excludeIgnored) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
}
hasNextPage
startCursor
endCursor
}
}
${Comment.fragments.comment}
`;
const descending = (a, b) => ascending(a, b) * -1;
const LOAD_COMMENT_COUNTS_QUERY = gql`
query CoralEmbedStream_LoadCommentCounts($assetUrl: String, , $commentId: ID!, $assetId: ID, $hasComment: Boolean!, $excludeIgnored: Boolean) {
comment(id: $commentId) @include(if: $hasComment) {
id
const COMMENTS_ADDED_SUBSCRIPTION = gql`
subscription onCommentAdded($assetId: ID!, $excludeIgnored: Boolean){
commentAdded(asset_id: $assetId){
parent {
id
replyCount(excludeIgnored: $excludeIgnored)
}
replyCount(excludeIgnored: $excludeIgnored)
...CoralEmbedStream_Stream_comment
}
asset(id: $assetId, url: $assetUrl) {
}
${commentFragment}
`;
const COMMENTS_EDITED_SUBSCRIPTION = gql`
subscription onCommentEdited($assetId: ID!){
commentEdited(asset_id: $assetId){
id
commentCount(excludeIgnored: $excludeIgnored)
comments(limit: 10) @skip(if: $hasComment) {
nodes {
id
replyCount(excludeIgnored: $excludeIgnored)
}
body
status
editing {
edited
}
user {
id
}
}
}
@@ -241,24 +225,6 @@ const LOAD_MORE_QUERY = gql`
${Comment.fragments.comment}
`;
const commentFragment = gql`
fragment CoralEmbedStream_Stream_comment on Comment {
id
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
}
hasNextPage
startCursor
endCursor
}
}
${Comment.fragments.comment}
`;
const fragments = {
root: gql`
fragment CoralEmbedStream_Stream_root on RootQuery {
@@ -316,6 +282,7 @@ const fragments = {
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
refetching: state.embed.refetching,
commentCountCache: state.stream.commentCountCache,
activeReplyBox: state.stream.activeReplyBox,
commentId: state.stream.commentId,
@@ -323,6 +290,7 @@ const mapStateToProps = (state) => ({
assetUrl: state.stream.assetUrl,
activeTab: state.embed.activeTab,
previousTab: state.embed.previousTab,
commentClassNames: state.stream.commentClassNames
});
const mapDispatchToProps = (dispatch) =>
@@ -331,7 +299,6 @@ const mapDispatchToProps = (dispatch) =>
addNotification,
setActiveReplyBox,
editName,
setCommentCountCache,
}, dispatch);
export default compose(
@@ -340,10 +307,9 @@ export default compose(
withPostComment,
withPostFlag,
withPostDontAgree,
withAddCommentTag,
withRemoveCommentTag,
withAddTag,
withRemoveTag,
withIgnoreUser,
withDeleteAction,
withEditComment,
)(StreamContainer);
+30 -7
View File
@@ -18,8 +18,8 @@ const extension = {
}
}
`,
RemoveCommentTagResponse: gql`
fragment CoralEmbedStream_RemoveCommentTagResponse on RemoveCommentTagResponse {
RemoveTagResponse: gql`
fragment CoralEmbedStream_RemoveTagResponse on RemoveTagResponse {
comment {
id
tags {
@@ -28,8 +28,8 @@ const extension = {
}
}
`,
AddCommentTagResponse: gql`
fragment CoralEmbedStream_AddCommentTagResponse on AddCommentTagResponse {
AddTagResponse: gql`
fragment CoralEmbedStream_AddTagResponse on AddTagResponse {
comment {
id
tags {
@@ -74,7 +74,13 @@ const extension = {
status
replyCount
tags {
name
tag {
name
created_at
}
assigned_by {
id
}
}
user {
id
@@ -91,6 +97,9 @@ const extension = {
edited
editableUntil
}
parent {
id
}
}
`,
},
@@ -141,9 +150,23 @@ const extension = {
parent_id,
asset_id,
action_summaries: [],
tags,
tags: tags.map((tag) => ({
tag: {
name: tag,
created_at: new Date().toISOString(),
__typename: 'Tag'
},
assigned_by: {
id: auth.toJS().user.id,
__typename: 'User'
},
__typename: 'TagLink'
})),
status: null,
replyCount: 0,
parent: parent_id
? {id: parent_id}
: null,
replies: {
__typename: 'CommentConnection',
nodes: [],
@@ -165,7 +188,7 @@ const extension = {
if (prev.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
return prev;
}
return insertCommentIntoEmbedQuery(prev, parent_id, comment);
return insertCommentIntoEmbedQuery(prev, comment);
},
}
}),
+108 -41
View File
@@ -1,11 +1,33 @@
import update from 'immutability-helper';
function findAndInsertComment(parent, id, comment) {
const [connectionField, countField, action] = parent.comments
function applyToCommentsOrigin(root, callback) {
if (root.comment) {
if (root.comment.parent) {
return update(root, {
comment: {
parent: {
$apply: (node) => callback(node),
},
},
});
}
return update(root, {
comment: {
$apply: (node) => callback(node),
},
});
}
return update(root, {
asset: {$apply: (asset) => callback(asset)},
});
}
function findAndInsertComment(parent, comment) {
const [connectionField, countField, action] = parent.__typename === 'Asset'
? ['comments', 'commentCount', '$unshift']
: ['replies', 'replyCount', '$push'];
if (!id || parent.id === id) {
if (!comment.parent || parent.id === comment.parent.id) {
return update(parent, {
[connectionField]: {
nodes: {[action]: [comment]},
@@ -21,13 +43,13 @@ function findAndInsertComment(parent, id, comment) {
[connectionField]: {
nodes: {
$apply: (nodes) =>
nodes.map((node) => findAndInsertComment(node, id, comment))
nodes.map((node) => findAndInsertComment(node, comment))
},
},
});
}
export function insertCommentIntoEmbedQuery(root, id, comment) {
export function insertCommentIntoEmbedQuery(root, comment) {
// Increase total comment count by one.
root = update(root, {
@@ -35,30 +57,11 @@ export function insertCommentIntoEmbedQuery(root, id, comment) {
totalCommentCount: {$apply: (c) => c + 1},
},
});
if (root.comment) {
if (root.comment.parent) {
return update(root, {
comment: {
parent: {
$apply: (node) => findAndInsertComment(node, id, comment),
},
},
});
}
return update(root, {
comment: {
$apply: (node) => findAndInsertComment(node, id, comment),
},
});
}
return update(root, {
asset: {$apply: (asset) => findAndInsertComment(asset, id, comment)},
});
return applyToCommentsOrigin(root, (origin) => findAndInsertComment(origin, comment));
}
function findAndRemoveComment(parent, id) {
const [connectionField, countField] = parent.comments
const [connectionField, countField] = parent.__typename === 'Asset'
? ['comments', 'commentCount']
: ['replies', 'replyCount'];
@@ -78,7 +81,7 @@ function findAndRemoveComment(parent, id) {
};
if (parent[countField] && next.length !== connection.nodes.length) {
changes[countField] = {$set: changes[countField] - 1};
changes[countField] = {$set: parent[countField] - 1};
}
return update(parent, changes);
}
@@ -91,24 +94,88 @@ export function removeCommentFromEmbedQuery(root, id) {
totalCommentCount: {$apply: (c) => c - 1},
},
});
return applyToCommentsOrigin(root, (origin) => findAndRemoveComment(origin, id));
}
if (root.comment) {
if (root.comment.parent) {
return update(root, {
comment: {
parent: {
$apply: (node) => findAndRemoveComment(node, id),
},
},
});
function findComment(nodes, callback) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (callback(node)) {
return node;
}
return update(root, {
comment: {
$apply: (node) => findAndRemoveComment(node, id),
if (node.replies) {
const find = findComment(node.replies.nodes, callback);
if (find){
return find;
}
}
}
return false;
}
export function findCommentInEmbedQuery(root, callbackOrId) {
let callback = callbackOrId;
if (typeof callbackOrId === 'string') {
callback = (node) => node.id === callbackOrId;
}
if (root.comment) {
if (callback(root.comment)) {
return root.comment;
}
if (root.comment.parent && callback(root.comment.parent)) {
return root.comment.parent;
}
}
if (!root.asset.comments) {
return false;
}
return findComment(root.asset.comments.nodes, callback);
}
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;
};
function findAndInsertFetchedComments(parent, comments, parent_id) {
const isAsset = parent.__typename === 'Asset';
const connectionField = isAsset ? 'comments' : 'replies';
if (!parent_id && connectionField === 'comments' || parent.id === parent_id) {
return update(parent, {
[connectionField]: {
hasNextPage: {$set: comments.hasNextPage},
endCursor: {$set: comments.endCursor},
nodes: {$apply: (nodes) => {
if (isAsset) {
return nodes.concat(comments.nodes);
}
return nodes
.concat(comments.nodes.filter(
(comment) => !nodes.some((node) => node.id === comment.id)
))
.sort(ascending);
}},
},
});
}
return update(root, {
asset: {$apply: (asset) => findAndRemoveComment(asset, id)},
const connection = parent[connectionField];
if (!connection) {
return parent;
}
return update(parent, {
[connectionField]: {
nodes: {
$apply: (nodes) =>
nodes.map((node) => findAndInsertFetchedComments(node, comments, parent_id))
},
},
});
}
export function insertFetchedCommentsIntoEmbedQuery(root, comments, parent_id) {
return applyToCommentsOrigin(root, (origin) => findAndInsertFetchedComments(origin, comments, parent_id));
}
+26 -11
View File
@@ -2,31 +2,46 @@ import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import {client} from 'coral-framework/services/client';
import {checkLogin} from 'coral-framework/actions/auth';
import './graphql';
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
import reducers from './reducers';
import {getStore, injectReducers} from 'coral-framework/services/store';
import {getClient} from 'coral-framework/services/client';
import AppRouter from './AppRouter';
import {pym} from 'coral-framework';
import {loadPluginsTranslations} from 'coral-framework/helpers/plugins';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
import reducers from './reducers';
const store = getStore();
const client = getClient();
loadPluginsTranslations();
injectPluginsReducers();
injectReducers(reducers);
function inIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
function init(config = {}) {
store.dispatch(addExternalConfig(config));
store.dispatch(checkLogin());
}
// Don't run this in the popup.
if (!window.opener) {
store.dispatch(checkLogin());
pym.sendMessage('getConfig');
pym.onMessage('config', (config) => {
store.dispatch(addExternalConfig(JSON.parse(config)));
});
if (inIframe()) {
pym.sendMessage('getConfig');
pym.onMessage('config', (config) => {
init(JSON.parse(config));
});
} else {
init();
}
}
render(
@@ -3,6 +3,8 @@ import * as actions from '../constants/embed';
const initialState = {
activeTab: 'stream',
previousTab: '',
refetching: false,
refetchRequestId: 0,
};
export default function stream(state = initialState, action) {
@@ -13,6 +15,23 @@ export default function stream(state = initialState, action) {
activeTab: action.tab,
previousTab: state.activeTab,
};
case 'APOLLO_QUERY_INIT':
if (action.queryString.indexOf('query CoralEmbedStream_Embed(') >= 0) {
return {
...state,
refetching: action.isRefetch ? true : state.refetching,
refetchRequestId: action.isRefetch ? action.requestId : state.refetchRequestId,
};
}
return state;
case 'APOLLO_QUERY_RESULT':
if (action.operationName === 'CoralEmbedStream_Embed') {
return {
...state,
refetching: action.requestId === state.refetchRequestId ? false : state.refetching,
};
}
return state;
default:
return state;
}
@@ -4,6 +4,6 @@ import stream from './stream';
export default {
embed,
config,
stream,
config
};
@@ -1,4 +1,5 @@
import * as actions from '../constants/stream';
import * as authActions from 'coral-framework/constants/auth';
function getQueryVariable(variable) {
let query = window.location.search.substring(1);
@@ -16,29 +17,42 @@ function getQueryVariable(variable) {
const initialState = {
activeReplyBox: '',
commentCountCache: -1,
assetId: getQueryVariable('asset_id'),
assetUrl: getQueryVariable('asset_url'),
commentId: getQueryVariable('comment_id'),
commentClassNames: []
};
export default function stream(state = initialState, action) {
switch (action.type) {
case authActions.LOGOUT:
return {
...state,
activeReplyBox: '',
};
case actions.SET_ACTIVE_REPLY_BOX:
return {
...state,
activeReplyBox: action.id,
};
case actions.SET_COMMENT_COUNT_CACHE:
return {
...state,
commentCountCache: action.amount,
};
case actions.VIEW_ALL_COMMENTS:
return {
...state,
commentId: '',
};
case actions.ADD_COMMENT_CLASSNAME :
return {
...state,
commentClassNames: [...state.commentClassNames, action.className]
};
case actions.REMOVE_COMMENT_CLASSNAME :
return {
...state,
commentClassNames: [
...state.commentClassNames.slice(0, action.idx),
...state.commentClassNames.slice(action.idx + 1)
]
};
default:
return state;
}
+17 -9
View File
@@ -119,7 +119,7 @@ hr {
.coral-plugin-questionbox-info {
top: 0;
border: 0;
background: rgb(213,213,213);
background: #F0F0F0;
color: black;
width: 100%;
text-align: left;
@@ -134,26 +134,25 @@ hr {
.coral-plugin-questionbox-icon.bubble{
position: absolute;
top: 11px;
top: 8px;
left: 10px;
color: #949393;
font-size: 20px;
color: #9E9E9E;
font-size: 24px;
z-index: 0;
}
.coral-plugin-questionbox-icon.person{
z-index: 2;
top: 20px;
left: 15px;
top: 12px;
left: 12px;
position: absolute;
font-size: 24px;
color: white;
font-size: 33px;
color: #262626;
}
.coral-plugin-questionbox-box {
position: relative;
border: 0;
background: black;
color: white;
padding: 20px;
margin-left: 0px !important;
@@ -178,6 +177,15 @@ hr {
display: none;
}
.talk-stream-wrapper-box {
padding: 10px 0;
}
.talk-stream-comments-container {
position: relative;
z-index: 0;
}
.commentStream {
/* prevent absolutely positioned final permalink popover from being clipped */
padding-bottom: 50px;
+1
View File
@@ -147,6 +147,7 @@ function configurePymParent(pymParent, opts) {
* @param {String} [opts.title] - Title of Stream (rendered in iframe)
* @param {String} [opts.asset_url] - Asset URL
* @param {String} [opts.asset_id] - Asset ID
* @param {String} [opts.auth_token] - (optional) A jwt representing the session
*/
Talk.render = function(el, opts) {
if (!el) {
+9
View File
@@ -4,6 +4,7 @@ import * as actions from '../constants/auth';
import * as Storage from '../helpers/storage';
import coralApi, {base} from '../helpers/request';
import {resetWebsocket} from 'coral-framework/services/client';
import t from 'coral-framework/services/i18n';
export const showSignInDialog = () => (dispatch, getState) => {
@@ -117,6 +118,7 @@ const signInFailure = (error) => ({
export const handleAuthToken = (token) => (dispatch) => {
Storage.setItem('exp', jwtDecode(token).exp);
Storage.setItem('token', token);
dispatch({type: 'HANDLE_AUTH_TOKEN'});
};
@@ -277,6 +279,10 @@ export const logout = () => (dispatch) => {
if (!bowser.safari && !bowser.ios) {
Storage.removeItem('token');
}
// Reset the websocket.
resetWebsocket();
dispatch({type: actions.LOGOUT});
});
};
@@ -305,6 +311,9 @@ export const checkLogin = () => (dispatch) => {
throw new Error('Not logged in');
}
// Reset the websocket.
resetWebsocket();
dispatch(checkLoginSuccess(result.user));
// Display create username dialog if necessary.
@@ -1,6 +1,8 @@
.message {
background: #D8D8D8;
padding: 25px;
margin-bottom: 8px;
background-color: white;
border: 1px #F0F0F0 solid;
text-align: center;
font-weight: 100;
}
+8 -3
View File
@@ -4,10 +4,15 @@ import styles from './Slot.css';
import {connect} from 'react-redux';
import {getSlotElements} from 'coral-framework/helpers/plugins';
function Slot ({fill, inline = false, plugin_config: config, ...rest}) {
function Slot ({fill, inline = false, className, plugin_config: config, defaultComponent: DefaultComponent, ...rest}) {
let children = getSlotElements(fill, {...rest, config});
if (children.length === 0 && DefaultComponent) {
children = <DefaultComponent {...rest} />;
}
return (
<div className={cn({[styles.inline]: inline, [styles.debug]: config.debug})}>
{getSlotElements(fill, {...rest, config})}
<div className={cn({[styles.inline]: inline, [styles.debug]: config.debug}, className)}>
{children}
</div>
);
}
@@ -0,0 +1 @@
export {default as Slot} from './Slot';
+16 -19
View File
@@ -1,23 +1,20 @@
import {gql} from 'react-apollo';
import * as mutations from './mutations';
function createDefaultResponseFragments() {
const names = Object.keys(mutations).map((key) => key.replace('with', ''));
const result = {};
names.forEach((name) => {
const response = `${name}Response`;
result[response] = gql`
fragment Coral_${response} on ${response} {
errors {
translation_key
}
}
`;
});
return result;
}
import {createDefaultResponseFragments} from '../utils';
// fragments defined here are automatically registered.
export default {
...createDefaultResponseFragments()
...createDefaultResponseFragments(
'SetCommentStatusResponse',
'SuspendUserResponse',
'RejectUsernameResponse',
'SetUserStatusResponse',
'CreateFlagResponse',
'EditCommentResponse',
'PostFlagResponse',
'CreateDontAgreeResponse',
'DeleteActionResponse',
'ModifyTagResponse',
'IgnoreUserResponse',
'StopIgnoringUserResponse',
)
};
+67 -14
View File
@@ -173,39 +173,93 @@ export const withDeleteAction = withMutation(
}}),
});
export const withAddCommentTag = withMutation(
const COMMENT_FRAGMENT = gql`
fragment CoralBest_UpdateFragment on Comment {
tags {
tag {
name
}
}
}
`;
export const withAddTag = withMutation(
gql`
mutation AddCommentTag($id: ID!, $tag: String!) {
addCommentTag(id:$id, tag:$tag) {
...AddCommentTagResponse
mutation AddTag($id: ID!, $asset_id: ID!, $name: String!) {
addTag(tag: {name: $name, id: $id, item_type: COMMENTS, asset_id: $asset_id}) {
...ModifyTagResponse
}
}
`, {
props: ({mutate}) => ({
addCommentTag: ({id, tag}) => {
addTag: ({id, name, assetId}) => {
return mutate({
variables: {
id,
tag
}
name,
asset_id: assetId
},
optimisticResponse: {
addTag: {
__typename: 'ModifyTagResponse',
errors: null,
}
},
update: (proxy) => {
const fragmentId = `Comment_${id}`;
// Read the data from our cache for this query.
const data = proxy.readFragment({fragment: COMMENT_FRAGMENT, id: fragmentId});
data.tags.push({
tag: {
__typename: 'Tag',
name: 'BEST'
},
__typename: 'TagLink'
});
// Write our data back to the cache.
proxy.writeFragment({fragment: COMMENT_FRAGMENT, id: fragmentId, data});
},
});
}}),
});
export const withRemoveCommentTag = withMutation(
export const withRemoveTag = withMutation(
gql`
mutation RemoveCommentTag($id: ID!, $tag: String!) {
removeCommentTag(id:$id, tag:$tag) {
...RemoveCommentTagResponse
mutation RemoveTag($id: ID!, $asset_id: ID!, $name: String!) {
removeTag(tag: {name: $name, id: $id, item_type: COMMENTS, asset_id: $asset_id}) {
...ModifyTagResponse
}
}
`, {
props: ({mutate}) => ({
removeCommentTag: ({id, tag}) => {
removeTag: ({id, name, assetId}) => {
return mutate({
variables: {
id,
tag
name,
asset_id: assetId
},
optimisticResponse: {
removeTag: {
__typename: 'ModifyTagResponse',
errors: null,
}
},
update: (proxy) => {
const fragmentId = `Comment_${id}`;
// Read the data from our cache for this query.
const data = proxy.readFragment({fragment: COMMENT_FRAGMENT, id: fragmentId});
const idx = data.tags.findIndex((i) => i.tag.name === 'BEST');
data.tags = [...data.tags.slice(0, idx), ...data.tags.slice(idx + 1)];
// Write our data back to the cache.
proxy.writeFragment({fragment: COMMENT_FRAGMENT, id: fragmentId, data});
}
});
}}),
@@ -246,4 +300,3 @@ export const withStopIgnoringUser = withMutation(
});
}}),
});
@@ -0,0 +1,16 @@
const regExp = /[-\s]+(.)?/g;
/**
* Convert dash separated strings to camel case.
*
* @param {String} str
* @return {String}
*/
export default function camelize(str) {
return str.replace(regExp, toUpper);
}
function toUpper(match, c) {
return c ? c.toUpperCase() : '';
}
+14 -9
View File
@@ -1,18 +1,14 @@
import React from 'react';
import merge from 'lodash/merge';
import flatten from 'lodash/flatten';
import flattenDeep from 'lodash/flattenDeep';
import uniq from 'lodash/uniq';
import pick from 'lodash/pick';
import merge from 'lodash/merge';
import plugins from 'pluginsConfig';
import flatten from 'lodash/flatten';
import flattenDeep from 'lodash/flattenDeep';
import {getDefinitionName, mergeDocuments} from 'coral-framework/utils';
import {loadTranslations} from 'coral-framework/services/i18n';
export const pluginReducers = merge(
...plugins
.filter((o) => o.module.reducer)
.map((o) => ({...o.module.reducer}))
);
import {injectReducers} from 'coral-framework/services/store';
import camelize from './camelize';
/**
* Returns React Elements for given slot.
@@ -98,3 +94,12 @@ function getTranslations() {
export function loadPluginsTranslations() {
getTranslations().forEach((t) => loadTranslations(t));
}
export function injectPluginsReducers() {
const reducers = merge(
...plugins
.filter((o) => o.module.reducer)
.map((o) => ({[camelize(o.plugin)] : o.module.reducer}))
);
injectReducers(reducers);
}
+28 -6
View File
@@ -1,6 +1,30 @@
import bowser from 'bowser';
import * as Storage from './storage';
import merge from 'lodash/merge';
import {getStore} from 'coral-framework/services/store';
/**
* getAuthToken returns the active auth token or null
* Note: this method does not have access to the cookie based token used by
* browsers that don't allow us to use cross domain iframe local storage.
* @return {string|null}
*/
export const getAuthToken = () => {
let state = getStore().getState();
if (state.config.auth_token) {
// if an auth_token exists in config, use it.
return state.config.auth_token;
} else if (!bowser.safari && !bowser.ios) {
// Use local storage auth tokens where there's a stable api.
return Storage.getItem('token');
}
return null;
};
const buildOptions = (inputOptions = {}) => {
const defaultOptions = {
@@ -14,12 +38,10 @@ const buildOptions = (inputOptions = {}) => {
let options = merge({}, defaultOptions, inputOptions);
if (!bowser.safari && !bowser.ios) {
let authorization = Storage.getItem('token');
if (authorization) {
options.headers.Authorization = `Bearer ${authorization}`;
}
// Apply authToken header
let authToken = getAuthToken();
if (authToken !== null) {
options.headers.Authorization = `Bearer ${authToken}`;
}
if (options.method.toLowerCase() !== 'get') {
-1
View File
@@ -1,5 +1,4 @@
export {default as withFragments} from './withFragments';
export {default as withMutation} from './withMutation';
export {default as withQuery} from './withQuery';
export {default as withReaction} from './withReaction';
-243
View File
@@ -1,243 +0,0 @@
import React from 'react';
import get from 'lodash/get';
import uuid from 'uuid/v4';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {getDisplayName} from '../helpers/hoc';
import {compose, gql, graphql} from 'react-apollo';
import withFragments from 'coral-framework/hocs/withFragments';
import {showSignInDialog} from 'coral-framework/actions/auth';
import {capitalize} from 'coral-framework/helpers/strings';
import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils';
export default (reaction) => (WrappedComponent) => {
if (typeof reaction !== 'string') {
console.error('Reaction must be a valid string');
return null;
}
reaction = reaction.toLowerCase();
class WithReactions extends React.Component {
render() {
const {comment} = this.props;
const reactionSummary = getMyActionSummary(
`${capitalize(reaction)}ActionSummary`,
comment
);
const count = getTotalActionCount(
`${capitalize(reaction)}ActionSummary`,
comment
);
const alreadyReacted = () => !!reactionSummary;
const withReactionProps = {reactionSummary, count, alreadyReacted};
return <WrappedComponent {...this.props} {...withReactionProps} />;
}
}
const isReaction = (a) =>
a.__typename === `${capitalize(reaction)}ActionSummary`;
const COMMENT_FRAGMENT = gql`
fragment ${capitalize(reaction)}Button_updateFragment on Comment {
action_summaries {
... on ${capitalize(reaction)}ActionSummary {
count
current_user {
id
}
}
}
}
`;
const withDeleteReaction = graphql(
gql`
mutation deleteReaction($id: ID!) {
deleteAction(id:$id) {
errors {
translation_key
}
}
}
`,
{
props: ({mutate, ownProps}) => ({
deleteReaction: () => {
const reactionSummary = getMyActionSummary(
`${capitalize(reaction)}ActionSummary`,
ownProps.comment
);
const reactionData = {
id: reactionSummary.current_user.id,
commentId: ownProps.comment.id
};
return mutate({
variables: {id: reactionData.id},
optimisticResponse: {
deleteAction: {
__typename: 'DeleteActionResponse',
errors: null
}
},
update: (proxy) => {
const fragmentId = `Comment_${reactionData.commentId}`;
// Read the data from our cache for this query.
const data = proxy.readFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId
});
// Check whether we liked this comment.
const idx = data.action_summaries.findIndex(isReaction);
if (
idx < 0 ||
get(data.action_summaries[idx], 'current_user.id') !== reactionData.id
) {
return;
}
data.action_summaries[idx] = {
...data.action_summaries[idx],
count: data.action_summaries[idx].count - 1,
current_user: null
};
// Write our data back to the cache.
proxy.writeFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId,
data
});
}
});
}
})
}
);
const withPostReaction = graphql(
gql`
mutation create${capitalize(reaction)}($${reaction}: Create${capitalize(reaction)}Input!) {
create${capitalize(reaction)}(${reaction}: $${reaction}) {
${reaction} {
id
}
errors {
translation_key
}
}
}
`,
{
props: ({mutate, ownProps}) => ({
postReaction: () => {
const reactionData = {
item_id: ownProps.comment.id,
item_type: 'COMMENTS'
};
return mutate({
variables: {[reaction]: reactionData},
optimisticResponse: {
[`create${capitalize(reaction)}`]: {
__typename: `Create${capitalize(reaction)}Response`,
errors: null,
[reaction]: {
__typename: `${capitalize(reaction)}Action`,
id: uuid()
}
}
},
update: (proxy, mutationResult) => {
const fragmentId = `Comment_${reactionData.item_id}`;
// Read the data from our cache for this query.
const data = proxy.readFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId
});
// Add our comment from the mutation to the end.
let idx = data.action_summaries.findIndex(isReaction);
// Check whether we already reactioned this comment.
if (idx >= 0 && data.action_summaries[idx].current_user) {
return;
}
if (idx < 0) {
// Add initial action when it doesn't exist.
data.action_summaries.push({
__typename: `${capitalize(reaction)}ActionSummary`,
count: 0,
current_user: null
});
idx = data.action_summaries.length - 1;
}
data.action_summaries[idx] = {
...data.action_summaries[idx],
count: data.action_summaries[idx].count + 1,
current_user: mutationResult.data[
`create${capitalize(reaction)}`
][reaction]
};
// Write our data back to the cache.
proxy.writeFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId,
data
});
}
});
}
})
}
);
const mapDispatchToProps = (dispatch) =>
bindActionCreators({showSignInDialog}, dispatch);
const enhance = compose(
withFragments({
root: gql`
fragment ${capitalize(reaction)}Button_root on RootQuery {
me {
status
}
}
`,
comment: gql`
fragment ${capitalize(reaction)}Button_comment on Comment {
action_summaries {
... on ${capitalize(reaction)}ActionSummary {
count
current_user {
id
}
}
}
}`
}),
connect(null, mapDispatchToProps),
withDeleteReaction,
withPostReaction
);
WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`;
return enhance(WithReactions);
};
-2
View File
@@ -2,12 +2,10 @@ import auth from './auth';
import user from './user';
import asset from './asset';
import {reducer as commentBox} from '../../coral-plugin-commentbox';
import {pluginReducers} from '../helpers/plugins';
export default {
auth,
user,
asset,
commentBox,
...pluginReducers
};
+65 -21
View File
@@ -1,28 +1,72 @@
import ApolloClient, {addTypename} from 'apollo-client';
import {networkInterface} from './transport';
import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
import MessageTypes from 'subscriptions-transport-ws/dist/message-types';
import {getAuthToken} from '../helpers/request';
// import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
let client, wsClient = null, wsClientToken = null;
// TODO: replace absolute reference with something loaded from the store/page.
// const wsClient = new SubscriptionClient('ws://localhost:3000/api/v1/live', {
// reconnect: true
// });
// const networkInterface = addGraphQLSubscriptions(
// getNetworkInterface(),
// wsClient,
// );
export const client = new ApolloClient({
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
export function resetWebsocket() {
if (wsClient === null) {
// Nothing to reset!
return;
}
// Close socket connection which will also unregister subscriptions on the server-side.
wsClient.close();
// Reconnect to the server.
wsClient.connect();
// Reregister all subscriptions (uses non public api).
// See: https://github.com/apollographql/subscriptions-transport-ws/issues/171
Object.keys(wsClient.operations).forEach((id) => {
wsClient.sendMessage(id, MessageTypes.GQL_START, wsClient.operations[id].options);
});
}
export function getClient() {
if (client) {
return client;
}
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
wsClient = new SubscriptionClient(`${protocol}://${location.host}/api/v1/live`, {
reconnect: true,
lazy: true,
connectionParams: {
get token() {
wsClientToken = getAuthToken();
// Try to get the token from localStorage. If it isn't here, it may
// be passed as a cookie.
// NOTE: THIS IS ONLY EVER EVALUATED ONCE, IN ORDER TO SEND A DIFFERNT
// TOKEN YOU MUST DISCONNECT AND RECONNECT THE WEBSOCKET CLIENT.
return wsClientToken;
}
}
return null;
},
networkInterface
});
});
export default client;
const networkInterfaceWithSubscriptions = addGraphQLSubscriptions(
networkInterface,
wsClient,
);
client = new ApolloClient({
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: networkInterfaceWithSubscriptions,
});
return client;
}
+1 -1
View File
@@ -7,7 +7,7 @@ import esTA from '../../../node_modules/timeago.js/locales/es';
import en from '../../../locales/en.yml';
import es from '../../../locales/es.yml';
// Translations are happening at https://www.transifex.com/the-coral-project/talk-1/dashboard/.
// Translations are happening at https://translate.lingohub.com/the-coral-project/dashboard
const defaultLanguage = 'en';
const translations = {...en, ...es};
+6
View File
@@ -23,7 +23,13 @@ export const can = (user, ...perms) => {
return false;
}
const banned = user.status === 'BANNED';
const suspended = user.suspension.until && new Date(user.suspension.until) > new Date();
return perms.every((perm) => {
if (perm === 'INTERACT_WITH_COMMUNITY') {
return !banned && !suspended;
}
const role = roles[perm];
if (typeof role === 'undefined') {
throw new Error(`${perm} is not a valid role`);
+23 -23
View File
@@ -1,28 +1,7 @@
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from '../reducers';
import {client} from './client';
const apolloErrorReporter = () => (next) => (action) => {
if (action.type === 'APOLLO_QUERY_ERROR') {
console.error(action.error);
}
return next(action);
};
const middlewares = [
applyMiddleware(
client.middleware(),
thunk,
apolloErrorReporter,
),
];
if (window.devToolsExtension) {
// we can't have the last argument of compose() be undefined
middlewares.push(window.devToolsExtension());
}
import {getClient} from './client';
export function injectReducers(reducers) {
const store = getStore();
@@ -35,9 +14,30 @@ export function getStore() {
return window.coralStore;
}
const apolloErrorReporter = () => (next) => (action) => {
if (action.type === 'APOLLO_QUERY_ERROR') {
console.error(action.error);
}
return next(action);
};
const middlewares = [
applyMiddleware(
getClient().middleware(),
thunk,
apolloErrorReporter,
),
];
if (window.devToolsExtension) {
// we can't have the last argument of compose() be undefined
middlewares.push(window.devToolsExtension());
}
const coralReducers = {
...mainReducer,
apollo: client.reducer()
apollo: getClient().reducer()
};
window.coralStore = createStore(
@@ -1,16 +0,0 @@
import {print} from 'graphql-tag/printer';
// quick way to add the subscribe and unsubscribe functions to the network interface
const addGraphQLSubscriptions = (networkInterface, wsClient) => {
return Object.assign(networkInterface, {
subscribe: (request, handler) => wsClient.subscribe({
query: print(request.query),
variables: request.variables,
}, handler),
unsubscribe: (id) => {
wsClient.unsubscribe(id);
},
});
};
export default addGraphQLSubscriptions;
+4 -4
View File
@@ -1,6 +1,5 @@
import {createNetworkInterface} from 'apollo-client';
import * as Storage from '../helpers/storage';
import bowser from 'bowser';
import {getAuthToken} from '../helpers/request';
//==============================================================================
// NETWORK INTERFACE
@@ -23,8 +22,9 @@ networkInterface.use([{
req.options.headers = {}; // Create the header object if needed.
}
if (!bowser.safari && !bowser.ios) {
req.options.headers['authorization'] = `Bearer ${Storage.getItem('token')}`;
let authToken = getAuthToken();
if (authToken) {
req.options.headers['authorization'] = `Bearer ${authToken}`;
}
next();
+14
View File
@@ -112,3 +112,17 @@ export function getResponseErrors(mutationResult) {
});
return result.length ? result : false;
}
export function createDefaultResponseFragments(...names) {
const result = {};
names.forEach((response) => {
result[response] = gql`
fragment Coral_${response} on ${response} {
errors {
translation_key
}
}
`;
});
return result;
}
-1
View File
@@ -1 +0,0 @@
export {withReaction} from '../coral-framework/hocs';
+2 -4
View File
@@ -7,10 +7,8 @@ import classnames from 'classnames';
// tag string for best comments
export const BEST_TAG = 'BEST';
export const commentIsBest = ({tags} = {}) => {
const isBest = Array.isArray(tags) && tags.some((t) => t.name === BEST_TAG);
return isBest;
};
export const commentIsBest = ({tags} = {}) => tags.some((t) => t.tag.name === BEST_TAG);
const name = 'coral-plugin-best';
+1 -18
View File
@@ -36,18 +36,10 @@ class CommentBox extends React.Component {
}
};
}
static get defaultProps() {
return {
setCommentCountCache: () => {}
};
}
postComment = ({body}) => {
const {
commentPostedHandler,
postComment,
setCommentCountCache,
commentCountCache,
isReply,
assetId,
parentId,
addNotification,
@@ -60,8 +52,6 @@ class CommentBox extends React.Component {
...this.props.commentBox
};
!isReply && setCommentCountCache(commentCountCache + 1);
// Execute preSubmit Hooks
this.state.hooks.preSubmit.forEach((hook) => hook());
@@ -74,19 +64,12 @@ class CommentBox extends React.Component {
notifyForNewCommentStatus(addNotification, postedComment.status);
if (postedComment.status === 'REJECTED') {
!isReply && setCommentCountCache(commentCountCache);
} else if (postedComment.status === 'PREMOD') {
!isReply && setCommentCountCache(commentCountCache);
}
if (commentPostedHandler) {
commentPostedHandler();
}
})
.catch((err) => {
console.error(err);
!isReply && setCommentCountCache(commentCountCache);
});
this.setState({postedCount: this.state.postedCount + 1});
@@ -167,6 +150,7 @@ class CommentBox extends React.Component {
fill="commentInputDetailArea"
registerHook={this.registerHook}
unregisterHook={this.unregisterHook}
isReply={isReply}
inline
/>}
cancelButtonClicked={cancelButtonClicked}
@@ -189,7 +173,6 @@ CommentBox.propTypes = {
authorId: PropTypes.string.isRequired,
isReply: PropTypes.bool.isRequired,
canPost: PropTypes.bool,
setCommentCountCache: PropTypes.func,
};
const mapStateToProps = ({commentBox}) => ({commentBox});
@@ -7,3 +7,7 @@ export const removeTag = (idx) => ({
type: 'REMOVE_TAG',
idx
});
export const clearTags = () => ({
type: 'CLEAR_TAGS',
});
@@ -1,2 +1,3 @@
export const ADD_TAG = 'ADD_TAG';
export const REMOVE_TAG = 'REMOVE_TAG';
export const CLEAR_TAGS = 'CLEAR_TAGS';
+3 -1
View File
@@ -1,4 +1,4 @@
import {ADD_TAG, REMOVE_TAG} from './constants';
import {ADD_TAG, REMOVE_TAG, CLEAR_TAGS} from './constants';
const initialState = {
tags: []
@@ -19,6 +19,8 @@ export default function commentBox (state = initialState, action) {
...state.tags.slice(action.idx + 1)
]
};
case CLEAR_TAGS :
return initialState;
default :
return state;
}
@@ -1,17 +0,0 @@
import React from 'react';
const name = 'coral-plugin-commentcontent';
const Content = ({body, styles}) => {
const textbreaks = body.split('\n');
return <div
className={`${name}-text`}
style={styles && styles.text}>
{
textbreaks.map((line, i) => <span key={i} className={`${name}-line`}>
{line} <br className={`${name}-linebreak`}/>
</span>)
}
</div>;
};
export default Content;
@@ -1,11 +0,0 @@
import React from 'react';
import {shallow} from 'enzyme';
import {expect} from 'chai';
import CommentContent from '../CommentContent';
describe('CommentContent', () => {
it('should render content', () => {
const render = shallow(<CommentContent content="test"/>);
expect(render.contains('test')).to.be.truthy;
});
});
+7 -4
View File
@@ -1,6 +1,7 @@
import React, {Component} from 'react';
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';
@@ -32,10 +33,12 @@ class FlagButton extends Component {
this.props.showSignInDialog();
return;
}
if (this.state.showMenu) {
this.closeMenu();
} else {
this.setState({showMenu: true});
if (can(currentUser, 'INTERACT_WITH_COMMUNITY')) {
if (this.state.showMenu) {
this.closeMenu();
} else {
this.setState({showMenu: true});
}
}
}
+6 -3
View File
@@ -1,8 +1,9 @@
import React, {PropTypes} from 'react';
import {Icon} from '../coral-ui';
import styles from './Comment.css';
import Slot from 'coral-framework/components/Slot';
import PubDate from '../coral-plugin-pubdate/PubDate';
import Content from '../coral-plugin-commentcontent/CommentContent';
import CommentContent from '../coral-embed-stream/src/components/CommentContent';
import t from 'coral-framework/services/i18n';
@@ -10,9 +11,11 @@ const Comment = (props) => {
return (
<div className={styles.myComment}>
<div>
<Content
<Slot
fill="commentContent"
defaultComponent={CommentContent}
className={`${styles.commentBody} myCommentBody`}
body={props.comment.body}
comment={props.comment}
/>
<p className="myCommentAsset">
<a
@@ -1,4 +1,6 @@
.moderationLink {
display: inline;
a {
color: #679af3;
text-decoration: none;
+5 -2
View File
@@ -9,6 +9,10 @@ class ReplyBox extends Component {
document.getElementById('replyText').focus();
}
cancelReply = () => {
this.props.setActiveReplyBox('');
};
render() {
const {
styles,
@@ -18,7 +22,6 @@ class ReplyBox extends Component {
addNotification,
parentId,
commentPostedHandler,
setActiveReplyBox,
maxCharCount,
charCountEnable
} = this.props;
@@ -28,7 +31,7 @@ class ReplyBox extends Component {
charCountEnable={charCountEnable}
commentPostedHandler={commentPostedHandler}
parentId={parentId}
cancelButtonClicked={setActiveReplyBox}
cancelButtonClicked={this.cancelReply}
addNotification={addNotification}
authorId={authorId}
assetId={assetId}
+2 -3
View File
@@ -6,21 +6,20 @@ import classnames from 'classnames';
const name = 'coral-plugin-replies';
const ReplyButton = ({banned, onClick}) => {
const ReplyButton = ({onClick}) => {
return (
<button
className={classnames(`${name}-reply-button`)}
onClick={onClick}>
{t('reply')}
<i className={`${name}-icon material-icons`}
aria-hidden={true}>{banned ? 'BANNED' : 'reply'}</i>
aria-hidden={true}>reply</i>
</button>
);
};
ReplyButton.propTypes = {
onClick: PropTypes.func.isRequired,
banned: PropTypes.bool.isRequired
};
export default ReplyButton;
@@ -1,5 +1,5 @@
import React, {Component, PropTypes} from 'react';
import t from 'coral-framework/services/i18n';
import styles from './IgnoredUsers.css';
export class IgnoredUsers extends Component {
@@ -18,7 +18,7 @@ export class IgnoredUsers extends Component {
<div>
{
users.length
? <p>Because you ignored these, you do not see their comments.</p>
? <p>{t('framework.because_you_ignored')}</p>
: null
}
<dl className={styles.ignoredUserList}>
@@ -29,7 +29,7 @@ export class IgnoredUsers extends Component {
<dd className={styles.stopListening}>
<a
onClick={() => stopIgnoring({id})}
className={styles.link}>Stop ignoring</a>
className={styles.link}>{t('framework.stop_ignoring')}</a>
</dd>
</span>
))
+13 -3
View File
@@ -1,15 +1,25 @@
.drawer {
max-width: 700px;
min-width: 400px;
padding: 20px;
min-width: 550px;
position: fixed;
top: 0;
right: 0;
right: -17px;
bottom: 0;
background-color: white;
transition: transform 500ms ease-in-out;
box-shadow: -3px 0px 4px 0px rgba(0,0,0,0.15);
z-index: 10000;
.content {
position: absolute;
padding: 20px;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-y: scroll;
overflow-x: hidden;
}
}
.closeButton {
+3 -1
View File
@@ -6,7 +6,9 @@ const Drawer = ({children, handleClickOutside}) => {
return (
<div className={styles.drawer}>
<div className={styles.closeButton} onClick={handleClickOutside}>×</div>
{children}
<div className={styles.content}>
{children}
</div>
</div>
);
};
+2 -3
View File
@@ -1,13 +1,12 @@
import React from 'react';
import styles from './Tab.css';
export default ({children, tabId, active, onTabClick, cStyle = 'base'}) => (
export default ({children, tabId, active, onTabClick, cStyle = 'base', ...props}) => (
<li
key={tabId}
className={active ? styles[`${cStyle}--active`] : ''}
className={`${active ? `${styles[`${cStyle}--active`]} coral-tab-active` : ''} coral-tab ${props.className}`}
onClick={() => onTabClick(tabId)}
>
{children}
</li>
);
+2 -2
View File
@@ -17,13 +17,13 @@ class TabBar extends React.Component {
const {children, activeTab, cStyle = 'base'} = this.props;
return (
<div>
<ul className={`${styles.base} ${cStyle ? styles[cStyle] : ''}`}>
<ul className={`${styles.base} ${cStyle ? styles[cStyle] : ''} ${this.props.className}`}>
{React.Children.toArray(children)
.filter((child) => !child.props.restricted)
.map((child, tabId) =>
React.cloneElement(child, {
tabId,
active: tabId === activeTab,
active: child.props.id === activeTab,
onTabClick: this.handleClickTab,
cStyle
})
+4 -2
View File
@@ -8,7 +8,7 @@ summary:
[![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk)
Online comments are broken. Our open-source Talk tool rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here.](https://coralproject.net/products/talk.html)
Online comments are broken. Mozilla's open-source, plugin-based tool Talk rethinks how moderation, comment display, and conversation function online, creating the opportunity for better, smarter discussions designed to fit your needs. [Read more about Talk here.](https://coralproject.net/products/talk.html)
Third party licenses are available via the `/client/3rdpartylicenses.txt`
endpoint when the server is running with built assets.
@@ -30,6 +30,8 @@ See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CO
- iPad
- iPad Pro
- iPhone 7 Plus
- iPhone 7
- iPhone 6 Plus
- iPhone 6
- iPhone 5
@@ -42,7 +44,7 @@ See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CO
### Android Devices
- Galaxy S5
- Galaxy S5x
- Nexus 5X
- Nexus 6P
+24 -1
View File
@@ -104,6 +104,20 @@ class ErrAuthentication extends APIError {
}
}
/**
* ErrAlreadyExists is returned when an attempt to create a resource failed due to an existing one.
*/
class ErrAlreadyExists extends APIError {
constructor(existing = null) {
super('resource already exists', {
translation_key: 'ALREADY_EXISTS',
status: 409
}, {
existing
});
}
}
// ErrContainsProfanity is returned in the event that the middleware detects
// profanity/wordlisted words in the payload.
const ErrContainsProfanity = new APIError('This username contains elements which are not permitted in our community. If you think this is in error, please contact us or try again.', {
@@ -168,9 +182,17 @@ const ErrCommentTooShort = new APIError('Comment was too short', {
status: 400
});
// ErrAssetURLAlreadyExists is returned when a rename operation is requested
// but an asset already exists with the new url.
const ErrAssetURLAlreadyExists = new APIError('Asset URL already exists, cannot rename', {
translation_key: 'ASSET_URL_ALREADY_EXISTS',
status: 409
});
module.exports = {
ExtendableError,
APIError,
ErrAlreadyExists,
ErrPasswordTooShort,
ErrSettingsNotInit,
ErrMissingEmail,
@@ -191,5 +213,6 @@ module.exports = {
ErrInstallLock,
ErrLoginAttemptMaximumExceeded,
ErrEditWindowHasEnded,
ErrCommentTooShort
ErrCommentTooShort,
ErrAssetURLAlreadyExists
};
+2
View File
@@ -6,6 +6,7 @@ const Assets = require('./assets');
const Comments = require('./comments');
const Metrics = require('./metrics');
const Settings = require('./settings');
const Tags = require('./tags');
const Users = require('./users');
const plugins = require('../../services/plugins');
@@ -18,6 +19,7 @@ let loaders = [
Comments,
Metrics,
Settings,
Tags,
Users,
// Load the plugin loaders from the manager.
-1
View File
@@ -1,5 +1,4 @@
const SettingsService = require('../../services/settings');
const util = require('./util');
/**
+22
View File
@@ -0,0 +1,22 @@
const DataLoader = require('dataloader');
const TagsService = require('../../services/tags');
/**
* Get all the tags for the context for the dataloader.
*/
const genAll = (context, queries) => {
return Promise.all(queries.map(({id, item_type, asset_id}) => {
return TagsService.getAll({id, item_type, asset_id});
}));
};
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
* @return {Object} object of loaders
*/
module.exports = (context) => ({
Tags: {
getAll: new DataLoader((queries) => genAll(context, queries))
}
});
+2 -2
View File
@@ -36,10 +36,10 @@ const createAction = async ({user = {}}, {item_id, item_type, action_type, group
* Deletes an action based on the user id if the user owns that action.
* @param {Object} user the user performing the request
* @param {String} id the id of the action to delete
* @return {Promise} resolves when the action is deleted
* @return {Promise} resolves to the deleted action, or null if not found.
*/
const deleteAction = ({user}, {id}) => {
return ActionModel.remove({
return ActionModel.findOneAndRemove({
id,
user_id: user.id
});
+62 -40
View File
@@ -1,22 +1,70 @@
const debug = require('debug')('talk:graph:mutators:comment');
const errors = require('../../errors');
const ActionModel = require('../../models/action');
const AssetsService = require('../../services/assets');
const ActionsService = require('../../services/actions');
const TagsService = require('../../services/tags');
const CommentsService = require('../../services/comments');
const KarmaService = require('../../services/karma');
const linkify = require('linkify-it')();
const Wordlist = require('../../services/wordlist');
const {
CREATE_COMMENT,
SET_COMMENT_STATUS,
ADD_COMMENT_TAG,
REMOVE_COMMENT_TAG,
EDIT_COMMENT
} = require('../../perms/constants');
const debug = require('debug')('talk:graph:mutators:tags');
const plugins = require('../../services/plugins');
const pluginTags = plugins.get('server', 'tags').reduce((acc, {plugin, tags}) => {
debug(`added plugin '${plugin.name}'`);
acc = acc.concat(tags);
return acc;
}, []);
const resolveTagsForComment = async ({user, loaders: {Tags}}, {asset_id, tags = []}) => {
const item_type = 'COMMENTS';
// Handle Tags
if (tags.length) {
// Get the global list of tags from the dataloader.
let globalTags = await Tags.getAll.load({
item_type,
asset_id
});
if (!Array.isArray(globalTags)) {
globalTags = [];
}
globalTags = globalTags.concat(pluginTags);
// Merge in the tags for the given comment.
tags = tags.map((name) => {
// Resolve the TagLink that we can use for the comment.
let {tagLink} = TagsService.resolveLink(user, globalTags, {name, item_type});
// Return the tagLink for tag insertion.
return tagLink;
});
}
// Add the staff tag for comments created as a staff member.
if (user.can(ADD_COMMENT_TAG)) {
tags.push(TagsService.newTagLink(user, {
name: 'STAFF',
item_type
}));
}
return tags;
};
/**
* adjustKarma will adjust the affected user's karma depending on the moderators
* action.
@@ -102,15 +150,11 @@ const adjustKarma = (Comments, id, status) => async () => {
* @param {String} [status='NONE'] the status of the new comment
* @return {Promise} resolves to the created comment
*/
const createComment = async ({user, loaders: {Comments}, pubsub}, {body, asset_id, parent_id = null, tags = []}, status = 'NONE') => {
const createComment = async (context, {tags = [], body, asset_id, parent_id = null}, status = 'NONE') => {
const {user, loaders: {Comments}, pubsub} = context;
// Building array of tags
tags = tags.map((tag) => ({name: tag}));
// If admin or moderator, adding STAFF tag
if (user.isStaff()) {
tags.push({name: 'STAFF'});
}
// Resolve the tags for the comment.
tags = await resolveTagsForComment(context, {asset_id, tags});
let comment = await CommentsService.publicCreate({
body,
@@ -305,24 +349,6 @@ const setStatus = async ({user, loaders: {Comments}}, {id, status}) => {
return comment;
};
/**
* Adds a tag to a Comment
* @param {String} id identifier of the comment (uuid)
* @param {String} tag name of the tag
*/
const addCommentTag = ({user, loaders: {Comments}}, {id, tag}) => {
return CommentsService.addTag(id, tag, user.id);
};
/**
* Removes a tag from a Comment
* @param {String} id identifier of the comment (uuid)
* @param {String} tag name of the tag
*/
const removeCommentTag = ({user, loaders: {Comments}}, {id, tag}) => {
return CommentsService.removeTag(id, tag);
};
/**
* Edit a Comment
* @param {String} id identifier of the comment (uuid)
@@ -340,6 +366,12 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
// Execute the edit.
const comment = await CommentsService.edit(id, context.user.id, {body, status});
if (context.pubsub) {
// Publish the edited comment via the subscription.
context.pubsub.publish('commentEdited', comment);
}
return comment;
};
@@ -348,9 +380,7 @@ module.exports = (context) => {
Comment: {
create: () => Promise.reject(errors.ErrNotAuthorized),
setStatus: () => Promise.reject(errors.ErrNotAuthorized),
addCommentTag: () => Promise.reject(errors.ErrNotAuthorized),
removeCommentTag: () => Promise.reject(errors.ErrNotAuthorized),
edit: () => Promise.reject(errors.ErrNotAuthorized),
edit: () => Promise.reject(errors.ErrNotAuthorized)
}
};
@@ -362,14 +392,6 @@ module.exports = (context) => {
mutators.Comment.setStatus = (action) => setStatus(context, action);
}
if (context.user && context.user.can(ADD_COMMENT_TAG)) {
mutators.Comment.addCommentTag = (action) => addCommentTag(context, action);
}
if (context.user && context.user.can(REMOVE_COMMENT_TAG)) {
mutators.Comment.removeCommentTag = (action) => removeCommentTag(context, action);
}
if (context.user && context.user.can(EDIT_COMMENT)) {
mutators.Comment.edit = (action) => edit(context, action);
}

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