diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index d2b8f1a3f..9a0452ea1 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -11,6 +11,7 @@ import { HIDE_BANUSER_DIALOG, SHOW_REJECT_USERNAME_DIALOG, HIDE_REJECT_USERNAME_DIALOG, + SET_INDICATOR_TRACK, } from '../constants/community'; import t from 'coral-framework/services/i18n'; @@ -68,3 +69,9 @@ export const showRejectUsernameDialog = user => ({ export const hideRejectUsernameDialog = () => ({ type: HIDE_REJECT_USERNAME_DIALOG, }); + +// Enable or disable the activity indicator subscriptions. +export const setIndicatorTrack = track => ({ + type: SET_INDICATOR_TRACK, + track, +}); diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index cd09b04b2..7899b7685 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -31,10 +31,16 @@ export const storySearchChange = value => ({ }); export const clearState = () => ({ - type: actions.MODERATION_CLEAR_STATE, + type: actions.CLEAR_STATE, }); export const selectCommentId = id => ({ - type: actions.MODERATION_SELECT_COMMENT, + type: actions.SELECT_COMMENT, id, }); + +// Enable or disable the activity indicator subscriptions. +export const setIndicatorTrack = track => ({ + type: actions.SET_INDICATOR_TRACK, + track, +}); diff --git a/client/coral-admin/src/components/Header.js b/client/coral-admin/src/components/Header.js index e6bd73f52..6b2274d27 100644 --- a/client/coral-admin/src/components/Header.js +++ b/client/coral-admin/src/components/Header.js @@ -15,6 +15,7 @@ const CoralHeader = ({ showShortcuts = () => {}, auth, root, + data, }) => { return (
@@ -31,7 +32,7 @@ const CoralHeader = ({ activeClassName={styles.active} > {t('configure.moderate')} - + )} {t('configure.community')} - + {can(auth.user, 'UPDATE_CONFIG') && ( @@ -119,6 +120,7 @@ CoralHeader.propTypes = { showShortcuts: PropTypes.func, handleLogout: PropTypes.func.isRequired, root: PropTypes.object.isRequired, + data: PropTypes.object.isRequired, }; export default CoralHeader; diff --git a/client/coral-admin/src/constants/community.js b/client/coral-admin/src/constants/community.js index a9cc2f1e3..1ffe3f8d6 100644 --- a/client/coral-admin/src/constants/community.js +++ b/client/coral-admin/src/constants/community.js @@ -18,3 +18,5 @@ export const SHOW_REJECT_USERNAME_DIALOG = `${prefix}_SHOW_REJECT_USERNAME_DIALO export const HIDE_REJECT_USERNAME_DIALOG = `${prefix}_HIDE_REJECT_USERNAME_DIALOG`; export const SET_SEARCH_VALUE = `${prefix}_SET_SEARCH_VALUE`; + +export const SET_INDICATOR_TRACK = `${prefix}_SET_INDICATOR_TRACK`; diff --git a/client/coral-admin/src/constants/moderation.js b/client/coral-admin/src/constants/moderation.js index 8eb939d76..c233692dc 100644 --- a/client/coral-admin/src/constants/moderation.js +++ b/client/coral-admin/src/constants/moderation.js @@ -1,9 +1,12 @@ -export const TOGGLE_MODAL = 'TOGGLE_MODAL'; -export const SINGLE_VIEW = 'SINGLE_VIEW'; -export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE'; -export const SET_SORT_ORDER = 'MODERATION_SET_SORT_ORDER'; -export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH'; -export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH'; -export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE'; -export const MODERATION_CLEAR_STATE = 'MODERATION_CLEAR_STATE'; -export const MODERATION_SELECT_COMMENT = 'MODERATION_SELECT_COMMENT'; +const prefix = `MODERATION`; + +export const TOGGLE_MODAL = `${prefix}_TOGGLE_MODAL`; +export const SINGLE_VIEW = `${prefix}_SINGLE_VIEW`; +export const HIDE_SHORTCUTS_NOTE = `${prefix}_HIDE_SHORTCUTS_NOTE`; +export const SET_SORT_ORDER = `${prefix}_SET_SORT_ORDER`; +export const SHOW_STORY_SEARCH = `${prefix}_SHOW_STORY_SEARCH`; +export const HIDE_STORY_SEARCH = `${prefix}_HIDE_STORY_SEARCH`; +export const STORY_SEARCH_CHANGE_VALUE = `${prefix}_STORY_SEARCH_CHANGE_VALUE`; +export const CLEAR_STATE = `${prefix}_CLEAR_STATE`; +export const SELECT_COMMENT = `${prefix}_SELECT_COMMENT`; +export const SET_INDICATOR_TRACK = `${prefix}_SET_INDICATOR_TRACK`; diff --git a/client/coral-admin/src/containers/CommentLabels.js b/client/coral-admin/src/containers/CommentLabels.js index 4cee8b1cc..ee65eb636 100644 --- a/client/coral-admin/src/containers/CommentLabels.js +++ b/client/coral-admin/src/containers/CommentLabels.js @@ -25,6 +25,7 @@ export default withFragments({ id role } + created_at } ${getSlotFragmentSpreads(slots, 'comment')} } diff --git a/client/coral-admin/src/containers/Header.js b/client/coral-admin/src/containers/Header.js index dae096517..d3be66764 100644 --- a/client/coral-admin/src/containers/Header.js +++ b/client/coral-admin/src/containers/Header.js @@ -7,7 +7,7 @@ import { getDefinitionName } from 'coral-framework/utils'; export default withQuery( gql` - query TalkAdmin_Header { + query TalkAdmin_Header($nullID: ID) { ...${getDefinitionName(ModerationIndicator.fragments.root)} ...${getDefinitionName(CommunityIndicator.fragments.root)} } @@ -16,7 +16,7 @@ export default withQuery( `, { options: { - pollInterval: 10000, + variables: { nullID: null }, }, } )(Header); diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js index 3a24702cd..95595c265 100644 --- a/client/coral-admin/src/graphql/index.js +++ b/client/coral-admin/src/graphql/index.js @@ -173,13 +173,17 @@ export default { }, updateQueries: { TalkAdmin_Community_FlaggedAccounts: (prev, { mutationResult }) => { + const decrement = { + flaggedUsernamesCount: { $apply: count => count - 1 }, + }; + // Remove from list after the mutation was "really" completed. if (get(mutationResult, 'data.approveUsername.isOptimistic')) { - return prev; + return update(prev, decrement); } const updated = update(prev, { - flaggedUsernamesCount: { $apply: count => count - 1 }, + ...decrement, flaggedUsers: { nodes: { $apply: nodes => nodes.filter(node => node.id !== id) }, }, @@ -227,13 +231,17 @@ export default { }, updateQueries: { TalkAdmin_Community_FlaggedAccounts: (prev, { mutationResult }) => { + const decrement = { + flaggedUsernamesCount: { $apply: count => count - 1 }, + }; + // Remove from list after the mutation was "really" completed. if (get(mutationResult, 'data.rejectUsername.isOptimistic')) { - return prev; + return update(prev, decrement); } const updated = update(prev, { - flaggedUsernamesCount: { $apply: count => count - 1 }, + ...decrement, flaggedUsers: { nodes: { $apply: nodes => nodes.filter(node => node.id !== id), diff --git a/client/coral-admin/src/reducers/community.js b/client/coral-admin/src/reducers/community.js index 9fe17ad8f..68e471935 100644 --- a/client/coral-admin/src/reducers/community.js +++ b/client/coral-admin/src/reducers/community.js @@ -9,6 +9,7 @@ import { HIDE_BANUSER_DIALOG, SHOW_REJECT_USERNAME_DIALOG, HIDE_REJECT_USERNAME_DIALOG, + SET_INDICATOR_TRACK, } from '../constants/community'; const initialState = { @@ -24,6 +25,10 @@ const initialState = { user: {}, banDialog: false, rejectUsernameDialog: false, + // If true the activity indicator will track flagged account changes + // in order to determine the current queue count. Set this to false + // if the queue count is determined by other means. + indicatorTrack: true, }; export default function community(state = initialState, action) { @@ -91,6 +96,11 @@ export default function community(state = initialState, action) { ...state, searchValue: action.value, }; + case SET_INDICATOR_TRACK: + return { + ...state, + indicatorTrack: action.track, + }; default: return state; } diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index e2c6a32ff..3dee12785 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -8,14 +8,19 @@ const initialState = { shortcutsNoteVisible: 'show', sortOrder: 'DESC', selectedCommentId: '', + // If true the activity indicator will turn on subscriptions + // in order to determine queue counts. Set this to false + // if the queue count is determined by other means. + indicatorTrack: true, }; export default function moderation(state = initialState, action) { switch (action.type) { - case actions.MODERATION_CLEAR_STATE: + case actions.CLEAR_STATE: return { ...initialState, shortcutsNoteVisible: state.shortcutsNoteVisible, + indicatorTrack: state.indicatorTrack, }; case actions.TOGGLE_MODAL: return { @@ -52,11 +57,16 @@ export default function moderation(state = initialState, action) { ...state, sortOrder: action.order, }; - case actions.MODERATION_SELECT_COMMENT: + case actions.SELECT_COMMENT: return { ...state, selectedCommentId: action.id, }; + case actions.SET_INDICATOR_TRACK: + return { + ...state, + indicatorTrack: action.track, + }; default: return state; } diff --git a/client/coral-admin/src/routes/Community/containers/FlaggedAccounts.js b/client/coral-admin/src/routes/Community/containers/FlaggedAccounts.js index 1d7e089e2..60ae4ee99 100644 --- a/client/coral-admin/src/routes/Community/containers/FlaggedAccounts.js +++ b/client/coral-admin/src/routes/Community/containers/FlaggedAccounts.js @@ -6,12 +6,15 @@ import withQuery from 'coral-framework/hocs/withQuery'; import { Spinner } from 'coral-ui'; import PropTypes from 'prop-types'; import { withApproveUsername } from 'coral-framework/graphql/mutations'; -import { showRejectUsernameDialog } from '../../../actions/community'; +import { + showRejectUsernameDialog, + setIndicatorTrack, +} from '../../../actions/community'; import { viewUserDetail } from '../../../actions/userDetail'; import { getDefinitionName } from 'coral-framework/utils'; import { appendNewNodes } from 'plugin-api/beta/client/utils'; import update from 'immutability-helper'; -import { handleFlaggedUsernameChange } from '../graphql'; +import { handleFlaggedAccountsChange } from '../graphql'; import { notify } from 'coral-framework/actions/notification'; import { isFlaggedUserDangling } from '../utils'; import t from 'coral-framework/services/i18n'; @@ -31,10 +34,6 @@ function whoFlagged(user) { class FlaggedAccountsContainer extends Component { subscriptions = []; - constructor(props) { - super(props); - } - getCountWithoutDangling() { return this.props.root.flaggedUsers.nodes.filter( node => !isFlaggedUserDangling(node) @@ -49,7 +48,7 @@ class FlaggedAccountsContainer extends Component { prev, { subscriptionData: { data: { usernameFlagged: user } } } ) => { - return handleFlaggedUsernameChange(prev, user, () => { + return handleFlaggedAccountsChange(prev, user, () => { const msg = t( 'flagged_usernames.notify_flagged', whoFlagged(user), @@ -65,7 +64,7 @@ class FlaggedAccountsContainer extends Component { prev, { subscriptionData: { data: { usernameApproved: user } } } ) => { - return handleFlaggedUsernameChange(prev, user, () => { + return handleFlaggedAccountsChange(prev, user, () => { const msg = t( 'flagged_usernames.notify_approved', whoChangedTheStatus(user.state.status.username), @@ -81,7 +80,7 @@ class FlaggedAccountsContainer extends Component { prev, { subscriptionData: { data: { usernameRejected: user } } } ) => { - return handleFlaggedUsernameChange(prev, user, () => { + return handleFlaggedAccountsChange(prev, user, () => { const msg = t( 'flagged_usernames.notify_rejected', whoChangedTheStatus(user.state.status.username), @@ -101,7 +100,7 @@ class FlaggedAccountsContainer extends Component { }, } ) => { - return handleFlaggedUsernameChange(prev, user, () => { + return handleFlaggedAccountsChange(prev, user, () => { const msg = t( 'flagged_usernames.notify_changed', previousUsername, @@ -124,10 +123,14 @@ class FlaggedAccountsContainer extends Component { } componentWillMount() { + // Stop activity indicator tracking, as we'll handle it here. + this.props.setIndicatorTrack(false); this.subscribeToUpdates(); } componentWillUnmount() { + // Restart activity indicator tracking. + this.props.setIndicatorTrack(true); this.unsubscribe(); } @@ -196,6 +199,7 @@ FlaggedAccountsContainer.propTypes = { approveUsername: PropTypes.func, data: PropTypes.object, root: PropTypes.object, + setIndicatorTrack: PropTypes.func, }; const LOAD_MORE_QUERY = gql` @@ -287,6 +291,7 @@ const mapDispatchToProps = dispatch => showRejectUsernameDialog, viewUserDetail, notify, + setIndicatorTrack, }, dispatch ); diff --git a/client/coral-admin/src/routes/Community/containers/Indicator.js b/client/coral-admin/src/routes/Community/containers/Indicator.js index 04a351fb6..9e5be831a 100644 --- a/client/coral-admin/src/routes/Community/containers/Indicator.js +++ b/client/coral-admin/src/routes/Community/containers/Indicator.js @@ -1,14 +1,152 @@ +import React, { Component } from 'react'; import { compose, gql } from 'react-apollo'; import Indicator from '../../../components/Indicator'; import { withFragments } from 'plugin-api/beta/client/hocs'; -import { branch, renderNothing } from 'recompose'; +import { handleIndicatorChange } from '../graphql'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; -const hideIfNoData = hasNoData => branch(hasNoData, renderNothing); +class IndicatorContainer extends Component { + subscriptions = []; + + subscribeToUpdates() { + const parameters = [ + { + document: USERNAME_FLAGGED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { usernameFlagged: user } } } + ) => { + return handleIndicatorChange(prev, user); + }, + }, + { + document: USERNAME_APPROVED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { usernameApproved: user } } } + ) => { + return handleIndicatorChange(prev, user); + }, + }, + { + document: USERNAME_REJECTED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { usernameRejected: user } } } + ) => { + return handleIndicatorChange(prev, user); + }, + }, + { + document: USERNAME_CHANGED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { usernameChanged: { user } } } } + ) => { + return handleIndicatorChange(prev, user); + }, + }, + ]; + + this.subscriptions = parameters.map(param => + this.props.data.subscribeToMore(param) + ); + } + + unsubscribe() { + this.subscriptions.forEach(unsubscribe => unsubscribe()); + this.subscriptions = []; + } + + componentWillMount() { + if (this.props.track) { + this.subscribeToUpdates(); + } + } + + componentWillUnmount() { + this.unsubscribe(); + } + + componentWillReceiveProps(nextProps) { + if (!this.props.track && nextProps.track) { + this.subscribeToUpdates(); + } + if (this.props.track && !nextProps.track) { + this.unsubscribe(); + } + } + + render() { + if (!this.props.root || !this.props.root.flaggedUsernamesCount) { + return null; + } + + return ; + } +} + +IndicatorContainer.propTypes = { + data: PropTypes.object, + root: PropTypes.object, + track: PropTypes.bool, +}; + +const fields = ` + state { + status { + username { + status + } + } + } +`; + +const USERNAME_FLAGGED_SUBSCRIPTION = gql` + subscription TalkAdmin_CommunityIndicator_UsernameFlagged { + usernameFlagged { + ${fields} + } + } +`; + +const USERNAME_APPROVED_SUBSCRIPTION = gql` + subscription TalkAdmin_ComunityIndicator_UsernameApproved { + usernameApproved { + ${fields} + } + } +`; + +const USERNAME_REJECTED_SUBSCRIPTION = gql` + subscription TalkAdmin_CommunityIndicator_UsernameRejected { + usernameRejected { + ${fields} + } + } +`; + +const USERNAME_CHANGED_SUBSCRIPTION = gql` + subscription TalkAdmin_ComunityIndicator_UsernameChanged { + usernameChanged { + previousUsername + user { + ${fields} + } + } + } +`; + +const mapStateToProps = state => ({ + track: state.community.indicatorTrack, +}); const enhance = compose( + connect(mapStateToProps), withFragments({ root: gql` - fragment TalkAdmin_Community_Indicator_root on RootQuery { + fragment TalkAdmin_CommunityIndicator_root on RootQuery { flaggedUsernamesCount: userCount( query: { action_type: FLAG @@ -17,8 +155,7 @@ const enhance = compose( ) } `, - }), - hideIfNoData(props => !props.root.flaggedUsernamesCount) + }) ); -export default enhance(Indicator); +export default enhance(IndicatorContainer); diff --git a/client/coral-admin/src/routes/Community/graphql.js b/client/coral-admin/src/routes/Community/graphql.js index af10c8e86..8d6941c41 100644 --- a/client/coral-admin/src/routes/Community/graphql.js +++ b/client/coral-admin/src/routes/Community/graphql.js @@ -49,13 +49,13 @@ function decrementFlaggedUserCount(root) { } /** - * Assimilate flagged user changes into current store. + * Assimilate flagged acount changes into current store. * @param {Object} root current state of the store * @param {Object} user user that was changed * @param {function} notify callback to show notification * @return {Object} next state of the store */ -export function handleFlaggedUsernameChange(root, user, notify) { +export function handleFlaggedAccountsChange(root, user, notify) { if (user.state.status.username.status !== 'SET') { // Check if change came from current user, if so ignore it. const lastChange = @@ -87,7 +87,7 @@ export function handleFlaggedUsernameChange(root, user, notify) { break; case 'APPROVED': case 'REJECTED': - return root; + return decrementFlaggedUserCount(root); default: } } @@ -105,3 +105,21 @@ export function handleFlaggedUsernameChange(root, user, notify) { } } } + +/** + * Track indicator status + * @param {Object} root current state of the store + * @param {Object} user user that was changed + * @return {Object} next state of the store + */ +export function handleIndicatorChange(root, user) { + switch (user.state.status.username.status) { + case 'SET': + case 'CHANGED': + return incrementFlaggedUserCount(root); + case 'APPROVED': + case 'REJECTED': + return decrementFlaggedUserCount(root); + default: + } +} diff --git a/client/coral-admin/src/routes/Moderation/containers/Indicator.js b/client/coral-admin/src/routes/Moderation/containers/Indicator.js index bd33a58ef..1475b6e2d 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Indicator.js +++ b/client/coral-admin/src/routes/Moderation/containers/Indicator.js @@ -1,25 +1,207 @@ +import React, { Component } from 'react'; import { compose, gql } from 'react-apollo'; import Indicator from '../../../components/Indicator'; import { withFragments } from 'plugin-api/beta/client/hocs'; -import { branch, renderNothing } from 'recompose'; +import { handleIndicatorChange, subscriptionFields } from '../graphql'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import withQueueConfig from '../hoc/withQueueConfig'; +import baseQueueConfig from '../queueConfig'; +import Slot from 'coral-framework/components/Slot'; -const hideIfNoData = hasNoData => branch(hasNoData, renderNothing); +class IndicatorContainer extends Component { + subscriptions = []; + + handleCommentChange = (root, comment) => { + return handleIndicatorChange(root, comment, this.props.queueConfig); + }; + + subscribeToUpdates() { + const parameters = [ + { + document: COMMENT_ADDED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentAdded: comment } } } + ) => { + return this.handleCommentChange(prev, comment); + }, + }, + { + document: COMMENT_FLAGGED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentFlagged: comment } } } + ) => { + return this.handleCommentChange(prev, comment); + }, + }, + { + document: COMMENT_EDITED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentEdited: comment } } } + ) => { + return this.handleCommentChange(prev, comment); + }, + }, + { + document: COMMENT_ACCEPTED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentAccepted: comment } } } + ) => { + return this.handleCommentChange(prev, comment); + }, + }, + { + document: COMMENT_REJECTED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentRejected: comment } } } + ) => { + return this.handleCommentChange(prev, comment); + }, + }, + { + document: COMMENT_RESET_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentReset: comment } } } + ) => { + return this.handleCommentChange(prev, comment); + }, + }, + ]; + + this.subscriptions = parameters.map(param => + this.props.data.subscribeToMore(param) + ); + } + + unsubscribe() { + this.subscriptions.forEach(unsubscribe => unsubscribe()); + this.subscriptions = []; + } + + componentWillMount() { + if (this.props.track) { + this.subscribeToUpdates(); + } + } + + componentWillUnmount() { + this.unsubscribe(); + } + + componentWillReceiveProps(nextProps) { + if (!this.props.track && nextProps.track) { + this.subscribeToUpdates(); + } + if (this.props.track && !nextProps.track) { + this.unsubscribe(); + } + } + + render() { + if ( + !this.props.root || + (!this.props.root.premodCount && !this.props.root.reportedCount) + ) { + return null; + } + + return ( + + + + + ); + } +} + +IndicatorContainer.propTypes = { + data: PropTypes.object, + root: PropTypes.object, + track: PropTypes.bool, + queueConfig: PropTypes.object, +}; + +const COMMENT_ADDED_SUBSCRIPTION = gql` + subscription TalkAdmin_ModerationIndicator_CommentAdded { + commentAdded(statuses: null) { + ${subscriptionFields} + } + } +`; + +const COMMENT_FLAGGED_SUBSCRIPTION = gql` + subscription TalkAdmin_ModerationIndicator_CommentFlagged { + commentFlagged { + ${subscriptionFields} + } + } +`; + +const COMMENT_EDITED_SUBSCRIPTION = gql` + subscription TalkAdmin_ModerationIndicator_CommentEdited { + commentEdited { + ${subscriptionFields} + } + } +`; + +const COMMENT_ACCEPTED_SUBSCRIPTION = gql` + subscription TalkAdmin_ModerationIndicator_CommentAccepted { + commentAccepted { + ${subscriptionFields} + } + } +`; + +const COMMENT_REJECTED_SUBSCRIPTION = gql` + subscription TalkAdmin_ModerationIndicator_CommentRejected { + commentRejected { + ${subscriptionFields} + } + } +`; + +const COMMENT_RESET_SUBSCRIPTION = gql` + subscription TalkAdmin_ModerationIndicator_CommentReset { + commentReset { + ${subscriptionFields} + } + } +`; + +const mapStateToProps = state => ({ + track: state.moderation.indicatorTrack, +}); const enhance = compose( + connect(mapStateToProps), withFragments({ root: gql` fragment TalkAdmin_Moderation_Indicator_root on RootQuery { - premodCount: commentCount(query: { statuses: [PREMOD] }) + premodCount: commentCount( + query: { statuses: [PREMOD], asset_id: $nullID } + ) reportedCount: commentCount( query: { statuses: [NONE, PREMOD, SYSTEM_WITHHELD] action_type: FLAG + asset_id: $nullID } ) } `, }), - hideIfNoData(props => !props.root.premodCount && !props.root.reportedCount) + withQueueConfig(baseQueueConfig) ); -export default enhance(Indicator); +export default enhance(IndicatorContainer); diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index e0e3f14fe..c0e2e60a6 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -15,6 +15,7 @@ import { handleCommentChange, commentBelongToQueue, cleanUpQueue, + subscriptionFields, } from '../graphql'; import { viewUserDetail } from '../../../actions/userDetail'; @@ -27,6 +28,7 @@ import { storySearchChange, clearState, selectCommentId, + setIndicatorTrack, } from 'actions/moderation'; import withQueueConfig from '../hoc/withQueueConfig'; import { notify } from 'coral-framework/actions/notification'; @@ -198,21 +200,42 @@ class ModerationContainer extends Component { } componentWillMount() { + if (!this.props.data.variables.asset_id) { + // Stop activity indicator tracking, as we'll handle it here. + this.props.setIndicatorTrack(false); + } this.props.clearState(); this.subscribeToUpdates(); } componentWillUnmount() { + if (!this.props.data.variables.asset_id) { + // Restart activity indicator tracking. + this.props.setIndicatorTrack(true); + } this.unsubscribe(); } componentWillReceiveProps(nextProps) { + const currentAssetId = this.props.data.variables.asset_id; + const nextAssetId = nextProps.data.variables.asset_id; + // Resubscribe when we change between assets. - if ( - this.props.data.variables.asset_id !== nextProps.data.variables.asset_id - ) { + if (currentAssetId !== nextAssetId) { this.resubscribe(nextProps.data.variables); } + + // We are only subscribing to a specific asset_id, so activity indicator + // needs to do its own tracking. + if (!currentAssetId && nextAssetId) { + this.props.setIndicatorTrack(true); + } + + // We are subscribing to all comment changes, and as such there is no + // need for the activity indicator to do the same. + if (currentAssetId && !nextAssetId) { + this.props.setIndicatorTrack(false); + } } cleanUpQueue = queue => { @@ -316,10 +339,12 @@ 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)} + ${subscriptionFields} } } ${Comment.fragments.comment} @@ -329,6 +354,7 @@ const COMMENT_EDITED_SUBSCRIPTION = gql` subscription CommentEdited($asset_id: ID){ commentEdited(asset_id: $asset_id){ ...${getDefinitionName(Comment.fragments.comment)} + ${subscriptionFields} } } ${Comment.fragments.comment} @@ -338,6 +364,7 @@ const COMMENT_FLAGGED_SUBSCRIPTION = gql` subscription CommentFlagged($asset_id: ID){ commentFlagged(asset_id: $asset_id){ ...${getDefinitionName(Comment.fragments.comment)} + ${subscriptionFields} } } ${Comment.fragments.comment} @@ -347,14 +374,7 @@ const COMMENT_ACCEPTED_SUBSCRIPTION = gql` subscription CommentAccepted($asset_id: ID){ commentAccepted(asset_id: $asset_id){ ...${getDefinitionName(Comment.fragments.comment)} - status_history { - type - created_at - assigned_by { - id - username - } - } + ${subscriptionFields} } } ${Comment.fragments.comment} @@ -364,14 +384,7 @@ const COMMENT_REJECTED_SUBSCRIPTION = gql` subscription CommentRejected($asset_id: ID){ commentRejected(asset_id: $asset_id){ ...${getDefinitionName(Comment.fragments.comment)} - status_history { - type - created_at - assigned_by { - id - username - } - } + ${subscriptionFields} } } ${Comment.fragments.comment} @@ -381,14 +394,7 @@ const COMMENT_RESET_SUBSCRIPTION = gql` subscription CommentReset($asset_id: ID){ commentReset(asset_id: $asset_id){ ...${getDefinitionName(Comment.fragments.comment)} - status_history { - type - created_at - assigned_by { - id - username - } - } + ${subscriptionFields} } } ${Comment.fragments.comment} @@ -525,6 +531,7 @@ const mapDispatchToProps = dispatch => ({ clearState, notify, selectCommentId, + setIndicatorTrack, }, dispatch ), diff --git a/client/coral-admin/src/routes/Moderation/graphql.js b/client/coral-admin/src/routes/Moderation/graphql.js index 481c42ab9..c542b8f84 100644 --- a/client/coral-admin/src/routes/Moderation/graphql.js +++ b/client/coral-admin/src/routes/Moderation/graphql.js @@ -91,6 +91,76 @@ function getCommentQueues(comment, queueConfig) { return queues; } +function getOlderDate(a, b) { + if (a) { + a = new Date(a); + } + if (b) { + b = new Date(b); + } + + if (!b) { + return a; + } + + if (!a) { + return b; + } + return a < b ? b : a; +} + +function determineLatestChange(comment) { + let lc = null; + + comment.status_history.forEach(item => { + lc = getOlderDate(lc, item.created_at); + }); + + comment.actions.forEach(item => { + lc = getOlderDate(lc, item.created_at); + }); + + return lc; +} + +function reconstructPreviousCommentState(comment) { + const history = comment.status_history; + const actions = comment.actions; + const lastChangeDate = determineLatestChange(comment); + const previousComment = { + ...comment, + status_history: history.filter( + item => new Date(item.created_at) < lastChangeDate + ), + actions: actions.filter(item => new Date(item.created_at) < lastChangeDate), + }; + + // Comment did not exist previously. + if (!previousComment.status_history.length) { + return null; + } + + previousComment.status = + previousComment.status_history[ + previousComment.status_history.length - 1 + ].type; + + return previousComment; +} + +/** + * getPreviousCommentQueues determines queues that this comment previously belonged to. + */ +function getPreviousCommentQueues(comment, queueConfig) { + const previousCommentState = reconstructPreviousCommentState(comment); + + if (!previousCommentState) { + return []; + } + + return getCommentQueues(previousCommentState, queueConfig); +} + /** * Return whether or not the comment belongs to the queue. */ @@ -203,17 +273,7 @@ export function handleCommentChange( let next = root; // Queues that this comment previously belonged to. - const prevQueues = - comment.status_history.length <= 1 - ? [] - : getCommentQueues( - { - ...comment, - status: - comment.status_history[comment.status_history.length - 2].type, - }, - queueConfig - ); + const prevQueues = getPreviousCommentQueues(comment, queueConfig); // Queues that this comment needs to be placed. const nextQueues = getCommentQueues(comment, queueConfig); @@ -291,3 +351,49 @@ export function handleCommentChange( }); return next; } + +const indicatorQueues = ['premod', 'reported']; + +/** + * Track indicator status + * @param {Object} root current state of the store + * @param {Object} comment comment that was changed + * @return {Object} next state of the store + */ +export function handleIndicatorChange(root, comment, queueConfig) { + let next = root; + + // Queues that this comment previously belonged to. + const prevQueues = getPreviousCommentQueues(comment, queueConfig); + + // Queues that this comment needs to be placed. + const nextQueues = getCommentQueues(comment, queueConfig); + + for (const queue of indicatorQueues) { + if (prevQueues.indexOf(queue) === -1 && nextQueues.indexOf(queue) >= 0) { + next = increaseCommentCount(next, queue); + } + if (prevQueues.indexOf(queue) >= 0 && nextQueues.indexOf(queue) === -1) { + next = decreaseCommentCount(next, queue); + } + } + + return next; +} + +export const subscriptionFields = ` + status + actions { + __typename + created_at + } + status_history { + type + assigned_by { + id + } + created_at + } + updated_at + created_at +`; diff --git a/graph/resolvers/util.js b/graph/resolvers/util.js index ff9ab9c95..992148b4f 100644 --- a/graph/resolvers/util.js +++ b/graph/resolvers/util.js @@ -50,8 +50,12 @@ const decorateWithPermissionCheck = (typeResolver, protect) => { */ const decorateUserField = (typeResolver, field) => { // The default resolver for the user decorator is loading the user by id. - let fieldResolver = (obj, args, ctx) => - ctx.loaders.Users.getByID.load(obj[field]); + let fieldResolver = (obj, args, ctx) => { + if (!obj[field]) { + return null; + } + return ctx.loaders.Users.getByID.load(obj[field]); + }; // The resolver can be overridden however. This decorator will simply wrap the // field with a permission check. diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 79d1c5b0f..550856082 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -502,6 +502,9 @@ type Comment { # The time when the comment was created created_at: Date! + # The time when the comment was updated. + updated_at: Date + # describes how the comment can be edited editing: EditInfo diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModIndicatorSubscription.js b/plugins/talk-plugin-featured-comments/client/containers/ModIndicatorSubscription.js new file mode 100644 index 000000000..86ae6bb2c --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/containers/ModIndicatorSubscription.js @@ -0,0 +1,63 @@ +import React from 'react'; +import { gql } from 'react-apollo'; +import { subscriptionFields } from 'coral-admin/src/routes/Moderation/graphql'; + +class ModIndicatorSubscription extends React.Component { + subscriptions = null; + + componentWillMount() { + const configs = [ + { + document: COMMENT_FEATURED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentFeatured: { comment } } } } + ) => { + return this.props.handleCommentChange(prev, comment); + }, + }, + { + document: COMMENT_UNFEATURED_SUBSCRIPTION, + updateQuery: ( + prev, + { subscriptionData: { data: { commentUnfeatured: { comment } } } } + ) => { + return this.props.handleCommentChange(prev, comment); + }, + }, + ]; + this.subscriptions = configs.map(config => + this.props.data.subscribeToMore(config) + ); + } + + componentWillUnmount() { + this.subscriptions.forEach(unsubscribe => unsubscribe()); + } + + render() { + return null; + } +} + +const COMMENT_FEATURED_SUBSCRIPTION = gql` + subscription TalkFeaturedComments_Indicator_CommentFeatured { + commentFeatured { + comment { + ${subscriptionFields} + } + } + } +`; + +const COMMENT_UNFEATURED_SUBSCRIPTION = gql` + subscription TalkFeaturedComments_Indicator_CommentUnfeatured { + commentUnfeatured { + comment { + ${subscriptionFields} + } + } + } +`; + +export default ModIndicatorSubscription; diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js b/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js index 3e7eb3aaf..6d0fdd811 100644 --- a/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js +++ b/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js @@ -5,6 +5,7 @@ import Comment from 'coral-admin/src/routes/Moderation/containers/Comment'; import { getDefinitionName } from 'coral-framework/utils'; import truncate from 'lodash/truncate'; import t from 'coral-framework/services/i18n'; +import { subscriptionFields } from 'coral-admin/src/routes/Moderation/graphql'; function prepareNotificationText(text) { return truncate(text, { length: 50 }).replace('\n', ' '); @@ -79,14 +80,7 @@ const COMMENT_FEATURED_SUBSCRIPTION = gql` commentFeatured(asset_id: $assetId) { comment { ...${getDefinitionName(Comment.fragments.comment)} - status_history { - type - created_at - assigned_by { - id - username - } - } + ${subscriptionFields} } user { id @@ -102,6 +96,7 @@ const COMMENT_UNFEATURED_SUBSCRIPTION = gql` commentUnfeatured(asset_id: $assetId){ comment { ...${getDefinitionName(Comment.fragments.comment)} + ${subscriptionFields} } user { id diff --git a/plugins/talk-plugin-featured-comments/client/index.js b/plugins/talk-plugin-featured-comments/client/index.js index 1f0bac704..2572a54f4 100644 --- a/plugins/talk-plugin-featured-comments/client/index.js +++ b/plugins/talk-plugin-featured-comments/client/index.js @@ -6,6 +6,7 @@ import update from 'immutability-helper'; import ModTag from './containers/ModTag'; import ModActionButton from './containers/ModActionButton'; import ModSubscription from './containers/ModSubscription'; +import ModIndicatorSubscription from './containers/ModIndicatorSubscription'; import FeaturedDialog from './containers/FeaturedDialog'; import { gql } from 'react-apollo'; import reducer from './reducer'; @@ -23,6 +24,7 @@ export default { moderationActions: [ModActionButton], adminModeration: [ModSubscription, FeaturedDialog], adminCommentInfoBar: [ModTag], + adminModerationIndicator: [ModIndicatorSubscription], }, mutations: { IgnoreUser: ({ variables }) => ({ diff --git a/plugins/talk-plugin-featured-comments/index.js b/plugins/talk-plugin-featured-comments/index.js index f5a9c8ae5..6dd3114e1 100644 --- a/plugins/talk-plugin-featured-comments/index.js +++ b/plugins/talk-plugin-featured-comments/index.js @@ -36,7 +36,7 @@ module.exports = { commentFeatured: (options, args) => ({ commentFeatured: { filter: ({ comment }, { user }) => { - if (args.asset_id === null) { + if (!args.asset_id) { return check(user, ['ADMIN', 'MODERATOR']); } return comment.asset_id === args.asset_id; @@ -46,7 +46,7 @@ module.exports = { commentUnfeatured: (options, args) => ({ commentUnfeatured: { filter: ({ comment }, { user }) => { - if (args.asset_id === null) { + if (!args.asset_id) { return check(user, ['ADMIN', 'MODERATOR']); } return comment.asset_id === args.asset_id;