Skip to content

Commit

Permalink
feat: Introduce User Report Section on Moderation Console (#30554)
Browse files Browse the repository at this point in the history
Co-authored-by: Douglas Fabris <[email protected]>
  • Loading branch information
Dnouv and dougfabris authored Jan 23, 2024
1 parent da410ef commit 2260c04
Show file tree
Hide file tree
Showing 34 changed files with 1,228 additions and 254 deletions.
8 changes: 8 additions & 0 deletions .changeset/rare-eels-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@rocket.chat/model-typings': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

**Added ‘Reported Users’ Tab to Moderation Console:** Enhances user monitoring by displaying reported users.
142 changes: 138 additions & 4 deletions apps/meteor/app/api/server/v1/moderation.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { IModerationReport, IUser } from '@rocket.chat/core-typings';
import type { IModerationReport, IUser, IUserEmail } from '@rocket.chat/core-typings';
import { ModerationReports, Users } from '@rocket.chat/models';
import {
isReportHistoryProps,
isArchiveReportProps,
isReportInfoParams,
isReportMessageHistoryParams,
isGetUserReportsParams,
isModerationReportUserPost,
isModerationDeleteMsgHistoryParams,
isReportsByMsgIdParams,
Expand Down Expand Up @@ -51,7 +51,7 @@ API.v1.addRoute(
});
}

const total = await ModerationReports.countMessageReportsInRange(latest, oldest, escapedSelector);
const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector, true);

return API.v1.success({
reports,
Expand All @@ -63,11 +63,60 @@ API.v1.addRoute(
},
);

API.v1.addRoute(
'moderation.userReports',
{
authRequired: true,
validateParams: isReportHistoryProps,
permissionsRequired: ['view-moderation-console'],
},
{
async get() {
const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams;

const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams);

const { sort } = await this.parseJsonQuery();

const latest = _latest ? new Date(_latest) : new Date();

const oldest = _oldest ? new Date(_oldest) : new Date(0);

const escapedSelector = escapeRegExp(selector);

const reports = await ModerationReports.findUserReports(latest, oldest, escapedSelector, {
offset,
count,
sort,
}).toArray();

if (reports.length === 0) {
return API.v1.success({
reports,
count: 0,
offset,
total: 0,
});
}

const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector);

const result = {
reports,
count: reports.length,
offset,
total,
};
return API.v1.success(result);
},
},
);

API.v1.addRoute(
'moderation.user.reportedMessages',
{
authRequired: true,
validateParams: isReportMessageHistoryParams,
validateParams: isGetUserReportsParams,
permissionsRequired: ['view-moderation-console'],
},
{
Expand Down Expand Up @@ -113,6 +162,64 @@ API.v1.addRoute(
},
);

API.v1.addRoute(
'moderation.user.reportsByUserId',
{
authRequired: true,
validateParams: isGetUserReportsParams,
permissionsRequired: ['view-moderation-console'],
},
{
async get() {
const { userId, selector = '' } = this.queryParams;
const { sort } = await this.parseJsonQuery();
const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams);

const user = await Users.findOneById<IUser>(userId, {
projection: {
_id: 1,
username: 1,
name: 1,
avatarETag: 1,
active: 1,
roles: 1,
emails: 1,
createdAt: 1,
},
});

const escapedSelector = escapeRegExp(selector);
const { cursor, totalCount } = ModerationReports.findUserReportsByReportedUserId(userId, escapedSelector, {
offset,
count,
sort,
});

const [reports, total] = await Promise.all([cursor.toArray(), totalCount]);

const emailSet = new Map<IUserEmail['address'], IUserEmail>();

reports.forEach((report) => {
const email = report.reportedUser?.emails?.[0];
if (email) {
emailSet.set(email.address, email);
}
});
if (user) {
user.emails = Array.from(emailSet.values());
}

return API.v1.success({
user,
reports,
count: reports.length,
total,
offset,
});
},
},
);

