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 ( - - ); - } -} 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..f7aeac4d8 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/Comment.css @@ -0,0 +1,179 @@ +@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; + text-align: right; + display: flex; + align-items: center; + + 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/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.