Merge branch 'add-class-names' of github.com:ryanyun/talk into add-class-names

This commit is contained in:
Ryan Yun
2017-09-27 11:21:19 -04:00
61 changed files with 1411 additions and 1514 deletions
+5 -13
View File
@@ -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`);
+2 -2
View File
@@ -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 */}
<Route path='community' component={CommunityLayout}>
<Route path='community'>
<Route path='flagged' components={Community}>
<Route path=':id' components={Community} />
</Route>
+2 -3
View File
@@ -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,
@@ -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;
}
@@ -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';
@@ -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;
}
@@ -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 = <Comment
suspectWords={suspectWords}
comment={item}
author={author}
key={index}
index={index}
onClickAction={this.onClickAction}
onClickShowBanDialog={this.onClickShowBanDialog}
modActions={modActions}
menuOptionsMap={menuOptionsMap}
isActive={itemId === active}
hideActive={hideActive} />;
} else {
// If the item is an action...
const user = users[item.item_id];
modItem = user && <User
suspectWords={suspectWords}
action={item}
user={user}
key={index}
index={index}
onClickAction={this.onClickAction}
onClickShowBanDialog={this.onClickShowBanDialog}
modActions={modActions}
menuOptionsMap={menuOptionsMap}
isActive={itemId === active}
hideActive={hideActive} />;
}
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 (
<ul
className={`${styles.list} ${singleView ? styles.singleView : ''}`} {...key}
id='moderationList'>
{moderationIds.map(this.mapModItems)}
<SuspendUserModal
action = {this.state.suspendUserModal}
onClose={() => this.setState({suspendUserModal:null})}
suspendUser={suspendUser} />
</ul>
);
}
}
+95 -86
View File
@@ -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
}) => (
<Header className={styles.header}>
<Logo className={styles.logo} />
<div>
{
auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
<Navigation className={styles.nav}>
<IndexLink
id='dashboardNav'
className={styles.navLink}
to="/admin/dashboard"
activeClassName={styles.active}>
{t('configure.dashboard')}
</IndexLink>
{
can(auth.user, 'MODERATE_COMMENTS') && (
<Link
id='moderateNav'
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{t('configure.moderate')}
</Link>
)
}
<Link
id='streamsNav'
className={styles.navLink}
to="/admin/stories"
activeClassName={styles.active}>
{t('configure.stories')}
</Link>
<Link
id='communityNav'
className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
{t('configure.community')}
</Link>
{
can(auth.user, 'UPDATE_CONFIG') && (
<Link
id='configureNav'
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{t('configure.configure')}
</Link>
)
}
</Navigation>
:
null
}
<div className={styles.rightPanel}>
<ul>
<li className={styles.settings}>
<div>
<IconButton name="settings" id="menu-settings"/>
<Menu target="menu-settings" align="right">
<MenuItem onClick={() => showShortcuts(true)}>{t('configure.shortcuts')}</MenuItem>
<MenuItem>
<a href="https://github.com/coralproject/talk/releases" target="_blank" rel="noopener noreferrer">
View latest version
</a>
</MenuItem>
<MenuItem>
<a href="https://coralproject.net/contribute.html#other-ideas-and-bug-reports" target="_blank" rel="noopener noreferrer">
Report a bug or give feedback
</a>
</MenuItem>
<MenuItem onClick={handleLogout}>
{t('configure.sign_out')}
</MenuItem>
</Menu>
</div>
</li>
<li>
{`v${process.env.VERSION}`}
</li>
</ul>
auth,
root
}) => {
return (
<Header className={styles.header}>
<Logo className={styles.logo} />
<div>
{
auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
<Navigation className={styles.nav}>
<IndexLink
id='dashboardNav'
className={styles.navLink}
to="/admin/dashboard"
activeClassName={styles.active}>
{t('configure.dashboard')}
</IndexLink>
{
can(auth.user, 'MODERATE_COMMENTS') && (
<Link
id='moderateNav'
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{t('configure.moderate')}
{(root.premodCount !== 0 || root.reportedCount !== 0) && <Indicator />}
</Link>
)
}
<Link
id='streamsNav'
className={styles.navLink}
to="/admin/stories"
activeClassName={styles.active}>
{t('configure.stories')}
</Link>
<Link
id='communityNav'
className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
{t('configure.community')}
{root.flaggedUsernamesCount !== 0 && <Indicator />}
</Link>
{
can(auth.user, 'UPDATE_CONFIG') && (
<Link
id='configureNav'
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{t('configure.configure')}
</Link>
)
}
</Navigation>
:
null
}
<div className={styles.rightPanel}>
<ul>
<li className={styles.settings}>
<div>
<IconButton name="settings" id="menu-settings"/>
<Menu target="menu-settings" align="right">
<MenuItem onClick={() => showShortcuts(true)}>{t('configure.shortcuts')}</MenuItem>
<MenuItem>
<a href="https://github.com/coralproject/talk/releases" target="_blank" rel="noopener noreferrer">
View latest version
</a>
</MenuItem>
<MenuItem>
<a href="https://coralproject.net/contribute.html#other-ideas-and-bug-reports" target="_blank" rel="noopener noreferrer">
Report a bug or give feedback
</a>
</MenuItem>
<MenuItem onClick={handleLogout}>
{t('configure.sign_out')}
</MenuItem>
</Menu>
</div>
</li>
<li>
{`v${process.env.VERSION}`}
</li>
</ul>
</div>
</div>
</div>
</Header>
);
</Header>
);
};
CoralHeader.propTypes = {
auth: PropTypes.object,
showShortcuts: PropTypes.func,
handleLogout: PropTypes.func.isRequired
handleLogout: PropTypes.func.isRequired,
root: PropTypes.object.isRequired
};
export default CoralHeader;
@@ -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;
}
@@ -0,0 +1,7 @@
import React from 'react';
import styles from './Indicator.css';
const Indicator = () =>
<span className={styles.indicator}></span>;
export default Indicator;
+12 -5
View File
@@ -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,
}) => (
<LayoutMDL className={styles.layout} fixedDrawer>
<Header
handleLogout={handleLogout}
showShortcuts={toggleShortcutModal}
{...props} />
<Drawer handleLogout={handleLogout} restricted={restricted} {...props} />
auth={auth}
/>
<Drawer
handleLogout={handleLogout}
restricted={restricted}
/>
<div className={styles.layout}>
{children}
</div>
@@ -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
@@ -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 = ''}) => (
<div className={`${styles.logo} ${className}`}>
@@ -10,3 +11,7 @@ export const Logo = ({className = ''}) => (
</h1>
</div>
);
Logo.propTypes = {
className: PropTypes.string
};
@@ -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);
+38 -18
View File
@@ -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 <FullLoading />;
}
if (!loggedIn) {
return (
<AdminLogin
@@ -48,17 +55,17 @@ class LayoutContainer extends Component {
/>
);
}
if (can(user, 'ACCESS_ADMIN') && loggedIn) {
return (
<Layout
handleLogout={handleLogout}
handleLogout={logout}
toggleShortcutModal={toggleShortcutModal}
{...this.props}
>
auth={this.props.auth} >
<BanUserDialog />
<SuspendUserDialog />
<UserDetail />
{this.props.children}
{children}
</Layout>
);
} 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);
+2 -2
View File
@@ -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)},
@@ -1,9 +0,0 @@
import React from 'react';
const CommunityLayout = (props) => (
<div>
{props.children}
</div>
);
export default CommunityLayout;
@@ -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 <Community
return <Community
fetchAccounts={this.props.fetchAccounts}
community={this.props.community}
hideRejectUsernameDialog={this.props.hideRejectUsernameDialog}
@@ -56,28 +54,6 @@ CommunityContainer.propTypes = {
root: PropTypes.object
};
const withData = withQuery(gql`
query TalkAdmin_FlaggedUsernamesCount {
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}
`, {
options: {
fetchPolicy: 'network-only',
},
});
const mapDispatchToProps = (dispatch) =>
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);
@@ -1,2 +0,0 @@
export {default as Community} from './containers/Community';
export {default as CommunityLayout} from './components/CommunityLayout';
@@ -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;
}
}
}
@@ -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 (
<li
tabIndex={0}
className={cn(className, 'mdl-card', selectionStateCSS, styles.Comment, styles.listItem, {[styles.selected]: selected})}
className={cn(className, 'mdl-card', selectionStateCSS, styles.root, {[styles.selected]: selected})}
id={`comment_${comment.id}`}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
@@ -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;
@@ -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}
/>
<ModerationQueue
key={`${activeTab}_${this.props.moderation.sortOrder}`}
data={this.props.data}
root={this.props.root}
currentAsset={asset}
comments={comments.nodes}
activeTab={activeTab}
singleView={moderation.singleView}
selectedIndex={this.state.selectedIndex}
selectedCommentId={this.state.selectedCommentId}
bannedWords={settings.wordlist.banned}
suspectWords={settings.wordlist.suspect}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
loadMore={props.loadMore}
loadMore={this.loadMore}
assetId={assetId}
sort={this.props.moderation.sortOrder}
commentCount={activeTabCount}
currentUserId={this.props.auth.user.id}
viewUserDetail={viewUserDetail}
hideUserDetail={hideUserDetail}
/>
<ModerationKeysModal
hideShortcutsNote={props.hideShortcutsNote}
@@ -177,3 +271,28 @@ export default class Moderation extends Component {
);
}
}
Moderation.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
toggleModal: PropTypes.func.isRequired,
toggleStorySearch: PropTypes.func.isRequired,
getModPath: PropTypes.func.isRequired,
storySearchChange: PropTypes.func.isRequired,
moderation: PropTypes.object.isRequired,
auth: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
queueConfig: PropTypes.object.isRequired,
handleCommentChange: PropTypes.func.isRequired,
setSortOrder: PropTypes.func.isRequired,
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
loadMore: PropTypes.func.isRequired,
singleView: PropTypes.func.isRequired,
activeTab: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
};
export default Moderation;
@@ -0,0 +1,39 @@
.header {
background-color: #2c2c2c;
color: white;
margin-bottom: -1px;
}
.searchTrigger {
position: relative;
top: .2em;
}
.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;
}
}
}
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Icon} from 'coral-ui';
import styles from './styles.css';
import styles from './ModerationHeader.css';
import t from 'coral-framework/services/i18n';
const ModerationHeader = ({asset, searchVisible, openSearch, closeSearch}) => {
@@ -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;
@@ -1,4 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
const ModerationLayout = (props) => (
<div>
@@ -6,4 +7,8 @@ const ModerationLayout = (props) => (
</div>
);
ModerationLayout.propTypes = {
children: PropTypes.node,
};
export default ModerationLayout;
@@ -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;
}
}
@@ -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;
@@ -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;
}
@@ -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 (
<div className={styles.root}>
<EmptyCard>{t('modqueue.empty_queue')}</EmptyCard>
</div>
);
}
if (singleView) {
const index = comments.findIndex((comment) => comment.id === selectedCommentId);
const comment = comments[index];
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
return (
<div className={styles.root}>
<Comment
data={this.props.data}
root={this.props.root}
key={comment.id}
comment={comment}
selected={true}
suspectWords={props.suspectWords}
bannedWords={props.bannedWords}
viewUserDetail={viewUserDetail}
actions={actionsMap[status]}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
currentAsset={props.currentAsset}
currentUserId={this.props.currentUserId}
/>;
</div>
);
}
const view = this.getVisibleComments();
return (
<div id="moderationList" className={`${styles.list} ${singleView ? styles.singleView : ''}`}>
<div className={styles.root}>
<ViewMore
viewMore={this.viewNewComments}
count={comments.length - view.length}
/>
<CSSTransitionGroup
key={activeTab}
component={'ul'}
style={{paddingLeft: 0}}
className={styles.list}
transitionName={{
enter: styles.commentEnter,
enterActive: styles.commentEnterActive,
@@ -80,36 +188,32 @@ class ModerationQueue extends React.Component {
transitionLeaveTimeout={1000}
>
{
comments.map((comment, i) => {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
return <Comment
data={this.props.data}
root={this.props.root}
key={comment.id}
comment={comment}
selected={i === selectedIndex}
suspectWords={props.suspectWords}
bannedWords={props.bannedWords}
viewUserDetail={viewUserDetail}
actions={actionsMap[status]}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
currentAsset={props.currentAsset}
currentUserId={this.props.currentUserId}
/>;
})
view
.map((comment) => {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
return <Comment
data={this.props.data}
root={this.props.root}
key={comment.id}
comment={comment}
selected={comment.id === selectedCommentId}
suspectWords={props.suspectWords}
bannedWords={props.bannedWords}
viewUserDetail={viewUserDetail}
actions={actionsMap[status]}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
currentAsset={props.currentAsset}
currentUserId={this.props.currentUserId}
/>;
})
}
</CSSTransitionGroup>
{comments.length === 0 &&
<div className={styles.emptyCardContainer}>
<EmptyCard>{t('modqueue.empty_queue')}</EmptyCard>
</div>
}
<LoadMore
loadMore={this.loadMore}
loadMore={this.props.loadMore}
showLoadMore={comments.length < commentCount}
/>
</div>
@@ -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;
@@ -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;
}
@@ -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) => (
<div className={`mdl-card mdl-shadow--2dp ${styles.notFound}`}>
@@ -11,4 +12,8 @@ const NotFound = (props) => (
</div>
);
NotFound.propTypes = {
assetId: PropTypes.string.isRequired,
};
export default NotFound;
@@ -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;
}
@@ -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;
@@ -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;
@@ -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;
@@ -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;
}
@@ -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}) =>
<div {...rest} className={cn(className, styles.viewMoreContainer)}>
{
count > 0 && <Button
className={styles.viewMore}
onClick={viewMore}>
{count === 1
? t('framework.new_count', count, t('framework.comment'))
: t('framework.new_count', count, t('framework.comments'))}
</Button>
}
</div>;
ViewMore.propTypes = {
viewMore: PropTypes.func.isRequired,
count: PropTypes.number.isRequired,
className: PropTypes.string
};
export default ViewMore;
@@ -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;
}
@@ -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 <NotFoundAsset assetId={assetId} />;
}
if (asset === undefined || asset.id !== assetId) {
}
// Still loading.
return <Spinner />;
}
} else if (asset !== undefined || !('premodCount' in root)) {
if(data.loading) {
// loading.
return <Spinner />;
@@ -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);
@@ -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';
@@ -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 {
<div className={cn('talk-embed-stream', {'talk-embed-stream-highlight-comment': hasHighlightedComment})}>
<IfSlotIsNotEmpty slot="login">
<Popup
href='embed/stream/login'
href={`embed/stream/login?parentUrl=${encodeURIComponent(parentUrl)}`}
title='Login'
features='menubar=0,resizable=0,width=500,height=550,top=200,left=500'
open={showSignInDialog}
@@ -2,18 +2,18 @@ import React from 'react';
const CommentContent = ({comment}) => {
const textbreaks = comment.body.split('\n');
return <div className={`${name}-text`}>
return <span className={`${name}-text`}>
{
textbreaks.map((line, i) => {
return (
<span key={i} className={`${name}-line`}>
{line}
<br className={`${name}-linebreak`}/>
{i === textbreaks.length - 1 && <br className={`${name}-linebreak`}/>}
</span>
);
})
}
</div>;
</span>;
};
export default CommentContent;
+1 -1
View File
@@ -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';
+3 -3
View File
@@ -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;
};
+21 -1
View File
@@ -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;
}
},
}),
+2 -1
View File
@@ -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.
+3 -6
View File
@@ -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"
@@ -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';
@@ -5,20 +5,20 @@ const name = 'talk-plugin-comment-content';
const CommentContent = ({comment}) => {
const textbreaks = comment.body.split('\n');
return <div className={`${name}-text`}>
return <span className={`${name}-text`}>
{
textbreaks.map((line, i) => {
return (
<span key={i} className={`${name}-line`}>
<Linkify properties={{target: '_blank'}}>
{line}
{line.trim()}
</Linkify>
<br className={`${name}-linebreak`}/>
{i !== textbreaks.length - 1 && <br className={`${name}-linebreak`}/>}
</span>
);
})
}
</div>;
</span>;
};
export default CommentContent;
@@ -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 (
<div className={cn(styles.root, `${pluginName}-comment`)}>
<blockquote className={cn(styles.quote, `${pluginName}-comment-body`)}>
{comment.body}
</blockquote>
<Slot
component={'blockquote'}
className={cn(styles.quote, `${pluginName}-comment-body`)}
fill="commentContent"
defaultComponent={CommentContent}
data={data}
queryData={queryData}
/>
<div className={cn(`${pluginName}-comment-username-box`)}>
+16 -23
View File
@@ -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);
+26 -27
View File
@@ -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);
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
<p><%= t('email.confirm.has_been_requested') %> <b><%= email %></b>.</p>
<p><%= t('email.confirm.to_confirm') %> <a href="<%= rootURL %>/admin/confirm-email#<%= token %>">Confirm Email</a></p>
<p><%= t('email.confirm.to_confirm') %> <a href="<%= BASE_URL %>admin/confirm-email#<%= token %>">Confirm Email</a></p>
<p><%= t('email.confirm.if_you_did_not') %></p>
+1 -1
View File
@@ -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') %>
+1 -1
View File
@@ -1,2 +1,2 @@
<p><%= t('email.password_reset.we_received_a_request') %><br />
<%= t('email.password_reset.if_you_did') %> <a href="<%= rootURL %>/admin/password-reset#<%= token %>"><%= t('email.password_reset.please_click') %></a>.</p>
<%= t('email.password_reset.if_you_did') %> <a href="<%= BASE_URL %>admin/password-reset#<%= token %>"><%= t('email.password_reset.please_click') %></a>.</p>
+1 -1
View File
@@ -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 %>
+20
View File
@@ -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,
};
+4
View File
@@ -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([
+19 -27
View File
@@ -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];
}
/**
+72 -1
View File
@@ -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;
+32 -10
View File
@@ -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 @@
</head>
<body>
<div id="root">
<form id="reset-password-form">
<div class="error-console container"></div>
<form id="reset-password-form" class="container">
<legend class="legend">Set new password</legend>
<label for="password">
New password
@@ -94,14 +97,18 @@
<input type="password" name="confirm-password" placeholder="confirm password" />
</label>
<button class="submit-password-reset" type="submit">Apply</button>
<div class="error-console">foo</div>
</form>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script>
$(function () {
function showError(message) {
$('.error-console').text(message).addClass('active');
function showError(error) {
try {
var err = JSON.parse(error);
$('.error-console').text(err.message).addClass('active');
} catch (err) {
$('.error-console').text(error).addClass('active');
}
}
function handleSubmit (e) {
@@ -111,8 +118,13 @@
var password = $('[name="password"]').val();
var confirm = $('[name="confirm-password"]').val();
if (password !== confirm || password === '' || password.length < 8) {
showError('passwords must match and be 8 characters.');
if (password === '' || password.length < 8) {
showError('Passwords must be at least 8 characters.');
return false;
}
if (password !== confirm) {
showError('New password and confirm password must match');
return false;
}
@@ -128,7 +140,17 @@
});
}
$('#reset-password-form').on('submit', handleSubmit);
$.ajax({
url: '<%= BASE_PATH %>api/v1/account/password/reset?check=true',
contentType: 'application/json',
method: 'PUT',
data: JSON.stringify({token: location.hash.replace('#', '')})
}).then(function () {
$('#reset-password-form').fadeIn().on('submit', handleSubmit);
}).catch(function (error) {
showError(error.responseText);
});
});
</script>
</body>
+20 -74
View File
@@ -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"