mirror of
https://github.com/wassname/talk.git
synced 2026-09-14 11:36:51 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
524d432ec1 | ||
|
|
99ca2cd631 | ||
|
|
c91727e22f | ||
|
|
7933948a89 | ||
|
|
997675cac5 | ||
|
|
562f2350c1 | ||
|
|
ea6330bf94 | ||
|
|
0be3dc2ceb | ||
|
|
eb98b32d21 | ||
|
|
127ee25d7f | ||
|
|
ed4a3c3250 | ||
|
|
7b53048a91 | ||
|
|
28e94d30b0 | ||
|
|
1c2fec29a4 | ||
|
|
56cc4989fd | ||
|
|
b32aebd6f1 | ||
|
|
244a276e1a | ||
|
|
245561bd52 | ||
|
|
d05a63bc2e | ||
|
|
5d118c26d1 | ||
|
|
5ab5b7ca70 | ||
|
|
b79c990ee5 | ||
|
|
1541a618b5 | ||
|
|
30b200d734 | ||
|
|
dc76d89c47 | ||
|
|
3d80df1db6 | ||
|
|
83832abd72 | ||
|
|
47fd522f68 | ||
|
|
1280b16bcd | ||
|
|
3d666111b0 | ||
|
|
919bdff40a | ||
|
|
518b7b5358 | ||
|
|
8b9463f501 | ||
|
|
f5313c0aed | ||
|
|
c59c09e1f4 | ||
|
|
e98dcb6647 | ||
|
|
39db11569f | ||
|
|
c3ad47f6f6 | ||
|
|
b9cb1a783f | ||
|
|
ea51fdf6cc | ||
|
|
324aa6db32 | ||
|
|
9065a64d13 | ||
|
|
9b36fcebe9 | ||
|
|
5d9d01d87f | ||
|
|
7f85129044 | ||
|
|
fc31a2882d | ||
|
|
23cc3548d4 | ||
|
|
574f5b7cea | ||
|
|
b19e050399 | ||
|
|
ef3c82fee1 | ||
|
|
202e7a2b10 | ||
|
|
2588f3946d | ||
|
|
1c2e850a07 | ||
|
|
0e8ecb37f3 | ||
|
|
d1b96f5830 | ||
|
|
a9c6014634 | ||
|
|
125168c7c8 | ||
|
|
eafcadd6a1 | ||
|
|
211f24476a | ||
|
|
f55e4ef895 | ||
|
|
bbf1da88e6 | ||
|
|
d688f70c19 | ||
|
|
da7aa8f6d0 | ||
|
|
b04a477f6a | ||
|
|
0546f741a5 | ||
|
|
a535baeb31 | ||
|
|
8b125eab48 | ||
|
|
053b687959 | ||
|
|
b620ac703b | ||
|
|
3cb0bb052c | ||
|
|
c4985e0007 | ||
|
|
066d9ce0ec | ||
|
|
dc6fa74d22 | ||
|
|
e9e79b4605 | ||
|
|
1d9b627d73 | ||
|
|
40f4419c9d | ||
|
|
8b70dc688d | ||
|
|
b05a6c00ce | ||
|
|
9063b8adf1 | ||
|
|
01fd667eb4 | ||
|
|
73ac8d16ef | ||
|
|
ced449a148 | ||
|
|
ffad851066 | ||
|
|
4281fd590c | ||
|
|
0c167fdfc4 | ||
|
|
5dfb372c42 | ||
|
|
932103c204 | ||
|
|
666225614b | ||
|
|
636f357186 | ||
|
|
be6337f17c | ||
|
|
2c7f5ca089 | ||
|
|
f09550a4ac | ||
|
|
3c3a0523de | ||
|
|
a2dabcac0e | ||
|
|
a686bc75a7 | ||
|
|
8960f67a4d | ||
|
|
e10c1f1a1f | ||
|
|
55e70dfbec | ||
|
|
af46e3a7b1 | ||
|
|
883dfedfab | ||
|
|
ae52b27009 | ||
|
|
34a4a005d1 | ||
|
|
ab72995a2c | ||
|
|
aac47c4686 | ||
|
|
362c072181 | ||
|
|
ef172ae5f8 | ||
|
|
1e4e1cb413 | ||
|
|
e819b96473 | ||
|
|
1e3c1feadf | ||
|
|
83d7e599c7 | ||
|
|
52d7ba8965 | ||
|
|
c3ec772ea2 | ||
|
|
d2b4c2bcac | ||
|
|
f738da4dd5 | ||
|
|
b796bf2607 | ||
|
|
3924ced659 | ||
|
|
23573501bd | ||
|
|
787c00325d | ||
|
|
db371d1c82 | ||
|
|
b744a98a3d | ||
|
|
37439a0896 | ||
|
|
af3cd8aa68 | ||
|
|
7baa551a5c | ||
|
|
b7c4dbd224 | ||
|
|
aad2455654 | ||
|
|
a8e5ad451d | ||
|
|
99425e89ca | ||
|
|
60608af8b2 | ||
|
|
7c753ee518 | ||
|
|
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,6 @@ plugins/*
|
||||
!plugins/coral-plugin-love
|
||||
!plugins/coral-plugin-viewing-options
|
||||
!plugins/coral-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
!plugins/talk-plugin-featured
|
||||
node_modules
|
||||
|
||||
@@ -25,5 +25,7 @@ plugins/*
|
||||
!plugins/coral-plugin-love
|
||||
!plugins/coral-plugin-viewing-options
|
||||
!plugins/coral-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
!plugins/talk-plugin-featured
|
||||
|
||||
**/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
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:7.8
|
||||
FROM node:7.10.1
|
||||
|
||||
# Create app directory
|
||||
RUN mkdir -p /usr/src/app
|
||||
|
||||
+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!
|
||||
|
||||
@@ -19,6 +19,10 @@ See our [Talk Documentation & Guides](https://coralproject.github.io/talk/index.
|
||||
|
||||
See our guide to using and building [Talk Plugins](https://github.com/coralproject/talk/blob/master/PLUGINS.md).
|
||||
|
||||
### Recipes
|
||||
|
||||
Recipes are plugin templates provided by the Coral Core team. Developers can use these recipes to build their own plugins. You can find all the Talk recipes here: https://github.com/coralproject/talk-recipes/
|
||||
|
||||
## Usage
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
+120
-1
@@ -8,13 +8,14 @@
|
||||
// https://yarnpkg.com/
|
||||
|
||||
const program = require('./commander');
|
||||
const inquirer = require('inquirer');
|
||||
|
||||
// Make things colorful!
|
||||
require('colors');
|
||||
const emoji = require('node-emoji');
|
||||
|
||||
const dir = process.cwd();
|
||||
const fs = require('fs');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const spawn = require('cross-spawn');
|
||||
const semver = require('semver');
|
||||
@@ -272,10 +273,128 @@ async function reconcilePluginDeps({skipLocal, skipRemote, dryRun, upgradeRemote
|
||||
console.log(`✨ Done in ${totalTime}s.`);
|
||||
}
|
||||
|
||||
async function createSeedPlugin() {
|
||||
const pluginsDir = path.join(__dirname, 'plugins');
|
||||
|
||||
function pluginNameExists(pluginName) {
|
||||
const pluginNames = fs.readdirSync(pluginsDir);
|
||||
return !!pluginNames
|
||||
.filter((pn) => pn === pluginName).length;
|
||||
}
|
||||
|
||||
let answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'pluginName',
|
||||
message: 'Plugin Name:',
|
||||
validate: (input) => {
|
||||
|
||||
if (pluginNameExists(input)) {
|
||||
return 'Please, choose another name. That name already exists';
|
||||
}
|
||||
|
||||
if (input && input.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Plugin Name is required.';
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'server',
|
||||
message: 'Is this plugin extending the server capabilities?'
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'client',
|
||||
message: 'Is this plugin extending the client capabilities?'
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'addPluginsJson',
|
||||
message: 'Should we add it to the plugins.json?'
|
||||
}
|
||||
]);
|
||||
|
||||
//==============================================================================
|
||||
// Creating plugin seed
|
||||
//==============================================================================
|
||||
|
||||
const seedPlugin = path.join(__dirname, 'bin/templates/plugin');
|
||||
const newPluginPath = path.join(pluginsDir, answers.pluginName);
|
||||
|
||||
if (fs.existsSync(seedPlugin)) {
|
||||
|
||||
if (answers.server && answers.client) {
|
||||
|
||||
// This is a server-side and client-side plugin!, let's copy the template
|
||||
fs.copySync(seedPlugin, newPluginPath);
|
||||
} else {
|
||||
|
||||
fs.copySync(seedPlugin, newPluginPath, {filter: (p) => {
|
||||
|
||||
// Allowing plugin folder and files with no subfolders
|
||||
const rootRx = /plugin$|plugin\/[^/]*(\.).{2,3}/igm;
|
||||
if (rootRx.test(p) && (fs.lstatSync(p).isDirectory() || fs.lstatSync(p).isFile())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's a client-side plugin, copying client folder
|
||||
if (answers.client) {
|
||||
return /client/.test(p);
|
||||
}
|
||||
|
||||
// If it's a server-side plugin, copying server folder
|
||||
if (answers.server) {
|
||||
return /server/.test(p);
|
||||
}
|
||||
|
||||
}});
|
||||
}
|
||||
|
||||
// Let's add this to the plugins.json
|
||||
if (answers.addPluginsJson) {
|
||||
const pluginsJson = path.join(dir, 'plugins.json');
|
||||
|
||||
fs.readJson(pluginsJson)
|
||||
.then((j) => {
|
||||
|
||||
// This is a client-side plugin, let's push this.
|
||||
if (answers.client) {
|
||||
j.client.push(answers.pluginName);
|
||||
|
||||
const output = JSON.stringify(j, null, 2);
|
||||
fs.writeFileSync(pluginsJson, output);
|
||||
}
|
||||
|
||||
// This is a server-side plugin, let's push this.
|
||||
if (answers.server) {
|
||||
j.server.push(answers.pluginName);
|
||||
|
||||
const output = JSON.stringify(j, null, 2);
|
||||
fs.writeFileSync(pluginsJson, output);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✨ Yay! Plugin created! Find your plugin: ${answers.pluginName} in the ./plugins folder`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.command('create')
|
||||
.description('creates a seed plugin')
|
||||
.action(createSeedPlugin);
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.description('')
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"presets": [
|
||||
"es2015"
|
||||
],
|
||||
"plugins": [
|
||||
"add-module-exports",
|
||||
"transform-class-properties",
|
||||
"transform-decorators-legacy",
|
||||
"transform-object-assign",
|
||||
"transform-object-rest-spread",
|
||||
"transform-async-to-generator",
|
||||
"transform-react-jsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"mocha": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"experimentalObjectRestSpread": true,
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"plugins": [
|
||||
"react"
|
||||
],
|
||||
"rules": {
|
||||
"react/jsx-uses-react": "error",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.myPluginContainer {
|
||||
padding: 10px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #d6d6d6;
|
||||
margin: 10px 0;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: block;
|
||||
animation: spin 2s infinite ease;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #444444;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import {CoralLogo} from 'plugin-api/beta/client/components/ui';
|
||||
import styles from './MyPluginComponent.css';
|
||||
|
||||
class MyPluginComponent extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.myPluginContainer}>
|
||||
<CoralLogo className={styles.logo}/>
|
||||
<div className={styles.description}>
|
||||
<h3>Plugin created by Talk CLI</h3>
|
||||
|
||||
<small>
|
||||
To read more about plugins check{' '}
|
||||
<a href="https://coralproject.github.io/talk/plugins-client.html">
|
||||
our docs and guides!
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MyPluginComponent;
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
/**
|
||||
This is a client index example file and it could look like this:
|
||||
|
||||
```
|
||||
import LoveButton from './components/LoveButton';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
commentReactions: [LoveButton]
|
||||
},
|
||||
reducer,
|
||||
translations
|
||||
};
|
||||
```
|
||||
|
||||
To read more info on how to build client plugins. Please, go to: https://coralproject.github.io/talk/plugins-client.html
|
||||
*/
|
||||
|
||||
import MyPluginComponent from './components/MyPluginComponent';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
stream: [MyPluginComponent]
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
#
|
||||
# This file is for translations and they should look like this:
|
||||
#
|
||||
#
|
||||
# ```
|
||||
# en:
|
||||
# coral-plugin-respect:
|
||||
# respect: Respect
|
||||
# respected: Respected
|
||||
# es:
|
||||
# coral-plugin-respect:
|
||||
# respect: Respetar
|
||||
# respected: Respetado
|
||||
# ```
|
||||
#
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = {};
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
machine:
|
||||
node:
|
||||
version: 7.9
|
||||
version: 7.10.1
|
||||
services:
|
||||
- docker
|
||||
- redis
|
||||
|
||||
@@ -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});
|
||||
@@ -47,3 +47,7 @@ export const removeCommentClassName = (idx) => ({
|
||||
type: actions.REMOVE_COMMENT_CLASSNAME,
|
||||
idx
|
||||
});
|
||||
|
||||
export const setActiveTab = (tab) => (dispatch) => {
|
||||
dispatch({type: actions.SET_ACTIVE_TAB, tab});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import React from 'react';
|
||||
|
||||
import LoadMore from './LoadMore';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import NewCount from './NewCount';
|
||||
import {TransitionGroup} from 'react-transition-group';
|
||||
import {forEachError} from 'coral-framework/utils';
|
||||
import Comment from '../components/Comment';
|
||||
|
||||
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
|
||||
|
||||
// resetCursors will return the id cursors of the first and second comment of
|
||||
// the current comment list. The cursors are used to dertermine which
|
||||
// comments to show. The spare cursor functions as a backup in case one
|
||||
// of the comments gets deleted.
|
||||
function resetCursors(state, props) {
|
||||
const comments = props.root.asset.comments;
|
||||
if (comments && comments.nodes.length) {
|
||||
const idCursors = [comments.nodes[0].id];
|
||||
if (comments.nodes[1]) {
|
||||
idCursors.push(comments.nodes[1].id);
|
||||
}
|
||||
return {idCursors};
|
||||
}
|
||||
return {idCursors: []};
|
||||
}
|
||||
|
||||
// invalidateCursor is called whenever a comment is removed which is referenced
|
||||
// by one of the 2 id cursors. It returns a new set of id cursors calculated
|
||||
// using the help of the backup cursor.
|
||||
function invalidateCursor(invalidated, state, props) {
|
||||
const alt = invalidated === 1 ? 0 : 1;
|
||||
const comments = props.root.asset.comments;
|
||||
const idCursors = [];
|
||||
if (state.idCursors[alt]) {
|
||||
idCursors.push(state.idCursors[alt]);
|
||||
const index = comments.nodes.findIndex((node) => node.id === idCursors[0]);
|
||||
const nextInLine = comments.nodes[index + 1];
|
||||
if (nextInLine) {
|
||||
idCursors.push(nextInLine.id);
|
||||
}
|
||||
}
|
||||
return {idCursors};
|
||||
}
|
||||
|
||||
class AllCommentsPane extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
...resetCursors(this.state, props),
|
||||
loadingState: '',
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
const {comments: prevComments} = this.props;
|
||||
const {comments: nextComments} = next;
|
||||
|
||||
if (!prevComments && nextComments) {
|
||||
this.setState(resetCursors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
prevComments && nextComments &&
|
||||
nextComments.nodes.length < prevComments.nodes.length
|
||||
) {
|
||||
|
||||
// Invalidate first cursor if referenced comment was removed.
|
||||
if (this.state.idCursors[0] && !hasComment(nextComments.nodes, this.state.idCursors[0])) {
|
||||
this.setState(invalidateCursor(0, this.state, next));
|
||||
}
|
||||
|
||||
// Invalidate second cursor if referenced comment was removed.
|
||||
if (this.state.idCursors[1] && !hasComment(nextComments.nodes, this.state.idCursors[1])) {
|
||||
this.setState(invalidateCursor(1, this.state, next));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMore = () => {
|
||||
this.setState({loadingState: 'loading'});
|
||||
this.props.loadMore()
|
||||
.then(() => {
|
||||
this.setState({loadingState: 'success'});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({loadingState: 'error'});
|
||||
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
|
||||
});
|
||||
}
|
||||
|
||||
viewNewComments = () => {
|
||||
this.setState(resetCursors);
|
||||
};
|
||||
|
||||
// getVisibileComments returns a list containing comments
|
||||
// which were authored by current user or comes after the `idCursor`.
|
||||
getVisibleComments() {
|
||||
const {comments, currentUser: user} = this.props;
|
||||
const idCursor = this.state.idCursors[0];
|
||||
const userId = user ? user.id : null;
|
||||
|
||||
if (!comments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const view = [];
|
||||
let pastCursor = false;
|
||||
comments.nodes.forEach((comment) => {
|
||||
if (comment.id === idCursor) {
|
||||
pastCursor = true;
|
||||
}
|
||||
if (pastCursor || comment.user.id === userId) {
|
||||
view.push(comment);
|
||||
}
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
data,
|
||||
root,
|
||||
comments,
|
||||
commentClassNames,
|
||||
addTag,
|
||||
removeTag,
|
||||
ignoreUser,
|
||||
setActiveReplyBox,
|
||||
activeReplyBox,
|
||||
addNotification,
|
||||
disableReply,
|
||||
postComment,
|
||||
asset,
|
||||
currentUser,
|
||||
postFlag,
|
||||
postDontAgree,
|
||||
loadNewReplies,
|
||||
deleteAction,
|
||||
showSignInDialog,
|
||||
commentIsIgnored,
|
||||
charCountEnable,
|
||||
maxCharCount,
|
||||
editComment,
|
||||
} = this.props;
|
||||
|
||||
const {loadingState} = this.state;
|
||||
const view = this.getVisibleComments();
|
||||
|
||||
return (
|
||||
<div className="talk-stream-comments-container">
|
||||
<NewCount
|
||||
count={comments.nodes.length - view.length}
|
||||
loadMore={this.viewNewComments}
|
||||
/>
|
||||
<TransitionGroup component='div' className="embed__stream">
|
||||
{view.map((comment) => {
|
||||
return commentIsIgnored(comment)
|
||||
? <IgnoredCommentTombstone key={comment.id} />
|
||||
: <Comment
|
||||
commentClassNames={commentClassNames}
|
||||
data={data}
|
||||
root={root}
|
||||
disableReply={disableReply}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postComment={postComment}
|
||||
asset={asset}
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
ignoreUser={ignoreUser}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
loadMore={loadNewReplies}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
editComment={editComment}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
moreComments={asset.comments.hasNextPage}
|
||||
loadMore={this.loadMore}
|
||||
loadingState={loadingState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AllCommentsPane;
|
||||
@@ -1,11 +1,49 @@
|
||||
.Reply {
|
||||
.root {
|
||||
margin-left: 20px;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
margin-bottom: 15px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.Comment {
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
.rootLevel0:first-child {
|
||||
padding-top: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.rootLevel0 {
|
||||
margin-left: 0px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.comment {
|
||||
padding-left: 15px;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
}
|
||||
|
||||
.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: 15px;
|
||||
border-left: 3px solid rgb(35,118,216);
|
||||
}
|
||||
|
||||
.pendingComment {
|
||||
@@ -22,6 +60,13 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* element in the top right of the Comment */
|
||||
.topRight {
|
||||
float: right;
|
||||
@@ -111,3 +156,20 @@
|
||||
.enter {
|
||||
animation: enter 1000ms;
|
||||
}
|
||||
|
||||
.commentContainer {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.commentAvatar {
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
.commentAvatar:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timerIcon {
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -132,7 +134,6 @@ export default class Comment extends React.Component {
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
reactKey: PropTypes.string.isRequired,
|
||||
|
||||
// id of currently opened ReplyBox. tracked in Stream.js
|
||||
activeReplyBox: PropTypes.string.isRequired,
|
||||
@@ -146,12 +147,8 @@ export default class Comment extends React.Component {
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
depth: PropTypes.number.isRequired,
|
||||
liveUpdates: PropTypes.bool.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
url: PropTypes.string
|
||||
}).isRequired,
|
||||
liveUpdates: PropTypes.bool,
|
||||
asset: PropTypes.object.isRequired,
|
||||
currentUser: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired
|
||||
}),
|
||||
@@ -170,7 +167,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 +193,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 +218,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 +244,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 +314,7 @@ export default class Comment extends React.Component {
|
||||
postDontAgree,
|
||||
setActiveReplyBox,
|
||||
activeReplyBox,
|
||||
loadMore,
|
||||
deleteAction,
|
||||
disableReply,
|
||||
maxCharCount,
|
||||
@@ -323,9 +329,12 @@ export default class Comment extends React.Component {
|
||||
} = this.props;
|
||||
|
||||
const view = this.getVisibileReplies();
|
||||
const {loadingState} = this.state;
|
||||
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 +347,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) {
|
||||
@@ -362,7 +366,7 @@ export default class Comment extends React.Component {
|
||||
addTag({
|
||||
id: comment.id,
|
||||
name: BEST_TAG,
|
||||
assetId: asset.id
|
||||
assetId: asset.id,
|
||||
}),
|
||||
() => 'Failed to tag comment as best'
|
||||
);
|
||||
@@ -372,13 +376,13 @@ export default class Comment extends React.Component {
|
||||
removeTag({
|
||||
id: comment.id,
|
||||
name: BEST_TAG,
|
||||
assetId: asset.id
|
||||
assetId: asset.id,
|
||||
}),
|
||||
() => 'Failed to remove best comment tag'
|
||||
);
|
||||
|
||||
/**
|
||||
* classNamesToAdd
|
||||
* conditionClassNames
|
||||
* adds classNames based on condition
|
||||
* classnames is an array of objects with key as classnames and value as conditions
|
||||
* i.e:
|
||||
@@ -388,55 +392,48 @@ 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' : ''}
|
||||
>
|
||||
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
|
||||
{commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
: null }
|
||||
|
||||
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
|
||||
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
<div className={commentClassName}>
|
||||
|
||||
<Slot
|
||||
className={styles.commentInfoBar}
|
||||
fill="commentInfoBar"
|
||||
depth={depth}
|
||||
className={styles.commentAvatar}
|
||||
fill="commentAvatar"
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
data={this.props.data}
|
||||
@@ -444,156 +441,189 @@ export default class Comment extends React.Component {
|
||||
inline
|
||||
/>
|
||||
|
||||
{ (currentUser && (comment.user.id === currentUser.id)) &&
|
||||
<div className={styles.commentContainer}>
|
||||
|
||||
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
|
||||
/* User can edit/delete their own comment for a short window after posting */
|
||||
<span className={cn(styles.topRight)}>
|
||||
{commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
: null }
|
||||
|
||||
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
|
||||
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
|
||||
{
|
||||
commentIsStillEditable(comment) &&
|
||||
<a
|
||||
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
|
||||
onClick={this.onClickEdit}>Edit</a>
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
}
|
||||
{ (currentUser && (comment.user.id !== currentUser.id)) &&
|
||||
|
||||
/* TopRightMenu allows currentUser to ignore other users' comments */
|
||||
<span className={cn(styles.topRight, styles.topRightMenu)}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification} />
|
||||
<Slot
|
||||
className={styles.commentInfoBar}
|
||||
fill="commentInfoBar"
|
||||
depth={depth}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
inline
|
||||
/>
|
||||
|
||||
{ (currentUser && (comment.user.id === currentUser.id)) &&
|
||||
|
||||
/* User can edit/delete their own comment for a short window after posting */
|
||||
<span className={cn(styles.topRight)}>
|
||||
{
|
||||
commentIsStillEditable(comment) &&
|
||||
<a
|
||||
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
|
||||
onClick={this.onClickEdit}>Edit</a>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
{
|
||||
this.state.isEditing
|
||||
? <EditableCommentContent
|
||||
editComment={this.props.editComment.bind(null, comment.id, asset.id)}
|
||||
addNotification={addNotification}
|
||||
asset={asset}
|
||||
comment={comment}
|
||||
currentUser={currentUser}
|
||||
maxCharCount={maxCharCount}
|
||||
parentId={parentId}
|
||||
stopEditing={this.stopEditing}
|
||||
/>
|
||||
: <div>
|
||||
<Slot fill="commentContent" comment={comment} defaultComponent={CommentContent} />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
{ (currentUser && (comment.user.id !== currentUser.id)) &&
|
||||
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<Slot
|
||||
fill="commentReactions"
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
<ActionButton>
|
||||
<IfUserCanModifyBest user={currentUser}>
|
||||
<BestButton
|
||||
isBest={commentIsBest(comment)}
|
||||
addBest={addBestTag}
|
||||
removeBest={removeBestTag}
|
||||
/>
|
||||
</IfUserCanModifyBest>
|
||||
</ActionButton>
|
||||
{!disableReply &&
|
||||
<ActionButton>
|
||||
<ReplyButton
|
||||
onClick={this.showReplyBox}
|
||||
parentCommentId={parentId || comment.id}
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
/>
|
||||
</ActionButton>}
|
||||
<Slot
|
||||
fill="commentActions"
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
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}
|
||||
flag={myFlag}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{activeReplyBox === comment.id
|
||||
? <ReplyBox
|
||||
commentPostedHandler={() => {
|
||||
setActiveReplyBox('');
|
||||
}}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
parentId={parentId || comment.id}
|
||||
addNotification={addNotification}
|
||||
authorId={currentUser.id}
|
||||
postComment={postComment}
|
||||
assetId={asset.id}
|
||||
/>
|
||||
: null}
|
||||
/* TopRightMenu allows currentUser to ignore other users' comments */
|
||||
<span className={cn(styles.topRight, styles.topRightMenu)}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification} />
|
||||
</span>
|
||||
}
|
||||
{
|
||||
this.state.isEditing
|
||||
? <EditableCommentContent
|
||||
editComment={this.editComment}
|
||||
addNotification={addNotification}
|
||||
comment={comment}
|
||||
currentUser={currentUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
parentId={parentId}
|
||||
stopEditing={this.stopEditing}
|
||||
/>
|
||||
: <div>
|
||||
<Slot fill="commentContent" comment={comment} defaultComponent={CommentContent} />
|
||||
</div>
|
||||
}
|
||||
|
||||
<TransitionGroup>
|
||||
{view.map((reply) => {
|
||||
return commentIsIgnored(reply)
|
||||
? <IgnoredCommentTombstone key={reply.id} />
|
||||
: <Comment
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<Slot
|
||||
fill="commentReactions"
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
<ActionButton>
|
||||
<IfUserCanModifyBest user={currentUser}>
|
||||
<BestButton
|
||||
isBest={commentIsBest(comment)}
|
||||
addBest={addBestTag}
|
||||
removeBest={removeBestTag}
|
||||
/>
|
||||
</IfUserCanModifyBest>
|
||||
</ActionButton>
|
||||
{!disableReply &&
|
||||
<ActionButton>
|
||||
<ReplyButton
|
||||
onClick={this.showReplyBox}
|
||||
parentCommentId={parentId || comment.id}
|
||||
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}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
disableReply={disableReply}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postComment={postComment}
|
||||
editComment={this.props.editComment}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
ignoreUser={ignoreUser}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
<ActionButton>
|
||||
<FlagComment
|
||||
flaggedByCurrentUser={!!myFlag}
|
||||
flag={myFlag}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
addNotification={addNotification}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeReplyBox === comment.id
|
||||
? <ReplyBox
|
||||
commentPostedHandler={() => {
|
||||
setActiveReplyBox('');
|
||||
}}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
showSignInDialog={showSignInDialog}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
liveUpdates={liveUpdates}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<div className="coral-load-more-replies">
|
||||
<LoadMore
|
||||
topLevel={false}
|
||||
replyCount={replyCount}
|
||||
moreComments={hasMoreComments}
|
||||
loadMore={this.loadNewReplies}
|
||||
/>
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
parentId={(depth < THREADING_LEVEL) ? comment.id : parentId}
|
||||
addNotification={addNotification}
|
||||
postComment={postComment}
|
||||
currentUser={currentUser}
|
||||
assetId={asset.id}
|
||||
/>
|
||||
: null}
|
||||
|
||||
<TransitionGroup>
|
||||
{view.map((reply) => {
|
||||
return commentIsIgnored(reply)
|
||||
? <IgnoredCommentTombstone key={reply.id} />
|
||||
: <Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
disableReply={disableReply}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postComment={postComment}
|
||||
editComment={this.props.editComment}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
loadMore={loadMore}
|
||||
ignoreUser={ignoreUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
showSignInDialog={showSignInDialog}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
liveUpdates={liveUpdates}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<div className="talk-load-more-replies">
|
||||
<LoadMore
|
||||
topLevel={false}
|
||||
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';
|
||||
@@ -16,11 +18,6 @@ export class EditableCommentContent extends React.Component {
|
||||
|
||||
// show notification to the user (e.g. for errors)
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
settings: PropTypes.shape({
|
||||
charCountEnable: PropTypes.bool,
|
||||
}),
|
||||
}).isRequired,
|
||||
|
||||
// comment that is being edited
|
||||
comment: PropTypes.shape({
|
||||
@@ -37,6 +34,7 @@ export class EditableCommentContent extends React.Component {
|
||||
currentUser: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired
|
||||
}),
|
||||
charCountEnable: PropTypes.bool,
|
||||
maxCharCount: PropTypes.number,
|
||||
|
||||
// edit a comment, passed {{ body }}
|
||||
@@ -45,10 +43,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 +67,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}
|
||||
charCountEnable={this.props.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')}
|
||||
{
|
||||
@@ -137,9 +145,9 @@ export class EditableCommentContent extends React.Component {
|
||||
}
|
||||
</span>
|
||||
: <span>
|
||||
<Icon name="timer"/> {t('edit_comment.edit_window_timer_prefix')}
|
||||
<Icon name="timer" className={styles.timerIcon}/> {t('edit_comment.edit_window_timer_prefix')}
|
||||
<CountdownSeconds
|
||||
until={editableUntil}
|
||||
until={this.getEditableUntil()}
|
||||
classNameForMsRemaining={(remainingMs) => (remainingMs <= 10 * 1000) ? styles.editWindowAlmostOver : '' }
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -4,67 +4,68 @@ import Slot from 'coral-framework/components/Slot';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
import {TabBar, Tab, TabContent, Button} from 'coral-ui';
|
||||
import Count from 'coral-plugin-comment-count/CommentCount';
|
||||
import {TabBar, Tab, TabContent, TabPane} from 'coral-ui';
|
||||
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
|
||||
import ConfigureStreamContainer
|
||||
from 'coral-configure/containers/ConfigureStreamContainer';
|
||||
import cn from 'classnames';
|
||||
|
||||
export default class Embed extends React.Component {
|
||||
changeTab = (tab) => {
|
||||
switch (tab) {
|
||||
case 0:
|
||||
this.props.setActiveTab('stream');
|
||||
break;
|
||||
case 1:
|
||||
this.props.setActiveTab('profile');
|
||||
|
||||
// TODO: move data fetching to profile container.
|
||||
// TODO: move data fetching to appropiate containers.
|
||||
switch (tab) {
|
||||
case 'profile':
|
||||
this.props.data.refetch();
|
||||
break;
|
||||
case 2:
|
||||
this.props.setActiveTab('config');
|
||||
|
||||
// TODO: move data fetching to config container.
|
||||
case 'config':
|
||||
this.props.data.refetch();
|
||||
break;
|
||||
}
|
||||
this.props.setActiveTab(tab);
|
||||
};
|
||||
|
||||
handleShowProfile = () => this.props.setActiveTab('profile');
|
||||
|
||||
render() {
|
||||
const {activeTab, viewAllComments, commentId} = this.props;
|
||||
const {asset: {totalCommentCount}} = this.props.root;
|
||||
const {activeTab, commentId} = this.props;
|
||||
const {user} = this.props.auth;
|
||||
const hasHighlightedComment = !!commentId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="commentStream">
|
||||
<TabBar onChange={this.changeTab} activeTab={activeTab} className='talk-stream-tabbar'>
|
||||
<Tab className={'talk-stream-comment-count-tab'} id='stream'><Count count={totalCommentCount}/></Tab>
|
||||
<Tab className={'talk-stream-profile-tab'} id='profile'>{t('framework.my_profile')}</Tab>
|
||||
<Tab className={'talk-stream-configuration-tab'} id='config' restricted={!can(user, 'UPDATE_CONFIG')}>{t('framework.configure_stream')}</Tab>
|
||||
</TabBar>
|
||||
{commentId &&
|
||||
<Button
|
||||
cStyle="darkGrey"
|
||||
style={{float: 'right'}}
|
||||
onClick={viewAllComments}
|
||||
>
|
||||
{t('framework.show_all_comments')}
|
||||
</Button>}
|
||||
<Slot fill="embed" />
|
||||
<TabContent show={activeTab === 'stream'}>
|
||||
<div className={cn('talk-embed-stream', {'talk-embed-stream-highlight-comment': hasHighlightedComment})}>
|
||||
<TabBar
|
||||
onTabClick={this.changeTab}
|
||||
activeTab={activeTab}
|
||||
className='talk-embed-stream-tab-bar'
|
||||
aria-controls='talk-embed-stream-tab-content'
|
||||
>
|
||||
<Tab tabId={'stream'} className={'talk-embed-stream-comments-tab'}>
|
||||
{t('embed_comments_tab')}
|
||||
</Tab>
|
||||
<Tab tabId={'profile'} className={'talk-embed-stream-profile-tab'}>
|
||||
{t('framework.my_profile')}
|
||||
</Tab>
|
||||
{can(user, 'UPDATE_CONFIG') &&
|
||||
<Tab tabId={'config'} className={'talk-embed-stream-configuration-tab'}>
|
||||
{t('framework.configure_stream')}
|
||||
</Tab>
|
||||
}
|
||||
</TabBar>
|
||||
<Slot fill="embed" />
|
||||
|
||||
<TabContent
|
||||
activeTab={activeTab}
|
||||
id='talk-embed-stream-tab-content'
|
||||
>
|
||||
<TabPane tabId={'stream'}>
|
||||
<Stream data={this.props.data} root={this.props.root} />
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 'profile'}>
|
||||
</TabPane>
|
||||
<TabPane tabId={'profile'}>
|
||||
<ProfileContainer />
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 'config'}>
|
||||
</TabPane>
|
||||
<TabPane tabId={'config'}>
|
||||
<ConfigureStreamContainer />
|
||||
</TabContent>
|
||||
</div>
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
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}>
|
||||
<Button onClick={loadMore}>
|
||||
{count === 1
|
||||
? t('framework.new_count', count, t('framework.comment'))
|
||||
: t('framework.new_count', count, t('framework.comments'))}
|
||||
</button>
|
||||
</Button>
|
||||
: null
|
||||
}
|
||||
</div>;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.root {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.viewAllButton {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.tabPanel {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.filterWrapper {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.highlightedContainer {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.tabContainer {
|
||||
position: relative;
|
||||
margin-top: 28px;
|
||||
padding-bottom: 50px;
|
||||
min-height: 600px;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
@@ -9,154 +9,79 @@ import {ModerationLink} from 'coral-plugin-moderation';
|
||||
import RestrictedMessageBox
|
||||
from 'coral-framework/components/RestrictedMessageBox';
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
import {getSlotComponents} from 'coral-framework/helpers/plugins';
|
||||
import CommentBox from 'coral-plugin-commentbox/CommentBox';
|
||||
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import NewCount from './NewCount';
|
||||
import {TransitionGroup} from 'react-transition-group';
|
||||
import {Button, TabBar, Tab, TabCount, TabContent, TabPane} from 'coral-ui';
|
||||
import cn from 'classnames';
|
||||
|
||||
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
|
||||
import {getTopLevelParent} from '../graphql/utils';
|
||||
import AllCommentsPane from './AllCommentsPane';
|
||||
|
||||
// resetCursors will return the id cursors of the first and second comment of
|
||||
// the current comment list. The cursors are used to dertermine which
|
||||
// comments to show. The spare cursor functions as a backup in case one
|
||||
// of the comments gets deleted.
|
||||
function resetCursors(state, props) {
|
||||
const comments = props.root.asset.comments;
|
||||
if (comments && comments.nodes.length) {
|
||||
const idCursors = [comments.nodes[0].id];
|
||||
if (comments.nodes[1]) {
|
||||
idCursors.push(comments.nodes[1].id);
|
||||
}
|
||||
return {idCursors};
|
||||
}
|
||||
return {idCursors: []};
|
||||
}
|
||||
|
||||
// invalidateCursor is called whenever a comment is removed which is referenced
|
||||
// by one of the 2 id cursors. It returns a new set of id cursors calculated
|
||||
// using the help of the backup cursor.
|
||||
function invalidateCursor(invalidated, state, props) {
|
||||
const alt = invalidated === 1 ? 0 : 1;
|
||||
const comments = props.root.asset.comments;
|
||||
const idCursors = [];
|
||||
if (state.idCursors[alt]) {
|
||||
idCursors.push(state.idCursors[alt]);
|
||||
const index = comments.nodes.findIndex((node) => node.id === idCursors[0]);
|
||||
const nextInLine = comments.nodes[index + 1];
|
||||
if (nextInLine) {
|
||||
idCursors.push(nextInLine.id);
|
||||
}
|
||||
}
|
||||
return {idCursors};
|
||||
}
|
||||
import styles from './Stream.css';
|
||||
|
||||
class Stream extends React.Component {
|
||||
|
||||
isLoadingMore = false;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = resetCursors(this.state, props);
|
||||
this.state = {
|
||||
keepCommentBox: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
const {root: {asset: {comments: prevComments}}} = this.props;
|
||||
const {root: {asset: {comments: nextComments}}} = next;
|
||||
|
||||
if (!prevComments && nextComments) {
|
||||
this.setState(resetCursors);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
prevComments && nextComments &&
|
||||
nextComments.nodes.length < prevComments.nodes.length
|
||||
) {
|
||||
|
||||
// Invalidate first cursor if referenced comment was removed.
|
||||
if (this.state.idCursors[0] && !hasComment(nextComments.nodes, this.state.idCursors[0])) {
|
||||
this.setState(invalidateCursor(0, this.state, next));
|
||||
}
|
||||
|
||||
// Invalidate second cursor if referenced comment was removed.
|
||||
if (this.state.idCursors[1] && !hasComment(nextComments.nodes, this.state.idCursors[1])) {
|
||||
this.setState(invalidateCursor(1, this.state, next));
|
||||
}
|
||||
// Keep comment box when user was live suspended, banned, ...
|
||||
if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) {
|
||||
this.setState({keepCommentBox: true});
|
||||
}
|
||||
}
|
||||
|
||||
viewNewComments = () => {
|
||||
this.setState(resetCursors);
|
||||
};
|
||||
|
||||
setActiveReplyBox = (reactKey) => {
|
||||
setActiveReplyBox = (id) => {
|
||||
if (!this.props.auth.user) {
|
||||
this.props.showSignInDialog();
|
||||
} else {
|
||||
this.props.setActiveReplyBox(reactKey);
|
||||
this.props.setActiveReplyBox(id);
|
||||
}
|
||||
};
|
||||
|
||||
loadMoreComments = () => {
|
||||
if (!this.isLoadingMore) {
|
||||
this.isLoadingMore = true;
|
||||
this.props.loadMoreComments()
|
||||
.then(() => this.isLoadingMore = false)
|
||||
.catch((e) => {
|
||||
this.isLoadingMore = false;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// getVisibileComments returns a list containing comments
|
||||
// which were authored by current user or comes after the `idCursor`.
|
||||
getVisibleComments() {
|
||||
const {root: {asset: {comments}}, auth: {user}} = this.props;
|
||||
const idCursor = this.state.idCursors[0];
|
||||
const userId = user ? user.id : null;
|
||||
|
||||
if (!comments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const view = [];
|
||||
let pastCursor = false;
|
||||
comments.nodes.forEach((comment) => {
|
||||
if (comment.id === idCursor) {
|
||||
pastCursor = true;
|
||||
}
|
||||
if (pastCursor || comment.user.id === userId) {
|
||||
view.push(comment);
|
||||
}
|
||||
});
|
||||
return view;
|
||||
userIsDegraged({auth: {user}} = this.props) {
|
||||
return !can(user, 'INTERACT_WITH_COMMUNITY');
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
data,
|
||||
root,
|
||||
activeReplyBox,
|
||||
setActiveReplyBox,
|
||||
appendItemArray,
|
||||
commentClassNames,
|
||||
root: {asset, asset: {comments}, comment, me},
|
||||
root: {asset, asset: {comments, totalCommentCount}, comment, me},
|
||||
postComment,
|
||||
addNotification,
|
||||
editComment,
|
||||
postFlag,
|
||||
postDontAgree,
|
||||
deleteAction,
|
||||
showSignInDialog,
|
||||
updateItem,
|
||||
addTag,
|
||||
ignoreUser,
|
||||
activeStreamTab,
|
||||
setActiveStreamTab,
|
||||
loadNewReplies,
|
||||
loadMoreComments,
|
||||
viewAllComments,
|
||||
auth: {loggedIn, user},
|
||||
removeTag,
|
||||
pluginProps,
|
||||
editName
|
||||
} = this.props;
|
||||
const view = this.getVisibleComments();
|
||||
const {keepCommentBox} = this.state;
|
||||
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,8 +96,24 @@ 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">
|
||||
<div id="stream" className={styles.root}>
|
||||
{comment &&
|
||||
<Button
|
||||
cStyle="darkGrey"
|
||||
className={cn('talk-stream-show-all-comments-button', styles.viewAllButton)}
|
||||
onClick={viewAllComments}
|
||||
>
|
||||
{t('framework.show_all_comments')}
|
||||
</Button>}
|
||||
|
||||
{open
|
||||
? <div id="commentBox">
|
||||
@@ -189,7 +130,7 @@ class Stream extends React.Component {
|
||||
<RestrictedMessageBox>
|
||||
{t(
|
||||
'stream.temporarily_suspended',
|
||||
this.props.root.settings.organizationName,
|
||||
root.settings.organizationName,
|
||||
timeago(user.suspension.until)
|
||||
)}
|
||||
</RestrictedMessageBox>}
|
||||
@@ -199,19 +140,16 @@ class Stream extends React.Component {
|
||||
editName={editName}
|
||||
currentUsername={user.username}
|
||||
/>}
|
||||
{loggedIn &&
|
||||
!banned &&
|
||||
!temporarilySuspended &&
|
||||
!highlightedComment &&
|
||||
{showCommentBox &&
|
||||
<CommentBox
|
||||
addNotification={this.props.addNotification}
|
||||
postComment={this.props.postComment}
|
||||
appendItemArray={this.props.appendItemArray}
|
||||
updateItem={this.props.updateItem}
|
||||
addNotification={addNotification}
|
||||
postComment={postComment}
|
||||
appendItemArray={appendItemArray}
|
||||
updateItem={updateItem}
|
||||
assetId={asset.id}
|
||||
premod={asset.settings.moderation}
|
||||
isReply={false}
|
||||
authorId={user.id}
|
||||
currentUser={user}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
/>}
|
||||
@@ -227,85 +165,103 @@ class Stream extends React.Component {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="talk-stream-wrapper-box">
|
||||
<Slot fill="streamBox" />
|
||||
</div>
|
||||
|
||||
{/* the highlightedComment is isolated after the user followed a permalink */}
|
||||
{highlightedComment
|
||||
? <Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.props.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
disableReply={!open}
|
||||
postComment={this.props.postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
highlighted={comment.id}
|
||||
postFlag={this.props.postFlag}
|
||||
postDontAgree={this.props.postDontAgree}
|
||||
loadMore={this.props.loadNewReplies}
|
||||
deleteAction={this.props.deleteAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
key={highlightedComment.id}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
reactKey={highlightedComment.id}
|
||||
comment={highlightedComment}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={this.props.editComment}
|
||||
liveUpdates={true}
|
||||
/>
|
||||
: <div className="talk-stream-comments-container">
|
||||
<NewCount
|
||||
count={comments.nodes.length - view.length}
|
||||
loadMore={this.viewNewComments}
|
||||
? (
|
||||
<div className={cn('talk-stream-highlighted-container', styles.highlightedContainer)}>
|
||||
<Comment
|
||||
data={data}
|
||||
root={root}
|
||||
commentClassNames={commentClassNames}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
ignoreUser={ignoreUser}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
disableReply={!open}
|
||||
postComment={postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
highlighted={comment.id}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
loadMore={loadNewReplies}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
key={highlightedComment.id}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
comment={highlightedComment}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={editComment}
|
||||
liveUpdates={true}
|
||||
/>
|
||||
<TransitionGroup component='div' className="embed__stream">
|
||||
{view.map((comment) => {
|
||||
return commentIsIgnored(comment)
|
||||
? <IgnoredCommentTombstone key={comment.id} />
|
||||
: <Comment
|
||||
commentClassNames={commentClassNames}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
disableReply={!open}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.props.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postComment={postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
ignoreUser={ignoreUser}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
loadMore={this.props.loadNewReplies}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
key={comment.id}
|
||||
reactKey={comment.id}
|
||||
comment={comment}
|
||||
pluginProps={pluginProps}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={this.props.editComment}
|
||||
liveUpdates={false}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
moreComments={asset.comments.hasNextPage}
|
||||
loadMore={this.loadMoreComments}
|
||||
/>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
: <div className={cn('talk-stream-tab-container', styles.tabContainer)}>
|
||||
<div
|
||||
className={cn('talk-stream-filter-wrapper', styles.filterWrapper)}
|
||||
>
|
||||
<Slot fill="streamFilter" />
|
||||
</div>
|
||||
<TabBar activeTab={activeStreamTab} onTabClick={setActiveStreamTab} sub>
|
||||
{getSlotComponents('streamTabs').map((PluginComponent) => (
|
||||
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
|
||||
<PluginComponent
|
||||
active={activeStreamTab === PluginComponent.talkPluginName}
|
||||
data={data}
|
||||
root={root}
|
||||
asset={asset}
|
||||
/>
|
||||
</Tab>
|
||||
))}
|
||||
<Tab tabId={'all'}>
|
||||
All Comments <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
|
||||
</Tab>
|
||||
</TabBar>
|
||||
<TabContent activeTab={activeStreamTab} sub>
|
||||
{getSlotComponents('streamTabPanes').map((PluginComponent) => (
|
||||
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
|
||||
<PluginComponent
|
||||
data={data}
|
||||
root={root}
|
||||
asset={asset}
|
||||
/>
|
||||
</TabPane>
|
||||
))}
|
||||
<TabPane tabId={'all'}>
|
||||
<AllCommentsPane
|
||||
data={data}
|
||||
root={root}
|
||||
comments={comments}
|
||||
commentClassNames={commentClassNames}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
ignoreUser={ignoreUser}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
disableReply={!open}
|
||||
postComment={postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
loadMore={loadMoreComments}
|
||||
loadNewReplies={loadNewReplies}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={editComment}
|
||||
/>
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
</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,5 @@ 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;
|
||||
export const SET_ACTIVE_TAB = 'CORAL_STREAM_SET_ACTIVE_TAB';
|
||||
|
||||
@@ -10,7 +10,8 @@ const pluginFragments = getSlotsFragments([
|
||||
'commentInfoBar',
|
||||
'commentActions',
|
||||
'commentContent',
|
||||
'commentReactions'
|
||||
'commentReactions',
|
||||
'commentAvatar'
|
||||
]);
|
||||
|
||||
export default withFragments({
|
||||
@@ -34,7 +35,7 @@ export default withFragments({
|
||||
}
|
||||
user {
|
||||
id
|
||||
name: username
|
||||
username
|
||||
}
|
||||
action_summaries {
|
||||
__typename
|
||||
|
||||
@@ -7,25 +7,79 @@ 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';
|
||||
|
||||
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,11 +106,47 @@ 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) {
|
||||
totalCommentCount(excludeIgnored: $excludeIgnored)
|
||||
}
|
||||
me {
|
||||
id
|
||||
status
|
||||
@@ -93,8 +183,8 @@ const mapDispatchToProps = (dispatch) =>
|
||||
logout,
|
||||
checkLogin,
|
||||
setActiveTab,
|
||||
viewAllComments,
|
||||
fetchAssetSuccess,
|
||||
addNotification,
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
@@ -2,18 +2,20 @@ 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 {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/stream';
|
||||
import Stream from '../components/Stream';
|
||||
import Comment from './Comment';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
|
||||
import {Spinner} from 'coral-ui';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
import {
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
insertCommentIntoEmbedQuery,
|
||||
removeCommentFromEmbedQuery,
|
||||
insertFetchedCommentsIntoEmbedQuery,
|
||||
nest,
|
||||
} from '../graphql/utils';
|
||||
|
||||
const {showSignInDialog} = authActions;
|
||||
@@ -30,11 +33,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 +52,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 +77,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 +94,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 +158,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 +188,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 +209,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
|
||||
@@ -225,14 +231,22 @@ const LOAD_MORE_QUERY = gql`
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const pluginFragments = getSlotsFragments([
|
||||
'streamTabs',
|
||||
'streamTabPanes',
|
||||
]);
|
||||
|
||||
const fragments = {
|
||||
root: gql`
|
||||
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
|
||||
@@ -263,6 +277,7 @@ const fragments = {
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
${pluginFragments.spreads('asset')}
|
||||
}
|
||||
me {
|
||||
status
|
||||
@@ -273,8 +288,11 @@ const fragments = {
|
||||
settings {
|
||||
organizationName
|
||||
}
|
||||
${pluginFragments.spreads('root')}
|
||||
...${getDefinitionName(Comment.fragments.root)}
|
||||
}
|
||||
${pluginFragments.definitions('asset')}
|
||||
${pluginFragments.definitions('root')}
|
||||
${Comment.fragments.root}
|
||||
${commentFragment}
|
||||
`,
|
||||
@@ -290,6 +308,8 @@ const mapStateToProps = (state) => ({
|
||||
assetUrl: state.stream.assetUrl,
|
||||
activeTab: state.embed.activeTab,
|
||||
previousTab: state.embed.previousTab,
|
||||
activeStreamTab: state.stream.activeTab,
|
||||
previousStreamTab: state.stream.previousTab,
|
||||
commentClassNames: state.stream.commentClassNames
|
||||
});
|
||||
|
||||
@@ -299,6 +319,8 @@ const mapDispatchToProps = (dispatch) =>
|
||||
addNotification,
|
||||
setActiveReplyBox,
|
||||
editName,
|
||||
viewAllComments,
|
||||
setActiveStreamTab: setActiveTab,
|
||||
}, dispatch);
|
||||
|
||||
export default compose(
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import update from 'immutability-helper';
|
||||
function determineCommentDepth(comment) {
|
||||
let depth = 0;
|
||||
let cur = comment;
|
||||
while (cur.parent) {
|
||||
cur = cur.parent;
|
||||
depth++;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
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 <= determineCommentDepth(comment); 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 +105,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 +134,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 +189,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';
|
||||
|
||||
@@ -48,5 +48,5 @@ render(
|
||||
<ApolloProvider client={client} store={store}>
|
||||
<AppRouter />
|
||||
</ApolloProvider>
|
||||
, document.querySelector('#coralStream')
|
||||
, document.querySelector('#talk-embed-stream-container')
|
||||
);
|
||||
|
||||
@@ -20,11 +20,19 @@ const initialState = {
|
||||
assetId: getQueryVariable('asset_id'),
|
||||
assetUrl: getQueryVariable('asset_url'),
|
||||
commentId: getQueryVariable('comment_id'),
|
||||
commentClassNames: []
|
||||
commentClassNames: [],
|
||||
activeTab: 'all',
|
||||
previousTab: '',
|
||||
};
|
||||
|
||||
export default function stream(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.SET_ACTIVE_TAB:
|
||||
return {
|
||||
...state,
|
||||
activeTab: action.tab,
|
||||
previousTab: state.activeTab,
|
||||
};
|
||||
case authActions.LOGOUT:
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -17,7 +17,7 @@ body {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
#coralStream {
|
||||
#talk-embed-stream-container {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ body {
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
button {
|
||||
.coralButton {
|
||||
margin: 5px 10px 5px 0px;
|
||||
background: none;
|
||||
padding: 0px;
|
||||
@@ -33,16 +33,16 @@ button {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
.coralButton:hover {
|
||||
border-radius: 2px;
|
||||
color: #767676;
|
||||
}
|
||||
|
||||
button i {
|
||||
.coralButton i {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
hr {
|
||||
.coralHr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
@@ -112,13 +112,6 @@ hr {
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.commentStream .material-icons {
|
||||
vertical-align: middle;
|
||||
width: 1em;
|
||||
font-size: 1em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Question Box Styles */
|
||||
.coral-plugin-questionbox-info {
|
||||
top: 0;
|
||||
@@ -181,28 +174,17 @@ hr {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.talk-stream-wrapper-box {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.talk-stream-comments-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.commentStream {
|
||||
/* prevent absolutely positioned final permalink popover from being clipped */
|
||||
padding-bottom: 50px;
|
||||
min-height: 600px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 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 +194,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 +210,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 +248,6 @@ hr {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.highlighted-comment {
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid rgb(35,118,216);
|
||||
}
|
||||
|
||||
/* Tag Labels */
|
||||
|
||||
.coral-plugin-tag-label {
|
||||
@@ -282,31 +259,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 {
|
||||
font-size: 12px;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
button.comment__action-button,
|
||||
@@ -335,33 +297,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 +379,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,34 +397,32 @@ 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;
|
||||
text-align: center;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.coral-new-comments {
|
||||
position: relative;
|
||||
top: 1.8em;
|
||||
z-index: 100;
|
||||
.talk-load-more-replies {
|
||||
width: 100%;
|
||||
padding-left: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.coral-load-more-replies button.coral-load-more, .coral-new-comments button.coral-load-more{
|
||||
width: initial;
|
||||
.talk-load-more-replies .talk-load-more-button {
|
||||
background-color: transparent;
|
||||
color: #979797;
|
||||
border: #979797 solid 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (min-device-width : 300px) and (max-device-width : 420px) {
|
||||
.commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button {
|
||||
visibility: collapse;
|
||||
margin-left: -30px;
|
||||
}
|
||||
|
||||
.commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button .coral-plugin-replies-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
.talk-load-more-replies .talk-load-more:hover button {
|
||||
background-color: #979797;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
export function getSlotComponents(slot) {
|
||||
const pluginConfig = getStore().getState().config.plugin_config;
|
||||
|
||||
return flatten(plugins
|
||||
|
||||
// Filter out components that have slots and have been disabled in `plugin_config`
|
||||
.filter((o) => o.module.slots && (!pluginConfig || !pluginConfig[o.name] || !pluginConfig[o.name].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}));
|
||||
}
|
||||
|
||||
@@ -64,8 +78,8 @@ export function getSlotsFragments(slots) {
|
||||
}
|
||||
const components = uniq(flattenDeep(slots.map((slot) => {
|
||||
return plugins
|
||||
.filter((o) => o.module.slots[slot])
|
||||
.map((o) => o.module.slots[slot]);
|
||||
.filter((o) => o.module.slots ? o.module.slots[slot] : false)
|
||||
.map((o) => o.module.slots[slot]);
|
||||
})));
|
||||
|
||||
const fragments = getComponentFragments(components);
|
||||
@@ -99,7 +113,22 @@ export function injectPluginsReducers() {
|
||||
const reducers = merge(
|
||||
...plugins
|
||||
.filter((o) => o.module.reducer)
|
||||
.map((o) => ({[camelize(o.plugin)] : o.module.reducer}))
|
||||
.map((o) => ({[camelize(o.name)] : o.module.reducer}))
|
||||
);
|
||||
injectReducers(reducers);
|
||||
}
|
||||
|
||||
function addMetaDataToSlotComponents() {
|
||||
|
||||
// Add talkPluginName to Slot Components.
|
||||
plugins.forEach((plugin) => {
|
||||
const slots = plugin.module.slots;
|
||||
slots && Object.keys(slots).forEach((slot) => {
|
||||
slots[slot].forEach((component) => {
|
||||
component.talkPluginName = plugin.name;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addMetaDataToSlotComponents();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -22,7 +22,7 @@ module.exports = function(source) {
|
||||
const config = this.exec(source, this.resourcePath);
|
||||
const plugins = getPluginList(config).map((plugin) => `{
|
||||
module: require('${plugin}/client'),
|
||||
plugin: '${plugin}'
|
||||
name: '${plugin}'
|
||||
}`);
|
||||
|
||||
return stripIndent`
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,29 @@
|
||||
.buttonReset {
|
||||
|
||||
/* reset button */
|
||||
user-select: none;
|
||||
outline: invert none medium;
|
||||
border: none;
|
||||
touch-action: manipulation;
|
||||
padding: 0;
|
||||
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* Unify anchor and button. */
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
align-items: flex-start;
|
||||
vertical-align: middle;
|
||||
whiteSpace: nowrap;
|
||||
background: transparent;
|
||||
font-size: inherit;
|
||||
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important;
|
||||
&::-moz-focus-inner: {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.button {
|
||||
composes: buttonReset from "coral-framework/styles/reset.css";
|
||||
margin: 5px 10px 5px 0px;
|
||||
}
|
||||
|
||||
.tagIcon {
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import React, {Component, PropTypes} from 'react';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
import {Icon} from 'coral-ui';
|
||||
import classnames from 'classnames';
|
||||
import cn from 'classnames';
|
||||
import styles from './BestButton.css';
|
||||
|
||||
// tag string for best comments
|
||||
export const BEST_TAG = 'BEST';
|
||||
@@ -17,7 +18,7 @@ const canModifyBestTag = ({roles = []} = {}) => roles && ['ADMIN', 'MODERATOR'].
|
||||
|
||||
// Put this on a comment to show that it is best
|
||||
|
||||
export const BestIndicator = ({children = <Icon name='star'/>}) => (
|
||||
export const BestIndicator = ({children = <Icon name='star' className={styles.tagIcon} />}) => (
|
||||
<span aria-label={t('comment_is_best')}>
|
||||
{ children }
|
||||
</span>
|
||||
@@ -95,9 +96,9 @@ export class BestButton extends Component {
|
||||
return (
|
||||
<button onClick={isBest ? this.onClickRemoveBest : this.onClickAddBest}
|
||||
disabled={disabled}
|
||||
className={classnames(`${name}-button`, `e2e__${isBest ? 'unset' : 'set'}-best-comment`)}
|
||||
className={cn(styles.button, `${name}-button`, `e2e__${isBest ? 'unset' : 'set'}-best-comment`)}
|
||||
aria-label={t(isBest ? 'unset_best' : 'set_best')}>
|
||||
<Icon name={ isBest ? 'star' : 'star_border' } />
|
||||
<Icon name={ isBest ? 'star' : 'star_border' } className={styles.icon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const name = 'coral-plugin-comment-count';
|
||||
|
||||
const CommentCount = ({count}) => {
|
||||
return <div className={`${name}-text`}>
|
||||
{`${count} ${count === 1 ? t('comment_singular') : t('comment_plural')}`}
|
||||
</div>;
|
||||
};
|
||||
|
||||
CommentCount.propTypes = {
|
||||
count: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default CommentCount;
|
||||
@@ -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', body: ''});
|
||||
const postedComment = data.createComment.comment;
|
||||
|
||||
// Execute postSubmit Hooks
|
||||
@@ -69,10 +80,13 @@ class CommentBox extends React.Component {
|
||||
}
|
||||
})
|
||||
.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 +137,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 +155,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 +163,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 +180,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`, {[`${name}-button-flagged`]: flagged}, 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', styles.icon, {
|
||||
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,14 @@
|
||||
.button {
|
||||
composes: buttonReset from "coral-framework/styles/reset.css";
|
||||
margin: 5px 0px 5px 10px;
|
||||
}
|
||||
|
||||
.flaggedIcon {
|
||||
color: #f00
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding: 0 2px;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -75,3 +75,8 @@
|
||||
margin: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.iconView, .iconDate {
|
||||
vertical-align: middle;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
@@ -29,12 +29,12 @@ const Comment = (props) => {
|
||||
<div className={styles.sidebar}>
|
||||
<ul>
|
||||
<li>
|
||||
<a onClick={props.link(`${props.asset.url}#${props.comment.id}`)}>
|
||||
<Icon name="open_in_new" />{t('view_conversation')}
|
||||
<a onClick={props.link(`${props.asset.url}?commentId=${props.comment.id}`)}>
|
||||
<Icon name="open_in_new" className={styles.iconView}/>{t('view_conversation')}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Icon name="schedule" />
|
||||
<Icon name="schedule" className={styles.iconDate}/>
|
||||
<PubDate
|
||||
className={styles.pubdate}
|
||||
created_at={props.comment.created_at}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.button {
|
||||
composes: buttonReset from "coral-framework/styles/reset.css";
|
||||
margin: 5px 10px 5px 0px;
|
||||
}
|
||||
|
||||
@media (max-width: 425px) {
|
||||
.label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding: 0 2px;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -2,17 +2,20 @@ import React, {PropTypes} from 'react';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
import classnames from 'classnames';
|
||||
import cn from 'classnames';
|
||||
import styles from './ReplyButton.css';
|
||||
|
||||
const name = 'coral-plugin-replies';
|
||||
|
||||
const ReplyButton = ({onClick}) => {
|
||||
return (
|
||||
<button
|
||||
className={classnames(`${name}-reply-button`)}
|
||||
className={cn(`${name}-reply-button`, styles.button)}
|
||||
onClick={onClick}>
|
||||
{t('reply')}
|
||||
<i className={`${name}-icon material-icons`}
|
||||
<span className={cn(`${name}-label`, styles.label)}>
|
||||
{t('reply')}
|
||||
</span>
|
||||
<i className={cn(`${name}-icon`, 'material-icons', styles.icon)}
|
||||
aria-hidden={true}>reply</i>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
.root {
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Icon as IconMDL} from 'react-mdl';
|
||||
import cn from 'classnames';
|
||||
import styles from './Icon.css';
|
||||
|
||||
const Icon = ({className = '', name}) => (
|
||||
<IconMDL className={className} name={name} />
|
||||
<IconMDL className={cn(styles.root, className)} name={name} />
|
||||
);
|
||||
|
||||
Icon.propTypes = {
|
||||
|
||||
@@ -1,9 +1,70 @@
|
||||
li.base--active {
|
||||
background: white;
|
||||
font-weight: bold;
|
||||
.root {
|
||||
display: inline-block;
|
||||
margin-right: -1px;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
li.material--active {
|
||||
font-weight: bold;
|
||||
border-bottom: solid 2px black;
|
||||
.rootActive {
|
||||
|
||||
}
|
||||
|
||||
.rootSub {
|
||||
display: inline-block;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.rootSubActive {
|
||||
|
||||
}
|
||||
|
||||
.button {
|
||||
composes: buttonReset from "coral-framework/styles/reset.css";
|
||||
padding: 8px 10px;
|
||||
color: #4E5259;
|
||||
border: solid 1px #D8D8D8;
|
||||
background: #F0F0F0;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.button:hover, .button:focus {
|
||||
background: #d5d5d5;
|
||||
border-bottom: 1px solid #d5d5d5;
|
||||
}
|
||||
|
||||
.buttonActive, .buttonActive:hover, .buttonActive:focus {
|
||||
background: white;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid white;
|
||||
}
|
||||
|
||||
.buttonSub {
|
||||
composes: buttonReset from "coral-framework/styles/reset.css";
|
||||
color: black;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 12px;
|
||||
margin-bottom: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.buttonSub:hover, .buttonSub:focus {
|
||||
background: transparent;
|
||||
border-bottom: solid 3px #d5d5d5;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.buttonSubActive, .buttonSubActive:hover, .buttonSubActive:focus {
|
||||
font-weight: bold;
|
||||
border-bottom: solid 3px #10589b;
|
||||
margin-bottom: 0px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.root:only-child .button, .rootSub:only-child .buttonSub {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,109 @@
|
||||
import React from 'react';
|
||||
import styles from './Tab.css';
|
||||
import cn from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default ({children, tabId, active, onTabClick, cStyle = 'base', ...props}) => (
|
||||
<li
|
||||
key={tabId}
|
||||
className={`${active ? `${styles[`${cStyle}--active`]} coral-tab-active` : ''} coral-tab ${props.className}`}
|
||||
onClick={() => onTabClick(tabId)}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
/**
|
||||
* The `Tab` component is used inside the `TabBar` Component, to
|
||||
* render tabs.
|
||||
*/
|
||||
class Tab extends React.Component {
|
||||
handleTabClick = () => {
|
||||
if (this.props.onTabClick) {
|
||||
this.props.onTabClick(this.props.tabId);
|
||||
}
|
||||
}
|
||||
|
||||
getRootClassName({active, className, sub, classNames = {}} = this.props) {
|
||||
return cn(
|
||||
'talk-tab',
|
||||
className,
|
||||
{
|
||||
[classNames.root || styles.root]: !sub,
|
||||
[classNames.rootSub || styles.rootSub]: sub,
|
||||
[classNames.rootActive || styles.rootActive]: active && !sub,
|
||||
[classNames.rootSubActive || styles.rootSubActive]: active && sub,
|
||||
'talk-tab-active': active,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getButtonClassName({sub, active, classNames = {}} = this.props) {
|
||||
return cn(
|
||||
'talk-tab-button',
|
||||
{
|
||||
[classNames.button || styles.button]: !sub,
|
||||
[classNames.buttonSub || styles.buttonSub]: sub,
|
||||
[classNames.buttonActive || styles.buttonActive]: active && !sub,
|
||||
[classNames.buttonSubActive || styles.buttonSubActive]: active && sub,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
classNames: _a,
|
||||
active,
|
||||
onTabClick: _c,
|
||||
tabId: _d,
|
||||
sub: _e,
|
||||
'aria-controls': ariaControls,
|
||||
...rest,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<li
|
||||
{...rest}
|
||||
role="presentation"
|
||||
className={this.getRootClassName()}
|
||||
>
|
||||
<button
|
||||
aria-controls={ariaControls}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={this.getButtonClassName()}
|
||||
onClick={this.handleTabClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Tab.propTypes = {
|
||||
|
||||
// className to be added to the root element.
|
||||
className: PropTypes.string,
|
||||
|
||||
// classNames allows full design customization of the component.
|
||||
classNames: PropTypes.shape({
|
||||
root: PropTypes.string,
|
||||
rootActive: PropTypes.string,
|
||||
rootSub: PropTypes.string,
|
||||
rootSubActive: PropTypes.string,
|
||||
button: PropTypes.string,
|
||||
buttonActive: PropTypes.string,
|
||||
buttonSub: PropTypes.string,
|
||||
buttonSubActive: PropTypes.string,
|
||||
}),
|
||||
|
||||
// active indicates that this tab is currently active.
|
||||
// This is injected by the `TabBar` component.
|
||||
active: PropTypes.bool,
|
||||
|
||||
// onTabClick is fired whenever the tab was clicked. The tabId is passed as
|
||||
// the first argument.
|
||||
onTabClick: PropTypes.func,
|
||||
|
||||
// Sub indicates that this is a tab of a sub-tab-panel.
|
||||
// This is injected by the `TabBar` component.
|
||||
sub: PropTypes.bool,
|
||||
|
||||
// `aria-controls` should be set to the `id` of the `TabContent` for accessibility.
|
||||
// This is injected by the `TabBar` component.
|
||||
'aria-controls': PropTypes.string,
|
||||
};
|
||||
|
||||
export default Tab;
|
||||
|
||||
@@ -1,44 +1,14 @@
|
||||
.base {
|
||||
.root {
|
||||
list-style: none;
|
||||
border-bottom: solid 1px #D8D8D8;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.base li {
|
||||
color: #4E5259;
|
||||
border: solid 1px #D8D8D8;
|
||||
background: #F0F0F0;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
display: inline-block;
|
||||
border-bottom: none;
|
||||
padding: 8px 10px;
|
||||
margin-right: -1px;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.base li:hover {
|
||||
background: #d5d5d5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.material {
|
||||
.rootSub {
|
||||
list-style: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.material li {
|
||||
color: black;
|
||||
border: none;
|
||||
border-bottom: solid 2px white;
|
||||
background: white;
|
||||
padding: 8px 0;
|
||||
margin-right: 40px;
|
||||
}
|
||||
|
||||
.material li:hover {
|
||||
background: white;
|
||||
border-bottom: solid 2px grey;
|
||||
margin: 0;
|
||||
border-bottom: solid 2px #eee;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,86 @@
|
||||
import React from 'react';
|
||||
import styles from './TabBar.css';
|
||||
import cn from 'classnames';
|
||||
import Tab from './Tab';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* The `TabBar` component accepts `Tab` components to create
|
||||
* a tab bar.
|
||||
*/
|
||||
class TabBar extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleClickTab = this.handleClickTab.bind(this);
|
||||
}
|
||||
|
||||
handleClickTab(tabId) {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(tabId);
|
||||
}
|
||||
getRootClassName({className, classNames = {}, sub} = this.props) {
|
||||
return cn(
|
||||
'talk-tab-bar',
|
||||
className,
|
||||
{
|
||||
[classNames.root || styles.root]: !sub,
|
||||
[classNames.rootSub || styles.rootSub]: sub,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {children, activeTab, cStyle = 'base'} = this.props;
|
||||
const {
|
||||
children,
|
||||
activeTab,
|
||||
tabClassNames,
|
||||
classNames: _a,
|
||||
onTabClick: _b,
|
||||
'aria-controls': ariaControls,
|
||||
sub,
|
||||
...rest,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className={`${styles.base} ${cStyle ? styles[cStyle] : ''} ${this.props.className}`}>
|
||||
{React.Children.toArray(children)
|
||||
.filter((child) => !child.props.restricted)
|
||||
.map((child, tabId) =>
|
||||
React.cloneElement(child, {
|
||||
tabId,
|
||||
active: child.props.id === activeTab,
|
||||
onTabClick: this.handleClickTab,
|
||||
cStyle
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<ul
|
||||
{...rest}
|
||||
role="tablist"
|
||||
className={this.getRootClassName()}
|
||||
>
|
||||
{React.Children.toArray(children)
|
||||
.map((child, i) =>
|
||||
React.cloneElement(child, {
|
||||
tabId: (child.props.tabId !== undefined) ? child.props.tabId : i,
|
||||
active: child.props.tabId === activeTab,
|
||||
onTabClick: this.props.onTabClick,
|
||||
classNames: tabClassNames,
|
||||
'aria-controls': ariaControls,
|
||||
sub,
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TabBar.propTypes = {
|
||||
|
||||
// className to be added to the root element.
|
||||
className: PropTypes.string,
|
||||
|
||||
// classNames allows full design customization of the component.
|
||||
classNames: PropTypes.shape({
|
||||
root: PropTypes.string,
|
||||
rootSub: PropTypes.string,
|
||||
}),
|
||||
|
||||
// classNames to be passed to the children.
|
||||
tabClassNames: Tab.propTypes.classNames,
|
||||
|
||||
// activeTab should be set to the currently active tabId.
|
||||
activeTab: PropTypes.string,
|
||||
|
||||
// onTabClick is fired whenever the tab was clicked. The tabId is passed as
|
||||
// the first argument.
|
||||
onTabClick: PropTypes.func,
|
||||
|
||||
// Sub indicates that this is a sub-tab-panel.
|
||||
sub: PropTypes.bool,
|
||||
|
||||
// `aria-controls` should be set to the `id` of the `TabContent` for accessibility.
|
||||
'aria-controls': PropTypes.string,
|
||||
};
|
||||
|
||||
export default TabBar;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
padding-top: 10px;
|
||||
}
|
||||
@@ -1,6 +1,43 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './TabContent.css';
|
||||
|
||||
export default ({children, show = true}) => (
|
||||
show ? <div>{children}</div> : null
|
||||
function getRootClassName(className) {
|
||||
return cn('talk-tab-content', className, styles.root);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `TabContent` component accepts `TabPane` components to render
|
||||
* the content of a `Tab`.
|
||||
*/
|
||||
const TabContent = ({children, className, activeTab, sub, ...rest}) => (
|
||||
<div
|
||||
{...rest}
|
||||
className={getRootClassName(className)}
|
||||
>
|
||||
{
|
||||
React.Children.toArray(children)
|
||||
.filter((child) => child.props.tabId === activeTab)
|
||||
.map((child, i) =>
|
||||
React.cloneElement(child, {
|
||||
tabId: (child.props.tabId !== undefined) ? child.props.tabId : i,
|
||||
sub,
|
||||
}))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
||||
TabContent.propTypes = {
|
||||
|
||||
// className to be added to the root element.
|
||||
className: PropTypes.string,
|
||||
|
||||
// activeTab should be set to the currently active tabId.
|
||||
activeTab: PropTypes.string,
|
||||
|
||||
// Sub indicates that this component belongs to a sub-tab-panel.
|
||||
sub: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default TabContent;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
.root, .rootSub {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
background: #616161;
|
||||
color: white;
|
||||
font-weight: normal;
|
||||
font-size: 10px;
|
||||
padding: 2px;
|
||||
margin-left: 2px;
|
||||
margin-top: -2px;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.rootSubActive {
|
||||
background: #10589b;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import styles from './TabCount.css';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function getNumber(no) {
|
||||
let result = Number.parseInt(no);
|
||||
if (no >= 1000) {
|
||||
result = `${Math.round(result / 100) / 10}k`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getRootClassName({className, active, sub}) {
|
||||
return cn(
|
||||
'talk-tab-count',
|
||||
className,
|
||||
{
|
||||
[styles.root]: !sub,
|
||||
[styles.rootSub]: sub,
|
||||
[styles.rootActive]: active && !sub,
|
||||
[styles.rootSubActive]: active && sub,
|
||||
'talk-tab-active': active,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `TabCount` renders a count number next to a tab name.
|
||||
*/
|
||||
const TabCount = ({children, active, sub, className}) => (
|
||||
<span className={getRootClassName({className, active, sub})}>{getNumber(children)}</span>
|
||||
);
|
||||
|
||||
TabCount.propTypes = {
|
||||
|
||||
// className to be added to the root element.
|
||||
className: PropTypes.string,
|
||||
|
||||
// active indicates that the related tab is currently active.
|
||||
active: PropTypes.bool,
|
||||
|
||||
// Sub indicates that this component belongs to a sub-tab-panel.
|
||||
sub: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default TabCount;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function getRootClassName(className) {
|
||||
return cn('talk-pane', className);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `TabPane` component is used inside the `TabContent` component to render
|
||||
* the content of a `Tab`.
|
||||
*/
|
||||
const TabPane = ({children, className, tabId: _a, sub: _b, ...rest}) => (
|
||||
<div
|
||||
{...rest}
|
||||
className={getRootClassName(className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
TabPane.propTypes = {
|
||||
|
||||
// className to be added to the root element.
|
||||
className: PropTypes.string,
|
||||
|
||||
tabId: PropTypes.string,
|
||||
|
||||
// Sub indicates that this component belongs to a sub-tab-panel.
|
||||
// This is injected by the `TabContent` component.
|
||||
sub: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default TabPane;
|
||||
@@ -4,7 +4,9 @@ export {default as CoralLogo} from './components/CoralLogo';
|
||||
export {default as FabButton} from './components/FabButton';
|
||||
export {default as TabBar} from './components/TabBar';
|
||||
export {default as Tab} from './components/Tab';
|
||||
export {default as TabCount} from './components/TabCount';
|
||||
export {default as TabContent} from './components/TabContent';
|
||||
export {default as TabPane} from './components/TabPane';
|
||||
export {default as Button} from './components/Button';
|
||||
export {default as Spinner} from './components/Spinner';
|
||||
export {default as Tooltip} from './components/Tooltip';
|
||||
|
||||
@@ -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
|
||||
@@ -30,10 +36,35 @@ entries:
|
||||
- title: Setup
|
||||
url: /install-setup.html
|
||||
output: web
|
||||
- title: Troubleshooting
|
||||
url: /install-troubleshooting.html
|
||||
output: web
|
||||
|
||||
- title: Architecture
|
||||
output: web
|
||||
folderitems:
|
||||
- title: Overview
|
||||
url: /architecture.html
|
||||
output: web
|
||||
- title: Tags
|
||||
url: /architecture-tags.html
|
||||
output: web
|
||||
- title: Metadata API
|
||||
url: /architecture-metadata.html
|
||||
output: web
|
||||
- title: cli
|
||||
url: /architecture-cli.html
|
||||
output: web
|
||||
|
||||
- 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,139 @@
|
||||
---
|
||||
title: The Talk cli
|
||||
keywords: architecture
|
||||
sidebar: talk_sidebar
|
||||
permalink: architecture-cli.html
|
||||
summary:
|
||||
---
|
||||
|
||||
Talk ships with a cli tool that allows access to a wide variety of functionality.
|
||||
|
||||
It is designed to provide a convenient way for engineers to perform key tasks without the need to muck about in the UI. It also opens the door for scripts to perform operations programmatically.
|
||||
|
||||
Note: the cli tool requires [the Talk environment to be configured](configuration.html) either via env vars or by using a `.cli` file via `bin/cli -c .env [command] ....`
|
||||
|
||||
## Using the cli
|
||||
|
||||
In a terminal, try:
|
||||
|
||||
```
|
||||
/path/to/talk/bin cli [options] [commands] [arguments]
|
||||
```
|
||||
|
||||
Commonly, you'll be in the `talk/` folder, in which case you can:
|
||||
|
||||
```
|
||||
bin/cli [options] [commands] [arguments]
|
||||
```
|
||||
|
||||
If you're a heavy cli user, consider adding the `bin` folder to your PATH so you can run `cli` from anywhere!
|
||||
|
||||
If you are using [our Docker environment](install-docker.html), the bin folder will already be in the PATH.
|
||||
|
||||
## What can I do with the cli?
|
||||
|
||||
The Talk cli ships with 'unix style' help. To access the docs, simply run the cli with insufficient arguments.
|
||||
|
||||
Let's say I wanted to figure out how to change a user's password. I'd start be seeing what the cli has for me.
|
||||
|
||||
(Note: the following output may change, please reference at the `--help` output for your version as you use the cli.)
|
||||
|
||||
```
|
||||
talk :) ]$ bin/cli --help
|
||||
|
||||
Usage: cli [options] [command]
|
||||
|
||||
|
||||
Commands:
|
||||
|
||||
serve serve the application
|
||||
settings interact with the application settings
|
||||
assets interact with assets
|
||||
setup setup the application
|
||||
jobs work with the job queues
|
||||
token work with the access tokens
|
||||
users work with the application auth
|
||||
migration provides utilities for migrating the database
|
||||
plugins provides utilities for interacting with the plugin system
|
||||
help [cmd] display help for [cmd]
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help output usage information
|
||||
-V, --version output the version number
|
||||
-c, --config [path] Specify the configuration file to load
|
||||
--pid [path] Specify a path to output the current PID to
|
||||
```
|
||||
|
||||
Most commands contain sub-commands. Running with cli with such a command generates the docs for the sub-commands and options therein.
|
||||
|
||||
Change user password is likely to be in the `users` command group.
|
||||
|
||||
```
|
||||
talk :) ]$ bin/cli users
|
||||
|
||||
Usage: cli-users [options] [command]
|
||||
|
||||
|
||||
Commands:
|
||||
|
||||
create [options] create a new user
|
||||
delete <userID> delete a user
|
||||
passwd <userID> change a password for a user
|
||||
update [options] <userID> update a user
|
||||
list list all the users in the database
|
||||
merge <dstUserID> <srcUserID> merge srcUser into the dstUser
|
||||
addrole <userID> <role> adds a role to a given user
|
||||
removerole <userID> <role> removes a role from a given user
|
||||
ban <userID> ban a given user
|
||||
uban <userID> unban a given user
|
||||
disable <userID> disable a given user from logging in
|
||||
enable <userID> enable a given user from logging in
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help output usage information
|
||||
-V, --version output the version number
|
||||
-c, --config [path] Specify the configuration file to load
|
||||
--pid [path] Specify a path to output the current PID to
|
||||
```
|
||||
|
||||
I now see that I can change a password like so:
|
||||
|
||||
```
|
||||
bin/cli users passwd [userID]
|
||||
```
|
||||
|
||||
You can also read these help prompts by [exploring the source code](https://github.com/coralproject/talk/blob/master/bin/cli).
|
||||
|
||||
### Usage Notes
|
||||
|
||||
If you haven't used a cli enabled system before, think of it like this: generally, you'd make a rest call, rpc, etc... to perform an action. The cli's api is designed in the same way, just for the audience of command line wielding engineers and scripts.
|
||||
|
||||
The best way to understand what the cli does is to explore the help commands. Uses of cli are also scattered through this documentation in context of their topics.
|
||||
|
||||
For some real world uses of the cli, see the scripts in the [package.json file](https://github.com/coralproject/talk/blob/d688f70c19d8dee48371784009fd07322dae4eb5/package.json#L8).
|
||||
|
||||
## What's really going on when I run the cli?
|
||||
|
||||
The cli tool is a standalone application. Running it starts up the internals of a talk process, executes the given command, then shuts it down. No server functionality is enabled by cli commands unless specifically noted.
|
||||
|
||||
The cli tool _does not require a talk server to be running._ This means that you can execute commands, for example, during and installation process before starting the server. The also means that you can execute commands using varying configurations (via the `-c [.env file]` flag).
|
||||
|
||||
### Accessing existing Talk installs with the cli
|
||||
|
||||
You may use the cli tool to 'remotely' control existing talk installs.
|
||||
|
||||
This is accomplished by running the cli tool on any box using the mongo/redis/etc... credentials for the environment that you would like to act on. For example, if you want something to happen periodically on your production Talk install, you could set up a utility box with a cron job that triggers the cli with the same db/cache credentials. If you want to do something quick on a staging server, you could run the cli locally with staging credentials.
|
||||
|
||||
The cli tool will connect directly with the install's db and redis instance(s) so ensure that your box can reach those servers on the appropriate ports.
|
||||
|
||||
Also, _please ensure your cli and the server(s) in an environment are using the same version of Talk._
|
||||
|
||||
Please secure your environments and credentials or the cli tool becomes a convenient way for someone to own your system.
|
||||
|
||||
## Extending the cli
|
||||
|
||||
The Talk cli is based on the excellent [commander](https://github.com/tj/commander.js/) library.
|
||||
|
||||
At the time of writing, there are no plugin hooks for the cli tool. If you would like to change this, whether by writing code yourself or recommending a need, please [write and issue](https://github.com/coralproject/talk/blob/053b687959d45bcd682a1a2a4b604ebfab7441bb/CONTRIBUTING.md#writing-code).
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Metadata
|
||||
keywords: architecture
|
||||
sidebar: talk_sidebar
|
||||
permalink: architecture-metadata.html
|
||||
summary:
|
||||
---
|
||||
|
||||
_Metadata_ allows you to add fields to models that are not represented in the core schema.
|
||||
|
||||
## Goals
|
||||
|
||||
The metadata api is designed to satisfy two product goals:
|
||||
|
||||
* Give developers flexibility in extending datatypes.
|
||||
* Protect core fields that are essential to Talk's operation.
|
||||
|
||||
## Design
|
||||
|
||||
Metadata is represented by an [subdocument in our Schemas](https://github.com/coralproject/talk/blob/c59c09e1f42c51eed3b0d57b7c2882fc7b5edc13/models/comment.js#L74). This takes advantage of Mongo's flexibility allowing for any data to be stored therein.
|
||||
|
||||
### Setting Metadata
|
||||
|
||||
Talk provides [a service layer](https://github.com/coralproject/talk/blob/c59c09e1f42c51eed3b0d57b7c2882fc7b5edc13/services/metadata.js) allowing developers to `set` and `unset` metadata on objects in a way similar to key-value stores.
|
||||
|
||||
Let's say that I want to add a custom field called `potency` to a comment.
|
||||
|
||||
```
|
||||
const MetadataService = require('services/metadata');
|
||||
const CommentModel = require('models/comment');
|
||||
|
||||
// Sets the property `potency` on the comment with `id=1`.
|
||||
MetadataService.set(CommentModel, '1', 'potency', 42);
|
||||
```
|
||||
|
||||
Note that the model passed here is the Model itself and not an individual comment object. This allows us to update the value on that document [in an atomic manner](https://github.com/coralproject/talk/blob/c59c09e1f42c51eed3b0d57b7c2882fc7b5edc13/services/metadata.js#L60) for efficiency and to prevent race conditions.
|
||||
|
||||
### Accessing Metadata
|
||||
|
||||
The metadata api does not contain a `get` method. The metadata object is retrieved via database queries along with the rest of the data.
|
||||
|
||||
## Metadata and the Graph
|
||||
|
||||
One of the first principles of GraphQL is that the shape of the graph does not need to be the same as the shape of the data in the database. In fact, it probably shouldn't be.
|
||||
|
||||
This enables us to treat metadata fields in any way that makes sense as we design our Graph. The fact that a value is stored in the metadata object is an implementation detail invisible to the front end.
|
||||
|
||||
Take for example, the `reason` field in the `FlagAction` type. This stores the user provided reason why they flagged a comment. As far as the front end knows, it's [just another field](https://github.com/coralproject/talk/blob/c59c09e1f42c51eed3b0d57b7c2882fc7b5edc13/graph/typeDefs.graphql#L453) alongside the core fields:
|
||||
|
||||
```
|
||||
# graph/typeDefs.graphql
|
||||
type FlagAction implements Action {
|
||||
|
||||
...
|
||||
|
||||
# The reason for which the Flag Action was created.
|
||||
reason: String
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
If, however, we [look at the resolver](https://github.com/coralproject/talk/blob/a47e2378e96f34f25447782f3e7ce59fa48ec791/graph/resolvers/dont_agree_action.js) for that field, we see that `reason` is [destructured](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) from the metadata object and returned.
|
||||
|
||||
```
|
||||
// graph/resolvers/dont_agree_action.js
|
||||
const DontAgreeAction = {
|
||||
|
||||
// Stored in the metadata, extract and return.
|
||||
reason({metadata: {reason}}) {
|
||||
return reason;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = DontAgreeAction;
|
||||
```
|
||||
|
||||
This is an extremely powerful pattern as it allows us absolute freedom in designing our graph and complete isolation of the added fields in the database.
|
||||
|
||||
## Some things to keep in mind
|
||||
|
||||
### Namespace your metadata fields
|
||||
|
||||
Since metadata can be added by the core and multiple plugins, collisions may occur. As you create your plugins, please be careful to pick unique names for metadata fields. We recommend namespacing all your fields in a subdocument named after your plugin.
|
||||
|
||||
```
|
||||
[model].metadata.[your_plugin_name].[the_field]
|
||||
```
|
||||
|
||||
### Querying by metadata fields
|
||||
|
||||
We currently do not have a clean way to index metadata fields. As a result queries that match against metadata fields will not scale. If you have a need to match, sort, etc... by a metadata field, [please let us know](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md#writing-code).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user