Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 12 additions & 307 deletions apps/web/src/components/views/dialogs/ReportEventDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Expand All @@ -11,25 +12,16 @@ import React, { type JSX, type ChangeEvent } from "react";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";

import { _t, UserFriendlyError } from "../../../languageHandler";
import { ensureDMExists } from "../../../createRoom";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import SdkConfig from "../../../SdkConfig";
import Markdown from "../../../Markdown";
import SettingsStore from "../../../settings/SettingsStore";
import StyledRadioButton from "../elements/StyledRadioButton";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import Field from "../elements/Field";
import Spinner from "../elements/Spinner";
import LabelledCheckbox from "../elements/LabelledCheckbox";

declare module "matrix-js-sdk/src/types" {
interface TimelineEvents {
[ABUSE_EVENT_TYPE]: AbuseEventContent;
}
}

interface IProps {
mxEvent: MatrixEvent;
onFinished(report?: boolean): void;
Expand All @@ -40,157 +32,25 @@ interface IState {
reason: string;
busy: boolean;
err?: string;
// If we know it, the nature of the abuse, as specified by MSC3215.
nature?: ExtendedNature;
ignoreUserToo: boolean; // if true, user will be ignored/blocked on submit
/*
* Whether the room is encrypted.
*/
isRoomEncrypted: boolean;
Comment thread
turt2live marked this conversation as resolved.
Outdated
}

const MODERATED_BY_STATE_EVENT_TYPE = [
"org.matrix.msc3215.room.moderation.moderated_by",
/**
* Unprefixed state event. Not ready for prime time.
*
* "m.room.moderation.moderated_by"
*/
];

export const ABUSE_EVENT_TYPE = "org.matrix.msc3215.abuse.report";

interface AbuseEventContent {
event_id: string;
room_id: string;
moderated_by_id: string;
nature?: ExtendedNature;
reporter: string;
comment: string;
}

// Standard abuse natures.
enum Nature {
Disagreement = "org.matrix.msc3215.abuse.nature.disagreement",
Toxic = "org.matrix.msc3215.abuse.nature.toxic",
Illegal = "org.matrix.msc3215.abuse.nature.illegal",
Spam = "org.matrix.msc3215.abuse.nature.spam",
Other = "org.matrix.msc3215.abuse.nature.other",
}

enum NonStandardValue {
// Non-standard abuse nature.
// It should never leave the client - we use it to fallback to
// server-wide abuse reporting.
Admin = "non-standard.abuse.nature.admin",
}

type ExtendedNature = Nature | NonStandardValue;

type Moderation = {
// The id of the moderation room.
moderationRoomId: string;
// The id of the bot in charge of forwarding abuse reports to the moderation room.
moderationBotUserId: string;
};
/*
* A dialog for reporting an event.
*
* The actual content of the dialog will depend on two things:
*
* 1. Is `feature_report_to_moderators` enabled?
* 2. Does the room support moderation as per MSC3215, i.e. is there
* a well-formed state event `m.room.moderation.moderated_by`
* /`org.matrix.msc3215.room.moderation.moderated_by`?
*/
export default class ReportEventDialog extends React.Component<IProps, IState> {
// If the room supports moderation, the moderation information.
private moderation?: Moderation;

public constructor(props: IProps) {
super(props);

let moderatedByRoomId: string | null = null;
let moderatedByUserId: string | null = null;

if (SettingsStore.getValue("feature_report_to_moderators")) {
// The client supports reporting to moderators.
// Does the room support it, too?

// Extract state events to determine whether we should display
const client = MatrixClientPeg.safeGet();
const room = client.getRoom(props.mxEvent.getRoomId());

for (const stateEventType of MODERATED_BY_STATE_EVENT_TYPE) {
const stateEvent = room?.currentState.getStateEvents(stateEventType, stateEventType);
if (!stateEvent) {
continue;
}
if (Array.isArray(stateEvent)) {
// Internal error.
throw new TypeError(
`getStateEvents(${stateEventType}, ${stateEventType}) ` +
"should return at most one state event",
);
}
const event = stateEvent.event;
if (!("content" in event) || typeof event["content"] != "object") {
// The room is improperly configured.
// Display this debug message for the sake of moderators.
console.debug(
"Moderation error",
"state event",
stateEventType,
"should have an object field `content`, got",
event,
);
continue;
}
const content = event["content"];
if (!("room_id" in content) || typeof content["room_id"] != "string") {
// The room is improperly configured.
// Display this debug message for the sake of moderators.
console.debug(
"Moderation error",
"state event",
stateEventType,
"should have a string field `content.room_id`, got",
event,
);
continue;
}
if (!("user_id" in content) || typeof content["user_id"] != "string") {
// The room is improperly configured.
// Display this debug message for the sake of moderators.
console.debug(
"Moderation error",
"state event",
stateEventType,
"should have a string field `content.user_id`, got",
event,
);
continue;
}
moderatedByRoomId = content["room_id"];
moderatedByUserId = content["user_id"];
}

if (moderatedByRoomId && moderatedByUserId) {
// The room supports moderation.
this.moderation = {
moderationRoomId: moderatedByRoomId,
moderationBotUserId: moderatedByUserId,
};
}
}

this.state = {
// A free-form text describing the abuse.
reason: "",
busy: false,
err: undefined,
// If specified, the nature of the abuse, as specified by MSC3215.
nature: undefined,
ignoreUserToo: false, // default false, for now. Could easily be argued as default true
isRoomEncrypted: false, // async, will be set later
};
Expand All @@ -215,11 +75,6 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
this.setState({ reason });
};

// The user has clicked on a nature.
private onNatureChosen = (e: React.FormEvent<HTMLInputElement>): void => {
this.setState({ nature: e.currentTarget.value as ExtendedNature });
};

// The user has clicked "cancel".
private onCancel = (): void => {
this.props.onFinished(false);
Expand All @@ -229,28 +84,13 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
private onSubmit = async (): Promise<void> => {
let reason = this.state.reason || "";
reason = reason.trim();
if (this.moderation) {
// This room supports moderation.
// We need a nature.
// If the nature is `NATURE.OTHER` or `NON_STANDARD_NATURE.ADMIN`, we also need a `reason`.
if (
!this.state.nature ||
((this.state.nature == Nature.Other || this.state.nature == NonStandardValue.Admin) && !reason)
) {
this.setState({
err: _t("report_content|missing_reason"),
});
return;
}
} else {
// This room does not support moderation.
// We need a `reason`.
if (!reason) {
this.setState({
err: _t("report_content|missing_reason"),
});
return;
}

// Reasons are required on the API, even if unhelpful like "."
if (!reason) {
this.setState({
err: _t("report_content|missing_reason"),
});
return;
}

this.setState({
Expand All @@ -261,28 +101,10 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
try {
const client = MatrixClientPeg.safeGet();
const ev = this.props.mxEvent;
if (this.moderation && this.state.nature !== NonStandardValue.Admin) {
const nature = this.state.nature;

// Report to moderators through to the dedicated bot,
// as configured in the room's state events.
const dmRoomId = await ensureDMExists(client, this.moderation.moderationBotUserId);
if (!dmRoomId) {
throw new UserFriendlyError("report_content|error_create_room_moderation_bot");
}

await client.sendEvent(dmRoomId, ABUSE_EVENT_TYPE, {
event_id: ev.getId()!,
room_id: ev.getRoomId()!,
moderated_by_id: this.moderation.moderationRoomId,
nature,
reporter: client.getUserId()!,
comment: this.state.reason.trim(),
} satisfies AbuseEventContent);
} else {
// Report to homeserver admin through the dedicated Matrix API.
await client.reportEvent(ev.getRoomId()!, ev.getId()!, -100, this.state.reason.trim());
}
// Report to homeserver admin through the dedicated Matrix API. We hardcode the "score" because it's
// not actually used by anything.
await client.reportEvent(ev.getRoomId()!, ev.getId()!, -100, this.state.reason.trim());

// if the user should also be ignored, do that
if (this.state.ignoreUserToo) {
Expand Down Expand Up @@ -331,123 +153,6 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
adminMessage = <p dangerouslySetInnerHTML={{ __html: html }} />;
}

if (this.moderation) {
// Display report-to-moderator dialog.
// We let the user pick a nature.
const homeServerName = SdkConfig.get("validated_server_config")!.hsName;
let subtitle: string;
switch (this.state.nature) {
case Nature.Disagreement:
subtitle = _t("report_content|nature_disagreement");
break;
case Nature.Toxic:
subtitle = _t("report_content|nature_toxic");
break;
case Nature.Illegal:
subtitle = _t("report_content|nature_illegal");
break;
case Nature.Spam:
subtitle = _t("report_content|nature_spam");
break;
case NonStandardValue.Admin:
if (this.state.isRoomEncrypted) {
subtitle = _t("report_content|nature_nonstandard_admin_encrypted", {
homeserver: homeServerName,
});
} else {
subtitle = _t("report_content|nature_nonstandard_admin", { homeserver: homeServerName });
}
break;
case Nature.Other:
subtitle = _t("report_content|nature_other");
break;
default:
subtitle = _t("report_content|nature");
break;
}

return (
<BaseDialog
className="mx_ReportEventDialog"
onFinished={this.props.onFinished}
title={_t("action|report_content")}
contentId="mx_ReportEventDialog"
>
<div>
<StyledRadioButton
name="nature"
value={Nature.Disagreement}
checked={this.state.nature == Nature.Disagreement}
onChange={this.onNatureChosen}
>
{_t("report_content|disagree")}
</StyledRadioButton>
<StyledRadioButton
name="nature"
value={Nature.Toxic}
checked={this.state.nature == Nature.Toxic}
onChange={this.onNatureChosen}
>
{_t("report_content|toxic_behaviour")}
</StyledRadioButton>
<StyledRadioButton
name="nature"
value={Nature.Illegal}
checked={this.state.nature == Nature.Illegal}
onChange={this.onNatureChosen}
>
{_t("report_content|illegal_content")}
</StyledRadioButton>
<StyledRadioButton
name="nature"
value={Nature.Spam}
checked={this.state.nature == Nature.Spam}
onChange={this.onNatureChosen}
>
{_t("report_content|spam_or_propaganda")}
</StyledRadioButton>
<StyledRadioButton
name="nature"
value={NonStandardValue.Admin}
checked={this.state.nature == NonStandardValue.Admin}
onChange={this.onNatureChosen}
>
{_t("report_content|report_entire_room")}
</StyledRadioButton>
<StyledRadioButton
name="nature"
value={Nature.Other}
checked={this.state.nature == Nature.Other}
onChange={this.onNatureChosen}
>
{_t("report_content|other_label")}
</StyledRadioButton>
<p>{subtitle}</p>
<Field
className="mx_ReportEventDialog_reason"
element="textarea"
label={_t("room_settings|permissions|ban_reason")}
rows={5}
onChange={this.onReasonChange}
value={this.state.reason}
disabled={this.state.busy}
/>
{progress}
{error}
{ignoreUserCheckbox}
</div>
<DialogButtons
primaryButton={_t("action|send_report")}
onPrimaryButtonClick={this.onSubmit}
focus={true}
onCancel={this.onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
// Report to homeserver admin.
// Currently, the API does not support natures.
return (
<BaseDialog
className="mx_ReportEventDialog"
Expand Down
Loading
Loading