Skip to content

Commit e1970df

Browse files
authored
Add report room dialog button/dialog. (#29513)
* Add report room dialog button/dialog. * Update copy * fixup tests / lint * Fix title in test. * update snapshot * Add unit tests for dialog * lint
1 parent b541228 commit e1970df

File tree

11 files changed

+556
-123
lines changed

11 files changed

+556
-123
lines changed

playwright/e2e/right-panel/right-panel.spec.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2024 New Vector Ltd.
2+
Copyright 2024, 2025 New Vector Ltd.
33
Copyright 2022 The Matrix.org Foundation C.I.C.
44
55
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -10,6 +10,7 @@ import { type Locator, type Page } from "@playwright/test";
1010

1111
import { test, expect } from "../../element-web-test";
1212
import { checkRoomSummaryCard, viewRoomSummaryByName } from "./utils";
13+
import { isDendrite } from "../../plugins/homeserver/dendrite";
1314

1415
const ROOM_NAME = "Test room";
1516
const ROOM_NAME_LONG =
@@ -133,6 +134,17 @@ test.describe("RightPanel", () => {
133134
await page.getByLabel("Room info").nth(1).click();
134135
await checkRoomSummaryCard(page, ROOM_NAME);
135136
});
137+
test.describe("room reporting", () => {
138+
test.skip(isDendrite, "Dendrite does not implement room reporting");
139+
test("should handle reporting a room", async ({ page, app }) => {
140+
await viewRoomSummaryByName(page, app, ROOM_NAME);
141+
await page.getByRole("menuitem", { name: "Report room" }).click();
142+
const dialog = await page.getByRole("dialog", { name: "Report Room" });
143+
await dialog.getByLabel("reason").fill("This room should be reported");
144+
await dialog.getByRole("button", { name: "Send report" }).click();
145+
await expect(page.getByText("Your report was sent.")).toBeVisible();
146+
});
147+
});
136148
});
137149

