Allow reporting a room when rejecting an invite. (#29570)

* Add report room dialog button/dialog.

* Update copy

* fixup tests / lint

* Fix title in test.

* update snapshot

* Add unit tests for dialog

* lint

* First pass at adding a report room on invite.

* Use a single line input field for reason to avoid bumping the layout.

* Fixups

* Embed reason to make it clear on grouping

* Revert accidental commit

* lint

* Add some playwright tests.

* tweaks

* Make ignored users list more accessible.

* i18n

* Fix sliding sync test.

* Add unit test

* Even more unit tests.

* move test

* Update to match designs.

* remove console statements

* fix css

* tidy up

* improve comments

* fix css

* updates
This commit is contained in:
Will Hunt
2025-04-08 09:08:00 +00:00
committed by GitHub
parent e2b7852998
commit 8fc6638d6e
29 changed files with 844 additions and 327 deletions
@@ -0,0 +1,82 @@
/*
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 { Field, Label, Root } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
interface IProps {
onFinished: (shouldReject: boolean, ignoreUser: boolean, reportRoom: false | string) => void;
roomName: string;
}
export const DeclineAndBlockInviteDialog: React.FunctionComponent<IProps> = ({ onFinished, roomName }) => {
const [shouldReport, setShouldReport] = useState<boolean>(false);
const [ignoreUser, setIgnoreUser] = useState<boolean>(false);
const [reportReason, setReportReason] = useState<string>("");
const reportReasonChanged = useCallback<ChangeEventHandler<HTMLTextAreaElement>>(
(e) => setReportReason(e.target.value),
[setReportReason],
);
const onCancel = useCallback(() => onFinished(false, false, false), [onFinished]);
const onOk = useCallback(
() => onFinished(true, ignoreUser, shouldReport ? reportReason : false),
[onFinished, ignoreUser, shouldReport, reportReason],
);
return (
<BaseDialog
className="mx_DeclineAndBlockInviteDialog"
onFinished={onCancel}
title={_t("decline_invitation_dialog|title")}
contentId="mx_Dialog_content"
>
<Root>
<p>{_t("decline_invitation_dialog|confirm", { roomName })}</p>
<LabelledToggleSwitch
label={_t("report_content|ignore_user")}
onChange={setIgnoreUser}
caption={_t("decline_invitation_dialog|ignore_user_help")}
value={ignoreUser}
/>
<LabelledToggleSwitch
label={_t("action|report_room")}
onChange={setShouldReport}
caption={_t("decline_invitation_dialog|report_room_description")}
value={shouldReport}
/>
<Field name="report-reason" aria-disabled={!shouldReport}>
<Label htmlFor="mx_DeclineAndBlockInviteDialog_reason">
{_t("room_settings|permissions|ban_reason")}
</Label>
<textarea
id="mx_DeclineAndBlockInviteDialog_reason"
className="mx_RoomReportTextArea"
placeholder={_t("decline_invitation_dialog|reason_description")}
rows={5}
onChange={reportReasonChanged}
value={shouldReport ? reportReason : ""}
disabled={!shouldReport}
/>
</Field>
<DialogButtons
primaryButton={_t("action|decline_invite")}
primaryButtonClass="danger"
cancelButton={_t("action|cancel")}
onPrimaryButtonClick={onOk}
onCancel={onCancel}
/>
</Root>
</BaseDialog>
);
};
@@ -6,9 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import React, { type FC, useId } from "react";
import classNames from "classnames";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import ToggleSwitch from "./ToggleSwitch";
import { Caption } from "../typography/Caption";
@@ -35,41 +34,50 @@ interface IProps {
"data-testid"?: string;
}
export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
private readonly id = `mx_LabelledToggleSwitch_${secureRandomString(12)}`;
const LabelledToggleSwitch: FC<IProps> = ({
label,
caption,
value,
disabled,
onChange,
tooltip,
toggleInFront,
className,
"data-testid": testId,
}) => {
// This is a minimal version of a SettingsFlag
const generatedId = useId();
const id = `mx_LabelledToggleSwitch_${generatedId}`;
let firstPart = (
<span className="mx_SettingsFlag_label">
<div id={id}>{label}</div>
{caption && <Caption id={`${id}_caption`}>{caption}</Caption>}
</span>
);
let secondPart = (
<ToggleSwitch
checked={value}
disabled={disabled}
onChange={onChange}
tooltip={tooltip}
aria-labelledby={id}
aria-describedby={caption ? `${id}_caption` : undefined}
/>
);
public render(): React.ReactNode {
// This is a minimal version of a SettingsFlag
const { label, caption } = this.props;
let firstPart = (
<span className="mx_SettingsFlag_label">
<div id={this.id}>{label}</div>
{caption && <Caption id={`${this.id}_caption`}>{caption}</Caption>}
</span>
);
let secondPart = (
<ToggleSwitch
checked={this.props.value}
disabled={this.props.disabled}
onChange={this.props.onChange}
tooltip={this.props.tooltip}
aria-labelledby={this.id}
aria-describedby={caption ? `${this.id}_caption` : undefined}
/>
);
if (this.props.toggleInFront) {
[firstPart, secondPart] = [secondPart, firstPart];
}
const classes = classNames("mx_SettingsFlag", this.props.className, {
mx_SettingsFlag_toggleInFront: this.props.toggleInFront,
});
return (
<div data-testid={this.props["data-testid"]} className={classes}>
{firstPart}
{secondPart}
</div>
);
if (toggleInFront) {
[firstPart, secondPart] = [secondPart, firstPart];
}
}
const classes = classNames("mx_SettingsFlag", className, {
mx_SettingsFlag_toggleInFront: toggleInFront,
});
return (
<div data-testid={testId} className={classes}>
{firstPart}
{secondPart}
</div>
);
};
export default LabelledToggleSwitch;
+26 -11
View File
@@ -14,6 +14,7 @@ import {
type RoomPreviewOpts,
RoomViewLifecycle,
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
import { Button } from "@vector-im/compound-web";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher";
@@ -90,12 +91,18 @@ interface IProps {
roomAlias?: string;
onJoinClick?(): void;
onRejectClick?(): void;
onRejectAndIgnoreClick?(): void;
onDeclineClick?(): void;
onDeclineAndBlockClick?(): void;
onForgetClick?(): void;
canAskToJoinAndMembershipIsLeave?: boolean;
promptAskToJoin?: boolean;
/**
* If true, this will prompt for additional safety options
* like reporting an invite or ignoring the user.
*/
promptRejectionOptions?: boolean;
knocked?: boolean;
onSubmitAskToJoin?(reason?: string): void;
onCancelAskToJoin?(): void;
@@ -313,6 +320,8 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
let primaryActionLabel: string | undefined;
let secondaryActionHandler: (() => void) | undefined;
let secondaryActionLabel: string | undefined;
let dangerActionHandler: (() => void) | undefined;
let dangerActionLabel: string | undefined;
let footer: JSX.Element | undefined;
const extraComponents: JSX.Element[] = [];
@@ -549,16 +558,11 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
}
primaryActionHandler = this.props.onJoinClick;
secondaryActionLabel = _t("action|reject");
secondaryActionHandler = this.props.onRejectClick;
secondaryActionLabel = _t("action|decline");
secondaryActionHandler = this.props.onDeclineClick;
dangerActionLabel = _t("action|decline_and_block");
dangerActionHandler = this.props.onDeclineAndBlockClick;
if (this.props.onRejectAndIgnoreClick) {
extraComponents.push(
<AccessibleButton kind="secondary" onClick={this.props.onRejectAndIgnoreClick} key="ignore">
{_t("room|invite_reject_ignore")}
</AccessibleButton>,
);
}
break;
}
case MessageCase.ViewingRoom: {
@@ -691,6 +695,15 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
);
}
let dangerActionButton;
if (dangerActionHandler) {
dangerActionButton = (
<Button destructive kind="tertiary" onClick={dangerActionHandler}>
{dangerActionLabel}
</Button>
);
}
const isPanel = this.props.canPreview;
const classes = classNames("mx_RoomPreviewBar", `mx_RoomPreviewBar_${messageCase}`, {
@@ -701,6 +714,7 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
// ensure correct tab order for both views
const actions = isPanel ? (
<>
{dangerActionButton}
{secondaryButton}
{extraComponents}
{primaryButton}
@@ -710,6 +724,7 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
{primaryButton}
{extraComponents}
{secondaryButton}
{dangerActionButton}
</>
);
@@ -112,7 +112,7 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
onRejectButtonClicked();
}}
>
{_t("action|reject")}
{_t("action|decline")}
</AccessibleButton>
<AccessibleButton
kind="primary"
@@ -67,7 +67,7 @@ export class IgnoredUser extends React.Component<IIgnoredUserProps> {
public render(): React.ReactNode {
const id = `mx_SecurityUserSettingsTab_ignoredUser_${this.props.userId}`;
return (
<div className="mx_SecurityUserSettingsTab_ignoredUser">
<li className="mx_SecurityUserSettingsTab_ignoredUser" aria-label={this.props.userId}>
<AccessibleButton
onClick={this.onUnignoreClicked}
kind="primary_sm"
@@ -77,7 +77,7 @@ export class IgnoredUser extends React.Component<IIgnoredUserProps> {
{_t("action|unignore")}
</AccessibleButton>
<span id={id}>{this.props.userId}</span>
</div>
</li>
);
}
}
@@ -234,23 +234,34 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
private renderIgnoredUsers(): JSX.Element {
const { waitingUnignored, ignoredUserIds } = this.state;
const userIds = !ignoredUserIds?.length
? _t("settings|security|ignore_users_empty")
: ignoredUserIds.map((u) => {
return (
<IgnoredUser
userId={u}
onUnignored={this.onUserUnignored}
key={u}
inProgress={waitingUnignored.includes(u)}
/>
);
});
if (!ignoredUserIds?.length) {
return (
<SettingsSubsection heading={_t("settings|security|ignore_users_section")}>
<SettingsSubsectionText>{_t("settings|security|ignore_users_empty")}</SettingsSubsectionText>
</SettingsSubsection>
);
}
return (
<SettingsSubsection heading={_t("settings|security|ignore_users_section")}>
<SettingsSubsectionText>{userIds}</SettingsSubsectionText>
<SettingsSubsection
id="mx_SecurityUserSettingsTab_ignoredUsersHeading"
heading={_t("settings|security|ignore_users_section")}
>
<SettingsSubsectionText>
<ul
aria-label={_t("settings|security|ignore_users_section")}
className="mx_SecurityUserSettingsTab_ignoredUsers"
>
{ignoredUserIds.map((u) => (
<IgnoredUser
userId={u}
onUnignored={this.onUserUnignored}
key={u}
inProgress={waitingUnignored.includes(u)}
/>
))}
</ul>
</SettingsSubsectionText>
</SettingsSubsection>
);
}