Merge pull request #5166 from seotts/message-already-muted

When a user is already muted, show a different message in the blue box
This commit is contained in:
Sarah Otts 2021-03-22 10:20:15 -04:00 committed by GitHub
commit 059b6bf2c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 138 additions and 28 deletions

View file

@ -28,8 +28,9 @@ const JUST_MUTED_ERROR = 'isBad';
const ComposeStatus = keyMirror({
EDITING: null,
SUBMITTING: null,
REJECTED: null,
REJECTED_MUTE: null
REJECTED: null, // comment rejected for a reason other than muting (such as commenting too quickly)
REJECTED_MUTE: null, // comment made in this ComposeComment was rejected and muted the user
COMPOSE_DISALLOWED: null // user is already muted due to past behavior
});
class ComposeComment extends React.Component {
@ -48,7 +49,7 @@ class ComposeComment extends React.Component {
this.props.muteStatus.muteExpiresAt * 1000 : 0; // convert to ms
this.state = {
message: '',
status: ComposeStatus.EDITING,
status: muteExpiresAtMs > Date.now() ? ComposeStatus.COMPOSE_DISALLOWED : ComposeStatus.EDITING,
error: null,
appealId: null,
muteOpen: muteExpiresAtMs > Date.now() && this.props.isReply,
@ -96,14 +97,23 @@ class ComposeComment extends React.Component {
let muteOpen = false;
let muteExpiresAtMs = 0;
let rejectedStatus = ComposeStatus.REJECTED;
let justMuted = true;
let showWarning = false;
let muteType = null;
if (body.status && body.status.mute_status) {
muteExpiresAtMs = body.status.mute_status.muteExpiresAt * 1000; // convert to ms
rejectedStatus = ComposeStatus.REJECTED_MUTE;
if (this.shouldShowMuteModal(body.status.mute_status)) {
if (body.rejected === JUST_MUTED_ERROR) {
rejectedStatus = ComposeStatus.REJECTED_MUTE;
} else {
rejectedStatus = ComposeStatus.COMPOSE_DISALLOWED;
justMuted = false;
}
if (this.shouldShowMuteModal(body.status.mute_status, justMuted)) {
muteOpen = true;
}
showWarning = body.status.mute_status.showWarning;
muteType = body.status.mute_status.currentMessageType;
this.setupMuteExpirationTimeout(muteExpiresAtMs);
@ -152,7 +162,7 @@ class ComposeComment extends React.Component {
// Cancel (i.e. complete) the reply action if the user clicked on the reply button while
// alreay muted. This "closes" the reply. If they just got muted, we want to leave it open
// so the blue CommentingStatus box shows.
if (this.props.isReply && this.state.status !== ComposeStatus.REJECTED_MUTE) {
if (this.props.isReply && this.state.status === ComposeStatus.COMPOSE_DISALLOWED) {
this.handleCancel();
}
}
@ -162,7 +172,7 @@ class ComposeComment extends React.Component {
muteOpen: true
});
}
shouldShowMuteModal (muteStatus) {
shouldShowMuteModal (muteStatus, justMuted) {
// We should show the mute modal if the user is in danger of being blocked or
// when the user is newly muted or hasn't seen it for a while.
// We don't want to show it more than about once a week.
@ -176,6 +186,17 @@ class ComposeComment extends React.Component {
return false;
}
// If the user is already muted (for example, in a different tab),
// do not show modal unless the comment is a reply. We always want to show
// the modal on replies when the user is already muted because the blue box
// may be out-of-sight for them.
if (!justMuted) {
if (this.props.isReply) {
return true;
}
return false;
}
// If the backend tells us to show a warning about getting blocked, we should show the modal
// regardless of what the offenses list looks like.
if (muteStatus.showWarning) {
@ -199,7 +220,7 @@ class ComposeComment extends React.Component {
// Decides which step of the mute modal to start on. If this was a reply button click,
// we show them the step that tells them how much time is left on their mute, otherwise
// they start at the beginning of the progression.
return this.props.isReply && this.state.status !== ComposeStatus.REJECTED_MUTE ?
return this.props.isReply && this.state.status === ComposeStatus.COMPOSE_DISALLOWED ?
MuteModal.steps.MUTE_INFO : MuteModal.steps.COMMENT_ISSUE;
}
@ -210,30 +231,35 @@ class ComposeComment extends React.Component {
pii: {
name: 'pii',
commentType: 'comment.type.pii',
commentTypePast: 'comment.type.pii.past',
muteStepHeader: 'comment.pii.header',
muteStepContent: ['comment.pii.content1', 'comment.pii.content2', 'comment.pii.content3']
},
unconstructive: {
name: 'unconstructive',
commentType: 'comment.type.unconstructive',
commentTypePast: 'comment.type.unconstructive.past',
muteStepHeader: 'comment.unconstructive.header',
muteStepContent: ['comment.unconstructive.content1', 'comment.unconstructive.content2']
},
vulgarity: {
name: 'vulgarity',
commentType: 'comment.type.vulgarity',
commentTypePast: 'comment.type.vulgarity.past',
muteStepHeader: 'comment.vulgarity.header',
muteStepContent: ['comment.vulgarity.content1', 'comment.vulgarity.content2']
},
spam: {
name: 'spam',
commentType: 'comment.type.spam',
commentTypePast: 'comment.type.spam.past',
muteStepHeader: 'comment.spam.header',
muteStepContent: ['comment.spam.content1', 'comment.spam.content2']
},
general: {
name: 'general',
commentType: 'comment.type.general',
commentTypePast: 'comment.type.general.past',
muteStepHeader: 'comment.general.header',
muteStepContent: ['comment.general.content1']
}
@ -258,10 +284,20 @@ class ComposeComment extends React.Component {
render () {
return (
<React.Fragment>
{(this.isMuted() && !(this.props.isReply && this.state.status !== ComposeStatus.REJECTED_MUTE)) ? (
{/* If a user is muted, show the blue mute box, unless
the comment is a reply and the user was already muted before attempting to make it. */}
{(this.isMuted() && !(this.props.isReply && this.state.status === ComposeStatus.COMPOSE_DISALLOWED)) ? (
<FlexRow className="comment">
<CommentingStatus>
<p><FormattedMessage id={this.getMuteMessageInfo().commentType} /></p>
<p>
<FormattedMessage
id={
this.state.status === ComposeStatus.REJECTED_MUTE ?
this.getMuteMessageInfo().commentType :
this.getMuteMessageInfo().commentTypePast
}
/>
</p>
<p>
<FormattedMessage
id="comments.muted.duration"
@ -287,7 +323,7 @@ class ComposeComment extends React.Component {
</CommentingStatus>
</FlexRow>
) : null }
{!this.isMuted() || (this.isMuted() && this.state.status === ComposeStatus.REJECTED_MUTE) ? (
{this.state.status === ComposeStatus.COMPOSE_DISALLOWED ? null : (
<div
className={classNames('flex-row',
'comment',
@ -298,7 +334,7 @@ class ComposeComment extends React.Component {
<Avatar src={this.props.user.thumbnailUrl} />
</a>
<FlexRow className="compose-comment column">
{this.state.error && this.state.status !== ComposeStatus.REJECTED_MUTE ? (
{this.state.status === ComposeStatus.REJECTED ? (
<FlexRow className="compose-error-row">
<div className="compose-error-tip">
<FormattedMessage
@ -360,7 +396,7 @@ class ComposeComment extends React.Component {
</Formsy>
</FlexRow>
</div>
) : null }
)}
{this.state.muteOpen ? (
<MuteModal
isOpen
@ -371,7 +407,7 @@ class ComposeComment extends React.Component {
muteModalMessages={this.getMuteMessageInfo()}
shouldCloseOnOverlayClick={false}
showFeedback={
this.state.status === ComposeStatus.REJECTED_MUTE && this.state.error === JUST_MUTED_ERROR
this.state.status === ComposeStatus.REJECTED_MUTE
}
showWarning={this.state.showWarning}
startStep={this.getMuteModalStartStep()}

View file

@ -47,22 +47,27 @@
"project.usernameBlockAlert": "This project can detect who is using it, through the \"username\" block. To hide your identity, sign out before using the project.",
"project.inappropriateUpdate": "Hmm...the bad word detector thinks there is a problem with your text. Please change it and remember to be respectful.",
"comment.type.general": "It appears that your most recent comment didn't follow the Scratch Community Guidelines.",
"comment.type.general.past": "It appears that one of your recent comments didnt follow the Scratch Community Guidelines.",
"comment.general.header": "We encourage you to post comments that follow the Scratch Community Guidelines.",
"comment.general.content1": "On Scratch, it's important for comments to be kind, to be appropriate for all ages, and to not contain spam.",
"comment.type.pii": "Your most recent comment appeared to be sharing or asking for private information.",
"comment.type.pii.past": "It appears that one of your recent comments was sharing or asking for private information.",
"comment.pii.header": "Please be sure not to share private information on Scratch.",
"comment.pii.content1": "It appears that you were sharing or asking for private information.",
"comment.pii.content2": "Things you share on Scratch can be seen by everyone, and can appear in search engines. Private information can be used by other people in harmful ways, so its important to keep it private.",
"comment.pii.content3": "This is a serious safety issue.",
"comment.type.unconstructive": "It appears that your most recent comment was saying something that might have been hurtful.",
"comment.type.unconstructive.past": "It appears that one of your recent comments was saying something that might have been hurtful.",
"comment.unconstructive.header": "We encourage you to be supportive when commenting on other peoples projects",
"comment.unconstructive.content1": "It appears that your comment was saying something that might have been hurtful.",
"comment.unconstructive.content2": "If you think something could be better, you can say something you like about the project, and make a suggestion about how to improve it.",
"comment.type.vulgarity": "Your most recent comment appeared to include a bad word.",
"comment.type.vulgarity.past": "It appears that one of your recent comments contained a bad word.",
"comment.vulgarity.header": "We encourage you to use language thats appropriate for all ages.",
"comment.vulgarity.content1": "It appears that your comment contains a bad word.",
"comment.vulgarity.content2": "Scratch has users of all ages, so its important to use language that is appropriate for all Scratchers.",
"comment.type.spam": "Your most recent comment appeared to contain advertising, text art, or a chain message.",
"comment.type.spam.past": "It appears that one of your recent comments contained advertising, text art, or a chain message.",
"comment.spam.header": "We encourage you not to advertise, copy and paste text art, or ask others to copy comments.",
"comment.spam.content1": "Even though advertisements, text art, and chain mail can be fun, they start to fill up the website, and we want to make sure there is room for other comments.",
"comment.spam.content2": "Thank you for helping us keep Scratch a friendly, creative community!"

View file

@ -51,6 +51,36 @@ describe('Compose Comment test', () => {
return wrapper.dive(); // unwrap redux connect(injectIntl(ComposeComment))
};
test('status is EDITING when props do not contain a muteStatus ', () => {
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.state.status).toBe('EDITING');
});
test('status is COMPOSE_DISALLOWED when props contain a future mute', () => {
jest.useFakeTimers();
const realDateNow = Date.now.bind(global.Date);
global.Date.now = () => 0;
const mutedStore = mockStore({
session: {
session: {
user: {},
permissions: {
mute_status: {
muteExpiresAt: 5,
offenses: [],
showWarning: true
}
}
}
}
});
const component = getComposeCommentWrapper({}, mutedStore);
const commentInstance = component.instance();
expect(commentInstance.state.status).toBe('COMPOSE_DISALLOWED');
global.Date.now = realDateNow;
});
test('Modal & Comment status do not show ', () => {
const component = getComposeCommentWrapper({});
// Comment compsoe box is there
@ -68,7 +98,10 @@ describe('Compose Comment test', () => {
test('Error messages shows when comment rejected ', () => {
const component = getComposeCommentWrapper({});
const commentInstance = component.instance();
commentInstance.setState({error: 'isFlood'});
commentInstance.setState({
error: 'isFlood',
status: 'REJECTED'
});
component.update();
expect(component.find('FlexRow.compose-error-row').exists()).toEqual(true);
// Buttons stay enabled when comment rejected for non-mute reasons
@ -76,24 +109,25 @@ describe('Compose Comment test', () => {
expect(component.find('Button.compose-cancel').props().disabled).toBe(false);
});
test('No error message shows when comment rejected because user muted ', () => {
test('No error message shows when comment rejected because user is already muted ', () => {
const component = getComposeCommentWrapper({});
const commentInstance = component.instance();
commentInstance.setState({
error: 'isMuted',
status: 'REJECTED_MUTE'
status: 'COMPOSE_DISALLOWED'
});
component.update();
expect(component.find('FlexRow.compose-error-row').exists()).toEqual(false);
});
test('Comment Status shows but compose box does not when mute expiration in the future ', () => {
test('Comment Status shows but compose box does not when you load the page and you are already muted', () => {
const realDateNow = Date.now.bind(global.Date);
global.Date.now = () => 0;
const component = getComposeCommentWrapper({});
const commentInstance = component.instance();
commentInstance.setState({muteExpiresAtMs: 100});
commentInstance.setState({muteExpiresAtMs: 100, status: 'COMPOSE_DISALLOWED'});
component.update();
// Compose box should be hidden if muted unless they got muted due to a comment they just posted.
expect(component.find('FlexRow.compose-comment').exists()).toEqual(false);
expect(component.find('MuteModal').exists()).toEqual(false);
@ -172,7 +206,7 @@ describe('Compose Comment test', () => {
expect(component.find('CommentingStatus').exists()).toEqual(true);
global.Date.now = realDateNow;
});
test('Comment Status shows when user just submitted a reply comment that got them muted', () => {
const realDateNow = Date.now.bind(global.Date);
global.Date.now = () => 0;
@ -233,7 +267,7 @@ describe('Compose Comment test', () => {
const commentInstance = component.instance();
commentInstance.setState({
error: 'some error',
status: 'FLOOD'
status: 'REJECTED'
});
component.update();
expect(component.find('FlexRow.compose-error-row').exists()).toEqual(true);
@ -335,7 +369,7 @@ describe('Compose Comment test', () => {
expect(component.find('MuteModal').props().showFeedback).toBe(true);
commentInstance.setState({
status: 'REJECTED_MUTE',
status: 'COMPOSE_DISALLOWED',
error: 'isMute',
showWarning: true,
muteOpen: true
@ -356,7 +390,6 @@ describe('Compose Comment test', () => {
expect(component.find('MuteModal').exists()).toEqual(true);
expect(component.find('MuteModal').props().showFeedback).toBe(false);
});
test('shouldShowMuteModal is false when muteStatus is undefined ', () => {
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.shouldShowMuteModal()).toBe(false);
@ -389,7 +422,7 @@ describe('Compose Comment test', () => {
offenses: [offense]
};
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.shouldShowMuteModal(muteStatus)).toBe(true);
expect(commentInstance.shouldShowMuteModal(muteStatus, true)).toBe(true);
global.Date.now = realDateNow;
});
@ -410,7 +443,7 @@ describe('Compose Comment test', () => {
offenses: offenses
};
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.shouldShowMuteModal(muteStatus)).toBe(false);
expect(commentInstance.shouldShowMuteModal(muteStatus, true)).toBe(false);
global.Date.now = realDateNow;
});
@ -432,11 +465,47 @@ describe('Compose Comment test', () => {
showWarning: true
};
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.shouldShowMuteModal(muteStatus)).toBe(true);
expect(commentInstance.shouldShowMuteModal(muteStatus, true)).toBe(true);
global.Date.now = realDateNow;
});
test('getMuteModalStartStep: not a reply ', () => {
test('shouldShowMuteModal is false when the user is already muted, even when only 1 recent offesnse ', () => {
const realDateNow = Date.now.bind(global.Date);
global.Date.now = () => 0;
// Since Date.now mocked to 0 above, we just need a small number to make
// it look like it was created < 2 minutes ago.
const offense = {
expiresAt: '1000',
createdAt: '-60' // ~1 ago min given shouldShowMuteModal's conversions,
};
const muteStatus = {
offenses: [offense]
};
const justMuted = false;
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.shouldShowMuteModal(muteStatus, justMuted)).toBe(false);
global.Date.now = realDateNow;
});
test('shouldShowMuteModal is true when the user is already muted if the comment is a reply', () => {
const realDateNow = Date.now.bind(global.Date);
global.Date.now = () => 0;
// Since Date.now mocked to 0 above, we just need a small number to make
// it look like it was created < 2 minutes ago.
const offense = {
expiresAt: '1000',
createdAt: '-60' // ~1 ago min given shouldShowMuteModal's conversions,
};
const muteStatus = {
offenses: [offense]
};
const justMuted = false;
const commentInstance = getComposeCommentWrapper({isReply: true}).instance();
expect(commentInstance.shouldShowMuteModal(muteStatus, justMuted)).toBe(true);
global.Date.now = realDateNow;
});
test('getMuteModalStartStep: not a reply', () => {
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.getMuteModalStartStep()).toBe(0);
});
@ -452,7 +521,7 @@ describe('Compose Comment test', () => {
test('getMuteModalStartStep: A reply click when already muted ', () => {
const commentInstance = getComposeCommentWrapper({isReply: true}).instance();
commentInstance.setState({
status: 'EDITING'
status: 'COMPOSE_DISALLOWED'
});
expect(commentInstance.getMuteModalStartStep()).toBe(1);
});