diff --git a/app.js b/app.js index 9da2041fc..2989ae00a 100644 --- a/app.js +++ b/app.js @@ -6,13 +6,9 @@ const merge = require('lodash/merge'); const helmet = require('helmet'); const compression = require('compression'); const cookieParser = require('cookie-parser'); -const { - BASE_URL, - BASE_PATH, - MOUNT_PATH, - STATIC_URL, - HELMET_CONFIGURATION, -} = require('./url'); +const {HELMET_CONFIGURATION} = require('./config'); +const {MOUNT_PATH} = require('./url'); +const {applyLocals} = require('./services/locals'); const routes = require('./routes'); const debug = require('debug')('talk:app'); @@ -57,12 +53,8 @@ app.set('view engine', 'ejs'); // ROUTES //============================================================================== -// Apply the BASE_PATH, BASE_URL, and MOUNT_PATH on the app.locals, which will -// make them available on the templates and the routers. -app.locals.BASE_URL = BASE_URL; -app.locals.BASE_PATH = BASE_PATH; -app.locals.MOUNT_PATH = MOUNT_PATH; -app.locals.STATIC_URL = STATIC_URL; +// Add the locals to the app renderer. +applyLocals(app.locals); debug(`mounting routes on the ${MOUNT_PATH} path`); diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index d50aeb3f6..d8e1b5377 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -6,7 +6,7 @@ import Configure from 'routes/Configure'; import Dashboard from 'routes/Dashboard'; import Install from 'routes/Install'; import Stories from 'routes/Stories'; -import {CommunityLayout, Community} from 'routes/Community'; +import Community from 'routes/Community/containers/Community'; import {ModerationLayout, Moderation} from 'routes/Moderation'; import Layout from 'containers/Layout'; @@ -22,7 +22,7 @@ const routes = ( {/* Community Routes */} - + diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index 890d834b4..921f0a0a2 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -1,4 +1,4 @@ -import qs from 'qs'; +import queryString from 'query-string'; import { FETCH_COMMENTERS_REQUEST, @@ -17,9 +17,8 @@ import { import t from 'coral-framework/services/i18n'; export const fetchAccounts = (query = {}) => (dispatch, _, {rest}) => { - dispatch(requestFetchAccounts()); - rest(`/users?${qs.stringify(query)}`) + rest(`/users?${queryString.stringify(query)}`) .then(({result, page, count, limit, totalPages}) =>{ dispatch({ type: FETCH_COMMENTERS_SUCCESS, diff --git a/client/coral-admin/src/components/ActionButton.css b/client/coral-admin/src/components/ActionButton.css new file mode 100644 index 000000000..226006c6b --- /dev/null +++ b/client/coral-admin/src/components/ActionButton.css @@ -0,0 +1,24 @@ +.actionButton { + transform: scale(.8); + margin: 0; +} + +.minimal { + width: 45px; + min-width: 0; +} + +.approve__active { + box-shadow: none; + color: white; + background-color: #519954; + cursor: not-allowed; +} + +.reject__active, .rejected__active { + color: white; + background-color: #D03235; + box-shadow: none; + cursor: not-allowed; +} + diff --git a/client/coral-admin/src/components/ActionButton.js b/client/coral-admin/src/components/ActionButton.js index 79fa2f182..77456b939 100644 --- a/client/coral-admin/src/components/ActionButton.js +++ b/client/coral-admin/src/components/ActionButton.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import styles from './ModerationList.css'; +import styles from './ActionButton.css'; import {Button} from 'coral-ui'; import {menuActionsMap} from '../utils/moderationQueueActionsMap'; diff --git a/client/coral-admin/src/components/ModerationList.css b/client/coral-admin/src/components/ModerationList.css deleted file mode 100644 index 4ac894290..000000000 --- a/client/coral-admin/src/components/ModerationList.css +++ /dev/null @@ -1,208 +0,0 @@ - -@custom-media --big-viewport (min-width: 780px); - -.list { - padding: 8px 0; - list-style: none; - display: block; - - &.singleView .listItem { - display: none; - } - - &.singleView .listItem.activeItem { - display: block; - height: 100%; - font-size: 1.5em; - line-height: 1.5em; - border: none; - - .actions { - position: fixed; - bottom: 60px; - left: 25%; - margin: 0 auto; - display: flex; - justify-content: space-around; - width: 50%; - margin: 0; - } - - .actionButton { - transform: scale(1.4); - } - } -} - -.listItem { - border-bottom: 1px solid #e0e0e0; - font-size: 16px; - width: 100%; - max-width: 660px; - min-width: 400px; - margin: 0 auto; - padding: 16px 14px; - position: relative; - transition: box-shadow 200ms; - - - &:hover { - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); - } - - &:last-child { - border-bottom: none; - } - - .sideActions { - position: absolute; - right: 0; - height: 100%; - top: 0; - padding: 40px 18px; - box-sizing: border-box; - } - - .itemHeader { - display: flex; - align-items: center; - justify-content: space-between; - - .author { - min-width: 230px; - display: flex; - align-items: center; - } - } - - .itemBody { - display: flex; - justify-content: space-between; - } - - .avatar { - margin-right: 16px; - height: 40px; - width: 40px; - border-radius: 50%; - background-color: #757575; - font-size: 40px; - color: #fff; - } - - .created { - color: #666; - font-size: 13px; - margin-left: 40px; - } - - .body { - margin-top: 20px; - flex: 1; - font-size: 0.88em; - color: black; - } - - .flagged { - color: rgba(255, 0, 0, .5); - padding-top: 15px; - padding-left: 10px; - } - - .flagCount{ - font-size: 12px; - color: #d32f2f; - } - -} - -.empty { - color: #444; - margin-top: 50px; - text-align: center; -} - - -@media (--big-viewport) { - .listItem { - border: 1px solid #e0e0e0; - margin-bottom: 30px; - - &:last-child { - border-bottom: 1px solid #e0e0e0; - } - - &.activeItem { - border: 2px solid #333; - } - } - -} - -.hasLinks { - color: #f00; - text-align: right; - display: flex; - align-items: center; - - i { - margin-right: 5px; - } -} - -.banned { - color: #f00; - text-align: left; - display: flex; - align-items: center; - - i { - margin-right: 5px; - } -} - -.ban { - display: block; - text-align: center; - margin-top: 5px; -} - -.banButton { - width: 114px; - letter-spacing: 1px; - - i { - vertical-align: middle; - margin-right: 10px; - font-size: 14px; - } -} - -.selected { - border-radius: 10px; -} - - -.actionButton { - transform: scale(.8); - margin: 0; -} - -.minimal { - width: 45px; - min-width: 0; -} - -.approve__active { - box-shadow: none; - color: white; - background-color: #519954; - cursor: not-allowed; -} - -.reject__active, .rejected__active { - color: white; - background-color: #D03235; - box-shadow: none; - cursor: not-allowed; -} diff --git a/client/coral-admin/src/components/ModerationList.js b/client/coral-admin/src/components/ModerationList.js deleted file mode 100644 index adfe63051..000000000 --- a/client/coral-admin/src/components/ModerationList.js +++ /dev/null @@ -1,228 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styles from './ModerationList.css'; -import key from 'keymaster'; -import Hammer from 'hammerjs'; -import Comment from './Comment'; -import User from './User'; -import SuspendUserModal from './SuspendUserModal'; - -// Each action has different meaning and configuration -const menuOptionsMap = { - 'reject': {status: 'REJECTED', icon: 'close', key: 'f'}, - 'approve': {status: 'ACCEPTED', icon: 'done', key: 'd'}, - 'flag': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'}, - 'ban': {status: 'BANNED', icon: 'not interested'} -}; - -// Renders a comment list and allow performing actions -export default class ModerationList extends React.Component { - static propTypes = { - isActive: PropTypes.bool, - singleView: PropTypes.bool, - commentIds: PropTypes.arrayOf(PropTypes.string), - actionIds: PropTypes.arrayOf(PropTypes.string), - comments: PropTypes.object, - users: PropTypes.object.isRequired, - actions: PropTypes.object, - userStatusUpdate: PropTypes.func.isRequired, - suspendUser: PropTypes.func.isRequired, - - // list of actions (flags, etc) associated with the comments - modActions: PropTypes.arrayOf(PropTypes.string).isRequired, - loading: PropTypes.bool, - - suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired - } - - state = {active: null, suspendUserModal: null, email: null}; - - // remove key handlers before leaving - componentWillUnmount () { - this.unbindKeyHandlers(); - } - - // add key handlers and gestures - componentDidMount () { - this.bindKeyHandlers(); - - // this.bindGestures() // need to check whether we're on a mobile device or this throws an Error - } - - // If entering to singleview and no active, active is the first eleement - componentWillReceiveProps (nextProps) { - if (nextProps.singleView && !this.state.active) { - this.setState({active: nextProps.commentIds[0]}); - } - } - - // Add swipe to approve or reject - bindGestures () { - const {modActions} = this.props; - this._hammer = new Hammer(this.base); - this._hammer.get('swipe').set({direction: Hammer.DIRECTION_HORIZONTAL}); - - if (modActions.indexOf('reject') !== -1) { - this._hammer.on('swipeleft', () => this.props.singleView && this.actionKeyHandler('Rejected')); - } - if (modActions.indexOf('approve') !== -1) { - this._hammer.on('swiperight', () => this.props.singleView && this.actionKeyHandler('Approved')); - } - } - - // Add key handlers. Each action has one and added j/k for moving around - bindKeyHandlers () { - const {modActions, isActive} = this.props; - modActions.filter((action) => menuOptionsMap[action].key).forEach((action) => { - key(menuOptionsMap[action].key, 'moderationList', () => isActive && this.actionKeyHandler(menuOptionsMap[action].status)); - }); - key('j', 'moderationList', () => isActive && this.moveKeyHandler('down')); - key('k', 'moderationList', () => isActive && this.moveKeyHandler('up')); - key.setScope('moderationList'); - } - - // Perform an action using the keys only if the comment is active - actionKeyHandler (action) { - if (this.props.isActive && this.state.active) { - this.onClickAction(action, this.state.active); - } - } - - // move around with j/k - moveKeyHandler (direction) { - if (!this.props.isActive) { - return; - } - - const {commentIds} = this.props; - const {active} = this.state; - - // check boundaries - if (active === null || !commentIds.length) { - this.setState({active: commentIds[0]}); - } else if (direction === 'up' && active !== commentIds[0]) { - this.setState({active: commentIds[commentIds.indexOf(active) - 1]}); - } else if (direction === 'down' && active !== commentIds[commentIds.length - 1]) { - this.setState({active: commentIds[commentIds.indexOf(active) + 1]}); - } - - // scroll to the position - const index = Math.max(commentIds.indexOf(this.state.active), 0); - this.base.childNodes[index] && this.base.childNodes[index].focus(); - } - - unbindKeyHandlers () { - key.deleteScope('moderationList'); - } - - // If we are performing an action over a comment (aka removing from the list) we need to select a new active. - // TODO: In the future this can be improved and look at the actual state to - // resolve since the content of the list could change externally. For now it works as expected - onClickAction = (menuOption, id, action) => { - - // activate the next comment - if (id === this.state.active) { - const moderationIds = this.getModerationIds(); - if (moderationIds[moderationIds.length - 1] === this.state.active) { - this.setState({active: moderationIds[moderationIds.length - 2]}); - } else { - this.setState({active: moderationIds[Math.min(moderationIds.indexOf(this.state.active) + 1, moderationIds.length - 1)]}); - } - } - - // Update the status right away if this is a comment - if (action.item_type === 'COMMENTS') { - this.props.updateCommentStatus(menuOption, id); - } else if (action.item_type === 'USERS') { - - // If a user bio or name is rejected, bring up a dialog before suspending them. - if (menuOption === 'REJECTED') { - this.setState({suspendUserModal: action}); - } else if (menuOption === 'ACCEPTED') { - this.props.userStatusUpdate('APPROVED', action.item_id); - } - } - } - - onClickShowBanDialog = (userId, userName, commentId) => { - this.props.onClickShowBanDialog(userId, userName, commentId); - } - - mapModItems = (itemId, index) => { - - const {comments = {}, users, actions = {}, modActions, suspectWords, hideActive} = this.props; - const {active} = this.state; - - // Because ids are unique, the id will either appear as an action or as a comment. - - const item = comments[itemId] || actions[itemId]; - let modItem; - - if (item.body) { - - // If the item is a comment... - const author = users[item.author_id]; - modItem = ; - } else { - - // If the item is an action... - const user = users[item.item_id]; - modItem = user && ; - } - return modItem; - } - - getModerationIds = () => { - const {commentIds = [], actionIds = [], comments, actions} = this.props; - if (comments && actions) { - return [ ...commentIds, ...actionIds ].sort((a, b) => { - const itemA = comments[a] || actions[a]; - const itemB = comments[b] || actions[b]; - return itemB.updated_at - itemA.updated_at; - }); - } else { - return comments ? commentIds : actionIds; - } - } - - render () { - const {singleView, key, suspendUser} = this.props; - - // Combine moderations and actions into a single stream and sort by most recently updated. - const moderationIds = this.getModerationIds(); - - return ( -
    - {moderationIds.map(this.mapModItems)} - this.setState({suspendUserModal:null})} - suspendUser={suspendUser} /> -