138150
test.describe("in spaces", () => {
Loading

res/css/_components.pcss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@
152152
@import "./views/dialogs/_ModalWidgetDialog.pcss";
153153
@import "./views/dialogs/_PollCreateDialog.pcss";
154154
@import "./views/dialogs/_RegistrationEmailPromptDialog.pcss";
155+
@import "./views/dialogs/_ReportRoomDialog.pcss";
155156
@import "./views/dialogs/_RoomSettingsDialog.pcss";
156157
@import "./views/dialogs/_RoomSettingsDialogBridges.pcss";
157158
@import "./views/dialogs/_RoomUpgradeDialog.pcss";
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
.mx_ReportRoomDialog {
9+
textarea {
10+
font: var(--cpd-font-body-md-regular);
11+
border: 1px solid var(--cpd-color-border-interactive-primary);
12+
background: var(--cpd-color-bg-canvas-default);
13+
border-radius: 0.5rem;
14+
padding: var(--cpd-space-3x) var(--cpd-space-4x);
15+
}
16+
}

res/css/views/right_panel/_RoomSummaryCard.pcss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2024 New Vector Ltd.
2+
Copyright 2024, 2025 New Vector Ltd.
33
Copyright 2020 The Matrix.org Foundation C.I.C.
44
55
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -101,6 +101,6 @@ Please see LICENSE files in the repository root for full details.
101101
margin: $spacing-12 0 $spacing-4;
102102
}
103103

104-
.mx_RoomSummaryCard_leave {
104+
.mx_RoomSummaryCard_bottomOptions {
105105
margin: 0 0 var(--cpd-space-8x);
106106
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import React, { type ChangeEventHandler, useCallback, useState } from "react";
9+
import { Root, Field, Label, InlineSpinner, ErrorMessage } from "@vector-im/compound-web";
10+
11+
import { _t } from "../../../languageHandler";
12+
import SdkConfig from "../../../SdkConfig";
13+
import Markdown from "../../../Markdown";
14+
import BaseDialog from "./BaseDialog";
15+
import DialogButtons from "../elements/DialogButtons";
16+
import { MatrixClientPeg } from "../../../MatrixClientPeg";
17+
18+
interface IProps {
19+
roomId: string;
20+
onFinished(complete: boolean): void;
21+
}
22+
23+
/*
24+
* A dialog for reporting a room.
25+
*/
26+
27+
export const ReportRoomDialog: React.FC<IProps> = function ({ roomId, onFinished }) {
28+
const [error, setErr] = useState<string>();
29+
const [busy, setBusy] = useState(false);
30+
const [sent, setSent] = useState(false);
31+
const [reason, setReason] = useState("");
32+
const client = MatrixClientPeg.safeGet();
33+
34+
const onReasonChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>((e) => setReason(e.target.value), []);
35+
const onCancel = useCallback(() => onFinished(sent), [sent, onFinished]);
36+
const onSubmit = useCallback(async () => {
37+
setBusy(true);
38+
try {
39+
await client.reportRoom(roomId, reason);
40+
setSent(true);
41+
} catch (ex) {
42+
if (ex instanceof Error) {
43+
setErr(ex.message);
44+
} else {
45+
setErr("Unknown error");
46+
}
47+
} finally {
48+
setBusy(false);
49+
}
50+
}, [roomId, reason, client]);
51+
52+
const adminMessageMD = SdkConfig.getObject("report_event")?.get("admin_message_md", "adminMessageMD");
53+
let adminMessage: JSX.Element | undefined;
54+
if (adminMessageMD) {
55+
const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true });
56+
adminMessage = <p dangerouslySetInnerHTML={{ __html: html }} />;
57+
}
58+
59+
return (
60+
<BaseDialog
61+
className="mx_ReportRoomDialog"
62+
onFinished={() => onFinished(sent)}
63+
title={_t("report_room|title")}
64+
contentId="mx_ReportEventDialog"
65+
>
66+
{sent && <p>{_t("report_room|sent")}</p>}
67+
{!sent && (
68+
<Root id="mx_ReportEventDialog" onSubmit={onSubmit}>
69+
<p>{_t("report_room|description")}</p>
70+
{adminMessage}
71+
<Field name="reason">
72+
<Label htmlFor="mx_ReportRoomDialog_reason">{_t("room_settings|permissions|ban_reason")}</Label>
73+
<textarea
74+
id="mx_ReportRoomDialog_reason"
75+
placeholder={_t("report_room|reason_placeholder")}
76+
rows={5}
77+
onChange={onReasonChange}
78+
value={reason}
79+
disabled={busy}
80+
/>
81+
{error ? <ErrorMessage>{error}</ErrorMessage> : null}
82+
</Field>
83+
{busy ? <InlineSpinner /> : null}
84+
<DialogButtons
85+
primaryButton={_t("action|send_report")}
86+
onPrimaryButtonClick={onSubmit}
87+
focus={true}
88+
onCancel={onCancel}
89+
disabled={busy}
90+
/>
91+
</Root>
92+
)}
93+
</BaseDialog>
94+
);
95+
};

src/components/views/right_panel/RoomSummaryCard.tsx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2024 New Vector Ltd.
2+
Copyright 2024, 2025 New Vector Ltd.
33
Copyright 2020 The Matrix.org Foundation C.I.C.
44
55
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -77,6 +77,7 @@ import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
7777
import { usePinnedEvents } from "../../../hooks/usePinnedEvents";
7878
import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx";
7979
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
80+
import { ReportRoomDialog } from "../dialogs/ReportRoomDialog.tsx";
8081

8182
interface IProps {
8283
room: Room;
@@ -231,6 +232,11 @@ const RoomSummaryCard: React.FC<IProps> = ({
231232
room_id: room.roomId,
232233
});
233234
};
235+
const onReportRoomClick = (): void => {
236+
Modal.createDialog(ReportRoomDialog, {
237+
roomId: room.roomId,
238+
});
239+
};
234240

235241
const isRoomEncrypted = useIsEncrypted(cli, room);
236242
const roomContext = useScopedRoomContext("e2eStatus", "timelineRenderingType");
@@ -439,14 +445,21 @@ const RoomSummaryCard: React.FC<IProps> = ({
439445
<MenuItem Icon={SettingsIcon} label={_t("common|settings")} onSelect={onRoomSettingsClick} />
440446

441447
<Separator />
442-
443-
<MenuItem
444-
className="mx_RoomSummaryCard_leave"
445-
Icon={LeaveIcon}
446-
kind="critical"
447-
label={_t("action|leave_room")}
448-
onSelect={onLeaveRoomClick}
449-
/>
448+
<div className="mx_RoomSummaryCard_bottomOptions">
449+
<MenuItem
450+
className="mx_RoomSummaryCard_leave"
451+
Icon={LeaveIcon}
452+
kind="critical"
453+
label={_t("action|leave_room")}
454+
onSelect={onLeaveRoomClick}
455+
/>
456+
<MenuItem
457+
Icon={ErrorIcon}
458+
kind="critical"
459+
label={_t("action|report_room")}
460+
onSelect={onReportRoomClick}
461+
/>
462+
</div>
450463
</div>
451464
</BaseCard>
452465
);

src/i18n/strings/en_EN.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"reply": "Reply",
105105
"reply_in_thread": "Reply in thread",
106106
"report_content": "Report Content",
107+
"report_room": "Report room",
107108
"resend": "Resend",
108109
"reset": "Reset",
109110
"resume": "Resume",
@@ -1810,6 +1811,12 @@
18101811
"spam_or_propaganda": "Spam or propaganda",
18111812
"toxic_behaviour": "Toxic Behaviour"
18121813
},
1814+
"report_room": {
1815+
"description": "Report this room to your homeserver admin. This will send the room's unique ID, but if messages are encrypted, the administrator won't be able to read them or view shared files.",
1816+
"reason_placeholder": " Reason for reporting...",
1817+
"sent": "Your report was sent.",
1818+
"title": "Report Room"
1819+
},
18131820
"restore_key_backup_dialog": {
18141821
"count_of_decryption_failures": "Failed to decrypt %(failedCount)s sessions!",
18151822
"count_of_successfully_restored_keys": "Successfully restored %(sessionCount)s keys",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { render } from "jest-matrix-react";
9+
import userEvent from "@testing-library/user-event";
10+
import React from "react";
11+
12+
import { ReportRoomDialog } from "../../../../../src/components/views/dialogs/ReportRoomDialog";
13+
import SdkConfig from "../../../../../src/SdkConfig";
14+
import { stubClient } from "../../../../test-utils";
15+
16+
const ROOM_ID = "!foo:bar";
17+
18+
describe("ReportRoomDialog", () => {
19+
const onFinished: jest.Mock<any, any> = jest.fn();
20+
const reportRoom: jest.Mock<any, any> = jest.fn();
21+
beforeEach(() => {
22+
jest.resetAllMocks();
23+
const client = stubClient();
24+
client.reportRoom = reportRoom;
25+
26+
SdkConfig.put({
27+
report_event: {
28+
admin_message_md: `
29+
# You should know
30+
31+
This doesn't actually go **anywhere**.`,
32+
},
33+
});
34+
});
35+
36+
afterEach(() => {
37+
SdkConfig.reset();
38+
});
39+
40+
it("can close the dialog", async () => {
41+
const { getByTestId } = render(<ReportRoomDialog roomId={ROOM_ID} onFinished={onFinished} />);
42+
await userEvent.click(getByTestId("dialog-cancel-button"));
43+
expect(onFinished).toHaveBeenCalledWith(false);
44+
});
45+
46+
it("displays admin message", async () => {
47+
const { container } = render(<ReportRoomDialog roomId={ROOM_ID} onFinished={onFinished} />);
48+
expect(container).toMatchSnapshot();
49+
});
50+
51+
it("can submit a report", async () => {
52+
const REASON = "This room is bad!";
53+
const { getByLabelText, getByText, getByRole } = render(
54+
<ReportRoomDialog roomId={ROOM_ID} onFinished={onFinished} />,
55+
);
56+
57+
await userEvent.type(getByLabelText("Reason"), REASON);
58+
await userEvent.click(getByRole("button", { name: "Send report" }));
59+
60+
expect(reportRoom).toHaveBeenCalledWith(ROOM_ID, REASON);
61+
expect(getByText("Your report was sent.")).toBeInTheDocument();
62+
63+
await userEvent.click(getByRole("button", { name: "Close dialog" }));
64+
expect(onFinished).toHaveBeenCalledWith(true);
65+
});
66+
});

0 commit comments

Comments
 (0)