mirror of
https://github.com/wassname/talk.git
synced 2026-09-14 11:36:51 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b4c2876c7 | ||
|
|
9ff2ccf1ed | ||
|
|
2c2c2dc604 | ||
|
|
94e429e431 | ||
|
|
7d0aac5d4a | ||
|
|
d4c4b5a3e9 | ||
|
|
b88cbe30de | ||
|
|
9c448d5143 | ||
|
|
67301e6e85 | ||
|
|
ad4386b235 | ||
|
|
5666c4ac90 | ||
|
|
37ac5157dd | ||
|
|
5db9ea6520 | ||
|
|
a4b9f86e29 | ||
|
|
cee1c5bc1c | ||
|
|
19a83a902a | ||
|
|
c74de328f2 | ||
|
|
801ce13960 | ||
|
|
b71b7cc883 | ||
|
|
78ef98bc8c | ||
|
|
b2428371c7 | ||
|
|
bb4f660bb6 | ||
|
|
aaa5d17ca3 | ||
|
|
f6938271f6 | ||
|
|
54b5dcb244 | ||
|
|
35938bbd38 | ||
|
|
ac2a636274 | ||
|
|
56e2f70ebb | ||
|
|
188db361ac | ||
|
|
860f434335 | ||
|
|
31c6e2f891 | ||
|
|
a8a32ef36f | ||
|
|
76e2066a51 | ||
|
|
c60cfebde7 | ||
|
|
2743f6faf6 | ||
|
|
a41108a53c | ||
|
|
e9c81f0639 | ||
|
|
d1ab279f74 | ||
|
|
c68afe9819 | ||
|
|
fcf25e0b96 | ||
|
|
5048fbb7f0 | ||
|
|
4e060b884b | ||
|
|
f20f0c5d10 | ||
|
|
35f01c2ecc | ||
|
|
945b0b5df8 | ||
|
|
a58e15c4c8 | ||
|
|
0dee09dd93 | ||
|
|
e8c3bdc5b1 | ||
|
|
bcf31e5fa9 | ||
|
|
a2131c205a | ||
|
|
4ae68b4bd3 | ||
|
|
267338c851 | ||
|
|
fddd8d63c7 | ||
|
|
bdd8ec3789 | ||
|
|
8708fcf2cd | ||
|
|
1db168df8c | ||
|
|
3dc2674f47 | ||
|
|
1ec30854c2 | ||
|
|
7c38b9b54f | ||
|
|
4519a550a2 | ||
|
|
00e24ce88a | ||
|
|
467a32038e | ||
|
|
2d212393ff | ||
|
|
d1729e5115 | ||
|
|
85e552cf04 | ||
|
|
2c5f46b3ae | ||
|
|
3ff9313b03 | ||
|
|
fc4b821f05 | ||
|
|
8fc2d5e8aa | ||
|
|
f0e584f0b3 | ||
|
|
6df2103da3 | ||
|
|
900cb33122 | ||
|
|
6075317564 | ||
|
|
f3f651c562 | ||
|
|
1ffa78dcfb | ||
|
|
cbba3acf4a | ||
|
|
f3f1cc53fe | ||
|
|
0de87af07a | ||
|
|
c47047c3ff | ||
|
|
c9207fd620 | ||
|
|
09cb518049 | ||
|
|
5b7750f7a5 | ||
|
|
50774f1922 | ||
|
|
eda15538fe | ||
|
|
50219922d5 | ||
|
|
b12d4ef4a2 | ||
|
|
348c01ae80 | ||
|
|
1b95fbd852 | ||
|
|
3bf2187688 | ||
|
|
51b866cd65 | ||
|
|
b7ffbdb762 | ||
|
|
ffdec225c7 | ||
|
|
59fbff3caf | ||
|
|
53f7ab86c0 | ||
|
|
4ac71c884b | ||
|
|
6155a46e71 | ||
|
|
13965e1111 | ||
|
|
7b4e8ef204 | ||
|
|
1abafb7037 | ||
|
|
1535f10450 | ||
|
|
82927b77be | ||
|
|
b34cd2d4c1 | ||
|
|
bd0852bf68 | ||
|
|
c72a5c6df9 | ||
|
|
9ddc6c35fd | ||
|
|
f724353cf0 | ||
|
|
7698bdf930 | ||
|
|
1750510d34 | ||
|
|
2eda1cb1e4 | ||
|
|
4ce4c74fd9 | ||
|
|
bfacc8f2f7 | ||
|
|
ad15b38a5d | ||
|
|
fd6282077f | ||
|
|
673bbb61b1 | ||
|
|
449a6f67f1 | ||
|
|
b5210f3727 | ||
|
|
cc6882168b | ||
|
|
df859dc782 | ||
|
|
a5d11a05d1 | ||
|
|
7526d64bc0 | ||
|
|
04a4918fd5 | ||
|
|
371e3b951d | ||
|
|
f66e12be18 | ||
|
|
fb010bfa67 | ||
|
|
e729a673a3 | ||
|
|
d6b2d6d876 | ||
|
|
55cdc98303 | ||
|
|
d5ff9d3eeb | ||
|
|
404301bd43 | ||
|
|
8c478d1c13 | ||
|
|
bee27da725 | ||
|
|
a873034288 | ||
|
|
8164fa7813 | ||
|
|
d1bc747009 | ||
|
|
e028e30afd | ||
|
|
ff3cffdc8f | ||
|
|
d4fc8a474d | ||
|
|
a19fe02782 | ||
|
|
81e040397e | ||
|
|
07ea548d46 | ||
|
|
99b0f8f2ad | ||
|
|
cb652dbb86 | ||
|
|
fa944927e4 | ||
|
|
fb6ba37366 | ||
|
|
9cf31e0bfa | ||
|
|
061e775227 | ||
|
|
258321201b | ||
|
|
71e1c01552 | ||
|
|
1fc0bf6ec1 | ||
|
|
e58cf8e933 | ||
|
|
7e34bc1d13 | ||
|
|
193ed8c863 | ||
|
|
642a9b87ca | ||
|
|
d9c8882d8f | ||
|
|
902071c0fe | ||
|
|
018e88f7cf | ||
|
|
c395bc8849 | ||
|
|
bd782b98ab | ||
|
|
d5552df41f | ||
|
|
7aa262e97f | ||
|
|
fa72a661ad | ||
|
|
90db03bc29 | ||
|
|
6aaca9130a | ||
|
|
7bcd336f51 | ||
|
|
9dae72dd8a | ||
|
|
118da885fd | ||
|
|
abc7aa7b30 | ||
|
|
7969985f59 | ||
|
|
22fc8555ad | ||
|
|
d9d3f5e85b | ||
|
|
32defa66e6 | ||
|
|
2e37512851 | ||
|
|
78286d3374 | ||
|
|
941db338d0 | ||
|
|
7424fc9774 | ||
|
|
d5016dff58 | ||
|
|
7fca451263 | ||
|
|
dbc95db805 | ||
|
|
42a1026f9c | ||
|
|
9e16e58d19 | ||
|
|
e04f0f7070 | ||
|
|
2fcfaa8a99 | ||
|
|
f7ee08bd3a | ||
|
|
31960d67f3 | ||
|
|
c0913d8e98 | ||
|
|
e8d9f8aab8 | ||
|
|
6c0e00155f | ||
|
|
3837c316d5 | ||
|
|
336dd261ca | ||
|
|
e256c4a014 | ||
|
|
0254086c28 | ||
|
|
19f402ddc8 | ||
|
|
ac632a69d2 | ||
|
|
72c698231b | ||
|
|
83eb6492c2 | ||
|
|
7590cee986 | ||
|
|
f313d2a7d2 | ||
|
|
2ecf64ae3e | ||
|
|
fbcea2de6d | ||
|
|
247583b858 | ||
|
|
8cf2130e25 | ||
|
|
295f4ddec2 | ||
|
|
a7be2772c3 | ||
|
|
847ad2263b | ||
|
|
eb1c1db09b | ||
|
|
12e383ef13 | ||
|
|
a43e713ac0 | ||
|
|
0dff670b70 | ||
|
|
58ffd61884 | ||
|
|
4d2e5ed81f | ||
|
|
7dfe12b874 | ||
|
|
58736586e4 | ||
|
|
b561e9f643 | ||
|
|
873da1cea9 | ||
|
|
c7d8c40770 | ||
|
|
80658f51d1 | ||
|
|
2656141d59 | ||
|
|
c59ddb72a6 | ||
|
|
70ec5d5f54 | ||
|
|
bc3ec84180 | ||
|
|
803db345ac | ||
|
|
990b9794e9 | ||
|
|
a9412c17c9 | ||
|
|
545f176bb8 | ||
|
|
d3d7045227 | ||
|
|
2dc882a12e | ||
|
|
152a9417b2 | ||
|
|
a77a623e94 | ||
|
|
5d81429673 | ||
|
|
536b2ed371 | ||
|
|
aeaa9ae80f | ||
|
|
014c23bb95 | ||
|
|
6aad4411d9 | ||
|
|
917be12667 | ||
|
|
e888161fe4 | ||
|
|
aef0f433c9 | ||
|
|
c6189e8b3a |
@@ -18,3 +18,6 @@ plugins.json
|
||||
plugins/*
|
||||
!plugins/coral-plugin-facebook-auth
|
||||
!plugins/coral-plugin-respect
|
||||
!plugins/coral-plugin-offtopic
|
||||
|
||||
**/node_modules/*
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:7
|
||||
FROM node:7.8
|
||||
|
||||
# Create app directory
|
||||
RUN mkdir -p /usr/src/app
|
||||
|
||||
+2
-2
@@ -3,12 +3,12 @@ FROM coralproject/talk:latest
|
||||
# Bundle app source
|
||||
ONBUILD COPY . /usr/src/app
|
||||
|
||||
# At this stage, we need to install the development dependancies again because
|
||||
# At this stage, we need to install the development dependancies again because
|
||||
# we need to have webpack available. We then build the new dependancies and
|
||||
# clear out the development dependancies again. After this we of course need to
|
||||
# clear out the yarn cache, this saves quite a lot of size.
|
||||
ONBUILD RUN NODE_ENV=development yarn install --frozen-lockfile && \
|
||||
NODE_ENV=production cli plugins reconcile && \
|
||||
NODE_ENV=production yarn build && \
|
||||
NODE_ENV=production yarn install --production && \
|
||||
NODE_ENV=production yarn install --production --force && \
|
||||
yarn cache clean
|
||||
+218
-37
@@ -1,37 +1,204 @@
|
||||
## Contents
|
||||
|
||||
- [Installation](#installation) - install the application on a machine
|
||||
- [Via Docker](#installation-from-docker)
|
||||
- [Via Source](#installation-from-source)
|
||||
- [Setup](#setup) - setup the application for first use
|
||||
- [Usage](#usage) - connect the application to a website
|
||||
|
||||
# Installation
|
||||
|
||||
## Requirements
|
||||
|
||||
### System
|
||||
|
||||
- Any flavour of Linux, OSX or Windows
|
||||
- 1GB memory (minimum)
|
||||
- 5GB storage (minimum)
|
||||
- [MongoDB](https://www.mongodb.com/) v3.4 or later
|
||||
- [Redis](https://redis.io/) v3.2 or later
|
||||
- SSL Certificate
|
||||
- This application assumes that you will be serving this application in a
|
||||
production environment, and therefore requires that you serve it behind a
|
||||
webserver with a valid SSL certificate. This is chosen in order to secure
|
||||
user's sessions.
|
||||
|
||||
## Installation From Docker
|
||||
|
||||
We currently support packaging the Talk application via Docker, which automates
|
||||
the dependency install and asset build process. This is the recommended way to
|
||||
deploy the application when used in production.
|
||||
|
||||
Available as [coralproject/talk](https://hub.docker.com/r/coralproject/talk/) on Docker Hub.
|
||||
|
||||
Images are tagged using the following notation:
|
||||
|
||||
- `x` (where `x` is the major version number): any minor or patch updates will be included in this. If you're ok getting
|
||||
new features occasionally and all the bug fixes, this is the tag for you.
|
||||
- `x.y` (where `y` is the minor version number): any patch updates will be
|
||||
included with this tag. If you like getting fixes and having features change
|
||||
only when you want, this is the tag for you. **(recommended)**
|
||||
- `x.y.z` (where `z` is the patch version): this tag never gets updated, and
|
||||
essentially freezes your version, this should only be used when you are either
|
||||
extending Talk or are sure of a specific version you want to freeze.
|
||||
|
||||
We provide tags with `*-onbuild` that can be used for easy plugin integration and
|
||||
acts as a customization endpoint. Instructions are provided in the `PLUGINS.md`
|
||||
document as to how to use it.
|
||||
|
||||
### Requirements
|
||||
|
||||
There are some runtime requirements for running Talk for Docker:
|
||||
|
||||
- [Docker](https://www.docker.com/) v1.13.0 or later
|
||||
- [Docker Compose](https://docs.docker.com/compose/) v1.10.0 or later
|
||||
|
||||
_Please be sure to check the versions of these requirements. Incorrect versions
|
||||
of these may lead to unexpected errors!_
|
||||
|
||||
### Installing
|
||||
|
||||
An example docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
version: '2'
|
||||
services:
|
||||
talk:
|
||||
image: coralproject/talk:1.5
|
||||
restart: always
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
environment:
|
||||
- TALK_MONGO_URL=mongodb://mongo/talk
|
||||
- TALK_REDIS_URL=redis://redis
|
||||
mongo:
|
||||
image: mongo:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- mongo:/data/db
|
||||
redis:
|
||||
image: redis:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- redis:/data
|
||||
volumes:
|
||||
mongo:
|
||||
external: false
|
||||
redis:
|
||||
external: false
|
||||
```
|
||||
|
||||
At this stage, you should refer to the `README.md` for configuration variables
|
||||
that are specific to your installation. Some pre-defined fields have been filled
|
||||
in the above example which are consistent with Docker Compose naming conventions
|
||||
for [Docker Links](https://docs.docker.com/compose/networking/#links).
|
||||
|
||||
### Scaling
|
||||
|
||||
If you are interested in splitting apart services, you can simply adjust the
|
||||
command being executed in the container to optimize for your use case. An
|
||||
example would be if you wanted to run the API server and the job processor
|
||||
on different machines. You can achieve this easily with docker compose:
|
||||
|
||||
```yaml
|
||||
version: '2'
|
||||
services:
|
||||
talk-api:
|
||||
image: coralproject/talk:1.5
|
||||
command: cli serve
|
||||
restart: always
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
environment:
|
||||
- TALK_MONGO_URL=mongodb://mongo/talk
|
||||
- TALK_REDIS_URL=redis://redis
|
||||
talk-jobs:
|
||||
image: coralproject/talk:1.5
|
||||
command: cli jobs process
|
||||
restart: always
|
||||
ports:
|
||||
- "5001:5000"
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
environment:
|
||||
- TALK_MONGO_URL=mongodb://mongo/talk
|
||||
- TALK_REDIS_URL=redis://redis
|
||||
mongo:
|
||||
image: mongo:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- mongo:/data/db
|
||||
redis:
|
||||
image: redis:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- redis:/data
|
||||
volumes:
|
||||
mongo:
|
||||
external: false
|
||||
redis:
|
||||
external: false
|
||||
```
|
||||
|
||||
Note that the only difference is in the `command` key. From this, you are able
|
||||
to discretely control which modules are running in order to have the maximum
|
||||
flexibility when managing your application.
|
||||
|
||||
### Running
|
||||
|
||||
If you're using docker compose:
|
||||
|
||||
```bash
|
||||
# Start the services using compose
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
If you're using plain docker:
|
||||
|
||||
```bash
|
||||
docker run -d -P coralproject/talk:latest
|
||||
```
|
||||
|
||||
## Installation From Source
|
||||
|
||||
This provides information on how to setup the application from source. Note that
|
||||
this is not recommended for production deploys, but will work for development
|
||||
and testing purposes.
|
||||
|
||||
### Requirements
|
||||
|
||||
There are some runtime requirements for running Talk from source:
|
||||
|
||||
- [Node](https://nodejs.org/) v7 or later
|
||||
- [MongoDB](https://www.mongodb.com/) v3.4 or later
|
||||
- [Redis](https://redis.io/) v3.2 or later
|
||||
- [Yarn](https://yarnpkg.com/) v0.19.1 or later
|
||||
- [Node](https://nodejs.org/) ~7.8
|
||||
- [Yarn](https://yarnpkg.com/) ^0.22.0
|
||||
|
||||
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
|
||||
_Please be sure to check the versions of these requirements. Incorrect versions
|
||||
of these may lead to unexpected errors!_
|
||||
|
||||
### Installing
|
||||
|
||||
#### Download
|
||||
|
||||
It is highly recommended that you download a released version as the code
|
||||
available in `master` may not be stable. You can download the latest release
|
||||
from the [releases page](https://github.com/coralproject/talk/releases).
|
||||
|
||||
You can also clone the git repository via:
|
||||
|
||||
```bash
|
||||
# Download the tarball containing the repository
|
||||
curl -L https://github.com/coralproject/talk/tarball/master -o coralproject-talk.tar.gz
|
||||
git clone https://github.com/coralproject/talk.git
|
||||
```
|
||||
|
||||
# Untar that file and change to that directory
|
||||
tar xpf coralproject-talk.tar.gz
|
||||
mv coralproject-talk-* coralproject-talk
|
||||
cd coralproject-talk
|
||||
#### Building
|
||||
|
||||
We now have to install the dependencies and build the static assets.
|
||||
|
||||
```bash
|
||||
# Install package dependancies
|
||||
yarn
|
||||
|
||||
@@ -39,6 +206,17 @@ yarn
|
||||
yarn build
|
||||
```
|
||||
|
||||
After you create/modify the `plugins.json` (refer to `PLUGINS.md` for plugin
|
||||
docs) file, you can re-run the following to install their dependencies:
|
||||
|
||||
```bash
|
||||
# Reconcile plugins
|
||||
./bin/cli plugins reconcile
|
||||
|
||||
# Build static files
|
||||
yarn build
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
Refer to the `README.md` file for required configuration variables to add to the
|
||||
@@ -50,45 +228,48 @@ You can start the server after configuring the server using the command:
|
||||
yarn start
|
||||
```
|
||||
|
||||
This will setup the server to serve everything on a single node.js process and
|
||||
is designed to be used in production.
|
||||
|
||||
You can see other scripts we've made available by consulting the `package.json`
|
||||
file under the `scripts` key including:
|
||||
|
||||
- `yarn test` run unit tests
|
||||
- `yarn e2e` run end to end tests
|
||||
- `yarn build-watch` watch for changes to client files and build static assets
|
||||
- `yarn dev-start` watch for changes to server files and reload the server
|
||||
- `yarn dev-start` watch for changes to server files and reload the server while
|
||||
also sourcing a `.env` file in your local directory for configuration
|
||||
|
||||
## Installation From Docker Hub
|
||||
# Setup
|
||||
|
||||
### Requirements
|
||||
Once you've installed Talk (either via Docker or source), you still need to
|
||||
setup the application. If you are unfamiliar with any terminoligy used in the
|
||||
setup process, refer to the `TERMINOLOGY.md` document.
|
||||
|
||||
There are some runtime requirements for running Talk for Docker:
|
||||
## Via Web
|
||||
|
||||
- [MongoDB](https://www.mongodb.com/) v3.2 or later
|
||||
- [Redis](https://redis.io/) v3.2 or later
|
||||
- [Docker](https://www.docker.com/) v1.13.0 or later
|
||||
- [Docker Compose](https://docs.docker.com/compose/) v1.10.0 or later
|
||||
If you want to perform your setup via the web, you can navigate to your
|
||||
installation of Talk at the path `/admin/install`. There you will be asked a
|
||||
series of questions for your installation.
|
||||
|
||||
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
|
||||
## Via CLI
|
||||
|
||||
### Installing
|
||||
If you want to perform your setup through the terminal, you can simply run:
|
||||
|
||||
```bash
|
||||
# Create a directory for talk
|
||||
mkdir coralproject-talk
|
||||
cd coralproject-talk
|
||||
|
||||
# Download the docker-compose.yml file from the repository
|
||||
curl -LO https://raw.githubusercontent.com/coralproject/talk/master/docker-compose.yml
|
||||
cli setup
|
||||
```
|
||||
|
||||
At this stage, you should refer to the `README.md` file for required
|
||||
configuration variables to add to the environment key for the `talk` service
|
||||
listed in the `docker-compose.yml` file.
|
||||
And follow the instructions to perform initial setup and create your first user
|
||||
account.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Start the services using compose
|
||||
docker-compose up -d
|
||||
```
|
||||
# Usage
|
||||
|
||||
After setup is complete, you can then refer to the `/admin/configure` path to
|
||||
get the embed code that you can copy/paste onto your blog or website in order to
|
||||
start using Talk.
|
||||
|
||||
_In order for the embed to work correctly, you will need to whitelist the domain
|
||||
that is allowed to embed your site on the `/admin/configure` page, failure to do
|
||||
so will result in the comment stream not loading._
|
||||
|
||||
+58
-12
@@ -46,27 +46,73 @@ External plugins can be resolved by running:
|
||||
./bin/cli plugins reconcile
|
||||
```
|
||||
|
||||
This will also traverse into local plugin folders and install their
|
||||
dependancies. _Note that if the plugin is already installed and available in the
|
||||
node_modules folder, it will not be fetched again unless there is a version
|
||||
mismatch._
|
||||
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
|
||||
|
||||
From your plugins you may import any component of server code relative to the
|
||||
project root. An example could be:
|
||||
|
||||
```js
|
||||
const cache = require('services/cache');
|
||||
```
|
||||
|
||||
You may also include additional external depenancies in your local packages by
|
||||
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.
|
||||
|
||||
+4
-3
@@ -67,7 +67,7 @@ function reconcilePackages({quiet = false, upgradeRemote = false}) {
|
||||
console.log(' +missing (m) packages are not found');
|
||||
console.log();
|
||||
}
|
||||
|
||||
|
||||
for (let i in plugins) {
|
||||
let section = itteratePlugins(plugins[i]);
|
||||
|
||||
@@ -118,7 +118,7 @@ function reconcilePackages({quiet = false, upgradeRemote = false}) {
|
||||
if (!quiet) {
|
||||
console.log(` e ${name}`);
|
||||
}
|
||||
|
||||
|
||||
if (upgradeRemote) {
|
||||
upgradable.push({name, version});
|
||||
}
|
||||
@@ -141,12 +141,13 @@ async function reconcileRemotePlugins({skipLocal, dryRun, upgradeRemote}) {
|
||||
|
||||
if (fetchable.length > 0) {
|
||||
|
||||
console.log(`$ yarn add ${fetchable.map(({name, version}) => `${name}@${version}`.cyan)}`);
|
||||
console.log(`$ yarn add --ignore-scripts ${fetchable.map(({name, version}) => `${name}@${version}`.cyan)}`);
|
||||
|
||||
if (!dryRun) {
|
||||
|
||||
let args = [
|
||||
'add',
|
||||
'--ignore-scripts',
|
||||
...fetchable.map(({name, version}) => `${name}@${version}`)
|
||||
];
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
machine:
|
||||
node:
|
||||
version: 7
|
||||
version: 7.9
|
||||
services:
|
||||
- docker
|
||||
- redis
|
||||
|
||||
@@ -39,6 +39,9 @@ const routes = (
|
||||
{/* Moderation Routes */}
|
||||
|
||||
<Route path='moderate' component={ModerationLayout}>
|
||||
<Route path='all' components={ModerationContainer}>
|
||||
<Route path=':id' components={ModerationContainer} />
|
||||
</Route>
|
||||
<Route path='premod' components={ModerationContainer}>
|
||||
<Route path=':id' components={ModerationContainer} />
|
||||
</Route>
|
||||
|
||||
@@ -6,3 +6,15 @@ export const singleView = () => ({type: actions.SINGLE_VIEW});
|
||||
// Ban User Dialog
|
||||
export const showBanUserDialog = (user, commentId, showRejectedNote) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId, showRejectedNote});
|
||||
export const hideBanUserDialog = (showDialog) => ({type: actions.HIDE_BANUSER_DIALOG, showDialog});
|
||||
|
||||
// hide shortcuts note
|
||||
export const hideShortcutsNote = () => {
|
||||
try {
|
||||
window.localStorage.setItem('coral:shortcutsNote', 'hide');
|
||||
} catch (e) {
|
||||
|
||||
// above will fail in Safari private mode
|
||||
}
|
||||
|
||||
return {type: actions.HIDE_SHORTCUTS_NOTE};
|
||||
};
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import React from 'react';
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './ModerationList.css';
|
||||
import {Button} from 'coral-ui';
|
||||
import {menuActionsMap} from '../containers/ModerationQueue/helpers/moderationQueueActionsMap';
|
||||
|
||||
const ActionButton = ({type = '', ...props}) => {
|
||||
const ActionButton = ({type = '', status, ...props}) => {
|
||||
const typeName = type.toLowerCase();
|
||||
const active = ((type === 'REJECT' && status === 'REJECTED') || (type === 'APPROVE' && status === 'ACCEPTED'));
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`${type.toLowerCase()} ${styles.actionButton}`}
|
||||
cStyle={type.toLowerCase()}
|
||||
className={`${typeName} ${styles.actionButton} ${active ? styles[`${typeName}__active`] : ''}`}
|
||||
cStyle={typeName}
|
||||
icon={menuActionsMap[type].icon}
|
||||
onClick={type === 'APPROVE' ? props.acceptComment : props.rejectComment}
|
||||
>{menuActionsMap[type].text}</Button>
|
||||
);
|
||||
};
|
||||
|
||||
ActionButton.propTypes = {
|
||||
status: PropTypes.string
|
||||
};
|
||||
|
||||
export default ActionButton;
|
||||
|
||||
@@ -18,3 +18,47 @@
|
||||
border-radius: 4px;
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
.callToAction {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
width: 280px;
|
||||
height: 200px;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.15);
|
||||
|
||||
.ctaHeader {
|
||||
font-size: 16px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
p, li {
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
li span:first-child, p:last-child span:first-child {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
float: right;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.smallKey {
|
||||
padding: 5px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
import React from 'react';
|
||||
import React, {PropTypes} from 'react';
|
||||
import Modal from 'components/Modal';
|
||||
import styles from './ModerationKeysModal.css';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
title: 'modqueue.navigation',
|
||||
@@ -23,29 +25,50 @@ const shortcuts = [
|
||||
}
|
||||
];
|
||||
|
||||
export default ({open, onClose}) => (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<h3>{lang.t('modqueue.shortcuts')}</h3>
|
||||
<div className={styles.container}>
|
||||
{shortcuts.map((shortcut, i) => (
|
||||
<table className={styles.table} key={i}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{lang.t(shortcut.title)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.keys(shortcut.shortcuts).map(key => (
|
||||
<tr key={`${key }tr`}>
|
||||
<td className={styles.shortcut}><span className={styles.key}>{key}</span></td>
|
||||
<td>{lang.t(shortcut.shortcuts[key])}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
export default class ModerationKeysModal extends React.Component {
|
||||
|
||||
const lang = new I18n(translations);
|
||||
static propTypes = {
|
||||
hideShortcutsNote: PropTypes.func.isRequired,
|
||||
shortcutsNoteVisible: PropTypes.string.isRequired
|
||||
}
|
||||
|
||||
render () {
|
||||
const {open, onClose, hideShortcutsNote, shortcutsNoteVisible} = this.props;
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.callToAction} style={{display: shortcutsNoteVisible === 'show' ? 'block' : 'none'}}>
|
||||
<div onClick={hideShortcutsNote} className={styles.closeButton}>×</div>
|
||||
<p className={styles.ctaHeader}>{lang.t('modqueue.mod-faster')}</p>
|
||||
<p><strong>{lang.t('modqueue.try-these')}:</strong></p>
|
||||
<ul>
|
||||
<li><span>{lang.t('modqueue.approve')}</span> <span className={styles.smallKey}>t</span></li>
|
||||
<li><span>{lang.t('modqueue.reject')}</span> <span className={styles.smallKey}>r</span></li>
|
||||
</ul>
|
||||
<p><span>{lang.t('modqueue.view-more-shortcuts')}</span> <span className={styles.smallKey}>{lang.t('modqueue.shift-key')}</span> + <span className={styles.smallKey}>/</span></p>
|
||||
</div>
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<h3>{lang.t('modqueue.shortcuts')}</h3>
|
||||
<div className={styles.container}>
|
||||
{shortcuts.map((shortcut, i) => (
|
||||
<table className={styles.table} key={i}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{lang.t(shortcut.title)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.keys(shortcut.shortcuts).map(key => (
|
||||
<tr key={`${key }tr`}>
|
||||
<td className={styles.shortcut}><span className={styles.key}>{key}</span></td>
|
||||
<td>{lang.t(shortcut.shortcuts[key])}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,3 +188,15 @@
|
||||
margin: 0;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.approve__active {
|
||||
box-shadow: none;
|
||||
color: white;
|
||||
background-color: #519954;
|
||||
}
|
||||
|
||||
.reject__active, .rejected__active {
|
||||
color: white;
|
||||
background-color: #D03235;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ export const TOGGLE_MODAL = 'TOGGLE_MODAL';
|
||||
export const SINGLE_VIEW = 'SINGLE_VIEW';
|
||||
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
|
||||
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
|
||||
export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE';
|
||||
|
||||
@@ -10,7 +10,7 @@ const FlagWidget = ({assets}) => {
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>Articles with the most flags</h2>
|
||||
<h2 className={styles.heading}>{lang.t('dashboard.most_flags')}</h2>
|
||||
<div className={styles.widgetHead}>
|
||||
<p>{lang.t('streams.article')}</p>
|
||||
<p>{lang.t('dashboard.flags')}</p>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {banUser, setCommentStatus} from '../../graphql/mutations';
|
||||
|
||||
import {fetchSettings} from 'actions/settings';
|
||||
import {updateAssets} from 'actions/assets';
|
||||
import {toggleModal, singleView, showBanUserDialog, hideBanUserDialog} from 'actions/moderation';
|
||||
import {toggleModal, singleView, showBanUserDialog, hideBanUserDialog, hideShortcutsNote} from 'actions/moderation';
|
||||
|
||||
import {Spinner} from 'coral-ui';
|
||||
import BanUserDialog from '../../components/BanUserDialog';
|
||||
@@ -116,7 +116,7 @@ class ModerationContainer extends Component {
|
||||
|
||||
let asset;
|
||||
|
||||
if (data.loading) {
|
||||
if (!('premodCount' in data)) {
|
||||
return <div><Spinner/></div>;
|
||||
}
|
||||
|
||||
@@ -135,6 +135,9 @@ class ModerationContainer extends Component {
|
||||
const comments = data[activeTab];
|
||||
let activeTabCount;
|
||||
switch(activeTab) {
|
||||
case 'all':
|
||||
activeTabCount = data.allCount;
|
||||
break;
|
||||
case 'premod':
|
||||
activeTabCount = data.premodCount;
|
||||
break;
|
||||
@@ -151,6 +154,7 @@ class ModerationContainer extends Component {
|
||||
<ModerationHeader asset={asset} />
|
||||
<ModerationMenu
|
||||
asset={asset}
|
||||
allCount={data.allCount}
|
||||
premodCount={data.premodCount}
|
||||
rejectedCount={data.rejectedCount}
|
||||
flaggedCount={data.flaggedCount}
|
||||
@@ -183,6 +187,8 @@ class ModerationContainer extends Component {
|
||||
rejectComment={props.rejectComment}
|
||||
/>
|
||||
<ModerationKeysModal
|
||||
hideShortcutsNote={props.hideShortcutsNote}
|
||||
shortcutsNoteVisible={moderation.shortcutsNoteVisible}
|
||||
open={moderation.modalOpen}
|
||||
onClose={onClose}/>
|
||||
</div>
|
||||
@@ -204,6 +210,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
fetchSettings: () => dispatch(fetchSettings()),
|
||||
showBanUserDialog: (user, commentId, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, showRejectedNote)),
|
||||
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
|
||||
hideShortcutsNote: () => dispatch(hideShortcutsNote()),
|
||||
});
|
||||
|
||||
export default compose(
|
||||
|
||||
@@ -21,7 +21,6 @@ const ModerationQueue = ({comments, selectedIndex, commentCount, singleView, loa
|
||||
key={i}
|
||||
index={i}
|
||||
comment={comment}
|
||||
commentType={activeTab}
|
||||
selected={i === selectedIndex}
|
||||
suspectWords={props.suspectWords}
|
||||
bannedWords={props.bannedWords}
|
||||
|
||||
@@ -10,6 +10,7 @@ import FlagBox from './FlagBox';
|
||||
import CommentType from './CommentType';
|
||||
import ActionButton from 'coral-admin/src/components/ActionButton';
|
||||
import BanUserButton from 'coral-admin/src/components/BanUserButton';
|
||||
import {getActionSummary} from 'coral-framework/utils';
|
||||
|
||||
const linkify = new Linkify();
|
||||
|
||||
@@ -17,25 +18,33 @@ import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const Comment = ({actions = [], ...props}) => {
|
||||
const links = linkify.getMatches(props.comment.body);
|
||||
const Comment = ({actions = [], comment, ...props}) => {
|
||||
const links = linkify.getMatches(comment.body);
|
||||
const linkText = links ? links.map(link => link.raw) : [];
|
||||
const actionSummaries = props.comment.action_summaries;
|
||||
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
|
||||
const flagActions = comment.actions && comment.actions.filter(a => a.__typename === 'FlagAction');
|
||||
let commentType = '';
|
||||
if (comment.status === 'PREMOD') {
|
||||
commentType = 'premod';
|
||||
} else if (flagActions && flagActions.length) {
|
||||
commentType = 'flagged';
|
||||
}
|
||||
|
||||
return (
|
||||
<li tabIndex={props.index} className={`mdl-card ${props.selected ? 'mdl-shadow--8dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem}`}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<span>
|
||||
{props.comment.user.name}
|
||||
{comment.user.name}
|
||||
</span>
|
||||
<span className={styles.created}>
|
||||
{timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
|
||||
{timeago().format(comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
|
||||
</span>
|
||||
<BanUserButton user={props.comment.user} onClick={() => props.showBanUserDialog(props.comment.user, props.comment.id, props.comment.status !== 'REJECTED')} />
|
||||
<CommentType type={props.commentType} />
|
||||
<BanUserButton user={comment.user} onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status !== 'REJECTED')} />
|
||||
<CommentType type={commentType} />
|
||||
</div>
|
||||
{props.comment.user.status === 'banned' ?
|
||||
{comment.user.status === 'banned' ?
|
||||
<span className={styles.banned}>
|
||||
<Icon name='error_outline'/>
|
||||
{lang.t('comment.banned_user')}
|
||||
@@ -43,16 +52,16 @@ const Comment = ({actions = [], ...props}) => {
|
||||
: null}
|
||||
</div>
|
||||
<div className={styles.moderateArticle}>
|
||||
Story: {props.comment.asset.title}
|
||||
Story: {comment.asset.title}
|
||||
{!props.currentAsset && (
|
||||
<Link to={`/admin/moderate/${props.comment.asset.id}`}>Moderate →</Link>
|
||||
<Link to={`/admin/moderate/${comment.asset.id}`}>Moderate →</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.itemBody}>
|
||||
<p className={styles.body}>
|
||||
<Highlighter
|
||||
searchWords={[...props.suspectWords, ...props.bannedWords, ...linkText]}
|
||||
textToHighlight={props.comment.body} />
|
||||
textToHighlight={comment.body} />
|
||||
</p>
|
||||
<div className={styles.sideActions}>
|
||||
{links ? <span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
|
||||
@@ -60,16 +69,21 @@ const Comment = ({actions = [], ...props}) => {
|
||||
{actions.map((action, i) =>
|
||||
<ActionButton key={i}
|
||||
type={action}
|
||||
user={props.comment.user}
|
||||
acceptComment={() => props.acceptComment({commentId: props.comment.id})}
|
||||
rejectComment={() => props.rejectComment({commentId: props.comment.id})}
|
||||
user={comment.user}
|
||||
status={comment.status}
|
||||
acceptComment={() => props.acceptComment({commentId: comment.id})}
|
||||
rejectComment={() => props.rejectComment({commentId: comment.id})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{actionSummaries && <FlagBox actionSummaries={actionSummaries} />}
|
||||
{
|
||||
flagActions && flagActions.length
|
||||
? <FlagBox actions={flagActions} actionSummaries={flagActionSummaries} />
|
||||
: null
|
||||
}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -83,6 +97,7 @@ Comment.propTypes = {
|
||||
comment: PropTypes.shape({
|
||||
body: PropTypes.string.isRequired,
|
||||
action_summaries: PropTypes.array,
|
||||
actions: PropTypes.array,
|
||||
created_at: PropTypes.string.isRequired,
|
||||
user: PropTypes.shape({
|
||||
status: PropTypes.string
|
||||
|
||||
@@ -53,3 +53,17 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.lessDetail {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.subDetail {
|
||||
font-weight: normal;
|
||||
color: #888;
|
||||
|
||||
span {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import {Icon} from 'coral-ui';
|
||||
import styles from './FlagBox.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const shortReasons = {
|
||||
'This comment is offensive': lang.t('modqueue.offensive'),
|
||||
'This looks like an ad/marketing': lang.t('modqueue.spam/ads'),
|
||||
'This user is impersonating': lang.t('modqueue.impersonating'),
|
||||
'I don\'t like this username': lang.t('modqueue.dont-like-username'),
|
||||
'Other': lang.t('modqueue.other')
|
||||
};
|
||||
|
||||
class FlagBox extends Component {
|
||||
constructor () {
|
||||
@@ -16,27 +27,50 @@ class FlagBox extends Component {
|
||||
}));
|
||||
}
|
||||
|
||||
reasonMap = (reason) => {
|
||||
const shortReason = shortReasons[reason];
|
||||
|
||||
// if the short reason isn't found, just return the long one.
|
||||
return shortReason ? shortReason : reason;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
const {actionSummaries, actions} = this.props;
|
||||
const {showDetail} = this.state;
|
||||
|
||||
return (
|
||||
<div className={styles.flagBox}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<Icon name='flag'/><h3>Flags ({props.actionSummaries.length}):</h3>
|
||||
<Icon name='flag'/><h3>Flags ({actionSummaries.length}):</h3>
|
||||
<ul>
|
||||
{props.actionSummaries.map((action, i) =>
|
||||
<li key={i}>{!action.reason ? <i>No reason provided</i> : action.reason} (<strong>{action.count}</strong>)</li>
|
||||
{actionSummaries.map((action, i) =>
|
||||
<li key={i} className={styles.lessDetail}>{this.reasonMap(action.reason)} (<strong>{action.count}</strong>)</li>
|
||||
)}
|
||||
</ul>
|
||||
{/* <a onClick={this.toggleDetail} className={styles.moreDetail}>More detail</a>*/}
|
||||
<a onClick={this.toggleDetail} className={styles.moreDetail}>{showDetail ? lang.t('modqueue.less-detail') : lang.t('modqueue.more-detail')}</a>
|
||||
</div>
|
||||
{this.state.showDetail && (<div className={styles.detail}>
|
||||
<ul>
|
||||
{props.actionSummaries.map((action, i) =>
|
||||
<li key={i}>{!action.reason ? <i>No reason provided</i> : action.reason} (<strong>{action.count}</strong>)</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>)}
|
||||
{showDetail && (
|
||||
<div className={styles.detail}>
|
||||
<ul>
|
||||
{actionSummaries.map((summary, i) => {
|
||||
|
||||
const actionList = actions.filter(a => a.reason === summary.reason);
|
||||
|
||||
return (
|
||||
<li key={i}>
|
||||
{this.reasonMap(summary.reason)} (<strong>{summary.count}</strong>)
|
||||
<ul>
|
||||
{
|
||||
actionList.map((action, j) => <li key={`${i}_${j}`} className={styles.subDetail}><span>{action.user.username}</span> {action.message}</li>)
|
||||
}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -44,7 +78,14 @@ class FlagBox extends Component {
|
||||
}
|
||||
|
||||
FlagBox.propTypes = {
|
||||
actionSummaries: PropTypes.array.isRequired
|
||||
actionSummaries: PropTypes.arrayOf(PropTypes.shape({
|
||||
reason: PropTypes.string,
|
||||
count: PropTypes.number
|
||||
})).isRequired,
|
||||
actions: PropTypes.arrayOf(PropTypes.shape({
|
||||
message: PropTypes.string,
|
||||
user: PropTypes.shape({username: PropTypes.string})
|
||||
})).isRequired
|
||||
};
|
||||
|
||||
export default FlagBox;
|
||||
|
||||
@@ -23,7 +23,7 @@ LoadMore.propTypes = {
|
||||
comments: PropTypes.array.isRequired,
|
||||
loadMore: PropTypes.func.isRequired,
|
||||
sort: PropTypes.oneOf(['CHRONOLOGICAL', 'REVERSE_CHRONOLOGICAL']).isRequired,
|
||||
tab: PropTypes.oneOf(['rejected', 'premod', 'flagged']).isRequired,
|
||||
tab: PropTypes.oneOf(['rejected', 'premod', 'flagged', 'all']).isRequired,
|
||||
assetId: PropTypes.string,
|
||||
showLoadMore: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
@@ -4,22 +4,18 @@ import styles from './styles.css';
|
||||
import {SelectField, Option} from 'react-mdl-selectfield';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
import {Icon} from 'coral-ui';
|
||||
import {Link} from 'react-router';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const ModerationMenu = (
|
||||
{asset, premodCount, rejectedCount, flaggedCount, selectSort, sort}
|
||||
{asset, allCount, premodCount, rejectedCount, flaggedCount, selectSort, sort}
|
||||
) => {
|
||||
const premodPath = asset
|
||||
? `/admin/moderate/premod/${asset.id}`
|
||||
: '/admin/moderate/premod';
|
||||
const rejectPath = asset
|
||||
? `/admin/moderate/rejected/${asset.id}`
|
||||
: '/admin/moderate/rejected';
|
||||
const flagPath = asset
|
||||
? `/admin/moderate/flagged/${asset.id}`
|
||||
: '/admin/moderate/flagged';
|
||||
|
||||
function getPath (type) {
|
||||
return asset ? `/admin/moderate/${type}/${asset.id}` : `/admin/moderate/${type}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mdl-tabs">
|
||||
@@ -27,22 +23,28 @@ const ModerationMenu = (
|
||||
<div className={styles.tabBarPadding} />
|
||||
<div>
|
||||
<Link
|
||||
to={premodPath}
|
||||
to={getPath('all')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('modqueue.premod')} <CommentCount count={premodCount} />
|
||||
<Icon name='question_answer' className={styles.tabIcon} /> {lang.t('modqueue.all')} <CommentCount count={allCount} />
|
||||
</Link>
|
||||
<Link
|
||||
to={flagPath}
|
||||
to={getPath('premod')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('modqueue.flagged')} <CommentCount count={flaggedCount} />
|
||||
<Icon name='access_time' className={styles.tabIcon} /> {lang.t('modqueue.premod')} <CommentCount count={premodCount} />
|
||||
</Link>
|
||||
<Link
|
||||
to={rejectPath}
|
||||
to={getPath('flagged')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('modqueue.rejected')} <CommentCount count={rejectedCount} />
|
||||
<Icon name='flag' className={styles.tabIcon} /> {lang.t('modqueue.flagged')} <CommentCount count={flaggedCount} />
|
||||
</Link>
|
||||
<Link
|
||||
to={getPath('rejected')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name='close' className={styles.tabIcon} /> {lang.t('modqueue.rejected')} <CommentCount count={rejectedCount} />
|
||||
</Link>
|
||||
</div>
|
||||
<SelectField
|
||||
@@ -59,6 +61,7 @@ const ModerationMenu = (
|
||||
};
|
||||
|
||||
ModerationMenu.propTypes = {
|
||||
allCount: PropTypes.number.isRequired,
|
||||
premodCount: PropTypes.number.isRequired,
|
||||
rejectedCount: PropTypes.number.isRequired,
|
||||
flaggedCount: PropTypes.number.isRequired,
|
||||
|
||||
@@ -418,3 +418,8 @@ span {
|
||||
.loadMore:hover {
|
||||
background-color: #4399FF;
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
position: relative;
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
@@ -12,4 +12,19 @@ fragment commentView on Comment {
|
||||
id
|
||||
title
|
||||
}
|
||||
action_summaries {
|
||||
count
|
||||
... on FlagActionSummary {
|
||||
reason
|
||||
}
|
||||
}
|
||||
actions {
|
||||
... on FlagAction {
|
||||
reason
|
||||
message
|
||||
user {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ export const getMetrics = graphql(METRICS, {
|
||||
export const loadMore = (fetchMore) => ({limit, cursor, sort, tab, asset_id}) => {
|
||||
let statuses;
|
||||
switch(tab) {
|
||||
case 'all':
|
||||
statuses = null;
|
||||
break;
|
||||
case 'premod':
|
||||
statuses = ['PREMOD'];
|
||||
break;
|
||||
@@ -55,7 +58,7 @@ export const loadMore = (fetchMore) => ({limit, cursor, sort, tab, asset_id}) =>
|
||||
statuses,
|
||||
asset_id
|
||||
},
|
||||
updateQuery: (oldData, {fetchMoreResult:{data:{comments}}}) => {
|
||||
updateQuery: (oldData, {fetchMoreResult:{comments}}) => {
|
||||
return {
|
||||
...oldData,
|
||||
[tab]: [
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
#import "../fragments/commentView.graphql"
|
||||
|
||||
query ModQueue ($asset_id: ID, $sort: SORT_ORDER) {
|
||||
all: comments(query: {
|
||||
statuses: [NONE, PREMOD, ACCEPTED, REJECTED],
|
||||
asset_id: $asset_id,
|
||||
sort: $sort
|
||||
}) {
|
||||
...commentView
|
||||
}
|
||||
premod: comments(query: {
|
||||
statuses: [PREMOD],
|
||||
asset_id: $asset_id,
|
||||
@@ -15,12 +22,6 @@ query ModQueue ($asset_id: ID, $sort: SORT_ORDER) {
|
||||
sort: $sort
|
||||
}) {
|
||||
...commentView
|
||||
action_summaries {
|
||||
count
|
||||
... on FlagActionSummary {
|
||||
reason
|
||||
}
|
||||
}
|
||||
}
|
||||
rejected: comments(query: {
|
||||
statuses: [REJECTED],
|
||||
@@ -34,6 +35,9 @@ query ModQueue ($asset_id: ID, $sort: SORT_ORDER) {
|
||||
title
|
||||
url
|
||||
}
|
||||
allCount: commentCount(query: {
|
||||
asset_id: $asset_id
|
||||
})
|
||||
premodCount: commentCount(query: {
|
||||
statuses: [PREMOD],
|
||||
asset_id: $asset_id
|
||||
|
||||
@@ -6,7 +6,8 @@ const initialState = Map({
|
||||
modalOpen: false,
|
||||
user: Map({}),
|
||||
commentId: null,
|
||||
banDialog: false
|
||||
banDialog: false,
|
||||
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show'
|
||||
});
|
||||
|
||||
export default function moderation (state = initialState, action) {
|
||||
@@ -31,6 +32,9 @@ export default function moderation (state = initialState, action) {
|
||||
case actions.SINGLE_VIEW:
|
||||
return state
|
||||
.set('singleView', !state.get('singleView'));
|
||||
case actions.HIDE_SHORTCUTS_NOTE:
|
||||
return state
|
||||
.set('shortcutsNoteVisible', 'hide');
|
||||
default :
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import ApolloClient, {addTypename} from 'apollo-client';
|
||||
import getNetworkInterface from './transport';
|
||||
import fragmentMatcher from './fragmentMatcher';
|
||||
|
||||
export const client = new ApolloClient({
|
||||
fragmentMatcher,
|
||||
addTypename: true,
|
||||
queryTransformer: addTypename,
|
||||
dataIdFromObject: (result) => {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {IntrospectionFragmentMatcher} from 'apollo-client';
|
||||
|
||||
// TODO this is a short-term fix
|
||||
// we need to set up something to query the server for the schema before ApolloClient initialization
|
||||
// https://github.com/apollographql/apollo-client/issues/1555#issuecomment-295834774
|
||||
const fm = new IntrospectionFragmentMatcher({
|
||||
introspectionQueryResultData: {
|
||||
__schema: {
|
||||
types: [
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Action',
|
||||
possibleTypes: [
|
||||
{name: 'FlagAction'},
|
||||
{name: 'LikeAction'},
|
||||
{name: 'DontAgreeAction'}
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'ActionSummary',
|
||||
possibleTypes: [
|
||||
{name: 'FlagActionSummary'},
|
||||
{name: 'LikeActionSummary'},
|
||||
{name: 'DontAgreeActionSummary'}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
export default fm;
|
||||
@@ -44,6 +44,10 @@
|
||||
"close": "Close",
|
||||
"actions": "Actions",
|
||||
"navigation": "Navigation",
|
||||
"mod-faster": "Moderate faster with keyboard shortcuts",
|
||||
"try-these": "Try these",
|
||||
"view-more-shortcuts": "View more shortcuts",
|
||||
"shift-key": "⇧",
|
||||
"approve": "Approve comment",
|
||||
"reject": "Reject comment",
|
||||
"nextcomment": "Go to the next comment",
|
||||
@@ -51,7 +55,14 @@
|
||||
"singleview": "Toggle single comment edit view",
|
||||
"thismenu": "Open this menu",
|
||||
"emptyqueue": "No more comments to moderate! You're all caught up. Go have some ☕️",
|
||||
"showshortcuts": "Show Shortcuts"
|
||||
"showshortcuts": "Show Shortcuts",
|
||||
"more-detail": "More detail",
|
||||
"less-detail": "Less detail",
|
||||
"dont-like-username": "Don't like username",
|
||||
"impersonating": "Impersonating",
|
||||
"offensive": "Offensive",
|
||||
"spam/ads": "Spam/Ads",
|
||||
"other": "Other"
|
||||
},
|
||||
"comment": {
|
||||
"flagged": "flagged",
|
||||
@@ -69,6 +80,10 @@
|
||||
},
|
||||
"configure": {
|
||||
"closed-stream-settings": "Closed Stream Message",
|
||||
"open-stream-configuration": "This comment stream is currently open. By closing this comment stream, no new comments may be submitted and all previous comments will still be displayed.",
|
||||
"close-stream-configuration": "This comment stream is currently closed. By opening this comment stream, new comments may be submitted and displayed",
|
||||
"close-stream": "Close Stream",
|
||||
"open-stream": "Open Stream",
|
||||
"stream-settings": "Stream Settings",
|
||||
"moderation-settings": "Moderation Settings",
|
||||
"tech-settings": "Tech Settings",
|
||||
@@ -139,7 +154,8 @@
|
||||
"no_likes": "There have been no likes in the last 5 minutes. All quiet.",
|
||||
"flags": "Flags",
|
||||
"no_activity": "There haven't been any comments anywhere in the last five minutes.",
|
||||
"comment_count": "comments"
|
||||
"comment_count": "comments",
|
||||
"most_flags": "Articles with the most flags"
|
||||
},
|
||||
"streams": {
|
||||
"empty_result": "No assets match this search. Maybe try widening your search?",
|
||||
@@ -214,9 +230,20 @@
|
||||
"rejected": "rechazado",
|
||||
"flagged": "marcado",
|
||||
"shortcuts": "Atajos de teclado",
|
||||
"mod-faster": "Moderar más rápido con atajos del teclado",
|
||||
"try-these": "Intenta estos",
|
||||
"view-more-shortcuts": "Ver más atajos",
|
||||
"shift-key": "⇧",
|
||||
"close": "Cerrar",
|
||||
"emptyqueue": "No se encontro ningún usuario. Están escondidos.",
|
||||
"showshortcuts": "Mostrar atajos"
|
||||
"showshortcuts": "Mostrar atajos",
|
||||
"more-detail": "Mas detalle",
|
||||
"less-detail": "Menos detalle",
|
||||
"dont-like-username": "No me gusta ese nombre de usuario",
|
||||
"impersonating": "Suplantación",
|
||||
"offensive": "Ofensivo",
|
||||
"spam/ads": "Spam/Propaganda",
|
||||
"other": "Otros"
|
||||
},
|
||||
"comment": {
|
||||
"flagged": "marcado",
|
||||
@@ -231,6 +258,10 @@
|
||||
},
|
||||
"configure": {
|
||||
"closed-stream-settings": "Mensaje a enviar cuando los comentarios están cerrados en el artículo",
|
||||
"open-stream-configuration": "Este hilo de comentarios esta abierto. Al cerrarlo, ningún nuevo comentario será publicado y todos los comentarios anteriores serán mostrados.",
|
||||
"close-stream-configuration": "Este hilo de comentario está en este momento cerrado. Al abrirlo, nuevos comentarios serán publicaods y mostrados.",
|
||||
"close-stream": "Cerrar Comentarios",
|
||||
"open-stream": "Abrir Comentarios",
|
||||
"stream-settings": "Configuración de Comentarios",
|
||||
"moderation-settings": "Configuración de Moderación",
|
||||
"tech-settings": "Configuración Técnica",
|
||||
@@ -283,14 +314,15 @@
|
||||
"cancel": "Cancelar",
|
||||
"yes_ban_user": "Si, Suspendan el usuario"
|
||||
},
|
||||
"dashbord": {
|
||||
"dashboard": {
|
||||
"next-update": "{0} minutos hasta la siguiente actualización.",
|
||||
"auto-update": "Los datos se actualizan automaticamente cada 5 minutos o cuando recargas.",
|
||||
"no_flags": "¡Nadie ha marcado nada en los últimos 5 minutos! ¡Bravo!",
|
||||
"no_likes": "A nadie le ha gustado algún comentario en los últimos 5 minutos. Todo tranquilo.",
|
||||
"flags": "Marcados",
|
||||
"no_activity": "No hubo comentarios en los ultimos 5 minutos",
|
||||
"comment_count": "comentarios"
|
||||
"comment_count": "comentarios",
|
||||
"most_flags": "Articulos con más reportes"
|
||||
},
|
||||
"streams": {
|
||||
"empty_result": "No se encuentro articulo con esta busqueda. ¿Tal vez puedas extender la busqueda?",
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import React from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
export default ({status, onClick}) => (
|
||||
status === 'open' ? (
|
||||
<div className="close-comments-intro-wrapper">
|
||||
<p>
|
||||
This comment stream is currently open. By closing this comment stream,
|
||||
no new comments may be submitted and all previous comments will still
|
||||
be displayed.
|
||||
{lang.t('configure.open-stream-configuration')}
|
||||
</p>
|
||||
<Button onClick={onClick}>Close Stream</Button>
|
||||
<Button onClick={onClick}>{lang.t('configure.close-stream')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="close-comments-intro-wrapper">
|
||||
<p>
|
||||
This comment stream is currently closed. By opening this comment stream,
|
||||
new comments may be submitted and displayed
|
||||
{lang.t('configure.close-stream-configuration')}
|
||||
</p>
|
||||
<Button onClick={onClick}>Open Stream</Button>
|
||||
<Button onClick={onClick}>{lang.t('configure.open-stream')}</Button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
right: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
.wrapper ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul ul {
|
||||
.wrapper ul ul {
|
||||
padding-left: 20px
|
||||
}
|
||||
|
||||
@@ -23,12 +23,12 @@ ul ul {
|
||||
margin: 12px 12px 12px 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
.wrapper h4 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
.wrapper p {
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
export default ({handleChange, handleApply, changed, updateQuestionBoxContent, ...props}) => (
|
||||
export default ({handleChange, handleApply, changed, ...props}) => (
|
||||
<form onSubmit={handleApply}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.container}>
|
||||
@@ -38,9 +38,9 @@ export default ({handleChange, handleApply, changed, updateQuestionBoxContent, .
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
cStyle={changed ? 'green' : 'darkGrey'}
|
||||
name="premodLinks"
|
||||
name="plinksenable"
|
||||
onChange={handleChange}
|
||||
defaultChecked={props.premodLinks}
|
||||
defaultChecked={props.premodLinksEnable}
|
||||
info={{
|
||||
title: lang.t('configureCommentStream.enablePremodLinks'),
|
||||
description: lang.t('configureCommentStream.enablePremodLinksDescription')
|
||||
@@ -57,11 +57,10 @@ export default ({handleChange, handleApply, changed, updateQuestionBoxContent, .
|
||||
title: lang.t('configureCommentStream.enableQuestionBox'),
|
||||
description: lang.t('configureCommentStream.enableQuestionBoxDescription')
|
||||
}} />
|
||||
|
||||
<div className={`${props.questionBoxEnable ? null : styles.hidden}`} >
|
||||
<TextField
|
||||
id="qboxcontent"
|
||||
onChange={updateQuestionBoxContent}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
value={props.questionBoxContent}
|
||||
label={lang.t('configureCommentStream.includeQuestionHere')}
|
||||
|
||||
@@ -15,13 +15,14 @@ class ConfigureStreamContainer extends Component {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
changed: false
|
||||
changed: false,
|
||||
dirtySettings: props.asset.settings,
|
||||
closedAt: (props.asset.closedAt === null ? 'open' : 'closed')
|
||||
};
|
||||
|
||||
this.toggleStatus = this.toggleStatus.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleApply = this.handleApply.bind(this);
|
||||
this.updateQuestionBoxContent = this.updateQuestionBoxContent.bind(this);
|
||||
}
|
||||
|
||||
handleApply (e) {
|
||||
@@ -31,7 +32,7 @@ class ConfigureStreamContainer extends Component {
|
||||
const questionBoxEnable = elements.qboxenable.checked;
|
||||
const questionBoxContent = elements.qboxcontent.value;
|
||||
|
||||
const premodLinksEnable = elements.premodLinks.checked;
|
||||
const premodLinksEnable = elements.plinksenable.checked;
|
||||
const {changed} = this.state;
|
||||
|
||||
const newConfig = {
|
||||
@@ -48,39 +49,52 @@ class ConfigureStreamContainer extends Component {
|
||||
changed: false
|
||||
});
|
||||
}, 300);
|
||||
|
||||
// this.props.loadAsset(this.props.data.asset);
|
||||
}
|
||||
}
|
||||
|
||||
handleChange (e) {
|
||||
|
||||
// TODO: Don’t directly manipulate state and make state change immutable.
|
||||
if (e.target && e.target.id === 'qboxenable') {
|
||||
this.props.asset.settings.questionBoxEnable = e.target.checked;
|
||||
this.state.dirtySettings.questionBoxEnable = e.target.checked;
|
||||
}
|
||||
if (e.target && e.target.id === 'qboxcontent') {
|
||||
this.state.dirtySettings.questionBoxContent = e.target.value;
|
||||
}
|
||||
if (e.target && e.target.id === 'plinksenable') {
|
||||
this.state.dirtySettings.premodLinksEnable = e.target.value;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
changed: true
|
||||
});
|
||||
}
|
||||
|
||||
updateQuestionBoxContent(e) {
|
||||
this.props.asset.settings.questionBoxContent = e.target.value;
|
||||
this.handleChange(e);
|
||||
}
|
||||
|
||||
toggleStatus () {
|
||||
|
||||
// update the closedAt status for the asset
|
||||
this.props.updateStatus(
|
||||
this.props.asset.closedAt === null ? 'closed' : 'open'
|
||||
this.state.closedAt === 'open' ? 'closed' : 'open'
|
||||
);
|
||||
this.setState({
|
||||
closedAt: (this.state.closedAt === 'open' ? 'closed' : 'open')
|
||||
});
|
||||
}
|
||||
|
||||
getClosedIn () {
|
||||
const {closedTimeout} = this.props.asset.settings;
|
||||
const {created_at} = this.props.asset;
|
||||
|
||||
return lang.timeago(new Date(created_at).getTime() + (1000 * closedTimeout));
|
||||
}
|
||||
|
||||
render () {
|
||||
const {settings, closedAt} = this.props.asset;
|
||||
const status = closedAt === null ? 'open' : 'closed';
|
||||
const premod = settings.moderation === 'PRE';
|
||||
const {dirtySettings} = this.state;
|
||||
const premod = dirtySettings.moderation === 'PRE';
|
||||
const {closedAt} = this.state;
|
||||
const closedTimeout = dirtySettings.closedTimeout;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -88,18 +102,17 @@ class ConfigureStreamContainer extends Component {
|
||||
handleChange={this.handleChange}
|
||||
handleApply={this.handleApply}
|
||||
changed={this.state.changed}
|
||||
premodLinks={settings.premodLinks}
|
||||
premodLinksEnable={dirtySettings.premodLinksEnable}
|
||||
premod={premod}
|
||||
updateQuestionBoxContent={this.updateQuestionBoxContent}
|
||||
questionBoxEnable={settings.questionBoxEnable}
|
||||
questionBoxContent={settings.questionBoxContent}
|
||||
questionBoxEnable={dirtySettings.questionBoxEnable}
|
||||
questionBoxContent={dirtySettings.questionBoxContent}
|
||||
/>
|
||||
<hr />
|
||||
<h3>{status === 'open' ? 'Close' : 'Open'} Comment Stream</h3>
|
||||
{status === 'open' ? <p>The comment stream will close in {this.getClosedIn()}.</p> : ''}
|
||||
<h3>{closedAt === 'open' ? 'Close' : 'Open'} Comment Stream</h3>
|
||||
{(closedAt === 'open' && closedTimeout) ? <p>The comment stream will close in {this.getClosedIn()}.</p> : ''}
|
||||
<CloseCommentsInfo
|
||||
onClick={this.toggleStatus}
|
||||
status={status}
|
||||
status={closedAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import {Router, Route, browserHistory} from 'react-router';
|
||||
|
||||
import Embed from './Embed';
|
||||
import SignInContainer from 'coral-sign-in/containers/SignInContainer';
|
||||
|
||||
const routes = (
|
||||
<div>
|
||||
<Route exact path="/embed/stream/login" component={SignInContainer}/>
|
||||
<Route exact path="*" component={Embed}/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AppRouter = () => <Router history={browserHistory} routes={routes} />;
|
||||
|
||||
export default AppRouter;
|
||||
@@ -12,3 +12,14 @@
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topRightMenu {
|
||||
float: right;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.topRightMenu > * {
|
||||
text-align: initial;
|
||||
}
|
||||
|
||||
@@ -19,12 +19,13 @@ import FlagComment from 'coral-plugin-flags/FlagComment';
|
||||
import LikeButton from 'coral-plugin-likes/LikeButton';
|
||||
import {BestButton, IfUserCanModifyBest, BEST_TAG, commentIsBest, BestIndicator} from 'coral-plugin-best/BestButton';
|
||||
import LoadMore from 'coral-embed-stream/src/LoadMore';
|
||||
import {Slot} from 'coral-framework';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import {TopRightMenu} from './TopRightMenu';
|
||||
import {getActionSummary, getTotalActionCount, iPerformedThisAction} from 'coral-framework/utils';
|
||||
|
||||
import styles from './Comment.css';
|
||||
|
||||
const getActionSummary = (type, comment) => comment.action_summaries
|
||||
.filter((a) => a.__typename === type)[0];
|
||||
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF') ;
|
||||
|
||||
// hold actions links (e.g. Like, Reply) along the comment footer
|
||||
@@ -63,6 +64,8 @@ class Comment extends React.Component {
|
||||
currentUser: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired
|
||||
}),
|
||||
charCountEnable: PropTypes.bool.isRequired,
|
||||
maxCharCount: PropTypes.number,
|
||||
comment: PropTypes.shape({
|
||||
depth: PropTypes.number,
|
||||
action_summaries: PropTypes.array.isRequired,
|
||||
@@ -84,11 +87,17 @@ class Comment extends React.Component {
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
|
||||
// given a comment, return whether it should be rendered as ignored
|
||||
commentIsIgnored: React.PropTypes.func,
|
||||
|
||||
// dispatch action to add a tag to a comment
|
||||
addCommentTag: React.PropTypes.func,
|
||||
|
||||
// dispatch action to remove a tag from a comment
|
||||
removeCommentTag: React.PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func,
|
||||
}
|
||||
|
||||
render () {
|
||||
@@ -111,20 +120,31 @@ class Comment extends React.Component {
|
||||
deleteAction,
|
||||
addCommentTag,
|
||||
removeCommentTag,
|
||||
ignoreUser,
|
||||
disableReply,
|
||||
commentIsIgnored,
|
||||
maxCharCount,
|
||||
charCountEnable,
|
||||
} = this.props;
|
||||
|
||||
const like = getActionSummary('LikeActionSummary', comment);
|
||||
const flag = getActionSummary('FlagActionSummary', comment);
|
||||
const dontagree = getActionSummary('DontAgreeActionSummary', comment);
|
||||
const likeSummary = getActionSummary('LikeActionSummary', comment);
|
||||
const flagSummary = getActionSummary('FlagActionSummary', comment);
|
||||
const dontAgreeSummary = getActionSummary('DontAgreeActionSummary', comment);
|
||||
let myFlag = null;
|
||||
if (iPerformedThisAction('FlagActionSummary', comment)) {
|
||||
myFlag = flagSummary.find(s => s.current_user);
|
||||
} else if (iPerformedThisAction('DontAgreeActionSummary', comment)) {
|
||||
myFlag = dontAgreeSummary.find(s => s.current_user);
|
||||
}
|
||||
|
||||
let commentClass = parentId ? `reply ${styles.Reply}` : `comment ${styles.Comment}`;
|
||||
commentClass += comment.id === 'pending' ? ` ${styles.pendingComment}` : '';
|
||||
|
||||
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
|
||||
const notifyOnError = (fn, errorToMessage) => async () => {
|
||||
const notifyOnError = (fn, errorToMessage) => async function (...args) {
|
||||
if (typeof errorToMessage !== 'function') {errorToMessage = (error) => error.message;}
|
||||
try {
|
||||
return await fn();
|
||||
return await fn(...args);
|
||||
} catch (error) {
|
||||
addNotification('error', errorToMessage(error));
|
||||
throw error;
|
||||
@@ -158,13 +178,25 @@ class Comment extends React.Component {
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
: null }
|
||||
<PubDate created_at={comment.created_at} />
|
||||
<Slot fill="commentInfoBar" commentId={comment.id} />
|
||||
<Slot fill="commentInfoBar" comment={comment} commentId={comment.id} inline/>
|
||||
|
||||
{ (currentUser && (comment.user.id !== currentUser.id))
|
||||
? <span className={styles.topRightMenu}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification} />
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
|
||||
<Content body={comment.body} />
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<ActionButton>
|
||||
{/* TODO implmement iPerformedThisAction for the like */}
|
||||
<LikeButton
|
||||
like={like}
|
||||
totalLikes={getTotalActionCount('LikeActionSummary', comment)}
|
||||
like={likeSummary[0]}
|
||||
id={comment.id}
|
||||
postLike={postLike}
|
||||
deleteAction={deleteAction}
|
||||
@@ -189,7 +221,7 @@ class Comment extends React.Component {
|
||||
removeBest={removeBestTag} />
|
||||
</IfUserCanModifyBest>
|
||||
</ActionButton>
|
||||
<Slot fill="commentDetail" commentId={comment.id} />
|
||||
<Slot fill="commentDetail" comment={comment} commentId={comment.id} inline/>
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<ActionButton>
|
||||
@@ -197,7 +229,8 @@ class Comment extends React.Component {
|
||||
</ActionButton>
|
||||
<ActionButton>
|
||||
<FlagComment
|
||||
flag={flag && flag.current_user ? flag : dontagree}
|
||||
flaggedByCurrentUser={!!myFlag}
|
||||
flag={myFlag}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
@@ -214,6 +247,8 @@ class Comment extends React.Component {
|
||||
commentPostedHandler={() => {
|
||||
setActiveReplyBox('');
|
||||
}}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
parentId={parentId || comment.id}
|
||||
addNotification={addNotification}
|
||||
@@ -225,27 +260,31 @@ class Comment extends React.Component {
|
||||
{
|
||||
comment.replies &&
|
||||
comment.replies.map(reply => {
|
||||
return <Comment
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
disableReply={disableReply}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postItem={postItem}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
currentUser={currentUser}
|
||||
postLike={postLike}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
addCommentTag={addCommentTag}
|
||||
removeCommentTag={removeCommentTag}
|
||||
showSignInDialog={showSignInDialog}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
/>;
|
||||
return commentIsIgnored(reply)
|
||||
? <IgnoredCommentTombstone key={reply.id} />
|
||||
: <Comment
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
disableReply={disableReply}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postItem={postItem}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
currentUser={currentUser}
|
||||
postLike={postLike}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
addCommentTag={addCommentTag}
|
||||
removeCommentTag={removeCommentTag}
|
||||
ignoreUser={ignoreUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
showSignInDialog={showSignInDialog}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply} />;
|
||||
})
|
||||
}
|
||||
{
|
||||
|
||||
@@ -8,13 +8,13 @@ const lang = new I18n(translations);
|
||||
|
||||
import {TabBar, Tab, TabContent, Spinner, Button} from 'coral-ui';
|
||||
|
||||
const {logout, showSignInDialog, requestConfirmEmail} = authActions;
|
||||
const {logout, showSignInDialog, requestConfirmEmail, openSignInPopUp, checkLogin} = authActions;
|
||||
const {addNotification, clearNotification} = notificationActions;
|
||||
const {fetchAssetSuccess} = assetActions;
|
||||
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments';
|
||||
|
||||
import {queryStream} from 'coral-framework/graphql/queries';
|
||||
import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag} from 'coral-framework/graphql/mutations';
|
||||
import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser} from 'coral-framework/graphql/mutations';
|
||||
import {editName} from 'coral-framework/actions/user';
|
||||
import {updateCountCache, viewAllComments} from 'coral-framework/actions/asset';
|
||||
import {notificationActions, authActions, assetActions, pym} from 'coral-framework';
|
||||
@@ -26,7 +26,6 @@ import {ModerationLink} from 'coral-plugin-moderation';
|
||||
import Count from 'coral-plugin-comment-count/CommentCount';
|
||||
import CommentBox from 'coral-plugin-commentbox/CommentBox';
|
||||
import UserBox from 'coral-sign-in/components/UserBox';
|
||||
import SignInContainer from 'coral-sign-in/containers/SignInContainer';
|
||||
import SuspendedAccount from 'coral-framework/components/SuspendedAccount';
|
||||
import ChangeUsernameContainer from '../../coral-sign-in/containers/ChangeUsernameContainer';
|
||||
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
|
||||
@@ -71,10 +70,14 @@ class Embed extends React.Component {
|
||||
|
||||
// dispatch action to remove a tag from a comment
|
||||
removeCommentTag: React.PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func,
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
pym.sendMessage('childReady');
|
||||
this.props.checkLogin();
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
@@ -116,8 +119,7 @@ class Embed extends React.Component {
|
||||
|
||||
setActiveReplyBox = (reactKey) => {
|
||||
if (!this.props.auth.user) {
|
||||
const offset = document.getElementById(`c_${reactKey}`).getBoundingClientRect().top - 75;
|
||||
this.props.showSignInDialog(offset);
|
||||
this.props.showSignInDialog();
|
||||
} else {
|
||||
this.setState({activeReplyBox: reactKey});
|
||||
}
|
||||
@@ -127,7 +129,7 @@ class Embed extends React.Component {
|
||||
const {activeTab} = this.state;
|
||||
const {closedAt, countCache = {}} = this.props.asset;
|
||||
const {asset, refetch, comment} = this.props.data;
|
||||
const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth;
|
||||
const {loggedIn, isAdmin, user, showSignInDialog} = this.props.auth;
|
||||
|
||||
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
|
||||
const highlightedComment = comment && comment.parent ? comment.parent : comment;
|
||||
@@ -155,13 +157,18 @@ class Embed extends React.Component {
|
||||
? asset.comments[0].created_at
|
||||
: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString();
|
||||
|
||||
const userBox = <UserBox user={user} logout={() => this.props.logout().then(refetch)} changeTab={this.changeTab}/>;
|
||||
|
||||
// TODO: This is a quickfix and will be replaced after our refactor.
|
||||
const ignoredUsers = this.props.userData.ignoredUsers;
|
||||
const commentIsIgnored = (comment) => ignoredUsers && ignoredUsers.includes(comment.user.id);
|
||||
|
||||
return (
|
||||
<div style={expandForLogin}>
|
||||
<div className="commentStream">
|
||||
<TabBar onChange={this.changeTab} activeTab={activeTab}>
|
||||
<Tab><Count count={asset.totalCommentCount}/>
|
||||
</Tab>
|
||||
<Tab>{lang.t('MY_COMMENTS')}</Tab>
|
||||
<Tab><Count count={asset.totalCommentCount}/></Tab>
|
||||
<Tab>{lang.t('myProfile')}</Tab>
|
||||
<Tab restricted={!isAdmin}>Configure Stream</Tab>
|
||||
</TabBar>
|
||||
{
|
||||
@@ -174,8 +181,8 @@ class Embed extends React.Component {
|
||||
this.props.data.refetch();
|
||||
}}>{lang.t('showAllComments')}</Button>
|
||||
}
|
||||
{loggedIn && <UserBox user={user} logout={() => this.props.logout().then(refetch)} changeTab={this.changeTab}/>}
|
||||
<TabContent show={activeTab === 0}>
|
||||
{ loggedIn ? userBox : null }
|
||||
{
|
||||
openStream
|
||||
? <div id="commentBox">
|
||||
@@ -207,18 +214,18 @@ class Embed extends React.Component {
|
||||
isReply={false}
|
||||
currentUser={this.props.auth.user}
|
||||
authorId={user.id}
|
||||
charCount={asset.settings.charCountEnable && asset.settings.charCount} />
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount} />
|
||||
: null
|
||||
}
|
||||
</RestrictedContent>
|
||||
</div>
|
||||
: <p>{asset.settings.closedMessage}</p>
|
||||
}
|
||||
{!loggedIn && <SignInContainer
|
||||
requireEmailConfirmation={asset.settings.requireEmailConfirmation}
|
||||
refetch={refetch}
|
||||
offset={signInOffset}/>}
|
||||
{loggedIn && user && <ChangeUsernameContainer loggedIn={loggedIn} offset={signInOffset} user={user} />}
|
||||
|
||||
{!loggedIn && <Button id='coralSignInButton' onClick={this.props.showSignInDialog} full>Sign in to comment</Button>}
|
||||
|
||||
{loggedIn && user && <ChangeUsernameContainer loggedIn={loggedIn} user={user} />}
|
||||
{loggedIn && <ModerationLink assetId={asset.id} isAdmin={isAdmin} />}
|
||||
|
||||
{/* the highlightedComment is isolated after the user followed a permalink */}
|
||||
@@ -240,6 +247,7 @@ class Embed extends React.Component {
|
||||
loadMore={this.props.loadMore}
|
||||
deleteAction={this.props.deleteAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
key={highlightedComment.id}
|
||||
reactKey={highlightedComment.id}
|
||||
comment={highlightedComment} />
|
||||
@@ -266,10 +274,14 @@ class Embed extends React.Component {
|
||||
postDontAgree={this.props.postDontAgree}
|
||||
addCommentTag={this.props.addCommentTag}
|
||||
removeCommentTag={this.props.removeCommentTag}
|
||||
ignoreUser={this.props.ignoreUser}
|
||||
loadMore={this.props.loadMore}
|
||||
deleteAction={this.props.deleteAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
comments={asset.comments} />
|
||||
comments={asset.comments}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
ignoredUsers={this.props.userData.ignoredUsers} />
|
||||
</div>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
@@ -279,22 +291,22 @@ class Embed extends React.Component {
|
||||
loadMore={this.props.loadMore} />
|
||||
</div>
|
||||
}
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 1}>
|
||||
<ProfileContainer
|
||||
loggedIn={loggedIn}
|
||||
userData={this.props.userData}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 2}>
|
||||
<RestrictedContent restricted={!loggedIn}>
|
||||
<ConfigureStreamContainer
|
||||
status={status}
|
||||
onClick={this.toggleStatus}
|
||||
/>
|
||||
</RestrictedContent>
|
||||
</TabContent>
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 1}>
|
||||
<ProfileContainer
|
||||
loggedIn={loggedIn}
|
||||
userData={this.props.userData}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 2}>
|
||||
<RestrictedContent restricted={!loggedIn}>
|
||||
{ loggedIn ? userBox : null }
|
||||
<ConfigureStreamContainer
|
||||
status={status}
|
||||
onClick={this.toggleStatus}
|
||||
/>
|
||||
</RestrictedContent>
|
||||
</TabContent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -304,7 +316,7 @@ class Embed extends React.Component {
|
||||
const mapStateToProps = state => ({
|
||||
auth: state.auth.toJS(),
|
||||
userData: state.user.toJS(),
|
||||
asset: state.asset.toJS()
|
||||
asset: state.asset.toJS(),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
@@ -313,11 +325,13 @@ const mapDispatchToProps = dispatch => ({
|
||||
addNotification: (type, text) => addNotification(type, text),
|
||||
clearNotification: () => dispatch(clearNotification()),
|
||||
editName: (username) => dispatch(editName(username)),
|
||||
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
|
||||
showSignInDialog: () => dispatch(showSignInDialog()),
|
||||
updateCountCache: (id, count) => dispatch(updateCountCache(id, count)),
|
||||
viewAllComments: () => dispatch(viewAllComments()),
|
||||
logout: () => dispatch(logout()),
|
||||
dispatch: d => dispatch(d)
|
||||
openSignInPopUp: cb => dispatch(openSignInPopUp(cb)),
|
||||
checkLogin: () => dispatch(checkLogin()),
|
||||
dispatch: d => dispatch(d),
|
||||
});
|
||||
|
||||
export default compose(
|
||||
@@ -328,6 +342,7 @@ export default compose(
|
||||
postDontAgree,
|
||||
addCommentTag,
|
||||
removeCommentTag,
|
||||
ignoreUser,
|
||||
deleteAction,
|
||||
queryStream,
|
||||
)(Embed);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.IgnoreUserWizard {
|
||||
background-color: #2E343B;
|
||||
color: white;
|
||||
padding: 1em;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.IgnoreUserWizard header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.IgnoreUserWizard .textAlignRight {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './IgnoreUserWizard.css';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
// Guides the user through ignoring another user, including confirming their decision
|
||||
export class IgnoreUserWizard extends React.Component {
|
||||
static propTypes = {
|
||||
|
||||
// comment on which this menu appears
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
}).isRequired,
|
||||
cancel: PropTypes.func.isRequired,
|
||||
|
||||
// actually submit the ignore. Provide {id: user id to ignore}
|
||||
ignoreUser: PropTypes.func.isRequired,
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
|
||||
// what step of the wizard is the user on
|
||||
step: 1
|
||||
};
|
||||
this.onClickCancel = this.onClickCancel.bind(this);
|
||||
}
|
||||
onClickCancel() {
|
||||
this.props.cancel();
|
||||
}
|
||||
render() {
|
||||
const {user, ignoreUser} = this.props;
|
||||
const goToStep = (stepNum) => this.setState({step: stepNum});
|
||||
const step1 = (
|
||||
<div>
|
||||
<header>Ignore User</header>
|
||||
<p>When you ignore a user, all comments they wrote on the site will be hidden from you. You can undo this later from the Profile tab.</p>
|
||||
<div className={styles.textAlignRight}>
|
||||
<Button cStyle='cancel' onClick={this.onClickCancel}>Cancel</Button>
|
||||
<Button onClick={() => goToStep(2)}>Ignore user</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const onClickIgnoreUser = async () => {
|
||||
await ignoreUser({id: user.id});
|
||||
};
|
||||
const step2Confirmation = (
|
||||
<div>
|
||||
<header>Ignore User</header>
|
||||
<p>Are you sure you want to ignore { user.name }?</p>
|
||||
<div className={styles.textAlignRight}>
|
||||
<Button cStyle='cancel' onClick={this.onClickCancel}>Cancel</Button>
|
||||
<Button onClick={onClickIgnoreUser}>Ignore user</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const elsForStep = [step1, step2Confirmation];
|
||||
const {step} = this.state;
|
||||
const elForThisStep = elsForStep[step - 1];
|
||||
return (
|
||||
<div className={styles.IgnoreUserWizard}>
|
||||
{ elForThisStep }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
// Render in place of a Comment when the author of the comment is ignored
|
||||
const IgnoredCommentTombstone = () => (
|
||||
<div>
|
||||
<hr aria-hidden={true} />
|
||||
<p style={{
|
||||
backgroundColor: '#F0F0F0',
|
||||
textAlign: 'center',
|
||||
padding: '1em',
|
||||
color: '#3E4F71',
|
||||
}}>
|
||||
{lang.t('commentIsIgnored')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default IgnoredCommentTombstone;
|
||||
@@ -7,9 +7,9 @@ const onLoadMoreClick = ({loadMore, commentCount, firstCommentDate, assetId, upd
|
||||
e.preventDefault();
|
||||
updateCountCache(assetId, commentCount);
|
||||
loadMore({
|
||||
asset_id: assetId,
|
||||
limit: 500,
|
||||
cursor: firstCommentDate,
|
||||
assetId,
|
||||
sort: 'CHRONOLOGICAL'
|
||||
}, true);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import Comment from './Comment';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
|
||||
class Stream extends React.Component {
|
||||
|
||||
@@ -14,11 +15,20 @@ class Stream extends React.Component {
|
||||
id: PropTypes.string
|
||||
}),
|
||||
|
||||
charCountEnable: PropTypes.bool.isRequired,
|
||||
maxCharCount: PropTypes.number,
|
||||
|
||||
// dispatch action to add a tag to a comment
|
||||
addCommentTag: PropTypes.func,
|
||||
|
||||
// dispatch action to remove a tag from a comment
|
||||
removeCommentTag: PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func,
|
||||
|
||||
// list of user ids that should be rendered as ignored
|
||||
ignoredUsers: React.PropTypes.arrayOf(React.PropTypes.string)
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
@@ -42,35 +52,47 @@ class Stream extends React.Component {
|
||||
showSignInDialog,
|
||||
addCommentTag,
|
||||
removeCommentTag,
|
||||
pluginProps
|
||||
pluginProps,
|
||||
ignoreUser,
|
||||
ignoredUsers,
|
||||
charCountEnable,
|
||||
maxCharCount,
|
||||
} = this.props;
|
||||
|
||||
const commentIsIgnored = (comment) => ignoredUsers && ignoredUsers.includes(comment.user.id);
|
||||
return (
|
||||
<div id='stream'>
|
||||
{
|
||||
comments.map(comment =>
|
||||
<Comment
|
||||
disableReply={!open}
|
||||
setActiveReplyBox={this.props.setActiveReplyBox}
|
||||
activeReplyBox={this.props.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postItem={postItem}
|
||||
asset={asset}
|
||||
currentUser={currentUser}
|
||||
postLike={postLike}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
addCommentTag={addCommentTag}
|
||||
removeCommentTag={removeCommentTag}
|
||||
loadMore={loadMore}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
key={comment.id}
|
||||
reactKey={comment.id}
|
||||
comment={comment}
|
||||
pluginProps={pluginProps}
|
||||
/>
|
||||
commentIsIgnored(comment)
|
||||
? <IgnoredCommentTombstone
|
||||
key={comment.id}
|
||||
/>
|
||||
: <Comment
|
||||
disableReply={!open}
|
||||
setActiveReplyBox={this.props.setActiveReplyBox}
|
||||
activeReplyBox={this.props.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postItem={postItem}
|
||||
asset={asset}
|
||||
currentUser={currentUser}
|
||||
postLike={postLike}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
addCommentTag={addCommentTag}
|
||||
removeCommentTag={removeCommentTag}
|
||||
ignoreUser={ignoreUser}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
loadMore={loadMore}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
key={comment.id}
|
||||
reactKey={comment.id}
|
||||
comment={comment}
|
||||
maxCharCount={maxCharCount}
|
||||
charCountEnable={charCountEnable}
|
||||
pluginProps={pluginProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
.Toggleable:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Up/Down Chevrons for the top right menu
|
||||
*/
|
||||
.chevron {
|
||||
}
|
||||
.chevron:before {
|
||||
content: '⌃';
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.25em;
|
||||
}
|
||||
|
||||
/* Down Arrow */
|
||||
.chevron.down:before {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
transform: rotate(180deg);
|
||||
top: 0;
|
||||
/*top: -0.25em;*/
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import {IgnoreUserWizard} from './IgnoreUserWizard';
|
||||
import styles from './TopRightMenu.css';
|
||||
|
||||
// TopRightMenu appears as a dropdown in the top right of the comment.
|
||||
// when you click the down cehvron, it expands and shows IgnoreUserWizard
|
||||
// when you click 'cancel' in the wizard, it closes the menu
|
||||
export class TopRightMenu extends React.Component {
|
||||
static propTypes = {
|
||||
|
||||
// comment on which this menu appears
|
||||
comment: PropTypes.shape({
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
ignoreUser: PropTypes.func,
|
||||
|
||||
// show notification to the user (e.g. for errors)
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
timesReset: 0
|
||||
};
|
||||
}
|
||||
render() {
|
||||
const {comment, ignoreUser, addNotification} = this.props;
|
||||
|
||||
// timesReset is used as Toggleable key so it re-renders on reset (closing the toggleable)
|
||||
const reset = () => this.setState({timesReset: this.state.timesReset + 1});
|
||||
const ignoreUserAndCloseMenuAndNotifyOnError = async ({id}) => {
|
||||
|
||||
// close menu
|
||||
reset();
|
||||
|
||||
// ignore user
|
||||
try {
|
||||
await ignoreUser({id});
|
||||
} catch (error) {
|
||||
addNotification('error', 'Failed to ignore user');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Toggleable key={this.state.timesReset}>
|
||||
<div style={{position: 'absolute', right: 0, zIndex: 1}}>
|
||||
<IgnoreUserWizard
|
||||
user={comment.user}
|
||||
cancel={reset}
|
||||
ignoreUser={ignoreUserAndCloseMenuAndNotifyOnError}
|
||||
/>
|
||||
</div>
|
||||
</Toggleable>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const upArrow = <span className={classnames(styles.chevron, styles.up)}></span>;
|
||||
const downArrow = <span className={classnames(styles.chevron, styles.down)}></span>;
|
||||
class Toggleable extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.toggle = this.toggle.bind(this);
|
||||
this.close = this.close.bind(this);
|
||||
this.state = {
|
||||
isOpen: false
|
||||
};
|
||||
}
|
||||
toggle() {
|
||||
this.setState({isOpen: ! this.state.isOpen});
|
||||
}
|
||||
close() {
|
||||
this.setState({isOpen: false});
|
||||
}
|
||||
render() {
|
||||
const {children} = this.props;
|
||||
const {isOpen} = this.state;
|
||||
return (
|
||||
|
||||
// /*onBlur={ this.close } */
|
||||
<span className={styles.Toggleable} tabIndex="0" >
|
||||
<span className={styles.toggler}
|
||||
onClick={this.toggle}>{isOpen ? upArrow : downArrow}</span>
|
||||
{isOpen ? children : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ import {render} from 'react-dom';
|
||||
import {ApolloProvider} from 'react-apollo';
|
||||
|
||||
import {client} from 'coral-framework/services/client';
|
||||
import store from 'coral-framework/services/store';
|
||||
import localStore from 'coral-framework/services/store';
|
||||
|
||||
import Embed from './Embed';
|
||||
import AppRouter from './AppRouter';
|
||||
|
||||
const store = (window.opener && window.opener.coralStore) ? window.opener.coralStore : localStore;
|
||||
|
||||
render(
|
||||
<ApolloProvider client={client} store={store}>
|
||||
<Embed />
|
||||
<AppRouter />
|
||||
</ApolloProvider>
|
||||
, document.querySelector('#coralStream')
|
||||
);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
* {
|
||||
font-weight: inherit;
|
||||
font-family: inherit;
|
||||
font-style: inherit;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
html, body {
|
||||
@@ -189,6 +187,7 @@ hr {
|
||||
/* Comment Box Styles */
|
||||
.coral-plugin-commentbox-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-textarea {
|
||||
@@ -196,7 +195,8 @@ hr {
|
||||
padding: 5px;
|
||||
min-height: 100px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-button-container {
|
||||
@@ -398,6 +398,7 @@ button.comment__action-button[disabled],
|
||||
margin-left: 20px;
|
||||
margin-top: 5px;
|
||||
width: 75%;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Close comments */
|
||||
@@ -477,3 +478,19 @@ button.comment__action-button[disabled],
|
||||
.coral-load-more-replies button.coral-load-more, .coral-new-comments button.coral-load-more{
|
||||
width: initial;
|
||||
}
|
||||
|
||||
@media (min-device-width : 300px) and (max-device-width : 420px) {
|
||||
.commentActionsLeft.comment__action-container .coral-plugin-likes-button-text,
|
||||
.commentActionsLeft.comment__action-container > div span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ function configurePymParent(pymParent) {
|
||||
|
||||
window.document.body.appendChild(snackbar);
|
||||
|
||||
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
|
||||
pymParent.el.firstChild.style.width = '1px';
|
||||
pymParent.el.firstChild.style.minWidth = '100%';
|
||||
|
||||
// Resize parent iframe height when child height changes
|
||||
pymParent.onMessage('height', function(height) {
|
||||
if (height !== cachedHeight) {
|
||||
|
||||
@@ -11,6 +11,16 @@ const ME_QUERY = gql`
|
||||
query Me {
|
||||
me {
|
||||
status
|
||||
comments {
|
||||
id
|
||||
body
|
||||
asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -22,8 +32,23 @@ function fetchMe() {
|
||||
}
|
||||
|
||||
// Dialog Actions
|
||||
export const showSignInDialog = (offset = 0) => ({type: actions.SHOW_SIGNIN_DIALOG, offset});
|
||||
export const hideSignInDialog = () => ({type: actions.HIDE_SIGNIN_DIALOG});
|
||||
export const showSignInDialog = () => dispatch => {
|
||||
const signInPopUp = window.open(
|
||||
'/embed/stream/login',
|
||||
'Login',
|
||||
'menubar=0,resizable=0,width=500,height=550,top=200,left=500'
|
||||
);
|
||||
|
||||
signInPopUp.onbeforeunload = () => {
|
||||
dispatch(checkLogin());
|
||||
fetchMe();
|
||||
};
|
||||
dispatch({type: actions.SHOW_SIGNIN_DIALOG});
|
||||
};
|
||||
export const hideSignInDialog = () => dispatch => {
|
||||
dispatch({type: actions.HIDE_SIGNIN_DIALOG});
|
||||
window.close();
|
||||
};
|
||||
|
||||
export const createUsernameRequest = () => ({type: actions.CREATE_USERNAME_REQUEST});
|
||||
export const showCreateUsernameDialog = () => ({type: actions.SHOW_CREATEUSERNAME_DIALOG});
|
||||
@@ -47,29 +72,39 @@ export const createUsername = (userId, formData) => dispatch => {
|
||||
});
|
||||
};
|
||||
|
||||
export const changeView = view => dispatch =>
|
||||
export const changeView = view => dispatch => {
|
||||
dispatch({
|
||||
type: actions.CHANGE_VIEW,
|
||||
view
|
||||
});
|
||||
|
||||
switch(view) {
|
||||
case 'SIGNUP':
|
||||
window.resizeTo(500, 800);
|
||||
break;
|
||||
case 'FORGOT':
|
||||
window.resizeTo(500, 400);
|
||||
break;
|
||||
default:
|
||||
window.resizeTo(500, 550);
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanState = () => ({type: actions.CLEAN_STATE});
|
||||
|
||||
// Sign In Actions
|
||||
|
||||
const signInRequest = () => ({type: actions.FETCH_SIGNIN_REQUEST});
|
||||
const signInSuccess = (user, isAdmin) => ({type: actions.FETCH_SIGNIN_SUCCESS, user, isAdmin});
|
||||
|
||||
// TODO: revisit login redux flow.
|
||||
// const signInSuccess = (user, isAdmin) => ({type: actions.FETCH_SIGNIN_SUCCESS, user, isAdmin});
|
||||
//
|
||||
const signInFailure = error => ({type: actions.FETCH_SIGNIN_FAILURE, error});
|
||||
|
||||
export const fetchSignIn = (formData) => (dispatch) => {
|
||||
dispatch(signInRequest());
|
||||
return coralApi('/auth/local', {method: 'POST', body: formData})
|
||||
.then(({user}) => {
|
||||
const isAdmin = !!user && !!user.roles.filter(i => i === 'ADMIN').length;
|
||||
dispatch(signInSuccess(user, isAdmin));
|
||||
dispatch(hideSignInDialog());
|
||||
fetchMe();
|
||||
})
|
||||
.then(() => dispatch(hideSignInDialog()))
|
||||
.catch(error => {
|
||||
if (error.metadata) {
|
||||
|
||||
@@ -121,7 +156,7 @@ export const facebookCallback = (err, data) => dispatch => {
|
||||
dispatch(signInFacebookSuccess(user));
|
||||
dispatch(hideSignInDialog());
|
||||
dispatch(showCreateUsernameDialog());
|
||||
fetchMe();
|
||||
dispatch(hideSignInDialog());
|
||||
} catch (err) {
|
||||
dispatch(signInFacebookFailure(err));
|
||||
return;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import pym from '../services/PymConnection';
|
||||
import * as actions from '../constants/notification';
|
||||
|
||||
export const addNotification = (notifType, text) => {
|
||||
pym.sendMessage('coral-alert', `${notifType}|${text}`);
|
||||
return {type: actions.ADD_NOTIFICATION, notifType, text};
|
||||
};
|
||||
|
||||
export const clearNotification = () => {
|
||||
pym.sendMessage('coral-clear-notification');
|
||||
return {type: actions.CLEAR_NOTIFICATION};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.inline {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, {Component} from 'react';
|
||||
import {getSlotElements} from 'coral-framework/helpers/plugins';
|
||||
import styles from './Slot.css';
|
||||
|
||||
class Slot extends Component {
|
||||
render() {
|
||||
const {fill, ...rest} = this.props;
|
||||
const {fill, inline = false, ...rest} = this.props;
|
||||
return (
|
||||
<div>
|
||||
<div className={inline ? styles.inline : ''}>
|
||||
{getSlotElements(fill, rest)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ADD_NOTIFICATION = 'ADD_NOTIFICATION';
|
||||
export const CLEAR_NOTIFICATION = 'CLEAR_NOTIFICATION';
|
||||
@@ -6,3 +6,5 @@ export const COMMENTS_BY_USER_SUCCESS = 'COMMENTS_BY_USER_SUCCESS';
|
||||
export const COMMENTS_BY_USER_FAILURE = 'COMMENTS_BY_USER_FAILURE';
|
||||
export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
|
||||
export const UPDATE_USERNAME = 'UPDATE_USERNAME';
|
||||
export const IGNORE_USER_SUCCESS = 'IGNORE_USER_SUCCESS';
|
||||
export const STOP_IGNORING_USER_SUCCESS = 'STOP_IGNORING_USER_SUCCESS';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation ignoreUser ($id: ID!) {
|
||||
ignoreUser(id:$id) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,12 @@ import POST_DONT_AGREE from './postDontAgree.graphql';
|
||||
import DELETE_ACTION from './deleteAction.graphql';
|
||||
import ADD_COMMENT_TAG from './addCommentTag.graphql';
|
||||
import REMOVE_COMMENT_TAG from './removeCommentTag.graphql';
|
||||
import IGNORE_USER from './ignoreUser.graphql';
|
||||
import STOP_IGNORING_USER from './stopIgnoringUser.graphql';
|
||||
|
||||
import MY_IGNORED_USERS from '../queries/myIgnoredUsers.graphql';
|
||||
import STREAM_QUERY from '../queries/streamQuery.graphql';
|
||||
import {variablesForStreamQuery} from '../queries';
|
||||
|
||||
import commentView from '../fragments/commentView.graphql';
|
||||
|
||||
@@ -14,12 +20,11 @@ export const postComment = graphql(POST_COMMENT, {
|
||||
fragments: commentView
|
||||
}),
|
||||
props: ({ownProps, mutate}) => ({
|
||||
postItem: ({asset_id, body, parent_id}) =>
|
||||
mutate({
|
||||
postItem: comment => {
|
||||
const {asset_id, body, parent_id, tags = []} = comment;
|
||||
return mutate({
|
||||
variables: {
|
||||
asset_id,
|
||||
body,
|
||||
parent_id
|
||||
comment
|
||||
},
|
||||
optimisticResponse: {
|
||||
createComment: {
|
||||
@@ -33,14 +38,14 @@ export const postComment = graphql(POST_COMMENT, {
|
||||
parent_id,
|
||||
asset_id,
|
||||
action_summaries: [],
|
||||
tags: [],
|
||||
tags,
|
||||
status: null,
|
||||
id: 'pending'
|
||||
}
|
||||
}
|
||||
},
|
||||
updateQueries: {
|
||||
AssetQuery: (oldData, {mutationResult:{data:{createComment:{comment}}}}) => {
|
||||
AssetQuery: (oldData, {mutationResult: {data: {createComment: {comment}}}}) => {
|
||||
|
||||
if (oldData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
|
||||
return oldData;
|
||||
@@ -56,8 +61,8 @@ export const postComment = graphql(POST_COMMENT, {
|
||||
...oldData.asset,
|
||||
comments: oldData.asset.comments.map((oldComment) => {
|
||||
return oldComment.id === parent_id
|
||||
? {...oldComment, replies: [...oldComment.replies, comment]}
|
||||
: oldComment;
|
||||
? {...oldComment, replies: [...oldComment.replies, comment]}
|
||||
: oldComment;
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -77,7 +82,8 @@ export const postComment = graphql(POST_COMMENT, {
|
||||
return updatedAsset;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -148,3 +154,40 @@ export const removeCommentTag = graphql(REMOVE_COMMENT_TAG, {
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const ignoreUser = graphql(IGNORE_USER, {
|
||||
props: ({mutate}) => ({
|
||||
ignoreUser: ({id}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
refetchQueries: [{
|
||||
query: MY_IGNORED_USERS,
|
||||
}]
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const stopIgnoringUser = graphql(STOP_IGNORING_USER, {
|
||||
props: ({mutate, ownProps}) => {
|
||||
return {
|
||||
stopIgnoringUser: ({id}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
refetchQueries: [
|
||||
{
|
||||
query: MY_IGNORED_USERS,
|
||||
},
|
||||
{
|
||||
query: STREAM_QUERY,
|
||||
variables: variablesForStreamQuery(ownProps),
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#import "../fragments/commentView.graphql"
|
||||
|
||||
mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) {
|
||||
createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) {
|
||||
mutation CreateComment ($comment: CreateCommentInput!) {
|
||||
createComment(comment: $comment) {
|
||||
comment {
|
||||
...commentView
|
||||
replyCount
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation stopIgnoringUser ($id: ID!) {
|
||||
stopIgnoringUser(id:$id) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import STREAM_QUERY from './streamQuery.graphql';
|
||||
import LOAD_MORE from './loadMore.graphql';
|
||||
import GET_COUNTS from './getCounts.graphql';
|
||||
import MY_COMMENT_HISTORY from './myCommentHistory.graphql';
|
||||
import MY_IGNORED_USERS from './myIgnoredUsers.graphql';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import isNil from 'lodash/isNil';
|
||||
@@ -28,10 +29,10 @@ export const getCounts = (data) => ({asset_id, limit, sort}) => {
|
||||
variables: {
|
||||
asset_id,
|
||||
limit,
|
||||
sort
|
||||
sort,
|
||||
excludeIgnored: data.variables.excludeIgnored,
|
||||
},
|
||||
updateQuery: (oldData, {fetchMoreResult:{asset}}) => {
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
asset: {
|
||||
@@ -52,7 +53,8 @@ export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, s
|
||||
cursor, // the date of the first/last comment depending on the sort order
|
||||
parent_id, // if null, we're loading more top-level comments, if not, we're loading more replies to a comment
|
||||
asset_id, // the id of the asset we're currently on
|
||||
sort // CHRONOLOGICAL or REVERSE_CHRONOLOGICAL
|
||||
sort, // CHRONOLOGICAL or REVERSE_CHRONOLOGICAL
|
||||
excludeIgnored: data.variables.excludeIgnored,
|
||||
},
|
||||
updateQuery: (oldData, {fetchMoreResult:{new_top_level_comments}}) => {
|
||||
let updatedAsset;
|
||||
@@ -119,21 +121,25 @@ export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, s
|
||||
});
|
||||
};
|
||||
|
||||
export const variablesForStreamQuery = ({auth}) => {
|
||||
|
||||
// where the query string is from the embeded iframe url
|
||||
let comment_id = getQueryVariable('comment_id');
|
||||
let has_comment = comment_id != null;
|
||||
return {
|
||||
asset_id: getQueryVariable('asset_id'),
|
||||
asset_url: getQueryVariable('asset_url'),
|
||||
comment_id: has_comment ? comment_id : 'no-comment',
|
||||
has_comment,
|
||||
excludeIgnored: Boolean(auth && auth.user && auth.user.id),
|
||||
};
|
||||
};
|
||||
|
||||
// load the comment stream.
|
||||
export const queryStream = graphql(STREAM_QUERY, {
|
||||
options: () => {
|
||||
|
||||
// where the query string is from the embeded iframe url
|
||||
let comment_id = getQueryVariable('comment_id');
|
||||
let has_comment = comment_id != null;
|
||||
|
||||
options: (props) => {
|
||||
return {
|
||||
variables: {
|
||||
asset_id: getQueryVariable('asset_id'),
|
||||
asset_url: getQueryVariable('asset_url'),
|
||||
comment_id: has_comment ? comment_id : 'no-comment',
|
||||
has_comment
|
||||
}
|
||||
variables: variablesForStreamQuery(props)
|
||||
};
|
||||
},
|
||||
props: ({data}) => ({
|
||||
@@ -144,3 +150,11 @@ export const queryStream = graphql(STREAM_QUERY, {
|
||||
});
|
||||
|
||||
export const myCommentHistory = graphql(MY_COMMENT_HISTORY, {});
|
||||
|
||||
export const myIgnoredUsers = graphql(MY_IGNORED_USERS, {
|
||||
props: ({data}) => {
|
||||
return ({
|
||||
myIgnoredUsersData: data
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#import "../fragments/commentView.graphql"
|
||||
|
||||
query LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER) {
|
||||
new_top_level_comments: comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort}) {
|
||||
query LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER, $excludeIgnored: Boolean) {
|
||||
new_top_level_comments: comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) {
|
||||
...commentView
|
||||
replyCount
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies(limit: 3) {
|
||||
...commentView
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
query myIgnoredUsers {
|
||||
myIgnoredUsers {
|
||||
id,
|
||||
username,
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
#import "../fragments/commentView.graphql"
|
||||
|
||||
query AssetQuery($asset_id: ID, $asset_url: String, $comment_id: ID!, $has_comment: Boolean!) {
|
||||
query AssetQuery($asset_id: ID, $asset_url: String, $comment_id: ID!, $has_comment: Boolean!, $excludeIgnored: Boolean) {
|
||||
# the comment here is for loading one comment and it's children, probably after following a permalink
|
||||
# $has_comment is derived from the comment_id query param in the iframe url,
|
||||
# which is in turn pulled from the host page url
|
||||
comment(id: $comment_id) @include(if: $has_comment) {
|
||||
...commentView
|
||||
replyCount
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies {
|
||||
...commentView
|
||||
}
|
||||
parent {
|
||||
...commentView
|
||||
replyCount
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies {
|
||||
...commentView
|
||||
}
|
||||
@@ -28,6 +28,7 @@ query AssetQuery($asset_id: ID, $asset_url: String, $comment_id: ID!, $has_comme
|
||||
moderation
|
||||
infoBoxEnable
|
||||
infoBoxContent
|
||||
premodLinksEnable
|
||||
questionBoxEnable
|
||||
questionBoxContent
|
||||
closeTimeout
|
||||
@@ -36,15 +37,15 @@ query AssetQuery($asset_id: ID, $asset_url: String, $comment_id: ID!, $has_comme
|
||||
charCount
|
||||
requireEmailConfirmation
|
||||
}
|
||||
commentCount
|
||||
totalCommentCount
|
||||
lastComment {
|
||||
id
|
||||
}
|
||||
comments(limit: 10) {
|
||||
commentCount(excludeIgnored: $excludeIgnored)
|
||||
totalCommentCount(excludeIgnored: $excludeIgnored)
|
||||
comments(limit: 10, excludeIgnored: $excludeIgnored) {
|
||||
...commentView
|
||||
replyCount
|
||||
replies(limit: 3) {
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies(limit: 3, excludeIgnored: $excludeIgnored) {
|
||||
...commentView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import store from './services/store';
|
||||
import pym from './services/PymConnection';
|
||||
import I18n from './modules/i18n/i18n';
|
||||
import actions from './actions';
|
||||
import Slot from './components/Slot';
|
||||
|
||||
// TODO (bc): Deprecate old actions. Spreading actions is now needed.
|
||||
|
||||
export default {
|
||||
pym,
|
||||
Slot,
|
||||
I18n,
|
||||
store,
|
||||
actions,
|
||||
...actions
|
||||
};
|
||||
|
||||
@@ -28,8 +28,7 @@ export default function auth (state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.SHOW_SIGNIN_DIALOG :
|
||||
return state
|
||||
.set('showSignInDialog', true)
|
||||
.set('signInOffset', action.offset);
|
||||
.set('showSignInDialog', true);
|
||||
case actions.HIDE_SIGNIN_DIALOG :
|
||||
return state.merge(Map({
|
||||
isLoading: false,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import auth from './auth';
|
||||
import user from './user';
|
||||
import asset from './asset';
|
||||
import {reducer as commentBox} from '../../coral-plugin-commentbox';
|
||||
import {pluginReducers} from '../helpers/plugins';
|
||||
|
||||
export default {
|
||||
auth,
|
||||
user,
|
||||
asset,
|
||||
commentBox,
|
||||
...pluginReducers
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Map} from 'immutable';
|
||||
import {Map, Set} from 'immutable';
|
||||
import * as authActions from '../constants/auth';
|
||||
import * as actions from '../constants/user';
|
||||
import * as assetActions from '../constants/assets';
|
||||
@@ -8,7 +8,8 @@ const initialState = Map({
|
||||
profiles: [],
|
||||
settings: {},
|
||||
myComments: [],
|
||||
myAssets: [] // the assets from which myComments (above) originated
|
||||
myAssets: [], // the assets from which myComments (above) originated
|
||||
ignoredUsers: Set(),
|
||||
});
|
||||
|
||||
const purge = user => {
|
||||
@@ -38,7 +39,14 @@ export default function user (state = initialState, action) {
|
||||
return state.set('myAssets', action.assets);
|
||||
case actions.LOGOUT_SUCCESS:
|
||||
return initialState;
|
||||
default :
|
||||
return state;
|
||||
case 'APOLLO_MUTATION_RESULT':
|
||||
switch (action.operationName) {
|
||||
case 'ignoreUser':
|
||||
return state.updateIn(['ignoredUsers'], i => i.add(action.variables.id));
|
||||
case 'stopIgnoringUser':
|
||||
return state.updateIn(['ignoredUsers'], i => i.delete(action.variables.id));
|
||||
}
|
||||
break;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ if (window.devToolsExtension) {
|
||||
middlewares.push(window.devToolsExtension());
|
||||
}
|
||||
|
||||
export default createStore(
|
||||
const store = createStore(
|
||||
combineReducers({
|
||||
...mainReducer,
|
||||
apollo: client.reducer()
|
||||
@@ -32,3 +32,6 @@ export default createStore(
|
||||
{},
|
||||
compose(...middlewares)
|
||||
);
|
||||
|
||||
export default store;
|
||||
window.coralStore = store;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"en": {
|
||||
"MY_COMMENTS": "My Comments",
|
||||
"profile": "Profile",
|
||||
"myProfile": "My profile",
|
||||
"successUpdateSettings": "The changes you have made have been applied to the comment stream on this article",
|
||||
"successNameUpdate": "Your username has been updated",
|
||||
"contentNotAvailable": "This content is not available",
|
||||
@@ -20,6 +21,7 @@
|
||||
"newCount": "View {0} new {1}",
|
||||
"comment": "comment",
|
||||
"comments": "comments",
|
||||
"commentIsIgnored": "This comment is hidden because you ignored this user.",
|
||||
"error": {
|
||||
"emailNotVerified": "Email address {0} not verified.",
|
||||
"email": "Not a valid E-Mail",
|
||||
@@ -44,7 +46,7 @@
|
||||
"es": {
|
||||
"profile": "Pérfil",
|
||||
"MY_COMMENTS": "Mis Comentarios",
|
||||
"profile": "Pérfil",
|
||||
"myProfile": "Mi pérfil",
|
||||
"successUpdateSettings": "La configuración de este articulo fue actualizada",
|
||||
"successBioUpdate": "Tu biografia fue actualizada",
|
||||
"contentNotAvailable": "El contenido no se encuentra disponible",
|
||||
@@ -57,6 +59,7 @@
|
||||
"newCount": "Ver {0} {1} más",
|
||||
"comment": "commentario",
|
||||
"comments": "commentarios",
|
||||
"commentIsIgnored": "Este comentario está escondido porque has ignorado al usuario.",
|
||||
"showAllComments": "Mostrar todos los comentarios",
|
||||
"error": {
|
||||
"emailNotVerified": "E-mail {0} no verificado.",
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
/**
|
||||
* getActionSummary
|
||||
* retrieves the action summary based on the type and the comment
|
||||
*/
|
||||
export const getTotalActionCount = (type, comment) => {
|
||||
return comment.action_summaries
|
||||
.filter(s => s.__typename === type)
|
||||
.reduce((total, summary) => {
|
||||
return total + summary.count;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
export const getActionSummary = (type, comment) =>
|
||||
comment.action_summaries.filter(a => a.__typename === type)[0];
|
||||
export const iPerformedThisAction = (type, comment) => {
|
||||
|
||||
// if there is a current_user on any of the ActionSummary(s), the user performed this action
|
||||
return comment.action_summaries
|
||||
.filter(a => a.__typename === type)
|
||||
.some(a => a.current_user);
|
||||
};
|
||||
|
||||
export const getMyActionSummary = (type, comment) => {
|
||||
return comment.action_summaries
|
||||
.filter(a => a.__typename === type)
|
||||
.find(a => a.current_user);
|
||||
};
|
||||
|
||||
/**
|
||||
* getActionSummary
|
||||
* retrieves the action summaries based on the type and the comment
|
||||
* array could be length > 1, as in the case of FlagActionSummary
|
||||
*/
|
||||
|
||||
export const getActionSummary = (type, comment) => {
|
||||
return comment.action_summaries.filter(a => a.__typename === type);
|
||||
};
|
||||
|
||||
@@ -2,56 +2,57 @@ import React, {Component, PropTypes} from 'react';
|
||||
import {I18n} from '../coral-framework';
|
||||
import translations from './translations.json';
|
||||
import {Button} from 'coral-ui';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
const name = 'coral-plugin-commentbox';
|
||||
|
||||
class CommentBox extends Component {
|
||||
|
||||
static propTypes = {
|
||||
commentPostedHandler: PropTypes.func,
|
||||
postItem: PropTypes.func.isRequired,
|
||||
cancelButtonClicked: PropTypes.func,
|
||||
assetId: PropTypes.string.isRequired,
|
||||
parentId: PropTypes.string,
|
||||
authorId: PropTypes.string.isRequired,
|
||||
isReply: PropTypes.bool.isRequired,
|
||||
canPost: PropTypes.bool,
|
||||
currentUser: PropTypes.object
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
state = {
|
||||
body: '',
|
||||
username: ''
|
||||
this.state = {
|
||||
username: '',
|
||||
body: '',
|
||||
hooks: {
|
||||
preSubmit: [],
|
||||
postSubmit: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
postComment = () => {
|
||||
const {
|
||||
commentPostedHandler,
|
||||
postItem,
|
||||
assetId,
|
||||
updateCountCache,
|
||||
isReply,
|
||||
countCache,
|
||||
assetId,
|
||||
parentId,
|
||||
postItem,
|
||||
countCache,
|
||||
addNotification,
|
||||
authorId
|
||||
updateCountCache,
|
||||
commentPostedHandler
|
||||
} = this.props;
|
||||
|
||||
let comment = {
|
||||
body: this.state.body,
|
||||
asset_id: assetId,
|
||||
author_id: authorId,
|
||||
parent_id: parentId
|
||||
parent_id: parentId,
|
||||
body: this.state.body,
|
||||
...this.props.commentBox
|
||||
};
|
||||
|
||||
if (this.props.charCount && this.state.body.length > this.props.charCount) {
|
||||
return;
|
||||
}
|
||||
!isReply && updateCountCache(assetId, countCache + 1);
|
||||
|
||||
// Execute preSubmit Hooks
|
||||
this.state.hooks.preSubmit.forEach(hook => hook());
|
||||
|
||||
postItem(comment, 'comments')
|
||||
.then(({data}) => {
|
||||
const postedComment = data.createComment.comment;
|
||||
|
||||
// Execute postSubmit Hooks
|
||||
this.state.hooks.postSubmit.forEach(hook => hook(data));
|
||||
|
||||
if (postedComment.status === 'REJECTED') {
|
||||
addNotification('error', lang.t('comment-post-banned-word'));
|
||||
!isReply && updateCountCache(assetId, countCache);
|
||||
@@ -64,14 +65,67 @@ class CommentBox extends Component {
|
||||
commentPostedHandler();
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
.catch((err) => console.error(err));
|
||||
|
||||
this.setState({body: ''});
|
||||
}
|
||||
|
||||
registerHook = (hookType = '', hook = () => {}) => {
|
||||
if (typeof hook !== 'function') {
|
||||
return console.warn(`Hooks must be functions. Please check your ${hookType} hooks`);
|
||||
} else if (typeof hookType === 'string') {
|
||||
this.setState(state => ({
|
||||
hooks: {
|
||||
...state.hooks,
|
||||
[hookType]: [
|
||||
...state.hooks[hookType],
|
||||
hook
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
return {
|
||||
hookType,
|
||||
hook
|
||||
};
|
||||
|
||||
} else {
|
||||
return console.warn('hookTypes must be a string. Please check your hooks');
|
||||
}
|
||||
}
|
||||
|
||||
unregisterHook = hookData => {
|
||||
const {hookType, hook} = hookData;
|
||||
|
||||
this.setState(state => {
|
||||
let newHooks = state.hooks[newHooks];
|
||||
const idx = state.hooks[hookType].indexOf(hook);
|
||||
|
||||
if (idx !== -1) {
|
||||
newHooks = [
|
||||
...state.hooks[hookType].slice(0, idx),
|
||||
...state.hooks[hookType].slice(idx + 1)
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
hooks: {
|
||||
...state.hooks,
|
||||
[hookType]: newHooks
|
||||
}
|
||||
};
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
handleChange = e => this.setState({body: e.target.value});
|
||||
|
||||
render () {
|
||||
const {styles, isReply, authorId, charCount} = this.props;
|
||||
const {styles, isReply, authorId, maxCharCount} = this.props;
|
||||
let {cancelButtonClicked} = this.props;
|
||||
|
||||
const length = this.state.body.length;
|
||||
const enablePostComment = !length || (maxCharCount && length > maxCharCount);
|
||||
|
||||
if (isReply && typeof cancelButtonClicked !== 'function') {
|
||||
console.warn('the CommentBox component should have a cancelButtonClicked callback defined if it lives in a Reply');
|
||||
@@ -93,33 +147,35 @@ class CommentBox extends Component {
|
||||
value={this.state.body}
|
||||
placeholder={lang.t('comment')}
|
||||
id={isReply ? 'replyText' : 'commentText'}
|
||||
onChange={(e) => this.setState({body: e.target.value})}
|
||||
onChange={this.handleChange}
|
||||
rows={3}/>
|
||||
</div>
|
||||
<div className={`${name}-char-count ${length > charCount ? `${name}-char-max` : ''}`}>
|
||||
{
|
||||
charCount &&
|
||||
`${charCount - length} ${lang.t('characters-remaining')}`
|
||||
}
|
||||
<div className={`${name}-char-count ${length > maxCharCount ? `${name}-char-max` : ''}`}>
|
||||
{maxCharCount && `${maxCharCount - length} ${lang.t('characters-remaining')}`}
|
||||
</div>
|
||||
<div className={`${name}-button-container`}>
|
||||
<Slot
|
||||
fill="commentBoxDetail"
|
||||
registerHook={this.registerHook}
|
||||
unregisterHook={this.unregisterHook}
|
||||
inline
|
||||
/>
|
||||
{
|
||||
isReply && (
|
||||
<Button
|
||||
cStyle='darkGrey'
|
||||
className={`${name}-cancel-button`}
|
||||
onClick={() => {
|
||||
cancelButtonClicked('');
|
||||
}}>
|
||||
onClick={() => cancelButtonClicked('')}>
|
||||
{lang.t('cancel')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{ authorId && (
|
||||
<Button
|
||||
cStyle={!length || (charCount && length > charCount) ? 'lightGrey' : 'darkGrey'}
|
||||
cStyle={enablePostComment ? 'lightGrey' : 'darkGrey'}
|
||||
className={`${name}-button`}
|
||||
onClick={this.postComment}>
|
||||
onClick={this.postComment}
|
||||
disabled={enablePostComment ? 'disabled' : ''}>
|
||||
{lang.t('post')}
|
||||
</Button>
|
||||
)
|
||||
@@ -129,6 +185,22 @@ class CommentBox extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default CommentBox;
|
||||
CommentBox.propTypes = {
|
||||
charCountEnable: PropTypes.bool.isRequired,
|
||||
maxCharCount: PropTypes.number,
|
||||
commentPostedHandler: PropTypes.func,
|
||||
postItem: PropTypes.func.isRequired,
|
||||
cancelButtonClicked: PropTypes.func,
|
||||
assetId: PropTypes.string.isRequired,
|
||||
parentId: PropTypes.string,
|
||||
authorId: PropTypes.string.isRequired,
|
||||
isReply: PropTypes.bool.isRequired,
|
||||
canPost: PropTypes.bool,
|
||||
currentUser: PropTypes.object
|
||||
};
|
||||
|
||||
const mapStateToProps = ({commentBox}) => ({commentBox});
|
||||
|
||||
export default connect(mapStateToProps, null)(CommentBox);
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const addTag = tag => ({
|
||||
type: 'ADD_TAG',
|
||||
tag
|
||||
});
|
||||
|
||||
export const removeTag = idx => ({
|
||||
type: 'REMOVE_TAG',
|
||||
idx
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ADD_TAG = 'ADD_TAG';
|
||||
export const REMOVE_TAG = 'REMOVE_TAG';
|
||||
@@ -0,0 +1,5 @@
|
||||
import reducer from './reducer';
|
||||
|
||||
export default {
|
||||
reducer
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import {ADD_TAG, REMOVE_TAG} from './constants';
|
||||
|
||||
const initialState = {
|
||||
tags: []
|
||||
};
|
||||
|
||||
export default function commentBox (state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case ADD_TAG :
|
||||
return {
|
||||
...state,
|
||||
tags: [...state.tags, action.tag]
|
||||
};
|
||||
case REMOVE_TAG :
|
||||
return {
|
||||
...state,
|
||||
tags: [
|
||||
...state.tags.slice(0, action.idx),
|
||||
...state.tags.slice(action.idx + 1)
|
||||
]
|
||||
};
|
||||
default :
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.slot {
|
||||
display: inline-block;
|
||||
div {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
@@ -21,15 +21,14 @@ class FlagButton extends Component {
|
||||
|
||||
// When the "report" button is clicked expand the menu
|
||||
onReportClick = () => {
|
||||
const {currentUser, flag, deleteAction} = this.props;
|
||||
const {currentUser, deleteAction, flaggedByCurrentUser, flag} = this.props;
|
||||
const {localPost, localDelete} = this.state;
|
||||
const flagged = (flag && flag.current_user && !localDelete) || localPost;
|
||||
const localFlagged = (flaggedByCurrentUser && !localDelete) || localPost;
|
||||
if (!currentUser) {
|
||||
const offset = document.getElementById(`c_${this.props.id}`).getBoundingClientRect().top - 75;
|
||||
this.props.showSignInDialog(offset);
|
||||
this.props.showSignInDialog();
|
||||
return;
|
||||
}
|
||||
if (flagged) {
|
||||
if (localFlagged) {
|
||||
this.setState((prev) => prev.localPost ? {...prev, localPost: null, step: 0} : {...prev, localDelete: true});
|
||||
deleteAction(localPost || flag.current_user.id);
|
||||
} else if (this.state.showMenu){
|
||||
@@ -130,9 +129,9 @@ class FlagButton extends Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const {flag, getPopupMenu} = this.props;
|
||||
const {getPopupMenu, flaggedByCurrentUser} = this.props;
|
||||
const {localPost, localDelete} = this.state;
|
||||
const flagged = (flag && flag.current_user && !localDelete) || localPost;
|
||||
const flagged = (flaggedByCurrentUser && !localDelete) || localPost;
|
||||
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
|
||||
|
||||
return <div className={`${name}-container`}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.myComment {
|
||||
margin: 1em 0;
|
||||
border-bottom: 1px solid lightgrey;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -24,6 +25,9 @@
|
||||
|
||||
.sidebar {
|
||||
ul {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
list-style-type: none;
|
||||
min-width: 136px;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,16 +27,15 @@ class LikeButton extends Component {
|
||||
|
||||
render() {
|
||||
const {like, id, postLike, deleteAction, showSignInDialog, currentUser} = this.props;
|
||||
let {totalLikes: count} = this.props;
|
||||
const {localPost, localDelete} = this.state;
|
||||
const liked = (like && like.current_user && !localDelete) || localPost;
|
||||
let count = like ? like.count : 0;
|
||||
if (localPost) {count += 1;}
|
||||
if (localDelete) {count -= 1;}
|
||||
|
||||
const onLikeClick = () => {
|
||||
if (!currentUser) {
|
||||
const offset = document.getElementById(`c_${id}`).getBoundingClientRect().top - 75;
|
||||
showSignInDialog(offset);
|
||||
showSignInDialog();
|
||||
return;
|
||||
}
|
||||
if (currentUser.banned) {
|
||||
|
||||
@@ -10,9 +10,22 @@ class ReplyBox extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {styles, postItem, assetId, authorId, addNotification, parentId, commentPostedHandler, setActiveReplyBox} = this.props;
|
||||
const {
|
||||
styles,
|
||||
postItem,
|
||||
assetId,
|
||||
authorId,
|
||||
addNotification,
|
||||
parentId,
|
||||
commentPostedHandler,
|
||||
setActiveReplyBox,
|
||||
maxCharCount,
|
||||
charCountEnable
|
||||
} = this.props;
|
||||
return <div className={`${name}-textarea`} style={styles && styles.container}>
|
||||
<CommentBox
|
||||
maxCharCount={maxCharCount}
|
||||
charCountEnable={charCountEnable}
|
||||
commentPostedHandler={commentPostedHandler}
|
||||
parentId={parentId}
|
||||
cancelButtonClicked={setActiveReplyBox}
|
||||
@@ -26,6 +39,8 @@ class ReplyBox extends Component {
|
||||
}
|
||||
|
||||
ReplyBox.propTypes = {
|
||||
charCountEnable: PropTypes.bool.isRequired,
|
||||
maxCharCount: PropTypes.number,
|
||||
setActiveReplyBox: PropTypes.func.isRequired,
|
||||
commentPostedHandler: PropTypes.func,
|
||||
parentId: PropTypes.string,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
.ignoredUser {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.ignoredUserList {
|
||||
display: table;
|
||||
}
|
||||
|
||||
.ignoredUser > * {
|
||||
display: table-cell;
|
||||
}
|
||||
|
||||
.stopListening {
|
||||
color: #D0011B;
|
||||
}
|
||||
|
||||
.link {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stopListening:before {
|
||||
content: '\00a0\00a0\00a0\00a0';
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
|
||||
import styles from './IgnoredUsers.css';
|
||||
|
||||
export class IgnoredUsers extends Component {
|
||||
static propTypes = {
|
||||
users: PropTypes.arrayOf(PropTypes.shape({
|
||||
username: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
})).isRequired,
|
||||
|
||||
// accepts { id }
|
||||
stopIgnoring: PropTypes.func.isRequired,
|
||||
}
|
||||
render() {
|
||||
const {users, stopIgnoring} = this.props;
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
users.length
|
||||
? <p>Because you ignored these, you do not see their comments.</p>
|
||||
: null
|
||||
}
|
||||
<dl className={styles.ignoredUserList}>
|
||||
{
|
||||
users.map(({username, id}) => (
|
||||
<span className={styles.ignoredUser} key={id}>
|
||||
<dt key={id}>{ username }</dt>
|
||||
<dd className={styles.stopListening}>
|
||||
<a
|
||||
onClick={() => stopIgnoring({id})}
|
||||
className={styles.link}>Stop ignoring</a>
|
||||
</dd>
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default IgnoredUsers;
|
||||
@@ -1,17 +1,13 @@
|
||||
import React from 'react';
|
||||
import styles from './NotLoggedIn.css';
|
||||
import SignInContainer from '../../coral-sign-in/containers/SignInContainer';
|
||||
import translations from '../translations';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
export default ({showSignInDialog, requireEmailConfirmation}) => (
|
||||
export default ({showSignInDialog}) => (
|
||||
<div className={styles.message}>
|
||||
<SignInContainer noButton={true} requireEmailConfirmation={requireEmailConfirmation}/>
|
||||
<div>
|
||||
<a onClick={() => {
|
||||
showSignInDialog();
|
||||
}}>{lang.t('signIn')}</a> {lang.t('toAccess')}
|
||||
<a onClick={showSignInDialog}>{lang.t('signIn')}</a> {lang.t('toAccess')}
|
||||
</div>
|
||||
<div>
|
||||
{lang.t('fromSettingsPage')}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
.header h1 {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './ProfileHeader.css';
|
||||
|
||||
const ProfileHeader = ({username}) => (
|
||||
<div className={styles.header}>
|
||||
<h1>{username}</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
ProfileHeader.propTypes = {username: PropTypes.string.isRequired};
|
||||
|
||||
export default ProfileHeader;
|
||||
@@ -2,14 +2,19 @@ import {connect} from 'react-redux';
|
||||
import {compose} from 'react-apollo';
|
||||
import React, {Component} from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import {bindActionCreators} from 'redux';
|
||||
|
||||
import {myCommentHistory} from 'coral-framework/graphql/queries';
|
||||
import {myCommentHistory, myIgnoredUsers} from 'coral-framework/graphql/queries';
|
||||
import {stopIgnoringUser} from 'coral-framework/graphql/mutations';
|
||||
|
||||
import {link} from 'coral-framework/services/PymConnection';
|
||||
import NotLoggedIn from '../components/NotLoggedIn';
|
||||
import IgnoredUsers from '../components/IgnoredUsers';
|
||||
import {Spinner} from 'coral-ui';
|
||||
import CommentHistory from 'coral-plugin-history/CommentHistory';
|
||||
|
||||
import {showSignInDialog, checkLogin} from 'coral-framework/actions/auth';
|
||||
|
||||
import translations from '../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
@@ -30,27 +35,46 @@ class ProfileContainer extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {loggedIn, asset, showSignInDialog, data} = this.props;
|
||||
const {loggedIn, asset, data, showSignInDialog, myIgnoredUsersData, stopIgnoringUser} = this.props;
|
||||
const {me} = this.props.data;
|
||||
|
||||
if (!loggedIn || !me) {
|
||||
return <NotLoggedIn showSignInDialog={showSignInDialog} requireEmailConfirmation={asset.settings.requireEmailConfirmation}/>;
|
||||
}
|
||||
|
||||
if (data.loading) {
|
||||
return <Spinner/>;
|
||||
}
|
||||
|
||||
if (!loggedIn || !me) {
|
||||
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
|
||||
}
|
||||
|
||||
const localProfile = this.props.user.profiles.find(p => p.provider === 'local');
|
||||
const emailAddress = localProfile && localProfile.id;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
<h2>{this.props.userData.username}</h2>
|
||||
{ emailAddress
|
||||
? <p>{ emailAddress }</p>
|
||||
: null
|
||||
}
|
||||
|
||||
// Hiding bio until moderation can get figured out
|
||||
/* <TabBar onChange={this.handleTabChange} activeTab={activeTab} cStyle='material'>
|
||||
<Tab>{lang.t('allComments')} ({user.myComments.length})</Tab>
|
||||
<Tab>{lang.t('profileSettings')}</Tab>
|
||||
</TabBar>
|
||||
<TabContent show={activeTab === 0}> */
|
||||
{
|
||||
myIgnoredUsersData.myIgnoredUsers && myIgnoredUsersData.myIgnoredUsers.length
|
||||
? (
|
||||
<div>
|
||||
<h3>Ignored users</h3>
|
||||
<IgnoredUsers
|
||||
users={myIgnoredUsersData.myIgnoredUsers}
|
||||
stopIgnoring={stopIgnoringUser}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
<hr />
|
||||
|
||||
<h3>My comments</h3>
|
||||
{
|
||||
me.comments.length ?
|
||||
<CommentHistory
|
||||
comments={me.comments}
|
||||
@@ -59,14 +83,7 @@ class ProfileContainer extends Component {
|
||||
/>
|
||||
:
|
||||
<p>{lang.t('userNoComment')}</p>
|
||||
|
||||
// Hiding user bio pending effective moderation system.
|
||||
/* </TabContent>
|
||||
<TabContent show={activeTab === 1}>
|
||||
<BioContainer bio={userData.settings.bio} handleSave={this.handleSave} {...this.props} />
|
||||
</TabContent> */
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,12 +95,12 @@ const mapStateToProps = state => ({
|
||||
auth: state.auth.toJS()
|
||||
});
|
||||
|
||||
const mapDispatchToProps = () => ({
|
||||
|
||||
// saveBio: (user_id, formData) => dispatch(saveBio(user_id, formData))
|
||||
});
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators({showSignInDialog, checkLogin}, dispatch);
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
myCommentHistory
|
||||
myCommentHistory,
|
||||
myIgnoredUsers,
|
||||
stopIgnoringUser,
|
||||
)(ProfileContainer);
|
||||
|
||||
@@ -10,16 +10,12 @@ import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const CreateUsernameDialog = ({open, handleClose, offset, formData, handleSubmitUsername, handleChange, ...props}) => {
|
||||
const CreateUsernameDialog = ({open, handleClose, formData, handleSubmitUsername, handleChange, ...props}) => {
|
||||
return (
|
||||
<Dialog
|
||||
className={styles.dialogusername}
|
||||
id="createUsernameDialog"
|
||||
open={open}
|
||||
style={{
|
||||
position: 'relative',
|
||||
top: offset !== 0 && offset
|
||||
}}>
|
||||
open={open}>
|
||||
<span className={styles.close} onClick={handleClose}>×</span>
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
@@ -42,6 +38,7 @@ const CreateUsernameDialog = ({open, handleClose, offset, formData, handleSubmit
|
||||
<div className={styles.saveusername}>
|
||||
<TextField
|
||||
id="username"
|
||||
style={{fontSize: 16}}
|
||||
type="string"
|
||||
label={lang.t('createdisplay.username')}
|
||||
value={formData.username}
|
||||
|
||||
@@ -31,6 +31,7 @@ class ForgotContent extends React.Component {
|
||||
<input
|
||||
ref={input => this.emailInput = input}
|
||||
type="text"
|
||||
style={{fontSize: 16}}
|
||||
id="email"
|
||||
name="email" />
|
||||
</div>
|
||||
|
||||
@@ -6,15 +6,11 @@ import SignInContent from './SignInContent';
|
||||
import SignUpContent from './SignUpContent';
|
||||
import ForgotContent from './ForgotContent';
|
||||
|
||||
const SignDialog = ({open, view, handleClose, offset, ...props}) => (
|
||||
const SignDialog = ({open, view, handleClose, ...props}) => (
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
id="signInDialog"
|
||||
open={open}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: offset !== 0 && offset
|
||||
}}>
|
||||
open={open}>
|
||||
<span className={styles.close} onClick={handleClose}>×</span>
|
||||
{view === 'SIGNIN' && <SignInContent {...props} />}
|
||||
{view === 'SIGNUP' && <SignUpContent {...props} />}
|
||||
|
||||
@@ -20,8 +20,8 @@ const SignInContent = ({
|
||||
}) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<div className="coral-sign-in">
|
||||
<div className={`${styles.header} header`}>
|
||||
<h1>
|
||||
{auth.emailVerificationFailure ? lang.t('signIn.emailVerifyCTA') : lang.t('signIn.signIn')}
|
||||
</h1>
|
||||
@@ -42,7 +42,7 @@ const SignInContent = ({
|
||||
{emailVerificationSuccess && <Success />}
|
||||
</form>
|
||||
: <div>
|
||||
<div className={styles.socialConnections}>
|
||||
<div className={`${styles.socialConnections} social-connections`}>
|
||||
<Button cStyle="facebook" onClick={fetchSignInFacebook} full>
|
||||
{lang.t('signIn.facebookSignIn')}
|
||||
</Button>
|
||||
@@ -58,6 +58,7 @@ const SignInContent = ({
|
||||
type="email"
|
||||
label={lang.t('signIn.email')}
|
||||
value={formData.email}
|
||||
style={{fontSize: 16}}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<TextField
|
||||
@@ -65,6 +66,7 @@ const SignInContent = ({
|
||||
type="password"
|
||||
label={lang.t('signIn.password')}
|
||||
value={formData.password}
|
||||
style={{fontSize: 16}}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className={styles.action}>
|
||||
@@ -80,7 +82,7 @@ const SignInContent = ({
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
<div className={styles.footer}>
|
||||
<div className={`${styles.footer} footer`}>
|
||||
<span><a onClick={() => changeView('FORGOT')}>{lang.t('signIn.forgotYourPass')}</a></span>
|
||||
<span>
|
||||
{lang.t('signIn.needAnAccount')}
|
||||
|
||||
@@ -83,6 +83,7 @@ class SignUpContent extends React.Component {
|
||||
type="email"
|
||||
label={lang.t('signIn.email')}
|
||||
value={formData.email}
|
||||
style={{fontSize: 16}}
|
||||
showErrors={showErrors}
|
||||
errorMsg={errors.email}
|
||||
onChange={handleChange}
|
||||
@@ -93,6 +94,7 @@ class SignUpContent extends React.Component {
|
||||
label={lang.t('signIn.username')}
|
||||
value={formData.username}
|
||||
showErrors={showErrors}
|
||||
style={{fontSize: 16}}
|
||||
errorMsg={errors.username}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
@@ -102,6 +104,7 @@ class SignUpContent extends React.Component {
|
||||
label={lang.t('signIn.password')}
|
||||
value={formData.password}
|
||||
showErrors={showErrors}
|
||||
style={{fontSize: 16}}
|
||||
errorMsg={errors.password}
|
||||
onChange={handleChange}
|
||||
minLength="8"
|
||||
@@ -112,6 +115,7 @@ class SignUpContent extends React.Component {
|
||||
type="password"
|
||||
label={lang.t('signIn.confirmPassword')}
|
||||
value={formData.confirmPassword}
|
||||
style={{fontSize: 16}}
|
||||
showErrors={showErrors}
|
||||
errorMsg={errors.confirmPassword}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -100,12 +100,11 @@ class ChangeUsernameContainer extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {loggedIn, auth, offset} = this.props;
|
||||
const {loggedIn, auth} = this.props;
|
||||
return (
|
||||
<div>
|
||||
<CreateUsernameDialog
|
||||
open={auth.showCreateUsernameDialog && auth.user.canEditName}
|
||||
offset={offset}
|
||||
handleClose={this.handleClose}
|
||||
loggedIn={loggedIn}
|
||||
handleSubmitUsername={this.handleSubmitUsername}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import SignDialog from '../components/SignDialog';
|
||||
import Button from 'coral-ui/components/Button';
|
||||
import validate from 'coral-framework/helpers/validate';
|
||||
import errorMsj from 'coral-framework/helpers/error';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
changeView,
|
||||
fetchSignUp,
|
||||
fetchSignIn,
|
||||
showSignInDialog,
|
||||
hideSignInDialog,
|
||||
fetchSignInFacebook,
|
||||
fetchSignUpFacebook,
|
||||
@@ -147,23 +145,18 @@ class SignInContainer extends Component {
|
||||
|
||||
handleSignIn(e) {
|
||||
e.preventDefault();
|
||||
this.props.fetchSignIn(this.state.formData)
|
||||
.then(this.props.refetch);
|
||||
this.props.fetchSignIn(this.state.formData);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {auth, showSignInDialog, noButton, offset, requireEmailConfirmation} = this.props;
|
||||
const {auth, requireEmailConfirmation} = this.props;
|
||||
const {emailVerificationLoading, emailVerificationSuccess} = auth;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!noButton && <Button id='coralSignInButton' onClick={showSignInDialog} full>
|
||||
Sign in to comment
|
||||
</Button>}
|
||||
<SignDialog
|
||||
open={auth.showSignInDialog}
|
||||
open={true}
|
||||
view={auth.view}
|
||||
offset={offset}
|
||||
emailVerificationEnabled={requireEmailConfirmation}
|
||||
emailVerificationLoading={emailVerificationLoading}
|
||||
emailVerificationSuccess={emailVerificationSuccess}
|
||||
@@ -189,7 +182,6 @@ const mapDispatchToProps = dispatch => ({
|
||||
fetchSignUpFacebook: () => dispatch(fetchSignUpFacebook()),
|
||||
fetchForgotPassword: formData => dispatch(fetchForgotPassword(formData)),
|
||||
requestConfirmEmail: (email, url) => dispatch(requestConfirmEmail(email, url)),
|
||||
showSignInDialog: () => dispatch(showSignInDialog()),
|
||||
changeView: view => dispatch(changeView(view)),
|
||||
handleClose: () => dispatch(hideSignInDialog()),
|
||||
invalidForm: error => dispatch(invalidForm(error)),
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.03), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.09);
|
||||
width: 128px;
|
||||
|
||||
&:hover {
|
||||
&:hover {
|
||||
color: white;
|
||||
background-color: #D03235;
|
||||
box-shadow: none;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React from 'react';
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Icon as IconMDL} from 'react-mdl';
|
||||
|
||||
const Icon = ({className = '', name}) => (
|
||||
<IconMDL className={className} name={name} />
|
||||
);
|
||||
|
||||
Icon.propTypes = {
|
||||
name: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
version: '2'
|
||||
services:
|
||||
talk:
|
||||
image: coralproject/talk:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
mongo:
|
||||
image: mongo:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- mongo:/data/db
|
||||
redis:
|
||||
image: redis:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- redis:/data
|
||||
volumes:
|
||||
mongo:
|
||||
external: false
|
||||
redis:
|
||||
external: false
|
||||
+71
-37
@@ -53,6 +53,71 @@ const forEachField = (schema, fn) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Decorates the field with the post resolvers (if available) and attaches a
|
||||
* default type in the form of `Default${typeName}`.
|
||||
*/
|
||||
const decorateResolveFunction = (field, typeName, fieldName, post) => {
|
||||
|
||||
// Cache the original resolverType function.
|
||||
let resolveType = field.resolveType;
|
||||
|
||||
// defaultResolveType is the default type that is resolved on a resolver
|
||||
// when the interface being looked up is not defined.
|
||||
const defaultResolveType = `Default${typeName}`;
|
||||
|
||||
// Return the function to handle the resolveType hooks.
|
||||
const defaultResolveFn = (obj, context, info) => {
|
||||
let type = resolveType(obj, context, info);
|
||||
|
||||
// Only if a previous resolver was unable to resolve the field type do we
|
||||
// progress to the hooks (in order!) to resolve the field name until we
|
||||
// have resolved it.
|
||||
if (typeof type !== 'undefined' && type != null) {
|
||||
return type;
|
||||
}
|
||||
|
||||
// All else fails, resort to the defaultResolveType.
|
||||
return defaultResolveType;
|
||||
};
|
||||
|
||||
// This only needs to do something if post hooks are defined.
|
||||
if (post.length === 0) {
|
||||
|
||||
// Set the default on the resolveType function.
|
||||
field.resolveType = defaultResolveFn;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure it matches the format we expect.
|
||||
Joi.assert(post, Joi.array().items(Joi.func().maxArity(3)), `invalid post hooks were found for ${typeName}.${fieldName}`);
|
||||
|
||||
// Return the function to handle the resolveType hooks.
|
||||
field.resolveType = (obj, context, info) => {
|
||||
let type = defaultResolveFn(obj, context, info);
|
||||
|
||||
// Only if a previous resolver was unable to resolve the field type do we
|
||||
// progress to the hooks (in order!) to resolve the field name until we
|
||||
// have resolved it.
|
||||
if (typeof type !== 'undefined' && type != null && type !== defaultResolveType) {
|
||||
return type;
|
||||
}
|
||||
|
||||
// We will walk through the post hooks until we find the right one. This
|
||||
// follows what redux does to combine existing reducers.
|
||||
for (let i = 0; i < post.length; i++) {
|
||||
let resolveType = post[i];
|
||||
let resolvedType = resolveType(obj, context, info);
|
||||
if (typeof resolvedType !== 'undefined' && resolvedType != null) {
|
||||
return resolvedType;
|
||||
}
|
||||
}
|
||||
|
||||
return type;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Decorates the schema with pre and post hooks as provided by the Plugin
|
||||
* Manager.
|
||||
@@ -115,11 +180,6 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa
|
||||
post: []
|
||||
});
|
||||
|
||||
// If we have no hooks to add here, don't try to modify anything.
|
||||
if (pre.length === 0 && post.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is a resolve type, we need to do some specific things to handle
|
||||
// this type of field.
|
||||
if (isResolveType) {
|
||||
@@ -129,39 +189,13 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa
|
||||
throw new Error(`invalid pre hooks were found for ${typeName}.${fieldName}, only post hooks are supported on the __resolveType hook`);
|
||||
}
|
||||
|
||||
// This only needs to do something if post hooks are defined.
|
||||
if (post.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure it matches the format we expect.
|
||||
Joi.assert(post, Joi.array().items(Joi.func().maxArity(3)), `invalid post hooks were found for ${typeName}.${fieldName}`);
|
||||
|
||||
// Cache the original resolverType function.
|
||||
let resolveType = field.resolveType;
|
||||
|
||||
// Return the function to handle the resolveType hooks.
|
||||
field.resolveType = (obj, context, info) => {
|
||||
let type = resolveType(obj, context, info);
|
||||
|
||||
// Only if a previous resolver was unable to resolve the field type do we
|
||||
// progress to the hooks (in order!) to resolve the field name until we
|
||||
// have resolved it.
|
||||
if (typeof type !== 'undefined' && type != null) {
|
||||
return type;
|
||||
}
|
||||
|
||||
// We will walk through the post hooks until we find the right one. This
|
||||
// follows what redux does to combine existing reducers.
|
||||
for (let i = 0; i < post.length; i++) {
|
||||
let resolveType = post[i];
|
||||
type = resolveType(obj, context, info);
|
||||
if (typeof type !== 'undefined' && type != null) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
};
|
||||
// Decorate the resolve function on the field with the new resolveType func.
|
||||
decorateResolveFunction(field, typeName, fieldName, post);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have no hooks to add here, don't try to modify anything.
|
||||
if (pre.length === 0 && post.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
const DataLoader = require('dataloader');
|
||||
|
||||
const CommentModel = require('../../models/comment');
|
||||
const UsersService = require('../../services/users');
|
||||
|
||||
/**
|
||||
* Returns the comment count for all comments that are public based on their
|
||||
@@ -39,6 +40,31 @@ const getCountsByAssetID = (context, asset_ids) => {
|
||||
.then((results) => results.map((result) => result ? result.count : 0));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the count of all public comments on an asset id, also filtering by personalization options.
|
||||
*
|
||||
* @param {Array<String>} id The ID of the asset
|
||||
* @param {Array<String>} excludeIgnored Exclude comments ignored by the requesting user
|
||||
*/
|
||||
const getCountsByAssetIDPersonalized = async (context, {assetId, excludeIgnored}) => {
|
||||
const query = {
|
||||
asset_id: assetId,
|
||||
status: {
|
||||
$in: ['NONE', 'ACCEPTED'],
|
||||
},
|
||||
};
|
||||
const user = context.user;
|
||||
if (excludeIgnored && user) {
|
||||
|
||||
// load afresh, as `user` may be from cache and not have recent ignores
|
||||
const freshUser = await UsersService.findById(user.id);
|
||||
const ignoredUsers = freshUser.ignoresUsers;
|
||||
query.author_id = {$nin: ignoredUsers};
|
||||
}
|
||||
const count = await CommentModel.where(query).count();
|
||||
return count;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the comment count for all comments that are public based on their
|
||||
* asset ids.
|
||||
@@ -72,6 +98,32 @@ const getParentCountsByAssetID = (context, asset_ids) => {
|
||||
.then((results) => results.map((result) => result ? result.count : 0));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the count of top-level comments on an asset id, also filtering by personalization options.
|
||||
*
|
||||
* @param {Array<String>} id The ID of the asset
|
||||
* @param {Array<String>} excludeIgnored Exclude comments ignored by the requesting user
|
||||
*/
|
||||
const getParentCountByAssetIDPersonalized = async (context, {assetId, excludeIgnored}) => {
|
||||
const query = {
|
||||
asset_id: assetId,
|
||||
parent_id: null,
|
||||
status: {
|
||||
$in: ['NONE', 'ACCEPTED'],
|
||||
},
|
||||
};
|
||||
const user = context.user;
|
||||
if (excludeIgnored && user) {
|
||||
|
||||
// load afresh, as `user` may be from cache and not have recent ignores
|
||||
const freshUser = await UsersService.findById(user.id);
|
||||
const ignoredUsers = freshUser.ignoresUsers;
|
||||
query.author_id = {$nin: ignoredUsers};
|
||||
}
|
||||
const count = await CommentModel.where(query).count();
|
||||
return count;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the comment count for all comments that are public based on their
|
||||
* parent ids.
|
||||
@@ -104,6 +156,33 @@ const getCountsByParentID = (context, parent_ids) => {
|
||||
.then((results) => results.map((result) => result ? result.count : 0));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the count of comments for the provided parent_id, also filtering by personalization options.
|
||||
*
|
||||
* @param {Array<String>} id The ID of the parent comment
|
||||
* @param {Array<String>} excludeIgnored Exclude comments ignored by context.user
|
||||
*/
|
||||
const getCountByParentIDPersonalized = async (context, {id, excludeIgnored}) => {
|
||||
const query = {
|
||||
parent_id: {
|
||||
$in: [id]
|
||||
},
|
||||
status: {
|
||||
$in: ['NONE', 'ACCEPTED']
|
||||
}
|
||||
};
|
||||
const user = context.user;
|
||||
if (excludeIgnored && user) {
|
||||
|
||||
// load afresh, as `user` may be from cache and not have recent ignores
|
||||
const freshUser = await UsersService.findById(user.id);
|
||||
const ignoredUsers = freshUser.ignoresUsers;
|
||||
query.author_id = {$nin: ignoredUsers};
|
||||
}
|
||||
const count = await CommentModel.where(query).count();
|
||||
return count;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the count of comments based on the passed in query.
|
||||
* @param {Object} context graph context
|
||||
@@ -142,7 +221,7 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id}) =
|
||||
* @param {Object} context graph context
|
||||
* @param {Object} query query terms to apply to the comments query
|
||||
*/
|
||||
const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sort}) => {
|
||||
const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sort, excludeIgnored}) => {
|
||||
let comments = CommentModel.find();
|
||||
|
||||
// Only administrators can search for comments with statuses that are not
|
||||
@@ -184,6 +263,16 @@ const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, author_
|
||||
comments = comments.where({parent_id});
|
||||
}
|
||||
|
||||
if (excludeIgnored && user) {
|
||||
|
||||
// load afresh, as `user` may be from cache and not have recent ignores
|
||||
const freshUser = await UsersService.findById(user.id);
|
||||
const ignoredUsers = freshUser.ignoresUsers;
|
||||
comments = comments.where({
|
||||
author_id: {$nin: ignoredUsers}
|
||||
});
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
if (sort === 'REVERSE_CHRONOLOGICAL') {
|
||||
comments = comments.where({
|
||||
@@ -344,8 +433,11 @@ module.exports = (context) => ({
|
||||
getByQuery: (query) => getCommentsByQuery(context, query),
|
||||
getCountByQuery: (query) => getCommentCountByQuery(context, query),
|
||||
countByAssetID: new SharedCounterDataLoader('Comments.totalCommentCount', 3600, (ids) => getCountsByAssetID(context, ids)),
|
||||
countByAssetIDPersonalized: (query) => getCountsByAssetIDPersonalized(context, query),
|
||||
parentCountByAssetID: new SharedCounterDataLoader('Comments.countByAssetID', 3600, (ids) => getParentCountsByAssetID(context, ids)),
|
||||
parentCountByAssetIDPersonalized: (query) => getParentCountByAssetIDPersonalized(context, query),
|
||||
countByParentID: new SharedCounterDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids)),
|
||||
countByParentIDPersonalized: (query) => getCountByParentIDPersonalized(context, query),
|
||||
genRecentReplies: new DataLoader((ids) => genRecentReplies(context, ids)),
|
||||
genRecentComments: new DataLoader((ids) => genRecentComments(context, ids))
|
||||
}
|
||||
|
||||
@@ -16,11 +16,14 @@ const Wordlist = require('../../services/wordlist');
|
||||
* @param {String} [status='NONE'] the status of the new comment
|
||||
* @return {Promise} resolves to the created comment
|
||||
*/
|
||||
const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null}, status = 'NONE') => {
|
||||
const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null, tags = []}, status = 'NONE') => {
|
||||
|
||||
let tags = [];
|
||||
// Building array of tags
|
||||
tags = tags.map(tag => ({name: tag}));
|
||||
|
||||
// If admin or moderator, adding STAFF tag
|
||||
if (user.hasRoles('ADMIN') || user.hasRoles('MODERATOR')) {
|
||||
tags = [{name: 'STAFF'}];
|
||||
tags.push({name: 'STAFF'});
|
||||
}
|
||||
|
||||
return CommentsService.publicCreate({
|
||||
@@ -177,9 +180,9 @@ const createPublicComment = (context, commentInput) => {
|
||||
* @param {String} status the new status of the comment
|
||||
*/
|
||||
|
||||
const setCommentStatus = ({loaders: {Comments}}, {id, status}) => {
|
||||
const setCommentStatus = ({user, loaders: {Comments}}, {id, status}) => {
|
||||
return CommentsService
|
||||
.setStatus(id, status)
|
||||
.pushStatus(id, status, user ? user.id : null)
|
||||
.then((comment) => {
|
||||
|
||||
// If the loaders are present, clear the caches for these values because we
|
||||
|
||||
+11
-1
@@ -13,11 +13,21 @@ const suspendUser = ({user}, {id, message}) => {
|
||||
});
|
||||
};
|
||||
|
||||
const ignoreUser = async ({user}, userToIgnore) => {
|
||||
return await UsersService.ignoreUsers(user.id, [userToIgnore.id]);
|
||||
};
|
||||
|
||||
const stopIgnoringUser = async ({user}, userToStopIgnoring) => {
|
||||
return await UsersService.stopIgnoringUsers(user.id, [userToStopIgnoring.id]);
|
||||
};
|
||||
|
||||
module.exports = (context) => {
|
||||
let mutators = {
|
||||
User: {
|
||||
setUserStatus: () => Promise.reject(errors.ErrNotAuthorized),
|
||||
suspendUser: () => Promise.reject(errors.ErrNotAuthorized)
|
||||
suspendUser: () => Promise.reject(errors.ErrNotAuthorized),
|
||||
ignoreUser: (action) => ignoreUser(context, action),
|
||||
stopIgnoringUser: (action) => stopIgnoringUser(context, action),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const ActionSummary = {
|
||||
case 'DONTAGREE':
|
||||
return 'DontAgreeActionSummary';
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = ActionSummary;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user