diff --git a/.gitignore b/.gitignore index f4014b561..e8dfd14e7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,6 @@ plugins.json plugins/* !plugins/coral-plugin-facebook-auth !plugins/coral-plugin-respect +!plugins/coral-plugin-offtopic + **/node_modules/* diff --git a/Dockerfile b/Dockerfile index 5bc38013a..73885d628 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:7 +FROM node:7.8 # Create app directory RUN mkdir -p /usr/src/app diff --git a/Dockerfile.onbuild b/Dockerfile.onbuild index 34c321448..06d006b42 100644 --- a/Dockerfile.onbuild +++ b/Dockerfile.onbuild @@ -3,12 +3,12 @@ FROM coralproject/talk:latest # Bundle app source ONBUILD COPY . /usr/src/app -# At this stage, we need to install the development dependancies again because +# At this stage, we need to install the development dependancies again because # we need to have webpack available. We then build the new dependancies and # clear out the development dependancies again. After this we of course need to # clear out the yarn cache, this saves quite a lot of size. ONBUILD RUN NODE_ENV=development yarn install --frozen-lockfile && \ NODE_ENV=production cli plugins reconcile && \ NODE_ENV=production yarn build && \ - NODE_ENV=production yarn install --production && \ + NODE_ENV=production yarn install --production --force && \ yarn cache clean \ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md index 33b634de8..49033acc8 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/) ~7.8 +- [Yarn](https://yarnpkg.com/) ^0.22.0 -_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_ +_Please be sure to check the versions of these requirements. Incorrect versions +of these may lead to unexpected errors!_ ### Installing +#### Download + +It is highly recommended that you download a released version as the code +available in `master` may not be stable. You can download the latest release +from the [releases page](https://github.com/coralproject/talk/releases). + +You can also clone the git repository via: + ```bash -# Download the tarball containing the repository -curl -L https://github.com/coralproject/talk/tarball/master -o coralproject-talk.tar.gz +git clone https://github.com/coralproject/talk.git +``` -# Untar that file and change to that directory -tar xpf coralproject-talk.tar.gz -mv coralproject-talk-* coralproject-talk -cd coralproject-talk +#### Building +We now have to install the dependencies and build the static assets. + +```bash # Install package dependancies yarn @@ -39,6 +206,17 @@ yarn yarn build ``` +After you create/modify the `plugins.json` (refer to `PLUGINS.md` for plugin +docs) file, you can re-run the following to install their dependencies: + +```bash +# Reconcile plugins +./bin/cli plugins reconcile + +# Build static files +yarn build +``` + ### Running Refer to the `README.md` file for required configuration variables to add to the @@ -50,45 +228,48 @@ You can start the server after configuring the server using the command: yarn start ``` +This will setup the server to serve everything on a single node.js process and +is designed to be used in production. + You can see other scripts we've made available by consulting the `package.json` file under the `scripts` key including: - `yarn test` run unit tests - `yarn e2e` run end to end tests - `yarn build-watch` watch for changes to client files and build static assets -- `yarn dev-start` watch for changes to server files and reload the server +- `yarn dev-start` watch for changes to server files and reload the server while + also sourcing a `.env` file in your local directory for configuration -## Installation From Docker Hub +# Setup -### Requirements +Once you've installed Talk (either via Docker or source), you still need to +setup the application. If you are unfamiliar with any terminoligy used in the +setup process, refer to the `TERMINOLOGY.md` document. -There are some runtime requirements for running Talk for Docker: +## Via Web -- [MongoDB](https://www.mongodb.com/) v3.2 or later -- [Redis](https://redis.io/) v3.2 or later -- [Docker](https://www.docker.com/) v1.13.0 or later -- [Docker Compose](https://docs.docker.com/compose/) v1.10.0 or later +If you want to perform your setup via the web, you can navigate to your +installation of Talk at the path `/admin/install`. There you will be asked a +series of questions for your installation. -_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_ +## Via CLI -### Installing +If you want to perform your setup through the terminal, you can simply run: ```bash -# Create a directory for talk -mkdir coralproject-talk -cd coralproject-talk - -# Download the docker-compose.yml file from the repository -curl -LO https://raw.githubusercontent.com/coralproject/talk/master/docker-compose.yml +cli setup ``` -At this stage, you should refer to the `README.md` file for required -configuration variables to add to the environment key for the `talk` service -listed in the `docker-compose.yml` file. +And follow the instructions to perform initial setup and create your first user +account. -### Running -```bash -# Start the services using compose -docker-compose up -d -``` +# Usage + +After setup is complete, you can then refer to the `/admin/configure` path to +get the embed code that you can copy/paste onto your blog or website in order to +start using Talk. + +_In order for the embed to work correctly, you will need to whitelist the domain +that is allowed to embed your site on the `/admin/configure` page, failure to do +so will result in the comment stream not loading._ 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/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/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index 61352b921..3487f9fde 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -116,7 +116,7 @@ class ModerationContainer extends Component { let asset; - if (data.loading) { + if (!('premodCount' in data)) { return
; } diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js index a2bbbf092..b18f239e4 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js @@ -10,6 +10,7 @@ import FlagBox from './FlagBox'; import CommentType from './CommentType'; import ActionButton from 'coral-admin/src/components/ActionButton'; import BanUserButton from 'coral-admin/src/components/BanUserButton'; +import {getActionSummary} from 'coral-framework/utils'; const linkify = new Linkify(); @@ -17,25 +18,27 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from 'coral-admin/src/translations.json'; const lang = new I18n(translations); -const Comment = ({actions = [], ...props}) => { - const links = linkify.getMatches(props.comment.body); +const Comment = ({actions = [], comment, ...props}) => { + const links = linkify.getMatches(comment.body); const linkText = links ? links.map(link => link.raw) : []; - const actionSummaries = props.comment.action_summaries; + const flagActionSummaries = getActionSummary('FlagActionSummary', comment); + const flagActions = comment.actions && comment.actions.filter(a => a.__typename === 'FlagAction'); + return (
  • - {props.comment.user.name} + {comment.user.name} - {timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} + {timeago().format(comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} - props.showBanUserDialog(props.comment.user, props.comment.id, props.comment.status !== 'REJECTED')} /> + props.showBanUserDialog(comment.user, comment.id, comment.status !== 'REJECTED')} />
    - {props.comment.user.status === 'banned' ? + {comment.user.status === 'banned' ? {lang.t('comment.banned_user')} @@ -43,16 +46,16 @@ const Comment = ({actions = [], ...props}) => { : null}
    - Story: {props.comment.asset.title} + Story: {comment.asset.title} {!props.currentAsset && ( - Moderate → + Moderate → )}

    + textToHighlight={comment.body} />

    {links ? Contains Link : null} @@ -60,16 +63,20 @@ const Comment = ({actions = [], ...props}) => { {actions.map((action, i) => props.acceptComment({commentId: props.comment.id})} - rejectComment={() => props.rejectComment({commentId: props.comment.id})} + user={comment.user} + acceptComment={() => props.acceptComment({commentId: comment.id})} + rejectComment={() => props.rejectComment({commentId: comment.id})} /> )}
    - {actionSummaries && } + { + flagActions && flagActions.length + ? + : null + }
  • ); }; @@ -83,6 +90,7 @@ Comment.propTypes = { comment: PropTypes.shape({ body: PropTypes.string.isRequired, action_summaries: PropTypes.array, + actions: PropTypes.array, created_at: PropTypes.string.isRequired, user: PropTypes.shape({ status: PropTypes.string diff --git a/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.css b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.css index fa7bb0cca..5b8ea4f91 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.css +++ b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.css @@ -53,3 +53,17 @@ font-size: 12px; } } + +.lessDetail { + display: inline-block; + margin-right: 10px; +} + +.subDetail { + font-weight: normal; + color: #888; + + span { + color: black; + } +} diff --git a/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js index 00b7d4273..62b4e554d 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js @@ -1,6 +1,17 @@ import React, {Component, PropTypes} from 'react'; import {Icon} from 'coral-ui'; import styles from './FlagBox.css'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from 'coral-admin/src/translations.json'; +const lang = new I18n(translations); + +const shortReasons = { + 'This comment is offensive': lang.t('modqueue.offensive'), + 'This looks like an ad/marketing': lang.t('modqueue.spam/ads'), + 'This user is impersonating': lang.t('modqueue.impersonating'), + 'I don\'t like this username': lang.t('modqueue.dont-like-username'), + 'Other': lang.t('modqueue.other') +}; class FlagBox extends Component { constructor () { @@ -16,27 +27,50 @@ class FlagBox extends Component { })); } + reasonMap = (reason) => { + const shortReason = shortReasons[reason]; + + // if the short reason isn't found, just return the long one. + return shortReason ? shortReason : reason; + } + render() { - const {props} = this; + const {actionSummaries, actions} = this.props; + const {showDetail} = this.state; + return (
    -

    Flags ({props.actionSummaries.length}):

    +

    Flags ({actionSummaries.length}):

      - {props.actionSummaries.map((action, i) => -
    • {!action.reason ? No reason provided : action.reason} ({action.count})
    • + {actionSummaries.map((action, i) => +
    • {this.reasonMap(action.reason)} ({action.count})
    • )}
    - {/* More detail*/} + {showDetail ? lang.t('modqueue.less-detail') : lang.t('modqueue.more-detail')}
    - {this.state.showDetail && (
    -
      - {props.actionSummaries.map((action, i) => -
    • {!action.reason ? No reason provided : action.reason} ({action.count})
    • - )} -
    -
    )} + {showDetail && ( +
    +
      + {actionSummaries.map((summary, i) => { + + const actionList = actions.filter(a => a.reason === summary.reason); + + return ( +
    • + {this.reasonMap(summary.reason)} ({summary.count}) +
        + { + actionList.map((action, j) =>
      • {action.user.username} {action.message}
      • ) + } +
      +
    • + ); + })} +
    +
    + )}
    ); @@ -44,7 +78,14 @@ class FlagBox extends Component { } FlagBox.propTypes = { - actionSummaries: PropTypes.array.isRequired + actionSummaries: PropTypes.arrayOf(PropTypes.shape({ + reason: PropTypes.string, + count: PropTypes.number + })).isRequired, + actions: PropTypes.arrayOf(PropTypes.shape({ + message: PropTypes.string, + user: PropTypes.shape({username: PropTypes.string}) + })).isRequired }; export default FlagBox; diff --git a/client/coral-admin/src/graphql/fragments/commentView.graphql b/client/coral-admin/src/graphql/fragments/commentView.graphql index e78c28a28..8c7310bdf 100644 --- a/client/coral-admin/src/graphql/fragments/commentView.graphql +++ b/client/coral-admin/src/graphql/fragments/commentView.graphql @@ -12,4 +12,13 @@ fragment commentView on Comment { id title } + actions { + ... on FlagAction { + reason + message + user { + username + } + } + } } diff --git a/client/coral-admin/src/graphql/queries/index.js b/client/coral-admin/src/graphql/queries/index.js index f0e8e5b70..7d03b5092 100644 --- a/client/coral-admin/src/graphql/queries/index.js +++ b/client/coral-admin/src/graphql/queries/index.js @@ -55,7 +55,7 @@ export const loadMore = (fetchMore) => ({limit, cursor, sort, tab, asset_id}) => statuses, asset_id }, - updateQuery: (oldData, {fetchMoreResult:{data:{comments}}}) => { + updateQuery: (oldData, {fetchMoreResult:{comments}}) => { return { ...oldData, [tab]: [ diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json index 911411523..f317b71a7 100644 --- a/client/coral-admin/src/translations.json +++ b/client/coral-admin/src/translations.json @@ -51,7 +51,14 @@ "singleview": "Toggle single comment edit view", "thismenu": "Open this menu", "emptyqueue": "No more comments to moderate! You're all caught up. Go have some ☕️", - "showshortcuts": "Show Shortcuts" + "showshortcuts": "Show Shortcuts", + "more-detail": "More detail", + "less-detail": "Less detail", + "dont-like-username": "Don't like username", + "impersonating": "Impersonating", + "offensive": "Offensive", + "spam/ads": "Spam/Ads", + "other": "Other" }, "comment": { "flagged": "flagged", @@ -221,7 +228,14 @@ "shortcuts": "Atajos de teclado", "close": "Cerrar", "emptyqueue": "No se encontro ningún usuario. Están escondidos.", - "showshortcuts": "Mostrar atajos" + "showshortcuts": "Mostrar atajos", + "more-detail": "Mas detalle", + "less-detail": "Menos detalle", + "dont-like-username": "No me gusta ese nombre de usuario", + "impersonating": "Suplantación", + "offensive": "Ofensivo", + "spam/ads": "Spam/Propaganda", + "other": "Otros" }, "comment": { "flagged": "marcado", diff --git a/client/coral-configure/components/ConfigureCommentStream.js b/client/coral-configure/components/ConfigureCommentStream.js index 348d26f53..bef53e1ef 100644 --- a/client/coral-configure/components/ConfigureCommentStream.js +++ b/client/coral-configure/components/ConfigureCommentStream.js @@ -7,7 +7,7 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations.json'; const lang = new I18n(translations); -export default ({handleChange, handleApply, changed, updateQuestionBoxContent, ...props}) => ( +export default ({handleChange, handleApply, changed, ...props}) => (
    @@ -38,9 +38,9 @@ export default ({handleChange, handleApply, changed, updateQuestionBoxContent, . -
    @@ -96,11 +102,10 @@ class ConfigureStreamContainer extends Component { handleChange={this.handleChange} handleApply={this.handleApply} changed={this.state.changed} - premodLinks={settings.premodLinks} + premodLinksEnable={dirtySettings.premodLinksEnable} premod={premod} - updateQuestionBoxContent={this.updateQuestionBoxContent} - questionBoxEnable={settings.questionBoxEnable} - questionBoxContent={settings.questionBoxContent} + questionBoxEnable={dirtySettings.questionBoxEnable} + questionBoxContent={dirtySettings.questionBoxContent} />

    {closedAt === 'open' ? 'Close' : 'Open'} Comment Stream

    diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/Comment.js index 4b01e19a7..51402e490 100644 --- a/client/coral-embed-stream/src/Comment.js +++ b/client/coral-embed-stream/src/Comment.js @@ -22,11 +22,10 @@ import LoadMore from 'coral-embed-stream/src/LoadMore'; import {Slot} from 'coral-framework'; import IgnoredCommentTombstone from './IgnoredCommentTombstone'; import {TopRightMenu} from './TopRightMenu'; +import {getActionSummary, getTotalActionCount, iPerformedThisAction} from 'coral-framework/utils'; import styles from './Comment.css'; -const getActionSummary = (type, comment) => comment.action_summaries - .filter((a) => a.__typename === type)[0]; const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF') ; // hold actions links (e.g. Like, Reply) along the comment footer @@ -124,9 +123,16 @@ class Comment extends React.Component { commentIsIgnored, } = this.props; - const like = getActionSummary('LikeActionSummary', comment); - const flag = getActionSummary('FlagActionSummary', comment); - const dontagree = getActionSummary('DontAgreeActionSummary', comment); + const likeSummary = getActionSummary('LikeActionSummary', comment); + const flagSummary = getActionSummary('FlagActionSummary', comment); + const dontAgreeSummary = getActionSummary('DontAgreeActionSummary', comment); + let myFlag = null; + if (iPerformedThisAction('FlagActionSummary', comment)) { + myFlag = flagSummary.find(s => s.current_user); + } else if (iPerformedThisAction('DontAgreeActionSummary', comment)) { + myFlag = dontAgreeSummary.find(s => s.current_user); + } + let commentClass = parentId ? `reply ${styles.Reply}` : `comment ${styles.Comment}`; commentClass += comment.id === 'pending' ? ` ${styles.pendingComment}` : ''; @@ -168,7 +174,7 @@ class Comment extends React.Component { ? : null } - + { (currentUser && (comment.user.id !== currentUser.id)) ? @@ -183,8 +189,10 @@ class Comment extends React.Component {
    + {/* TODO implmement iPerformedThisAction for the like */} - +
    @@ -217,7 +225,8 @@ class Comment extends React.Component { div span { + display: none; + } + + .commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button { + visibility: collapse; + margin-left: -30px; + } + + .commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button .coral-plugin-replies-icon { + visibility: visible; + } +} \ No newline at end of file diff --git a/client/coral-framework/actions/notification.js b/client/coral-framework/actions/notification.js index 37679972d..d77a3a16c 100644 --- a/client/coral-framework/actions/notification.js +++ b/client/coral-framework/actions/notification.js @@ -1,9 +1,12 @@ import pym from '../services/PymConnection'; +import * as actions from '../constants/notification'; export const addNotification = (notifType, text) => { pym.sendMessage('coral-alert', `${notifType}|${text}`); + return {type: actions.ADD_NOTIFICATION, notifType, text}; }; export const clearNotification = () => { pym.sendMessage('coral-clear-notification'); + return {type: actions.CLEAR_NOTIFICATION}; }; diff --git a/client/coral-framework/components/Slot.css b/client/coral-framework/components/Slot.css new file mode 100644 index 000000000..6048da181 --- /dev/null +++ b/client/coral-framework/components/Slot.css @@ -0,0 +1,3 @@ +.inline { + display: inline-block; +} diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index 0e5cf86c2..c1e5dfe65 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -1,13 +1,14 @@ import React, {Component} from 'react'; import {getSlotElements} from 'coral-framework/helpers/plugins'; +import styles from './Slot.css'; class Slot extends Component { render() { - const {fill, ...rest} = this.props; + const {fill, inline = false, ...rest} = this.props; return ( - +
    {getSlotElements(fill, rest)} - +
    ); } } diff --git a/client/coral-framework/constants/notification.js b/client/coral-framework/constants/notification.js new file mode 100644 index 000000000..a7334119a --- /dev/null +++ b/client/coral-framework/constants/notification.js @@ -0,0 +1,2 @@ +export const ADD_NOTIFICATION = 'ADD_NOTIFICATION'; +export const CLEAR_NOTIFICATION = 'CLEAR_NOTIFICATION'; diff --git a/client/coral-framework/graphql/mutations/index.js b/client/coral-framework/graphql/mutations/index.js index da1603f9e..4a3abb48e 100644 --- a/client/coral-framework/graphql/mutations/index.js +++ b/client/coral-framework/graphql/mutations/index.js @@ -20,12 +20,11 @@ export const postComment = graphql(POST_COMMENT, { fragments: commentView }), props: ({ownProps, mutate}) => ({ - postItem: ({asset_id, body, parent_id}) => - mutate({ + postItem: comment => { + const {asset_id, body, parent_id, tags = []} = comment; + return mutate({ variables: { - asset_id, - body, - parent_id + comment }, optimisticResponse: { createComment: { @@ -39,14 +38,14 @@ export const postComment = graphql(POST_COMMENT, { parent_id, asset_id, action_summaries: [], - tags: [], + tags, status: null, id: 'pending' } } }, updateQueries: { - AssetQuery: (oldData, {mutationResult:{data:{createComment:{comment}}}}) => { + AssetQuery: (oldData, {mutationResult: {data: {createComment: {comment}}}}) => { if (oldData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') { return oldData; @@ -62,8 +61,8 @@ export const postComment = graphql(POST_COMMENT, { ...oldData.asset, comments: oldData.asset.comments.map((oldComment) => { return oldComment.id === parent_id - ? {...oldComment, replies: [...oldComment.replies, comment]} - : oldComment; + ? {...oldComment, replies: [...oldComment.replies, comment]} + : oldComment; }) } }; @@ -83,7 +82,8 @@ export const postComment = graphql(POST_COMMENT, { return updatedAsset; } } - }) + }); + } }), }); diff --git a/client/coral-framework/graphql/mutations/postComment.graphql b/client/coral-framework/graphql/mutations/postComment.graphql index 110ab4b4e..f98558804 100644 --- a/client/coral-framework/graphql/mutations/postComment.graphql +++ b/client/coral-framework/graphql/mutations/postComment.graphql @@ -1,7 +1,7 @@ #import "../fragments/commentView.graphql" -mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) { - createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) { +mutation CreateComment ($comment: CreateCommentInput!) { + createComment(comment: $comment) { comment { ...commentView replyCount diff --git a/client/coral-framework/graphql/queries/streamQuery.graphql b/client/coral-framework/graphql/queries/streamQuery.graphql index 9f27ce235..c9a7690fc 100644 --- a/client/coral-framework/graphql/queries/streamQuery.graphql +++ b/client/coral-framework/graphql/queries/streamQuery.graphql @@ -28,6 +28,7 @@ query AssetQuery($asset_id: ID, $asset_url: String, $comment_id: ID!, $has_comme moderation infoBoxEnable infoBoxContent + premodLinksEnable questionBoxEnable questionBoxContent closeTimeout diff --git a/client/coral-framework/reducers/index.js b/client/coral-framework/reducers/index.js index fae7e60d5..d5b66bbae 100644 --- a/client/coral-framework/reducers/index.js +++ b/client/coral-framework/reducers/index.js @@ -1,11 +1,13 @@ import auth from './auth'; import user from './user'; import asset from './asset'; +import {reducer as commentBox} from '../../coral-plugin-commentbox'; import {pluginReducers} from '../helpers/plugins'; export default { auth, user, asset, + commentBox, ...pluginReducers }; diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 6d589cf23..4e9c29db7 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -1,7 +1,31 @@ -/** -* getActionSummary -* retrieves the action summary based on the type and the comment -*/ +export const getTotalActionCount = (type, comment) => { + return comment.action_summaries + .filter(s => s.__typename === type) + .reduce((total, summary) => { + return total + summary.count; + }, 0); +}; -export const getActionSummary = (type, comment) => - comment.action_summaries.filter(a => a.__typename === type)[0]; +export const iPerformedThisAction = (type, comment) => { + + // if there is a current_user on any of the ActionSummary(s), the user performed this action + return comment.action_summaries + .filter(a => a.__typename === type) + .some(a => a.current_user); +}; + +export const getMyActionSummary = (type, comment) => { + return comment.action_summaries + .filter(a => a.__typename === type) + .find(a => a.current_user); +}; + + /** + * getActionSummary + * retrieves the action summaries based on the type and the comment + * array could be length > 1, as in the case of FlagActionSummary + */ + +export const getActionSummary = (type, comment) => { + return comment.action_summaries.filter(a => a.__typename === type); +}; diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js index 19472ef70..f395b56c9 100644 --- a/client/coral-plugin-commentbox/CommentBox.js +++ b/client/coral-plugin-commentbox/CommentBox.js @@ -2,56 +2,57 @@ import React, {Component, PropTypes} from 'react'; import {I18n} from '../coral-framework'; import translations from './translations.json'; import {Button} from 'coral-ui'; +import {Slot} from 'coral-framework'; +import {connect} from 'react-redux'; const name = 'coral-plugin-commentbox'; class CommentBox extends Component { - static propTypes = { - commentPostedHandler: PropTypes.func, - postItem: PropTypes.func.isRequired, - cancelButtonClicked: PropTypes.func, - assetId: PropTypes.string.isRequired, - parentId: PropTypes.string, - authorId: PropTypes.string.isRequired, - isReply: PropTypes.bool.isRequired, - canPost: PropTypes.bool, - currentUser: PropTypes.object - } + constructor(props) { + super(props); - state = { - body: '', - username: '' + this.state = { + username: '', + body: '', + hooks: { + preSubmit: [], + postSubmit: [] + } + }; } postComment = () => { const { - commentPostedHandler, - postItem, - assetId, - updateCountCache, isReply, - countCache, + assetId, parentId, + postItem, + countCache, addNotification, - authorId + updateCountCache, + commentPostedHandler } = this.props; let comment = { - body: this.state.body, asset_id: assetId, - author_id: authorId, - parent_id: parentId + parent_id: parentId, + body: this.state.body, + ...this.props.commentBox }; - if (this.props.charCount && this.state.body.length > this.props.charCount) { - return; - } !isReply && updateCountCache(assetId, countCache + 1); + + // Execute preSubmit Hooks + this.state.hooks.preSubmit.forEach(hook => hook()); + postItem(comment, 'comments') .then(({data}) => { const postedComment = data.createComment.comment; + // Execute postSubmit Hooks + this.state.hooks.postSubmit.forEach(hook => hook(data)); + if (postedComment.status === 'REJECTED') { addNotification('error', lang.t('comment-post-banned-word')); !isReply && updateCountCache(assetId, countCache); @@ -64,14 +65,67 @@ class CommentBox extends Component { commentPostedHandler(); } }) - .catch((err) => console.error(err)); + .catch((err) => console.error(err)); + this.setState({body: ''}); } + registerHook = (hookType = '', hook = () => {}) => { + if (typeof hook !== 'function') { + return console.warn(`Hooks must be functions. Please check your ${hookType} hooks`); + } else if (typeof hookType === 'string') { + this.setState(state => ({ + hooks: { + ...state.hooks, + [hookType]: [ + ...state.hooks[hookType], + hook + ] + } + })); + + return { + hookType, + hook + }; + + } else { + return console.warn('hookTypes must be a string. Please check your hooks'); + } + } + + unregisterHook = hookData => { + const {hookType, hook} = hookData; + + this.setState(state => { + let newHooks = state.hooks[newHooks]; + const idx = state.hooks[hookType].indexOf(hook); + + if (idx !== -1) { + newHooks = [ + ...state.hooks[hookType].slice(0, idx), + ...state.hooks[hookType].slice(idx + 1) + ]; + } + + return { + hooks: { + ...state.hooks, + [hookType]: newHooks + } + }; + + }); + } + + handleChange = e => this.setState({body: e.target.value}); + render () { const {styles, isReply, authorId, charCount} = this.props; let {cancelButtonClicked} = this.props; + const length = this.state.body.length; + const enablePostComment = !length || (charCount && length > charCount); if (isReply && typeof cancelButtonClicked !== 'function') { console.warn('the CommentBox component should have a cancelButtonClicked callback defined if it lives in a Reply'); @@ -93,33 +147,35 @@ class CommentBox extends Component { value={this.state.body} placeholder={lang.t('comment')} id={isReply ? 'replyText' : 'commentText'} - onChange={(e) => this.setState({body: e.target.value})} + onChange={this.handleChange} rows={3}/>
    charCount ? `${name}-char-max` : ''}`}> - { - charCount && - `${charCount - length} ${lang.t('characters-remaining')}` - } + {charCount && `${charCount - length} ${lang.t('characters-remaining')}`}
    + { isReply && ( ) } { authorId && ( ) @@ -129,6 +185,20 @@ class CommentBox extends Component { } } -export default CommentBox; +CommentBox.propTypes = { + commentPostedHandler: PropTypes.func, + postItem: PropTypes.func.isRequired, + cancelButtonClicked: PropTypes.func, + assetId: PropTypes.string.isRequired, + parentId: PropTypes.string, + authorId: PropTypes.string.isRequired, + isReply: PropTypes.bool.isRequired, + canPost: PropTypes.bool, + currentUser: PropTypes.object +}; + +const mapStateToProps = ({commentBox}) => ({commentBox}); + +export default connect(mapStateToProps, null)(CommentBox); const lang = new I18n(translations); diff --git a/client/coral-plugin-commentbox/actions.js b/client/coral-plugin-commentbox/actions.js new file mode 100644 index 000000000..46bcf4b1b --- /dev/null +++ b/client/coral-plugin-commentbox/actions.js @@ -0,0 +1,9 @@ +export const addTag = tag => ({ + type: 'ADD_TAG', + tag +}); + +export const removeTag = idx => ({ + type: 'REMOVE_TAG', + idx +}); diff --git a/client/coral-plugin-commentbox/constants.js b/client/coral-plugin-commentbox/constants.js new file mode 100644 index 000000000..b1290f02e --- /dev/null +++ b/client/coral-plugin-commentbox/constants.js @@ -0,0 +1,2 @@ +export const ADD_TAG = 'ADD_TAG'; +export const REMOVE_TAG = 'REMOVE_TAG'; diff --git a/client/coral-plugin-commentbox/index.js b/client/coral-plugin-commentbox/index.js new file mode 100644 index 000000000..9f166caeb --- /dev/null +++ b/client/coral-plugin-commentbox/index.js @@ -0,0 +1,5 @@ +import reducer from './reducer'; + +export default { + reducer +}; diff --git a/client/coral-plugin-commentbox/reducer.js b/client/coral-plugin-commentbox/reducer.js new file mode 100644 index 000000000..d9065fe87 --- /dev/null +++ b/client/coral-plugin-commentbox/reducer.js @@ -0,0 +1,25 @@ +import {ADD_TAG, REMOVE_TAG} from './constants'; + +const initialState = { + tags: [] +}; + +export default function commentBox (state = initialState, action) { + switch (action.type) { + case ADD_TAG : + return { + ...state, + tags: [...state.tags, action.tag] + }; + case REMOVE_TAG : + return { + ...state, + tags: [ + ...state.tags.slice(0, action.idx), + ...state.tags.slice(action.idx + 1) + ] + }; + default : + return state; + } +} diff --git a/client/coral-plugin-commentbox/styles.css b/client/coral-plugin-commentbox/styles.css new file mode 100644 index 000000000..d0f851f18 --- /dev/null +++ b/client/coral-plugin-commentbox/styles.css @@ -0,0 +1,6 @@ +.slot { + display: inline-block; + div { + display: inline-block; + } +} diff --git a/client/coral-plugin-flags/FlagButton.js b/client/coral-plugin-flags/FlagButton.js index 45110f407..054ca2fb1 100644 --- a/client/coral-plugin-flags/FlagButton.js +++ b/client/coral-plugin-flags/FlagButton.js @@ -21,15 +21,15 @@ class FlagButton extends Component { // When the "report" button is clicked expand the menu onReportClick = () => { - const {currentUser, flag, deleteAction} = this.props; + const {currentUser, deleteAction, flaggedByCurrentUser, flag} = this.props; const {localPost, localDelete} = this.state; - const flagged = (flag && flag.current_user && !localDelete) || localPost; + const localFlagged = (flaggedByCurrentUser && !localDelete) || localPost; if (!currentUser) { const offset = document.getElementById(`c_${this.props.id}`).getBoundingClientRect().top - 75; this.props.showSignInDialog(offset); return; } - if (flagged) { + if (localFlagged) { this.setState((prev) => prev.localPost ? {...prev, localPost: null, step: 0} : {...prev, localDelete: true}); deleteAction(localPost || flag.current_user.id); } else if (this.state.showMenu){ @@ -130,9 +130,9 @@ class FlagButton extends Component { } render () { - const {flag, getPopupMenu} = this.props; + const {getPopupMenu, flaggedByCurrentUser} = this.props; const {localPost, localDelete} = this.state; - const flagged = (flag && flag.current_user && !localDelete) || localPost; + const flagged = (flaggedByCurrentUser && !localDelete) || localPost; const popupMenu = getPopupMenu[this.state.step](this.state.itemType); return
    diff --git a/client/coral-plugin-likes/LikeButton.js b/client/coral-plugin-likes/LikeButton.js index 51854c57f..8f6964051 100644 --- a/client/coral-plugin-likes/LikeButton.js +++ b/client/coral-plugin-likes/LikeButton.js @@ -27,9 +27,9 @@ class LikeButton extends Component { render() { const {like, id, postLike, deleteAction, showSignInDialog, currentUser} = this.props; + let {totalLikes: count} = this.props; const {localPost, localDelete} = this.state; const liked = (like && like.current_user && !localDelete) || localPost; - let count = like ? like.count : 0; if (localPost) {count += 1;} if (localDelete) {count -= 1;} 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/mutators/comment.js b/graph/mutators/comment.js index 096db4913..0290d9f16 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -16,7 +16,7 @@ const Wordlist = require('../../services/wordlist'); * @param {String} [status='NONE'] the status of the new comment * @return {Promise} resolves to the created comment */ -const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null}, status = 'NONE') => { +const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null, tags = []}, status = 'NONE') => { return CommentsService.publicCreate({ body, diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index 38183bff6..661cc9f96 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -2,8 +2,8 @@ const wrapResponse = require('../helpers/response'); const CommentsService = require('../../services/comments'); const RootMutation = { - createComment(_, {asset_id, parent_id, body}, {mutators: {Comment}}) { - return wrapResponse('comment')(Comment.create({asset_id, parent_id, body})); + createComment(_, {comment}, {mutators: {Comment}}) { + return wrapResponse('comment')(Comment.create(comment)); }, createLike(_, {like: {item_id, item_type}}, {mutators: {Action}}) { return wrapResponse('like')(Action.create({item_id, item_type, action_type: 'LIKE'})); diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index b26ecabca..4dcb7ea11 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -84,6 +84,9 @@ const RootQuery = { }, myIgnoredUsers: async (_, args, {user, loaders: {Users}}) => { + if (!user) { + return null; + } // 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]; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index ef6ff5937..eb376b0de 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -31,7 +31,7 @@ type User { username: String! # Action summaries against the user. - action_summaries: [ActionSummary] + action_summaries: [ActionSummary]! # Actions completed on the parent. actions: [Action] @@ -197,7 +197,7 @@ type Comment { actions: [Action] # Action summaries against a comment. - action_summaries: [ActionSummary] + action_summaries: [ActionSummary]! # The asset that a comment was made on. asset: Asset @@ -440,7 +440,7 @@ type Asset { # Summary of all Actions against all entities associated with the Asset. # (likes, flags, etc.). Requires the `ADMIN` role. - action_summaries: [AssetActionSummary] + action_summaries: [AssetActionSummary!] # The date that the asset was created. created_at: Date @@ -602,6 +602,26 @@ input CreateLikeInput { item_type: ACTION_ITEM_TYPE! } +enum TAG_TYPE { + STAFF +} + +input CreateCommentInput { + + # The asset id + asset_id: ID! + + # The id of the parent comment + parent_id: ID + + # The body of the comment + body: String! + + # Tags + tags: [TAG_TYPE] + +} + type CreateLikeResponse implements Response { # The like that was created. @@ -728,7 +748,7 @@ type StopIgnoringUserResponse implements Response { type RootMutation { # Creates a comment on the asset. - createComment(asset_id: ID!, parent_id: ID, body: String!): CreateCommentResponse + createComment(comment: CreateCommentInput!): CreateCommentResponse # Creates a like on an entity. createLike(like: CreateLikeInput!): CreateLikeResponse diff --git a/out.gif b/out.gif new file mode 100644 index 000000000..e69de29bb diff --git a/package.json b/package.json index 41d8732de..6c725a852 100644 --- a/package.json +++ b/package.json @@ -182,6 +182,6 @@ "webpack": "^2.3.1" }, "engines": { - "node": "^7.7.0" + "node": "^7.8.0" } } diff --git a/plugins/coral-plugin-offtopic/client/.babelrc b/plugins/coral-plugin-offtopic/client/.babelrc new file mode 100644 index 000000000..60be246eb --- /dev/null +++ b/plugins/coral-plugin-offtopic/client/.babelrc @@ -0,0 +1,14 @@ +{ + "presets": [ + "es2015" + ], + "plugins": [ + "add-module-exports", + "transform-class-properties", + "transform-decorators-legacy", + "transform-object-assign", + "transform-object-rest-spread", + "transform-async-to-generator", + "transform-react-jsx" + ] +} \ No newline at end of file diff --git a/plugins/coral-plugin-offtopic/client/.eslintrc.json b/plugins/coral-plugin-offtopic/client/.eslintrc.json new file mode 100644 index 000000000..9fe56bd14 --- /dev/null +++ b/plugins/coral-plugin-offtopic/client/.eslintrc.json @@ -0,0 +1,23 @@ +{ + "env": { + "browser": true, + "es6": true, + "mocha": true + }, + "parserOptions": { + "sourceType": "module", + "ecmaFeatures": { + "experimentalObjectRestSpread": true, + "jsx": true + } + }, + "parser": "babel-eslint", + "plugins": [ + "react" + ], + "rules": { + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "no-console": ["warn", { "allow": ["warn", "error"] }] + } +} diff --git a/plugins/coral-plugin-offtopic/client/components/OffTopicCheckbox.js b/plugins/coral-plugin-offtopic/client/components/OffTopicCheckbox.js new file mode 100644 index 000000000..4e7ef7adf --- /dev/null +++ b/plugins/coral-plugin-offtopic/client/components/OffTopicCheckbox.js @@ -0,0 +1,38 @@ +import React from 'react'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {addTag, removeTag} from 'coral-plugin-commentbox/actions'; +import styles from './styles.css'; + +class OffTopicCheckbox extends React.Component { + + label = 'OFF_TOPIC'; + + handleChange = (e) => { + if (e.target.checked) { + this.props.addTag(this.label) + } else { + const idx = this.props.commentBox.tags.indexOf(this.label); + this.props.removeTag(idx); + } + } + + render() { + return ( +
    + +
    + ) + } +} + + +const mapStateToProps = ({commentBox}) => ({commentBox}); + +const mapDispatchToProps = dispatch => + bindActionCreators({addTag, removeTag}, dispatch); + +export default connect(mapStateToProps, mapDispatchToProps)(OffTopicCheckbox); diff --git a/plugins/coral-plugin-offtopic/client/components/OffTopicTag.js b/plugins/coral-plugin-offtopic/client/components/OffTopicTag.js new file mode 100644 index 000000000..73e2372ed --- /dev/null +++ b/plugins/coral-plugin-offtopic/client/components/OffTopicTag.js @@ -0,0 +1,18 @@ +import React from 'react'; +import styles from './styles.css'; + +const isOffTopic = (tags) => { + return !!tags.filter(tag => tag.name === 'OFF_TOPIC').length +} + +export default (props) => ( + + { + isOffTopic(props.comment.tags) ? ( + + Off-topic + + ) : null + } + +); diff --git a/plugins/coral-plugin-offtopic/client/components/styles.css b/plugins/coral-plugin-offtopic/client/components/styles.css new file mode 100644 index 000000000..f3e290b09 --- /dev/null +++ b/plugins/coral-plugin-offtopic/client/components/styles.css @@ -0,0 +1,18 @@ +.offTopic { + height: 100%; +} + +.offTopicLabel { + padding: 10px 20px; + display: block; +} + +.tag { + background: rgba(245, 188, 168, 0.6); + display: inline-block; + margin: 0px 5px; + padding: 5px 5px; + border-radius: 2px; +} + + diff --git a/plugins/coral-plugin-offtopic/client/index.js b/plugins/coral-plugin-offtopic/client/index.js new file mode 100644 index 000000000..c006c9a9e --- /dev/null +++ b/plugins/coral-plugin-offtopic/client/index.js @@ -0,0 +1,9 @@ +import OffTopicCheckbox from './components/OffTopicCheckbox'; +import OffTopicTag from './components/OffTopicTag'; + +export default { + slots: { + commentBoxDetail: [OffTopicCheckbox], + commentInfoBar: [OffTopicTag] + } +}; diff --git a/plugins/coral-plugin-offtopic/index.js b/plugins/coral-plugin-offtopic/index.js new file mode 100644 index 000000000..51ad1c24e --- /dev/null +++ b/plugins/coral-plugin-offtopic/index.js @@ -0,0 +1,6 @@ +const {readFileSync} = require('fs'); +const path = require('path'); + +module.exports = { + typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8') +}; diff --git a/plugins/coral-plugin-offtopic/server/typeDefs.graphql b/plugins/coral-plugin-offtopic/server/typeDefs.graphql new file mode 100644 index 000000000..48ba577cf --- /dev/null +++ b/plugins/coral-plugin-offtopic/server/typeDefs.graphql @@ -0,0 +1,4 @@ +## Extending TAG_TYPE by adding OFF_TOPIC Tag +enum TAG_TYPE { + OFF_TOPIC +} diff --git a/plugins/coral-plugin-respect/client/components/RespectButton.js b/plugins/coral-plugin-respect/client/components/RespectButton.js index 9e851460e..417b978ad 100644 --- a/plugins/coral-plugin-respect/client/components/RespectButton.js +++ b/plugins/coral-plugin-respect/client/components/RespectButton.js @@ -5,6 +5,7 @@ import Icon from './Icon'; import {I18n} from 'coral-framework'; import cn from 'classnames'; import translations from '../translations.json'; +import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils'; const lang = new I18n(translations); @@ -14,8 +15,7 @@ class RespectButton extends Component { const {postRespect, showSignInDialog, deleteAction, commentId} = this.props; const {me, comment} = this.props.data; - const respect = comment.action_summaries[0]; - const respected = (respect && respect.current_user); + const myRespectActionSummary = getMyActionSummary('RespectActionSummary', comment); // If the current user does not exist, trigger sign in dialog. if (!me) { @@ -29,29 +29,33 @@ class RespectButton extends Component { return; } - if (!respected) { + if (myRespectActionSummary) { + deleteAction(myRespectActionSummary.current_user.id); + } else { postRespect({ item_id: commentId, item_type: 'COMMENTS' }); - } else { - deleteAction(respect.current_user.id); } } render() { const {comment} = this.props.data; - const respect = comment && comment.action_summaries && comment.action_summaries[0]; - const respected = respect && respect.current_user; - let count = respect ? respect.count : 0; + + if (!comment) { + return null; + } + + const myRespect = getMyActionSummary('RespectActionSummary', comment); + let count = getTotalActionCount('RespectActionSummary', comment); return (
    @@ -64,4 +68,3 @@ RespectButton.propTypes = { }; export default RespectButton; - diff --git a/plugins/coral-plugin-respect/client/containers/RespectButton.js b/plugins/coral-plugin-respect/client/containers/RespectButton.js index 047f7991f..bcf0e342d 100644 --- a/plugins/coral-plugin-respect/client/containers/RespectButton.js +++ b/plugins/coral-plugin-respect/client/containers/RespectButton.js @@ -10,6 +10,8 @@ import RespectButton from '../components/RespectButton'; // See https://dev-blog.apollodata.com/apollo-clients-new-imperative-store-api-6cb69318a1e3 // and https://github.com/apollographql/apollo-client/issues/1224 +const isRespectAction = (a) => a.__typename === 'RespectActionSummary'; + export const RESPECT_QUERY = gql` query RespectQuery($commentId: ID!) { comment(id: $commentId) { @@ -52,18 +54,21 @@ const withDeleteAction = graphql(gql` }, updateQueries: { RespectQuery: (prev) => { - if (get(prev, 'comment.action_summaries.0.current_user.id') !== id) { + const action_summaries = prev.comment.action_summaries; + const idx = action_summaries.findIndex(isRespectAction); + if (idx < 0 || get(action_summaries[idx], 'current_user.id') !== id) { return prev; } const next = { ...prev, comment: { ...prev.comment, - action_summaries: [{ - __typename: 'RespectActionSummary', - count: prev.comment.action_summaries[0].count - 1, - current_user: null, - }], + action_summaries: action_summaries.map( + (a, i) => i !== idx ? a : ({ + ...a, + count: a.count - 1, + current_user: null, + })), } }; return next; @@ -102,21 +107,40 @@ const withPostRespect = graphql(gql` }, updateQueries: { RespectQuery: (prev, {mutationResult, queryVariables}) => { - if (queryVariables.commentId !== respect.item_id || - get(prev, 'comment.action_summaries.0.current_user')) { + if (queryVariables.commentId !== respect.item_id) { return prev; } + + let action_summaries = prev.comment.action_summaries; + let idx = action_summaries.findIndex(isRespectAction); + + // Check whether we already respected this comment. + if (idx >= 0 && action_summaries[idx].current_user) { + return prev; + } + + if (idx < 0) { + + // Add initial action when it doesn't exist. + action_summaries = action_summaries.concat([{ + __typename: 'RespectActionSummary', + count: 0, + current_user: null, + }]); + idx = action_summaries.length - 1; + } + const respectAction = mutationResult.data.createRespect.respect; - const count = prev.comment.action_summaries[0] ? prev.comment.action_summaries[0].count : 0; const next = { ...prev, comment: { ...prev.comment, - action_summaries: [{ - __typename: 'RespectActionSummary', - count: count + 1, - current_user: respectAction, - }], + action_summaries: action_summaries.map( + (a, i) => i !== idx ? a : ({ + ...a, + count: a.count + 1, + current_user: respectAction, + })), } }; return next; @@ -138,4 +162,3 @@ const enhance = compose( ); export default enhance(RespectButton); - diff --git a/plugins/coral-plugin-respect/client/index.js b/plugins/coral-plugin-respect/client/index.js index 2caa6611b..a336786d6 100644 --- a/plugins/coral-plugin-respect/client/index.js +++ b/plugins/coral-plugin-respect/client/index.js @@ -1,4 +1,5 @@ import RespectButton from './containers/RespectButton'; + export default { slots: { commentDetail: [RespectButton], diff --git a/services/actions.js b/services/actions.js index 0bcb81af0..40bef65bb 100644 --- a/services/actions.js +++ b/services/actions.js @@ -48,10 +48,16 @@ module.exports = class ActionsService { * Finds actions in an array of ids. * @param {String} ids array of user identifiers (uuid) */ - static findByItemIdArray(item_ids) { - return ActionModel.find({ + static async findByItemIdArray(item_ids) { + let actions = await ActionModel.find({ 'item_id': {$in: item_ids} }); + + if (actions === null) { + return []; + } + + return actions; } /** 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 de9678089..14301ccf4 100644 --- a/services/users.js +++ b/services/users.js @@ -254,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; } /** diff --git a/test/server/graph/mutations/createComment.js b/test/server/graph/mutations/createComment.js index 11fa666d0..d132c46c7 100644 --- a/test/server/graph/mutations/createComment.js +++ b/test/server/graph/mutations/createComment.js @@ -15,8 +15,8 @@ describe('graph.mutations.createComment', () => { beforeEach(() => SettingsService.init()); const query = ` - mutation CreateComment($body: String = "Here's my comment!") { - createComment(asset_id: "123", body: $body) { + mutation CreateComment($comment: CreateCommentInput = {asset_id: 123, body: "Here's my comment!"}) { + createComment(comment: $comment) { comment { id status @@ -173,7 +173,10 @@ describe('graph.mutations.createComment', () => { const context = new Context({user: new UserModel({status: 'ACTIVE'})}); return graphql(schema, query, {}, context, { - body + comment: { + asset_id: '123', + body + } }) .then(({data, errors}) => { expect(errors).to.be.undefined; diff --git a/test/graph/mutations/ignoreUser.js b/test/server/graph/mutations/ignoreUser.js similarity index 93% rename from test/graph/mutations/ignoreUser.js rename to test/server/graph/mutations/ignoreUser.js index c5633bfb4..54d6305a1 100644 --- a/test/graph/mutations/ignoreUser.js +++ b/test/server/graph/mutations/ignoreUser.js @@ -1,10 +1,10 @@ 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 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!) { @@ -94,7 +94,7 @@ describe('graph.mutations.stopIgnoringUser', () => { if (response.errors && response.errors.length) { console.error(response.errors); } - expect(response.errors).to.be.empty; + expect(response.errors).to.be.empty; }); const stopIgnoringUserMutation = ` @@ -112,7 +112,7 @@ describe('graph.mutations.stopIgnoringUser', () => { if (stopIgnoringUserResponse.errors && stopIgnoringUserResponse.errors.length) { console.error(stopIgnoringUserResponse.errors); } - expect(stopIgnoringUserResponse.errors).to.be.empty; + expect(stopIgnoringUserResponse.errors).to.be.empty; // now check my ignored users const myIgnoredUsersResponse = await graphql(schema, getMyIgnoredUsersQuery, {}, context, {}); diff --git a/test/graph/queries/asset.js b/test/server/graph/queries/asset.js similarity index 88% rename from test/graph/queries/asset.js rename to test/server/graph/queries/asset.js index ba31fe330..79abeda0e 100644 --- a/test/graph/queries/asset.js +++ b/test/server/graph/queries/asset.js @@ -1,12 +1,12 @@ 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'); +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 () => { @@ -87,7 +87,7 @@ describe('graph.queries.asset', () => { `; const assetCommentsResponse = await graphql(schema, assetCommentsWithoutIgnoredQuery, {}, context, {assetId, assetUrl, excludeIgnored: true}); const comments = assetCommentsResponse.data.asset.comments; - expect(comments.length).to.equal(2); + expect(comments.length).to.equal(2); }); });