Merge branch 'reports' into 'develop'

Reports

See merge request soapbox-pub/soapbox-fe!409
merge-requests/410/merge
Alex Gleason 4 years ago
commit ecd9e60a5d

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

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

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

@ -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&apos;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&apos;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,

Loading…
Cancel
Save