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 (
-
+