mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb910efbc0 | ||
|
|
8dd8544154 | ||
|
|
b053be8e4d | ||
|
|
035c00ee09 | ||
|
|
69ab46d32a | ||
|
|
c30d05fd6c | ||
|
|
b40d592819 | ||
|
|
25376bd00f | ||
|
|
787ba75ede | ||
|
|
60905bb99d | ||
|
|
fd875031dd | ||
|
|
687b135adf | ||
|
|
82acab2726 | ||
|
|
987d4a5610 | ||
|
|
4536d218d2 | ||
|
|
5717f0bf3d | ||
|
|
d17144d436 | ||
|
|
7d67566f00 | ||
|
|
5633dd27fc | ||
|
|
fd2fcb4ed9 | ||
|
|
b8c5461b90 | ||
|
|
cdfa8379ff | ||
|
|
99745723fb | ||
|
|
b6237892d7 | ||
|
|
7d8f2aa876 | ||
|
|
aed265efcc | ||
|
|
00d1b0e6f5 | ||
|
|
65f7ee2ab3 | ||
|
|
d84ddfe1ec | ||
|
|
efc4db56d0 | ||
|
|
32da779ac1 | ||
|
|
09d74e8fd6 | ||
|
|
9fc139964f | ||
|
|
681e25ebdb | ||
|
|
e2fdba4898 | ||
|
|
ce76335fda | ||
|
|
70177d3a2c | ||
|
|
d628c26693 | ||
|
|
2a283a9a5a | ||
|
|
74ad3c3a35 | ||
|
|
cc4c943a21 | ||
|
|
dfd580bf7c | ||
|
|
f929059daf | ||
|
|
f2f5ddd131 | ||
|
|
1fbeacdc36 | ||
|
|
3c58888129 | ||
|
|
b5065bad3e | ||
|
|
f6012526b3 | ||
|
|
452b2c3cdb | ||
|
|
fc64a98cb7 | ||
|
|
8d24dcd58d | ||
|
|
5d59f2923e | ||
|
|
291d3b583b | ||
|
|
c7ca3c5e4a | ||
|
|
66c9e0e232 | ||
|
|
1d3cc906eb | ||
|
|
3bf35bdc7f | ||
|
|
99174565fa | ||
|
|
8d2c1b7247 | ||
|
|
399f1e7458 | ||
|
|
140f980c14 | ||
|
|
32708e9098 | ||
|
|
d0b3845d0f | ||
|
|
dbb0a68766 | ||
|
|
3545bf01cd | ||
|
|
4e5680577e | ||
|
|
428246cbe2 | ||
|
|
a9a24b96c0 | ||
|
|
1153c52c2c | ||
|
|
995dd9fd60 | ||
|
|
007b9604af | ||
|
|
57747e8f13 | ||
|
|
27445f7b19 | ||
|
|
b06a58ebe1 | ||
|
|
b95e20e493 | ||
|
|
5a89ca51f5 | ||
|
|
80900a98fd | ||
|
|
d55f22b09c | ||
|
|
9b839b76f5 | ||
|
|
8eb8010a33 | ||
|
|
40709c5b79 | ||
|
|
6bc5793e57 | ||
|
|
490a5d3263 | ||
|
|
7e5adcd477 | ||
|
|
283c13fae8 | ||
|
|
8d5085b7a3 | ||
|
|
ebbbe72d15 | ||
|
|
f6798af74d | ||
|
|
1f261f5216 | ||
|
|
480bab3c1a | ||
|
|
3a285d92a5 | ||
|
|
177edf8ed8 | ||
|
|
82a72e506f | ||
|
|
f3dcfd23f3 | ||
|
|
15b5dc2236 | ||
|
|
38077cf3cf | ||
|
|
ef43ba3a35 | ||
|
|
fb39446c6a | ||
|
|
5d28f611b2 | ||
|
|
63a35cbceb | ||
|
|
4cbc8b2226 | ||
|
|
6827ff2f1f | ||
|
|
cf82f5b707 | ||
|
|
1e904584e7 | ||
|
|
0007773b4d | ||
|
|
ec03c6b48c | ||
|
|
8f219c47a7 | ||
|
|
132b0a854d | ||
|
|
17e7b28688 | ||
|
|
b795368674 | ||
|
|
f57fa7cb7b | ||
|
|
7a256bdd2b | ||
|
|
6664f73d2d | ||
|
|
fb0e89485c | ||
|
|
b2f1707f17 | ||
|
|
9db77360b4 | ||
|
|
9dc0693530 | ||
|
|
54eb078a13 | ||
|
|
793f634959 | ||
|
|
c59346eb0a | ||
|
|
d02412a734 | ||
|
|
c3d02a14a0 | ||
|
|
820ffb14c1 | ||
|
|
3eea5e1a8a | ||
|
|
ff6a9cf0d5 | ||
|
|
b60ed166d0 | ||
|
|
0a8bfe8a38 | ||
|
|
e6b2ff19b8 | ||
|
|
e2f6d5989a | ||
|
|
0d957bcfb9 | ||
|
|
899805b956 | ||
|
|
aeaf809a0f | ||
|
|
d815d9dc02 | ||
|
|
f1e4f98055 | ||
|
|
0d47ee895c | ||
|
|
2e1bc8d75a | ||
|
|
b8e530ca1f | ||
|
|
da0cebbda7 | ||
|
|
7c09902294 | ||
|
|
96cd5197e3 | ||
|
|
743a96cd21 | ||
|
|
c4c0ee79cd | ||
|
|
6310528804 | ||
|
|
a90e1e132b | ||
|
|
d262663e3f | ||
|
|
85fae3104e | ||
|
|
607ab6c0d6 | ||
|
|
c72cb2428e | ||
|
|
d7e72bb138 | ||
|
|
cb09c50820 | ||
|
|
28920eb357 | ||
|
|
3fcf21f949 | ||
|
|
058aa3f907 | ||
|
|
c4ffe58f58 | ||
|
|
0a4fd15fa2 | ||
|
|
b52325f8e9 | ||
|
|
91af8c2ece | ||
|
|
962a9bd6c9 | ||
|
|
183dab9826 | ||
|
|
8458a9aecc | ||
|
|
bd72bfc6ec | ||
|
|
1137fca3b4 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Executable
+101
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
const SettingsService = require('../../services/settings');
|
||||
|
||||
const util = require('./util');
|
||||
|
||||
/**
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const Tag = {
|
||||
|
||||
};
|
||||
|
||||
module.exports = Tag;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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..."
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = [
|
||||
'LIKE',
|
||||
'FLAG'
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = [
|
||||
'ACCEPTED',
|
||||
'REJECTED',
|
||||
'PREMOD',
|
||||
'NONE'
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = [
|
||||
'ASSETS',
|
||||
'COMMENTS',
|
||||
'USERS'
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = [
|
||||
'PRE',
|
||||
'POST'
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = [
|
||||
'ADMIN',
|
||||
'MODERATOR',
|
||||
'STAFF'
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = [
|
||||
'ACTIVE',
|
||||
'BANNED',
|
||||
'PENDING',
|
||||
'APPROVED' // Indicates that the users' username has been approved
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 @@
|
||||
export {withReaction} from 'coral-framework/hocs';
|
||||
export {default as withReaction} from './withReaction';
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
export {t} from 'coral-framework/services/i18n';
|
||||
export {can} from 'coral-framework/services/perms';
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
getReactionConfig: require('./getReactionConfig'),
|
||||
};
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user