Skip to content

Add report room dialog button/dialog. #29513

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Mar 21, 2025
Merged
14 changes: 13 additions & 1 deletion playwright/e2e/right-panel/right-panel.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Expand All @@ -10,6 +10,7 @@ import { type Locator, type Page } from "@playwright/test";

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

const ROOM_NAME = "Test room";
const ROOM_NAME_LONG =
Expand Down Expand Up @@ -133,6 +134,17 @@ test.describe("RightPanel", () => {
await page.getByLabel("Room info").nth(1).click();
await checkRoomSummaryCard(page, ROOM_NAME);
});
test.describe("room reporting", () => {
test.skip(isDendrite, "Dendrite does not implement room reporting");
test("should handle reporting a room", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Report room" }).click();
const dialog = await page.getByRole("dialog", { name: "Report Room" });
await dialog.getByLabel("reason").fill("This room should be reported");
await dialog.getByRole("button", { name: "Send report" }).click();
await expect(page.getByText("Your report was sent.")).toBeVisible();
});
});
});

test.describe("in spaces", () => {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions res/css/_components.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@
@import "./views/dialogs/_ModalWidgetDialog.pcss";
@import "./views/dialogs/_PollCreateDialog.pcss";
@import "./views/dialogs/_RegistrationEmailPromptDialog.pcss";
@import "./views/dialogs/_ReportRoomDialog.pcss";
@import "./views/dialogs/_RoomSettingsDialog.pcss";
@import "./views/dialogs/_RoomSettingsDialogBridges.pcss";
@import "./views/dialogs/_RoomUpgradeDialog.pcss";
Expand Down
16 changes: 16 additions & 0 deletions res/css/views/dialogs/_ReportRoomDialog.pcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
Copyright 2025 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

.mx_ReportRoomDialog {
textarea {
font: var(--cpd-font-body-md-regular);
border: 1px solid var(--cpd-color-border-interactive-primary);
background: var(--cpd-color-bg-canvas-default);
border-radius: 0.5rem;
padding: var(--cpd-space-3x) var(--cpd-space-4x);
}
}
4 changes: 2 additions & 2 deletions res/css/views/right_panel/_RoomSummaryCard.pcss
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Expand Down Expand Up @@ -101,6 +101,6 @@ Please see LICENSE files in the repository root for full details.
margin: $spacing-12 0 $spacing-4;
}

.mx_RoomSummaryCard_leave {
.mx_RoomSummaryCard_bottomOptions {
margin: 0 0 var(--cpd-space-8x);
}
95 changes: 95 additions & 0 deletions src/components/views/dialogs/ReportRoomDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
Copyright 2025 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import React, { type ChangeEventHandler, useCallback, useState } from "react";
import { Root, Field, Label, InlineSpinner, ErrorMessage } from "@vector-im/compound-web";

import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import Markdown from "../../../Markdown";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import { MatrixClientPeg } from "../../../MatrixClientPeg";

interface IProps {
roomId: string;
onFinished(complete: boolean): void;
}

/*
* A dialog for reporting a room.
*/

export const ReportRoomDialog: React.FC<IProps> = function ({ roomId, onFinished }) {
const [error, setErr] = useState<string>();
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState(false);
const [reason, setReason] = useState("");
const client = MatrixClientPeg.safeGet();

const onReasonChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>((e) => setReason(e.target.value), []);
const onCancel = useCallback(() => onFinished(sent), [sent, onFinished]);
const onSubmit = useCallback(async () => {
setBusy(true);
try {
await client.reportRoom(roomId, reason);
setSent(true);
} catch (ex) {
if (ex instanceof Error) {
setErr(ex.message);
} else {
setErr("Unknown error");
}
} finally {
setBusy(false);
}
}, [roomId, reason, client]);

const adminMessageMD = SdkConfig.getObject("report_event")?.get("admin_message_md", "adminMessageMD");
let adminMessage: JSX.Element | undefined;
if (adminMessageMD) {
const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true });
adminMessage = <p dangerouslySetInnerHTML={{ __html: html }} />;
}

return (
<BaseDialog
className="mx_ReportRoomDialog"
onFinished={() => onFinished(sent)}
title={_t("report_room|title")}
contentId="mx_ReportEventDialog"
>
{sent && <p>{_t("report_room|sent")}</p>}
{!sent && (
<Root id="mx_ReportEventDialog" onSubmit={onSubmit}>
<p>{_t("report_room|description")}</p>
{adminMessage}
<Field name="reason">
<Label htmlFor="mx_ReportRoomDialog_reason">{_t("room_settings|permissions|ban_reason")}</Label>
<textarea
id="mx_ReportRoomDialog_reason"
placeholder={_t("report_room|reason_placeholder")}
rows={5}
onChange={onReasonChange}
value={reason}
disabled={busy}
/>
{error ? <ErrorMessage>{error}</ErrorMessage> : null}
</Field>
{busy ? <InlineSpinner /> : null}
<DialogButtons
primaryButton={_t("action|send_report")}
onPrimaryButtonClick={onSubmit}
focus={true}
onCancel={onCancel}
disabled={busy}
/>
</Root>
)}
</BaseDialog>
);
};
31 changes: 22 additions & 9 deletions src/components/views/right_panel/RoomSummaryCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Expand Down Expand Up @@ -77,6 +77,7 @@ import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
import { usePinnedEvents } from "../../../hooks/usePinnedEvents";
import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
import { ReportRoomDialog } from "../dialogs/ReportRoomDialog.tsx";

