From 3b81baaaaf51ff1c70fb1f865eef07fdb33a5950 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 14 Feb 2017 20:59:26 +0100 Subject: [PATCH] Adding POST /api/v1/reports API, and a UI for submitting reports --- .../components/actions/compose.jsx | 2 +- .../components/actions/reports.jsx | 64 +++++++++ .../components/status_action_bar.jsx | 14 +- .../components/containers/mastodon.jsx | 2 + .../containers/status_container.jsx | 5 + .../account/components/action_bar.jsx | 11 +- .../account_timeline/components/header.jsx | 9 +- .../containers/header_container.jsx | 5 + .../report/components/status_check_box.jsx | 38 +++++ .../containers/status_check_box_container.jsx | 19 +++ .../components/features/report/index.jsx | 130 ++++++++++++++++++ .../features/status/components/action_bar.jsx | 10 +- .../components/features/status/index.jsx | 7 +- .../javascripts/components/reducers/index.jsx | 4 +- .../components/reducers/reports.jsx | 57 ++++++++ app/assets/stylesheets/components.scss | 40 ++++++ app/assets/stylesheets/forms.scss | 1 + app/controllers/api/v1/reports_controller.rb | 24 ++++ app/models/report.rb | 9 ++ app/views/api/v1/reports/index.rabl | 2 + app/views/api/v1/reports/show.rabl | 2 + config/routes.rb | 1 + db/migrate/20170214110202_create_reports.rb | 13 ++ db/schema.rb | 12 +- spec/fabricators/report_fabricator.rb | 4 + spec/models/report_spec.rb | 5 + 26 files changed, 480 insertions(+), 10 deletions(-) create mode 100644 app/assets/javascripts/components/actions/reports.jsx create mode 100644 app/assets/javascripts/components/features/report/components/status_check_box.jsx create mode 100644 app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx create mode 100644 app/assets/javascripts/components/features/report/index.jsx create mode 100644 app/assets/javascripts/components/reducers/reports.jsx create mode 100644 app/controllers/api/v1/reports_controller.rb create mode 100644 app/models/report.rb create mode 100644 app/views/api/v1/reports/index.rabl create mode 100644 app/views/api/v1/reports/show.rabl create mode 100644 db/migrate/20170214110202_create_reports.rb create mode 100644 spec/fabricators/report_fabricator.rb create mode 100644 spec/models/report_spec.rb diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index f87518751..03aae885e 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -1,4 +1,4 @@ -import api from '../api' +import api from '../api'; import { updateTimeline } from './timelines'; diff --git a/app/assets/javascripts/components/actions/reports.jsx b/app/assets/javascripts/components/actions/reports.jsx new file mode 100644 index 000000000..2c1245dc4 --- /dev/null +++ b/app/assets/javascripts/components/actions/reports.jsx @@ -0,0 +1,64 @@ +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 + }; +}; diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx index f2cc1fb12..35c458b5e 100644 --- a/app/assets/javascripts/components/components/status_action_bar.jsx +++ b/app/assets/javascripts/components/components/status_action_bar.jsx @@ -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 ( diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index e23c65121..ebef5c81b 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -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({ + diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx index f5fb09d52..fc096a375 100644 --- a/app/assets/javascripts/components/containers/status_container.jsx +++ b/app/assets/javascripts/components/containers/status_container.jsx @@ -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)); } }); diff --git a/app/assets/javascripts/components/features/account/components/action_bar.jsx b/app/assets/javascripts/components/features/account/components/action_bar.jsx index fe110954d..a2ab8172b 100644 --- a/app/assets/javascripts/components/features/account/components/action_bar.jsx +++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx @@ -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 (
diff --git a/app/assets/javascripts/components/features/account_timeline/components/header.jsx b/app/assets/javascripts/components/features/account_timeline/components/header.jsx index ff3e8af2d..0cdfc8b02 100644 --- a/app/assets/javascripts/components/features/account_timeline/components/header.jsx +++ b/app/assets/javascripts/components/features/account_timeline/components/header.jsx @@ -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} />
); diff --git a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx index dca826596..e4ce905fe 100644 --- a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx +++ b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx @@ -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)); } }); diff --git a/app/assets/javascripts/components/features/report/components/status_check_box.jsx b/app/assets/javascripts/components/features/report/components/status_check_box.jsx new file mode 100644 index 000000000..df4a31457 --- /dev/null +++ b/app/assets/javascripts/components/features/report/components/status_check_box.jsx @@ -0,0 +1,38 @@ +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 ( +
+
+ +
+ +
+
+ ); + } + +}); + +export default StatusCheckBox; diff --git a/app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx b/app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx new file mode 100644 index 000000000..67ce9d9f3 --- /dev/null +++ b/app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx @@ -0,0 +1,19 @@ +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); diff --git a/app/assets/javascripts/components/features/report/index.jsx b/app/assets/javascripts/components/features/report/index.jsx new file mode 100644 index 000000000..eb8d28fe8 --- /dev/null +++ b/app/assets/javascripts/components/features/report/index.jsx @@ -0,0 +1,130 @@ +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 ( + + +
+
+ + {account.get('acct')} +
+ +
+
+ {statusIds.map(statusId => )} +
+
+ +
+