diff --git a/playwright/e2e/room/invites.spec.ts b/playwright/e2e/room/invites.spec.ts new file mode 100644 index 0000000000..d81fb13de1 --- /dev/null +++ b/playwright/e2e/room/invites.spec.ts @@ -0,0 +1,67 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("Invites", () => { + test.use({ + displayName: "Alice", + botCreateOpts: { + displayName: "Bob", + }, + }); + + test("should render an invite view", { tag: "@screenshot" }, async ({ page, homeserver, user, bot, app }) => { + const roomId = await bot.createRoom({ is_direct: true }); + await bot.inviteUser(roomId, user.userId); + await app.viewRoomByName("Bob"); + await expect(page.locator(".mx_RoomView")).toMatchScreenshot("Invites_room_view.png"); + }); + + test("should be able to decline an invite", async ({ page, homeserver, user, bot, app }) => { + const roomId = await bot.createRoom({ is_direct: true }); + await bot.inviteUser(roomId, user.userId); + await app.viewRoomByName("Bob"); + await page.getByRole("button", { name: "Decline", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible(); + await expect( + page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Bob", exact: true }), + ).not.toBeVisible(); + }); + + test( + "should be able to decline an invite, report the room and ignore the user", + { tag: "@screenshot" }, + async ({ page, homeserver, user, bot, app }) => { + const roomId = await bot.createRoom({ is_direct: true }); + await bot.inviteUser(roomId, user.userId); + await app.viewRoomByName("Bob"); + await page.getByRole("button", { name: "Decline and block" }).click(); + await page.getByLabel("Ignore user").click(); + await page.getByLabel("Report room").click(); + await page.getByLabel("Reason").fill("Do not want the room"); + const roomReported = page.waitForRequest( + (req) => + req.url().endsWith(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/report`) && + req.method() === "POST", + ); + await expect(page.getByRole("dialog", { name: "Decline invitation" })).toMatchScreenshot( + "Invites_reject_dialog.png", + ); + await page.getByRole("button", { name: "Decline invite" }).click(); + + // Check room was reported. + await roomReported; + + // Check user is ignored. + await app.settings.openUserSettings("Security & Privacy"); + const ignoredUsersList = page.getByRole("list", { name: "Ignored users" }); + await ignoredUsersList.scrollIntoViewIfNeeded(); + await expect(ignoredUsersList.getByRole("listitem", { name: bot.credentials.userId })).toBeVisible(); + }, + ); +}); diff --git a/playwright/e2e/sliding-sync/sliding-sync.spec.ts b/playwright/e2e/sliding-sync/sliding-sync.spec.ts index 118bd4585e..b540cd11d5 100644 --- a/playwright/e2e/sliding-sync/sliding-sync.spec.ts +++ b/playwright/e2e/sliding-sync/sliding-sync.spec.ts @@ -255,8 +255,8 @@ test.describe("Sliding Sync", () => { // Select the room to reject await page.getByRole("treeitem", { name: "Room to Reject" }).click(); - // Reject the invite - await page.locator(".mx_RoomView").getByRole("button", { name: "Reject", exact: true }).click(); + // Decline the invite + await page.locator(".mx_RoomView").getByRole("button", { name: "Decline", exact: true }).click(); await expect( page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), diff --git a/playwright/snapshots/right-panel/right-panel.spec.ts/room-report-dialog-linux.png b/playwright/snapshots/right-panel/right-panel.spec.ts/room-report-dialog-linux.png index bfaf0e909e..3beae421d4 100644 Binary files a/playwright/snapshots/right-panel/right-panel.spec.ts/room-report-dialog-linux.png and b/playwright/snapshots/right-panel/right-panel.spec.ts/room-report-dialog-linux.png differ diff --git a/playwright/snapshots/room/invites.spec.ts/Invites-reject-dialog-linux.png b/playwright/snapshots/room/invites.spec.ts/Invites-reject-dialog-linux.png new file mode 100644 index 0000000000..71f3e420ab Binary files /dev/null and b/playwright/snapshots/room/invites.spec.ts/Invites-reject-dialog-linux.png differ diff --git a/playwright/snapshots/room/invites.spec.ts/Invites-room-view-linux.png b/playwright/snapshots/room/invites.spec.ts/Invites-room-view-linux.png new file mode 100644 index 0000000000..8466ecab00 Binary files /dev/null and b/playwright/snapshots/room/invites.spec.ts/Invites-room-view-linux.png differ diff --git a/res/css/views/dialogs/_ReportRoomDialog.pcss b/res/css/views/dialogs/_ReportRoomDialog.pcss index fc9d087de1..b656638d9c 100644 --- a/res/css/views/dialogs/_ReportRoomDialog.pcss +++ b/res/css/views/dialogs/_ReportRoomDialog.pcss @@ -5,7 +5,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. */ -.mx_ReportRoomDialog { +.mx_ReportRoomDialog, +.mx_DeclineAndBlockInviteDialog { textarea { font: var(--cpd-font-body-md-regular); border: 1px solid var(--cpd-color-border-interactive-primary); @@ -14,7 +15,26 @@ Please see LICENSE files in the repository root for full details. padding: var(--cpd-space-3x) var(--cpd-space-4x); } - label { + /* + Workaround to fix labels appearing with the wrong color. + + .mx_Dialog (in res/css/_common.pcss) redefines the body color + as $light-fg-color rather than the standard primary color. + + This forces the colour to match the Compound style, but + in the future the Dialogs should not force a color. + */ + form label { + color: var(--cpd-color-text-primary); + } +} + +.mx_DeclineAndBlockInviteDialog { + div[aria-disabled="true"] > label { + color: var(--cpd-color-text-secondary); + } + + .mx_SettingsFlag_label { color: var(--cpd-color-text-primary); font-weight: var(--cpd-font-weight-semibold); } diff --git a/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.pcss index cb5d1fbc94..82f839042b 100644 --- a/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.pcss +++ b/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.pcss @@ -11,6 +11,12 @@ Please see LICENSE files in the repository root for full details. column-gap: $spacing-8; } +.mx_SecurityUserSettingsTab_ignoredUsers { + padding-left: 0; + margin: 0; + list-style: none; +} + .mx_SecurityUserSettingsTab_ignoredUser { margin-bottom: $spacing-4; } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 0f722ae53d..3c7941bd4c 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -711,36 +711,6 @@ export default class MatrixChat extends React.PureComponent { case "copy_room": this.copyRoom(payload.room_id); break; - case "reject_invite": - Modal.createDialog(QuestionDialog, { - title: _t("reject_invitation_dialog|title"), - description: _t("reject_invitation_dialog|confirmation"), - onFinished: (confirm) => { - if (confirm) { - // FIXME: controller shouldn't be loading a view :( - const modal = Modal.createDialog(Spinner, undefined, "mx_Dialog_spinner"); - - MatrixClientPeg.safeGet() - .leave(payload.room_id) - .then( - () => { - modal.close(); - if (this.state.currentRoomId === payload.room_id) { - dis.dispatch({ action: Action.ViewHomePage }); - } - }, - (err) => { - modal.close(); - Modal.createDialog(ErrorDialog, { - title: _t("reject_invitation_dialog|failed"), - description: err.toString(), - }); - }, - ); - } - }, - }); - break; case "view_user_info": this.viewUser(payload.userId, payload.subAction); break; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index b86e7937e8..3426ef7e97 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -134,6 +134,7 @@ import { onView3pidInvite } from "../../stores/right-panel/action-handlers"; import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel"; import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner"; import { ScopedRoomContextProvider, useScopedRoomContext } from "../../contexts/ScopedRoomContext"; +import { DeclineAndBlockInviteDialog } from "../views/dialogs/DeclineAndBlockInviteDialog"; const DEBUG = false; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; @@ -1732,48 +1733,61 @@ export class RoomView extends React.Component { }); }; - private onRejectButtonClicked = (): void => { - const roomId = this.getRoomId(); - if (!roomId) return; + private onDeclineAndBlockButtonClicked = async (): Promise => { + if (!this.state.room || !this.context.client) return; + const [shouldReject, ignoreUser, reportRoom] = await Modal.createDialog(DeclineAndBlockInviteDialog, { + roomName: this.state.room.name, + }).finished; + if (!shouldReject) { + return; + } + this.setState({ rejecting: true, }); - this.context.client?.leave(roomId).then( - () => { - defaultDispatcher.dispatch({ action: Action.ViewHomePage }); - this.setState({ - rejecting: false, - }); - }, - (error) => { - logger.error(`Failed to reject invite: ${error}`); - const msg = error.message ? error.message : JSON.stringify(error); - Modal.createDialog(ErrorDialog, { - title: _t("room|failed_reject_invite"), - description: msg, - }); + const actions: Promise[] = []; - this.setState({ - rejecting: false, - }); - }, - ); + if (ignoreUser) { + const myMember = this.state.room.getMember(this.context.client!.getSafeUserId()); + const inviteEvent = myMember!.events.member; + const ignoredUsers = this.context.client.getIgnoredUsers(); + ignoredUsers.push(inviteEvent!.getSender()!); // de-duped internally in the js-sdk + actions.push(this.context.client.setIgnoredUsers(ignoredUsers)); + } + + if (reportRoom !== false) { + actions.push(this.context.client.reportRoom(this.state.room.roomId, reportRoom)); + } + + actions.push(this.context.client.leave(this.state.room.roomId)); + try { + await Promise.all(actions); + defaultDispatcher.dispatch({ action: Action.ViewHomePage }); + this.setState({ + rejecting: false, + }); + } catch (error) { + logger.error(`Failed to reject invite: ${error}`); + + const msg = error instanceof Error ? error.message : JSON.stringify(error); + Modal.createDialog(ErrorDialog, { + title: _t("room|failed_reject_invite"), + description: msg, + }); + + this.setState({ + rejecting: false, + }); + } }; - private onRejectAndIgnoreClick = async (): Promise => { - this.setState({ - rejecting: true, - }); - + private onDeclineButtonClicked = async (): Promise => { + if (!this.state.room || !this.context.client) { + return; + } try { - const myMember = this.state.room!.getMember(this.context.client!.getSafeUserId()); - const inviteEvent = myMember!.events.member; - const ignoredUsers = this.context.client!.getIgnoredUsers(); - ignoredUsers.push(inviteEvent!.getSender()!); // de-duped internally in the js-sdk - await this.context.client!.setIgnoredUsers(ignoredUsers); - - await this.context.client!.leave(this.state.roomId!); + await this.context.client.leave(this.state.room.roomId); defaultDispatcher.dispatch({ action: Action.ViewHomePage }); this.setState({ rejecting: false, @@ -2126,7 +2140,7 @@ export class RoomView extends React.Component { { ; @@ -2196,8 +2210,9 @@ export class RoomView extends React.Component { { { onRejectButtonClicked={ this.props.threepidInvite ? this.onRejectThreepidInviteButtonClicked - : this.onRejectButtonClicked + : this.onDeclineButtonClicked } /> ); diff --git a/src/components/views/dialogs/DeclineAndBlockInviteDialog.tsx b/src/components/views/dialogs/DeclineAndBlockInviteDialog.tsx new file mode 100644 index 0000000000..a10dfbe42b --- /dev/null +++ b/src/components/views/dialogs/DeclineAndBlockInviteDialog.tsx @@ -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 = ({ onFinished, roomName }) => { + const [shouldReport, setShouldReport] = useState(false); + const [ignoreUser, setIgnoreUser] = useState(false); + + const [reportReason, setReportReason] = useState(""); + const reportReasonChanged = useCallback>( + (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 ( + + +

{_t("decline_invitation_dialog|confirm", { roomName })}

+ + + + + + +
+ + + + +
+ +
+
+
+
+`; diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/ReportRoomDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/ReportRoomDialog-test.tsx.snap index 63f6f3ee10..44edfa9130 100644 --- a/test/unit-tests/components/views/dialogs/__snapshots__/ReportRoomDialog-test.tsx.snap +++ b/test/unit-tests/components/views/dialogs/__snapshots__/ReportRoomDialog-test.tsx.snap @@ -43,7 +43,7 @@ exports[`ReportRoomDialog displays admin message 1`] = ` /> Report this room to your account provider. If the messages are encrypted, your admin will not be able to read them. @@ -71,7 +71,7 @@ exports[`ReportRoomDialog displays admin message 1`] = ` class="mx_SettingsFlag_label" >
Leave room
@@ -79,7 +79,7 @@ exports[`ReportRoomDialog displays admin message 1`] = `
with live location disabled goes to labs flag scr class="mx_SettingsFlag_label" >
Enable live location sharing
@@ -34,7 +34,7 @@ exports[` with live location disabled goes to labs flag scr
", () => { }); it("renders join and reject action buttons correctly", () => { - const component = getComponent({ inviterName, room, onJoinClick, onRejectClick }); - expect(getActions(component)).toMatchSnapshot(); - }); - - it("renders reject and ignore action buttons when handler is provided", () => { - const onRejectAndIgnoreClick = jest.fn(); - const component = getComponent({ - inviterName, - room, - onJoinClick, - onRejectClick, - onRejectAndIgnoreClick, - }); + const component = getComponent({ inviterName, room, onJoinClick, onDeclineClick: onRejectClick }); expect(getActions(component)).toMatchSnapshot(); }); it("renders join and reject action buttons in reverse order when room can previewed", () => { // when room is previewed action buttons are rendered left to right, with primary on the right - const component = getComponent({ inviterName, room, onJoinClick, onRejectClick, canPreview: true }); + const component = getComponent({ + inviterName, + room, + onJoinClick, + onDeclineClick: onRejectClick, + canPreview: true, + }); expect(getActions(component)).toMatchSnapshot(); }); it("joins room on primary button click", () => { - const component = getComponent({ inviterName, room, onJoinClick, onRejectClick }); + const component = getComponent({ inviterName, room, onJoinClick, onDeclineClick: onRejectClick }); fireEvent.click(getPrimaryActionButton(component)!); expect(onJoinClick).toHaveBeenCalled(); }); it("rejects invite on secondary button click", () => { - const component = getComponent({ inviterName, room, onJoinClick, onRejectClick }); + const component = getComponent({ inviterName, room, onJoinClick, onDeclineClick: onRejectClick }); fireEvent.click(getSecondaryActionButton(component)!); expect(onRejectClick).toHaveBeenCalled(); @@ -337,18 +331,6 @@ describe("", () => { const component = getComponent({ inviterName, room }); expect(getMessage(component)).toMatchSnapshot(); }); - - it("renders join and reject action buttons with correct labels", () => { - const onRejectAndIgnoreClick = jest.fn(); - const component = getComponent({ - inviterName, - room, - onJoinClick, - onRejectAndIgnoreClick, - onRejectClick, - }); - expect(getActions(component)).toMatchSnapshot(); - }); }); }); @@ -364,7 +346,7 @@ describe("", () => { async () => { const onJoinClick = jest.fn(); const onRejectClick = jest.fn(); - const component = getComponent({ ...props, onJoinClick, onRejectClick }); + const component = getComponent({ ...props, onJoinClick, onDeclineClick: onRejectClick }); await waitFor(() => expect(getPrimaryActionButton(component)).toBeTruthy()); if (expectSecondaryButton) expect(getSecondaryActionButton(component)).toBeFalsy(); fireEvent.click(getPrimaryActionButton(component)!); diff --git a/test/unit-tests/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap b/test/unit-tests/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap index e89552dd85..dc44db8d21 100644 --- a/test/unit-tests/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap @@ -339,34 +339,6 @@ exports[` with an invite without an invited email for a dm roo
`; -exports[` with an invite without an invited email for a dm room renders join and reject action buttons with correct labels 1`] = ` -
-
- Start chatting -
-
- Reject & Ignore user -
-
- Reject -
-
-`; - exports[` with an invite without an invited email for a non-dm room renders invite message 1`] = `
with an invite without an invited email for a non-dm role="button" tabindex="0" > - Reject + Decline
`; @@ -435,7 +407,7 @@ exports[` with an invite without an invited email for a non-dm role="button" tabindex="0" > - Reject + Decline
with an invite without an invited email for a non-dm
`; - -exports[` with an invite without an invited email for a non-dm room renders reject and ignore action buttons when handler is provided 1`] = ` -
-
- Accept -
-
- Reject & Ignore user -
-
- Reject -
-
-`; diff --git a/test/unit-tests/components/views/settings/__snapshots__/Notifications-test.tsx.snap b/test/unit-tests/components/views/settings/__snapshots__/Notifications-test.tsx.snap index 25d06ffc23..f6fcc63262 100644 --- a/test/unit-tests/components/views/settings/__snapshots__/Notifications-test.tsx.snap +++ b/test/unit-tests/components/views/settings/__snapshots__/Notifications-test.tsx.snap @@ -10,22 +10,22 @@ exports[` main notification switches renders only enable notifi class="mx_SettingsFlag_label" >
Enable notifications for this account
Turn off to disable notifications on all your devices and sessions
main notification switches renders only enable notifi >