Compare commits

..
162 Commits
Author SHA1 Message Date
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 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
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
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
Belen Curcio 82a72e506f Off-topic and Staff working 2017-06-08 12:04:33 -03:00
Kim Gardner f3dcfd23f3 Merge branch 'master' into 142993479-tags 2017-06-08 11:01:57 -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 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
Belen Curcio 63a35cbceb Merge issues 2017-06-07 16:38:36 -03:00
Wyatt Johnson 4cbc8b2226 Updates to setup process 2017-06-07 11:08:39 -06:00
Wyatt Johnson 6827ff2f1f Added support for version enforcing 2017-06-07 10:27:42 -06: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
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
155 changed files with 3450 additions and 2141 deletions
+1
View File
@@ -11,4 +11,5 @@ plugins/*
!plugins/coral-plugin-mod
!plugins/coral-plugin-love
!plugins/coral-plugin-viewing-options
!plugins/coral-plugin-comment-content
node_modules
+1
View File
@@ -24,5 +24,6 @@ plugins/*
!plugins/coral-plugin-mod
!plugins/coral-plugin-love
!plugins/coral-plugin-viewing-options
!plugins/coral-plugin-comment-content
**/node_modules/*
+24
View File
@@ -342,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.
+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);
+108 -48
View File
@@ -8,9 +8,12 @@ const program = require('./commander');
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);
});
@@ -77,7 +77,7 @@
letter-spacing: .8;
&:hover {
background-color: #232323;
background-color: #404040;
}
&.active {
@@ -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;
@@ -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;
@@ -12,4 +12,5 @@
right: 0;
margin-top: -2px;
font-size: 12px;
color: white;
}
@@ -15,7 +15,7 @@
display: flex;
.stat {
margin: 0 4px 12px;
margin: 0 4px 10px 0px;
}
.stat:last-child {
@@ -49,7 +49,7 @@
li {
display: inline-block;
margin: 0 10px;
margin-right: 10px;
cursor: pointer;
padding: 0 10px;
}
@@ -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;
@@ -245,7 +254,6 @@ span {
padding: 5px;
color: #262626;
font-size: 14px;
margin-left: 15px;
line-height: 1px;
font-weight: 300;
}
@@ -421,17 +429,21 @@ span {
.tabIcon {
position: relative;
top: 2px;
top: 3px;
font-size: 16px;
}
.username {
color: blue;
text-decoration: underline;
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;
}
}
@@ -26,8 +26,7 @@ const fm = new IntrospectionFragmentMatcher({
{name: 'SetUserStatusResponse'},
{name: 'SuspendUserResponse'},
{name: 'SetCommentStatusResponse'},
{name: 'AddCommentTagResponse'},
{name: 'RemoveCommentTagResponse'},
{name: 'ModifyTagResponse'},
{name: 'IgnoreUserResponse'},
{name: 'StopIgnoringUserResponse'}
]
@@ -3,12 +3,13 @@ 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,
@@ -17,17 +18,17 @@ import {
commentIsBest,
BestIndicator
} from 'coral-plugin-best/BestButton';
import Slot from 'coral-framework/components/Slot';
import LoadMore from './LoadMore';
import {getEditableUntilDate} from './util';
import {TopRightMenu} from './TopRightMenu';
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 hasTag = (tags, lookupTag) => !!tags.filter((tag) => tag.name === lookupTag).length;
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
@@ -180,10 +181,10 @@ export default 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,
@@ -219,6 +220,18 @@ export default class Comment extends React.Component {
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() {
@@ -286,11 +299,11 @@ export default class Comment extends React.Component {
deleteAction,
disableReply,
maxCharCount,
addCommentTag,
addNotification,
charCountEnable,
showSignInDialog,
removeCommentTag,
addTag,
removeTag,
liveUpdates,
commentIsIgnored,
commentClassNames = []
@@ -333,18 +346,20 @@ export default 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'
);
@@ -451,8 +466,7 @@ export default class Comment extends React.Component {
stopEditing={this.stopEditing}
/>
: <div>
<Content body={comment.body} />
<Slot fill="commentContent" />
<Slot fill="commentContent" comment={comment} defaultComponent={CommentContent} />
</div>
}
@@ -477,10 +491,9 @@ export default class Comment extends React.Component {
{!disableReply &&
<ActionButton>
<ReplyButton
onClick={() => setActiveReplyBox(comment.id)}
onClick={this.showReplyBox}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
banned={false}
/>
</ActionButton>}
<Slot
@@ -547,8 +560,8 @@ export default class Comment extends React.Component {
currentUser={currentUser}
postFlag={postFlag}
deleteAction={deleteAction}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
addTag={addTag}
removeTag={removeTag}
ignoreUser={ignoreUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
@@ -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;
@@ -129,10 +129,10 @@ class Stream extends React.Component {
postDontAgree,
deleteAction,
showSignInDialog,
addCommentTag,
addTag,
ignoreUser,
auth: {loggedIn, user},
removeCommentTag,
removeTag,
pluginProps,
editName
} = this.props;
@@ -223,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}
@@ -264,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}
@@ -298,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,
@@ -28,7 +28,9 @@ export default withFragments({
created_at
status
tags {
name
tag {
name
}
}
user {
id
@@ -5,7 +5,7 @@ import {bindActionCreators} from 'redux';
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 {notificationActions, authActions} from 'coral-framework';
@@ -307,10 +307,9 @@ export default compose(
withPostComment,
withPostFlag,
withPostDontAgree,
withAddCommentTag,
withRemoveCommentTag,
withAddTag,
withRemoveTag,
withIgnoreUser,
withDeleteAction,
withEditComment,
)(StreamContainer);
+23 -6
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
@@ -144,7 +150,18 @@ const extension = {
parent_id,
asset_id,
action_summaries: [],
tags: tags.map((t) => ({name: t, __typename: 'Tag'})),
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
+21 -6
View File
@@ -19,14 +19,29 @@ 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) {
pym.sendMessage('getConfig');
pym.onMessage('config', (config) => {
store.dispatch(addExternalConfig(JSON.parse(config)));
store.dispatch(checkLogin());
});
if (inIframe()) {
pym.sendMessage('getConfig');
pym.onMessage('config', (config) => {
init(JSON.parse(config));
});
} else {
init();
}
}
render(
+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.
+7 -2
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, className, 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}, className)}>
{getSlotElements(fill, {...rest, config})}
{children}
</div>
);
}
+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(
});
}}),
});
-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);
};
+39 -3
View File
@@ -1,8 +1,30 @@
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';
let client;
let client, wsClient = null, wsClientToken = null;
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) {
@@ -10,8 +32,22 @@ export function getClient() {
}
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const wsClient = new SubscriptionClient(`${protocol}://${location.host}/api/v1/live`, {
reconnect: true
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;
}
}
});
const networkInterfaceWithSubscriptions = addGraphQLSubscriptions(
+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`);
+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;
}
+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';
@@ -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
+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;
+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
});
+56 -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)
@@ -354,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)
}
};
@@ -368,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);
}
+2
View File
@@ -3,6 +3,7 @@ const debug = require('debug')('talk:graph:mutators');
const Comment = require('./comment');
const Action = require('./action');
const Tag = require('./tag');
const User = require('./user');
const plugins = require('../../services/plugins');
@@ -12,6 +13,7 @@ let mutators = [
// Load in the core mutators.
Comment,
Action,
Tag,
User,
// Load the plugin mutators from the manager.
+39
View File
@@ -0,0 +1,39 @@
const TagsService = require('../../services/tags');
const errors = require('../../errors');
const {ADD_COMMENT_TAG, REMOVE_COMMENT_TAG} = require('../../perms/constants');
/**
* Modifies the targeted model with the specified operation to add/remove a tag.
*/
const modify = async ({user, loaders: {Tags}}, operation, {name, id, item_type, asset_id}) => {
// Get the global list of tags from the dataloader.
const tags = await Tags.getAll.load({id, item_type, asset_id});
// Resolve the TagLink that should be used to insert to the user. This will
// addtionally return with an ownership property that can be used to determine
// that the user who adds this tag must also be the owner of the resource.
let {tagLink, ownership} = TagsService.resolveLink(user, tags, {name, item_type});
// Actually modify the tag on the model.
return operation(id, item_type, tagLink, ownership);
};
module.exports = (context) => {
let mutators = {
Tag: {
add: () => Promise.reject(errors.ErrNotAuthorized),
remove: () => Promise.reject(errors.ErrNotAuthorized)
}
};
if (context.user && context.user.can(ADD_COMMENT_TAG)) {
mutators.Tag.add = (tag) => modify(context, TagsService.add, tag);
}
if (context.user && context.user.can(REMOVE_COMMENT_TAG)) {
mutators.Tag.remove = (tag) => modify(context, TagsService.remove, tag);
}
return mutators;
};
+5
View File
@@ -1,3 +1,5 @@
const {decorateWithTags} = require('./util');
const Asset = {
recentComments({id}, _, {loaders: {Comments}}) {
return Comments.genRecentComments.load(id);
@@ -48,4 +50,7 @@ const Asset = {
}
};
// Decorate the Asset type resolver with a tags field.
decorateWithTags(Asset);
module.exports = Asset;
+5
View File
@@ -1,3 +1,5 @@
const {decorateWithTags} = require('./util');
const Comment = {
parent({parent_id}, _, {loaders: {Comments}}) {
if (parent_id == null) {
@@ -57,4 +59,7 @@ const Comment = {
}
};
// Decorate the Comment type resolver with a tags field.
decorateWithTags(Comment);
module.exports = Comment;
+4
View File
@@ -16,6 +16,8 @@ const RootMutation = require('./root_mutation');
const RootQuery = require('./root_query');
const Settings = require('./settings');
const Subscription = require('./subscription');
const TagLink = require('./tag_link');
const Tag = require('./tag');
const UserError = require('./user_error');
const User = require('./user');
const ValidationUserError = require('./validation_user_error');
@@ -39,6 +41,8 @@ let resolvers = {
RootQuery,
Settings,
Subscription,
TagLink,
Tag,
UserError,
User,
ValidationUserError,
+5 -6
View File
@@ -1,5 +1,4 @@
const wrapResponse = require('../helpers/response');
const CommentsService = require('../../services/comments');
const RootMutation = {
createComment(_, {comment}, {mutators: {Comment}}) {
@@ -35,12 +34,12 @@ const RootMutation = {
setCommentStatus(_, {id, status}, {mutators: {Comment}}) {
return wrapResponse(null)(Comment.setStatus({id, status}));
},
addCommentTag(_, {id, tag}, {mutators: {Comment}}) {
return wrapResponse('comment')(Comment.addCommentTag({id, tag}).then(() => CommentsService.findById(id)));
},
removeCommentTag(_, {id, tag}, {mutators: {Comment}}) {
return wrapResponse('comment')(Comment.removeCommentTag({id, tag}).then(() => CommentsService.findById(id)));
addTag(_, {tag}, {mutators: {Tag}}) {
return wrapResponse(null)(Tag.add(tag));
},
removeTag(_, {tag}, {mutators: {Tag}}) {
return wrapResponse(null)(Tag.remove(tag));
}
};
module.exports = RootMutation;
+5
View File
@@ -0,0 +1,5 @@
const Tag = {
};
module.exports = Tag;
+11
View File
@@ -0,0 +1,11 @@
const {SEARCH_OTHER_USERS} = require('../../perms/constants');
const TagLink = {
assigned_by({assigned_by}, _, {user, loaders: {Users}}) {
if (user && user.can(SEARCH_OTHER_USERS) && assigned_by != null) {
return Users.getByID.load(assigned_by);
}
}
};
module.exports = TagLink;
+4
View File
@@ -1,3 +1,4 @@
const {decorateWithTags} = require('./util');
const KarmaService = require('../../services/karma');
const {
SEARCH_ACTIONS,
@@ -78,4 +79,7 @@ const User = {
}
};
// Decorate the User type resolver with a tags field.
decorateWithTags(User);
module.exports = User;
+18
View File
@@ -0,0 +1,18 @@
const {ADD_COMMENT_TAG} = require('../../perms/constants');
/**
* Decorates the typeResolver with the tags field.
*/
const decorateWithTags = (typeResolver) => {
typeResolver.tags = ({tags = []}, _, {user}) => {
if (user && user.can(ADD_COMMENT_TAG)) {
return tags;
}
return tags.filter((t) => t.tag.permissions.public);
};
};
module.exports = {
decorateWithTags
};
+24 -6
View File
@@ -41,16 +41,34 @@ const createSubscriptionManager = (server) => new SubscriptionServer({
pubsub,
setupFunctions,
}),
onSubscribe: (parsedMessage, baseParams, connection) => {
onConnect: ({token}, connection) => {
// Attach the token from the connection options if it was provided.
if (token) {
// Attach it to the upgrade request.
connection.upgradeReq.headers['authorization'] = `Bearer ${token}`;
}
},
onOperation: (parsedMessage, baseParams, connection) => {
// Cache the upgrade request.
let upgradeReq = connection.upgradeReq;
// Attach the context per request.
baseParams.context = () => deserializeUser(connection.upgradeReq)
.then((req) => new Context(req, pubsub))
.catch((err) => {
console.error(err);
baseParams.context = async () => {
let req;
try {
req = await deserializeUser(upgradeReq);
} catch (e) {
console.error(e);
return new Context({}, pubsub);
});
}
return new Context(req, pubsub);
};
return baseParams;
}
+96 -41
View File
@@ -69,6 +69,9 @@ type User {
# the current profiles of the user.
profiles: [UserProfile]
# the tags on the user
tags: [TagLink!]
# determines whether the user can edit their username
canEditName: Boolean
@@ -87,17 +90,6 @@ type User {
status: USER_STATUS
}
type Tag {
# the actual tag for the comment.
name: String!
# the user that assigned the tag. If NULL then the system automatically tagged it.
assigned_by: String
# the time when the tag was assigned.
created_at: Date!
}
# UsersQuery allows the ability to query users by a specific fields.
input UsersQuery {
action_type: ACTION_TYPE
@@ -112,6 +104,51 @@ input UsersQuery {
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
}
################################################################################
## Tags
################################################################################
# Used to represent the item type for a tag.
enum TAGGABLE_ITEM_TYPE {
# The action references a entity of type Asset.
ASSETS
# The action references a entity of type Comment.
COMMENTS
# The action references a entity of type User.
USERS
}
# Tag represents the underlying Tag that can be either stored in a global list
# or added uniquely to the entity.
type Tag {
# The actual name of the tag entry.
name: String!
# The time that this Tag was created.
created_at: Date!
}
# TagLink is used to associate a given Tag with a Model via a TagLink.
type TagLink {
# The underlying Tag that is either duplicated from the global list or created
# uniquely for this specific model.
tag: Tag!
# The user that assigned the tag. This TagLink could have been created by the
# system, in which case this will be null. It could also be null if the
# current user is not an Admin/Moderator.
assigned_by: User
# The date that the TagLink was created.
created_at: Date!
}
################################################################################
## Comments
################################################################################
@@ -223,7 +260,7 @@ type Comment {
body: String!
# the tags on the comment
tags: [Tag]
tags: [TagLink!]
# the user who authored the comment.
user: User
@@ -507,6 +544,9 @@ type Asset {
# The date that the asset was created.
created_at: Date
# the tags on the asset
tags: [TagLink!]
# The author(s) of the asset.
author: String
}
@@ -543,7 +583,7 @@ type ValidationUserError implements UserError {
}
################################################################################
## Queries;
## Queries
################################################################################
# Establishes the ordering of the content by their created_at time stamp.
@@ -625,7 +665,7 @@ type RootQuery {
interface Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# CreateCommentResponse is returned with the comment that was created and any
@@ -636,7 +676,7 @@ type CreateCommentResponse implements Response {
comment: Comment
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# Used to represent the item type for an action.
@@ -652,8 +692,13 @@ enum ACTION_ITEM_TYPE {
USERS
}
enum TAG_TYPE {
STAFF
input CreateLikeInput {
# The item's id for which we are to create a like.
item_id: ID!
# The type of the item for which we are to create the like.
item_type: ACTION_ITEM_TYPE!
}
# CreateCommentInput is the input content used to create a new comment.
@@ -669,7 +714,7 @@ input CreateCommentInput {
body: String!
# Tags
tags: [TAG_TYPE]
tags: [String]
}
@@ -697,7 +742,7 @@ type CreateFlagResponse implements Response {
flag: FlagAction
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
@@ -710,7 +755,7 @@ type CreateDontAgreeResponse implements Response {
dontagree: DontAgreeAction
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
input CreateDontAgreeInput {
@@ -756,7 +801,7 @@ input RejectUsernameInput {
type DeleteActionResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# SetUserStatusResponse is the response returned with possibly some errors
@@ -764,7 +809,7 @@ type DeleteActionResponse implements Response {
type SetUserStatusResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# SuspendUserResponse is the response returned with possibly some errors
@@ -772,7 +817,7 @@ type SetUserStatusResponse implements Response {
type SuspendUserResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# RejectUsernameResponse is the response returned with possibly some errors
@@ -780,7 +825,7 @@ type SuspendUserResponse implements Response {
type RejectUsernameResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# SetCommentStatusResponse is the response returned with possibly some errors
@@ -788,33 +833,43 @@ type RejectUsernameResponse implements Response {
type SetCommentStatusResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError]
errors: [UserError!]
}
# Response to addCommentTag mutation
type AddCommentTagResponse implements Response {
# An array of errors relating to the mutation that occured.
comment: Comment
errors: [UserError]
# ModifyTagInput is the input used to modify a tag.
input ModifyTagInput {
# name is the actual tag to add to the model.
name: String!
# id is the ID of the model in question that we are modifying the tag of.
id: ID!
# item_type is the type of item that we are modifying the tag if.
item_type: TAGGABLE_ITEM_TYPE!
# asset_id is used when the item_type is `COMMENTS`, the is needed to rectify
# the settings to get the asset specific tags/settings.
asset_id: ID
}
# Response to removeCommentTag mutation
type RemoveCommentTagResponse implements Response {
# Response to the addTag or removeTag mutations.
type ModifyTagResponse implements Response {
# An array of errors relating to the mutation that occured.
comment: Comment
errors: [UserError]
errors: [UserError!]
}
# Response to ignoreUser mutation
type IgnoreUserResponse implements Response {
# An array of errors relating to the mutation that occured.
errors: [UserError]
errors: [UserError!]
}
# Response to stopIgnoringUser mutation
type StopIgnoringUserResponse implements Response {
# An array of errors relating to the mutation that occured.
errors: [UserError]
errors: [UserError!]
}
# Input to editComment mutation.
@@ -831,7 +886,7 @@ type EditCommentResponse implements Response {
comment: Comment
# An array of errors relating to the mutation that occured.
errors: [UserError]
errors: [UserError!]
}
# All mutations for the application are defined on this object.
@@ -864,11 +919,11 @@ type RootMutation {
# Sets Comment status. Requires the `ADMIN` role.
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse
# Add tag to comment.
addCommentTag(id: ID!, tag: String!): AddCommentTagResponse
# Add a tag.
addTag(tag: ModifyTagInput!): ModifyTagResponse!
# Remove tag from comment.
removeCommentTag(id: ID!, tag: String!): RemoveCommentTagResponse
# Removes a tag.
removeTag(tag: ModifyTagInput!): ModifyTagResponse!
# Ignore comments by another user
ignoreUser(id: ID!): IgnoreUserResponse
+7 -6
View File
@@ -42,17 +42,17 @@ en:
banned: Banned
banned_user: "Banned User"
cancel: Cancel
dont_like_username: "I don't like this username"
dont_like_username: "Dislike username"
flaggedaccounts: "Flagged Usernames"
flags: Flags
impersonating: "This user is impersonating"
impersonating: "Impersonation"
loading: "Loading results"
moderator: Moderator
newsroom_role: "Newsroom Role"
no_flagged_accounts: "The Account Flags queue is currently empty."
no_flagged_accounts: "The Flagged Usernames queue is currently empty."
no_results: "No users found with that user name or email address. They're hiding!"
note: "Note: Banning this user will not let them edit comment or remove anything."
offensive: "This comment is offensive"
offensive: "Offensive"
other: Other
people: People
role: "Select role..."
@@ -144,8 +144,8 @@ en:
auto_update: "Data automatically updates every five minutes or when you Reload."
comment_count: comments
flags: Flags
most_flags: "Articles with the most flags"
most_conversations: "Articles with the most conversations"
most_flags: "Stories with the most flags"
most_conversations: "Stories with the most conversations"
next_update: "{0} minutes until next update."
no_activity: "There haven't been any comments anywhere in the last five minutes."
no_flags: "There have been no flags in the last 5 minutes! Hooray!"
@@ -189,6 +189,7 @@ en:
PASSWORD_REQUIRED: "Must input a password"
COMMENTING_CLOSED: "Commenting is already closed"
NOT_FOUND: "Resource not found"
ALREADY_EXISTS: "Resource already exists"
INVALID_ASSET_URL: "Assert URL is invalid"
email: "Not a valid E-Mail"
confirm_password: "Passwords don't match. Please check again"
+3 -3
View File
@@ -44,14 +44,14 @@ es:
dont_like_username: "No me gusta este nombre de usuario"
flaggedaccounts: "Nombres de Usuario Reportados"
flags: Reportes
impersonating: "El usuario esta suplantando identidad"
impersonating: Impersonando
loading: "Cargando resultados"
moderator: Moderator
newsroom_role: "Rol en la redacción"
no_flagged_accounts: "No hay ninguna cuenta reportada en este momento."
no_flagged_accounts: "No hay ningún nombre de usario reportado en este momento."
no_results: "No se encontraron usuarios con ese nombre o correo."
note: "Nota: Suspender a este usuario no le va a permitir (al usuario) borrar ni editar ni comentar."
offensive: "Este comentario es ofensivo"
offensive: Ofensivo
other: Otro
people: Gente
role: "Seleccionar rol..."
+83
View File
@@ -0,0 +1,83 @@
const CommentModel = require('../models/comment');
module.exports = {
async up() {
// Find all comments that have tags.
let comments = await CommentModel.aggregate([
{$match: {
tags: {
$exists: true,
$ne: []
}
}},
{$project: {
id: true,
tags: true
}}
]);
// If no comments were found, nothing needes to be done!
if (comments.length <= 0) {
return;
}
// Create a new batch operation.
let batch = CommentModel.collection.initializeUnorderedBulkOp();
// Loop over the comments retrieved, updating the tag structure.
for (let {id, tags} of comments) {
// OLD
//
// [
// {
// name: 'OFF_TOPIC',
// assigned_by: '',
// created_at: new Date()
// }
// ]
// NEW
//
// [
// {
// tag: {
// name: 'OFF_TOPIC',
// permissions: {
// public: true,
// self: false,
// roles: []
// },
// models: ['COMMENTS'],
// created_at: new Date()
// },
// assigned_by: '',
// created_at: new Date()
// }
// ]
// Remap the tag structure.
tags = tags.map(({name, assigned_by, created_at}) => ({
tag: {
name,
permissions: {
public: true,
self: name === 'OFF_TOPIC', // at the time of migration, only off topic tags were self assigning
roles: []
},
models: ['COMMENTS'],
created_at
},
assigned_by,
created_at
}));
// Execute the batch operation.
batch.find({id}).updateOne({$set: {tags}});
}
// Execute the batch update operation.
await batch.execute();
}
};
+2 -11
View File
@@ -1,17 +1,8 @@
const mongoose = require('../services/mongoose');
const uuid = require('uuid');
const Schema = mongoose.Schema;
const ACTION_TYPES = [
'LIKE',
'FLAG'
];
const ITEM_TYPES = [
'ASSETS',
'COMMENTS',
'USERS'
];
const ACTION_TYPES = require('./enum/action_types');
const ITEM_TYPES = require('./enum/item_types');
const ActionSchema = new Schema({
id: {
+4
View File
@@ -1,6 +1,7 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const uuid = require('uuid');
const TagLinkSchema = require('./schema/tag_link');
const AssetSchema = new Schema({
id: {
@@ -47,6 +48,9 @@ const AssetSchema = new Schema({
default: null
},
// Tags are added by the self or by administrators.
tags: [TagLinkSchema],
// Additional metadata stored on the field.
metadata: {
default: {},
+15 -31
View File
@@ -1,13 +1,8 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const TagLinkSchema = require('./schema/tag_link');
const uuid = require('uuid');
const STATUSES = [
'ACCEPTED',
'REJECTED',
'PREMOD',
'NONE'
];
const COMMENT_STATUS = require('./enum/comment_status');
/**
* The Mongo schema for a Comment Status.
@@ -16,7 +11,7 @@ const STATUSES = [
const StatusSchema = new Schema({
type: {
type: String,
enum: STATUSES,
enum: COMMENT_STATUS,
},
// The User ID of the user that assigned the status.
@@ -30,27 +25,6 @@ const StatusSchema = new Schema({
_id: false
});
/**
* The Mongo schema for a Comment Tag.
* @type {Schema}
*/
const TagSchema = new Schema({
name: String,
// The User ID of the user that assigned the status.
assigned_by: {
type: String,
default: null
},
created_at: {
type: Date,
default: Date
}
}, {
_id: false
});
/**
* A record of old body values for a Comment
*/
@@ -89,12 +63,14 @@ const CommentSchema = new Schema({
status_history: [StatusSchema],
status: {
type: String,
enum: STATUSES,
enum: COMMENT_STATUS,
default: 'NONE'
},
tags: [TagSchema],
parent_id: String,
// Tags are added by the self or by administrators.
tags: [TagLinkSchema],
// Additional metadata stored on the field.
metadata: {
default: {},
@@ -110,6 +86,14 @@ const CommentSchema = new Schema({
},
});
// Add the indexes for the id of the comment.
CommentSchema.index({
'id': 1
}, {
unique: true,
background: false
});
CommentSchema.virtual('edited').get(function() {
return this.body_history.length > 1;
});
+4
View File
@@ -0,0 +1,4 @@
module.exports = [
'LIKE',
'FLAG'
];
+6
View File
@@ -0,0 +1,6 @@
module.exports = [
'ACCEPTED',
'REJECTED',
'PREMOD',
'NONE'
];
+5
View File
@@ -0,0 +1,5 @@
module.exports = [
'ASSETS',
'COMMENTS',
'USERS'
];
+4
View File
@@ -0,0 +1,4 @@
module.exports = [
'PRE',
'POST'
];
+5
View File
@@ -0,0 +1,5 @@
module.exports = [
'ADMIN',
'MODERATOR',
'STAFF'
];
+6
View File
@@ -0,0 +1,6 @@
module.exports = [
'ACTIVE',
'BANNED',
'PENDING',
'APPROVED' // Indicates that the users' username has been approved
];
+10
View File
@@ -0,0 +1,10 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const MigrationSchema = new Schema({
version: Number
});
const Migration = mongoose.model('Migration', MigrationSchema);
module.exports = Migration;
+53
View File
@@ -0,0 +1,53 @@
const mongoose = require('../../services/mongoose');
const Schema = mongoose.Schema;
const ITEM_TYPES = require('../enum/item_types');
const USER_ROLES = require('../enum/user_roles');
/**
* The Mongo schema for a Tag.
* @type {Schema}
*/
const TagSchema = new Schema({
// The actual name of the tag.
name: String,
// Contains permission data.
permissions: {
// Determines if this tag is public or not.
public: {
type: Boolean,
default: true
},
// Determines if the owner of the Model can add/remove this tag on their own
// resources.
self: Boolean,
// Determines other roles that are allowed to set this tag on other
// resources.
roles: [{
type: String,
enum: USER_ROLES,
default: []
}]
},
// A list of all the model types that this tag can be added to.
models: [{
type: String,
enum: ITEM_TYPES
}],
// The date for when the tag was created.
created_at: {
type: Date,
default: Date
}
}, {
_id: false
});
module.exports = TagSchema;
+31
View File
@@ -0,0 +1,31 @@
const mongoose = require('../../services/mongoose');
const Schema = mongoose.Schema;
const TagSchema = require('./tag');
/**
* The Mongo schema for linking a Tag to a Model.
* @type {Schema}
*/
const TagLinkSchema = new Schema({
// A deep copy of the tag that is the origin for this link. If the ID matches
// with existing tags in the global/asset context then content will be
// substituted.
tag: TagSchema,
// The User ID of the user that assigned the status.
assigned_by: {
type: String,
default: null
},
// The date when the tag was added to the model.
created_at: {
type: Date,
default: Date
}
}, {
_id: false
});
module.exports = TagLinkSchema;
+4 -7
View File
@@ -1,10 +1,7 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const MODERATION_OPTIONS = [
'PRE',
'POST'
];
const TagSchema = require('./schema/tag');
const MODERATION_OPTIONS = require('./enum/moderation_options');
/**
* SettingSchema manages application settings that get used on front and backend.
@@ -95,7 +92,8 @@ const SettingSchema = new Schema({
type: Number,
min: [0, 'Edit Comment Window length must be greater than zero'],
default: 30 * 1000,
}
},
tags: [TagSchema]
}, {
timestamps: {
createdAt: 'created_at',
@@ -127,4 +125,3 @@ SettingSchema.method('merge', function(src) {
const Setting = mongoose.model('Setting', SettingSchema);
module.exports = Setting;
module.exports.MODERATION_OPTIONS = MODERATION_OPTIONS;
+10 -16
View File
@@ -1,27 +1,20 @@
const mongoose = require('../services/mongoose');
const bcrypt = require('bcrypt');
const Schema = mongoose.Schema;
const uuid = require('uuid');
const TagLinkSchema = require('./schema/tag_link');
const intersection = require('lodash/intersection');
const can = require('../perms');
// USER_ROLES is the array of roles that is permissible as a user role.
const USER_ROLES = [
'ADMIN',
'MODERATOR',
'STAFF'
];
const USER_ROLES = require('./enum/user_roles');
// USER_STATUS is the list of statuses that are permitted for the user status.
const USER_STATUS = [
'ACTIVE',
'BANNED',
'PENDING',
'APPROVED' // Indicates that the users' username has been approved
];
const USER_STATUS = require('./enum/user_status');
// ProfileSchema is the mongoose schema defined as the representation of a
// User's profile stored in MongoDB.
const ProfileSchema = new mongoose.Schema({
const ProfileSchema = new Schema({
// ID provides the identifier for the user profile, in the case of a local
// provider, the id would be an email, in the case of a social provider,
@@ -44,7 +37,7 @@ const ProfileSchema = new mongoose.Schema({
// used by the `local` provider to indicate when the email address was
// confirmed.
metadata: {
type: mongoose.Schema.Types.Mixed
type: Schema.Types.Mixed
}
}, {
_id: false
@@ -52,7 +45,7 @@ const ProfileSchema = new mongoose.Schema({
// UserSchema is the mongoose schema defined as the representation of a User in
// MongoDB.
const UserSchema = new mongoose.Schema({
const UserSchema = new Schema({
// This ID represents the most unique identifier for a user, it is generated
// when the user is created as a random uuid.
@@ -136,6 +129,9 @@ const UserSchema = new mongoose.Schema({
type: String,
}],
// Tags are added by the self or by administrators.
tags: [TagLinkSchema],
// Additional metadata stored on the field.
metadata: {
default: {},
@@ -205,5 +201,3 @@ UserSchema.method('can', function(...actions) {
const UserModel = mongoose.model('User', UserSchema);
module.exports = UserModel;
module.exports.USER_ROLES = USER_ROLES;
module.exports.USER_STATUS = USER_STATUS;
+9 -3
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "1.9.1",
"version": "2.0.0",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"scripts": {
@@ -19,6 +19,11 @@
"embed-start": "NODE_ENV=development yarn build && ./bin/cli serve --jobs",
"heroku-postbuild": "./bin/cli plugins reconcile && yarn build"
},
"talk": {
"migration": {
"minVersion": 1496771633
}
},
"config": {
"pre-git": {
"commit-msg": [],
@@ -76,7 +81,7 @@
"graphql-errors": "^2.1.0",
"graphql-redis-subscriptions": "^1.1.5",
"graphql-server-express": "^0.6.0",
"graphql-subscriptions": "^0.3.1",
"graphql-subscriptions": "^0.4.3",
"graphql-tools": "^0.10.1",
"helmet": "^3.5.0",
"immutability-helper": "^2.2.0",
@@ -110,7 +115,8 @@
"resolve": "^1.3.2",
"semver": "^5.3.0",
"simplemde": "^1.11.2",
"subscriptions-transport-ws": "^0.5.5-alpha.0",
"snake-case": "^2.1.0",
"subscriptions-transport-ws": "^0.7.2",
"timekeeper": "^1.0.0",
"uuid": "^3.0.1",
"yaml-loader": "^0.4.0",
@@ -1,2 +1 @@
export {Slot} from 'coral-framework/components';
export {Icon} from 'coral-ui';
@@ -0,0 +1 @@
export * from 'coral-ui';
+1 -1
View File
@@ -1 +1 @@
export {withReaction} from 'coral-framework/hocs';
export {default as withReaction} from './withReaction';
+377
View File
@@ -0,0 +1,377 @@
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 'coral-framework/helpers/hoc';
import {compose, gql} from 'react-apollo';
import withFragments from 'coral-framework/hocs/withFragments';
import withMutation from 'coral-framework/hocs/withMutation';
import {showSignInDialog} from 'coral-framework/actions/auth';
import {capitalize} from 'coral-framework/helpers/strings';
import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils';
import * as PropTypes from 'prop-types';
export default (reaction) => (WrappedComponent) => {
if (typeof reaction !== 'string') {
console.error('Reaction must be a valid string');
return null;
}
// Global instance counter for each `reaction` type.
let instances = 0;
// Track current subscriptions.
let createdSubscription = null;
let deletedSubscription = null;
reaction = reaction.toLowerCase();
const Reaction = capitalize(reaction);
const COMMENT_FRAGMENT = gql`
fragment ${Reaction}Button_updateFragment on Comment {
action_summaries {
... on ${Reaction}ActionSummary {
count
current_user {
id
}
}
}
}
`;
const isReaction = (a) =>
a.__typename === `${Reaction}ActionSummary`;
const addReactionToStore = (proxy, {action, self}) => {
const fragmentId = `Comment_${action.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 (self && 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: `${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: self ? action : data.action_summaries[idx].current_user
};
// Write our data back to the cache.
proxy.writeFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId,
data
});
};
const deleteReactionFromStore = (proxy, {action, self}) => {
const fragmentId = `Comment_${action.item_id}`;
// 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 (
self &&
(idx < 0 || get(data.action_summaries[idx], 'current_user.id') !== action.id)
) {
return;
}
data.action_summaries[idx] = {
...data.action_summaries[idx],
count: data.action_summaries[idx].count - 1,
current_user: self ? null : data.action_summaries[idx].current_user,
};
// Write our data back to the cache.
proxy.writeFragment({
fragment: COMMENT_FRAGMENT,
id: fragmentId,
data
});
};
const REACTION_CREATED_SUBSCRIPTION = gql`
subscription ${Reaction}ActionCreated($assetId: ID!) {
${reaction}ActionCreated(asset_id: $assetId) {
id
user {
id
}
item_id
}
}
`;
const REACTION_DELETED_SUBSCRIPTION = gql`
subscription ${Reaction}ActionDeleted($assetId: ID!) {
${reaction}ActionDeleted(asset_id: $assetId) {
id
user {
id
}
item_id
}
}
`;
class WithReactions extends React.Component {
static contextTypes = {
client: PropTypes.object.isRequired,
};
// Whether or not a mutation is currently active.
duringMutation = false;
constructor(props, context) {
super(props, context);
// Start subscriptions when it is first needed.
if (instances === 0) {
createdSubscription = context.client.subscribe({
query: REACTION_CREATED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
}).subscribe({
next: this.onReactionCreated,
error(err) { console.error('err', err); },
});
deletedSubscription = context.client.subscribe({
query: REACTION_DELETED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
}).subscribe({
next: this.onReactionDeleted,
error(err) { console.error('err', err); },
});
}
instances++;
}
// onReactionCreated handles live updates through the subscriptions.
onReactionCreated = ({[`${reaction}ActionCreated`]: action}) => {
if (this.props.user && action.user && this.props.user.id === action.user.id) {
return;
}
addReactionToStore(this.context.client, {action, self: false});
};
// onReactionDeleted handles live updates through the subscriptions.
onReactionDeleted = ({[`${reaction}ActionDeleted`]: action}) => {
if (this.props.user && action.user && this.props.user.id === action.user.id) {
return;
}
deleteReactionFromStore(this.context.client, {action, self: false});
};
componentWillUnmount() {
instances--;
// End subscriptions when last component will be unmounted.
if (instances === 0) {
try {
createdSubscription.unsubscribe();
deletedSubscription.unsubscribe();
}
catch(e) {
console.warn(e);
}
}
}
postReaction = () => {
if (this.duringMutation) {
return;
}
this.duringMutation = true;
return this.props.postReaction(this.props.comment)
.then((result) => {this.duringMutation = false; return Promise.resolve(result); })
.catch((err) => {this.duringMutation = false; throw err; });
}
deleteReaction = () => {
if (this.duringMutation) {
return;
}
this.duringMutation = true;
return this.props.deleteReaction(this.props.comment)
.then((result) => {this.duringMutation = false; return Promise.resolve(result); })
.catch((err) => {this.duringMutation = false; throw err; });
}
render() {
const {comment} = this.props;
const reactionSummary = getMyActionSummary(
`${Reaction}ActionSummary`,
comment
);
const count = getTotalActionCount(
`${Reaction}ActionSummary`,
comment
);
const alreadyReacted = !!reactionSummary;
return <WrappedComponent
showSignInDialog={this.props.showSignInDialog}
user={this.props.user}
comment={comment}
reactionSummary={reactionSummary}
count={count}
alreadyReacted={alreadyReacted}
postReaction={this.postReaction}
deleteReaction={this.deleteReaction}
/>;
}
}
const withDeleteReaction = withMutation(
gql`
mutation Delete${Reaction}Action($input: Delete${Reaction}ActionInput!) {
delete${Reaction}Action(input: $input) {
errors {
translation_key
}
}
}
`,
{
props: ({mutate}) => ({
deleteReaction: (comment) => {
const reactionSummary = getMyActionSummary(
`${Reaction}ActionSummary`,
comment
);
const id = reactionSummary.current_user.id;
const item_id = comment.id;
const input = {id};
return mutate({
variables: {input},
optimisticResponse: {
[`delete${Reaction}Action`]: {
__typename: `Delete${Reaction}ActionResponse`,
errors: null
}
},
update: (proxy) => {
deleteReactionFromStore(proxy, {action: {item_id, id}, self: true});
}
});
}
})
}
);
const withPostReaction = withMutation(
gql`
mutation Create${Reaction}Action($input: Create${Reaction}ActionInput!) {
create${Reaction}Action(input: $input) {
${reaction} {
id
}
errors {
translation_key
}
}
}
`,
{
props: ({mutate}) => ({
postReaction: (comment) => {
const input = {
item_id: comment.id,
};
return mutate({
variables: {input},
optimisticResponse: {
[`create${Reaction}Action`]: {
__typename: `Create${Reaction}ActionResponse`,
errors: null,
[reaction]: {
__typename: `${Reaction}Action`,
id: uuid()
}
}
},
update: (proxy, {data: {[`create${Reaction}Action`]: {[reaction]: action}}}) => {
const a = {
...action,
item_id: input.item_id,
};
addReactionToStore(proxy, {action: a, self: true});
}
});
}
})
}
);
const mapStateToProps = (state) => ({
user: state.auth.toJS().user,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({showSignInDialog}, dispatch);
const enhance = compose(
withFragments({
comment: gql`
fragment ${Reaction}Button_comment on Comment {
action_summaries {
... on ${Reaction}ActionSummary {
count
current_user {
id
}
}
}
}`
}),
connect(mapStateToProps, mapDispatchToProps),
withDeleteReaction,
withPostReaction
);
WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`;
return enhance(WithReactions);
};
+1
View File
@@ -1 +1,2 @@
export {t} from 'coral-framework/services/i18n';
export {can} from 'coral-framework/services/perms';
+205
View File
@@ -0,0 +1,205 @@
const wrapResponse = require('../../../graph/helpers/response');
const {SEARCH_OTHER_USERS} = require('../../../perms/constants');
const errors = require('../../../errors');
function getReactionConfig(reaction) {
reaction = reaction.toLowerCase();
const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1);
const REACTION = reaction.toUpperCase();
const typeDefs = `
enum ACTION_TYPE {
# Represents a ${Reaction}.
${REACTION}
}
enum ASSET_METRICS_SORT {
# Represents a ${Reaction}Action.
${REACTION}
}
input Create${Reaction}ActionInput {
# The item's id for which we are to create a ${reaction}.
item_id: ID!
}
input Delete${Reaction}ActionInput {
# The item's id for which we are deleting a ${reaction}.
id: ID!
}
# ${Reaction}Action is used by users who "${reaction}" a specific entity.
type ${Reaction}Action implements Action {
# The ID of the action.
id: ID!
# The author of the action.
user: User
# The time when the Action was updated.
updated_at: Date
# The time when the Action was created.
created_at: Date
# The item's id for which the Action was created.
item_id: ID!
}
type ${Reaction}ActionSummary implements ActionSummary {
# The count of actions with this group.
count: Int
# The current user's action.
current_user: ${Reaction}Action
}
# A summary of counts related to all the ${Reaction}s on an Asset.
type ${Reaction}AssetActionSummary implements AssetActionSummary {
# Number of ${reaction}s associated with actionable types on this this Asset.
actionCount: Int
# Number of unique actionable types that are referenced by the ${reaction}s.
actionableItemCount: Int
}
type Create${Reaction}ActionResponse implements Response {
# The ${reaction} that was created.
${reaction}: ${Reaction}Action
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
type Delete${Reaction}ActionResponse implements Response {
# The ${reaction} that was created.
${reaction}: ${Reaction}Action
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
type RootMutation {
# Creates a ${reaction} on an entity.
create${Reaction}Action(input: Create${Reaction}ActionInput!): Create${Reaction}ActionResponse
delete${Reaction}Action(input: Delete${Reaction}ActionInput!): Delete${Reaction}ActionResponse
}
type Subscription {
# Subscribe to ${reaction}s.
${reaction}ActionCreated(asset_id: ID!): ${Reaction}Action
# Subscribe to ${reaction} removals.
${reaction}ActionDeleted(asset_id: ID!): ${Reaction}Action
}
`;
return {
typeDefs,
resolvers: {
Subscription: {
[`${reaction}ActionCreated`]: ({action}) => {
return action;
},
[`${reaction}ActionDeleted`]: ({action}) => {
return action;
},
},
[`${Reaction}Action`]: {
// This will load the user for the specific action. We'll limit this to the
// admin users only or the current logged in user.
user({user_id}, _, {loaders: {Users}, user}) {
if (user && (user.can(SEARCH_OTHER_USERS) || user_id === user.id)) {
return Users.getByID.load(user_id);
}
}
},
RootMutation: {
[`create${Reaction}Action`]: (_, {input: {item_id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => {
const response = Comments.get.load(item_id).then((comment) => {
return Action.create({item_id, item_type: 'COMMENTS', action_type: REACTION})
.then((action) => {
// The comment is needed to allow better filtering e.g. by asset_id.
pubsub.publish(`${reaction}ActionCreated`, {action, comment});
return Promise.resolve(action);
})
.catch((err) => {
if (err instanceof errors.ErrAlreadyExists) {
return Promise.resolve(err.metadata.existing);
}
throw err;
});
});
return wrapResponse(reaction)(response);
},
[`delete${Reaction}Action`]: (_, {input: {id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => {
const response = Action.delete({id})
.then((action) => {
// Action doesn't exist or was already deleted.
if (!action) {
return Promise.resolve(null);
}
return Comments.get.load(action.item_id).then((comment) => {
// The comment is needed to allow better filtering e.g. by asset_id.
pubsub.publish(`${reaction}ActionDeleted`, {action, comment});
return Promise.resolve(action);
});
});
return wrapResponse(reaction)(response);
}
},
},
hooks: {
Action: {
__resolveType: {
post({action_type}) {
switch (action_type) {
case REACTION:
return `${Reaction}Action`;
}
}
}
},
ActionSummary: {
__resolveType: {
post({action_type}) {
switch (action_type) {
case REACTION:
return `${Reaction}ActionSummary`;
}
}
}
}
},
setupFunctions: {
[`${reaction}ActionCreated`]: (options, args) => ({
[`${reaction}ActionCreated`]: {
filter: ({comment}) => comment.asset_id === args.asset_id,
},
}),
[`${reaction}ActionDeleted`]: (options, args) => ({
[`${reaction}ActionDeleted`]: {
filter: ({comment}) => comment.asset_id === args.asset_id,
},
}),
},
};
}
module.exports = getReactionConfig;
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
getReactionConfig: require('./getReactionConfig'),
};
+6 -5
View File
@@ -1,16 +1,17 @@
{
"server": [
"coral-plugin-respect",
"coral-plugin-like",
"coral-plugin-facebook-auth",
"coral-plugin-auth",
"coral-plugin-offtopic"
"coral-plugin-like",
"coral-plugin-respect",
"coral-plugin-offtopic",
"coral-plugin-facebook-auth"
],
"client": [
"coral-plugin-respect",
"coral-plugin-like",
"coral-plugin-auth",
"coral-plugin-offtopic",
"coral-plugin-viewing-options"
"coral-plugin-viewing-options",
"coral-plugin-comment-content"
]
}
+65 -37
View File
@@ -10,6 +10,7 @@ const PLUGINS_JSON = process.env.TALK_PLUGINS_JSON;
// Add the current path to the module root.
amp.addPath(__dirname);
let pluginsPath;
let plugins = {};
// Try to parse the plugins.json file, logging out an error if the plugins.json
@@ -22,14 +23,16 @@ try {
if (PLUGINS_JSON && PLUGINS_JSON.length > 0) {
debug('Now using TALK_PLUGINS_JSON environment variable for plugins');
plugins = require(envPlugins);
pluginsPath = envPlugins;
} else if (fs.existsSync(customPlugins)) {
debug(`Now using ${customPlugins} for plugins`);
plugins = JSON.parse(fs.readFileSync(customPlugins, 'utf8'));
pluginsPath = customPlugins;
} else {
debug(`Now using ${defaultPlugins} for plugins`);
plugins = JSON.parse(fs.readFileSync(defaultPlugins, 'utf8'));
pluginsPath = defaultPlugins;
}
plugins = require(pluginsPath);
} catch (err) {
if (err.code === 'ENOENT') {
console.error('plugins.json and plugins.default.json not found, plugins will not be active');
@@ -78,7 +81,12 @@ function isInternal(name) {
*/
function pluginPath(name) {
if (isInternal(name)) {
return path.join(__dirname, 'plugins', name);
try {
return resolve.sync(name, {moduleDirectory: 'plugins', basedir: process.cwd()});
} catch (e) {
console.warn(e);
return undefined;
}
}
try {
@@ -88,15 +96,8 @@ function pluginPath(name) {
}
}
/**
* Itterates over the plugins and gets the plugin path's, version, and name.
*
* @param {Array<Object|String>} plugins
* @returns {Array<Object>}
*/
function itteratePlugins(plugins) {
return plugins.map((p) => {
let plugin = {};
class Plugin {
constructor(entry) {
// This checks to see if the structure for this entry is an object:
//
@@ -106,23 +107,47 @@ function itteratePlugins(plugins) {
//
// "people"
//
if (typeof p === 'object') {
plugin.name = Object.keys(p).find((name) => name !== null);
plugin.version = p[plugin.name];
} else if (typeof p === 'string') {
plugin.name = p;
plugin.version = `file:./plugins/${plugin.name}`;
if (typeof entry === 'object') {
this.name = Object.keys(entry).find((name) => name !== null);
this.version = entry[this.name];
} else if (typeof entry === 'string') {
this.name = entry;
this.version = `file:./plugins/${this.name}`;
} else {
throw new Error(`plugins.json is malformed, refer to PLUGINS.md for formatting, expected a string or an object for a plugin entry, found a ${typeof p}`);
throw new Error(`plugins.json is malformed, refer to PLUGINS.md for formatting, expected a string or an object for a plugin entry, found a ${typeof entry}`);
}
// Get the path for the plugin.
plugin.path = pluginPath(plugin.name);
this.path = pluginPath(this.name);
}
return plugin;
});
require() {
if (typeof this.path === 'undefined') {
throw new Error(`plugin '${this.name}' is not local and is not resolvable, plugin reconsiliation may be required`);
}
try {
this.module = require(this.path);
} catch (e) {
if (e && e.code && e.code === 'MODULE_NOT_FOUND' && isInternal(this.name)) {
console.error(new Error(`plugin '${this.name}' could not be loaded due to missing dependencies, plugin reconsiliation may be required`));
throw e;
}
console.error(new Error(`plugin '${this.name}' could not be required from '${this.path}': ${e.message}`));
throw e;
}
}
}
/**
* Itterates over the plugins and gets the plugin path's, version, and name.
*
* @param {Array<Object|String>} plugins
* @returns {Array<Object>}
*/
const itteratePlugins = (plugins) => plugins.map((p) => new Plugin(p));
// Add each plugin folder to the allowed import path so that they can import our
// internal dependancies.
Object.keys(plugins).forEach((type) => itteratePlugins(plugins[type]).forEach((plugin) => {
@@ -139,22 +164,20 @@ Object.keys(plugins).forEach((type) => itteratePlugins(plugins[type]).forEach((p
*/
class PluginSection {
constructor(plugins) {
this.plugins = itteratePlugins(plugins).map((plugin) => {
if (typeof plugin.path === 'undefined') {
throw new Error(`plugin '${plugin.name}' is not local and is not resolvable, plugin reconsiliation may be required`);
}
this.required = false;
this.plugins = itteratePlugins(plugins);
}
try {
plugin.module = require(plugin.path);
} catch (e) {
if (e && e.code && e.code === 'MODULE_NOT_FOUND' && isInternal(plugin.name)) {
console.error(new Error(`plugin '${plugin.name}' could not be loaded due to missing dependencies, plugin reconsiliation may be required`));
throw e;
}
require() {
if (this.required) {
return;
}
this.required = true;
this.plugins.forEach((plugin) => {
console.error(new Error(`plugin '${plugin.name}' could not be required from '${plugin.path}': ${e.message}`));
throw e;
}
// Load the plugin.
plugin.require();
if (isInternal(plugin.name)) {
debug(`loading internal plugin '${plugin.name}' from '${plugin.path}'`);
@@ -171,6 +194,10 @@ class PluginSection {
* available.
*/
hook(hook) {
// Load the plugin source if we haven't already.
this.require();
return this.plugins
.filter(({module}) => hook in module)
.filter((plugin) => {
@@ -226,6 +253,7 @@ class PluginManager {
module.exports = {
plugins,
pluginsPath,
PluginManager,
isInternal,
pluginPath,
@@ -1,8 +1,7 @@
import React from 'react';
import styles from './styles.css';
import {Dialog, Alert, TextField} from 'coral-ui';
import {Dialog, Alert, TextField, Button} from 'plugin-api/beta/client/components/ui';
import {FakeComment} from './FakeComment';
import Button from 'coral-ui/components/Button';
import t from 'coral-framework/services/i18n';
const CreateUsernameDialog = ({
@@ -33,7 +32,7 @@ const CreateUsernameDialog = ({
className={styles.fakeComment}
username={formData.username}
created_at={Date.now()}
body={t('createdisplay.fake_comment_body')}
comment={{body:t('createdisplay.fake_comment_body')}}
/>
<p className={styles.ifyoudont}>
{t('createdisplay.if_you_dont_change_your_name')}
@@ -1,17 +1,17 @@
import React from 'react';
import t from 'coral-framework/services/i18n';
import {ReplyButton} from 'coral-plugin-replies';
import PubDate from 'coral-plugin-pubdate/PubDate';
import Slot from 'coral-framework/components/Slot';
import AuthorName from 'coral-plugin-author-name/AuthorName';
import Content from 'coral-plugin-commentcontent/CommentContent';
import styles from 'coral-embed-stream/src/components/Comment.css';
import t from 'coral-framework/services/i18n';
export const FakeComment = ({username, created_at, body}) => (
export const FakeComment = ({username, created_at, comment}) => (
<div className={`comment ${styles.Comment}`} style={{marginLeft: 0 * 30}}>
<hr aria-hidden={true} />
<AuthorName author={{name: username}} />
<PubDate created_at={created_at} />
<Content body={body} />
<Slot comment={comment} />
<div className="commentActionsLeft comment__action-container">
<div className={`${'coral-plugin-likes'}-container`}>
<button className={'coral-plugin-likes-button'}>
@@ -30,7 +30,6 @@ export const FakeComment = ({username, created_at, body}) => (
onClick={() => {}}
parentCommentId={'commentID'}
currentUserId={{}}
banned={false}
/>
</div>
<div className="commentActionsRight comment__action-container">
@@ -1,6 +1,6 @@
import React from 'react';
import styles from './styles.css';
import Button from 'coral-ui/components/Button';
import {Button} from 'plugin-api/beta/client/components/ui';
import t from 'coral-framework/services/i18n';
class ForgotContent extends React.Component {
@@ -1,5 +1,5 @@
import React from 'react';
import {Dialog} from 'coral-ui';
import {Dialog} from 'plugin-api/beta/client/components/ui';
import styles from './styles.css';
import SignInContent from './SignInContent';
@@ -1,5 +1,5 @@
import React from 'react';
import {Button} from 'coral-ui';
import {Button} from 'plugin-api/beta/client/components/ui';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {showSignInDialog} from 'coral-framework/actions/auth';
@@ -1,5 +1,5 @@
import React, {PropTypes} from 'react';
import {Button, TextField, Spinner, Success, Alert} from 'coral-ui';
import {Button, TextField, Spinner, Success, Alert} from 'plugin-api/beta/client/components/ui';
import styles from './styles.css';
import t from 'coral-framework/services/i18n';
@@ -1,6 +1,6 @@
import styles from './styles.css';
import React from 'react';
import {Button, TextField, Spinner, Success, Alert} from 'coral-ui';
import {Button, TextField, Spinner, Success, Alert} from 'plugin-api/beta/client/components/ui';
import t from 'coral-framework/services/i18n';
class SignUpContent extends React.Component {
@@ -0,0 +1,14 @@
{
"presets": [
"es2015"
],
"plugins": [
"add-module-exports",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
"transform-object-rest-spread",
"transform-async-to-generator",
"transform-react-jsx"
]
}
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
@@ -0,0 +1,24 @@
import React from 'react';
import Linkify from 'react-linkify';
const name = 'coral-plugin-comment-content';
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`}>
<Linkify properties={{target: '_blank'}}>
{line}
</Linkify>
<br className={`${name}-linebreak`}/>
</span>
);
})
}
</div>;
};
export default CommentContent;
@@ -0,0 +1,7 @@
import CommentContent from './components/CommentContent';
export default {
slots: {
commentContent: [CommentContent]
}
};
@@ -0,0 +1 @@
module.exports = {};
@@ -0,0 +1,14 @@
{
"name": "coral-plugin-comment-content",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"react-linkify": "^0.2.1"
}
}

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