Skip to content

Commit b541228

Browse files
RoomListViewModel: Make the active room sticky in the list (#29551)
* Add new hook for sticky room This hook takes the filtered, sorted rooms and returns a new list of rooms such that the active room is kept in the same index even when the list has changes. * Use new hook in view model * Add tests * Use single * in comments
1 parent 0d28df0 commit b541228

File tree

4 files changed

+230
-67
lines changed

4 files changed

+230
-67
lines changed

src/components/viewmodels/roomlist/RoomListViewModel.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import SpaceStore from "../../../stores/spaces/SpaceStore";
1818
import dispatcher from "../../../dispatcher/dispatcher";
1919
import { Action } from "../../../dispatcher/actions";
2020
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
21-
import { useIndexForActiveRoom } from "./useIndexForActiveRoom";
21+
import { useStickyRoomList } from "./useStickyRoomList";
2222

2323
export interface RoomListViewState {
2424
/**
@@ -97,8 +97,14 @@ export interface RoomListViewState {
9797
*/
9898
export function useRoomListViewModel(): RoomListViewState {
9999
const matrixClient = useMatrixClientContext();
100-
const { primaryFilters, activePrimaryFilter, rooms, activateSecondaryFilter, activeSecondaryFilter } =
101-
useFilteredRooms();
100+
const {
101+
primaryFilters,
102+
activePrimaryFilter,
103+
rooms: filteredRooms,
104+
activateSecondaryFilter,
105+
activeSecondaryFilter,
106+
} = useFilteredRooms();
107+
const { activeIndex, rooms } = useStickyRoomList(filteredRooms);
102108

103109
const currentSpace = useEventEmitterState<Room | null>(
104110
SpaceStore.instance,
@@ -107,7 +113,6 @@ export function useRoomListViewModel(): RoomListViewState {
107113
);
108114
const canCreateRoom = hasCreateRoomRights(matrixClient, currentSpace);
109115

110-
const activeIndex = useIndexForActiveRoom(rooms);
111116
const { activeSortOption, sort } = useSorter();
112117
const { shouldShowMessagePreview, toggleMessagePreview } = useMessagePreviewToggle();
113118

src/components/viewmodels/roomlist/useIndexForActiveRoom.tsx

Lines changed: 0 additions & 44 deletions
This file was deleted.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { useCallback, useEffect, useState } from "react";
9+
10+
import { SdkContextClass } from "../../../contexts/SDKContext";
11+
import { useDispatcher } from "../../../hooks/useDispatcher";
12+
import dispatcher from "../../../dispatcher/dispatcher";
13+
import { Action } from "../../../dispatcher/actions";
14+
import type { Room } from "matrix-js-sdk/src/matrix";
15+
import type { Optional } from "matrix-events-sdk";
16+
17+
function getIndexByRoomId(rooms: Room[], roomId: Optional<string>): number | undefined {
18+
const index = rooms.findIndex((room) => room.roomId === roomId);
19+
return index === -1 ? undefined : index;
20+
}
21+
22+
function getRoomsWithStickyRoom(
23+
rooms: Room[],
24+
oldIndex: number | undefined,
25+
newIndex: number | undefined,
26+
isRoomChange: boolean,
27+
): { newRooms: Room[]; newIndex: number | undefined } {
28+
const updated = { newIndex, newRooms: rooms };
29+
if (isRoomChange) {
30+
/*
31+
* When opening another room, the index should obviously change.
32+
*/
33+
return updated;
34+
}
35+
if (newIndex === undefined || oldIndex === undefined) {
36+
/*
37+
* If oldIndex is undefined, then there was no active room before.
38+
* So nothing to do in regards to sticky room.
39+
* Similarly, if newIndex is undefined, there's no active room anymore.
40+
*/
41+
return updated;
42+
}
43+
if (newIndex === oldIndex) {
44+
/*
45+
* If the index hasn't changed, we have nothing to do.
46+
*/
47+
return updated;
48+
}
49+
if (oldIndex > rooms.length - 1) {
50+
/*
51+
* If the old index falls out of the bounds of the rooms array
52+
* (usually because rooms were removed), we can no longer place
53+
* the active room in the same old index.
54+
*/
55+
return updated;
56+
}
57+
58+
/*
59+
* Making the active room sticky is as simple as removing it from
60+
* its new index and placing it in the old index.
61+
*/
62+
const newRooms = [...rooms];
63+
const [newRoom] = newRooms.splice(newIndex, 1);
64+
newRooms.splice(oldIndex, 0, newRoom);
65+
66+
return { newIndex: oldIndex, newRooms };
67+
}
68+
69+
interface StickyRoomListResult {
70+
/**
71+
* List of rooms with sticky active room.
72+
*/
73+
rooms: Room[];
74+
/**
75+
* Index of the active room in the room list.
76+
*/
77+
activeIndex: number | undefined;
78+
}
79+
80+
/**
81+
* - Provides a list of rooms such that the active room is sticky i.e the active room is kept
82+
* in the same index even when the order of rooms in the list changes.
83+
* - Provides the index of the active room.
84+
* @param rooms list of rooms
85+
* @see {@link StickyRoomListResult} details what this hook returns..
86+
*/
87+
export function useStickyRoomList(rooms: Room[]): StickyRoomListResult {
88+
const [listState, setListState] = useState<{ index: number | undefined; roomsWithStickyRoom: Room[] }>({
89+
index: undefined,
90+
roomsWithStickyRoom: rooms,
91+
});
92+
93+
const updateRoomsAndIndex = useCallback(
94+
(newRoomId?: string, isRoomChange: boolean = false) => {
95+
setListState((current) => {
96+
const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId();
97+
const newActiveIndex = getIndexByRoomId(rooms, activeRoomId);
98+
const oldIndex = current.index;
99+
const { newIndex, newRooms } = getRoomsWithStickyRoom(rooms, oldIndex, newActiveIndex, isRoomChange);
100+
return { index: newIndex, roomsWithStickyRoom: newRooms };
101+
});
102+
},
103+
[rooms],
104+
);
105+
106+
// Re-calculate the index when the active room has changed.
107+
useDispatcher(dispatcher, (payload) => {
108+
if (payload.action === Action.ActiveRoomChanged) updateRoomsAndIndex(payload.newRoomId, true);
109+
});
110+
111+
// Re-calculate the index when the list of rooms has changed.
112+
useEffect(() => {
113+
updateRoomsAndIndex();
114+
}, [rooms, updateRoomsAndIndex]);
115+
116+
return { activeIndex: listState.index, rooms: listState.roomsWithStickyRoom };
117+
}

test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx

Lines changed: 104 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -314,34 +314,51 @@ describe("RoomListViewModel", () => {
314314
});
315315
});
316316

317-
describe("active index", () => {
318-
it("should recalculate active index when list of rooms change", () => {
317+
describe("Sticky room and active index", () => {
318+
function expectActiveRoom(vm: ReturnType<typeof useRoomListViewModel>, i: number, roomId: string) {
319+
expect(vm.activeIndex).toEqual(i);
320+
expect(vm.rooms[i].roomId).toEqual(roomId);
321+
}
322+
323+
it("active room and active index are retained on order change", () => {
319324
const { rooms } = mockAndCreateRooms();
320-
// Let's say that the first room is the active room initially
321-
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => rooms[0].roomId);
325+
326+
// Let's say that the room at index 5 is active
327+
const roomId = rooms[5].roomId;
328+
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
322329

323330
const { result: vm } = renderHook(() => useRoomListViewModel());
324-
expect(vm.current.activeIndex).toEqual(0);
331+
expect(vm.current.activeIndex).toEqual(5);
332+
333+
// Let's say that room at index 9 moves to index 5
334+
const room9 = rooms[9];
335+
rooms.splice(9, 1);
336+
rooms.splice(5, 0, room9);
337+
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
338+
339+
// Active room index should still be 5
340+
expectActiveRoom(vm.current, 5, roomId);
325341

326-
// Let's say that a new room is added and that becomes active
327-
const newRoom = mkStubRoom("bar:matrix.org", "Bar", undefined);
328-
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => newRoom.roomId);
329-
rooms.push(newRoom);
342+
// Let's add 2 new rooms from index 0
343+
const newRoom1 = mkStubRoom("bar1:matrix.org", "Bar 1", undefined);
344+
const newRoom2 = mkStubRoom("bar2:matrix.org", "Bar 2", undefined);
345+
rooms.unshift(newRoom1, newRoom2);
330346
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
331347

332-
// Now the active room should be the last room which we just added
333-
expect(vm.current.activeIndex).toEqual(rooms.length - 1);
348+
// Active room index should still be 5
349+
expectActiveRoom(vm.current, 5, roomId);
334350
});
335351

336-
it("should recalculate active index when active room changes", () => {
352+
it("active room and active index are updated when another room is opened", () => {
337353
const { rooms } = mockAndCreateRooms();
338-
const { result: vm } = renderHook(() => useRoomListViewModel());
354+
const roomId = rooms[5].roomId;
355+
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
339356

340-
// No active room yet
341-
expect(vm.current.activeIndex).toBeUndefined();
357+
const { result: vm } = renderHook(() => useRoomListViewModel());
358+
expectActiveRoom(vm.current, 5, roomId);
342359

343-
// Let's say that room at index 5 becomes active
344-
const room = rooms[5];
360+
// Let's say that room at index 9 becomes active
361+
const room = rooms[9];
345362
act(() => {
346363
dispatcher.dispatch(
347364
{
@@ -353,8 +370,76 @@ describe("RoomListViewModel", () => {
353370
);
354371
});
355372

356-
// We expect index 5 to be active now
357-
expect(vm.current.activeIndex).toEqual(5);
373+
// Active room index should change to reflect new room
374+
expectActiveRoom(vm.current, 9, room.roomId);
375+
});
376+
377+
it("active room and active index are updated when active index spills out of rooms array bounds", () => {
378+
const { rooms } = mockAndCreateRooms();
379+
// Let's say that the room at index 5 is active
380+
const roomId = rooms[5].roomId;
381+
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
382+
383+
const { result: vm } = renderHook(() => useRoomListViewModel());
384+
expectActiveRoom(vm.current, 5, roomId);
385+
386+
// Let's say that we remove rooms from the start of the array
387+
for (let i = 0; i < 4; ++i) {
388+
// We should be able to do 4 deletions before we run out of rooms
389+
rooms.splice(0, 1);
390+
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
391+
expectActiveRoom(vm.current, 5, roomId);
392+
}
393+
394+
// If we remove one more room from the start, there's not going to be enough rooms
395+
// to maintain the active index.
396+
rooms.splice(0, 1);
397+
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
398+
expectActiveRoom(vm.current, 0, roomId);
399+
});
400+
401+
it("active room and active index are retained when rooms that appear after the active room are deleted", () => {
402+
const { rooms } = mockAndCreateRooms();
403+
// Let's say that the room at index 5 is active
404+
const roomId = rooms[5].roomId;
405+
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
406+
407+
const { result: vm } = renderHook(() => useRoomListViewModel());
408+
expectActiveRoom(vm.current, 5, roomId);
409+
410+
// Let's say that we remove rooms from the start of the array
411+
for (let i = 0; i < 4; ++i) {
412+
// Deleting rooms after index 5 (active) should not update the active index
413+
rooms.splice(6, 1);
414+
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
415+
expectActiveRoom(vm.current, 5, roomId);
416+
}
417+
});
418+
419+
it("active room index becomes undefined when active room is deleted", () => {
420+
const { rooms } = mockAndCreateRooms();
421+
// Let's say that the room at index 5 is active
422+
let roomId: string | undefined = rooms[5].roomId;
423+
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
424+
425+
const { result: vm } = renderHook(() => useRoomListViewModel());
426+
expectActiveRoom(vm.current, 5, roomId);
427+
428+
// Let's remove the active room (i.e room at index 5)
429+
rooms.splice(5, 1);
430+
roomId = undefined;
431+
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
432+
expect(vm.current.activeIndex).toBeUndefined();
433+
});
434+
435+
it("active room index is initially undefined", () => {
436+
mockAndCreateRooms();
437+
438+
// Let's say that there's no active room currently
439+
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => undefined);
440+
441+
const { result: vm } = renderHook(() => useRoomListViewModel());
442+
expect(vm.current.activeIndex).toEqual(undefined);
358443
});
359444
});
360445
});

0 commit comments

Comments
 (0)