Merge branch 'dashboard-reports' into dashboard

merge-requests/2997/head
marcin mikołajczak 2024-04-18 23:47:39 +02:00
commit 290b139fbb
20 zmienionych plików z 177 dodań i 96 usunięć

Wyświetl plik

@ -122,6 +122,8 @@ const fetchMastodonReports = (params: Record<string, any>) =>
reports.forEach((report: APIEntity) => {
dispatch(importFetchedAccount(report.account?.account));
dispatch(importFetchedAccount(report.target_account?.account));
dispatch(importFetchedAccount(report.assigned_account?.account));
dispatch(importFetchedAccount(report.action_taken_by_account?.account));
dispatch(importFetchedStatuses(report.statuses));
});
dispatch({ type: ADMIN_REPORTS_FETCH_SUCCESS, reports, params });
@ -539,7 +541,6 @@ const expandUserIndex = () =>
});
};
export {
ADMIN_CONFIG_FETCH_REQUEST,
ADMIN_CONFIG_FETCH_SUCCESS,

Wyświetl plik

@ -68,7 +68,7 @@ const importFetchedAccounts = (accounts: APIEntity[], args = { should_refetch: f
const normalAccounts: APIEntity[] = [];
const processAccount = (account: APIEntity) => {
if (!account.id) return;
if (!account?.id) return;
if (should_refetch) {
account.should_refetch = true;

Wyświetl plik

@ -160,7 +160,6 @@ const expandFollowedHashtagsFail = (error: unknown) => ({
error,
});
export {
HASHTAG_FETCH_REQUEST,
HASHTAG_FETCH_SUCCESS,

Wyświetl plik

@ -0,0 +1,25 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi, useFeatures } from 'soapbox/hooks';
import { type Report, reportSchema } from 'soapbox/schemas';
const useReports = () => {
const api = useApi();
const features = useFeatures();
const { entities: reports, ...result } = useEntities<Report>(
[Entities.GROUPS, 'popular'],
() => api.get('/api/v1/admin/reports'),
{
schema: reportSchema,
enabled: features.groupsDiscovery,
},
);
return {
...result,
reports,
};
};
export { reports };

Wyświetl plik

@ -91,7 +91,6 @@ const Announcements: React.FC = () => {
const { data: announcements, isLoading } = useAnnouncements();
const handleCreateAnnouncement = () => {
dispatch(openModal('EDIT_ANNOUNCEMENT'));
};

Wyświetl plik

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { deleteStatusModal } from 'soapbox/actions/moderation';
@ -6,9 +6,10 @@ import DropdownMenu from 'soapbox/components/dropdown-menu';
import StatusContent from 'soapbox/components/status-content';
import StatusMedia from 'soapbox/components/status-media';
import { HStack, Stack } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetStatus } from 'soapbox/selectors';
import type { AdminReport, Status } from 'soapbox/types/entities';
import type { Report as ReportEntity } from 'soapbox/schemas';
const messages = defineMessages({
viewStatus: { id: 'admin.reports.actions.view_status', defaultMessage: 'View post' },
@ -16,14 +17,19 @@ const messages = defineMessages({
});
interface IReportStatus {
status: Status;
report?: AdminReport;
statusId: string;
report?: ReportEntity;
}
const ReportStatus: React.FC<IReportStatus> = ({ status }) => {
const ReportStatus: React.FC<IReportStatus> = ({ statusId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector(state => getStatus(state, { id: statusId }));
if (!status) return null;
const handleDeleteStatus = () => {
dispatch(deleteStatusModal(intl, status.id));
};

Wyświetl plik

@ -1,20 +1,19 @@
import React, { useCallback, useState } from 'react';
import React, { useState } from 'react';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import { closeReports } from 'soapbox/actions/admin';
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
import { useAccount } from 'soapbox/api/hooks';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import { Accordion, Avatar, Button, Stack, HStack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetReport } from 'soapbox/selectors';
import toast from 'soapbox/toast';
import ReportStatus from './report-status';
import type { List as ImmutableList } from 'immutable';
import type { Account, AdminReport, Status } from 'soapbox/types/entities';
import type { Account as AccountEntity, Report as ReportEntity } from 'soapbox/schemas';
const messages = defineMessages({
reportClosed: { id: 'admin.reports.report_closed_message', defaultMessage: 'Report on @{name} was closed' },
@ -30,16 +29,14 @@ const Report: React.FC<IReport> = ({ id }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const getReport = useCallback(makeGetReport(), []);
const report = useAppSelector((state) => getReport(state, id) as AdminReport | undefined);
const report = useAppSelector((state) => state.admin.reports.get(id) as ReportEntity | undefined);
const [accordionExpanded, setAccordionExpanded] = useState(false);
if (!report) return null;
const { account } = useAccount(report?.account.id) as { account: AccountEntity };
const { account: targetAccount } = useAccount(report?.target_account.id) as { account: AccountEntity };
const account = report.account as Account;
const targetAccount = report.target_account as Account;
if (!report) return null;
const makeMenu = () => {
return [{
@ -76,8 +73,8 @@ const Report: React.FC<IReport> = ({ id }) => {
};
const menu = makeMenu();
const statuses = report.statuses as ImmutableList<Status>;
const statusCount = statuses.count();
const statuses = report.statuses;
const statusCount = statuses.length;
const acct = targetAccount.acct as string;
const reporterAcct = account.acct as string;
@ -113,7 +110,7 @@ const Report: React.FC<IReport> = ({ id }) => {
<ReportStatus
key={status.id}
report={report}
status={status}
statusId={status.id}
/>
))}
</Stack>

Wyświetl plik

@ -270,7 +270,6 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
</FormGroup>
)}
{!features.nostrSignup && (
<Input
type='email'

Wyświetl plik

@ -9,7 +9,6 @@ import { useFeatures } from 'soapbox/hooks';
import NewFolderForm from './components/new-folder-form';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});

Wyświetl plik

@ -13,7 +13,6 @@ import GroupMemberListItem from './components/group-member-list-item';
import type { Group } from 'soapbox/types/entities';
interface IGroupMembers {
params: { groupId: string };
}

Wyświetl plik

@ -32,7 +32,6 @@ const GroupTagTimeline: React.FC<IGroupTimeline> = (props) => {
}
}, [groupId, tag]);
if (isLoading || !tag || !group) {
return null;
}

Wyświetl plik

@ -4,7 +4,6 @@ import { render, screen } from 'soapbox/jest/test-helpers';
import Blankslate from './blankslate';
describe('<Blankslate />', () => {
describe('with string props', () => {
it('should render correctly', () => {

Wyświetl plik

@ -20,7 +20,8 @@ import {
ADMIN_USERS_APPROVE_REQUEST,
ADMIN_USERS_APPROVE_SUCCESS,
} from 'soapbox/actions/admin';
import { normalizeAdminReport, normalizeAdminAccount } from 'soapbox/normalizers';
import { normalizeAdminAccount } from 'soapbox/normalizers';
import { reportSchema, Report } from 'soapbox/schemas';
import { normalizeId } from 'soapbox/utils/normalizers';
import type { AnyAction } from 'redux';
@ -28,7 +29,7 @@ import type { APIEntity } from 'soapbox/types/entities';
import type { Config } from 'soapbox/utils/config-db';
const ReducerRecord = ImmutableRecord({
reports: ImmutableMap<string, ReducerAdminReport>(),
reports: ImmutableMap<string, Report>(),
openReports: ImmutableOrderedSet<string>(),
users: ImmutableMap<string, ReducerAdminAccount>(),
latestUsers: ImmutableOrderedSet<string>(),
@ -40,20 +41,11 @@ const ReducerRecord = ImmutableRecord({
type State = ReturnType<typeof ReducerRecord>;
type AdminAccountRecord = ReturnType<typeof normalizeAdminAccount>;
type AdminReportRecord = ReturnType<typeof normalizeAdminReport>;
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<string | null>;
}
// Lol https://javascript.plainenglish.io/typescript-essentials-conditionally-filter-types-488705bfbf56
type FilterConditionally<Source, Condition> = Pick<Source, {[K in keyof Source]: Source[K] extends Condition ? K : never}[keyof Source]>;
@ -139,26 +131,11 @@ function approveUsers(state: State, users: APIUser[]): 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 => {
const normalizedReport = fixReport(report);
console.log(reportSchema.safeParse(report));
const normalizedReport = reportSchema.parse(report);
if (!normalizedReport.action_taken) {
state.update('openReports', orderedSet => orderedSet.add(report.id));
}

Wyświetl plik

@ -11,27 +11,19 @@ import { relationshipSchema } from './relationship';
import { coerceObject, contentSchema, filteredArray, makeCustomEmojiMap } from './utils';
import type { Resolve } from 'soapbox/utils/types';
import { roleSchema } from './role';
const avatarMissing = require('soapbox/assets/images/avatar-missing.png');
const headerMissing = require('soapbox/assets/images/header-missing.png');
const birthdaySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const hexSchema = z.string().regex(/^#[a-f0-9]{6}$/i);
const fieldSchema = z.object({
name: z.string(),
value: z.string(),
verified_at: z.string().datetime().nullable().catch(null),
});
const roleSchema = z.object({
id: z.string().catch(''),
name: z.string().catch(''),
color: hexSchema.catch(''),
highlighted: z.boolean().catch(true),
});
const baseAccountSchema = z.object({
acct: z.string().catch(''),
avatar: z.string().catch(avatarMissing),

Wyświetl plik

@ -0,0 +1,60 @@
import { z } from 'zod';
import { accountSchema } from './account';
import { roleSchema } from './role';
import { filteredArray } from './utils';
import type { Resolve } from 'soapbox/utils/types';
const adminIpSchema = z.object({
ip: z.string(),
used_at: z.string().datetime(),
});
const adminRoleSchema = z.preprocess((data: any) => {
if (typeof data === 'string') {
return { name: data };
}
return data;
}, roleSchema.extend({
permissions: z.number().nullable().catch(null),
}));
const adminAccountSchema = z.preprocess((data: any) => {
if (!data.account) {
return {
...data,
approved: data.is_approved,
confirmed: data.is_confirmed,
disabled: !data.is_active,
invite_request: data.registration_reason,
role: data.roles?.admin ? 'admin' : (data.roles?.moderator ? 'moderator' : null),
};
}
return data;
}, z.object({
account: accountSchema,
id: z.string(),
username: z.string().catch(''),
domain: z.string().nullable().catch(null),
created_at: z.string().datetime().catch(new Date().toUTCString()),
email: z.string().catch(''),
ip: z.string().nullable().catch(null),
ips: filteredArray(adminIpSchema),
locale: z.string().catch(''),
invite_request: z.string().nullable().catch(null),
role: adminRoleSchema.nullable().catch(null),
confirmed: z.boolean().catch(false),
approved: z.boolean().catch(false),
disabled: z.boolean().catch(false),
silenced: z.boolean().catch(false),
suspended: z.boolean().catch(false),
created_by_application_id: z.string().nullable().catch(null),
invited_by_account_id: z.string().nullable().catch(null),
}));
type AdminAccount = Resolve<z.infer<typeof adminAccountSchema>>;
export { adminAccountSchema, type AdminAccount };

Wyświetl plik

@ -1,6 +1,7 @@
export { accountSchema, type Account } from './account';
export { announcementSchema, adminAnnouncementSchema, type Announcement, type AdminAnnouncement } from './announcement';
export { announcementReactionSchema, type AnnouncementReaction } from './announcement-reaction';
export { adminAccountSchema, type AdminAccount } from './admin-account';
export { attachmentSchema, type Attachment } from './attachment';
export { bookmarkFolderSchema, type BookmarkFolder } from './bookmark-folder';
export { cardSchema, type Card } from './card';
@ -20,6 +21,8 @@ export { patronUserSchema, type PatronUser } from './patron';
export { pollSchema, type Poll, type PollOption } from './poll';
export { relationshipSchema, type Relationship } from './relationship';
export { relaySchema, type Relay } from './relay';
export { reportSchema, type Report } from './report';
export { roleSchema, type Role } from './role';
export { ruleSchema, adminRuleSchema, type Rule, type AdminRule } from './rule';
export { statusSchema, type Status } from './status';
export { tagSchema, type Tag } from './tag';

Wyświetl plik

@ -0,0 +1,42 @@
import { z } from 'zod';
import { adminAccountSchema } from './admin-account';
import { ruleSchema } from './rule';
import { statusSchema } from './status';
import { filteredArray } from './utils';
import type { Resolve } from 'soapbox/utils/types';
const reportSchema = z.preprocess((data: any) => {
if (data.actor) {
return {
...data,
target_account: data.account,
account: data.actor,
action_taken: data.state !== 'open',
comment: data.content,
updated_at: data.created_at,
};
}
return data;
}, z.object({
account: adminAccountSchema,
action_taken: z.boolean().catch(false),
action_taken_at: z.string().datetime().nullable().catch(null),
action_taken_by_account: adminAccountSchema.nullable().catch(null),
assigned_account: adminAccountSchema.nullable().catch(null),
category: z.enum(['spam', 'violation', 'other']).catch('other'),
comment: z.string().catch(''),
created_at: z.string().datetime().catch(new Date().toUTCString()),
forwarded: z.boolean().catch(false),
id: z.string(),
rules: filteredArray(ruleSchema),
statuses: filteredArray(statusSchema),
target_account: adminAccountSchema,
updated_at: z.string().datetime().catch(new Date().toUTCString()),
}));
type Report = Resolve<z.infer<typeof reportSchema>>;
export { reportSchema, type Report };

Wyświetl plik

@ -0,0 +1,16 @@
import z from 'zod';
import type { Resolve } from 'soapbox/utils/types';
const hexSchema = z.string().regex(/^#[a-f0-9]{6}$/i);
const roleSchema = z.object({
id: z.string().catch(''),
name: z.string().catch(''),
color: hexSchema.catch(''),
highlighted: z.boolean().catch(true),
});
type Role = Resolve<z.infer<typeof roleSchema>>;
export { roleSchema, type Role };

Wyświetl plik

@ -3,7 +3,6 @@ import {
List as ImmutableList,
OrderedSet as ImmutableOrderedSet,
Record as ImmutableRecord,
fromJS,
} from 'immutable';
import { createSelector } from 'reselect';
@ -233,34 +232,6 @@ export const makeGetChat = () => {
);
};
export const makeGetReport = () => {
const getStatus = makeGetStatus();
return createSelector(
[
(state: RootState, id: string) => state.admin.reports.get(id),
(state: RootState, id: string) => selectAccount(state, state.admin.reports.get(id)?.account || ''),
(state: RootState, id: string) => selectAccount(state, state.admin.reports.get(id)?.target_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, account, targetAccount, statuses) => {
if (!report) return null;
return report.withMutations((report) => {
// @ts-ignore
report.set('account', account);
// @ts-ignore
report.set('target_account', targetAccount);
// @ts-ignore
report.set('statuses', statuses);
});
},
);
};
const getAuthUserIds = createSelector([
(state: RootState) => state.auth.users,
], authUsers => {
@ -318,7 +289,6 @@ const getRemoteInstanceFederation = (state: RootState, host: string): HostFedera
) as HostFederation;
};
export const makeGetHosts = () => {
return createSelector([getSimplePolicy], (simplePolicy) => {
const { accept, reject_deletes, report_removal, ...rest } = simplePolicy;

Wyświetl plik

@ -59,7 +59,6 @@ function immutableizeStore<T, S extends Record<string, T | undefined>>(state: S)
};
}
export {
immutableizeStore,
immutableizeEntity,