mirror of
https://github.com/vector-im/element-web.git
synced 2025-11-28 14:01:16 +01:00
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:
parent
0d28df0f67
commit
b54122884c
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
117
src/components/viewmodels/roomlist/useStickyRoomList.tsx
Normal file
117
src/components/viewmodels/roomlist/useStickyRoomList.tsx
Normal 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 };
|
||||||
|
}
|
||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user