interface IProps {
room: Room;
Expand Down Expand Up @@ -231,6 +232,11 @@ const RoomSummaryCard: React.FC<IProps> = ({
room_id: room.roomId,
});
};
const onReportRoomClick = (): void => {
Modal.createDialog(ReportRoomDialog, {
roomId: room.roomId,
});
};

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

<Separator />

<MenuItem
className="mx_RoomSummaryCard_leave"
Icon={LeaveIcon}
kind="critical"
label={_t("action|leave_room")}
onSelect={onLeaveRoomClick}
/>
<div className="mx_RoomSummaryCard_bottomOptions">
<MenuItem
className="mx_RoomSummaryCard_leave"
Icon={LeaveIcon}
kind="critical"
label={_t("action|leave_room")}
onSelect={onLeaveRoomClick}
/>
<MenuItem
Icon={ErrorIcon}
kind="critical"
label={_t("action|report_room")}
onSelect={onReportRoomClick}
/>
</div>
</div>
</BaseCard>
);
Expand Down
7 changes: 7 additions & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"reply": "Reply",
"reply_in_thread": "Reply in thread",
"report_content": "Report Content",
"report_room": "Report room",
"resend": "Resend",
"reset": "Reset",
"resume": "Resume",
Expand Down Expand Up @@ -1810,6 +1811,12 @@
"spam_or_propaganda": "Spam or propaganda",
"toxic_behaviour": "Toxic Behaviour"
},
"report_room": {
"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.",
"reason_placeholder": " Reason for reporting...",
"sent": "Your report was sent.",
"title": "Report Room"
},
"restore_key_backup_dialog": {
"count_of_decryption_failures": "Failed to decrypt %(failedCount)s sessions!",
"count_of_successfully_restored_keys": "Successfully restored %(sessionCount)s keys",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
Copyright 2025 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import { render } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import React from "react";

import { ReportRoomDialog } from "../../../../../src/components/views/dialogs/ReportRoomDialog";
import SdkConfig from "../../../../../src/SdkConfig";
import { stubClient } from "../../../../test-utils";

const ROOM_ID = "!foo:bar";

describe("ReportRoomDialog", () => {
const onFinished: jest.Mock<any, any> = jest.fn();
const reportRoom: jest.Mock<any, any> = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
const client = stubClient();
client.reportRoom = reportRoom;

SdkConfig.put({
report_event: {
admin_message_md: `
# You should know

This doesn't actually go **anywhere**.`,
},
});
});

afterEach(() => {
SdkConfig.reset();
});

it("can close the dialog", async () => {
const { getByTestId } = render(<ReportRoomDialog roomId={ROOM_ID} onFinished={onFinished} />);
await userEvent.click(getByTestId("dialog-cancel-button"));
expect(onFinished).toHaveBeenCalledWith(false);
});

it("displays admin message", async () => {
const { container } = render(<ReportRoomDialog roomId={ROOM_ID} onFinished={onFinished} />);
expect(container).toMatchSnapshot();
});

it("can submit a report", async () => {
const REASON = "This room is bad!";
const { getByLabelText, getByText, getByRole } = render(
<ReportRoomDialog roomId={ROOM_ID} onFinished={onFinished} />,
);

await userEvent.type(getByLabelText("Reason"), REASON);
await userEvent.click(getByRole("button", { name: "Send report" }));

expect(reportRoom).toHaveBeenCalledWith(ROOM_ID, REASON);
expect(getByText("Your report was sent.")).toBeInTheDocument();

await userEvent.click(getByRole("button", { name: "Close dialog" }));
expect(onFinished).toHaveBeenCalledWith(true);
});
});
Loading
Loading