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
This commit is contained in:
R Midhun Suresh 2025-03-21 17:41:59 +05:30 committed by GitHub
parent 0d28df0f67
commit b54122884c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 230 additions and 67 deletions

View File

@ -18,7 +18,7 @@ import SpaceStore from "../../../stores/spaces/SpaceStore";
import dispatcher from "../../../dispatcher/dispatcher"; import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useIndexForActiveRoom } from "./useIndexForActiveRoom"; import { useStickyRoomList } from "./useStickyRoomList";
export interface RoomListViewState { export interface RoomListViewState {
/** /**
@ -97,8 +97,14 @@ export interface RoomListViewState {
*/ */
export function useRoomListViewModel(): RoomListViewState { export function useRoomListViewModel(): RoomListViewState {
const matrixClient = useMatrixClientContext(); const matrixClient = useMatrixClientContext();
const { primaryFilters, activePrimaryFilter, rooms, activateSecondaryFilter, activeSecondaryFilter } = const {
useFilteredRooms(); primaryFilters,
activePrimaryFilter,
rooms: filteredRooms,
activateSecondaryFilter,
activeSecondaryFilter,
} = useFilteredRooms();
const { activeIndex, rooms } = useStickyRoomList(filteredRooms);
const currentSpace = useEventEmitterState<Room | null>( const currentSpace = useEventEmitterState<Room | null>(
SpaceStore.instance, SpaceStore.instance,
@ -107,7 +113,6 @@ export function useRoomListViewModel(): RoomListViewState {
); );
const canCreateRoom = hasCreateRoomRights(matrixClient, currentSpace); const canCreateRoom = hasCreateRoomRights(matrixClient, currentSpace);
const activeIndex = useIndexForActiveRoom(rooms);
const { activeSortOption, sort } = useSorter(); const { activeSortOption, sort } = useSorter();
const { shouldShowMessagePreview, toggleMessagePreview } = useMessagePreviewToggle(); const { shouldShowMessagePreview, toggleMessagePreview } = useMessagePreviewToggle();

View File

@ -1,44 +0,0 @@
/*
* 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 { useCallback, useEffect, useState } from "react";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { useDispatcher } from "../../../hooks/useDispatcher";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import type { Room } from "matrix-js-sdk/src/matrix";
/**
* Tracks the index of the active room in the given array of rooms.
* @param rooms list of rooms
* @returns index of the active room or undefined otherwise.
*/
export function useIndexForActiveRoom(rooms: Room[]): number | undefined {
const [index, setIndex] = useState<number | undefined>(undefined);
const calculateIndex = useCallback(
(newRoomId?: string) => {
const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId();
const index = rooms.findIndex((room) => room.roomId === activeRoomId);
setIndex(index === -1 ? undefined : index);
},
[rooms],
);
// Re-calculate the index when the active room has changed.
useDispatcher(dispatcher, (payload) => {
if (payload.action === Action.ActiveRoomChanged) calculateIndex(payload.newRoomId);
});
// Re-calculate the index when the list of rooms has changed.
useEffect(() => {
calculateIndex();
}, [calculateIndex, rooms]);
return index;
}

View File

@ -0,0 +1,117 @@
/*
* 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 { useCallback, useEffect, useState } from "react";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { useDispatcher } from "../../../hooks/useDispatcher";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import type { Room } from "matrix-js-sdk/src/matrix";
import type { Optional } from "matrix-events-sdk";
function getIndexByRoomId(rooms: Room[], roomId: Optional<string>): number | undefined {
const index = rooms.findIndex((room) => room.roomId === roomId);
return index === -1 ? undefined : index;
}
function getRoomsWithStickyRoom(
rooms: Room[],
oldIndex: number | undefined,
newIndex: number | undefined,
isRoomChange: boolean,
): { newRooms: Room[]; newIndex: number | undefined } {
const updated = { newIndex, newRooms: rooms };
if (isRoomChange) {
/*
* When opening another room, the index should obviously change.
*/
return updated;
}
if (newIndex === undefined || oldIndex === undefined) {
/*
* If oldIndex is undefined, then there was no active room before.
* So nothing to do in regards to sticky room.
* Similarly, if newIndex is undefined, there's no active room anymore.
*/
return updated;
}
if (newIndex === oldIndex) {
/*
* If the index hasn't changed, we have nothing to do.
*/
return updated;
}
if (oldIndex > rooms.length - 1) {
/*
* If the old index falls out of the bounds of the rooms array
* (usually because rooms were removed), we can no longer place
* the active room in the same old index.
*/
return updated;
}
/*
* Making the active room sticky is as simple as removing it from
* its new index and placing it in the old index.
*/
const newRooms = [...rooms];
const [newRoom] = newRooms.splice(newIndex, 1);
newRooms.splice(oldIndex, 0, newRoom);
return { newIndex: oldIndex, newRooms };
}
interface StickyRoomListResult {
/**
* List of rooms with sticky active room.
*/
rooms: Room[];
/**
* Index of the active room in the room list.
*/
activeIndex: number | undefined;
}
/**
* - Provides a list of rooms such that the active room is sticky i.e the active room is kept
* in the same index even when the order of rooms in the list changes.
* - Provides the index of the active room.
* @param rooms list of rooms
* @see {@link StickyRoomListResult} details what this hook returns..
*/
export function useStickyRoomList(rooms: Room[]): StickyRoomListResult {
const [listState, setListState] = useState<{ index: number | undefined; roomsWithStickyRoom: Room[] }>({
index: undefined,
roomsWithStickyRoom: rooms,
});
const updateRoomsAndIndex = useCallback(
(newRoomId?: string, isRoomChange: boolean = false) => {
setListState((current) => {
const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId();
const newActiveIndex = getIndexByRoomId(rooms, activeRoomId);
const oldIndex = current.index;
const { newIndex, newRooms } = getRoomsWithStickyRoom(rooms, oldIndex, newActiveIndex, isRoomChange);
return { index: newIndex, roomsWithStickyRoom: newRooms };
});
},
[rooms],
);
// Re-calculate the index when the active room has changed.
useDispatcher(dispatcher, (payload) => {
if (payload.action === Action.ActiveRoomChanged) updateRoomsAndIndex(payload.newRoomId, true);
});
// Re-calculate the index when the list of rooms has changed.
useEffect(() => {
updateRoomsAndIndex();
}, [rooms, updateRoomsAndIndex]);
return { activeIndex: listState.index, rooms: listState.roomsWithStickyRoom };
}

View File

@ -314,34 +314,51 @@ describe("RoomListViewModel", () => {
}); });
}); });
describe("active index", () => { describe("Sticky room and active index", () => {
it("should recalculate active index when list of rooms change", () => { function expectActiveRoom(vm: ReturnType<typeof useRoomListViewModel>, i: number, roomId: string) {
expect(vm.activeIndex).toEqual(i);
expect(vm.rooms[i].roomId).toEqual(roomId);
}
it("active room and active index are retained on order change", () => {
const { rooms } = mockAndCreateRooms(); const { rooms } = mockAndCreateRooms();
// Let's say that the first room is the active room initially
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => rooms[0].roomId); // Let's say that the room at index 5 is active
const roomId = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel()); const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.activeIndex).toEqual(0); expect(vm.current.activeIndex).toEqual(5);
// Let's say that a new room is added and that becomes active // Let's say that room at index 9 moves to index 5
const newRoom = mkStubRoom("bar:matrix.org", "Bar", undefined); const room9 = rooms[9];
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => newRoom.roomId); rooms.splice(9, 1);
rooms.push(newRoom); rooms.splice(5, 0, room9);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT)); act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
// Now the active room should be the last room which we just added // Active room index should still be 5
expect(vm.current.activeIndex).toEqual(rooms.length - 1); expectActiveRoom(vm.current, 5, roomId);
// Let's add 2 new rooms from index 0
const newRoom1 = mkStubRoom("bar1:matrix.org", "Bar 1", undefined);
const newRoom2 = mkStubRoom("bar2:matrix.org", "Bar 2", undefined);
rooms.unshift(newRoom1, newRoom2);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
// Active room index should still be 5
expectActiveRoom(vm.current, 5, roomId);
}); });
it("should recalculate active index when active room changes", () => { it("active room and active index are updated when another room is opened", () => {
const { rooms } = mockAndCreateRooms(); const { rooms } = mockAndCreateRooms();
const roomId = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel()); const { result: vm } = renderHook(() => useRoomListViewModel());
expectActiveRoom(vm.current, 5, roomId);
// No active room yet // Let's say that room at index 9 becomes active
expect(vm.current.activeIndex).toBeUndefined(); const room = rooms[9];
// Let's say that room at index 5 becomes active
const room = rooms[5];
act(() => { act(() => {
dispatcher.dispatch( dispatcher.dispatch(
{ {
@ -353,8 +370,76 @@ describe("RoomListViewModel", () => {
); );
}); });
// We expect index 5 to be active now // Active room index should change to reflect new room
expect(vm.current.activeIndex).toEqual(5); expectActiveRoom(vm.current, 9, room.roomId);
});
it("active room and active index are updated when active index spills out of rooms array bounds", () => {
const { rooms } = mockAndCreateRooms();
// Let's say that the room at index 5 is active
const roomId = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expectActiveRoom(vm.current, 5, roomId);
// Let's say that we remove rooms from the start of the array
for (let i = 0; i < 4; ++i) {
// We should be able to do 4 deletions before we run out of rooms
rooms.splice(0, 1);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
expectActiveRoom(vm.current, 5, roomId);
}
// If we remove one more room from the start, there's not going to be enough rooms
// to maintain the active index.
rooms.splice(0, 1);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
expectActiveRoom(vm.current, 0, roomId);
});
it("active room and active index are retained when rooms that appear after the active room are deleted", () => {
const { rooms } = mockAndCreateRooms();
// Let's say that the room at index 5 is active
const roomId = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expectActiveRoom(vm.current, 5, roomId);
// Let's say that we remove rooms from the start of the array
for (let i = 0; i < 4; ++i) {
// Deleting rooms after index 5 (active) should not update the active index
rooms.splice(6, 1);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
expectActiveRoom(vm.current, 5, roomId);
}
});
it("active room index becomes undefined when active room is deleted", () => {
const { rooms } = mockAndCreateRooms();
// Let's say that the room at index 5 is active
let roomId: string | undefined = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expectActiveRoom(vm.current, 5, roomId);
// Let's remove the active room (i.e room at index 5)
rooms.splice(5, 1);
roomId = undefined;
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
expect(vm.current.activeIndex).toBeUndefined();
});
it("active room index is initially undefined", () => {
mockAndCreateRooms();
// Let's say that there's no active room currently
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => undefined);
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.activeIndex).toEqual(undefined);
}); });
}); });
}); });