mirror of
https://github.com/wassname/talk.git
synced 2026-06-28 19:49:58 +08:00
Merge branch 'master' of github.com:coralproject/talk into plugin_examples
This commit is contained in:
@@ -20,3 +20,4 @@ plugins/*
|
||||
!plugins/coral-plugin-respect
|
||||
!plugins/coral-plugin-offtopic
|
||||
|
||||
**/node_modules/*
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:7
|
||||
FROM node:7.9
|
||||
|
||||
# Create app directory
|
||||
RUN mkdir -p /usr/src/app
|
||||
|
||||
+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/) v7.9 or later
|
||||
- [Yarn](https://yarnpkg.com/) v0.22.0 or later
|
||||
|
||||
_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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -69,6 +69,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 +143,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?",
|
||||
@@ -231,6 +236,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 +292,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ class ConfigureStreamContainer extends Component {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
changed: false
|
||||
changed: false,
|
||||
closedAt: (props.asset.closedAt === null ? 'open' : 'closed')
|
||||
};
|
||||
|
||||
this.toggleStatus = this.toggleStatus.bind(this);
|
||||
@@ -66,21 +67,28 @@ class ConfigureStreamContainer extends Component {
|
||||
}
|
||||
|
||||
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 {settings} = this.props.asset;
|
||||
const {closedAt} = this.state;
|
||||
const premod = settings.moderation === 'PRE';
|
||||
const closedTimeout = settings.closedTimeout;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -95,11 +103,11 @@ class ConfigureStreamContainer extends Component {
|
||||
questionBoxContent={settings.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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ 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 IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import {TopRightMenu} from './TopRightMenu';
|
||||
|
||||
import styles from './Comment.css';
|
||||
|
||||
@@ -84,11 +86,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,7 +119,9 @@ class Comment extends React.Component {
|
||||
deleteAction,
|
||||
addCommentTag,
|
||||
removeCommentTag,
|
||||
ignoreUser,
|
||||
disableReply,
|
||||
commentIsIgnored,
|
||||
} = this.props;
|
||||
|
||||
const like = getActionSummary('LikeActionSummary', comment);
|
||||
@@ -121,10 +131,10 @@ class Comment extends React.Component {
|
||||
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;
|
||||
@@ -160,6 +170,16 @@ class Comment extends React.Component {
|
||||
<PubDate created_at={comment.created_at} />
|
||||
<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>
|
||||
@@ -225,27 +245,29 @@ 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}
|
||||
showSignInDialog={showSignInDialog}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply} />;
|
||||
})
|
||||
}
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@ 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';
|
||||
@@ -71,6 +71,9 @@ 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 () {
|
||||
@@ -155,13 +158,14 @@ 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}/>;
|
||||
|
||||
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 +178,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">
|
||||
@@ -266,10 +270,12 @@ 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}
|
||||
ignoredUsers={this.props.userData.ignoredUsers} />
|
||||
</div>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
@@ -279,22 +285,23 @@ 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}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 2}>
|
||||
<RestrictedContent restricted={!loggedIn}>
|
||||
{ loggedIn ? userBox : null }
|
||||
<ConfigureStreamContainer
|
||||
status={status}
|
||||
onClick={this.toggleStatus}
|
||||
/>
|
||||
</RestrictedContent>
|
||||
</TabContent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -304,7 +311,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 => ({
|
||||
@@ -317,7 +324,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
updateCountCache: (id, count) => dispatch(updateCountCache(id, count)),
|
||||
viewAllComments: () => dispatch(viewAllComments()),
|
||||
logout: () => dispatch(logout()),
|
||||
dispatch: d => dispatch(d)
|
||||
dispatch: d => dispatch(d),
|
||||
});
|
||||
|
||||
export default compose(
|
||||
@@ -328,6 +335,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;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import Comment from './Comment';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
|
||||
class Stream extends React.Component {
|
||||
|
||||
@@ -19,6 +20,12 @@ class Stream extends React.Component {
|
||||
|
||||
// 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 +49,43 @@ class Stream extends React.Component {
|
||||
showSignInDialog,
|
||||
addCommentTag,
|
||||
removeCommentTag,
|
||||
pluginProps
|
||||
pluginProps,
|
||||
ignoreUser,
|
||||
ignoredUsers,
|
||||
} = 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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
* {
|
||||
font-weight: inherit;
|
||||
font-family: inherit;
|
||||
font-style: inherit;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
html, body {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -36,15 +36,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,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;
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
@@ -3,10 +3,12 @@ import {compose} from 'react-apollo';
|
||||
import React, {Component} from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
|
||||
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';
|
||||
|
||||
@@ -30,7 +32,7 @@ class ProfileContainer extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {loggedIn, asset, showSignInDialog, data} = this.props;
|
||||
const {loggedIn, asset, showSignInDialog, data, myIgnoredUsersData, stopIgnoringUser} = this.props;
|
||||
const {me} = this.props.data;
|
||||
|
||||
if (!loggedIn || !me) {
|
||||
@@ -41,16 +43,35 @@ class ProfileContainer extends Component {
|
||||
return <Spinner/>;
|
||||
}
|
||||
|
||||
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,12 +80,6 @@ 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>
|
||||
@@ -85,5 +100,7 @@ const mapDispatchToProps = () => ({
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
myCommentHistory
|
||||
myCommentHistory,
|
||||
myIgnoredUsers,
|
||||
stopIgnoringUser,
|
||||
)(ProfileContainer);
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+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),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,26 +9,31 @@ const Asset = {
|
||||
recentComments({id}, _, {loaders: {Comments}}) {
|
||||
return Comments.genRecentComments.load(id);
|
||||
},
|
||||
comments({id}, {sort, limit}, {loaders: {Comments}}) {
|
||||
comments({id}, {sort, limit, excludeIgnored}, {loaders: {Comments}}) {
|
||||
return Comments.getByQuery({
|
||||
asset_id: id,
|
||||
sort,
|
||||
limit,
|
||||
parent_id: null
|
||||
parent_id: null,
|
||||
excludeIgnored,
|
||||
});
|
||||
},
|
||||
commentCount({id, commentCount}, _, {loaders: {Comments}}) {
|
||||
commentCount({id, commentCount}, {excludeIgnored}, {user, loaders: {Comments}}) {
|
||||
if (user && excludeIgnored) {
|
||||
return Comments.parentCountByAssetIDPersonalized({assetId: id, excludeIgnored});
|
||||
}
|
||||
if (commentCount != null) {
|
||||
return commentCount;
|
||||
}
|
||||
|
||||
return Comments.parentCountByAssetID.load(id);
|
||||
},
|
||||
totalCommentCount({id, totalCommentCount}, _, {loaders: {Comments}}) {
|
||||
totalCommentCount({id, totalCommentCount}, {excludeIgnored}, {user, loaders: {Comments}}) {
|
||||
if (user && excludeIgnored) {
|
||||
return Comments.countByAssetIDPersonalized({assetId: id, excludeIgnored});
|
||||
}
|
||||
if (totalCommentCount != null) {
|
||||
return totalCommentCount;
|
||||
}
|
||||
|
||||
return Comments.countByAssetID.load(id);
|
||||
},
|
||||
settings({settings = null}, _, {loaders: {Settings}}) {
|
||||
|
||||
@@ -12,16 +12,20 @@ const Comment = {
|
||||
recentReplies({id}, _, {loaders: {Comments}}) {
|
||||
return Comments.genRecentReplies.load(id);
|
||||
},
|
||||
replies({id, asset_id}, {sort, limit}, {loaders: {Comments}}) {
|
||||
replies({id, asset_id}, {sort, limit, excludeIgnored}, {loaders: {Comments}}) {
|
||||
return Comments.getByQuery({
|
||||
asset_id,
|
||||
parent_id: id,
|
||||
sort,
|
||||
limit
|
||||
limit,
|
||||
excludeIgnored,
|
||||
});
|
||||
},
|
||||
replyCount({id}, _, {loaders: {Comments}}) {
|
||||
return Comments.countByParentID.load(id);
|
||||
replyCount({id}, {excludeIgnored}, {user, loaders: {Comments}}) {
|
||||
if (user && excludeIgnored) {
|
||||
return Comments.countByParentIDPersonalized({id, excludeIgnored});
|
||||
}
|
||||
return Comments.countByParentID.load(id);
|
||||
},
|
||||
actions({id}, _, {user, loaders: {Actions}}) {
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ const RootMutation = {
|
||||
suspendUser(_, {id, message}, {mutators: {User}}) {
|
||||
return wrapResponse(null)(User.suspendUser({id, message}));
|
||||
},
|
||||
ignoreUser(_, {id}, {mutators: {User}}) {
|
||||
return wrapResponse(null)(User.ignoreUser({id}));
|
||||
},
|
||||
stopIgnoringUser(_, {id}, {mutators: {User}}) {
|
||||
return wrapResponse(null)(User.stopIgnoringUser({id}));
|
||||
},
|
||||
setCommentStatus(_, {id, status}, {mutators: {Comment}}) {
|
||||
return wrapResponse(null)(Comment.setCommentStatus({id, status}));
|
||||
},
|
||||
|
||||
@@ -19,15 +19,15 @@ const RootQuery = {
|
||||
|
||||
// This endpoint is used for loading moderation queues, so hide it in the
|
||||
// event that we aren't an admin.
|
||||
comments(_, {query: {action_type, statuses, asset_id, parent_id, limit, cursor, sort}}, {user, loaders: {Comments, Actions}}) {
|
||||
let query = {statuses, asset_id, parent_id, limit, cursor, sort};
|
||||
comments(_, {query: {action_type, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored}}, {user, loaders: {Comments, Actions}}) {
|
||||
let query = {statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored};
|
||||
|
||||
if (user != null && user.hasRoles('ADMIN') && action_type) {
|
||||
return Actions.getByTypes({action_type, item_type: 'COMMENTS'})
|
||||
.then((ids) => {
|
||||
|
||||
// Perform the query using the available resolver.
|
||||
return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort});
|
||||
return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,6 +83,16 @@ const RootQuery = {
|
||||
return user;
|
||||
},
|
||||
|
||||
myIgnoredUsers: async (_, args, {user, loaders: {Users}}) => {
|
||||
|
||||
// get currentUser again since context.user was out of date when running test/graph/mutations/ignoreUser
|
||||
const currentUser = (await Users.getByQuery({ids: [user.id], limit: 1}))[0];
|
||||
if ( ! (currentUser && Array.isArray(currentUser.ignoresUsers) && currentUser.ignoresUsers.length)) {
|
||||
return [];
|
||||
}
|
||||
return await Users.getByQuery({ids: currentUser.ignoresUsers});
|
||||
},
|
||||
|
||||
// This endpoint is used for loading the user moderation queues (users whose username has been flagged),
|
||||
// so hide it in the event that we aren't an admin.
|
||||
users(_, {query: {action_type, limit, cursor, sort}}, {user, loaders: {Users, Actions}}) {
|
||||
|
||||
+31
-7
@@ -140,6 +140,9 @@ input CommentsQuery {
|
||||
|
||||
# Sort the results by created_at.
|
||||
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
|
||||
|
||||
# Exclude comments ignored by the requesting user
|
||||
excludeIgnored: Boolean
|
||||
}
|
||||
|
||||
# CommentCountQuery allows the ability to query comment counts by specific
|
||||
@@ -185,10 +188,10 @@ type Comment {
|
||||
recentReplies: [Comment]
|
||||
|
||||
# the replies that were made to the comment.
|
||||
replies(sort: SORT_ORDER = CHRONOLOGICAL, limit: Int = 3): [Comment]
|
||||
replies(sort: SORT_ORDER = CHRONOLOGICAL, limit: Int = 3, excludeIgnored: Boolean): [Comment]
|
||||
|
||||
# The count of replies on a comment.
|
||||
replyCount: Int
|
||||
replyCount(excludeIgnored: Boolean): Int
|
||||
|
||||
# Actions completed on the parent. Requires the `ADMIN` role.
|
||||
actions: [Action]
|
||||
@@ -420,13 +423,13 @@ type Asset {
|
||||
recentComments: [Comment]
|
||||
|
||||
# The top level comments that are attached to the asset.
|
||||
comments(sort: SORT_ORDER = REVERSE_CHRONOLOGICAL, limit: Int = 10): [Comment]
|
||||
comments(sort: SORT_ORDER = REVERSE_CHRONOLOGICAL, limit: Int = 10, excludeIgnored: Boolean): [Comment]
|
||||
|
||||
# The count of top level comments on the asset.
|
||||
commentCount: Int
|
||||
commentCount(excludeIgnored: Boolean): Int
|
||||
|
||||
# The total count of all comments made on the asset.
|
||||
totalCommentCount: Int
|
||||
totalCommentCount(excludeIgnored: Boolean): Int
|
||||
|
||||
# The settings (rectified with the global settings) that should be applied to
|
||||
# this asset.
|
||||
@@ -540,15 +543,18 @@ type RootQuery {
|
||||
# role.
|
||||
me: User
|
||||
|
||||
# Users that the currently logged in user ignores
|
||||
myIgnoredUsers: [User]
|
||||
|
||||
# Users returned based on a query.
|
||||
users(query: UsersQuery): [User]
|
||||
|
||||
# Asset metrics related to user actions are saturated into the assets
|
||||
# returned.
|
||||
# returned. Parameters `from` and `to` are related to the action created_at field.
|
||||
assetMetrics(from: Date!, to: Date!, sort: ASSET_METRICS_SORT!, limit: Int = 10): [Asset!]
|
||||
|
||||
# Comment metrics related to user actions are saturated into the comments
|
||||
# returned.
|
||||
# returned. Parameters `from` and `to` are related to the action created_at field.
|
||||
commentMetrics(from: Date!, to: Date!, sort: ACTION_TYPE!, limit: Int = 10): [Comment!]
|
||||
}
|
||||
|
||||
@@ -726,6 +732,18 @@ type RemoveCommentTagResponse implements Response {
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
# Response to ignoreUser mutation
|
||||
type IgnoreUserResponse implements Response {
|
||||
# An array of errors relating to the mutation that occured.
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
# Response to stopIgnoringUser mutation
|
||||
type StopIgnoringUserResponse implements Response {
|
||||
# An array of errors relating to the mutation that occured.
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
# All mutations for the application are defined on this object.
|
||||
type RootMutation {
|
||||
|
||||
@@ -758,6 +776,12 @@ type RootMutation {
|
||||
|
||||
# Remove tag from comment.
|
||||
removeCommentTag(id: ID!, tag: String!): RemoveCommentTagResponse
|
||||
|
||||
# Ignore comments by another user
|
||||
ignoreUser(id: ID!): IgnoreUserResponse
|
||||
|
||||
# Stop Ignoring comments by another user
|
||||
stopIgnoringUser(id: ID!): StopIgnoringUserResponse
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
+7
-1
@@ -117,7 +117,13 @@ const UserSchema = new mongoose.Schema({
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
ignoresUsers: [{
|
||||
|
||||
// user id of another user
|
||||
type: String,
|
||||
}]
|
||||
}, {
|
||||
|
||||
// This will ensure that we have proper timestamps available on this model.
|
||||
|
||||
+1
-1
@@ -182,6 +182,6 @@
|
||||
"webpack": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^7.7.0"
|
||||
"node": "^7.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -36,7 +36,14 @@ class MetadataService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an object on the metadata field of an object.
|
||||
* Sets an object on the metadata field of an object. An example could be:
|
||||
*
|
||||
* @example
|
||||
* const MetadataService = require('services/metadata');
|
||||
* const CommentModel = require('models/comment');
|
||||
*
|
||||
* // Sets the property `loaded` on the comment with `id=1`.
|
||||
* MetadataService.set(CommentModel, '1', 'loaded', true);
|
||||
*
|
||||
* @static
|
||||
* @param {mongoose.Model} model the mongoose model for the object
|
||||
@@ -60,6 +67,13 @@ class MetadataService {
|
||||
/**
|
||||
* Removes the value for the metadata field as the specific key.
|
||||
*
|
||||
* @example
|
||||
* const MetadataService = require('services/metadata');
|
||||
* const CommentModel = require('models/comment');
|
||||
*
|
||||
* // Removes the property `loaded` on the comment with `id=1`.
|
||||
* MetadataService.unset(CommentModel, '1', 'loaded');
|
||||
*
|
||||
* @static
|
||||
* @param {mongoose.Model} model the mongoose model for the object
|
||||
* @param {String} id the value for the field `id` of the model
|
||||
|
||||
+47
-6
@@ -1,3 +1,4 @@
|
||||
const assert = require('assert');
|
||||
const bcrypt = require('bcrypt');
|
||||
const url = require('url');
|
||||
const jwt = require('jsonwebtoken');
|
||||
@@ -253,26 +254,28 @@ module.exports = class UsersService {
|
||||
* @param {Boolean} checkAgainstWordlist enables cheching against the wordlist
|
||||
* @return {Promise}
|
||||
*/
|
||||
static isValidUsername(username, checkAgainstWordlist = true) {
|
||||
static async isValidUsername(username, checkAgainstWordlist = true) {
|
||||
const onlyLettersNumbersUnderscore = /^[A-Za-z0-9_]+$/;
|
||||
|
||||
if (!username) {
|
||||
return Promise.reject(errors.ErrMissingUsername);
|
||||
throw errors.ErrMissingUsername;
|
||||
}
|
||||
|
||||
if (!onlyLettersNumbersUnderscore.test(username)) {
|
||||
|
||||
return Promise.reject(errors.ErrSpecialChars);
|
||||
throw errors.ErrSpecialChars;
|
||||
}
|
||||
|
||||
if (checkAgainstWordlist) {
|
||||
|
||||
// check for profanity
|
||||
console.log('Username profanity check disabled: ', Wordlist.usernameCheck(username));
|
||||
let err = await Wordlist.usernameCheck(username);
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// No errors found!
|
||||
return Promise.resolve(username);
|
||||
return username;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -834,4 +837,42 @@ module.exports = class UsersService {
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore another user
|
||||
* @param {String} userId the id of the user that is ignoring another users
|
||||
* @param {String[]} usersToIgnore Array of user IDs to ignore
|
||||
*/
|
||||
static ignoreUsers(userId, usersToIgnore) {
|
||||
assert(Array.isArray(usersToIgnore), 'usersToIgnore is an array');
|
||||
assert(usersToIgnore.every(u => typeof u === 'string'), 'usersToIgnore is an array of string user IDs');
|
||||
if (usersToIgnore.includes(userId)) {
|
||||
throw new Error('Users cannot ignore themselves');
|
||||
}
|
||||
|
||||
// TODO: For each usersToIgnore, make sure they exist?
|
||||
return UserModel.update({id: userId}, {
|
||||
$addToSet: {
|
||||
ignoresUsers: {
|
||||
$each: usersToIgnore
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ignoring other users
|
||||
* @param {String} userId the id of the user that is ignoring another users
|
||||
* @param {String[]} usersToStopIgnoring Array of user IDs to stop ignoring
|
||||
*/
|
||||
static async stopIgnoringUsers(userId, usersToStopIgnoring) {
|
||||
assert(Array.isArray(usersToStopIgnoring), 'usersToStopIgnoring is an array');
|
||||
assert(usersToStopIgnoring.every(u => typeof u === 'string'), 'usersToStopIgnoring is an array of string user IDs');
|
||||
await UserModel.update({id: userId}, {
|
||||
$pullAll: {
|
||||
ignoresUsers: usersToStopIgnoring
|
||||
}
|
||||
});
|
||||
console.log('Mongo wrote stopIgnoringUsers', usersToStopIgnoring);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
const expect = require('chai').expect;
|
||||
const {graphql} = require('graphql');
|
||||
|
||||
const schema = require('../../../graph/schema');
|
||||
const Context = require('../../../graph/context');
|
||||
const UsersService = require('../../../services/users');
|
||||
const SettingsService = require('../../../services/settings');
|
||||
|
||||
const ignoreUserMutation = `
|
||||
mutation ignoreUser ($id: ID!) {
|
||||
ignoreUser(id:$id) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const getMyIgnoredUsersQuery = `
|
||||
query myIgnoredUsers {
|
||||
myIgnoredUsers {
|
||||
id,
|
||||
username
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
describe('graph.mutations.ignoreUser', () => {
|
||||
beforeEach(async () => {
|
||||
await SettingsService.init();
|
||||
});
|
||||
|
||||
// @TODO (bengo) - test a user can't ignore themselves
|
||||
it('users can ignoreUser', async () => {
|
||||
const user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
|
||||
const userToIgnore = await UsersService.createLocalUser('usernameB@example.com', 'password', 'usernameB');
|
||||
const context = new Context({user});
|
||||
const ignoreUserResponse = await graphql(schema, ignoreUserMutation, {}, context, {id: userToIgnore.id});
|
||||
if (ignoreUserResponse.errors && ignoreUserResponse.errors.length) {
|
||||
console.error(ignoreUserResponse.errors);
|
||||
}
|
||||
expect(ignoreUserResponse.errors).to.be.empty;
|
||||
|
||||
// now check my ignored users
|
||||
const myIgnoredUsersResponse = await graphql(schema, getMyIgnoredUsersQuery, {}, context, {});
|
||||
if (myIgnoredUsersResponse.errors && myIgnoredUsersResponse.errors.length) {
|
||||
console.error(myIgnoredUsersResponse.errors);
|
||||
}
|
||||
expect(myIgnoredUsersResponse.errors).to.be.empty;
|
||||
const myIgnoredUsers = myIgnoredUsersResponse.data.myIgnoredUsers;
|
||||
expect(myIgnoredUsers.length).to.equal(1);
|
||||
expect(myIgnoredUsers[0].id).to.equal(userToIgnore.id);
|
||||
expect(myIgnoredUsers[0].username).to.equal(userToIgnore.username);
|
||||
});
|
||||
|
||||
it('users cannot ignore themselves', async () => {
|
||||
const user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
|
||||
const context = new Context({user});
|
||||
const ignoreUserResponse = await graphql(schema, ignoreUserMutation, {}, context, {id: user.id});
|
||||
expect(ignoreUserResponse.errors).to.not.be.empty;
|
||||
|
||||
// now check my ignored users
|
||||
const myIgnoredUsersResponse = await graphql(schema, getMyIgnoredUsersQuery, {}, context, {});
|
||||
if (myIgnoredUsersResponse.errors && myIgnoredUsersResponse.errors.length) {
|
||||
console.error(myIgnoredUsersResponse.errors);
|
||||
}
|
||||
expect(myIgnoredUsersResponse.errors).to.be.empty;
|
||||
const myIgnoredUsers = myIgnoredUsersResponse.data.myIgnoredUsers;
|
||||
expect(myIgnoredUsers.length).to.equal(0);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('graph.mutations.stopIgnoringUser', () => {
|
||||
beforeEach(async () => {
|
||||
await SettingsService.init();
|
||||
});
|
||||
|
||||
it('users can stop ignoring another user they ignore', async () => {
|
||||
|
||||
// We're going to ignore 2 users,
|
||||
// then stopIgnoring 1 of them
|
||||
// then assert myIgnoredUsers only lists the one remaining
|
||||
const user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
|
||||
const usersToIgnore = await Promise.all([
|
||||
UsersService.createLocalUser('usernameB@example.com', 'password', 'usernameB'),
|
||||
UsersService.createLocalUser('usernameC@example.com', 'password', 'usernameC'),
|
||||
]);
|
||||
const context = new Context({user});
|
||||
|
||||
// ignore two users
|
||||
const ignoreUserResponses = await Promise.all(usersToIgnore.map(u => graphql(schema, ignoreUserMutation, {}, context, {id: u.id})));
|
||||
ignoreUserResponses.forEach(response => {
|
||||
if (response.errors && response.errors.length) {
|
||||
console.error(response.errors);
|
||||
}
|
||||
expect(response.errors).to.be.empty;
|
||||
});
|
||||
|
||||
const stopIgnoringUserMutation = `
|
||||
mutation stopIgnoringUser ($id: ID!) {
|
||||
stopIgnoringUser(id:$id) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// stop ignoring one user
|
||||
const stopIgnoringUserResponse = await graphql(schema, stopIgnoringUserMutation, {}, context, {id: usersToIgnore[0].id});
|
||||
if (stopIgnoringUserResponse.errors && stopIgnoringUserResponse.errors.length) {
|
||||
console.error(stopIgnoringUserResponse.errors);
|
||||
}
|
||||
expect(stopIgnoringUserResponse.errors).to.be.empty;
|
||||
|
||||
// now check my ignored users
|
||||
const myIgnoredUsersResponse = await graphql(schema, getMyIgnoredUsersQuery, {}, context, {});
|
||||
if (myIgnoredUsersResponse.errors && myIgnoredUsersResponse.errors.length) {
|
||||
console.error(myIgnoredUsersResponse.errors);
|
||||
}
|
||||
expect(myIgnoredUsersResponse.errors).to.be.empty;
|
||||
const myIgnoredUsers = myIgnoredUsersResponse.data.myIgnoredUsers;
|
||||
expect(myIgnoredUsers.length).to.equal(1);
|
||||
expect(myIgnoredUsers[0].id).to.equal(usersToIgnore[1].id);
|
||||
expect(myIgnoredUsers[0].username).to.equal(usersToIgnore[1].username);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
const expect = require('chai').expect;
|
||||
const {graphql} = require('graphql');
|
||||
|
||||
const schema = require('../../../graph/schema');
|
||||
const Context = require('../../../graph/context');
|
||||
const UsersService = require('../../../services/users');
|
||||
const SettingsService = require('../../../services/settings');
|
||||
const Asset = require('../../../models/asset');
|
||||
const CommentsService = require('../../../services/comments');
|
||||
|
||||
describe('graph.queries.asset', () => {
|
||||
beforeEach(async () => {
|
||||
await SettingsService.init();
|
||||
});
|
||||
|
||||
it('can get comments edge', async () => {
|
||||
const assetId = 'fakeAssetId';
|
||||
const assetUrl = 'https://bengo.is';
|
||||
await Asset.create({id: assetId, url: assetUrl});
|
||||
|
||||
const user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
|
||||
const context = new Context({user});
|
||||
|
||||
await CommentsService.publicCreate([1, 2].map(() => ({
|
||||
author_id: user.id,
|
||||
asset_id: assetId,
|
||||
body: `hello there! ${ String(Math.random()).slice(2)}`,
|
||||
})));
|
||||
|
||||
const assetCommentsQuery = `
|
||||
query assetCommentsQuery($assetId: ID!, $assetUrl: String!) {
|
||||
asset(id: $assetId, url: $assetUrl) {
|
||||
comments(limit: 10) {
|
||||
id,
|
||||
body,
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const assetCommentsResponse = await graphql(schema, assetCommentsQuery, {}, context, {assetId, assetUrl});
|
||||
const comments = assetCommentsResponse.data.asset.comments;
|
||||
expect(comments.length).to.equal(2);
|
||||
});
|
||||
|
||||
it('can query comments edge to exclude comments ignored by user', async () => {
|
||||
const assetId = 'fakeAssetId1';
|
||||
const assetUrl = 'https://bengo.is/1';
|
||||
await Asset.create({id: assetId, url: assetUrl});
|
||||
|
||||
const userA = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
|
||||
const userB = await UsersService.createLocalUser('usernameB@example.com', 'password', 'usernameB');
|
||||
const userC = await UsersService.createLocalUser('usernameC@example.com', 'password', 'usernameC');
|
||||
const context = new Context({user: userA});
|
||||
|
||||
// create 2 comments each for userB, userC
|
||||
await Promise.all([userB, userC].map(user => CommentsService.publicCreate([1, 2].map(() => ({
|
||||
author_id: user.id,
|
||||
asset_id: assetId,
|
||||
body: `hello there! ${ String(Math.random()).slice(2)}`,
|
||||
})))));
|
||||
|
||||
// ignore userB
|
||||
const ignoreUserMutation = `
|
||||
mutation ignoreUser ($id: ID!) {
|
||||
ignoreUser(id:$id) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const ignoreUserResponse = await graphql(schema, ignoreUserMutation, {}, context, {id: userB.id});
|
||||
if (ignoreUserResponse.errors && ignoreUserResponse.errors.length) {
|
||||
console.error(ignoreUserResponse.errors);
|
||||
}
|
||||
expect(ignoreUserResponse.errors).to.be.empty;
|
||||
|
||||
const assetCommentsWithoutIgnoredQuery = `
|
||||
query assetCommentsQuery($assetId: ID!, $assetUrl: String!, $excludeIgnored: Boolean!) {
|
||||
asset(id: $assetId, url: $assetUrl) {
|
||||
comments(limit: 10, excludeIgnored: $excludeIgnored) {
|
||||
id,
|
||||
body,
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const assetCommentsResponse = await graphql(schema, assetCommentsWithoutIgnoredQuery, {}, context, {assetId, assetUrl, excludeIgnored: true});
|
||||
const comments = assetCommentsResponse.data.asset.comments;
|
||||
expect(comments.length).to.equal(2);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -169,6 +169,24 @@ describe('services.UsersService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ignoreUser', () => {
|
||||
|
||||
// @TODO: assert cannot ignore yourself
|
||||
|
||||
it('should add user id to ignoredUsers set', async () => {
|
||||
const user = mockUsers[0];
|
||||
const usersToIgnore = [mockUsers[1], mockUsers[2]];
|
||||
await UsersService.ignoreUsers(user.id, usersToIgnore.map(u => u.id));
|
||||
const userAfterIgnoring = await UsersService.findById(user.id);
|
||||
expect(userAfterIgnoring.ignoresUsers.length).to.equal(2);
|
||||
|
||||
// ignore same user another time, make sure it's not added to the list.
|
||||
await UsersService.ignoreUsers(user.id, usersToIgnore.slice(0, 1).map(u => u.id));
|
||||
const userAfterIgnoring2 = await UsersService.findById(user.id);
|
||||
expect(userAfterIgnoring2.ignoresUsers.length).to.equal(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ban', () => {
|
||||
it('should set the status to banned', () => {
|
||||
return UsersService
|
||||
|
||||
Reference in New Issue
Block a user