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";