Merge branch 'master' into 142993479-tags

This commit is contained in:
gaba
2017-04-20 12:34:32 -07:00
58 changed files with 940 additions and 263 deletions
+2
View File
@@ -18,4 +18,6 @@ plugins.json
plugins/*
!plugins/coral-plugin-facebook-auth
!plugins/coral-plugin-respect
!plugins/coral-plugin-offtopic
**/node_modules/*
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:7
FROM node:7.8
# Create app directory
RUN mkdir -p /usr/src/app
+2 -2
View File
@@ -3,12 +3,12 @@ FROM coralproject/talk:latest
# Bundle app source
ONBUILD COPY . /usr/src/app
# At this stage, we need to install the development dependancies again because
# At this stage, we need to install the development dependancies again because
# we need to have webpack available. We then build the new dependancies and
# clear out the development dependancies again. After this we of course need to
# clear out the yarn cache, this saves quite a lot of size.
ONBUILD RUN NODE_ENV=development yarn install --frozen-lockfile && \
NODE_ENV=production cli plugins reconcile && \
NODE_ENV=production yarn build && \
NODE_ENV=production yarn install --production && \
NODE_ENV=production yarn install --production --force && \
yarn cache clean
+218 -37
View File
@@ -1,37 +1,204 @@
## Contents
- [Installation](#installation) - install the application on a machine
- [Via Docker](#installation-from-docker)
- [Via Source](#installation-from-source)
- [Setup](#setup) - setup the application for first use
- [Usage](#usage) - connect the application to a website
# Installation
## Requirements
### System
- Any flavour of Linux, OSX or Windows
- 1GB memory (minimum)
- 5GB storage (minimum)
- [MongoDB](https://www.mongodb.com/) v3.4 or later
- [Redis](https://redis.io/) v3.2 or later
- SSL Certificate
- This application assumes that you will be serving this application in a
production environment, and therefore requires that you serve it behind a
webserver with a valid SSL certificate. This is chosen in order to secure
user's sessions.
## Installation From Docker
We currently support packaging the Talk application via Docker, which automates
the dependency install and asset build process. This is the recommended way to
deploy the application when used in production.
Available as [coralproject/talk](https://hub.docker.com/r/coralproject/talk/) on Docker Hub.
Images are tagged using the following notation:
- `x` (where `x` is the major version number): any minor or patch updates will be included in this. If you're ok getting
new features occasionally and all the bug fixes, this is the tag for you.
- `x.y` (where `y` is the minor version number): any patch updates will be
included with this tag. If you like getting fixes and having features change
only when you want, this is the tag for you. **(recommended)**
- `x.y.z` (where `z` is the patch version): this tag never gets updated, and
essentially freezes your version, this should only be used when you are either
extending Talk or are sure of a specific version you want to freeze.
We provide tags with `*-onbuild` that can be used for easy plugin integration and
acts as a customization endpoint. Instructions are provided in the `PLUGINS.md`
document as to how to use it.
### Requirements
There are some runtime requirements for running Talk for Docker:
- [Docker](https://www.docker.com/) v1.13.0 or later
- [Docker Compose](https://docs.docker.com/compose/) v1.10.0 or later
_Please be sure to check the versions of these requirements. Incorrect versions
of these may lead to unexpected errors!_
### Installing
An example docker-compose.yml:
```yaml
version: '2'
services:
talk:
image: coralproject/talk:1.5
restart: always
ports:
- "5000:5000"
depends_on:
- mongo
- redis
environment:
- TALK_MONGO_URL=mongodb://mongo/talk
- TALK_REDIS_URL=redis://redis
mongo:
image: mongo:3.2
restart: always
volumes:
- mongo:/data/db
redis:
image: redis:3.2
restart: always
volumes:
- redis:/data
volumes:
mongo:
external: false
redis:
external: false
```
At this stage, you should refer to the `README.md` for configuration variables
that are specific to your installation. Some pre-defined fields have been filled
in the above example which are consistent with Docker Compose naming conventions
for [Docker Links](https://docs.docker.com/compose/networking/#links).
### Scaling
If you are interested in splitting apart services, you can simply adjust the
command being executed in the container to optimize for your use case. An
example would be if you wanted to run the API server and the job processor
on different machines. You can achieve this easily with docker compose:
```yaml
version: '2'
services:
talk-api:
image: coralproject/talk:1.5
command: cli serve
restart: always
ports:
- "5000:5000"
depends_on:
- mongo
- redis
environment:
- TALK_MONGO_URL=mongodb://mongo/talk
- TALK_REDIS_URL=redis://redis
talk-jobs:
image: coralproject/talk:1.5
command: cli jobs process
restart: always
ports:
- "5001:5000"
depends_on:
- mongo
- redis
environment:
- TALK_MONGO_URL=mongodb://mongo/talk
- TALK_REDIS_URL=redis://redis
mongo:
image: mongo:3.2
restart: always
volumes:
- mongo:/data/db
redis:
image: redis:3.2
restart: always
volumes:
- redis:/data
volumes:
mongo:
external: false
redis:
external: false
```
Note that the only difference is in the `command` key. From this, you are able
to discretely control which modules are running in order to have the maximum
flexibility when managing your application.
### Running
If you're using docker compose:
```bash
# Start the services using compose
docker-compose up -d
```
If you're using plain docker:
```bash
docker run -d -P coralproject/talk:latest
```
## Installation From Source
This provides information on how to setup the application from source. Note that
this is not recommended for production deploys, but will work for development
and testing purposes.
### Requirements
There are some runtime requirements for running Talk from source:
- [Node](https://nodejs.org/) v7 or later
- [MongoDB](https://www.mongodb.com/) v3.4 or later
- [Redis](https://redis.io/) v3.2 or later
- [Yarn](https://yarnpkg.com/) v0.19.1 or later
- [Node](https://nodejs.org/) ~7.8
- [Yarn](https://yarnpkg.com/) ^0.22.0
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
_Please be sure to check the versions of these requirements. Incorrect versions
of these may lead to unexpected errors!_
### Installing
#### Download
It is highly recommended that you download a released version as the code
available in `master` may not be stable. You can download the latest release
from the [releases page](https://github.com/coralproject/talk/releases).
You can also clone the git repository via:
```bash
# Download the tarball containing the repository
curl -L https://github.com/coralproject/talk/tarball/master -o coralproject-talk.tar.gz
git clone https://github.com/coralproject/talk.git
```
# Untar that file and change to that directory
tar xpf coralproject-talk.tar.gz
mv coralproject-talk-* coralproject-talk
cd coralproject-talk
#### Building
We now have to install the dependencies and build the static assets.
```bash
# Install package dependancies
yarn
@@ -39,6 +206,17 @@ yarn
yarn build
```
After you create/modify the `plugins.json` (refer to `PLUGINS.md` for plugin
docs) file, you can re-run the following to install their dependencies:
```bash
# Reconcile plugins
./bin/cli plugins reconcile
# Build static files
yarn build
```
### Running
Refer to the `README.md` file for required configuration variables to add to the
@@ -50,45 +228,48 @@ You can start the server after configuring the server using the command:
yarn start
```
This will setup the server to serve everything on a single node.js process and
is designed to be used in production.
You can see other scripts we've made available by consulting the `package.json`
file under the `scripts` key including:
- `yarn test` run unit tests
- `yarn e2e` run end to end tests
- `yarn build-watch` watch for changes to client files and build static assets
- `yarn dev-start` watch for changes to server files and reload the server
- `yarn dev-start` watch for changes to server files and reload the server while
also sourcing a `.env` file in your local directory for configuration
## Installation From Docker Hub
# Setup
### Requirements
Once you've installed Talk (either via Docker or source), you still need to
setup the application. If you are unfamiliar with any terminoligy used in the
setup process, refer to the `TERMINOLOGY.md` document.
There are some runtime requirements for running Talk for Docker:
## Via Web
- [MongoDB](https://www.mongodb.com/) v3.2 or later
- [Redis](https://redis.io/) v3.2 or later
- [Docker](https://www.docker.com/) v1.13.0 or later
- [Docker Compose](https://docs.docker.com/compose/) v1.10.0 or later
If you want to perform your setup via the web, you can navigate to your
installation of Talk at the path `/admin/install`. There you will be asked a
series of questions for your installation.
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
## Via CLI
### Installing
If you want to perform your setup through the terminal, you can simply run:
```bash
# Create a directory for talk
mkdir coralproject-talk
cd coralproject-talk
# Download the docker-compose.yml file from the repository
curl -LO https://raw.githubusercontent.com/coralproject/talk/master/docker-compose.yml
cli setup
```
At this stage, you should refer to the `README.md` file for required
configuration variables to add to the environment key for the `talk` service
listed in the `docker-compose.yml` file.
And follow the instructions to perform initial setup and create your first user
account.
### Running
```bash
# Start the services using compose
docker-compose up -d
```
# Usage
After setup is complete, you can then refer to the `/admin/configure` path to
get the embed code that you can copy/paste onto your blog or website in order to
start using Talk.
_In order for the embed to work correctly, you will need to whitelist the domain
that is allowed to embed your site on the `/admin/configure` page, failure to do
so will result in the comment stream not loading._
+58 -12
View File
@@ -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.
+1 -1
View File
@@ -1,6 +1,6 @@
machine:
node:
version: 7
version: 7.9
services:
- docker
- redis
@@ -116,7 +116,7 @@ class ModerationContainer extends Component {
let asset;
if (data.loading) {
if (!('premodCount' in data)) {
return <div><Spinner/></div>;
}
@@ -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 (
<li tabIndex={props.index} className={`mdl-card ${props.selected ? 'mdl-shadow--8dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem}`}>
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
<span>
{props.comment.user.name}
{comment.user.name}
</span>
<span className={styles.created}>
{timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
{timeago().format(comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
</span>
<BanUserButton user={props.comment.user} onClick={() => props.showBanUserDialog(props.comment.user, props.comment.id, props.comment.status !== 'REJECTED')} />
<BanUserButton user={comment.user} onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status !== 'REJECTED')} />
<CommentType type={props.commentType} />
</div>
{props.comment.user.status === 'banned' ?
{comment.user.status === 'banned' ?
<span className={styles.banned}>
<Icon name='error_outline'/>
{lang.t('comment.banned_user')}
@@ -43,16 +46,16 @@ const Comment = ({actions = [], ...props}) => {
: null}
</div>
<div className={styles.moderateArticle}>
Story: {props.comment.asset.title}
Story: {comment.asset.title}
{!props.currentAsset && (
<Link to={`/admin/moderate/${props.comment.asset.id}`}>Moderate &rarr;</Link>
<Link to={`/admin/moderate/${comment.asset.id}`}>Moderate </Link>
)}
</div>
<div className={styles.itemBody}>
<p className={styles.body}>
<Highlighter
searchWords={[...props.suspectWords, ...props.bannedWords, ...linkText]}
textToHighlight={props.comment.body} />
textToHighlight={comment.body} />
</p>
<div className={styles.sideActions}>
{links ? <span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
@@ -60,16 +63,20 @@ const Comment = ({actions = [], ...props}) => {
{actions.map((action, i) =>
<ActionButton key={i}
type={action}
user={props.comment.user}
acceptComment={() => props.acceptComment({commentId: props.comment.id})}
rejectComment={() => props.rejectComment({commentId: props.comment.id})}
user={comment.user}
acceptComment={() => props.acceptComment({commentId: comment.id})}
rejectComment={() => props.rejectComment({commentId: comment.id})}
/>
)}
</div>
</div>
</div>
</div>
{actionSummaries && <FlagBox actionSummaries={actionSummaries} />}
{
flagActions && flagActions.length
? <FlagBox actions={flagActions} actionSummaries={flagActionSummaries} />
: null
}
</li>
);
};
@@ -83,6 +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
@@ -53,3 +53,17 @@
font-size: 12px;
}
}
.lessDetail {
display: inline-block;
margin-right: 10px;
}
.subDetail {
font-weight: normal;
color: #888;
span {
color: black;
}
}
@@ -1,6 +1,17 @@
import React, {Component, PropTypes} from 'react';
import {Icon} from 'coral-ui';
import styles from './FlagBox.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-admin/src/translations.json';
const lang = new I18n(translations);
const shortReasons = {
'This comment is offensive': lang.t('modqueue.offensive'),
'This looks like an ad/marketing': lang.t('modqueue.spam/ads'),
'This user is impersonating': lang.t('modqueue.impersonating'),
'I don\'t like this username': lang.t('modqueue.dont-like-username'),
'Other': lang.t('modqueue.other')
};
class FlagBox extends Component {
constructor () {
@@ -16,27 +27,50 @@ class FlagBox extends Component {
}));
}
reasonMap = (reason) => {
const shortReason = shortReasons[reason];
// if the short reason isn't found, just return the long one.
return shortReason ? shortReason : reason;
}
render() {
const {props} = this;
const {actionSummaries, actions} = this.props;
const {showDetail} = this.state;
return (
<div className={styles.flagBox}>
<div className={styles.container}>
<div className={styles.header}>
<Icon name='flag'/><h3>Flags ({props.actionSummaries.length}):</h3>
<Icon name='flag'/><h3>Flags ({actionSummaries.length}):</h3>
<ul>
{props.actionSummaries.map((action, i) =>
<li key={i}>{!action.reason ? <i>No reason provided</i> : action.reason} (<strong>{action.count}</strong>)</li>
{actionSummaries.map((action, i) =>
<li key={i} className={styles.lessDetail}>{this.reasonMap(action.reason)} (<strong>{action.count}</strong>)</li>
)}
</ul>
{/* <a onClick={this.toggleDetail} className={styles.moreDetail}>More detail</a>*/}
<a onClick={this.toggleDetail} className={styles.moreDetail}>{showDetail ? lang.t('modqueue.less-detail') : lang.t('modqueue.more-detail')}</a>
</div>
{this.state.showDetail && (<div className={styles.detail}>
<ul>
{props.actionSummaries.map((action, i) =>
<li key={i}>{!action.reason ? <i>No reason provided</i> : action.reason} (<strong>{action.count}</strong>)</li>
)}
</ul>
</div>)}
{showDetail && (
<div className={styles.detail}>
<ul>
{actionSummaries.map((summary, i) => {
const actionList = actions.filter(a => a.reason === summary.reason);
return (
<li key={i}>
{this.reasonMap(summary.reason)} (<strong>{summary.count}</strong>)
<ul>
{
actionList.map((action, j) => <li key={`${i}_${j}`} className={styles.subDetail}><span>{action.user.username}</span> {action.message}</li>)
}
</ul>
</li>
);
})}
</ul>
</div>
)}
</div>
</div>
);
@@ -44,7 +78,14 @@ class FlagBox extends Component {
}
FlagBox.propTypes = {
actionSummaries: PropTypes.array.isRequired
actionSummaries: PropTypes.arrayOf(PropTypes.shape({
reason: PropTypes.string,
count: PropTypes.number
})).isRequired,
actions: PropTypes.arrayOf(PropTypes.shape({
message: PropTypes.string,
user: PropTypes.shape({username: PropTypes.string})
})).isRequired
};
export default FlagBox;
@@ -12,4 +12,13 @@ fragment commentView on Comment {
id
title
}
actions {
... on FlagAction {
reason
message
user {
username
}
}
}
}
@@ -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]: [
+16 -2
View File
@@ -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",
@@ -7,7 +7,7 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations.json';
const lang = new I18n(translations);
export default ({handleChange, handleApply, changed, updateQuestionBoxContent, ...props}) => (
export default ({handleChange, handleApply, changed, ...props}) => (
<form onSubmit={handleApply}>
<div className={styles.wrapper}>
<div className={styles.container}>
@@ -38,9 +38,9 @@ export default ({handleChange, handleApply, changed, updateQuestionBoxContent, .
<Checkbox
className={styles.checkbox}
cStyle={changed ? 'green' : 'darkGrey'}
name="premodLinks"
name="plinksenable"
onChange={handleChange}
defaultChecked={props.premodLinks}
defaultChecked={props.premodLinksEnable}
info={{
title: lang.t('configureCommentStream.enablePremodLinks'),
description: lang.t('configureCommentStream.enablePremodLinksDescription')
@@ -57,11 +57,10 @@ export default ({handleChange, handleApply, changed, updateQuestionBoxContent, .
title: lang.t('configureCommentStream.enableQuestionBox'),
description: lang.t('configureCommentStream.enableQuestionBoxDescription')
}} />
<div className={`${props.questionBoxEnable ? null : styles.hidden}`} >
<TextField
id="qboxcontent"
onChange={updateQuestionBoxContent}
onChange={handleChange}
rows={3}
value={props.questionBoxContent}
label={lang.t('configureCommentStream.includeQuestionHere')}
@@ -16,13 +16,13 @@ class ConfigureStreamContainer extends Component {
this.state = {
changed: false,
dirtySettings: props.asset.settings,
closedAt: (props.asset.closedAt === null ? 'open' : 'closed')
};
this.toggleStatus = this.toggleStatus.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleApply = this.handleApply.bind(this);
this.updateQuestionBoxContent = this.updateQuestionBoxContent.bind(this);
}
handleApply (e) {
@@ -32,7 +32,7 @@ class ConfigureStreamContainer extends Component {
const questionBoxEnable = elements.qboxenable.checked;
const questionBoxContent = elements.qboxcontent.value;
const premodLinksEnable = elements.premodLinks.checked;
const premodLinksEnable = elements.plinksenable.checked;
const {changed} = this.state;
const newConfig = {
@@ -49,23 +49,29 @@ class ConfigureStreamContainer extends Component {
changed: false
});
}, 300);
// this.props.loadAsset(this.props.data.asset);
}
}
handleChange (e) {
// TODO: Dont directly manipulate state and make state change immutable.
if (e.target && e.target.id === 'qboxenable') {
this.props.asset.settings.questionBoxEnable = e.target.checked;
this.state.dirtySettings.questionBoxEnable = e.target.checked;
}
if (e.target && e.target.id === 'qboxcontent') {
this.state.dirtySettings.questionBoxContent = e.target.value;
}
if (e.target && e.target.id === 'plinksenable') {
this.state.dirtySettings.premodLinksEnable = e.target.value;
}
this.setState({
changed: true
});
}
updateQuestionBoxContent(e) {
this.props.asset.settings.questionBoxContent = e.target.value;
this.handleChange(e);
}
toggleStatus () {
// update the closedAt status for the asset
@@ -85,10 +91,10 @@ class ConfigureStreamContainer extends Component {
}
render () {
const {settings} = this.props.asset;
const {dirtySettings} = this.state;
const premod = dirtySettings.moderation === 'PRE';
const {closedAt} = this.state;
const premod = settings.moderation === 'PRE';
const closedTimeout = settings.closedTimeout;
const closedTimeout = dirtySettings.closedTimeout;
return (
<div>
@@ -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}
/>
<hr />
<h3>{closedAt === 'open' ? 'Close' : 'Open'} Comment Stream</h3>
+18 -9
View File
@@ -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 {
? <TagLabel><BestIndicator /></TagLabel>
: null }
<PubDate created_at={comment.created_at} />
<Slot fill="commentInfoBar" commentId={comment.id} />
<Slot fill="commentInfoBar" comment={comment} commentId={comment.id} inline/>
{ (currentUser && (comment.user.id !== currentUser.id))
? <span className={styles.topRightMenu}>
@@ -183,8 +189,10 @@ class Comment extends React.Component {
<Content body={comment.body} />
<div className="commentActionsLeft comment__action-container">
<ActionButton>
{/* TODO implmement iPerformedThisAction for the like */}
<LikeButton
like={like}
totalLikes={getTotalActionCount('LikeActionSummary', comment)}
like={likeSummary[0]}
id={comment.id}
postLike={postLike}
deleteAction={deleteAction}
@@ -209,7 +217,7 @@ class Comment extends React.Component {
removeBest={removeBestTag} />
</IfUserCanModifyBest>
</ActionButton>
<Slot fill="commentDetail" commentId={comment.id} />
<Slot fill="commentDetail" comment={comment} commentId={comment.id} inline/>
</div>
<div className="commentActionsRight comment__action-container">
<ActionButton>
@@ -217,7 +225,8 @@ class Comment extends React.Component {
</ActionButton>
<ActionButton>
<FlagComment
flag={flag && flag.current_user ? flag : dontagree}
flaggedByCurrentUser={!!myFlag}
flag={myFlag}
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
@@ -475,3 +475,19 @@ button.comment__action-button[disabled],
.coral-load-more-replies button.coral-load-more, .coral-new-comments button.coral-load-more{
width: initial;
}
@media (min-device-width : 300px) and (max-device-width : 420px) {
.commentActionsLeft.comment__action-container .coral-plugin-likes-button-text,
.commentActionsLeft.comment__action-container > div span {
display: none;
}
.commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button {
visibility: collapse;
margin-left: -30px;
}
.commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button .coral-plugin-replies-icon {
visibility: visible;
}
}
@@ -1,9 +1,12 @@
import pym from '../services/PymConnection';
import * as actions from '../constants/notification';
export const addNotification = (notifType, text) => {
pym.sendMessage('coral-alert', `${notifType}|${text}`);
return {type: actions.ADD_NOTIFICATION, notifType, text};
};
export const clearNotification = () => {
pym.sendMessage('coral-clear-notification');
return {type: actions.CLEAR_NOTIFICATION};
};
@@ -0,0 +1,3 @@
.inline {
display: inline-block;
}
+4 -3
View File
@@ -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 (
<span>
<div className={inline ? styles.inline : ''}>
{getSlotElements(fill, rest)}
</span>
</div>
);
}
}
@@ -0,0 +1,2 @@
export const ADD_NOTIFICATION = 'ADD_NOTIFICATION';
export const CLEAR_NOTIFICATION = 'CLEAR_NOTIFICATION';
@@ -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;
}
}
})
});
}
}),
});
@@ -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
@@ -28,6 +28,7 @@ query AssetQuery($asset_id: ID, $asset_url: String, $comment_id: ID!, $has_comme
moderation
infoBoxEnable
infoBoxContent
premodLinksEnable
questionBoxEnable
questionBoxContent
closeTimeout
+2
View File
@@ -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
};
+30 -6
View File
@@ -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);
};
+108 -38
View File
@@ -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}/>
</div>
<div className={`${name}-char-count ${length > charCount ? `${name}-char-max` : ''}`}>
{
charCount &&
`${charCount - length} ${lang.t('characters-remaining')}`
}
{charCount && `${charCount - length} ${lang.t('characters-remaining')}`}
</div>
<div className={`${name}-button-container`}>
<Slot
fill="commentBoxDetail"
registerHook={this.registerHook}
unregisterHook={this.unregisterHook}
inline
/>
{
isReply && (
<Button
cStyle='darkGrey'
className={`${name}-cancel-button`}
onClick={() => {
cancelButtonClicked('');
}}>
onClick={() => cancelButtonClicked('')}>
{lang.t('cancel')}
</Button>
)
}
{ authorId && (
<Button
cStyle={!length || (charCount && length > charCount) ? 'lightGrey' : 'darkGrey'}
cStyle={enablePostComment ? 'lightGrey' : 'darkGrey'}
className={`${name}-button`}
onClick={this.postComment}>
onClick={this.postComment}
disabled={enablePostComment ? 'disabled' : ''}>
{lang.t('post')}
</Button>
)
@@ -129,6 +185,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);
@@ -0,0 +1,9 @@
export const addTag = tag => ({
type: 'ADD_TAG',
tag
});
export const removeTag = idx => ({
type: 'REMOVE_TAG',
idx
});
@@ -0,0 +1,2 @@
export const ADD_TAG = 'ADD_TAG';
export const REMOVE_TAG = 'REMOVE_TAG';
+5
View File
@@ -0,0 +1,5 @@
import reducer from './reducer';
export default {
reducer
};
+25
View File
@@ -0,0 +1,25 @@
import {ADD_TAG, REMOVE_TAG} from './constants';
const initialState = {
tags: []
};
export default function commentBox (state = initialState, action) {
switch (action.type) {
case ADD_TAG :
return {
...state,
tags: [...state.tags, action.tag]
};
case REMOVE_TAG :
return {
...state,
tags: [
...state.tags.slice(0, action.idx),
...state.tags.slice(action.idx + 1)
]
};
default :
return state;
}
}
@@ -0,0 +1,6 @@
.slot {
display: inline-block;
div {
display: inline-block;
}
}
+5 -5
View File
@@ -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 <div className={`${name}-container`}>
+1 -1
View File
@@ -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;}
-25
View File
@@ -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
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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'}));
+3
View File
@@ -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];
+24 -4
View File
@@ -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
View File
+1 -1
View File
@@ -182,6 +182,6 @@
"webpack": "^2.3.1"
},
"engines": {
"node": "^7.7.0"
"node": "^7.8.0"
}
}
@@ -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"
]
}
@@ -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"] }]
}
}
@@ -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 (
<div className={styles.offTopic}>
<label className={styles.offTopicLabel}>
<input type="checkbox" onChange={this.handleChange}/>
Off-Topic
</label>
</div>
)
}
}
const mapStateToProps = ({commentBox}) => ({commentBox});
const mapDispatchToProps = dispatch =>
bindActionCreators({addTag, removeTag}, dispatch);
export default connect(mapStateToProps, mapDispatchToProps)(OffTopicCheckbox);
@@ -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) => (
<span>
{
isOffTopic(props.comment.tags) ? (
<span className={styles.tag}>
Off-topic
</span>
) : null
}
</span>
);
@@ -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;
}
@@ -0,0 +1,9 @@
import OffTopicCheckbox from './components/OffTopicCheckbox';
import OffTopicTag from './components/OffTopicTag';
export default {
slots: {
commentBoxDetail: [OffTopicCheckbox],
commentInfoBar: [OffTopicTag]
}
};
+6
View File
@@ -0,0 +1,6 @@
const {readFileSync} = require('fs');
const path = require('path');
module.exports = {
typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8')
};
@@ -0,0 +1,4 @@
## Extending TAG_TYPE by adding OFF_TOPIC Tag
enum TAG_TYPE {
OFF_TOPIC
}
@@ -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 (
<div className={styles.respect}>
<button
className={cn(styles.button, {[styles.respected]: respected})}
className={cn(styles.button, {[styles.respected]: myRespect})}
onClick={this.handleClick} >
<span>{lang.t(respected ? 'respected' : 'respect')}</span>
<Icon className={cn(styles.icon, {[styles.respected]: respected})} />
<span>{lang.t(myRespect ? 'respected' : 'respect')}</span>
<Icon className={cn(styles.icon, {[styles.respected]: myRespect})} />
{count > 0 && count}
</button>
</div>
@@ -64,4 +68,3 @@ RespectButton.propTypes = {
};
export default RespectButton;
@@ -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);
@@ -1,4 +1,5 @@
import RespectButton from './containers/RespectButton';
export default {
slots: {
commentDetail: [RespectButton],
+8 -2
View File
@@ -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;
}
/**
+15 -1
View File
@@ -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
+8 -6
View File
@@ -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;
}
/**
+6 -3
View File
@@ -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;
@@ -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, {});
@@ -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);
});
});