Merge pull request #4616 from picklesrus/mute-behavior

Skeleton of code to show mute modal and comment status.
This commit is contained in:
picklesrus 2020-11-10 10:24:04 -05:00 committed by GitHub
commit 2005b23777
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 288 additions and 68 deletions

View file

@ -10,6 +10,8 @@ const FlexRow = require('../../../components/flex-row/flex-row.jsx');
const Avatar = require('../../../components/avatar/avatar.jsx'); const Avatar = require('../../../components/avatar/avatar.jsx');
const InplaceInput = require('../../../components/forms/inplace-input.jsx'); const InplaceInput = require('../../../components/forms/inplace-input.jsx');
const Button = require('../../../components/forms/button.jsx'); const Button = require('../../../components/forms/button.jsx');
const CommentingStatus = require('../../../components/commenting-status/commenting-status.jsx');
const MuteModal = require('../../../components/modal/mute/modal.jsx');
const connect = require('react-redux').connect; const connect = require('react-redux').connect;
@ -24,7 +26,8 @@ const MAX_COMMENT_LENGTH = 500;
const ComposeStatus = keyMirror({ const ComposeStatus = keyMirror({
EDITING: null, EDITING: null,
SUBMITTING: null, SUBMITTING: null,
REJECTED: null REJECTED: null,
REJECTED_MUTE: null
}); });
class ComposeComment extends React.Component { class ComposeComment extends React.Component {
@ -33,13 +36,17 @@ class ComposeComment extends React.Component {
bindAll(this, [ bindAll(this, [
'handlePost', 'handlePost',
'handleCancel', 'handleCancel',
'handleInput' 'handleInput',
'handleMuteClose',
'handleMuteOpen'
]); ]);
this.state = { this.state = {
message: '', message: '',
status: ComposeStatus.EDITING, status: ComposeStatus.EDITING,
error: null, error: null,
appealId: null appealId: null,
muteOpen: false
}; };
} }
handleInput (event) { handleInput (event) {
@ -67,13 +74,24 @@ class ComposeComment extends React.Component {
if (err || res.statusCode !== 200) { if (err || res.statusCode !== 200) {
body = {rejected: 'error'}; body = {rejected: 'error'};
} }
if (body.rejected && this.state.status === ComposeStatus.SUBMITTING) { if (body.rejected && this.state.status === ComposeStatus.SUBMITTING) {
let muteOpen = false;
let muteExpiresAt = 0;
let rejectedStatus = ComposeStatus.REJECTED;
if (body.status && body.status.mute_status) {
muteExpiresAt = body.status.mute_status.muteExpiresAt;
rejectedStatus = ComposeStatus.REJECTED_MUTE;
if (this.shouldShowMuteModal(body.status.mute_status.offenses)) {
muteOpen = true;
}
}
// Note: does not reset the message state // Note: does not reset the message state
this.setState({ this.setState({
status: ComposeStatus.REJECTED, status: rejectedStatus,
error: body.rejected, error: body.rejected,
appealId: body.appealId appealId: body.appealId,
muteOpen: muteOpen,
muteExpiresAt: muteExpiresAt
}); });
return; return;
} }
@ -92,6 +110,44 @@ class ComposeComment extends React.Component {
this.props.onAddComment(body); this.props.onAddComment(body);
}); });
} }
handleMuteClose () {
this.setState({
muteOpen: false
});
}
handleMuteOpen () {
this.setState({
muteOpen: true
});
}
shouldShowMuteModal (offensesList) {
// We should show the mute modal whne 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.
// A newly muted user has only 1 offense and it happened in the last coulpe of minutes.
// If a user has more than 1 offense, it means that they have have been muted in the
// last week.
// Assumption: The offenses list is ordered by time with the most recent at the end.
// This check is here just in case we somehow get bad data back from a backend.
if (!offensesList) {
return false;
}
const numOffenses = offensesList.length;
// This isn't intended to be called if there are no offenses, but
// say no just in case.
if (numOffenses === 0) {
return false;
}
const mostRecent = offensesList[numOffenses - 1];
const creationTimeMinutesAgo = (Date.now() - (mostRecent.createdAt * 1000)) / (60 * 1000);
return creationTimeMinutesAgo < 2 && numOffenses === 1;
}
handleCancel () { handleCancel () {
this.setState({ this.setState({
message: '', message: '',
@ -103,70 +159,101 @@ class ComposeComment extends React.Component {
} }
render () { render () {
return ( return (
<div <React.Fragment>
className="flex-row comment" {this.state.status === ComposeStatus.REJECTED_MUTE ? (
> <FlexRow className="comment">
<a href={`/users/${this.props.user.username}`}> <CommentingStatus>
<Avatar src={this.props.user.thumbnailUrl} /> <p>Scratch thinks your comment was disrespectful.</p>
</a> <p>
<FlexRow className="compose-comment column"> For the next {this.state.muteExpiresAt} you
{this.state.error ? ( won&apos;t be able to post comments.
<FlexRow className="compose-error-row"> Once {this.state.muteExpiresAt} have passed,
<div className="compose-error-tip"> you will be able to comment again.
<FormattedMessage </p>
id={`comments.${this.state.error}`} <p className="bottom-text">For more information,
values={{ <a
appealId: this.state.appealId href="#comment"
}} onClick={this.handleMuteOpen}
/> > click here</a>.</p>
</div> </CommentingStatus>
</FlexRow> </FlexRow>
) : null} ) : null }
<Formsy className="full-width-form"> <div
<InplaceInput className="flex-row comment"
className={classNames('compose-input', >
MAX_COMMENT_LENGTH - this.state.message.length >= 0 ? <a href={`/users/${this.props.user.username}`}>
'compose-valid' : 'compose-invalid')} <Avatar src={this.props.user.thumbnailUrl} />
handleUpdate={onUpdate} </a>
name="compose-comment" <FlexRow className="compose-comment column">
type="textarea" {this.state.error && this.state.status !== ComposeStatus.REJECTED_MUTE ? (
value={this.state.message} <FlexRow className="compose-error-row">
onInput={this.handleInput} <div className="compose-error-tip">
/> <FormattedMessage
<FlexRow className="compose-bottom-row"> id={`comments.${this.state.error}`}
<Button values={{
className="compose-post" appealId: this.state.appealId
disabled={this.state.status === ComposeStatus.SUBMITTING} }}
onClick={this.handlePost} />
> </div>
{this.state.status === ComposeStatus.SUBMITTING ? ( </FlexRow>
<FormattedMessage id="comments.posting" /> ) : null}
) : ( <Formsy className="full-width-form">
<FormattedMessage id="comments.post" /> <InplaceInput
)} className={classNames('compose-input',
</Button>
<Button
className="compose-cancel"
onClick={this.handleCancel}
>
<FormattedMessage id="comments.cancel" />
</Button>
<span
className={classNames('compose-limit',
MAX_COMMENT_LENGTH - this.state.message.length >= 0 ? MAX_COMMENT_LENGTH - this.state.message.length >= 0 ?
'compose-valid' : 'compose-invalid')} 'compose-valid' : 'compose-invalid')}
> handleUpdate={onUpdate}
<FormattedMessage name="compose-comment"
id="comments.lengthWarning" type="textarea"
values={{ value={this.state.message}
remainingCharacters: MAX_COMMENT_LENGTH - this.state.message.length onInput={this.handleInput}
}} />
/> <FlexRow className="compose-bottom-row">
</span> <Button
</FlexRow> className="compose-post"
</Formsy> disabled={this.state.status === ComposeStatus.SUBMITTING}
</FlexRow> onClick={this.handlePost}
</div> >
{this.state.status === ComposeStatus.SUBMITTING ? (
<FormattedMessage id="comments.posting" />
) : (
<FormattedMessage id="comments.post" />
)}
</Button>
<Button
className="compose-cancel"
onClick={this.handleCancel}
>
<FormattedMessage id="comments.cancel" />
</Button>
<span
className={classNames('compose-limit',
MAX_COMMENT_LENGTH - this.state.message.length >= 0 ?
'compose-valid' : 'compose-invalid')}
>
<FormattedMessage
id="comments.lengthWarning"
values={{
remainingCharacters: MAX_COMMENT_LENGTH - this.state.message.length
}}
/>
</span>
</FlexRow>
</Formsy>
</FlexRow>
{this.state.muteOpen ? (
<MuteModal
isOpen
showCloseButton
useStandardSizes
className="mod-mute"
shouldCloseOnOverlayClick={false}
timeMuted={this.state.muteExpiresAt}
onRequestClose={this.handleMuteClose}
/>
) : null}
</div>
</React.Fragment>
); );
} }
} }

View file

@ -0,0 +1,133 @@
const React = require('react');
const {shallowWithIntl} = require('../../helpers/intl-helpers.jsx');
const ComposeComment = require('../../../src/views/preview/comment/compose-comment.jsx');
import configureStore from 'redux-mock-store';
describe('Compose Comment test', () => {
const mockStore = configureStore();
const defaultProps = () =>({
user: {
thumbnailUrl: 'scratch.mit.edu',
username: 'auser'
}
});
let store;
beforeEach(() => {
store = mockStore({
session: {
session: {
user: {}
}
}
});
});
const getComposeCommentWrapper = props => {
const wrapper = shallowWithIntl(
<ComposeComment
{...defaultProps()}
{...props}
/>
, {context: {store}}
);
return wrapper.dive(); // unwrap redux connect(injectIntl(JoinFlow))
};
test('Modal & Comment status do not show ', () => {
const component = getComposeCommentWrapper({});
// Comment compsoe box is there
expect(component.find('FlexRow.compose-comment').exists()).toEqual(true);
// No error message
expect(component.find('FlexRow.compose-error-row').exists()).toEqual(false);
expect(component.find('MuteModal').exists()).toEqual(false);
expect(component.find('CommentingStatus').exists()).toEqual(false);
});
test('Error messages shows when comment rejected ', () => {
const component = getComposeCommentWrapper({});
const commentInstance = component.instance();
commentInstance.setState({error: 'isFlood'});
component.update();
expect(component.find('FlexRow.compose-error-row').exists()).toEqual(true);
});
test('No error message shows when comment rejected because user muted ', () => {
const component = getComposeCommentWrapper({});
const commentInstance = component.instance();
commentInstance.setState({
error: 'isMuted',
status: 'REJECTED_MUTE'
});
component.update();
expect(component.find('FlexRow.compose-error-row').exists()).toEqual(false);
});
test('Comment Status shows when state is REJECTED_MUTE ', () => {
const component = getComposeCommentWrapper({});
const commentInstance = component.instance();
commentInstance.setState({status: 'REJECTED_MUTE'});
component.update();
expect(component.find('FlexRow.compose-comment').exists()).toEqual(true);
expect(component.find('MuteModal').exists()).toEqual(false);
expect(component.find('CommentingStatus').exists()).toEqual(true);
});
test('Mute Modal shows when muteOpen is true ', () => {
const component = getComposeCommentWrapper({});
const commentInstance = component.instance();
commentInstance.setState({muteOpen: true});
component.update();
expect(component.find('FlexRow.compose-comment').exists()).toEqual(true);
expect(component.find('MuteModal').exists()).toEqual(true);
});
test('shouldShowMuteModal is false when list is undefined ', () => {
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.shouldShowMuteModal()).toBe(false);
});
test('shouldShowMuteModal is false when list empty ', () => {
const offenses = [];
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.shouldShowMuteModal(offenses)).toBe(false);
});
test('shouldShowMuteModal is true when only 1 recent offesnse ', () => {
const offenses = [];
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,
};
offenses.push(offense);
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.shouldShowMuteModal(offenses)).toBe(true);
global.Date.now = realDateNow;
});
test('shouldShowMuteModal is false when multiple offenses, even if 1 is recent ', () => {
const offenses = [];
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 more than 2 minutes ago.
let offense = {
expiresAt: '1000',
createdAt: '-119' // just shy of two min ago
};
offenses.push(offense);
offense.createdAt = '-180'; // 3 minutes ago;
offenses.push(offense);
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.shouldShowMuteModal(offenses)).toBe(false);
global.Date.now = realDateNow;
});
});