diff --git a/.gitignore b/.gitignore
index 8982613ac..f4014b561 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,4 @@ plugins.json
plugins/*
!plugins/coral-plugin-facebook-auth
!plugins/coral-plugin-respect
+**/node_modules/*
diff --git a/Dockerfile b/Dockerfile
index 5bc38013a..90c08057f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:7
+FROM node:7.9
# Create app directory
RUN mkdir -p /usr/src/app
diff --git a/INSTALL.md b/INSTALL.md
index 33b634de8..18244f635 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -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._
diff --git a/PLUGINS.md b/PLUGINS.md
index 0c5f727d9..0d17ccd55 100644
--- a/PLUGINS.md
+++ b/PLUGINS.md
@@ -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.
diff --git a/bin/cli-plugins b/bin/cli-plugins
index 94ce74904..b1dee381b 100755
--- a/bin/cli-plugins
+++ b/bin/cli-plugins
@@ -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}`)
];
diff --git a/circle.yml b/circle.yml
index 013ef28b8..e7427094b 100644
--- a/circle.yml
+++ b/circle.yml
@@ -1,6 +1,6 @@
machine:
node:
- version: 7
+ version: 7.9
services:
- docker
- redis
diff --git a/client/coral-admin/src/containers/Dashboard/FlagWidget.js b/client/coral-admin/src/containers/Dashboard/FlagWidget.js
index 17be2acbd..92de6c4d2 100644
--- a/client/coral-admin/src/containers/Dashboard/FlagWidget.js
+++ b/client/coral-admin/src/containers/Dashboard/FlagWidget.js
@@ -10,7 +10,7 @@ const FlagWidget = ({assets}) => {
return (
-
Articles with the most flags
+
{lang.t('dashboard.most_flags')}
{lang.t('streams.article')}
{lang.t('dashboard.flags')}
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js b/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js
index 8d9f6b7e0..e823be9fe 100644
--- a/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js
+++ b/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js
@@ -14,13 +14,13 @@ const ModerationHeader = props => (
{props.asset.title}
-
Select Stream
+
Select Stream
:
}
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js b/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js
index ffa1adfcd..90610a577 100644
--- a/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js
+++ b/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js
@@ -6,7 +6,7 @@ const NotFound = props => (
The provided asset id {props.assetId} does not exist.
- Go to Streams
+ Go to Streams
);
diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json
index 1021978f1..911411523 100644
--- a/client/coral-admin/src/translations.json
+++ b/client/coral-admin/src/translations.json
@@ -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?",
diff --git a/client/coral-configure/components/CloseCommentsInfo.js b/client/coral-configure/components/CloseCommentsInfo.js
index 627328f9b..fae7bc5e5 100644
--- a/client/coral-configure/components/CloseCommentsInfo.js
+++ b/client/coral-configure/components/CloseCommentsInfo.js
@@ -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' ? (
- 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')}
-
Close Stream
+
{lang.t('configure.close-stream')}
) : (
- This comment stream is currently closed. By opening this comment stream,
- new comments may be submitted and displayed
+ {lang.t('configure.close-stream-configuration')}
-
Open Stream
+
{lang.t('configure.open-stream')}
)
);
diff --git a/client/coral-configure/components/ConfigureCommentStream.css b/client/coral-configure/components/ConfigureCommentStream.css
index ea20e4c05..335c94377 100644
--- a/client/coral-configure/components/ConfigureCommentStream.css
+++ b/client/coral-configure/components/ConfigureCommentStream.css
@@ -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;
}
diff --git a/client/coral-configure/containers/ConfigureStreamContainer.js b/client/coral-configure/containers/ConfigureStreamContainer.js
index a621f0cbd..0d9d024c6 100644
--- a/client/coral-configure/containers/ConfigureStreamContainer.js
+++ b/client/coral-configure/containers/ConfigureStreamContainer.js
@@ -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 (
@@ -95,11 +103,11 @@ class ConfigureStreamContainer extends Component {
questionBoxContent={settings.questionBoxContent}
/>
-
{status === 'open' ? 'Close' : 'Open'} Comment Stream
- {status === 'open' ?
The comment stream will close in {this.getClosedIn()}.
: ''}
+
{closedAt === 'open' ? 'Close' : 'Open'} Comment Stream
+ {(closedAt === 'open' && closedTimeout) ?
The comment stream will close in {this.getClosedIn()}.
: ''}
);
diff --git a/client/coral-embed-stream/src/Comment.css b/client/coral-embed-stream/src/Comment.css
index 4383cbc5b..8d7c7ebf9 100644
--- a/client/coral-embed-stream/src/Comment.css
+++ b/client/coral-embed-stream/src/Comment.css
@@ -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;
+}
diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/Comment.js
index 45f2ef084..4b01e19a7 100644
--- a/client/coral-embed-stream/src/Comment.js
+++ b/client/coral-embed-stream/src/Comment.js
@@ -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 {
+ { (currentUser && (comment.user.id !== currentUser.id))
+ ?
+
+
+ : null
+ }
+
@@ -225,27 +245,29 @@ class Comment extends React.Component {
{
comment.replies &&
comment.replies.map(reply => {
- return ;
+ return commentIsIgnored(reply)
+ ?
+ : ;
})
}
{
diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js
index d9b5c8310..b67e9e1c4 100644
--- a/client/coral-embed-stream/src/Embed.js
+++ b/client/coral-embed-stream/src/Embed.js
@@ -1,4 +1,4 @@
-import React, {Component} from 'react';
+import React from 'react';
import {compose} from 'react-apollo';
import {connect} from 'react-redux';
import isEqual from 'lodash/isEqual';
@@ -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';
@@ -36,15 +36,22 @@ import HighlightedComment from './Comment';
import LoadMore from './LoadMore';
import NewCount from './NewCount';
-class Embed extends Component {
+class Embed extends React.Component {
- state = {activeTab: 0, showSignInDialog: false, activeReplyBox: ''};
+ constructor(props) {
+ super(props);
+ this.state = {
+ activeTab: 0,
+ showSignInDialog: false,
+ activeReplyBox: ''
+ };
+ }
changeTab = (tab) => {
- const {isAdmin} = this.props.auth;
// Everytime the comes from another tab, the Stream needs to be updated.
- if (tab === 0 && isAdmin) {
+ if (tab === 0) {
+ this.props.viewAllComments();
this.props.data.refetch();
}
@@ -64,6 +71,9 @@ class Embed extends 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 () {
@@ -79,10 +89,12 @@ class Embed extends Component {
if(!isEqual(nextProps.data.asset, this.props.data.asset)) {
loadAsset(nextProps.data.asset);
- const {getCounts, updateCountCache} = this.props;
+ const {getCounts, updateCountCache, asset: {countCache}} = this.props;
const {asset} = nextProps.data;
- updateCountCache(asset.id, asset.commentCount);
+ if (!countCache) {
+ updateCountCache(asset.id, asset.commentCount);
+ }
this.setState({
countPoll: setInterval(() => {
@@ -127,6 +139,12 @@ class Embed extends Component {
const banned = user && user.status === 'BANNED';
+ const hasOlderComments = !!(
+ asset &&
+ asset.lastComment &&
+ asset.lastComment.id !== asset.comments[asset.comments.length - 1].id
+ );
+
const expandForLogin = showSignInDialog ? {
minHeight: document.body.scrollHeight + 200
} : {};
@@ -140,12 +158,14 @@ class Embed extends Component {
? asset.comments[0].created_at
: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString();
+ const userBox = this.props.logout().then(refetch)} changeTab={this.changeTab}/>;
+
return (
- {lang.t('MY_COMMENTS')}
+ {lang.t('myProfile')}
Configure Stream
{
@@ -158,8 +178,8 @@ class Embed extends Component {
this.props.data.refetch();
}}>{lang.t('showAllComments')}
}
- {loggedIn && this.props.logout().then(refetch)} changeTab={this.changeTab}/>}
+ { loggedIn ? userBox : null }
{
openStream
?
asset.comments.length}
+ moreComments={hasOlderComments}
loadMore={this.props.loadMore} />
}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ { loggedIn ? userBox : null }
+
+
+
);
@@ -288,7 +311,7 @@ class Embed extends Component {
const mapStateToProps = state => ({
auth: state.auth.toJS(),
userData: state.user.toJS(),
- asset: state.asset.toJS()
+ asset: state.asset.toJS(),
});
const mapDispatchToProps = dispatch => ({
@@ -301,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(
@@ -312,6 +335,7 @@ export default compose(
postDontAgree,
addCommentTag,
removeCommentTag,
+ ignoreUser,
deleteAction,
queryStream,
)(Embed);
diff --git a/client/coral-embed-stream/src/IgnoreUserWizard.css b/client/coral-embed-stream/src/IgnoreUserWizard.css
new file mode 100644
index 000000000..838f2f76a
--- /dev/null
+++ b/client/coral-embed-stream/src/IgnoreUserWizard.css
@@ -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;
+}
diff --git a/client/coral-embed-stream/src/IgnoreUserWizard.js b/client/coral-embed-stream/src/IgnoreUserWizard.js
new file mode 100644
index 000000000..371e6d48b
--- /dev/null
+++ b/client/coral-embed-stream/src/IgnoreUserWizard.js
@@ -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 = (
+
+
+
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.
+
+ Cancel
+ goToStep(2)}>Ignore user
+
+
+ );
+ const onClickIgnoreUser = async () => {
+ await ignoreUser({id: user.id});
+ };
+ const step2Confirmation = (
+
+
+
Are you sure you want to ignore { user.name }?
+
+ Cancel
+ Ignore user
+
+
+ );
+ const elsForStep = [step1, step2Confirmation];
+ const {step} = this.state;
+ const elForThisStep = elsForStep[step - 1];
+ return (
+
+ { elForThisStep }
+
+ );
+ }
+}
diff --git a/client/coral-embed-stream/src/IgnoredCommentTombstone.js b/client/coral-embed-stream/src/IgnoredCommentTombstone.js
new file mode 100644
index 000000000..0ed746976
--- /dev/null
+++ b/client/coral-embed-stream/src/IgnoredCommentTombstone.js
@@ -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 = () => (
+
+
+
+ {lang.t('commentIsIgnored')}
+
+
+);
+
+export default IgnoredCommentTombstone;
diff --git a/client/coral-embed-stream/src/NewCount.js b/client/coral-embed-stream/src/NewCount.js
index 2368c1ab8..8d7333aab 100644
--- a/client/coral-embed-stream/src/NewCount.js
+++ b/client/coral-embed-stream/src/NewCount.js
@@ -17,10 +17,10 @@ const onLoadMoreClick = ({loadMore, commentCount, firstCommentDate, assetId, upd
const NewCount = (props) => {
const newComments = props.commentCount - props.countCache;
- return
+ return
{
props.countCache && newComments > 0 ?
-
+
{newComments === 1
? lang.t('newCount', newComments, lang.t('comment'))
: lang.t('newCount', newComments, lang.t('comments'))}
diff --git a/client/coral-embed-stream/src/Stream.js b/client/coral-embed-stream/src/Stream.js
index 66c19e0ab..f91e496e8 100644
--- a/client/coral-embed-stream/src/Stream.js
+++ b/client/coral-embed-stream/src/Stream.js
@@ -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 (
{
comments.map(comment =>
-
+ commentIsIgnored(comment)
+ ?
+ :
)
}
diff --git a/client/coral-embed-stream/src/TopRightMenu.css b/client/coral-embed-stream/src/TopRightMenu.css
new file mode 100644
index 000000000..5cddae125
--- /dev/null
+++ b/client/coral-embed-stream/src/TopRightMenu.css
@@ -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;*/
+}
diff --git a/client/coral-embed-stream/src/TopRightMenu.js b/client/coral-embed-stream/src/TopRightMenu.js
new file mode 100644
index 000000000..e6b264c27
--- /dev/null
+++ b/client/coral-embed-stream/src/TopRightMenu.js
@@ -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 (
+
+
+
+
+
+ );
+ }
+}
+
+const upArrow = ;
+const downArrow = ;
+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 } */
+
+ {isOpen ? upArrow : downArrow}
+ {isOpen ? children : null}
+
+ );
+ }
+}
+
diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css
index 41ec7a64b..51028949b 100644
--- a/client/coral-embed-stream/style/default.css
+++ b/client/coral-embed-stream/style/default.css
@@ -1,8 +1,6 @@
* {
- font-weight: inherit;
font-family: inherit;
font-style: inherit;
- font-size: 100%;
}
html, body {
diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js
index 3d1d61328..0e5cf86c2 100644
--- a/client/coral-framework/components/Slot.js
+++ b/client/coral-framework/components/Slot.js
@@ -5,9 +5,9 @@ class Slot extends Component {
render() {
const {fill, ...rest} = this.props;
return (
-
+
{getSlotElements(fill, rest)}
-
+
);
}
}
diff --git a/client/coral-framework/constants/user.js b/client/coral-framework/constants/user.js
index 5f040db5b..1557a42c9 100644
--- a/client/coral-framework/constants/user.js
+++ b/client/coral-framework/constants/user.js
@@ -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';
diff --git a/client/coral-framework/graphql/mutations/ignoreUser.graphql b/client/coral-framework/graphql/mutations/ignoreUser.graphql
new file mode 100644
index 000000000..ad3c399f3
--- /dev/null
+++ b/client/coral-framework/graphql/mutations/ignoreUser.graphql
@@ -0,0 +1,7 @@
+mutation ignoreUser ($id: ID!) {
+ ignoreUser(id:$id) {
+ errors {
+ translation_key
+ }
+ }
+}
diff --git a/client/coral-framework/graphql/mutations/index.js b/client/coral-framework/graphql/mutations/index.js
index 04dba8402..da1603f9e 100644
--- a/client/coral-framework/graphql/mutations/index.js
+++ b/client/coral-framework/graphql/mutations/index.js
@@ -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),
+ }
+ ]
+ });
+ }
+ };
+ }
+});
diff --git a/client/coral-framework/graphql/mutations/stopIgnoringUser.graphql b/client/coral-framework/graphql/mutations/stopIgnoringUser.graphql
new file mode 100644
index 000000000..042452ff5
--- /dev/null
+++ b/client/coral-framework/graphql/mutations/stopIgnoringUser.graphql
@@ -0,0 +1,7 @@
+mutation stopIgnoringUser ($id: ID!) {
+ stopIgnoringUser(id:$id) {
+ errors {
+ translation_key
+ }
+ }
+}
diff --git a/client/coral-framework/graphql/queries/index.js b/client/coral-framework/graphql/queries/index.js
index 32bdfb6c9..5dbea5822 100644
--- a/client/coral-framework/graphql/queries/index.js
+++ b/client/coral-framework/graphql/queries/index.js
@@ -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
+ });
+ }
+});
diff --git a/client/coral-framework/graphql/queries/loadMore.graphql b/client/coral-framework/graphql/queries/loadMore.graphql
index 10b3c2e34..b18f4d84e 100644
--- a/client/coral-framework/graphql/queries/loadMore.graphql
+++ b/client/coral-framework/graphql/queries/loadMore.graphql
@@ -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
}
diff --git a/client/coral-framework/graphql/queries/myIgnoredUsers.graphql b/client/coral-framework/graphql/queries/myIgnoredUsers.graphql
new file mode 100644
index 000000000..d81531e37
--- /dev/null
+++ b/client/coral-framework/graphql/queries/myIgnoredUsers.graphql
@@ -0,0 +1,6 @@
+query myIgnoredUsers {
+ myIgnoredUsers {
+ id,
+ username,
+ }
+}
diff --git a/client/coral-framework/graphql/queries/streamQuery.graphql b/client/coral-framework/graphql/queries/streamQuery.graphql
index 102c656e5..9f27ce235 100644
--- a/client/coral-framework/graphql/queries/streamQuery.graphql
+++ b/client/coral-framework/graphql/queries/streamQuery.graphql
@@ -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,12 +36,15 @@ query AssetQuery($asset_id: ID, $asset_url: String, $comment_id: ID!, $has_comme
charCount
requireEmailConfirmation
}
- commentCount
- totalCommentCount
- comments(limit: 10) {
+ lastComment {
+ id
+ }
+ 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
}
}
diff --git a/client/coral-framework/reducers/user.js b/client/coral-framework/reducers/user.js
index b8ff54380..efa967cf0 100644
--- a/client/coral-framework/reducers/user.js
+++ b/client/coral-framework/reducers/user.js
@@ -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;
}
diff --git a/client/coral-framework/translations.json b/client/coral-framework/translations.json
index 31c3e98ea..88ead57af 100644
--- a/client/coral-framework/translations.json
+++ b/client/coral-framework/translations.json
@@ -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.",
diff --git a/client/coral-plugin-history/Comment.css b/client/coral-plugin-history/Comment.css
index 2c0dee094..bc2134639 100644
--- a/client/coral-plugin-history/Comment.css
+++ b/client/coral-plugin-history/Comment.css
@@ -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;
}
diff --git a/client/coral-settings/components/IgnoredUsers.css b/client/coral-settings/components/IgnoredUsers.css
new file mode 100644
index 000000000..716140256
--- /dev/null
+++ b/client/coral-settings/components/IgnoredUsers.css
@@ -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';
+}
\ No newline at end of file
diff --git a/client/coral-settings/components/IgnoredUsers.js b/client/coral-settings/components/IgnoredUsers.js
new file mode 100644
index 000000000..4bbf6cc50
--- /dev/null
+++ b/client/coral-settings/components/IgnoredUsers.js
@@ -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 (
+
+ );
+ }
+}
+
+export default IgnoredUsers;
diff --git a/client/coral-settings/components/ProfileHeader.css b/client/coral-settings/components/ProfileHeader.css
deleted file mode 100644
index f97c4731e..000000000
--- a/client/coral-settings/components/ProfileHeader.css
+++ /dev/null
@@ -1,8 +0,0 @@
-.header h1 {
- margin: 4px 0;
-}
-
-.header h2 {
- font-size: 13px;
-}
-
diff --git a/client/coral-settings/components/ProfileHeader.js b/client/coral-settings/components/ProfileHeader.js
deleted file mode 100644
index 24b2222bd..000000000
--- a/client/coral-settings/components/ProfileHeader.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import React, {PropTypes} from 'react';
-import styles from './ProfileHeader.css';
-
-const ProfileHeader = ({username}) => (
-
-
{username}
-
-);
-
-ProfileHeader.propTypes = {username: PropTypes.string.isRequired};
-
-export default ProfileHeader;
diff --git a/client/coral-settings/containers/ProfileContainer.js b/client/coral-settings/containers/ProfileContainer.js
index 331069058..2d756077d 100644
--- a/client/coral-settings/containers/ProfileContainer.js
+++ b/client/coral-settings/containers/ProfileContainer.js
@@ -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 ;
}
+ const localProfile = this.props.user.profiles.find(p => p.provider === 'local');
+ const emailAddress = localProfile && localProfile.id;
+
return (
- {
+
{this.props.userData.username}
+ { emailAddress
+ ?
{ emailAddress }
+ : null
+ }
- // Hiding bio until moderation can get figured out
- /*
- {lang.t('allComments')} ({user.myComments.length})
- {lang.t('profileSettings')}
-
-
*/
+ {
+ myIgnoredUsersData.myIgnoredUsers && myIgnoredUsersData.myIgnoredUsers.length
+ ? (
+
+
Ignored users
+
+
+ )
+ : null
+ }
+
+
+
+ My comments
+ {
me.comments.length ?
:
{lang.t('userNoComment')}
-
- // Hiding user bio pending effective moderation system.
- /*
-
-
- */
}
@@ -85,5 +100,7 @@ const mapDispatchToProps = () => ({
export default compose(
connect(mapStateToProps, mapDispatchToProps),
- myCommentHistory
+ myCommentHistory,
+ myIgnoredUsers,
+ stopIgnoringUser,
)(ProfileContainer);
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 76c28d20f..000000000
--- a/docker-compose.yml
+++ /dev/null
@@ -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
diff --git a/graph/hooks.js b/graph/hooks.js
index 499eb60b1..c5b7541b3 100644
--- a/graph/hooks.js
+++ b/graph/hooks.js
@@ -3,6 +3,7 @@ const {
GraphQLInterfaceType
} = require('graphql');
const debug = require('debug')('talk:graph:schema');
+const Joi = require('joi');
/**
* XXX taken from graphql-js: src/execution/execute.js, because that function
@@ -82,6 +83,8 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa
Object.keys(hooks).forEach((hook) => {
switch (hook) {
case 'pre':
+ Joi.assert(hooks.pre, Joi.func().maxArity(4));
+
debug(`adding pre hook to resolver ${typeName}.${fieldName} from plugin '${plugin.name}'`);
if (typeof hooks.pre !== 'function') {
@@ -91,6 +94,8 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa
acc.pre.push(hooks.pre);
break;
case 'post':
+ Joi.assert(hooks.pre, Joi.func().maxArity(5));
+
debug(`adding post hook to resolver ${typeName}.${fieldName} from plugin '${plugin.name}'`);
if (typeof hooks.post !== 'function') {
@@ -129,6 +134,9 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa
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;
diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js
index e2ae29f65..0faa124da 100644
--- a/graph/loaders/comments.js
+++ b/graph/loaders/comments.js
@@ -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} id The ID of the asset
+ * @param {Array} 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} id The ID of the asset
+ * @param {Array} 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} id The ID of the parent comment
+ * @param {Array} 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))
}
diff --git a/graph/mutators/user.js b/graph/mutators/user.js
index 48966c5e8..d68351701 100644
--- a/graph/mutators/user.js
+++ b/graph/mutators/user.js
@@ -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),
}
};
diff --git a/graph/resolvers/asset.js b/graph/resolvers/asset.js
index c2a0a2b66..c260d0070 100644
--- a/graph/resolvers/asset.js
+++ b/graph/resolvers/asset.js
@@ -1,27 +1,39 @@
const Asset = {
+ lastComment({id}, _, {loaders: {Comments}}) {
+ return Comments.getByQuery({
+ asset_id: id,
+ limit: 1,
+ parent_id: null
+ }).then(data => data[0]);
+ },
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}}) {
diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js
index 2752ad0e3..19ea11efe 100644
--- a/graph/resolvers/comment.js
+++ b/graph/resolvers/comment.js
@@ -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}}) {
diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js
index 3d5ea9ad9..38183bff6 100644
--- a/graph/resolvers/root_mutation.js
+++ b/graph/resolvers/root_mutation.js
@@ -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}));
},
diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js
index 32e2e4263..b26ecabca 100644
--- a/graph/resolvers/root_query.js
+++ b/graph/resolvers/root_query.js
@@ -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}}) {
diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql
index b881cc66e..5d86cbdb5 100644
--- a/graph/typeDefs.graphql
+++ b/graph/typeDefs.graphql
@@ -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]
@@ -413,17 +416,20 @@ type Asset {
# The URL that the asset is located on.
url: String
+ # Returns last comment
+ lastComment: Comment
+
# Returns recent comments
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.
@@ -537,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!]
}
@@ -703,6 +712,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 {
@@ -735,6 +756,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
}
################################################################################
diff --git a/models/asset.js b/models/asset.js
index f0046aef0..51ff42705 100644
--- a/models/asset.js
+++ b/models/asset.js
@@ -46,6 +46,12 @@ const AssetSchema = new Schema({
type: Schema.Types.Mixed,
default: null
},
+
+ // Additional metadata stored on the field.
+ metadata: {
+ default: {},
+ type: Object
+ }
}, {
versionKey: false,
timestamps: {
diff --git a/models/comment.js b/models/comment.js
index e6523e45f..0984a0832 100644
--- a/models/comment.js
+++ b/models/comment.js
@@ -75,7 +75,13 @@ const CommentSchema = new Schema({
default: 'NONE'
},
tags: [TagSchema],
- parent_id: String
+ parent_id: String,
+
+ // Additional metadata stored on the field.
+ metadata: {
+ default: {},
+ type: Object
+ }
}, {
timestamps: {
createdAt: 'created_at',
diff --git a/models/user.js b/models/user.js
index e1cbb466b..2663410a7 100644
--- a/models/user.js
+++ b/models/user.js
@@ -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.
diff --git a/package.json b/package.json
index 3471a5a9a..c81b76ab4 100644
--- a/package.json
+++ b/package.json
@@ -185,6 +185,6 @@
"webpack": "^2.3.1"
},
"engines": {
- "node": "^7.7.0"
+ "node": "^7.9.0"
}
}
diff --git a/services/metadata.js b/services/metadata.js
new file mode 100644
index 000000000..511429e24
--- /dev/null
+++ b/services/metadata.js
@@ -0,0 +1,94 @@
+/**
+ * The key must be composed of alpha characters with periods seperating them.
+ */
+const KEY_REGEX = /^(?:[A-Za-z][A-Za-z\.]*[A-Za-z])?(?:[A-Za-z]*)$/;
+
+/**
+ * Allows metadata properties to be set/unset from specific models. It is the
+ * expecatation of this API that the metadata field is either accessed later
+ * directly, or accessed as a result of another database load rather than
+ * this service providing an interface to do so.
+ *
+ * @class MetadataService
+ */
+class MetadataService {
+
+ /**
+ * Parses a key by ensuring that if it is either a string, or an array with
+ * only characters defined in the `KEY_REGEX`
+ *
+ * @static
+ * @param {String|Array} key
+ * @returns {String} string form of the key
+ *
+ * @memberOf Metadata
+ */
+ static parseKey(key) {
+ if (Array.isArray(key)) {
+ key = key.join('.');
+ }
+
+ if ((typeof key !== 'string') || !KEY_REGEX.test(key) || key.length === 0) {
+ throw new Error(`${key} is not valid, only a-zA-Z. allowed`);
+ }
+
+ return ['metadata', key].join('.');
+ }
+
+ /**
+ * 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
+ * @param {String} id the value for the field `id` of the model
+ * @param {String|Array} key key for the metadata field
+ * @param {any} value javascript object to set the value of the metadata to
+ * @returns {Promise} resolves when the update is complete
+ *
+ * @memberOf Metadata
+ */
+ static async set(model, id, key, value) {
+ key = MetadataService.parseKey(key);
+
+ return model.update({id}, {
+ $set: {
+ [key]: value
+ }
+ });
+ }
+
+ /**
+ * 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
+ * @param {String|Array} key key for the metadata field
+ * @returns
+ *
+ * @memberOf Metadata
+ */
+ static async unset(model, id, key) {
+ key = MetadataService.parseKey(key);
+
+ return model.update({id}, {
+ $unset: {[key]: ''}
+ });
+ }
+}
+
+module.exports = MetadataService;
diff --git a/services/users.js b/services/users.js
index a86a6686d..14301ccf4 100644
--- a/services/users.js
+++ b/services/users.js
@@ -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);
+ }
};
diff --git a/test/graph/mutations/ignoreUser.js b/test/graph/mutations/ignoreUser.js
new file mode 100644
index 000000000..c5633bfb4
--- /dev/null
+++ b/test/graph/mutations/ignoreUser.js
@@ -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);
+ });
+
+});
diff --git a/test/graph/queries/asset.js b/test/graph/queries/asset.js
new file mode 100644
index 000000000..ba31fe330
--- /dev/null
+++ b/test/graph/queries/asset.js
@@ -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);
+ });
+
+});
diff --git a/test/server/services/users.js b/test/server/services/users.js
index 22fe7ddd5..04c5c457e 100644
--- a/test/server/services/users.js
+++ b/test/server/services/users.js
@@ -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