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

This commit is contained in:
Eugen Rochko 2017-02-14 20:59:26 +01:00
parent 94a6dfab3a
commit 2cc31b3194
26 changed files with 480 additions and 10 deletions

View file

@ -1,4 +1,4 @@
import api from '../api'
import api from '../api';
import { updateTimeline } from './timelines';

View file

@ -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
};
};

View file

@ -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 (

View file

@ -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>

View file

@ -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));
}
});

View file

@ -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}>

View file

@ -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>
);

View file

@ -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));
}
});

View file

@ -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 (
<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;

View file

@ -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);

View file

@ -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 (
<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));

View file

@ -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 (

View file

@ -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>

View file

@ -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
});

View file

@ -0,0 +1,57 @@
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;
}
};

View file

@ -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%);
}
}
.status__prepend {
margin-left: 68px;
color: lighten($color1, 26%);
@ -1142,3 +1150,35 @@ button.active i.fa-retweet {
color: $color3;
}
.report__target {
border-bottom: 1px solid lighten($color1, 4%);
color: $color2;
padding-bottom: 10px;
strong {
display: block;
color: $color5;
font-weight: 500;
}
}
.report__textarea {
background: transparent;
box-sizing: border-box;
border: 0;
border-bottom: 2px solid $color3;
border-radius: 2px 2px 0 0;
padding: 7px 4px;
font-size: 14px;
color: $color5;
display: block;
width: 100%;
outline: 0;
font-family: inherit;
resize: vertical;
&:active, &:focus {
border-bottom-color: $color4;
background: rgba($color8, 0.1);
}
}

View file

@ -93,6 +93,7 @@ code {
width: 100%;
outline: 0;
font-family: inherit;
resize: vertical;
&:invalid {
box-shadow: none;

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
class Api::V1::ReportsController < ApiController
before_action -> { doorkeeper_authorize! :read }, except: [:create]
before_action -> { doorkeeper_authorize! :write }, only: [:create]
before_action :require_user!
respond_to :json
def index
@reports = Report.where(account: current_account)
end
def create
status_ids = params[:status_ids].is_a?(Enumerable) ? params[:status_ids] : [params[:status_ids]]
@report = Report.create!(account: current_account,
target_account: Account.find(params[:account_id]),
status_ids: Status.find(status_ids).pluck(:id),
comment: params[:comment])
render :show
end
end

9
app/models/report.rb Normal file
View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Report < ApplicationRecord
belongs_to :account
belongs_to :target_account, class_name: 'Account'
scope :unresolved, -> { where(action_taken: false) }
scope :resolved, -> { where(action_taken: true) }
end

View file

@ -0,0 +1,2 @@
collection @reports
extends 'api/v1/reports/show'

View file

@ -0,0 +1,2 @@
object @report
attributes :id, :action_taken

View file

@ -115,6 +115,7 @@ Rails.application.routes.draw do
resources :apps, only: [:create]
resources :blocks, only: [:index]
resources :favourites, only: [:index]
resources :reports, only: [:index, :create]
resources :follow_requests, only: [:index] do
member do

View file

@ -0,0 +1,13 @@
class CreateReports < ActiveRecord::Migration[5.0]
def change
create_table :reports do |t|
t.integer :account_id, null: false
t.integer :target_account_id, null: false
t.integer :status_ids, array: true, null: false, default: []
t.text :comment, null: false, default: ''
t.boolean :action_taken, null: false, default: false
t.timestamps
end
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170209184350) do
ActiveRecord::Schema.define(version: 20170214110202) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -173,6 +173,16 @@ ActiveRecord::Schema.define(version: 20170209184350) do
t.index ["status_id"], name: "index_preview_cards_on_status_id", unique: true, using: :btree
end
create_table "reports", force: :cascade do |t|
t.integer "account_id", null: false
t.integer "target_account_id", null: false
t.integer "status_ids", default: [], null: false, array: true
t.text "comment", default: "", null: false
t.boolean "action_taken", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "settings", force: :cascade do |t|
t.string "var", null: false
t.text "value"

View file

@ -0,0 +1,4 @@
Fabricator(:report) do
comment "You nasty"
action_taken false
end

View file

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe Report, type: :model do
end