diff --git a/.eslintignore b/.eslintignore index 21af2df92..fc0214dd2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -25,5 +25,6 @@ plugins/* !plugins/talk-plugin-moderation-actions !plugins/talk-plugin-toxic-comments !plugins/talk-plugin-remember-sort +!plugins/talk-plugin-deep-reply-count node_modules diff --git a/.eslintrc.json b/.eslintrc.json index 8ca153cbc..324bc9666 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,63 +1,75 @@ { - "env": { - "es6": true, - "node": true - }, - "extends": [ - "eslint:recommended" - ], - "parserOptions": { - "ecmaVersion": 2017 - }, - "plugins": [ - "promise", - "json" - ], - "rules": { - "indent": ["error", 2], - "no-console": "off", - "linebreak-style": ["error", "unix"], - "quotes": ["error", "single"], - "semi": ["error", "always"], - "no-template-curly-in-string": "warn", - "no-unsafe-negation": "warn", - "array-callback-return": "warn", - "arrow-parens": ["warn", "always"], - "template-curly-spacing": "warn", - "eqeqeq": ["error", "smart"], - "no-eval": "error", - "no-global-assign": "error", - "no-implied-eval": "error", - "lines-around-comment": ["warn", {"beforeLineComment": true}], - "spaced-comment": ["warn", "always", {"line": {"exceptions": ["-", "="]}}], - "no-script-url": "error", - "no-throw-literal": "error", - "yoda": "warn", - "no-path-concat": "error", - "eol-last": "warn", - "no-nested-ternary": "warn", - "no-tabs": "error", - "no-unneeded-ternary": "warn", - "object-curly-spacing": "warn", - "space-infix-ops": ["error"], - "space-in-parens": ["error", "never"], - "space-unary-ops": ["error", {"words": true, "nonwords": false}], - "no-const-assign": "error", - "no-duplicate-imports": "error", - "prefer-template": "warn", - "comma-spacing": ["error", {"after": true}], - "no-var": "error", - "no-lonely-if": "error", - "curly": "error", - "no-unused-vars": ["error", {"argsIgnorePattern": "^_|next", "varsIgnorePattern": "^_"}], - "no-multiple-empty-lines": ["error", {"max": 1}], - "newline-per-chained-call": ["error", {"ignoreChainWithDepth": 2}], - "promise/no-return-wrap": "error", - "promise/param-names": "error", - "promise/catch-or-return": "warn", - "promise/no-native": "off", - "promise/no-nesting": "warn", - "promise/no-promise-in-callback": "warn", - "promise/no-callback-in-promise": "warn" - } + "env": { + "es6": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended" + ], + "parserOptions": { + "ecmaVersion": 2017 + }, + "plugins": [ + "json", + "promise", + "react" + ], + "rules": { + "promise/no-return-wrap": "error", + "promise/param-names": "error", + "promise/catch-or-return": "warn", + "promise/no-native": "off", + "promise/no-nesting": "warn", + "promise/no-promise-in-callback": "warn", + "promise/no-callback-in-promise": "warn", + + "react/display-name": "off", + + // We'll keeping using this, as we'll switch over to Preact and we don't + // need to worry about deprecation. + "react/no-find-dom-node": "off", + + // TODO: Use default after fixing the errors. + "react/prop-types": "warn", + + "array-callback-return": "warn", + "arrow-parens": ["warn", "always"], + "comma-spacing": ["error", {"after": true}], + "curly": "error", + "eol-last": "warn", + "eqeqeq": ["error", "smart"], + "indent": ["error", 2], + "linebreak-style": ["error", "unix"], + "lines-around-comment": ["warn", {"beforeLineComment": true}], + "newline-per-chained-call": ["error", {"ignoreChainWithDepth": 2}], + "no-console": "off", + "no-const-assign": "error", + "no-duplicate-imports": "error", + "no-eval": "error", + "no-global-assign": "error", + "no-implied-eval": "error", + "no-lonely-if": "error", + "no-multiple-empty-lines": ["error", {"max": 1}], + "no-nested-ternary": "warn", + "no-path-concat": "error", + "no-script-url": "error", + "no-tabs": "error", + "no-template-curly-in-string": "warn", + "no-throw-literal": "error", + "no-unneeded-ternary": "warn", + "no-unsafe-negation": "warn", + "no-unused-vars": ["error", {"argsIgnorePattern": "^_|next", "varsIgnorePattern": "^_"}], + "no-var": "error", + "object-curly-spacing": "warn", + "prefer-template": "warn", + "quotes": ["error", "single"], + "semi": ["error", "always"], + "space-in-parens": ["error", "never"], + "space-infix-ops": ["error"], + "space-unary-ops": ["error", {"words": true, "nonwords": false}], + "spaced-comment": ["warn", "always", {"line": {"exceptions": ["-", "="]}}], + "template-curly-spacing": "warn", + "yoda": "warn" + } } diff --git a/.gitignore b/.gitignore index bba75a377..bcc7c89b1 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,6 @@ plugins/* !plugins/talk-plugin-moderation-actions !plugins/talk-plugin-toxic-comments !plugins/talk-plugin-remember-sort +!plugins/talk-plugin-deep-reply-count **/node_modules/* diff --git a/README.md b/README.md index 4bec86d07..57562fc1a 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,14 @@ Online comments are broken. Our open-source Talk tool rethinks how moderation, c Third party licenses are available via the `/client/3rdpartylicenses.txt` endpoint when the server is running with built assets. -## Important Links +## Try Talk! -- Developer Documentation & Setup Guides: https://coralproject.github.io/talk/ +- Developer Documentation & Setup Guides: https://coralproject.github.io/talk/ (includes Installation Guide, Quickstart, Plugin Guide, API Docs, and more) + +## Roadmap and Release Schedule + +- Talk Roadmap: https://www.pivotaltracker.com/n/projects/1863625 -- Pivotal Tracker Backlog & Release Schedule: https://www.pivotaltracker.com/n/projects/1863625 ## Learn More about Coral diff --git a/app.js b/app.js index 741dac93f..4e74062d5 100644 --- a/app.js +++ b/app.js @@ -5,7 +5,12 @@ const path = require('path'); const helmet = require('helmet'); const compression = require('compression'); const cookieParser = require('cookie-parser'); -const {BASE_URL, BASE_PATH, MOUNT_PATH} = require('./url'); +const { + BASE_URL, + BASE_PATH, + MOUNT_PATH, + STATIC_URL, +} = require('./url'); const routes = require('./routes'); const debug = require('debug')('talk:app'); @@ -55,6 +60,7 @@ app.set('view engine', 'ejs'); app.locals.BASE_URL = BASE_URL; app.locals.BASE_PATH = BASE_PATH; app.locals.MOUNT_PATH = MOUNT_PATH; +app.locals.STATIC_URL = STATIC_URL; debug(`mounting routes on the ${MOUNT_PATH} path`); diff --git a/client/coral-admin/src/actions/userDetail.js b/client/coral-admin/src/actions/userDetail.js index fe9aa2df5..504183f97 100644 --- a/client/coral-admin/src/actions/userDetail.js +++ b/client/coral-admin/src/actions/userDetail.js @@ -4,10 +4,8 @@ export const viewUserDetail = (userId) => ({type: actions.VIEW_USER_DETAIL, user export const hideUserDetail = () => ({type: actions.HIDE_USER_DETAIL}); export const changeUserDetailStatuses = (tab) => { - let statuses; - if (tab === 'all') { - statuses = ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD']; - } else if (tab === 'rejected') { + let statuses = []; + if (tab === 'rejected') { statuses = ['REJECTED']; } return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses}; diff --git a/client/coral-admin/src/components/ActionButton.js b/client/coral-admin/src/components/ActionButton.js index b8778bef0..79fa2f182 100644 --- a/client/coral-admin/src/components/ActionButton.js +++ b/client/coral-admin/src/components/ActionButton.js @@ -28,7 +28,11 @@ const ActionButton = ({type = '', active, ...props}) => { }; ActionButton.propTypes = { - active: PropTypes.bool + active: PropTypes.bool, + type: PropTypes.oneOf(['APPROVE', 'REJECT']), + minimal: PropTypes.bool, + acceptComment: PropTypes.func, + rejectComment: PropTypes.func, }; export default ActionButton; diff --git a/client/coral-admin/src/components/ActionsMenu.js b/client/coral-admin/src/components/ActionsMenu.js index 6e236bf1c..25aee1fbc 100644 --- a/client/coral-admin/src/components/ActionsMenu.js +++ b/client/coral-admin/src/components/ActionsMenu.js @@ -58,6 +58,7 @@ class ActionsMenu extends React.Component { ActionsMenu.propTypes = { icon: PropTypes.string, + children: PropTypes.node, }; export default ActionsMenu; diff --git a/client/coral-admin/src/components/ActionsMenuItem.js b/client/coral-admin/src/components/ActionsMenuItem.js index 82dab96f0..d9497a6ce 100644 --- a/client/coral-admin/src/components/ActionsMenuItem.js +++ b/client/coral-admin/src/components/ActionsMenuItem.js @@ -1,9 +1,14 @@ import React from 'react'; import cn from 'classnames'; import {MenuItem} from 'react-mdl'; +import PropTypes from 'prop-types'; import styles from './ActionsMenu.css'; const ActionsMenuItem = (props) => ; +ActionsMenuItem.propTypes = { + className: PropTypes.string, +}; + export default ActionsMenuItem; diff --git a/client/coral-admin/src/components/AdminLogin.js b/client/coral-admin/src/components/AdminLogin.js index 5f9712127..be8dd8b09 100644 --- a/client/coral-admin/src/components/AdminLogin.js +++ b/client/coral-admin/src/components/AdminLogin.js @@ -105,7 +105,9 @@ AdminLogin.propTypes = { handleLogin: PropTypes.func.isRequired, passwordRequestSuccess: PropTypes.string, loginError: PropTypes.string, - recaptchaPublic: PropTypes.string + recaptchaPublic: PropTypes.string, + requestPasswordReset: PropTypes.func, + errorMessage: PropTypes.string, }; export default AdminLogin; diff --git a/client/coral-admin/src/components/CommentAnimatedEdit.js b/client/coral-admin/src/components/CommentAnimatedEdit.js index 2515d7d94..e24496138 100644 --- a/client/coral-admin/src/components/CommentAnimatedEdit.js +++ b/client/coral-admin/src/components/CommentAnimatedEdit.js @@ -2,8 +2,9 @@ import React from 'react'; import {murmur3} from 'murmurhash-js'; import {CSSTransitionGroup} from 'react-transition-group'; import styles from './CommentAnimatedEdit.css'; +import PropTypes from 'prop-types'; -export default ({children, body}) => { +const CommentBodyHighlighter = ({children, body}) => { return ( { ); }; + +CommentBodyHighlighter.propTypes = { + children: PropTypes.node, + body: PropTypes.string, +}; + +export default CommentBodyHighlighter; diff --git a/client/coral-admin/src/components/CommentBodyHighlighter.js b/client/coral-admin/src/components/CommentBodyHighlighter.js index 9a430f7c7..e27d3ce6d 100644 --- a/client/coral-admin/src/components/CommentBodyHighlighter.js +++ b/client/coral-admin/src/components/CommentBodyHighlighter.js @@ -1,25 +1,78 @@ import React from 'react'; -import Highlighter from 'react-highlight-words'; -import Linkify from 'react-linkify'; -const linkify = new Linkify(); +import {matchLinks} from '../utils'; +import memoize from 'lodash/memoize'; + +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +// generate a regulare expression that catches the `phrases`. +function generateRegExp(phrases) { + const inner = phrases + .map((phrase) => + phrase.split(/\s+/) + .map((word) => escapeRegExp(word)) + .join('[\\s"?!.]+') + ).join('|'); + + return new RegExp(`(^|[^\\w])(${inner})(?=[^\\w]|$)`, 'iu'); +} + +// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases. +function getPhrasesRegexp(suspectWords, bannedWords) { + return generateRegExp([...suspectWords, ...bannedWords]); +} + +// Memoized version as arguments rarely change. +const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp); + +// markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning +// an array of React Elements. +function markPhrases(body, suspectWords, bannedWords, keyPrefix) { + const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords); + const tokens = body.split(regexp); + return tokens.map((token, i) => + i % 3 === 2 + ? {token} + : token + ); +} + +// markLinks looks for links inside `body` and highlights them by returning +// an array of React Elements. +function markLinks(body) { + const matches = matchLinks(body); + const content = []; + let index = 0; + if (matches) { + matches + .forEach((match, i) => { + content.push(body.substring(index, match.index)); + content.push({match.text}); + index = match.lastIndex; + }); + } + content.push(body.substring(index)); + return content; +} export default ({suspectWords, bannedWords, body, ...rest}) => { - const links = linkify.getMatches(body); - const linkText = links ? links.map((link) => link.raw) : []; + // First highlight links. + const content = markLinks(body) + .map((element, index) => { - const searchWords = [ - ...suspectWords, - ...bannedWords, - ...linkText - ]; + // Keep highlighted links. + if (typeof element !== 'string') { + return element; + } + // Highlight suspect and banned phrase inside this part of text. + return markPhrases(element, suspectWords, bannedWords, index); + }); return ( - +
+ {content} +
); }; diff --git a/client/coral-admin/src/components/CommentBox.css b/client/coral-admin/src/components/CommentBox.css deleted file mode 100644 index 34c7ca6ef..000000000 --- a/client/coral-admin/src/components/CommentBox.css +++ /dev/null @@ -1,4 +0,0 @@ - -.textareaContainer { - width: 100%; -} diff --git a/client/coral-admin/src/components/CommentBox.js b/client/coral-admin/src/components/CommentBox.js deleted file mode 100644 index ce3438960..000000000 --- a/client/coral-admin/src/components/CommentBox.js +++ /dev/null @@ -1,35 +0,0 @@ - -import React from 'react'; -import styles from './CommentBox.css'; -import {Button} from 'react-mdl'; - -// Renders a comment box for creating a new comment -export default class CommentBox extends React.Component { - constructor (props) { - super(props); - this.state = {name: '', body: ''}; - this.onSubmit = this.onSubmit.bind(this); - } - - onSubmit () { - const {name, body} = this.state; - this.props.onSubmit({name, body}); - this.setState({body: '', name: ''}); - } - - render (props, {name, body}) { - return ( -
-
- - -
-
-