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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user