From 19a9997e0f3c02f729d2600f714ac6740a588013 Mon Sep 17 00:00:00 2001 From: seotts Date: Fri, 14 May 2021 15:20:29 -0400 Subject: [PATCH 01/10] =?UTF-8?q?Muted=20users=20can=E2=80=99t=20edit=20st?= =?UTF-8?q?udios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit linting fixes --- .../commenting-status/commenting-status.scss | 1 + src/redux/session.js | 4 ++ src/redux/studio-permissions.js | 27 +++++---- src/views/preview/comment/compose-comment.jsx | 5 +- src/views/studio/l10n.json | 6 +- src/views/studio/studio-projects.jsx | 22 ++++++- src/views/studio/studio.jsx | 25 +++++++- src/views/studio/studio.scss | 4 ++ test/helpers/state-fixtures.json | 13 +++++ test/unit/redux/studio-permissions.test.js | 57 +++++++++++++------ 10 files changed, 128 insertions(+), 36 deletions(-) diff --git a/src/components/commenting-status/commenting-status.scss b/src/components/commenting-status/commenting-status.scss index 4b6e85c11..8e0e50e5a 100644 --- a/src/components/commenting-status/commenting-status.scss +++ b/src/components/commenting-status/commenting-status.scss @@ -8,6 +8,7 @@ background-color: $ui-blue-10percent; width: 100%; text-align: center; + box-sizing: border-box; p { margin-bottom: 0; diff --git a/src/redux/session.js b/src/redux/session.js index 36ce89927..8a4608ffd 100644 --- a/src/redux/session.js +++ b/src/redux/session.js @@ -128,6 +128,10 @@ module.exports.selectToken = state => get(state, ['session', 'session', 'user', module.exports.selectIsAdmin = state => get(state, ['session', 'session', 'permissions', 'admin'], false); module.exports.selectIsSocial = state => get(state, ['session', 'session', 'permissions', 'social'], false); module.exports.selectIsEducator = state => get(state, ['session', 'session', 'permissions', 'educator'], false); +module.exports.selectMuteStatus = state => get(state, ['session', 'session', 'permissions', 'mute_status'], + {muteExpiresAt: 0, offenses: [], showWarning: false}); +module.exports.selectIsMuted = state => (module.exports.selectMuteStatus(state).muteExpiresAt || 0) * 1000 > Date.now(); + // NB logged out user id as NaN so that it can never be used in equality testing since NaN !== NaN module.exports.selectUserId = state => get(state, ['session', 'session', 'user', 'id'], NaN); diff --git a/src/redux/studio-permissions.js b/src/redux/studio-permissions.js index 25c445fe0..79b726a2b 100644 --- a/src/redux/studio-permissions.js +++ b/src/redux/studio-permissions.js @@ -1,4 +1,7 @@ -const {selectUserId, selectIsAdmin, selectIsSocial, selectIsLoggedIn, selectUsername} = require('./session'); +import { false } from 'tap'; + +const {selectUserId, selectIsAdmin, selectIsSocial, + selectIsLoggedIn, selectUsername, selectIsMuted} = require('./session'); // Fine-grain selector helpers - not exported, use the higher level selectors below const isCreator = state => selectUserId(state) === state.studio.owner; @@ -6,11 +9,12 @@ const isCurator = state => state.studio.curator; const isManager = state => state.studio.manager || isCreator(state); // Action-based permissions selectors -const selectCanEditInfo = state => selectIsAdmin(state) || isManager(state); +const selectCanEditInfo = state => !selectIsMuted(state) && (selectIsAdmin(state) || isManager(state)); const selectCanAddProjects = state => - isManager(state) || + !selectIsMuted(state) && + (isManager(state) || isCurator(state) || - (selectIsSocial(state) && state.studio.openToAll); + (selectIsSocial(state) && state.studio.openToAll)); // This isn't "canComment" since they could be muted, but comment composer handles that const selectShowCommentComposer = state => selectIsSocial(state); @@ -26,12 +30,13 @@ const selectCanDeleteCommentWithoutConfirm = state => selectIsAdmin(state); const selectCanFollowStudio = state => selectIsLoggedIn(state); // Matching existing behavior, only admin/creator is allowed to toggle comments. -const selectCanEditCommentsAllowed = state => selectIsAdmin(state) || isCreator(state); -const selectCanEditOpenToAll = state => isManager(state); +const selectCanEditCommentsAllowed = state => !selectIsMuted(state) && (selectIsAdmin(state) || isCreator(state)); +const selectCanEditOpenToAll = state => !selectIsMuted(state) && isManager(state); -const selectShowCuratorInvite = state => !!state.studio.invited; -const selectCanInviteCurators = state => isManager(state); +const selectShowCuratorInvite = state => !selectIsMuted(state) && !!state.studio.invited; +const selectCanInviteCurators = state => !selectIsMuted(state) && isManager(state); const selectCanRemoveCurator = (state, username) => { + if (selectIsMuted(state)) return false; // Admins/managers can remove any curators if (isManager(state) || selectIsAdmin(state)) return true; // Curators can remove themselves @@ -41,10 +46,12 @@ const selectCanRemoveCurator = (state, username) => { return false; }; const selectCanRemoveManager = (state, managerId) => - (selectIsAdmin(state) || isManager(state)) && managerId !== state.studio.owner; -const selectCanPromoteCurators = state => isManager(state); + !selectIsMuted(state) && (selectIsAdmin(state) || isManager(state)) && managerId !== state.studio.owner; +const selectCanPromoteCurators = state => !selectIsMuted(state) && isManager(state); const selectCanRemoveProject = (state, creatorUsername, actorId) => { + if (selectIsMuted(state)) return false; + // Admins/managers can remove any projects if (isManager(state) || selectIsAdmin(state)) return true; // Project owners can always remove their projects diff --git a/src/views/preview/comment/compose-comment.jsx b/src/views/preview/comment/compose-comment.jsx index 4f2fc532e..ebe19e789 100644 --- a/src/views/preview/comment/compose-comment.jsx +++ b/src/views/preview/comment/compose-comment.jsx @@ -17,6 +17,7 @@ const formatTime = require('../../../lib/format-time'); const connect = require('react-redux').connect; const api = require('../../../lib/api'); +const {selectMuteStatus} = require('../../../redux/session.js'); require('./comment.scss'); @@ -444,9 +445,7 @@ ComposeComment.propTypes = { }; const mapStateToProps = state => ({ - muteStatus: state.session.session.permissions.mute_status ? - state.session.session.permissions.mute_status : - {muteExpiresAt: 0, offenses: [], showWarning: false}, + muteStatus: selectMuteStatus(state), user: state.session.session.user }); diff --git a/src/views/studio/l10n.json b/src/views/studio/l10n.json index adae385a0..eb56db725 100644 --- a/src/views/studio/l10n.json +++ b/src/views/studio/l10n.json @@ -73,5 +73,9 @@ "studio.reportPleaseExplain": "Please select which part of the studio you find to be disrespectful or inappropriate, or otherwise breaks the Scratch Community Guidelines.", "studio.reportAreThereComments": "Are there inappropriate comments in the studio? Please report them by clicking the \"report\" button on the individual comments.", "studio.reportThanksForLettingUsKnow": "Thanks for letting us know!", - "studio.reportYourFeedback": "Your feedback will help us make Scratch better." + "studio.reportYourFeedback": "Your feedback will help us make Scratch better.", + + "studios.mutedCurators": "You will be able to invite curators and add managers again {inDuration}.", + "studios.mutedProjects": "You will be able to add and remove projects again {inDuration}.", + "studios.mutedPaused": "Your account has been paused from using studios until then." } diff --git a/src/views/studio/studio-projects.jsx b/src/views/studio/studio-projects.jsx index 7672b113d..e36c54a79 100644 --- a/src/views/studio/studio-projects.jsx +++ b/src/views/studio/studio-projects.jsx @@ -11,9 +11,12 @@ import StudioProjectAdder from './studio-project-adder.jsx'; import StudioProjectTile from './studio-project-tile.jsx'; import {loadProjects} from './lib/studio-project-actions.js'; import classNames from 'classnames'; +import CommentingStatus from '../../components/commenting-status/commenting-status.jsx'; +import {selectMuteStatus} from '../../redux/session.js'; +import {formatRelativeTime} from '../../lib/format-time.js'; const StudioProjects = ({ - canAddProjects, canEditOpenToAll, items, error, loading, moreToLoad, onLoadMore + canAddProjects, canEditOpenToAll, items, error, loading, moreToLoad, onLoadMore, muteExpiresAtMs }) => { useEffect(() => { if (items.length === 0) onLoadMore(); @@ -25,6 +28,19 @@ const StudioProjects = ({

{canEditOpenToAll && } + {muteExpiresAtMs > Date.now() && + +

+ + +

+
+ } {canAddProjects && } {error && ({ ...projects.selector(state), canAddProjects: selectCanAddProjects(state), - canEditOpenToAll: selectCanEditOpenToAll(state) + canEditOpenToAll: selectCanEditOpenToAll(state), + muteExpiresAtMs: (selectMuteStatus(state).muteExpiresAt * 1000 || 0) }), { onLoadMore: loadProjects diff --git a/src/views/studio/studio.jsx b/src/views/studio/studio.jsx index aa945457e..ba10a78bd 100644 --- a/src/views/studio/studio.jsx +++ b/src/views/studio/studio.jsx @@ -38,8 +38,12 @@ const {commentsReducer} = require('../../redux/comments'); const {studioMutationsReducer} = require('../../redux/studio-mutations'); import './studio.scss'; +import {selectIsMuted, selectMuteStatus} from '../../redux/session.js'; +import {formatRelativeTime} from '../../lib/format-time.js'; +import CommentingStatus from '../../components/commenting-status/commenting-status.jsx'; +import {FormattedMessage} from 'react-intl'; -const StudioShell = ({studioLoadFailed}) => { +const StudioShell = ({isMuted, muteExpiresAtMs, studioLoadFailed}) => { const match = useRouteMatch(); return ( @@ -54,6 +58,19 @@ const StudioShell = ({studioLoadFailed}) => {
+ {isMuted && + +

+ + +

+
+ }
@@ -78,12 +95,16 @@ const StudioShell = ({studioLoadFailed}) => { }; StudioShell.propTypes = { + isMuted: PropTypes.bool, + muteExpiresAtMs: PropTypes.number, studioLoadFailed: PropTypes.bool }; const ConnectedStudioShell = connect( state => ({ - studioLoadFailed: selectStudioLoadFailed(state) + isMuted: selectIsMuted(state), + studioLoadFailed: selectStudioLoadFailed(state), + muteExpiresAtMs: (selectMuteStatus(state).muteExpiresAt * 1000 || 0) }), )(StudioShell); diff --git a/src/views/studio/studio.scss b/src/views/studio/studio.scss index 1b32141ee..d4810b836 100644 --- a/src/views/studio/studio.scss +++ b/src/views/studio/studio.scss @@ -427,4 +427,8 @@ $radius: 8px; .mod-form-error { /* When a field contains a value is causing an error */ border-color: $ui-orange !important; +} + +.studio-curator-mute-box { + margin: 20px 0; } \ No newline at end of file diff --git a/test/helpers/state-fixtures.json b/test/helpers/state-fixtures.json index 4fd5dfaa7..be7548bc8 100644 --- a/test/helpers/state-fixtures.json +++ b/test/helpers/state-fixtures.json @@ -50,6 +50,19 @@ "social": false } } + }, + "isMuted": { + "session": { + "user": { + "id": 1, + "username": "user1-username", + "token": "user1-token" + }, + "permissions": { + "mute_status": {"muteExpiresAt": 32515480478, "offenses": [], "showWarning": false}, + "social": true + } + } } } } \ No newline at end of file diff --git a/test/unit/redux/studio-permissions.test.js b/test/unit/redux/studio-permissions.test.js index dcad14c33..34b96c77a 100644 --- a/test/unit/redux/studio-permissions.test.js +++ b/test/unit/redux/studio-permissions.test.js @@ -51,6 +51,9 @@ const setStateByRole = (role) => { case 'invited': state.studio = studios.isInvited; break; + case 'muted': + state.session = sessions.isMuted; + break; default: throw new Error('Unknown user role in test: ' + role); } @@ -72,7 +75,8 @@ describe('studio info', () => { ['creator', true], ['logged in', false], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanEditInfo(state)).toBe(expected); @@ -89,7 +93,8 @@ describe('studio projects', () => { ['creator', true], ['logged in', false], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanAddProjects(state)).toBe(expected); @@ -100,7 +105,8 @@ describe('studio projects', () => { test.each([ ['logged in', true], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); state.studio.openToAll = true; @@ -116,7 +122,8 @@ describe('studio projects', () => { ['creator', true], ['logged in', false], // false for projects that are not theirs, see below ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanRemoveProject(state, 'not-me', 'not-me')).toBe(expected); @@ -147,7 +154,8 @@ describe('studio comments', () => { test.each([ ['logged in', true], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', true] // comment composer is there, but contains muted ComposeStatus ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectShowCommentComposer(state)).toBe(expected); @@ -158,7 +166,8 @@ describe('studio comments', () => { test.each([ ['logged in', true], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', true] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanReportComment(state)).toBe(expected); @@ -173,7 +182,8 @@ describe('studio comments', () => { ['creator', false], ['logged in', false], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanDeleteComment(state)).toBe(expected); @@ -188,7 +198,8 @@ describe('studio comments', () => { ['creator', false], ['logged in', false], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanDeleteCommentWithoutConfirm(state)).toBe(expected); @@ -203,7 +214,8 @@ describe('studio comments', () => { ['creator', false], ['logged in', false], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanRestoreComment(state)).toBe(expected); @@ -214,7 +226,8 @@ describe('studio comments', () => { test.each([ ['logged in', true], ['unconfirmed', true], - ['logged out', false] + ['logged out', false], + ['muted', true] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanFollowStudio(state)).toBe(expected); @@ -229,7 +242,8 @@ describe('studio comments', () => { ['creator', true], ['logged in', false], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanEditCommentsAllowed(state)).toBe(expected); @@ -244,7 +258,8 @@ describe('studio comments', () => { ['creator', true], ['logged in', false], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanEditOpenToAll(state)).toBe(expected); @@ -262,7 +277,8 @@ describe('studio members', () => { ['invited', true], ['logged in', false], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectShowCuratorInvite(state)).toBe(expected); @@ -277,7 +293,8 @@ describe('studio members', () => { ['creator', true], ['logged in', false], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanPromoteCurators(state)).toBe(expected); @@ -292,7 +309,8 @@ describe('studio members', () => { ['creator', true], ['logged in', false], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanRemoveCurator(state, 'others-username')).toBe(expected); @@ -313,7 +331,8 @@ describe('studio members', () => { ['creator', true], ['logged in', false], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanRemoveManager(state, '123')).toBe(expected); @@ -327,7 +346,8 @@ describe('studio members', () => { ['creator', false], ['logged in', false], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); state.studio.owner = 'the creator'; @@ -344,7 +364,8 @@ describe('studio members', () => { ['creator', false], ['logged in', false], ['unconfirmed', false], - ['logged out', false] + ['logged out', false], + ['muted', false] ])('%s: %s', (role, expected) => { setStateByRole(role); expect(selectCanInviteCurators(state)).toBe(expected); From db5358d79c0bfc69d4c5d08633340b85ca7f062d Mon Sep 17 00:00:00 2001 From: seotts Date: Tue, 18 May 2021 15:02:48 -0400 Subject: [PATCH 02/10] Show mute edit errors on hover --- src/views/studio/l10n.json | 1 + src/views/studio/studio-description.jsx | 21 +++++++++--- src/views/studio/studio-image.jsx | 21 +++++++++--- src/views/studio/studio-mute-edit-message.jsx | 34 +++++++++++++++++++ src/views/studio/studio-title.jsx | 21 +++++++++--- 5 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 src/views/studio/studio-mute-edit-message.jsx diff --git a/src/views/studio/l10n.json b/src/views/studio/l10n.json index eb56db725..5fff6f951 100644 --- a/src/views/studio/l10n.json +++ b/src/views/studio/l10n.json @@ -77,5 +77,6 @@ "studios.mutedCurators": "You will be able to invite curators and add managers again {inDuration}.", "studios.mutedProjects": "You will be able to add and remove projects again {inDuration}.", + "studios.mutedEdit": "You will be able to edit studios again {inDuration}.", "studios.mutedPaused": "Your account has been paused from using studios until then." } diff --git a/src/views/studio/studio-description.jsx b/src/views/studio/studio-description.jsx index 1fb0e5e39..7e3fde20e 100644 --- a/src/views/studio/studio-description.jsx +++ b/src/views/studio/studio-description.jsx @@ -1,5 +1,5 @@ /* eslint-disable react/jsx-no-bind */ -import React from 'react'; +import React, {useState} from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import classNames from 'classnames'; @@ -13,6 +13,8 @@ import { import ValidationMessage from '../../components/forms/validation-message.jsx'; import decorateText from '../../lib/decorate-text.jsx'; +import {selectIsMuted} from '../../redux/session'; +import StudioMuteEditMessage from './studio-mute-edit-message.jsx'; const errorToMessageId = error => { switch (error) { @@ -24,15 +26,23 @@ const errorToMessageId = error => { }; const StudioDescription = ({ - descriptionError, isFetching, isMutating, description, canEditInfo, handleUpdate + descriptionError, isFetching, isMutating, isMuted, description, canEditInfo, handleUpdate }) => { const fieldClassName = classNames('studio-description', { 'mod-fetching': isFetching, 'mod-mutating': isMutating, - 'mod-form-error': !!descriptionError + 'mod-form-error': !!descriptionError, + 'muted': isMuted }); + + const [showMuteMessage, setShowMuteMessage] = useState(false); + return ( -
+
isMuted && setShowMuteMessage(true)} + onMouseLeave={() => isMuted && setShowMuteMessage(false)} + > {canEditInfo ? (