mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60b4909395 | ||
|
|
0973600e68 | ||
|
|
e3ead31964 | ||
|
|
7858ccc45d | ||
|
|
c1807daeaf | ||
|
|
e244d4a286 | ||
|
|
9de99a9ef4 | ||
|
|
01883851ea | ||
|
|
c8d9447033 | ||
|
|
9f8c8e9c83 | ||
|
|
997dffd326 | ||
|
|
f6d652df0d | ||
|
|
c3ee442775 | ||
|
|
bad869bdfe | ||
|
|
2289c405d1 | ||
|
|
354295c0ae | ||
|
|
737cea6e76 | ||
|
|
867f6966ba | ||
|
|
fbefd7fd20 | ||
|
|
a0a7971866 | ||
|
|
16355169ce | ||
|
|
a7f4dea74a | ||
|
|
8aa0c16769 | ||
|
|
d31044f39e | ||
|
|
a804973990 | ||
|
|
db9c3b45c1 | ||
|
|
ddac5c2c85 | ||
|
|
0312530b86 | ||
|
|
87f8ed401f | ||
|
|
06ebd9ccc5 | ||
|
|
e615624b62 | ||
|
|
9803ccfaf5 | ||
|
|
ab84436a78 | ||
|
|
ccaa744a78 | ||
|
|
32318567c9 | ||
|
|
6cf8c01d61 | ||
|
|
b92afff4e7 | ||
|
|
089ed0f09b | ||
|
|
2258125789 | ||
|
|
8de4b475a1 | ||
|
|
4691ee07de | ||
|
|
f767b6a998 | ||
|
|
9b26b0a434 | ||
|
|
7817804bae | ||
|
|
b2738afdbe | ||
|
|
e0a32e5a70 | ||
|
|
b7a981e84b | ||
|
|
52431a4c4c | ||
|
|
ca0431054b | ||
|
|
3f03994a51 | ||
|
|
198efd02b4 | ||
|
|
aff486e909 | ||
|
|
55e62b2b22 | ||
|
|
37e6c2c45c | ||
|
|
270491dc0f | ||
|
|
04b562543e | ||
|
|
dd76ac1ed4 | ||
|
|
e03e15fb3b | ||
|
|
f13682c125 | ||
|
|
6d4765b45d | ||
|
|
b94824b8ee | ||
|
|
cb1b9a1463 | ||
|
|
35ccc5070d | ||
|
|
efb18cdb32 | ||
|
|
2338ff1694 | ||
|
|
b134d4099c | ||
|
|
1b40521f88 | ||
|
|
88473a1c71 | ||
|
|
97ab338486 | ||
|
|
8d4e45c0ed | ||
|
|
53b481dc68 | ||
|
|
b67ac375ba | ||
|
|
500d5d582e | ||
|
|
5b9fe5688f | ||
|
|
175dd30d27 | ||
|
|
33990053fb | ||
|
|
3f4747c2ec | ||
|
|
db3187cb56 | ||
|
|
c90c177b7c | ||
|
|
933891b2e3 | ||
|
|
43dd72c715 | ||
|
|
a7f0de6e62 | ||
|
|
53742934f9 | ||
|
|
b8aed80f1c | ||
|
|
4e94727749 | ||
|
|
8412fa5613 | ||
|
|
bee1cc124c | ||
|
|
2ac6ad8eb8 | ||
|
|
fa9a4ecc50 | ||
|
|
b00d2f569e | ||
|
|
5e1450ffb0 | ||
|
|
a14e4859a9 | ||
|
|
2745245564 | ||
|
|
3bb1279acf | ||
|
|
fedca113d2 | ||
|
|
59407e99d7 | ||
|
|
c7c33dcf13 | ||
|
|
0e152797ad | ||
|
|
58d9f294b7 | ||
|
|
fddbf2c00f | ||
|
|
10ae39ba09 | ||
|
|
a025f4ca24 | ||
|
|
eb4d85fb86 | ||
|
|
5e6a295f11 | ||
|
|
b864761ef8 | ||
|
|
01f6bfa979 | ||
|
|
a49a53903d | ||
|
|
a3059a9fa3 | ||
|
|
d57d05fda9 | ||
|
|
57e0a160a2 |
@@ -12,4 +12,5 @@ plugins/*
|
||||
!plugins/coral-plugin-love
|
||||
!plugins/coral-plugin-viewing-options
|
||||
!plugins/coral-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
node_modules
|
||||
|
||||
@@ -25,5 +25,6 @@ plugins/*
|
||||
!plugins/coral-plugin-love
|
||||
!plugins/coral-plugin-viewing-options
|
||||
!plugins/coral-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
|
||||
**/node_modules/*
|
||||
|
||||
+1
-525
@@ -1,527 +1,3 @@
|
||||
# Talk Plugins
|
||||
|
||||
Plugins for Talk can take various forms, currently we are only supporting server
|
||||
side plugins.
|
||||
|
||||
## Plugin Registration
|
||||
|
||||
The parsing order for the plugin registration is as follows:
|
||||
|
||||
- `TALK_PLUGINS_JSON` environment variable
|
||||
- `plugins.json` file
|
||||
- `plugins.default.json` file
|
||||
|
||||
If you need to "disable all plugins", you can simply provide `{}` as the
|
||||
contents of `process.env.TALK_PLUGINS_JSON` or the `plugins.json`.
|
||||
|
||||
The format for this is thus:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": [
|
||||
"people"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Where we have a `server` key with an array of plugins that match the folder
|
||||
name in the `plugins/` folder. For example, the above config 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 achieves two things:
|
||||
|
||||
1. It will traverse into local plugin folders and install their dependencies.
|
||||
_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._ This will result in the
|
||||
project `package.json` and `yarn.lock` files to be modified, this is normal as
|
||||
this ensures that repeated deployments (with the same config) will have the
|
||||
same config, these changes should not be committed to source control.
|
||||
2. It will seek out dependencies that are listed in the object notation and try
|
||||
to install them from npm.
|
||||
|
||||
## Plugin Dependencies
|
||||
|
||||
You may also include additional external dependencies 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.
|
||||
|
||||
## Deployment Solutions
|
||||
|
||||
Plugins can be deployed with a production instance of Talk.
|
||||
|
||||
### Source
|
||||
|
||||
Source deployments can just modify the `plugins.json` file and include any
|
||||
local plugins into the `plugins/` directory. After including the config, you
|
||||
need to reconcile the plugins and build the static assets:
|
||||
|
||||
```bash
|
||||
# get plugin dependancies and remote plugins
|
||||
./bin/cli plugins reconcile
|
||||
|
||||
# build staic assets (including enabled client side plugins)
|
||||
yarn build
|
||||
```
|
||||
|
||||
Then the application can be started as is.
|
||||
|
||||
### Docker
|
||||
|
||||
If you deploy using Docker, you can extend from the `*-onbuild` image, an
|
||||
example `Dockerfile` for your project could be:
|
||||
|
||||
```Dockerfile
|
||||
FROM coralproject/talk:latest-onbuild
|
||||
```
|
||||
|
||||
Where the directory for your instance would contain a `plugins.json` file
|
||||
describing the plugin requirements and a `plugins` directory containing any
|
||||
other local plugins that should be included.
|
||||
|
||||
Onbuild triggers will execute when the image is building with your custom
|
||||
configuration and will ensure that the image is ready to use by building all
|
||||
assets inside the image as well.
|
||||
|
||||
## Server Plugins
|
||||
|
||||
### API
|
||||
|
||||
You can access any API available inside the talk directory in a plugin by simply
|
||||
importing the file relative to the talk project root. An example would be if you
|
||||
wanted to import the `MetadataService`, you would simply write:
|
||||
|
||||
```javascript
|
||||
const MetadataService = require('services/metadata');
|
||||
```
|
||||
|
||||
### Specification
|
||||
|
||||
Each plugin should export a single object with all hooks available on it.
|
||||
|
||||
_**Note: You will have access to the whole core and other plugin's typeDefs,
|
||||
context, loaders, mutators, resolvers, hooks. This is intentional, as it
|
||||
encourages composing plugins to merge functionality, like a Slack plugin which
|
||||
provides a Slack notify context function as well as having the loader for
|
||||
comments.**_
|
||||
|
||||
The following are the hooks available:
|
||||
|
||||
#### Field: `typeDefs`
|
||||
|
||||
```graphql
|
||||
enum COLOUR {
|
||||
RED
|
||||
BLUE
|
||||
}
|
||||
|
||||
type Person {
|
||||
name: String!
|
||||
colour: COLOUR!
|
||||
}
|
||||
|
||||
type RootMutation {
|
||||
createPerson(name: String!): Person
|
||||
}
|
||||
|
||||
type RootQuery {
|
||||
people: [Person!]
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
leader: Person
|
||||
}
|
||||
```
|
||||
|
||||
Thanks to [gql-merge](https://www.npmjs.com/package/gql-merge) the contents of
|
||||
`typeDefs` should be a string that will be _merged_ with the existing type
|
||||
definitions. `enum`'s will be appended to, types will be appended, and new types
|
||||
will be added.
|
||||
|
||||
#### Field: `context`
|
||||
|
||||
```js
|
||||
{
|
||||
Slack: (context) => ({
|
||||
notify: (message) => {
|
||||
// return a promise after we're done sending notifications.
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Any property provided here will be added to the context parameter available
|
||||
inside all resolvers, loaders, mutators, and of course, other context based
|
||||
plugins.
|
||||
|
||||
The top level item must accept a context for the request which it should use to
|
||||
configure the context plugin before it would be mounted at `context.plugins`.
|
||||
This plugin above would mount at: `context.plugins.Slack`, or, if you're using
|
||||
[object destructuring](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), `{plugins: {Slack}}`.
|
||||
|
||||
#### Field: `loaders`
|
||||
|
||||
```js
|
||||
(context) => ({
|
||||
People: {
|
||||
load: () => db.people.find({user: context.user})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Loaders should be provided as a function which returns a map which is used in
|
||||
the resolvers function. These must return a promise or a value.
|
||||
|
||||
#### Field: `mutators`
|
||||
|
||||
```js
|
||||
(context) => ({
|
||||
People: {
|
||||
create: (name) => {
|
||||
return db.people.insert({user: context.user, name});
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Mutators should be provided as a function which returns a map which is used in
|
||||
the resolvers function. These must return a promise or a value.
|
||||
|
||||
#### Field: `resolvers`
|
||||
|
||||
```js
|
||||
{
|
||||
Person: {
|
||||
name(obj, args, context) {
|
||||
return obj.name;
|
||||
},
|
||||
colour(obj, args, context) {
|
||||
// Bill likes the colour red, everyone else likes blue.
|
||||
return obj.name === 'bill' ? 'RED' : 'BLUE';
|
||||
}
|
||||
},
|
||||
RootQuery: {
|
||||
people(obj, args, {loaders: {People}}) {
|
||||
return People.load();
|
||||
}
|
||||
},
|
||||
RootMutation: {
|
||||
createPerson(obj, {name}, {mutators: {People}}) {
|
||||
return People.create(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Should return a resolver map as described in the
|
||||
[Apollo Docs](http://dev.apollodata.com/tools/graphql-tools/resolvers.html#Resolver-map).
|
||||
|
||||
This will merge with the existing resolvers in core and from previous plugins.
|
||||
|
||||
#### Field: `hooks`
|
||||
|
||||
```js
|
||||
{
|
||||
RootMutation: {
|
||||
createPerson: {
|
||||
post: async (obj, args, {plugins: {Slack}}, info, person) {
|
||||
if (!person) {
|
||||
return person;
|
||||
}
|
||||
|
||||
await Slack.notify(`A new person just was created with name ${person.name}`);
|
||||
|
||||
return person;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Hooks here are pretty special, for each resolver field, you can specify a
|
||||
pre/post hook that will execute pre and post field resolution.
|
||||
|
||||
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: `setupFunctions`
|
||||
|
||||
```js
|
||||
setupFunctions: {
|
||||
leader: (options, args) => ({
|
||||
leader: {
|
||||
filter: (person) => person.place === 1
|
||||
},
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
Setup functions allow you to create filters that control which pubsub.publish() events
|
||||
send data to the client. If the type in question contains args, clients may subscribe using those arguments to further filter their subscription.
|
||||
|
||||
For more information, see the [Apollo Docs](https://github.com/apollographql/graphql-subscriptions).
|
||||
|
||||
#### Field: `tokenUserNotFound`
|
||||
|
||||
```js
|
||||
tokenUserNotFound: async ({jwt, token}) => {
|
||||
let profile = await someExternalService(token);
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let user = await UserModel.findOneAndUpdate({
|
||||
id: profile.id
|
||||
}, {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
lowercaseUsername: profile.username.toLowerCase(),
|
||||
roles: [],
|
||||
profiles: []
|
||||
}, {
|
||||
setDefaultsOnInsert: true,
|
||||
new: true,
|
||||
upsert: true
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
The `tokenUserNotFound` hook allows auth integrations to hook into the event
|
||||
when a valid token is provided but a user can't be found in the database that
|
||||
matches the provided id.
|
||||
|
||||
The function is async, and should return the user object that was created in the
|
||||
database, or null if the user wasn't found. The `jwt` paramenter of the object
|
||||
is the unpacked token, while `token` is the original jwt token string.
|
||||
|
||||
#### Field: `router`
|
||||
|
||||
```js
|
||||
(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: `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
|
||||
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`:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": [
|
||||
"people"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Located in `plugins/people/index.js`:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
typeDefs: `
|
||||
enum COLOUR {
|
||||
RED
|
||||
BLUE
|
||||
}
|
||||
|
||||
type Person {
|
||||
name: String!
|
||||
colour: COLOUR!
|
||||
}
|
||||
|
||||
type RootMutation {
|
||||
createPerson(name: String!): Person
|
||||
}
|
||||
|
||||
type RootQuery {
|
||||
people: [Person!]
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
leader: Person
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
Slack: () => ({
|
||||
notify: (message) => {
|
||||
// return a promise after we're done sending notifications.
|
||||
}
|
||||
})
|
||||
},
|
||||
loaders: ({user}) => ({
|
||||
People: {
|
||||
load: () => db.people.find({user})
|
||||
}
|
||||
}),
|
||||
mutators: ({user}) => ({
|
||||
People: {
|
||||
create: (name) => {
|
||||
return db.people.insert({user, name});
|
||||
}
|
||||
}
|
||||
}),
|
||||
resolvers: {
|
||||
Person: {
|
||||
name(obj, args, context) {
|
||||
return obj.name;
|
||||
},
|
||||
colour(obj, args, context) {
|
||||
// Bill likes the colour red, everyone else likes blue.
|
||||
return obj.name === 'bill' ? 'RED' : 'BLUE';
|
||||
}
|
||||
},
|
||||
RootQuery: {
|
||||
people(obj, args, {loaders: {People}}) {
|
||||
return People.load();
|
||||
}
|
||||
},
|
||||
RootMutation: {
|
||||
createPerson(obj, {name}, {mutators: {People}}) {
|
||||
return People.create(name);
|
||||
}
|
||||
}
|
||||
},
|
||||
hooks: {
|
||||
RootMutation: {
|
||||
createPerson: {
|
||||
post: async (obj, args, {plugins: {Slack}}, info, person) => {
|
||||
if (!person) {
|
||||
return person;
|
||||
}
|
||||
|
||||
await Slack.notify(`A new person just was created with name ${person.name}`);
|
||||
|
||||
return person;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
setupFunctions: {
|
||||
leader: (options, args) => ({
|
||||
leader: {
|
||||
filter: (person) => person.place === 1
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
Our documentation for Talk has moved! Utilize [this hyperlink](https://coralproject.github.io/talk/plugins.html) via click or tap to navigate your browser to the new location!
|
||||
|
||||
@@ -66,6 +66,28 @@ const validation = (formData, dispatch, next) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm Validation
|
||||
const prefixLength = 'confirm'.length;
|
||||
const confirm = validKeys
|
||||
.filter((name) => {
|
||||
if (!name.startsWith('confirm')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that 'confirmX' equals 'X'.
|
||||
const other = name.substr(prefixLength, 1).toLowerCase() + name.substr(prefixLength + 1);
|
||||
const cond = formData[other] !== formData[name];
|
||||
if (cond) {
|
||||
dispatch(addError(name, errorMsj[name]));
|
||||
}
|
||||
return cond;
|
||||
});
|
||||
|
||||
if (confirm.length) {
|
||||
dispatch(hasError());
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(clearErrors());
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
.root {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
overflow: hidden;
|
||||
padding-left: 5px;
|
||||
padding-top: 5px;
|
||||
|
||||
}
|
||||
|
||||
.rootFocuesd {
|
||||
border-color: rgb(142, 76, 65);
|
||||
}
|
||||
|
||||
.tag {
|
||||
background-color: rgb(255, 220, 214);
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgb(244, 126, 107);
|
||||
color: rgb(244, 126, 107);
|
||||
display: inline-block;
|
||||
font-family: sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 5px;
|
||||
margin-right: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.tagRemove {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
color: rgb(101, 24, 23);
|
||||
|
||||
&::before {
|
||||
content: " ×";
|
||||
}
|
||||
}
|
||||
|
||||
.input > input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #777;
|
||||
font-family: sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 6px;
|
||||
margin-top: 1px;
|
||||
outline: none;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
import styles from './TagsInput.css';
|
||||
import AutosizeInput from 'react-input-autosize';
|
||||
|
||||
const autosizingRenderInput = ({onChange, value, addTag: _, ...other}) =>
|
||||
<AutosizeInput type='text' onChange={onChange} value={value} {...other} />;
|
||||
|
||||
export default (props) => {
|
||||
return (
|
||||
<TagsInput
|
||||
addOnBlur={true}
|
||||
addOnPaste={true}
|
||||
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
|
||||
className={styles.root}
|
||||
focusedClassName={styles.rootFocus}
|
||||
renderInput={autosizingRenderInput}
|
||||
{...props}
|
||||
|
||||
tagProps={{
|
||||
className: styles.tag,
|
||||
classNameRemove: styles.tagRemove,
|
||||
...props.tagProps,
|
||||
}}
|
||||
inputProps={{
|
||||
className: styles.input,
|
||||
...props.inputProps,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import {render} from 'react-dom';
|
||||
import {ApolloProvider} from 'react-apollo';
|
||||
import smoothscroll from 'smoothscroll-polyfill';
|
||||
|
||||
import {getClient} from './services/client';
|
||||
import store from './services/store';
|
||||
@@ -13,6 +14,7 @@ import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/he
|
||||
|
||||
loadPluginsTranslations();
|
||||
injectPluginsReducers();
|
||||
smoothscroll.polyfill();
|
||||
|
||||
render(
|
||||
<ApolloProvider client={getClient()} store={store}>
|
||||
|
||||
@@ -2,17 +2,18 @@ import React from 'react';
|
||||
import styles from './Community.css';
|
||||
|
||||
import ActionButton from './ActionButton';
|
||||
import {username} from 'coral-plugin-flags/helpers/flagReasons';
|
||||
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
|
||||
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const shortReasons = {
|
||||
'This comment is offensive': t('community.offensive'),
|
||||
'This looks like an ad/marketing': t('community.spam_ads'),
|
||||
'This user is impersonating': t('community.impersonating'),
|
||||
'I don\'t like this username': t('community.dont_like_username'),
|
||||
'Other': t('community.other')
|
||||
[username.other]: t('community.other'),
|
||||
[username.spam]: t('community.spam_ads'),
|
||||
[username.offensive]: t('community.offensive'),
|
||||
[username.nolike]: t('community.dont_like_username'),
|
||||
[username.impersonating]: t('community.impersonating'),
|
||||
};
|
||||
|
||||
// Render a single user for the list
|
||||
|
||||
@@ -136,13 +136,6 @@
|
||||
resize: none;
|
||||
}
|
||||
|
||||
#bannedWordlist, #suspectWordlist {
|
||||
display: block;
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.customCSSInput {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import {Card} from 'coral-ui';
|
||||
import styles from './Configure.css';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
import TagsInput from 'coral-admin/src/components/TagsInput';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const Domainlist = ({domains, onChangeDomainlist}) => {
|
||||
@@ -14,8 +14,6 @@ const Domainlist = ({domains, onChangeDomainlist}) => {
|
||||
<TagsInput
|
||||
value={domains}
|
||||
inputProps={{placeholder: 'URL'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
|
||||
onChange={(tags) => onChangeDomainlist('whitelist', tags)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
import TagsInput from 'coral-admin/src/components/TagsInput';
|
||||
import styles from './Configure.css';
|
||||
import {Card} from 'coral-ui';
|
||||
|
||||
@@ -13,8 +13,6 @@ const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
|
||||
<TagsInput
|
||||
value={bannedWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
|
||||
onChange={(tags) => onChangeWordlist('banned', tags)}
|
||||
/>
|
||||
</div>
|
||||
@@ -26,8 +24,6 @@ const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
|
||||
<TagsInput
|
||||
value={suspectWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
|
||||
onChange={(tags) => onChangeWordlist('suspect', tags)} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -48,7 +48,7 @@ const InitialStep = (props) => {
|
||||
label={t('install.create.confirm_password')}
|
||||
onChange={handleUserChange}
|
||||
showErrors={install.showErrors}
|
||||
errorMsg={install.errors.confirm_password}
|
||||
errorMsg={install.errors.confirmPassword}
|
||||
/>
|
||||
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import styles from './style.css';
|
||||
import {Button, Card} from 'coral-ui';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
import TagsInput from 'coral-admin/src/components/TagsInput';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
@@ -16,9 +16,6 @@ const PermittedDomainsStep = (props) => {
|
||||
<TagsInput
|
||||
value={domains}
|
||||
inputProps={{placeholder: 'URL'}}
|
||||
addOnBlur={true}
|
||||
addOnPaste={true}
|
||||
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
|
||||
onChange={(tags) => handleDomainsChange(tags)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -54,7 +54,7 @@ class Comment extends React.Component {
|
||||
// currently the highlighter plugin does not support out of the box.
|
||||
const searchWords = [...suspectWords, ...bannedWords]
|
||||
.filter((w) => {
|
||||
return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body);
|
||||
return new RegExp(`(^|\\s)${w}(\\s|$)`, 'i').test(comment.body);
|
||||
})
|
||||
.concat(linkText);
|
||||
|
||||
@@ -168,7 +168,7 @@ class Comment extends React.Component {
|
||||
{' '}
|
||||
<a
|
||||
className={styles.external}
|
||||
href={`${comment.asset.url}#${comment.id}`}
|
||||
href={`${comment.asset.url}?commentId=${comment.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon name="open_in_new" /> {t('comment.view_context')}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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 PubDate from 'coral-plugin-pubdate/PubDate';
|
||||
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
|
||||
import FlagComment from 'coral-plugin-flags/FlagComment';
|
||||
import {FlagComment} from 'coral-plugin-flags';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
import {TransitionGroup} from 'react-transition-group';
|
||||
import cn from 'classnames';
|
||||
@@ -25,7 +24,7 @@ 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 {getActionSummary, iPerformedThisAction, forEachError} from 'coral-framework/utils';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF');
|
||||
@@ -76,7 +75,6 @@ const ActionButton = ({children}) => {
|
||||
};
|
||||
|
||||
export default class Comment extends React.Component {
|
||||
isLoadingReplies = false;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -91,6 +89,7 @@ export default class Comment extends React.Component {
|
||||
isEditing: false,
|
||||
replyBoxVisible: false,
|
||||
animateEnter: false,
|
||||
loadingState: '',
|
||||
...resetCursors({}, props),
|
||||
};
|
||||
}
|
||||
@@ -170,7 +169,7 @@ export default class Comment extends React.Component {
|
||||
replies: PropTypes.object,
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
username: PropTypes.string.isRequired
|
||||
}).isRequired,
|
||||
editing: PropTypes.shape({
|
||||
edited: PropTypes.bool,
|
||||
@@ -196,8 +195,16 @@ export default class Comment extends React.Component {
|
||||
editComment: React.PropTypes.func,
|
||||
}
|
||||
|
||||
editComment = (...args) => {
|
||||
return this.props.editComment(this.props.comment.id, this.props.asset.id, ...args);
|
||||
}
|
||||
|
||||
onClickEdit (e) {
|
||||
e.preventDefault();
|
||||
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
|
||||
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
|
||||
return;
|
||||
}
|
||||
this.setState({isEditing: true});
|
||||
}
|
||||
|
||||
@@ -213,24 +220,23 @@ export default class Comment extends React.Component {
|
||||
}
|
||||
|
||||
loadNewReplies = () => {
|
||||
if (!this.isLoadingReplies) {
|
||||
this.isLoadingReplies = true;
|
||||
const {replies, replyCount, id} = this.props.comment;
|
||||
if (replyCount > replies.nodes.length) {
|
||||
this.props.loadMore(id)
|
||||
.then(() => {
|
||||
this.setState(resetCursors(this.state, this.props));
|
||||
this.isLoadingReplies = false;
|
||||
})
|
||||
.catch((e) => {
|
||||
this.isLoadingReplies = false;
|
||||
throw e;
|
||||
const {replies, replyCount, id} = this.props.comment;
|
||||
if (replyCount > replies.nodes.length) {
|
||||
this.setState({loadingState: 'loading'});
|
||||
this.props.loadMore(id)
|
||||
.then(() => {
|
||||
this.setState({
|
||||
...resetCursors(this.state, this.props),
|
||||
loadingState: 'success',
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState(resetCursors);
|
||||
this.isLoadingReplies = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({loadingState: 'error'});
|
||||
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState(resetCursors);
|
||||
};
|
||||
|
||||
showReplyBox = () => {
|
||||
@@ -240,7 +246,8 @@ export default class Comment extends React.Component {
|
||||
}
|
||||
if (can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
|
||||
this.props.setActiveReplyBox(this.props.comment.id);
|
||||
return;
|
||||
} else {
|
||||
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -323,6 +330,7 @@ export default class Comment extends React.Component {
|
||||
} = this.props;
|
||||
|
||||
const view = this.getVisibileReplies();
|
||||
const {loadingState} = this.state;
|
||||
|
||||
const hasMoreComments = comment.replies && (comment.replies.hasNextPage || comment.replies.nodes.length > view.length);
|
||||
const replyCount = this.hasIgnoredReplies() ? '' : comment.replyCount;
|
||||
@@ -469,7 +477,7 @@ export default class Comment extends React.Component {
|
||||
{
|
||||
this.state.isEditing
|
||||
? <EditableCommentContent
|
||||
editComment={this.props.editComment.bind(null, comment.id, asset.id)}
|
||||
editComment={this.editComment}
|
||||
addNotification={addNotification}
|
||||
asset={asset}
|
||||
comment={comment}
|
||||
@@ -509,19 +517,18 @@ export default class Comment extends React.Component {
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
/>
|
||||
</ActionButton>}
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<Slot
|
||||
fill="commentActions"
|
||||
wrapperComponent={ActionButton}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
asset={asset}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<ActionButton>
|
||||
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
|
||||
</ActionButton>
|
||||
<ActionButton>
|
||||
<FlagComment
|
||||
flaggedByCurrentUser={!!myFlag}
|
||||
@@ -529,6 +536,7 @@ export default class Comment extends React.Component {
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
addNotification={addNotification}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
@@ -547,8 +555,8 @@ export default class Comment extends React.Component {
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
parentId={parentId || comment.id}
|
||||
addNotification={addNotification}
|
||||
authorId={currentUser.id}
|
||||
postComment={postComment}
|
||||
currentUser={currentUser}
|
||||
assetId={asset.id}
|
||||
/>
|
||||
: null}
|
||||
@@ -587,12 +595,13 @@ export default class Comment extends React.Component {
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<div className="coral-load-more-replies">
|
||||
<div className="talk-load-more-replies">
|
||||
<LoadMore
|
||||
topLevel={false}
|
||||
replyCount={replyCount}
|
||||
moreComments={hasMoreComments}
|
||||
loadMore={this.loadNewReplies}
|
||||
loadingState={loadingState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {CommentForm} from 'coral-plugin-commentbox/CommentForm';
|
||||
import styles from './Comment.css';
|
||||
import {CountdownSeconds} from './CountdownSeconds';
|
||||
import {getEditableUntilDate} from './util';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
import {forEachError} from 'coral-framework/utils';
|
||||
|
||||
import {Icon} from 'coral-ui';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
@@ -45,10 +47,14 @@ export class EditableCommentContent extends React.Component {
|
||||
// called when editing should be stopped
|
||||
stopEditing: React.PropTypes.func,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.editComment = this.editComment.bind(this);
|
||||
this.editWindowExpiryTimeout = null;
|
||||
this.state = {
|
||||
body: props.comment.body,
|
||||
loadingState: '',
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
const editableUntil = getEditableUntilDate(this.props.comment);
|
||||
@@ -65,69 +71,75 @@ export class EditableCommentContent extends React.Component {
|
||||
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
|
||||
}
|
||||
}
|
||||
async editComment(edit) {
|
||||
|
||||
handleBodyChange = (body) => {
|
||||
this.setState({body});
|
||||
}
|
||||
|
||||
handleSubmit = async () => {
|
||||
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
|
||||
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({loadingState: 'loading'});
|
||||
|
||||
const {editComment, addNotification, stopEditing} = this.props;
|
||||
if (typeof editComment !== 'function') {return;}
|
||||
let response;
|
||||
let successfullyEdited = false;
|
||||
try {
|
||||
response = await editComment(edit);
|
||||
const errors = (response && response.data && response.data.editComment)
|
||||
? response.data.editComment.errors
|
||||
: null;
|
||||
if (errors && (errors.length === 1)) {
|
||||
throw errors[0];
|
||||
}
|
||||
successfullyEdited = true;
|
||||
} catch (error) {
|
||||
const errors = error.errors || [error];
|
||||
errors.forEach((e) => {
|
||||
if (e.translation_key) {
|
||||
addNotification('error', t(`error.${e.translation_key}`));
|
||||
} else if (error.networkError) {
|
||||
addNotification('error', t('error.network_error'));
|
||||
} else {
|
||||
addNotification('error', t('edit_comment.unexpected_error'));
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (successfullyEdited) {
|
||||
response = await editComment({body: this.state.body});
|
||||
this.setState({loadingState: 'success'});
|
||||
const status = response.data.editComment.comment.status;
|
||||
notifyForNewCommentStatus(this.props.addNotification, status);
|
||||
}
|
||||
if (successfullyEdited && typeof stopEditing === 'function') {
|
||||
stopEditing();
|
||||
if (typeof stopEditing === 'function') {
|
||||
stopEditing();
|
||||
}
|
||||
} catch (error) {
|
||||
this.setState({loadingState: 'error'});
|
||||
forEachError(error, ({msg}) => addNotification('error', msg));
|
||||
}
|
||||
}
|
||||
|
||||
getEditableUntil = (props = this.props) => {
|
||||
return getEditableUntilDate(props.comment);
|
||||
}
|
||||
|
||||
isEditWindowExpired = (props = this.props) => {
|
||||
return (this.getEditableUntil(props) - new Date()) < 0;
|
||||
}
|
||||
|
||||
isSubmitEnabled = (comment) => {
|
||||
|
||||
// should be disabled if user hasn't actually changed their
|
||||
// original comment
|
||||
return (comment.body !== this.props.comment.body) && !this.isEditWindowExpired();
|
||||
}
|
||||
|
||||
render() {
|
||||
const originalBody = this.props.comment.body;
|
||||
const editableUntil = getEditableUntilDate(this.props.comment);
|
||||
const editWindowExpired = (editableUntil - new Date()) < 0;
|
||||
return (
|
||||
<div className={styles.editCommentForm}>
|
||||
<CommentForm
|
||||
defaultValue={this.props.comment.body}
|
||||
charCountEnable={this.props.asset.settings.charCountEnable}
|
||||
maxCharCount={this.props.maxCharCount}
|
||||
saveCommentEnabled={(comment) => {
|
||||
|
||||
// should be disabled if user hasn't actually changed their
|
||||
// original comment
|
||||
return (comment.body !== originalBody) && !editWindowExpired;
|
||||
}}
|
||||
saveComment={this.editComment}
|
||||
submitEnabled={this.isSubmitEnabled}
|
||||
body={this.state.body}
|
||||
onBodyChange={this.handleBodyChange}
|
||||
onSubmit={this.handleSubmit}
|
||||
bodyLabel={t('edit_comment.body_input_label')}
|
||||
bodyPlaceholder=""
|
||||
submitText={<span>{t('edit_comment.save_button')}</span>}
|
||||
saveButtonCStyle="green"
|
||||
cancelButtonClicked={this.props.stopEditing}
|
||||
buttonClass={styles.button}
|
||||
submitButtonCStyle="green"
|
||||
onCancel={this.props.stopEditing}
|
||||
submitButtonClassName={styles.button}
|
||||
cancelButtonClassName={styles.button}
|
||||
loadingState={this.state.loadingState}
|
||||
buttonContainerStart={
|
||||
<div className={styles.buttonContainerLeft}>
|
||||
<span className={styles.editWindowRemaining}>
|
||||
{
|
||||
editWindowExpired
|
||||
this.isEditWindowExpired()
|
||||
? <span>
|
||||
{t('edit_comment.edit_window_expired')}
|
||||
{
|
||||
@@ -139,7 +151,7 @@ export class EditableCommentContent extends React.Component {
|
||||
: <span>
|
||||
<Icon name="timer"/> {t('edit_comment.edit_window_timer_prefix')}
|
||||
<CountdownSeconds
|
||||
until={editableUntil}
|
||||
until={this.getEditableUntil()}
|
||||
classNameForMsRemaining={(remainingMs) => (remainingMs <= 10 * 1000) ? styles.editWindowAlmostOver : '' }
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -9,7 +9,7 @@ export class IgnoreUserWizard extends React.Component {
|
||||
// comment on which this menu appears
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
username: PropTypes.string.isRequired
|
||||
}).isRequired,
|
||||
cancel: PropTypes.func.isRequired,
|
||||
|
||||
@@ -47,7 +47,7 @@ export class IgnoreUserWizard extends React.Component {
|
||||
const step2Confirmation = (
|
||||
<div>
|
||||
<header>Ignore User</header>
|
||||
<p>Are you sure you want to ignore { user.name }?</p>
|
||||
<p>Are you sure you want to ignore { user.username }?</p>
|
||||
<div className={styles.textAlignRight}>
|
||||
<Button cStyle='cancel' onClick={this.onClickCancel}>Cancel</Button>
|
||||
<Button onClick={onClickIgnoreUser}>Ignore user</Button>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Button} from 'coral-ui';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import cn from 'classnames';
|
||||
|
||||
class LoadMore extends React.Component {
|
||||
|
||||
componentDidMount () {
|
||||
this.initialState = true;
|
||||
}
|
||||
initialState = true;
|
||||
|
||||
replyCountFormat = (count) => {
|
||||
if (!count) {
|
||||
@@ -23,17 +22,22 @@ class LoadMore extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
loadMore = () => {
|
||||
this.initialState = false;
|
||||
this.props.loadMore();
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (['success', 'error'].indexOf(nextProps.loadingState) >= 0) {
|
||||
this.initialState = false;
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const {topLevel, moreComments, replyCount} = this.props;
|
||||
const {topLevel, moreComments, replyCount, loadingState, loadMore} = this.props;
|
||||
const disabled = loadingState === 'loading';
|
||||
return moreComments
|
||||
? <div className='coral-load-more'>
|
||||
? <div className='talk-load-more'>
|
||||
<Button
|
||||
onClick={this.loadMore}>
|
||||
onClick={loadMore}
|
||||
className={cn('talk-load-more-button', {[`talk-load-more-button-${loadingState}`]: loadingState})}
|
||||
disabled={disabled}
|
||||
>
|
||||
{topLevel ? t('framework.view_more_comments') : this.replyCountFormat(replyCount)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -44,7 +48,9 @@ class LoadMore extends React.Component {
|
||||
LoadMore.propTypes = {
|
||||
replyCount: PropTypes.number,
|
||||
topLevel: PropTypes.bool.isRequired,
|
||||
loadMore: PropTypes.func.isRequired
|
||||
loadMore: PropTypes.func.isRequired,
|
||||
moreComments: PropTypes.bool,
|
||||
loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']),
|
||||
};
|
||||
|
||||
export default LoadMore;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import LoadMore from './LoadMore';
|
||||
import {StreamError} from './StreamError';
|
||||
import Comment from '../components/Comment';
|
||||
import SuspendedAccount from './SuspendedAccount';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
@@ -14,6 +15,7 @@ import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import NewCount from './NewCount';
|
||||
import {TransitionGroup} from 'react-transition-group';
|
||||
import {forEachError} from 'coral-framework/utils';
|
||||
|
||||
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
|
||||
|
||||
@@ -53,11 +55,13 @@ function invalidateCursor(invalidated, state, props) {
|
||||
|
||||
class Stream extends React.Component {
|
||||
|
||||
isLoadingMore = false;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = resetCursors(this.state, props);
|
||||
this.state = {
|
||||
...resetCursors(this.state, props),
|
||||
keepCommentBox: false,
|
||||
loadingState: '',
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
@@ -68,6 +72,12 @@ class Stream extends React.Component {
|
||||
this.setState(resetCursors);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep comment box when user was live suspended, banned, ...
|
||||
if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) {
|
||||
this.setState({keepCommentBox: true});
|
||||
}
|
||||
|
||||
if (
|
||||
prevComments && nextComments &&
|
||||
nextComments.nodes.length < prevComments.nodes.length
|
||||
@@ -98,15 +108,15 @@ class Stream extends React.Component {
|
||||
};
|
||||
|
||||
loadMoreComments = () => {
|
||||
if (!this.isLoadingMore) {
|
||||
this.isLoadingMore = true;
|
||||
this.props.loadMoreComments()
|
||||
.then(() => this.isLoadingMore = false)
|
||||
.catch((e) => {
|
||||
this.isLoadingMore = false;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
this.setState({loadingState: 'loading'});
|
||||
this.props.loadMoreComments()
|
||||
.then(() => {
|
||||
this.setState({loadingState: 'success'});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({loadingState: 'error'});
|
||||
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
|
||||
});
|
||||
}
|
||||
|
||||
// getVisibileComments returns a list containing comments
|
||||
@@ -133,6 +143,10 @@ class Stream extends React.Component {
|
||||
return view;
|
||||
}
|
||||
|
||||
userIsDegraged({auth: {user}} = this.props) {
|
||||
return !can(user, 'INTERACT_WITH_COMMUNITY');
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
commentClassNames,
|
||||
@@ -150,6 +164,7 @@ class Stream extends React.Component {
|
||||
pluginProps,
|
||||
editName
|
||||
} = this.props;
|
||||
const {keepCommentBox, loadingState} = this.state;
|
||||
const view = this.getVisibleComments();
|
||||
const open = asset.closedAt === null;
|
||||
|
||||
@@ -171,6 +186,14 @@ class Stream extends React.Component {
|
||||
me.ignoredUsers.find((u) => u.id === comment.user.id)
|
||||
);
|
||||
};
|
||||
|
||||
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
|
||||
|
||||
if (!comment && !comments) {
|
||||
console.error('Talk: No comments came back from the graph given that query. Please, check the query params.');
|
||||
return <StreamError />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="stream">
|
||||
|
||||
@@ -199,10 +222,7 @@ class Stream extends React.Component {
|
||||
editName={editName}
|
||||
currentUsername={user.username}
|
||||
/>}
|
||||
{loggedIn &&
|
||||
!banned &&
|
||||
!temporarilySuspended &&
|
||||
!highlightedComment &&
|
||||
{showCommentBox &&
|
||||
<CommentBox
|
||||
addNotification={this.props.addNotification}
|
||||
postComment={this.props.postComment}
|
||||
@@ -211,7 +231,7 @@ class Stream extends React.Component {
|
||||
assetId={asset.id}
|
||||
premod={asset.settings.moderation}
|
||||
isReply={false}
|
||||
authorId={user.id}
|
||||
currentUser={user}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
/>}
|
||||
@@ -304,6 +324,7 @@ class Stream extends React.Component {
|
||||
topLevel={true}
|
||||
moreComments={asset.comments.hasNextPage}
|
||||
loadMore={this.loadMoreComments}
|
||||
loadingState={loadingState}
|
||||
/>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.streamError {
|
||||
padding: 10px 0;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import styles from './StreamError.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export const StreamError = () => (
|
||||
<div className={styles.streamError}>
|
||||
{t('common.error')}
|
||||
</div>
|
||||
);
|
||||
@@ -12,7 +12,7 @@ export class TopRightMenu extends React.Component {
|
||||
comment: PropTypes.shape({
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
username: PropTypes.string.isRequired
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
ignoreUser: PropTypes.func,
|
||||
|
||||
@@ -34,7 +34,7 @@ export default withFragments({
|
||||
}
|
||||
user {
|
||||
id
|
||||
name: username
|
||||
username
|
||||
}
|
||||
action_summaries {
|
||||
__typename
|
||||
|
||||
@@ -12,6 +12,8 @@ import {getDefinitionName} from 'coral-framework/utils';
|
||||
import {withQuery} from 'coral-framework/hocs';
|
||||
import Embed from '../components/Embed';
|
||||
import Stream from './Stream';
|
||||
import {addNotification} from 'coral-framework/actions/notification';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
import {setActiveTab} from '../actions/embed';
|
||||
import {viewAllComments} from '../actions/stream';
|
||||
@@ -20,12 +22,63 @@ const {logout, checkLogin} = authActions;
|
||||
const {fetchAssetSuccess} = assetActions;
|
||||
|
||||
class EmbedContainer extends React.Component {
|
||||
subscriptions = [];
|
||||
|
||||
subscribeToUpdates(props = this.props) {
|
||||
if (props.auth.loggedIn) {
|
||||
const newSubscriptions = [{
|
||||
document: USER_BANNED_SUBSCRIPTION,
|
||||
updateQuery: () => {
|
||||
addNotification('info', t('your_account_has_been_banned'));
|
||||
},
|
||||
},
|
||||
{
|
||||
document: USER_SUSPENDED_SUBSCRIPTION,
|
||||
updateQuery: () => {
|
||||
addNotification('info', t('your_account_has_been_suspended'));
|
||||
},
|
||||
},
|
||||
{
|
||||
document: USERNAME_REJECTED_SUBSCRIPTION,
|
||||
updateQuery: () => {
|
||||
addNotification('info', t('your_username_has_been_rejected'));
|
||||
},
|
||||
}];
|
||||
|
||||
this.subscriptions = newSubscriptions.map((s) => props.data.subscribeToMore({
|
||||
document: s.document,
|
||||
variables: {
|
||||
user_id: props.auth.user.id,
|
||||
},
|
||||
updateQuery: s.updateQuery,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribe() {
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.subscriptions = [];
|
||||
}
|
||||
|
||||
resubscribe(props) {
|
||||
this.unsubscribe();
|
||||
this.subscribeToUpdates(props);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.subscribeToUpdates();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.props.auth.loggedIn !== nextProps.auth.loggedIn) {
|
||||
|
||||
// Refetch after login/logout.
|
||||
this.props.data.refetch();
|
||||
this.resubscribe(nextProps);
|
||||
}
|
||||
|
||||
const {fetchAssetSuccess} = this.props;
|
||||
@@ -52,6 +105,45 @@ class EmbedContainer extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
const USER_BANNED_SUBSCRIPTION = gql`
|
||||
subscription UserBanned($user_id: ID!) {
|
||||
userBanned(user_id: $user_id){
|
||||
id
|
||||
status
|
||||
canEditName
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const USER_SUSPENDED_SUBSCRIPTION = gql`
|
||||
subscription UserSuspended($user_id: ID!) {
|
||||
userSuspended(user_id: $user_id){
|
||||
id
|
||||
status
|
||||
canEditName
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const USERNAME_REJECTED_SUBSCRIPTION = gql`
|
||||
subscription UsernameRejected($user_id: ID!) {
|
||||
usernameRejected(user_id: $user_id){
|
||||
id
|
||||
status
|
||||
canEditName
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const EMBED_QUERY = gql`
|
||||
query CoralEmbedStream_Embed($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) {
|
||||
asset(id: $assetId, url: $assetUrl) {
|
||||
@@ -95,6 +187,7 @@ const mapDispatchToProps = (dispatch) =>
|
||||
setActiveTab,
|
||||
viewAllComments,
|
||||
fetchAssetSuccess,
|
||||
addNotification,
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
@@ -30,11 +30,8 @@ class StreamContainer extends React.Component {
|
||||
subscriptions = [];
|
||||
|
||||
subscribeToUpdates() {
|
||||
const sub1 = this.props.data.subscribeToMore({
|
||||
const newSubscriptions = [{
|
||||
document: COMMENTS_EDITED_SUBSCRIPTION,
|
||||
variables: {
|
||||
assetId: this.props.root.asset.id,
|
||||
},
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => {
|
||||
|
||||
// Ignore mutations from me.
|
||||
@@ -52,13 +49,9 @@ class StreamContainer extends React.Component {
|
||||
return removeCommentFromEmbedQuery(prev, commentEdited.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const sub2 = this.props.data.subscribeToMore({
|
||||
},
|
||||
{
|
||||
document: COMMENTS_ADDED_SUBSCRIPTION,
|
||||
variables: {
|
||||
assetId: this.props.root.asset.id,
|
||||
},
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => {
|
||||
|
||||
// Ignore mutations from me.
|
||||
@@ -81,9 +74,15 @@ class StreamContainer extends React.Component {
|
||||
|
||||
return insertCommentIntoEmbedQuery(prev, commentAdded);
|
||||
}
|
||||
});
|
||||
}];
|
||||
|
||||
this.subscriptions.push(sub1, sub2);
|
||||
this.subscriptions = newSubscriptions.map((s) => this.props.data.subscribeToMore({
|
||||
document: s.document,
|
||||
variables: {
|
||||
assetId: this.props.root.asset.id,
|
||||
},
|
||||
updateQuery: s.updateQuery,
|
||||
}));
|
||||
}
|
||||
|
||||
unsubscribe() {
|
||||
@@ -173,7 +172,7 @@ const commentFragment = gql`
|
||||
`;
|
||||
|
||||
const COMMENTS_ADDED_SUBSCRIPTION = gql`
|
||||
subscription onCommentAdded($assetId: ID!, $excludeIgnored: Boolean){
|
||||
subscription CommentAdded($assetId: ID!, $excludeIgnored: Boolean){
|
||||
commentAdded(asset_id: $assetId){
|
||||
parent {
|
||||
id
|
||||
@@ -185,7 +184,7 @@ const COMMENTS_ADDED_SUBSCRIPTION = gql`
|
||||
`;
|
||||
|
||||
const COMMENTS_EDITED_SUBSCRIPTION = gql`
|
||||
subscription onCommentEdited($assetId: ID!){
|
||||
subscription CommentEdited($assetId: ID!){
|
||||
commentEdited(asset_id: $assetId){
|
||||
id
|
||||
body
|
||||
|
||||
@@ -197,12 +197,12 @@ hr {
|
||||
}
|
||||
|
||||
/* Comment Box Styles */
|
||||
.coral-plugin-commentbox-container {
|
||||
.talk-plugin-commentbox-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-textarea {
|
||||
.talk-plugin-commentbox-textarea {
|
||||
color: #262626;
|
||||
flex: 1;
|
||||
padding: 1em;
|
||||
@@ -212,13 +212,13 @@ hr {
|
||||
border: 1px solid #9E9E9E;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-button-container {
|
||||
.talk-plugin-commentbox-button-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-button {
|
||||
.talk-plugin-commentbox-button {
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
padding: 5px 10px;
|
||||
@@ -228,18 +228,18 @@ hr {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-username {
|
||||
.talk-plugin-commentbox-username {
|
||||
width: 50%;
|
||||
padding-left: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-char-count {
|
||||
.talk-plugin-commentbox-char-count {
|
||||
color: #ccc;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-char-max {
|
||||
.talk-plugin-commentbox-char-max {
|
||||
color: #d50000;
|
||||
}
|
||||
|
||||
@@ -335,33 +335,6 @@ button.comment__action-button[disabled],
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.coral-plugin-permalinks-container {
|
||||
/*position: relative;*/
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.coral-plugin-permalinks-popover {
|
||||
display: none;
|
||||
background-color: white;
|
||||
border: 1px solid black;
|
||||
position: absolute;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.coral-plugin-permalinks-popover.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.coral-plugin-permalinks-copy-field {
|
||||
display: block;
|
||||
width: calc(100% - 5px);
|
||||
}
|
||||
|
||||
.coral-plugin-permalinks-copied-text {
|
||||
float: right;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
/* Flag Styles */
|
||||
|
||||
.coral-plugin-flags-popup-form {
|
||||
@@ -444,11 +417,11 @@ button.comment__action-button[disabled],
|
||||
|
||||
/* Load More */
|
||||
|
||||
.coral-load-more {
|
||||
.talk-load-more {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.coral-load-more button {
|
||||
.talk-load-more button {
|
||||
text-align: center;
|
||||
color: #FFF;
|
||||
background-color: #2376D8;
|
||||
@@ -460,11 +433,11 @@ button.comment__action-button[disabled],
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.coral-load-more:hover button {
|
||||
.talk-load-more:hover button {
|
||||
background-color: #4399FF;
|
||||
}
|
||||
|
||||
.coral-load-more-replies, .coral-new-comments {
|
||||
.talk-load-more-replies, .coral-new-comments {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -477,7 +450,7 @@ button.comment__action-button[disabled],
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.coral-load-more-replies button.coral-load-more, .coral-new-comments button.coral-load-more{
|
||||
.talk-load-more-replies button.talk-load-more, .coral-new-comments button.talk-load-more{
|
||||
width: initial;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pym from 'pym.js';
|
||||
import URLSearchParams from 'url-search-params';
|
||||
|
||||
import {stringify} from 'querystring';
|
||||
|
||||
@@ -28,6 +29,7 @@ const snackbarStyles = {
|
||||
// This function should return value of window.Coral
|
||||
const Coral = {};
|
||||
const Talk = (Coral.Talk = {});
|
||||
let notificationTimeout = null;
|
||||
|
||||
// build the URL to load in the pym iframe
|
||||
function buildStreamIframeUrl(talkBaseUrl, query) {
|
||||
@@ -110,14 +112,15 @@ function configurePymParent(pymParent, opts) {
|
||||
snackbar.className = `coral-notif-${type}`;
|
||||
snackbar.textContent = text;
|
||||
|
||||
setTimeout(() => {
|
||||
clearTimeout(notificationTimeout);
|
||||
notificationTimeout = setTimeout(() => {
|
||||
snackbar.style.transform = 'translate(-50%, 0)';
|
||||
snackbar.style.opacity = 1;
|
||||
}, 0);
|
||||
|
||||
setTimeout(() => {
|
||||
snackbar.style.opacity = 0;
|
||||
}, 5000);
|
||||
notificationTimeout = setTimeout(() => {
|
||||
snackbar.style.opacity = 0;
|
||||
}, 7000);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Helps child show notifications at the right scrollTop
|
||||
@@ -188,7 +191,10 @@ Talk.render = function(el, opts) {
|
||||
// Compose the query to send down to the Talk API so it knows what to load.
|
||||
let query = {};
|
||||
|
||||
query.comment_id = window.location.hash.slice(1);
|
||||
let urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
query.comment_id = urlParams.get('commentId');
|
||||
|
||||
query.asset_id = opts.asset_id;
|
||||
|
||||
query.asset_url = opts.asset_url;
|
||||
|
||||
@@ -151,6 +151,20 @@ export default function auth (state = initialState, action) {
|
||||
case actions.SET_REDIRECT_URI:
|
||||
return state
|
||||
.set('redirectUri', action.uri);
|
||||
case 'APOLLO_SUBSCRIPTION_RESULT':
|
||||
if (action.operationName === 'UserBanned' && state.getIn(['user', 'id']) === action.variables.user_id) {
|
||||
return state
|
||||
.mergeIn(['user'], action.result.data.userBanned);
|
||||
}
|
||||
if (action.operationName === 'UserSuspended' && state.getIn(['user', 'id']) === action.variables.user_id) {
|
||||
return state
|
||||
.mergeIn(['user'], action.result.data.userSuspended);
|
||||
}
|
||||
if (action.operationName === 'UsernameRejected' && state.getIn(['user', 'id']) === action.variables.user_id) {
|
||||
return state
|
||||
.mergeIn(['user'], action.result.data.usernameRejected);
|
||||
}
|
||||
return state;
|
||||
default :
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export const getTotalActionCount = (type, comment) => {
|
||||
return comment.action_summaries
|
||||
@@ -126,3 +127,20 @@ export function createDefaultResponseFragments(...names) {
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function forEachError(error, callback) {
|
||||
const errors = error.errors || [error];
|
||||
errors.forEach((e) => {
|
||||
console.error(e);
|
||||
|
||||
let msg = '';
|
||||
if (e.translation_key) {
|
||||
msg = t(`error.${e.translation_key}`);
|
||||
} else if (error.networkError) {
|
||||
msg = t('error.network_error');
|
||||
} else {
|
||||
msg = t('error.unexpected');
|
||||
}
|
||||
callback({error: e, msg});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default class AuthorName extends Component {
|
||||
return (
|
||||
<div
|
||||
className={`${packagename}-text`}>
|
||||
{author && author.name}
|
||||
{author && author.username}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
import {forEachError} from 'coral-framework/utils';
|
||||
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import {connect} from 'react-redux';
|
||||
import {CommentForm} from './CommentForm';
|
||||
|
||||
export const name = 'coral-plugin-commentbox';
|
||||
export const name = 'talk-plugin-commentbox';
|
||||
|
||||
// Given a newly posted comment's status, show a notification to the user
|
||||
// if needed
|
||||
@@ -27,36 +29,45 @@ class CommentBox extends React.Component {
|
||||
|
||||
this.state = {
|
||||
username: '',
|
||||
body: '',
|
||||
loadingState: '',
|
||||
|
||||
// incremented on successful post to clear form
|
||||
postedCount: 0,
|
||||
hooks: {
|
||||
preSubmit: [],
|
||||
postSubmit: []
|
||||
}
|
||||
};
|
||||
}
|
||||
postComment = ({body}) => {
|
||||
|
||||
handleSubmit = () => {
|
||||
const {
|
||||
commentPostedHandler,
|
||||
postComment,
|
||||
assetId,
|
||||
parentId,
|
||||
addNotification,
|
||||
currentUser,
|
||||
} = this.props;
|
||||
|
||||
if (!can(currentUser, 'INTERACT_WITH_COMMUNITY')) {
|
||||
addNotification('error', t('error.NOT_AUTHORIZED'));
|
||||
return;
|
||||
}
|
||||
|
||||
let comment = {
|
||||
asset_id: assetId,
|
||||
parent_id: parentId,
|
||||
body,
|
||||
body: this.state.body,
|
||||
...this.props.commentBox
|
||||
};
|
||||
|
||||
// Execute preSubmit Hooks
|
||||
this.state.hooks.preSubmit.forEach((hook) => hook());
|
||||
this.setState({loadingState: 'loading'});
|
||||
|
||||
postComment(comment, 'comments')
|
||||
.then(({data}) => {
|
||||
this.setState({loadingState: 'success'});
|
||||
const postedComment = data.createComment.comment;
|
||||
|
||||
// Execute postSubmit Hooks
|
||||
@@ -67,12 +78,17 @@ class CommentBox extends React.Component {
|
||||
if (commentPostedHandler) {
|
||||
commentPostedHandler();
|
||||
}
|
||||
|
||||
this.setState({body: ''});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
this.setState({loadingState: 'error'});
|
||||
forEachError(err, ({msg}) => addNotification('error', msg));
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({postedCount: this.state.postedCount + 1});
|
||||
handleBodyChange = (body) => {
|
||||
this.setState({body});
|
||||
}
|
||||
|
||||
registerHook = (hookType = '', hook = () => {}) => {
|
||||
@@ -123,21 +139,17 @@ class CommentBox extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
handleChange = (e) => this.setState({body: e.target.value});
|
||||
|
||||
render () {
|
||||
const {styles, isReply, authorId, maxCharCount} = this.props;
|
||||
let {cancelButtonClicked} = this.props;
|
||||
const {isReply, maxCharCount} = this.props;
|
||||
let {onCancel} = this.props;
|
||||
|
||||
if (isReply && typeof cancelButtonClicked !== 'function') {
|
||||
console.warn('the CommentBox component should have a cancelButtonClicked callback defined if it lives in a Reply');
|
||||
cancelButtonClicked = () => {};
|
||||
if (isReply && typeof onCancel !== 'function') {
|
||||
console.warn('the CommentBox component should have a onCancel callback defined if it lives in a Reply');
|
||||
onCancel = () => {};
|
||||
}
|
||||
|
||||
return <div>
|
||||
<CommentForm
|
||||
styles={styles}
|
||||
key={this.state.postedCount}
|
||||
defaultValue={this.props.defaultValue}
|
||||
bodyInputId={isReply ? 'replyText' : 'commentText'}
|
||||
bodyLabel={isReply ? t('comment_box.reply') : t('comment.comment')}
|
||||
@@ -145,7 +157,7 @@ class CommentBox extends React.Component {
|
||||
charCountEnable={this.props.charCountEnable}
|
||||
bodyPlaceholder={t('comment.comment')}
|
||||
bodyInputId={isReply ? 'replyText' : 'commentText'}
|
||||
saveComment={authorId && this.postComment}
|
||||
body={this.state.body}
|
||||
buttonContainerStart={<Slot
|
||||
fill="commentInputDetailArea"
|
||||
registerHook={this.registerHook}
|
||||
@@ -153,7 +165,10 @@ class CommentBox extends React.Component {
|
||||
isReply={isReply}
|
||||
inline
|
||||
/>}
|
||||
cancelButtonClicked={cancelButtonClicked}
|
||||
onBodyChange={this.handleBodyChange}
|
||||
loadingState={this.state.loadingState}
|
||||
onCancel={onCancel}
|
||||
onSubmit={this.handleSubmit}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
@@ -167,12 +182,13 @@ CommentBox.propTypes = {
|
||||
maxCharCount: PropTypes.number,
|
||||
commentPostedHandler: PropTypes.func,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
cancelButtonClicked: PropTypes.func,
|
||||
onCancel: PropTypes.func,
|
||||
assetId: PropTypes.string.isRequired,
|
||||
parentId: PropTypes.string,
|
||||
authorId: PropTypes.string.isRequired,
|
||||
currentUser: PropTypes.object.isRequired,
|
||||
isReply: PropTypes.bool.isRequired,
|
||||
canPost: PropTypes.bool,
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({commentBox}) => ({commentBox});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
import classnames from 'classnames';
|
||||
import cn from 'classnames';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
|
||||
import {name} from './CommentBox';
|
||||
import styles from './styles.css';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
@@ -13,15 +14,8 @@ import t from 'coral-framework/services/i18n';
|
||||
export class CommentForm extends React.Component {
|
||||
static propTypes = {
|
||||
|
||||
// Initial value for underlying comment body textarea
|
||||
defaultValue: PropTypes.string,
|
||||
charCountEnable: PropTypes.bool.isRequired,
|
||||
maxCharCount: PropTypes.number,
|
||||
cancelButtonClicked: PropTypes.func,
|
||||
|
||||
// Save the comment in the form.
|
||||
// Will be passed { body: String }
|
||||
saveComment: PropTypes.func.isRequired,
|
||||
|
||||
// DOM ID for form input that edits comment body
|
||||
bodyInputId: PropTypes.string,
|
||||
@@ -38,53 +32,63 @@ export class CommentForm extends React.Component {
|
||||
// render inside submit button
|
||||
submitText: PropTypes.node,
|
||||
|
||||
styles: PropTypes.shape({
|
||||
textarea: PropTypes.string
|
||||
}),
|
||||
// cStyle for enabled submit <coral-ui/Button>
|
||||
submitButtonCStyle: PropTypes.string,
|
||||
|
||||
// cStyle for enabled save <coral-ui/Button>
|
||||
saveButtonCStyle: PropTypes.string,
|
||||
|
||||
// return whether the save button should be enabled for the provided
|
||||
// return whether the submit button should be enabled for the provided
|
||||
// comment ({ body }) (for reasons other than charCount)
|
||||
saveCommentEnabled: PropTypes.func,
|
||||
submitEnabled: PropTypes.func,
|
||||
|
||||
// className to add to buttons
|
||||
buttonClass: PropTypes.string,
|
||||
submitButtonClassName: PropTypes.string,
|
||||
cancelButtonClassName: PropTypes.string,
|
||||
|
||||
body: PropTypes.string.isRequired,
|
||||
onBodyChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func,
|
||||
state: PropTypes.string,
|
||||
loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']),
|
||||
}
|
||||
static get defaultProps() {
|
||||
return {
|
||||
bodyLabel: t('comment_box.comment'),
|
||||
bodyPlaceholder: t('comment_box.comment'),
|
||||
submitText: t('comment_box.post'),
|
||||
saveButtonCStyle: 'darkGrey',
|
||||
saveCommentEnabled: () => true,
|
||||
submitButtonCStyle: 'darkGrey',
|
||||
submitEnabled: () => true,
|
||||
};
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onBodyChange = this.onBodyChange.bind(this);
|
||||
this.onClickSubmit = this.onClickSubmit.bind(this);
|
||||
this.state = {
|
||||
body: props.defaultValue || ''
|
||||
};
|
||||
}
|
||||
onBodyChange(e) {
|
||||
this.setState({body: e.target.value});
|
||||
}
|
||||
onClickSubmit(e) {
|
||||
e.preventDefault();
|
||||
const {saveComment} = this.props;
|
||||
const {body} = this.state;
|
||||
saveComment({body});
|
||||
}
|
||||
render() {
|
||||
const {maxCharCount, styles, saveCommentEnabled, buttonClass, charCountEnable} = this.props;
|
||||
|
||||
const body = this.state.body;
|
||||
onBodyChange = (e) => {
|
||||
this.props.onBodyChange(e.target.value);
|
||||
}
|
||||
|
||||
onClickSubmit = () => {
|
||||
this.props.onSubmit();
|
||||
}
|
||||
|
||||
getButtonClassName = () => {
|
||||
switch (this.props.loadingState) {
|
||||
case 'loading':
|
||||
return cn(`${name}-button-loading`, styles.buttonLoading);
|
||||
case 'success':
|
||||
return cn(`${name}-button-success`, styles.buttonSuccess);
|
||||
case 'error':
|
||||
return cn(`${name}-button-error`, styles.buttonError);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {maxCharCount, submitEnabled, cancelButtonClassName, submitButtonClassName, charCountEnable, body, loadingState} = this.props;
|
||||
|
||||
const length = body.length;
|
||||
const isRespectingMaxCount = (length) => charCountEnable && maxCharCount && length > maxCharCount;
|
||||
const disablePostComment = !length || isRespectingMaxCount(length) || !saveCommentEnabled({body});
|
||||
const disableSubmitButton = !length || isRespectingMaxCount(length) || !submitEnabled({body}) || loadingState === 'loading';
|
||||
const disableCancelButton = loadingState === 'loading';
|
||||
const disableTextArea = loadingState === 'loading';
|
||||
|
||||
return <div>
|
||||
<div className={`${name}-container`}>
|
||||
@@ -95,13 +99,14 @@ export class CommentForm extends React.Component {
|
||||
{this.props.bodyLabel}
|
||||
</label>
|
||||
<textarea
|
||||
style={styles && styles.textarea}
|
||||
className={`${name}-textarea`}
|
||||
value={this.state.body}
|
||||
value={body}
|
||||
placeholder={this.props.bodyPlaceholder}
|
||||
id={this.props.bodyInputId}
|
||||
onChange={this.onBodyChange}
|
||||
rows={3}/>
|
||||
rows={3}
|
||||
disabled={disableTextArea}
|
||||
/>
|
||||
<Slot fill='commentInputArea' />
|
||||
</div>
|
||||
{
|
||||
@@ -113,20 +118,22 @@ export class CommentForm extends React.Component {
|
||||
<div className={`${name}-button-container`}>
|
||||
{ this.props.buttonContainerStart }
|
||||
{
|
||||
typeof this.props.cancelButtonClicked === 'function' && (
|
||||
typeof this.props.onCancel === 'function' && (
|
||||
<Button
|
||||
cStyle='darkGrey'
|
||||
className={classnames(`${name}-cancel-button`, buttonClass)}
|
||||
onClick={this.props.cancelButtonClicked}>
|
||||
className={cn(`${name}-cancel-button`, cancelButtonClassName)}
|
||||
onClick={this.props.onCancel}
|
||||
disabled={disableCancelButton}
|
||||
>
|
||||
{t('comment_box.cancel')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
cStyle={disablePostComment ? 'lightGrey' : this.props.saveButtonCStyle}
|
||||
className={classnames(`${name}-button`, buttonClass)}
|
||||
cStyle={disableSubmitButton ? 'lightGrey' : this.props.submitButtonCStyle}
|
||||
className={cn(`${name}-button`, submitButtonClassName, this.getButtonClassName())}
|
||||
onClick={this.onClickSubmit}
|
||||
disabled={disablePostComment ? 'disabled' : ''}>
|
||||
disabled={disableSubmitButton ? 'disabled' : ''}>
|
||||
{this.props.submitText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,90 @@
|
||||
.slot {
|
||||
display: inline-block;
|
||||
div {
|
||||
display: inline-block;
|
||||
}
|
||||
/**
|
||||
* Example loading state animations on the button.
|
||||
*/
|
||||
|
||||
/*
|
||||
|
||||
@-webkit-keyframes sk-scaleout {
|
||||
0% { -webkit-transform: scale(0) }
|
||||
100% {
|
||||
-webkit-transform: scale(1.0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sk-scaleout {
|
||||
0% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
} 100% {
|
||||
-webkit-transform: scale(1.0);
|
||||
transform: scale(1.0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes comeAndGo {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1.0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonLoading, .buttonLoading:disabled, .buttonLoading:hover {
|
||||
transition: none;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.buttonSuccess, .buttonSuccess:disabled, .buttonSuccess:hover {
|
||||
}
|
||||
|
||||
.buttonSuccess::before {
|
||||
content: '✓';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 3px 0px;
|
||||
animation: comeAndGo 2s forwards;
|
||||
background: rgb(51, 204, 51);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.buttonLoading::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
background-color: #333;
|
||||
border-radius: 100%;
|
||||
-webkit-animation: sk-scaleout 1.0s infinite ease-in-out;
|
||||
animation: sk-scaleout 1.0s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.buttonError, .buttonError:disabled, .buttonError:hover {
|
||||
}
|
||||
|
||||
.buttonError::before {
|
||||
content: '×';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 3px 0px;
|
||||
animation: comeAndGo 2s forwards;
|
||||
background: rgb(250, 100, 100);
|
||||
color: white;
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
+2
@@ -39,6 +39,8 @@ export default class FlagButton extends Component {
|
||||
} else {
|
||||
this.setState({showMenu: true});
|
||||
}
|
||||
} else {
|
||||
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
|
||||
}
|
||||
}
|
||||
|
||||
+10
-9
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import FlagButton from './FlagButton';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {username, comment} from '../helpers/flagReasons';
|
||||
|
||||
const FlagComment = (props) => <FlagButton {...props} getPopupMenu={getPopupMenu} />;
|
||||
|
||||
@@ -20,17 +21,17 @@ const getPopupMenu = [
|
||||
(itemType) => {
|
||||
const options = itemType === 'COMMENTS' ?
|
||||
[
|
||||
{val: 'This comment is offensive', text: t('comment_offensive')},
|
||||
{val: 'This looks like an ad/marketing', text: t('marketing')},
|
||||
{val: 'I don\'t agree with this comment', text: t('no_agree_comment')},
|
||||
{val: 'Other', text: t('other')}
|
||||
{val: comment.offensive, text: t('comment_offensive')},
|
||||
{val: comment.spam, text: t('marketing')},
|
||||
{val: comment.noagree, text: t('no_agree_comment')},
|
||||
{val: comment.other, text: t('other')}
|
||||
]
|
||||
: [
|
||||
{val: 'This username is offensive', text: t('username_offensive')},
|
||||
{val: 'I don\'t like this username', text: t('no_like_username')},
|
||||
{val: 'This user is impersonating', text: t('user_impersonating')},
|
||||
{val: 'This looks like an ad/marketing', text: t('marketing')},
|
||||
{val: 'Other', text: t('other')}
|
||||
{val: username.offensive, text: t('username_offensive')},
|
||||
{val: username.nolike, text: t('no_like_username')},
|
||||
{val: username.impersonating, text: t('user_impersonating')},
|
||||
{val: username.spam, text: t('marketing')},
|
||||
{val: username.other, text: t('other')}
|
||||
];
|
||||
return {
|
||||
header: t('step_2_header'),
|
||||
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
username: {
|
||||
offensive: 'USERNAME_OFFENSIVE',
|
||||
nolike: 'USERNAME_NOLIKE',
|
||||
impersonating: 'USERNAME_IMPERSONATING',
|
||||
spam: 'USERNAME_SPAM',
|
||||
other: 'USERNAME_OTHER'
|
||||
},
|
||||
comment: {
|
||||
offensive: 'COMMENT_OFFENSIVE',
|
||||
spam: 'COMMENT_SPAM',
|
||||
noagree: 'COMMENT_NOAGREE',
|
||||
other: 'COMMENT_OTHER'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {default as FlagComment} from './components/FlagComment';
|
||||
@@ -29,7 +29,7 @@ const Comment = (props) => {
|
||||
<div className={styles.sidebar}>
|
||||
<ul>
|
||||
<li>
|
||||
<a onClick={props.link(`${props.asset.url}#${props.comment.id}`)}>
|
||||
<a onClick={props.link(`${props.asset.url}?commentId=${props.comment.id}`)}>
|
||||
<Icon name="open_in_new" />{t('view_conversation')}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
const name = 'coral-plugin-permalinks';
|
||||
import {Button} from 'coral-ui';
|
||||
import styles from './styles.css';
|
||||
import ClickOutside from 'coral-framework/components/ClickOutside';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export default class PermalinkButton extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
articleURL: PropTypes.string.isRequired,
|
||||
commentId: PropTypes.string.isRequired
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {popoverOpen: false, copySuccessful: null, copyFailure: null};
|
||||
this.toggle = this.toggle.bind(this);
|
||||
this.copyPermalink = this.copyPermalink.bind(this);
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
handleClickOutside = () => {
|
||||
this.setState({popoverOpen: false});
|
||||
}
|
||||
|
||||
copyPermalink () {
|
||||
this.permalinkInput.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
this.setState({copySuccessful: true, copyFailure: null});
|
||||
} catch (err) {
|
||||
this.setState({copyFailure: true, copySuccessful: null});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.setState({copyFailure: null, copySuccessful: null});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {copySuccessful, copyFailure} = this.state;
|
||||
return (
|
||||
<ClickOutside onClickOutside={this.handleClickOutside}>
|
||||
<div className={`${name}-container`}>
|
||||
<button
|
||||
ref={(ref) => this.linkButton = ref}
|
||||
onClick={this.toggle}
|
||||
className={`${name}-button`}>
|
||||
{t('permalink')}
|
||||
<i className={`${name}-icon material-icons`} aria-hidden={true}>link</i>
|
||||
</button>
|
||||
<div
|
||||
ref={(ref) => this.popover = ref}
|
||||
className={`${name}-popover ${styles.container} ${this.state.popoverOpen ? 'active' : ''}`}>
|
||||
<input
|
||||
className={`${name}-copy-field`}
|
||||
type='text'
|
||||
ref={(input) => this.permalinkInput = input}
|
||||
value={`${this.props.articleURL}#${this.props.commentId}`}
|
||||
onChange={() => {}} />
|
||||
<Button className={`${name}-copy-button ${copySuccessful ? styles.success : ''} ${copyFailure ? styles.failure : ''}`}
|
||||
onClick={this.copyPermalink} >
|
||||
{!copyFailure && !copySuccessful && 'Copy'}
|
||||
{copySuccessful && 'Copied'}
|
||||
{copyFailure && 'Not supported'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ClickOutside>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
.container {
|
||||
border-radius: 3px;
|
||||
padding: 15px 10px;
|
||||
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);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
border: 10px solid transparent;
|
||||
border-top-color: white;
|
||||
position: absolute;
|
||||
right: 7em;
|
||||
bottom: -20px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&::after{
|
||||
content: '';
|
||||
border: 10px solid transparent;
|
||||
border-top-color: rgba(153, 153, 153, 0.33);
|
||||
position: absolute;
|
||||
right: 7em;
|
||||
bottom: -21px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
input {
|
||||
display: inline-block;
|
||||
width: calc(100% - 78px);
|
||||
padding: 8px;
|
||||
border-radius: 3px;
|
||||
border: solid 1px #e0e0e0;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
background-color: #e0e0e0;
|
||||
font-size: 1em;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 2px;
|
||||
transition: background-color 0.4s ease;
|
||||
|
||||
&:hover{
|
||||
color: black;
|
||||
}
|
||||
|
||||
&.success {
|
||||
background-color: #00897B;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.failure {
|
||||
background-color: #FF5252;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class ReplyBox extends Component {
|
||||
styles,
|
||||
postComment,
|
||||
assetId,
|
||||
authorId,
|
||||
currentUser,
|
||||
addNotification,
|
||||
parentId,
|
||||
commentPostedHandler,
|
||||
@@ -31,9 +31,9 @@ class ReplyBox extends Component {
|
||||
charCountEnable={charCountEnable}
|
||||
commentPostedHandler={commentPostedHandler}
|
||||
parentId={parentId}
|
||||
cancelButtonClicked={this.cancelReply}
|
||||
onCancel={this.cancelReply}
|
||||
addNotification={addNotification}
|
||||
authorId={authorId}
|
||||
currentUser={currentUser}
|
||||
assetId={assetId}
|
||||
postComment={postComment}
|
||||
isReply={true} />
|
||||
@@ -48,7 +48,6 @@ ReplyBox.propTypes = {
|
||||
commentPostedHandler: PropTypes.func,
|
||||
parentId: PropTypes.string,
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
authorId: PropTypes.string.isRequired,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
assetId: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
@@ -24,12 +24,6 @@
|
||||
margin: 2px;
|
||||
letter-spacing: 0.7px;
|
||||
font-weight: 400;
|
||||
|
||||
&:disabled {
|
||||
background: #E0E0E0;
|
||||
color: #4f5c67;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -224,3 +218,11 @@
|
||||
.raised {
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
|
||||
.button:disabled {
|
||||
background: #E0E0E0;
|
||||
color: #4f5c67;
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import styles from './Tab.css';
|
||||
export default ({children, tabId, active, onTabClick, cStyle = 'base', ...props}) => (
|
||||
<li
|
||||
key={tabId}
|
||||
className={`${active ? `${styles[`${cStyle}--active`]} coral-tab-active` : ''} coral-tab ${props.className}`}
|
||||
className={`${active ? `${styles[`${cStyle}--active`]} talk-tab-active` : ''} talk-tab ${props.className}`}
|
||||
onClick={() => onTabClick(tabId)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -17,7 +17,7 @@ class TabBar extends React.Component {
|
||||
const {children, activeTab, cStyle = 'base'} = this.props;
|
||||
return (
|
||||
<div>
|
||||
<ul className={`${styles.base} ${cStyle ? styles[cStyle] : ''} ${this.props.className}`}>
|
||||
<ul className={`${styles.base} ${cStyle ? styles[cStyle] : ''} talk-tab-bar ${this.props.className}`}>
|
||||
{React.Children.toArray(children)
|
||||
.filter((child) => !child.props.restricted)
|
||||
.map((child, tabId) =>
|
||||
|
||||
@@ -18,6 +18,9 @@ entries:
|
||||
- title: Installation
|
||||
output: web
|
||||
folderitems:
|
||||
- title: Getting Started
|
||||
output: web
|
||||
url: /install.html
|
||||
- title: Configuration
|
||||
output: web
|
||||
url: /configuration.html
|
||||
@@ -34,6 +37,12 @@ entries:
|
||||
- title: Plugins
|
||||
output: web
|
||||
folderitems:
|
||||
- title: Overview
|
||||
url: /plugins.html
|
||||
output: web
|
||||
- title: Quickstart
|
||||
url: /plugins-quickstart.html
|
||||
output: web
|
||||
- title: Client API
|
||||
url: /plugins-client.html
|
||||
output: web
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ We strive for clear inline documentation across our codebase, but have gaps. Con
|
||||
|
||||
If you are considering changing a core component (aka, one that is not in a plugin), you are entering the realm of a core developer. We strongly ask that you reach out the coral team before forking and changing core code. We are glad to help talk through your product need and come up with a strategy for implementing as a plugin, or working with you to extend the plugin API for your use case.
|
||||
|
||||
### How do I contribue to these docs?
|
||||
### How do I contribute to these docs?
|
||||
|
||||
Contributions to the docs are much appreciated and a great way to get involved in the project.
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: Installation
|
||||
sidebar: talk_sidebar
|
||||
permalink: install.html
|
||||
summary:
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
Talk requires MongoDB and Redis.
|
||||
|
||||
- MongoDB ^3.2 - [Docs](https://docs.mongodb.com/manual/installation/)
|
||||
- Redis ^3.2.5 - [Docs](https://redis.io/topics/quickstart)
|
||||
|
||||
## Installation method
|
||||
|
||||
While Talk can be installed in many ways, we support two install paths:
|
||||
|
||||
* [Install from Source](install-source.html) (development)
|
||||
* [Install via Docker](install-docker.html) (deployment)
|
||||
|
||||
If you have success installing Talk in another way, please consider [contributing to this documentation](faq.html#how-do-i-contribute-to-these-docs)!
|
||||
@@ -0,0 +1,244 @@
|
||||
---
|
||||
title: Plugins Quickstart
|
||||
keywords: plugins
|
||||
sidebar: talk_sidebar
|
||||
permalink: plugins-quickstart.html
|
||||
summary:
|
||||
---
|
||||
|
||||
This tutorial walks through the mechanics of creating and publishing a Talk plugin. Along the way I call out some particular habits and techniques that I employ. If you have other practices that you find valuable, please don't hesitate to [contribute!](faq.html#how-do-i-contribute-to-these-docs)
|
||||
|
||||
We will create a plugin that exposes a route that allows assets to be created or updated.
|
||||
|
||||
## Setup the environment
|
||||
|
||||
Before I begin working on the plugin, I've installed [Talk from source](/install-source.html).
|
||||
|
||||
### Watch the Server
|
||||
|
||||
In a terminal, I run `yarn dev-start`. This:
|
||||
|
||||
* starts my server, showing plugin and configuration information
|
||||
* restarts it when I save files
|
||||
* shows my temporary `console.log()` statements
|
||||
* shows real time access logs
|
||||
* shows verbose debug output if enabled (more on this later)
|
||||
|
||||
### Watch the Client build process
|
||||
|
||||
In a separate terminal I run `yarn build-watch`. This:
|
||||
|
||||
* builds the client side javascript bundles
|
||||
* watches relevant files and rebuilds the bundle on change
|
||||
* displays _compile time_ errors, including (the many) syntax errors I cause
|
||||
|
||||
If you need to run `yarn install`, you will see missing module error messages here.
|
||||
|
||||
### Watch from the Browser
|
||||
|
||||
I open up `http://localhost:3000` in a web browser and see the default comment stream. I then open the dev tools console, which:
|
||||
|
||||
* shows any _run time_ errors/warnings generated on the front end.
|
||||
* shows any temporary `console.log()` statements I add during development.
|
||||
|
||||
I also often toggle to the Network Tab to see:
|
||||
|
||||
* which files are being loaded
|
||||
* requests sent from my front end code, including headers, the payload/queries sent and the data returned
|
||||
|
||||
I strongly recommend taking the time to fully explore all the features of your browser's dev tools!
|
||||
|
||||
## Create a home for my new plugin
|
||||
|
||||
My goals for this tutorial are to:
|
||||
|
||||
* build this plugin locally
|
||||
* use source control and publish for collaboration
|
||||
* publish the plugin as an npm library
|
||||
|
||||
### Create a repo
|
||||
|
||||
I create a new repo called `talk-plugin-asset-manager`. (I use github, but this you could store this anywhere, bitbucket, svn, etc...)
|
||||
|
||||
_make sure to respect the naming convention `talk-plugin-*`. This will allow for easy identification of the repo and, eventually, easy searching on npm._
|
||||
|
||||
### Set up a local file structure
|
||||
|
||||
In a blatant rip off from the Golang community, I create an environment var to hold the path to the root of my Coral directory. This allows absolute pathing.
|
||||
|
||||
```
|
||||
export CORALPATH=/path/to/my/coral/root/dir
|
||||
```
|
||||
|
||||
I like to put my plugins in a directory next to talk, but you could put this anywhere.
|
||||
|
||||
```
|
||||
cd $CORALPATH
|
||||
git clone https://github.com/jde/talk-plugin-asset-manager.git
|
||||
```
|
||||
|
||||
### Register your plugin
|
||||
|
||||
Add the plugin to the plugins.json file:
|
||||
|
||||
```
|
||||
{
|
||||
"server": [
|
||||
...
|
||||
"talk-plugin-asset-manager"
|
||||
],
|
||||
"client": [
|
||||
...
|
||||
// no client side components so I won't add it here
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
But wait! Talk looks in `talk/plugins/[plugin-name]` for plugin code. Why couldn't we just add that plugin there?
|
||||
|
||||
We could have.
|
||||
|
||||
This would make it _a little_ easier to register, but _a lot_ harder to cleanly manage in version control. In order to avoid it being sucked into your Talk repo, you would have to manually `.gitignore` it or use [sub modules]() or something similar.
|
||||
|
||||
As a user of a Linux_y_ os, I prefer to create a symbolic link.
|
||||
|
||||
```
|
||||
cd $CORALPATH/talk/plugins
|
||||
ln -s $CORALPATH/talk-plugin-asset-manager
|
||||
```
|
||||
|
||||
Now, as far as Talk knows, our plugin is right there in the folder. Git is wise, however, and will not include it in the Talk repo. Best of all, our `yarn dev-start` based watch statement follows symbolic links and will restart our sever each time a file is saved.
|
||||
|
||||
### Create the initial index file
|
||||
|
||||
All plugins contain server and/or client index files, which export all plugin functionality.
|
||||
|
||||
```
|
||||
// talk-plugin-asset-manager/index.js
|
||||
module.exports = {};
|
||||
```
|
||||
|
||||
## Build the feature!
|
||||
|
||||
Now that the plugin is set up I can get down to writing the feature. My goal is to allow my CMS to push new assets as they are created into Talk. To accomplish this, I will create a POST endpoint using Talk's [route api](plugins-server.html#field-router).
|
||||
|
||||
### Create a route
|
||||
|
||||
When designing my api, I want to be careful to avoid conflicts with not only the existing Talk api, but other plugins in the open source ecosystem that may be creating routes. To do this, I'll follow the golden rule of creating universals with plugins:
|
||||
|
||||
_Always namespace all universals with your plugin's unique name._
|
||||
|
||||
To ensure everything is hooked up, I'll log the request body (POST payload in this case) to the console and echo it as the response:
|
||||
|
||||
```
|
||||
// talk-plugin-asset-manager/index.js
|
||||
module.exports = {
|
||||
router(router) {
|
||||
router.post('/api/v1/asset-manager', (req, res) => {
|
||||
console.log(req.body);
|
||||
res.json(req.body);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When I save this file, I reflexively check my console to be sure that the server restarts.
|
||||
|
||||
To test that this works, I can:
|
||||
|
||||
```
|
||||
$ curl -H "Content-Type: application/json" -X POST -d '{"url":"http://localhost:3000/my-article.html","title":"My Article"}' http://localhost:3000/api/v1/asset-manager
|
||||
{"url":"http://localhost:3000/my-article.html","title":"My Article"}
|
||||
```
|
||||
|
||||
After hitting the endpoint, I can also look at the terminal running `yarn dev-start` and see my `console.log()` and the access log:
|
||||
|
||||
```
|
||||
{ url: 'http://localhost:3000/my-article.html',
|
||||
title: 'My Article' }
|
||||
POST /api/v1/asset-manager 200 1.379 ms - 68
|
||||
```
|
||||
|
||||
### Save the asset
|
||||
|
||||
When I save this asset, I will use Talk's [asset model](https://github.com/coralproject/talk/blob/master/models/asset.js).
|
||||
|
||||
Mongo has a handy method [findOneAndUpdate](https://docs.mongodb.com/v3.2/reference/method/db.collection.findOneAndUpdate/) that will take care determining whether or not this asset exists, then either updating or inserting it. Whenever possible, we recommend using these atomic patterns that prevent multiple queries to the db and the efficiency problems and race conditions that they cause.
|
||||
|
||||
```
|
||||
// talk-plugin-asset-manager/index.js
|
||||
|
||||
const AssetModel = require('models/asset');
|
||||
|
||||
module.exports = {
|
||||
router(router) {
|
||||
router.post('/api/v1/asset-manager', (req, res) => {
|
||||
|
||||
const asset = req.body;
|
||||
const update = {$setOnInsert: {url: asset.url}};
|
||||
|
||||
AssetModel.findOneAndUpdate(asset, update, {
|
||||
new: true,
|
||||
upsert: true,
|
||||
setDefaultsOnInsert: true
|
||||
})
|
||||
.then((asset) => res.json(asset));
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
I can now run the `curl` command as before and see that the response contains a complete asset document!
|
||||
|
||||
In addition, I can change the title in the json payload and verify that the id is the same, indicating that the record was updated!
|
||||
|
||||
Lastly, I can see the asset in the admin panel at `http://localhost:3000/admin` as well as in my database.
|
||||
|
||||
We have an alpha version of our plugin!
|
||||
|
||||
### More work to be done
|
||||
|
||||
The purpose of this tutorial is to follow the full lifecycle of a plugin, from conception through publication into deployment. With that in mind we'll move forward with this alpha version.
|
||||
|
||||
Some things to make this production ready:
|
||||
|
||||
* refactoring to separate concerns
|
||||
* commenting
|
||||
* adding tests
|
||||
* validating data
|
||||
* [adding security](https://github.com/coralproject/talk/blob/b805451d376d2892c81c58d8822a85563e469b88/routes/api/users/index.js#L14)
|
||||
* incorporating [domain whitelisting](https://github.com/coralproject/talk/blob/b805451d376d2892c81c58d8822a85563e469b88/services/assets.js#L60).
|
||||
|
||||
It is important to realize that when you're writing a Talk plugin you are writing a program that may be touched by other devs and could grow in size and complexity. Bring your best engineering sensibilities to bear.
|
||||
|
||||
## Publishing the plugin
|
||||
|
||||
### Publish to npm
|
||||
|
||||
In order to [register](http://localhost:4000/plugins.html#plugin-registration) your _published_ plugin, you will need to [publish it to npm](https://docs.npmjs.com/getting-started/publishing-npm-packages).
|
||||
|
||||
Once the package is published, update `plugins.json` to use the published plugin:
|
||||
|
||||
```
|
||||
{
|
||||
"server": [
|
||||
...
|
||||
{"talk-plugin-asset-manager": "^0.1"}
|
||||
],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Finally, run the `reconcile` script to install the plugin from npm.
|
||||
|
||||
```
|
||||
$ bin/cli plugins reconcile
|
||||
```
|
||||
|
||||
Once you've taken this step, anyone can register your plugin into their Talk server! Thank you for contributing to the open source community!
|
||||
|
||||
### Publish to version control
|
||||
|
||||
This plugin is open source, so I'm also going to [publish it to github](https://github.com/jde/talk-plugin-asset-manager/commit/66b626caa85cb8030b3ddaa7c1a4821bf01e350a) and [cut a release](https://github.com/jde/talk-plugin-asset-manager/releases/tag/v0.1) that mirrors the npm relese.
|
||||
|
||||
## Done!
|
||||
@@ -189,6 +189,41 @@ send data to the client. If the type in question contains args, clients may subs
|
||||
|
||||
For more information, see the [Apollo Docs](https://github.com/apollographql/graphql-subscriptions).
|
||||
|
||||
#### Field: `tokenUserNotFound`
|
||||
|
||||
```js
|
||||
tokenUserNotFound: async ({jwt, token}) => {
|
||||
let profile = await someExternalService(token);
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let user = await UserModel.findOneAndUpdate({
|
||||
id: profile.id
|
||||
}, {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
lowercaseUsername: profile.username.toLowerCase(),
|
||||
roles: [],
|
||||
profiles: []
|
||||
}, {
|
||||
setDefaultsOnInsert: true,
|
||||
new: true,
|
||||
upsert: true
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
The `tokenUserNotFound` hook allows auth integrations to hook into the event
|
||||
when a valid token is provided but a user can't be found in the database that
|
||||
matches the provided id.
|
||||
|
||||
The function is async, and should return the user object that was created in the
|
||||
database, or null if the user wasn't found. The `jwt` paramenter of the object
|
||||
is the unpacked token, while `token` is the original jwt token string.
|
||||
|
||||
### Routes
|
||||
|
||||
#### Field: `router`
|
||||
@@ -205,6 +240,30 @@ 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.
|
||||
|
||||
#### 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.
|
||||
|
||||
### Authorization middleware
|
||||
|
||||
The following example creates the requisite callback route and passport
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Plugins Overview
|
||||
keywords: plugins
|
||||
sidebar: talk_sidebar
|
||||
permalink: plugins.html
|
||||
summary:
|
||||
---
|
||||
|
||||
## Plugin Registration
|
||||
|
||||
In order for a plugin to be active in a Talk install, it must be _registered_. The parsing order for the plugin registration is as follows:
|
||||
|
||||
- `TALK_PLUGINS_JSON` environment variable
|
||||
- `plugins.json` file
|
||||
- `plugins.default.json` file
|
||||
|
||||
If you need to "disable all plugins", you can simply provide `{}` as the
|
||||
contents of `process.env.TALK_PLUGINS_JSON` or the `plugins.json`.
|
||||
|
||||
The format for this is thus:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": [
|
||||
"people"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Where we have a `server` key with an array of plugins that match the folder
|
||||
name in the `plugins/` folder. For example, the above config 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 achieves two things:
|
||||
|
||||
1. It will traverse into local plugin folders and install their dependencies.
|
||||
_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._ This will result in the
|
||||
project `package.json` and `yarn.lock` files to be modified, this is normal as
|
||||
this ensures that repeated deployments (with the same config) will have the
|
||||
same config, these changes should not be committed to source control.
|
||||
2. It will seek out dependencies that are listed in the object notation and try
|
||||
to install them from npm.
|
||||
|
||||
## Plugin Dependencies
|
||||
|
||||
You may also include additional external dependencies 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.
|
||||
|
||||
## Deployment Solutions
|
||||
|
||||
Plugins can be deployed with a production instance of Talk.
|
||||
|
||||
### Source
|
||||
|
||||
Source deployments can just modify the `plugins.json` file and include any
|
||||
local plugins into the `plugins/` directory. After including the config, you
|
||||
need to reconcile the plugins and build the static assets:
|
||||
|
||||
```bash
|
||||
# get plugin dependancies and remote plugins
|
||||
./bin/cli plugins reconcile
|
||||
|
||||
# build staic assets (including enabled client side plugins)
|
||||
yarn build
|
||||
```
|
||||
|
||||
Then the application can be started as is.
|
||||
|
||||
### Docker
|
||||
|
||||
If you deploy using Docker, you can extend from the `*-onbuild` image, an
|
||||
example `Dockerfile` for your project could be:
|
||||
|
||||
```Dockerfile
|
||||
FROM coralproject/talk:latest-onbuild
|
||||
```
|
||||
|
||||
Where the directory for your instance would contain a `plugins.json` file
|
||||
describing the plugin requirements and a `plugins` directory containing any
|
||||
other local plugins that should be included.
|
||||
|
||||
Onbuild triggers will execute when the image is building with your custom
|
||||
configuration and will ensure that the image is ready to use by building all
|
||||
assets inside the image as well.
|
||||
+2
-1
@@ -3,6 +3,7 @@ const mutators = require('./mutators');
|
||||
const uuid = require('uuid');
|
||||
|
||||
const plugins = require('../services/plugins');
|
||||
const pubsub = require('../services/pubsub');
|
||||
const debug = require('debug')('talk:graph:context');
|
||||
|
||||
/**
|
||||
@@ -32,7 +33,7 @@ const decorateContextPlugins = (context, contextPlugins) => contextPlugins.reduc
|
||||
* Stores the request context.
|
||||
*/
|
||||
class Context {
|
||||
constructor({user = null}, pubsub) {
|
||||
constructor({user = null}) {
|
||||
|
||||
// Generate a new context id for the request.
|
||||
this.id = uuid.v4();
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
const schema = require('./schema');
|
||||
const Context = require('./context');
|
||||
const pubsub = require('./pubsub');
|
||||
const {createSubscriptionManager} = require('./subscriptions');
|
||||
|
||||
module.exports = {
|
||||
@@ -11,7 +10,7 @@ module.exports = {
|
||||
|
||||
// Load in the new context here, this'll create the loaders + mutators for
|
||||
// the lifespan of this request.
|
||||
context: new Context(req, pubsub)
|
||||
context: new Context(req)
|
||||
}),
|
||||
createSubscriptionManager
|
||||
};
|
||||
|
||||
+14
-1
@@ -63,18 +63,31 @@ const getAssetsForMetrics = async ({loaders: {Actions, Comments}}) => {
|
||||
.then((connection) => connection.nodes);
|
||||
};
|
||||
|
||||
const findByUrl = async (context, asset_url) => {
|
||||
|
||||
// Verify that the asset_url is parsable.
|
||||
let parsed_asset_url = url.parse(asset_url);
|
||||
if (!parsed_asset_url.protocol) {
|
||||
throw errors.ErrInvalidAssetURL;
|
||||
}
|
||||
|
||||
return AssetsService.findByUrl(asset_url);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) => ({
|
||||
Assets: {
|
||||
|
||||
// TODO: decide whether we want to move these to mutators or not, as in fact
|
||||
// this operation create a new asset if one isn't found.
|
||||
getByURL: (url) => findOrCreateAssetByURL(context, url),
|
||||
|
||||
|
||||
findByUrl: (url) => findByUrl(context, url),
|
||||
search: (query) => getAssetsByQuery(context, query),
|
||||
getByID: new DataLoader((ids) => genAssetsByID(context, ids)),
|
||||
getForMetrics: () => getAssetsForMetrics(context),
|
||||
|
||||
@@ -16,7 +16,7 @@ const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants');
|
||||
const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id, item_type, action_type, group_id, metadata = {}}) => {
|
||||
|
||||
let comment;
|
||||
if (pubsub && item_type === 'COMMENTS') {
|
||||
if (item_type === 'COMMENTS') {
|
||||
comment = await Comments.get.load(item_id);
|
||||
if (!comment) {
|
||||
throw new Error('Comment not found');
|
||||
@@ -38,7 +38,7 @@ const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id,
|
||||
await UsersService.setStatus(item_id, 'PENDING');
|
||||
}
|
||||
|
||||
if (pubsub && comment) {
|
||||
if (comment) {
|
||||
pubsub.publish('commentFlagged', comment);
|
||||
}
|
||||
|
||||
|
||||
+10
-18
@@ -177,11 +177,8 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu
|
||||
}
|
||||
Comments.countByAssetID.incr(asset_id);
|
||||
|
||||
if (pubsub) {
|
||||
|
||||
// Publish the newly added comment via the subscription.
|
||||
pubsub.publish('commentAdded', comment);
|
||||
}
|
||||
// Publish the newly added comment via the subscription.
|
||||
pubsub.publish('commentAdded', comment);
|
||||
}
|
||||
|
||||
return comment;
|
||||
@@ -346,17 +343,14 @@ const setStatus = async ({user, loaders: {Comments}, pubsub}, {id, status}) => {
|
||||
// adjust the affected user's karma in the next tick.
|
||||
process.nextTick(adjustKarma(Comments, id, status));
|
||||
|
||||
if (pubsub) {
|
||||
if (status === 'ACCEPTED') {
|
||||
|
||||
if (status === 'ACCEPTED') {
|
||||
// Publish the comment status change via the subscription.
|
||||
pubsub.publish('commentAccepted', comment);
|
||||
} else if (status === 'REJECTED') {
|
||||
|
||||
// Publish the comment status change via the subscription.
|
||||
pubsub.publish('commentAccepted', comment);
|
||||
} else if (status === 'REJECTED') {
|
||||
|
||||
// Publish the comment status change via the subscription.
|
||||
pubsub.publish('commentRejected', comment);
|
||||
}
|
||||
// Publish the comment status change via the subscription.
|
||||
pubsub.publish('commentRejected', comment);
|
||||
}
|
||||
|
||||
return comment;
|
||||
@@ -379,11 +373,9 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
|
||||
// Execute the edit.
|
||||
const comment = await CommentsService.edit(id, context.user.id, {body, status});
|
||||
|
||||
if (context.pubsub) {
|
||||
// Publish the edited comment via the subscription.
|
||||
context.pubsub.publish('commentEdited', comment);
|
||||
|
||||
// Publish the edited comment via the subscription.
|
||||
context.pubsub.publish('commentEdited', comment);
|
||||
}
|
||||
return comment;
|
||||
};
|
||||
|
||||
|
||||
+18
-6
@@ -2,16 +2,28 @@ const errors = require('../../errors');
|
||||
const UsersService = require('../../services/users');
|
||||
const {SET_USER_STATUS, SUSPEND_USER, REJECT_USERNAME} = require('../../perms/constants');
|
||||
|
||||
const setUserStatus = ({user}, {id, status}) => {
|
||||
return UsersService.setStatus(id, status);
|
||||
const setUserStatus = async ({user, pubsub}, {id, status}) => {
|
||||
const result = await UsersService.setStatus(id, status);
|
||||
if (result && result.status === 'BANNED') {
|
||||
pubsub.publish('userBanned', result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const suspendUser = ({user}, {id, message, until}) => {
|
||||
return UsersService.suspendUser(id, message, until);
|
||||
const suspendUser = async ({user, pubsub}, {id, message, until}) => {
|
||||
const result = await UsersService.suspendUser(id, message, until);
|
||||
if (result) {
|
||||
pubsub.publish('userSuspended', result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const rejectUsername = ({user}, {id, message}) => {
|
||||
return UsersService.rejectUsername(id, message);
|
||||
const rejectUsername = async ({user, pubsub}, {id, message}) => {
|
||||
const result = await UsersService.rejectUsername(id, message);
|
||||
if (result) {
|
||||
pubsub.publish('usernameRejected', result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const ignoreUser = ({user}, userToIgnore) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ const Asset = {
|
||||
});
|
||||
},
|
||||
commentCount({id, commentCount}, {excludeIgnored}, {user, loaders: {Comments}}) {
|
||||
|
||||
|
||||
// TODO: remove
|
||||
if (user && excludeIgnored) {
|
||||
return Comments.parentCountByAssetIDPersonalized({assetId: id, excludeIgnored});
|
||||
|
||||
@@ -40,17 +40,25 @@ const RootQuery = {
|
||||
return Comments.get.load(id);
|
||||
},
|
||||
|
||||
async commentCount(_, {query}, {user, loaders: {Actions, Comments}}) {
|
||||
async commentCount(_, {query}, {user, loaders: {Actions, Comments, Assets}}) {
|
||||
|
||||
if (user == null || !user.can(SEARCH_OTHERS_COMMENTS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {action_type} = query;
|
||||
|
||||
const {action_type, asset_url} = query;
|
||||
|
||||
if (action_type) {
|
||||
query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
|
||||
}
|
||||
|
||||
if (asset_url) {
|
||||
let asset = await Assets.findByUrl(asset_url);
|
||||
if (asset) {
|
||||
query.asset_id = asset.id;
|
||||
}
|
||||
}
|
||||
|
||||
return Comments.getCountByQuery(query);
|
||||
},
|
||||
|
||||
|
||||
@@ -14,6 +14,15 @@ const Subscription = {
|
||||
commentFlagged(comment) {
|
||||
return comment;
|
||||
},
|
||||
userBanned(user) {
|
||||
return user;
|
||||
},
|
||||
userSuspended(user) {
|
||||
return user;
|
||||
},
|
||||
usernameRejected(user) {
|
||||
return user;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = Subscription;
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
SEARCH_OTHERS_COMMENTS,
|
||||
UPDATE_USER_ROLES,
|
||||
SEARCH_COMMENT_METRICS,
|
||||
VIEW_SUSPENSION_INFO,
|
||||
LIST_OWN_TOKENS
|
||||
} = require('../../perms/constants');
|
||||
|
||||
@@ -84,6 +85,13 @@ const User = {
|
||||
if (requestingUser && requestingUser.can(SEARCH_COMMENT_METRICS)) {
|
||||
return KarmaService.model(user);
|
||||
}
|
||||
},
|
||||
|
||||
suspension({id, suspension}, _, {user}) {
|
||||
if (user.id !== id && !user.can(VIEW_SUSPENSION_INFO)) {
|
||||
return null;
|
||||
}
|
||||
return suspension;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+45
-3
@@ -3,7 +3,7 @@ const {SubscriptionServer} = require('subscriptions-transport-ws');
|
||||
const _ = require('lodash');
|
||||
const debug = require('debug')('talk:graph:subscriptions');
|
||||
|
||||
const pubsub = require('./pubsub');
|
||||
const pubsub = require('../services/pubsub');
|
||||
const schema = require('./schema');
|
||||
const Context = require('./context');
|
||||
const plugins = require('../services/plugins');
|
||||
@@ -21,6 +21,9 @@ const {
|
||||
SUBSCRIBE_COMMENT_FLAGGED,
|
||||
SUBSCRIBE_ALL_COMMENT_EDITED,
|
||||
SUBSCRIBE_ALL_COMMENT_ADDED,
|
||||
SUBSCRIBE_ALL_USER_SUSPENDED,
|
||||
SUBSCRIBE_ALL_USER_BANNED,
|
||||
SUBSCRIBE_ALL_USERNAME_REJECTED,
|
||||
} = require('../perms/constants');
|
||||
|
||||
/**
|
||||
@@ -83,6 +86,45 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu
|
||||
}
|
||||
},
|
||||
}),
|
||||
userSuspended: (options, args) => ({
|
||||
userSuspended: {
|
||||
filter: (user, context) => {
|
||||
if (
|
||||
!context.user
|
||||
|| args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USER_SUSPENDED)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return !args.user_id || user.id === args.user_id;
|
||||
}
|
||||
},
|
||||
}),
|
||||
userBanned: (options, args) => ({
|
||||
userBanned: {
|
||||
filter: (user, context) => {
|
||||
if (
|
||||
!context.user
|
||||
|| args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USER_BANNED)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return !args.user_id || user.id === args.user_id;
|
||||
}
|
||||
},
|
||||
}),
|
||||
usernameRejected: (options, args) => ({
|
||||
usernameRejected: {
|
||||
filter: (user, context) => {
|
||||
if (
|
||||
!context.user
|
||||
|| args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USERNAME_REJECTED)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return !args.user_id || user.id === args.user_id;
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -117,10 +159,10 @@ const createSubscriptionManager = (server) => new SubscriptionServer({
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
return new Context({}, pubsub);
|
||||
return new Context({});
|
||||
}
|
||||
|
||||
return new Context(req, pubsub);
|
||||
return new Context(req);
|
||||
};
|
||||
|
||||
return baseParams;
|
||||
|
||||
@@ -61,6 +61,10 @@ type UserProfile {
|
||||
provider: String!
|
||||
}
|
||||
|
||||
type SuspensionInfo {
|
||||
until: Date
|
||||
}
|
||||
|
||||
# Any person who can author comments, create actions, and view comments on a
|
||||
# stream.
|
||||
type User {
|
||||
@@ -108,6 +112,10 @@ type User {
|
||||
|
||||
# returns user status
|
||||
status: USER_STATUS
|
||||
|
||||
# returns suspension info. Only available to Admins and Moderators
|
||||
# or on own logged in User.
|
||||
suspension: SuspensionInfo
|
||||
}
|
||||
|
||||
# UsersQuery allows the ability to query users by a specific fields.
|
||||
@@ -255,6 +263,9 @@ input CommentCountQuery {
|
||||
# Asset that a comment is on.
|
||||
asset_id: ID
|
||||
|
||||
# The URL that the asset is located on.
|
||||
asset_url: String
|
||||
|
||||
# the parent of the comment that we want to retrieve.
|
||||
parent_id: ID
|
||||
|
||||
@@ -1031,6 +1042,21 @@ type Subscription {
|
||||
# Get an update whenever a comment has been rejected.
|
||||
# Requires the `ADMIN` or `MODERATOR` role.
|
||||
commentRejected(asset_id: ID): Comment
|
||||
|
||||
# Get an update whenever a user has been suspended.
|
||||
# `user_id` must match id of current user except for
|
||||
# users with the `ADMIN` or `MODERATOR` role.
|
||||
userSuspended(user_id: ID): User
|
||||
|
||||
# Get an update whenever a user has been banned.
|
||||
# `user_id` must match id of current user except for
|
||||
# users with the `ADMIN` or `MODERATOR` role.
|
||||
userBanned(user_id: ID): User
|
||||
|
||||
# Get an update whenever a username has been rejected.
|
||||
# `user_id` must match id of current user except for
|
||||
# users with the `ADMIN` or `MODERATOR` role.
|
||||
usernameRejected(user_id: ID): User
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
+5
-1
@@ -1,4 +1,7 @@
|
||||
en:
|
||||
your_account_has_been_suspended: Your account has been temporarily suspended.
|
||||
your_account_has_been_banned: Your account has been banned.
|
||||
your_username_has_been_rejected: Your account has been suspended because your username has been deemed inappropriate. To restore your account please enter a new username.
|
||||
bandialog:
|
||||
are_you_sure: "Are you sure you would like to ban {0}?"
|
||||
ban_user: "Ban User?"
|
||||
@@ -37,6 +40,7 @@ en:
|
||||
comment_post_notif_premod: "Thank you for posting. Our moderation team will review your comment shortly."
|
||||
common:
|
||||
copy: 'Copy'
|
||||
error: 'An error has occurred.'
|
||||
community:
|
||||
account_creation_date: "Account Creation Date"
|
||||
active: Active
|
||||
@@ -163,7 +167,6 @@ en:
|
||||
edit_window_timer_prefix: "Edit Window: "
|
||||
second: "second"
|
||||
seconds_plural: "seconds"
|
||||
unexpected_error: "Unexpected error while saving changes. Sorry!"
|
||||
email:
|
||||
confirm:
|
||||
has_been_requested: "A email confirmation has been requested for the following account:"
|
||||
@@ -204,6 +207,7 @@ en:
|
||||
organization_name: "Organization name must only contain letters or numbers."
|
||||
password: "Password must be at least 8 characters"
|
||||
username: "Usernames can contain letters numbers and _ only"
|
||||
unexpected: "Unexpected error occurred. Sorry!"
|
||||
flag_comment: "Report comment"
|
||||
flag_reason: "Reason for reporting (Optional)"
|
||||
flag_username: "Report username"
|
||||
|
||||
+5
-1
@@ -1,4 +1,7 @@
|
||||
es:
|
||||
your_account_has_been_suspended: Su cuenta ha sido temporalmente suspendida.
|
||||
your_account_has_been_banned: Su cuenta ha sido suspendida.
|
||||
your_username_has_been_rejected: Su cuenta ha sido suspendida porque tu nombre de usuario ha sido considerado no apropiado para el espacio. Para recuperar la cuenta, por favor ingresar un nuevo nombre de usuario.
|
||||
bandialog:
|
||||
are_you_sure: "¿Estás segura que quieres suspender a {0}?"
|
||||
ban_user: "¿Quieres suspender el Usuario?"
|
||||
@@ -37,6 +40,7 @@ es:
|
||||
comment_post_notif_premod: "Gracias por el comentario. Nuestro equipo de moderación va a revisarlo muy pronto."
|
||||
common:
|
||||
copy: 'Copiar'
|
||||
error: 'Ha ocurrido un error.'
|
||||
community:
|
||||
account_creation_date: "Fecha de creación de la cuenta"
|
||||
active: Activa
|
||||
@@ -162,7 +166,6 @@ es:
|
||||
edit_window_timer_prefix: "Ventana Edición:"
|
||||
second: "segundo"
|
||||
seconds_plural: "segundos"
|
||||
unexpected_error: "Lo siento. Ha habido un error no previsto al guardar los cambios."
|
||||
email:
|
||||
confirm:
|
||||
has_been_requested: "Un correo de confirmación ha sido pedido para la siguiente cuenta:"
|
||||
@@ -189,6 +192,7 @@ es:
|
||||
email_required: "Se requiere un correo"
|
||||
email_username_in_use: "Correo o nombre en uso."
|
||||
INVALID_ASSET_URL: "La URL del articulo no es valida"
|
||||
unexpected: "Lo siento. Ha habido un error no previsto."
|
||||
login_maximum_exceeded: "Ha realizado demasiados intentos fallidos de usar la contraseña. Por favor espere."
|
||||
network_error: "Error al conectar con el servidor. Compruebe su conexión a Internet y vuelva a intentarlo."
|
||||
NOT_AUTHORIZED: "Acción no autorizada."
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "talk",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
@@ -57,6 +57,7 @@
|
||||
"dependencies": {
|
||||
"accepts": "^1.3.3",
|
||||
"app-module-path": "^2.2.0",
|
||||
"async": "^2.5.0",
|
||||
"bcrypt": "^1.0.2",
|
||||
"body-parser": "^1.17.1",
|
||||
"bowser": "^1.7.0",
|
||||
@@ -109,6 +110,7 @@
|
||||
"passport-local": "^1.0.0",
|
||||
"prop-types": "^15.5.10",
|
||||
"react-apollo": "^1.1.0",
|
||||
"react-input-autosize": "^1.1.4",
|
||||
"react-recaptcha": "^2.2.6",
|
||||
"react-toastify": "^1.5.0",
|
||||
"react-transition-group": "^1.1.3",
|
||||
@@ -117,9 +119,11 @@
|
||||
"resolve": "^1.3.2",
|
||||
"semver": "^5.3.0",
|
||||
"simplemde": "^1.11.2",
|
||||
"smoothscroll-polyfill": "^0.3.5",
|
||||
"snake-case": "^2.1.0",
|
||||
"subscriptions-transport-ws": "^0.7.2",
|
||||
"timekeeper": "^1.0.0",
|
||||
"url-search-params": "^0.9.0",
|
||||
"uuid": "^3.0.1",
|
||||
"yaml-loader": "^0.4.0",
|
||||
"yamljs": "^0.2.10"
|
||||
@@ -195,7 +199,7 @@
|
||||
"react-mdl-selectfield": "^0.2.0",
|
||||
"react-redux": "^4.4.5",
|
||||
"react-router": "^3.0.0",
|
||||
"react-tagsinput": "^3.14.0",
|
||||
"react-tagsinput": "^3.17.0",
|
||||
"redux": "^3.6.0",
|
||||
"redux-mock-store": "^1.2.1",
|
||||
"redux-thunk": "^2.1.0",
|
||||
|
||||
@@ -26,6 +26,7 @@ module.exports = {
|
||||
SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS',
|
||||
LIST_OWN_TOKENS: 'LIST_OWN_TOKENS',
|
||||
SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY',
|
||||
VIEW_SUSPENSION_INFO: 'VIEW_SUSPENSION_INFO',
|
||||
|
||||
// subscriptions
|
||||
SUBSCRIBE_COMMENT_ACCEPTED: 'SUBSCRIBE_COMMENT_ACCEPTED',
|
||||
@@ -33,4 +34,7 @@ module.exports = {
|
||||
SUBSCRIBE_COMMENT_FLAGGED: 'SUBSCRIBE_COMMENT_FLAGGED',
|
||||
SUBSCRIBE_ALL_COMMENT_ADDED: 'SUBSCRIBE_ALL_COMMENT_ADDED',
|
||||
SUBSCRIBE_ALL_COMMENT_EDITED: 'SUBSCRIBE_ALL_COMMENT_EDITED',
|
||||
SUBSCRIBE_ALL_USER_SUSPENDED: 'SUBSCRIBE_ALL_USER_SUSPENDED',
|
||||
SUBSCRIBE_ALL_USER_BANNED: 'SUBSCRIBE_ALL_USER_BANNED',
|
||||
SUBSCRIBE_ALL_USERNAME_REJECTED: 'SUBSCRIBE_ALL_USERNAME_REJECTED',
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ module.exports = (user, perm) => {
|
||||
return check(user, ['ADMIN']);
|
||||
case types.SEARCH_COMMENT_STATUS_HISTORY:
|
||||
return check(user, ['ADMIN', 'MODERATOR']);
|
||||
case types.VIEW_SUSPENSION_INFO:
|
||||
return check(user, ['ADMIN', 'MODERATOR']);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ module.exports = (user, perm) => {
|
||||
return check(user, ['ADMIN', 'MODERATOR']);
|
||||
case types.SUBSCRIBE_ALL_COMMENT_ADDED:
|
||||
return check(user, ['ADMIN', 'MODERATOR']);
|
||||
case types.SUBSCRIBE_ALL_USER_SUSPENDED:
|
||||
return check(user, ['ADMIN', 'MODERATOR']);
|
||||
case types.SUBSCRIBE_ALL_USER_BANNED:
|
||||
return check(user, ['ADMIN', 'MODERATOR']);
|
||||
case types.SUBSCRIBE_ALL_USERNAME_REJECTED:
|
||||
return check(user, ['ADMIN', 'MODERATOR']);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export {Slot} from 'coral-framework/components';
|
||||
export {default as ClickOutside} from 'coral-framework/components/ClickOutside';
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 {addNotification} from 'coral-framework/actions/notification';
|
||||
import {capitalize} from 'coral-framework/helpers/strings';
|
||||
import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils';
|
||||
import * as PropTypes from 'prop-types';
|
||||
@@ -248,6 +249,7 @@ export default (reaction) => (WrappedComponent) => {
|
||||
|
||||
return <WrappedComponent
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
addNotification={this.props.addNotification}
|
||||
user={this.props.user}
|
||||
comment={comment}
|
||||
reactionSummary={reactionSummary}
|
||||
@@ -350,7 +352,7 @@ export default (reaction) => (WrappedComponent) => {
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({showSignInDialog}, dispatch);
|
||||
bindActionCreators({showSignInDialog, addNotification}, dispatch);
|
||||
|
||||
const enhance = compose(
|
||||
withFragments({
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"coral-plugin-auth",
|
||||
"coral-plugin-offtopic",
|
||||
"coral-plugin-viewing-options",
|
||||
"coral-plugin-comment-content"
|
||||
"coral-plugin-comment-content",
|
||||
"talk-plugin-permalink"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ class LikeButton extends React.Component {
|
||||
postReaction,
|
||||
deleteReaction,
|
||||
showSignInDialog,
|
||||
addNotification,
|
||||
alreadyReacted,
|
||||
user,
|
||||
} = this.props;
|
||||
@@ -25,6 +26,7 @@ class LikeButton extends React.Component {
|
||||
|
||||
// If the current user is suspended, do nothing.
|
||||
if (!can(user, 'INTERACT_WITH_COMMUNITY')) {
|
||||
addNotification('error', t('error.NOT_AUTHORIZED'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ class LoveButton extends React.Component {
|
||||
postReaction,
|
||||
deleteReaction,
|
||||
showSignInDialog,
|
||||
addNotification,
|
||||
alreadyReacted,
|
||||
user,
|
||||
} = this.props;
|
||||
@@ -25,6 +26,7 @@ class LoveButton extends React.Component {
|
||||
|
||||
// If the current user is suspended, do nothing.
|
||||
if (!can(user, 'INTERACT_WITH_COMMUNITY')) {
|
||||
addNotification('error', t('error.NOT_AUTHORIZED'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class RespectButton extends React.Component {
|
||||
deleteReaction,
|
||||
showSignInDialog,
|
||||
alreadyReacted,
|
||||
addNotification,
|
||||
user,
|
||||
} = this.props;
|
||||
|
||||
@@ -25,6 +26,7 @@ class RespectButton extends React.Component {
|
||||
|
||||
// If the current user is suspended, do nothing.
|
||||
if (!can(user, 'INTERACT_WITH_COMMUNITY')) {
|
||||
addNotification('error', t('error.NOT_AUTHORIZED'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,107 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import styles from './styles.css';
|
||||
import {t} from 'plugin-api/beta/client/services';
|
||||
import {ClickOutside} from 'plugin-api/beta/client/components';
|
||||
import {Icon, Button} from 'plugin-api/beta/client/components/ui';
|
||||
|
||||
const name = 'talk-plugin-permalink';
|
||||
|
||||
export default class PermalinkButton extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
popoverOpen: false,
|
||||
copySuccessful: null,
|
||||
copyFailure: null
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
toggle = () => {
|
||||
this.popover.style.top = `${this.linkButton.offsetTop - 80}px`;
|
||||
|
||||
this.setState({
|
||||
popoverOpen: !this.state.popoverOpen
|
||||
});
|
||||
}
|
||||
|
||||
handleClickOutside = () => {
|
||||
this.setState({
|
||||
popoverOpen: false
|
||||
});
|
||||
}
|
||||
|
||||
copyPermalink = () => {
|
||||
this.permalinkInput.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
this.setState({
|
||||
copySuccessful: true,
|
||||
copyFailure: null
|
||||
});
|
||||
} catch (err) {
|
||||
this.setState({
|
||||
copyFailure: true,
|
||||
copySuccessful: null
|
||||
});
|
||||
}
|
||||
|
||||
this.timeout = window.setTimeout(() => {
|
||||
this.setState({
|
||||
copyFailure: null,
|
||||
copySuccessful: null
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {copySuccessful, copyFailure, popoverOpen} = this.state;
|
||||
const {asset} = this.props;
|
||||
return (
|
||||
<ClickOutside onClickOutside={this.handleClickOutside}>
|
||||
<div className={cn(`${name}-container`, styles.container)}>
|
||||
|
||||
<button
|
||||
ref={(ref) => this.linkButton = ref}
|
||||
onClick={this.toggle}
|
||||
className={`${name}-button`}>
|
||||
{t('permalink')}
|
||||
<Icon name="link" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={(ref) => this.popover = ref}
|
||||
className={cn([`${name}-popover`, styles.popover, {[styles.active]: popoverOpen}])}>
|
||||
|
||||
<input
|
||||
className={cn(styles.input, `${name}-copy-field`)}
|
||||
type='text'
|
||||
ref={(input) => this.permalinkInput = input}
|
||||
defaultValue={`${asset.url}?commentId=${this.props.commentId}`}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={this.copyPermalink}
|
||||
className={cn([
|
||||
styles.button,
|
||||
`${name}-copy-button`, {
|
||||
[styles.success]:copySuccessful,
|
||||
[styles.failure]: copyFailure
|
||||
}])}>
|
||||
{!copyFailure && !copySuccessful && 'Copy'}
|
||||
{copySuccessful && 'Copied'}
|
||||
{copyFailure && 'Not supported'}
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ClickOutside>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.popover {
|
||||
display: none;
|
||||
background-color: white;
|
||||
position: absolute;
|
||||
border-radius: 3px;
|
||||
padding: 15px 10px;
|
||||
box-sizing: border-box;
|
||||
border: solid 1px rgba(153, 153, 153, 0.33);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
.popover::before {
|
||||
content: '';
|
||||
border: 10px solid transparent;
|
||||
border-top-color: white;
|
||||
position: absolute;
|
||||
right: 7em;
|
||||
bottom: -20px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.popover::after{
|
||||
content: '';
|
||||
border: 10px solid transparent;
|
||||
border-top-color: rgba(153, 153, 153, 0.33);
|
||||
position: absolute;
|
||||
right: 7em;
|
||||
bottom: -21px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.input {
|
||||
display: inline-block;
|
||||
width: calc(100% - 78px);
|
||||
padding: 8px;
|
||||
border-radius: 3px;
|
||||
border: solid 1px #e0e0e0;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
background-color: #e0e0e0;
|
||||
font-size: 1em;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 2px;
|
||||
transition: background-color 0.4s ease;
|
||||
}
|
||||
|
||||
.button:hover{
|
||||
color: black;
|
||||
}
|
||||
|
||||
.button.success {
|
||||
background-color: #00897B;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.failure {
|
||||
background-color: #FF5252;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.active {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import PermalinkButton from './components/PermalinkButton';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
commentActions: [PermalinkButton]
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = {};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "talk-plugin-permalink",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const UsersService = require('../../../services/users');
|
||||
const CommentsService = require('../../../services/comments');
|
||||
const mailer = require('../../../services/mailer');
|
||||
const pubsub = require('../../../services/pubsub');
|
||||
const errors = require('../../../errors');
|
||||
const authorization = require('../../../middleware/authorization');
|
||||
const i18n = require('../../../services/i18n');
|
||||
@@ -53,11 +53,16 @@ router.post('/:user_id/role', authorization.needed('ADMIN'), (req, res, next) =>
|
||||
router.post('/:user_id/status', authorization.needed('ADMIN'), (req, res, next) => {
|
||||
UsersService
|
||||
.setStatus(req.params.user_id, req.body.status)
|
||||
.then((status) => {
|
||||
res.status(201).json(status);
|
||||
.then((user) => {
|
||||
|
||||
if (status === 'BANNED' && req.body.comment_id) {
|
||||
return CommentsService.pushStatus(req.body.comment_id, 'rejected', req.params.user_id);
|
||||
// TODO: current updating status behavior is weird.
|
||||
if (user) {
|
||||
if (user.status === 'BANNED') {
|
||||
pubsub.publish('userBanned', user);
|
||||
}
|
||||
res.status(201).json(user.status);
|
||||
} else {
|
||||
res.status(500).json();
|
||||
}
|
||||
})
|
||||
.catch(next);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const {RedisPubSub} = require('graphql-redis-subscriptions');
|
||||
|
||||
const {connectionOptions} = require('../services/redis');
|
||||
const {connectionOptions} = require('./redis');
|
||||
|
||||
module.exports = new RedisPubSub({connection: connectionOptions});
|
||||
+70
-46
@@ -435,7 +435,10 @@ module.exports = class UsersService {
|
||||
return Promise.reject(new Error(`status ${status} is not supported`));
|
||||
}
|
||||
|
||||
return UserModel.update({
|
||||
// TODO: current updating status behavior is weird.
|
||||
// once a user has been `APPROVED` its status cannot be
|
||||
// changed anymore.
|
||||
return UserModel.findOneAndUpdate({
|
||||
id,
|
||||
status: {
|
||||
$ne: 'APPROVED'
|
||||
@@ -444,6 +447,8 @@ module.exports = class UsersService {
|
||||
$set: {
|
||||
status
|
||||
}
|
||||
}, {
|
||||
new: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -453,34 +458,37 @@ module.exports = class UsersService {
|
||||
* @param {String} message message to be send to the user
|
||||
* @param {Date} until date until the suspension is valid.
|
||||
*/
|
||||
static suspendUser(id, message, until) {
|
||||
return UserModel.findOneAndUpdate(
|
||||
static async suspendUser(id, message, until) {
|
||||
const user = await UserModel.findOneAndUpdate(
|
||||
{id}, {
|
||||
$set: {
|
||||
suspension: {
|
||||
until,
|
||||
},
|
||||
}
|
||||
})
|
||||
.then((user) => {
|
||||
if (message) {
|
||||
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
|
||||
if (localProfile) {
|
||||
const options =
|
||||
{
|
||||
template: 'suspension', // needed to know which template to render!
|
||||
locals: { // specifies the template locals.
|
||||
body: message
|
||||
},
|
||||
subject: 'Your account has been suspended',
|
||||
to: localProfile.id // This only works if the user has registered via e-mail.
|
||||
// We may want a standard way to access a user's e-mail address in the future
|
||||
};
|
||||
|
||||
return MailerService.sendSimple(options);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
new: true,
|
||||
});
|
||||
|
||||
if (message) {
|
||||
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
|
||||
if (localProfile) {
|
||||
const options =
|
||||
{
|
||||
template: 'suspension', // needed to know which template to render!
|
||||
locals: { // specifies the template locals.
|
||||
body: message
|
||||
},
|
||||
subject: 'Your account has been suspended',
|
||||
to: localProfile.id // This only works if the user has registered via e-mail.
|
||||
// We may want a standard way to access a user's e-mail address in the future
|
||||
};
|
||||
|
||||
await MailerService.sendSimple(options);
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,34 +497,37 @@ module.exports = class UsersService {
|
||||
* @param {String} message message to be send to the user
|
||||
* @param {Date} until date until the suspension is valid.
|
||||
*/
|
||||
static rejectUsername(id, message) {
|
||||
return UserModel.findOneAndUpdate({
|
||||
static async rejectUsername(id, message) {
|
||||
const user = await UserModel.findOneAndUpdate({
|
||||
id
|
||||
}, {
|
||||
$set: {
|
||||
status: 'BANNED',
|
||||
canEditName: true,
|
||||
}
|
||||
})
|
||||
.then((user) => {
|
||||
if (message) {
|
||||
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
|
||||
if (localProfile) {
|
||||
const options =
|
||||
{
|
||||
template: 'suspension', // needed to know which template to render!
|
||||
locals: { // specifies the template locals.
|
||||
body: message
|
||||
},
|
||||
subject: 'Email Suspension',
|
||||
to: localProfile.id // This only works if the user has registered via e-mail.
|
||||
// We may want a standard way to access a user's e-mail address in the future
|
||||
};
|
||||
|
||||
return MailerService.sendSimple(options);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
new: true,
|
||||
});
|
||||
|
||||
if (message) {
|
||||
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
|
||||
if (localProfile) {
|
||||
const options =
|
||||
{
|
||||
template: 'suspension', // needed to know which template to render!
|
||||
locals: { // specifies the template locals.
|
||||
body: message
|
||||
},
|
||||
subject: 'Email Suspension',
|
||||
to: localProfile.id // This only works if the user has registered via e-mail.
|
||||
// We may want a standard way to access a user's e-mail address in the future
|
||||
};
|
||||
|
||||
await MailerService.sendSimple(options);
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -863,11 +874,24 @@ module.exports = class UsersService {
|
||||
* @return {Promise}
|
||||
*/
|
||||
static async editName(id, username) {
|
||||
|
||||
// TODO: Revisit this when we revamped User status workflows.
|
||||
const queryUsernameRejected = {
|
||||
id,
|
||||
username: {$ne: username},
|
||||
status: 'BANNED',
|
||||
canEditName: true
|
||||
};
|
||||
|
||||
const queryCreateUsername = {
|
||||
id,
|
||||
status: 'ACTIVE',
|
||||
canEditName: true
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await UserModel.findOneAndUpdate({
|
||||
id,
|
||||
username: {$ne: username},
|
||||
canEditName: true
|
||||
$or: [queryUsernameRejected, queryCreateUsername],
|
||||
}, {
|
||||
$set: {
|
||||
username: username,
|
||||
|
||||
@@ -3,14 +3,16 @@ const expect = require('chai').expect;
|
||||
const User = require('../../../models/user');
|
||||
const Context = require('../../../graph/context');
|
||||
const errors = require('../../../errors');
|
||||
const SettingsService = require('../../../services/settings');
|
||||
|
||||
describe('graph.Context', () => {
|
||||
beforeEach(() => SettingsService.init());
|
||||
|
||||
describe('#constructor: with a user', () => {
|
||||
let c;
|
||||
|
||||
beforeEach(() => {
|
||||
c = new Context({user: new User({id: '1'})});
|
||||
c = new Context({user: new User({id: '1', roles: ['ADMIN']})});
|
||||
});
|
||||
|
||||
it('creates a context with a user', (done) => {
|
||||
@@ -21,15 +23,10 @@ describe('graph.Context', () => {
|
||||
});
|
||||
|
||||
it('does have access to mutators', () => {
|
||||
return c.mutators.Action.create({
|
||||
item_id: '1',
|
||||
item_type: 'COMMENTS',
|
||||
action_type: 'LIKE'
|
||||
})
|
||||
.then((action) => {
|
||||
expect(action).to.have.property('item_id', '1');
|
||||
expect(action).to.have.property('item_type', 'COMMENTS');
|
||||
expect(action).to.have.property('action_type', 'LIKE');
|
||||
return c.mutators.Tag.add({
|
||||
item_type: 'USERS',
|
||||
id: '1',
|
||||
name: 'Tag',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -48,13 +45,13 @@ describe('graph.Context', () => {
|
||||
});
|
||||
|
||||
it('does not have access to mutators', () => {
|
||||
return c.mutators.Action.create({
|
||||
item_id: '1',
|
||||
return c.mutators.Tag.add({
|
||||
item_type: 'COMMENTS',
|
||||
action_type: 'LIKE'
|
||||
id: '1',
|
||||
name: 'Tag',
|
||||
})
|
||||
.then((action) => {
|
||||
expect(action).to.be.null;
|
||||
.then(() => {
|
||||
throw new Error('should not reach this point');
|
||||
})
|
||||
.catch((err) => {
|
||||
expect(err).to.be.equal(errors.ErrNotAuthorized);
|
||||
|
||||
@@ -54,23 +54,6 @@ describe('/api/v1/account/username', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('it should return an error when the user submits the same username', (done) => {
|
||||
chai.request(app)
|
||||
.post(`/api/v1/users/${mockUser.id}/username-enable`)
|
||||
.set(passport.inject({id: '456', roles: ['ADMIN']}))
|
||||
.then(() => chai.request(app)
|
||||
.put('/api/v1/account/username')
|
||||
.set(passport.inject({id: mockUser.id, roles: []}))
|
||||
.send({username: 'Ana'}))
|
||||
.then(() => {
|
||||
done(new Error('Expected Error'));
|
||||
})
|
||||
.catch((err) => {
|
||||
expect(err).to.be.ok;
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('it should return an error when the user tries to edit their username if canEditName is disabled', (done) => {
|
||||
chai.request(app)
|
||||
.put('/api/v1/account/username')
|
||||
|
||||
@@ -238,6 +238,32 @@ describe('services.UsersService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should let the user submit the same username if user is not banned (create username)', () => {
|
||||
return UsersService
|
||||
.toggleNameEdit(mockUsers[0].id, true)
|
||||
.then(() => UsersService.editName(mockUsers[0].id, mockUsers[0].username))
|
||||
.then(() => UsersService.findById(mockUsers[0].id))
|
||||
.then((user) => {
|
||||
expect(user).to.have.property('username', mockUsers[0].username);
|
||||
expect(user).to.have.property('canEditName', false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when a banned user submits the same username (rejected username)', () => {
|
||||
return UsersService
|
||||
.toggleNameEdit(mockUsers[0].id, true)
|
||||
.then(() => UsersService.setStatus(mockUsers[0].id, 'BANNED'))
|
||||
.then(() => UsersService.editName(mockUsers[0].id, mockUsers[0].username))
|
||||
.then(() => UsersService.findById(mockUsers[0].id))
|
||||
.then(() => {
|
||||
throw new Error('Error expected');
|
||||
})
|
||||
.catch((err) => {
|
||||
expect(err.status).to.equal(400);
|
||||
expect(err.translation_key).to.equal('SAME_USERNAME_PROVIDED');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error if canEditName is false', (done) => {
|
||||
UsersService
|
||||
.editName(mockUsers[0].id, 'Jojo')
|
||||
|
||||
@@ -33,58 +33,6 @@
|
||||
#root > div {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* putting this here until I can get webpack to behave */
|
||||
.react-tagsinput {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
overflow: hidden;
|
||||
padding-left: 5px;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.react-tagsinput--focused {
|
||||
border-color: rgb(142, 76, 65);
|
||||
}
|
||||
|
||||
.react-tagsinput-tag {
|
||||
background-color: rgb(255, 220, 214);
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgb(244, 126, 107);
|
||||
color: rgb(244, 126, 107);
|
||||
display: inline-block;
|
||||
font-family: sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 5px;
|
||||
margin-right: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.react-tagsinput-remove {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
color: rgb(101, 24, 23);
|
||||
}
|
||||
|
||||
.react-tagsinput-tag a::before {
|
||||
content: " ×";
|
||||
}
|
||||
|
||||
.react-tagsinput-input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #777;
|
||||
font-family: sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 6px;
|
||||
margin-top: 1px;
|
||||
outline: none;
|
||||
padding: 5px;
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
</style>
|
||||
<% if (data != null) { %>
|
||||
<script id="data" type="application/json"><%- JSON.stringify(data) %></script>
|
||||
|
||||
@@ -352,9 +352,9 @@ async@2.1.4:
|
||||
dependencies:
|
||||
lodash "^4.14.0"
|
||||
|
||||
async@^2.0.1, async@^2.1.2, async@^2.1.4:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/async/-/async-2.3.0.tgz#1013d1051047dd320fe24e494d5c66ecaf6147d9"
|
||||
async@^2.0.1, async@^2.1.2, async@^2.1.4, async@^2.5.0:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/async/-/async-2.5.0.tgz#843190fd6b7357a0b9e1c956edddd5ec8462b54d"
|
||||
dependencies:
|
||||
lodash "^4.14.0"
|
||||
|
||||
@@ -2041,7 +2041,7 @@ create-hmac@^1.1.0, create-hmac@^1.1.2:
|
||||
create-hash "^1.1.0"
|
||||
inherits "^2.0.1"
|
||||
|
||||
create-react-class@^15.5.1:
|
||||
create-react-class@^15.5.1, create-react-class@^15.5.2:
|
||||
version "15.5.2"
|
||||
resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.5.2.tgz#6a8758348df660b88326a0e764d569f274aad681"
|
||||
dependencies:
|
||||
@@ -6878,6 +6878,13 @@ react-highlight-words@^0.6.0:
|
||||
dependencies:
|
||||
highlight-words-core "^1.0.2"
|
||||
|
||||
react-input-autosize@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-1.1.4.tgz#cbc45072d4084ddc57806db8e3b34e644b8366ac"
|
||||
dependencies:
|
||||
create-react-class "^15.5.2"
|
||||
prop-types "^15.5.8"
|
||||
|
||||
react-linkify@^0.1.3:
|
||||
version "0.1.3"
|
||||
resolved "https://registry.yarnpkg.com/react-linkify/-/react-linkify-0.1.3.tgz#6e886180bda6c8fdc5f9f8a7ebe82fc0f48db7ad"
|
||||
@@ -6931,9 +6938,9 @@ react-router@^3.0.0:
|
||||
prop-types "^15.5.6"
|
||||
warning "^3.0.0"
|
||||
|
||||
react-tagsinput@^3.14.0:
|
||||
version "3.16.1"
|
||||
resolved "https://registry.yarnpkg.com/react-tagsinput/-/react-tagsinput-3.16.1.tgz#dfb3bcbe5fc4430f60c145716c17cdc2613ce117"
|
||||
react-tagsinput@^3.17.0:
|
||||
version "3.17.0"
|
||||
resolved "https://registry.yarnpkg.com/react-tagsinput/-/react-tagsinput-3.17.0.tgz#d699bc7ff3be3492d5a3d2a57f6976908774fba3"
|
||||
|
||||
react-toastify@^1.5.0:
|
||||
version "1.5.0"
|
||||
@@ -7595,6 +7602,10 @@ smart-buffer@^1.0.4:
|
||||
version "1.1.15"
|
||||
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-1.1.15.tgz#7f114b5b65fab3e2a35aa775bb12f0d1c649bf16"
|
||||
|
||||
smoothscroll-polyfill@^0.3.5:
|
||||
version "0.3.5"
|
||||
resolved "https://registry.yarnpkg.com/smoothscroll-polyfill/-/smoothscroll-polyfill-0.3.5.tgz#466e6039b51cb525d70e1a5077ef81e064678eae"
|
||||
|
||||
smtp-connection@2.12.0:
|
||||
version "2.12.0"
|
||||
resolved "https://registry.yarnpkg.com/smtp-connection/-/smtp-connection-2.12.0.tgz#d76ef9127cb23c2259edb1e8349c2e8d5e2d74c1"
|
||||
@@ -8272,6 +8283,10 @@ urijs@1.16.1:
|
||||
version "1.16.1"
|
||||
resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.16.1.tgz#859ad31890f5f9528727be89f1932c94fb4731e2"
|
||||
|
||||
url-search-params@^0.9.0:
|
||||
version "0.9.0"
|
||||
resolved "https://registry.yarnpkg.com/url-search-params/-/url-search-params-0.9.0.tgz#e71d7764a6503533cbfe9771b2963cb61ea1c225"
|
||||
|
||||
url@^0.11.0:
|
||||
version "0.11.0"
|
||||
resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
|
||||
|
||||
Reference in New Issue
Block a user