diff --git a/.gitignore b/.gitignore index f47efe570..70b6d482e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ client/coral-framework/graphql/introspection.json .idea/ *.swp *.DS_STORE +.prettierrc.json coverage/ test/e2e/reports/ diff --git a/app.js b/app.js index 597da28b9..5826a0272 100644 --- a/app.js +++ b/app.js @@ -1,8 +1,10 @@ const express = require('express'); const morgan = require('morgan'); const path = require('path'); +const uuid = require('uuid'); const merge = require('lodash/merge'); const helmet = require('helmet'); +const plugins = require('./services/plugins'); const compression = require('compression'); const {HELMET_CONFIGURATION} = require('./config'); const {MOUNT_PATH} = require('./url'); @@ -12,6 +14,25 @@ const {ENABLE_TRACING, APOLLO_ENGINE_KEY, PORT} = require('./config'); const app = express(); +// Request Identity Middleware +app.use((req, res, next) => { + req.id = uuid.v4(); + + next(); +}); + +//============================================================================== +// PLUGIN PRE APPLICATION MIDDLEWARE +//============================================================================== + +// Inject server route plugins. +plugins.get('server', 'app').forEach(({plugin, app: callback}) => { + debug(`added plugin '${plugin.name}'`); + + // Pass the app to the plugin to mount it's routes. + callback(app); +}); + //============================================================================== // APPLICATION WIDE MIDDLEWARE //============================================================================== diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index a3ff2ca3e..18e31091f 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -34,3 +34,8 @@ export const storySearchChange = (value) => ({ export const clearState = () => ({ type: actions.MODERATION_CLEAR_STATE }); + +export const selectCommentId = (id) => ({ + type: actions.MODERATION_SELECT_COMMENT, + id, +}); diff --git a/client/coral-admin/src/components/CommentDetails.js b/client/coral-admin/src/components/CommentDetails.js index cd504a40f..418be0354 100644 --- a/client/coral-admin/src/components/CommentDetails.js +++ b/client/coral-admin/src/components/CommentDetails.js @@ -21,10 +21,11 @@ class CommentDetails extends Component { this.setState((state) => ({ showDetail: !state.showDetail })); + this.props.clearHeightCache && this.props.clearHeightCache(); } render() { - const {data, root, comment} = this.props; + const {data, root, comment, clearHeightCache} = this.props; const {showDetail} = this.state; const queryData = { root, @@ -44,12 +45,14 @@ class CommentDetails extends Component { {showDetail && } @@ -61,6 +64,7 @@ CommentDetails.propTypes = { data: PropTypes.object.isRequired, root: PropTypes.object.isRequired, comment: PropTypes.object.isRequired, + clearHeightCache: PropTypes.func, }; export default CommentDetails; diff --git a/client/coral-admin/src/constants/moderation.js b/client/coral-admin/src/constants/moderation.js index cae13947d..8eb939d76 100644 --- a/client/coral-admin/src/constants/moderation.js +++ b/client/coral-admin/src/constants/moderation.js @@ -6,3 +6,4 @@ export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH'; export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH'; export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE'; export const MODERATION_CLEAR_STATE = 'MODERATION_CLEAR_STATE'; +export const MODERATION_SELECT_COMMENT = 'MODERATION_SELECT_COMMENT'; diff --git a/client/coral-admin/src/containers/UserDetail.js b/client/coral-admin/src/containers/UserDetail.js index e436cfa90..f026bb47d 100644 --- a/client/coral-admin/src/containers/UserDetail.js +++ b/client/coral-admin/src/containers/UserDetail.js @@ -144,7 +144,7 @@ UserDetailContainer.propTypes = { const LOAD_MORE_QUERY = gql` query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $author_id: ID!, $statuses: [COMMENT_STATUS!]) { comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) { - ...CoralAdmin_Moderation_CommentConnection + ...CoralAdmin_UserDetail_CommentConnection } } ${commentConnectionFragment} diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index 43ae81573..6a64b380a 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -7,6 +7,7 @@ const initialState = { storySearchString: '', shortcutsNoteVisible: 'show', sortOrder: 'DESC', + selectedCommentId: '', }; export default function moderation (state = initialState, action) { @@ -51,6 +52,11 @@ export default function moderation (state = initialState, action) { ...state, sortOrder: action.order, }; + case actions.MODERATION_SELECT_COMMENT: + return { + ...state, + selectedCommentId: action.id, + }; default: return state; } diff --git a/client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js b/client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js new file mode 100644 index 000000000..4ba7c3298 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js @@ -0,0 +1,25 @@ +import React from 'react'; +import {Spinner} from 'coral-ui'; +import PropTypes from 'prop-types'; + +/** + * AutoLoadMore with call `loadMore` the moment it is rendered and shows a Spinner. + */ +class AutoLoadMore extends React.Component { + componentDidMount() { + if(!this.props.loading) { + this.props.loadMore(); + } + } + + render() { + return ; + } +} + +AutoLoadMore.propTypes = { + loading: PropTypes.bool.isRequired, + loadMore: PropTypes.func.isRequired, +}; + +export default AutoLoadMore; diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.css b/client/coral-admin/src/routes/Moderation/components/Comment.css index 10cc707fb..119688b73 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.css +++ b/client/coral-admin/src/routes/Moderation/components/Comment.css @@ -10,7 +10,9 @@ position: relative; transition: all 200ms; padding: 10px 0; + margin-top: 13px; min-height: 0; + outline: 0; /* Fix rendering issues in Safari by promoting this @@ -25,6 +27,10 @@ } } +.dangling { + background-color: #efefef; +} + .container { padding: 0 14px; } diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index faeb694f6..b1dec473a 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -17,21 +17,36 @@ import RejectButton from 'coral-admin/src/components/RejectButton'; import t, {timeago} from 'coral-framework/services/i18n'; class Comment extends React.Component { + ref = null; + + handleRef = (ref) => (this.ref = ref); + + handleFocusOrClick = () => { + if (!this.props.selected) { + this.props.selectComment(); + } + }; viewUserDetail = () => { const {viewUserDetail, comment} = this.props; return viewUserDetail(comment.user.id); }; - approve = () => (this.props.comment.status === 'ACCEPTED' - ? null - : this.props.acceptComment({commentId: this.props.comment.id}) - ); + approve = () => + this.props.comment.status === 'ACCEPTED' + ? null + : this.props.acceptComment({commentId: this.props.comment.id}); - reject = () => (this.props.comment.status === 'REJECTED' - ? null - : this.props.rejectComment({commentId: this.props.comment.id}) - ); + reject = () => + this.props.comment.status === 'REJECTED' + ? null + : this.props.rejectComment({commentId: this.props.comment.id}); + + componentDidUpdate(prev) { + if (!prev.selected && this.props.selected) { + this.ref.focus(); + } + } render() { const { @@ -42,6 +57,8 @@ class Comment extends React.Component { root, root: {settings}, currentAsset, + clearHeightCache, + dangling, } = this.props; const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'; @@ -50,34 +67,48 @@ class Comment extends React.Component { return (
  • - + className={cn( + styles.username, + 'talk-admin-moderate-comment-username' + )} + onClick={this.viewUserDetail} + > {comment.user.username} {timeago(comment.created_at)} - { - (comment.editing && comment.editing.edited) - ?  ({t('comment.edited')}) - : null - } + {comment.editing && comment.editing.edited ? ( + +   + ({t('comment.edited')}) + + + ) : null}
    - +
    @@ -86,8 +117,11 @@ class Comment extends React.Component {
    Story: {comment.asset.title} - {!currentAsset && - {t('modqueue.moderate')}} + {!currentAsset && ( + + {t('modqueue.moderate')} + + )}
    @@ -96,8 +130,7 @@ class Comment extends React.Component { suspectWords={settings.wordlist.suspect} bannedWords={settings.wordlist.banned} body={comment.body} - /> - {' '} + />{' '}
    @@ -130,6 +164,7 @@ class Comment extends React.Component {
    @@ -140,6 +175,7 @@ class Comment extends React.Component { data={data} root={root} comment={comment} + clearHeightCache={clearHeightCache} />
  • ); @@ -149,10 +185,14 @@ class Comment extends React.Component { Comment.propTypes = { viewUserDetail: PropTypes.func.isRequired, acceptComment: PropTypes.func.isRequired, + selectComment: PropTypes.func, rejectComment: PropTypes.func.isRequired, + onClick: PropTypes.func, className: PropTypes.string, + dangling: PropTypes.bool, currentAsset: PropTypes.object, currentUserId: PropTypes.string.isRequired, + clearHeightCache: PropTypes.func, comment: PropTypes.shape({ id: PropTypes.string.isRequired, status: PropTypes.string.isRequired, @@ -162,12 +202,12 @@ Comment.propTypes = { created_at: PropTypes.string.isRequired, user: PropTypes.shape({ id: PropTypes.string, - status: PropTypes.string + status: PropTypes.string, }).isRequired, asset: PropTypes.shape({ title: PropTypes.string, url: PropTypes.string, - id: PropTypes.string + id: PropTypes.string, }), }), data: PropTypes.object.isRequired, diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index a2ffe3614..e14df7a89 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -12,15 +12,9 @@ import Slot from 'coral-framework/components/Slot'; import ViewOptions from './ViewOptions'; class Moderation extends Component { - constructor(props) { - super(props); - const comments = this.getComments(props); - - this.state = { - selectedCommentId: comments[0] ? comments[0].id : null, - }; - - } + state = { + isLoadingMore: false, + }; componentWillMount() { const {toggleModal, singleView} = this.props; @@ -30,17 +24,16 @@ class Moderation extends Component { key('esc', () => toggleModal(false)); key('ctrl+f', () => this.openSearch()); key('t', () => this.nextQueue()); - key('j', () => this.select(true)); - key('k', () => this.select(false)); key('f', () => this.moderate(false)); key('d', () => this.moderate(true)); - this.getMenuItems() - .forEach((menuItem, idx) => key(`${idx + 1}`, () => this.selectQueue(menuItem))); + this.getMenuItems().forEach((menuItem, idx) => + key(`${idx + 1}`, () => this.selectQueue(menuItem)) + ); } onClose = () => { this.props.toggleModal(false); - } + }; nextQueue = () => { const activeTab = this.props.activeTab; @@ -48,39 +41,45 @@ class Moderation extends Component { const menuItems = this.getMenuItems(); const activeTabIndex = menuItems.findIndex((item) => item === activeTab); - const nextQueueIndex = (activeTabIndex === menuItems.length - 1) ? 0 : activeTabIndex + 1; + const nextQueueIndex = + activeTabIndex === menuItems.length - 1 ? 0 : activeTabIndex + 1; this.selectQueue(menuItems[nextQueueIndex]); - } + }; selectQueue = (key) => { const assetId = this.props.data.variables.asset_id; this.props.router.push(this.props.getModPath(key, assetId)); - } + }; getMenuItems = () => Object.keys(this.props.queueConfig); closeSearch = () => { const {toggleStorySearch} = this.props; toggleStorySearch(false); - } + }; openSearch = () => { this.props.toggleStorySearch(true); - } + }; getActiveTabCount = (props = this.props) => { return props.root[`${props.activeTab}Count`]; - } + }; moderate = (accept) => { - const {acceptComment, rejectComment} = this.props; - const {selectedCommentId} = this.state; + const { + acceptComment, + rejectComment, + moderation: {selectedCommentId}, + } = this.props; // Accept or reject only if there's a selected comment - if(selectedCommentId != null){ + if (selectedCommentId != null) { const comments = this.getComments(); - const commentIdx = comments.findIndex((comment) => comment.id === selectedCommentId); + const commentIdx = comments.findIndex( + (comment) => comment.id === selectedCommentId + ); const comment = comments[commentIdx]; if (accept) { @@ -89,86 +88,27 @@ class Moderation extends Component { comment.status !== 'REJECTED' && rejectComment({commentId: comment.id}); } } - } + }; getComments = (props = this.props) => { const {root, activeTab} = props; return root[activeTab].nodes; - } - - scrollTo = (toId, smooth = true) => - document.querySelector(`#comment_${toId}`).scrollIntoView(smooth ? {behavior: 'smooth'} : {}); - - select = async (next, props = this.props, selectedCommentId = this.state.selectedCommentId) => { - const comments = this.getComments(props); - - // No comments to be selected. - if (comments.length === 0){ - return; - } - - // Find current index if we have a selected comment. - const index = selectedCommentId - ? comments.findIndex((comment) => comment.id === selectedCommentId) - : null; - - if (next) { - - // Grab first one if we don't have a selected comment yet. - if (!selectedCommentId) { - this.setState({selectedCommentId: comments[0].id}, () => this.scrollTo(comments[0].id)); - return; - } - - // Select next one when we still have more comments left. - if (index < comments.length - 1) { - this.setState({selectedCommentId: comments[index + 1].id}, () => this.scrollTo(comments[index + 1].id)); - return; - } else { - - // We hit the end of the list, load more comments if we have. - if (comments.length < this.getActiveTabCount()) { - const res = await this.loadMore(); - - // If `loadMore` was already in progress, res would be false. - if (res) { - - // Select next comment after loading has completed. - this.select(true); - } - } - return; - } - } else { - - // We have no selected comment, so just skip it. - if (!selectedCommentId) { - return; - } - - // If we still have previous comments take the one before. - if (index > 0) { - this.setState({selectedCommentId: comments[index - 1].id}, () => this.scrollTo(comments[index - 1].id)); - return; - } - } - } + }; loadMore = async () => { - if (!this.isLoadingMore) { - this.isLoadingMore = true; + if (!this.state.isLoadingMore) { + this.setState({isLoadingMore: true}); try { const result = await this.props.loadMore(this.props.activeTab); - this.isLoadingMore = false; + this.setState({isLoadingMore: false}); return result; - } - catch (e) { - this.isLoadingMore = false; + } catch (e) { + this.setState({isLoadingMore: false}); throw e; } } return false; - } + }; componentWillUnmount() { key.unbind('s'); @@ -176,58 +116,23 @@ class Moderation extends Component { key.unbind('esc'); key.unbind('ctrl+f'); key.unbind('t'); - key.unbind('j'); - key.unbind('k'); key.unbind('f'); key.unbind('d'); - this.getMenuItems() - .forEach((menuItem, idx) => key.unbind(`${idx + 1}`)); + this.getMenuItems().forEach((menuItem, idx) => key.unbind(`${idx + 1}`)); } - componentWillReceiveProps(nextProps) { - - if (this.props.activeTab !== nextProps.activeTab) { - - // Reset selection when changing tabs. - this.select(true, nextProps, null); - } else { - - // Detect if comment has left the queue and find next or prev selected comment to set it - // as the new selectedCommentId. - const prevComments = this.getComments(this.props); - const nextComments = this.getComments(nextProps); - if (nextComments.length < prevComments.length) { - - // Comments have changed, now check if our selected comment has left the queue. - if ( - this.state.selectedCommentId && - !nextComments.some((comment) => comment.id === this.state.selectedCommentId) - ) { - - // Determine a comment to select. - const prevIndex = prevComments.findIndex((comment) => comment.id === this.state.selectedCommentId); - if (prevIndex !== prevComments.length - 1) { - this.setState({selectedCommentId: prevComments[prevIndex + 1].id}); - } else if(prevIndex > 0) { - this.setState({selectedCommentId: prevComments[prevIndex - 1].id}); - } else { - this.setState({selectedCommentId: null}); - } - } - } - } - } - - componentDidUpdate(prevProps) { - - // Scroll to comment when changing from single wiew to normal view. - if (prevProps.moderation.singleView !== this.props.moderation.singleView && this.state.selectedCommentId) { - this.scrollTo(this.state.selectedCommentId, false); - } - } - - render () { - const {root, data, moderation, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props; + render() { + const { + root, + data, + moderation, + viewUserDetail, + activeTab, + getModPath, + queueConfig, + handleCommentChange, + ...props + } = this.props; const {asset} = root; const assetId = asset && asset.id; @@ -238,7 +143,7 @@ class Moderation extends Component { key: queue, name: queueConfig[queue].name, icon: queueConfig[queue].icon, - count: root[`${queue}Count`] + count: root[`${queue}Count`], })); return ( @@ -255,7 +160,9 @@ class Moderation extends Component { items={menuItems} activeTab={activeTab} /> -
    +
    ); @@ -305,12 +217,15 @@ class Moderation extends Component { Moderation.propTypes = { viewUserDetail: PropTypes.func.isRequired, toggleModal: PropTypes.func.isRequired, + selectedCommentId: PropTypes.string, toggleStorySearch: PropTypes.func.isRequired, getModPath: PropTypes.func.isRequired, + cleanUpQueue: PropTypes.func.isRequired, storySearchChange: PropTypes.func.isRequired, moderation: PropTypes.object.isRequired, auth: PropTypes.object.isRequired, queueConfig: PropTypes.object.isRequired, + commentBelongToQueue: PropTypes.func.isRequired, handleCommentChange: PropTypes.func.isRequired, setSortOrder: PropTypes.func.isRequired, rejectComment: PropTypes.func.isRequired, diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationLayout.css b/client/coral-admin/src/routes/Moderation/components/ModerationLayout.css new file mode 100644 index 000000000..7214b4550 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/ModerationLayout.css @@ -0,0 +1,3 @@ +.root { + height: 100%; +} diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css index 6f1d4ae95..465c7ccbd 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css @@ -2,29 +2,15 @@ padding: 8px 0; list-style: none; display: block; + min-height: 650px; margin-top: 16px; } +:global(html) { + height: inherit; +} + .list { - padding: 0; - margin: 0; -} - -.commentLeave { - opacity: 1.0; -} - -.commentLeaveActive { - opacity: 0; - transition: opacity 800ms; -} - -.commentEnter { - opacity: 0; -} - -.commentEnterActive { - opacity: 1.0; - transition: opacity 800ms; + outline: none; } diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index ddbfeb898..0485a8625 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -4,26 +4,34 @@ import PropTypes from 'prop-types'; import Comment from '../containers/Comment'; import styles from './ModerationQueue.css'; import EmptyCard from '../../../components/EmptyCard'; -import LoadMore from '../../../components/LoadMore'; +import AutoLoadMore from './AutoLoadMore'; import ViewMore from './ViewMore'; import t from 'coral-framework/services/i18n'; -import {CSSTransitionGroup} from 'react-transition-group'; +import { + WindowScroller, + CellMeasurer, + CellMeasurerCache, + List, +} from 'react-virtualized'; +import throttle from 'lodash/throttle'; +import key from 'keymaster'; const hasComment = (nodes, id) => nodes.some((node) => node.id === id); // resetCursors will return the id cursors of the first and second comment of -// the current comment list. The cursors are used to dertermine which -// comments to show. The spare cursor functions as a backup in case one -// of the comments gets deleted. +// the current comment The spare cursor functions as a backup in case one +// of the comments gets deleted. Additionally the new view based on the new +// cursors are also returned. function resetCursors(state, props) { + let idCursors = []; if (props.comments && props.comments.length) { - const idCursors = [props.comments[0].id]; + idCursors.push(props.comments[0].id); if (props.comments[1]) { idCursors.push(props.comments[1].id); } - return {idCursors}; } - return {idCursors: []}; + const view = getVisibleComments(props.comments, idCursors[0]); + return {idCursors, view}; } // invalidateCursor is called whenever a comment is removed which is referenced @@ -40,91 +48,322 @@ function invalidateCursor(invalidated, state, props) { idCursors.push(nextInLine.id); } } - return {idCursors}; + return idCursors; } +// getVisibileComments returns a list containing comments +// which comes after the `idCursor`. +function getVisibleComments(comments, idCursor) { + if (!comments) { + return []; + } + + const view = []; + let pastCursor = false; + comments.forEach((comment) => { + if (comment.id === idCursor) { + pastCursor = true; + } + if (pastCursor) { + view.push(comment); + } + }); + return view; +} + +// Current keymapper to use for the CellMeasurer Cache. +let keyMapper = null; + +// CellMeasurerCache is used to measure the size of the elements +// of the virtual list. We use a global one with a keyMapper that +// should resolve to a comment id, which is then used to cache the height. +const cache = new CellMeasurerCache({ + fixedWidth: true, + defaultHeight: 250, + keyMapper: (index) => keyMapper(index), +}); + class ModerationQueue extends React.Component { - isLoadingMore = false; + listRef = null; + callbackCaches = { + clearHeightCache: {}, + selectCommentId: {}, + }; constructor(props) { super(props); this.state = { ...resetCursors(this.state, props), }; + + // Set keyMapper to map to comment ids. + keyMapper = (index) => { + const view = this.state.view; + if (index < view.length) { + return view[index].id; + } else if (index === view.length) { + return 'loadMore'; + } + throw new Error(`unknown index ${index}`); + }; + + // Select first comment. + if (this.state.view.length) { + props.selectCommentId(this.state.view[0].id); + } } - componentDidUpdate (prev) { - const {comments, commentCount} = this.props; + componentDidMount() { + key('j', () => this.selectDown()); + key('k', () => this.selectUp()); - // if the user just moderated the last (visible) comment - // AND there are more comments available on the server, - // go ahead and load more comments - if (prev.comments.length > 0 && comments.length === 0 && commentCount > 0) { - this.props.loadMore(); - } + // TODO: Workaround for issue https://github.com/bvaughn/react-virtualized/issues/866 + this.reflowList(); + } + + componentWillUnmount() { + key.unbind('j'); + key.unbind('k'); + + // When switching queues, clean it up first. + // Removes dangling comments and reduce overly large + // lists and restore chronological order. + this.props.cleanUpQueue(this.props.activeTab); } componentWillReceiveProps(next) { const {comments: prevComments} = this.props; const {comments: nextComments} = next; - if (!prevComments && nextComments) { - this.setState(resetCursors); + // New comments where added and our cursor list is incomplete. + if ( + this.state.idCursors.length < 2 && + nextComments.length > this.state.idCursors.length + ) { + this.setState(resetCursors(this.state, next)); return; } - if ( - prevComments && nextComments && - nextComments.length < prevComments.length - ) { + let idCursors = this.state.idCursors; + // Comments have been removed. + if ( + prevComments && + nextComments && + nextComments.length < prevComments.length + ) { // Invalidate first cursor if referenced comment was removed. - if (this.state.idCursors[0] && !hasComment(nextComments, this.state.idCursors[0])) { - this.setState(invalidateCursor(0, this.state, next)); + if ( + this.state.idCursors[0] && + !hasComment(nextComments, this.state.idCursors[0]) + ) { + idCursors = invalidateCursor(0, this.state, next); } // Invalidate second cursor if referenced comment was removed. - if (this.state.idCursors[1] && !hasComment(nextComments, this.state.idCursors[1])) { - this.setState(invalidateCursor(1, this.state, next)); + if ( + this.state.idCursors[1] && + !hasComment(nextComments, this.state.idCursors[1]) + ) { + idCursors = invalidateCursor(1, this.state, next); } + + // Selected comment was removed, determine and set next selected comment. + if ( + this.props.selectedCommentId && + !hasComment(nextComments, this.props.selectedCommentId) + ) { + const view = this.state.view; + let nextSelectedCommentId = null; + + // Determine a comment to select. + const prevIndex = view.findIndex( + (comment) => comment.id === this.props.selectedCommentId + ); + if (prevIndex !== view.length - 1) { + nextSelectedCommentId = view[prevIndex + 1].id; + } else if (prevIndex > 0) { + nextSelectedCommentId = view[prevIndex - 1].id; + } + this.props.selectCommentId(nextSelectedCommentId); + } + } + + // Comments changed. + if (prevComments !== nextComments) { + const nextView = getVisibleComments(nextComments, idCursors[0]); + this.setState({idCursors, view: nextView}); + + // TODO: removing or adding a comment from the list seems to render incorrect, is this a bug? + // Find first changed comment and perform a reflow. + const index = this.state.view.findIndex( + (comment, i) => !nextView[i] || nextView[i].id !== comment.id + ); + this.reflowList(index); } } - viewNewComments = () => { - this.setState(resetCursors); + componentDidUpdate(prev) { + const {commentCount, selectedCommentId} = this.props; + + // If the user just moderated the last (visible) comment + // AND there are more comments available on the server, + // go ahead and load more comments + if ( + prev.comments.length > 0 && + this.getCommentCountWithoutDagling() === 0 && + commentCount > 0 + ) { + this.props.loadMore(); + } + + // Scroll to selected comment. + if (prev.selectedCommentId !== selectedCommentId && this.listRef) { + const view = this.state.view; + const index = view.findIndex(({id}) => id === selectedCommentId); + + this.listRef.scrollToRow(index); + } + } + + // Returns comment counts without dangling comments. + getCommentCountWithoutDagling(props = this.props) { + return props.comments.filter((comment) => + props.commentBelongToQueue(props.activeTab, comment) + ).length; + } + + async selectDown() { + const view = this.state.view; + const index = view.findIndex(({id}) => id === this.props.selectedCommentId); + if ( + index === view.length - 1 && + this.getCommentCountWithoutDagling() !== this.props.commentCount + ) { + await this.props.loadMore(); + this.selectDown(); + return; + } + if (index < view.length - 1) { + this.props.selectCommentId(view[index + 1].id); + } + } + + selectUp() { + const view = this.state.view; + const index = view.findIndex(({id}) => id === this.props.selectedCommentId); + + if (index === 0 && view.length < this.props.comments.length) { + this.viewNewComments(() => this.selectUp()); + return; + } + if (index > 0) { + this.props.selectCommentId(view[index - 1].id); + } + } + + handleListRef = (list) => { + this.listRef = list; }; - // getVisibileComments returns a list containing comments - // which comes after the `idCursor`. - getVisibleComments() { - const {comments} = this.props; - const idCursor = this.state.idCursors[0]; + viewNewComments = (callback) => { + this.setState(resetCursors, () => { + this.reflowList(); + callback && callback(); + }); + }; - if (!comments) { - return []; + reflowList = throttle((index) => { + if (index >= 0) { + cache.clear(index); + this.listRef && this.listRef.recomputeRowHeights(index); + } else { + cache.clearAll(); + this.listRef && this.listRef.recomputeRowHeights(); + } + }, 500); + + rowRenderer = ({ + index, // Index of row within collection + parent, + style, // Style object to be applied to row (to position it) + }) => { + const view = this.state.view; + const rowCount = view.length + 1; + + let child = null; + let key = null; + + // Last element of list is our AutoLoadMore component and contains an + // id indicating that this is the last element in list. + if (index === rowCount - 1) { + key = 'end-of-comment-list'; + child = ( +
    + {this.props.hasNextPage && ( + + )} +
    + ); + } else { + const comment = view[index]; + + // Use callback cache so not to change the identity of these arrow functions. + // Otherwise shallow compare will fail to optimize. + if (!this.callbackCaches.clearHeightCache[index]) { + this.callbackCaches.clearHeightCache[index] = () => + this.reflowList(index); + } + if (!this.callbackCaches.selectCommentId[comment.id]) { + this.callbackCaches.selectCommentId[comment.id] = () => + this.props.selectCommentId(comment.id); + } + + key = comment.id; + child = ( +
    + +
    + ); } - const view = []; - let pastCursor = false; - comments.forEach((comment) => { - if (comment.id === idCursor) { - pastCursor = true; - } - if (pastCursor) { - view.push(comment); - } - }); - return view; - } + return ( + + {child} + + ); + }; - render () { + render() { const { comments, selectedCommentId, - commentCount, singleView, viewUserDetail, - activeTab, ...props } = this.props; @@ -137,7 +376,9 @@ class ModerationQueue extends React.Component { } if (singleView) { - const index = comments.findIndex((comment) => comment.id === selectedCommentId); + const index = comments.findIndex( + (comment) => comment.id === selectedCommentId + ); const comment = comments[index]; return (
    @@ -157,67 +398,55 @@ class ModerationQueue extends React.Component { ); } - const view = this.getVisibleComments(); + const view = this.state.view; return (
    this.viewNewComments()} count={comments.length - view.length} /> - - { - view - .map((comment) => { - return ; - }) - } - - - + + {({height, isScrolling, onChildScroll, scrollTop}) => ( + + )} +
    ); } } ModerationQueue.propTypes = { + selectCommentId: PropTypes.func.isRequired, + selectedCommentId: PropTypes.string, viewUserDetail: PropTypes.func.isRequired, currentAsset: PropTypes.object, rejectComment: PropTypes.func.isRequired, acceptComment: PropTypes.func.isRequired, - comments: PropTypes.array.isRequired, + commentBelongToQueue: PropTypes.func.isRequired, + cleanUpQueue: PropTypes.func.isRequired, commentCount: PropTypes.number.isRequired, loadMore: PropTypes.func.isRequired, - selectedCommentId: PropTypes.string, singleView: PropTypes.bool, + isLoadingMore: PropTypes.bool, + hasNextPage: PropTypes.bool, + comments: PropTypes.array, activeTab: PropTypes.string.isRequired, data: PropTypes.object.isRequired, root: PropTypes.object.isRequired, diff --git a/client/coral-admin/src/routes/Moderation/components/ViewMore.css b/client/coral-admin/src/routes/Moderation/components/ViewMore.css index 2dcce0da5..e5c572ef5 100644 --- a/client/coral-admin/src/routes/Moderation/components/ViewMore.css +++ b/client/coral-admin/src/routes/Moderation/components/ViewMore.css @@ -13,6 +13,9 @@ background-color: #2376D8; cursor: pointer; text-transform: capitalize; + margin: 0; + margin-top: -18px; + margin-bottom: -4px; } .viewMore:hover { diff --git a/client/coral-admin/src/routes/Moderation/components/ViewOptions.css b/client/coral-admin/src/routes/Moderation/components/ViewOptions.css index d68578a33..68f089c27 100644 --- a/client/coral-admin/src/routes/Moderation/components/ViewOptions.css +++ b/client/coral-admin/src/routes/Moderation/components/ViewOptions.css @@ -8,7 +8,8 @@ overflow: visible; height: 144px; min-height: auto; - z-index: 4; + margin-top: 16px; + z-index: 10; @media (--tablet) { width: 650px; diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index 039d7e0c4..fcfe4c20a 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -11,7 +11,12 @@ import NotFoundAsset from '../components/NotFoundAsset'; import {isPremod, getModPath} from '../../../utils'; import {withSetCommentStatus} from 'coral-framework/graphql/mutations'; -import {handleCommentChange} from '../graphql'; +import { + handleCommentChange, + commentBelongToQueue, + cleanUpQueue, +} from '../graphql'; + import {viewUserDetail} from '../../../actions/userDetail'; import { toggleModal, @@ -20,7 +25,8 @@ import { toggleStorySearch, setSortOrder, storySearchChange, - clearState + clearState, + selectCommentId, } from 'actions/moderation'; import withQueueConfig from '../hoc/withQueueConfig'; import {notify} from 'coral-framework/actions/notification'; @@ -63,13 +69,15 @@ class ModerationContainer extends Component { }; get activeTab() { - const {root: {asset, settings}} = this.props; const id = getAssetId(this.props); const tab = getTab(this.props); // Grab premod from asset or from settings if it's defined. - const setting = id && asset && asset.settings ? asset.settings.moderation : settings.moderation; + const setting = + id && asset && asset.settings + ? asset.settings.moderation + : settings.moderation; const queue = isPremod(setting) ? 'premod' : 'new'; const activeTab = tab ? tab : queue; @@ -82,60 +90,101 @@ class ModerationContainer extends Component { { document: COMMENT_ADDED_SUBSCRIPTION, variables, - updateQuery: (prev, {subscriptionData: {data: {commentAdded: comment}}}) => { + updateQuery: ( + prev, + {subscriptionData: {data: {commentAdded: comment}}} + ) => { return this.handleCommentChange(prev, comment); }, }, { document: COMMENT_ACCEPTED_SUBSCRIPTION, variables, - updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => { - const user = comment.status_history[comment.status_history.length - 1].assigned_by; - const notifyText = this.props.auth.user.id === user.id - ? '' - : t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body)); + updateQuery: ( + prev, + {subscriptionData: {data: {commentAccepted: comment}}} + ) => { + const user = + comment.status_history[comment.status_history.length - 1] + .assigned_by; + const notifyText = + this.props.auth.user.id === user.id + ? '' + : t( + 'modqueue.notify_accepted', + user.username, + prepareNotificationText(comment.body) + ); return this.handleCommentChange(prev, comment, notifyText); }, }, { document: COMMENT_REJECTED_SUBSCRIPTION, variables, - updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => { - const user = comment.status_history[comment.status_history.length - 1].assigned_by; - const notifyText = this.props.auth.user.id === user.id - ? '' - : t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body)); + updateQuery: ( + prev, + {subscriptionData: {data: {commentRejected: comment}}} + ) => { + const user = + comment.status_history[comment.status_history.length - 1] + .assigned_by; + const notifyText = + this.props.auth.user.id === user.id + ? '' + : t( + 'modqueue.notify_rejected', + user.username, + prepareNotificationText(comment.body) + ); return this.handleCommentChange(prev, comment, notifyText); }, }, { document: COMMENT_RESET_SUBSCRIPTION, variables, - updateQuery: (prev, {subscriptionData: {data: {commentReset: comment}}}) => { - const user = comment.status_history[comment.status_history.length - 1].assigned_by; - const notifyText = this.props.auth.user.id === user.id - ? '' - : t('modqueue.notify_reset', user.username, prepareNotificationText(comment.body)); + updateQuery: ( + prev, + {subscriptionData: {data: {commentReset: comment}}} + ) => { + const user = + comment.status_history[comment.status_history.length - 1] + .assigned_by; + const notifyText = + this.props.auth.user.id === user.id + ? '' + : t( + 'modqueue.notify_reset', + user.username, + prepareNotificationText(comment.body) + ); return this.handleCommentChange(prev, comment, notifyText); }, }, { document: COMMENT_EDITED_SUBSCRIPTION, variables, - updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => { + updateQuery: ( + prev, + {subscriptionData: {data: {commentEdited: comment}}} + ) => { return this.handleCommentChange(prev, comment); }, }, { document: COMMENT_FLAGGED_SUBSCRIPTION, variables, - updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => { + updateQuery: ( + prev, + {subscriptionData: {data: {commentFlagged: comment}}} + ) => { return this.handleCommentChange(prev, comment); }, }, ]; - this.subscriptions = parameters.map((param) => this.props.data.subscribeToMoreThrottled(param)); + this.subscriptions = parameters.map((param) => + this.props.data.subscribeToMoreThrottled(param) + ); } unsubscribe() { @@ -158,24 +207,42 @@ class ModerationContainer extends Component { } componentWillReceiveProps(nextProps) { - // Resubscribe when we change between assets. - if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) { + if ( + this.props.data.variables.asset_id !== nextProps.data.variables.asset_id + ) { this.resubscribe(nextProps.data.variables); } } + cleanUpQueue = (queue) => { + if (!this.props.data.loading) { + this.props.data.updateQuery((query) => { + return cleanUpQueue( + query, + queue, + this.props.moderation.sortOrder, + this.props.queueConfig + ); + }); + } + }; + acceptComment = ({commentId}) => { return this.props.setCommentStatus({commentId, status: 'ACCEPTED'}); - } + }; rejectComment = ({commentId}) => { return this.props.setCommentStatus({commentId, status: 'REJECTED'}); - } + }; + + commentBelongToQueue = (queue, comment) => { + return commentBelongToQueue(queue, comment, this.props.queueConfig); + }; loadMore = (tab) => { const variables = { - limit: 10, + limit: 20, cursor: this.props.root[tab].endCursor, sortOrder: this.props.data.variables.sortOrder, asset_id: this.props.data.variables.asset_id, @@ -186,20 +253,19 @@ class ModerationContainer extends Component { return this.props.data.fetchMore({ query: LOAD_MORE_QUERY, variables, - updateQuery: (prev, {fetchMoreResult:{comments}}) => { + updateQuery: (prev, {fetchMoreResult: {comments}}) => { return update(prev, { [tab]: { nodes: {$push: comments.nodes}, hasNextPage: {$set: comments.hasNextPage}, - startCursor: {$set: comments.startCursor}, endCursor: {$set: comments.endCursor}, }, }); - } + }, }); }; - render () { + render() { const {root, root: {asset, settings}, data} = this.props; const assetId = getAssetId(this.props); @@ -209,20 +275,19 @@ class ModerationContainer extends Component { if (assetId) { if (asset === null) { - // Not found. return ; } } - if(data.loading) { - + if (data.loading) { // loading. return ; } - const premodEnabled = assetId ? isPremod(asset.settings.moderation) : - isPremod(settings.moderation); + const premodEnabled = assetId + ? isPremod(asset.settings.moderation) + : isPremod(settings.moderation); const currentQueueConfig = Object.assign({}, this.props.queueConfig); @@ -234,16 +299,21 @@ class ModerationContainer extends Component { delete currentQueueConfig.premod; } - return ; + return ( + + ); } } const COMMENT_ADDED_SUBSCRIPTION = gql` @@ -350,27 +420,57 @@ const commentConnectionFragment = gql` ${Comment.fragments.comment} `; -const withModQueueQuery = withQuery(({queueConfig}) => gql` +const withModQueueQuery = withQuery( + ({queueConfig}) => gql` query CoralAdmin_Moderation($asset_id: ID, $sortOrder: SORT_ORDER, $allAssets: Boolean!, $nullStatuses: [COMMENT_STATUS!]) { - ${Object.keys(queueConfig).map((queue) => ` + ${Object.keys(queueConfig).map( + (queue) => ` ${queue}: comments(query: { - statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'} - ${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''} - ${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''} + statuses: ${ + queueConfig[queue].statuses + ? `[${queueConfig[queue].statuses.join(', ')}],` + : '$nullStatuses' + } + ${ + queueConfig[queue].tags + ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` + : '' + } + ${ + queueConfig[queue].action_type + ? `action_type: ${queueConfig[queue].action_type}` + : '' + } asset_id: $asset_id, - sortOrder: $sortOrder + sortOrder: $sortOrder, + limit: 20, }) { ...CoralAdmin_Moderation_CommentConnection } - `)} - ${Object.keys(queueConfig).map((queue) => ` + ` + )} + ${Object.keys(queueConfig).map( + (queue) => ` ${queue}Count: commentCount(query: { - statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'} - ${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''} - ${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''} + statuses: ${ + queueConfig[queue].statuses + ? `[${queueConfig[queue].statuses.join(', ')}],` + : '$nullStatuses' + } + ${ + queueConfig[queue].tags + ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` + : '' + } + ${ + queueConfig[queue].action_type + ? `action_type: ${queueConfig[queue].action_type}` + : '' + } asset_id: $asset_id, }) - `)} + ` + )} asset(id: $asset_id) @skip(if: $allAssets) { id title @@ -383,24 +483,29 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql` organizationName moderation } + me { + id + } ...${getDefinitionName(Comment.fragments.root)} } ${Comment.fragments.root} ${commentConnectionFragment} -`, { - options: (props) => { - const id = getAssetId(props); - return { - variables: { - asset_id: id, - sortOrder: props.moderation.sortOrder, - allAssets: id === null, - nullStatuses: null, - }, - fetchPolicy: 'network-only' - }; - }, -}); +`, + { + options: (props) => { + const id = getAssetId(props); + return { + variables: { + asset_id: id, + sortOrder: props.moderation.sortOrder, + allAssets: id === null, + nullStatuses: null, + }, + fetchPolicy: 'network-only', + }; + }, + } +); const mapStateToProps = (state) => ({ moderation: state.moderation, @@ -408,22 +513,26 @@ const mapStateToProps = (state) => ({ }); const mapDispatchToProps = (dispatch) => ({ - ...bindActionCreators({ - toggleModal, - singleView, - hideShortcutsNote, - toggleStorySearch, - viewUserDetail, - setSortOrder, - storySearchChange, - clearState, - notify, - }, dispatch), + ...bindActionCreators( + { + toggleModal, + singleView, + hideShortcutsNote, + toggleStorySearch, + viewUserDetail, + setSortOrder, + storySearchChange, + clearState, + notify, + selectCommentId, + }, + dispatch + ), }); export default compose( withQueueConfig(baseQueueConfig), connect(mapStateToProps, mapDispatchToProps), withSetCommentStatus, - withModQueueQuery, + withModQueueQuery )(ModerationContainer); diff --git a/client/coral-admin/src/routes/Moderation/graphql.js b/client/coral-admin/src/routes/Moderation/graphql.js index 866218d73..86bc524b8 100644 --- a/client/coral-admin/src/routes/Moderation/graphql.js +++ b/client/coral-admin/src/routes/Moderation/graphql.js @@ -16,16 +16,21 @@ function queueHasComment(root, queue, id) { return root[queue].nodes.find((c) => c.id === id); } -function removeCommentFromQueue(root, queue, id) { +function removeCommentFromQueue(root, queue, id, dangling = false) { if (!queueHasComment(root, queue, id)) { return root; } - return update(root, { + const changes = { [`${queue}Count`]: {$set: root[`${queue}Count`] - 1}, - [queue]: { + }; + + if (!dangling) { + changes[queue] = { nodes: {$apply: (nodes) => nodes.filter((c) => c.id !== id)}, - }, - }); + }; + } + + return update(root, changes); } function shouldCommentBeAdded(root, queue, comment, sortOrder) { @@ -40,26 +45,46 @@ function shouldCommentBeAdded(root, queue, comment, sortOrder) { : new Date(comment.created_at) >= cursor; } -function addCommentToQueue(root, queue, comment, sortOrder) { +function addCommentToQueue(root, queue, comment, sortOrder, cleanup) { if (queueHasComment(root, queue, comment.id)) { return root; } - const sortAlgo = sortOrder === 'ASC' ? ascending : descending; const changes = { [`${queue}Count`]: {$set: root[`${queue}Count`] + 1}, }; - if (shouldCommentBeAdded(root, queue, comment, sortOrder)) { - const nodes = root[queue].nodes.concat(comment).sort(sortAlgo); - changes[queue] = { - nodes: {$set: nodes}, - startCursor: {$set: nodes[0].created_at}, - endCursor: {$set: nodes[nodes.length - 1].created_at}, - }; + if (!shouldCommentBeAdded(root, queue, comment, sortOrder)) { + return update(root, changes); } - return update(root, changes); + const cursor = new Date(root[queue].startCursor); + const date = new Date(comment.created_at); + + let append = sortOrder === 'ASC' + ? date >= cursor + : date <= cursor; + + const nodes = append + ? root[queue].nodes.concat(comment) + : [comment].concat(...root[queue].nodes); + + changes[queue] = { + nodes: {$set: nodes}, + }; + + const next = update(root, changes); + + if (!cleanup) { + return next; + } + + return cleanUpQueue(next, queue, sortOrder); +} + +function sortComments(nodes, sortOrder) { + const sortAlgo = sortOrder === 'ASC' ? ascending : descending; + return nodes.sort(sortAlgo); } /** @@ -68,24 +93,92 @@ function addCommentToQueue(root, queue, comment, sortOrder) { function getCommentQueues(comment, queueConfig) { const queues = []; Object.keys(queueConfig).forEach((key) => { - const {action_type, statuses, tags} = queueConfig[key]; - let addToQueues = true; - if (statuses && statuses.indexOf(comment.status) === -1) { - addToQueues = false; - } - if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) { - addToQueues = false; - } - if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) { - addToQueues = false; - } - if (addToQueues) { + if (commentBelongToQueue(key, comment, queueConfig)) { queues.push(key); } }); return queues; } +/** + * Return whether or not the comment belongs to the queue. + */ +export function commentBelongToQueue(queue, comment, queueConfig) { + const {action_type, statuses, tags} = queueConfig[queue]; + let belong = true; + if (statuses && statuses.indexOf(comment.status) === -1) { + belong = false; + } + if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) { + belong = false; + } + if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) { + belong = false; + } + return belong; +} + +function isVisible(id) { + return !!document.getElementById(`comment_${id}`); +} + +function isEndOfListVisible(root, queue) { + return root[queue].nodes.length === 0 || !!document.getElementById('end-of-comment-list'); +} + +function applyCommentChanges(root, comment, queueConfig) { + const queues = Object.keys(queueConfig); + for (let i = 0; i < queues.length; i++) { + const queue = queues[i]; + const index = root[queue].nodes.findIndex(({id}) => id === comment.id); + if (index > -1) { + return update(root, { + [queue]: { + nodes: { + [index]: {$merge: comment}, + }, + }, + }); + } + } + return root; +} + +/** + * Remove dangling comments, sort and resize queues. + * If queueConfig is omitted, dangling comments are not removed. + */ +export function cleanUpQueue(root, queue, sortOrder, queueConfig) { + let nodes = root[queue].nodes; + let hasNextPage = root[queue].hasNextPage; + + if (!nodes.length) { + return root; + } + + if (queueConfig) { + nodes = root[queue].nodes.filter((comment) => commentBelongToQueue(queue, comment, queueConfig)); + } + + nodes = sortComments( + nodes, + sortOrder, + ); + + if (nodes.length > 100) { + nodes = nodes.slice(0, 100); + hasNextPage = true; + } + + return update(root, { + [queue]: { + nodes: {$set: nodes}, + endCursor: {$set: nodes[nodes.length - 1].created_at}, + hasNextPage: {$set: hasNextPage}, + }, + }); +} + /** * Assimilate comment changes into current store. * @param {Object} root current state of the store @@ -113,25 +206,27 @@ export function handleCommentChange(root, comment, sortOrder, notify, queueConfi Object.keys(queueConfig).forEach((queue) => { if (nextQueues.indexOf(queue) >= 0) { if (!queueHasComment(next, queue, comment.id)) { - next = addCommentToQueue(next, queue, comment, sortOrder); - if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sortOrder)) { + next = addCommentToQueue(next, queue, comment, sortOrder, activeQueue !== queue); + if (notify && activeQueue === queue && isEndOfListVisible(root, queue)) { showNotificationOnce(); } } } else if(queueHasComment(next, queue, comment.id)){ - next = removeCommentFromQueue(next, queue, comment.id); - if (notify && activeQueue === queue) { + const dangling = activeQueue === queue && comment.status_history[comment.status_history.length - 1].assigned_by.id !== root.me.id; + next = removeCommentFromQueue(next, queue, comment.id, dangling); + if (notify && isVisible(comment.id)) { showNotificationOnce(); } } - if ( - notify - && queueHasComment(next, queue, comment.id) - && activeQueue === queue - ) { + if (notify && isVisible(comment.id)) { showNotificationOnce(); } + + // We need to apply every comment change, because we use + // batched subscription handler which bypasses apollo that would + // have done that for us. + next = applyCommentChanges(next, comment, queueConfig); }); return next; } diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js index d77e6356a..542a767e2 100644 --- a/client/coral-embed-stream/src/graphql/index.js +++ b/client/coral-embed-stream/src/graphql/index.js @@ -193,7 +193,12 @@ export default { }, updateQueries: { CoralEmbedStream_Embed: (prev, {mutationResult: {data: {createComment: {comment}}}}) => { - if (prev.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED' || comment.status === 'SYSTEM_WITHHELD') { + if ( + prev.me.roles.indexOf('ADMIN') === -1 && prev.asset.settings.moderation === 'PRE' || + comment.status === 'PREMOD' || + comment.status === 'REJECTED' || + comment.status === 'SYSTEM_WITHHELD' + ) { return prev; } return insertCommentIntoEmbedQuery(prev, comment); diff --git a/client/coral-embed-stream/src/graphql/utils.js b/client/coral-embed-stream/src/graphql/utils.js index f4be867dd..177f131dc 100644 --- a/client/coral-embed-stream/src/graphql/utils.js +++ b/client/coral-embed-stream/src/graphql/utils.js @@ -38,7 +38,7 @@ function applyToCommentsOrigin(root, callback) { function findAndInsertComment(parent, comment) { const isAsset = parent.__typename === 'Asset'; const [connectionField, countField, action] = isAsset - ? ['comments', 'commentCount', '$unshift'] + ? ['comments', 'totalCommentCount', '$unshift'] : ['replies', 'replyCount', '$push']; if ( @@ -67,19 +67,12 @@ function findAndInsertComment(parent, comment) { } export function insertCommentIntoEmbedQuery(root, comment) { - - // Increase total comment count by one. - root = update(root, { - asset: { - totalCommentCount: {$apply: (c) => c + 1}, - }, - }); return applyToCommentsOrigin(root, (origin) => findAndInsertComment(origin, comment)); } function findAndRemoveComment(parent, id) { const [connectionField, countField] = parent.__typename === 'Asset' - ? ['comments', 'commentCount'] + ? ['comments', 'totalCommentCount'] : ['replies', 'replyCount']; const connection = parent[connectionField]; @@ -104,13 +97,6 @@ function findAndRemoveComment(parent, id) { } export function removeCommentFromEmbedQuery(root, id) { - - // Decrease total comment by one. - root = update(root, { - asset: { - totalCommentCount: {$apply: (c) => c - 1}, - }, - }); return applyToCommentsOrigin(root, (origin) => findAndRemoveComment(origin, id)); } diff --git a/client/coral-embed-stream/src/tabs/stream/containers/Stream.js b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js index c39da3a6f..ccbf6c86c 100644 --- a/client/coral-embed-stream/src/tabs/stream/containers/Stream.js +++ b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js @@ -297,6 +297,7 @@ const fragments = { ignoredUsers { id } + roles } settings { organizationName @@ -336,7 +337,6 @@ const fragments = { charCount requireEmailConfirmation } - commentCount @skip(if: $hasComment) totalCommentCount @skip(if: $hasComment) comments(query: {limit: 10, excludeIgnored: $excludeIgnored, sortOrder: $sortOrder, sortBy: $sortBy}) @skip(if: $hasComment) { nodes { @@ -357,7 +357,6 @@ const fragments = { const mapStateToProps = (state) => ({ auth: state.auth, refetching: state.embed.refetching, - commentCountCache: state.stream.commentCountCache, activeReplyBox: state.stream.activeReplyBox, commentId: state.stream.commentId, assetId: state.stream.assetId, diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js index 280191f89..433d4e43b 100644 --- a/client/coral-framework/hocs/withQuery.js +++ b/client/coral-framework/hocs/withQuery.js @@ -49,8 +49,9 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { memoized = null; resolvedDocument = null; lastNetworkStatus = null; - data = null; name = ''; + apolloData = null; + data = null; // Pending subscription data. subscriptionQueue = []; @@ -83,6 +84,9 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { // Handle any pending susbcription data in the subscription queue at max once every second. // Updates are batched in written into apollo in one go. processSubscriptionQueue = throttle(() => { + if (!this.subscriptionQueue.length) { + return; + } const variables = typeof this.wrappedOptions === 'function' ? this.wrappedOptions(this.props).variables : this.wrappedOptions.variables; @@ -151,6 +155,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { }; nextData(data) { + this.apolloData = data; this.emitWhenNeeded(data); // If data was previously set, we update it in a immutable way. @@ -181,16 +186,24 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { variables: data.variables, networkStatus: data.networkStatus, loading: data.loading, - startPolling: data.startPolling, - stopPolling: data.stopPolling, - refetch: data.refetch, - updateQuery: data.updateQuery, subscribeToMoreThrottled: this.subscribeToMoreThrottled, + startPolling: (...args) => { + return this.apolloData.startPolling(...args); + }, + stopPolling: (...args) => { + return this.apolloData.stopPolling(...args); + }, + updateQuery: (...args) => { + return this.apolloData.updateQuery(...args); + }, + refetch: (...args) => { + return this.apolloData.refetch(...args); + }, subscribeToMore: (stmArgs) => { const resolvedDocument = this.resolveDocument(stmArgs.document); // Resolve document fragments before passing it to `apollo-client`. - return data.subscribeToMore({ + return this.apolloData.subscribeToMore({ ...stmArgs, document: resolvedDocument, onError: (err) => { @@ -209,7 +222,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { {variables: lmArgs.variables}); // Resolve document fragments before passing it to `apollo-client`. - return data.fetchMore({ + return this.apolloData.fetchMore({ ...lmArgs, query: resolvedDocument, }) diff --git a/client/coral-settings/containers/ProfileContainer.js b/client/coral-settings/containers/ProfileContainer.js index 1ad8b98c9..5b4f5f0ed 100644 --- a/client/coral-settings/containers/ProfileContainer.js +++ b/client/coral-settings/containers/ProfileContainer.js @@ -61,7 +61,7 @@ class ProfileContainer extends Component { return ; } - if (loading) { + if (loading || !me) { return ; } diff --git a/client/coral-ui/components/Dialog.css b/client/coral-ui/components/Dialog.css new file mode 100644 index 000000000..47bc43918 --- /dev/null +++ b/client/coral-ui/components/Dialog.css @@ -0,0 +1,13 @@ + +.dialog { +} + +:global(.backdrop) { + left: 0; + top: 0; + width: 100%; + height: 100%; + position: absolute; + background-color: black; + opacity: 0.1; +} diff --git a/client/coral-ui/components/Dialog.js b/client/coral-ui/components/Dialog.js index a7d34a512..bba386026 100644 --- a/client/coral-ui/components/Dialog.js +++ b/client/coral-ui/components/Dialog.js @@ -2,6 +2,8 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import dialogPolyfill from 'dialog-polyfill'; import 'dialog-polyfill/dialog-polyfill.css'; +import styles from './Dialog.css'; +import {Portal} from 'react-portal'; export default class Dialog extends Component { static propTypes = { @@ -52,13 +54,15 @@ export default class Dialog extends Component { const {children, className = '', onClose, onCancel, open, ...rest} = this.props; // eslint-disable-line return ( - { this.dialog = el; }} - className={`mdl-dialog ${className}`} - {...rest} - > - {children} - + + { this.dialog = el; }} + className={`mdl-dialog ${className} ${styles.dialog}`} + {...rest} + > + {children} + + ); } } diff --git a/config.js b/config.js index 7b18aba8f..ce830957c 100644 --- a/config.js +++ b/config.js @@ -16,17 +16,27 @@ const debug = require('debug')('talk:config'); //============================================================================== const CONFIG = { - // WEBPACK indicates when webpack is currently building. WEBPACK: process.env.WEBPACK === 'TRUE', APOLLO_ENGINE_KEY: process.env.APOLLO_ENGINE_KEY || null, ENABLE_TRACING: Boolean(process.env.APOLLO_ENGINE_KEY), + // EMAIL_SUBJECT_PREFIX is the string before emails in the subject. + EMAIL_SUBJECT_PREFIX: process.env.TALK_EMAIL_SUBJECT_PREFIX || '[Talk]', + + // DEFAULT_LANG is the default language used for server sent emails and + // rendered text. + DEFAULT_LANG: process.env.TALK_DEFAULT_LANG || 'en', + // When TRUE, it ensures that database indexes created in core will not add // indexes. CREATE_MONGO_INDEXES: process.env.DISABLE_CREATE_MONGO_INDEXES !== 'TRUE', + // SETTINGS_CACHE_TIME is the time that we'll cache the settings in redis before + // fetching again. + SETTINGS_CACHE_TIME: ms(process.env.TALK_SETTINGS_CACHE_TIME || '1hr'), + //------------------------------------------------------------------------------ // JWT based configuration //------------------------------------------------------------------------------ @@ -44,14 +54,19 @@ const CONFIG = { // JWT_SIGNING_COOKIE_NAME will be the cookie set when cookies are issued. // This defaults to the TALK_JWT_COOKIE_NAME value. - JWT_SIGNING_COOKIE_NAME: process.env.TALK_JWT_SIGNING_COOKIE_NAME || process.env.TALK_JWT_COOKIE_NAME || 'authorization', + JWT_SIGNING_COOKIE_NAME: + process.env.TALK_JWT_SIGNING_COOKIE_NAME || + process.env.TALK_JWT_COOKIE_NAME || + 'authorization', // JWT_COOKIE_NAMES declares the many cookie names used for verification. JWT_COOKIE_NAMES: process.env.TALK_JWT_COOKIE_NAMES || null, // JWT_CLEAR_COOKIE_LOGOUT specifies whether the named cookie should be // cleared when the user is logged out. - JWT_CLEAR_COOKIE_LOGOUT: process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT ? process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT !== 'FALSE' : true, + JWT_CLEAR_COOKIE_LOGOUT: process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT + ? process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT !== 'FALSE' + : true, // JWT_DISABLE_AUDIENCE when TRUE will disable the audience claim (aud) from tokens. JWT_DISABLE_AUDIENCE: process.env.TALK_JWT_DISABLE_AUDIENCE === 'TRUE', @@ -92,7 +107,9 @@ const CONFIG = { // HELMET_CONFIGURATION provides the entrypoint to override options for the // helmet middleware used. - HELMET_CONFIGURATION: JSON.parse(process.env.TALK_HELMET_CONFIGURATION || '{}'), + HELMET_CONFIGURATION: JSON.parse( + process.env.TALK_HELMET_CONFIGURATION || '{}' + ), //------------------------------------------------------------------------------ // External database url's @@ -111,22 +128,30 @@ const CONFIG = { // REDIS_CLUSTER_CONFIGURATION contains the json string for the redis cluster // configuration. - REDIS_CLUSTER_CONFIGURATION: process.env.TALK_REDIS_CLUSTER_CONFIGURATION || '[]', + REDIS_CLUSTER_CONFIGURATION: + process.env.TALK_REDIS_CLUSTER_CONFIGURATION || '[]', // REDIS_RECONNECTION_BACKOFF_FACTOR is the factor that will be multiplied // against the current attempt count inbetween attempts to connect to redis. - REDIS_RECONNECTION_BACKOFF_FACTOR: ms(process.env.TALK_REDIS_RECONNECTION_BACKOFF_FACTOR || '500 ms'), + REDIS_RECONNECTION_BACKOFF_FACTOR: ms( + process.env.TALK_REDIS_RECONNECTION_BACKOFF_FACTOR || '500 ms' + ), // REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME is the minimum time used to delay // before attempting to reconnect to redis. - REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME: ms(process.env.TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME || '1 sec'), + REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME: ms( + process.env.TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME || '1 sec' + ), //------------------------------------------------------------------------------ // Server Config //------------------------------------------------------------------------------ // Port to bind to. - PORT: process.env.TALK_PORT || process.env.PORT || (process.env.NODE_ENV === 'test' ? '3001' : '3000'), + PORT: + process.env.TALK_PORT || + process.env.PORT || + (process.env.NODE_ENV === 'test' ? '3001' : '3000'), // The URL for this Talk Instance as viewable from the outside. ROOT_URL: process.env.TALK_ROOT_URL || null, @@ -150,7 +175,8 @@ const CONFIG = { // Cache configuration //------------------------------------------------------------------------------ - CACHE_EXPIRY_COMMENT_COUNT: process.env.TALK_CACHE_EXPIRY_COMMENT_COUNT || '1hr', + CACHE_EXPIRY_COMMENT_COUNT: + process.env.TALK_CACHE_EXPIRY_COMMENT_COUNT || '1hr', //------------------------------------------------------------------------------ // Recaptcha configuration @@ -170,7 +196,9 @@ const CONFIG = { SMTP_FROM_ADDRESS: process.env.TALK_SMTP_FROM_ADDRESS, SMTP_HOST: process.env.TALK_SMTP_HOST, SMTP_PASSWORD: process.env.TALK_SMTP_PASSWORD, - SMTP_PORT: process.env.TALK_SMTP_PORT ? parseInt(process.env.TALK_SMTP_PORT) : undefined, + SMTP_PORT: process.env.TALK_SMTP_PORT + ? parseInt(process.env.TALK_SMTP_PORT) + : undefined, SMTP_USERNAME: process.env.TALK_SMTP_USERNAME, //------------------------------------------------------------------------------ @@ -179,7 +207,8 @@ const CONFIG = { // DISABLE_AUTOFLAG_SUSPECT_WORDS is true when the suspect words that are // matched should not be flagged. - DISABLE_AUTOFLAG_SUSPECT_WORDS: process.env.TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS === 'TRUE', + DISABLE_AUTOFLAG_SUSPECT_WORDS: + process.env.TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS === 'TRUE', // TRUST_THRESHOLDS defines the thresholds used for automoderation. TRUST_THRESHOLDS: process.env.TRUST_THRESHOLDS || 'comment:2,-1;flag:2,-1', @@ -187,7 +216,8 @@ const CONFIG = { // IGNORE_FLAGS_AGAINST_STAFF disables staff members from entering the // reported queue from comments after this was enabled and from reports // against the staff members user account. - IGNORE_FLAGS_AGAINST_STAFF: process.env.TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF !== 'TRUE', + IGNORE_FLAGS_AGAINST_STAFF: + process.env.TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF !== 'TRUE', }; //============================================================================== @@ -214,7 +244,9 @@ if (CONFIG.JWT_SECRETS) { } else if (!CONFIG.JWT_SECRET) { if (process.env.NODE_ENV === 'test') { if (!CONFIG.JWT_ALG.startsWith('HS')) { - throw new Error('Providing a asymmetric signing/verfying algorithm without a corresponding secret is not permitted'); + throw new Error( + 'Providing a asymmetric signing/verfying algorithm without a corresponding secret is not permitted' + ); } CONFIG.JWT_SECRET = 'keyboard cat'; @@ -243,7 +275,12 @@ if (CONFIG.JWT_COOKIE_NAMES) { } // Add in the default cookie names and strip duplicates. -CONFIG.JWT_COOKIE_NAMES = uniq(CONFIG.JWT_COOKIE_NAMES.concat([CONFIG.JWT_COOKIE_NAME, CONFIG.JWT_SIGNING_COOKIE_NAME])); +CONFIG.JWT_COOKIE_NAMES = uniq( + CONFIG.JWT_COOKIE_NAMES.concat([ + CONFIG.JWT_COOKIE_NAME, + CONFIG.JWT_SIGNING_COOKIE_NAME, + ]) +); //------------------------------------------------------------------------------ // External database url's @@ -265,17 +302,25 @@ if (process.env.NODE_ENV === 'test' && !CONFIG.REDIS_URL) { // REDIS_CLUSTER_CONFIGURATION should be parsed when the cluster mode !== none. if (CONFIG.REDIS_CLUSTER_MODE === 'CLUSTER') { try { - CONFIG.REDIS_CLUSTER_CONFIGURATION = JSON.parse(CONFIG.REDIS_CLUSTER_CONFIGURATION); + CONFIG.REDIS_CLUSTER_CONFIGURATION = JSON.parse( + CONFIG.REDIS_CLUSTER_CONFIGURATION + ); } catch (err) { - throw new Error('TALK_REDIS_CLUSTER_CONFIGURATION is not valid JSON, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes'); + throw new Error( + 'TALK_REDIS_CLUSTER_CONFIGURATION is not valid JSON, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes' + ); } if (!Array.isArray(CONFIG.REDIS_CLUSTER_CONFIGURATION)) { - throw new Error('TALK_REDIS_CLUSTER_MODE is CLUSTER, but the TALK_REDIS_CLUSTER_CONFIGURATION is invalid, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes'); + throw new Error( + 'TALK_REDIS_CLUSTER_MODE is CLUSTER, but the TALK_REDIS_CLUSTER_CONFIGURATION is invalid, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes' + ); } if (CONFIG.REDIS_CLUSTER_CONFIGURATION.length === 0) { - throw new Error('TALK_REDIS_CLUSTER_CONFIGURATION must have at least one node specified in the cluster, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes'); + throw new Error( + 'TALK_REDIS_CLUSTER_CONFIGURATION must have at least one node specified in the cluster, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes' + ); } } @@ -296,7 +341,13 @@ CONFIG.RECAPTCHA_ENABLED = CONFIG.RECAPTCHA_PUBLIC && CONFIG.RECAPTCHA_PUBLIC.length > 0; -debug(`reCAPTCHA is ${CONFIG.RECAPTCHA_ENABLED ? 'enabled' : 'disabled, required config is not present'}`); +debug( + `reCAPTCHA is ${ + CONFIG.RECAPTCHA_ENABLED + ? 'enabled' + : 'disabled, required config is not present' + }` +); //------------------------------------------------------------------------------ // SMTP Server configuration @@ -312,6 +363,12 @@ CONFIG.EMAIL_ENABLED = CONFIG.SMTP_HOST && CONFIG.SMTP_HOST.length > 0; -debug(`Email is ${CONFIG.EMAIL_ENABLED ? 'enabled' : 'disabled, required config is not present'}`); +debug( + `Email is ${ + CONFIG.EMAIL_ENABLED + ? 'enabled' + : 'disabled, required config is not present' + }` +); module.exports = CONFIG; diff --git a/docs/_docs/02-02-advanced-configuration.md b/docs/_docs/02-02-advanced-configuration.md index 94a6b402d..47be44fcc 100644 --- a/docs/_docs/02-02-advanced-configuration.md +++ b/docs/_docs/02-02-advanced-configuration.md @@ -460,4 +460,10 @@ Could be read as: ## TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF When `TRUE`, staff members will have their accounts and comments moderated the -same as any other user in the system. (Default `FALSE`) \ No newline at end of file +same as any other user in the system. (Default `FALSE`) + +## TALK_EMAIL_SUBJECT_PREFIX + +The prefix for the subject of emails sent. An email with the specified subject +of `Email Confirmation` would then be sent as `[Talk] Email Confirmation`. +(Default `[Talk]`) \ No newline at end of file diff --git a/graph/connectors.js b/graph/connectors.js index 34758e707..761d3d8ed 100644 --- a/graph/connectors.js +++ b/graph/connectors.js @@ -1,3 +1,6 @@ +const debug = require('debug')('talk:graph:connectors'); +const merge = require('lodash/merge'); + // Errors. const errors = require('../errors'); @@ -76,4 +79,9 @@ const connectors = { }, }; -module.exports = connectors; +module.exports = Plugins.get('server', 'connectors').reduce((defaultConnectors, {plugin, connectors: pluginConnectors}) => { + debug(`adding plugin '${plugin.name}'`); + + // Merge in the plugin connectors. + return merge(defaultConnectors, pluginConnectors); +}, connectors); diff --git a/graph/context.js b/graph/context.js index d6d85a651..e894c1534 100644 --- a/graph/context.js +++ b/graph/context.js @@ -42,14 +42,15 @@ const decorateContextPlugins = (context, contextPlugins) => { * Stores the request context. */ class Context { - constructor({user = null}) { + constructor(parent) { - // Generate a new context id for the request. - this.id = uuid.v4(); + // Generate a new context id for the request if the parent doesn't provide + // one. + this.id = parent.id || uuid.v4(); - // Load the current logged in user to `user`, otherwise this'll be null. - if (user) { - this.user = user; + // Load the current logged in user to `user`, otherwise this will be null. + if (parent.user) { + this.user = parent.user; } // Attach the connectors. @@ -66,6 +67,9 @@ class Context { // Bind the publish/subscribe to the context. this.pubsub = pubsub.getClient(); + + // Bind the parent context. + this.parent = parent; } } diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index e01ecfd36..8757a02b3 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -162,7 +162,7 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu // just added a new comment, hence the counts should be updated. We should // perform these increments in the event that we do have a new comment that // is approved or without a comment. - if (status === 'NONE' || status === 'APPROVED') { + if (status === 'NONE' || status === 'ACCEPTED') { if (parent_id === null) { Comments.parentCountByAssetID.incr(asset_id); } diff --git a/graph/schema.js b/graph/schema.js index d4cd9f890..d9df096a4 100644 --- a/graph/schema.js +++ b/graph/schema.js @@ -1,4 +1,8 @@ -const {makeExecutableSchema} = require('graphql-tools'); +const { + makeExecutableSchema, + addSchemaLevelResolveFunction, +} = require('graphql-tools'); +const debug = require('debug')('talk:graph:schema'); const {decorateWithHooks} = require('./hooks'); const {decorateWithErrorHandler} = require('./errorHandler'); @@ -14,4 +18,11 @@ decorateWithHooks(schema, plugins.get('server', 'hooks')); // Handle errors like masking in production and mutation errors. decorateWithErrorHandler(schema); +// For each schemaLevelResolveFunction, add it to the schema. +plugins.get('server', 'schemaLevelResolveFunction').forEach(({plugin, schemaLevelResolveFunction}) => { + debug(`added schemaLevelResolveFunction from plugin '${plugin.name}'`); + + addSchemaLevelResolveFunction(schema, schemaLevelResolveFunction); +}); + module.exports = schema; diff --git a/graph/subscriptions.js b/graph/subscriptions.js index 59dd6e923..60afc9c45 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions.js @@ -5,6 +5,7 @@ const debug = require('debug')('talk:graph:subscriptions'); const pubsub = require('../services/pubsub'); const schema = require('./schema'); const Context = require('./context'); +const plugins = require('../services/plugins'); const {deserializeUser} = require('../services/subscriptions'); const setupFunctions = require('./setupFunctions'); @@ -16,16 +17,42 @@ const { const {BASE_PATH} = require('../url'); -const onConnect = ({token}, connection) => { +// Collect all the plugin hooks that should be executed onConnect and +// onDisconnect. +const hooks = plugins.get('server', 'websockets') + .map(({plugin, websockets}) => { + debug(`added websocket hooks ${Object.keys(websockets)} from plugin '${plugin.name}'`); + + return websockets; + }) + .reduce((hooks, {onConnect = null, onDisconnect = null}) => { + if (onConnect) { + hooks.onConnect.push(onConnect); + } + + if (onDisconnect) { + hooks.onDisconnect.push(onDisconnect); + } + + return hooks; + }, { + onConnect: [], + onDisconnect: [], + }); + +const onConnect = async (connectionParams, connection) => { // Attach the token from the connection options if it was provided. - if (token) { + if (connectionParams.token) { debug('token sent via onConnect, attaching to the headers of the upgrade request'); // Attach it to the upgrade request. - connection.upgradeReq.headers['authorization'] = `Bearer ${token}`; + connection.upgradeReq.headers['authorization'] = `Bearer ${connectionParams.token}`; } + + // Call all the hooks. + await Promise.all(hooks.onConnect.map((hook) => hook(connectionParams, connection))); }; const onOperation = (parsedMessage, baseParams, connection) => { @@ -52,6 +79,9 @@ const onOperation = (parsedMessage, baseParams, connection) => { return baseParams; }; +const onDisconnect = (connection) => + Promise.all(hooks.onDisconnect.map((hook) => hook(connection))); + /** * This creates a new subscription manager. */ @@ -62,6 +92,7 @@ const createSubscriptionManager = (server) => new SubscriptionServer({ setupFunctions, }), onConnect, + onDisconnect, onOperation, keepAlive: ms(KEEP_ALIVE) }, { diff --git a/locales/da.yml b/locales/da.yml index 0a5152cd9..d4544043c 100644 --- a/locales/da.yml +++ b/locales/da.yml @@ -285,7 +285,7 @@ da: no_like_bio: "Jeg kan ikke lide denne biografi" no_like_username: "Jeg kan ikke lide dette brugernavn" other: "Andet" - permalink: "Link" + permalink: "Delen" personal_info: "Denne kommentar afslører personligt identificerbare oplysninger" post: "Post" profile: "Profil" diff --git a/locales/en.yml b/locales/en.yml index 10f537fb2..2926d140a 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -171,6 +171,11 @@ en: minute: "minute" minutes_plural: "minutes" email: + suspended: + subject: "Your account has been suspended" + banned: + subject: "Your account has been banned" + body: "In accordance with The Coral Project’s community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community." confirm: has_been_requested: "A email confirmation has been requested for the following account:" to_confirm: "To confirm the account, please visit the following link:" @@ -320,7 +325,7 @@ en: no_like_bio: "I don't like this bio" no_like_username: "I don't like this username" other: Other - permalink: Link + permalink: Share personal_info: "This comment reveals personally identifiable information" post: Post profile: Profile diff --git a/locales/es.yml b/locales/es.yml index e8369d949..584e83f7b 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -301,7 +301,7 @@ es: no_like_bio: "No me gusta esta biografia" no_like_username: "No me gusta este nombre de usuario" other: Otro - permalink: Enlace + permalink: Compartir personal_info: "Este comentario muestra información personal" post: Publicar profile: Perfil diff --git a/locales/fr.yml b/locales/fr.yml index 31ad3ca91..e9f291b65 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -237,7 +237,7 @@ fr: no_like_bio: "Je n'aime pas cette biographie" no_like_username: "Je n'aime pas ce nom d'utilisateur" other: Autre - permalink: Lien + permalink: Partager personal_info: "Ce commentaire révèle des informations personnelles identifiables" post: Publier profile: Profil diff --git a/locales/pt_BR.yml b/locales/pt_BR.yml index bdfe72d5a..7ed111287 100644 --- a/locales/pt_BR.yml +++ b/locales/pt_BR.yml @@ -288,7 +288,7 @@ pt_BR: no_like_bio: "Eu não gosto dessa descrição de perfil" no_like_username: "Eu não gosto deste nome de usuário" other: Outro - permalink: Link + permalink: Compartilhar personal_info: "Este comentário revela informações de identificação pessoal" post: Publicar profile: Perfil diff --git a/middleware/i18n.js b/middleware/i18n.js new file mode 100644 index 000000000..c2ca2436a --- /dev/null +++ b/middleware/i18n.js @@ -0,0 +1,6 @@ +const i18n = require('../services/i18n'); + +module.exports = (req, res, next) => { + res.locals.t = i18n.request(req); + next(); +}; diff --git a/nightwatch.conf.js b/nightwatch.conf.js index e04cc04a8..260738e35 100644 --- a/nightwatch.conf.js +++ b/nightwatch.conf.js @@ -9,13 +9,13 @@ module.exports = { globals_path: './test/e2e/globals', selenium: { start_process: true, - server_path: 'node_modules/selenium-standalone/.selenium/selenium-server/3.6.0-server.jar', + server_path: 'node_modules/selenium-standalone/.selenium/selenium-server/3.7.1-server.jar', log_path: './test/e2e/', host: '127.0.0.1', port: 6666, cli_args: { 'webdriver.chrome.driver': 'node_modules/selenium-standalone/.selenium/chromedriver/2.33-x64-chromedriver', - 'webdriver.gecko.driver': 'node_modules/selenium-standalone/.selenium/geckodriver/0.19.0-x64-geckodriver', + 'webdriver.gecko.driver': 'node_modules/selenium-standalone/.selenium/geckodriver/0.19.1-x64-geckodriver', } }, test_settings: { diff --git a/package.json b/package.json index cb30fce0f..febd7551d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "talk", - "version": "3.8.2", + "version": "3.9.0", "description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net", "main": "app.js", "private": true, @@ -71,7 +71,6 @@ "babel-preset-es2015": "6.24.1", "babel-preset-react": "^6.23.0", "bcryptjs": "^2.4.3", - "body-parser": "1.18.2", "bowser": "^1.7.2", "brotli-webpack-plugin": "^0.5.0", "cli-table": "^0.3.1", @@ -153,6 +152,7 @@ "react-mdl": "^1.11.0", "react-mdl-selectfield": "^0.2.0", "react-paginate": "^5.0.0", + "react-portal": "^4.1.2", "react-recaptcha": "^2.2.6", "react-redux": "^4.4.5", "react-router": "^3.0.0", @@ -160,6 +160,7 @@ "react-test-renderer": "15.5", "react-toastify": "^1.5.0", "react-transition-group": "^1.1.3", + "react-virtualized": "9.13.0", "recompose": "^0.23.1", "redux": "^3.6.0", "redux-thunk": "^2.1.0", diff --git a/plugin-api/beta/client/hocs/withTags.js b/plugin-api/beta/client/hocs/withTags.js index 66fd7ad2e..3edc66824 100644 --- a/plugin-api/beta/client/hocs/withTags.js +++ b/plugin-api/beta/client/hocs/withTags.js @@ -87,11 +87,12 @@ export default (tag, options = {}) => hoistStatics((WrappedComponent) => { } render() { - const {root, asset, comment, user, config} = this.props; + const {root, asset, comment, user, config, ...rest} = this.props; const alreadyTagged = isTagged(comment.tags, TAG); return hoistStatics((WrappedComponent) => { ${fragments.comment ? fragments.comment : ''} ` }), - connect(mapStateToProps, mapDispatchToProps), withAddTag, - withRemoveTag + withRemoveTag, + connect(mapStateToProps, mapDispatchToProps), ); WithTags.displayName = `WithTags(${getDisplayName(WrappedComponent)})`; diff --git a/plugins.js b/plugins.js index dfe3d3312..577289b25 100644 --- a/plugins.js +++ b/plugins.js @@ -55,7 +55,12 @@ const hookSchemas = { loaders: Joi.func().maxArity(1), mutators: Joi.func().maxArity(1), resolvers: Joi.object().pattern(/\w/, Joi.object().pattern(/(?:__resolveType|\w+)/, Joi.func())), - typeDefs: Joi.string() + typeDefs: Joi.string(), + schemaLevelResolveFunction: Joi.func(), + websockets: Joi.object({ + onConnect: Joi.func(), + onDisconnect: Joi.func(), + }), }; /** @@ -172,7 +177,7 @@ class PluginSection { if (this.required) { return; } - + this.required = true; this.plugins.forEach((plugin) => { diff --git a/plugins/talk-plugin-featured-comments/client/actions.js b/plugins/talk-plugin-featured-comments/client/actions.js new file mode 100644 index 000000000..e16f06f48 --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/actions.js @@ -0,0 +1,16 @@ +import {OPEN_FEATURED_DIALOG, CLOSE_FEATURED_DIALOG} from './constants'; + +export const openFeaturedDialog = (comment, asset) => ({ + type: OPEN_FEATURED_DIALOG, + comment: { + id: comment.id, + tags: comment.tags, + }, + asset: { + id: asset.id, + }, +}); + +export const closeFeaturedDialog = () => ({ + type: CLOSE_FEATURED_DIALOG, +}); diff --git a/plugins/talk-plugin-featured-comments/client/components/FeaturedDialog.css b/plugins/talk-plugin-featured-comments/client/components/FeaturedDialog.css new file mode 100644 index 000000000..4326d5c5c --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/components/FeaturedDialog.css @@ -0,0 +1,41 @@ +.dialog { + border: none; + box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2); + width: 400px; + top: 50%; + transform: translateY(-50%); + padding: 20px; + border-radius: 4px; +} + +.header { + color: black; + font-size: 1.5em; + font-weight: 500; + margin: 0 0 8px 0; +} + +.close { + display: block; + position: absolute; + top: 24px; + right: 20px; +} + +.cancel { + margin-right: 5px; +} + +.perform { + min-width: 90px; +} + +.buttons { + margin-top: 8px; + margin-bottom: 6px; + text-align: right; +} + +.content { + margin-bottom: 20px; +} diff --git a/plugins/talk-plugin-featured-comments/client/components/FeaturedDialog.js b/plugins/talk-plugin-featured-comments/client/components/FeaturedDialog.js new file mode 100644 index 000000000..81c054ae8 --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/components/FeaturedDialog.js @@ -0,0 +1,52 @@ +import React from 'react'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; +import {Dialog} from 'coral-ui'; +import styles from './FeaturedDialog.css'; +import {t} from 'plugin-api/beta/client/services'; +import Button from 'coral-ui/components/Button'; + +const FeaturedDialog = ({showFeaturedDialog, closeFeaturedDialog, postTag}) => { + + const onPerform = async () => { + await postTag(); + await closeFeaturedDialog(); + }; + + return ( + + × +

    {t('talk-plugin-featured-comments.feature_comment')}

    +
    + {t('talk-plugin-featured-comments.are_you_sure')} +
    +
    + + +
    +
    + ); +}; + +FeaturedDialog.propTypes = { + showFeaturedDialog: PropTypes.bool.isRequired, + closeFeaturedDialog: PropTypes.func.isRequired, +}; + +export default FeaturedDialog; diff --git a/plugins/talk-plugin-featured-comments/client/components/ModTag.js b/plugins/talk-plugin-featured-comments/client/components/ModTag.js index 0664f07cd..70f51dcbc 100644 --- a/plugins/talk-plugin-featured-comments/client/components/ModTag.js +++ b/plugins/talk-plugin-featured-comments/client/components/ModTag.js @@ -1,9 +1,9 @@ import React from 'react'; +import PropTypes from 'prop-types'; import cn from 'classnames'; import styles from './ModTag.css'; import {t} from 'plugin-api/beta/client/services'; import {Icon} from 'plugin-api/beta/client/components/ui'; -import {getErrorMessages} from 'plugin-api/beta/client/utils'; export default class ModTag extends React.Component { constructor() { @@ -29,17 +29,12 @@ export default class ModTag extends React.Component { }); } - postTag = async () => { - try { - await this.props.postTag(); - } - catch(err) { - this.props.notify('error', getErrorMessages(err)); - } + openFeaturedDialog = (comment, asset) => { + this.props.openFeaturedDialog(comment, asset); } render() { - const {alreadyTagged, deleteTag} = this.props; + const {alreadyTagged, deleteTag, comment, asset} = this.props; return alreadyTagged ? ( ) : ( + onClick={() => this.openFeaturedDialog(comment, asset)} > {alreadyTagged ? t('talk-plugin-featured-comments.featured') : t('talk-plugin-featured-comments.feature')} ); } } + +ModTag.propTypes = { + alreadyTagged: PropTypes.bool, + deleteTag: PropTypes.func, + notify: PropTypes.func, + openFeaturedDialog: PropTypes.func, + comment: PropTypes.object, + asset: PropTypes.object, +}; diff --git a/plugins/talk-plugin-featured-comments/client/constants.js b/plugins/talk-plugin-featured-comments/client/constants.js new file mode 100644 index 000000000..912f6f002 --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/constants.js @@ -0,0 +1,4 @@ +const prefix = 'TALK_FEATURED_COMMENTS_ACTIONS'; + +export const OPEN_FEATURED_DIALOG = `${prefix}_OPEN_FEATURED_DIALOG`; +export const CLOSE_FEATURED_DIALOG = `${prefix}_CLOSE_FEATURED_DIALOG`; diff --git a/plugins/talk-plugin-featured-comments/client/containers/FeaturedDialog.js b/plugins/talk-plugin-featured-comments/client/containers/FeaturedDialog.js new file mode 100644 index 000000000..0440e9d08 --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/containers/FeaturedDialog.js @@ -0,0 +1,23 @@ +import {compose} from 'react-apollo'; +import {bindActionCreators} from 'redux'; +import FeaturedDialog from '../components/FeaturedDialog'; +import {withTags, connect} from 'plugin-api/beta/client/hocs'; +import {closeFeaturedDialog} from '../actions'; + +const mapStateToProps = ({talkPluginFeaturedComments: state}) => ({ + showFeaturedDialog: state.showFeaturedDialog, + comment: state.comment, + asset: state.asset, +}); + +const mapDispatchToProps = (dispatch) => + bindActionCreators({ + closeFeaturedDialog, + }, dispatch); + +const enhance = compose( + connect(mapStateToProps, mapDispatchToProps), + withTags('featured'), +); + +export default enhance(FeaturedDialog); diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js b/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js index 0e9fc8c89..7e8d7d426 100644 --- a/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js +++ b/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js @@ -65,6 +65,14 @@ const COMMENT_FEATURED_SUBSCRIPTION = gql` commentFeatured(asset_id: $assetId) { comment { ...${getDefinitionName(Comment.fragments.comment)} + status_history { + type + created_at + assigned_by { + id + username + } + } } user { id diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModTag.js b/plugins/talk-plugin-featured-comments/client/containers/ModTag.js index 88aee73f6..35e7edfcd 100644 --- a/plugins/talk-plugin-featured-comments/client/containers/ModTag.js +++ b/plugins/talk-plugin-featured-comments/client/containers/ModTag.js @@ -2,11 +2,13 @@ import ModTag from '../components/ModTag'; import {withTags, connect} from 'plugin-api/beta/client/hocs'; import {gql, compose} from 'react-apollo'; import {bindActionCreators} from 'redux'; +import {openFeaturedDialog} from '../actions'; import {notify} from 'plugin-api/beta/client/actions/notification'; const mapDispatchToProps = (dispatch) => bindActionCreators({ notify, + openFeaturedDialog, }, dispatch); const fragments = { @@ -24,4 +26,3 @@ const enhance = compose( ); export default enhance(ModTag); - diff --git a/plugins/talk-plugin-featured-comments/client/index.js b/plugins/talk-plugin-featured-comments/client/index.js index 7a0b9827d..304bff647 100644 --- a/plugins/talk-plugin-featured-comments/client/index.js +++ b/plugins/talk-plugin-featured-comments/client/index.js @@ -6,19 +6,22 @@ import update from 'immutability-helper'; import ModTag from './containers/ModTag'; import ModActionButton from './containers/ModActionButton'; import ModSubscription from './containers/ModSubscription'; +import FeaturedDialog from './containers/FeaturedDialog'; import {gql} from 'react-apollo'; +import reducer from './reducer'; import {findCommentInEmbedQuery} from 'coral-embed-stream/src/graphql/utils'; import {prependNewNodes} from 'plugin-api/beta/client/utils'; export default { translations, + reducer, slots: { streamTabsPrepend: [Tab], streamTabPanes: [TabPane], commentInfoBar: [Tag], moderationActions: [ModActionButton], - adminModeration: [ModSubscription], + adminModeration: [ModSubscription, FeaturedDialog], adminCommentInfoBar: [ModTag], }, mutations: { diff --git a/plugins/talk-plugin-featured-comments/client/reducer.js b/plugins/talk-plugin-featured-comments/client/reducer.js new file mode 100644 index 000000000..eb6d302ff --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/reducer.js @@ -0,0 +1,32 @@ +import {OPEN_FEATURED_DIALOG, CLOSE_FEATURED_DIALOG} from './constants'; + +const initialState = { + showFeaturedDialog: false, + comment: { + id: null, + tags: [] + }, + asset: { + id: null, + }, +}; + +export default function reducer(state = initialState, action) { + switch (action.type) { + case OPEN_FEATURED_DIALOG: + return { + ...state, + comment: action.comment, + asset: action.asset, + showFeaturedDialog: true, + }; + case CLOSE_FEATURED_DIALOG: + return { + ...state, + featuredCommentId: null, + showFeaturedDialog: false, + }; + default : + return state; + } +} diff --git a/plugins/talk-plugin-featured-comments/client/translations.yml b/plugins/talk-plugin-featured-comments/client/translations.yml index ea504cc9e..460a76be9 100644 --- a/plugins/talk-plugin-featured-comments/client/translations.yml +++ b/plugins/talk-plugin-featured-comments/client/translations.yml @@ -9,6 +9,10 @@ en: notify_self_featured: 'The comment from {0} is now featured and approved' notify_featured: '{0} featured and approved comment "{1}"' notify_unfeatured: '{0} unfeatured comment "{1}"' + feature_comment: Feature comment? + are_you_sure: Are you sure you would like to feature this comment? + cancel: Cancel + yes_feature_comment: Yes, feature comment es: talk-plugin-featured-comments: un_feature: Desmarcar @@ -17,3 +21,7 @@ es: featured_comments: Comentarios Remarcados go_to_conversation: Ir al comentario tooltip_description: Comentarios seleccionados por nuestro equipo que valen la pena ser leidos + feature_comment: Destacar comentario? + are_you_sure: Está seguro que desea destacar este comentario? + cancel: Cancelar + yes_feature_comment: Si, destacar comentario \ No newline at end of file diff --git a/routes/index.js b/routes/index.js index 3085fcf8e..510a6b403 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,13 +1,12 @@ const SetupService = require('../services/setup'); const apollo = require('apollo-server-express'); const authentication = require('../middleware/authentication'); -const bodyParser = require('body-parser'); const cookieParser = require('cookie-parser'); const debug = require('debug')('talk:routes'); const enabled = require('debug').enabled; const errors = require('../errors'); const express = require('express'); -const i18n = require('../services/i18n'); +const i18n = require('../middleware/i18n'); const path = require('path'); const plugins = require('../services/plugins'); const staticTemplate = require('../middleware/staticTemplate'); @@ -41,6 +40,9 @@ if (!DISABLE_STATIC_SERVER) { })); } +// Add the i18n middleware to all routes. +router.use(i18n); + //============================================================================== // STATIC ROUTES //============================================================================== @@ -56,7 +58,7 @@ router.use('/embed', staticTemplate, require('./embed')); router.use(cookieParser()); // Parse the body json if it's there. -router.use(bodyParser.json()); +router.use(express.json()); const passportDebug = require('debug')('talk:passport'); @@ -100,12 +102,12 @@ if (process.env.NODE_ENV !== 'production') { } +router.use('/api/v1', require('./api')); + //============================================================================== // ROUTES //============================================================================== -router.use('/api/v1', require('./api')); - // Development routes. if (process.env.NODE_ENV !== 'production') { router.use('/assets', staticTemplate, require('./assets')); @@ -175,8 +177,6 @@ router.use('/', (err, req, res, next) => { console.error(err); } - i18n.init(req); - if (err instanceof errors.APIError) { res.status(err.status); res.render('error', { diff --git a/scripts/e2e-ci.sh b/scripts/e2e-ci.sh index 3e9726340..a509ccee7 100755 --- a/scripts/e2e-ci.sh +++ b/scripts/e2e-ci.sh @@ -2,17 +2,26 @@ REPORTS_FOLDER=${CIRCLE_TEST_REPORTS:-./test/e2e/reports} CIRCLE_BRANCH=${CIRCLE_BRANCH:-master} +E2E_DISABLE=${E2E_DISABLE:-false} # Amount of retries before failure. E2E_MAX_RETRIES=${E2E_MAX_RETRIES:-1} +# Timeout for WaitForConditions. +E2E_WAIT_FOR_TIMEOUT=${E2E_WAIT_FOR_TIMEOUT:-10000} + # Safari >= 8 has issues connecting to browserstack-local. Safari < 8 is too old. # IE 64bit has issues with receiving keyboard input. Let's wait for them to fix it. -BROWSERS="chrome,firefox,edge" #ie safari +E2E_BROWSERS=${E2E_BROWSERS:-chrome,firefox,edge} #ie safari + +if [[ "${E2E_DISABLE}" == "true" ]]; then + echo E2E is disabled. + exit +fi if [[ "${CIRCLE_BRANCH}" == "master" && -n "$BROWSERSTACK_KEY" ]]; then echo Testing on browserstack - yarn e2e --reports-folder "$REPORTS_FOLDER" --bs-key "$BROWSERSTACK_KEY" --retries "$E2E_MAX_RETRIES" --browsers $BROWSERS + yarn e2e --reports-folder "$REPORTS_FOLDER" --bs-key "$BROWSERSTACK_KEY" --retries "$E2E_MAX_RETRIES" --timeout "$E2E_WAIT_FOR_TIMEOUT" --browsers "$E2E_BROWSERS" else # When browserstack is not available test locally using chrome headless. echo Testing locally diff --git a/scripts/e2e.js b/scripts/e2e.js index 97e321310..d1d06e99a 100755 --- a/scripts/e2e.js +++ b/scripts/e2e.js @@ -59,7 +59,7 @@ function seleniumInstall() { }); } -function nightwatch(env, config, reportsFolder, browserstack) { +function nightwatch(env, config, reportsFolder, browserstack, timeout) { return new Promise((resolve, reject) => { try { const nw = childProcess.spawn( @@ -71,6 +71,7 @@ function nightwatch(env, config, reportsFolder, browserstack) { 'BROWSERSTACK_KEY': browserstack.key, 'BROWSERSTACK_USER': browserstack.user, 'REPORTS_FOLDER': `${reportsFolder}/${env}`, + 'WAIT_FOR_TIMEOUT': timeout, }), stdio: 'inherit', }); @@ -111,13 +112,13 @@ function printSection(txt) { console.log('*****************************'.magenta); } -async function runBrowserTests(browsers, config, retries = 1, reportsFolder, browserstack) { +async function runBrowserTests(browsers, config, retries = 1, reportsFolder, browserstack, timeout) { const succeeded = {}; for (let browser of browsers) { for (let t = 0; t < retries + 1; t++) { try { printSection(`e2e test for ${browser} #${t}`); - await nightwatch(browser, config, reportsFolder, browserstack); + await nightwatch(browser, config, reportsFolder, browserstack, timeout); succeeded[browser] = t; console.log(`\n==> Succeeded e2e for ${browser} #${t}\n`.green); break; @@ -143,6 +144,7 @@ async function start(program) { const date = new Date().toISOString() .replace(/[T.]/g, '-') .replace(/:/g, ''); + const timeout = program.timeout; const reportsFolder = `${program.reportsFolder}/${date}`; let exitCode = 0; let config = 'nightwatch.conf.js'; @@ -175,6 +177,7 @@ async function start(program) { retries, reportsFolder, browserstack, + timeout, ); if (!succeeded) { exitCode = 1; @@ -202,6 +205,7 @@ program .option('-r, --retries [number]', 'Number of retries before failing', '1') .option('--headless', 'Start in headless mode for local e2e') .option('--reports-folder [folder]', 'Reports folder', './test/e2e/reports') + .option('--timeout [number]', 'Timeout for WaitForConditions', '10000') .parse(process.argv); start(program); diff --git a/services/email/email-confirm.html.ejs b/services/email/email-confirm.html.ejs index 8a1bd05f7..76a8ea47b 100644 --- a/services/email/email-confirm.html.ejs +++ b/services/email/email-confirm.html.ejs @@ -1,3 +1,3 @@

    <%= t('email.confirm.has_been_requested') %> <%= email %>.

    -

    <%= t('email.confirm.to_confirm') %> Confirm Email

    +

    <%= t('email.confirm.to_confirm') %> <%= t('email.confirm.confirm_email') %>

    <%= t('email.confirm.if_you_did_not') %>

    diff --git a/services/i18n.js b/services/i18n.js index 4c65ed1d4..cd1bacd93 100644 --- a/services/i18n.js +++ b/services/i18n.js @@ -1,57 +1,91 @@ -const has = require('lodash/has'); -const get = require('lodash/get'); - -const yaml = require('yamljs'); - -const da = yaml.load('./locales/da.yml'); -const es = yaml.load('./locales/es.yml'); -const en = yaml.load('./locales/en.yml'); -const fr = yaml.load('./locales/fr.yml'); -const pt_BR = yaml.load('./locales/pt_BR.yml'); - +const fs = require('fs'); +const path = require('path'); +const debug = require('debug')('talk:services:i18n'); const accepts = require('accepts'); +const _ = require('lodash'); +const yaml = require('yamljs'); +const plugins = require('./plugins'); +const {DEFAULT_LANG} = require('../config'); -// default language -let defaultLanguage = 'en'; -let language = defaultLanguage; -const languages = ['en', 'da', 'es', 'fr', 'pt_BR']; +const resolve = (...paths) => path.resolve(path.join(__dirname, '..', 'locales', ...paths)); -const translations = Object.assign(en, es, fr, pt_BR, da); +// Load all the translations. +let translations = fs.readdirSync(resolve()) + + // Resolve all the filenames relative the the locales directory. + .map((filename) => resolve(filename)) + + // Translations are only yml/yaml files. + .filter((filename) => /\.(yaml|yml)$/.test(filename)) + + // Load the translation files from disk. + .map((filename) => fs.readFileSync(filename, 'utf8')) + + // Load the translation files. + .reduce((packs, contents) => { + + const pack = yaml.parse(contents); + + return _.merge(packs, pack); + }, {}); + +// Create a list of all supported translations. +const languages = Object.keys(translations); + +let loadedPluginTranslations = false; +const loadPluginTranslations = () => { + if (loadedPluginTranslations) { + return; + } + + // Load the plugin translations. + plugins.get('server', 'translations').forEach(({plugin, translations: filename}) => { + debug(`added plugin '${plugin.name}'`); + + const pack = yaml.parse(fs.readFileSync(filename, 'utf8')); + + translations = _.merge(translations, pack); + }); + + loadedPluginTranslations = true; +}; + +const t = (language) => (key, ...replacements) => { + + // Loads the translations into the translations array from plugins. This is + // done lazily to ensure that we don't have an import cycle. + loadPluginTranslations(); + + // Check if the translation exists on the object. + if (_.has(translations[language], key)) { + + // Get the translation value. + let translation = _.get(translations[language], key); + + // Replace any {n} with the arguments passed to this method. + replacements.forEach((str, n) => { + translation = translation.replace(new RegExp(`\\{${n}\\}`, 'g'), str); + }); + + return translation; + } else { + console.warn(`${key} language key not set`); + return key; + } +}; /** * Exposes a service object to allow translations. * @type {Object} */ const i18n = { - - /** - * Create the new Task kue. - */ - init(req) { + request(req) { const lang = accepts(req).language(languages); - language = lang ? lang : defaultLanguage; - }, - - /** - * Translates a key. - */ - t(key, ...replacements) { - - if (has(translations[language], key)) { - - let translation = get(translations[language], key); - - // replace any {n} with the arguments passed to this method - replacements.forEach((str, i) => { - translation = translation.replace(new RegExp(`\\{${i}\\}`, 'g'), str); - }); - - return translation; - } else { - console.warn(`${key} language key not set`); - return key; - } + const language = lang ? lang : DEFAULT_LANG; + + return t(language); }, + t: t(DEFAULT_LANG), }; module.exports = i18n; diff --git a/services/kue.js b/services/kue.js index 01806632e..a3eb6d985 100644 --- a/services/kue.js +++ b/services/kue.js @@ -9,7 +9,8 @@ const kue = require('kue'); // singleton Queue instance. So you can configure and use only a single Queue // object within your node.js process. let queue = null; -const getQueue = () => { +let isManaging = false; +const getQueue = ({managed = false} = {}) => { if (queue) { return queue; } @@ -21,8 +22,16 @@ const getQueue = () => { } }); - // Watch for stuck jobs to manage. - queue.watchStuckJobs(1000); + // If this is a managed queue, and we aren't managing yet, then start the + // management. + if (managed && !isManaging) { + + // Watch for stuck jobs to manage. + queue.watchStuckJobs(60000); + + // Mark that we've now started management routines. + isManaging = true; + } return queue; }; @@ -67,7 +76,9 @@ class Task { * Process jobs for the queue. */ process(callback) { - return getQueue().process(this.name, callback); + + // Get the queue in managed mode. + return getQueue({managed: true}).process(this.name, callback); } /** diff --git a/services/mailer.js b/services/mailer.js index a4f694b30..4433b9ef5 100644 --- a/services/mailer.js +++ b/services/mailer.js @@ -13,7 +13,8 @@ const { SMTP_USERNAME, SMTP_PORT, SMTP_PASSWORD, - SMTP_FROM_ADDRESS + SMTP_FROM_ADDRESS, + EMAIL_SUBJECT_PREFIX, } = require('../config'); // load all the templates as strings @@ -95,12 +96,12 @@ const mailer = module.exports = { } // Prefix the subject with `[Talk]`. - subject = `[Talk] ${subject}`; + subject = `${EMAIL_SUBJECT_PREFIX} ${subject}`; attachLocals(locals); - // Attach the templating function. - locals['t'] = i18n.t; + // Attach the translation function. + locals.t = i18n.t; return Promise.all([ @@ -112,7 +113,7 @@ const mailer = module.exports = { ]) .then(([html, text]) => { - // Create the job. + // Create the job. return mailer.task.create({ title: 'Mail', message: { diff --git a/services/settings.js b/services/settings.js index e8537c9c9..72915f43b 100644 --- a/services/settings.js +++ b/services/settings.js @@ -2,6 +2,7 @@ const SettingModel = require('../models/setting'); const cache = require('./cache'); const errors = require('../errors'); const {dotize} = require('./utils'); +const {SETTINGS_CACHE_TIME} = require('../config'); /** * The selector used to uniquely identify the settings document. @@ -35,7 +36,7 @@ module.exports = class SettingsService { if (process.env.NODE_ENV === 'production') { // When in production, wrap the settings retrieval with a cache. - const settings = await cache.h.wrap('settings', fields, 60, () => retrieve(fields)); + const settings = await cache.h.wrap('settings', fields, SETTINGS_CACHE_TIME / 1000, () => retrieve(fields)); return new SettingModel(settings); } diff --git a/services/users.js b/services/users.js index 52cd76dc2..1a8ea7684 100644 --- a/services/users.js +++ b/services/users.js @@ -12,13 +12,9 @@ const { } = require('./events/constants'); const events = require('./events'); -const { - ROOT_URL -} = require('../config'); +const {ROOT_URL} = require('../config'); -const { - jwt: JWT_SECRET -} = require('../secrets'); +const {jwt: JWT_SECRET} = require('../secrets'); const debug = require('debug')('talk:services:users'); @@ -43,12 +39,15 @@ const SALT_ROUNDS = 10; // Create a redis client to use for authentication. const Limit = require('./limit'); -const loginRateLimiter = new Limit('loginAttempts', RECAPTCHA_INCORRECT_TRIGGER, RECAPTCHA_WINDOW); +const loginRateLimiter = new Limit( + 'loginAttempts', + RECAPTCHA_INCORRECT_TRIGGER, + RECAPTCHA_WINDOW +); // UsersService is the interface for the application to interact with the // UserModel through. class UsersService { - /** * Returns a user (if found) for the given email address. */ @@ -57,9 +56,9 @@ class UsersService { profiles: { $elemMatch: { id: email.toLowerCase(), - provider: 'local' - } - } + provider: 'local', + }, + }, }); } @@ -85,22 +84,26 @@ class UsersService { } static async setSuspensionStatus(id, until, assignedBy = null, message) { - let user = await UserModel.findOneAndUpdate({id}, { - $set: { - 'status.suspension.until': until + let user = await UserModel.findOneAndUpdate( + {id}, + { + $set: { + 'status.suspension.until': until, + }, + $push: { + 'status.suspension.history': { + until, + assigned_by: assignedBy, + message, + created_at: Date.now(), + }, + }, }, - $push: { - 'status.suspension.history': { - until, - assigned_by: assignedBy, - message, - created_at: Date.now() - } + { + new: true, + runValidators: true, } - }, { - new: true, - runValidators: true - }); + ); if (user === null) { user = await UserModel.findOne({id}); if (user === null) { @@ -112,45 +115,53 @@ class UsersService { // check if the date is within 1 second of the time we're trying to set. if ( user.status.suspension.until === until || - ( - user.status.suspension.until.getTime() > until.getTime() - 1000 && - user.status.suspension.until.getTime() < until.getTime() + 1000 - ) + (user.status.suspension.until.getTime() > until.getTime() - 1000 && + user.status.suspension.until.getTime() < until.getTime() + 1000) ) { return user; } - throw new Error('suspension status change edit failed for an unknown reason'); + throw new Error( + 'suspension status change edit failed for an unknown reason' + ); } // Emit that the user username status was changed. - await events.emitAsync(USERS_SUSPENSION_CHANGE, user, {until, message, assignedBy}); + await events.emitAsync(USERS_SUSPENSION_CHANGE, user, { + until, + message, + assignedBy, + }); return user; } static async setBanStatus(id, status, assignedBy = null, message) { - let user = await UserModel.findOneAndUpdate({ - id, - status: { - $ne: status - } - }, { - $set: { - 'status.banned.status': status + let user = await UserModel.findOneAndUpdate( + { + id, + status: { + $ne: status, + }, }, - $push: { - 'status.banned.history': { - status, - assigned_by: assignedBy, - message, - created_at: Date.now() - } + { + $set: { + 'status.banned.status': status, + }, + $push: { + 'status.banned.history': { + status, + assigned_by: assignedBy, + message, + created_at: Date.now(), + }, + }, + }, + { + new: true, + runValidators: true, } - }, { - new: true, - runValidators: true - }); + ); if (user === null) { user = await UserModel.findOne({id}); if (user === null) { @@ -165,31 +176,39 @@ class UsersService { } // Emit that the user ban status was changed. - await events.emitAsync(USERS_BAN_CHANGE, user, {status, assignedBy, message}); + await events.emitAsync(USERS_BAN_CHANGE, user, { + status, + assignedBy, + message, + }); return user; } static async setUsernameStatus(id, status, assignedBy = null) { - let user = await UserModel.findOneAndUpdate({ - id, - status: { - $ne: status - } - }, { - $set: { - 'status.username.status': status + let user = await UserModel.findOneAndUpdate( + { + id, + status: { + $ne: status, + }, }, - $push: { - 'status.username.history': { - status, - assigned_by: assignedBy, - created_at: Date.now() - } + { + $set: { + 'status.username.status': status, + }, + $push: { + 'status.username.history': { + status, + assigned_by: assignedBy, + created_at: Date.now(), + }, + }, + }, + { + new: true, } - }, { - new: true - }); + ); if (user === null) { user = await UserModel.findOne({id}); if (user === null) { @@ -200,41 +219,57 @@ class UsersService { return user; } - throw new Error('username status change edit failed for an unknown reason'); + throw new Error( + 'username status change edit failed for an unknown reason' + ); } // Emit that the user username status was changed. - await events.emitAsync(USERS_USERNAME_STATUS_CHANGE, user, {status, assignedBy}); + await events.emitAsync(USERS_USERNAME_STATUS_CHANGE, user, { + status, + assignedBy, + }); return user; } - static async _setUsername(id, username, fromStatus, toStatus, assignedBy, resetAllowed = false) { + static async _setUsername( + id, + username, + fromStatus, + toStatus, + assignedBy, + resetAllowed = false + ) { try { const query = { id, - 'status.username.status': fromStatus + 'status.username.status': fromStatus, }; if (!resetAllowed) { query.username = {$ne: username}; } - let user = await UserModel.findOneAndUpdate(query, { - $set: { - username, - lowercaseUsername: username.toLowerCase(), - 'status.username.status': toStatus, + let user = await UserModel.findOneAndUpdate( + query, + { + $set: { + username, + lowercaseUsername: username.toLowerCase(), + 'status.username.status': toStatus, + }, + $push: { + 'status.username.history': { + status: toStatus, + assigned_by: assignedBy, + created_at: Date.now(), + }, + }, }, - $push: { - 'status.username.history': { - status: toStatus, - assigned_by: assignedBy, - created_at: Date.now() - } + { + new: true, } - }, { - new: true - }); + ); if (!user) { user = await UsersService.findById(id); if (user === null) { @@ -266,11 +301,24 @@ class UsersService { } static async setUsername(id, username, assignedBy) { - return UsersService._setUsername(id, username, 'UNSET', 'SET', assignedBy, true); + return UsersService._setUsername( + id, + username, + 'UNSET', + 'SET', + assignedBy, + true + ); } static async changeUsername(id, username, assignedBy) { - return UsersService._setUsername(id, username, 'REJECTED', 'CHANGED', assignedBy); + return UsersService._setUsername( + id, + username, + 'REJECTED', + 'CHANGED', + assignedBy + ); } /** @@ -294,18 +342,21 @@ class UsersService { * Sets or unsets the recaptcha_required flag on a user's local profile. */ static flagForRecaptchaRequirement(email, required) { - return UserModel.update({ - profiles: { - $elemMatch: { - id: email.toLowerCase(), - provider: 'local' - } + return UserModel.update( + { + profiles: { + $elemMatch: { + id: email.toLowerCase(), + provider: 'local', + }, + }, + }, + { + $set: { + 'profiles.$.metadata.recaptcha_required': required, + }, } - }, { - $set: { - 'profiles.$.metadata.recaptcha_required': required - } - }); + ); } /** @@ -320,11 +371,10 @@ class UsersService { static mergeUsers(dstUserID, srcUserID) { let srcUser, dstUser; - return Promise - .all([ - UserModel.findOne({id: dstUserID}).exec(), - UserModel.findOne({id: srcUserID}).exec() - ]) + return Promise.all([ + UserModel.findOne({id: dstUserID}).exec(), + UserModel.findOne({id: srcUserID}).exec(), + ]) .then((users) => { dstUser = users[0]; srcUser = users[1]; @@ -353,9 +403,9 @@ class UsersService { profiles: { $elemMatch: { id, - provider - } - } + provider, + }, + }, }); if (user) { return user; @@ -375,10 +425,10 @@ class UsersService { username: { status: 'UNSET', history: { - status: 'UNSET' - } - } - } + status: 'UNSET', + }, + }, + }, }); // Save the user in the database. @@ -396,22 +446,28 @@ class UsersService { * @param {String} email the email for the user to send the email to */ static async sendEmailConfirmation(user, email, redirectURI = ROOT_URL) { - let token = await UsersService.createEmailConfirmToken(user, email, redirectURI); + let token = await UsersService.createEmailConfirmToken( + user, + email, + redirectURI + ); return MailerService.sendSimple({ template: 'email-confirm', locals: { token, rootURL: ROOT_URL, - email + email, }, subject: i18n.t('email.confirm.subject'), - to: email + to: email, }); } static async sendEmail(user, options) { - const localProfile = user.profiles.find((profile) => profile.provider === 'local'); + const localProfile = user.profiles.find( + (profile) => profile.provider === 'local' + ); if (!localProfile) { throw new Error('user does not have an email'); } @@ -428,12 +484,15 @@ class UsersService { static async changePassword(id, password) { const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); - return UserModel.update({id}, { - $inc: {__v: 1}, - $set: { - password: hashedPassword + return UserModel.update( + {id}, + { + $inc: {__v: 1}, + $set: { + password: hashedPassword, + }, } - }); + ); } /** @@ -442,10 +501,15 @@ class UsersService { * @return {Promise} Resolves with the users that were created */ static createLocalUsers(users) { - return Promise.all(users.map((user) => { - return UsersService - .createLocalUser(user.email, user.password, user.username); - })); + return Promise.all( + users.map((user) => { + return UsersService.createLocalUser( + user.email, + user.password, + user.username + ); + }) + ); } /** @@ -466,7 +530,6 @@ class UsersService { } if (checkAgainstWordlist) { - // check for profanity let err = await Wordlist.usernameCheck(username); if (err) { @@ -501,7 +564,6 @@ class UsersService { * @param {Function} done callback */ static async createLocalUser(email, password, username) { - if (!email) { throw errors.ErrMissingEmail; } @@ -511,7 +573,7 @@ class UsersService { await Promise.all([ UsersService.isValidUsername(username), - UsersService.isValidPassword(password) + UsersService.isValidPassword(password), ]); const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); @@ -523,17 +585,17 @@ class UsersService { profiles: [ { id: email, - provider: 'local' - } + provider: 'local', + }, ], status: { username: { status: 'SET', history: { - status: 'SET' - } - } - } + status: 'SET', + }, + }, + }, }); try { @@ -566,7 +628,7 @@ class UsersService { /** * Finds a user with the id. * @param {String} id user id (uuid) - */ + */ static findById(id) { return UserModel.findOne({id}); } @@ -577,13 +639,11 @@ class UsersService { * @param {Object} token a jwt token used to sign in the user */ static async findOrCreateByIDToken(id, token) { - // Try to get the user. let user = await UserModel.findOne({id}); // If the user was not found, try to look it up. if (user === null) { - // If the user wasn't found, it will return null and the variable will be // unchanged. user = await lookupUserNotFound(token); @@ -595,21 +655,24 @@ class UsersService { /** * Finds users in an array of ids. * @param {Array} ids array of user identifiers (uuid) - */ + */ static findByIdArray(ids) { return UserModel.find({ - id: {$in: ids} + id: {$in: ids}, }); } /** * Finds public user information by an array of ids. * @param {Array} ids array of user identifiers (uuid) - */ + */ static findPublicByIdArray(ids) { - return UserModel.find({ - id: {$in: ids} - }, 'id username'); + return UserModel.find( + { + id: {$in: ids}, + }, + 'id username' + ); } /** @@ -618,7 +681,9 @@ class UsersService { */ static async createPasswordResetToken(email, loc) { if (!email || typeof email !== 'string') { - throw new Error('email is required when creating a JWT for resetting passord'); + throw new Error( + 'email is required when creating a JWT for resetting passord' + ); } email = email.toLowerCase(); @@ -628,7 +693,6 @@ class UsersService { DomainList.urlCheck(loc), ]); if (!user) { - // Since we don't want to reveal that the email does/doesn't exist // just go ahead and resolve the Promise with null and check in the // endpoint. @@ -646,12 +710,12 @@ class UsersService { email, loc, userId: user.id, - version: user.__v + version: user.__v, }; return JWT_SECRET.sign(payload, { expiresIn: '1d', - subject: PASSWORD_RESET_JWT_SUBJECT + subject: PASSWORD_RESET_JWT_SUBJECT, }); } @@ -678,7 +742,7 @@ class UsersService { */ static async verifyPasswordResetToken(token) { const {userId, loc, version} = await UsersService.verifyToken(token, { - subject: PASSWORD_RESET_JWT_SUBJECT + subject: PASSWORD_RESET_JWT_SUBJECT, }); const user = await UsersService.findById(userId); @@ -708,17 +772,16 @@ class UsersService { return UserModel.find({ $or: [ - // Search by a prefix match on the username. { - 'lowercaseUsername': { + lowercaseUsername: { $regex, }, }, // Search by a prefix match on the email address. { - 'profiles': { + profiles: { $elemMatch: { id: { $regex, @@ -752,13 +815,16 @@ class UsersService { * @return {Promise} */ static updateSettings(id, settings) { - return UserModel.update({ - id - }, { - $set: { - settings + return UserModel.update( + { + id, + }, + { + $set: { + settings, + }, } - }); + ); } /** @@ -774,7 +840,7 @@ class UsersService { item_type: 'users', user_id, action_type, - metadata + metadata, }); } @@ -787,7 +853,9 @@ class UsersService { */ static async createEmailConfirmToken(user, email, referer = ROOT_URL) { if (!email || typeof email !== 'string') { - throw new Error('email is required when creating a JWT for resetting password'); + throw new Error( + 'email is required when creating a JWT for resetting password' + ); } // Conform the email to lowercase. @@ -796,22 +864,27 @@ class UsersService { const tokenOptions = { jwtid: uuid.v4(), expiresIn: '1d', - subject: EMAIL_CONFIRM_JWT_SUBJECT + subject: EMAIL_CONFIRM_JWT_SUBJECT, }; // Get the profile representing the local account. - let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local'); + let profile = user.profiles.find( + (profile) => profile.id === email && profile.provider === 'local' + ); // Ensure that the user email hasn't already been verified. if (profile && profile.metadata && profile.metadata.confirmed_at) { throw new Error('email address already confirmed'); } - return JWT_SECRET.sign({ - email, - referer, - userID: user.id - }, tokenOptions); + return JWT_SECRET.sign( + { + email, + referer, + userID: user.id, + }, + tokenOptions + ); } /** @@ -823,7 +896,7 @@ class UsersService { */ static async verifyEmailConfirmation(token) { let {userID, email, referer} = await UsersService.verifyToken(token, { - subject: EMAIL_CONFIRM_JWT_SUBJECT + subject: EMAIL_CONFIRM_JWT_SUBJECT, }); await UsersService.confirmEmail(userID, email); @@ -835,20 +908,22 @@ class UsersService { * Marks the email on the user as confirmed. */ static confirmEmail(id, email) { - return UserModel - .update({ + return UserModel.update( + { id, profiles: { $elemMatch: { id: email, - provider: 'local' - } - } - }, { + provider: 'local', + }, + }, + }, + { $set: { - 'profiles.$.metadata.confirmed_at': new Date() - } - }); + 'profiles.$.metadata.confirmed_at': new Date(), + }, + } + ); } /** @@ -866,13 +941,16 @@ class UsersService { throw errors.ErrCannotIgnoreStaff; } - return UserModel.update({id}, { - $addToSet: { - ignoresUsers: { - $each: usersToIgnore - } + return UserModel.update( + {id}, + { + $addToSet: { + ignoresUsers: { + $each: usersToIgnore, + }, + }, } - }); + ); } /** @@ -881,24 +959,26 @@ class UsersService { * @param {Array} usersToStopIgnoring Array of user IDs to stop ignoring */ static async stopIgnoringUsers(id, usersToStopIgnoring) { - await UserModel.update({id}, { - $pullAll: { - ignoresUsers: usersToStopIgnoring + await UserModel.update( + {id}, + { + $pullAll: { + ignoresUsers: usersToStopIgnoring, + }, } - }); + ); } } module.exports = UsersService; events.on(USERS_BAN_CHANGE, async (user, {status, message}) => { - // Check to see if the user was banned now and is currently banned. if (user.banned && status && message && message.length > 0) { await UsersService.sendEmail(user, { template: 'plain', locals: { - body: message + body: message, }, subject: 'Your account has been banned', }); @@ -906,9 +986,14 @@ events.on(USERS_BAN_CHANGE, async (user, {status, message}) => { }); events.on(USERS_SUSPENSION_CHANGE, async (user, {until, message}) => { - // Check to see if the user was suspended now and is currently suspended. - if (user.suspended && until !== null && until > Date.now() && message && message.length > 0) { + if ( + user.suspended && + until !== null && + until > Date.now() && + message && + message.length > 0 + ) { await UsersService.sendEmail(user, { template: 'plain', locals: { diff --git a/test/e2e/globals.js b/test/e2e/globals.js index 4d2740404..09b297a24 100644 --- a/test/e2e/globals.js +++ b/test/e2e/globals.js @@ -11,7 +11,7 @@ module.exports = { shutdown(); done(); }, - waitForConditionTimeout: 5000, + waitForConditionTimeout: parseInt(process.env.WAIT_FOR_TIMEOUT) || 10000, testData: { admin: { email: 'admin@test.com', diff --git a/test/e2e/page_objects/admin.js b/test/e2e/page_objects/admin.js index 173fff747..fd9d79a5b 100644 --- a/test/e2e/page_objects/admin.js +++ b/test/e2e/page_objects/admin.js @@ -57,6 +57,10 @@ module.exports = { suspendUserDialog: '.talk-admin-suspend-user-dialog', suspendUserConfirmButton: '.talk-admin-suspend-user-dialog-confirm', supendUserSendButton: '.talk-admin-suspend-user-dialog-send', + usernameDialog: '.talk-reject-username-dialog', + usernameDialogButtons: '.talk-reject-username-dialog-buttons', + usernameDialogSuspend: '.talk-reject-username-dialog-button-k', + usernameDialogSuspensionMessage: '.talk-reject-username-dialog-suspension-message', toast: '.toastify', toastClose: '.toastify__close', }, @@ -95,10 +99,6 @@ module.exports = { flaggedUser:'.talk-admin-community-flagged-user', flaggedUserApproveButton: '.talk-admin-flagged-user-approve-button', flaggedUserRejectButton: '.talk-admin-flagged-user-reject-button', - usernameDialog: '.talk-reject-username-dialog', - usernameDialogButtons: '.talk-reject-username-dialog-buttons', - usernameDialogSuspend: '.talk-reject-username-dialog-button-k', - usernameDialogSuspensionMessage: '.talk-reject-username-dialog-suspension-message' }, sections: { people: { @@ -143,6 +143,10 @@ module.exports = { this.parent .click('@drawerOverlay') .waitForElementNotPresent('@drawerOverlay'); + + // Wait a bit to let animations terminate cleanly. + this.api.pause(200); + return this.parent; }, }], diff --git a/test/e2e/page_objects/embedStream.js b/test/e2e/page_objects/embedStream.js index 4177f5925..4875eec45 100644 --- a/test/e2e/page_objects/embedStream.js +++ b/test/e2e/page_objects/embedStream.js @@ -62,24 +62,12 @@ module.exports = { // Focusing on the Login PopUp windowHandler.windowHandles((handles) => { this.api.switchWindow(handles[1]); - }); - const popup = this.api.page.popup().ready(); - callback(popup); + const popup = this.api.page.popup().ready(); + callback(popup); - // Give a tiny bit of time to let popup close. - this.api.pause(50); - - if (this.api.capabilities.browserName === 'MicrosoftEdge') { - - // More time for edge. - // https://www.browserstack.com/automate/builds/1ceccf4efb4683b7feb890f45a32b5922b40ed3f/sessions/7393dbfda8387e43b6d5851f359b0c07db414973 - this.api.pause(1000); - } - - // Focusing on the Embed Window - windowHandler.windowHandles((handles) => { - this.api.switchWindow(handles[0]); + // Focus on the Embed Window. + windowHandler.pop(); // For some reasons firefox does not automatically load auth after login. // https://www.browserstack.com/automate/builds/37650cb4e66c6edce0ba0800a1c1b7e7f74bf991/sessions/7a4e9da69b0f9ecdf8b7fa9150639e47b1532cb0#automate_button @@ -89,6 +77,7 @@ module.exports = { this.parent.switchToIframe(); } }); + return this; }, logout() { diff --git a/test/e2e/specs/04_userStatus.js b/test/e2e/specs/04_userStatus.js index 3fa9fb4f0..34d661b61 100644 --- a/test/e2e/specs/04_userStatus.js +++ b/test/e2e/specs/04_userStatus.js @@ -20,12 +20,10 @@ module.exports = { adminPage.navigateAndLogin(admin); }, - 'admin flags user\'s username as offensive': (client) => { + 'admin flags users username as offensive': (client) => { const embedStream = client.page.embedStream(); - const comments = embedStream - .navigate() - .ready(); + const comments = embedStream.navigate().ready(); comments .waitForElementVisible('@firstComment') @@ -63,15 +61,18 @@ module.exports = { .click('@flaggedUserRejectButton'); }, 'admin suspends the user': (client) => { + const adminPage = client.page.admin(); const community = client.page.admin().section.community; - community + adminPage .waitForElementVisible('@usernameDialog') .waitForElementVisible('@usernameDialogButtons') .waitForElementVisible('@usernameDialogSuspend') .click('@usernameDialogSuspend') - .click('@usernameDialogSuspend') - .waitForElementNotPresent('@flaggedUser'); + .waitForElementVisible('@usernameDialogSuspensionMessage') + .click('@usernameDialogSuspend'); + + community.waitForElementNotPresent('@flaggedUser'); }, 'admin logs out': (client) => { client.page.admin().logout(); @@ -89,8 +90,15 @@ module.exports = { const embedStream = client.page.embedStream(); const comments = embedStream.section.comments; + comments.waitForElementVisible('@restrictedMessageBox'); + }, + 'user should not be able to comment': (client) => { + const embedStream = client.page.embedStream(); + const comments = embedStream.section.comments; + comments - .waitForElementVisible('@restrictedMessageBox'); + .waitForElementNotPresent('@commentBoxTextarea') + .waitForElementNotPresent('@commentBoxPostButton'); }, 'user picks another username': (client) => { const embedStream = client.page.embedStream(); @@ -103,5 +111,13 @@ module.exports = { .waitForElementVisible('@changeUsernameSubmitButton') .click('@changeUsernameSubmitButton') .waitForElementNotPresent('@changeUsernameInput'); - } + }, + 'user should be able to comment': (client) => { + const embedStream = client.page.embedStream(); + const comments = embedStream.section.comments; + + comments + .waitForElementVisible('@commentBoxTextarea') + .waitForElementVisible('@commentBoxPostButton'); + }, }; diff --git a/test/e2e/utils/SortedWindowHandler.js b/test/e2e/utils/SortedWindowHandler.js index f57856814..a23f71506 100644 --- a/test/e2e/utils/SortedWindowHandler.js +++ b/test/e2e/utils/SortedWindowHandler.js @@ -25,8 +25,14 @@ class SortedWindowHandler { */ windowHandles(callback) { this.client.windowHandles((result) => { + + if (result.value.message) { + throw new Error(result.value.message); + } + this.handles = this.handles.filter((handle) => result.value.includes(handle)); const remaining = result.value.filter((handle) => !this.handles.includes(handle)); + if (remaining.length === 1) { this.handles.push(remaining[0]); } @@ -36,6 +42,14 @@ class SortedWindowHandler { callback(this.handles); }); } + + /** + * Switch to main window handle and remove latest handle. + */ + pop() { + this.client.switchWindow(this.handles[0]); + this.handles.splice(-1, 1); + } } module.exports = SortedWindowHandler; diff --git a/yarn.lock b/yarn.lock index 5046e89c3..c1394bd96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24,6 +24,28 @@ git-url-parse "^6.0.2" shelljs "^0.7.0" +<<<<<<< HEAD +======= +"@remy/pstree@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@remy/pstree/-/pstree-1.1.0.tgz#414045d4fec60946604f3718023052aaf49bd8d3" + dependencies: + ps-tree "^1.1.0" + +"@types/express-serve-static-core@*": + version "4.0.53" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.0.53.tgz#1723a35d1447f2c55e13c8721eab3448e42f4d82" + dependencies: + "@types/node" "*" + +"@types/express@^4.0.35": + version "4.0.37" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.0.37.tgz#625ac3765169676e01897ca47011c26375784971" + dependencies: + "@types/express-serve-static-core" "*" + "@types/serve-static" "*" + +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 "@types/graphql@0.10.2": version "0.10.2" resolved "https://registry.yarnpkg.com/@types/graphql/-/graphql-0.10.2.tgz#d7c79acbaa17453b6681c80c34b38fcb10c4c08c" @@ -55,6 +77,13 @@ abab@^1.0.0, abab@^1.0.3: resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e" abbrev@1: +<<<<<<< HEAD +======= + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + +abbrev@1.0.x: +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 version "1.0.9" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135" @@ -101,10 +130,14 @@ acorn@^4.0.3, acorn@^4.0.4, acorn@~4.0.2: version "4.0.13" resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787" -acorn@^5.0.0, acorn@^5.1.1: +acorn@^5.0.0: version "5.1.2" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.1.2.tgz#911cb53e036807cf0fa778dc5d370fbd864246d7" +acorn@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.2.1.tgz#317ac7821826c22c702d66189ab8359675f135d7" + addressparser@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/addressparser/-/addressparser-1.0.1.tgz#47afbe1a2a9262191db6838e4fd1d39b40821746" @@ -121,8 +154,8 @@ ajv-keywords@^1.1.1: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.5.1.tgz#314dd0a4b3368fad3dfcdc54ede6171b886daf3c" ajv-keywords@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.0.tgz#a296e17f7bfae7c1ce4f7e0de53d29cb32162df0" + version "2.1.1" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.1.tgz#617997fc5f60576894c435f940d819e135b80762" ajv@^4.7.0, ajv@^4.9.1: version "4.11.8" @@ -131,7 +164,7 @@ ajv@^4.7.0, ajv@^4.9.1: co "^4.6.0" json-stable-stringify "^1.0.1" -ajv@^5.0.0, ajv@^5.1.0, ajv@^5.2.0, ajv@^5.2.3: +ajv@^5.0.0, ajv@^5.1.0: version "5.2.3" resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.3.tgz#c06f598778c44c6b161abafe3466b81ad1814ed2" dependencies: @@ -140,6 +173,15 @@ ajv@^5.0.0, ajv@^5.1.0, ajv@^5.2.0, ajv@^5.2.3: json-schema-traverse "^0.3.0" json-stable-stringify "^1.0.1" +ajv@^5.2.3, ajv@^5.3.0: + version "5.5.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965" + dependencies: + co "^4.6.0" + fast-deep-equal "^1.0.0" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.3.0" + align-text@^0.1.1, align-text@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" @@ -422,8 +464,8 @@ assertion-error@^1.0.1: resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.0.2.tgz#13ca515d86206da0bac66e834dd397d87581094c" ast-types@0.x.x: - version "0.9.14" - resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.14.tgz#d34ba5dffb9d15a44351fd2a9d82e4ab2838b5ba" + version "0.10.1" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.10.1.tgz#f52fca9715579a14f841d67d7f8d25432ab6a3dd" astral-regex@^1.0.0: version "1.0.0" @@ -443,17 +485,25 @@ async@2.1.4: dependencies: lodash "^4.14.0" +<<<<<<< HEAD async@2.4.1: +======= +async@2.4.1, async@^2.1.2: +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 version "2.4.1" resolved "https://registry.yarnpkg.com/async/-/async-2.4.1.tgz#62a56b279c98a11d0987096a01cc3eeb8eb7bbd7" dependencies: lodash "^4.14.0" +<<<<<<< HEAD async@^1.4.0, async@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" async@^2.1.2, async@^2.1.4, async@~2.6.0: +======= +async@^2.1.4: +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 version "2.6.0" resolved "https://registry.yarnpkg.com/async/-/async-2.6.0.tgz#61a29abb6fcc026fea77e56d1c6ec53a795951f4" dependencies: @@ -1064,7 +1114,7 @@ babel-register@^6.26.0: mkdirp "^0.5.1" source-map-support "^0.4.15" -babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0, babel-runtime@^6.6.1: +babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.23.0, babel-runtime@^6.26.0, babel-runtime@^6.6.1: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" dependencies: @@ -1157,8 +1207,8 @@ big.js@^3.1.3: resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" binary-extensions@^1.0.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.10.0.tgz#9aeb9a6c5e88638aad171e167f5900abe24835d0" + version "1.11.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205" bl@^1.0.0: version "1.2.1" @@ -1226,12 +1276,12 @@ boom@5.x.x: hoek "4.x.x" bowser@^1.7.2: - version "1.8.0" - resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.8.0.tgz#889d46ac922ec5db8297672747362ef836781ba7" + version "1.8.1" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.8.1.tgz#49785777e7302febadb1a5b71d9a646520ed310d" boxen@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.2.2.tgz#3f1d4032c30ffea9d4b02c322eaf2ea741dcbce5" + version "1.3.0" + resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" dependencies: ansi-align "^2.0.0" camelcase "^4.0.0" @@ -1239,7 +1289,7 @@ boxen@^1.2.1: cli-boxes "^1.0.0" string-width "^2.0.0" term-size "^1.2.0" - widest-line "^1.0.0" + widest-line "^2.0.0" brace-expansion@^1.0.0, brace-expansion@^1.1.7: version "1.1.8" @@ -1534,7 +1584,11 @@ chain-function@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc" +<<<<<<< HEAD chalk@2.3.0, chalk@^2.0.1: +======= +chalk@2.3.0, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0: +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 version "2.3.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.0.tgz#b5ea48efc9c1793dccc9b4767c93914d3f2d52ba" dependencies: @@ -1552,6 +1606,7 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" +<<<<<<< HEAD chalk@^2.0.0, chalk@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.2.0.tgz#477b3bf2f9b8fd5ca9e429747e37f724ee7af240" @@ -1560,6 +1615,8 @@ chalk@^2.0.0, chalk@^2.1.0: escape-string-regexp "^1.0.5" supports-color "^4.0.0" +======= +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 change-emitter@^0.1.2: version "0.1.6" resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" @@ -1570,6 +1627,10 @@ character-parser@^2.1.1: dependencies: is-regex "^1.0.3" +chardet@^0.4.0: + version "0.4.2" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2" + charenc@~0.0.1: version "0.0.2" resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" @@ -1781,12 +1842,18 @@ codemirror@*: version "5.30.0" resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.30.0.tgz#86e57dd5ea5535acbcf9c720797b4cefe05b5a70" -color-convert@^1.3.0, color-convert@^1.9.0: +color-convert@^1.3.0: version "1.9.0" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a" dependencies: color-name "^1.1.1" +color-convert@^1.9.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.1.tgz#c1261107aeb2f294ebffec9ed9ecad529a6097ed" + dependencies: + color-name "^1.1.1" + color-name@^1.0.0, color-name@^1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" @@ -1841,7 +1908,7 @@ combined-stream@~0.0.4: dependencies: delayed-stream "0.0.5" -commander@2.11.0, commander@^2.11.0, commander@^2.9.0: +commander@2.11.0, commander@^2.11.0: version "2.11.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563" @@ -1857,6 +1924,10 @@ commander@2.9.0: dependencies: graceful-readlink ">= 1.0.0" +commander@^2.9.0: + version "2.12.2" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.12.2.tgz#0f5946c427ed9ec0d91a46bb9def53e54650e555" + commander@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.1.0.tgz#d121bbae860d9992a3d517ba96f56588e47c6781" @@ -2297,13 +2368,13 @@ date-now@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" -debug@*, debug@3.1.0, debug@^3.0.1, debug@^3.1.0: +debug@*, debug@3.1.0, debug@^3.0.0, debug@^3.0.1, debug@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" dependencies: ms "2.0.0" -debug@2, debug@2.6.9, debug@^2.2.0, debug@^2.6.3, debug@^2.6.8, debug@^2.6.9: +debug@2, debug@2.6.9, debug@^2.2.0, debug@^2.6.3, debug@^2.6.8: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" dependencies: @@ -2425,9 +2496,15 @@ detect-indent@^4.0.0: dependencies: repeating "^2.0.0" +<<<<<<< HEAD detect-libc@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-0.2.0.tgz#47fdf567348a17ec25fcbf0b9e446348a76f9fb5" +======= +detect-libc@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 dialog-polyfill@^0.4.9: version "0.4.9" @@ -2464,18 +2541,17 @@ dns-prefetch-control@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/dns-prefetch-control/-/dns-prefetch-control-0.1.0.tgz#60ddb457774e178f1f9415f0cabb0e85b0b300b2" -doctrine@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.0.0.tgz#c73d8d2909d22291e1a007a395804da8b665fe63" +doctrine@^2.0.0, doctrine@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.0.2.tgz#68f96ce8efc56cc42651f1faadb4f175273b0075" dependencies: esutils "^2.0.2" - isarray "^1.0.0" doctypes@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9" -dom-helpers@^3.2.0: +"dom-helpers@^2.4.0 || ^3.0.0", dom-helpers@^3.2.0: version "3.2.1" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a" @@ -2554,11 +2630,7 @@ ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" -ejs@0.8.3: - version "0.8.3" - resolved "https://registry.yarnpkg.com/ejs/-/ejs-0.8.3.tgz#db8aac47ff80a7df82b4c82c126fe8970870626f" - -ejs@^2.5.7: +ejs@2.5.7, ejs@^2.5.7: version "2.5.7" resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.7.tgz#cc872c168880ae3c7189762fd5ffc00896c9518a" @@ -2620,15 +2692,14 @@ env-rewrite@^1.0.2: resolved "https://registry.yarnpkg.com/env-rewrite/-/env-rewrite-1.0.2.tgz#3e344a95af1bdaab34a559479b8be3abd0804183" enzyme-adapter-react-15@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/enzyme-adapter-react-15/-/enzyme-adapter-react-15-1.0.2.tgz#350393ba85670936b8f9179257b1a6510f6aae3c" + version "1.0.5" + resolved "https://registry.yarnpkg.com/enzyme-adapter-react-15/-/enzyme-adapter-react-15-1.0.5.tgz#99f9a03ff2c2303e517342935798a6bdfbb75fac" dependencies: - enzyme-adapter-utils "^1.0.0" + enzyme-adapter-utils "^1.1.0" lodash "^4.17.4" object.assign "^4.0.4" object.values "^1.0.4" prop-types "^15.5.10" - react-test-renderer "^15.5.0" enzyme-adapter-react-16@^1.0.0: version "1.0.2" @@ -2641,9 +2712,9 @@ enzyme-adapter-react-16@^1.0.0: prop-types "^15.5.10" react-test-renderer "^16.0.0-0" -enzyme-adapter-utils@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/enzyme-adapter-utils/-/enzyme-adapter-utils-1.0.1.tgz#fcd81223339a55a312f7552641e045c404084009" +enzyme-adapter-utils@^1.0.0, enzyme-adapter-utils@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/enzyme-adapter-utils/-/enzyme-adapter-utils-1.2.0.tgz#7f4471ee0a70b91169ec8860d2bf0a6b551664b2" dependencies: lodash "^4.17.4" object.assign "^4.0.4" @@ -2676,7 +2747,21 @@ error-ex@^1.2.0, error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" +<<<<<<< HEAD es-abstract@^1.4.3, es-abstract@^1.6.1, es-abstract@^1.7.0: +======= +es-abstract@^1.6.1: + version "1.10.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.10.0.tgz#1ecb36c197842a00d8ee4c2dfd8646bb97d60864" + dependencies: + es-to-primitive "^1.1.1" + function-bind "^1.1.1" + has "^1.0.1" + is-callable "^1.1.3" + is-regex "^1.0.4" + +es-abstract@^1.7.0: +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 version "1.9.0" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.9.0.tgz#690829a07cae36b222e7fd9b75c0d0573eb25227" dependencies: @@ -2766,31 +2851,31 @@ eslint-scope@^3.7.1: estraverse "^4.1.1" eslint@^4.5.0: - version "4.9.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-4.9.0.tgz#76879d274068261b191fe0f2f56c74c2f4208e8b" + version "4.13.1" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-4.13.1.tgz#0055e0014464c7eb7878caf549ef2941992b444f" dependencies: - ajv "^5.2.0" + ajv "^5.3.0" babel-code-frame "^6.22.0" chalk "^2.1.0" concat-stream "^1.6.0" cross-spawn "^5.1.0" debug "^3.0.1" - doctrine "^2.0.0" + doctrine "^2.0.2" eslint-scope "^3.7.1" - espree "^3.5.1" + espree "^3.5.2" esquery "^1.0.0" estraverse "^4.2.0" esutils "^2.0.2" file-entry-cache "^2.0.0" functional-red-black-tree "^1.0.1" glob "^7.1.2" - globals "^9.17.0" + globals "^11.0.1" ignore "^3.3.3" imurmurhash "^0.1.4" inquirer "^3.0.6" is-resolvable "^1.0.0" js-yaml "^3.9.1" - json-stable-stringify "^1.0.1" + json-stable-stringify-without-jsonify "^1.0.1" levn "^0.3.0" lodash "^4.17.4" minimatch "^3.0.2" @@ -2807,11 +2892,11 @@ eslint@^4.5.0: table "^4.0.1" text-table "~0.2.0" -espree@^3.5.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.1.tgz#0c988b8ab46db53100a1954ae4ba995ddd27d87e" +espree@^3.5.2: + version "3.5.2" + resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.2.tgz#756ada8b979e9dcfcdb30aad8d1a9304a905e1ca" dependencies: - acorn "^5.1.1" + acorn "^5.2.1" acorn-jsx "^3.0.0" esprima@3.x.x, esprima@^3.1.3: @@ -2996,11 +3081,11 @@ extend@^1.2.1: resolved "https://registry.yarnpkg.com/extend/-/extend-1.3.0.tgz#d1516fb0ff5624d2ebf9123ea1dac5a1994004f8" external-editor@^2.0.4: - version "2.0.5" - resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-2.0.5.tgz#52c249a3981b9ba187c7cacf5beb50bf1d91a6bc" + version "2.1.0" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-2.1.0.tgz#3d026a21b7f95b5726387d4200ac160d372c3b48" dependencies: + chardet "^0.4.0" iconv-lite "^0.4.17" - jschardet "^1.4.2" tmp "^0.0.33" extglob@^0.3.1: @@ -3009,10 +3094,14 @@ extglob@^0.3.1: dependencies: is-extglob "^1.0.0" -extsprintf@1.3.0, extsprintf@^1.2.0: +extsprintf@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" +extsprintf@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" + eyes@0.1.x: version "0.1.8" resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0" @@ -3021,6 +3110,10 @@ fast-deep-equal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff" +fast-json-stable-stringify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" + fast-levenshtein@~2.0.4: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" @@ -3268,7 +3361,15 @@ fs-extra@^0.24.0: path-is-absolute "^1.0.0" rimraf "^2.2.8" -fs-extra@^4.0.1, fs-extra@^4.0.2: +fs-extra@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94" + dependencies: + graceful-fs "^4.1.2" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs-extra@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.2.tgz#f91704c53d1b461f893452b0c307d9997647ab6b" dependencies: @@ -3290,7 +3391,14 @@ fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" -fsevents@^1.0.0, fsevents@^1.1.1: +fsevents@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.1.3.tgz#11f82318f5fe7bb2cd22965a108e9306208216d8" + dependencies: + nan "^2.3.0" + node-pre-gyp "^0.6.39" + +fsevents@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.1.2.tgz#3282b713fb3ad80ede0e9fcf4611b5aa6fc033f4" dependencies: @@ -3521,12 +3629,16 @@ glob@^5.0.3: path-is-absolute "^1.0.0" global-dirs@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.0.tgz#10d34039e0df04272e262cf24224f7209434df4f" + version "0.1.1" + resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445" dependencies: ini "^1.3.4" -globals@^9.17.0, globals@^9.18.0: +globals@^11.0.1: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.1.0.tgz#632644457f5f0e3ae711807183700ebf2e4633e4" + +globals@^9.18.0: version "9.18.0" resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a" @@ -3885,9 +3997,9 @@ home-or-tmp@^2.0.0: os-homedir "^1.0.0" os-tmpdir "^1.0.1" -hooks-fixed@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/hooks-fixed/-/hooks-fixed-2.0.0.tgz#a01d894d52ac7f6599bbb1f63dfc9c411df70cba" +hooks-fixed@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hooks-fixed/-/hooks-fixed-2.0.2.tgz#20076daa07e77d8a6106883ce3f1722e051140b0" hosted-git-info@^2.1.4: version "2.5.0" @@ -4033,8 +4145,8 @@ ignore-by-default@^1.0.1: resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" ignore@^3.3.3: - version "3.3.5" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.5.tgz#c4e715455f6073a8d7e5dae72d2fc9d71663dba6" + version "3.3.7" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.7.tgz#612289bfb3c220e186a58118618d5be8c1bab021" iltorb@^2.0.1: version "2.0.2" @@ -4081,7 +4193,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.0, inherits@~2.0.1: +inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" @@ -4089,10 +4201,14 @@ inherits@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" -ini@^1.3.0, ini@^1.3.4, ini@~1.3.0: +ini@^1.3.0: version "1.3.4" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e" +ini@^1.3.4, ini@~1.3.0: + version "1.3.5" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" + inquirer-confirm@0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/inquirer-confirm/-/inquirer-confirm-0.2.2.tgz#6f406d037bf9d9e455ef0f953929f357fe9a8848" @@ -4151,8 +4267,8 @@ inquirer@3.3.0, inquirer@^3.0.6, inquirer@^3.2.2: through "^2.3.6" interpret@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.4.tgz#820cdd588b868ffb191a809506d6c9c8f212b1b0" + version "1.1.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614" invariant@^2.0.0, invariant@^2.2.0, invariant@^2.2.1, invariant@^2.2.2: version "2.2.2" @@ -4200,7 +4316,7 @@ ip@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/ip/-/ip-1.0.1.tgz#c7e356cdea225ae71b36d70f2e71a92ba4e42590" -ip@^1.1.2: +ip@^1.1.2, ip@^1.1.4: version "1.1.5" resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" @@ -4222,7 +4338,11 @@ is-binary-path@^1.0.0: dependencies: binary-extensions "^1.0.0" -is-buffer@^1.1.5, is-buffer@~1.1.1: +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + +is-buffer@~1.1.1: version "1.1.5" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.5.tgz#1f3b26ef613b214b88cbca23cc6c01d87961eecc" @@ -4371,8 +4491,8 @@ is-path-in-cwd@^1.0.0: is-path-inside "^1.0.0" is-path-inside@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.0.tgz#fc06e5a1683fbda13de667aff717bbc10a48f37f" + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" dependencies: path-is-inside "^1.0.1" @@ -4407,10 +4527,8 @@ is-regex@^1.0.3, is-regex@^1.0.4: has "^1.0.1" is-resolvable@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.0.0.tgz#8df57c61ea2e3c501408d100fb013cf8d6e0cc62" - dependencies: - tryit "^1.0.1" + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.0.1.tgz#acca1cd36dbe44b974b924321555a70ba03b1cf4" is-retry-allowed@^1.0.0: version "1.1.0" @@ -4845,10 +4963,6 @@ jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" -jschardet@^1.4.2: - version "1.5.1" - resolved "https://registry.yarnpkg.com/jschardet/-/jschardet-1.5.1.tgz#c519f629f86b3a5bedba58a88d311309eec097f9" - jsdom@^7.0.2: version "7.2.2" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-7.2.2.tgz#40b402770c2bda23469096bee91ab675e3b1fc6e" @@ -4934,6 +5048,10 @@ json-schema@0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + json-stable-stringify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af" @@ -5146,6 +5264,15 @@ libqp@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/libqp/-/libqp-1.1.0.tgz#f5e6e06ad74b794fb5b5b66988bf728ef1dedbe8" +<<<<<<< HEAD +======= +license-webpack-plugin@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/license-webpack-plugin/-/license-webpack-plugin-1.1.1.tgz#76b2cedccc78f139fd7877e576f756cfc141b8c2" + dependencies: + ejs "^2.5.7" + +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 linkify-it@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.0.3.tgz#d94a4648f9b1c179d64fa97291268bdb6ce9434f" @@ -5360,7 +5487,7 @@ lodash.foreach@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53" -lodash.get@^4.4.2: +lodash.get@4.4.2, lodash.get@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" @@ -5510,7 +5637,7 @@ longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848" dependencies: @@ -5547,10 +5674,10 @@ mailcomposer@4.0.1: libmime "3.0.0" make-dir@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.0.0.tgz#97a011751e91dd87cfadef58832ebb04936de978" + version "1.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.1.0.tgz#19b4369fe48c116f53c2af95ad102c0e39e85d51" dependencies: - pify "^2.3.0" + pify "^3.0.0" make-error-cause@^1.0.1: version "1.2.2" @@ -5572,10 +5699,14 @@ map-stream@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194" -marked@*, marked@^0.3.5, marked@^0.3.6: +marked@*, marked@^0.3.5: version "0.3.6" resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.6.tgz#b2c6c618fccece4ef86c4fc6cb8a7cbf5aeda8d7" +marked@^0.3.6: + version "0.3.7" + resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.7.tgz#80ef3bbf1bd00d1c9cfebe42ba1b8c85da258d0d" + material-design-lite@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/material-design-lite/-/material-design-lite-1.3.0.tgz#d004ce3fee99a1eeb74a78b8a325134a5f1171d3" @@ -5716,7 +5847,7 @@ minimatch@3.0.3: dependencies: brace-expansion "^1.0.0" -minimist@0.0.8, minimist@~0.0.1: +minimist@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" @@ -5724,7 +5855,15 @@ minimist@^1.1.1, minimist@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" +<<<<<<< HEAD mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: +======= +minimist@~0.0.1: + version "0.0.10" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" + +mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 version "0.5.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" dependencies: @@ -5786,10 +5925,18 @@ moment@2.18.1: version "2.18.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f" +<<<<<<< HEAD moment@2.19.1, moment@2.x.x, "moment@>= 2.9.0", moment@^2.10.3, moment@^2.18.1: +======= +moment@2.19.1, moment@^2.10.3: +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 version "2.19.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.1.tgz#56da1a2d1cbf01d38b7e1afc31c10bcfa1929167" +moment@2.x.x, "moment@>= 2.9.0", moment@^2.18.1: + version "2.19.4" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.4.tgz#17e5e2c6ead8819c8ecfad83a0acccb312e94682" + mongodb-core@2.1.17: version "2.1.17" resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-2.1.17.tgz#a418b337a14a14990fb510b923dee6a813173df8" @@ -5806,17 +5953,18 @@ mongodb@2.2.33: readable-stream "2.2.7" mongoose@^4.12.3: - version "4.12.3" - resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-4.12.3.tgz#7099bf8ce4945150001f4c2462e56c9e958ddcb9" + version "4.13.7" + resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-4.13.7.tgz#f760c770e6c8cdf34a6fe8b7443882b5fced1032" dependencies: async "2.1.4" bson "~1.0.4" - hooks-fixed "2.0.0" + hooks-fixed "2.0.2" kareem "1.5.0" + lodash.get "4.4.2" mongodb "2.2.33" mpath "0.3.0" mpromise "0.5.5" - mquery "2.3.2" + mquery "2.3.3" ms "2.0.0" muri "1.3.0" regexp-clone "0.0.1" @@ -5844,23 +5992,27 @@ mpromise@0.5.5: version "0.5.5" resolved "https://registry.yarnpkg.com/mpromise/-/mpromise-0.5.5.tgz#f5b24259d763acc2257b0a0c8c6d866fd51732e6" -mquery@2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/mquery/-/mquery-2.3.2.tgz#e2c60ad117cf080f2efb1ecdd144e7bbffbfca11" +mquery@2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/mquery/-/mquery-2.3.3.tgz#221412e5d4e7290ca5582dd16ea8f190a506b518" dependencies: - bluebird "^3.5.0" - debug "^2.6.9" - regexp-clone "^0.0.1" + bluebird "3.5.0" + debug "2.6.9" + regexp-clone "0.0.1" sliced "0.0.5" ms@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098" -ms@2.0.0, ms@^2.0.0: +ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" +ms@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + muri@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/muri/-/muri-1.3.0.tgz#aeccf3db64c56aa7c5b34e00f95b7878527a4721" @@ -5881,7 +6033,11 @@ mute-stream@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" +<<<<<<< HEAD nan@^2.3.0, nan@^2.6.2: +======= +nan@^2.3.0: +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 version "2.8.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.8.0.tgz#ed715f3fe9de02b57a5e6252d90a96675e1f085a" @@ -5933,11 +6089,11 @@ nib@~1.1.2: stylus "0.54.5" nightwatch@^0.9.16: - version "0.9.16" - resolved "https://registry.yarnpkg.com/nightwatch/-/nightwatch-0.9.16.tgz#c4ac3ec711b0ff047c3dca9c6557365ee236519f" + version "0.9.19" + resolved "https://registry.yarnpkg.com/nightwatch/-/nightwatch-0.9.19.tgz#4bd9757273d30b845f04847a98b71be9bb7c4b3b" dependencies: chai-nightwatch "~0.1.x" - ejs "0.8.3" + ejs "2.5.7" lodash.clone "3.0.3" lodash.defaultsdeep "4.3.2" minimatch "3.0.3" @@ -6051,10 +6207,11 @@ node-notifier@^5.0.2: shellwords "^0.1.0" which "^1.2.12" -node-pre-gyp@^0.6.36: - version "0.6.38" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.38.tgz#e92a20f83416415bb4086f6d1fb78b3da73d113d" +node-pre-gyp@^0.6.36, node-pre-gyp@^0.6.39: + version "0.6.39" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.39.tgz#c00e96860b23c0e1420ac7befc5044e1d78d8649" dependencies: + detect-libc "^1.0.2" hawk "3.1.3" mkdirp "^0.5.1" nopt "^4.0.1" @@ -6130,19 +6287,19 @@ nodemailer@^2.6.4: socks "1.1.9" nodemon@^1.11.0: - version "1.12.1" - resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.12.1.tgz#996a56dc49d9f16bbf1b78a4de08f13634b3878d" + version "1.13.3" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.13.3.tgz#92d23e6b91dca351215a4b8a50d2bd838cd0f9e3" dependencies: + "@remy/pstree" "^1.1.0" chokidar "^1.7.0" debug "^2.6.8" es6-promise "^3.3.1" ignore-by-default "^1.0.1" lodash.defaults "^3.1.2" minimatch "^3.0.4" - ps-tree "^1.1.0" touch "^3.1.0" undefsafe "0.0.3" - update-notifier "^2.2.0" + update-notifier "^2.3.0" nomnom@~1.6.2: version "1.6.2" @@ -6496,8 +6653,8 @@ parseurl@~1.3.2: resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3" passport-jwt@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-3.0.0.tgz#7d9e4ff0fa0522108540ce1fcfa83bbd8faa29c9" + version "3.0.1" + resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-3.0.1.tgz#e4f7276dad8bd251d43c6fc38883130b963272f6" dependencies: jsonwebtoken "^7.0.0" passport-strategy "^1.0.0" @@ -7084,7 +7241,11 @@ postcss@^6.0.1: source-map "^0.6.1" supports-color "^4.4.0" +<<<<<<< HEAD pre-git@^3.16.0: +======= +pre-git@^3.15.3: +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 version "3.16.0" resolved "https://registry.yarnpkg.com/pre-git/-/pre-git-3.16.0.tgz#a7656bc5f277185fd213c78f39f24f2cb603eb61" dependencies: @@ -7174,11 +7335,7 @@ process@^0.11.0: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" -progress@1.1.8: - version "1.1.8" - resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" - -progress@^2.0.0: +progress@2.0.0, progress@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f" @@ -7402,8 +7559,8 @@ query-string@^4.1.0, query-string@^4.2.2: strict-uri-encode "^1.0.0" query-string@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.0.0.tgz#fbdf7004b4d2aff792f9871981b7a2794f555947" + version "5.0.1" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.0.1.tgz#6e2b86fe0e08aef682ecbe86e85834765402bd88" dependencies: decode-uri-component "^0.2.0" object-assign "^4.1.0" @@ -7545,6 +7702,12 @@ react-paginate@^5.0.0: prop-types "^15.6.0" react-addons-create-fragment "^15.0.0" +react-portal@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-4.1.2.tgz#7f28f3c8c2ed5c541907c0ed0f24e8996acf627f" + dependencies: + prop-types "^15.5.8" + react-recaptcha@^2.2.6: version "2.3.5" resolved "https://registry.yarnpkg.com/react-recaptcha/-/react-recaptcha-2.3.5.tgz#a5db337125bb00fb13c2fa2e4ebfbe8b0cd06bb7" @@ -7576,7 +7739,7 @@ react-tagsinput@^3.17.0: version "3.18.0" resolved "https://registry.yarnpkg.com/react-tagsinput/-/react-tagsinput-3.18.0.tgz#40e036fc0f4c3d6b4689858189ab02926717a818" -react-test-renderer@15.5, react-test-renderer@^15.5.0: +react-test-renderer@15.5: version "15.5.4" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-15.5.4.tgz#d4ebb23f613d685ea8f5390109c2d20fbf7c83bc" dependencies: @@ -7607,6 +7770,16 @@ react-transition-group@^1.1.2, react-transition-group@^1.1.3: prop-types "^15.5.6" warning "^3.0.0" +react-virtualized@9.13.0: + version "9.13.0" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.13.0.tgz#83e4d984271a37631225e5fe6faeaeada6e59f53" + dependencies: + babel-runtime "^6.23.0" + classnames "^2.2.3" + dom-helpers "^2.4.0 || ^3.0.0" + loose-envify "^1.3.0" + prop-types "^15.5.4" + react@^15.3.1, react@^15.4.2: version "15.6.2" resolved "https://registry.yarnpkg.com/react/-/react-15.6.2.tgz#dba0434ab439cfe82f108f0f511663908179aa72" @@ -7653,6 +7826,7 @@ read-pkg@^2.0.0: normalize-package-data "^2.3.2" path-type "^2.0.0" +<<<<<<< HEAD read-pkg@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" @@ -7662,6 +7836,9 @@ read-pkg@^3.0.0: path-type "^3.0.0" readable-stream@1.1, readable-stream@1.1.x: +======= +readable-stream@1.1: +>>>>>>> b2cc4bdb12f26c733306ba1789404ba040977387 version "1.1.13" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.13.tgz#f6eef764f514c89e2b9e23146a75ba106756d23e" dependencies: @@ -7670,7 +7847,28 @@ readable-stream@1.1, readable-stream@1.1.x: isarray "0.0.1" string_decoder "~0.10.x" -readable-stream@2, readable-stream@2.2.7, readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.2.2, readable-stream@^2.2.6: +readable-stream@1.1.x: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@2, readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.2.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~1.0.6" + safe-buffer "~5.1.1" + string_decoder "~1.0.3" + util-deprecate "~1.0.1" + +readable-stream@2.2.7, readable-stream@^2.0.1, readable-stream@^2.2.6: version "2.2.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.2.7.tgz#07057acbe2467b22042d36f98c5ad507054e95b1" dependencies: @@ -7805,7 +8003,7 @@ regex-cache@^0.4.2: dependencies: is-equal-shallow "^0.1.3" -regexp-clone@0.0.1, regexp-clone@^0.0.1: +regexp-clone@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/regexp-clone/-/regexp-clone-0.0.1.tgz#a7c2e09891fdbf38fbb10d376fb73003e68ac589" @@ -7987,7 +8185,13 @@ resolve@1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" -resolve@^1.1.6, resolve@^1.1.7, resolve@^1.4.0: +resolve@^1.1.6, resolve@^1.4.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.5.0.tgz#1f09acce796c9a762579f31b2c1cc4c3cddf9f36" + dependencies: + path-parse "^1.0.5" + +resolve@^1.1.7: version "1.4.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.4.0.tgz#a75be01c53da25d934a98ebd0e4c4a7312f92a86" dependencies: @@ -8081,7 +8285,7 @@ rx@^2.4.3: version "2.5.3" resolved "https://registry.yarnpkg.com/rx/-/rx-2.5.3.tgz#21adc7d80f02002af50dae97fd9dbf248755f566" -safe-buffer@5.1.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0: +safe-buffer@5.1.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" @@ -8130,17 +8334,17 @@ select@^1.1.2: resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" selenium-standalone@^6.11.0: - version "6.11.0" - resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.11.0.tgz#eb21ff65f3b2ece75061b35d4f9e7572ac0280c2" + version "6.12.0" + resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.12.0.tgz#789730db09a105f1cce12c6424d795d11c543bd4" dependencies: async "^2.1.4" commander "^2.9.0" cross-spawn "^5.1.0" - debug "^2.6.3" + debug "^3.0.0" lodash "^4.17.4" minimist "^1.2.0" mkdirp "^0.5.1" - progress "1.1.8" + progress "2.0.0" request "2.79.0" tar-stream "1.5.2" urijs "^1.18.4" @@ -8341,7 +8545,7 @@ sliced@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/sliced/-/sliced-1.0.1.tgz#0b3a662b5d04c3177b1926bea82b03f837a2ef41" -smart-buffer@^1.0.4: +smart-buffer@^1.0.13, smart-buffer@^1.0.4: version "1.1.15" resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-1.1.15.tgz#7f114b5b65fab3e2a35aa775bb12f0d1c649bf16" @@ -8382,13 +8586,20 @@ socks-proxy-agent@2: extend "3" socks "~1.1.5" -socks@1.1.9, socks@~1.1.5: +socks@1.1.9: version "1.1.9" resolved "https://registry.yarnpkg.com/socks/-/socks-1.1.9.tgz#628d7e4d04912435445ac0b6e459376cb3e6d691" dependencies: ip "^1.1.2" smart-buffer "^1.0.4" +socks@~1.1.5: + version "1.1.10" + resolved "https://registry.yarnpkg.com/socks/-/socks-1.1.10.tgz#5b8b7fc7c8f341c53ed056e929b7bf4de8ba7b5a" + dependencies: + ip "^1.1.4" + smart-buffer "^1.0.13" + sort-keys@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" @@ -8481,7 +8692,11 @@ stack-trace@0.0.x: version "0.0.10" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" -"statuses@>= 1.3.1 < 2", statuses@~1.3.1: +"statuses@>= 1.3.1 < 2": + version "1.4.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" + +statuses@~1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" @@ -8556,7 +8771,7 @@ string_decoder@^0.10.25, string_decoder@~0.10.x: version "0.10.31" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" -string_decoder@~1.0.0: +string_decoder@~1.0.0, string_decoder@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab" dependencies: @@ -8730,8 +8945,8 @@ tar-fs@^1.13.0: tar-stream "^1.1.2" tar-pack@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.4.0.tgz#23be2d7f671a8339376cbdb0b8fe3fdebf317984" + version "3.4.1" + resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.4.1.tgz#e1dbc03a9b9d3ba07e896ad027317eb679a10a1f" dependencies: debug "^2.2.0" fstream "^1.0.10" @@ -8842,8 +9057,8 @@ title-case-minors@^1.0.0: resolved "https://registry.yarnpkg.com/title-case-minors/-/title-case-minors-1.0.0.tgz#51f17037c294747a1d1cda424b5004c86d8eb115" tlds@^1.196.0: - version "1.198.0" - resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.198.0.tgz#e7413ea5cccb5e1f4de2a1f45230b0c80994a886" + version "1.199.0" + resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.199.0.tgz#a4fc8c3058216488a80aaaebb427925007e55217" tmp@^0.0.33: version "0.0.33" @@ -8930,10 +9145,6 @@ trim-right@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" -tryit@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb" - tty-browserify@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" @@ -9070,7 +9281,7 @@ unzip-response@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97" -update-notifier@^2.2.0: +update-notifier@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.3.0.tgz#4e8827a6bb915140ab093559d7014e3ebb837451" dependencies: @@ -9328,11 +9539,11 @@ wide-align@^1.1.0: dependencies: string-width "^1.0.2" -widest-line@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-1.0.0.tgz#0c09c85c2a94683d0d7eaf8ee097d564bf0e105c" +widest-line@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.0.tgz#0142a4e8a243f8882c0233aa0e0281aa76152273" dependencies: - string-width "^1.0.1" + string-width "^2.1.1" window-size@0.1.0: version "0.1.0" @@ -9607,8 +9818,8 @@ yargs@~3.10.0: window-size "0.1.0" yauzl@^2.5.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.8.0.tgz#79450aff22b2a9c5a41ef54e02db907ccfbf9ee2" + version "2.9.1" + resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.9.1.tgz#a81981ea70a57946133883f029c5821a89359a7f" dependencies: buffer-crc32 "~0.2.3" fd-slicer "~1.0.1"