mirror of
https://github.com/wassname/talk.git
synced 2026-07-23 13:10:20 +08:00
Merge
This commit is contained in:
@@ -1,3 +1,18 @@
|
||||
# excluded because we'll likely need to rebuild this.
|
||||
node_modules
|
||||
|
||||
# scripts are used during development and testing, not
|
||||
# production.
|
||||
scripts
|
||||
|
||||
# documentation should not be visable in production.
|
||||
docs
|
||||
|
||||
# static assets are rebuild in the docker container.
|
||||
dist
|
||||
|
||||
# tests are not run in the docker container.
|
||||
test
|
||||
|
||||
# we won't use the .git folder in production.
|
||||
.git
|
||||
|
||||
+1
-4
@@ -2,14 +2,11 @@ root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[{package.json,.*rc,*.yml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
dist
|
||||
client/lib
|
||||
**/*.html
|
||||
plugins/
|
||||
plugins/**/node_modules
|
||||
+4
-11
@@ -1,20 +1,13 @@
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
dist
|
||||
!dist/coral-admin
|
||||
dist/coral-admin/bundle.js
|
||||
test/e2e/reports
|
||||
.DS_Store
|
||||
*.iml
|
||||
*.swp
|
||||
dump.rdb
|
||||
.env
|
||||
*.cfg
|
||||
.idea/
|
||||
coverage/
|
||||
.tags
|
||||
.tags1
|
||||
|
||||
# remove plugin folders
|
||||
# plugins
|
||||
# plugins.json
|
||||
*.swp
|
||||
plugins.json
|
||||
plugins
|
||||
!plugins/coral-plugin-respect
|
||||
|
||||
+10
-5
@@ -5,16 +5,21 @@ RUN mkdir -p /usr/src/app
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Setup the environment
|
||||
ENV NODE_ENV production
|
||||
ENV PATH /usr/src/app/bin:$PATH
|
||||
ENV TALK_PORT 5000
|
||||
EXPOSE 5000
|
||||
|
||||
# Install app dependencies
|
||||
COPY package.json yarn.lock /usr/src/app/
|
||||
RUN yarn install --production
|
||||
|
||||
# Bundle app source
|
||||
COPY . /usr/src/app
|
||||
|
||||
# Install app dependencies and build static assets.
|
||||
RUN yarn install --frozen-lockfile && \
|
||||
cli plugins reconcile && \
|
||||
yarn build && \
|
||||
yarn install --production && \
|
||||
yarn cache clean
|
||||
|
||||
# Ensure the runtime of the container is in production mode.
|
||||
ENV NODE_ENV production
|
||||
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM coralproject/talk:latest
|
||||
|
||||
# Bundle app source
|
||||
ONBUILD COPY . /usr/src/app
|
||||
|
||||
# At this stage, we need to install the development dependancies again because
|
||||
# we need to have webpack available. We then build the new dependancies and
|
||||
# clear out the development dependancies again. After this we of course need to
|
||||
# clear out the yarn cache, this saves quite a lot of size.
|
||||
ONBUILD RUN NODE_ENV=development yarn install --frozen-lockfile && \
|
||||
NODE_ENV=production cli plugins reconcile && \
|
||||
NODE_ENV=production yarn build && \
|
||||
NODE_ENV=production yarn install --production && \
|
||||
yarn cache clean
|
||||
+119
-1
@@ -9,7 +9,7 @@ All plugins must be registered in the root file `plugins.json`.
|
||||
|
||||
The format for this file is thus:
|
||||
|
||||
```js
|
||||
```json
|
||||
{
|
||||
"server": [
|
||||
"people"
|
||||
@@ -22,6 +22,42 @@ name in the `plugins/` folder. For example, the above `plugins.json` would
|
||||
require a plugin from `plugins/people`, which must provide a `index.js` file
|
||||
that returns an object that matches the Plugin Specification.
|
||||
|
||||
If the package is external (available on NPM) you can specify the string for
|
||||
the version by using an object instead, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": [
|
||||
{"people": "^1.2.0"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
External plugins can be resolved by running:
|
||||
|
||||
```bash
|
||||
./bin/cli plugins reconcile
|
||||
```
|
||||
|
||||
This will also traverse into local plugin folders and install their
|
||||
dependancies. _Note that if the plugin is already installed and available in the
|
||||
node_modules folder, it will not be fetched again unless there is a version
|
||||
mismatch._
|
||||
|
||||
## Plugin Dependencies
|
||||
|
||||
From your plugins you may import any component of server code relative to the
|
||||
project root. An example could be:
|
||||
|
||||
```js
|
||||
const cache = require('services/cache');
|
||||
```
|
||||
|
||||
You may also include additional external depenancies in your local packages by
|
||||
specifying a `package.json` at your plugin root which will result in a
|
||||
`node_modules` folder being generated at the plugin root with your specific
|
||||
dependencies.
|
||||
|
||||
## Server Plugins
|
||||
|
||||
### Specification
|
||||
@@ -170,6 +206,88 @@ If your post function accepts four parameters, then it can modify the field
|
||||
result. It is *required* that the function resolves a promise (or returns) with
|
||||
the modified value or simply the original if you didn't modify it.
|
||||
|
||||
#### Field: `router`
|
||||
|
||||
```js
|
||||
(router) => {
|
||||
router.get('/api/v1/people', (req, res) => {
|
||||
res.json({people: [{name: 'Bob'}]});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The Router hook allows you to create a function that accepts the base express
|
||||
router where you can mount any amount of middleware/routes to do any form of
|
||||
action needed by external applications. We also provide the authorization
|
||||
middleware via:
|
||||
|
||||
```js
|
||||
const authorization = require('middleware/authorization');
|
||||
|
||||
module.exports = {
|
||||
router(router) {
|
||||
router.get('/api/v1/people', authorization.needed('ADMIN'), (req, res) => {
|
||||
res.json({people: [{name: 'SECRET PEOPLE'}]});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Field: `passport`
|
||||
|
||||
```js
|
||||
const FacebookStrategy = require('passport-facebook').Strategy;
|
||||
const UsersService = require('services/users');
|
||||
const {ValidateUserLogin, HandleAuthPopupCallback} = require('services/passport');
|
||||
|
||||
module.exports = {
|
||||
passport(passport) {
|
||||
passport.use(new FacebookStrategy({
|
||||
clientID: process.env.TALK_FACEBOOK_APP_ID,
|
||||
clientSecret: process.env.TALK_FACEBOOK_APP_SECRET,
|
||||
callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`,
|
||||
passReqToCallback: true,
|
||||
profileFields: ['id', 'displayName', 'picture.type(large)']
|
||||
}, async (req, accessToken, refreshToken, profile, done) => {
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await UsersService.findOrCreateExternalUser(profile);
|
||||
} catch (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
return ValidateUserLogin(profile, user, done);
|
||||
}));
|
||||
},
|
||||
router(router) {
|
||||
|
||||
// Note that we have to import the passport instance here, it is
|
||||
// instantiated after all the strategies have been mounted.
|
||||
const {passport} = require('services/passport');
|
||||
|
||||
/**
|
||||
* Facebook auth endpoint, this will redirect the user immediatly to facebook
|
||||
* for authorization.
|
||||
*/
|
||||
router.get('/facebook', passport.authenticate('facebook', {display: 'popup', authType: 'rerequest', scope: ['public_profile']}));
|
||||
|
||||
/**
|
||||
* Facebook callback endpoint, this will send the user a html page designed to
|
||||
* send back the user credentials upon sucesfull login.
|
||||
*/
|
||||
router.get('/facebook/callback', (req, res, next) => {
|
||||
|
||||
// Perform the facebook login flow and pass the data back through the opener.
|
||||
passport.authenticate('facebook', HandleAuthPopupCallback(req, res, next))(req, res, next);
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
This is a full example including the routes hook to add the required components
|
||||
to the application router to support a different auth strategy.
|
||||
|
||||
### Full Example
|
||||
|
||||
Contents of `plugins.json`:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Talk [](https://circleci.com/gh/coralproject/talk)
|
||||
|
||||
A commenting platform from [The Coral Project](https://coralproject.net). Talk enters a closed beta in March 2017, but you can download the code for our alpha here. [Read more about Talk here.](https://coralproject.net/products/talk.html)
|
||||
Talk is currently in Beta! [Read more about Talk here.](https://coralproject.net/products/talk.html)
|
||||
|
||||
Third party licenses are available via the `/client/3rdpartylicenses.txt`
|
||||
endpoint when the server is running with built assets.
|
||||
|
||||
@@ -3,7 +3,7 @@ const bodyParser = require('body-parser');
|
||||
const morgan = require('morgan');
|
||||
const path = require('path');
|
||||
const helmet = require('helmet');
|
||||
const passport = require('./services/passport');
|
||||
const {passport} = require('./services/passport');
|
||||
const session = require('express-session');
|
||||
const enabled = require('debug').enabled;
|
||||
const RedisStore = require('connect-redis')(session);
|
||||
|
||||
@@ -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('plugins', 'provides utilities for interacting with the plugin system')
|
||||
.parse(process.argv);
|
||||
|
||||
/**
|
||||
|
||||
Executable
+297
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
// Interface heavily inspired by the yarn package manager:
|
||||
// https://yarnpkg.com/
|
||||
|
||||
const program = require('./commander');
|
||||
|
||||
// Make things colorful!
|
||||
require('colors');
|
||||
const emoji = require('node-emoji');
|
||||
|
||||
const dir = process.cwd();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const spawn = require('cross-spawn');
|
||||
const semver = require('semver');
|
||||
const resolve = require('resolve');
|
||||
const {plugins, itteratePlugins, isInternal} = require('../plugins');
|
||||
|
||||
function existsInNodeModules(name) {
|
||||
try {
|
||||
resolve.sync(name, {basedir: dir});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function versionMatch(name, version) {
|
||||
try {
|
||||
let matched = false;
|
||||
|
||||
resolve.sync(name, {
|
||||
basedir: dir,
|
||||
packageFilter: (pkg) => {
|
||||
if (pkg && pkg.version && semver.satisfies(pkg.version, version)) {
|
||||
matched = true;
|
||||
}
|
||||
|
||||
return pkg;
|
||||
}
|
||||
});
|
||||
|
||||
return matched;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const EXTERNAL = /^\w[a-z\-0-9\.]+$/; // Match "react", "path", "fs", "lodash.random", etc.
|
||||
|
||||
function reconcilePackages({quiet = false, upgradeRemote = false}) {
|
||||
const fetchable = [];
|
||||
const local = [];
|
||||
const upgradable = [];
|
||||
|
||||
if (!quiet) {
|
||||
console.log();
|
||||
console.log(' +local (l) packages in your project');
|
||||
console.log(' +external (e) packages are external');
|
||||
console.log(' +outofdate (oe) packages are external but are out of date');
|
||||
console.log(' +missing (m) packages are not found');
|
||||
console.log();
|
||||
}
|
||||
|
||||
for (let i in plugins) {
|
||||
let section = itteratePlugins(plugins[i]);
|
||||
|
||||
for (let j in section) {
|
||||
let {name, version} = section[j];
|
||||
|
||||
let namespaced = name.charAt(0) === '@';
|
||||
let dep = name.split('/')
|
||||
.slice(0, namespaced ? 2 : 1)
|
||||
.join('/');
|
||||
|
||||
// Ignore relative modules, which aren't installed by NPM
|
||||
if (!dep.match(EXTERNAL) && !namespaced) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternal(dep)) {
|
||||
if (!quiet) {
|
||||
console.log(` l ${name}`);
|
||||
}
|
||||
|
||||
local.push({name, version});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!existsInNodeModules(dep)) {
|
||||
if (!quiet) {
|
||||
console.log(` m ${name}`);
|
||||
}
|
||||
fetchable.push({name, version});
|
||||
} else if (!versionMatch(dep, version)) {
|
||||
|
||||
// A plugin was found, yet the current version does not match the
|
||||
// current version installed. We should warn if upgradeRemote is
|
||||
// not enabled that it is currently not supported.
|
||||
if (!upgradeRemote) {
|
||||
if (!quiet) {
|
||||
console.warn(` oe ${name} (package upgrade may be required)`.bgRed);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` oe ${name} (package upgrade may be required)`);
|
||||
|
||||
upgradable.push({name, version});
|
||||
} else {
|
||||
if (!quiet) {
|
||||
console.log(` e ${name}`);
|
||||
}
|
||||
|
||||
if (upgradeRemote) {
|
||||
upgradable.push({name, version});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!quiet) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
return {local, fetchable, upgradable};
|
||||
}
|
||||
|
||||
async function reconcileRemotePlugins({skipLocal, dryRun, upgradeRemote}) {
|
||||
console.log(`\n[${skipLocal ? '1/2' : '2/3'}] ${emoji.get('mag')} Reconciling plugins...`.yellow);
|
||||
const {fetchable, upgradable} = reconcilePackages({upgradeRemote});
|
||||
|
||||
console.log(`[${skipLocal ? '2/2' : '3/3'}] ${emoji.get('truck')} Fetching plugins...\n`.yellow);
|
||||
|
||||
if (fetchable.length > 0) {
|
||||
|
||||
console.log(`$ yarn add ${fetchable.map(({name, version}) => `${name}@${version}`.cyan)}`);
|
||||
|
||||
if (!dryRun) {
|
||||
|
||||
let args = [
|
||||
'add',
|
||||
...fetchable.map(({name, version}) => `${name}@${version}`)
|
||||
];
|
||||
|
||||
let output = spawn.sync('yarn', args, {
|
||||
stdio: ['ignore', 'pipe', 'inherit']
|
||||
});
|
||||
|
||||
if (output.status) {
|
||||
throw new Error('Could not install external plugins, errors occured during install');
|
||||
}
|
||||
|
||||
console.log(output.stdout.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (upgradable.length > 0) {
|
||||
console.log(`$ yarn upgrade ${upgradable.map(({name, version}) => `${name}@${version}`.cyan)}`);
|
||||
|
||||
if (!dryRun) {
|
||||
|
||||
let args = [
|
||||
'upgrade',
|
||||
...upgradable.map(({name, version}) => `${name}@${version}`)
|
||||
];
|
||||
|
||||
let output = spawn.sync('yarn', args, {
|
||||
stdio: ['ignore', 'pipe', 'inherit']
|
||||
});
|
||||
|
||||
if (output.status) {
|
||||
throw new Error('Could not install external plugins, errors occured during install');
|
||||
}
|
||||
|
||||
console.log(output.stdout.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return {upgradable, fetchable};
|
||||
}
|
||||
|
||||
async function reconcileLocalPlugins({skipRemote, dryRun}) {
|
||||
console.log(`\n[${skipRemote ? '1/1' : '1/3'}] ${emoji.get('pick')} Installing local plugin dependencies...\n`.yellow);
|
||||
const {local} = reconcilePackages({quiet: true});
|
||||
|
||||
for (let i in local) {
|
||||
let {name} = local[i];
|
||||
|
||||
if (!fs.existsSync(path.join(dir, 'plugins', name, 'package.json'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let wd = path.join(dir, 'plugins', name);
|
||||
|
||||
console.log(`$ cd ${wd.cyan} && yarn`);
|
||||
|
||||
if (!dryRun) {
|
||||
let args = [];
|
||||
|
||||
let output = spawn.sync('yarn', args, {
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
cwd: wd
|
||||
});
|
||||
|
||||
if (output.status) {
|
||||
throw new Error('Could not install local plugin dependencies, errors occured during install');
|
||||
}
|
||||
|
||||
console.log(output.stdout.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This traverses the local plugins and installs any dependencies listed there,
|
||||
// this only is really needed for plugins that are installed via docker because
|
||||
// core plugins will have their dependencies already included in core.
|
||||
async function reconcilePluginDeps({skipLocal, skipRemote, dryRun, upgradeRemote}) {
|
||||
let startTime = new Date();
|
||||
|
||||
// We don't need to do anything if we skip everything....
|
||||
if (skipLocal && skipRemote) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Traverse local plugins and install dependencies if enabled.
|
||||
if (!skipLocal) {
|
||||
await reconcileLocalPlugins({skipRemote, dryRun});
|
||||
}
|
||||
|
||||
// Locate any external plugins and install them.
|
||||
if (!skipRemote) {
|
||||
let results = [];
|
||||
try {
|
||||
results = await reconcileRemotePlugins({skipLocal, skipRemote, dryRun, upgradeRemote});
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
let status;
|
||||
if (dryRun) {
|
||||
status = '[dry-run] success'.green;
|
||||
} else {
|
||||
status = 'success'.green;
|
||||
}
|
||||
|
||||
let message;
|
||||
if (results.upgradable.length === 0 && results.fetchable.length === 0) {
|
||||
message = 'Already up-to-date.';
|
||||
} else if (results.upgradable.length === 0) {
|
||||
message = `Fetched ${results.fetchable.length} new plugins.`;
|
||||
} else if (results.fetchable.length === 0) {
|
||||
message = `Upgraded ${results.upgradable.length} new plugins.`;
|
||||
} else {
|
||||
message = `Fetched ${results.fetchable.length} new plugins, upgraded ${results.upgradable.length} plugins.`;
|
||||
}
|
||||
|
||||
console.log(`\n${status} ${message}`);
|
||||
}
|
||||
|
||||
let endTime = new Date();
|
||||
|
||||
let totalTime = ((endTime.getTime() - startTime.getTime()) / 1000).toFixed(2);
|
||||
console.log(`✨ Done in ${totalTime}s.`);
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.description('')
|
||||
.action(reconcilePackages);
|
||||
|
||||
program
|
||||
.command('reconcile')
|
||||
.description('reconciles local plugin dependencies and downloads external plugins')
|
||||
.option('-u, --upgrade-remote', 'upgrades remote dependencies')
|
||||
.option('-d, --dry-run', 'does not actually change anything on the filesystem acts only as a simulation')
|
||||
.option('--skip-local', 'skips the local dependancy reconciliation')
|
||||
.option('--skip-remote', 'skips the remote plugin reconciliation')
|
||||
.action(reconcilePluginDeps);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
// If there is no command listed, output help.
|
||||
if (!process.argv.slice(2).length) {
|
||||
program.outputHelp();
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
machine:
|
||||
node:
|
||||
version: 7.6
|
||||
version: 7
|
||||
services:
|
||||
- docker
|
||||
- redis
|
||||
@@ -20,6 +20,7 @@ dependencies:
|
||||
# - sudo service mongod restart
|
||||
|
||||
# Install node dependencies.
|
||||
- yarn --version
|
||||
- yarn
|
||||
cache_directories:
|
||||
- ~/.cache/yarn
|
||||
@@ -47,9 +48,9 @@ deployment:
|
||||
release:
|
||||
tag: /v[0-9]+(\.[0-9]+)*/
|
||||
commands:
|
||||
- bash ./scripts/deploy.sh
|
||||
- bash ./scripts/docker.sh deploy
|
||||
|
||||
latest:
|
||||
branch: master
|
||||
commands:
|
||||
- bash ./scripts/deploy.sh
|
||||
- bash ./scripts/docker.sh deploy
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
|
||||
# Coral Admin
|
||||
|
||||
This app handles moderation for Talk (and maybe more later on)
|
||||
|
||||
## Installation
|
||||
|
||||
$ yarn install
|
||||
$ cp config.sample.json config.json
|
||||
|
||||
Then change `config.json` to adjust it to your project
|
||||
|
||||
## Building for production
|
||||
|
||||
$ yarn build
|
||||
|
||||
The public folder has everything you need for deployment. You can just copy that folder to your favorite static web server
|
||||
|
||||
## Development
|
||||
|
||||
$ yarn start
|
||||
|
||||
A development server will be running at http://localhost:4132
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
.Comment {
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pendingComment {
|
||||
|
||||
@@ -148,8 +148,7 @@ class Comment extends React.Component {
|
||||
id={`c_${comment.id}`}
|
||||
style={{marginLeft: depth * 30}}>
|
||||
<hr aria-hidden={true} />
|
||||
|
||||
<div>
|
||||
<div className={highlighted === comment.id ? 'highlighted-comment' : ''}>
|
||||
<AuthorName
|
||||
author={comment.user}/>
|
||||
{ isStaff(comment.tags)
|
||||
@@ -192,7 +191,6 @@ class Comment extends React.Component {
|
||||
removeBest={removeBestTag} />
|
||||
</IfUserCanModifyBest>
|
||||
</ActionButton>
|
||||
<Slot fill="Comment.Detail" commentId={comment.id} />
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<ActionButton>
|
||||
@@ -246,8 +244,7 @@ class Comment extends React.Component {
|
||||
showSignInDialog={showSignInDialog}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
/>;
|
||||
comment={reply} />;
|
||||
})
|
||||
}
|
||||
{
|
||||
|
||||
@@ -6,16 +6,17 @@ import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
import {TabBar, Tab, TabContent, Spinner} from 'coral-ui';
|
||||
import {TabBar, Tab, TabContent, Spinner, Button} from 'coral-ui';
|
||||
|
||||
const {logout, showSignInDialog, requestConfirmEmail} = authActions;
|
||||
const {addNotification, clearNotification} = notificationActions;
|
||||
const {fetchAssetSuccess} = assetActions;
|
||||
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments';
|
||||
|
||||
import {queryStream} from 'coral-framework/graphql/queries';
|
||||
import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag} from 'coral-framework/graphql/mutations';
|
||||
import {editName} from 'coral-framework/actions/user';
|
||||
import {updateCountCache} from 'coral-framework/actions/asset';
|
||||
import {updateCountCache, viewAllComments} from 'coral-framework/actions/asset';
|
||||
import {notificationActions, authActions, assetActions, pym} from 'coral-framework';
|
||||
|
||||
import Stream from './Stream';
|
||||
@@ -31,7 +32,7 @@ import ChangeUsernameContainer from '../../coral-sign-in/containers/ChangeUserna
|
||||
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
|
||||
import RestrictedContent from 'coral-framework/components/RestrictedContent';
|
||||
import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer';
|
||||
import Comment from './Comment';
|
||||
import HighlightedComment from './Comment';
|
||||
import LoadMore from './LoadMore';
|
||||
import NewCount from './NewCount';
|
||||
|
||||
@@ -69,10 +70,30 @@ class Embed extends Component {
|
||||
pym.sendMessage('childReady');
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
clearInterval(this.state.countPoll);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const {loadAsset} = this.props;
|
||||
if(!isEqual(nextProps.data.asset, this.props.data.asset)) {
|
||||
loadAsset(nextProps.data.asset);
|
||||
|
||||
const {getCounts, updateCountCache} = this.props;
|
||||
const {asset} = nextProps.data;
|
||||
|
||||
updateCountCache(asset.id, asset.commentCount);
|
||||
|
||||
this.setState({
|
||||
countPoll: setInterval(() => {
|
||||
const {asset} = this.props.data;
|
||||
getCounts({
|
||||
asset_id: asset.id,
|
||||
limit: asset.comments.length,
|
||||
sort: 'REVERSE_CHRONOLOGICAL'
|
||||
});
|
||||
}, NEW_COMMENT_COUNT_POLL_INTERVAL)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +101,7 @@ class Embed extends Component {
|
||||
if(!isEqual(prevProps.data.comment, this.props.data.comment)) {
|
||||
|
||||
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
|
||||
setTimeout(()=>pym.scrollParentToChildEl(`c_${this.props.data.comment.id}`), 0);
|
||||
setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +119,8 @@ class Embed extends Component {
|
||||
const {closedAt, countCache = {}} = this.props.asset;
|
||||
const {loading, asset, refetch, comment} = this.props.data;
|
||||
const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth;
|
||||
|
||||
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
|
||||
const highlightedComment = comment && comment.parent ? comment.parent : comment;
|
||||
|
||||
const openStream = closedAt === null;
|
||||
@@ -125,6 +148,16 @@ class Embed extends Component {
|
||||
<Tab>{lang.t('MY_COMMENTS')}</Tab>
|
||||
<Tab restricted={!isAdmin}>Configure Stream</Tab>
|
||||
</TabBar>
|
||||
{
|
||||
highlightedComment &&
|
||||
<Button
|
||||
cStyle='darkGrey'
|
||||
style={{float: 'right'}}
|
||||
onClick={() => {
|
||||
this.props.viewAllComments();
|
||||
this.props.data.refetch();
|
||||
}}>{lang.t('showAllComments')}</Button>
|
||||
}
|
||||
{loggedIn && <UserBox user={user} logout={() => this.props.logout().then(refetch)} changeTab={this.changeTab}/>}
|
||||
<TabContent show={activeTab === 0}>
|
||||
{
|
||||
@@ -171,9 +204,11 @@ class Embed extends Component {
|
||||
offset={signInOffset}/>}
|
||||
{loggedIn && user && <ChangeUsernameContainer loggedIn={loggedIn} offset={signInOffset} user={user} />}
|
||||
{loggedIn && <ModerationLink assetId={asset.id} isAdmin={isAdmin} />}
|
||||
|
||||
{/* the highlightedComment is isolated after the user followed a permalink */}
|
||||
{
|
||||
highlightedComment &&
|
||||
<Comment
|
||||
highlightedComment
|
||||
? <HighlightedComment
|
||||
refetch={refetch}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.state.activeReplyBox}
|
||||
@@ -230,6 +265,43 @@ class Embed extends Component {
|
||||
comments={asset.comments}
|
||||
moreComments={countCache[asset.id] > asset.comments.length}
|
||||
loadMore={this.props.loadMore}/>
|
||||
comment={highlightedComment} />
|
||||
: <div>
|
||||
<NewCount
|
||||
commentCount={asset.commentCount}
|
||||
countCache={countCache[asset.id]}
|
||||
loadMore={this.props.loadMore}
|
||||
firstCommentDate={firstCommentDate}
|
||||
assetId={asset.id}
|
||||
updateCountCache={this.props.updateCountCache}
|
||||
/>
|
||||
<div className="embed__stream">
|
||||
<Stream
|
||||
open={openStream}
|
||||
addNotification={this.props.addNotification}
|
||||
postItem={this.props.postItem}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.state.activeReplyBox}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
postLike={this.props.postLike}
|
||||
postFlag={this.props.postFlag}
|
||||
postDontAgree={this.props.postDontAgree}
|
||||
addCommentTag={this.props.addCommentTag}
|
||||
removeCommentTag={this.props.removeCommentTag}
|
||||
loadMore={this.props.loadMore}
|
||||
deleteAction={this.props.deleteAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
comments={asset.comments} />
|
||||
</div>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
assetId={asset.id}
|
||||
comments={asset.comments}
|
||||
moreComments={countCache[asset.id] > asset.comments.length}
|
||||
loadMore={this.props.loadMore} />
|
||||
</div>
|
||||
}
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 1}>
|
||||
<ProfileContainer
|
||||
@@ -266,6 +338,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
editName: (username) => dispatch(editName(username)),
|
||||
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
|
||||
updateCountCache: (id, count) => dispatch(updateCountCache(id, count)),
|
||||
viewAllComments: () => dispatch(viewAllComments()),
|
||||
logout: () => dispatch(logout()),
|
||||
dispatch: d => dispatch(d)
|
||||
});
|
||||
@@ -279,5 +352,5 @@ export default compose(
|
||||
addCommentTag,
|
||||
removeCommentTag,
|
||||
deleteAction,
|
||||
queryStream,
|
||||
queryStream
|
||||
)(Embed);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {ADDTL_COMMENTS_ON_LOAD_MORE} from 'coral-framework/constants/comments';
|
||||
import {Button} from 'coral-ui';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const loadMoreComments = (assetId, comments, loadMore, parentId) => {
|
||||
const loadMoreComments = (assetId, comments, loadMore, parentId, replyCount) => {
|
||||
|
||||
let cursor = null;
|
||||
if (comments.length) {
|
||||
@@ -15,7 +15,7 @@ const loadMoreComments = (assetId, comments, loadMore, parentId) => {
|
||||
}
|
||||
|
||||
loadMore({
|
||||
limit: ADDTL_COMMENTS_ON_LOAD_MORE,
|
||||
limit: parentId ? replyCount : ADDTL_COMMENTS_ON_LOAD_MORE,
|
||||
cursor,
|
||||
asset_id: assetId,
|
||||
parent_id: parentId,
|
||||
@@ -48,7 +48,7 @@ class LoadMore extends React.Component {
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.initialState = false;
|
||||
loadMoreComments(assetId, comments, loadMore, parentId);
|
||||
loadMoreComments(assetId, comments, loadMore, parentId, replyCount);
|
||||
}}>
|
||||
{topLevel ? lang.t('viewMoreComments') : this.replyCountFormat(replyCount)}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import Comment from './Comment';
|
||||
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments';
|
||||
|
||||
class Stream extends React.Component {
|
||||
|
||||
@@ -27,26 +26,6 @@ class Stream extends React.Component {
|
||||
this.state = {activeReplyBox: '', countPoll: null};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {asset, getCounts, updateCountCache} = this.props;
|
||||
|
||||
updateCountCache(asset.id, asset.commentCount);
|
||||
|
||||
// Note: Apollo's built-in polling doesn't work with fetchMore queries, so a
|
||||
// setInterval is being used instead.
|
||||
this.setState({
|
||||
countPoll: setInterval(() => getCounts({
|
||||
asset_id: asset.id,
|
||||
limit: asset.comments.length,
|
||||
sort: 'REVERSE_CHRONOLOGICAL'
|
||||
}), NEW_COMMENT_COUNT_POLL_INTERVAL),
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.state.countPoll);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
comments,
|
||||
|
||||
@@ -16,6 +16,7 @@ body {
|
||||
font-size: 14px;
|
||||
margin: 0px;
|
||||
padding: 0px 0px 50px 0px;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.expandForSignin {
|
||||
@@ -306,10 +307,7 @@ button.comment__action-button[disabled],
|
||||
display: none;
|
||||
background-color: white;
|
||||
border: 1px solid black;
|
||||
width: calc(100% - 15px);
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
right: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,15 @@ function configurePymParent(pymParent, asset_url) {
|
||||
snackbar.style.opacity = 0;
|
||||
});
|
||||
|
||||
// remove the permalink comment id from the hash
|
||||
pymParent.onMessage('coral-view-all-comments', function () {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
location.origin + location.pathname + location.search
|
||||
);
|
||||
});
|
||||
|
||||
pymParent.onMessage('coral-alert', function (message) {
|
||||
const [type, text] = message.split('|');
|
||||
snackbar.style.transform = 'translate(-50%, 20px)';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as actions from '../constants/asset';
|
||||
import coralApi from '../helpers/response';
|
||||
import {addNotification} from '../actions/notification';
|
||||
import {pym} from 'coral-framework';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from './../translations';
|
||||
@@ -49,3 +50,37 @@ export const updateOpenStatus = status => dispatch => {
|
||||
dispatch(updateOpenStream({closedAt: new Date().getTime()}));
|
||||
}
|
||||
};
|
||||
|
||||
function removeParam(key, sourceURL) {
|
||||
let rtn = sourceURL.split('?')[0];
|
||||
let param;
|
||||
let params_arr = [];
|
||||
let queryString = (sourceURL.indexOf('?') !== -1) ? sourceURL.split('?')[1] : '';
|
||||
if (queryString !== '') {
|
||||
params_arr = queryString.split('&');
|
||||
for (let i = params_arr.length - 1; i >= 0; i -= 1) {
|
||||
param = params_arr[i].split('=')[0];
|
||||
if (param === key) {
|
||||
params_arr.splice(i, 1);
|
||||
}
|
||||
}
|
||||
rtn = `${rtn}?${params_arr.join('&')}`;
|
||||
}
|
||||
return rtn;
|
||||
}
|
||||
|
||||
export const viewAllComments = () => {
|
||||
|
||||
// remove the comment_id url param
|
||||
const modifiedUrl = removeParam('comment_id', location.href);
|
||||
try {
|
||||
|
||||
// "window" here refers to the embedded iframe
|
||||
window.history.replaceState({}, document.title, modifiedUrl);
|
||||
|
||||
// also change the parent url
|
||||
pym.sendMessage('coral-view-all-comments');
|
||||
} catch (e) { /* not sure if we're worried about old browsers */ }
|
||||
|
||||
return {type: actions.VIEW_ALL_COMMENTS};
|
||||
};
|
||||
|
||||
@@ -9,3 +9,5 @@ export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE';
|
||||
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
|
||||
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
|
||||
export const UPDATE_COUNT_CACHE = 'UPDATE_COUNT_CACHE';
|
||||
|
||||
export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS';
|
||||
|
||||
@@ -5,6 +5,7 @@ import GET_COUNTS from './getCounts.graphql';
|
||||
import MY_COMMENT_HISTORY from './myCommentHistory.graphql';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import isNil from 'lodash/isNil';
|
||||
|
||||
function getQueryVariable(variable) {
|
||||
let query = window.location.search.substring(1);
|
||||
@@ -20,6 +21,7 @@ function getQueryVariable(variable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// get the counts of the top-level comments
|
||||
export const getCounts = (data) => ({asset_id, limit, sort}) => {
|
||||
return data.fetchMore({
|
||||
query: GET_COUNTS,
|
||||
@@ -41,23 +43,48 @@ export const getCounts = (data) => ({asset_id, limit, sort}) => {
|
||||
});
|
||||
};
|
||||
|
||||
// handle paginated requests for more Comments pertaining to the Asset
|
||||
export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, sort}, newComments) => {
|
||||
return data.fetchMore({
|
||||
query: LOAD_MORE,
|
||||
variables: {
|
||||
limit,
|
||||
cursor,
|
||||
parent_id,
|
||||
asset_id,
|
||||
sort
|
||||
limit, // how many comments are we returning
|
||||
cursor, // the date of the first/last comment depending on the sort order
|
||||
parent_id, // if null, we're loading more top-level comments, if not, we're loading more replies to a comment
|
||||
asset_id, // the id of the asset we're currently on
|
||||
sort // CHRONOLOGICAL or REVERSE_CHRONOLOGICAL
|
||||
},
|
||||
updateQuery: (oldData, {fetchMoreResult:{data:{new_top_level_comments}}}) => {
|
||||
|
||||
let updatedAsset;
|
||||
|
||||
if (parent_id) {
|
||||
if (!isNil(oldData.comment)) { // loaded replies on a highlighted (permalinked) comment
|
||||
|
||||
let comment = {};
|
||||
if (oldData.comment && oldData.comment.parent) {
|
||||
|
||||
// put comments (replies) onto the oldData.comment.parent object
|
||||
// the initial comment permalinked was a reply
|
||||
const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.parent.replies], 'id');
|
||||
comment.parent = {...oldData.comment.parent, replies: sortBy(uniqReplies, 'created_at')};
|
||||
} else if (oldData.comment) {
|
||||
|
||||
// put the comments (replies) directly onto oldData.comment
|
||||
// the initial comment permalinked was a top-level comment
|
||||
const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.replies], 'id');
|
||||
comment.replies = sortBy(uniqReplies, 'created_at');
|
||||
}
|
||||
|
||||
updatedAsset = {
|
||||
...oldData,
|
||||
comment: {
|
||||
...oldData.comment,
|
||||
...comment
|
||||
}
|
||||
};
|
||||
|
||||
} else if (parent_id) { // If loading more replies
|
||||
|
||||
// If loading more replies
|
||||
updatedAsset = {
|
||||
...oldData,
|
||||
asset: {
|
||||
@@ -76,9 +103,8 @@ export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, s
|
||||
})
|
||||
}
|
||||
};
|
||||
} else {
|
||||
} else { // If loading more top-level comments
|
||||
|
||||
// If loading more top-level comments
|
||||
updatedAsset = {
|
||||
...oldData,
|
||||
asset: {
|
||||
@@ -94,8 +120,11 @@ export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, s
|
||||
});
|
||||
};
|
||||
|
||||
// load the comment stream.
|
||||
export const queryStream = graphql(STREAM_QUERY, {
|
||||
options: () => {
|
||||
|
||||
// where the query string is from the embeded iframe url
|
||||
let comment_id = getQueryVariable('comment_id');
|
||||
let has_comment = comment_id != null;
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
#import "../fragments/commentView.graphql"
|
||||
|
||||
query AssetQuery($asset_id: ID, $asset_url: String!, $comment_id: ID!, $has_comment: Boolean!) {
|
||||
# the comment here is for loading one comment and it's children, probably after following a permalink
|
||||
# $has_comment is derived from the comment_id query param in the iframe url,
|
||||
# which is in turn pulled from the host page url
|
||||
comment(id: $comment_id) @include(if: $has_comment) {
|
||||
...commentView
|
||||
replyCount
|
||||
replies {
|
||||
...commentView
|
||||
}
|
||||
parent {
|
||||
...commentView
|
||||
replyCount
|
||||
replies {
|
||||
...commentView
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"error": "Usernames can contain letters, numbers and _ only"
|
||||
},
|
||||
"viewMoreComments": "view more comments",
|
||||
"showAllComments": "Show all comments",
|
||||
"viewReply": "view reply",
|
||||
"viewAllRepliesInitial": "view all {0} replies",
|
||||
"viewAllReplies": "view {0} replies",
|
||||
@@ -56,6 +57,7 @@
|
||||
"newCount": "Ver {0} {1} más",
|
||||
"comment": "commentario",
|
||||
"comments": "commentarios",
|
||||
"showAllComments": "Mostrar todos los comentarios",
|
||||
"error": {
|
||||
"emailNotVerified": "E-mail {0} no verificado.",
|
||||
"email": "No es un e-mail válido",
|
||||
|
||||
@@ -23,6 +23,10 @@ class PermalinkButton extends React.Component {
|
||||
}
|
||||
|
||||
toggle () {
|
||||
|
||||
// I wish I could position this with a stylesheet, but top-level comments with
|
||||
// nested replies throws everything off, as well as very long comments
|
||||
this.popover.style.top = `${this.linkButton.offsetTop - 80}px`;
|
||||
this.setState({popoverOpen: !this.state.popoverOpen});
|
||||
}
|
||||
|
||||
@@ -48,11 +52,16 @@ class PermalinkButton extends React.Component {
|
||||
const {copySuccessful, copyFailure} = this.state;
|
||||
return (
|
||||
<div className={`${name}-container`}>
|
||||
<button onClick={this.toggle} className={`${name}-button`}>
|
||||
<button
|
||||
ref={ref => this.linkButton = ref}
|
||||
onClick={this.toggle}
|
||||
className={`${name}-button`}>
|
||||
{lang.t('permalink.permalink')}
|
||||
<i className={`${name}-icon material-icons`} aria-hidden={true}>link</i>
|
||||
</button>
|
||||
<div className={`${name}-popover ${styles.container} ${this.state.popoverOpen ? 'active' : ''}`}>
|
||||
<div
|
||||
ref={ref => this.popover = ref}
|
||||
className={`${name}-popover ${styles.container} ${this.state.popoverOpen ? 'active' : ''}`}>
|
||||
<input
|
||||
className={`${name}-copy-field`}
|
||||
type='text'
|
||||
|
||||
@@ -4,22 +4,15 @@
|
||||
box-sizing: border-box;
|
||||
/* box-shadow: 3px 3px 5px 0 rgba(0, 0, 0, 0.3); */
|
||||
border: solid 1px rgba(153, 153, 153, 0.33);
|
||||
width: auto;
|
||||
margin: 0 auto;
|
||||
left: 0;
|
||||
margin: 0 5px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
margin-top: -13px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
border: 10px solid transparent;
|
||||
border-top-color: white;
|
||||
position: absolute;
|
||||
right: 8.69em;
|
||||
right: 7em;
|
||||
bottom: -20px;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -29,7 +22,7 @@
|
||||
border: 10px solid transparent;
|
||||
border-top-color: rgba(153, 153, 153, 0.33);
|
||||
position: absolute;
|
||||
right: 8.69em;
|
||||
right: 7em;
|
||||
bottom: -21px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
const loaders = require('./loaders');
|
||||
const mutators = require('./mutators');
|
||||
|
||||
const plugins = require('../plugins');
|
||||
const plugins = require('../services/plugins');
|
||||
const debug = require('debug')('talk:graph:context');
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ const Metrics = require('./metrics');
|
||||
const Settings = require('./settings');
|
||||
const Users = require('./users');
|
||||
|
||||
const plugins = require('../../plugins');
|
||||
const plugins = require('../../services/plugins');
|
||||
|
||||
let loaders = [
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const Comment = require('./comment');
|
||||
const Action = require('./action');
|
||||
const User = require('./user');
|
||||
|
||||
const plugins = require('../../plugins');
|
||||
const plugins = require('../../services/plugins');
|
||||
|
||||
let mutators = [
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const UserError = require('./user_error');
|
||||
const User = require('./user');
|
||||
const ValidationUserError = require('./validation_user_error');
|
||||
|
||||
const plugins = require('../../plugins');
|
||||
const plugins = require('../../services/plugins');
|
||||
|
||||
// Provide the core resolvers.
|
||||
let resolvers = {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ const {makeExecutableSchema} = require('graphql-tools');
|
||||
const {maskErrors} = require('graphql-errors');
|
||||
const {decorateWithHooks} = require('./hooks');
|
||||
|
||||
const plugins = require('../plugins');
|
||||
const plugins = require('../services/plugins');
|
||||
const resolvers = require('./resolvers');
|
||||
const typeDefs = require('./typeDefs');
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {mergeStrings} = require('gql-merge');
|
||||
const debug = require('debug')('talk:graph:typeDefs');
|
||||
const plugins = require('../plugins');
|
||||
const plugins = require('../services/plugins');
|
||||
|
||||
/**
|
||||
* Plugin support requires us to merge the type definitions from the loaded
|
||||
|
||||
@@ -49,11 +49,14 @@
|
||||
},
|
||||
"homepage": "https://github.com/coralproject/talk#readme",
|
||||
"dependencies": {
|
||||
"app-module-path": "^2.2.0",
|
||||
"bcrypt": "^1.0.2",
|
||||
"body-parser": "^1.17.1",
|
||||
"cli-table": "^0.3.1",
|
||||
"colors": "^1.1.2",
|
||||
"commander": "^2.9.0",
|
||||
"connect-redis": "^3.1.0",
|
||||
"cross-spawn": "^5.1.0",
|
||||
"csurf": "^1.9.0",
|
||||
"dataloader": "^1.3.0",
|
||||
"debug": "^2.6.3",
|
||||
@@ -80,6 +83,7 @@
|
||||
"mongoose": "^4.9.1",
|
||||
"morgan": "^1.8.1",
|
||||
"natural": "^0.4.0",
|
||||
"node-emoji": "^1.5.1",
|
||||
"node-fetch": "^1.6.3",
|
||||
"nodemailer": "^2.6.4",
|
||||
"parse-duration": "^0.1.1",
|
||||
@@ -89,6 +93,8 @@
|
||||
"react-apollo": "^1.0.0",
|
||||
"react-recaptcha": "^2.2.6",
|
||||
"redis": "^2.7.1",
|
||||
"resolve": "^1.3.2",
|
||||
"semver": "^5.3.0",
|
||||
"simplemde": "^1.11.2",
|
||||
"uuid": "^2.0.3"
|
||||
},
|
||||
|
||||
+101
-9
@@ -1,5 +1,10 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const resolve = require('resolve');
|
||||
const debug = require('debug')('talk:plugins');
|
||||
|
||||
// Add support for require rewriting.
|
||||
require('app-module-path').addPath(__dirname);
|
||||
|
||||
let plugins = {};
|
||||
|
||||
@@ -16,17 +21,95 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* isInternal checks to see if a given plugin is internal, and returns true
|
||||
* if it is.
|
||||
*
|
||||
* @param {String} name
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function isInternal(name) {
|
||||
const internalPluginPath = path.join(__dirname, 'plugins', name);
|
||||
|
||||
// Check to see if this plugin exists internally, because if it doesn't, it is
|
||||
// external.
|
||||
return fs.existsSync(internalPluginPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the plugin path for the given plugin name.
|
||||
*
|
||||
* @param {any} name
|
||||
* @returns
|
||||
*/
|
||||
function pluginPath(name) {
|
||||
if (isInternal(name)) {
|
||||
return path.join(__dirname, 'plugins', name);
|
||||
}
|
||||
|
||||
try {
|
||||
return resolve.sync(name, {basedir: process.cwd()});
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function itteratePlugins(plugins) {
|
||||
return plugins.map((p) => {
|
||||
let plugin = {};
|
||||
|
||||
// This checks to see if the structure for this entry is an object:
|
||||
//
|
||||
// {"people": "^1.2.0"}
|
||||
//
|
||||
// otherwise it's checked whether it matches the local version:
|
||||
//
|
||||
// "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}`;
|
||||
} 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}`);
|
||||
}
|
||||
|
||||
// Get the path for the plugin.
|
||||
plugin.path = pluginPath(plugin.name);
|
||||
|
||||
return plugin;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a reference to a section for a section of Plugins.
|
||||
*/
|
||||
class PluginSection {
|
||||
constructor(plugin_names) {
|
||||
this.plugins = plugin_names.map((plugin_name) => {
|
||||
let plugin = require(`./plugins/${plugin_name}`);
|
||||
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`);
|
||||
}
|
||||
|
||||
// Ensure we have a default plugin name, but allow the name to be
|
||||
// overrided by the plugin.
|
||||
plugin.name = plugin.name || plugin_name;
|
||||
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;
|
||||
}
|
||||
|
||||
console.error(new Error(`plugin '${plugin.name}' could not be required from '${plugin.path}': ${e.message}`));
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (isInternal(plugin.name)) {
|
||||
debug(`loading internal plugin '${plugin.name}' from '${plugin.path}'`);
|
||||
} else {
|
||||
debug(`loading external plugin '${plugin.name}' from '${plugin.path}'`);
|
||||
}
|
||||
|
||||
return plugin;
|
||||
});
|
||||
@@ -38,8 +121,11 @@ class PluginSection {
|
||||
*/
|
||||
hook(hook) {
|
||||
return this.plugins
|
||||
.filter((plugin) => hook in plugin)
|
||||
.map((plugin) => ({plugin, [hook]: plugin[hook]}));
|
||||
.filter(({module}) => hook in module)
|
||||
.map((plugin) => ({
|
||||
plugin,
|
||||
[hook]: plugin.module[hook]
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,4 +164,10 @@ class PluginManager {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new PluginManager(plugins);
|
||||
module.exports = {
|
||||
plugins,
|
||||
PluginManager,
|
||||
isInternal,
|
||||
pluginPath,
|
||||
itteratePlugins
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const express = require('express');
|
||||
const passport = require('../../../services/passport');
|
||||
const {passport, HandleAuthCallback, HandleAuthPopupCallback} = require('../../../services/passport');
|
||||
const authorization = require('../../../middleware/authorization');
|
||||
const errors = require('../../../errors');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -34,53 +33,6 @@ router.delete('/', authorization.needed(), (req, res) => {
|
||||
// PASSPORT ROUTES
|
||||
//==============================================================================
|
||||
|
||||
/**
|
||||
* This sends back the user data as JSON.
|
||||
*/
|
||||
const HandleAuthCallback = (req, res, next) => (err, user) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return next(errors.ErrNotAuthorized);
|
||||
}
|
||||
|
||||
// Perform the login of the user!
|
||||
req.logIn(user, (err) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
// We logged in the user! Let's send back the user data and the CSRF token.
|
||||
res.json({user});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the response to the login attempt via a popup callback with some JS.
|
||||
*/
|
||||
|
||||
const HandleAuthPopupCallback = (req, res, next) => (err, user) => {
|
||||
if (err) {
|
||||
return res.render('auth-callback', {err: JSON.stringify(err), data: null});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.render('auth-callback', {err: JSON.stringify(errors.ErrNotAuthorized), data: null});
|
||||
}
|
||||
|
||||
// Perform the login of the user!
|
||||
req.logIn(user, (err) => {
|
||||
if (err) {
|
||||
return res.render('auth-callback', {err: JSON.stringify(err), data: null});
|
||||
}
|
||||
|
||||
// We logged in the user! Let's send back the user data.
|
||||
res.render('auth-callback', {err: null, data: JSON.stringify(user)});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Local auth endpoint, will recieve a email and password
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const plugins = require('../services/plugins');
|
||||
const debug = require('debug')('talk:routes');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -22,4 +24,12 @@ if (process.env.NODE_ENV !== 'production') {
|
||||
});
|
||||
}
|
||||
|
||||
// Inject server route plugins.
|
||||
plugins.get('server', 'router').forEach((plugin) => {
|
||||
debug(`added plugin '${plugin.plugin.name}'`);
|
||||
|
||||
// Pass the root router to the plugin to mount it's routes.
|
||||
plugin.router(router);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Regular → Executable
+24
-11
@@ -2,9 +2,7 @@
|
||||
|
||||
set -e
|
||||
|
||||
docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS
|
||||
|
||||
# Sourced from https://segment.com/blog/ci-at-segment/
|
||||
# Inspired by https://segment.com/blog/ci-at-segment/
|
||||
|
||||
deploy_tag() {
|
||||
# Find our individual versions from the tags
|
||||
@@ -28,6 +26,7 @@ deploy_tag() {
|
||||
do
|
||||
echo "==> tagging $version"
|
||||
docker tag coralproject/talk:latest coralproject/talk:$version
|
||||
docker tag coralproject/talk:latest-onbuild coralproject/talk:$version-onbuild
|
||||
done
|
||||
|
||||
# Push each of the tags to docker hub, including latest
|
||||
@@ -35,21 +34,35 @@ deploy_tag() {
|
||||
do
|
||||
echo "==> pushing $version"
|
||||
docker push coralproject/talk:$version
|
||||
docker push coralproject/talk:$version-onbuild
|
||||
done
|
||||
}
|
||||
|
||||
deploy_latest() {
|
||||
echo "==> pushing latest"
|
||||
docker push coralproject/talk:latest
|
||||
docker push coralproject/talk:latest-onbuild
|
||||
}
|
||||
|
||||
# build the repo
|
||||
docker build -t coralproject/talk .
|
||||
# build the repo, including the onbuild tagged versions.
|
||||
docker build -t coralproject/talk:latest -f Dockerfile .
|
||||
docker build -t coralproject/talk:latest-onbuild -f Dockerfile.onbuild .
|
||||
|
||||
# deploy based on the env
|
||||
if [ -n "$CIRCLE_TAG" ]
|
||||
if [ "$1" = "deploy" ]
|
||||
then
|
||||
deploy_tag
|
||||
else
|
||||
deploy_latest
|
||||
fi
|
||||
|
||||
if [[ -n "$DOCKER_EMAIL" && -n "$DOCKER_USER" && -n "$DOCKER_PASS" ]]
|
||||
then
|
||||
|
||||
# Log the Docker Daemon in
|
||||
docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS
|
||||
fi
|
||||
|
||||
# deploy based on the env
|
||||
if [ -n "$CIRCLE_TAG" ]
|
||||
then
|
||||
deploy_tag
|
||||
else
|
||||
deploy_latest
|
||||
fi
|
||||
fi
|
||||
+63
-1
@@ -7,6 +7,7 @@ const LocalStrategy = require('passport-local').Strategy;
|
||||
const FacebookStrategy = require('passport-facebook').Strategy;
|
||||
const errors = require('../errors');
|
||||
const debug = require('debug')('talk:passport');
|
||||
const plugins = require('./plugins');
|
||||
|
||||
//==============================================================================
|
||||
// SESSION SERIALIZATION
|
||||
@@ -27,6 +28,52 @@ passport.deserializeUser((id, done) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* This sends back the user data as JSON.
|
||||
*/
|
||||
const HandleAuthCallback = (req, res, next) => (err, user) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return next(errors.ErrNotAuthorized);
|
||||
}
|
||||
|
||||
// Perform the login of the user!
|
||||
req.logIn(user, (err) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
// We logged in the user! Let's send back the user data and the CSRF token.
|
||||
res.json({user});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the response to the login attempt via a popup callback with some JS.
|
||||
*/
|
||||
const HandleAuthPopupCallback = (req, res, next) => (err, user) => {
|
||||
if (err) {
|
||||
return res.render('auth-callback', {err: JSON.stringify(err), data: null});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.render('auth-callback', {err: JSON.stringify(errors.ErrNotAuthorized), data: null});
|
||||
}
|
||||
|
||||
// Perform the login of the user!
|
||||
req.logIn(user, (err) => {
|
||||
if (err) {
|
||||
return res.render('auth-callback', {err: JSON.stringify(err), data: null});
|
||||
}
|
||||
|
||||
// We logged in the user! Let's send back the user data.
|
||||
res.render('auth-callback', {err: null, data: JSON.stringify(user)});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that a user is allowed to login.
|
||||
* @param {User} user the user to be validated
|
||||
@@ -329,4 +376,19 @@ if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET &&
|
||||
console.error('Facebook cannot be enabled, missing one of TALK_FACEBOOK_APP_ID, TALK_FACEBOOK_APP_SECRET, TALK_ROOT_URL');
|
||||
}
|
||||
|
||||
module.exports = passport;
|
||||
// Inject server route plugins.
|
||||
plugins.get('server', 'passport').forEach((plugin) => {
|
||||
debug(`added plugin '${plugin.plugin.name}'`);
|
||||
|
||||
// Pass the passport.js instance to the plugin to allow it to inject it's
|
||||
// functionality.
|
||||
plugin.passport(passport);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
passport,
|
||||
ValidateUserLogin,
|
||||
HandleFailedAttempt,
|
||||
HandleAuthCallback,
|
||||
HandleAuthPopupCallback
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
const {plugins, PluginManager} = require('../plugins');
|
||||
|
||||
module.exports = new PluginManager(plugins);
|
||||
@@ -194,6 +194,10 @@ apollo-client@^1.0.0, apollo-client@^1.0.0-rc.9:
|
||||
"@types/graphql" "^0.9.0"
|
||||
"@types/isomorphic-fetch" "0.0.33"
|
||||
|
||||
app-module-path@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/app-module-path/-/app-module-path-2.2.0.tgz#641aa55dfb7d6a6f0a8141c4b9c0aa50b6c24dd5"
|
||||
|
||||
"apparatus@>= 0.0.9":
|
||||
version "0.0.9"
|
||||
resolved "https://registry.yarnpkg.com/apparatus/-/apparatus-0.0.9.tgz#37dcd25834ad0b651076596291db823eeb1908bd"
|
||||
@@ -1715,7 +1719,7 @@ colors@1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b"
|
||||
|
||||
colors@1.1.2, colors@~1.1.2:
|
||||
colors@1.1.2, colors@^1.1.2, colors@~1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63"
|
||||
|
||||
@@ -1963,6 +1967,14 @@ create-hmac@^1.1.0, create-hmac@^1.1.2:
|
||||
create-hash "^1.1.0"
|
||||
inherits "^2.0.1"
|
||||
|
||||
cross-spawn@^5.1.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
|
||||
dependencies:
|
||||
lru-cache "^4.0.1"
|
||||
shebang-command "^1.2.0"
|
||||
which "^1.2.9"
|
||||
|
||||
crypt@~0.0.1:
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
|
||||
@@ -4865,6 +4877,13 @@ lru-cache@2, lru-cache@^2.5.0:
|
||||
version "2.7.3"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952"
|
||||
|
||||
lru-cache@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.0.2.tgz#1d17679c069cda5d040991a09dbc2c0db377e55e"
|
||||
dependencies:
|
||||
pseudomap "^1.0.1"
|
||||
yallist "^2.0.0"
|
||||
|
||||
lru-cache@~2.6.5:
|
||||
version "2.6.5"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.6.5.tgz#e56d6354148ede8d7707b58d143220fd08df0fd5"
|
||||
@@ -5253,6 +5272,12 @@ node-dir@^0.1.10:
|
||||
dependencies:
|
||||
minimatch "^3.0.2"
|
||||
|
||||
node-emoji@^1.5.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.5.1.tgz#fd918e412769bf8c448051238233840b2aff16a1"
|
||||
dependencies:
|
||||
string.prototype.codepointat "^0.2.0"
|
||||
|
||||
node-fetch@^1.0.1, node-fetch@^1.3.3, node-fetch@^1.6.3:
|
||||
version "1.6.3"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.6.3.tgz#dc234edd6489982d58e8f0db4f695029abcd8c04"
|
||||
@@ -6373,6 +6398,10 @@ ps-tree@^1.0.1:
|
||||
dependencies:
|
||||
event-stream "~3.3.0"
|
||||
|
||||
pseudomap@^1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
|
||||
|
||||
public-encrypt@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.0.tgz#39f699f3a46560dd5ebacbca693caf7c65c18cc6"
|
||||
@@ -7014,9 +7043,11 @@ resolve-from@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-2.0.0.tgz#9480ab20e94ffa1d9e80a804c7ea147611966b57"
|
||||
|
||||
resolve@^1.1.6, resolve@^1.1.7:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.2.0.tgz#9589c3f2f6149d1417a40becc1663db6ec6bc26c"
|
||||
resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.3.2.tgz#1f0442c9e0cbb8136e87b9305f932f46c7f28235"
|
||||
dependencies:
|
||||
path-parse "^1.0.5"
|
||||
|
||||
restore-cursor@^1.0.1:
|
||||
version "1.0.1"
|
||||
@@ -7183,6 +7214,16 @@ sha.js@^2.3.6:
|
||||
dependencies:
|
||||
inherits "^2.0.1"
|
||||
|
||||
shebang-command@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
|
||||
dependencies:
|
||||
shebang-regex "^1.0.0"
|
||||
|
||||
shebang-regex@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
|
||||
|
||||
shelljs@0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.6.0.tgz#ce1ed837b4b0e55b5ec3dab84251ab9dbdc0c7ec"
|
||||
@@ -7451,6 +7492,10 @@ string-width@^2.0.0:
|
||||
is-fullwidth-code-point "^2.0.0"
|
||||
strip-ansi "^3.0.0"
|
||||
|
||||
string.prototype.codepointat@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.codepointat/-/string.prototype.codepointat-0.2.0.tgz#6b26e9bd3afcaa7be3b4269b526de1b82000ac78"
|
||||
|
||||
string_decoder@^0.10.25, string_decoder@~0.10.x:
|
||||
version "0.10.31"
|
||||
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
|
||||
@@ -8081,7 +8126,7 @@ which@1.1.1:
|
||||
dependencies:
|
||||
is-absolute "^0.1.7"
|
||||
|
||||
which@^1.1.1:
|
||||
which@^1.1.1, which@^1.2.9:
|
||||
version "1.2.12"
|
||||
resolved "https://registry.yarnpkg.com/which/-/which-1.2.12.tgz#de67b5e450269f194909ef23ece4ebe416fa1192"
|
||||
dependencies:
|
||||
@@ -8183,6 +8228,10 @@ y18n@^3.2.0, y18n@^3.2.1:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"
|
||||
|
||||
yallist@^2.0.0:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
|
||||
|
||||
yargs-parser@^2.4.1:
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-2.4.1.tgz#85568de3cf150ff49fa51825f03a8c880ddcc5c4"
|
||||
|
||||
Reference in New Issue
Block a user