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 InplaceInput = require('../../../components/forms/inplace-input.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;
@ -24,7 +26,8 @@ const MAX_COMMENT_LENGTH = 500;
const ComposeStatus = keyMirror({
EDITING: null,
SUBMITTING: null,
REJECTED: null
REJECTED: null,
REJECTED_MUTE: null
});
class ComposeComment extends React.Component {
@ -33,13 +36,17 @@ class ComposeComment extends React.Component {
bindAll(this, [
'handlePost',
'handleCancel',
'handleInput'
'handleInput',
'handleMuteClose',
'handleMuteOpen'
]);
this.state = {
message: '',
status: ComposeStatus.EDITING,
error: null,
appealId: null
appealId: null,
muteOpen: false
};
}
handleInput (event) {
@ -67,13 +74,24 @@ class ComposeComment extends React.Component {
if (err || res.statusCode !== 200) {
body = {rejected: 'error'};
}
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
this.setState({
status: ComposeStatus.REJECTED,
status: rejectedStatus,
error: body.rejected,
appealId: body.appealId
appealId: body.appealId,
muteOpen: muteOpen,
muteExpiresAt: muteExpiresAt
});
return;
}
@ -92,6 +110,44 @@ class ComposeComment extends React.Component {
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 () {
this.setState({
message: '',
@ -103,70 +159,101 @@ class ComposeComment extends React.Component {
}
render () {
return (
<div
className="flex-row comment"
>
<a href={`/users/${this.props.user.username}`}>
<Avatar src={this.props.user.thumbnailUrl} />
</a>
<FlexRow className="compose-comment column">
{this.state.error ? (
<FlexRow className="compose-error-row">
<div className="compose-error-tip">
<FormattedMessage
id={`comments.${this.state.error}`}
values={{
appealId: this.state.appealId
}}
/>
</div>
</FlexRow>
) : null}
<Formsy className="full-width-form">
<InplaceInput
className={classNames('compose-input',
MAX_COMMENT_LENGTH - this.state.message.length >= 0 ?
'compose-valid' : 'compose-invalid')}
handleUpdate={onUpdate}
name="compose-comment"
type="textarea"
value={this.state.message}
onInput={this.handleInput}
/>
<FlexRow className="compose-bottom-row">
<Button
className="compose-post"
disabled={this.state.status === ComposeStatus.SUBMITTING}
onClick={this.handlePost}
>
{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',
<React.Fragment>
{this.state.status === ComposeStatus.REJECTED_MUTE ? (
<FlexRow className="comment">
<CommentingStatus>
<p>Scratch thinks your comment was disrespectful.</p>
<p>
For the next {this.state.muteExpiresAt} you
won&apos;t be able to post comments.
Once {this.state.muteExpiresAt} have passed,
you will be able to comment again.
</p>
<p className="bottom-text">For more information,
<a
href="#comment"
onClick={this.handleMuteOpen}
> click here</a>.</p>
</CommentingStatus>
</FlexRow>
) : null }
<div
className="flex-row comment"
>
<a href={`/users/${this.props.user.username}`}>
<Avatar src={this.props.user.thumbnailUrl} />
</a>
<FlexRow className="compose-comment column">
{this.state.error && this.state.status !== ComposeStatus.REJECTED_MUTE ? (
<FlexRow className="compose-error-row">
<div className="compose-error-tip">
<FormattedMessage
id={`comments.${this.state.error}`}
values={{
appealId: this.state.appealId
}}
/>
</div>
</FlexRow>
) : null}
<Formsy className="full-width-form">
<InplaceInput
className={classNames('compose-input',
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>
</div>
handleUpdate={onUpdate}
name="compose-comment"
type="textarea"
value={this.state.message}
onInput={this.handleInput}
/>
<FlexRow className="compose-bottom-row">
<Button
className="compose-post"
disabled={this.state.status === ComposeStatus.SUBMITTING}
onClick={this.handlePost}
>
{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;
});
});