Commit 3b81baaa authored by Eugen Rochko's avatar Eugen Rochko

Adding POST /api/v1/reports API, and a UI for submitting reports

parent 40a40537
import api from '../api'
import api from '../api';
import { updateTimeline } from './timelines';
......
import api from '../api';
export const REPORT_INIT = 'REPORT_INIT';
export const REPORT_CANCEL = 'REPORT_CANCEL';
export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST';
export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS';
export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL';
export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE';
export function initReport(account, status) {
return {
type: REPORT_INIT,
account,
status
};
};
export function cancelReport() {
return {
type: REPORT_CANCEL
};
};
export function toggleStatusReport(statusId, checked) {
return {
type: REPORT_STATUS_TOGGLE,
statusId,
checked,
};
};
export function submitReport() {
return (dispatch, getState) => {
dispatch(submitReportRequest());
api(getState).post('/api/v1/reports', {
account_id: getState().getIn(['reports', 'new', 'account_id']),
status_ids: getState().getIn(['reports', 'new', 'status_ids']),
comment: getState().getIn(['reports', 'new', 'comment'])
}).then(response => dispatch(submitReportSuccess(response.data))).catch(error => dispatch(submitReportFail(error)));
};
};
export function submitReportRequest() {
return {
type: REPORT_SUBMIT_REQUEST
};
};
export function submitReportSuccess(report) {
return {
type: REPORT_SUBMIT_SUCCESS,
report
};
};
export function submitReportFail(error) {
return {
type: REPORT_SUBMIT_FAIL,
error
};
};
......@@ -11,7 +11,8 @@ const messages = defineMessages({
reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
open: { id: 'status.open', defaultMessage: 'Expand' }
open: { id: 'status.open', defaultMessage: 'Expand' },
report: { id: 'status.report', defaultMessage: 'Report' }
});
const StatusActionBar = React.createClass({
......@@ -27,7 +28,10 @@ const StatusActionBar = React.createClass({
onReblog: React.PropTypes.func,
onDelete: React.PropTypes.func,
onMention: React.PropTypes.func,
onBlock: React.PropTypes.func
onBlock: React.PropTypes.func,
onReport: React.PropTypes.func,
me: React.PropTypes.number.isRequired,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
......@@ -60,6 +64,11 @@ const StatusActionBar = React.createClass({
this.context.router.push(`/statuses/${this.props.status.get('id')}`);
},
handleReport () {
this.props.onReport(this.props.status);
this.context.router.push('/report');
},
render () {
const { status, me, intl } = this.props;
let menu = [];
......@@ -71,6 +80,7 @@ const StatusActionBar = React.createClass({
} else {
menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.block), action: this.handleBlockClick });
menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport });
}
return (
......
......@@ -34,6 +34,7 @@ import FollowRequests from '../features/follow_requests';
import GenericNotFound from '../features/generic_not_found';
import FavouritedStatuses from '../features/favourited_statuses';
import Blocks from '../features/blocks';
import Report from '../features/report';
import { IntlProvider, addLocaleData } from 'react-intl';
import en from 'react-intl/locale-data/en';
import de from 'react-intl/locale-data/de';
......@@ -131,6 +132,7 @@ const Mastodon = React.createClass({
<Route path='follow_requests' component={FollowRequests} />
<Route path='blocks' component={Blocks} />
<Route path='report' component={Report} />
<Route path='*' component={GenericNotFound} />
</Route>
......
......@@ -13,6 +13,7 @@ import {
} from '../actions/interactions';
import { blockAccount } from '../actions/accounts';
import { deleteStatus } from '../actions/statuses';
import { initReport } from '../actions/reports';
import { openMedia } from '../actions/modal';
import { createSelector } from 'reselect'
import { isMobile } from '../is_mobile'
......@@ -97,6 +98,10 @@ const mapDispatchToProps = (dispatch) => ({
onBlock (account) {
dispatch(blockAccount(account.get('id')));
},
onReport (status) {
dispatch(initReport(status.get('account'), status));
}
});
......
......@@ -11,7 +11,8 @@ const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
block: { id: 'account.block', defaultMessage: 'Block' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
block: { id: 'account.block', defaultMessage: 'Block' }
block: { id: 'account.block', defaultMessage: 'Block' },
report: { id: 'account.report', defaultMessage: 'Report' }
});
const outerDropdownStyle = {
......@@ -32,7 +33,9 @@ const ActionBar = React.createClass({
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func,
onBlock: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired
onMention: React.PropTypes.func.isRequired,
onReport: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
......@@ -54,6 +57,10 @@ const ActionBar = React.createClass({
menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
}
if (account.get('id') !== me) {
menu.push({ text: intl.formatMessage(messages.report), action: this.props.onReport });
}
return (
<div className='account__action-bar'>
<div style={outerDropdownStyle}>
......
......@@ -13,7 +13,8 @@ const Header = React.createClass({
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired,
onBlock: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired
onMention: React.PropTypes.func.isRequired,
onReport: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
......@@ -30,6 +31,11 @@ const Header = React.createClass({
this.props.onMention(this.props.account, this.context.router);
},
handleReport () {
this.props.onReport(this.props.account);
this.context.router.push('/report');
},
render () {
const { account, me } = this.props;
......@@ -50,6 +56,7 @@ const Header = React.createClass({
me={me}
onBlock={this.handleBlock}
onMention={this.handleMention}
onReport={this.handleReport}
/>
</div>
);
......
......@@ -8,6 +8,7 @@ import {
unblockAccount
} from '../../../actions/accounts';
import { mentionCompose } from '../../../actions/compose';
import { initReport } from '../../../actions/reports';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
......@@ -39,6 +40,10 @@ const mapDispatchToProps = dispatch => ({
onMention (account, router) {
dispatch(mentionCompose(account, router));
},
onReport (account) {
dispatch(initReport(account));
}
});
......
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import emojify from '../../../emoji';
import Toggle from 'react-toggle';
const StatusCheckBox = React.createClass({
propTypes: {
status: ImmutablePropTypes.map.isRequired,
checked: React.PropTypes.bool,
onToggle: React.PropTypes.func.isRequired,
disabled: React.PropTypes.bool
},
mixins: [PureRenderMixin],
render () {
const { status, checked, onToggle, disabled } = this.props;
const content = { __html: emojify(status.get('content')) };
return (
<div className='status-check-box' style={{ display: 'flex' }}>
<div
className='status__content'
style={{ flex: '1 1 auto', padding: '10px' }}
dangerouslySetInnerHTML={content}
/>
<div style={{ flex: '0 0 auto', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Toggle checked={checked} onChange={onToggle} disabled={disabled} />
</div>
</div>
);
}
});
export default StatusCheckBox;
import { connect } from 'react-redux';
import StatusCheckBox from '../components/status_check_box';
import { toggleStatusReport } from '../../../actions/reports';
import Immutable from 'immutable';
const mapStateToProps = (state, { id }) => ({
status: state.getIn(['statuses', id]),
checked: state.getIn(['reports', 'new', 'status_ids'], Immutable.Set()).includes(id)
});
const mapDispatchToProps = (dispatch, { id }) => ({
onToggle (e) {
dispatch(toggleStatusReport(id, e.target.checked));
}
});
export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox);
import { connect } from 'react-redux';
import { cancelReport, changeReportComment, submitReport } from '../../actions/reports';
import { fetchAccountTimeline } from '../../actions/accounts';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Column from '../ui/components/column';
import Button from '../../components/button';
import { makeGetAccount } from '../../selectors';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import StatusCheckBox from './containers/status_check_box_container';
import Immutable from 'immutable';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
const messages = defineMessages({
heading: { id: 'report.heading', defaultMessage: 'New report' },
placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
submit: { id: 'report.submit', defaultMessage: 'Submit' }
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = state => {
const accountId = state.getIn(['reports', 'new', 'account_id']);
return {
isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
account: getAccount(state, accountId),
comment: state.getIn(['reports', 'new', 'comment']),
statusIds: state.getIn(['timelines', 'accounts_timelines', accountId, 'items'], Immutable.List())
};
};
return mapStateToProps;
};
const textareaStyle = {
marginBottom: '10px'
};
const Report = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
propTypes: {
isSubmitting: React.PropTypes.bool,
account: ImmutablePropTypes.map,
statusIds: ImmutablePropTypes.list.isRequired,
comment: React.PropTypes.string.isRequired,
dispatch: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
componentWillMount () {
if (!this.props.account) {
this.context.router.replace('/');
}
},
componentDidMount () {
if (!this.props.account) {
return;
}
this.props.dispatch(fetchAccountTimeline(this.props.account.get('id')));
},
componentWillReceiveProps (nextProps) {
if (this.props.account !== nextProps.account && nextProps.account) {
this.props.dispatch(fetchAccountTimeline(nextProps.account.get('id')));
}
},
handleCommentChange (e) {
this.props.dispatch(changeReportComment(e.target.value));
},
handleSubmit () {
this.props.dispatch(submitReport());
this.context.router.replace('/');
},
render () {
const { account, comment, intl, statusIds, isSubmitting } = this.props;
if (!account) {
return null;
}
return (
<Column heading={intl.formatMessage(messages.heading)} icon='flag'>
<ColumnBackButtonSlim />
<div className='report' style={{ display: 'flex', flexDirection: 'column', maxHeight: '100%', boxSizing: 'border-box' }}>
<div className='report__target' style={{ flex: '0 0 auto', padding: '10px' }}>
<FormattedMessage id='report.target' defaultMessage='Reporting' />
<strong>{account.get('acct')}</strong>
</div>
<div style={{ flex: '1 1 auto' }} className='scrollable'>
<div>
{statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)}
</div>
</div>
<div style={{ flex: '0 0 160px', padding: '10px' }}>
<textarea
className='report__textarea'
placeholder={intl.formatMessage(messages.placeholder)}
value={comment}
onChange={this.handleCommentChange}
style={textareaStyle}
disabled={isSubmitting}
/>
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
<div style={{ float: 'right' }}><Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /></div>
</div>
</div>
</div>
</Column>
);
}
});
export default connect(makeMapStateToProps)(injectIntl(Report));
......@@ -9,7 +9,8 @@ const messages = defineMessages({
mention: { id: 'status.mention', defaultMessage: 'Mention' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
report: { id: 'status.report', defaultMessage: 'Report' }
});
const ActionBar = React.createClass({
......@@ -25,6 +26,7 @@ const ActionBar = React.createClass({
onFavourite: React.PropTypes.func.isRequired,
onDelete: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired,
onReport: React.PropTypes.func,
me: React.PropTypes.number.isRequired,
intl: React.PropTypes.object.isRequired
},
......@@ -51,6 +53,11 @@ const ActionBar = React.createClass({
this.props.onMention(this.props.status.get('account'), this.context.router);
},
handleReport () {
this.props.onReport(this.props.status);
this.context.router.push('/report');
},
render () {
const { status, me, intl } = this.props;
......@@ -60,6 +67,7 @@ const ActionBar = React.createClass({
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else {
menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport });
}
return (
......
......@@ -14,6 +14,7 @@ import {
mentionCompose
} from '../../actions/compose';
import { deleteStatus } from '../../actions/statuses';
import { initReport } from '../../actions/reports';
import {
makeGetStatus,
getStatusAncestors,
......@@ -88,6 +89,10 @@ const Status = React.createClass({
this.props.dispatch(openMedia(media, index));
},
handleReport (status) {
this.props.dispatch(initReport(status.get('account'), status));
},
renderChildren (list) {
return list.map(id => <StatusContainer key={id} id={id} />);
},
......@@ -123,7 +128,7 @@ const Status = React.createClass({
{ancestors}
<DetailedStatus status={status} me={me} onOpenMedia={this.handleOpenMedia} />
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} />
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} />
{descendants}
</div>
......
......@@ -14,6 +14,7 @@ import notifications from './notifications';
import settings from './settings';
import status_lists from './status_lists';
import cards from './cards';
import reports from './reports';
export default combineReducers({
timelines,
......@@ -30,5 +31,6 @@ export default combineReducers({
search,
notifications,
settings,
cards
cards,
reports
});
import {
REPORT_INIT,
REPORT_SUBMIT_REQUEST,
REPORT_SUBMIT_SUCCESS,
REPORT_SUBMIT_FAIL,
REPORT_CANCEL,
REPORT_STATUS_TOGGLE
} from '../actions/reports';
import Immutable from 'immutable';
const initialState = Immutable.Map({
new: Immutable.Map({
isSubmitting: false,
account_id: null,
status_ids: Immutable.Set(),
comment: ''
})
});
export default function reports(state = initialState, action) {
switch(action.type) {
case REPORT_INIT:
return state.withMutations(map => {
map.setIn(['new', 'isSubmitting'], false);
map.setIn(['new', 'account_id'], action.account.get('id'));
if (state.getIn(['new', 'account_id']) !== action.account.get('id')) {
map.setIn(['new', 'status_ids'], action.status ? Immutable.Set([action.status.get('id')]) : Immutable.Set());
map.setIn(['new', 'comment'], '');
} else {
map.updateIn(['new', 'status_ids'], Immutable.Set(), set => set.add(action.status.get('id')));
}
});
case REPORT_STATUS_TOGGLE:
return state.updateIn(['new', 'status_ids'], Immutable.Set(), set => {
if (action.checked) {
return set.add(action.statusId);
}
return set.remove(action.statusId);
});
case REPORT_SUBMIT_REQUEST:
return state.setIn(['new', 'isSubmitting'], true);
case REPORT_SUBMIT_FAIL:
return state.setIn(['new', 'isSubmitting'], false);
case REPORT_CANCEL:
case REPORT_SUBMIT_SUCCESS:
return state.withMutations(map => {
map.setIn(['new', 'account_id'], null);
map.setIn(['new', 'status_ids'], Immutable.Set());
map.setIn(['new', 'comment'], '');
map.setIn(['new', 'isSubmitting'], false);
});
default:
return state;
}
};
......@@ -228,6 +228,14 @@ a.status__content__spoiler-link {
}
}
.status-check-box {
border-bottom: 1px solid lighten($color1, 8%);
.status__content {
background: lighten($color1, 4%);