diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index 3f8d975df..37e09590d 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -42,3 +42,12 @@ export const changeUserDetailStatuses = (tab) => { } return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses}; }; + +export const clearUserDetailSelections = () => ({type: actions.CLEAR_USER_DETAIL_SELECTIONS}); + +export const toggleSelectCommentInUserDetail = (id, active) => { + return { + type: active ? actions.SELECT_USER_DETAIL_COMMENT : actions.UNSELECT_USER_DETAIL_COMMENT, + id + }; +}; diff --git a/client/coral-admin/src/constants/moderation.js b/client/coral-admin/src/constants/moderation.js index aa8a51a24..374de616a 100644 --- a/client/coral-admin/src/constants/moderation.js +++ b/client/coral-admin/src/constants/moderation.js @@ -9,3 +9,6 @@ export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL'; export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL'; export const SET_SORT_ORDER = 'MODERATION_SET_SORT_ORDER'; export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES'; +export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT'; +export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT'; +export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS'; diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index 4201ffb06..1b95bdb90 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -1,4 +1,4 @@ -import {fromJS, Map} from 'immutable'; +import {fromJS, Map, Set} from 'immutable'; import * as actions from '../constants/moderation'; const initialState = fromJS({ @@ -10,6 +10,7 @@ const initialState = fromJS({ userDetailId: null, userDetailActiveTab: 'all', userDetailStatuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'], + userDetailSelectedIds: new Set(), banDialog: false, shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show', sortOrder: 'REVERSE_CHRONOLOGICAL', @@ -66,11 +67,19 @@ export default function moderation (state = initialState, action) { case actions.VIEW_USER_DETAIL: return state.set('userDetailId', action.userId); case actions.HIDE_USER_DETAIL: - return state.set('userDetailId', null); + return state + .set('userDetailId', null) + .update('userDetailSelectedIds', (set) => set.clear()); + case actions.CLEAR_USER_DETAIL_SELECTIONS: + return state.update('userDetailSelectedIds', (set) => set.clear()); case actions.CHANGE_USER_DETAIL_STATUSES: return state .set('userDetailActiveTab', action.tab) .set('userDetailStatuses', action.statuses); + case actions.SELECT_USER_DETAIL_COMMENT: + return state.update('userDetailSelectedIds', (set) => set.add(action.id)); + case actions.UNSELECT_USER_DETAIL_COMMENT: + return state.update('userDetailSelectedIds', (set) => set.delete(action.id)); case actions.SET_SORT_ORDER: return state.set('sortOrder', action.order); default : diff --git a/client/coral-admin/src/routes/Configure/components/Configure.css b/client/coral-admin/src/routes/Configure/components/Configure.css index 3d5750693..22e46518c 100644 --- a/client/coral-admin/src/routes/Configure/components/Configure.css +++ b/client/coral-admin/src/routes/Configure/components/Configure.css @@ -99,7 +99,7 @@ .inlineTextfield { border-color: #ccc; border-style: solid; - border-width: 0px 0px 1px 0px; + border-width: 0px 0px 1px 0px; text-align: center; font-size: inherit; } @@ -108,7 +108,7 @@ outline: none; } -.charCountTexfield { +.charCountTexfield, .editCommentTimeframeTextfield { width: 4em; padding: 0px; } diff --git a/client/coral-admin/src/routes/Configure/components/ModerationSettings.js b/client/coral-admin/src/routes/Configure/components/ModerationSettings.js index 6b4795721..99f3435e2 100644 --- a/client/coral-admin/src/routes/Configure/components/ModerationSettings.js +++ b/client/coral-admin/src/routes/Configure/components/ModerationSettings.js @@ -25,12 +25,6 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => { const on = styles.enabledSetting; const off = styles.disabledSetting; - const onChangeEditCommentWindowLength = (e) => { - const value = e.target.value; - const valueAsNumber = parseFloat(value); - const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000); - updateSettings({editCommentWindowLength: milliseconds || value}); - }; return (
@@ -76,27 +70,6 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => { bannedWords={settings.wordlist.banned} suspectWords={settings.wordlist.suspect} onChangeWordlist={onChangeWordlist} /> - - {/* Edit Comment Timeframe */} - -
{t('configure.edit_comment_timeframe_heading')}
-

- {t('configure.edit_comment_timeframe_text_pre')} -   - -   - {t('configure.edit_comment_timeframe_text_post')} -

-
); }; diff --git a/client/coral-admin/src/routes/Configure/components/StreamSettings.js b/client/coral-admin/src/routes/Configure/components/StreamSettings.js index 41c62c153..2de82ec1f 100644 --- a/client/coral-admin/src/routes/Configure/components/StreamSettings.js +++ b/client/coral-admin/src/routes/Configure/components/StreamSettings.js @@ -61,6 +61,13 @@ const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => { } }; +const updateEditCommentWindowLength = (updateSettings) => (e) => { + const value = e.target.value; + const valueAsNumber = parseFloat(value); + const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000); + updateSettings({editCommentWindowLength: milliseconds || value}); +}; + const StreamSettings = ({updateSettings, settingsError, settings, errors}) => { // just putting this here for shorthand below @@ -132,6 +139,25 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => { + {/* Edit Comment Timeframe */} + +
{t('configure.edit_comment_timeframe_heading')}
+

+ {t('configure.edit_comment_timeframe_text_pre')} +   + +   + {t('configure.edit_comment_timeframe_text_post')} +

+
{ const links = linkify.getMatches(comment.body); @@ -48,10 +50,17 @@ const Comment = ({ }) .concat(linkText); + let selectionStateCSS; + if (minimal) { + selectionStateCSS = selected ? styles.minimalSelection : ''; + } else { + selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'; + } + return (
  • @@ -63,6 +72,16 @@ const Comment = ({ ) } + { + minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && ( + toggleSelect(e.target.value, e.target.checked)} /> + ) + } {timeago(comment.created_at || Date.now() - props.index * 60 * 1000)} @@ -187,6 +206,7 @@ Comment.propTypes = { showBanUserDialog: PropTypes.func.isRequired, showSuspendUserDialog: PropTypes.func.isRequired, currentUserId: PropTypes.string.isRequired, + toggleSelect: PropTypes.func, comment: PropTypes.shape({ body: PropTypes.string.isRequired, action_summaries: PropTypes.array, diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.css b/client/coral-admin/src/routes/Moderation/components/UserDetail.css index 1119be8c4..d4b5f0db7 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.css +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.css @@ -41,8 +41,11 @@ } .commentStatuses { - padding: 0; + padding: 10px 0 0 0; + margin: 0; + height: 52px; list-style: none; + box-sizing: border-box; li { display: inline-block; @@ -56,3 +59,24 @@ font-weight: bold; border-bottom: 3px solid #F36451; } + +.bulkActionGroup { + height: 52px; + background-color: #efefef; + + i { + margin-right: 0; + } + + .bulkAction { + display: inline-block; + width: 48px; + height: 48px; + transform: scale(.7); + min-width: 0; + } + + .bulkAction:last-child { + margin-left: -10px; + } +} diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.js b/client/coral-admin/src/routes/Moderation/components/UserDetail.js index c461220c8..688ac75d0 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.js @@ -16,6 +16,10 @@ export default class UserDetail extends React.Component { showSuspendUserDialog: PropTypes.func.isRequired, acceptComment: PropTypes.func.isRequired, rejectComment: PropTypes.func.isRequired, + changeStatus: PropTypes.func.isRequired, + toggleSelect: PropTypes.func.isRequired, + bulkAccept: PropTypes.func.isRequired, + bulkReject: PropTypes.func.isRequired, } copyPermalink = () => { @@ -28,12 +32,24 @@ export default class UserDetail extends React.Component { } } - changeStatus = (tab) => { - if (tab === 'all') { - this.props.changeStatus('all'); - } else if (tab === 'rejected') { - this.props.changeStatus('rejected'); - } + rejectThenReload = (info) => { + this.props.rejectComment(info).then(() => { + this.props.data.refetch(); + }); + } + + acceptThenReload = (info) => { + this.props.acceptComment(info).then(() => { + this.props.data.refetch(); + }); + } + + showAll = () => { + this.props.changeStatus('all'); + } + + showRejected = () => { + this.props.changeStatus('rejected'); } render () { @@ -44,13 +60,17 @@ export default class UserDetail extends React.Component { rejectedComments, comments: {nodes} }, - moderation: {userDetailActiveTab: tab}, + moderation: { + userDetailActiveTab: tab, + userDetailSelectedIds: selectedIds + }, bannedWords, suspectWords, + toggleSelect, + bulkAccept, + bulkReject, showBanUserDialog, showSuspendUserDialog, - acceptComment, - rejectComment, hideUserDetail } = this.props; const localProfile = user.profiles.find((p) => p.provider === 'local'); @@ -60,7 +80,7 @@ export default class UserDetail extends React.Component { profile = localProfile.id; } - let rejectedPercent = rejectedComments / totalComments; + let rejectedPercent = (rejectedComments / totalComments) * 100; if (rejectedPercent === Infinity || isNaN(rejectedPercent)) { // if totalComments is 0, you're dividing by zero, which is naughty @@ -94,14 +114,38 @@ export default class UserDetail extends React.Component {

    {`${(rejectedPercent).toFixed(1)}%`}

    -
      -
    • All
    • -
    • Rejected
    • -
    + { + selectedIds.length === 0 + ? ( +
      +
    • All
    • +
    • Rejected
    • +
    + ) + : ( +
    + + + {`${selectedIds.length} comments selected`} +
    + ) + } +
    { nodes.map((comment, i) => { const status = comment.action_summaries ? 'FLAGGED' : comment.status; + const selected = selectedIds.indexOf(comment.id) !== -1; return ; diff --git a/client/coral-admin/src/routes/Moderation/components/styles.css b/client/coral-admin/src/routes/Moderation/components/styles.css index e7bea9430..d10311f74 100644 --- a/client/coral-admin/src/routes/Moderation/components/styles.css +++ b/client/coral-admin/src/routes/Moderation/components/styles.css @@ -185,10 +185,6 @@ span { padding: 0 14px; } - &: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; } @@ -291,7 +287,6 @@ span { @media (--big-viewport) { .listItem { - border: 1px solid #e0e0e0; margin-bottom: 30px; &:last-child { @@ -460,3 +455,15 @@ span { position: relative; } } + +.minimal { + margin: 0; +} + +.minimalSelection { + background-color: #ecf4ff; +} + +.bulkSelectInput { + cursor: pointer; +} diff --git a/client/coral-admin/src/routes/Moderation/containers/UserDetail.js b/client/coral-admin/src/routes/Moderation/containers/UserDetail.js index 9afd10efb..0e36ed51a 100644 --- a/client/coral-admin/src/routes/Moderation/containers/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/containers/UserDetail.js @@ -6,7 +6,12 @@ import UserDetail from '../components/UserDetail'; import withQuery from 'coral-framework/hocs/withQuery'; import {getSlotsFragments} from 'coral-framework/helpers/plugins'; import {getDefinitionName} from 'coral-framework/utils'; -import {changeUserDetailStatuses} from 'coral-admin/src/actions/moderation'; +import { + changeUserDetailStatuses, + clearUserDetailSelections, + toggleSelectCommentInUserDetail +} from 'coral-admin/src/actions/moderation'; +import {withSetCommentStatus} from 'coral-framework/graphql/mutations'; import Comment from './Comment'; const commentConnectionFragment = gql` @@ -31,12 +36,37 @@ class UserDetailContainer extends React.Component { hideUserDetail: PropTypes.func.isRequired } + // status can be 'ACCEPTED' or 'REJECTED' + bulkSetCommentStatus = (status) => { + const changes = this.props.moderation.userDetailSelectedIds.map((commentId) => { + return this.props.setCommentStatus({commentId, status}); + }); + + Promise.all(changes).then(() => { + this.props.data.refetch(); // some comments may have moved out of this tab + this.props.clearUserDetailSelections(); // un-select everything + }); + } + + bulkReject = () => { + this.bulkSetCommentStatus('REJECTED'); + } + + bulkAccept = () => { + this.bulkSetCommentStatus('ACCEPTED'); + } + render () { if (!('user' in this.props.root)) { return null; } - return ; + return ; } } @@ -79,10 +109,15 @@ const mapStateToProps = (state) => ({ }); const mapDispatchToProps = (dispatch) => ({ - ...bindActionCreators({changeUserDetailStatuses}, dispatch) + ...bindActionCreators({ + changeUserDetailStatuses, + clearUserDetailSelections, + toggleSelectCommentInUserDetail + }, dispatch) }); export default compose( connect(mapStateToProps, mapDispatchToProps), withUserDetailQuery, + withSetCommentStatus, )(UserDetailContainer); diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 71117563d..228dd4c41 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -297,6 +297,7 @@ class Comment extends React.Component { } = this.props; const view = this.getVisibileReplies(); + const hasMoreComments = comment.replies && (comment.replies.hasNextPage || comment.replies.nodes.length > view.length); const replyCount = this.hasIgnoredReplies() ? '' : comment.replyCount; const flagSummary = getActionSummary('FlagActionSummary', comment); diff --git a/client/coral-embed-stream/src/reducers/embed.js b/client/coral-embed-stream/src/reducers/embed.js index 8ccf4cbbc..06073c54e 100644 --- a/client/coral-embed-stream/src/reducers/embed.js +++ b/client/coral-embed-stream/src/reducers/embed.js @@ -4,6 +4,7 @@ const initialState = { activeTab: 'stream', previousTab: '', refetching: false, + refetchRequestId: 0, }; export default function stream(state = initialState, action) { @@ -18,7 +19,8 @@ export default function stream(state = initialState, action) { if (action.queryString.indexOf('query CoralEmbedStream_Embed(') >= 0) { return { ...state, - refetching: action.isRefetch, + refetching: action.isRefetch ? true : state.refetching, + refetchRequestId: action.isRefetch ? action.requestId : state.refetchRequestId, }; } return state; @@ -26,7 +28,7 @@ export default function stream(state = initialState, action) { if (action.operationName === 'CoralEmbedStream_Embed') { return { ...state, - refetching: false, + refetching: action.requestId === state.refetchRequestId ? false : state.refetching, }; } return state; diff --git a/services/passport.js b/services/passport.js index 03602cdb6..d42dfa295 100644 --- a/services/passport.js +++ b/services/passport.js @@ -40,6 +40,7 @@ const SetTokenForSafari = (req, res, token) => { if (browser.ios || browser.safari) { res.cookie('authorization', token, { httpOnly: true, + secure: process.env.NODE_ENV === 'production', expires: new Date(Date.now() + ms(JWT_EXPIRY)) }); } diff --git a/services/users.js b/services/users.js index 5c6440a0e..9bca10ea5 100644 --- a/services/users.js +++ b/services/users.js @@ -930,16 +930,21 @@ module.exports = class UsersService { // Extract all the tokenUserNotFound plugins so we can integrate with other // providers. -const tokenUserNotFoundHooks = require('./plugins') - .get('server', 'tokenUserNotFound') - .map(({plugin, tokenUserNotFound}) => { - debug(`added plugin '${plugin.name}' to tokenUserNotFound hooks`); +let tokenUserNotFoundHooks = null; - return tokenUserNotFound; - }); - -// Provide a function that +// Provide a function that can loop over the hooks and search for a provider +// can crack the token to a user. const lookupUserNotFound = async (token) => { + if (!Array.isArray(tokenUserNotFoundHooks)) { + tokenUserNotFoundHooks = require('./plugins') + .get('server', 'tokenUserNotFound') + .map(({plugin, tokenUserNotFound}) => { + debug(`added plugin '${plugin.name}' to tokenUserNotFound hooks`); + + return tokenUserNotFound; + }); + } + for (let hook of tokenUserNotFoundHooks) { let user = await hook(token); if (user !== null && typeof user !== 'undefined') {