diff --git a/app/soapbox/actions/admin.js b/app/soapbox/actions/admin.js index ecf70923a..84857abba 100644 --- a/app/soapbox/actions/admin.js +++ b/app/soapbox/actions/admin.js @@ -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 }); + }); + }; +} diff --git a/app/soapbox/components/helmet.js b/app/soapbox/components/helmet.js index 080f234c1..d89b36aae 100644 --- a/app/soapbox/components/helmet.js +++ b/app/soapbox/components/helmet.js @@ -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; }; diff --git a/app/soapbox/components/intersection_observer_article.js b/app/soapbox/components/intersection_observer_article.js index d21ea2565..12ebe20a2 100644 --- a/app/soapbox/components/intersection_observer_article.js +++ b/app/soapbox/components/intersection_observer_article.js @@ -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) { diff --git a/app/soapbox/features/admin/awaiting_approval.js b/app/soapbox/features/admin/awaiting_approval.js index 94c7284c9..7706eb4a8 100644 --- a/app/soapbox/features/admin/awaiting_approval.js +++ b/app/soapbox/features/admin/awaiting_approval.js @@ -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 ( - + {users.map((user, i) => (
@{user.get('nickname')}
-
{user.get('registration_reason')}
+
{user.get('registration_reason')}
diff --git a/app/soapbox/features/admin/components/admin_nav.js b/app/soapbox/features/admin/components/admin_nav.js index 1d0a9a47f..1c0a17ea9 100644 --- a/app/soapbox/features/admin/components/admin_nav.js +++ b/app/soapbox/features/admin/components/admin_nav.js @@ -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 { - + - + {((instance.get('registrations') && instance.get('approval_required')) || approvalCount > 0) && ( diff --git a/app/soapbox/features/admin/components/registration_mode_picker.js b/app/soapbox/features/admin/components/registration_mode_picker.js index dd007dba4..1e2f153ae 100644 --- a/app/soapbox/features/admin/components/registration_mode_picker.js +++ b/app/soapbox/features/admin/components/registration_mode_picker.js @@ -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 => { diff --git a/app/soapbox/features/admin/components/report.js b/app/soapbox/features/admin/components/report.js new file mode 100644 index 000000000..a1e7d82bb --- /dev/null +++ b/app/soapbox/features/admin/components/report.js @@ -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 ( +
+
+ + + +
+
+

+ @{acct} }} + /> +

+
+ {statusCount > 0 && ( + + {statuses.map(status => )} + + )} +
+
+ {report.get('content', '').length > 0 && +
+ } + @{reporterAcct} +
+
+
+ + +
+
+ ); + } + +} diff --git a/app/soapbox/features/admin/components/report_status.js b/app/soapbox/features/admin/components/report_status.js new file mode 100644 index 000000000..d98e9c755 --- /dev/null +++ b/app/soapbox/features/admin/components/report_status.js @@ -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 ( + + {Component => ( + + )} + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const audio = status.getIn(['media_attachments', 0]); + + return ( + + {Component => ( + + )} + + ); + } else { + return ( + + {Component => } + + ); + } + } + + 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 ( +
+
+ + {media} +
+
+ +
+
+ ); + } + +} diff --git a/app/soapbox/features/admin/reports.js b/app/soapbox/features/admin/reports.js new file mode 100644 index 000000000..50c7c33a7 --- /dev/null +++ b/app/soapbox/features/admin/reports.js @@ -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 ( + + + {reports.map(report => )} + + + ); + } + +} diff --git a/app/soapbox/features/public_timeline/index.js b/app/soapbox/features/public_timeline/index.js index 9173274fb..0708ca1c1 100644 --- a/app/soapbox/features/public_timeline/index.js +++ b/app/soapbox/features/public_timeline/index.js @@ -92,27 +92,26 @@ class CommunityTimeline extends React.PureComponent {
} - content={( - - - - ), - }} - /> - )} expanded={explanationBoxExpanded} onToggle={this.toggleExplanationBox} - /> + > + + + + ), + }} + /> +
- -
- )} expanded={this.state.jsonEditorExpanded} onToggle={this.toggleJSONEditor} - /> + > +
+ +
+
- {content} + {children}
); diff --git a/app/soapbox/features/ui/components/tabs_bar.js b/app/soapbox/features/ui/components/tabs_bar.js index 013eef313..fa4a685f4 100644 --- a/app/soapbox/features/ui/components/tabs_bar.js +++ b/app/soapbox/features/ui/components/tabs_bar.js @@ -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]), diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index 806f56bfe..59c36c831 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -89,6 +89,7 @@ import { ServerInfo, Dashboard, AwaitingApproval, + Reports, } from './util/async-components'; // Dummy import, to make sure that ends up in the application bundle. @@ -280,6 +281,7 @@ class SwitchingColumnsArea extends React.PureComponent { + diff --git a/app/soapbox/features/ui/util/async-components.js b/app/soapbox/features/ui/util/async-components.js index 6e313b4d8..1e9cc7550 100644 --- a/app/soapbox/features/ui/util/async-components.js +++ b/app/soapbox/features/ui/util/async-components.js @@ -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'); +} diff --git a/app/soapbox/reducers/__tests__/admin-test.js b/app/soapbox/reducers/__tests__/admin-test.js index 9f983af0a..588abe7aa 100644 --- a/app/soapbox/reducers/__tests__/admin-test.js +++ b/app/soapbox/reducers/__tests__/admin-test.js @@ -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(), diff --git a/app/soapbox/reducers/admin.js b/app/soapbox/reducers/admin.js index eaacf7323..9f6000ec8 100644 --- a/app/soapbox/reducers/admin.js +++ b/app/soapbox/reducers/admin.js @@ -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: diff --git a/app/soapbox/selectors/index.js b/app/soapbox/selectors/index.js index 89d20b209..949c7a63d 100644 --- a/app/soapbox/selectors/index.js +++ b/app/soapbox/selectors/index.js @@ -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); + }, + ); +}; diff --git a/app/styles/components/accordion.scss b/app/styles/components/accordion.scss index 35ad3ea2e..9fea4d42e 100644 --- a/app/styles/components/accordion.scss +++ b/app/styles/components/accordion.scss @@ -20,6 +20,7 @@ text-transform: none !important; text-align: left !important; display: flex !important; + align-items: center; border: 0; width: 100%; diff --git a/app/styles/components/admin.scss b/app/styles/components/admin.scss index b544d9f70..8943e5555 100644 --- a/app/styles/components/admin.scss +++ b/app/styles/components/admin.scss @@ -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; + } + } } diff --git a/app/styles/components/buttons.scss b/app/styles/components/buttons.scss index 5eeda468e..e8b382a38 100644 --- a/app/styles/components/buttons.scss +++ b/app/styles/components/buttons.scss @@ -72,7 +72,7 @@ button { } &.button-alternative { - color: var(--primary-text-color); + color: #fff; background: var(--brand-color); &:active,