diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 761b7cf0e0..cc69455a8a 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,6 +107,7 @@ import Views from "../../Views"; import { type FocusNextType, type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { type ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; import { type AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload"; +import { type AfterForgetRoomPayload } from "../../dispatcher/payloads/AfterForgetRoomPayload"; import { type DoAfterSyncPreparedPayload } from "../../dispatcher/payloads/DoAfterSyncPreparedPayload"; import { type ViewStartChatOrReusePayload } from "../../dispatcher/payloads/ViewStartChatOrReusePayload"; import { leaveRoomBehaviour } from "../../utils/leave-behaviour"; @@ -1269,10 +1270,12 @@ export default class MatrixChat extends React.PureComponent { dis.dispatch({ action: Action.ViewHomePage }); } - // We have to manually update the room list because the forgotten room will not - // be notified to us, therefore the room list will have no other way of knowing - // the room is forgotten. - if (room) RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved); + if (room) { + // Legacy room list store needs to be told to manually remove this room + RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved); + // New room list store will remove the room on the following dispatch + dis.dispatch({ action: Action.AfterForgetRoom, room }); + } }) .catch((err) => { const errCode = err.errcode || _td("error|unknown_error_code"); diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 41021f956a..803ad61fd3 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -235,6 +235,12 @@ export enum Action { */ AfterLeaveRoom = "after_leave_room", + /** + * Dispatched after a room has been successfully forgotten + * Should be used with AfterForgetRoomPayload. + */ + AfterForgetRoom = "after_forget_room", + /** * Used to defer actions until after sync is complete * LifecycleStore will emit deferredAction payload after 'MatrixActions.sync' diff --git a/src/dispatcher/payloads/AfterForgetRoomPayload.ts b/src/dispatcher/payloads/AfterForgetRoomPayload.ts new file mode 100644 index 0000000000..ba480e7ff7 --- /dev/null +++ b/src/dispatcher/payloads/AfterForgetRoomPayload.ts @@ -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. + */ + +import { type Room } from "matrix-js-sdk/src/matrix"; + +import { type Action } from "../actions"; +import { type ActionPayload } from "../payloads"; + +export interface AfterForgetRoomPayload extends ActionPayload { + action: Action.AfterForgetRoom; + room: Room; +} diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 7003a71999..e613f15959 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -34,6 +34,7 @@ import { type Sorter, SortingAlgorithm } from "./skip-list/sorters"; import { SettingLevel } from "../../settings/SettingLevel"; import { MARKED_UNREAD_TYPE_STABLE, MARKED_UNREAD_TYPE_UNSTABLE } from "../../utils/notifications"; import { getChangedOverrideRoomMutePushRules } from "../room-list/utils/roomMute"; +import { Action } from "../../dispatcher/actions"; /** * These are the filters passed to the room skip list. @@ -245,6 +246,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.addRoomAndEmit(payload.room, true); break; } + + case Action.AfterForgetRoom: { + const room = payload.room; + this.roomSkipList.removeRoom(room); + this.emit(LISTS_UPDATE_EVENT); + break; + } } } diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index 5c10e6b465..4487ae3f6a 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -69,6 +69,7 @@ import { type ValidatedServerConfig } from "../../../../src/utils/ValidatedServe import Modal from "../../../../src/Modal.tsx"; import { SetupEncryptionStore } from "../../../../src/stores/SetupEncryptionStore.ts"; import { clearStorage } from "../../../../src/Lifecycle"; +import RoomListStore from "../../../../src/stores/room-list/RoomListStore.ts"; jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({ completeAuthorizationCodeGrant: jest.fn(), @@ -155,6 +156,7 @@ describe("", () => { whoami: jest.fn(), logout: jest.fn(), getDeviceId: jest.fn(), + forget: () => Promise.resolve(), }); let mockClient: Mocked; const serverConfig = { @@ -675,6 +677,34 @@ describe("", () => { jest.restoreAllMocks(); }); + describe("forget_room", () => { + it("should dispatch after_forget_room action on successful forget", async () => { + await clearAllModals(); + await getComponentAndWaitForReady(); + + // Mock out the old room list store + jest.spyOn(RoomListStore.instance, "manualRoomUpdate").mockImplementation(async () => {}); + + // Register a mock function to the dispatcher + const fn = jest.fn(); + defaultDispatcher.register(fn); + + // Forge the room + defaultDispatcher.dispatch({ + action: "forget_room", + room_id: roomId, + }); + + // On success, we expect the following action to have been dispatched. + await waitFor(() => { + expect(fn).toHaveBeenCalledWith({ + action: Action.AfterForgetRoom, + room: room, + }); + }); + }); + }); + describe("leave_room", () => { beforeEach(async () => { await clearAllModals(); diff --git a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index a6bde429c3..0f6c4203e6 100644 --- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -27,6 +27,7 @@ import { SortingAlgorithm } from "../../../../src/stores/room-list-v3/skip-list/ import SettingsStore from "../../../../src/settings/SettingsStore"; import * as utils from "../../../../src/utils/notifications"; import * as roomMute from "../../../../src/stores/room-list/utils/roomMute"; +import { Action } from "../../../../src/dispatcher/actions"; describe("RoomListStoreV3", () => { async function getRoomListStore() { @@ -121,6 +122,27 @@ describe("RoomListStoreV3", () => { expect(store.getSortedRooms()[0].roomId).toEqual(room.roomId); }); + it("Forgotten room is removed", async () => { + const { store, rooms, dispatcher } = await getRoomListStore(); + const room = rooms[37]; + + // Room at index 37 should be in the store now + expect(store.getSortedRooms().map((r) => r.roomId)).toContain(room.roomId); + + // Forget room at index 37 + const payload = { + action: Action.AfterForgetRoom, + room: room, + }; + const fn = jest.fn(); + store.on(LISTS_UPDATE_EVENT, fn); + dispatcher.dispatch(payload, true); + + // Room at index 37 should no longer be in the store + expect(fn).toHaveBeenCalled(); + expect(store.getSortedRooms().map((r) => r.roomId)).not.toContain(room.roomId); + }); + it.each([KnownMembership.Join, KnownMembership.Invite])( "Room is removed when membership changes to leave", async (membership) => {