From 88b91bce3e7a25df7d229ad8980ca83a6c9abf73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 15 May 2022 15:11:59 +0200 Subject: [PATCH] Mastodon admin API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/admin.js | 136 ++++++++++++++++-- .../features/admin/components/report.tsx | 41 +++--- .../admin/components/report_status.tsx | 5 +- .../admin/components/unapproved_account.tsx | 4 +- app/soapbox/features/admin/tabs/reports.tsx | 2 +- app/soapbox/features/admin/user_index.js | 10 +- app/soapbox/features/ui/index.tsx | 2 +- app/soapbox/normalizers/admin_account.ts | 55 +++++++ app/soapbox/normalizers/admin_report.ts | 51 +++++++ app/soapbox/normalizers/index.ts | 2 + app/soapbox/reducers/accounts.ts | 2 +- app/soapbox/reducers/admin.ts | 75 +++++++--- app/soapbox/selectors/index.ts | 17 ++- app/soapbox/types/entities.ts | 6 + app/soapbox/utils/features.ts | 14 ++ 15 files changed, 361 insertions(+), 61 deletions(-) create mode 100644 app/soapbox/normalizers/admin_account.ts create mode 100644 app/soapbox/normalizers/admin_report.ts diff --git a/app/soapbox/actions/admin.js b/app/soapbox/actions/admin.js index 3704e114e..313cf6203 100644 --- a/app/soapbox/actions/admin.js +++ b/app/soapbox/actions/admin.js @@ -1,7 +1,8 @@ import { fetchRelationships } from 'soapbox/actions/accounts'; -import { importFetchedAccount, importFetchedStatuses } from 'soapbox/actions/importer'; +import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer'; +import { getFeatures } from 'soapbox/utils/features'; -import api from '../api'; +import api, { getLinks } from '../api'; export const ADMIN_CONFIG_FETCH_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST'; export const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS'; @@ -99,11 +100,36 @@ export function updateConfig(configs) { }; } -export function fetchReports(params) { +export function fetchReports(params = {}) { return (dispatch, getState) => { + const state = getState(); + + const instance = state.get('instance'); + const features = getFeatures(instance); + dispatch({ type: ADMIN_REPORTS_FETCH_REQUEST, params }); + + if (features.mastodonAdminApi) { + return api(getState) + .get('/api/v1/admin/reports', { params }) + .then(({ data: reports }) => { + reports.forEach(report => { + dispatch(importFetchedAccount(report.account?.account)); + dispatch(importFetchedAccount(report.target_account?.account)); + dispatch(importFetchedStatuses(report.statuses)); + }); + dispatch({ type: ADMIN_REPORTS_FETCH_SUCCESS, reports, params }); + }).catch(error => { + dispatch({ type: ADMIN_REPORTS_FETCH_FAIL, error, params }); + }); + } + + const { resolved } = params; + return api(getState) - .get('/api/pleroma/admin/reports', { params }) + .get('/api/pleroma/admin/reports', { params: { + state: resolved === false ? 'open' : (resolved ? 'resolved' : null), + } }) .then(({ data: { reports } }) => { reports.forEach(report => { dispatch(importFetchedAccount(report.account)); @@ -118,9 +144,27 @@ export function fetchReports(params) { } function patchReports(ids, state) { - const reports = ids.map(id => ({ id, state })); return (dispatch, getState) => { + const state = getState(); + + const instance = state.get('instance'); + const features = getFeatures(instance); + + const reports = ids.map(id => ({ id, state })); + dispatch({ type: ADMIN_REPORTS_PATCH_REQUEST, reports }); + + if (features.mastodonAdminApi) { + return Promise.all(ids.map(id => api(getState) + .post(`/api/v1/admin/reports/${id}/${state === 'resolved' ? 'reopen' : 'resolve'}`) + .then(({ data }) => { + dispatch({ type: ADMIN_REPORTS_PATCH_SUCCESS, reports }); + }).catch(error => { + dispatch({ type: ADMIN_REPORTS_PATCH_FAIL, error, reports }); + }), + )); + } + return api(getState) .patch('/api/pleroma/admin/reports', { reports }) .then(() => { @@ -134,12 +178,45 @@ export function closeReports(ids) { return patchReports(ids, 'closed'); } -export function fetchUsers(filters = [], page = 1, query, pageSize = 50) { +export function fetchUsers(filters = [], page = 1, query, pageSize = 50, next) { return (dispatch, getState) => { + const state = getState(); + + const instance = state.get('instance'); + const features = getFeatures(instance); + + dispatch({ type: ADMIN_USERS_FETCH_REQUEST, filters, page, pageSize }); + + if (features.mastodonAdminApi) { + const params = { + username: query, + }; + + if (filters.includes('local')) params.local = true; + if (filters.includes('active')) params.active = true; + if (filters.includes('need_approval')) params.pending = true; + + return api(getState) + .get(next || '/api/v1/admin/accounts', { params }) + .then(({ data: accounts, ...response }) => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + const count = next + ? page * pageSize + 1 + : (page - 1) * pageSize + accounts.length; + + dispatch(importFetchedAccounts(accounts.map(({ account }) => account))); + dispatch(fetchRelationships(accounts.map(account => account.id))); + dispatch({ type: ADMIN_USERS_FETCH_SUCCESS, users: accounts, count, pageSize, filters, page, next: next?.uri || false }); + return { users: accounts, count, pageSize, next: next?.uri || false }; + }).catch(error => { + dispatch({ type: ADMIN_USERS_FETCH_FAIL, error, filters, page, pageSize }); + }); + } + const params = { filters: filters.join(), page, page_size: pageSize }; if (query) params.query = query; - dispatch({ type: ADMIN_USERS_FETCH_REQUEST, filters, page, pageSize }); return api(getState) .get('/api/pleroma/admin/users', { params }) .then(({ data: { users, count, page_size: pageSize } }) => { @@ -152,10 +229,31 @@ export function fetchUsers(filters = [], page = 1, query, pageSize = 50) { }; } -export function deactivateUsers(accountIds) { +export function deactivateUsers(accountIds, reportId) { return (dispatch, getState) => { - const nicknames = nicknamesFromIds(getState, accountIds); + const state = getState(); + + const instance = state.get('instance'); + const features = getFeatures(instance); + dispatch({ type: ADMIN_USERS_DEACTIVATE_REQUEST, accountIds }); + + if (features.mastodonAdminApi) { + return Promise.all(accountIds.map(accountId => { + api(getState) + .post(`/api/v1/admin/accounts/${accountId}/action`, { + type: 'disable', + report_id: reportId, + }) + .then(() => { + dispatch({ type: ADMIN_USERS_DEACTIVATE_SUCCESS, accountIds: [accountId] }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_DEACTIVATE_FAIL, error, accountIds: [accountId] }); + }); + })); + } + + const nicknames = nicknamesFromIds(getState, accountIds); return api(getState) .patch('/api/pleroma/admin/users/deactivate', { nicknames }) .then(({ data: { users } }) => { @@ -182,8 +280,26 @@ export function deleteUsers(accountIds) { export function approveUsers(accountIds) { return (dispatch, getState) => { - const nicknames = nicknamesFromIds(getState, accountIds); + const state = getState(); + + const instance = state.get('instance'); + const features = getFeatures(instance); + dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountIds }); + + if (features.mastodonAdminApi) { + return Promise.all(accountIds.map(accountId => { + api(getState) + .post(`/api/v1/admin/accounts/${accountId}/approve`) + .then(({ data: user }) => { + dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users: [user], accountIds: [accountId] }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountIds: [accountId] }); + }); + })); + } + + const nicknames = nicknamesFromIds(getState, accountIds); return api(getState) .patch('/api/pleroma/admin/users/approve', { nicknames }) .then(({ data: { users } }) => { diff --git a/app/soapbox/features/admin/components/report.tsx b/app/soapbox/features/admin/components/report.tsx index 6d3da6009..82ec5804e 100644 --- a/app/soapbox/features/admin/components/report.tsx +++ b/app/soapbox/features/admin/components/report.tsx @@ -14,8 +14,8 @@ import { useAppDispatch } from 'soapbox/hooks'; import ReportStatus from './report_status'; -import type { Map as ImmutableMap, List as ImmutableList } from 'immutable'; -import type { Status } from 'soapbox/types/entities'; +import type { List as ImmutableList } from 'immutable'; +import type { Account, AdminReport, Status } from 'soapbox/types/entities'; const messages = defineMessages({ reportClosed: { id: 'admin.reports.report_closed_message', defaultMessage: 'Report on @{name} was closed' }, @@ -24,7 +24,7 @@ const messages = defineMessages({ }); interface IReport { - report: ImmutableMap; + report: AdminReport; } const Report: React.FC = ({ report }) => { @@ -33,32 +33,35 @@ const Report: React.FC = ({ report }) => { const [accordionExpanded, setAccordionExpanded] = useState(false); + const account = report.account as Account; + const targetAccount = report.target_account as Account; + const makeMenu = () => { return [{ - text: intl.formatMessage(messages.deactivateUser, { name: report.getIn(['account', 'username']) as string }), + text: intl.formatMessage(messages.deactivateUser, { name: targetAccount.username as string }), action: handleDeactivateUser, icon: require('@tabler/icons/icons/user-off.svg'), }, { - text: intl.formatMessage(messages.deleteUser, { name: report.getIn(['account', 'username']) as string }), + text: intl.formatMessage(messages.deleteUser, { name: targetAccount.username as string }), action: handleDeleteUser, icon: require('@tabler/icons/icons/user-minus.svg'), }]; }; const handleCloseReport = () => { - dispatch(closeReports([report.get('id')])).then(() => { - const message = intl.formatMessage(messages.reportClosed, { name: report.getIn(['account', 'username']) as string }); + dispatch(closeReports([report.id])).then(() => { + const message = intl.formatMessage(messages.reportClosed, { name: targetAccount.username as string }); dispatch(snackbar.success(message)); }).catch(() => {}); }; const handleDeactivateUser = () => { - const accountId = report.getIn(['account', 'id']); + const accountId = targetAccount.id; dispatch(deactivateUserModal(intl, accountId, () => handleCloseReport())); }; const handleDeleteUser = () => { - const accountId = report.getIn(['account', 'id']) as string; + const accountId = targetAccount.id as string; dispatch(deleteUserModal(intl, accountId, () => handleCloseReport())); }; @@ -67,17 +70,17 @@ const Report: React.FC = ({ report }) => { }; const menu = makeMenu(); - const statuses = report.get('statuses') as ImmutableList; + const statuses = report.statuses as ImmutableList; const statusCount = statuses.count(); - const acct = report.getIn(['account', 'acct']) as string; - const reporterAcct = report.getIn(['actor', 'acct']) as string; + const acct = targetAccount.acct as string; + const reporterAcct = account.acct as string; return ( -
+
- + - +
@@ -87,7 +90,7 @@ const Report: React.FC = ({ report }) => { id='admin.reports.report_title' defaultMessage='Report on {acct}' values={{ acct: ( - + @{acct} ) }} @@ -105,12 +108,12 @@ const Report: React.FC = ({ report }) => { )}
- {report.get('content', '').length > 0 && ( -
+ {(report.comment || '').length > 0 && ( +
)} — - + @{reporterAcct} diff --git a/app/soapbox/features/admin/components/report_status.tsx b/app/soapbox/features/admin/components/report_status.tsx index 00755a6c4..c882f0f5a 100644 --- a/app/soapbox/features/admin/components/report_status.tsx +++ b/app/soapbox/features/admin/components/report_status.tsx @@ -10,8 +10,7 @@ import Bundle from 'soapbox/features/ui/components/bundle'; import { MediaGallery, Video, Audio } from 'soapbox/features/ui/util/async-components'; import { useAppDispatch } from 'soapbox/hooks'; -import type { Map as ImmutableMap } from 'immutable'; -import type { Status, Attachment } from 'soapbox/types/entities'; +import type { AdminReport, Attachment, Status } from 'soapbox/types/entities'; const messages = defineMessages({ viewStatus: { id: 'admin.reports.actions.view_status', defaultMessage: 'View post' }, @@ -20,7 +19,7 @@ const messages = defineMessages({ interface IReportStatus { status: Status, - report?: ImmutableMap, + report?: AdminReport, } const ReportStatus: React.FC = ({ status }) => { diff --git a/app/soapbox/features/admin/components/unapproved_account.tsx b/app/soapbox/features/admin/components/unapproved_account.tsx index f000bb173..5f38684cb 100644 --- a/app/soapbox/features/admin/components/unapproved_account.tsx +++ b/app/soapbox/features/admin/components/unapproved_account.tsx @@ -26,6 +26,7 @@ const UnapprovedAccount: React.FC = ({ accountId }) => { const dispatch = useAppDispatch(); const account = useAppSelector(state => getAccount(state, accountId)); + const adminAccount = useAppSelector(state => state.admin.users.get(accountId)); if (!account) return null; @@ -45,12 +46,11 @@ const UnapprovedAccount: React.FC = ({ accountId }) => { })); }; - return (
@{account.get('acct')}
-
{account.pleroma.getIn(['admin', 'registration_reason'], '') as string}
+
{adminAccount?.invite_request || ''}
diff --git a/app/soapbox/features/admin/tabs/reports.tsx b/app/soapbox/features/admin/tabs/reports.tsx index 78cc7a6de..8a2eca8c7 100644 --- a/app/soapbox/features/admin/tabs/reports.tsx +++ b/app/soapbox/features/admin/tabs/reports.tsx @@ -42,7 +42,7 @@ const Reports: React.FC = () => { scrollKey='admin-reports' emptyMessage={intl.formatMessage(messages.emptyMessage)} > - {reports.map(report => )} + {reports.map(report => report && )} ); }; diff --git a/app/soapbox/features/admin/user_index.js b/app/soapbox/features/admin/user_index.js index eeeba734a..7c06609a3 100644 --- a/app/soapbox/features/admin/user_index.js +++ b/app/soapbox/features/admin/user_index.js @@ -34,6 +34,7 @@ class UserIndex extends ImmutablePureComponent { pageSize: 50, page: 0, query: '', + nextLink: undefined, } clearState = callback => { @@ -45,11 +46,11 @@ class UserIndex extends ImmutablePureComponent { } fetchNextPage = () => { - const { filters, page, query, pageSize } = this.state; + const { filters, page, query, pageSize, nextLink } = this.state; const nextPage = page + 1; - this.props.dispatch(fetchUsers(filters, nextPage, query, pageSize)) - .then(({ users, count }) => { + this.props.dispatch(fetchUsers(filters, nextPage, query, pageSize, nextLink)) + .then(({ users, count, next }) => { const newIds = users.map(user => user.id); this.setState({ @@ -57,6 +58,7 @@ class UserIndex extends ImmutablePureComponent { accountIds: this.state.accountIds.union(newIds), total: count, page: nextPage, + nextLink: next, }); }) .catch(() => {}); @@ -97,7 +99,7 @@ class UserIndex extends ImmutablePureComponent { render() { const { intl } = this.props; const { accountIds, isLoading } = this.state; - const hasMore = accountIds.count() < this.state.total; + const hasMore = accountIds.count() < this.state.total && this.state.nextLink !== false; const showLoading = isLoading && accountIds.isEmpty(); diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 6cfab8d89..1a97617cb 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -455,7 +455,7 @@ const UI: React.FC = ({ children }) => { } if (account.staff) { - dispatch(fetchReports({ state: 'open' })); + dispatch(fetchReports({ resolved: false })); dispatch(fetchUsers(['local', 'need_approval'])); } diff --git a/app/soapbox/normalizers/admin_account.ts b/app/soapbox/normalizers/admin_account.ts new file mode 100644 index 000000000..4e040dfb1 --- /dev/null +++ b/app/soapbox/normalizers/admin_account.ts @@ -0,0 +1,55 @@ +/** + * Admin account normalizer: + * Converts API admin-level account information into our internal format. + */ +import { + Map as ImmutableMap, + List as ImmutableList, + Record as ImmutableRecord, + fromJS, +} from 'immutable'; + +import type { ReducerAccount } from 'soapbox/reducers/accounts'; +import type { Account, EmbeddedEntity } from 'soapbox/types/entities'; + +export const AdminAccountRecord = ImmutableRecord({ + account: null as EmbeddedEntity, + approved: false, + confirmed: false, + created_at: new Date(), + disabled: false, + domain: '', + email: '', + id: '', + invite_request: null as string | null, + ip: null as string | null, + ips: ImmutableList(), + locale: null as string | null, + role: null as 'admin' | 'moderator' | null, + sensitized: false, + silenced: false, + suspended: false, + username: '', +}); + +const normalizePleromaAccount = (account: ImmutableMap) => { + if (!account.get('account')) { + return account.withMutations(account => { + account.set('approved', account.get('is_approved')); + account.set('confirmed', account.get('is_confirmed')); + account.set('disabled', !account.get('is_active')); + account.set('invite_request', account.get('registration_reason')); + account.set('role', account.getIn(['roles', 'admin']) ? 'admin' : (account.getIn(['roles', 'moderator']) ? 'moderator' : null)); + }); + } + + return account; +}; + +export const normalizeAdminAccount = (account: Record) => { + return AdminAccountRecord( + ImmutableMap(fromJS(account)).withMutations((account: ImmutableMap) => { + normalizePleromaAccount(account); + }), + ); +}; diff --git a/app/soapbox/normalizers/admin_report.ts b/app/soapbox/normalizers/admin_report.ts new file mode 100644 index 000000000..23a60bc04 --- /dev/null +++ b/app/soapbox/normalizers/admin_report.ts @@ -0,0 +1,51 @@ +/** + * Admin report normalizer: + * Converts API admin-level report information into our internal format. + */ +import { + Map as ImmutableMap, + List as ImmutableList, + Record as ImmutableRecord, + fromJS, +} from 'immutable'; + +import type { ReducerAccount } from 'soapbox/reducers/accounts'; +import type { Account, EmbeddedEntity, Status } from 'soapbox/types/entities'; + +export const AdminReportRecord = ImmutableRecord({ + account: null as EmbeddedEntity, + action_taken: false, + action_taken_by_account: null as EmbeddedEntity | null, + assigned_account: null as EmbeddedEntity | null, + category: '', + comment: '', + created_at: new Date(), + id: '', + rules: ImmutableList(), + statuses: ImmutableList>(), + target_account: null as EmbeddedEntity, + updated_at: new Date(), +}); + +const normalizePleromaReport = (report: ImmutableMap) => { + if (report.get('actor')){ + return report.withMutations(report => { + report.set('target_account', report.get('account')); + report.set('account', report.get('actor')); + + report.set('action_taken', report.get('state') !== 'open'); + report.set('comment', report.get('content')); + report.set('updated_at', report.get('created_at')); + }); + } + + return report; +}; + +export const normalizeAdminReport = (report: Record) => { + return AdminReportRecord( + ImmutableMap(fromJS(report)).withMutations((report: ImmutableMap) => { + normalizePleromaReport(report); + }), + ); +}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 60802a057..9beba41af 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -1,4 +1,6 @@ export { AccountRecord, FieldRecord, normalizeAccount } from './account'; +export { AdminAccountRecord, normalizeAdminAccount } from './admin_account'; +export { AdminReportRecord, normalizeAdminReport } from './admin_report'; export { AttachmentRecord, normalizeAttachment } from './attachment'; export { CardRecord, normalizeCard } from './card'; export { ChatRecord, normalizeChat } from './chat'; diff --git a/app/soapbox/reducers/accounts.ts b/app/soapbox/reducers/accounts.ts index e7f4d4753..5952d2ef1 100644 --- a/app/soapbox/reducers/accounts.ts +++ b/app/soapbox/reducers/accounts.ts @@ -228,7 +228,7 @@ const importAdminUser = (state: State, adminUser: ImmutableMap): St const importAdminUsers = (state: State, adminUsers: Array>): State => { return state.withMutations((state: State) => { - adminUsers.forEach(adminUser => { + adminUsers.filter(adminUser => !adminUser.account).forEach(adminUser => { importAdminUser(state, ImmutableMap(fromJS(adminUser))); }); }); diff --git a/app/soapbox/reducers/admin.ts b/app/soapbox/reducers/admin.ts index a22650da4..fb2635371 100644 --- a/app/soapbox/reducers/admin.ts +++ b/app/soapbox/reducers/admin.ts @@ -19,15 +19,18 @@ import { ADMIN_USERS_DELETE_SUCCESS, ADMIN_USERS_APPROVE_REQUEST, ADMIN_USERS_APPROVE_SUCCESS, -} from '../actions/admin'; +} from 'soapbox/actions/admin'; +import { normalizeAdminReport, normalizeAdminAccount } from 'soapbox/normalizers'; +import { APIEntity } from 'soapbox/types/entities'; +import { normalizeId } from 'soapbox/utils/normalizers'; import type { AnyAction } from 'redux'; import type { Config } from 'soapbox/utils/config_db'; const ReducerRecord = ImmutableRecord({ - reports: ImmutableMap(), + reports: ImmutableMap(), openReports: ImmutableOrderedSet(), - users: ImmutableMap(), + users: ImmutableMap(), latestUsers: ImmutableOrderedSet(), awaitingApproval: ImmutableOrderedSet(), configs: ImmutableList(), @@ -36,6 +39,21 @@ const ReducerRecord = ImmutableRecord({ type State = ReturnType; +type AdminAccountRecord = ReturnType; +type AdminReportRecord = ReturnType; + +export interface ReducerAdminAccount extends AdminAccountRecord { + account: string | null, +} + +export interface ReducerAdminReport extends AdminReportRecord { + account: string | null, + target_account: string | null, + action_taken_by_account: string | null, + assigned_account: string | null, + statuses: ImmutableList, +} + // Umm... based? // https://itnext.io/typescript-extract-unpack-a-type-from-a-generic-baca7af14e51 type InnerRecord = R extends ImmutableRecord ? TProps : never; @@ -46,7 +64,6 @@ type InnerState = InnerRecord; type FilterConditionally = Pick; type SetKeys = keyof FilterConditionally>; - type APIReport = { id: string, state: string, statuses: any[] }; type APIUser = { id: string, email: string, nickname: string, registration_reason: string }; @@ -84,12 +101,17 @@ const maybeImportLatest = (state: State, users: APIUser[], filters: Filter[], pa } }; -const importUser = (state: State, user: APIUser): State => ( - state.setIn(['users', user.id], ImmutableMap({ - email: user.email, - registration_reason: user.registration_reason, - })) -); +const minifyUser = (user: AdminAccountRecord): ReducerAdminAccount => { + return user.mergeWith((o, n) => n || o, { + account: normalizeId(user.getIn(['account', 'id'])), + }) as ReducerAdminAccount; +}; + +const fixUser = (user: APIEntity): ReducerAdminAccount => { + return normalizeAdminAccount(user).withMutations(user => { + minifyUser(user); + }) as ReducerAdminAccount; +}; function importUsers(state: State, users: APIUser[], filters: Filter[], page: number): State { return state.withMutations(state => { @@ -97,7 +119,8 @@ function importUsers(state: State, users: APIUser[], filters: Filter[], page: nu maybeImportLatest(state, users, filters, page); users.forEach(user => { - importUser(state, user); + const normalizedUser = fixUser(user); + state.setIn(['users', user.id], normalizedUser); }); }); } @@ -114,20 +137,38 @@ function deleteUsers(state: State, accountIds: string[]): State { function approveUsers(state: State, users: APIUser[]): State { return state.withMutations(state => { users.forEach(user => { - state.update('awaitingApproval', orderedSet => orderedSet.delete(user.nickname)); - state.setIn(['users', user.nickname], fromJS(user)); + const normalizedUser = fixUser(user); + state.update('awaitingApproval', orderedSet => orderedSet.delete(user.id)); + state.setIn(['users', user.id], normalizedUser); }); }); } -function importReports(state: State, reports: APIReport[]): State { +const minifyReport = (report: AdminReportRecord): ReducerAdminReport => { + return report.mergeWith((o, n) => n || o, { + account: normalizeId(report.getIn(['account', 'id'])), + target_account: normalizeId(report.getIn(['target_account', 'id'])), + action_taken_by_account: normalizeId(report.getIn(['action_taken_by_account', 'id'])), + assigned_account: normalizeId(report.getIn(['assigned_account', 'id'])), + + statuses: report.get('statuses').map((status: any) => normalizeId(status.get('id'))), + }) as ReducerAdminReport; +}; + +const fixReport = (report: APIEntity): ReducerAdminReport => { + return normalizeAdminReport(report).withMutations(report => { + minifyReport(report); + }) as ReducerAdminReport; +}; + +function importReports(state: State, reports: APIEntity[]): State { return state.withMutations(state => { reports.forEach(report => { - report.statuses = report.statuses.map(status => status.id); - if (report.state === 'open') { + const normalizedReport = fixReport(report); + if (!normalizedReport.action_taken) { state.update('openReports', orderedSet => orderedSet.add(report.id)); } - state.setIn(['reports', report.id], fromJS(report)); + state.setIn(['reports', report.id], normalizedReport); }); }); } diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts index d6e69e90f..7d77b4891 100644 --- a/app/soapbox/selectors/index.ts +++ b/app/soapbox/selectors/index.ts @@ -266,15 +266,26 @@ export const makeGetReport = () => { return createSelector( [ (state: RootState, id: string) => state.admin.reports.get(id), - (state: RootState, id: string) => ImmutableList(fromJS(state.admin.reports.getIn([id, 'statuses']))).map( + (state: RootState, id: string) => state.accounts.get(state.admin.reports.get(id)?.account || ''), + (state: RootState, id: string) => state.accounts.get(state.admin.reports.get(id)?.target_account || ''), + // (state: RootState, id: string) => state.accounts.get(state.admin.reports.get(id)?.action_taken_by_account || ''), + // (state: RootState, id: string) => state.accounts.get(state.admin.reports.get(id)?.assigned_account || ''), + (state: RootState, id: string) => ImmutableList(fromJS(state.admin.reports.get(id)?.statuses)).map( statusId => state.statuses.get(normalizeId(statusId))) .filter((s: any) => s) .map((s: any) => getStatus(state, s.toJS())), ], - (report, statuses) => { + (report, account, targetAccount, statuses) => { if (!report) return null; - return report.set('statuses', statuses); + return report.withMutations((report) => { + // @ts-ignore + report.set('account', account); + // @ts-ignore + report.set('target_account', targetAccount); + // @ts-ignore + report.set('statuses', statuses); + }); }, ); }; diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index d01698e46..c65cecaa4 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -1,4 +1,6 @@ import { + AdminAccountRecord, + AdminReportRecord, AccountRecord, AttachmentRecord, CardRecord, @@ -17,6 +19,8 @@ import { import type { Record as ImmutableRecord } from 'immutable'; +type AdminAccount = ReturnType; +type AdminReport = ReturnType; type Attachment = ReturnType; type Card = ReturnType; type Chat = ReturnType; @@ -47,6 +51,8 @@ type APIEntity = Record; type EmbeddedEntity = null | string | ReturnType>; export { + AdminAccount, + AdminReport, Account, Attachment, Card, diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 1e4d76266..2f2a69d32 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -316,6 +316,20 @@ const getInstanceFeatures = (instance: Instance) => { v.software === PLEROMA && gte(v.version, '0.9.9'), ]), + /** + * Can perform moderation actions with account and reports. + * @see {@link https://docs.joinmastodon.org/methods/admin/} + * @see GET /api/v1/admin/reports + * @see POST /api/v1/admin/reports/:report_id/resolve + * @see POST /api/v1/admin/reports/:report_id/reopen + * @see POST /api/v1/admin/accounts/:account_id/action + * @see POST /api/v1/admin/accounts/:account_id/approve + */ + mastodonAdminApi: any([ + v.software === MASTODON && gte(v.compatVersion, '2.9.1'), + v.software === PLEROMA && v.build === SOAPBOX && gte(v.version, '2.4.50'), + ]), + /** * Can upload media attachments to statuses. * @see POST /api/v1/media