diff --git a/.gitignore b/.gitignore index bd86e6906..e8dfd14e7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ plugins/* !plugins/coral-plugin-respect !plugins/coral-plugin-offtopic +**/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 8bf00229d..068a0886f 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/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')}

- +
) : (

- This comment stream is currently closed. By opening this comment stream, - new comments may be submitted and displayed + {lang.t('configure.close-stream-configuration')}

- +
) ); 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 19840db33..4a75a19cb 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 aa76390d0..b67e9e1c4 100644 --- a/client/coral-embed-stream/src/Embed.js +++ b/client/coral-embed-stream/src/Embed.js @@ -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 = this.props.logout().then(refetch)} changeTab={this.changeTab}/>; + return (
- - - {lang.t('MY_COMMENTS')} + + {lang.t('myProfile')} Configure Stream { @@ -174,8 +178,8 @@ class Embed extends React.Component { this.props.data.refetch(); }}>{lang.t('showAllComments')} } - {loggedIn && this.props.logout().then(refetch)} changeTab={this.changeTab}/>} + { loggedIn ? userBox : null } { openStream ?
@@ -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} />
} - - - - - - - - - + + + + + + + { loggedIn ? userBox : null } + + +
); @@ -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); 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 = ( +
+
Ignore User
+

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.

+
+ + +
+
+ ); + const onClickIgnoreUser = async () => { + await ignoreUser({id: user.id}); + }; + const step2Confirmation = ( +
+
Ignore User
+

Are you sure you want to ignore { user.name }?

+
+ + +
+
+ ); + 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/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/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 9eb3d1a21..4a3abb48e 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 94dc3d32e..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,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 } } 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 ( +
+ { + users.length + ?

Because you ignored these, you do not see their comments.

+ : null + } +
+ { + users.map(({username, id}) => ( + +
{ username }
+
+ stopIgnoring({id})} + className={styles.link}>Stop ignoring +
+
+ )) + } +
+
+ ); + } +} + +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/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 877b5e7aa..c260d0070 100644 --- a/graph/resolvers/asset.js +++ b/graph/resolvers/asset.js @@ -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}}) { 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 641d566cb..661cc9f96 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 b672419d9..0a067b49c 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] @@ -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 } ################################################################################ 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 41d8732de..1b09c78c8 100644 --- a/package.json +++ b/package.json @@ -182,6 +182,6 @@ "webpack": "^2.3.1" }, "engines": { - "node": "^7.7.0" + "node": "^7.9.0" } } diff --git a/services/metadata.js b/services/metadata.js index 2cabed8b8..511429e24 100644 --- a/services/metadata.js +++ b/services/metadata.js @@ -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 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