mirror of
https://github.com/wassname/talk.git
synced 2026-09-14 11:36:51 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f738da4dd5 | ||
|
|
b796bf2607 | ||
|
|
3924ced659 | ||
|
|
23573501bd | ||
|
|
787c00325d | ||
|
|
db371d1c82 | ||
|
|
b744a98a3d | ||
|
|
37439a0896 | ||
|
|
af3cd8aa68 | ||
|
|
7baa551a5c | ||
|
|
b7c4dbd224 | ||
|
|
b28902312d | ||
|
|
f32c03f002 | ||
|
|
693879647e | ||
|
|
c01d1f95bc | ||
|
|
65d6d3221b | ||
|
|
15af61de67 | ||
|
|
1adca208dd | ||
|
|
935982cb08 | ||
|
|
4b9f3390d3 | ||
|
|
f94c36e9c9 | ||
|
|
c976ba0ea4 | ||
|
|
6d71bcbee9 | ||
|
|
9be2a520db | ||
|
|
d155615102 | ||
|
|
4d71a5a448 | ||
|
|
cfa7a74742 | ||
|
|
524be1b869 | ||
|
|
31ab54d13c | ||
|
|
c5203e3460 | ||
|
|
79c1bd9bdd | ||
|
|
e2575cb346 | ||
|
|
3a2a4379b8 | ||
|
|
f1899f2f4a | ||
|
|
4d821afa6a | ||
|
|
c16278221c | ||
|
|
a50b39091f | ||
|
|
4c7506e4f5 | ||
|
|
2f06281439 | ||
|
|
fd1f8b24c1 | ||
|
|
270c3b7634 | ||
|
|
e4647cab98 | ||
|
|
60b4909395 | ||
|
|
0973600e68 | ||
|
|
e3ead31964 | ||
|
|
7858ccc45d | ||
|
|
c1807daeaf | ||
|
|
e244d4a286 | ||
|
|
9de99a9ef4 | ||
|
|
01883851ea | ||
|
|
c8d9447033 | ||
|
|
4319bb9da3 | ||
|
|
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 | ||
|
|
d55fc6b601 | ||
|
|
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 | ||
|
|
3695ce5cb7 | ||
|
|
d68169757d | ||
|
|
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 | ||
|
|
d22f5215ed | ||
|
|
19b6dfaa0a | ||
|
|
b009d7a67a |
@@ -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/*
|
||||
|
||||
+51
-42
@@ -6,16 +6,46 @@ This document is a companion to help you approach contributing. If it does not d
|
||||
|
||||
By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct.html).
|
||||
|
||||
## Product Roadmap
|
||||
## What should I Contribute?
|
||||
|
||||
You can view what the Coral Team is working on next here https://www.pivotaltracker.com/n/projects/1863625.
|
||||
There are at least three ways to contribute to Talk:
|
||||
|
||||
If you'd like to see our longer term roadmap, [get in touch.](https://coralproject.net/contact.html)
|
||||
* Writing Code
|
||||
* Writing Docs
|
||||
* Providing Translations
|
||||
|
||||
## Writing Code
|
||||
|
||||
## Contribute to the documentation
|
||||
Conversation surrounding contributions begins in [issues](https://github.com/coralproject/talk/issues).
|
||||
|
||||
Clear docs are a prerequisite for a successful open source project. We value non-code and code contributions equally.
|
||||
### When should I Create an Issue?
|
||||
|
||||
File an issue as soon as you have an idea of something you'd like to contribute. We would love to hear what you're thinking and help refine the idea to make it into the Talk ecosystem.
|
||||
|
||||
Please file issues if:
|
||||
|
||||
* you would like to contribute to Talk Core.
|
||||
* you are building a Plugin that the current Plugin API doesn't support.
|
||||
* you are building a Plugin and want advice.
|
||||
|
||||
### What should I include?
|
||||
|
||||
Coral has adopted an iterative, agile development philosophy. All contributions that make it into the Talk repo should start with a story or this form:
|
||||
|
||||
`As a [type of person] I'd like to be able to [do something] so that I can [get some result].`
|
||||
|
||||
This exercise does two things:
|
||||
|
||||
* allows us to ground our technical choices in a clear, simple product need.
|
||||
* expresses that product need in a way that doesn't imply a specific technical solution allowing for debate as to the best way to solve the problem.
|
||||
|
||||
Please feel free to provide as much detail as possible when filing the issue but please do keep the initial issue specific to one need and try to avoid including technical or design solutions.
|
||||
|
||||
If you have a specific technical or design solution in mind, please submit it as the first comment on the thread.
|
||||
|
||||
## Contributing Documentation
|
||||
|
||||
Clear docs are a prerequisite for a successful open source project. Contributing to the documentation is often more important than contributing to the code!
|
||||
|
||||
We are looking for _documentarians_ to:
|
||||
|
||||
@@ -23,55 +53,34 @@ We are looking for _documentarians_ to:
|
||||
* create new / missing sections, and
|
||||
* take the lead in making sections, or the over all structure better.
|
||||
|
||||
### But how?
|
||||
Information about how to update docs can be found in our [FAQ](faq.html#how-do-i-contribute-to-these-docs).
|
||||
|
||||
* Our public docs site can be updated [here](https://github.com/coralproject/docs).
|
||||
* [Let us know](https://github.com/coralproject/talk/wiki/Contact-Us) if you'd like permission to update our wiki.
|
||||
* Update any of our .md docs files by following [this guide](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging).
|
||||
* Our API docs need to be kept honest. [Update them here](https://github.com/coralproject/talk/blob/master/docs/swagger.yaml).
|
||||
If you'd like to discuss a contribution, please [file an issue](https://github.com/coralproject/talk/issues) describing the changes you would like to see.
|
||||
|
||||
## Integrate into your environment
|
||||
## Contributing Translations
|
||||
|
||||
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
|
||||
Talk's tranlations are stored in `.yml` files [here](https://github.com/coralproject/talk/tree/master/locales).
|
||||
|
||||
Talk is designed to integrate into existing environments in a variety of ways:
|
||||
Translations can be submitted via pull request. If you do not use github, you can use 'en.yml' as a template and [email](https://coralproject.net/contact.html) the translations to us. We can import it into the repo.
|
||||
|
||||
* [Auth integrations](https://github.com/coralproject/talk/wiki/Security#authentication-strategies)
|
||||
* [Push assets into Talk](https://github.com/coralproject/talk/blob/master/routes/api/assets/index.js)
|
||||
* Monitoring Hooks (coming in 2017)
|
||||
## I want to contribute but I'm not sure what to do!
|
||||
|
||||
If you're considering deploying Talk, [please let us know](https://github.com/coralproject/talk/wiki/Contact-Us)! We are quite literally doing this for you and want to help you succeed any way we can.
|
||||
If you want to contribute but don't have a clear idea of exactly what that may be, here are some resources that may help:
|
||||
|
||||
If you are writing custom integration code in your fork of Talk, please consider keeping it generic and filing a Pull Request to contribute it back to the project! See our [forking and merging guidelines](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging) for more info.
|
||||
### Product Roadmap
|
||||
|
||||
## Write some code
|
||||
Please visit our product roadmap here: https://www.pivotaltracker.com/n/projects/1863625. If you'd like to take on any of our scheduled tasks we'd be forever grateful!
|
||||
|
||||
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
|
||||
### Discussion Forum
|
||||
|
||||
### Build a New Feature / Plugin
|
||||
If you'd like to discuss what we're up to, please visit or [community](https://community.coralproject.net/) or [contact us](https://coralproject.net/contact.html).
|
||||
|
||||
Talk is beginning life as a Commenting Platform, but is architected to support many varieties of community engagement.
|
||||
### Integrations
|
||||
|
||||
Please [contact us](https://github.com/coralproject/talk/wiki/Contact-Us) early and often if you'd like to help. We would love to hear your ideas for features and plugins and help you find a way to productively engage the project.
|
||||
Have a favorite analytics engine? Data science service? CMS? Auth platform? Deployment platform or pipeline? Pet project? Consider building a plugin to integrate them!
|
||||
|
||||
To get an idea of where the Coral Team is going, see:
|
||||
### Favorite Features?
|
||||
|
||||
* our [Slack channel](https://coralprojectslackin.herokuapp.com/), where you can talk directly with the Team and other community members,
|
||||
* our [current stories](https://www.pivotaltracker.com/n/projects/1863625), and
|
||||
* our [issues](https://github.com/coralproject/talk/issues).
|
||||
Do you have a favorite feature of an existing platform that's not yet been done in Talk? Sounds like Talk needs that feature.
|
||||
|
||||
|
||||
Examples:
|
||||
|
||||
* [Add An Emoji Button to Comments](https://github.com/coralproject/talk/wiki/Add-An-Emoji-Button-to-Comments)
|
||||
|
||||
|
||||
### Work on the Core
|
||||
|
||||
There is always more work to be done to make an application more stable, scaleable and secure.
|
||||
|
||||
If you see issues in the code or have ideas on how we may improve Talk, please consider:
|
||||
|
||||
* [contributing a fix](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging),
|
||||
* [filing an issue](https://github.com/coralproject/talk/issues), or
|
||||
* or otherwise [letting us know](https://github.com/coralproject/talk/wiki/Contact-Us).
|
||||
## Thanks!
|
||||
|
||||
+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!
|
||||
|
||||
@@ -9,13 +9,17 @@ const program = require('./commander');
|
||||
|
||||
program
|
||||
.command('serve', 'serve the application')
|
||||
.command('settings', 'interact with the application settings')
|
||||
.command('assets', 'interact with assets')
|
||||
.command('setup', 'setup the application')
|
||||
.command('jobs', 'work with the job queues')
|
||||
.command('token', 'work with the access tokens')
|
||||
.command('users', 'work with the application auth')
|
||||
.command('migration', 'provides utilities for migrating the database')
|
||||
.command('plugins', 'provides utilities for interacting with the plugin system')
|
||||
.command(
|
||||
'plugins',
|
||||
'provides utilities for interacting with the plugin system'
|
||||
)
|
||||
.parse(process.argv);
|
||||
|
||||
/**
|
||||
@@ -25,7 +29,10 @@ program
|
||||
* labled with the PID written out by the parent process.
|
||||
*/
|
||||
process.once('exit', () => {
|
||||
if ((program.runningCommand.killed === false) && (program.runningCommand.exitCode === null)) {
|
||||
if (
|
||||
program.runningCommand.killed === false &&
|
||||
program.runningCommand.exitCode === null
|
||||
) {
|
||||
program.runningCommand.kill('SIGINT');
|
||||
}
|
||||
});
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const program = require('./commander');
|
||||
const inquirer = require('inquirer');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const SettingsService = require('../services/settings');
|
||||
const util = require('./util');
|
||||
|
||||
// Register the shutdown criteria.
|
||||
util.onshutdown([() => mongoose.disconnect()]);
|
||||
|
||||
/**
|
||||
* Change the organization name
|
||||
*/
|
||||
async function changeOrgName() {
|
||||
try {
|
||||
let settings = await SettingsService.retrieve();
|
||||
|
||||
let {organizationName} = await inquirer.prompt([
|
||||
{
|
||||
name: 'organizationName',
|
||||
message: 'Organization Name',
|
||||
default: settings.organizationName
|
||||
}
|
||||
]);
|
||||
|
||||
if (settings.organizationName !== organizationName) {
|
||||
settings.organizationName = organizationName;
|
||||
|
||||
await SettingsService.update(settings);
|
||||
|
||||
console.log('Settings were updated.');
|
||||
} else {
|
||||
console.log('No update needed, no change was made.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
}
|
||||
|
||||
util.shutdown();
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.command('change-org-name')
|
||||
.description('change the organization name')
|
||||
.action(changeOrgName);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
// If there is no command listed, output help.
|
||||
if (!process.argv.slice(2).length) {
|
||||
program.outputHelp();
|
||||
util.shutdown();
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
import {pym} from 'coral-framework';
|
||||
import pym from 'coral-framework/services/pym';
|
||||
import * as actions from '../constants/stream';
|
||||
|
||||
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
.Reply {
|
||||
position: relative;
|
||||
.root {
|
||||
margin-top: 16px;
|
||||
margin-left: 20px;
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.Comment {
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
.rootLevel0 {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
.comment {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.commentLevel0 {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.commentLevel1 {
|
||||
border-left: 3px #212121 solid;
|
||||
}
|
||||
|
||||
.commentLevel2 {
|
||||
border-left: 2px #6a6a6a solid;
|
||||
}
|
||||
|
||||
.commentLevel3 {
|
||||
border-left: 2px #9e9e9e solid;
|
||||
}
|
||||
|
||||
.commentLevel4 {
|
||||
border-left: 2px #c1c1c1 solid;
|
||||
}
|
||||
|
||||
.highlightedComment {
|
||||
padding-left: 20px;
|
||||
border-left: 3px solid rgb(35,118,216);
|
||||
}
|
||||
|
||||
.pendingComment {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
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';
|
||||
import styles from './Comment.css';
|
||||
import {THREADING_LEVEL} from '../constants/stream';
|
||||
import merge from 'lodash/merge';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
|
||||
import {
|
||||
BestButton,
|
||||
@@ -25,7 +27,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 +78,6 @@ const ActionButton = ({children}) => {
|
||||
};
|
||||
|
||||
export default class Comment extends React.Component {
|
||||
isLoadingReplies = false;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -91,6 +92,7 @@ export default class Comment extends React.Component {
|
||||
isEditing: false,
|
||||
replyBoxVisible: false,
|
||||
animateEnter: false,
|
||||
loadingState: '',
|
||||
...resetCursors({}, props),
|
||||
};
|
||||
}
|
||||
@@ -170,7 +172,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 +198,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 +223,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 +249,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;
|
||||
}
|
||||
@@ -309,6 +319,7 @@ export default class Comment extends React.Component {
|
||||
postDontAgree,
|
||||
setActiveReplyBox,
|
||||
activeReplyBox,
|
||||
loadMore,
|
||||
deleteAction,
|
||||
disableReply,
|
||||
maxCharCount,
|
||||
@@ -323,9 +334,13 @@ export default class Comment extends React.Component {
|
||||
} = this.props;
|
||||
|
||||
const view = this.getVisibileReplies();
|
||||
const {loadingState} = this.state;
|
||||
const isReply = !!parentId;
|
||||
const isPending = comment.id.indexOf('pending') >= 0;
|
||||
const isHighlighted = highlighted === comment.id;
|
||||
|
||||
const hasMoreComments = comment.replies && (comment.replies.hasNextPage || comment.replies.nodes.length > view.length);
|
||||
const replyCount = this.hasIgnoredReplies() ? '' : comment.replyCount;
|
||||
const moreRepliesCount = this.hasIgnoredReplies() ? -1 : comment.replyCount - view.length;
|
||||
const flagSummary = getActionSummary('FlagActionSummary', comment);
|
||||
const dontAgreeSummary = getActionSummary(
|
||||
'DontAgreeActionSummary',
|
||||
@@ -338,11 +353,6 @@ export default class Comment extends React.Component {
|
||||
myFlag = dontAgreeSummary.find((s) => s.current_user);
|
||||
}
|
||||
|
||||
let commentClass = parentId
|
||||
? `reply ${styles.Reply}`
|
||||
: `comment ${styles.Comment}`;
|
||||
commentClass += comment.id.indexOf('pending') >= 0 ? ` ${styles.pendingComment}` : '';
|
||||
|
||||
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
|
||||
const notifyOnError = (fn, errorToMessage) =>
|
||||
async function(...args) {
|
||||
@@ -378,7 +388,7 @@ export default class Comment extends React.Component {
|
||||
);
|
||||
|
||||
/**
|
||||
* classNamesToAdd
|
||||
* conditionClassNames
|
||||
* adds classNames based on condition
|
||||
* classnames is an array of objects with key as classnames and value as conditions
|
||||
* i.e:
|
||||
@@ -388,35 +398,45 @@ export default class Comment extends React.Component {
|
||||
*
|
||||
* This will add myClassName to comments tagged with STAFF TAG.
|
||||
* **/
|
||||
|
||||
const classNamesToAdd = commentClassNames.reduce((acc, className) => {
|
||||
let res = [];
|
||||
|
||||
// Adding classNames based on tags
|
||||
Object.keys(className).forEach((cn) => {
|
||||
const condition = className[cn];
|
||||
condition.tags.forEach((tag) => {
|
||||
if (hasTag(comment.tags, tag)) {
|
||||
res = [...res, cn];
|
||||
}
|
||||
});
|
||||
const conditionalClassNames =
|
||||
mapValues(merge({}, ...commentClassNames), (condition) => {
|
||||
if (condition.tags) {
|
||||
return condition.tags.some((tag) => hasTag(comment.tags, tag));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// TODO: Compare rest of the comment obj to the condition if needed
|
||||
const rootClassName = cn(
|
||||
'talk-stream-comment-wrapper',
|
||||
`talk-stream-comment-wrapper-level-${depth}`,
|
||||
styles.root,
|
||||
styles[`rootLevel${depth}`],
|
||||
{
|
||||
...conditionalClassNames,
|
||||
[styles.enter]: this.state.animateEnter,
|
||||
},
|
||||
);
|
||||
|
||||
return [...acc, ...res];
|
||||
}, []);
|
||||
const commentClassName = cn(
|
||||
'talk-stream-comment',
|
||||
`talk-stream-comment-level-${depth}`,
|
||||
styles.comment,
|
||||
styles[`commentLevel${depth}`],
|
||||
{
|
||||
[styles.pendingComment]: isPending,
|
||||
[styles.highlightedComment]: isHighlighted,
|
||||
'talk-stream-pending-comment': isPending,
|
||||
'talk-stream-highlighted-comment': isHighlighted,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(commentClass, 'talk-stream-comment-wrapper', classNamesToAdd, {[styles.enter]: this.state.animateEnter})}
|
||||
className={rootClassName}
|
||||
id={`c_${comment.id}`}
|
||||
style={{marginLeft: depth * 30}}
|
||||
>
|
||||
<hr aria-hidden={true} />
|
||||
<div
|
||||
className={highlighted === comment.id ? 'highlighted-comment' : ''}
|
||||
>
|
||||
{!isReply && <hr aria-hidden={true} />}
|
||||
<div className={commentClassName}>
|
||||
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
|
||||
@@ -469,7 +489,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 +529,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 +548,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}
|
||||
@@ -545,10 +565,10 @@ export default class Comment extends React.Component {
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
parentId={parentId || comment.id}
|
||||
parentId={(depth < THREADING_LEVEL) ? comment.id : parentId}
|
||||
addNotification={addNotification}
|
||||
authorId={currentUser.id}
|
||||
postComment={postComment}
|
||||
currentUser={currentUser}
|
||||
assetId={asset.id}
|
||||
/>
|
||||
: null}
|
||||
@@ -575,6 +595,7 @@ export default class Comment extends React.Component {
|
||||
deleteAction={deleteAction}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
loadMore={loadMore}
|
||||
ignoreUser={ignoreUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
@@ -587,12 +608,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}
|
||||
replyCount={moreRepliesCount}
|
||||
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,39 +1,31 @@
|
||||
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;
|
||||
}
|
||||
|
||||
replyCountFormat = (count) => {
|
||||
if (!count) {
|
||||
return t('framework.view_all_replies_unknown_number');
|
||||
return t('framework.show_all_replies');
|
||||
}
|
||||
if (count === 1) {
|
||||
return t('framework.view_reply');
|
||||
return t('framework.show_1_more_reply');
|
||||
}
|
||||
|
||||
if (this.initialState) {
|
||||
return t('framework.view_all_replies_initial', count);
|
||||
} else {
|
||||
return t('framework.view_all_replies', count);
|
||||
}
|
||||
}
|
||||
|
||||
loadMore = () => {
|
||||
this.initialState = false;
|
||||
this.props.loadMore();
|
||||
return t('framework.show_x_more_replies', count);
|
||||
}
|
||||
|
||||
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 +36,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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, {PropTypes} from 'react';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const NewCount = ({count, loadMore}) => {
|
||||
return <div className='coral-new-comments coral-load-more'>
|
||||
return <div className='talk-new-comments talk-load-more'>
|
||||
{
|
||||
count ?
|
||||
<button onClick={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,8 @@ 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';
|
||||
import {getTopLevelParent} from '../graphql/utils';
|
||||
|
||||
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
|
||||
|
||||
@@ -53,11 +56,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 +73,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 +109,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 +144,10 @@ class Stream extends React.Component {
|
||||
return view;
|
||||
}
|
||||
|
||||
userIsDegraged({auth: {user}} = this.props) {
|
||||
return !can(user, 'INTERACT_WITH_COMMUNITY');
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
commentClassNames,
|
||||
@@ -150,13 +165,12 @@ class Stream extends React.Component {
|
||||
pluginProps,
|
||||
editName
|
||||
} = this.props;
|
||||
const {keepCommentBox, loadingState} = this.state;
|
||||
const view = this.getVisibleComments();
|
||||
const open = asset.closedAt === null;
|
||||
|
||||
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
|
||||
const highlightedComment = comment && comment.parent
|
||||
? comment.parent
|
||||
: comment;
|
||||
const highlightedComment = comment && getTopLevelParent(comment);
|
||||
|
||||
const banned = user && user.status === 'BANNED';
|
||||
const temporarilySuspended =
|
||||
@@ -171,6 +185,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 +221,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 +230,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 +323,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,
|
||||
|
||||
@@ -3,3 +3,4 @@ export const ADDTL_COMMENTS_ON_LOAD_MORE = 10;
|
||||
export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS';
|
||||
export const ADD_COMMENT_CLASSNAME = 'ADD_COMMENT_CLASSNAME';
|
||||
export const REMOVE_COMMENT_CLASSNAME = 'REMOVE_COMMENT_CLASSNAME';
|
||||
export const THREADING_LEVEL = process.env.TALK_THREADING_LEVEL;
|
||||
|
||||
@@ -34,7 +34,7 @@ export default withFragments({
|
||||
}
|
||||
user {
|
||||
id
|
||||
name: username
|
||||
username
|
||||
}
|
||||
action_summaries {
|
||||
__typename
|
||||
|
||||
@@ -7,11 +7,15 @@ import branch from 'recompose/branch';
|
||||
import renderComponent from 'recompose/renderComponent';
|
||||
|
||||
import {Spinner} from 'coral-ui';
|
||||
import {authActions, assetActions, pym} from 'coral-framework';
|
||||
import * as authActions from 'coral-framework/actions/auth';
|
||||
import * as assetActions from 'coral-framework/actions/asset';
|
||||
import pym from 'coral-framework/services/pym';
|
||||
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 +24,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 +107,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 +189,7 @@ const mapDispatchToProps = (dispatch) =>
|
||||
setActiveTab,
|
||||
viewAllComments,
|
||||
fetchAssetSuccess,
|
||||
addNotification,
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
@@ -2,13 +2,14 @@ import React from 'react';
|
||||
import {gql, compose} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream';
|
||||
import {ADDTL_COMMENTS_ON_LOAD_MORE, THREADING_LEVEL} from '../constants/stream';
|
||||
import {
|
||||
withPostComment, withPostFlag, withPostDontAgree, withDeleteAction,
|
||||
withAddTag, withRemoveTag, withIgnoreUser, withEditComment,
|
||||
} from 'coral-framework/graphql/mutations';
|
||||
|
||||
import {notificationActions, authActions} from 'coral-framework';
|
||||
import * as authActions from 'coral-framework/actions/auth';
|
||||
import * as notificationActions from 'coral-framework/actions/notification';
|
||||
import {editName} from 'coral-framework/actions/user';
|
||||
import {setActiveReplyBox} from '../actions/stream';
|
||||
import Stream from '../components/Stream';
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
insertCommentIntoEmbedQuery,
|
||||
removeCommentFromEmbedQuery,
|
||||
insertFetchedCommentsIntoEmbedQuery,
|
||||
nest,
|
||||
} from '../graphql/utils';
|
||||
|
||||
const {showSignInDialog} = authActions;
|
||||
@@ -30,11 +32,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 +51,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 +76,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() {
|
||||
@@ -92,9 +93,7 @@ class StreamContainer extends React.Component {
|
||||
}
|
||||
|
||||
loadNewReplies = (parent_id) => {
|
||||
const comment = this.props.root.comment
|
||||
? this.props.root.comment.parent || this.props.root.comment // highlighted comment.
|
||||
: this.props.root.asset.comments.nodes.filter((comment) => comment.id === parent_id)[0];
|
||||
const comment = findCommentInEmbedQuery(this.props.root, parent_id);
|
||||
|
||||
return this.props.data.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
@@ -158,22 +157,25 @@ const commentFragment = gql`
|
||||
fragment CoralEmbedStream_Stream_comment on Comment {
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies(excludeIgnored: $excludeIgnored) {
|
||||
nodes {
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
${nest(`
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies(excludeIgnored: $excludeIgnored) {
|
||||
nodes {
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
...nest
|
||||
}
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
`, THREADING_LEVEL)}
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
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 +187,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
|
||||
@@ -206,16 +208,19 @@ const LOAD_MORE_QUERY = gql`
|
||||
nodes {
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies(limit: 3, excludeIgnored: $excludeIgnored) {
|
||||
nodes {
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
${nest(`
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies(limit: 3, excludeIgnored: $excludeIgnored) {
|
||||
nodes {
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
...nest
|
||||
}
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
`, THREADING_LEVEL)}
|
||||
}
|
||||
hasNextPage
|
||||
startCursor
|
||||
@@ -230,9 +235,12 @@ const fragments = {
|
||||
fragment CoralEmbedStream_Stream_root on RootQuery {
|
||||
comment(id: $commentId) @include(if: $hasComment) {
|
||||
...CoralEmbedStream_Stream_comment
|
||||
parent {
|
||||
...CoralEmbedStream_Stream_comment
|
||||
}
|
||||
${nest(`
|
||||
parent {
|
||||
...CoralEmbedStream_Stream_comment
|
||||
...nest
|
||||
}
|
||||
`, THREADING_LEVEL)}
|
||||
}
|
||||
asset(id: $assetId, url: $assetUrl) {
|
||||
id
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import update from 'immutability-helper';
|
||||
import {THREADING_LEVEL} from '../constants/stream';
|
||||
|
||||
function applyToCommentsOrigin(root, callback) {
|
||||
if (root.comment) {
|
||||
if (root.comment.parent) {
|
||||
return update(root, {
|
||||
comment: {
|
||||
parent: {
|
||||
$apply: (node) => callback(node),
|
||||
},
|
||||
},
|
||||
});
|
||||
let comment = root.comment;
|
||||
for (let depth = 0; depth <= THREADING_LEVEL; depth++) {
|
||||
let changes = {$apply: (node) => node ? callback(node) : node};
|
||||
for (let i = 0; i < depth; i++) {
|
||||
changes = {parent: changes};
|
||||
}
|
||||
comment = update(comment, changes);
|
||||
}
|
||||
return update(root, {
|
||||
comment: {
|
||||
$apply: (node) => callback(node),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...root,
|
||||
comment,
|
||||
};
|
||||
}
|
||||
return update(root, {
|
||||
asset: {$apply: (asset) => callback(asset)},
|
||||
@@ -97,6 +97,13 @@ export function removeCommentFromEmbedQuery(root, id) {
|
||||
return applyToCommentsOrigin(root, (origin) => findAndRemoveComment(origin, id));
|
||||
}
|
||||
|
||||
export function getTopLevelParent(comment) {
|
||||
if (comment.parent) {
|
||||
return getTopLevelParent(comment.parent);
|
||||
}
|
||||
return comment;
|
||||
}
|
||||
|
||||
function findComment(nodes, callback) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
@@ -119,12 +126,7 @@ export function findCommentInEmbedQuery(root, callbackOrId) {
|
||||
callback = (node) => node.id === callbackOrId;
|
||||
}
|
||||
if (root.comment) {
|
||||
if (callback(root.comment)) {
|
||||
return root.comment;
|
||||
}
|
||||
if (root.comment.parent && callback(root.comment.parent)) {
|
||||
return root.comment.parent;
|
||||
}
|
||||
return findComment([getTopLevelParent(root.comment)], callback);
|
||||
}
|
||||
if (!root.asset.comments) {
|
||||
return false;
|
||||
@@ -179,3 +181,33 @@ function findAndInsertFetchedComments(parent, comments, parent_id) {
|
||||
export function insertFetchedCommentsIntoEmbedQuery(root, comments, parent_id) {
|
||||
return applyToCommentsOrigin(root, (origin) => findAndInsertFetchedComments(origin, comments, parent_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Nest a string in itself repeatly until `level` has been reached.
|
||||
*
|
||||
* Example:
|
||||
* nest(`
|
||||
* a
|
||||
* ...nest
|
||||
* b
|
||||
* `, 2)
|
||||
*
|
||||
* Output:
|
||||
* `
|
||||
* a
|
||||
* a
|
||||
* b
|
||||
* b
|
||||
* `
|
||||
*/
|
||||
export function nest(document, level) {
|
||||
let result = '';
|
||||
for (let x = 0; x < level; x++) {
|
||||
if (x === 0) {
|
||||
result += document;
|
||||
continue;
|
||||
}
|
||||
result = result.replace('...nest', document);
|
||||
}
|
||||
return result.replace('...nest', '');
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import './graphql';
|
||||
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
|
||||
import {getStore, injectReducers} from 'coral-framework/services/store';
|
||||
import {getClient} from 'coral-framework/services/client';
|
||||
import pym from 'coral-framework/services/pym';
|
||||
import AppRouter from './AppRouter';
|
||||
import {pym} from 'coral-framework';
|
||||
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
|
||||
import reducers from './reducers';
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ body {
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 5px 10px 5px 0px;
|
||||
margin: 5px 0px 5px 0px;
|
||||
background: none;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -266,11 +266,6 @@ hr {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.highlighted-comment {
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid rgb(35,118,216);
|
||||
}
|
||||
|
||||
/* Tag Labels */
|
||||
|
||||
.coral-plugin-tag-label {
|
||||
@@ -282,26 +277,16 @@ hr {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Reply styles */
|
||||
|
||||
.comment .reply {
|
||||
margin: 0px 0px 10px 20px;
|
||||
}
|
||||
|
||||
/* Comment Action Styles */
|
||||
|
||||
.commentActionsRight, .replyActionsRight {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 30%;
|
||||
}
|
||||
.commentActionsLeft, .replyActionsLeft {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
float: left;
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.comment__action-container .material-icons {
|
||||
@@ -335,33 +320,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,14 +402,16 @@ button.comment__action-button[disabled],
|
||||
|
||||
/* Load More */
|
||||
|
||||
.coral-load-more {
|
||||
.talk-load-more {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.coral-load-more button {
|
||||
.talk-load-more button {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #FFF;
|
||||
background-color: #2376D8;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
border-radius: 2px;
|
||||
@@ -460,27 +420,46 @@ 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-new-comments {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.coral-new-comments {
|
||||
.talk-load-more-replies {
|
||||
width: 100%;
|
||||
padding-left: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.talk-new-comments {
|
||||
position: relative;
|
||||
top: 1.8em;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.coral-load-more-replies button.coral-load-more, .coral-new-comments button.coral-load-more{
|
||||
.talk-load-more-replies .talk-load-more-button {
|
||||
background-color: transparent;
|
||||
color: #979797;
|
||||
border: #979797 solid 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.talk-load-more-replies .talk-load-more:hover button {
|
||||
background-color: #979797;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.talk-new-comments button.talk-load-more{
|
||||
width: initial;
|
||||
}
|
||||
|
||||
|
||||
@media (min-device-width : 300px) and (max-device-width : 420px) {
|
||||
.commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button {
|
||||
visibility: collapse;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,12 +3,16 @@ import bowser from 'bowser';
|
||||
import * as actions from '../constants/auth';
|
||||
import * as Storage from '../helpers/storage';
|
||||
import coralApi, {base} from '../helpers/request';
|
||||
import pym from '../services/PymConnection';
|
||||
import pym from '../services/pym';
|
||||
|
||||
import {resetWebsocket} from 'coral-framework/services/client';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {isSlotEmpty} from 'plugin-api/beta/client/services';
|
||||
|
||||
export const showSignInDialog = () => (dispatch, getState) => {
|
||||
if (isSlotEmpty('login')) {
|
||||
return;
|
||||
}
|
||||
const signInPopUp = window.open(
|
||||
'/embed/stream/login',
|
||||
'Login',
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import * as authActions from './auth';
|
||||
import * as assetActions from './asset';
|
||||
import * as notificationActions from './notification';
|
||||
|
||||
export default {
|
||||
authActions,
|
||||
assetActions,
|
||||
notificationActions,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import pym from '../services/PymConnection';
|
||||
import pym from '../services/pym';
|
||||
import * as actions from '../constants/notification';
|
||||
|
||||
export const addNotification = (notifType, text) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Component, cloneElement, Children} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {findDOMNode} from 'react-dom';
|
||||
import {pym} from 'coral-framework';
|
||||
import pym from 'coral-framework/services/pym';
|
||||
|
||||
export default class ClickOutside extends Component {
|
||||
static propTypes = {
|
||||
|
||||
@@ -7,17 +7,31 @@ import flatten from 'lodash/flatten';
|
||||
import flattenDeep from 'lodash/flattenDeep';
|
||||
import {getDefinitionName, mergeDocuments} from 'coral-framework/utils';
|
||||
import {loadTranslations} from 'coral-framework/services/i18n';
|
||||
import {injectReducers} from 'coral-framework/services/store';
|
||||
import {injectReducers, getStore} from 'coral-framework/services/store';
|
||||
import camelize from './camelize';
|
||||
|
||||
function getSlotComponents(slot) {
|
||||
const pluginConfig = getStore().getState().config.plugin_config;
|
||||
|
||||
// Filter out components that have been disabled in `plugin_config`
|
||||
return flatten(plugins
|
||||
|
||||
// Filter out components that have been disabled in `plugin_config`
|
||||
.filter((o) => !pluginConfig || !pluginConfig[o.plugin] || !pluginConfig[o.plugin].disable_components)
|
||||
|
||||
.filter((o) => o.module.slots[slot])
|
||||
.map((o) => o.module.slots[slot]));
|
||||
}
|
||||
|
||||
export function isSlotEmpty(slot) {
|
||||
return getSlotComponents(slot).length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns React Elements for given slot.
|
||||
*/
|
||||
export function getSlotElements(slot, props = {}) {
|
||||
const components = flatten(plugins
|
||||
.filter((o) => o.module.slots[slot])
|
||||
.map((o) => o.module.slots[slot]));
|
||||
return components
|
||||
return getSlotComponents(slot)
|
||||
.map((component, i) => React.createElement(component, {key: i, ...props}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import pym from './services/PymConnection';
|
||||
import actions from './actions';
|
||||
|
||||
// TODO (bc): Deprecate old actions. Spreading actions is now needed.
|
||||
|
||||
export default {
|
||||
pym,
|
||||
actions,
|
||||
...actions
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Map, fromJS} from 'immutable';
|
||||
import * as actions from '../constants/auth';
|
||||
import {pym} from 'coral-framework';
|
||||
import pym from 'coral-framework/services/pym';
|
||||
|
||||
const initialState = Map({
|
||||
isLoading: false,
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@ import get from 'lodash/get';
|
||||
import merge from 'lodash/merge';
|
||||
|
||||
import esTA from '../../../node_modules/timeago.js/locales/es';
|
||||
import frTA from '../../../node_modules/timeago.js/locales/fr';
|
||||
import en from '../../../locales/en.yml';
|
||||
import es from '../../../locales/es.yml';
|
||||
import fr from '../../../locales/fr.yml';
|
||||
|
||||
// Translations are happening at https://translate.lingohub.com/the-coral-project/dashboard
|
||||
|
||||
const defaultLanguage = 'en';
|
||||
const translations = {...en, ...es};
|
||||
const translations = {...en, ...es, ...fr};
|
||||
|
||||
let lang;
|
||||
let timeagoInstance;
|
||||
@@ -40,6 +42,7 @@ function init() {
|
||||
}
|
||||
|
||||
ta.register('es', esTA);
|
||||
ta.register('fr', frTA);
|
||||
timeagoInstance = ta();
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import Pym from '../../../node_modules/pym.js';
|
||||
import Pym from 'pym.js';
|
||||
|
||||
const pym = new Pym.Child({polling: 100});
|
||||
export default pym;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
+10
-12
@@ -5,6 +5,8 @@ import {can} from 'coral-framework/services/perms';
|
||||
|
||||
import {PopupMenu, Button} from 'coral-ui';
|
||||
import ClickOutside from 'coral-framework/components/ClickOutside';
|
||||
import cn from 'classnames';
|
||||
import styles from './styles.css';
|
||||
|
||||
const name = 'coral-plugin-flags';
|
||||
|
||||
@@ -39,6 +41,8 @@ export default class FlagButton extends Component {
|
||||
} else {
|
||||
this.setState({showMenu: true});
|
||||
}
|
||||
} else {
|
||||
this.props.addNotification('error', t('error.NOT_AUTHORIZED'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,14 +148,17 @@ export default class FlagButton extends Component {
|
||||
<button
|
||||
ref={(ref) => this.flagButton = ref}
|
||||
onClick={!this.props.banned && !flaggedByCurrentUser && !localPost ? this.onReportClick : null}
|
||||
className={`${name}-button`}>
|
||||
className={cn(`${name}-button`, styles.button)}>
|
||||
{
|
||||
flagged
|
||||
? <span className={`${name}-button-text`}>{t('reported')}</span>
|
||||
: <span className={`${name}-button-text`}>{t('report')}</span>
|
||||
}
|
||||
<i className={`${name}-icon material-icons ${flagged && 'flaggedIcon'}`}
|
||||
style={flagged ? styles.flaggedIcon : {}}
|
||||
<i className={
|
||||
cn(`${name}-icon`, 'material-icons', {
|
||||
flaggedIcon: flagged,
|
||||
[styles.flaggedIcon]: flagged,
|
||||
})}
|
||||
aria-hidden={true}>flag</i>
|
||||
</button>
|
||||
{
|
||||
@@ -212,12 +219,3 @@ export default class FlagButton extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = {
|
||||
flaggedIcon: {
|
||||
color: '#F00'
|
||||
},
|
||||
unflaggedIcon: {
|
||||
color: 'inherit'
|
||||
}
|
||||
};
|
||||
+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,7 @@
|
||||
.button {
|
||||
margin: 5px 0px 5px 10px;
|
||||
}
|
||||
|
||||
.flaggedIcon {
|
||||
color: #f00
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import {bindActionCreators} from 'redux';
|
||||
|
||||
import {withStopIgnoringUser} from 'coral-framework/graphql/mutations';
|
||||
|
||||
import {link} from 'coral-framework/services/PymConnection';
|
||||
import {link} from 'coral-framework/services/pym';
|
||||
import NotLoggedIn from '../components/NotLoggedIn';
|
||||
import IgnoredUsers from '../components/IgnoredUsers';
|
||||
import {Spinner} from 'coral-ui';
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -29,7 +29,7 @@ const CONFIG = {
|
||||
// JWT_ISSUER is the value for the issuer for the tokens that will be verified
|
||||
// when decoding. If `JWT_ISSUER` is not in the environment, then it will try
|
||||
// `TALK_ROOT_URL`, otherwise, it will be undefined.
|
||||
JWT_ISSUER: process.env.TALK_JWT_ISSUER || process.env.TALK_ROOT_URL || undefined,
|
||||
JWT_ISSUER: process.env.TALK_JWT_ISSUER || process.env.TALK_ROOT_URL,
|
||||
|
||||
// JWT_EXPIRY is the time for which a given token is valid for.
|
||||
JWT_EXPIRY: process.env.TALK_JWT_EXPIRY || '1 day',
|
||||
@@ -91,7 +91,9 @@ const CONFIG = {
|
||||
if (process.env.NODE_ENV === 'test' && !CONFIG.JWT_SECRET) {
|
||||
CONFIG.JWT_SECRET = 'keyboard cat';
|
||||
} else if (!CONFIG.JWT_SECRET) {
|
||||
throw new Error('TALK_JWT_SECRET must be provided in the environment to sign/verify tokens');
|
||||
throw new Error(
|
||||
'TALK_JWT_SECRET must be provided in the environment to sign/verify tokens'
|
||||
);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
@@ -108,7 +110,7 @@ if (process.env.NODE_ENV === 'test' && !CONFIG.MONGO_URL) {
|
||||
// Reset the redis url in the event it hasn't been overrided and we are in a
|
||||
// testing environment.
|
||||
if (process.env.NODE_ENV === 'test' && !CONFIG.REDIS_URL) {
|
||||
CONFIG.REDIS_URL = 'redis://localhost';
|
||||
CONFIG.REDIS_URL = 'redis://localhost/1';
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
@@ -119,10 +121,15 @@ if (process.env.NODE_ENV === 'test' && !CONFIG.REDIS_URL) {
|
||||
* This is true when the recaptcha secret is provided and the Recaptcha feature
|
||||
* is to be enabled.
|
||||
*/
|
||||
CONFIG.RECAPTCHA_ENABLED = CONFIG.RECAPTCHA_SECRET && CONFIG.RECAPTCHA_SECRET.length > 0 &&
|
||||
CONFIG.RECAPTCHA_PUBLIC && CONFIG.RECAPTCHA_PUBLIC.length > 0;
|
||||
CONFIG.RECAPTCHA_ENABLED =
|
||||
CONFIG.RECAPTCHA_SECRET &&
|
||||
CONFIG.RECAPTCHA_SECRET.length > 0 &&
|
||||
CONFIG.RECAPTCHA_PUBLIC &&
|
||||
CONFIG.RECAPTCHA_PUBLIC.length > 0;
|
||||
if (!CONFIG.RECAPTCHA_ENABLED) {
|
||||
console.warn('Recaptcha is not enabled for login/signup abuse prevention, set TALK_RECAPTCHA_SECRET and TALK_RECAPTCHA_PUBLIC to enable Recaptcha.');
|
||||
console.warn(
|
||||
'Recaptcha is not enabled for login/signup abuse prevention, set TALK_RECAPTCHA_SECRET and TALK_RECAPTCHA_PUBLIC to enable Recaptcha.'
|
||||
);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
@@ -138,7 +145,13 @@ if (!CONFIG.RECAPTCHA_ENABLED) {
|
||||
];
|
||||
|
||||
if (requiredProps.some((prop) => !CONFIG[prop])) {
|
||||
console.warn(`${requiredProps.map((v) => `TALK_${v}`).join(', ')} should be defined in the environment if you would like to send password reset emails from Talk`);
|
||||
console.warn(
|
||||
`${requiredProps
|
||||
.map((v) => `TALK_${v}`)
|
||||
.join(
|
||||
', '
|
||||
)} should be defined in the environment if you would like to send password reset emails from Talk`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ entries:
|
||||
- title: About
|
||||
url: /index.html
|
||||
output: web
|
||||
- title: Contribute
|
||||
url: /contribute.html
|
||||
output: web
|
||||
- title: FAQ
|
||||
url: /faq.html
|
||||
output: web
|
||||
@@ -18,6 +21,9 @@ entries:
|
||||
- title: Installation
|
||||
output: web
|
||||
folderitems:
|
||||
- title: Getting Started
|
||||
output: web
|
||||
url: /install.html
|
||||
- title: Configuration
|
||||
output: web
|
||||
url: /configuration.html
|
||||
@@ -34,6 +40,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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: Contributor's Guide
|
||||
keywords: homepage
|
||||
sidebar: talk_sidebar
|
||||
permalink: contribute.html
|
||||
summary:
|
||||
---
|
||||
|
||||
Our Contributor's Guide can be found [here](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md).
|
||||
+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
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
+8
-4
@@ -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"
|
||||
@@ -231,9 +235,9 @@ en:
|
||||
success_bio_update: "Your biography has been updated"
|
||||
success_name_update: "Your username has been updated"
|
||||
success_update_settings: "The changes you have made have been applied to the comment stream on this article"
|
||||
view_all_replies_unknown_number: "view all replies"
|
||||
view_all_replies: "view {0} replies"
|
||||
view_all_replies_initial: "view all {0} replies"
|
||||
show_all_replies: Show all replies
|
||||
show_1_more_reply: Show 1 more reply
|
||||
show_x_more_replies: Show {0} more replies
|
||||
view_more_comments: "view more comments"
|
||||
view_reply: "view reply"
|
||||
from_settings_page: "From the Profile Page you can see your comment history."
|
||||
|
||||
+8
-4
@@ -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."
|
||||
@@ -228,9 +232,9 @@ es:
|
||||
success_bio_update: "Tu biografia fue actualizada"
|
||||
success_name_update: "Tu nombre de usuario ha sido actualizado"
|
||||
success_update_settings: "La configuración de este articulo fue actualizada"
|
||||
view_all_replies_unknown_number: "ver todas las respuestas"
|
||||
view_all_replies: "ver {0} respuestas"
|
||||
view_all_replies_initial: "ver todas las {0} respuestas"
|
||||
show_all_replies: "Mostrar todas las respuestas"
|
||||
show_1_more_reply: "Mostrar 1 respuesta más"
|
||||
show_x_more_replies: "Mostrar {0} respuestas más"
|
||||
view_more_comments: "Ver más comentarios"
|
||||
view_reply: "ver respuesta"
|
||||
from_settings_page: "Desde la página de configuración puedes ver tu historia de comentarios."
|
||||
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
fr:
|
||||
bandialog:
|
||||
are_you_sure: "Êtes-vous sûr de vouloir bannir {0}?"
|
||||
ban_user: "Bannir l'utilisateur ?"
|
||||
banned_user: "Utilisateur banni"
|
||||
cancel: Annuler
|
||||
note: "Remarque: bannir cet utilisateur rejettera également ce commentaire."
|
||||
yes_ban_user: "Oui, bannir cet utilisateur"
|
||||
bio_offensive: "Cette biographie est offensante"
|
||||
cancel: Annuler
|
||||
characters_remaining: "caractères restants"
|
||||
comment:
|
||||
anon: Anonyme
|
||||
ban_user: "Bannir utilisateur"
|
||||
comment: "Publier un commentaire"
|
||||
flagged: signalé
|
||||
view_context: "Afficher le contexte"
|
||||
comment_box:
|
||||
post: "Publier"
|
||||
cancel: "Annuler"
|
||||
reply: "Répondre"
|
||||
comment: "Publier un commentaire"
|
||||
name: "Nom"
|
||||
comment_post_notif: "Votre commentaire a été publié."
|
||||
comment_post_notif_premod: "Merci d'avoir envoyé un commentaire. Notre équipe de modération passera en revue votre commentaire sous peu."
|
||||
comment_post_banned_word: "Votre commentaire contient un ou plusieurs mots qui ne sont pas autorisés, il ne sera pas publié. Si vous pensez qu'il y a une erreur, contactez notre équipe de modération."
|
||||
characters_remaining: "caractères restants"
|
||||
comment_is_best: "Ce commentaire est l'un des meilleurs"
|
||||
comment_offensive: "Ce commentaire est offensant"
|
||||
comment_plural: Commentaires
|
||||
comment_post_banned_word: "Votre commentaire contient un ou plusieurs mots qui ne sont pas autorisés, il ne sera pas publié. Si vous pensez qu'il y a une erreur, contactez notre équipe de modération."
|
||||
comment_post_notif: "Votre commentaire a été publié."
|
||||
comment_post_notif_premod: "Merci d'avoir envoyé un commentaire. Notre équipe de modération passera en revue votre commentaire sous peu."
|
||||
community:
|
||||
account_creation_date: "Date de création du compte"
|
||||
active: Actif
|
||||
admin: Administrateur
|
||||
ads_marketing: "Cela ressemble à du marketing / un message publicitaire"
|
||||
are_you_sure: "Êtes-vous sûr de vouloir bannir {0} ?"
|
||||
ban_user: "Bannir l'utilisateur ?"
|
||||
banned: Banni
|
||||
banned_user: "Utilisateur banni"
|
||||
cancel: Signalé
|
||||
dont_like_username: "Je n'aime pas ce nom d'utilisateur"
|
||||
flaggedaccounts: "Noms d'utilisateurs signalés"
|
||||
flags: Signalements
|
||||
impersonating: "Cet utilisateur se fait passer pour quelqu'un d'autre"
|
||||
loading: "Chargement des résultats"
|
||||
moderator: Modérateur
|
||||
newsroom_role: "Rôle de la salle de presse"
|
||||
no_flagged_accounts: "La liste des comptes signalés est vide."
|
||||
no_results: "Aucun utilisateur n'a été trouvé avec ce nom d'utilisateur ou cette adresse de messagerie. Ils se cachent !"
|
||||
note: "Remarque: bannir cet utilisateur ne lui permettra pas de modifier les commentaires ou de supprimer quoi que ce soit."
|
||||
offensive: "Ce commentaire est offensant"
|
||||
other: Autre
|
||||
people: Gens
|
||||
role: "Sélectionnez le rôle ..."
|
||||
select_status: "Sélectionnez l'état ..."
|
||||
spam_ads: "Spam / Annonces"
|
||||
staff: "Équipe"
|
||||
status: Statut
|
||||
username_and_email: "Nom d'utilisateur et e-mail"
|
||||
yes_ban_user: "Oui bannir l'utilisateur"
|
||||
configure:
|
||||
apply: Appliquer
|
||||
banned_word_text: "Les commentaires qui contiennent ces mots ou ces expressions (non sensibles à la casse) seront automatiquement supprimés du flux de commentaires. Tapez un mot et appuyez sur Entrée ou Tabulation pour ajouter. En option, envoyez une liste séparée par des virgules."
|
||||
banned_words_title: "Liste des mots interdits"
|
||||
close: "Fermer"
|
||||
close_after: "Fermer les commentaires après"
|
||||
close_stream: "Fermer le fil"
|
||||
close_stream_configuration: "Ce fil de commentaires est actuellement fermé. En ouvrant ce fil commentaire, de nouveaux commentaires peuvent être soumis et publiés."
|
||||
closed_comments_desc: "Écrivez un message à afficher lorsque votre fil de commentaires est fermé et n'accepte plus de nouveaux commentaires."
|
||||
closed_comments_label: "Écrire un message..."
|
||||
closed_stream_settings: "Message de fil de commentaires fermé"
|
||||
comment_count_error: "S'il vous plait, entrez un nombre valide."
|
||||
comment_count_header: "Limiter la longueur d'un commentaire"
|
||||
comment_count_text_post: caractères
|
||||
comment_count_text_pre: "Les commentaires seront limités à"
|
||||
comment_settings: Paramètres
|
||||
comment_stream: "Fil de commentaires"
|
||||
comment_stream_will_close: "Le fil de commentaires va se fermer dans"
|
||||
community: Communauté
|
||||
configure: Configurer
|
||||
copy_and_paste: "Copiez et collez le code ci-dessous dans votre CMS pour intégrer votre boîte de commentaires dans vos articles."
|
||||
custom_css_url: "URL CSS personnalisée"
|
||||
custom_css_url_desc: "URL d'une feuille de style CSS qui remplacera les styles par défaut d'intégration des commentaires. Peut être interne ou externe."
|
||||
dashboard: Tableau de bord
|
||||
days: Journées
|
||||
description: "En tant qu'administrateur, vous pouvez personnaliser les paramètres du fil de commentaires pour cet élément."
|
||||
domain_list_text: "Entrez les domaines que vous souhaitez autoriser pour Talk, par exemple vos environnements locaux de production et de production (ex : localhost: 3000 staging.domain.com domain.com)."
|
||||
domain_list_title: "Domaines autorisés"
|
||||
edit_comment_timeframe_heading: "Modifier l'horodatage des commentaires"
|
||||
edit_comment_timeframe_text_pre: "Les commentateurs auront"
|
||||
edit_comment_timeframe_text_post: "secondes pour modifier leurs commentaires."
|
||||
embed_comment_stream: "Intégrer le fil"
|
||||
enable_premod_links_text: "Les modérateurs doivent approuver tout commentaire contenant un lien avant sa publication."
|
||||
enable_pre_moderation: "Activer modération a priori"
|
||||
enable_pre_moderation_text: "Les modérateurs doivent approuver tout commentaire avant qu'il ne soit publié."
|
||||
enable_premod_links: "Modérer les commentaires contenant des liens"
|
||||
enable_premod: "Activer la modération a priori"
|
||||
enable_premod_description: "Les modérateurs doivent approuver tout commentaire avant sa publication."
|
||||
enable_premod_links_description: "Les modérateurs doivent approuver tout commentaire contenant un lien avant sa publication."
|
||||
enable_questionbox: "Posez une question aux lecteurs."
|
||||
enable_questionbox_description: "Cette question apparaîtra en haut de ce fil de commentaires. Demandez aux lecteurs de s'exprimer sur un sujet évoqué dans l'article ou posez les termes de la discussion, etc."
|
||||
hours: Heures
|
||||
include_comment_stream: "Inclure la description du fil de commentaires pour les lecteurs"
|
||||
include_comment_stream_desc: "Écrivez un message à ajouter au haut de votre flux de commentaires. Publiez un sujet de discussion, avec des suggestion sur la tenue des débats, etc."
|
||||
include_text: "Entrez votre texte ici."
|
||||
include_question_here: "Écrivez votre question ici."
|
||||
moderate: Modérer
|
||||
moderation_settings: "Paramètres de la modération"
|
||||
open: "Ouvert"
|
||||
open_stream: "Ouvrir le fil"
|
||||
open_stream_configuration: "Ce fil de commentaires est actuellement ouvert. En fermant ce fil de commentaires, aucun nouveau commentaire ne pourra être envoyé, les commentaires précédents seront toujours affichés."
|
||||
require_email_verification: "Exiger une vérification de l'e-mail"
|
||||
require_email_verification_text: "Les nouveaux utilisateurs doivent vérifier leur adresse e-mail avant de commenter."
|
||||
save_changes: "Sauvegarder les changements"
|
||||
shortcuts: Raccourcis
|
||||
sign_out: "Se Déconnecter"
|
||||
stories: Histoires
|
||||
stream_settings: "Paramètres du fil"
|
||||
suspect_word_title: "Liste des mots suspects"
|
||||
suspect_word_text: "Les commentaires contenant ces mots ou expressions (non sensibles à la casse) seront mis en évidence dans le flux de commentaires. Tapez un mot et appuyez sur Entrée ou Tabulation pour ajouter. En option, entrez une liste séparée par des virgules."
|
||||
tech_settings: "Paramètres techniques"
|
||||
title: "Configurer le fil de commentaires"
|
||||
weeks: Semaines
|
||||
wordlist: "Mots interdits"
|
||||
continue: Continuer
|
||||
dashboard:
|
||||
auto_update: "Les données sont automatiquement mises à jour toutes les cinq minutes ou lorsque vous rechargez."
|
||||
comment_count: commentaires
|
||||
flags: signalements
|
||||
most_flags: "Articles avec le plus de signalements"
|
||||
most_conversations: "Articles avec le plus de conversations"
|
||||
next_update: "{0} minutes jusqu'à la prochaine mise à jour."
|
||||
no_activity: "Il n'y a eu aucun commentaire nulle part dans les cinq dernières minutes."
|
||||
no_flags: "Il n'y a pas eu aucun signalement au cours des 5 dernières minutes! Hooray !"
|
||||
no_likes: "Il n'y a pas eu de \"J'aime\" au cours des 5 dernières minutes. Tout est calme."
|
||||
done: Terminé
|
||||
edit_comment:
|
||||
body_input_label: "Modifier ce commentaire"
|
||||
save_button: "Sauvegarder les changements"
|
||||
edit_window_expired: "Vous ne pouvez plus modifier ce commentaire. La fenêtre de temps pour le faire a expiré. Pourquoi ne pas en publier un autre ?"
|
||||
edit_window_expired_close: "Fermer"
|
||||
edit_window_timer_prefix: "Fenêtre d'édition :"
|
||||
second: "seconde"
|
||||
seconds_plural: "secondes"
|
||||
unexpected_error: "Erreur inattendue lors de l'enregistrement des modifications. Désolé !"
|
||||
embedlink:
|
||||
copy: "Copier dans le presse-papier"
|
||||
error:
|
||||
COMMENT_TOO_SHORT: "Votre commentaire doit contenir quelque chose"
|
||||
NOT_AUTHORIZED: "Vous n'êtes pas autorisé à effectuer cette action."
|
||||
NO_SPECIAL_CHARACTERS: "Les noms d'utilisateur ne peuvent contenir que des lettres, des chiffres et \"_\" seulement"
|
||||
PASSWORD_LENGTH: "Le mot de passe est trop court"
|
||||
PROFANITY_ERROR: "Les noms d'utilisateur ne doivent pas contenir de mots offensants. Veuillez contacter l'administrateur si vous pensez qu'il y a une erreur."
|
||||
USERNAME_IN_USE: "Ce nom d'utilisateur est déjà pris"
|
||||
USERNAME_REQUIRED: "Doit entrer un nom d'utilisateur"
|
||||
EDIT_WINDOW_ENDED: "Vous ne pouvez plus modifier ce commentaire. La fenêtre de temps pour le faire a expiré."
|
||||
EDIT_USERNAME_NOT_AUTHORIZED: "Vous n'avez pas la permission de mettre à jour votre nom d'utilisateur."
|
||||
EMAIL_IN_USE: "Adresse e-mail déjà utilisée"
|
||||
EMAIL_REQUIRED: "Une adresse email est requise"
|
||||
LOGIN_MAXIMUM_EXCEEDED: "Vous avez effectué trop de tentatives infructueuses pour entrer votre mot de passe. S'il vous plaît, attendez."
|
||||
PASSWORD_REQUIRED: "Doit entrer un mot de passe"
|
||||
COMMENTING_CLOSED: "Les commentaires sont déjà fermés"
|
||||
NOT_FOUND: "Ressource introuvable"
|
||||
INVALID_ASSET_URL: "L'URL est invalide"
|
||||
email: "Pas une adresse e-mail valide"
|
||||
confirm_password: "Les mots de passe ne correspondent pas. Vérifiez à nouveau"
|
||||
network_error: "Échec de connexion au serveur. Vérifiez votre connexion Internet et réessayez."
|
||||
email_not_verified: "L'adresse e-mail {0} n'est pas vérifiée."
|
||||
email_password: "Combinaison e-mail / mot de passe incorrecte."
|
||||
organization_name: "Le nom de l'organisation ne peut contenir que des lettres ou des chiffres."
|
||||
password: "Le mot de passe doit être d'au moins 8 caractères"
|
||||
username: "Les noms d'utilisateur ne peuvent contenir que des chiffres, des lettres et \"_\""
|
||||
flag_comment: "Signaler un commentaire"
|
||||
flag_reason: "Motif du signalement (facultatif)"
|
||||
flag_username: "Signaler un nom d'utilisateur"
|
||||
framework:
|
||||
banned_account_msg: "Votre compte est actuellement suspendu. Cela signifie que vous ne pouvez pas Aimer Signaler ou Écrire des commentaires. Contactez-nous si vous avez des questions."
|
||||
comment: commentaire
|
||||
comment_is_ignored: "Ce commentaire est caché car vous avez ignoré cet utilisateur."
|
||||
comments: commentaires
|
||||
configure_stream: "Configure le fil"
|
||||
content_not_available: "Ce contenu n'est pas disponible"
|
||||
edit_name:
|
||||
button: Soumettre
|
||||
error: "Les noms d'utilisateur ne peuvent contenir que des chiffres, des lettres et \"_\""
|
||||
label: "Nouveau nom d'utilisateur"
|
||||
msg: "Votre compte est actuellement suspendu car votre nom d'utilisateur a été jugé inapproprié. Pour restaurer votre compte, entrez un nouveau nom d'utilisateur. Contactez-nous si vous avez des questions."
|
||||
ignored_users: "Utilisateurs ignorés"
|
||||
my_comments: "Mes commentaires"
|
||||
my_profile: "Mon profil"
|
||||
new_count: "Voir {0} nouveau {1}"
|
||||
profile: Profil
|
||||
show_all_comments: "Afficher tous les commentaires"
|
||||
success_bio_update: "Votre biographie a été mise à jour"
|
||||
success_name_update: "Votre nom d'utilisateur a été mis à jour"
|
||||
success_update_settings: "Les modifications que vous avez apportées ont été appliqué es au fil de commentaires sur cet article"
|
||||
view_all_replies: "Voir {0} réponses"
|
||||
view_all_replies_initial: "Voir toutes les {0} réponses"
|
||||
view_more_comments: "Voir plus de commentaires"
|
||||
view_reply: "Voir la réponse"
|
||||
from_settings_page: "Dans la page Profil, vous pouvez voir l'historique de vos commentaires."
|
||||
like: "J'aime"
|
||||
loading_results: "Chargement des résultats"
|
||||
marketing: "Cela ressemble à du marketing / une publicité"
|
||||
moderate_this_stream: "Modifiez ce fil"
|
||||
modqueue:
|
||||
account: "Signalements du compte"
|
||||
actions: Actions
|
||||
all: tous
|
||||
all_streams: "Tous les fils"
|
||||
approve: "Approuver"
|
||||
approved: "Approuvé"
|
||||
ban_user: "Bannir"
|
||||
billion: B
|
||||
close: Fermer
|
||||
dont_like_username: "N'aime pas le nom d'utilisateur"
|
||||
empty_queue: "Plus de commentaires à modérer ! Vous avez terminé, allez prendre un ☕️"
|
||||
flagged: signalé
|
||||
impersonating: "problème d'identité"
|
||||
less_detail: "Moins de détails"
|
||||
likes: "J'aime"
|
||||
million: M
|
||||
mod_faster: "Modérer plus rapidement avec les raccourcis clavier"
|
||||
moderate: "Modérer →"
|
||||
more_detail: "Plus de détails"
|
||||
newest_first: "Le plus récent d'abord"
|
||||
navigation: Navigation
|
||||
next_comment: "Aller au prochain commentaire"
|
||||
offensive: Offensant
|
||||
oldest_first: "Le plus ancien d'abord"
|
||||
other: Autre
|
||||
premod: Pre-modérer
|
||||
prev_comment: "Aller au commentaire précédent"
|
||||
reject: "Rejeter"
|
||||
rejected: "Rejeté"
|
||||
select_stream: "Sélectionnez le fil"
|
||||
shift_key: ⇧
|
||||
shortcuts: Raccourcis
|
||||
show_shortcuts: "Afficher les raccourcis"
|
||||
singleview: "Passer en mode d'édition de commentaire unique"
|
||||
spam_ads: Spam / Publicités
|
||||
thismenu: "Ouvrir ce menu"
|
||||
thousand: k
|
||||
try_these: "Essayez ces"
|
||||
view_more_shortcuts: "Afficher plus de raccourcis"
|
||||
my_comment_history: "Mon historique de commentaires"
|
||||
name: Nom
|
||||
no_agree_comment: "Je ne suis pas d'accord avec ce commentaire"
|
||||
no_like_bio: "Je n'aime pas cette biographie"
|
||||
no_like_username: "Je n'aime pas ce nom d'utilisateur"
|
||||
other: Autre
|
||||
permalink: Lien
|
||||
personal_info: "Ce commentaire révèle des informations personnelles identifiables"
|
||||
post: Publier
|
||||
profile: Profil
|
||||
profile_settings: "Paramètres du profil"
|
||||
reply: Répondre
|
||||
report: Signaler
|
||||
report_notif: "Merci de signaler ce commentaire. Notre équipe de modération a é té informée."
|
||||
report_notif_remove: "Votre signalement a été supprimé."
|
||||
reported: Signalé
|
||||
set_best: "Sélectionner comme le meilleur"
|
||||
settings:
|
||||
all_comments: "Tous les commentaires"
|
||||
from_settings_page: "Dans la page Profil, vous pouvez voir l'historique de vos commentaires."
|
||||
my_comment_history: "Mon historique de commentaires"
|
||||
profile: Profil
|
||||
profile_settings: "Paramètres du profil"
|
||||
sign_in: "Se connecter"
|
||||
to_access: "Accéder au profil"
|
||||
user_no_comment: "Vous n'avez jamais laissé de commentaire. Rejoignez la conversation!"
|
||||
stream:
|
||||
temporarily_suspended: "Conformément à la charte d'utilisation des commentaires de {0}, votre compte a été temporairement suspendu. Merci de revenir dans la conversation {1}."
|
||||
step_1_header: "Signaler un problème"
|
||||
step_2_header: "Aidez-nous à comprendre"
|
||||
step_3_header: "Merci pour votre participation"
|
||||
streams:
|
||||
all: Tous
|
||||
article: Récit
|
||||
closed: Fermé
|
||||
empty_result: "Aucun contenu ne correspond à cette recherche. Peut-être essayer d'élargir votre recherche ?"
|
||||
filter_streams: "Filtrer les flux"
|
||||
newest: Le plus récent
|
||||
oldest: Le plus ancien
|
||||
open: Ouvrir
|
||||
pubdate: "Date de publication"
|
||||
search: Recherche
|
||||
sort_by: "Trier par"
|
||||
status: "Statut du fil"
|
||||
stream_status: "Statut du fil"
|
||||
suspenduser:
|
||||
bio: Bio
|
||||
cancel: "Annuler"
|
||||
days: "{0} jours"
|
||||
description_0: "Voulez-vous interdire temporairement cet utilisateur en raison de leur {0} ? Cela dissimulera temporairement leurs commentaires jusqu'à ce qu'ils réécrivent leurs {0}."
|
||||
description_1: "Suspendre cet utilisateur désactivera temporairement son compte et cachera tous ses commentaires sur le site."
|
||||
description_notify: "Suspendre cet utilisateur désactivera temporairement son compte et cachera tous ses commentaires sur le site."
|
||||
description_reject: "Voulez-vous interdire temporairement cet utilisateur en raison de leur {0}? Cela dissimulera temporairement leurs commentaires jusqu'à ce qu'ils réécrivent leurs {0}."
|
||||
description_suspend: "Vous suspendez {0}. Ce commentaire sera rejeté, et {0} ne sera pas autorisé à aimer, à signaler, à répondre ou à publier jusqu'à ce que la suspension soit levée."
|
||||
email: "Un autre membre de la communauté a récemment signalé votre nom d'utilisateur pour examen. En raison de son contenu, votre utilisateur a été rejeté. Cela signifie que vous ne pouvez plus commenter ou signaler du contenu jusqu'à ce que vous changiez votre nom d'utilisateur. Veuillez nous envoyer un e-mail si vous avez des questions ou des préoccupations."
|
||||
email_subject: "Votre compte a été suspendu"
|
||||
email_message_reject: "Un autre membre de la communauté a récemment signalé votre nom d'utilisateur pour examen. En raison de son contenu, votre nom d'utilisateur a été rejeté. Cela signifie que vous ne pouvez plus commenter, ni signaler du contenu jusqu'à ce que vous changiez votre nom d'utilisateur. Veuillez nous envoyer un e-mail si vous avez des questions ou des préoccupations."
|
||||
email_message_suspend: "Cher {0},\n\nConformément à la charte des commentaires de {1}, votre compte a été temporairement suspendu. Pendant cette période, vous ne pourrez pas commenter, signaler ou participer à d'autres commentaires. \n\nMerci de revenir dans la conversation {2}."
|
||||
error_email_message_empty: "Vous devez spécifier un message dans l'e-mail."
|
||||
hours: "{0} heures"
|
||||
no_cancel: "Pas d'annulation"
|
||||
notify_suspend_until: "L'utilisateur {0} a été temporairement suspendu. Cette suspension se terminera automatiquement {1}."
|
||||
one_hour: "1 heure"
|
||||
send: Envoyer
|
||||
select_duration: "Sélectionnez la durée de la suspension"
|
||||
suspend_user: "Suspendre l'utilisateur"
|
||||
title: "Suspendre un utilisateur"
|
||||
title_0: "Nous avons remarqué que vous avez rejeté un nom d'utilisateur"
|
||||
title_1: "Aviser l'utilisateur de sa suspension temporaire"
|
||||
title_notify: "Aviser l'utilisateur de sa suspension temporaire"
|
||||
title_reject: "Nous avons remarqué que vous avez rejeté un nom d'utilisateur"
|
||||
title_suspend: "Suspendre l'utilisateur"
|
||||
username: "Nom d'utilisateur"
|
||||
write_message: "Écrire un message"
|
||||
yes_suspend: "Oui suspendre"
|
||||
thank_you: "Nous apprécions vos commentaires. Un modérateur passera en revue votre signalement. Merci pour votre aide."
|
||||
unset_best: "Déselectionner comme le meilleur"
|
||||
user:
|
||||
bio_flags: "Signaler pour cette biographie"
|
||||
user_bio: "Bio de l'utilisateur"
|
||||
username_flags: "Signaler pour ce nom d'utilisateur"
|
||||
user_impersonating: "Cet utilisateur se fait passer pour quelqu'un d'autre"
|
||||
user_no_comment: "Vous n'avez jamais laissé de commentaire. Rejoignez la conversation !"
|
||||
username_offensive: "Ce nom d'utilisateur est offensant"
|
||||
view_conversation: "Afficher la conversation"
|
||||
install:
|
||||
initial:
|
||||
description: "Configurez votre communauté Talk en quelques petites étapes."
|
||||
submit: "Commencer"
|
||||
add_organization:
|
||||
description: "Veuillez nous indiquer le nom de votre organisation. Cela apparaîtra dans les e-mails lors de l'invitation de nouveaux membres dans l'équipe."
|
||||
label: "Nom de l'organisation"
|
||||
save: "Sauvegarder"
|
||||
create:
|
||||
email: "Adresse e-mail"
|
||||
username: "Nom d'utilisateur"
|
||||
password: "Mot de passe"
|
||||
confirm_password: "Confirmez Le mot de passe"
|
||||
save: "Sauvegarder"
|
||||
permitted_domains:
|
||||
title: "Domaines autorisés"
|
||||
description: "Entrez les domaines que vous souhaitez autoriser pour Talk, par ex. vos environnements locaux, de déploiement et de production (ex localhost:3000, staging.domain.com, domain.com)."
|
||||
submit: "Terminer l'installation"
|
||||
final:
|
||||
description: "Merci d'avoir installé Talk ! Nous avons envoyé un e-mail pour vérifier votre adresse électronique. Pendant que vous terminez la configuration, vous pouvez commencer à engager vos lecteurs dès maintenant."
|
||||
launch: "Lancer Talk"
|
||||
close: "Fermez cet installateur"
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "talk",
|
||||
"version": "2.1.0",
|
||||
"version": "2.3.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({
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export {t} from 'coral-framework/services/i18n';
|
||||
export {can} from 'coral-framework/services/perms';
|
||||
export {isSlotEmpty} from 'coral-framework/helpers/plugins';
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -89,3 +89,47 @@ es:
|
||||
username: Nombre
|
||||
write_your_username: "Edita tu nombre"
|
||||
your_username: "Tu nombre aparece en cada comentario que publiques."
|
||||
fr:
|
||||
sign_in:
|
||||
email_verify_cta: "Merci de vérifier votre adresse e-mail."
|
||||
request_new_verify_email: "Demander un nouvel envoi d'e-mail :"
|
||||
verify_email: "Merci d'avoir créé un compte ! Nous avons envoyé un courrier électronique à l'adresse que vous avez fournie pour vérifier votre adresse e-mail."
|
||||
verify_email2: "Vous devez vérifier votre adresse e-mail avant de vous engager auprès de la communauté."
|
||||
not_you: "Pas vous ?"
|
||||
logged_in_as: "Connecté en tant que"
|
||||
facebook_sign_in: "Connectez-vous avec Facebook"
|
||||
facebook_sign_up: "Inscrivez-vous avec Facebook"
|
||||
logout: "Se déconnecter"
|
||||
sign_in: "Se connecter"
|
||||
sign_in_to_join: "Connectez-vous pour participer à la conversation"
|
||||
or: "Ou"
|
||||
email: "Adresse e-mail"
|
||||
password: "Mot de passe"
|
||||
forgot_your_pass: "Mot de passe oublié ?"
|
||||
need_an_account: "Besoin d'un compte ?"
|
||||
register: "Inscription"
|
||||
sign_up: "S'inscrire"
|
||||
confirm_password: "Confirmez Le mot de passe"
|
||||
username: "Nom d'utilisateur"
|
||||
already_have_an_account: "Vous avez déjà un compte ?"
|
||||
recover_password: "Récupérer le mot de passe"
|
||||
email_in_use: "L'adresse e-mail est déjà utilisée"
|
||||
email_or_username_in_use: "Adresse e-mail ou nom d'utilisateur déjà utilisé"
|
||||
required_field: "Ce champ est obligatoire"
|
||||
passwords_dont_match: "Les mots de passe ne correspondent pas."
|
||||
special_characters: "Les noms d'utilisateur ne peuvent contenir que des lettres, des chiffres et \"_\" seulement"
|
||||
sign_in_to_comment: "Connectez-vous pour commenter"
|
||||
check_the_form: "Formulaire non valide. Veuillez vérifier les champs"
|
||||
createdisplay:
|
||||
check_the_form: "Formulaire non valide. Veuillez vérifier les champs"
|
||||
continue: "Continuez avec le même nom d'utilisateur que sur Facebook"
|
||||
error_create: "Erreur lors de la modification du nom d'utilisateur"
|
||||
fake_comment_body: "Ceci est un exemple de commentaire. Les lecteurs peuvent partager leurs pensées et opinions avec les rédactions dans la section des commentaires."
|
||||
fake_comment_date: "Il y a 1 minute"
|
||||
if_you_dont_change_your_name: "Si vous ne modifiez pas votre nom d'utilisateur à cette étape, votre nom d'affichage Facebook apparaîtra à côté de tous vos commentaires."
|
||||
required_field: "champs requis"
|
||||
save: Sauvegarder
|
||||
special_characters: "Les noms d'utilisateur ne peuvent contenir que des chiffres, des lettres et \"_\""
|
||||
username: "Nom d'utilisateur"
|
||||
write_your_username: "Modifier votre nom d'utilisateur"
|
||||
your_username: "Votre nom d'utilisateur apparaît sur chaque commentaire que vous publiez."
|
||||
|
||||
@@ -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"] }]
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user