Skip to content

Commit 6ba21da

Browse files
New Room List: Prevent old tombstoned rooms from appearing in the list (#29881)
* Write failing playwright test Basically when someone changes their name, any old tombstoned rooms that were previously hidden would suddenly show up in the list. * Split addRoom into two methods - `reInsertRoom` that re-inserts a room that is already known by the skiplist. - `addNewRoom` to add new rooms The idea is that sometimes you only want to re-insert to noop, eg: when you get an event in an old room that was upgraded. * Use new methods in the RLS Only use `addNewRoom` when absolutely necessary. Most events should instead use `reInsertRoom` which will noop when the room isn't already known by the skiplist. * Fix broken tests * Add new test * Fix playwright test
1 parent 8ac2f60 commit 6ba21da

File tree

5 files changed

+143
-39
lines changed

5 files changed

+143
-39
lines changed

playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
* Please see LICENSE files in the repository root for full details.
66
*/
77

8+
import { type Visibility } from "matrix-js-sdk/src/matrix";
9+
import { type Locator, type Page } from "@playwright/test";
10+
811
import { expect, test } from "../../../element-web-test";
9-
import type { Locator, Page } from "@playwright/test";
12+
import { SettingLevel } from "../../../../src/settings/SettingLevel";
1013

1114
test.describe("Room list filters and sort", () => {
1215
test.use({
@@ -39,6 +42,65 @@ test.describe("Room list filters and sort", () => {
3942
await app.closeNotificationToast();
4043
});
4144

45+
test("Tombstoned rooms are not shown even when they receive updates", async ({ page, app, bot }) => {
46+
// This bug shows up with this setting turned on
47+
await app.settings.setValue("Spaces.allRoomsInHome", null, SettingLevel.DEVICE, true);
48+
49+
/*
50+
We will first create a room named 'Old Room' and will invite the bot user to this room.
51+
We will also send a simple message in this room.
52+
*/
53+
const oldRoomId = await app.client.createRoom({ name: "Old Room" });
54+
await app.client.inviteUser(oldRoomId, bot.credentials.userId);
55+
await bot.joinRoom(oldRoomId);
56+
const response = await app.client.sendMessage(oldRoomId, "Hello!");
57+
58+
/*
59+
At this point, we haven't done anything interesting.
60+
So we expect 'Old Room' to show up in the room list.
61+
*/
62+
const roomListView = getRoomList(page);
63+
const oldRoomTile = roomListView.getByRole("gridcell", { name: "Open room Old Room" });
64+
await expect(oldRoomTile).toBeVisible();
65+
66+
/*
67+
Now let's tombstone 'Old Room'.
68+
First we create a new room ('New Room') with the predecessor set to the old room..
69+
*/
70+
const newRoomId = await bot.createRoom({
71+
name: "New Room",
72+
creation_content: {
73+
predecessor: {
74+
event_id: response.event_id,
75+
room_id: oldRoomId,
76+
},
77+
},
78+
visibility: "public" as Visibility,
79+
});
80+
81+
/*
82+
Now we can send the tombstone event itself to the 'Old Room'.
83+
*/
84+
await app.client.sendStateEvent(oldRoomId, "m.room.tombstone", {
85+
body: "This room has been replaced",
86+
replacement_room: newRoomId,
87+
});
88+
89+
// Let's join the replaced room.
90+
await app.client.joinRoom(newRoomId);
91+
92+
// We expect 'Old Room' to be hidden from the room list.
93+
await expect(oldRoomTile).not.toBeVisible();
94+
95+
/*
96+
Let's say some user in the 'Old Room' changes their display name.
97+
This will send events to the all the rooms including 'Old Room'.
98+
Nevertheless, the replaced room should not be shown in the room list.
99+
*/
100+
await bot.setDisplayName("MyNewName");
101+
await expect(oldRoomTile).not.toBeVisible();
102+
});
103+
42104
test.describe("Scroll behaviour", () => {
43105
test("should scroll to the top of list when filter is applied and active room is not in filtered list", async ({
44106
page,

src/stores/room-list-v3/RoomListStoreV3.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -211,23 +211,28 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
211211
const oldMembership = getEffectiveMembership(payload.oldMembership);
212212
const newMembership = getEffectiveMembershipTag(payload.room, payload.membership);
213213

214+
// If the user is kicked, re-insert the room and do nothing more.
214215
const ownUserId = this.matrixClient.getSafeUserId();
215216
const isKicked = (payload.room as Room).getMember(ownUserId)?.isKicked();
216-
const shouldRemove =
217-
!isKicked &&
217+
if (isKicked) {
218+
this.addRoomAndEmit(payload.room);
219+
return;
220+
}
221+
222+
// If the user has left this room, remove it from the skiplist.
223+
if (
218224
(payload.oldMembership === KnownMembership.Invite ||
219225
payload.oldMembership === KnownMembership.Join) &&
220-
payload.membership === KnownMembership.Leave;
221-
222-
if (shouldRemove) {
226+
payload.membership === KnownMembership.Leave
227+
) {
223228
this.roomSkipList.removeRoom(payload.room);
224229
this.emit(LISTS_UPDATE_EVENT);
225230
return;
226231
}
227232

233+
// If we're joining an upgraded room, we'll want to make sure we don't proliferate
234+
// the dead room in the list.
228235
if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) {
229-
// If we're joining an upgraded room, we'll want to make sure we don't proliferate
230-
// the dead room in the list.
231236
const roomState: RoomState = payload.room.currentState;
232237
const predecessor = roomState.findPredecessor(this.msc3946ProcessDynamicPredecessor);
233238
if (predecessor) {
@@ -236,7 +241,8 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
236241
else logger.warn(`Unable to find predecessor room with id ${predecessor.roomId}`);
237242
}
238243
}
239-
this.addRoomAndEmit(payload.room);
244+
245+
this.addRoomAndEmit(payload.room, true);
240246
break;
241247
}
242248
}
@@ -260,7 +266,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
260266
logger.warn(`${roomId} was found in DMs but the room is not in the store`);
261267
continue;
262268
}
263-
this.roomSkipList!.addRoom(room);
269+
this.roomSkipList!.reInsertRoom(room);
264270
needsEmit = true;
265271
}
266272
}
@@ -274,7 +280,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
274280
.map((id) => this.matrixClient?.getRoom(id))
275281
.filter((room) => !!room);
276282
for (const room of rooms) {
277-
this.roomSkipList!.addRoom(room);
283+
this.roomSkipList!.reInsertRoom(room);
278284
needsEmit = true;
279285
}
280286
break;
@@ -303,10 +309,12 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
303309
/**
304310
* Add a room to the skiplist and emit an update.
305311
* @param room The room to add to the skiplist
312+
* @param isNewRoom Set this to true if this a new room that the isn't already in the skiplist
306313
*/
307-
private addRoomAndEmit(room: Room): void {
314+
private addRoomAndEmit(room: Room, isNewRoom = false): void {
308315
if (!this.roomSkipList) throw new Error("roomSkipList hasn't been created yet!");
309-
this.roomSkipList.addRoom(room);
316+
if (isNewRoom) this.roomSkipList.addNewRoom(room);
317+
else this.roomSkipList.reInsertRoom(room);
310318
this.emit(LISTS_UPDATE_EVENT);
311319
}
312320

src/stores/room-list-v3/skip-list/RoomSkipList.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,34 @@ export class RoomSkipList implements Iterable<Room> {
9090
}
9191

9292
/**
93-
* Adds a given room to the correct sorted position in the list.
94-
* If the room is already present in the list, it is first removed.
93+
* Re-inserts a room that is already in the skiplist.
94+
* This method does nothing if the room isn't already in the skiplist.
95+
* @param room the room to add
9596
*/
96-
public addRoom(room: Room): void {
97-
/**
98-
* Remove this room from the skip list if necessary.
99-
*/
97+
public reInsertRoom(room: Room): void {
98+
if (!this.roomNodeMap.has(room.roomId)) {
99+
return;
100+
}
100101
this.removeRoom(room);
102+
this.addNewRoom(room);
103+
}
101104

105+
/**
106+
* Adds a new room to the skiplist.
107+
* This method will throw an error if the room is already in the skiplist.
108+
* @param room the room to add
109+
*/
110+
public addNewRoom(room: Room): void {
111+
if (this.roomNodeMap.has(room.roomId)) {
112+
throw new Error(`Can't add room to skiplist: ${room.roomId} is already in the skiplist!`);
113+
}
114+
this.insertRoom(room);
115+
}
116+
117+
/**
118+
* Adds a given room to the correct sorted position in the list.
119+
*/
120+
private insertRoom(room: Room): void {
102121
const newNode = new RoomNode(room);
103122
newNode.checkIfRoomBelongsToActiveSpace();
104123
newNode.applyFilters(this.filters);

test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { RoomNotificationState } from "../../../../src/stores/notifications
1313
import { LISTS_UPDATE_EVENT, RoomListStoreV3Class } from "../../../../src/stores/room-list-v3/RoomListStoreV3";
1414
import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClient";
1515
import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter";
16-
import { mkEvent, mkMessage, mkSpace, stubClient, upsertRoomStateEvents } from "../../../test-utils";
16+
import { mkEvent, mkMessage, mkSpace, mkStubRoom, stubClient, upsertRoomStateEvents } from "../../../test-utils";
1717
import { getMockedRooms } from "./skip-list/getMockedRooms";
1818
import { AlphabeticSorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter";
1919
import dispatcher from "../../../../src/dispatcher/dispatcher";
@@ -205,14 +205,17 @@ describe("RoomListStoreV3", () => {
205205
expect(roomIds).toContain(newRoom.roomId);
206206
});
207207

208-
it("Rooms are inserted on m.direct event", async () => {
209-
const { store, dispatcher } = await getRoomListStore();
208+
it("Rooms are re-inserted on m.direct event", async () => {
209+
const { store, dispatcher, client } = await getRoomListStore();
210+
211+
// Let's mock the client to return new rooms with the name "My DM Room"
212+
client.getRoom = (roomId: string) => mkStubRoom(roomId, "My DM Room", client);
210213

211214
// Let's create a m.direct event that we can dispatch
212215
const content = {
213-
"@bar1:matrix.org": ["!newroom1:matrix.org", "!newroom2:matrix.org"],
214-
"@bar2:matrix.org": ["!newroom3:matrix.org", "!newroom4:matrix.org"],
215-
"@bar3:matrix.org": ["!newroom5:matrix.org"],
216+
"@bar1:matrix.org": ["!foo1:matrix.org", "!foo2:matrix.org"],
217+
"@bar2:matrix.org": ["!foo3:matrix.org", "!foo4:matrix.org"],
218+
"@bar3:matrix.org": ["!foo5:matrix.org"],
216219
};
217220
const event = mkEvent({
218221
event: true,
@@ -223,6 +226,8 @@ describe("RoomListStoreV3", () => {
223226

224227
const fn = jest.fn();
225228
store.on(LISTS_UPDATE_EVENT, fn);
229+
230+
// Do the actual dispatch
226231
dispatcher.dispatch(
227232
{
228233
action: "MatrixActions.accountData",
@@ -235,17 +240,21 @@ describe("RoomListStoreV3", () => {
235240
// Ensure only one emit occurs
236241
expect(fn).toHaveBeenCalledTimes(1);
237242

238-
// Each of these rooms should now appear in the store
239-
// We don't need to mock the rooms themselves since our mocked
240-
// client will create the rooms on getRoom() call.
241-
const roomIds = store.getSortedRooms().map((r) => r.roomId);
242-
[
243-
"!newroom1:matrix.org",
244-
"!newroom2:matrix.org",
245-
"!newroom3:matrix.org",
246-
"!newroom4:matrix.org",
247-
"!newroom5:matrix.org",
248-
].forEach((id) => expect(roomIds).toContain(id));
243+
/*
244+
When the dispatched event is processed by the room-list, the associated
245+
rooms will be fetched via client.getRoom and will be re-inserted into the
246+
skip list. We can confirm that this happened by checking if all the dm rooms
247+
have the same name ("My DM Room") since we've mocked the client to return such rooms.
248+
*/
249+
const ids = [
250+
"!foo1:matrix.org",
251+
"!foo2:matrix.org",
252+
"!foo3:matrix.org",
253+
"!foo4:matrix.org",
254+
"!foo5:matrix.org",
255+
];
256+
const rooms = store.getSortedRooms().filter((r) => ids.includes(r.roomId));
257+
rooms.forEach((room) => expect(room.name).toBe("My DM Room"));
249258
});
250259

251260
it("Room is re-inserted on tag change", async () => {

test/unit-tests/stores/room-list-v3/skip-list/RoomSkipList-test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ describe("RoomSkipList", () => {
6262
for (const room of toInsert) {
6363
// Insert this room 10 times
6464
for (let i = 0; i < 10; ++i) {
65-
skipList.addRoom(room);
65+
skipList.reInsertRoom(room);
6666
}
6767
}
6868
// Sorting order should be the same as before
@@ -84,7 +84,7 @@ describe("RoomSkipList", () => {
8484
event: true,
8585
});
8686
room.timeline.push(event);
87-
skipList.addRoom(room);
87+
skipList.reInsertRoom(room);
8888
expect(skipList.size).toEqual(rooms.length);
8989
}
9090
const sortedRooms = [...skipList];
@@ -93,6 +93,12 @@ describe("RoomSkipList", () => {
9393
}
9494
});
9595

96+
it("Throws error when same room is added via addNewRoom", () => {
97+
const { skipList, rooms } = generateSkipList();
98+
const room = rooms[5];
99+
expect(() => skipList.addNewRoom(room)).toThrow("Can't add room to skiplist");
100+
});
101+
96102
it("Re-sort works when sorter is swapped", () => {
97103
const { skipList, rooms, sorter } = generateSkipList();
98104
const sortedByRecency = [...rooms].sort((a, b) => sorter.comparator(a, b));
@@ -120,7 +126,7 @@ describe("RoomSkipList", () => {
120126

121127
// Shuffle and insert the rooms
122128
for (const room of shuffle(rooms)) {
123-
roomSkipList.addRoom(room);
129+
roomSkipList.addNewRoom(room);
124130
}
125131

126132
expect(roomSkipList.size).toEqual(totalRooms);

0 commit comments

Comments
 (0)