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 · [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) · [![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8/badge)](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8) · [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md#pull-requests) +# Talk · [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) · [![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946/badge)](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946) · [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](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 ( -
    -
    - +
    + + +
    +
    +
    + -
    -
    + {isStaff(comment.tags) ? Staff : null} + + +
    + + - -
    - {isStaff(comment.tags) ? Staff : null} - - -
    - - - - {comment.editing && comment.editing.edited ? ( - -   - ({t('comment.edited')}) - + {comment.editing && comment.editing.edited ? ( + +   + ({t('comment.edited')}) - ) : null} - -
    - - - - {isActive && - (currentUser && comment.user.id === currentUser.id) && ( - /* User can edit/delete their own comment for a short window after posting */ - - {this.state.isEditable && ( - - Edit - - )} - )} - {!isActive && } + ) : null} +
    -
    - {this.state.isEditing ? ( - - ) : ( -
    - -
    - )} -
    -
    - {isActive && ( -
    -
    - - {!disableReply && ( - - - - )} -
    -
    - - - - -
    -
    + + + {isActive && + (currentUser && comment.user.id === currentUser.id) && ( + /* User can edit/delete their own comment for a short window after posting */ + + {this.state.isEditable && ( + + Edit + + )} + )} -
    + {!isActive && }
    -
    - - {activeReplyBox === comment.id ? ( - - ) : null} - - - {view.map(reply => { - return ( - + {this.state.isEditing ? ( + - ); - })} - -
    - + ) : ( +
    + +
    + )} +
    +
    + {isActive && ( +
    +
    + + + {!disableReply && ( + + + + )} +
    +
    + + + + +
    +
    + )} +
    ); } + + render() { + const { + depth, + comment, + activeReplyBox, + highlighted, + animateEnter, + } = this.props; + + if (!highlighted && this.commentIsRejected(comment)) { + return ; + } + + if (this.commentIsIgnored(comment)) { + return ; + } + + const rootClassName = cn( + 'talk-stream-comment-wrapper', + `talk-stream-comment-wrapper-level-${depth}`, + styles.root, + styles[`rootLevel${depth}`], + { + ...this.getConditionalClassNames(), + [styles.enter]: animateEnter, + } + ); + + const id = `c_${comment.id}`; + + return ( +
    + {this.renderComment()} + {activeReplyBox === comment.id && this.renderReplyBox()} + {this.renderRepliesContainer()} +
    + ); + } } // return whether the comment is editable diff --git a/client/coral-embed-stream/src/tabs/stream/components/CommentForm.css b/client/coral-embed-stream/src/tabs/stream/components/CommentForm.css index 23b6564b5..ebea11033 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/CommentForm.css +++ b/client/coral-embed-stream/src/tabs/stream/components/CommentForm.css @@ -1,3 +1,18 @@ +.buttonContainer { + display: flex; + justify-content: flex-end; + margin-top: 10px; +} + +.button { + float: right; + padding: 5px 10px; + background: rgb(105, 105, 105); + color: #FFF; + border: none; + border-radius: 2px; +} + /** * Example loading state animations on the button. */ diff --git a/client/coral-embed-stream/src/tabs/stream/components/CommentForm.js b/client/coral-embed-stream/src/tabs/stream/components/CommentForm.js index 28b8bb668..65937b3ce 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/CommentForm.js +++ b/client/coral-embed-stream/src/tabs/stream/components/CommentForm.js @@ -13,7 +13,7 @@ import DraftArea from '../containers/DraftArea'; /** * Common UI for Creating or Editing a Comment */ -export class CommentForm extends React.Component { +class CommentForm extends React.Component { static propTypes = { charCountEnable: PropTypes.bool.isRequired, maxCharCount: PropTypes.number, @@ -50,6 +50,11 @@ export class CommentForm extends React.Component { onCancel: PropTypes.func, state: PropTypes.string, loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']), + registerHook: PropTypes.func, + unregisterHook: PropTypes.func, + isReply: PropTypes.bool, + root: PropTypes.object.isRequired, + comment: PropTypes.object, }; static get defaultProps() { return { @@ -87,6 +92,8 @@ export class CommentForm extends React.Component { charCountEnable, body, loadingState, + comment, + root, } = this.props; const length = body.length; @@ -104,6 +111,8 @@ export class CommentForm extends React.Component { return (
    -
    +
    {this.props.buttonContainerStart} {typeof this.props.onCancel === 'function' && (