Merge branch 'reports' into 'develop'
Reports See merge request soapbox-pub/soapbox-fe!409
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import api from '../api';
|
||||
import { importFetchedStatuses } from 'soapbox/actions/importer';
|
||||
|
||||
export const ADMIN_CONFIG_FETCH_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST';
|
||||
export const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS';
|
||||
@@ -12,6 +13,10 @@ export const ADMIN_REPORTS_FETCH_REQUEST = 'ADMIN_REPORTS_FETCH_REQUEST';
|
||||
export const ADMIN_REPORTS_FETCH_SUCCESS = 'ADMIN_REPORTS_FETCH_SUCCESS';
|
||||
export const ADMIN_REPORTS_FETCH_FAIL = 'ADMIN_REPORTS_FETCH_FAIL';
|
||||
|
||||
export const ADMIN_REPORTS_PATCH_REQUEST = 'ADMIN_REPORTS_PATCH_REQUEST';
|
||||
export const ADMIN_REPORTS_PATCH_SUCCESS = 'ADMIN_REPORTS_PATCH_SUCCESS';
|
||||
export const ADMIN_REPORTS_PATCH_FAIL = 'ADMIN_REPORTS_PATCH_FAIL';
|
||||
|
||||
export const ADMIN_USERS_FETCH_REQUEST = 'ADMIN_USERS_FETCH_REQUEST';
|
||||
export const ADMIN_USERS_FETCH_SUCCESS = 'ADMIN_USERS_FETCH_SUCCESS';
|
||||
export const ADMIN_USERS_FETCH_FAIL = 'ADMIN_USERS_FETCH_FAIL';
|
||||
@@ -24,6 +29,14 @@ export const ADMIN_USERS_APPROVE_REQUEST = 'ADMIN_USERS_APPROVE_REQUEST';
|
||||
export const ADMIN_USERS_APPROVE_SUCCESS = 'ADMIN_USERS_APPROVE_SUCCESS';
|
||||
export const ADMIN_USERS_APPROVE_FAIL = 'ADMIN_USERS_APPROVE_FAIL';
|
||||
|
||||
export const ADMIN_USERS_DEACTIVATE_REQUEST = 'ADMIN_USERS_DEACTIVATE_REQUEST';
|
||||
export const ADMIN_USERS_DEACTIVATE_SUCCESS = 'ADMIN_USERS_DEACTIVATE_SUCCESS';
|
||||
export const ADMIN_USERS_DEACTIVATE_FAIL = 'ADMIN_USERS_DEACTIVATE_FAIL';
|
||||
|
||||
export const ADMIN_STATUS_DELETE_REQUEST = 'ADMIN_STATUS_DELETE_REQUEST';
|
||||
export const ADMIN_STATUS_DELETE_SUCCESS = 'ADMIN_STATUS_DELETE_SUCCESS';
|
||||
export const ADMIN_STATUS_DELETE_FAIL = 'ADMIN_STATUS_DELETE_FAIL';
|
||||
|
||||
export function fetchConfig() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: ADMIN_CONFIG_FETCH_REQUEST });
|
||||
@@ -55,14 +68,32 @@ export function fetchReports(params) {
|
||||
dispatch({ type: ADMIN_REPORTS_FETCH_REQUEST, params });
|
||||
return api(getState)
|
||||
.get('/api/pleroma/admin/reports', { params })
|
||||
.then(({ data }) => {
|
||||
dispatch({ type: ADMIN_REPORTS_FETCH_SUCCESS, data, params });
|
||||
.then(({ data: { reports } }) => {
|
||||
reports.forEach(report => dispatch(importFetchedStatuses(report.statuses)));
|
||||
dispatch({ type: ADMIN_REPORTS_FETCH_SUCCESS, reports, params });
|
||||
}).catch(error => {
|
||||
dispatch({ type: ADMIN_REPORTS_FETCH_FAIL, error, params });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function patchReports(ids, state) {
|
||||
const reports = ids.map(id => ({ id, state }));
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: ADMIN_REPORTS_PATCH_REQUEST, reports });
|
||||
return api(getState)
|
||||
.patch('/api/pleroma/admin/reports', { reports })
|
||||
.then(() => {
|
||||
dispatch({ type: ADMIN_REPORTS_PATCH_SUCCESS, reports });
|
||||
}).catch(error => {
|
||||
dispatch({ type: ADMIN_REPORTS_PATCH_FAIL, error, reports });
|
||||
});
|
||||
};
|
||||
}
|
||||
export function closeReports(ids) {
|
||||
return patchReports(ids, 'closed');
|
||||
}
|
||||
|
||||
export function fetchUsers(params) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: ADMIN_USERS_FETCH_REQUEST, params });
|
||||
@@ -76,6 +107,19 @@ export function fetchUsers(params) {
|
||||
};
|
||||
}
|
||||
|
||||
export function deactivateUsers(nicknames) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: ADMIN_USERS_DEACTIVATE_REQUEST, nicknames });
|
||||
return api(getState)
|
||||
.patch('/api/pleroma/admin/users/deactivate', { nicknames })
|
||||
.then(({ data: { users } }) => {
|
||||
dispatch({ type: ADMIN_USERS_DEACTIVATE_SUCCESS, users, nicknames });
|
||||
}).catch(error => {
|
||||
dispatch({ type: ADMIN_USERS_DEACTIVATE_FAIL, error, nicknames });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteUsers(nicknames) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: ADMIN_USERS_DELETE_REQUEST, nicknames });
|
||||
@@ -101,3 +145,16 @@ export function approveUsers(nicknames) {
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteStatus(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: ADMIN_STATUS_DELETE_REQUEST, id });
|
||||
return api(getState)
|
||||
.delete(`/api/pleroma/admin/statuses/${id}`)
|
||||
.then(() => {
|
||||
dispatch({ type: ADMIN_STATUS_DELETE_SUCCESS, id });
|
||||
}).catch(error => {
|
||||
dispatch({ type: ADMIN_STATUS_DELETE_FAIL, error, id });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Helmet } from'react-helmet';
|
||||
const getNotifTotals = state => {
|
||||
const notifications = state.getIn(['notifications', 'unread'], 0);
|
||||
const chats = state.get('chats').reduce((acc, curr) => acc + Math.min(curr.get('unread', 0), 1), 0);
|
||||
const reports = state.getIn(['admin', 'open_report_count'], 0);
|
||||
const reports = state.getIn(['admin', 'openReports']).count();
|
||||
const approvals = state.getIn(['admin', 'awaitingApproval']).count();
|
||||
return notifications + chats + reports + approvals;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ export default class IntersectionObserverArticle extends React.Component {
|
||||
|
||||
state = {
|
||||
isHidden: false, // set to true in requestIdleCallback to trigger un-render
|
||||
isIntersecting: true,
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
|
||||
@@ -68,15 +68,16 @@ class AwaitingApproval extends ImmutablePureComponent {
|
||||
render() {
|
||||
const { intl, users } = this.props;
|
||||
const { isLoading } = this.state;
|
||||
const showLoading = isLoading && users.count() === 0;
|
||||
|
||||
return (
|
||||
<Column icon='user' heading={intl.formatMessage(messages.heading)} backBtnSlim>
|
||||
<ScrollableList isLoading={isLoading} showLoading={isLoading} scrollKey='awaiting-approval' emptyMessage={intl.formatMessage(messages.emptyMessage)}>
|
||||
<ScrollableList isLoading={isLoading} showLoading={showLoading} scrollKey='awaiting-approval' emptyMessage={intl.formatMessage(messages.emptyMessage)}>
|
||||
{users.map((user, i) => (
|
||||
<div className='unapproved-account' key={user.get('id')}>
|
||||
<div className='unapproved-account__bio'>
|
||||
<div className='unapproved-account__nickname'>@{user.get('nickname')}</div>
|
||||
<blockquote className='unapproved-account__reason'>{user.get('registration_reason')}</blockquote>
|
||||
<blockquote className='md'>{user.get('registration_reason')}</blockquote>
|
||||
</div>
|
||||
<div className='unapproved-account__actions'>
|
||||
<IconButton icon='check' size={22} onClick={this.handleApprove(user.get('nickname'))} />
|
||||
|
||||
@@ -10,7 +10,7 @@ import { FormattedMessage } from 'react-intl';
|
||||
const mapStateToProps = (state, props) => ({
|
||||
instance: state.get('instance'),
|
||||
approvalCount: state.getIn(['admin', 'awaitingApproval']).count(),
|
||||
reportsCount: state.getIn(['admin', 'open_report_count']),
|
||||
reportsCount: state.getIn(['admin', 'openReports']).count(),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@@ -33,10 +33,10 @@ class AdminNav extends React.PureComponent {
|
||||
<Icon id='tachometer' className='promo-panel-item__icon' fixedWidth />
|
||||
<FormattedMessage id='admin_nav.dashboard' defaultMessage='Dashboard' />
|
||||
</NavLink>
|
||||
<a className='promo-panel-item' href='/pleroma/admin/#/reports/index' target='_blank'>
|
||||
<NavLink className='promo-panel-item' to='/admin/reports'>
|
||||
<IconWithCounter icon='gavel' count={reportsCount} fixedWidth />
|
||||
<FormattedMessage id='admin_nav.reports' defaultMessage='Reports' />
|
||||
</a>
|
||||
</NavLink>
|
||||
{((instance.get('registrations') && instance.get('approval_required')) || approvalCount > 0) && (
|
||||
<NavLink className='promo-panel-item' to='/admin/approval'>
|
||||
<IconWithCounter icon='user' count={approvalCount} fixedWidth />
|
||||
|
||||
@@ -17,7 +17,6 @@ const messages = defineMessages({
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
mode: modeFromInstance(state.get('instance')),
|
||||
openReportCount: state.getIn(['admin', 'open_report_count']),
|
||||
});
|
||||
|
||||
const generateConfig = mode => {
|
||||
|
||||
149
app/soapbox/features/admin/components/report.js
Normal file
149
app/soapbox/features/admin/components/report.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
import Avatar from 'soapbox/components/avatar';
|
||||
import Button from 'soapbox/components/button';
|
||||
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
||||
import Accordion from 'soapbox/features/ui/components/accordion';
|
||||
import ReportStatus from './report_status';
|
||||
import { closeReports, deactivateUsers, deleteUsers } from 'soapbox/actions/admin';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import { openModal } from 'soapbox/actions/modal';
|
||||
|
||||
const messages = defineMessages({
|
||||
reportClosed: { id: 'admin.reports.report_closed_message', defaultMessage: 'Report on {acct} was closed' },
|
||||
deactivateUser: { id: 'admin.reports.actions.deactivate_user', defaultMessage: 'Deactivate {acct}' },
|
||||
deactivateUserPrompt: { id: 'confirmations.admin.deactivate_user.message', defaultMessage: 'You are about to deactivate {acct}. Deactivating a user is a reversible action.' },
|
||||
deactivateUserConfirm: { id: 'confirmations.admin.deactivate_user.confirm', defaultMessage: 'Deactivate {acct}' },
|
||||
userDeactivated: { id: 'admin.reports.user_deactivated_message', defaultMessage: '{acct} was deactivated' },
|
||||
deleteUser: { id: 'admin.reports.actions.delete_user', defaultMessage: 'Delete {acct}' },
|
||||
deleteUserPrompt: { id: 'confirmations.admin.delete_user.message', defaultMessage: 'You are about to delete {acct}. THIS IS A DESTRUCTIVE ACTION THAT CANNOT BE UNDONE.' },
|
||||
deleteUserConfirm: { id: 'confirmations.admin.delete_user.confirm', defaultMessage: 'Delete {acct}' },
|
||||
userDeleted: { id: 'admin.reports.user_deleted_message', defaultMessage: '{acct} was deleted' },
|
||||
});
|
||||
|
||||
export default @connect()
|
||||
@injectIntl
|
||||
class Report extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
report: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
accordionExpanded: false,
|
||||
};
|
||||
|
||||
makeMenu = () => {
|
||||
const { intl, report } = this.props;
|
||||
|
||||
return [{
|
||||
text: intl.formatMessage(messages.deactivateUser, { acct: `@${report.getIn(['account', 'acct'])}` }),
|
||||
action: this.handleDeactivateUser,
|
||||
}, {
|
||||
text: intl.formatMessage(messages.deleteUser, { acct: `@${report.getIn(['account', 'acct'])}` }),
|
||||
action: this.handleDeleteUser,
|
||||
}];
|
||||
}
|
||||
|
||||
handleCloseReport = () => {
|
||||
const { intl, dispatch, report } = this.props;
|
||||
const nickname = report.getIn(['account', 'acct']);
|
||||
dispatch(closeReports([report.get('id')])).then(() => {
|
||||
const message = intl.formatMessage(messages.reportClosed, { acct: `@${nickname}` });
|
||||
dispatch(snackbar.success(message));
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
handleDeactivateUser = () => {
|
||||
const { intl, dispatch, report } = this.props;
|
||||
const nickname = report.getIn(['account', 'acct']);
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.deactivateUserPrompt, { acct: `@${nickname}` }),
|
||||
confirm: intl.formatMessage(messages.deactivateUserConfirm, { acct: `@${nickname}` }),
|
||||
onConfirm: () => {
|
||||
dispatch(deactivateUsers([nickname])).then(() => {
|
||||
const message = intl.formatMessage(messages.userDeactivated, { acct: `@${nickname}` });
|
||||
dispatch(snackbar.success(message));
|
||||
}).catch(() => {});
|
||||
this.handleCloseReport();
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
handleDeleteUser = () => {
|
||||
const { intl, dispatch, report } = this.props;
|
||||
const nickname = report.getIn(['account', 'acct']);
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.deleteUserPrompt, { acct: `@${nickname}` }),
|
||||
confirm: intl.formatMessage(messages.deleteUserConfirm, { acct: `@${nickname}` }),
|
||||
onConfirm: () => {
|
||||
dispatch(deleteUsers([nickname])).then(() => {
|
||||
const message = intl.formatMessage(messages.userDeleted, { acct: `@${nickname}` });
|
||||
dispatch(snackbar.success(message));
|
||||
}).catch(() => {});
|
||||
this.handleCloseReport();
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
handleAccordionToggle = setting => {
|
||||
this.setState({ accordionExpanded: setting });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { report } = this.props;
|
||||
const { accordionExpanded } = this.state;
|
||||
const menu = this.makeMenu();
|
||||
const statuses = report.get('statuses');
|
||||
const statusCount = statuses.count();
|
||||
const acct = report.getIn(['account', 'acct']);
|
||||
const reporterAcct = report.getIn(['actor', 'acct']);
|
||||
|
||||
return (
|
||||
<div className='admin-report' key={report.get('id')}>
|
||||
<div className='admin-report__avatar'>
|
||||
<Link to={`/@${acct}`} title={acct}>
|
||||
<Avatar account={report.get('account')} size={32} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className='admin-report__content'>
|
||||
<h4 className='admin-report__title'>
|
||||
<FormattedMessage
|
||||
id='admin.reports.report_title'
|
||||
defaultMessage='Report on {acct}'
|
||||
values={{ acct: <Link to={`/@${acct}`} title={acct}>@{acct}</Link> }}
|
||||
/>
|
||||
</h4>
|
||||
<div className='admin-report__statuses'>
|
||||
{statusCount > 0 && (
|
||||
<Accordion
|
||||
headline={`Reported posts (${statusCount})`}
|
||||
expanded={accordionExpanded}
|
||||
onToggle={this.handleAccordionToggle}
|
||||
>
|
||||
{statuses.map(status => <ReportStatus report={report} status={status} key={status.get('id')} />)}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
<div className='admin-report__quote'>
|
||||
{report.get('content', '').length > 0 &&
|
||||
<blockquote className='md' dangerouslySetInnerHTML={{ __html: report.get('content') }} />
|
||||
}
|
||||
<span className='byline'>— <Link to={`/@${reporterAcct}`} title={reporterAcct}>@{reporterAcct}</Link></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='admin-report__actions'>
|
||||
<Button className='button-alternative' size={30} onClick={this.handleCloseReport}>
|
||||
<FormattedMessage id='admin.reports.actions.close' defaultMessage='Close' />
|
||||
</Button>
|
||||
<DropdownMenu items={menu} icon='ellipsis-v' size={24} direction='right' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
140
app/soapbox/features/admin/components/report_status.js
Normal file
140
app/soapbox/features/admin/components/report_status.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import StatusContent from 'soapbox/components/status_content';
|
||||
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
||||
import { deleteStatus } from 'soapbox/actions/admin';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import { openModal } from 'soapbox/actions/modal';
|
||||
import noop from 'lodash/noop';
|
||||
import { MediaGallery, Video, Audio } from 'soapbox/features/ui/util/async-components';
|
||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||
|
||||
const messages = defineMessages({
|
||||
viewStatus: { id: 'admin.reports.actions.view_status', defaultMessage: 'View post' },
|
||||
deleteStatus: { id: 'admin.reports.actions.delete_status', defaultMessage: 'Delete post' },
|
||||
deleteStatusPrompt: { id: 'confirmations.admin.delete_status.message', defaultMessage: 'You are about to delete a post by {acct}. This action cannot be undone.' },
|
||||
deleteStatusConfirm: { id: 'confirmations.admin.delete_status.confirm', defaultMessage: 'Delete post' },
|
||||
statusDeleted: { id: 'admin.reports.status_deleted_message', defaultMessage: 'Post by {acct} was deleted' },
|
||||
});
|
||||
|
||||
export default @connect()
|
||||
@injectIntl
|
||||
class ReportStatus extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
report: ImmutablePropTypes.map,
|
||||
};
|
||||
|
||||
makeMenu = () => {
|
||||
const { intl, status } = this.props;
|
||||
const acct = status.getIn(['account', 'acct']);
|
||||
|
||||
return [{
|
||||
text: intl.formatMessage(messages.viewStatus, { acct: `@${acct}` }),
|
||||
to: `/@${acct}/posts/${status.get('id')}`,
|
||||
}, {
|
||||
text: intl.formatMessage(messages.deleteStatus, { acct: `@${acct}` }),
|
||||
action: this.handleDeleteStatus,
|
||||
}];
|
||||
}
|
||||
|
||||
getMedia = () => {
|
||||
const { status } = this.props;
|
||||
|
||||
if (status.get('media_attachments').size > 0) {
|
||||
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
||||
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const video = status.getIn(['media_attachments', 0]);
|
||||
|
||||
return (
|
||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
||||
{Component => (
|
||||
<Component
|
||||
preview={video.get('preview_url')}
|
||||
blurhash={video.get('blurhash')}
|
||||
src={video.get('url')}
|
||||
alt={video.get('description')}
|
||||
aspectRatio={video.getIn(['meta', 'small', 'aspect'])}
|
||||
width={239}
|
||||
height={110}
|
||||
inline
|
||||
sensitive={status.get('sensitive')}
|
||||
onOpenVideo={noop}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const audio = status.getIn(['media_attachments', 0]);
|
||||
|
||||
return (
|
||||
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
||||
{Component => (
|
||||
<Component
|
||||
src={audio.get('url')}
|
||||
alt={audio.get('description')}
|
||||
inline
|
||||
sensitive={status.get('sensitive')}
|
||||
onOpenAudio={noop}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
|
||||
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.handleOpenMedia} />}
|
||||
</Bundle>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
handleOpenMedia = (media, index) => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(openModal('MEDIA', { media, index }));
|
||||
}
|
||||
|
||||
handleDeleteStatus = () => {
|
||||
const { intl, dispatch, status } = this.props;
|
||||
const nickname = status.getIn(['account', 'acct']);
|
||||
const statusId = status.get('id');
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.deleteStatusPrompt, { acct: `@${nickname}` }),
|
||||
confirm: intl.formatMessage(messages.deleteStatusConfirm),
|
||||
onConfirm: () => {
|
||||
dispatch(deleteStatus(statusId)).then(() => {
|
||||
const message = intl.formatMessage(messages.statusDeleted, { acct: `@${nickname}` });
|
||||
dispatch(snackbar.success(message));
|
||||
}).catch(() => {});
|
||||
this.handleCloseReport();
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status } = this.props;
|
||||
const media = this.getMedia();
|
||||
const menu = this.makeMenu();
|
||||
|
||||
return (
|
||||
<div className='admin-report__status'>
|
||||
<div className='admin-report__status-content'>
|
||||
<StatusContent status={status} />
|
||||
{media}
|
||||
</div>
|
||||
<div className='admin-report__status-actions'>
|
||||
<DropdownMenu items={menu} icon='ellipsis-v' size={18} direction='right' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
66
app/soapbox/features/admin/reports.js
Normal file
66
app/soapbox/features/admin/reports.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Column from '../ui/components/column';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { fetchReports } from 'soapbox/actions/admin';
|
||||
import Report from './components/report';
|
||||
import { makeGetReport } from 'soapbox/selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.reports', defaultMessage: 'Reports' },
|
||||
emptyMessage: { id: 'admin.reports.empty_message', defaultMessage: 'There are no open reports. If a user gets reported, they will show up here.' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const getReport = makeGetReport();
|
||||
const ids = state.getIn(['admin', 'openReports']);
|
||||
|
||||
return {
|
||||
reports: ids.toList().map(id => getReport(state, id)),
|
||||
};
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class Reports extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
reports: ImmutablePropTypes.list.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
isLoading: true,
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(fetchReports())
|
||||
.then(() => this.setState({ isLoading: false }))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl, reports } = this.props;
|
||||
const { isLoading } = this.state;
|
||||
const showLoading = isLoading && reports.count() === 0;
|
||||
|
||||
return (
|
||||
<Column icon='gavel' heading={intl.formatMessage(messages.heading)} backBtnSlim>
|
||||
<ScrollableList
|
||||
isLoading={isLoading}
|
||||
showLoading={showLoading}
|
||||
scrollKey='admin-reports'
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
>
|
||||
{reports.map(report => <Report report={report} key={report.get('id')} />)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -92,27 +92,26 @@ class CommunityTimeline extends React.PureComponent {
|
||||
<div className='explanation-box'>
|
||||
<Accordion
|
||||
headline={<FormattedMessage id='fediverse_tab.explanation_box.title' defaultMessage='What is the Fediverse?' />}
|
||||
content={(
|
||||
<FormattedMessage
|
||||
id='fediverse_tab.explanation_box.explanation'
|
||||
defaultMessage='{site_title} is part of the Fediverse, a social network made up of thousands of independent social media sites (aka "servers"). The posts you see here are from 3rd-party servers. You have the freedom to engage with them, or to block any server you don't like. Pay attention to the full username after the second @ symbol to know which server a post is from. To see only {site_title} posts, visit {local}.'
|
||||
values={{
|
||||
site_title: siteTitle,
|
||||
local: (
|
||||
<Link to='/timeline/local'>
|
||||
<FormattedMessage
|
||||
id='empty_column.home.local_tab'
|
||||
defaultMessage='the {site_title} tab'
|
||||
values={{ site_title: siteTitle }}
|
||||
/>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
expanded={explanationBoxExpanded}
|
||||
onToggle={this.toggleExplanationBox}
|
||||
/>
|
||||
>
|
||||
<FormattedMessage
|
||||
id='fediverse_tab.explanation_box.explanation'
|
||||
defaultMessage='{site_title} is part of the Fediverse, a social network made up of thousands of independent social media sites (aka "servers"). The posts you see here are from 3rd-party servers. You have the freedom to engage with them, or to block any server you don't like. Pay attention to the full username after the second @ symbol to know which server a post is from. To see only {site_title} posts, visit {local}.'
|
||||
values={{
|
||||
site_title: siteTitle,
|
||||
local: (
|
||||
<Link to='/timeline/local'>
|
||||
<FormattedMessage
|
||||
id='empty_column.home.local_tab'
|
||||
defaultMessage='the {site_title} tab'
|
||||
values={{ site_title: siteTitle }}
|
||||
/>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Accordion>
|
||||
</div>
|
||||
<StatusListContainer
|
||||
scrollKey={`${timelineId}_timeline`}
|
||||
|
||||
@@ -308,19 +308,18 @@ class SoapboxConfig extends ImmutablePureComponent {
|
||||
</FieldsGroup>
|
||||
<Accordion
|
||||
headline={intl.formatMessage(messages.rawJSONLabel)}
|
||||
content={(
|
||||
<div className={this.state.jsonValid ? 'code-editor' : 'code-editor code-editor--invalid'}>
|
||||
<SimpleTextarea
|
||||
hint={intl.formatMessage(messages.rawJSONHint)}
|
||||
value={this.state.rawJSON}
|
||||
onChange={this.handleEditJSON}
|
||||
rows={12}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
expanded={this.state.jsonEditorExpanded}
|
||||
onToggle={this.toggleJSONEditor}
|
||||
/>
|
||||
>
|
||||
<div className={this.state.jsonValid ? 'code-editor' : 'code-editor code-editor--invalid'}>
|
||||
<SimpleTextarea
|
||||
hint={intl.formatMessage(messages.rawJSONHint)}
|
||||
value={this.state.rawJSON}
|
||||
onChange={this.handleEditJSON}
|
||||
rows={12}
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
</fieldset>
|
||||
<div className='actions'>
|
||||
<button name='button' type='submit' className='btn button button-primary'>
|
||||
|
||||
@@ -12,7 +12,7 @@ export default @injectIntl class Accordion extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
headline: PropTypes.node.isRequired,
|
||||
content: PropTypes.oneOfType([PropTypes.string, PropTypes.element]),
|
||||
children: PropTypes.oneOfType([PropTypes.string, PropTypes.element, PropTypes.node]),
|
||||
expanded: PropTypes.bool,
|
||||
onToggle: PropTypes.func,
|
||||
intl: PropTypes.object.isRequired,
|
||||
@@ -29,7 +29,7 @@ export default @injectIntl class Accordion extends React.PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { headline, content, expanded, intl } = this.props;
|
||||
const { headline, children, expanded, intl } = this.props;
|
||||
|
||||
return (
|
||||
<div className={classNames('accordion', { 'accordion--expanded' : expanded })}>
|
||||
@@ -42,7 +42,7 @@ export default @injectIntl class Accordion extends React.PureComponent {
|
||||
{headline}
|
||||
</button>
|
||||
<div className='accordion__content'>
|
||||
{content}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -154,7 +154,7 @@ class TabsBar extends React.PureComponent {
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const me = state.get('me');
|
||||
const reportsCount = state.getIn(['admin', 'open_report_count']);
|
||||
const reportsCount = state.getIn(['admin', 'openReports']).count();
|
||||
const approvalCount = state.getIn(['admin', 'awaitingApproval']).count();
|
||||
return {
|
||||
account: state.getIn(['accounts', me]),
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
ServerInfo,
|
||||
Dashboard,
|
||||
AwaitingApproval,
|
||||
Reports,
|
||||
} from './util/async-components';
|
||||
|
||||
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
||||
@@ -280,6 +281,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
||||
<Redirect from='/admin/dashboard' to='/admin' exact />
|
||||
<WrappedRoute path='/admin' page={AdminPage} component={Dashboard} content={children} exact />
|
||||
<WrappedRoute path='/admin/approval' page={AdminPage} component={AwaitingApproval} content={children} exact />
|
||||
<WrappedRoute path='/admin/reports' page={AdminPage} component={Reports} content={children} exact />
|
||||
<WrappedRoute path='/info' layout={LAYOUT.EMPTY} component={ServerInfo} content={children} />
|
||||
|
||||
<WrappedRoute layout={LAYOUT.EMPTY} component={GenericNotFound} content={children} />
|
||||
|
||||
@@ -225,3 +225,7 @@ export function Dashboard() {
|
||||
export function AwaitingApproval() {
|
||||
return import(/* webpackChunkName: "features/admin/awaiting_approval" */'../../admin/awaiting_approval');
|
||||
}
|
||||
|
||||
export function Reports() {
|
||||
return import(/* webpackChunkName: "features/admin/reports" */'../../admin/reports');
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@ import {
|
||||
Map as ImmutableMap,
|
||||
List as ImmutableList,
|
||||
OrderedSet as ImmutableOrderedSet,
|
||||
fromJS,
|
||||
} from 'immutable';
|
||||
|
||||
describe('admin reducer', () => {
|
||||
it('should return the initial state', () => {
|
||||
expect(reducer(undefined, {})).toEqual(fromJS({
|
||||
reports: [],
|
||||
open_report_count: 0,
|
||||
expect(reducer(undefined, {})).toEqual(ImmutableMap({
|
||||
reports: ImmutableMap(),
|
||||
openReports: ImmutableOrderedSet(),
|
||||
users: ImmutableMap(),
|
||||
awaitingApproval: ImmutableOrderedSet(),
|
||||
configs: ImmutableList(),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
ADMIN_CONFIG_FETCH_SUCCESS,
|
||||
ADMIN_REPORTS_FETCH_SUCCESS,
|
||||
ADMIN_REPORTS_PATCH_REQUEST,
|
||||
ADMIN_REPORTS_PATCH_SUCCESS,
|
||||
ADMIN_USERS_FETCH_SUCCESS,
|
||||
ADMIN_USERS_DELETE_REQUEST,
|
||||
ADMIN_USERS_DELETE_SUCCESS,
|
||||
@@ -15,9 +17,9 @@ import {
|
||||
} from 'immutable';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
reports: ImmutableList(),
|
||||
reports: ImmutableMap(),
|
||||
openReports: ImmutableOrderedSet(),
|
||||
users: ImmutableMap(),
|
||||
open_report_count: 0,
|
||||
awaitingApproval: ImmutableOrderedSet(),
|
||||
configs: ImmutableList(),
|
||||
needsReboot: false,
|
||||
@@ -52,18 +54,43 @@ function approveUsers(state, users) {
|
||||
});
|
||||
}
|
||||
|
||||
function importReports(state, reports) {
|
||||
return state.withMutations(state => {
|
||||
reports.forEach(report => {
|
||||
report.statuses = report.statuses.map(status => status.id);
|
||||
if (report.state === 'open') {
|
||||
state.update('openReports', orderedSet => orderedSet.add(report.id));
|
||||
}
|
||||
state.setIn(['reports', report.id], fromJS(report));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleReportDiffs(state, reports) {
|
||||
// Note: the reports here aren't full report objects
|
||||
// hence the need for a new function.
|
||||
return state.withMutations(state => {
|
||||
reports.forEach(report => {
|
||||
switch(report.state) {
|
||||
case 'open':
|
||||
state.update('openReports', orderedSet => orderedSet.add(report.id));
|
||||
break;
|
||||
default:
|
||||
state.update('openReports', orderedSet => orderedSet.delete(report.id));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default function admin(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case ADMIN_CONFIG_FETCH_SUCCESS:
|
||||
return state.set('configs', fromJS(action.configs));
|
||||
case ADMIN_REPORTS_FETCH_SUCCESS:
|
||||
if (action.params && action.params.state === 'open') {
|
||||
return state
|
||||
.set('reports', fromJS(action.data.reports))
|
||||
.set('open_report_count', action.data.total);
|
||||
} else {
|
||||
return state.set('reports', fromJS(action.data.reports));
|
||||
}
|
||||
return importReports(state, action.reports);
|
||||
case ADMIN_REPORTS_PATCH_REQUEST:
|
||||
case ADMIN_REPORTS_PATCH_SUCCESS:
|
||||
return handleReportDiffs(state, action.reports);
|
||||
case ADMIN_USERS_FETCH_SUCCESS:
|
||||
return importUsers(state, action.data.users);
|
||||
case ADMIN_USERS_DELETE_REQUEST:
|
||||
|
||||
@@ -175,3 +175,22 @@ export const makeGetChat = () => {
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const makeGetReport = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
return createSelector(
|
||||
[
|
||||
(state, id) => state.getIn(['admin', 'reports', id]),
|
||||
(state, id) => state.getIn(['admin', 'reports', id, 'statuses']).map(
|
||||
statusId => state.getIn(['statuses', statusId]))
|
||||
.filter(s => s)
|
||||
.map(s => getStatus(state, s.toJS())),
|
||||
],
|
||||
|
||||
(report, statuses) => {
|
||||
if (!report) return null;
|
||||
return report.set('statuses', statuses);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
text-transform: none !important;
|
||||
text-align: left !important;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -77,12 +77,6 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__reason {
|
||||
padding: 5px 0 5px 15px;
|
||||
border-left: 3px solid hsla(var(--primary-text-color_hsl), 0.4);
|
||||
color: var(--primary-text-color--faint);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
@@ -118,4 +112,88 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blockquote.md {
|
||||
padding: 5px 0 5px 15px;
|
||||
border-left: 3px solid hsla(var(--primary-text-color_hsl), 0.4);
|
||||
color: var(--primary-text-color--faint);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-report {
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--brand-color--faint);
|
||||
|
||||
&__content {
|
||||
padding: 0 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: bold;
|
||||
|
||||
a {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__quote {
|
||||
font-size: 14px;
|
||||
|
||||
.byline {
|
||||
font-size: 12px;
|
||||
|
||||
a {
|
||||
color: var(--primary-text-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
|
||||
.icon-button {
|
||||
padding-left: 10px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__statuses .accordion {
|
||||
padding: 10px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
&__title {
|
||||
font-size: 12px !important;
|
||||
font-weight: normal !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--accent-color--med);
|
||||
padding: 10px 0;
|
||||
|
||||
&:last-child {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.status__content {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
padding: 3px 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ button {
|
||||
}
|
||||
|
||||
&.button-alternative {
|
||||
color: var(--primary-text-color);
|
||||
color: #fff;
|
||||
background: var(--brand-color);
|
||||
|
||||
&:active,
|
||||
|
||||
Reference in New Issue
Block a user