API.v1.addRoute(
'moderation.user.deleteReportedMessages',
{
Expand Down Expand Up @@ -196,6 +303,33 @@ API.v1.addRoute(
},
);

API.v1.addRoute(
'moderation.dismissUserReports',
{
authRequired: true,
validateParams: isArchiveReportProps,
permissionsRequired: ['manage-moderation-actions'],
},
{
async post() {
const { userId, reason, action: actionParam } = this.bodyParams;

if (!userId) {
return API.v1.failure('error-user-id-param-not-provided');
}

const sanitizedReason: string = reason ?? 'No reason provided';
const action: string = actionParam ?? 'None';

const { userId: moderatorId } = this;

await ModerationReports.hideUserReportsByUserId(userId, moderatorId, sanitizedReason, action);

return API.v1.success();
},
},
);

API.v1.addRoute(
'moderation.reports',
{
Expand Down
33 changes: 14 additions & 19 deletions apps/meteor/client/views/admin/moderation/MessageContextFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Button, ButtonGroup } from '@rocket.chat/fuselage';
import { Button, ButtonGroup, Box } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
import type { FC } from 'react';
Expand All @@ -17,27 +17,22 @@ const MessageContextFooter: FC<{ userId: string; deleted: boolean }> = ({ userId

return (
<ButtonGroup stretch>
<Button onClick={dismissUserAction.onClick} title={t('Moderation_Dismiss_all_reports')} aria-label={t('Moderation_Dismiss_reports')}>
{t('Moderation_Dismiss_all_reports')}
</Button>
<Button
onClick={deleteMessagesAction.onClick}
title={t('delete-message')}
aria-label={t('Moderation_Delete_all_messages')}
secondary
danger
>
<Button onClick={dismissUserAction.onClick}>{t('Moderation_Dismiss_all_reports')}</Button>
<Button onClick={deleteMessagesAction.onClick} secondary danger>
{t('Moderation_Delete_all_messages')}
</Button>

<GenericMenu
title={t('More')}
items={[
{ ...useDeactivateUserAction(userId), ...(deleted && { disabled: true }) },
{ ...useResetAvatarAction(userId), ...(deleted && { disabled: true }) },
]}
placement='top-end'
/>
<Box flexGrow={0} marginInlineStart={8}>
<GenericMenu
large
title={t('More')}
items={[
{ ...useDeactivateUserAction(userId), ...(deleted && { disabled: true }) },
{ ...useResetAvatarAction(userId), ...(deleted && { disabled: true }) },
]}
placement='top-end'
/>
</Box>
</ButtonGroup>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const MessageReportInfo = ({ msgId }: { msgId: string }): JSX.Element => {
isSuccess: isSuccessReportsByMessage,
isError: isErrorReportsByMessage,
} = useQuery(
['moderation.reports', { msgId }],
['moderation', 'msgReports', 'fetchReasons', { msgId }],
async () => {
const reports = await getReportsByMessage({ msgId });
return reports;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Tabs, TabsItem, ContextualbarHeader, ContextualbarTitle } from '@rocket.chat/fuselage';
import { useTranslation, useRouter, useRouteParameter } from '@rocket.chat/ui-contexts';
import React, { useState } from 'react';

import { Contextualbar, ContextualbarClose } from '../../../components/Contextualbar';
import UserMessages from './UserMessages';
import UserReportInfo from './UserReports/UserReportInfo';

type ModConsoleReportDetailsProps = {
userId: IUser['_id'];
default: string;
onRedirect: (mid: string) => void;
};

const ModConsoleReportDetails = ({ userId, default: defaultTab, onRedirect }: ModConsoleReportDetailsProps) => {
const t = useTranslation();
const [tab, setTab] = useState<string>(defaultTab);
const moderationRoute = useRouter();

const activeTab = useRouteParameter('tab');

return (
<Contextualbar>
<ContextualbarHeader>
<ContextualbarTitle>{t('Reports')}</ContextualbarTitle>
<ContextualbarClose onClick={() => moderationRoute.navigate(`/admin/moderation/${activeTab}`, { replace: true })} />
</ContextualbarHeader>
<Tabs paddingBlockStart={8}>
<TabsItem selected={tab === 'messages'} onClick={() => setTab('messages')}>
{t('Messages')}
</TabsItem>
<TabsItem selected={tab === 'users'} onClick={() => setTab('users')}>
{t('User')}
</TabsItem>
</Tabs>
{tab === 'messages' && <UserMessages userId={userId} onRedirect={onRedirect} />}
{tab === 'users' && <UserReportInfo userId={userId} />}
</Contextualbar>
);
};

export default ModConsoleReportDetails;
55 changes: 40 additions & 15 deletions apps/meteor/client/views/admin/moderation/ModerationConsolePage.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,62 @@
import { Tabs, TabsItem } from '@rocket.chat/fuselage';
import { useTranslation, useRouteParameter, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import React from 'react';
import React, { useCallback } from 'react';

import { Contextualbar } from '../../../components/Contextualbar';
import { Page, PageHeader, PageContent } from '../../../components/Page';
import { getPermaLink } from '../../../lib/getPermaLink';
import ModConsoleReportDetails from './ModConsoleReportDetails';
import ModerationConsoleTable from './ModerationConsoleTable';
import UserMessages from './UserMessages';
import ModConsoleUsersTable from './UserReports/ModConsoleUsersTable';

const ModerationConsolePage = () => {
type TabType = 'users' | 'messages';

type ModerationConsolePageProps = {
tab: TabType;
onSelectTab?: (tab: TabType) => void;
};

const ModerationConsolePage = ({ tab = 'messages', onSelectTab }: ModerationConsolePageProps) => {
const t = useTranslation();
const context = useRouteParameter('context');
const id = useRouteParameter('id');
const dispatchToastMessage = useToastMessageDispatch();

const handleRedirect = async (mid: string) => {
try {
const permalink = await getPermaLink(mid);
// open the permalink in same tab
window.open(permalink, '_self');
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
const handleRedirect = useCallback(
async (mid: string) => {
try {
const permalink = await getPermaLink(mid);
window.open(permalink, '_self');
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
[dispatchToastMessage],
);

const handleTabClick = useCallback(
(tab: TabType): undefined | (() => void) => (onSelectTab ? (): void => onSelectTab(tab) : undefined),
[onSelectTab],
);

return (
<Page flexDirection='row'>
<Page>
<PageHeader title={t('Moderation')} />

<Tabs>
<TabsItem selected={tab === 'messages'} onClick={handleTabClick('messages')}>
{t('Reported_Messages')}
</TabsItem>
<TabsItem selected={tab === 'users'} onClick={handleTabClick('users')}>
{t('Reported_Users')}
</TabsItem>
</Tabs>
<PageContent>
<ModerationConsoleTable />
{tab === 'messages' && <ModerationConsoleTable />}
{tab === 'users' && <ModConsoleUsersTable />}
</PageContent>
</Page>
{context && <Contextualbar>{context === 'info' && id && <UserMessages userId={id} onRedirect={handleRedirect} />}</Contextualbar>}
{context === 'info' && id && <ModConsoleReportDetails userId={id} onRedirect={handleRedirect} default={tab} />}
</Page>
);
};
Expand Down
Loading

0 comments on commit 2260c04

Please sign in to comment.