diff --git a/res/css/_components.pcss b/res/css/_components.pcss
index 38bce14b06..26ce9b7bd8 100644
--- a/res/css/_components.pcss
+++ b/res/css/_components.pcss
@@ -280,6 +280,7 @@
@import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss";
@import "./views/rooms/RoomListPanel/_RoomListSearch.pcss";
@import "./views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss";
+@import "./views/rooms/RoomListPanel/_RoomListSkeleton.pcss";
@import "./views/rooms/_AppsDrawer.pcss";
@import "./views/rooms/_Autocomplete.pcss";
@import "./views/rooms/_AuxPanel.pcss";
diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSkeleton.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSkeleton.pcss
new file mode 100644
index 0000000000..2e644cbba1
--- /dev/null
+++ b/res/css/views/rooms/RoomListPanel/_RoomListSkeleton.pcss
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+.mx_RoomListSkeleton {
+ position: relative;
+ margin-left: 4px;
+ height: 100%;
+
+ &::before {
+ background-color: var(--cpd-color-bg-subtle-secondary);
+ width: 100%;
+ height: 100%;
+
+ content: "";
+ position: absolute;
+ mask-repeat: repeat-y;
+ mask-size: auto 96px;
+ mask-image: url("$(res)/img/element-icons/roomlist/room-list-item-skeleton.svg");
+ }
+}
diff --git a/res/css/views/rooms/_RoomSublist.pcss b/res/css/views/rooms/_RoomSublist.pcss
index 7b0da5608e..3361bce4bb 100644
--- a/res/css/views/rooms/_RoomSublist.pcss
+++ b/res/css/views/rooms/_RoomSublist.pcss
@@ -404,8 +404,7 @@ Please see LICENSE files in the repository root for full details.
height: 240px;
&::before {
- background: $roomsublist-skeleton-ui-bg;
-
+ background-color: var(--cpd-color-bg-subtle-secondary);
width: 100%;
height: 100%;
diff --git a/res/img/element-icons/roomlist/room-list-item-skeleton.svg b/res/img/element-icons/roomlist/room-list-item-skeleton.svg
new file mode 100644
index 0000000000..adf56e4ed8
--- /dev/null
+++ b/res/img/element-icons/roomlist/room-list-item-skeleton.svg
@@ -0,0 +1,14 @@
+
diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx
index c07ac83eaa..ddc38e17de 100644
--- a/src/components/viewmodels/roomlist/RoomListViewModel.tsx
+++ b/src/components/viewmodels/roomlist/RoomListViewModel.tsx
@@ -22,6 +22,11 @@ import { useStickyRoomList } from "./useStickyRoomList";
import { useRoomListNavigation } from "./useRoomListNavigation";
export interface RoomListViewState {
+ /**
+ * Whether the list of rooms is being loaded.
+ */
+ isLoadingRooms: boolean;
+
/**
* A list of rooms to be displayed in the left panel.
*/
@@ -99,6 +104,7 @@ export interface RoomListViewState {
export function useRoomListViewModel(): RoomListViewState {
const matrixClient = useMatrixClientContext();
const {
+ isLoadingRooms,
primaryFilters,
activePrimaryFilter,
rooms: filteredRooms,
@@ -123,6 +129,7 @@ export function useRoomListViewModel(): RoomListViewState {
const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]);
return {
+ isLoadingRooms,
rooms,
canCreateRoom,
createRoom,
diff --git a/src/components/viewmodels/roomlist/useFilteredRooms.tsx b/src/components/viewmodels/roomlist/useFilteredRooms.tsx
index 4b4b9c0ec8..73eae7642b 100644
--- a/src/components/viewmodels/roomlist/useFilteredRooms.tsx
+++ b/src/components/viewmodels/roomlist/useFilteredRooms.tsx
@@ -10,8 +10,7 @@ import { useCallback, useMemo, useState } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
import { _t, _td, type TranslationKey } from "../../../languageHandler";
-import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
-import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
+import RoomListStoreV3, { LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT } from "../../../stores/room-list-v3/RoomListStoreV3";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
@@ -35,6 +34,7 @@ export interface PrimaryFilter {
interface FilteredRooms {
primaryFilters: PrimaryFilter[];
+ isLoadingRooms: boolean;
rooms: Room[];
activateSecondaryFilter: (filter: SecondaryFilters) => void;
activeSecondaryFilter: SecondaryFilters;
@@ -115,6 +115,7 @@ export function useFilteredRooms(): FilteredRooms {
);
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
+ const [isLoadingRooms, setIsLoadingRooms] = useState(() => RoomListStoreV3.instance.isLoadingRooms);
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
@@ -139,6 +140,10 @@ export function useFilteredRooms(): FilteredRooms {
updateRoomsFromStore(filters);
});
+ useEventEmitter(RoomListStoreV3.instance, LISTS_LOADED_EVENT, () => {
+ setIsLoadingRooms(false);
+ });
+
/**
* Secondary filters are activated using this function.
* This is different to how primary filters work because the secondary
@@ -194,5 +199,12 @@ export function useFilteredRooms(): FilteredRooms {
const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]);
- return { primaryFilters, activePrimaryFilter, rooms, activateSecondaryFilter, activeSecondaryFilter };
+ return {
+ isLoadingRooms,
+ primaryFilters,
+ activePrimaryFilter,
+ rooms,
+ activateSecondaryFilter,
+ activeSecondaryFilter,
+ };
}
diff --git a/src/components/views/rooms/RoomListPanel/RoomListView.tsx b/src/components/views/rooms/RoomListPanel/RoomListView.tsx
index 98273345a8..1f91b45a36 100644
--- a/src/components/views/rooms/RoomListPanel/RoomListView.tsx
+++ b/src/components/views/rooms/RoomListPanel/RoomListView.tsx
@@ -19,12 +19,19 @@ import { RoomListSecondaryFilters } from "./RoomListSecondaryFilters";
export function RoomListView(): JSX.Element {
const vm = useRoomListViewModel();
const isRoomListEmpty = vm.rooms.length === 0;
-
+ let listBody;
+ if (vm.isLoadingRooms) {
+ listBody =
;
+ } else if (isRoomListEmpty) {
+ listBody = ;
+ } else {
+ listBody = ;
+ }
return (
<>
- {isRoomListEmpty ? : }
+ {listBody}
>
);
}
diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts
index 9a6a7702b9..99cb444ae4 100644
--- a/src/stores/room-list-v3/RoomListStoreV3.ts
+++ b/src/stores/room-list-v3/RoomListStoreV3.ts
@@ -16,7 +16,6 @@ import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import SettingsStore from "../../settings/SettingsStore";
import { VisibilityProvider } from "../room-list/filters/VisibilityProvider";
import defaultDispatcher from "../../dispatcher/dispatcher";
-import { LISTS_UPDATE_EVENT } from "../room-list/RoomListStore";
import { RoomSkipList } from "./skip-list/RoomSkipList";
import { RecencySorter } from "./skip-list/sorters/RecencySorter";
import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter";
@@ -49,6 +48,15 @@ const FILTERS = [
new LowPriorityFilter(),
];
+export enum RoomListStoreV3Event {
+ // The event/channel which is called when the room lists have been changed.
+ ListsUpdate = "lists_update",
+ // The event which is called when the room list is loaded.
+ ListsLoaded = "lists_loaded",
+}
+
+export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
+export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
/**
* This store allows for fast retrieval of the room list in a sorted and filtered manner.
* This is the third such implementation hence the "V3".
@@ -76,6 +84,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient {
return rooms;
}
+ /**
+ * Check whether the initial list of rooms has loaded.
+ */
+ public get isLoadingRooms(): boolean {
+ return !this.roomSkipList?.initialized;
+ }
+
/**
* Get a list of sorted rooms.
*/
@@ -127,6 +142,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient {
await SpaceStore.instance.storeReadyPromise;
const rooms = this.getRooms();
this.roomSkipList.seed(rooms);
+ this.emit(LISTS_LOADED_EVENT);
this.emit(LISTS_UPDATE_EVENT);
}
diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx
index d0fd6c420b..3a95f6dab9 100644
--- a/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx
+++ b/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx
@@ -9,9 +9,8 @@ import { range } from "lodash";
import { act, renderHook, waitFor } from "jest-matrix-react";
import { mocked } from "jest-mock";
-import RoomListStoreV3 from "../../../../../src/stores/room-list-v3/RoomListStoreV3";
+import RoomListStoreV3, { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list-v3/RoomListStoreV3";
import { mkStubRoom } from "../../../../test-utils";
-import { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list/RoomListStore";
import { useRoomListViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
import { FilterKey } from "../../../../../src/stores/room-list-v3/skip-list/filters";
import { SecondaryFilters } from "../../../../../src/components/viewmodels/roomlist/useFilteredRooms";
diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/EmptyRoomList-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/EmptyRoomList-test.tsx
index b68929f74a..1483797778 100644
--- a/test/unit-tests/components/views/rooms/RoomListPanel/EmptyRoomList-test.tsx
+++ b/test/unit-tests/components/views/rooms/RoomListPanel/EmptyRoomList-test.tsx
@@ -20,6 +20,7 @@ describe("", () => {
beforeEach(() => {
vm = {
+ isLoadingRooms: false,
rooms: [],
primaryFilters: [],
activateSecondaryFilter: jest.fn().mockReturnValue({}),
diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx
index 0d837c3a20..d72a78d36a 100644
--- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx
+++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx
@@ -29,6 +29,7 @@ describe("", () => {
matrixClient = stubClient();
const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`));
vm = {
+ isLoadingRooms: false,
rooms,
primaryFilters: [],
activateSecondaryFilter: () => {},
diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx
index 3e14a64f8e..e93d69052e 100644
--- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx
+++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx
@@ -26,6 +26,7 @@ describe("", () => {
beforeEach(() => {
vm = {
+ isLoadingRooms: false,
rooms: [],
canCreateRoom: true,
createRoom: jest.fn(),
diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListPrimaryFilters-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListPrimaryFilters-test.tsx
index 79bfbb6dc0..4a80ea068e 100644
--- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListPrimaryFilters-test.tsx
+++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListPrimaryFilters-test.tsx
@@ -20,6 +20,7 @@ describe("", () => {
beforeEach(() => {
vm = {
+ isLoadingRooms: false,
rooms: [],
canCreateRoom: true,
createRoom: jest.fn(),
diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListSecondaryFilters-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListSecondaryFilters-test.tsx
index b3ae99c3d2..c2f6ef2cd8 100644
--- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListSecondaryFilters-test.tsx
+++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListSecondaryFilters-test.tsx
@@ -18,6 +18,7 @@ describe("", () => {
beforeEach(() => {
vm = {
+ isLoadingRooms: false,
rooms: [],
canCreateRoom: true,
createRoom: jest.fn(),
diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListView-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListView-test.tsx
index f56d30976a..b1bfe914e5 100644
--- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListView-test.tsx
+++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListView-test.tsx
@@ -24,6 +24,7 @@ jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListViewMode
describe("", () => {
const defaultValue: RoomListViewState = {
+ isLoadingRooms: false,
rooms: [],
primaryFilters: [],
activateSecondaryFilter: jest.fn().mockReturnValue({}),
@@ -43,6 +44,16 @@ describe("", () => {
jest.resetAllMocks();
});
+ it("should render the loading room list", () => {
+ mocked(useRoomListViewModel).mockReturnValue({
+ ...defaultValue,
+ isLoadingRooms: true,
+ });
+
+ const roomList = render();
+ expect(roomList.container.querySelector(".mx_RoomListSkeleton")).not.toBeNull();
+ });
+
it("should render an empty room list", () => {
mocked(useRoomListViewModel).mockReturnValue(defaultValue);
diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap
index e51c4b8ca0..6536e9cc19 100644
--- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap
+++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap
@@ -183,47 +183,8 @@ exports[` should not render the RoomListSearch component when U
-
- No chats yet
-
-
- Get started by messaging someone
-
-
-
-
-
+ class="mx_RoomListSkeleton"
+ />
`;
@@ -473,68 +434,8 @@ exports[` should render the RoomListSearch component when UICom
-
- No chats yet
-
-
- Get started by messaging someone or by creating a room
-
-
-
-
-
-
+ class="mx_RoomListSkeleton"
+ />
`;
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 9b9e1ee665..78e79ca44a 100644
--- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts
+++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts
@@ -10,13 +10,12 @@ import { logger } from "matrix-js-sdk/src/logger";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import type { RoomNotificationState } from "../../../../src/stores/notifications/RoomNotificationState";
-import { RoomListStoreV3Class } from "../../../../src/stores/room-list-v3/RoomListStoreV3";
+import { LISTS_UPDATE_EVENT, RoomListStoreV3Class } from "../../../../src/stores/room-list-v3/RoomListStoreV3";
import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClient";
import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter";
import { mkEvent, mkMessage, mkSpace, stubClient, upsertRoomStateEvents } from "../../../test-utils";
import { getMockedRooms } from "./skip-list/getMockedRooms";
import { AlphabeticSorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter";
-import { LISTS_UPDATE_EVENT } from "../../../../src/stores/room-list/RoomListStore";
import dispatcher from "../../../../src/dispatcher/dispatcher";
import SpaceStore from "../../../../src/stores/spaces/SpaceStore";
import { MetaSpace, UPDATE_SELECTED_SPACE } from "../../../../src/stores/spaces";