- ); - } -} diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index 74dc85a17..30c5fa7e6 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -6,101 +6,110 @@ import styles from './Header.css'; import t from 'coral-framework/services/i18n'; import {Logo} from './Logo'; import {can} from 'coral-framework/services/perms'; +import Indicator from './Indicator'; const CoralHeader = ({ handleLogout, showShortcuts = () => {}, - auth -}) => ( -
- -
- { - auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? - - - {t('configure.dashboard')} - - { - can(auth.user, 'MODERATE_COMMENTS') && ( - - {t('configure.moderate')} - - ) - } - - {t('configure.stories')} - - - {t('configure.community')} - - { - can(auth.user, 'UPDATE_CONFIG') && ( - - {t('configure.configure')} - - ) - } - - : - null - } -
- + auth, + root +}) => { + return ( +
+ +
+ { + auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? + + + {t('configure.dashboard')} + + { + can(auth.user, 'MODERATE_COMMENTS') && ( + + {t('configure.moderate')} + {(root.premodCount !== 0 || root.reportedCount !== 0) && } + + ) + } + + {t('configure.stories')} + + + + {t('configure.community')} + {root.flaggedUsernamesCount !== 0 && } + + + { + can(auth.user, 'UPDATE_CONFIG') && ( + + {t('configure.configure')} + + ) + } + + : + null + } +
+ +
-
-
-); + + ); +}; CoralHeader.propTypes = { auth: PropTypes.object, showShortcuts: PropTypes.func, - handleLogout: PropTypes.func.isRequired + handleLogout: PropTypes.func.isRequired, + root: PropTypes.object.isRequired }; export default CoralHeader; diff --git a/client/coral-admin/src/components/ui/Indicator.css b/client/coral-admin/src/components/ui/Indicator.css new file mode 100644 index 000000000..ec84c7027 --- /dev/null +++ b/client/coral-admin/src/components/ui/Indicator.css @@ -0,0 +1,10 @@ +.indicator { + background-color: #E46D59; + border-radius: 10px; + position: absolute; + top: 50%; + width: 7px; + height: 7px; + margin-top: -4px; + margin-left: 7px; +} diff --git a/client/coral-admin/src/components/ui/Indicator.js b/client/coral-admin/src/components/ui/Indicator.js new file mode 100644 index 000000000..dd1d36202 --- /dev/null +++ b/client/coral-admin/src/components/ui/Indicator.js @@ -0,0 +1,7 @@ +import React from 'react'; +import styles from './Indicator.css'; + +const Indicator = () => + ; + +export default Indicator; diff --git a/client/coral-admin/src/components/ui/Layout.js b/client/coral-admin/src/components/ui/Layout.js index 3aa056d9e..9ee9fda9f 100644 --- a/client/coral-admin/src/components/ui/Layout.js +++ b/client/coral-admin/src/components/ui/Layout.js @@ -1,22 +1,27 @@ import React from 'react'; import PropTypes from 'prop-types'; import {Layout as LayoutMDL} from 'react-mdl'; -import Header from './Header'; +import Header from '../../containers/Header'; import Drawer from './Drawer'; import styles from './Layout.css'; const Layout = ({ children, handleLogout = () => {}, - toggleShortcutModal, + toggleShortcutModal = () => {}, restricted = false, - ...props}) => ( + auth, +}) => (
- + auth={auth} + /> +
{children}
@@ -24,6 +29,8 @@ const Layout = ({ ); Layout.propTypes = { + children: PropTypes.node, + auth: PropTypes.object, handleLogout: PropTypes.func, toggleShortcutModal: PropTypes.func, restricted: PropTypes.bool // hide elements from a user that's logged out diff --git a/client/coral-admin/src/components/ui/Logo.js b/client/coral-admin/src/components/ui/Logo.js index 8c57cde11..2538a0716 100644 --- a/client/coral-admin/src/components/ui/Logo.js +++ b/client/coral-admin/src/components/ui/Logo.js @@ -1,6 +1,7 @@ import React from 'react'; import styles from './Logo.css'; import {CoralLogo} from 'coral-ui'; +import PropTypes from 'prop-types'; export const Logo = ({className = ''}) => (
@@ -10,3 +11,7 @@ export const Logo = ({className = ''}) => (
); + +Logo.propTypes = { + className: PropTypes.string +}; diff --git a/client/coral-admin/src/containers/Header.js b/client/coral-admin/src/containers/Header.js new file mode 100644 index 000000000..a5c1f0afc --- /dev/null +++ b/client/coral-admin/src/containers/Header.js @@ -0,0 +1,24 @@ +import {gql} from 'react-apollo'; +import withQuery from 'coral-framework/hocs/withQuery'; +import Header from '../components/ui/Header'; + +export default withQuery(gql` + query TalkAdmin_Header { + __typename + premodCount: commentCount(query: { + statuses: [PREMOD] + }) + reportedCount: commentCount(query: { + statuses: [NONE, PREMOD, SYSTEM_WITHHELD], + action_type: FLAG + }) + flaggedUsernamesCount: userCount(query: { + action_type: FLAG, + statuses: [PENDING] + }) + } +`, { + options: { + pollInterval: 5000 + } + })(Header); diff --git a/client/coral-admin/src/containers/Layout.js b/client/coral-admin/src/containers/Layout.js index f2d71c93b..b90016102 100644 --- a/client/coral-admin/src/containers/Layout.js +++ b/client/coral-admin/src/containers/Layout.js @@ -1,5 +1,6 @@ -import React, {Component} from 'react'; +import React from 'react'; import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; import Layout from '../components/ui/Layout'; import {fetchConfig} from '../actions/config'; import AdminLogin from '../components/AdminLogin'; @@ -10,15 +11,18 @@ import {toggleModal as toggleShortcutModal} from '../actions/moderation'; import {checkLogin, handleLogin, requestPasswordReset, logout} from '../actions/auth'; import {can} from 'coral-framework/services/perms'; import UserDetail from 'coral-admin/src/containers/UserDetail'; +import PropTypes from 'prop-types'; -class LayoutContainer extends Component { +class LayoutContainer extends React.Component { componentWillMount() { const {checkLogin, fetchConfig} = this.props; checkLogin(); fetchConfig(); } + render() { + const { user, loggedIn, @@ -29,13 +33,16 @@ class LayoutContainer extends Component { } = this.props.auth; const { - handleLogout, + children, + logout, toggleShortcutModal, - TALK_RECAPTCHA_PUBLIC + TALK_RECAPTCHA_PUBLIC, } = this.props; + if (loadingUser) { return ; } + if (!loggedIn) { return ( ); } + if (can(user, 'ACCESS_ADMIN') && loggedIn) { return ( + auth={this.props.auth} > - {this.props.children} + {children} ); } else if (loggedIn) { @@ -72,19 +79,32 @@ class LayoutContainer extends Component { } } +LayoutContainer.propTypes = { + children: PropTypes.node, + requestPasswordReset: PropTypes.func, + handleLogin: PropTypes.func, + auth: PropTypes.object, + handleLogout: PropTypes.func, + logout: PropTypes.func, + toggleShortcutModal: PropTypes.func, + TALK_RECAPTCHA_PUBLIC: PropTypes.string, + checkLogin: PropTypes.func, + fetchConfig: PropTypes.func +}; + const mapStateToProps = (state) => ({ auth: state.auth, TALK_RECAPTCHA_PUBLIC: state.config.data.TALK_RECAPTCHA_PUBLIC, }); -const mapDispatchToProps = (dispatch) => ({ - checkLogin: () => dispatch(checkLogin()), - fetchConfig: () => dispatch(fetchConfig()), - handleLogin: (username, password, recaptchaResponse) => - dispatch(handleLogin(username, password, recaptchaResponse)), - requestPasswordReset: (email) => dispatch(requestPasswordReset(email)), - toggleShortcutModal: (toggle) => dispatch(toggleShortcutModal(toggle)), - handleLogout: () => dispatch(logout()) -}); - +const mapDispatchToProps = (dispatch) => + bindActionCreators({ + checkLogin, + fetchConfig, + handleLogin, + requestPasswordReset, + toggleShortcutModal, + logout + }, dispatch); + export default connect(mapStateToProps, mapDispatchToProps)(LayoutContainer); diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js index 8fedf6ebe..6b8aa7456 100644 --- a/client/coral-admin/src/graphql/index.js +++ b/client/coral-admin/src/graphql/index.js @@ -4,7 +4,7 @@ export default { mutations: { SetUserStatus: ({variables: {status, userId}}) => ({ updateQueries: { - TalkAdmin_FlaggedAccounts: (prev) => { + TalkAdmin_Community: (prev) => { if (status !== 'APPROVED') { return prev; } @@ -19,7 +19,7 @@ export default { }), RejectUsername: ({variables: {input: {id: userId}}}) => ({ updateQueries: { - TalkAdmin_FlaggedAccounts: (prev) => { + TalkAdmin_Community: (prev) => { const updated = update(prev, { users: { nodes: {$apply: (nodes) => nodes.filter((node) => node.id !== userId)}, diff --git a/client/coral-admin/src/routes/Community/components/CommunityLayout.js b/client/coral-admin/src/routes/Community/components/CommunityLayout.js deleted file mode 100644 index 0665b967d..000000000 --- a/client/coral-admin/src/routes/Community/components/CommunityLayout.js +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -const CommunityLayout = (props) => ( -
- {props.children} -
-); - -export default CommunityLayout; diff --git a/client/coral-admin/src/routes/Community/containers/Community.js b/client/coral-admin/src/routes/Community/containers/Community.js index 445b7f413..4e60c64d7 100644 --- a/client/coral-admin/src/routes/Community/containers/Community.js +++ b/client/coral-admin/src/routes/Community/containers/Community.js @@ -1,16 +1,14 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; +import PropTypes from 'prop-types'; import {compose, gql} from 'react-apollo'; import withQuery from 'coral-framework/hocs/withQuery'; -import PropTypes from 'prop-types'; - +import {getDefinitionName} from 'coral-framework/utils'; +import {withSetUserStatus, withRejectUsername} from 'coral-framework/graphql/mutations'; import FlaggedAccounts from '../containers/FlaggedAccounts'; import FlaggedUser from '../containers/FlaggedUser'; -import {withSetUserStatus, withRejectUsername} from 'coral-framework/graphql/mutations'; -import {getDefinitionName} from 'coral-framework/utils'; - import { fetchAccounts, updateSorting, @@ -27,7 +25,7 @@ class CommunityContainer extends Component { } render() { - return bindActionCreators({ fetchAccounts, @@ -86,9 +62,27 @@ const mapDispatchToProps = (dispatch) => newPage, }, dispatch); +const withData = withQuery(gql` + query TalkAdmin_Community { + flaggedUsernamesCount: userCount(query: { + action_type: FLAG, + statuses: [PENDING] + }) + ...${getDefinitionName(FlaggedAccounts.fragments.root)} + ...${getDefinitionName(FlaggedUser.fragments.root)} + me { + ...${getDefinitionName(FlaggedUser.fragments.me)} + __typename + } + } + ${FlaggedAccounts.fragments.root} + ${FlaggedUser.fragments.root} + ${FlaggedUser.fragments.me} +`); + export default compose( connect(mapStateToProps, mapDispatchToProps), withSetUserStatus, withRejectUsername, - withData, + withData )(CommunityContainer); diff --git a/client/coral-admin/src/routes/Community/index.js b/client/coral-admin/src/routes/Community/index.js deleted file mode 100644 index 528fe63cd..000000000 --- a/client/coral-admin/src/routes/Community/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export {default as Community} from './containers/Community'; -export {default as CommunityLayout} from './components/CommunityLayout'; diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.css b/client/coral-admin/src/routes/Moderation/components/Comment.css new file mode 100644 index 000000000..a1862640f --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/Comment.css @@ -0,0 +1,180 @@ +@custom-media --big-viewport (min-width: 780px); + +.root { + border-bottom: 1px solid #e0e0e0; + font-size: 18px; + width: 100%; + max-width: 650px; + min-width: 400px; + margin: 0 auto; + position: relative; + transition: all 200ms; + padding: 10px 0; + min-height: 0; + + /* + Fix rendering issues in Safari by promoting this + into its own layer. + + https://www.pivotaltracker.com/story/show/151142211 + */ + transform: translateZ(0); + + &:last-child { + border-bottom: none; + } +} + +.container { + padding: 0 14px; +} + +.itemHeader { + display: flex; + align-items: center; + justify-content: space-between; + +} + +.author { + font-weight: 300; + width: 100%; + display: flex; + align-items: center; + color: #262626; + font-size: 16px; + position: relative; +} + +.sideActions { + height: 100%; + top: 0; + box-sizing: border-box; +} + +.itemBody { + display: flex; + justify-content: space-between; + font-size: 14px; + line-height: 1.5; + font-weight: 300; +} + +.body { + margin-top: 0px; + flex: 1; + color: black; + max-width: 500px; + word-wrap: break-word; + font-weight: 300; + font-size: 16px; +} + +.created { + padding: 5px; + color: #262626; + font-size: 14px; + line-height: 1px; + font-weight: 300; +} + +.moderateArticle { + font-size: 14px; + margin: 10px 0; + font-weight: 500; + line-height: 1.2; + max-width: 500px; + + a { + display: inline-block; + color: #063b9a; + text-decoration: none; + font-weight: 500; + letter-spacing: .5px; + margin-left: 10px; + + font-size: 13px; + margin-left: 5px; + padding-bottom: 0px; + border-bottom: solid 1px; + line-height: 16px; + + &:hover { + opacity: .9; + cursor: pointer; + } + } +} + +.username { + color: #393B44; + text-decoration: none; + cursor: pointer; + font-weight: 600; + padding: 2px 5px; + border-radius: 2px; + margin-left: -5px; + transition: background-color 200ms ease; + &:hover { + background-color: #E0E0E0; + } +} + +.external { + font-size: .7em; + text-decoration: none; + color: #063b9a; + cursor: pointer; + font-weight: normal; + margin-left: 10px; + white-space: nowrap; + + &:hover { + text-decoration: underline; + opacity: .9; + } + + i { + font-size: 12px; + top: 2px; + position: relative; + } +} + +.editedMarker { + font-style: italic; + color: #666; + font-size: 12px; + line-height: 1px; + font-weight: 300; +} + +.adminCommentInfoBar { + min-width: 100px; + position: absolute; + right: 0px; + top: 0px; + text-align: right; +} + +.hasLinks { + color: #f00; + display: flex; + align-items: center; + justify-content: flex-end; + margin-right: 14px; + + i { + margin-right: 5px; + } +} + +@media (--big-viewport) { + .root { + margin-bottom: 30px; + + &:last-child { + border-bottom: 1px solid #e0e0e0; + } + } +} diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index b210d8f23..f72b0ef12 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -4,7 +4,7 @@ import {Link} from 'react-router'; import {Icon} from 'coral-ui'; import FlagBox from 'coral-admin/src/components/FlagBox'; -import styles from './styles.css'; +import styles from './Comment.css'; import CommentLabels from 'coral-admin/src/components/CommentLabels'; import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit'; import Slot from 'coral-framework/components/Slot'; @@ -72,7 +72,8 @@ class Comment extends React.Component { return (
  • @@ -225,8 +226,12 @@ Comment.propTypes = { title: PropTypes.string, url: PropTypes.string, id: PropTypes.string - }) - }) + }), + }), + data: PropTypes.object.isRequired, + root: PropTypes.object.isRequired, + actions: PropTypes.array.isRequired, + selected: PropTypes.bool, }; export default Comment; diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index 98a5d5837..c374c7fbf 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -1,6 +1,6 @@ import React, {Component} from 'react'; +import PropTypes from 'prop-types'; import key from 'keymaster'; -import styles from './styles.css'; import ModerationQueue from './ModerationQueue'; import ModerationMenu from './ModerationMenu'; @@ -9,12 +9,13 @@ import ModerationKeysModal from '../../../components/ModerationKeysModal'; import StorySearch from '../containers/StorySearch'; import Slot from 'coral-framework/components/Slot'; -export default class Moderation extends Component { - constructor() { - super(); +class Moderation extends Component { + constructor(props) { + super(props); + const comments = this.getComments(props); this.state = { - selectedIndex: 0 + selectedCommentId: comments[0] ? comments[0].id : null, }; } @@ -25,10 +26,10 @@ export default class Moderation extends Component { key('s', () => singleView()); key('shift+/', () => toggleModal(true)); key('esc', () => toggleModal(false)); - key('j', this.select(true)); - key('k', this.select(false)); - key('f', this.moderate(false)); - key('d', this.moderate(true)); + key('j', () => this.select(true)); + key('k', () => this.select(false)); + key('f', () => this.moderate(false)); + key('d', () => this.moderate(true)); } onClose = () => { @@ -44,11 +45,15 @@ export default class Moderation extends Component { this.props.toggleStorySearch(true); } - moderate = (accept) => () => { + getActiveTabCount = (props = this.props) => { + return props.root[`${props.activeTab}Count`]; + } + + moderate = (accept) => { const {acceptComment, rejectComment} = this.props; - const {selectedIndex} = this.state; + const {selectedCommentId} = this.state; const comments = this.getComments(); - const comment = comments[selectedIndex]; + const comment = comments[selectedCommentId]; const commentId = {commentId: comment.id}; if (accept) { @@ -58,25 +63,83 @@ export default class Moderation extends Component { } } - getComments = () => { - const {root, activeTab} = this.props; + getComments = (props = this.props) => { + const {root, activeTab} = props; return root[activeTab].nodes; } - select = (next) => () => { - if (next) { - this.setState((state) => ({ - ...state, - selectedIndex: state.selectedIndex < this.getComments().length - 1 - ? state.selectedIndex + 1 : state.selectedIndex - })); - } else { - this.setState((state) => ({ - ...state, - selectedIndex: state.selectedIndex > 0 - ? state.selectedIndex - 1 : state.selectedIndex - })); + scrollTo = (toId, smooth = true) => + document.querySelector(`#comment_${toId}`).scrollIntoView(smooth ? {behavior: 'smooth'} : {}); + + select = async (next, props = this.props, selectedCommentId = this.state.selectedCommentId) => { + const comments = this.getComments(props); + + // No comments to be selected. + if (comments.length === 0){ + return; } + + // Find current index if we have a selected comment. + const index = selectedCommentId + ? comments.findIndex((comment) => comment.id === selectedCommentId) + : null; + + if (next) { + + // Grab first one if we don't have a selected comment yet. + if (!selectedCommentId) { + this.setState({selectedCommentId: comments[0].id}, () => this.scrollTo(comments[0].id)); + return; + } + + // Select next one when we still have more comments left. + if (index < comments.length - 1) { + this.setState({selectedCommentId: comments[index + 1].id}, () => this.scrollTo(comments[index + 1].id)); + return; + } else { + + // We hit the end of the list, load more comments if we have. + if (comments.length < this.getActiveTabCount()) { + const res = await this.loadMore(); + + // If `loadMore` was already in progress, res would be false. + if (res) { + + // Select next comment after loading has completed. + this.select(true); + } + } + return; + } + } else { + + // We have no selected comment, so just skip it. + if (!selectedCommentId) { + return; + } + + // If we still have previous comments take the one before. + if (index > 0) { + this.setState({selectedCommentId: comments[index - 1].id}, () => this.scrollTo(comments[index - 1].id)); + return; + } + } + } + + loadMore = async () => { + if (!this.isLoadingMore) { + this.isLoadingMore = true; + try { + const result = await this.props.loadMore(this.props.activeTab); + this.isLoadingMore = false; + return result; + } + catch (e) { + this.isLoadingMore = false; + throw e; + } + } + return false; } componentWillUnmount() { @@ -89,25 +152,56 @@ export default class Moderation extends Component { key.unbind('d'); } - componentDidUpdate(_, prevState) { + componentWillReceiveProps(nextProps) { - // If paging through using keybaord shortcuts, scroll the page to keep the selected - // comment in view. - if (prevState.selectedIndex !== this.state.selectedIndex) { + if (this.props.activeTab !== nextProps.activeTab) { - // the 'smooth' flag only works in FF as of March 2017 - document.querySelector(`.${styles.selected}`).scrollIntoView({behavior: 'smooth'}); + // Reset selection when changing tabs. + this.select(true, nextProps, null); + } else { + + // Detect if comment has left the queue and find next or prev selected comment to set it + // as the new selectedCommentId. + const prevComments = this.getComments(this.props); + const nextComments = this.getComments(nextProps); + if (nextComments.length < prevComments.length) { + + // Comments have changed, now check if our selected comment has left the queue. + if ( + this.state.selectedCommentId && + !nextComments.some((comment) => comment.id === this.state.selectedCommentId) + ) { + + // Determine a comment to select. + const prevIndex = prevComments.findIndex((comment) => comment.id === this.state.selectedCommentId); + if (prevIndex !== prevComments.length - 1) { + this.setState({selectedCommentId: prevComments[prevIndex + 1].id}); + } else if(prevIndex > 0) { + this.setState({selectedCommentId: prevComments[prevIndex - 1].id}); + } else { + this.setState({selectedCommentId: null}); + } + } + } + } + } + + componentDidUpdate(prevProps) { + + // Scroll to comment when changing from single wiew to normal view. + if (prevProps.moderation.singleView !== this.props.moderation.singleView && this.state.selectedCommentId) { + this.scrollTo(this.state.selectedCommentId, false); } } render () { - const {root, data, moderation, settings, viewUserDetail, hideUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props; + const {root, data, moderation, settings, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props; const {asset} = root; const assetId = asset && asset.id; const comments = root[activeTab]; - const activeTabCount = root[`${activeTab}Count`]; + const activeTabCount = this.getActiveTabCount(); const menuItems = Object.keys(queueConfig).map((queue) => ({ key: queue, name: queueConfig[queue].name, @@ -132,26 +226,26 @@ export default class Moderation extends Component { activeTab={activeTab} /> { @@ -30,7 +30,8 @@ ModerationHeader.propTypes = { id: PropTypes.string }), openSearch: PropTypes.func.isRequired, - closeSearch: PropTypes.func.isRequired + closeSearch: PropTypes.func.isRequired, + searchVisible: PropTypes.bool, }; export default ModerationHeader; diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationLayout.js b/client/coral-admin/src/routes/Moderation/components/ModerationLayout.js index 02a798900..4b921b515 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationLayout.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationLayout.js @@ -1,4 +1,5 @@ import React from 'react'; +import PropTypes from 'prop-types'; const ModerationLayout = (props) => (
    @@ -6,4 +7,8 @@ const ModerationLayout = (props) => (
    ); +ModerationLayout.propTypes = { + children: PropTypes.node, +}; + export default ModerationLayout; diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationMenu.css b/client/coral-admin/src/routes/Moderation/components/ModerationMenu.css new file mode 100644 index 000000000..2ee0b4ec4 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/ModerationMenu.css @@ -0,0 +1,102 @@ +@custom-media --big-viewport (min-width: 780px); + +.tabBar { + background-color: rgba(44, 44, 44, 0.89); + z-index: 5; + display: flex; + justify-content: space-between; +} + +.tabBarPadding { + width: 150px; +} + +.tab { + flex: 1; + color: #BDBDBD; + text-transform: capitalize; + font-weight: 100; + font-size: 14px; + letter-spacing: 1px; + transition: border-bottom 200ms; + transition: color 200ms; + padding: 0px 10px; + margin-right: 20px; + &:hover { + color: white; + /*border-bottom: solid 2px #F36451;*/ + box-sizing: border-box; + } +} + +.active { + color: white; + box-sizing: border-box; + border-bottom: solid 4px #F36451; + font-weight: 400; + &:hover { + border-bottom: solid 4px #F36451; + font-weight: 400; + } +} + +.active > span { + color: white; +} + +.active:after { + background: transparent !important; +} + +.selectField { + position: relative; + width: 140px; + height: 36px; + top: 5px; + margin-right: 10px; + background: #FFF; + padding: 10px 15px; + box-sizing: border-box; + border-radius: 2px; + bor + box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12); + + > div { + padding: 0; + } + + i { + position: absolute; + top: 7px; + right: 7px; + } + + input { + padding: 0; + font-size: 13px; + letter-spacing: 0.7px; + font-weight: 400; + border-bottom: 0px; + } + + label { + top: -4px; + } + + &:hover { + cursor: pointer; + } +} + +.tabIcon { + position: relative; + top: 3px; + font-size: 16px; +} + + +@media (--big-viewport) { + .tab { + flex: none; + } +} diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js b/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js index 27b8409c6..81293d06b 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationMenu.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import CountBadge from '../../../components/CountBadge'; -import styles from './styles.css'; +import styles from './ModerationMenu.css'; import {SelectField, Option} from 'react-mdl-selectfield'; import {Icon} from 'coral-ui'; import {Link} from 'react-router'; @@ -49,7 +49,11 @@ ModerationMenu.propTypes = { items: PropTypes.array.isRequired, asset: PropTypes.shape({ id: PropTypes.string - }) + }), + selectSort: PropTypes.func.isRequired, + sort: PropTypes.string.isRequired, + getModPath: PropTypes.func.isRequired, + activeTab: PropTypes.string.isRequired, }; export default ModerationMenu; diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css new file mode 100644 index 000000000..6f1d4ae95 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css @@ -0,0 +1,30 @@ +.root { + padding: 8px 0; + list-style: none; + display: block; + margin-top: 16px; +} + +.list { + padding: 0; + margin: 0; +} + +.commentLeave { + opacity: 1.0; +} + +.commentLeaveActive { + opacity: 0; + transition: opacity 800ms; +} + +.commentEnter { + opacity: 0; +} + +.commentEnterActive { + opacity: 1.0; + transition: opacity 800ms; +} + diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 3197ddd78..517892a77 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -2,42 +2,56 @@ import React from 'react'; import PropTypes from 'prop-types'; import Comment from '../containers/Comment'; -import styles from './styles.css'; +import styles from './ModerationQueue.css'; import EmptyCard from '../../../components/EmptyCard'; import {actionsMap} from '../../../utils/moderationQueueActionsMap'; import LoadMore from '../../../components/LoadMore'; +import ViewMore from './ViewMore'; import t from 'coral-framework/services/i18n'; import {CSSTransitionGroup} from 'react-transition-group'; +const hasComment = (nodes, id) => nodes.some((node) => node.id === id); + +// resetCursors will return the id cursors of the first and second comment of +// the current comment list. The cursors are used to dertermine which +// comments to show. The spare cursor functions as a backup in case one +// of the comments gets deleted. +function resetCursors(state, props) { + if (props.comments && props.comments.length) { + const idCursors = [props.comments[0].id]; + if (props.comments[1]) { + idCursors.push(props.comments[1].id); + } + return {idCursors}; + } + return {idCursors: []}; +} + +// invalidateCursor is called whenever a comment is removed which is referenced +// by one of the 2 id cursors. It returns a new set of id cursors calculated +// using the help of the backup cursor. +function invalidateCursor(invalidated, state, props) { + const alt = invalidated === 1 ? 0 : 1; + const idCursors = []; + if (state.idCursors[alt]) { + idCursors.push(state.idCursors[alt]); + const index = props.comments.findIndex((node) => node.id === idCursors[0]); + const nextInLine = props.comments[index + 1]; + if (nextInLine) { + idCursors.push(nextInLine.id); + } + } + return {idCursors}; +} + class ModerationQueue extends React.Component { isLoadingMore = false; - static propTypes = { - viewUserDetail: PropTypes.func.isRequired, - bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired, - suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired, - currentAsset: PropTypes.object, - showBanUserDialog: PropTypes.func.isRequired, - showSuspendUserDialog: PropTypes.func.isRequired, - rejectComment: PropTypes.func.isRequired, - acceptComment: PropTypes.func.isRequired, - comments: PropTypes.array.isRequired - } - - loadMore = () => { - if (!this.isLoadingMore) { - this.isLoadingMore = true; - this.props.loadMore(this.props.activeTab) - .then(() => this.isLoadingMore = false) - .catch((e) => { - this.isLoadingMore = false; - throw e; - }); - } - } - constructor(props) { super(props); + this.state = { + ...resetCursors(this.state, props), + }; } componentDidUpdate (prev) { @@ -47,14 +61,67 @@ class ModerationQueue extends React.Component { // AND there are more comments available on the server, // go ahead and load more comments if (prev.comments.length > 0 && comments.length === 0 && commentCount > 0) { - this.loadMore(); + this.props.loadMore(); } } + componentWillReceiveProps(next) { + const {comments: prevComments} = this.props; + const {comments: nextComments} = next; + + if (!prevComments && nextComments) { + this.setState(resetCursors); + return; + } + + if ( + prevComments && nextComments && + nextComments.length < prevComments.length + ) { + + // Invalidate first cursor if referenced comment was removed. + if (this.state.idCursors[0] && !hasComment(nextComments, this.state.idCursors[0])) { + this.setState(invalidateCursor(0, this.state, next)); + } + + // Invalidate second cursor if referenced comment was removed. + if (this.state.idCursors[1] && !hasComment(nextComments, this.state.idCursors[1])) { + this.setState(invalidateCursor(1, this.state, next)); + } + } + } + + viewNewComments = () => { + this.setState(resetCursors); + }; + + // getVisibileComments returns a list containing comments + // which comes after the `idCursor`. + getVisibleComments() { + const {comments} = this.props; + const idCursor = this.state.idCursors[0]; + + if (!comments) { + return []; + } + + const view = []; + let pastCursor = false; + comments.forEach((comment) => { + if (comment.id === idCursor) { + pastCursor = true; + } + if (pastCursor) { + view.push(comment); + } + }); + return view; + } + render () { const { comments, - selectedIndex, + selectedCommentId, commentCount, singleView, viewUserDetail, @@ -62,12 +129,53 @@ class ModerationQueue extends React.Component { ...props } = this.props; + if (comments.length === 0) { + return ( +
    + {t('modqueue.empty_queue')} +
    + ); + } + + if (singleView) { + const index = comments.findIndex((comment) => comment.id === selectedCommentId); + const comment = comments[index]; + const status = comment.action_summaries ? 'FLAGGED' : comment.status; + return ( +
    + ; +
    + ); + } + + const view = this.getVisibleComments(); + return ( -
    +
    + { - comments.map((comment, i) => { - const status = comment.action_summaries ? 'FLAGGED' : comment.status; - return ; - }) + view + .map((comment) => { + const status = comment.action_summaries ? 'FLAGGED' : comment.status; + return ; + }) } - {comments.length === 0 && -
    - {t('modqueue.empty_queue')} -
    - }
    @@ -117,4 +221,24 @@ class ModerationQueue extends React.Component { } } +ModerationQueue.propTypes = { + viewUserDetail: PropTypes.func.isRequired, + bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired, + suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired, + currentAsset: PropTypes.object, + showBanUserDialog: PropTypes.func.isRequired, + showSuspendUserDialog: PropTypes.func.isRequired, + rejectComment: PropTypes.func.isRequired, + acceptComment: PropTypes.func.isRequired, + comments: PropTypes.array.isRequired, + commentCount: PropTypes.number.isRequired, + loadMore: PropTypes.func.isRequired, + selectedCommentId: PropTypes.string, + singleView: PropTypes.bool, + activeTab: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + root: PropTypes.object.isRequired, + currentUserId: PropTypes.string.isRequired, +}; + export default ModerationQueue; diff --git a/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.css b/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.css new file mode 100644 index 000000000..6b03bfd69 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.css @@ -0,0 +1,20 @@ + +.notFound { + position: relative; + margin: 20px auto; + text-align: center; + padding: 68px 45px; + vertical-align: middle; + min-width: 500px; + + a { + color: rgb(244, 126, 107); + font-weight: 500; + } +} + +.goToStreams { + position: absolute; + right: 10px; + bottom: 10px; +} diff --git a/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.js b/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.js index ee5d2ccd1..820bff81c 100644 --- a/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.js +++ b/client/coral-admin/src/routes/Moderation/components/NotFoundAsset.js @@ -1,6 +1,7 @@ import React from 'react'; import {Link} from 'react-router'; -import styles from './styles.css'; +import styles from './NotFoundAsset.css'; +import PropTypes from 'prop-types'; const NotFound = (props) => (
    @@ -11,4 +12,8 @@ const NotFound = (props) => (
    ); +NotFound.propTypes = { + assetId: PropTypes.string.isRequired, +}; + export default NotFound; diff --git a/client/coral-admin/src/routes/Moderation/components/Story.css b/client/coral-admin/src/routes/Moderation/components/Story.css new file mode 100644 index 000000000..fa43bc1cd --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/Story.css @@ -0,0 +1,49 @@ + +.story { + padding: 7px 50px; + border-bottom: 1px solid #ddd; + cursor: pointer; + display: block; + text-decoration: none; + min-height: 50px; + box-sizing: border-box; + transition: background-color 400ms; + + &:hover { + background-color: #efefef; + } + + &:last-child { + border-bottom: none; + } +} + +.title, .meta { + margin: 0; + color: black; + font-size: 15px; +} + +.author, .createdAt, .status { + font-size: 17px; + display: inline-block; + font-size: .8em; +} + +.createdAt { + text-align: center; +} + +.author { + display: inline-block; + width: 200px; + color: #aaa; +} + +.createdAt { + display: inline-block; + width: 200px; + color: #aaa; +} + + diff --git a/client/coral-admin/src/routes/Moderation/components/Story.js b/client/coral-admin/src/routes/Moderation/components/Story.js index eaad6c2a6..6720124c7 100644 --- a/client/coral-admin/src/routes/Moderation/components/Story.js +++ b/client/coral-admin/src/routes/Moderation/components/Story.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import styles from './StorySearch.css'; +import styles from './Story.css'; const formatDate = (date) => { const d = new Date(date); @@ -25,7 +25,8 @@ Story.propTypes = { author: PropTypes.string.isRequired, title: PropTypes.string.isRequired, createdAt: PropTypes.string.isRequired, - open: PropTypes.bool.isRequired + open: PropTypes.bool.isRequired, + goToStory: PropTypes.func.isRequired, }; export default Story; diff --git a/client/coral-admin/src/routes/Moderation/components/StorySearch.css b/client/coral-admin/src/routes/Moderation/components/StorySearch.css index 973d97c24..1a317ad26 100644 --- a/client/coral-admin/src/routes/Moderation/components/StorySearch.css +++ b/client/coral-admin/src/routes/Moderation/components/StorySearch.css @@ -67,54 +67,6 @@ /*.storyList { border-top: 1px solid #ddd; }*/ - -.story { - padding: 7px 50px; - border-bottom: 1px solid #ddd; - cursor: pointer; - display: block; - text-decoration: none; - min-height: 50px; - box-sizing: border-box; - transition: background-color 400ms; - - &:hover { - background-color: #efefef; - } - - &:last-child { - border-bottom: none; - } -} - -.title, .meta { - margin: 0; - color: black; - font-size: 15px; -} - -.author, .createdAt, .status { - font-size: 17px; - display: inline-block; - font-size: .8em; -} - -.createdAt { - text-align: center; -} - -.author { - display: inline-block; - width: 200px; - color: #aaa; -} - -.createdAt { - display: inline-block; - width: 200px; - color: #aaa; -} - .searchButton { width: 90px; height: 35px; diff --git a/client/coral-admin/src/routes/Moderation/components/StorySearch.js b/client/coral-admin/src/routes/Moderation/components/StorySearch.js index 7ac18f907..512ec542f 100644 --- a/client/coral-admin/src/routes/Moderation/components/StorySearch.js +++ b/client/coral-admin/src/routes/Moderation/components/StorySearch.js @@ -93,7 +93,13 @@ StorySearch.propTypes = { clearAndCloseSearch: PropTypes.func.isRequired, moderation: PropTypes.object.isRequired, handleSearchChange: PropTypes.func.isRequired, - assetId: PropTypes.string + assetId: PropTypes.string, + data: PropTypes.object.isRequired, + root: PropTypes.object.isRequired, + handleEsc: PropTypes.func.isRequired, + handleEnter: PropTypes.func.isRequired, + goToModerateAll: PropTypes.func.isRequired, + searchValue: PropTypes.string, }; export default StorySearch; diff --git a/client/coral-admin/src/routes/Moderation/components/ViewMore.css b/client/coral-admin/src/routes/Moderation/components/ViewMore.css new file mode 100644 index 000000000..2dcce0da5 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/ViewMore.css @@ -0,0 +1,21 @@ +.viewMoreContainer { + display: flex; + justify-content: center; + width: 100%; +} + +.viewMore { + width: 100%; + text-align: center; + color: #FFF; + max-width: 660px; + margin-bottom: 30px; + background-color: #2376D8; + cursor: pointer; + text-transform: capitalize; +} + +.viewMore:hover { + background-color: #4399FF; +} + diff --git a/client/coral-admin/src/routes/Moderation/components/ViewMore.js b/client/coral-admin/src/routes/Moderation/components/ViewMore.js new file mode 100644 index 000000000..d7a6f156c --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/ViewMore.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {Button} from 'coral-ui'; +import styles from './ViewMore.css'; +import cn from 'classnames'; +import t from 'coral-framework/services/i18n'; + +const ViewMore = ({viewMore, count, className, ...rest}) => +
    + { + count > 0 && + } +
    ; + +ViewMore.propTypes = { + viewMore: PropTypes.func.isRequired, + count: PropTypes.number.isRequired, + className: PropTypes.string +}; + +export default ViewMore; diff --git a/client/coral-admin/src/routes/Moderation/components/styles.css b/client/coral-admin/src/routes/Moderation/components/styles.css deleted file mode 100644 index 8d4214760..000000000 --- a/client/coral-admin/src/routes/Moderation/components/styles.css +++ /dev/null @@ -1,495 +0,0 @@ -/** - * @TODO: deprecated as this file contains styles from multiple components. Please remove this file - * when styles have been refactored. - */ - -@custom-media --big-viewport (min-width: 780px); - -.listContainer { - max-width: 860px; - margin: 0 auto; -} - -.tabBar { - background-color: rgba(44, 44, 44, 0.89); - z-index: 5; - display: flex; - justify-content: space-between; -} - -.tabBarPadding { - width: 150px; -} - -.tab { - flex: 1; - color: #BDBDBD; - text-transform: capitalize; - font-weight: 100; - font-size: 14px; - letter-spacing: 1px; - transition: border-bottom 200ms; - transition: color 200ms; - padding: 0px 10px; - margin-right: 20px; - &:hover { - color: white; - /*border-bottom: solid 2px #F36451;*/ - box-sizing: border-box; - } -} - -.active { - color: white; - box-sizing: border-box; - border-bottom: solid 4px #F36451; - font-weight: 400; - &:hover { - border-bottom: solid 4px #F36451; - font-weight: 400; - } -} - -.active > span { - color: white; -} - -.active:after { - background: transparent !important; -} - -.showShortcuts { - position: absolute; - right: 130px; - display: flex; - align-items: center; - font-size: 13px; - -span { - margin-left: 7px; -} -} - -@media (--big-viewport) { - .tab { - flex: none; - } -} - -.notFound { - position: relative; - margin: 20px auto; - text-align: center; - padding: 68px 45px; - vertical-align: middle; - min-width: 500px; - - a { - color: rgb(244, 126, 107); - font-weight: 500; - - &.goToStreams { - position: absolute; - right: 10px; - bottom: 10px; - } - } -} - -.header { - background-color: #2c2c2c; - color: white; - margin-bottom: -1px; - - .settingsButton { - vertical-align: middle; - margin-left: 10px; - margin-top: -4px; - font-size: 16px; - } - - .moderateAsset { - a { - -webkit-box-flex: 1; - -ms-flex: 1; - flex: 1; - color: white; - text-transform: capitalize; - font-weight: 400; - font-size: 20px; - letter-spacing: 1px; - transition: background-color 200ms; - opacity: 1; - - &:hover { - cursor: pointer; - background-color: #212121; - } - span { - text-align: center; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - max-width: 344px; - display: inline-block; - vertical-align: top; - } - - } - } -} - - -@custom-media --big-viewport (min-width: 780px); - -.list { - padding: 8px 0; - list-style: none; - display: block; - - &.singleView .listItem { - display: none; - } - - &.singleView .listItem.selected { - display: block; - height: 100%; - font-size: 1.5em; - line-height: 1.5em; - border: none; - - .actions { - position: fixed; - bottom: 60px; - left: 25%; - display: flex; - justify-content: space-around; - width: 50%; - margin: 0; - } - - .actionButton { - transform: scale(1.4); - } - } -} - -.listItem { - border-bottom: 1px solid #e0e0e0; - font-size: 18px; - width: 100%; - max-width: 650px; - min-width: 400px; - margin: 0 auto; - position: relative; - transition: all 200ms; - padding: 10px 0; - min-height: 0; - - /* - Fix rendering issues in Safari by promoting this - into its own layer. - - https://www.pivotaltracker.com/story/show/151142211 - */ - transform: translateZ(0); - - .container { - padding: 0 14px; - } - - &:last-child { - border-bottom: none; - } - - &.selected { - } - - .context { - a { - color: #f36451; - text-decoration: underline; - float: right; - } - } - - .sideActions { - height: 100%; - top: 0; - box-sizing: border-box; - } - - .itemHeader { - display: flex; - align-items: center; - justify-content: space-between; - - .author { - font-weight: 300; - width: 100%; - display: flex; - align-items: center; - color: #262626; - font-size: 16px; - position: relative; - } - } - - .itemBody { - display: flex; - justify-content: space-between; - font-size: 14px; - line-height: 1.5; - font-weight: 300; - } - - .avatar { - margin-right: 16px; - height: 40px; - width: 40px; - border-radius: 50%; - background-color: #757575; - font-size: 40px; - color: #fff; - } - - .created { - padding: 5px; - color: #262626; - font-size: 14px; - line-height: 1px; - font-weight: 300; - } - - .actionButton { - transform: scale(.8); - margin: 0; - } - - .body { - margin-top: 0px; - flex: 1; - color: black; - max-width: 500px; - word-wrap: break-word; - font-weight: 300; - font-size: 16px; - } - - .flagged { - color: rgba(255, 0, 0, .5); - padding-top: 15px; - padding-left: 10px; - } - - .flagCount{ - font-size: 12px; - color: #d32f2f; - } - -} - -.empty { - color: #444; - margin-top: 50px; - text-align: center; -} - - -@media (--big-viewport) { - .listItem { - margin-bottom: 30px; - - &:last-child { - border-bottom: 1px solid #e0e0e0; - } - - &.activeItem { - border: 2px solid #333; - } - } - -} - -.hasLinks { - color: #f00; - text-align: right; - display: flex; - align-items: center; - - i { - margin-right: 5px; - } -} - -.banned { - color: #f00; - text-align: left; - display: flex; - align-items: center; - - i { - margin-right: 5px; - } -} - -.ban { - display: block; - text-align: center; - margin-top: 5px; -} - -.Comment { - - .moderateArticle { - font-size: 14px; - margin: 10px 0; - font-weight: 500; - line-height: 1.2; - max-width: 500px; - - a { - display: inline-block; - color: #063b9a; - text-decoration: none; - font-weight: 500; - letter-spacing: .5px; - margin-left: 10px; - - font-size: 13px; - margin-left: 5px; - padding-bottom: 0px; - border-bottom: solid 1px; - line-height: 16px; - - &:hover { - opacity: .9; - cursor: pointer; - } - } - } -} - -.selectField { - position: relative; - width: 140px; - height: 36px; - top: 5px; - margin-right: 10px; - background: #FFF; - padding: 10px 15px; - box-sizing: border-box; - border-radius: 2px; - bor - box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12); - - > div { - padding: 0; - } - - i { - position: absolute; - top: 7px; - right: 7px; - } - - input { - padding: 0; - font-size: 13px; - letter-spacing: 0.7px; - font-weight: 400; - border-bottom: 0px; - } - - label { - top: -4px; - } - - &:hover { - cursor: pointer; - } -} - -.tabIcon { - position: relative; - top: 3px; - font-size: 16px; -} - -.username { - color: #393B44; - text-decoration: none; - cursor: pointer; - font-weight: 600; - padding: 2px 5px; - border-radius: 2px; - margin-left: -5px; - transition: background-color 200ms ease; - &:hover { - background-color: #E0E0E0; - } -} - -.external { - font-size: .7em; - text-decoration: none; - color: #063b9a; - cursor: pointer; - font-weight: normal; - margin-left: 10px; - white-space: nowrap; - - &:hover { - text-decoration: underline; - opacity: .9; - } - - i { - font-size: 12px; - top: 2px; - position: relative; - } -} - -.emptyCardContainer { - margin-top: 16px; -} - -.commentLeave { - opacity: 1.0; -} - -.commentLeaveActive { - opacity: 0; - transition: opacity 800ms; -} - -.commentEnter { - opacity: 0; -} - -.commentEnterActive { - opacity: 1.0; - transition: opacity 800ms; -} - -.editedMarker { - font-style: italic; - color: #666; - font-size: 12px; - line-height: 1px; - font-weight: 300; -} - -.searchTrigger { - position: relative; - top: .2em; -} - -.adminCommentInfoBar { - min-width: 100px; - position: absolute; - right: 0px; - top: 0px; - text-align: right; -} diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index baf6b8dfd..48b4a9364 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -82,50 +82,56 @@ class ModerationContainer extends Component { } subscribeToUpdates(variables = this.props.data.variables) { - const sub1 = this.props.data.subscribeToMore({ - document: COMMENT_ACCEPTED_SUBSCRIPTION, - variables, - updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => { - const user = comment.status_history[comment.status_history.length - 1].assigned_by; - const notifyText = this.props.auth.user.id === user.id - ? '' - : t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body)); - return this.handleCommentChange(prev, comment, notifyText); + const parameters = [ + { + document: COMMENT_ADDED_SUBSCRIPTION, + variables, + updateQuery: (prev, {subscriptionData: {data: {commentAdded: comment}}}) => { + return this.handleCommentChange(prev, comment); + }, }, - }); - - const sub2 = this.props.data.subscribeToMore({ - document: COMMENT_REJECTED_SUBSCRIPTION, - variables, - updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => { - const user = comment.status_history[comment.status_history.length - 1].assigned_by; - const notifyText = this.props.auth.user.id === user.id - ? '' - : t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body)); - return this.handleCommentChange(prev, comment, notifyText); + { + document: COMMENT_ACCEPTED_SUBSCRIPTION, + variables, + updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => { + const user = comment.status_history[comment.status_history.length - 1].assigned_by; + const notifyText = this.props.auth.user.id === user.id + ? '' + : t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body)); + return this.handleCommentChange(prev, comment, notifyText); + }, }, - }); - - const sub3 = this.props.data.subscribeToMore({ - document: COMMENT_EDITED_SUBSCRIPTION, - variables, - updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => { - const notifyText = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body)); - return this.handleCommentChange(prev, comment, notifyText); + { + document: COMMENT_REJECTED_SUBSCRIPTION, + variables, + updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => { + const user = comment.status_history[comment.status_history.length - 1].assigned_by; + const notifyText = this.props.auth.user.id === user.id + ? '' + : t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body)); + return this.handleCommentChange(prev, comment, notifyText); + }, }, - }); - - const sub4 = this.props.data.subscribeToMore({ - document: COMMENT_FLAGGED_SUBSCRIPTION, - variables, - updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => { - const user = comment.actions[comment.actions.length - 1].user; - const notifyText = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)); - return this.handleCommentChange(prev, comment, notifyText); + { + document: COMMENT_EDITED_SUBSCRIPTION, + variables, + updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => { + const notifyText = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body)); + return this.handleCommentChange(prev, comment, notifyText); + }, }, - }); + { + document: COMMENT_FLAGGED_SUBSCRIPTION, + variables, + updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => { + const user = comment.actions[comment.actions.length - 1].user; + const notifyText = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)); + return this.handleCommentChange(prev, comment, notifyText); + }, + }, + ]; - this.subscriptions.push(sub1, sub2, sub3, sub4); + this.subscriptions = parameters.map((param) => this.props.data.subscribeToMore(param)); } unsubscribe() { @@ -204,12 +210,9 @@ class ModerationContainer extends Component { // Not found. return ; } - if (asset === undefined || asset.id !== assetId) { + } - // Still loading. - return ; - } - } else if (asset !== undefined || !('premodCount' in root)) { + if(data.loading) { // loading. return ; @@ -240,6 +243,14 @@ class ModerationContainer extends Component { />; } } +const COMMENT_ADDED_SUBSCRIPTION = gql` + subscription CommentAdded($asset_id: ID){ + commentAdded(asset_id: $asset_id, statuses: null){ + ...${getDefinitionName(Comment.fragments.comment)} + } + } + ${Comment.fragments.comment} +`; const COMMENT_EDITED_SUBSCRIPTION = gql` subscription CommentEdited($asset_id: ID){ @@ -369,29 +380,6 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql` }, }); -const withQueueCountPolling = withQuery(({queueConfig}) => gql` - query CoralAdmin_ModerationCountPoll($asset_id: ID) { - ${Object.keys(queueConfig).map((queue) => ` - ${queue}Count: commentCount(query: { - ${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''} - ${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''} - ${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''} - asset_id: $asset_id, - }) - `)} - } -`, { - options: (props) => { - const id = getAssetId(props); - return { - pollInterval: 5000, - variables: { - asset_id: id - } - }; - } -}); - const mapStateToProps = (state) => ({ moderation: state.moderation, settings: state.settings, @@ -419,6 +407,5 @@ export default compose( withQueueConfig(baseQueueConfig), connect(mapStateToProps, mapDispatchToProps), withSetCommentStatus, - withQueueCountPolling, withModQueueQuery, )(ModerationContainer); diff --git a/client/coral-embed-stream/src/components/Comment.css b/client/coral-embed-stream/src/components/Comment.css index 6dc8eee80..e32b7333b 100644 --- a/client/coral-embed-stream/src/components/Comment.css +++ b/client/coral-embed-stream/src/components/Comment.css @@ -50,11 +50,6 @@ pointer-events: none; } -.bylineSecondary { - color: #696969; - font-size: 12px; -} - .editedMarker { font-style: italic; } diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index c6a3b740d..44448786a 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import TagLabel from 'talk-plugin-tag-label/TagLabel'; -import PubDate from 'talk-plugin-pubdate/PubDate'; +import CommentTimestamp from 'coral-framework/components/CommentTimestamp'; import {ReplyBox, ReplyButton} from 'talk-plugin-replies'; import {FlagComment} from 'talk-plugin-flags'; import {can} from 'coral-framework/services/perms'; @@ -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'; @@ -465,7 +465,14 @@ export default class Comment extends React.Component { /> - + { (comment.editing && comment.editing.edited) ?  ({t('comment.edited')}) diff --git a/client/coral-embed-stream/src/components/Embed.js b/client/coral-embed-stream/src/components/Embed.js index 86d36d47e..7fc6d1474 100644 --- a/client/coral-embed-stream/src/components/Embed.js +++ b/client/coral-embed-stream/src/components/Embed.js @@ -29,7 +29,7 @@ export default class Embed extends React.Component { }; render() { - const {activeTab, commentId, root, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog} = this.props; + const {activeTab, commentId, root, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog, router: {location: {query: {parentUrl}}}} = this.props; const {user} = this.props.auth; const hasHighlightedComment = !!commentId; @@ -37,7 +37,7 @@ export default class Embed extends React.Component {
    { const textbreaks = comment.body.split('\n'); - return
    + return { textbreaks.map((line, i) => { return ( {line} -
    + {i === textbreaks.length - 1 &&
    }
    ); }) } -
    ; + ; }; export default CommentContent; diff --git a/client/coral-framework/components/CommentTimestamp.css b/client/coral-framework/components/CommentTimestamp.css new file mode 100644 index 000000000..a4620902c --- /dev/null +++ b/client/coral-framework/components/CommentTimestamp.css @@ -0,0 +1,6 @@ +.timestamp { + display: inline-block; + color: #696969; + font-size: 12px; +} + diff --git a/client/coral-framework/components/CommentTimestamp.js b/client/coral-framework/components/CommentTimestamp.js new file mode 100644 index 000000000..0d1c72f2b --- /dev/null +++ b/client/coral-framework/components/CommentTimestamp.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {timeago} from 'coral-framework/services/i18n'; +import cn from 'classnames'; +import styles from './CommentTimestamp.css'; + +const CommentTimestamp = ({className, created_at}) => ( +
    + {timeago(created_at)} +
    +); + +CommentTimestamp.propTypes = { + className: PropTypes.string, + created_at: PropTypes.string, +}; + +export default CommentTimestamp; diff --git a/client/coral-settings/containers/ProfileContainer.js b/client/coral-settings/containers/ProfileContainer.js index aab0e2a6a..1391fa83f 100644 --- a/client/coral-settings/containers/ProfileContainer.js +++ b/client/coral-settings/containers/ProfileContainer.js @@ -95,8 +95,9 @@ class ProfileContainer extends Component { const slots = [ 'profileSections', - // TODO: This Slot should be included in `talk-plugin-history` instead. + // TODO: These Slots should be included in `talk-plugin-history` instead. 'commentContent', + 'historyCommentTimestamp', ]; const CommentFragment = gql` diff --git a/client/talk-plugin-history/Comment.css b/client/talk-plugin-history/Comment.css index 1200709a3..23289c17d 100644 --- a/client/talk-plugin-history/Comment.css +++ b/client/talk-plugin-history/Comment.css @@ -49,27 +49,32 @@ li { margin-bottom: 5px; - - &:nth-child(1) { - color: #5394D7; - } - - &:nth-child(2) { - color: #909090; - } - - - i { - margin-right: 5px; - font-size: 15px; - } - - a:hover { - cursor: pointer; - } } } +.viewLink { + color: #5394D7; + + &:hover { + cursor: pointer; + } +} + +.iconView { + margin-right: 5px; + font-size: 15px; + vertical-align: middle; + padding-bottom: 1px; +} + +.iconDate { + margin-right: 5px; + font-size: 15px; + color: #696969; + vertical-align: middle; + padding-bottom: 1px; +} + @custom-media --mobile-viewport (max-width: 480px); @media (--mobile-viewport) { @@ -92,7 +97,3 @@ color: inherit; } -.iconView, .iconDate { - vertical-align: middle; - padding-bottom: 1px; -} diff --git a/client/talk-plugin-history/Comment.js b/client/talk-plugin-history/Comment.js index 51ab5c84a..9d1208708 100644 --- a/client/talk-plugin-history/Comment.js +++ b/client/talk-plugin-history/Comment.js @@ -3,8 +3,8 @@ import PropTypes from 'prop-types'; import {Icon} from '../coral-ui'; import styles from './Comment.css'; import Slot from 'coral-framework/components/Slot'; -import PubDate from '../talk-plugin-pubdate/PubDate'; -import CommentContent from '../coral-embed-stream/src/components/CommentContent'; +import CommentTimestamp from 'coral-framework/components/CommentTimestamp'; +import CommentContent from 'coral-framework/components/CommentContent'; import cn from 'classnames'; import {getTotalReactionsCount} from 'coral-framework/utils'; @@ -15,6 +15,7 @@ class Comment extends React.Component { render() { const {comment, link, data, root} = this.props; const reactionCount = getTotalReactionsCount(comment.action_summaries); + const queryData = {root, comment, asset: comment.asset}; return (
    @@ -24,7 +25,7 @@ class Comment extends React.Component { defaultComponent={CommentContent} className={cn(styles.commentBody, 'my-comment-body')} data={data} - queryData={{root, comment, asset: comment.asset}} + queryData={queryData} />
    @@ -37,7 +38,7 @@ class Comment extends React.Component { - {comment.replyCount} + {comment.replyCount} {comment.replyCount === 1 ? t('common.reply') : t('common.replies')} @@ -53,15 +54,20 @@ class Comment extends React.Component {
    diff --git a/client/talk-plugin-pubdate/PubDate.js b/client/talk-plugin-pubdate/PubDate.js deleted file mode 100644 index 7afd45e2c..000000000 --- a/client/talk-plugin-pubdate/PubDate.js +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import {timeago} from 'coral-framework/services/i18n'; - -const name = 'talk-plugin-pubdate'; - -const PubDate = ({created_at}) =>
    - {timeago(created_at)} -
    ; - -export default PubDate; diff --git a/config.js b/config.js index 62c6fccf4..c42a1e4d2 100644 --- a/config.js +++ b/config.js @@ -94,14 +94,17 @@ const CONFIG = { MONGO_URL: process.env.TALK_MONGO_URL, REDIS_URL: process.env.TALK_REDIS_URL, - // REDIS_RECONNECTION_MAX_ATTEMPTS is the amount of attempts that a redis - // connection will attempt to reconnect before aborting with an error. - REDIS_RECONNECTION_MAX_ATTEMPTS: parseInt(process.env.TALK_REDIS_RECONNECTION_MAX_ATTEMPTS || '100'), + // REDIS_CLIENT_CONFIG is the optional configuration that is merged with the + // function config to provide deep control of the redis connection beheviour. + REDIS_CLIENT_CONFIG: process.env.TALK_REDIS_CLIENT_CONFIGURATION || '{}', - // REDIS_RECONNECTION_MAX_RETRY_TIME is the time in string format for the - // maximum amount of time that a client can be considered "connecting" before - // attempts at reconnection are aborted with an error. - REDIS_RECONNECTION_MAX_RETRY_TIME: ms(process.env.TALK_REDIS_RECONNECTION_MAX_RETRY_TIME || '1 min'), + // REDIS_CLUSTER_MODE allows configuration on the type of cluster mode enabled + // on the redis client. Can be either `NONE` or `CLUSTER`. + REDIS_CLUSTER_MODE: process.env.TALK_REDIS_CLUSTER_MODE || 'NONE', + + // REDIS_CLUSTER_CONFIGURATION contains the json string for the redis cluster + // configuration. + REDIS_CLUSTER_CONFIGURATION: process.env.TALK_REDIS_CLUSTER_CONFIGURATION || '[]', // REDIS_RECONNECTION_BACKOFF_FACTOR is the factor that will be multiplied // against the current attempt count inbetween attempts to connect to redis. @@ -246,6 +249,26 @@ if (process.env.NODE_ENV === 'test' && !CONFIG.REDIS_URL) { CONFIG.REDIS_URL = 'redis://localhost/1'; } +// REDIS_CLUSTER_CONFIGURATION should be parsed when the cluster mode !== none. +if (CONFIG.REDIS_CLUSTER_MODE === 'CLUSTER') { + try { + CONFIG.REDIS_CLUSTER_CONFIGURATION = JSON.parse(CONFIG.REDIS_CLUSTER_CONFIGURATION); + } catch (err) { + throw new Error('TALK_REDIS_CLUSTER_CONFIGURATION is not valid JSON, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes'); + } + + if (!Array.isArray(CONFIG.REDIS_CLUSTER_CONFIGURATION)) { + throw new Error('TALK_REDIS_CLUSTER_MODE is CLUSTER, but the TALK_REDIS_CLUSTER_CONFIGURATION is invalid, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes'); + } + + if (CONFIG.REDIS_CLUSTER_CONFIGURATION.length === 0) { + throw new Error('TALK_REDIS_CLUSTER_CONFIGURATION must have at least one node specified in the cluster, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes'); + } +} + +// Client config is a JSON encoded string, defaulting to `{}`. +CONFIG.REDIS_CLIENT_CONFIG = JSON.parse(CONFIG.REDIS_CLIENT_CONFIG); + //------------------------------------------------------------------------------ // Recaptcha configuration //------------------------------------------------------------------------------ diff --git a/docs/_docs/02-01-configuration.md b/docs/_docs/02-01-configuration.md index 04cabff78..2c7b9bc8a 100644 --- a/docs/_docs/02-01-configuration.md +++ b/docs/_docs/02-01-configuration.md @@ -55,18 +55,21 @@ These are only used during the webpack build. #### Advanced {:.no_toc} -- `TALK_REDIS_RECONNECTION_MAX_ATTEMPTS` (_optional_) - the amount of attempts - that a redis connection will attempt to reconnect before aborting with an - error. (Default `100`) -- `TALK_REDIS_RECONNECTION_MAX_RETRY_TIME` (_optional_) - the time in string - format for the maximum amount of time that a client can be considered - "connecting" before attempts at reconnection are aborted with an error. - (Default `1 min`) +- `TALK_REDIS_CLIENT_CONFIG` (_optional_) - configuration overrides for the + redis client configuration in a JSON encoded string. Configuration is + overridden as the second parameter to the redis client constructor, and is + merged with default configuration. (Default `{}`) - `TALK_REDIS_RECONNECTION_BACKOFF_FACTOR` (_optional_) - the time factor that will be multiplied against the current attempt count inbetween attempts to connect to redis. (Default `500 ms`) - `TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME` (_optional_) - the minimum time used to delay before attempting to reconnect to redis. (Default `1 sec`) +- `TALK_REDIS_CLUSTER_MODE` (_optional_) - the cluster mode of the redis client. + Can be either `NONE` or `CLUSTER`. (Default `NONE`) +- `TALK_REDIS_CLUSTER_CONFIGURATION` (_optional_) - the json serialized form of + the cluster nodes. Only required when `TALK_REDIS_CLUSTER_MODE=CLUSTER`. See + https://github.com/luin/ioredis#cluster for configuration details. + (Default `[]`) ### Server @@ -137,7 +140,7 @@ is not needed in most situations. use to set a cookie containing a JWT that was issued by Talk. (Default `process.env.TALK_JWT_COOKIE_NAME`) - `TALK_JWT_COOKIE_NAMES` (_optional_) - the different cookie names to check for - a JWT token in, seperated by `,`. By default, we always use the + a JWT token in, separated by `,`. By default, we always use the `process.env.TALK_JWT_COOKIE_NAME` and `process.env.TALK_JWT_SIGNING_COOKIE_NAME` for this value. Any additional cookie names specified here will be appended to the list of cookie names to inspect. diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index a1315d847..de3345257 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -182,11 +182,11 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu Comments.parentCountByAssetID.incr(asset_id); } Comments.countByAssetID.incr(asset_id); - - // Publish the newly added comment via the subscription. - pubsub.publish('commentAdded', comment); } + // Publish the newly added comment via the subscription. + pubsub.publish('commentAdded', comment); + return comment; }; diff --git a/graph/setupFunctions.js b/graph/setupFunctions.js index 273aedfa2..42dc15a9f 100644 --- a/graph/setupFunctions.js +++ b/graph/setupFunctions.js @@ -26,10 +26,30 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu commentAdded: (options, args) => ({ commentAdded: { filter: (comment, context) => { + + // Only priviledged users can subscribe to all assets. if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_ADDED))) { return false; } - return !args.asset_id || comment.asset_id === args.asset_id; + + // If user scubsscribes for statuses other than NONE and/or ACCEPTED statuses, it needs + // special priviledges. + if ( + (!args.statuses || args.statuses.some((status) => !['NONE', 'ACCEPTED'].includes(status))) && + (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_ADDED)) + ) { + return false; + } + + if (args.asset_id && comment.asset_id !== args.asset_id) { + return false; + } + + if (args.statuses && !args.statuses.includes(comment.status)) { + return false; + } + + return true; } }, }), diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 70167bb67..ca2a39871 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -1356,7 +1356,8 @@ type Subscription { # Get an update whenever a comment was added. # `asset_id` is required except for users with the `ADMIN` or `MODERATOR` role. - commentAdded(asset_id: ID): Comment + # Non privileged user can only subscribe to 'NONE' and/or 'ACCEPTED' statuses. + commentAdded(asset_id: ID, statuses: [COMMENT_STATUS!] = [NONE, ACCEPTED]): Comment # Get an update whenever a comment was edited. # `asset_id` is required except for users with the `ADMIN` or `MODERATOR` role. diff --git a/package.json b/package.json index e669dad7d..7d6768d6a 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "fs-extra": "^4.0.1", "gql-merge": "^0.0.4", "graphql": "^0.9.1", + "graphql-anywhere": "^3.1.0", "graphql-docs": "^0.2.0", "graphql-errors": "^2.1.0", "graphql-redis-subscriptions": "^1.3.0", @@ -144,12 +145,11 @@ "passport-local": "^1.0.0", "pluralize": "^7.0.0", "postcss-loader": "^1.3.3", - "postcss-modules": "^0.5.2", "postcss-smart-import": "^0.5.1", "precss": "^1.4.0", "prop-types": "^15.5.10", "pym.js": "^1.1.1", - "query-strings": "^0.0.1", + "query-string": "^5.0.0", "react": "^15.4.2", "react-apollo": "^1.4.12", "react-dom": "^15.4.2", @@ -180,7 +180,6 @@ "url-search-params": "^0.9.0", "uuid": "^3.1.0", "webpack": "^2.3.1", - "webpack-sources": "^1.0.1", "yaml-loader": "^0.4.0", "yamljs": "^0.2.10" }, @@ -190,7 +189,6 @@ "chai-http": "^3.0.0", "enzyme": "^2.9.1", "eslint": "^4.5.0", - "eslint-module-utils": "^2.1.1", "eslint-plugin-json": "^1.2.0", "eslint-plugin-mocha": "^4.11.0", "eslint-plugin-promise": "^3.3.1", @@ -200,8 +198,7 @@ "nodemon": "^1.11.0", "pre-git": "^3.15.3", "sinon": "^3.2.1", - "sinon-chai": "^2.13.0", - "supertest": "^2.0.1" + "sinon-chai": "^2.13.0" }, "engines": { "node": "^8" diff --git a/plugin-api/beta/client/components/index.js b/plugin-api/beta/client/components/index.js index 4367d3420..392d923a0 100644 --- a/plugin-api/beta/client/components/index.js +++ b/plugin-api/beta/client/components/index.js @@ -3,3 +3,5 @@ export {default as ClickOutside} from 'coral-framework/components/ClickOutside'; export {default as IfSlotIsEmpty} from 'coral-framework/components/IfSlotIsEmpty'; export {default as IfSlotIsNotEmpty} from 'coral-framework/components/IfSlotIsNotEmpty'; export {default as CommentAuthorName} from 'coral-framework/components/CommentAuthorName'; +export {default as CommentTimestamp} from 'coral-framework/components/CommentTimestamp'; +export {default as CommentContent} from 'coral-framework/components/CommentContent'; diff --git a/plugins/talk-plugin-auth/client/components/CreateUsernameDialog.js b/plugins/talk-plugin-auth/client/components/CreateUsernameDialog.js index 4b0babed6..5c289c1c7 100644 --- a/plugins/talk-plugin-auth/client/components/CreateUsernameDialog.js +++ b/plugins/talk-plugin-auth/client/components/CreateUsernameDialog.js @@ -31,7 +31,7 @@ const CreateUsernameDialog = ({

    diff --git a/plugins/talk-plugin-auth/client/components/FakeComment.js b/plugins/talk-plugin-auth/client/components/FakeComment.js index 6997ca984..e843a0cbf 100644 --- a/plugins/talk-plugin-auth/client/components/FakeComment.js +++ b/plugins/talk-plugin-auth/client/components/FakeComment.js @@ -1,16 +1,16 @@ import React from 'react'; import t from 'coral-framework/services/i18n'; import {ReplyButton} from 'talk-plugin-replies'; -import PubDate from 'talk-plugin-pubdate/PubDate'; import styles from './FakeComment.css'; import {Icon} from 'plugin-api/beta/client/components/ui'; +import {CommentTimestamp} from 'plugin-api/beta/client/components'; export const FakeComment = ({username, created_at, body}) => (

    {username} - +
    {body}
    diff --git a/plugins/talk-plugin-comment-content/client/components/CommentContent.js b/plugins/talk-plugin-comment-content/client/components/CommentContent.js index cc42f1b8d..8819d63ce 100644 --- a/plugins/talk-plugin-comment-content/client/components/CommentContent.js +++ b/plugins/talk-plugin-comment-content/client/components/CommentContent.js @@ -5,20 +5,20 @@ const name = 'talk-plugin-comment-content'; const CommentContent = ({comment}) => { const textbreaks = comment.body.split('\n'); - return
    + return { textbreaks.map((line, i) => { return ( - {line} + {line.trim()} -
    + {i !== textbreaks.length - 1 &&
    }
    ); }) } -
    ; + ; }; export default CommentContent; diff --git a/plugins/talk-plugin-featured-comments/client/components/Comment.css b/plugins/talk-plugin-featured-comments/client/components/Comment.css index b800401e3..9481b4396 100644 --- a/plugins/talk-plugin-featured-comments/client/components/Comment.css +++ b/plugins/talk-plugin-featured-comments/client/components/Comment.css @@ -64,4 +64,8 @@ .actionsContainer { text-align: right; -} \ No newline at end of file +} + +.timestamp { + padding-left: 6px; +} diff --git a/plugins/talk-plugin-featured-comments/client/components/Comment.js b/plugins/talk-plugin-featured-comments/client/components/Comment.js index 779844177..8396e27fb 100644 --- a/plugins/talk-plugin-featured-comments/client/components/Comment.js +++ b/plugins/talk-plugin-featured-comments/client/components/Comment.js @@ -1,8 +1,8 @@ import React from 'react'; import cn from 'classnames'; import styles from './Comment.css'; -import {t, timeago} from 'plugin-api/beta/client/services'; -import {Slot, CommentAuthorName} from 'plugin-api/beta/client/components'; +import {t} from 'plugin-api/beta/client/services'; +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'; @@ -15,12 +15,18 @@ class Comment extends React.Component { render() { const {comment, asset, root, data} = this.props; + const queryData = {comment, asset, root}; return (
    -
    - {comment.body} -
    +
    @@ -28,14 +34,20 @@ class Comment extends React.Component { className={cn(styles.username, `${pluginName}-comment-username`)} fill="commentAuthorName" defaultComponent={CommentAuthorName} - queryData={{comment, asset, root}} + queryData={queryData} data={data} inline /> - - ,{' '}{timeago(comment.created_at)} - +