diff --git a/.circleci/config.yml b/.circleci/config.yml
index d280fda2c..9e1ee91e0 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -8,6 +8,7 @@ job_defaults: &job_defaults
integration_environment: &integration_environment
NODE_ENV: test
CIRCLE_TEST_REPORTS: /tmp/circleci-test-results
+ E2E_MAX_RETRIES: 3
# integration_job runs the integration tests and saves the test results.
integration_job: &integration_job
@@ -29,6 +30,7 @@ integration_job: &integration_job
name: Run the integration tests
command: bash .circleci/e2e.sh
- store_test_results:
+ when: always
path: /tmp/circleci-test-results
version: 2
@@ -104,13 +106,19 @@ jobs:
name: Setup the test results directory
command: mkdir -p /tmp/circleci-test-results
- run:
- name: Run the unit tests
- command: yarn test
+ name: Run the client unit tests
+ command: yarn test:client --ci
environment:
- MOCHA_FILE: /tmp/circleci-test-results/junit/test-results.xml
+ JEST_JUNIT_OUTPUT: /tmp/circleci-test-results/jest/test-results.xml
+ JEST_REPORTER: jest-junit
+ - run:
+ name: Run the server unit tests
+ command: yarn test:server
+ environment:
+ MOCHA_FILE: /tmp/circleci-test-results/mocha/test-results.xml
MOCHA_REPORTER: mocha-junit-reporter
- NODE_ENV: test
- store_test_results:
+ when: always
path: /tmp/circleci-test-results
# test_integration_chrome_local will run the integration tests locally with
@@ -164,6 +172,8 @@ jobs:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: ie
+ # TODO: remove when more reliable
+ E2E_MAX_RETRIES: 1
# test_integration_safari will run the integration tests with safari in
# browserstack.
@@ -173,6 +183,8 @@ jobs:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: safari
+ # TODO: remove when more reliable
+ E2E_MAX_RETRIES: 1
# deploy will deploy the application as a docker image.
deploy:
@@ -182,7 +194,7 @@ jobs:
- setup_remote_docker
- run:
name: Deploy the code
- command: bash ./scripts/docker.sh
+ command: bash ./scripts/docker.sh deploy
# filter_deploy will add the filters for a deploy job in a workflow to make it
# only execute on a deploy related job.
@@ -274,4 +286,7 @@ workflows:
- test_unit
- test_integration_chrome
- test_integration_firefox
- - test_integration_edge
\ No newline at end of file
+ - test_integration_edge
+ # TODO: uncomment when more reliable
+ # - test_integration_ie
+ # - test_integration_safari
\ No newline at end of file
diff --git a/.eslintignore b/.eslintignore
index 36d26da4e..bdce1b9f9 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -2,4 +2,4 @@
dist
node_modules
public
-**/*.min.js
\ No newline at end of file
+**/*.min.js
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
index 079766d25..0801532f0 100644
--- a/.github/ISSUE_TEMPLATE.md
+++ b/.github/ISSUE_TEMPLATE.md
@@ -1,5 +1,34 @@
-### Expected behavior
+
+
+#### Do you want to request a **feature** or report a **bug**?
+
+
+#### Intended outcome:
+
+
+#### Actual outcome:
+
+
+#### How to reproduce the issue:
+
+
+#### Version and environment
+
diff --git a/.gitignore b/.gitignore
index aa44e0e9b..ac6035bf8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,7 +44,9 @@ plugins/*
!plugins/talk-plugin-mod
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-notifications
+!plugins/talk-plugin-notifications-category-featured
!plugins/talk-plugin-notifications-category-reply
+!plugins/talk-plugin-notifications-category-staff
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-permalink
!plugins/talk-plugin-profile-settings
@@ -60,6 +62,8 @@ plugins/*
!plugins/talk-plugin-subscriber
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-viewing-options
+!plugins/talk-plugin-rich-text
+!plugins/talk-plugin-rich-text-pell
**/node_modules/*
yarn-error.log
diff --git a/Dockerfile b/Dockerfile
index 4d0fea440..0b7c8caa4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,6 +15,10 @@ COPY . /usr/src/app
# Ensure the runtime of the container is in production mode.
ENV NODE_ENV production
+# Store the current git revision.
+ARG REVISION_HASH
+ENV REVISION_HASH=${REVISION_HASH}
+
# Install app dependencies and build static assets.
RUN yarn global add node-gyp && \
yarn install --frozen-lockfile && \
diff --git a/Dockerfile.onbuild b/Dockerfile.onbuild
index 5739d6f95..3e837aad6 100644
--- a/Dockerfile.onbuild
+++ b/Dockerfile.onbuild
@@ -18,5 +18,6 @@ ONBUILD COPY . /usr/src/app
# clear out the development dependencies again. After this we of course need to
# clear out the yarn cache, this saves quite a lot of size.
ONBUILD RUN cli plugins reconcile && \
+ yarn && \
yarn build && \
yarn cache clean
\ No newline at end of file
diff --git a/README.md b/README.md
index 89474bf6d..db9f65c3d 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Talk · [](https://circleci.com/gh/coralproject/talk) · [](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8) · [](CONTRIBUTING.md#pull-requests)
+# Talk · [](https://circleci.com/gh/coralproject/talk) · [](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946) · [](CONTRIBUTING.md#pull-requests)
Online comments are broken. Our open-source commenting platform, Talk, rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/products/talk.html).
diff --git a/client/coral-admin/src/components/UserDetailComment.css b/client/coral-admin/src/components/UserDetailComment.css
index 75b968625..71cc19640 100644
--- a/client/coral-admin/src/components/UserDetailComment.css
+++ b/client/coral-admin/src/components/UserDetailComment.css
@@ -78,6 +78,12 @@
font-weight: 300;
font-size: 16px;
max-width: 360px;
+
+ blockquote {
+ font-size: inherit;
+ line-height: inherit;
+ letter-spacing: inherit;
+ }
}
.sideActions {
diff --git a/client/coral-admin/src/components/UserDetailComment.js b/client/coral-admin/src/components/UserDetailComment.js
index 2d98edcb3..ba185a9df 100644
--- a/client/coral-admin/src/components/UserDetailComment.js
+++ b/client/coral-admin/src/components/UserDetailComment.js
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
+import Slot from 'coral-framework/components/Slot';
import { Link } from 'react-router';
-
import { Icon } from 'coral-ui';
import CommentDetails from './CommentDetails';
import styles from './UserDetailComment.css';
@@ -33,9 +33,17 @@ class UserDetailComment extends React.Component {
toggleSelect,
className,
data,
- root: { settings: { wordlist: { banned, suspect } } },
+ root: { settings },
} = this.props;
+ const queryData = { root, comment };
+
+ const formatterSettings = {
+ suspectWords: settings.wordlist.suspect,
+ bannedWords: settings.wordlist.banned,
+ body: comment.body,
+ };
+
return (
-
-
+
diff --git a/client/coral-embed-stream/src/graphql/utils.js b/client/coral-embed-stream/src/graphql/utils.js
index 720cb7f2e..96cf49cad 100644
--- a/client/coral-embed-stream/src/graphql/utils.js
+++ b/client/coral-embed-stream/src/graphql/utils.js
@@ -141,7 +141,7 @@ export function findCommentInAsset(asset, callbackOrId) {
callback = node => node.id === callbackOrId;
}
if (asset.comment) {
- return findComment([getTopLevelParent(asset.comment)], callback);
+ return findComment([reverseCommentParentTree(asset.comment)], callback);
}
if (!asset.comments) {
return false;
@@ -187,11 +187,12 @@ export function insertFetchedCommentsIntoEmbedQuery(root, comments, parent_id) {
);
}
-/**
- * attachCommentToParent recurses through the comment tree starting at `topLevelComment`
- * to find the parent of `comment` and attach it to the replies.
- */
-export function attachCommentToParent(topLevelComment, comment) {
+function attachComment(topLevelComment, comment) {
+ if (!topLevelComment.replies) {
+ topLevelComment = update(topLevelComment, {
+ replies: { $set: { nodes: [] } },
+ });
+ }
if (topLevelComment.id === comment.parent.id) {
return update(topLevelComment, {
replies: {
@@ -204,16 +205,40 @@ export function attachCommentToParent(topLevelComment, comment) {
},
});
}
+ if (!topLevelComment.replies.nodes.length) {
+ return topLevelComment;
+ }
return update(topLevelComment, {
replies: {
nodes: {
- $apply: nodes =>
- nodes.map(node => attachCommentToParent(node, comment)),
+ $apply: nodes => nodes.map(node => attachComment(node, comment)),
},
},
});
}
+/**
+ * attachCommentToParent recurses through the comment tree starting at `topLevelComment`
+ * to find the ancestor of `comment` and attach it to the replies.
+ */
+export function attachCommentToParent(topLevelComment, comment) {
+ let result = topLevelComment;
+ if (comment.parent.parent) {
+ result = attachCommentToParent(result, comment.parent);
+ }
+ return attachComment(result, comment);
+}
+
+/**
+ * reverseCommentParentTree reverses a comment parent relationship tree
+ * like `comment -> parent -> parent` into `parent -> parent -> comment -> replies`.
+ */
+export function reverseCommentParentTree(comment) {
+ return comment.parent
+ ? attachCommentToParent(getTopLevelParent(comment), comment)
+ : comment;
+}
+
/**
* Nest a string in itself repeatly until `level` has been reached.
*
diff --git a/client/coral-embed-stream/src/tabs/stream/components/Comment.css b/client/coral-embed-stream/src/tabs/stream/components/Comment.css
index 15d42bef0..58ff0e731 100644
--- a/client/coral-embed-stream/src/tabs/stream/components/Comment.css
+++ b/client/coral-embed-stream/src/tabs/stream/components/Comment.css
@@ -22,6 +22,10 @@
.commentLevel0 {
padding-left: 0px;
+
+ &.highlightedComment {
+ margin-top: 8px;
+ }
}
.commentLevel1 {
@@ -41,8 +45,8 @@
}
.highlightedComment {
- padding-left: 15px;
- border-left: 3px solid rgb(35,118,216);
+ padding: 1px 15px 8px 15px;
+ background-color: #E3F2FD;
}
.bylineSecondary {
diff --git a/client/coral-embed-stream/src/tabs/stream/components/Comment.js b/client/coral-embed-stream/src/tabs/stream/components/Comment.js
index f0dcc1017..c495b54f9 100644
--- a/client/coral-embed-stream/src/tabs/stream/components/Comment.js
+++ b/client/coral-embed-stream/src/tabs/stream/components/Comment.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import TagLabel from './TagLabel';
import CommentTimestamp from 'coral-framework/components/CommentTimestamp';
import ReplyButton from './ReplyButton';
-import ReplyBox from './ReplyBox';
+import ReplyBox from '../containers/ReplyBox';
import FlagComment from './FlagComment';
import { can } from 'coral-framework/services/perms';
import { TransitionGroup } from 'react-transition-group';
@@ -13,6 +13,7 @@ import styles from './Comment.css';
import { THREADING_LEVEL } from '../../../constants/stream';
import merge from 'lodash/merge';
import mapValues from 'lodash/mapValues';
+import get from 'lodash/get';
import LoadMore from './LoadMore';
import { getEditableUntilDate } from './util';
@@ -21,7 +22,7 @@ import CommentContent from 'coral-framework/components/CommentContent';
import Slot from 'coral-framework/components/Slot';
import CommentTombstone from './CommentTombstone';
import InactiveCommentLabel from './InactiveCommentLabel';
-import { EditableCommentContent } from './EditableCommentContent';
+import EditableCommentContent from '../containers/EditableCommentContent';
import {
getActionSummary,
iPerformedThisAction,
@@ -169,7 +170,7 @@ export default class Comment extends React.Component {
postFlag: PropTypes.func.isRequired,
deleteAction: PropTypes.func.isRequired,
parentId: PropTypes.string,
- highlighted: PropTypes.string,
+ highlighted: PropTypes.object,
notify: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
depth: PropTypes.number.isRequired,
@@ -186,28 +187,7 @@ export default class Comment extends React.Component {
postDontAgree: PropTypes.func,
animateEnter: PropTypes.bool,
commentClassNames: PropTypes.array,
- comment: PropTypes.shape({
- depth: PropTypes.number,
- action_summaries: PropTypes.array.isRequired,
- body: PropTypes.string.isRequired,
- id: PropTypes.string.isRequired,
- tags: PropTypes.arrayOf(
- PropTypes.shape({
- name: PropTypes.string,
- })
- ),
- replies: PropTypes.object,
- user: PropTypes.shape({
- id: PropTypes.string.isRequired,
- username: PropTypes.string.isRequired,
- }).isRequired,
- editing: PropTypes.shape({
- edited: PropTypes.bool,
-
- // ISO8601
- editableUntil: PropTypes.string,
- }),
- }).isRequired,
+ comment: PropTypes.object.isRequired,
setCommentStatus: PropTypes.func.isRequired,
// edit a comment, passed (id, asset_id, { body })
@@ -301,6 +281,14 @@ export default class Comment extends React.Component {
this.props.setActiveReplyBox('');
};
+ undoStatus = () =>
+ this.props.setCommentStatus({
+ commentId: this.props.comment.id,
+ status: this.props.comment.status_history[
+ this.props.comment.status_history.length - 2
+ ].type,
+ });
+
// getVisibileReplies returns a list containing comments
// which were authored by current user or comes before the `idCursor`.
getVisibileReplies() {
@@ -329,6 +317,27 @@ export default class Comment extends React.Component {
return view;
}
+ /**
+ * getConditionalClassNames
+ * conditionalClassNames adds classNames based on condition
+ * classnames is an array of objects with key as classnames and value as conditions
+ * i.e:
+ * {
+ * 'myClassName': { tags: [STAFF]}
+ * }
+ *
+ * This will add myClassName to comments tagged with STAFF TAG.
+ **/
+ getConditionalClassNames() {
+ const { commentClassNames = [] } = this.props;
+ return mapValues(merge({}, ...commentClassNames), condition => {
+ if (condition.tags) {
+ return condition.tags.some(tag => hasTag(this.props.comment.tags, tag));
+ }
+ return false;
+ });
+ }
+
componentDidMount() {
this._isMounted = true;
if (this.editWindowExpiryTimeout) {
@@ -343,25 +352,54 @@ export default class Comment extends React.Component {
}, Math.max(msLeftToEdit, 0));
}
}
+
componentWillUnmount() {
if (this.editWindowExpiryTimeout) {
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
}
this._isMounted = false;
}
- render() {
+
+ renderReplyBox() {
const {
asset,
- data,
+ depth,
+ comment,
+ parentId,
+ postComment,
+ currentUser,
+ setActiveReplyBox,
+ maxCharCount,
+ notify,
+ charCountEnable,
root,
+ } = this.props;
+ return (
+
+ );
+ }
+
+ renderReplies(view) {
+ const {
+ asset,
depth,
comment,
postFlag,
- parentId,
highlighted,
postComment,
currentUser,
- postDontAgree,
setActiveReplyBox,
activeReplyBox,
loadMore,
@@ -372,39 +410,47 @@ export default class Comment extends React.Component {
charCountEnable,
showSignInDialog,
liveUpdates,
- animateEnter,
emit,
- commentClassNames = [],
} = this.props;
+ return (
+
+ {view.map(reply => {
+ return (
+
+ );
+ })}
+
+ );
+ }
- if (!highlighted && this.commentIsRejected(comment)) {
- return (
- {
- this.props.setCommentStatus({
- commentId: comment.id,
- status:
- comment.status_history[comment.status_history.length - 2].type,
- });
- }}
- />
- );
- }
-
- if (this.commentIsIgnored(comment)) {
- return ;
- }
-
- const view = this.getVisibileReplies();
-
- // Inactive comments can be viewed by moderators and admins (e.g. using permalinks).
- const isActive = isCommentActive(comment.status);
-
+ renderLoadMoreReplies(view) {
+ const { comment } = this.props;
const { loadingState } = this.state;
- const isPending = comment.id.indexOf('pending') >= 0;
- const isHighlighted = highlighted === comment.id;
-
const hasMoreComments =
comment.replies &&
(comment.replies.hasNextPage ||
@@ -412,6 +458,57 @@ export default class Comment extends React.Component {
const moreRepliesCount = this.hasIgnoredReplies()
? -1
: comment.replyCount - view.length;
+ return (
+
+
+
+ );
+ }
+
+ renderRepliesContainer() {
+ const { highlighted, comment } = this.props;
+
+ // Only render highlighted reply when we are the parent of it.
+ if (get(highlighted, 'parent.id') === comment.id) {
+ return this.renderReplies([highlighted]);
+ }
+
+ // Otherwise render replies in current view and a load more button if needed.
+ const view = this.getVisibileReplies();
+ return [this.renderReplies(view), this.renderLoadMoreReplies(view)];
+ }
+
+ renderComment() {
+ const {
+ asset,
+ data,
+ root,
+ depth,
+ comment,
+ postFlag,
+ parentId,
+ highlighted,
+ currentUser,
+ postDontAgree,
+ deleteAction,
+ disableReply,
+ maxCharCount,
+ notify,
+ charCountEnable,
+ showSignInDialog,
+ } = this.props;
+
+ // Inactive comments can be viewed by moderators and admins (e.g. using permalinks).
+ const isActive = isCommentActive(comment.status);
+
+ const isPending = comment.id.indexOf('pending') >= 0;
+ const isHighlighted = highlighted && highlighted.id === comment.id;
const flagSummary = getActionSummary('FlagActionSummary', comment);
const dontAgreeSummary = getActionSummary(
'DontAgreeActionSummary',
@@ -424,38 +521,6 @@ export default class Comment extends React.Component {
myFlag = dontAgreeSummary.find(s => s.current_user);
}
- /**
- * conditionClassNames
- * adds classNames based on condition
- * classnames is an array of objects with key as classnames and value as conditions
- * i.e:
- * {
- * 'myClassName': { tags: [STAFF]}
- * }
- *
- * This will add myClassName to comments tagged with STAFF TAG.
- * **/
- const conditionalClassNames = mapValues(
- merge({}, ...commentClassNames),
- condition => {
- if (condition.tags) {
- return condition.tags.some(tag => hasTag(comment.tags, tag));
- }
- return false;
- }
- );
-
- const rootClassName = cn(
- 'talk-stream-comment-wrapper',
- `talk-stream-comment-wrapper-level-${depth}`,
- styles.root,
- styles[`rootLevel${depth}`],
- {
- ...conditionalClassNames,
- [styles.enter]: animateEnter,
- }
- );
-
const commentClassName = cn(
'talk-stream-comment',
`talk-stream-comment-level-${depth}`,
@@ -482,241 +547,221 @@ export default class Comment extends React.Component {
};
return (
-