diff --git a/app.js b/app.js index 9da2041fc..2989ae00a 100644 --- a/app.js +++ b/app.js @@ -6,13 +6,9 @@ const merge = require('lodash/merge'); const helmet = require('helmet'); const compression = require('compression'); const cookieParser = require('cookie-parser'); -const { - BASE_URL, - BASE_PATH, - MOUNT_PATH, - STATIC_URL, - HELMET_CONFIGURATION, -} = require('./url'); +const {HELMET_CONFIGURATION} = require('./config'); +const {MOUNT_PATH} = require('./url'); +const {applyLocals} = require('./services/locals'); const routes = require('./routes'); const debug = require('debug')('talk:app'); @@ -57,12 +53,8 @@ app.set('view engine', 'ejs'); // ROUTES //============================================================================== -// Apply the BASE_PATH, BASE_URL, and MOUNT_PATH on the app.locals, which will -// make them available on the templates and the routers. -app.locals.BASE_URL = BASE_URL; -app.locals.BASE_PATH = BASE_PATH; -app.locals.MOUNT_PATH = MOUNT_PATH; -app.locals.STATIC_URL = STATIC_URL; +// Add the locals to the app renderer. +applyLocals(app.locals); debug(`mounting routes on the ${MOUNT_PATH} path`); diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index d50aeb3f6..d8e1b5377 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -6,7 +6,7 @@ import Configure from 'routes/Configure'; import Dashboard from 'routes/Dashboard'; import Install from 'routes/Install'; import Stories from 'routes/Stories'; -import {CommunityLayout, Community} from 'routes/Community'; +import Community from 'routes/Community/containers/Community'; import {ModerationLayout, Moderation} from 'routes/Moderation'; import Layout from 'containers/Layout'; @@ -22,7 +22,7 @@ const routes = ( {/* Community Routes */} - + diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index 890d834b4..921f0a0a2 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -1,4 +1,4 @@ -import qs from 'qs'; +import queryString from 'query-string'; import { FETCH_COMMENTERS_REQUEST, @@ -17,9 +17,8 @@ import { import t from 'coral-framework/services/i18n'; export const fetchAccounts = (query = {}) => (dispatch, _, {rest}) => { - dispatch(requestFetchAccounts()); - rest(`/users?${qs.stringify(query)}`) + rest(`/users?${queryString.stringify(query)}`) .then(({result, page, count, limit, totalPages}) =>{ dispatch({ type: FETCH_COMMENTERS_SUCCESS, diff --git a/client/coral-admin/src/components/ActionButton.css b/client/coral-admin/src/components/ActionButton.css new file mode 100644 index 000000000..226006c6b --- /dev/null +++ b/client/coral-admin/src/components/ActionButton.css @@ -0,0 +1,24 @@ +.actionButton { + transform: scale(.8); + margin: 0; +} + +.minimal { + width: 45px; + min-width: 0; +} + +.approve__active { + box-shadow: none; + color: white; + background-color: #519954; + cursor: not-allowed; +} + +.reject__active, .rejected__active { + color: white; + background-color: #D03235; + box-shadow: none; + cursor: not-allowed; +} + diff --git a/client/coral-admin/src/components/ActionButton.js b/client/coral-admin/src/components/ActionButton.js index 79fa2f182..77456b939 100644 --- a/client/coral-admin/src/components/ActionButton.js +++ b/client/coral-admin/src/components/ActionButton.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import styles from './ModerationList.css'; +import styles from './ActionButton.css'; import {Button} from 'coral-ui'; import {menuActionsMap} from '../utils/moderationQueueActionsMap'; diff --git a/client/coral-admin/src/components/ModerationList.css b/client/coral-admin/src/components/ModerationList.css deleted file mode 100644 index 4ac894290..000000000 --- a/client/coral-admin/src/components/ModerationList.css +++ /dev/null @@ -1,208 +0,0 @@ - -@custom-media --big-viewport (min-width: 780px); - -.list { - padding: 8px 0; - list-style: none; - display: block; - - &.singleView .listItem { - display: none; - } - - &.singleView .listItem.activeItem { - display: block; - height: 100%; - font-size: 1.5em; - line-height: 1.5em; - border: none; - - .actions { - position: fixed; - bottom: 60px; - left: 25%; - margin: 0 auto; - display: flex; - justify-content: space-around; - width: 50%; - margin: 0; - } - - .actionButton { - transform: scale(1.4); - } - } -} - -.listItem { - border-bottom: 1px solid #e0e0e0; - font-size: 16px; - width: 100%; - max-width: 660px; - min-width: 400px; - margin: 0 auto; - padding: 16px 14px; - position: relative; - transition: box-shadow 200ms; - - - &:hover { - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); - } - - &:last-child { - border-bottom: none; - } - - .sideActions { - position: absolute; - right: 0; - height: 100%; - top: 0; - padding: 40px 18px; - box-sizing: border-box; - } - - .itemHeader { - display: flex; - align-items: center; - justify-content: space-between; - - .author { - min-width: 230px; - display: flex; - align-items: center; - } - } - - .itemBody { - display: flex; - justify-content: space-between; - } - - .avatar { - margin-right: 16px; - height: 40px; - width: 40px; - border-radius: 50%; - background-color: #757575; - font-size: 40px; - color: #fff; - } - - .created { - color: #666; - font-size: 13px; - margin-left: 40px; - } - - .body { - margin-top: 20px; - flex: 1; - font-size: 0.88em; - color: black; - } - - .flagged { - color: rgba(255, 0, 0, .5); - padding-top: 15px; - padding-left: 10px; - } - - .flagCount{ - font-size: 12px; - color: #d32f2f; - } - -} - -.empty { - color: #444; - margin-top: 50px; - text-align: center; -} - - -@media (--big-viewport) { - .listItem { - border: 1px solid #e0e0e0; - margin-bottom: 30px; - - &:last-child { - border-bottom: 1px solid #e0e0e0; - } - - &.activeItem { - border: 2px solid #333; - } - } - -} - -.hasLinks { - color: #f00; - text-align: right; - display: flex; - align-items: center; - - i { - margin-right: 5px; - } -} - -.banned { - color: #f00; - text-align: left; - display: flex; - align-items: center; - - i { - margin-right: 5px; - } -} - -.ban { - display: block; - text-align: center; - margin-top: 5px; -} - -.banButton { - width: 114px; - letter-spacing: 1px; - - i { - vertical-align: middle; - margin-right: 10px; - font-size: 14px; - } -} - -.selected { - border-radius: 10px; -} - - -.actionButton { - transform: scale(.8); - margin: 0; -} - -.minimal { - width: 45px; - min-width: 0; -} - -.approve__active { - box-shadow: none; - color: white; - background-color: #519954; - cursor: not-allowed; -} - -.reject__active, .rejected__active { - color: white; - background-color: #D03235; - box-shadow: none; - cursor: not-allowed; -} diff --git a/client/coral-admin/src/components/ModerationList.js b/client/coral-admin/src/components/ModerationList.js deleted file mode 100644 index adfe63051..000000000 --- a/client/coral-admin/src/components/ModerationList.js +++ /dev/null @@ -1,228 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styles from './ModerationList.css'; -import key from 'keymaster'; -import Hammer from 'hammerjs'; -import Comment from './Comment'; -import User from './User'; -import SuspendUserModal from './SuspendUserModal'; - -// Each action has different meaning and configuration -const menuOptionsMap = { - 'reject': {status: 'REJECTED', icon: 'close', key: 'f'}, - 'approve': {status: 'ACCEPTED', icon: 'done', key: 'd'}, - 'flag': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'}, - 'ban': {status: 'BANNED', icon: 'not interested'} -}; - -// Renders a comment list and allow performing actions -export default class ModerationList extends React.Component { - static propTypes = { - isActive: PropTypes.bool, - singleView: PropTypes.bool, - commentIds: PropTypes.arrayOf(PropTypes.string), - actionIds: PropTypes.arrayOf(PropTypes.string), - comments: PropTypes.object, - users: PropTypes.object.isRequired, - actions: PropTypes.object, - userStatusUpdate: PropTypes.func.isRequired, - suspendUser: PropTypes.func.isRequired, - - // list of actions (flags, etc) associated with the comments - modActions: PropTypes.arrayOf(PropTypes.string).isRequired, - loading: PropTypes.bool, - - suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired - } - - state = {active: null, suspendUserModal: null, email: null}; - - // remove key handlers before leaving - componentWillUnmount () { - this.unbindKeyHandlers(); - } - - // add key handlers and gestures - componentDidMount () { - this.bindKeyHandlers(); - - // this.bindGestures() // need to check whether we're on a mobile device or this throws an Error - } - - // If entering to singleview and no active, active is the first eleement - componentWillReceiveProps (nextProps) { - if (nextProps.singleView && !this.state.active) { - this.setState({active: nextProps.commentIds[0]}); - } - } - - // Add swipe to approve or reject - bindGestures () { - const {modActions} = this.props; - this._hammer = new Hammer(this.base); - this._hammer.get('swipe').set({direction: Hammer.DIRECTION_HORIZONTAL}); - - if (modActions.indexOf('reject') !== -1) { - this._hammer.on('swipeleft', () => this.props.singleView && this.actionKeyHandler('Rejected')); - } - if (modActions.indexOf('approve') !== -1) { - this._hammer.on('swiperight', () => this.props.singleView && this.actionKeyHandler('Approved')); - } - } - - // Add key handlers. Each action has one and added j/k for moving around - bindKeyHandlers () { - const {modActions, isActive} = this.props; - modActions.filter((action) => menuOptionsMap[action].key).forEach((action) => { - key(menuOptionsMap[action].key, 'moderationList', () => isActive && this.actionKeyHandler(menuOptionsMap[action].status)); - }); - key('j', 'moderationList', () => isActive && this.moveKeyHandler('down')); - key('k', 'moderationList', () => isActive && this.moveKeyHandler('up')); - key.setScope('moderationList'); - } - - // Perform an action using the keys only if the comment is active - actionKeyHandler (action) { - if (this.props.isActive && this.state.active) { - this.onClickAction(action, this.state.active); - } - } - - // move around with j/k - moveKeyHandler (direction) { - if (!this.props.isActive) { - return; - } - - const {commentIds} = this.props; - const {active} = this.state; - - // check boundaries - if (active === null || !commentIds.length) { - this.setState({active: commentIds[0]}); - } else if (direction === 'up' && active !== commentIds[0]) { - this.setState({active: commentIds[commentIds.indexOf(active) - 1]}); - } else if (direction === 'down' && active !== commentIds[commentIds.length - 1]) { - this.setState({active: commentIds[commentIds.indexOf(active) + 1]}); - } - - // scroll to the position - const index = Math.max(commentIds.indexOf(this.state.active), 0); - this.base.childNodes[index] && this.base.childNodes[index].focus(); - } - - unbindKeyHandlers () { - key.deleteScope('moderationList'); - } - - // If we are performing an action over a comment (aka removing from the list) we need to select a new active. - // TODO: In the future this can be improved and look at the actual state to - // resolve since the content of the list could change externally. For now it works as expected - onClickAction = (menuOption, id, action) => { - - // activate the next comment - if (id === this.state.active) { - const moderationIds = this.getModerationIds(); - if (moderationIds[moderationIds.length - 1] === this.state.active) { - this.setState({active: moderationIds[moderationIds.length - 2]}); - } else { - this.setState({active: moderationIds[Math.min(moderationIds.indexOf(this.state.active) + 1, moderationIds.length - 1)]}); - } - } - - // Update the status right away if this is a comment - if (action.item_type === 'COMMENTS') { - this.props.updateCommentStatus(menuOption, id); - } else if (action.item_type === 'USERS') { - - // If a user bio or name is rejected, bring up a dialog before suspending them. - if (menuOption === 'REJECTED') { - this.setState({suspendUserModal: action}); - } else if (menuOption === 'ACCEPTED') { - this.props.userStatusUpdate('APPROVED', action.item_id); - } - } - } - - onClickShowBanDialog = (userId, userName, commentId) => { - this.props.onClickShowBanDialog(userId, userName, commentId); - } - - mapModItems = (itemId, index) => { - - const {comments = {}, users, actions = {}, modActions, suspectWords, hideActive} = this.props; - const {active} = this.state; - - // Because ids are unique, the id will either appear as an action or as a comment. - - const item = comments[itemId] || actions[itemId]; - let modItem; - - if (item.body) { - - // If the item is a comment... - const author = users[item.author_id]; - modItem = ; - } else { - - // If the item is an action... - const user = users[item.item_id]; - modItem = user && ; - } - return modItem; - } - - getModerationIds = () => { - const {commentIds = [], actionIds = [], comments, actions} = this.props; - if (comments && actions) { - return [ ...commentIds, ...actionIds ].sort((a, b) => { - const itemA = comments[a] || actions[a]; - const itemB = comments[b] || actions[b]; - return itemB.updated_at - itemA.updated_at; - }); - } else { - return comments ? commentIds : actionIds; - } - } - - render () { - const {singleView, key, suspendUser} = this.props; - - // Combine moderations and actions into a single stream and sort by most recently updated. - const moderationIds = this.getModerationIds(); - - return ( -
    - {moderationIds.map(this.mapModItems)} - this.setState({suspendUserModal:null})} - suspendUser={suspendUser} /> -
- ); - } -} diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index 74dc85a17..30c5fa7e6 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -6,101 +6,110 @@ import styles from './Header.css'; import t from 'coral-framework/services/i18n'; import {Logo} from './Logo'; import {can} from 'coral-framework/services/perms'; +import Indicator from './Indicator'; const CoralHeader = ({ handleLogout, showShortcuts = () => {}, - auth -}) => ( -
- -
- { - auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? - - - {t('configure.dashboard')} - - { - can(auth.user, 'MODERATE_COMMENTS') && ( - - {t('configure.moderate')} - - ) - } - - {t('configure.stories')} - - - {t('configure.community')} - - { - can(auth.user, 'UPDATE_CONFIG') && ( - - {t('configure.configure')} - - ) - } - - : - null - } -
- + auth, + root +}) => { + return ( +
+ +
+ { + auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? + + + {t('configure.dashboard')} + + { + can(auth.user, 'MODERATE_COMMENTS') && ( + + {t('configure.moderate')} + {(root.premodCount !== 0 || root.reportedCount !== 0) && } + + ) + } + + {t('configure.stories')} + + + + {t('configure.community')} + {root.flaggedUsernamesCount !== 0 && } + + + { + can(auth.user, 'UPDATE_CONFIG') && ( + + {t('configure.configure')} + + ) + } + + : + null + } +
+ +
-
-
-); + + ); +}; CoralHeader.propTypes = { auth: PropTypes.object, showShortcuts: PropTypes.func, - handleLogout: PropTypes.func.isRequired + handleLogout: PropTypes.func.isRequired, + root: PropTypes.object.isRequired }; export default CoralHeader; diff --git a/client/coral-admin/src/components/ui/Indicator.css b/client/coral-admin/src/components/ui/Indicator.css new file mode 100644 index 000000000..ec84c7027 --- /dev/null +++ b/client/coral-admin/src/components/ui/Indicator.css @@ -0,0 +1,10 @@ +.indicator { + background-color: #E46D59; + border-radius: 10px; + position: absolute; + top: 50%; + width: 7px; + height: 7px; + margin-top: -4px; + margin-left: 7px; +} diff --git a/client/coral-admin/src/components/ui/Indicator.js b/client/coral-admin/src/components/ui/Indicator.js new file mode 100644 index 000000000..dd1d36202 --- /dev/null +++ b/client/coral-admin/src/components/ui/Indicator.js @@ -0,0 +1,7 @@ +import React from 'react'; +import styles from './Indicator.css'; + +const Indicator = () => + ; + +export default Indicator; diff --git a/client/coral-admin/src/components/ui/Layout.js b/client/coral-admin/src/components/ui/Layout.js index 3aa056d9e..9ee9fda9f 100644 --- a/client/coral-admin/src/components/ui/Layout.js +++ b/client/coral-admin/src/components/ui/Layout.js @@ -1,22 +1,27 @@ import React from 'react'; import PropTypes from 'prop-types'; import {Layout as LayoutMDL} from 'react-mdl'; -import Header from './Header'; +import Header from '../../containers/Header'; import Drawer from './Drawer'; import styles from './Layout.css'; const Layout = ({ children, handleLogout = () => {}, - toggleShortcutModal, + toggleShortcutModal = () => {}, restricted = false, - ...props}) => ( + auth, +}) => (
- + auth={auth} + /> +
{children}
@@ -24,6 +29,8 @@ const Layout = ({ ); Layout.propTypes = { + children: PropTypes.node, + auth: PropTypes.object, handleLogout: PropTypes.func, toggleShortcutModal: PropTypes.func, restricted: PropTypes.bool // hide elements from a user that's logged out diff --git a/client/coral-admin/src/components/ui/Logo.js b/client/coral-admin/src/components/ui/Logo.js index 8c57cde11..2538a0716 100644 --- a/client/coral-admin/src/components/ui/Logo.js +++ b/client/coral-admin/src/components/ui/Logo.js @@ -1,6 +1,7 @@ import React from 'react'; import styles from './Logo.css'; import {CoralLogo} from 'coral-ui'; +import PropTypes from 'prop-types'; export const Logo = ({className = ''}) => (
@@ -10,3 +11,7 @@ export const Logo = ({className = ''}) => (
); + +Logo.propTypes = { + className: PropTypes.string +}; diff --git a/client/coral-admin/src/containers/Header.js b/client/coral-admin/src/containers/Header.js new file mode 100644 index 000000000..a5c1f0afc --- /dev/null +++ b/client/coral-admin/src/containers/Header.js @@ -0,0 +1,24 @@ +import {gql} from 'react-apollo'; +import withQuery from 'coral-framework/hocs/withQuery'; +import Header from '../components/ui/Header'; + +export default withQuery(gql` + query TalkAdmin_Header { + __typename + premodCount: commentCount(query: { + statuses: [PREMOD] + }) + reportedCount: commentCount(query: { + statuses: [NONE, PREMOD, SYSTEM_WITHHELD], + action_type: FLAG + }) + flaggedUsernamesCount: userCount(query: { + action_type: FLAG, + statuses: [PENDING] + }) + } +`, { + options: { + pollInterval: 5000 + } + })(Header); diff --git a/client/coral-admin/src/containers/Layout.js b/client/coral-admin/src/containers/Layout.js index f2d71c93b..b90016102 100644 --- a/client/coral-admin/src/containers/Layout.js +++ b/client/coral-admin/src/containers/Layout.js @@ -1,5 +1,6 @@ -import React, {Component} from 'react'; +import React from 'react'; import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; import Layout from '../components/ui/Layout'; import {fetchConfig} from '../actions/config'; import AdminLogin from '../components/AdminLogin'; @@ -10,15 +11,18 @@ import {toggleModal as toggleShortcutModal} from '../actions/moderation'; import {checkLogin, handleLogin, requestPasswordReset, logout} from '../actions/auth'; import {can} from 'coral-framework/services/perms'; import UserDetail from 'coral-admin/src/containers/UserDetail'; +import PropTypes from 'prop-types'; -class LayoutContainer extends Component { +class LayoutContainer extends React.Component { componentWillMount() { const {checkLogin, fetchConfig} = this.props; checkLogin(); fetchConfig(); } + render() { + const { user, loggedIn, @@ -29,13 +33,16 @@ class LayoutContainer extends Component { } = this.props.auth; const { - handleLogout, + children, + logout, toggleShortcutModal, - TALK_RECAPTCHA_PUBLIC + TALK_RECAPTCHA_PUBLIC, } = this.props; + if (loadingUser) { return ; } + if (!loggedIn) { return ( ); } + if (can(user, 'ACCESS_ADMIN') && loggedIn) { return ( + auth={this.props.auth} > - {this.props.children} + {children} ); } else if (loggedIn) { @@ -72,19 +79,32 @@ class LayoutContainer extends Component { } } +LayoutContainer.propTypes = { + children: PropTypes.node, + requestPasswordReset: PropTypes.func, + handleLogin: PropTypes.func, + auth: PropTypes.object, + handleLogout: PropTypes.func, + logout: PropTypes.func, + toggleShortcutModal: PropTypes.func, + TALK_RECAPTCHA_PUBLIC: PropTypes.string, + checkLogin: PropTypes.func, + fetchConfig: PropTypes.func +}; + const mapStateToProps = (state) => ({ auth: state.auth, TALK_RECAPTCHA_PUBLIC: state.config.data.TALK_RECAPTCHA_PUBLIC, }); -const mapDispatchToProps = (dispatch) => ({ - checkLogin: () => dispatch(checkLogin()), - fetchConfig: () => dispatch(fetchConfig()), - handleLogin: (username, password, recaptchaResponse) => - dispatch(handleLogin(username, password, recaptchaResponse)), - requestPasswordReset: (email) => dispatch(requestPasswordReset(email)), - toggleShortcutModal: (toggle) => dispatch(toggleShortcutModal(toggle)), - handleLogout: () => dispatch(logout()) -}); - +const mapDispatchToProps = (dispatch) => + bindActionCreators({ + checkLogin, + fetchConfig, + handleLogin, + requestPasswordReset, + toggleShortcutModal, + logout + }, dispatch); + export default connect(mapStateToProps, mapDispatchToProps)(LayoutContainer); diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js index 8fedf6ebe..6b8aa7456 100644 --- a/client/coral-admin/src/graphql/index.js +++ b/client/coral-admin/src/graphql/index.js @@ -4,7 +4,7 @@ export default { mutations: { SetUserStatus: ({variables: {status, userId}}) => ({ updateQueries: { - TalkAdmin_FlaggedAccounts: (prev) => { + TalkAdmin_Community: (prev) => { if (status !== 'APPROVED') { return prev; } @@ -19,7 +19,7 @@ export default { }), RejectUsername: ({variables: {input: {id: userId}}}) => ({ updateQueries: { - TalkAdmin_FlaggedAccounts: (prev) => { + TalkAdmin_Community: (prev) => { const updated = update(prev, { users: { nodes: {$apply: (nodes) => nodes.filter((node) => node.id !== userId)}, diff --git a/client/coral-admin/src/routes/Community/components/CommunityLayout.js b/client/coral-admin/src/routes/Community/components/CommunityLayout.js deleted file mode 100644 index 0665b967d..000000000 --- a/client/coral-admin/src/routes/Community/components/CommunityLayout.js +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -const CommunityLayout = (props) => ( -
- {props.children} -
-); - -export default CommunityLayout; diff --git a/client/coral-admin/src/routes/Community/containers/Community.js b/client/coral-admin/src/routes/Community/containers/Community.js index 445b7f413..4e60c64d7 100644 --- a/client/coral-admin/src/routes/Community/containers/Community.js +++ b/client/coral-admin/src/routes/Community/containers/Community.js @@ -1,16 +1,14 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; +import PropTypes from 'prop-types'; import {compose, gql} from 'react-apollo'; import withQuery from 'coral-framework/hocs/withQuery'; -import PropTypes from 'prop-types'; - +import {getDefinitionName} from 'coral-framework/utils'; +import {withSetUserStatus, withRejectUsername} from 'coral-framework/graphql/mutations'; import FlaggedAccounts from '../containers/FlaggedAccounts'; import FlaggedUser from '../containers/FlaggedUser'; -import {withSetUserStatus, withRejectUsername} from 'coral-framework/graphql/mutations'; -import {getDefinitionName} from 'coral-framework/utils'; - import { fetchAccounts, updateSorting, @@ -27,7 +25,7 @@ class CommunityContainer extends Component { } render() { - return bindActionCreators({ fetchAccounts, @@ -86,9 +62,27 @@ const mapDispatchToProps = (dispatch) => newPage, }, dispatch); +const withData = withQuery(gql` + query TalkAdmin_Community { + flaggedUsernamesCount: userCount(query: { + action_type: FLAG, + statuses: [PENDING] + }) + ...${getDefinitionName(FlaggedAccounts.fragments.root)} + ...${getDefinitionName(FlaggedUser.fragments.root)} + me { + ...${getDefinitionName(FlaggedUser.fragments.me)} + __typename + } + } + ${FlaggedAccounts.fragments.root} + ${FlaggedUser.fragments.root} + ${FlaggedUser.fragments.me} +`); + export default compose( connect(mapStateToProps, mapDispatchToProps), withSetUserStatus, withRejectUsername, - withData, + withData )(CommunityContainer); diff --git a/client/coral-admin/src/routes/Community/index.js b/client/coral-admin/src/routes/Community/index.js deleted file mode 100644 index 528fe63cd..000000000 --- a/client/coral-admin/src/routes/Community/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export {default as Community} from './containers/Community'; -export {default as CommunityLayout} from './components/CommunityLayout'; diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.css b/client/coral-admin/src/routes/Moderation/components/Comment.css new file mode 100644 index 000000000..a1862640f --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/Comment.css @@ -0,0 +1,180 @@ +@custom-media --big-viewport (min-width: 780px); + +.root { + border-bottom: 1px solid #e0e0e0; + font-size: 18px; + width: 100%; + max-width: 650px; + min-width: 400px; + margin: 0 auto; + position: relative; + transition: all 200ms; + padding: 10px 0; + min-height: 0; + + /* + Fix rendering issues in Safari by promoting this + into its own layer. + + https://www.pivotaltracker.com/story/show/151142211 + */ + transform: translateZ(0); + + &:last-child { + border-bottom: none; + } +} + +.container { + padding: 0 14px; +} + +.itemHeader { + display: flex; + align-items: center; + justify-content: space-between; + +} + +.author { + font-weight: 300; + width: 100%; + display: flex; + align-items: center; + color: #262626; + font-size: 16px; + position: relative; +} + +.sideActions { + height: 100%; + top: 0; + box-sizing: border-box; +} + +.itemBody { + display: flex; + justify-content: space-between; + font-size: 14px; + line-height: 1.5; + font-weight: 300; +} + +.body { + margin-top: 0px; + flex: 1; + color: black; + max-width: 500px; + word-wrap: break-word; + font-weight: 300; + font-size: 16px; +} + +.created { + padding: 5px; + color: #262626; + font-size: 14px; + line-height: 1px; + font-weight: 300; +} + +.moderateArticle { + font-size: 14px; + margin: 10px 0; + font-weight: 500; + line-height: 1.2; + max-width: 500px; + + a { + display: inline-block; + color: #063b9a; + text-decoration: none; + font-weight: 500; + letter-spacing: .5px; + margin-left: 10px; + + font-size: 13px; + margin-left: 5px; + padding-bottom: 0px; + border-bottom: solid 1px; + line-height: 16px; + + &:hover { + opacity: .9; + cursor: pointer; + } + } +} + +.username { + color: #393B44; + text-decoration: none; + cursor: pointer; + font-weight: 600; + padding: 2px 5px; + border-radius: 2px; + margin-left: -5px; + transition: background-color 200ms ease; + &:hover { + background-color: #E0E0E0; + } +} + +.external { + font-size: .7em; + text-decoration: none; + color: #063b9a; + cursor: pointer; + font-weight: normal; + margin-left: 10px; + white-space: nowrap; + + &:hover { + text-decoration: underline; + opacity: .9; + } + + i { + font-size: 12px; + top: 2px; + position: relative; + } +} + +.editedMarker { + font-style: italic; + color: #666; + font-size: 12px; + line-height: 1px; + font-weight: 300; +} + +.adminCommentInfoBar { + min-width: 100px; + position: absolute; + right: 0px; + top: 0px; + text-align: right; +} + +.hasLinks { + color: #f00; + display: flex; + align-items: center; + justify-content: flex-end; + margin-right: 14px; + + i { + margin-right: 5px; + } +} + +@media (--big-viewport) { + .root { + margin-bottom: 30px; + + &:last-child { + border-bottom: 1px solid #e0e0e0; + } + } +} diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index b210d8f23..f72b0ef12 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -4,7 +4,7 @@ import {Link} from 'react-router'; import {Icon} from 'coral-ui'; import FlagBox from 'coral-admin/src/components/FlagBox'; -import styles from './styles.css'; +import styles from './Comment.css'; import CommentLabels from 'coral-admin/src/components/CommentLabels'; import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit'; import Slot from 'coral-framework/components/Slot'; @@ -72,7 +72,8 @@ class Comment extends React.Component { return (
  • @@ -225,8 +226,12 @@ Comment.propTypes = { title: PropTypes.string, url: PropTypes.string, id: PropTypes.string - }) - }) + }), + }), + data: PropTypes.object.isRequired, + root: PropTypes.object.isRequired, + actions: PropTypes.array.isRequired, + selected: PropTypes.bool, }; export default Comment; diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index 98a5d5837..c374c7fbf 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -1,6 +1,6 @@ import React, {Component} from 'react'; +import PropTypes from 'prop-types'; import key from 'keymaster'; -import styles from './styles.css'; import ModerationQueue from './ModerationQueue'; import ModerationMenu from './ModerationMenu'; @@ -9,12 +9,13 @@ import ModerationKeysModal from '../../../components/ModerationKeysModal'; import StorySearch from '../containers/StorySearch'; import Slot from 'coral-framework/components/Slot'; -export default class Moderation extends Component { - constructor() { - super(); +class Moderation extends Component { + constructor(props) { + super(props); + const comments = this.getComments(props); this.state = { - selectedIndex: 0 + selectedCommentId: comments[0] ? comments[0].id : null, }; } @@ -25,10 +26,10 @@ export default class Moderation extends Component { key('s', () => singleView()); key('shift+/', () => toggleModal(true)); key('esc', () => toggleModal(false)); - key('j', this.select(true)); - key('k', this.select(false)); - key('f', this.moderate(false)); - key('d', this.moderate(true)); + key('j', () => this.select(true)); + key('k', () => this.select(false)); + key('f', () => this.moderate(false)); + key('d', () => this.moderate(true)); } onClose = () => { @@ -44,11 +45,15 @@ export default class Moderation extends Component { this.props.toggleStorySearch(true); } - moderate = (accept) => () => { + getActiveTabCount = (props = this.props) => { + return props.root[`${props.activeTab}Count`]; + } + + moderate = (accept) => { const {acceptComment, rejectComment} = this.props; - const {selectedIndex} = this.state; + const {selectedCommentId} = this.state; const comments = this.getComments(); - const comment = comments[selectedIndex]; + const comment = comments[selectedCommentId]; const commentId = {commentId: comment.id}; if (accept) { @@ -58,25 +63,83 @@ export default class Moderation extends Component { } } - getComments = () => { - const {root, activeTab} = this.props; + getComments = (props = this.props) => { + const {root, activeTab} = props; return root[activeTab].nodes; } - select = (next) => () => { - if (next) { - this.setState((state) => ({ - ...state, - selectedIndex: state.selectedIndex < this.getComments().length - 1 - ? state.selectedIndex + 1 : state.selectedIndex - })); - } else { - this.setState((state) => ({ - ...state, - selectedIndex: state.selectedIndex > 0 - ? state.selectedIndex - 1 : state.selectedIndex - })); + 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; + try { + const result = await this.props.loadMore(this.props.activeTab); + this.isLoadingMore = false; + return result; + } + catch (e) { + this.isLoadingMore = false; + throw e; + } + } + return false; } componentWillUnmount() { @@ -89,25 +152,56 @@ export default class Moderation extends Component { key.unbind('d'); } - componentDidUpdate(_, prevState) { + componentWillReceiveProps(nextProps) { - // If paging through using keybaord shortcuts, scroll the page to keep the selected - // comment in view. - if (prevState.selectedIndex !== this.state.selectedIndex) { + if (this.props.activeTab !== nextProps.activeTab) { - // the 'smooth' flag only works in FF as of March 2017 - document.querySelector(`.${styles.selected}`).scrollIntoView({behavior: 'smooth'}); + // 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, settings, viewUserDetail, hideUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props; + const {root, data, moderation, settings, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props; const {asset} = root; const assetId = asset && asset.id; const comments = root[activeTab]; - const activeTabCount = root[`${activeTab}Count`]; + const activeTabCount = this.getActiveTabCount(); const menuItems = Object.keys(queueConfig).map((queue) => ({ key: queue, name: queueConfig[queue].name, @@ -132,26 +226,26 @@ export default class Moderation extends Component { activeTab={activeTab} /> { @@ -30,7 +30,8 @@ ModerationHeader.propTypes = { id: PropTypes.string }), openSearch: PropTypes.func.isRequired, - closeSearch: PropTypes.func.isRequired + closeSearch: PropTypes.func.isRequired, + searchVisible: PropTypes.bool, }; export default ModerationHeader; diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationLayout.js b/client/coral-admin/src/routes/Moderation/components/ModerationLayout.js index 02a798900..4b921b515 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationLayout.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationLayout.js @@ -1,4 +1,5 @@ import React from 'react'; +import PropTypes from 'prop-types'; const ModerationLayout = (props) => (
    @@ -6,4 +7,8 @@ const ModerationLayout = (props) => (
    ); +ModerationLayout.propTypes = { + children: PropTypes.node, +}; + export default ModerationLayout; diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationMenu.css b/client/coral-admin/src/routes/Moderation/components/ModerationMenu.css new file mode 100644 index 000000000..2ee0b4ec4 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/ModerationMenu.css @@ -0,0 +1,102 @@ +@custom-media --big-viewport (min-width: 780px); + +.tabBar { + background-color: rgba(44, 44, 44, 0.89); + z-index: 5; + display: flex; + justify-content: space-between; +} + +.tabBarPadding { + width: 150px; +} + +.tab { + flex: 1; + color: #BDBDBD; + text-transform: capitalize; + font-weight: 100; + font-size: 14px; + letter-spacing: 1px; + transition: border-bottom 200ms; + transition: color 200ms; + padding: 0px 10px; + margin-right: 20px; + &:hover { + color: white; + /*border-bottom: solid 2px #F36451;*/ + box-sizing: border-box; + } +} + +.active { + color: white; + box-sizing: border-box; + border-bottom: solid 4px #F36451; + font-weight: 400; + &:hover { + border-bottom: solid 4px #F36451; + font-weight: 400; + } +} + +.active > span { + color: white; +} + +.active:after { + background: transparent !important; +} + +.selectField { + position: relative; + width: 140px; + height: 36px; + top: 5px; + margin-right: 10px; + background: #FFF; + padding: 10px 15px; + box-sizing: border-box; + border-radius: 2px; + bor + box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12); + + > div { + padding: 0; + } + + i { + position: absolute; + top: 7px; + right: 7px; + } + + input { + padding: 0; + font-size: 13px; + letter-spacing: 0.7px; + font-weight: 400; + border-bottom: 0px; + } + + label { + top: -4px; + } + + &:hover { + cursor: pointer; + } +} + +.tabIcon { + position: relative; + top: 3px; + font-size: 16px; +} + + +@media (--big-viewport) { + .tab { + flex: none; + } +} diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js b/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js index 27b8409c6..81293d06b 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import CountBadge from '../../../components/CountBadge'; -import styles from './styles.css'; +import styles from './ModerationMenu.css'; import {SelectField, Option} from 'react-mdl-selectfield'; import {Icon} from 'coral-ui'; import {Link} from 'react-router'; @@ -49,7 +49,11 @@ ModerationMenu.propTypes = { items: PropTypes.array.isRequired, asset: PropTypes.shape({ id: PropTypes.string - }) + }), + selectSort: PropTypes.func.isRequired, + sort: PropTypes.string.isRequired, + getModPath: PropTypes.func.isRequired, + activeTab: PropTypes.string.isRequired, }; export default ModerationMenu; diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css new file mode 100644 index 000000000..6f1d4ae95 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css @@ -0,0 +1,30 @@ +.root { + padding: 8px 0; + list-style: none; + display: block; + margin-top: 16px; +} + +.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; +} + diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 3197ddd78..517892a77 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -2,42 +2,56 @@ import React from 'react'; import PropTypes from 'prop-types'; import Comment from '../containers/Comment'; -import styles from './styles.css'; +import styles from './ModerationQueue.css'; import EmptyCard from '../../../components/EmptyCard'; import {actionsMap} from '../../../utils/moderationQueueActionsMap'; import LoadMore from '../../../components/LoadMore'; +import ViewMore from './ViewMore'; import t from 'coral-framework/services/i18n'; import {CSSTransitionGroup} from 'react-transition-group'; +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. +function resetCursors(state, props) { + if (props.comments && props.comments.length) { + const idCursors = [props.comments[0].id]; + if (props.comments[1]) { + idCursors.push(props.comments[1].id); + } + return {idCursors}; + } + return {idCursors: []}; +} + +// invalidateCursor is called whenever a comment is removed which is referenced +// by one of the 2 id cursors. It returns a new set of id cursors calculated +// using the help of the backup cursor. +function invalidateCursor(invalidated, state, props) { + const alt = invalidated === 1 ? 0 : 1; + const idCursors = []; + if (state.idCursors[alt]) { + idCursors.push(state.idCursors[alt]); + const index = props.comments.findIndex((node) => node.id === idCursors[0]); + const nextInLine = props.comments[index + 1]; + if (nextInLine) { + idCursors.push(nextInLine.id); + } + } + return {idCursors}; +} + class ModerationQueue extends React.Component { isLoadingMore = false; - static propTypes = { - viewUserDetail: PropTypes.func.isRequired, - bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired, - suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired, - currentAsset: PropTypes.object, - showBanUserDialog: PropTypes.func.isRequired, - showSuspendUserDialog: PropTypes.func.isRequired, - rejectComment: PropTypes.func.isRequired, - acceptComment: PropTypes.func.isRequired, - comments: PropTypes.array.isRequired - } - - loadMore = () => { - if (!this.isLoadingMore) { - this.isLoadingMore = true; - this.props.loadMore(this.props.activeTab) - .then(() => this.isLoadingMore = false) - .catch((e) => { - this.isLoadingMore = false; - throw e; - }); - } - } - constructor(props) { super(props); + this.state = { + ...resetCursors(this.state, props), + }; } componentDidUpdate (prev) { @@ -47,14 +61,67 @@ class ModerationQueue extends React.Component { // 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.loadMore(); + this.props.loadMore(); } } + componentWillReceiveProps(next) { + const {comments: prevComments} = this.props; + const {comments: nextComments} = next; + + if (!prevComments && nextComments) { + this.setState(resetCursors); + return; + } + + 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)); + } + + // 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)); + } + } + } + + viewNewComments = () => { + this.setState(resetCursors); + }; + + // getVisibileComments returns a list containing comments + // which comes after the `idCursor`. + getVisibleComments() { + const {comments} = this.props; + const idCursor = this.state.idCursors[0]; + + if (!comments) { + return []; + } + + const view = []; + let pastCursor = false; + comments.forEach((comment) => { + if (comment.id === idCursor) { + pastCursor = true; + } + if (pastCursor) { + view.push(comment); + } + }); + return view; + } + render () { const { comments, - selectedIndex, + selectedCommentId, commentCount, singleView, viewUserDetail, @@ -62,12 +129,53 @@ class ModerationQueue extends React.Component { ...props } = this.props; + if (comments.length === 0) { + return ( +
    + {t('modqueue.empty_queue')} +
    + ); + } + + if (singleView) { + const index = comments.findIndex((comment) => comment.id === selectedCommentId); + const comment = comments[index]; + const status = comment.action_summaries ? 'FLAGGED' : comment.status; + return ( +
    + ; +
    + ); + } + + const view = this.getVisibleComments(); + return ( -
    +
    + { - comments.map((comment, i) => { - const status = comment.action_summaries ? 'FLAGGED' : comment.status; - return ; - }) + view + .map((comment) => { + const status = comment.action_summaries ? 'FLAGGED' : comment.status; + return ; + }) } - {comments.length === 0 && -
    - {t('modqueue.empty_queue')} -
    - }
    @@ -117,4 +221,24 @@ class ModerationQueue extends React.Component { } } +ModerationQueue.propTypes = { + viewUserDetail: PropTypes.func.isRequired, + bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired, + suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired, + currentAsset: PropTypes.object, + showBanUserDialog: PropTypes.func.isRequired, + showSuspendUserDialog: PropTypes.func.isRequired, + rejectComment: PropTypes.func.isRequired, + acceptComment: PropTypes.func.isRequired, + comments: PropTypes.array.isRequired, + commentCount: PropTypes.number.isRequired, + loadMore: PropTypes.func.isRequired, + selectedCommentId: PropTypes.string, + singleView: PropTypes.bool, + activeTab: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + root: PropTypes.object.isRequired, + currentUserId: PropTypes.string.isRequired, +}; + export default ModerationQueue; diff --git a/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.css b/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.css new file mode 100644 index 000000000..6b03bfd69 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.css @@ -0,0 +1,20 @@ + +.notFound { + position: relative; + margin: 20px auto; + text-align: center; + padding: 68px 45px; + vertical-align: middle; + min-width: 500px; + + a { + color: rgb(244, 126, 107); + font-weight: 500; + } +} + +.goToStreams { + position: absolute; + right: 10px; + bottom: 10px; +} diff --git a/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.js b/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.js index ee5d2ccd1..820bff81c 100644 --- a/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.js +++ b/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.js @@ -1,6 +1,7 @@ import React from 'react'; import {Link} from 'react-router'; -import styles from './styles.css'; +import styles from './NotFoundAsset.css'; +import PropTypes from 'prop-types'; const NotFound = (props) => (
    @@ -11,4 +12,8 @@ const NotFound = (props) => (
    ); +NotFound.propTypes = { + assetId: PropTypes.string.isRequired, +}; + export default NotFound; diff --git a/client/coral-admin/src/routes/Moderation/components/Story.css b/client/coral-admin/src/routes/Moderation/components/Story.css new file mode 100644 index 000000000..fa43bc1cd --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/Story.css @@ -0,0 +1,49 @@ + +.story { + padding: 7px 50px; + border-bottom: 1px solid #ddd; + cursor: pointer; + display: block; + text-decoration: none; + min-height: 50px; + box-sizing: border-box; + transition: background-color 400ms; + + &:hover { + background-color: #efefef; + } + + &:last-child { + border-bottom: none; + } +} + +.title, .meta { + margin: 0; + color: black; + font-size: 15px; +} + +.author, .createdAt, .status { + font-size: 17px; + display: inline-block; + font-size: .8em; +} + +.createdAt { + text-align: center; +} + +.author { + display: inline-block; + width: 200px; + color: #aaa; +} + +.createdAt { + display: inline-block; + width: 200px; + color: #aaa; +} + + diff --git a/client/coral-admin/src/routes/Moderation/components/Story.js b/client/coral-admin/src/routes/Moderation/components/Story.js index eaad6c2a6..6720124c7 100644 --- a/client/coral-admin/src/routes/Moderation/components/Story.js +++ b/client/coral-admin/src/routes/Moderation/components/Story.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import styles from './StorySearch.css'; +import styles from './Story.css'; const formatDate = (date) => { const d = new Date(date); @@ -25,7 +25,8 @@ Story.propTypes = { author: PropTypes.string.isRequired, title: PropTypes.string.isRequired, createdAt: PropTypes.string.isRequired, - open: PropTypes.bool.isRequired + open: PropTypes.bool.isRequired, + goToStory: PropTypes.func.isRequired, }; export default Story; diff --git a/client/coral-admin/src/routes/Moderation/components/StorySearch.css b/client/coral-admin/src/routes/Moderation/components/StorySearch.css index 973d97c24..1a317ad26 100644 --- a/client/coral-admin/src/routes/Moderation/components/StorySearch.css +++ b/client/coral-admin/src/routes/Moderation/components/StorySearch.css @@ -67,54 +67,6 @@ /*.storyList { border-top: 1px solid #ddd; }*/ - -.story { - padding: 7px 50px; - border-bottom: 1px solid #ddd; - cursor: pointer; - display: block; - text-decoration: none; - min-height: 50px; - box-sizing: border-box; - transition: background-color 400ms; - - &:hover { - background-color: #efefef; - } - - &:last-child { - border-bottom: none; - } -} - -.title, .meta { - margin: 0; - color: black; - font-size: 15px; -} - -.author, .createdAt, .status { - font-size: 17px; - display: inline-block; - font-size: .8em; -} - -.createdAt { - text-align: center; -} - -.author { - display: inline-block; - width: 200px; - color: #aaa; -} - -.createdAt { - display: inline-block; - width: 200px; - color: #aaa; -} - .searchButton { width: 90px; height: 35px; diff --git a/client/coral-admin/src/routes/Moderation/components/StorySearch.js b/client/coral-admin/src/routes/Moderation/components/StorySearch.js index 7ac18f907..512ec542f 100644 --- a/client/coral-admin/src/routes/Moderation/components/StorySearch.js +++ b/client/coral-admin/src/routes/Moderation/components/StorySearch.js @@ -93,7 +93,13 @@ StorySearch.propTypes = { clearAndCloseSearch: PropTypes.func.isRequired, moderation: PropTypes.object.isRequired, handleSearchChange: PropTypes.func.isRequired, - assetId: PropTypes.string + assetId: PropTypes.string, + data: PropTypes.object.isRequired, + root: PropTypes.object.isRequired, + handleEsc: PropTypes.func.isRequired, + handleEnter: PropTypes.func.isRequired, + goToModerateAll: PropTypes.func.isRequired, + searchValue: PropTypes.string, }; export default StorySearch; diff --git a/client/coral-admin/src/routes/Moderation/components/ViewMore.css b/client/coral-admin/src/routes/Moderation/components/ViewMore.css new file mode 100644 index 000000000..2dcce0da5 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/ViewMore.css @@ -0,0 +1,21 @@ +.viewMoreContainer { + display: flex; + justify-content: center; + width: 100%; +} + +.viewMore { + width: 100%; + text-align: center; + color: #FFF; + max-width: 660px; + margin-bottom: 30px; + background-color: #2376D8; + cursor: pointer; + text-transform: capitalize; +} + +.viewMore:hover { + background-color: #4399FF; +} + diff --git a/client/coral-admin/src/routes/Moderation/components/ViewMore.js b/client/coral-admin/src/routes/Moderation/components/ViewMore.js new file mode 100644 index 000000000..d7a6f156c --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/ViewMore.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {Button} from 'coral-ui'; +import styles from './ViewMore.css'; +import cn from 'classnames'; +import t from 'coral-framework/services/i18n'; + +const ViewMore = ({viewMore, count, className, ...rest}) => +
    + { + count > 0 && + } +
    ; + +ViewMore.propTypes = { + viewMore: PropTypes.func.isRequired, + count: PropTypes.number.isRequired, + className: PropTypes.string +}; + +export default ViewMore; diff --git a/client/coral-admin/src/routes/Moderation/components/styles.css b/client/coral-admin/src/routes/Moderation/components/styles.css deleted file mode 100644 index 8d4214760..000000000 --- a/client/coral-admin/src/routes/Moderation/components/styles.css +++ /dev/null @@ -1,495 +0,0 @@ -/** - * @TODO: deprecated as this file contains styles from multiple components. Please remove this file - * when styles have been refactored. - */ - -@custom-media --big-viewport (min-width: 780px); - -.listContainer { - max-width: 860px; - margin: 0 auto; -} - -.tabBar { - background-color: rgba(44, 44, 44, 0.89); - z-index: 5; - display: flex; - justify-content: space-between; -} - -.tabBarPadding { - width: 150px; -} - -.tab { - flex: 1; - color: #BDBDBD; - text-transform: capitalize; - font-weight: 100; - font-size: 14px; - letter-spacing: 1px; - transition: border-bottom 200ms; - transition: color 200ms; - padding: 0px 10px; - margin-right: 20px; - &:hover { - color: white; - /*border-bottom: solid 2px #F36451;*/ - box-sizing: border-box; - } -} - -.active { - color: white; - box-sizing: border-box; - border-bottom: solid 4px #F36451; - font-weight: 400; - &:hover { - border-bottom: solid 4px #F36451; - font-weight: 400; - } -} - -.active > span { - color: white; -} - -.active:after { - background: transparent !important; -} - -.showShortcuts { - position: absolute; - right: 130px; - display: flex; - align-items: center; - font-size: 13px; - -span { - margin-left: 7px; -} -} - -@media (--big-viewport) { - .tab { - flex: none; - } -} - -.notFound { - position: relative; - margin: 20px auto; - text-align: center; - padding: 68px 45px; - vertical-align: middle; - min-width: 500px; - - a { - color: rgb(244, 126, 107); - font-weight: 500; - - &.goToStreams { - position: absolute; - right: 10px; - bottom: 10px; - } - } -} - -.header { - background-color: #2c2c2c; - color: white; - margin-bottom: -1px; - - .settingsButton { - vertical-align: middle; - margin-left: 10px; - margin-top: -4px; - font-size: 16px; - } - - .moderateAsset { - a { - -webkit-box-flex: 1; - -ms-flex: 1; - flex: 1; - color: white; - text-transform: capitalize; - font-weight: 400; - font-size: 20px; - letter-spacing: 1px; - transition: background-color 200ms; - opacity: 1; - - &:hover { - cursor: pointer; - background-color: #212121; - } - span { - text-align: center; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - max-width: 344px; - display: inline-block; - vertical-align: top; - } - - } - } -} - - -@custom-media --big-viewport (min-width: 780px); - -.list { - padding: 8px 0; - list-style: none; - display: block; - - &.singleView .listItem { - display: none; - } - - &.singleView .listItem.selected { - display: block; - height: 100%; - font-size: 1.5em; - line-height: 1.5em; - border: none; - - .actions { - position: fixed; - bottom: 60px; - left: 25%; - display: flex; - justify-content: space-around; - width: 50%; - margin: 0; - } - - .actionButton { - transform: scale(1.4); - } - } -} - -.listItem { - border-bottom: 1px solid #e0e0e0; - font-size: 18px; - width: 100%; - max-width: 650px; - min-width: 400px; - margin: 0 auto; - position: relative; - transition: all 200ms; - padding: 10px 0; - min-height: 0; - - /* - Fix rendering issues in Safari by promoting this - into its own layer. - - https://www.pivotaltracker.com/story/show/151142211 - */ - transform: translateZ(0); - - .container { - padding: 0 14px; - } - - &:last-child { - border-bottom: none; - } - - &.selected { - } - - .context { - a { - color: #f36451; - text-decoration: underline; - float: right; - } - } - - .sideActions { - height: 100%; - top: 0; - box-sizing: border-box; - } - - .itemHeader { - display: flex; - align-items: center; - justify-content: space-between; - - .author { - font-weight: 300; - width: 100%; - display: flex; - align-items: center; - color: #262626; - font-size: 16px; - position: relative; - } - } - - .itemBody { - display: flex; - justify-content: space-between; - font-size: 14px; - line-height: 1.5; - font-weight: 300; - } - - .avatar { - margin-right: 16px; - height: 40px; - width: 40px; - border-radius: 50%; - background-color: #757575; - font-size: 40px; - color: #fff; - } - - .created { - padding: 5px; - color: #262626; - font-size: 14px; - line-height: 1px; - font-weight: 300; - } - - .actionButton { - transform: scale(.8); - margin: 0; - } - - .body { - margin-top: 0px; - flex: 1; - color: black; - max-width: 500px; - word-wrap: break-word; - font-weight: 300; - font-size: 16px; - } - - .flagged { - color: rgba(255, 0, 0, .5); - padding-top: 15px; - padding-left: 10px; - } - - .flagCount{ - font-size: 12px; - color: #d32f2f; - } - -} - -.empty { - color: #444; - margin-top: 50px; - text-align: center; -} - - -@media (--big-viewport) { - .listItem { - margin-bottom: 30px; - - &:last-child { - border-bottom: 1px solid #e0e0e0; - } - - &.activeItem { - border: 2px solid #333; - } - } - -} - -.hasLinks { - color: #f00; - text-align: right; - display: flex; - align-items: center; - - i { - margin-right: 5px; - } -} - -.banned { - color: #f00; - text-align: left; - display: flex; - align-items: center; - - i { - margin-right: 5px; - } -} - -.ban { - display: block; - text-align: center; - margin-top: 5px; -} - -.Comment { - - .moderateArticle { - font-size: 14px; - margin: 10px 0; - font-weight: 500; - line-height: 1.2; - max-width: 500px; - - a { - display: inline-block; - color: #063b9a; - text-decoration: none; - font-weight: 500; - letter-spacing: .5px; - margin-left: 10px; - - font-size: 13px; - margin-left: 5px; - padding-bottom: 0px; - border-bottom: solid 1px; - line-height: 16px; - - &:hover { - opacity: .9; - cursor: pointer; - } - } - } -} - -.selectField { - position: relative; - width: 140px; - height: 36px; - top: 5px; - margin-right: 10px; - background: #FFF; - padding: 10px 15px; - box-sizing: border-box; - border-radius: 2px; - bor - box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12); - - > div { - padding: 0; - } - - i { - position: absolute; - top: 7px; - right: 7px; - } - - input { - padding: 0; - font-size: 13px; - letter-spacing: 0.7px; - font-weight: 400; - border-bottom: 0px; - } - - label { - top: -4px; - } - - &:hover { - cursor: pointer; - } -} - -.tabIcon { - position: relative; - top: 3px; - font-size: 16px; -} - -.username { - color: #393B44; - text-decoration: none; - cursor: pointer; - font-weight: 600; - padding: 2px 5px; - border-radius: 2px; - margin-left: -5px; - transition: background-color 200ms ease; - &:hover { - background-color: #E0E0E0; - } -} - -.external { - font-size: .7em; - text-decoration: none; - color: #063b9a; - cursor: pointer; - font-weight: normal; - margin-left: 10px; - white-space: nowrap; - - &:hover { - text-decoration: underline; - opacity: .9; - } - - i { - font-size: 12px; - top: 2px; - position: relative; - } -} - -.emptyCardContainer { - margin-top: 16px; -} - -.commentLeave { - opacity: 1.0; -} - -.commentLeaveActive { - opacity: 0; - transition: opacity 800ms; -} - -.commentEnter { - opacity: 0; -} - -.commentEnterActive { - opacity: 1.0; - transition: opacity 800ms; -} - -.editedMarker { - font-style: italic; - color: #666; - font-size: 12px; - line-height: 1px; - font-weight: 300; -} - -.searchTrigger { - position: relative; - top: .2em; -} - -.adminCommentInfoBar { - min-width: 100px; - position: absolute; - right: 0px; - top: 0px; - text-align: right; -} diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index baf6b8dfd..48b4a9364 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -82,50 +82,56 @@ class ModerationContainer extends Component { } subscribeToUpdates(variables = this.props.data.variables) { - const sub1 = this.props.data.subscribeToMore({ - 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)); - return this.handleCommentChange(prev, comment, notifyText); + const parameters = [ + { + document: COMMENT_ADDED_SUBSCRIPTION, + variables, + updateQuery: (prev, {subscriptionData: {data: {commentAdded: comment}}}) => { + return this.handleCommentChange(prev, comment); + }, }, - }); - - const sub2 = this.props.data.subscribeToMore({ - 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)); - return this.handleCommentChange(prev, comment, notifyText); + { + 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)); + return this.handleCommentChange(prev, comment, notifyText); + }, }, - }); - - const sub3 = this.props.data.subscribeToMore({ - document: COMMENT_EDITED_SUBSCRIPTION, - variables, - updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => { - const notifyText = t('modqueue.notify_edited', comment.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)); + return this.handleCommentChange(prev, comment, notifyText); + }, }, - }); - - const sub4 = this.props.data.subscribeToMore({ - document: COMMENT_FLAGGED_SUBSCRIPTION, - variables, - updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => { - const user = comment.actions[comment.actions.length - 1].user; - const notifyText = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)); - return this.handleCommentChange(prev, comment, notifyText); + { + document: COMMENT_EDITED_SUBSCRIPTION, + variables, + updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => { + const notifyText = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body)); + return this.handleCommentChange(prev, comment, notifyText); + }, }, - }); + { + document: COMMENT_FLAGGED_SUBSCRIPTION, + variables, + updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => { + const user = comment.actions[comment.actions.length - 1].user; + const notifyText = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)); + return this.handleCommentChange(prev, comment, notifyText); + }, + }, + ]; - this.subscriptions.push(sub1, sub2, sub3, sub4); + this.subscriptions = parameters.map((param) => this.props.data.subscribeToMore(param)); } unsubscribe() { @@ -204,12 +210,9 @@ class ModerationContainer extends Component { // Not found. return ; } - if (asset === undefined || asset.id !== assetId) { + } - // Still loading. - return ; - } - } else if (asset !== undefined || !('premodCount' in root)) { + if(data.loading) { // loading. return ; @@ -240,6 +243,14 @@ class ModerationContainer extends Component { />; } } +const COMMENT_ADDED_SUBSCRIPTION = gql` + subscription CommentAdded($asset_id: ID){ + commentAdded(asset_id: $asset_id, statuses: null){ + ...${getDefinitionName(Comment.fragments.comment)} + } + } + ${Comment.fragments.comment} +`; const COMMENT_EDITED_SUBSCRIPTION = gql` subscription CommentEdited($asset_id: ID){ @@ -369,29 +380,6 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql` }, }); -const withQueueCountPolling = withQuery(({queueConfig}) => gql` - query CoralAdmin_ModerationCountPoll($asset_id: ID) { - ${Object.keys(queueConfig).map((queue) => ` - ${queue}Count: commentCount(query: { - ${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''} - ${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''} - ${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''} - asset_id: $asset_id, - }) - `)} - } -`, { - options: (props) => { - const id = getAssetId(props); - return { - pollInterval: 5000, - variables: { - asset_id: id - } - }; - } -}); - const mapStateToProps = (state) => ({ moderation: state.moderation, settings: state.settings, @@ -419,6 +407,5 @@ export default compose( withQueueConfig(baseQueueConfig), connect(mapStateToProps, mapDispatchToProps), withSetCommentStatus, - withQueueCountPolling, withModQueueQuery, )(ModerationContainer); diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 622ec5e29..4d9947769 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -16,7 +16,7 @@ import mapValues from 'lodash/mapValues'; import LoadMore from './LoadMore'; import {getEditableUntilDate} from './util'; import {findCommentWithId} from '../graphql/utils'; -import CommentContent from './CommentContent'; +import CommentContent from 'coral-framework/components/CommentContent'; import Slot from 'coral-framework/components/Slot'; import CommentTombstone from './CommentTombstone'; import InactiveCommentLabel from './InactiveCommentLabel'; diff --git a/client/coral-embed-stream/src/components/Embed.js b/client/coral-embed-stream/src/components/Embed.js index 86d36d47e..7fc6d1474 100644 --- a/client/coral-embed-stream/src/components/Embed.js +++ b/client/coral-embed-stream/src/components/Embed.js @@ -29,7 +29,7 @@ export default class Embed extends React.Component { }; render() { - const {activeTab, commentId, root, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog} = this.props; + const {activeTab, commentId, root, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog, router: {location: {query: {parentUrl}}}} = this.props; const {user} = this.props.auth; const hasHighlightedComment = !!commentId; @@ -37,7 +37,7 @@ export default class Embed extends React.Component {
    { const textbreaks = comment.body.split('\n'); - return
    + return { textbreaks.map((line, i) => { return ( {line} -
    + {i === textbreaks.length - 1 &&
    }
    ); }) } -
    ; + ; }; export default CommentContent; diff --git a/client/talk-plugin-history/Comment.js b/client/talk-plugin-history/Comment.js index 7532548cf..9d1208708 100644 --- a/client/talk-plugin-history/Comment.js +++ b/client/talk-plugin-history/Comment.js @@ -4,7 +4,7 @@ import {Icon} from '../coral-ui'; import styles from './Comment.css'; import Slot from 'coral-framework/components/Slot'; import CommentTimestamp from 'coral-framework/components/CommentTimestamp'; -import CommentContent from '../coral-embed-stream/src/components/CommentContent'; +import CommentContent from 'coral-framework/components/CommentContent'; import cn from 'classnames'; import {getTotalReactionsCount} from 'coral-framework/utils'; diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index a1315d847..de3345257 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -182,11 +182,11 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu Comments.parentCountByAssetID.incr(asset_id); } Comments.countByAssetID.incr(asset_id); - - // Publish the newly added comment via the subscription. - pubsub.publish('commentAdded', comment); } + // Publish the newly added comment via the subscription. + pubsub.publish('commentAdded', comment); + return comment; }; diff --git a/graph/setupFunctions.js b/graph/setupFunctions.js index 273aedfa2..42dc15a9f 100644 --- a/graph/setupFunctions.js +++ b/graph/setupFunctions.js @@ -26,10 +26,30 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu commentAdded: (options, args) => ({ commentAdded: { filter: (comment, context) => { + + // Only priviledged users can subscribe to all assets. if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_ADDED))) { return false; } - return !args.asset_id || comment.asset_id === args.asset_id; + + // If user scubsscribes for statuses other than NONE and/or ACCEPTED statuses, it needs + // special priviledges. + if ( + (!args.statuses || args.statuses.some((status) => !['NONE', 'ACCEPTED'].includes(status))) && + (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_ADDED)) + ) { + return false; + } + + if (args.asset_id && comment.asset_id !== args.asset_id) { + return false; + } + + if (args.statuses && !args.statuses.includes(comment.status)) { + return false; + } + + return true; } }, }), diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 70167bb67..ca2a39871 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -1356,7 +1356,8 @@ type Subscription { # Get an update whenever a comment was added. # `asset_id` is required except for users with the `ADMIN` or `MODERATOR` role. - commentAdded(asset_id: ID): Comment + # Non privileged user can only subscribe to 'NONE' and/or 'ACCEPTED' statuses. + commentAdded(asset_id: ID, statuses: [COMMENT_STATUS!] = [NONE, ACCEPTED]): Comment # Get an update whenever a comment was edited. # `asset_id` is required except for users with the `ADMIN` or `MODERATOR` role. diff --git a/package.json b/package.json index 115d94c12..702a351cd 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "fs-extra": "^4.0.1", "gql-merge": "^0.0.4", "graphql": "^0.9.1", + "graphql-anywhere": "^3.1.0", "graphql-docs": "^0.2.0", "graphql-errors": "^2.1.0", "graphql-redis-subscriptions": "^1.3.0", @@ -144,12 +145,11 @@ "passport-local": "^1.0.0", "pluralize": "^7.0.0", "postcss-loader": "^1.3.3", - "postcss-modules": "^0.5.2", "postcss-smart-import": "^0.5.1", "precss": "^1.4.0", "prop-types": "^15.5.10", "pym.js": "^1.1.1", - "query-strings": "^0.0.1", + "query-string": "^5.0.0", "react": "^15.4.2", "react-apollo": "^1.4.12", "react-dom": "^15.4.2", @@ -180,7 +180,6 @@ "url-search-params": "^0.9.0", "uuid": "^3.1.0", "webpack": "^2.3.1", - "webpack-sources": "^1.0.1", "yaml-loader": "^0.4.0", "yamljs": "^0.2.10" }, @@ -190,7 +189,6 @@ "chai-http": "^3.0.0", "enzyme": "^2.9.1", "eslint": "^4.5.0", - "eslint-module-utils": "^2.1.1", "eslint-plugin-json": "^1.2.0", "eslint-plugin-mocha": "^4.11.0", "eslint-plugin-promise": "^3.3.1", @@ -200,8 +198,7 @@ "nodemon": "^1.11.0", "pre-git": "^3.15.3", "sinon": "^3.2.1", - "sinon-chai": "^2.13.0", - "supertest": "^2.0.1" + "sinon-chai": "^2.13.0" }, "engines": { "node": "^8" diff --git a/plugin-api/beta/client/components/index.js b/plugin-api/beta/client/components/index.js index a0ae0f3da..392d923a0 100644 --- a/plugin-api/beta/client/components/index.js +++ b/plugin-api/beta/client/components/index.js @@ -4,3 +4,4 @@ export {default as IfSlotIsEmpty} from 'coral-framework/components/IfSlotIsEmpty export {default as IfSlotIsNotEmpty} from 'coral-framework/components/IfSlotIsNotEmpty'; export {default as CommentAuthorName} from 'coral-framework/components/CommentAuthorName'; export {default as CommentTimestamp} from 'coral-framework/components/CommentTimestamp'; +export {default as CommentContent} from 'coral-framework/components/CommentContent'; diff --git a/plugins/talk-plugin-comment-content/client/components/CommentContent.js b/plugins/talk-plugin-comment-content/client/components/CommentContent.js index cc42f1b8d..8819d63ce 100644 --- a/plugins/talk-plugin-comment-content/client/components/CommentContent.js +++ b/plugins/talk-plugin-comment-content/client/components/CommentContent.js @@ -5,20 +5,20 @@ const name = 'talk-plugin-comment-content'; const CommentContent = ({comment}) => { const textbreaks = comment.body.split('\n'); - return
    + return { textbreaks.map((line, i) => { return ( - {line} + {line.trim()} -
    + {i !== textbreaks.length - 1 &&
    }
    ); }) } -
    ; + ; }; export default CommentContent; diff --git a/plugins/talk-plugin-featured-comments/client/components/Comment.js b/plugins/talk-plugin-featured-comments/client/components/Comment.js index 31852d63d..57c2b0392 100644 --- a/plugins/talk-plugin-featured-comments/client/components/Comment.js +++ b/plugins/talk-plugin-featured-comments/client/components/Comment.js @@ -2,7 +2,7 @@ import React from 'react'; import cn from 'classnames'; import styles from './Comment.css'; import {t} from 'plugin-api/beta/client/services'; -import {Slot, CommentAuthorName, CommentTimestamp} from 'plugin-api/beta/client/components'; +import {Slot, CommentAuthorName, CommentTimestamp, CommentContent} from 'plugin-api/beta/client/components'; import {Icon} from 'plugin-api/beta/client/components/ui'; import {pluginName} from '../../package.json'; import FeaturedButton from '../containers/FeaturedButton'; @@ -19,9 +19,14 @@ class Comment extends React.Component { return (
    -
    - {comment.body} -
    +
    diff --git a/routes/api/account/index.js b/routes/api/account/index.js index 99cb9a468..c35f709cb 100644 --- a/routes/api/account/index.js +++ b/routes/api/account/index.js @@ -4,9 +4,6 @@ const UsersService = require('../../../services/users'); const mailer = require('../../../services/mailer'); const authorization = require('../../../middleware/authorization'); const errors = require('../../../errors'); -const { - ROOT_URL -} = require('../../../config'); //============================================================================== // ROUTES @@ -50,22 +47,17 @@ router.post('/password/reset', async (req, res, next) => { try { let token = await UsersService.createPasswordResetToken(email, loc); - if (!token) { - res.status(204).end(); - return; + if (token) { + await mailer.sendSimple({ + template: 'password-reset', + locals: { + token, + }, + subject: 'Password Reset', + to: email + }); } - // Send the password reset email. - await mailer.sendSimple({ - template: 'password-reset', // needed to know which template to render! - locals: { // specifies the template locals. - token, - rootURL: ROOT_URL - }, - subject: 'Password Reset', - to: email - }); - res.status(204).end(); } catch (e) { return next(e); @@ -78,22 +70,23 @@ router.post('/password/reset', async (req, res, next) => { * 2) the new password {String} */ router.put('/password/reset', async (req, res, next) => { - - const { - token, - password - } = req.body; + const {check} = req.query; + const {token, password} = req.body; if (!token) { return next(errors.ErrMissingToken); } - if (!password || password.length < 8) { + if (check !== 'true' && (!password || password.length < 8)) { return next(errors.ErrPasswordTooShort); } try { let [user, loc] = await UsersService.verifyPasswordResetToken(token); + if (check === 'true') { + res.status(204).end(); + return; + } // Change the users' password. await UsersService.changePassword(user.id, password); diff --git a/services/domainlist.js b/services/domainlist.js index 29673e2b6..a75d62dca 100644 --- a/services/domainlist.js +++ b/services/domainlist.js @@ -2,6 +2,8 @@ const debug = require('debug')('talk:services:domainlist'); const _ = require('lodash'); const SettingsService = require('./settings'); +const {ROOT_URL} = require('../config'); + /** * The root domainlist object. * @type {Object} @@ -17,31 +19,24 @@ class Domainlist { /** * Loads domains white list in from the database */ - load() { - return SettingsService - .retrieve() - .then((settings) => { - - // Insert the settings domains whitelist. - this.upsert(settings.domains); - }); + async load() { + const {domains} = await SettingsService.retrieve(); + this.upsert(domains); } /** * Inserts the domains whitelist data * @param {Array} list list of domains to be set to the whitelist */ - upsert(lists) { + async upsert(lists) { // Add the domains to this array and also be sure are all unique domains if (!('whitelist' in lists)) { return; } - this.lists['whitelist'] = Domainlist.parseList(lists['whitelist']); - debug(`Added ${lists['whitelist'].length} domains to the whitelist.`); - - return Promise.resolve(this); + this.lists.whitelist = Domainlist.parseList(lists.whitelist); + debug(`Added ${lists.whitelist.length} domains to the whitelist.`); } /** @@ -51,19 +46,22 @@ class Domainlist { */ match(list, url) { + // Parse the url that we're matching with. const domainToMatch = Domainlist.parseURL(url); // This will return true in the event that at least one blockword is found // in the phrase. - for (let i = 0; i < list.length; i++) { - if (list[i] === domainToMatch) { - return true; - } - } + return list.indexOf(domainToMatch) >= 0; + } - // We've walked over all the whitelisted domains, and haven't had a - // mismatch... It is not an allowed domain! - return false; + /** + * Checks to see if the passed url matches the domain of the root path. + * + * @param {String} url + * @returns {Boolean} true if the domains match + */ + static matchMount(url) { + return Domainlist.parseURL(url) === Domainlist.parseURL(ROOT_URL); } /** @@ -84,7 +82,7 @@ class Domainlist { let domain; // removes protocol and get domain - if (url.indexOf('://') > -1) { + if (url.indexOf('//') > -1) { domain = url.split('/')[2]; } else { domain = url.split('/')[0]; @@ -96,13 +94,14 @@ class Domainlist { return domain.toLowerCase(); } - static urlCheck(url) { + static async urlCheck(url) { const dl = new Domainlist(); - return dl.load() - .then(() => { - return dl.match(dl.lists['whitelist'], url); - }); + // Load the domain list. + await dl.load(); + + // Perform a match. + return dl.match(dl.lists.whitelist, url); } } diff --git a/services/email/email-confirm.html.ejs b/services/email/email-confirm.html.ejs index 08a505521..8a1bd05f7 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') %> Confirm Email

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

    diff --git a/services/email/email-confirm.txt.ejs b/services/email/email-confirm.txt.ejs index 6d3cb219c..d327220a7 100644 --- a/services/email/email-confirm.txt.ejs +++ b/services/email/email-confirm.txt.ejs @@ -4,6 +4,6 @@ <%= t('email.confirm.to_confirm') %> - <%= rootURL %>/confirm/endpoint#<%= token %> + <%= BASE_URL %>confirm/endpoint#<%= token %> <%= t('email.confirm.if_you_did_not') %> diff --git a/services/email/password-reset.html.ejs b/services/email/password-reset.html.ejs index 258f0d079..c0ec4ea46 100644 --- a/services/email/password-reset.html.ejs +++ b/services/email/password-reset.html.ejs @@ -1,2 +1,2 @@

    <%= t('email.password_reset.we_received_a_request') %>
    -<%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>.

    +<%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>.

    diff --git a/services/email/password-reset.txt.ejs b/services/email/password-reset.txt.ejs index a8387925d..e8db4bab2 100644 --- a/services/email/password-reset.txt.ejs +++ b/services/email/password-reset.txt.ejs @@ -1,3 +1,3 @@ <%= t('email.password_reset.we_received_a_request') %>. <%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>: -<%= rootURL %>/admin/password-reset#<%= token %> +<%= BASE_URL %>admin/password-reset#<%= token %> diff --git a/services/locals.js b/services/locals.js new file mode 100644 index 000000000..9a6a6bec8 --- /dev/null +++ b/services/locals.js @@ -0,0 +1,20 @@ +const { + BASE_URL, + BASE_PATH, + MOUNT_PATH, + STATIC_URL, +} = require('../url'); + +const applyLocals = (locals) => { + + // Apply the BASE_PATH, BASE_URL, and MOUNT_PATH on the app.locals, which will + // make them available on the templates and the routers. + locals.BASE_URL = BASE_URL; + locals.BASE_PATH = BASE_PATH; + locals.MOUNT_PATH = MOUNT_PATH; + locals.STATIC_URL = STATIC_URL; +}; + +module.exports = { + applyLocals, +}; diff --git a/services/mailer.js b/services/mailer.js index 68252bea3..8d650726d 100644 --- a/services/mailer.js +++ b/services/mailer.js @@ -4,6 +4,7 @@ const kue = require('./kue'); const path = require('path'); const fs = require('fs'); const _ = require('lodash'); +const {applyLocals} = require('./locals'); const i18n = require('./i18n'); @@ -92,6 +93,9 @@ const mailer = module.exports = { // Prefix the subject with `[Talk]`. subject = `[Talk] ${subject}`; + applyLocals(locals); + + // Attach the templating function. locals['t'] = i18n.t; return Promise.all([ diff --git a/services/users.js b/services/users.js index 35810ef7f..1dc9bceb6 100644 --- a/services/users.js +++ b/services/users.js @@ -1,8 +1,6 @@ const assert = require('assert'); const uuid = require('uuid'); const bcrypt = require('bcryptjs'); -const url = require('url'); -const Wordlist = require('./wordlist'); const errors = require('../errors'); const { @@ -22,9 +20,10 @@ const USER_ROLES = require('../models/enum/user_roles'); const RECAPTCHA_WINDOW_SECONDS = 60 * 10; // 10 minutes. const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 3 incorrect attempts, recaptcha will be required. -const SettingsService = require('./settings'); const ActionsService = require('./actions'); const MailerService = require('./mailer'); +const Wordlist = require('./wordlist'); +const Domainlist = require('./domainlist'); const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm'; const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; @@ -557,11 +556,10 @@ module.exports = class UsersService { email = email.toLowerCase(); - const [user, settings] = await Promise.all([ + const [user, domainValidated] = await Promise.all([ UserModel.findOne({profiles: {$elemMatch: {id: email}}}), - SettingsService.retrieve(), + Domainlist.urlCheck(loc), ]); - if (!user) { // Since we don't want to reveal that the email does/doesn't exist @@ -569,19 +567,11 @@ module.exports = class UsersService { // endpoint. return; } - let redirectDomain; - try { - const {hostname, port} = url.parse(loc); - redirectDomain = hostname; - if (port) { - redirectDomain += `:${port}`; - } - } catch (e) { - throw new Error('redirect location is invalid'); - } - if (settings.domains.whitelist.indexOf(redirectDomain) === -1) { - throw new Error('redirect location is not on the list of acceptable domains'); + // If the domain didn't match any of the whitelisted domains and if it + // didn't match the mount domain, then throw an error. + if (!domainValidated && !Domainlist.matchMount(loc)) { + throw new Error('user supplied location exists on non-permitted domain'); } const payload = { @@ -619,16 +609,18 @@ module.exports = class UsersService { * Verifies a jwt and returns the associated user. * @param {String} token the JSON Web Token to verify */ - static verifyPasswordResetToken(token) { - return UsersService - .verifyToken(token, { - subject: PASSWORD_RESET_JWT_SUBJECT - }) + static async verifyPasswordResetToken(token) { + const {userId, loc, version} = await UsersService.verifyToken(token, { + subject: PASSWORD_RESET_JWT_SUBJECT + }); - // TODO: add search by __v as well - .then((decoded) => { - return Promise.all([UsersService.findById(decoded.userId), decoded.loc]); - }); + const user = await UsersService.findById(userId); + + if (version !== user.__v) { + throw new Error('password reset token has expired'); + } + + return [user, loc]; } /** diff --git a/test/server/services/domainlist.js b/test/server/services/domainlist.js index 9dafa438a..33afdda87 100644 --- a/test/server/services/domainlist.js +++ b/test/server/services/domainlist.js @@ -26,13 +26,84 @@ describe('services.Domainlist', () => { }); + describe('#parseURL', () => { + it('parses the domain correctly', () => { + [ + ['http://google.ca/test', 'google.ca'], + ['http://google.ca:80/test', 'google.ca'], + ['https://google.ca/test', 'google.ca'], + ['https://google.ca:443/test', 'google.ca'], + ['//google.ca/test', 'google.ca'], + ['//google.ca:80/test', 'google.ca'], + ['//google.ca:443/test', 'google.ca'], + ['google.ca/test', 'google.ca'], + ['google.ca:80/test', 'google.ca'], + ['google.ca:443/test', 'google.ca'], + ['http://google.ca/', 'google.ca'], + ['http://google.ca:80/', 'google.ca'], + ['https://google.ca/', 'google.ca'], + ['https://google.ca:443/', 'google.ca'], + ['//google.ca/', 'google.ca'], + ['//google.ca:80/', 'google.ca'], + ['//google.ca:443/', 'google.ca'], + ['google.ca/', 'google.ca'], + ['google.ca:80/', 'google.ca'], + ['google.ca:443/', 'google.ca'], + ['google.ca', 'google.ca'], + ['http://google.ca', 'google.ca'], + ['http://google.ca:80', 'google.ca'], + ['https://google.ca', 'google.ca'], + ['https://google.ca:443', 'google.ca'], + ['//google.ca', 'google.ca'], + ['//google.ca:80', 'google.ca'], + ['//google.ca:443', 'google.ca'], + ['google.ca', 'google.ca'], + ['google.ca:80', 'google.ca'], + ['google.ca:443', 'google.ca'], + ['http://google.Ca/test', 'google.ca'], + ['http://google.ca:80/test', 'google.ca'], + ['https://google.Ca/test', 'google.ca'], + ['https://google.ca:443/test', 'google.ca'], + ['//google.Ca/test', 'google.ca'], + ['//google.Ca:80/test', 'google.ca'], + ['//google.Ca:443/test', 'google.ca'], + ['google.Ca/test', 'google.ca'], + ['google.ca:80/test', 'google.ca'], + ['google.ca:443/test', 'google.ca'], + ['http://Google.ca/', 'google.ca'], + ['http://google.Ca:80/', 'google.ca'], + ['https://Google.ca/', 'google.ca'], + ['https://google.Ca:443/', 'google.ca'], + ['//Google.ca/', 'google.ca'], + ['//google.Ca:80/', 'google.ca'], + ['//google.Ca:443/', 'google.ca'], + ['Google.ca/', 'google.ca'], + ['google.Ca:80/', 'google.ca'], + ['google.Ca:443/', 'google.ca'], + ['Google.ca', 'google.ca'], + ['http://Google.ca', 'google.ca'], + ['http://google.Ca:80', 'google.ca'], + ['https://Google.ca', 'google.ca'], + ['https://google.Ca:443', 'google.ca'], + ['//Google.ca', 'google.ca'], + ['//google.Ca:80', 'google.ca'], + ['//google.Ca:443', 'google.ca'], + ['Google.ca', 'google.ca'], + ['google.Ca:80', 'google.ca'], + ['google.Ca:443', 'google.ca'], + ].forEach(([domain, hostname]) => { + expect(Domainlist.parseURL(domain), `domain ${domain} should be parsed as ${hostname}`).to.equal(hostname); + }); + }); + }); + describe('#match', () => { const whiteList = Domainlist.parseList(domainlists['whitelist']); it('does match on an included domain', () => { [ - 'wapo.com', + 'http://wapo.com', 'nytimes.com' ].forEach((domain) => { expect(domainlist.match(whiteList, domain)).to.be.true; diff --git a/views/admin/password-reset.ejs b/views/admin/password-reset.ejs index f02e5b6f8..6725409d1 100644 --- a/views/admin/password-reset.ejs +++ b/views/admin/password-reset.ejs @@ -15,11 +15,13 @@ background: #fff; } - #root form { + .container { max-width: 300px; - border: 1px solid lightgrey; - box-shadow: 0px 10px 24px 2px rgba(0,0,0,0.2); margin: 50px auto; + } + + #root form { + display: none; padding: 15px; } @@ -81,7 +83,8 @@
    -
    +
    + Set new password -
    foo
    diff --git a/yarn.lock b/yarn.lock index 5451ae7bd..6baec97cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1344,10 +1344,6 @@ check-more-types@2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.3.0.tgz#b8397c69dc92a3e645f18932c045b09c74419ec4" -check-valid-url@0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/check-valid-url/-/check-valid-url-0.0.2.tgz#938fc545fc90b71edf800f7345bfd36f3aa0a057" - cheerio@^0.20.0: version "0.20.0" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-0.20.0.tgz#5c710f2bab95653272842ba01c6ea61b3545ec35" @@ -1874,17 +1870,6 @@ css-loader@^0.28.5: postcss-value-parser "^3.3.0" source-list-map "^2.0.0" -css-modules-loader-core@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/css-modules-loader-core/-/css-modules-loader-core-1.0.1.tgz#94e3eec9bc8174df0f974641f3e0d0550497f694" - dependencies: - icss-replace-symbols "1.0.2" - postcss "5.1.2" - postcss-modules-extract-imports "1.0.0" - postcss-modules-local-by-default "1.1.1" - postcss-modules-scope "1.0.2" - postcss-modules-values "1.2.2" - css-parse@1.7.x: version "1.7.0" resolved "https://registry.yarnpkg.com/css-parse/-/css-parse-1.7.0.tgz#321f6cf73782a6ff751111390fc05e2c657d8c9b" @@ -2057,6 +2042,10 @@ decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + deep-eql@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-0.1.3.tgz#ef558acab8de25206cd713906d74e56930eb69f2" @@ -2406,13 +2395,6 @@ escodegen@^1.6.1: optionalDependencies: source-map "~0.2.0" -eslint-module-utils@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.1.1.tgz#abaec824177613b8a95b299639e1b6facf473449" - dependencies: - debug "^2.6.8" - pkg-dir "^1.0.0" - eslint-plugin-json@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/eslint-plugin-json/-/eslint-plugin-json-1.2.0.tgz#9ba73bb0be99d50093e889f5b968463d2a30efae" @@ -2973,12 +2955,6 @@ gauge@~2.7.1: strip-ansi "^3.0.1" wide-align "^1.1.0" -generic-names@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/generic-names/-/generic-names-1.0.2.tgz#e25b7feceb5b5a8f28f5f972a7ccfe57e562adcd" - dependencies: - loader-utils "^0.2.16" - get-caller-file@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5" @@ -3200,6 +3176,10 @@ graphql-anywhere@^3.0.0, graphql-anywhere@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-3.0.1.tgz#73531db861174c8f212eafb9f8e84944b38b4e5a" +graphql-anywhere@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-3.1.0.tgz#3ea0d8e8646b5cee68035016a9a7557c15c21e96" + graphql-docs@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/graphql-docs/-/graphql-docs-0.2.0.tgz#cf803f9c9d354fa03e89073d74e419261a5bfa74" @@ -3526,7 +3506,7 @@ iconv-lite@^0.4.17, iconv-lite@~0.4.13: version "0.4.18" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.18.tgz#23d8656b16aae6742ac29732ea8f0336a4789cf2" -icss-replace-symbols@1.0.2, icss-replace-symbols@^1.0.2: +icss-replace-symbols@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.0.2.tgz#cb0b6054eb3af6edc9ab1d62d01933e2d4c8bfa5" @@ -4761,7 +4741,7 @@ metascraper@^1.0.7: popsicle "^6.2.0" to-title-case "^1.0.0" -methods@1.x, methods@^1.1.1, methods@^1.1.2, methods@~1.1.2: +methods@^1.1.1, methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -5542,12 +5522,6 @@ pinkie@^2.0.0: version "2.0.4" resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" -pkg-dir@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-1.0.0.tgz#7a4b508a8d5bb2d629d447056ff4e9c9314cf3d4" - dependencies: - find-up "^1.0.0" - pkg-dir@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" @@ -5803,48 +5777,33 @@ postcss-mixins@^2.1.0: postcss "^5.0.10" postcss-simple-vars "^1.0.1" -postcss-modules-extract-imports@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.0.0.tgz#5b07f368e350cda6fd5c8844b79123a7bd3e37be" - dependencies: - postcss "^5.0.4" - postcss-modules-extract-imports@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.0.1.tgz#8fb3fef9a6dd0420d3f6d4353cf1ff73f2b2a341" dependencies: postcss "^5.0.4" -postcss-modules-local-by-default@1.1.1, postcss-modules-local-by-default@^1.0.1: +postcss-modules-local-by-default@^1.0.1: version "1.1.1" resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.1.1.tgz#29a10673fa37d19251265ca2ba3150d9040eb4ce" dependencies: css-selector-tokenizer "^0.6.0" postcss "^5.0.4" -postcss-modules-scope@1.0.2, postcss-modules-scope@^1.0.0: +postcss-modules-scope@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-1.0.2.tgz#ff977395e5e06202d7362290b88b1e8cd049de29" dependencies: css-selector-tokenizer "^0.6.0" postcss "^5.0.4" -postcss-modules-values@1.2.2, postcss-modules-values@^1.1.0: +postcss-modules-values@^1.1.0: version "1.2.2" resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-1.2.2.tgz#f0e7d476fe1ed88c5e4c7f97533a3e772ad94ca1" dependencies: icss-replace-symbols "^1.0.2" postcss "^5.0.14" -postcss-modules@^0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/postcss-modules/-/postcss-modules-0.5.2.tgz#9d682fed3f282bd64b2aa4feb6f22a2af435ffda" - dependencies: - css-modules-loader-core "^1.0.1" - generic-names "^1.0.1" - postcss "^5.1.2" - string-hash "^1.1.0" - postcss-nested@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-1.0.1.tgz#91f28f4e6e23d567241ac154558a0cfab4cc0d8f" @@ -5995,15 +5954,7 @@ postcss-zindex@^2.0.1: postcss "^5.0.4" uniqs "^2.0.0" -postcss@5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.1.2.tgz#bd84886a66bcad489afaf7c673eed5ef639551e2" - dependencies: - js-base64 "^2.1.9" - source-map "^0.5.6" - supports-color "^3.1.2" - -postcss@^5.0.0, postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0.13, postcss@^5.0.14, postcss@^5.0.16, postcss@^5.0.19, postcss@^5.0.2, postcss@^5.0.4, postcss@^5.0.5, postcss@^5.0.6, postcss@^5.0.8, postcss@^5.1.2, postcss@^5.2.13, postcss@^5.2.15, postcss@^5.2.16, postcss@^5.2.17, postcss@^5.2.4, postcss@^5.2.5: +postcss@^5.0.0, postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0.13, postcss@^5.0.14, postcss@^5.0.16, postcss@^5.0.19, postcss@^5.0.2, postcss@^5.0.4, postcss@^5.0.5, postcss@^5.0.6, postcss@^5.0.8, postcss@^5.2.13, postcss@^5.2.15, postcss@^5.2.16, postcss@^5.2.17, postcss@^5.2.4, postcss@^5.2.5: version "5.2.17" resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.17.tgz#cf4f597b864d65c8a492b2eabe9d706c879c388b" dependencies: @@ -6274,11 +6225,13 @@ query-string@^4.1.0, query-string@^4.2.2: object-assign "^4.1.0" strict-uri-encode "^1.0.0" -query-strings@^0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/query-strings/-/query-strings-0.0.1.tgz#d22bab97c9d39e2267b3b8e5f78592424b3e58cd" +query-string@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.0.0.tgz#fbdf7004b4d2aff792f9871981b7a2794f555947" dependencies: - check-valid-url "0.0.2" + decode-uri-component "^0.2.0" + object-assign "^4.1.0" + strict-uri-encode "^1.0.0" querystring-es3@^0.2.0: version "0.2.1" @@ -7280,13 +7233,6 @@ superagent@^2.0.0: qs "^6.1.0" readable-stream "^2.0.5" -supertest@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/supertest/-/supertest-2.0.1.tgz#a058081d788f1515d4700d7502881e6b759e44cd" - dependencies: - methods "1.x" - superagent "^2.0.0" - supports-color@3.1.2, supports-color@^3.1.0: version "3.1.2" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.1.2.tgz#72a262894d9d408b956ca05ff37b2ed8a6e2a2d5"