Skip to content

Commit a430501

Browse files
authored
Add loading state to the new room list view (#29725)
* add loading state to view model and spinner to room list vieqw * Update snapshots and add loading test * avoid nested ternary operator * Add room list skeleton loading state * Fix loading logic - Create RoomListStoreV3Event as to not conflict with loading event definition in Create RoomListStoreEvent. - Add a loaded event - Use it to determine loaded state in useFilteredRooms rather than the update event which gets fired in other cases. * Fix isLoadingRooms logic * update snapshots and fix test * Forcing an empty commit to fix PR * Fix _components.pcss order * Fix test that wasn't doing anything * fix tests
1 parent 72429c1 commit a430501

File tree

17 files changed

+110
-115
lines changed

17 files changed

+110
-115
lines changed

res/css/_components.pcss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@
280280
@import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss";
281281
@import "./views/rooms/RoomListPanel/_RoomListSearch.pcss";
282282
@import "./views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss";
283+
@import "./views/rooms/RoomListPanel/_RoomListSkeleton.pcss";
283284
@import "./views/rooms/_AppsDrawer.pcss";
284285
@import "./views/rooms/_Autocomplete.pcss";
285286
@import "./views/rooms/_AuxPanel.pcss";
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
.mx_RoomListSkeleton {
9+
position: relative;
10+
margin-left: 4px;
11+
height: 100%;
12+
13+
&::before {
14+
background-color: var(--cpd-color-bg-subtle-secondary);
15+
width: 100%;
16+
height: 100%;
17+
18+
content: "";
19+
position: absolute;
20+
mask-repeat: repeat-y;
21+
mask-size: auto 96px;
22+
mask-image: url("$(res)/img/element-icons/roomlist/room-list-item-skeleton.svg");
23+
}
24+
}

res/css/views/rooms/_RoomSublist.pcss

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,8 +404,7 @@ Please see LICENSE files in the repository root for full details.
404404
height: 240px;
405405

406406
&::before {
407-
background: $roomsublist-skeleton-ui-bg;
408-
407+
background-color: var(--cpd-color-bg-subtle-secondary);
409408
width: 100%;
410409
height: 100%;
411410

res/img/element-icons/roomlist/room-list-item-skeleton.svg

Lines changed: 14 additions & 0 deletions
Loading

src/components/viewmodels/roomlist/RoomListViewModel.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ import { useStickyRoomList } from "./useStickyRoomList";
2222
import { useRoomListNavigation } from "./useRoomListNavigation";
2323

2424
export interface RoomListViewState {
25+
/**
26+
* Whether the list of rooms is being loaded.
27+
*/
28+
isLoadingRooms: boolean;
29+
2530
/**
2631
* A list of rooms to be displayed in the left panel.
2732
*/
@@ -99,6 +104,7 @@ export interface RoomListViewState {
99104
export function useRoomListViewModel(): RoomListViewState {
100105
const matrixClient = useMatrixClientContext();
101106
const {
107+
isLoadingRooms,
102108
primaryFilters,
103109
activePrimaryFilter,
104110
rooms: filteredRooms,
@@ -123,6 +129,7 @@ export function useRoomListViewModel(): RoomListViewState {
123129
const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]);
124130

125131
return {
132+
isLoadingRooms,
126133
rooms,
127134
canCreateRoom,
128135
createRoom,

src/components/viewmodels/roomlist/useFilteredRooms.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ import { useCallback, useMemo, useState } from "react";
1010
import type { Room } from "matrix-js-sdk/src/matrix";
1111
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
1212
import { _t, _td, type TranslationKey } from "../../../languageHandler";
13-
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
14-
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
13+
import RoomListStoreV3, { LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT } from "../../../stores/room-list-v3/RoomListStoreV3";
1514
import { useEventEmitter } from "../../../hooks/useEventEmitter";
1615
import SpaceStore from "../../../stores/spaces/SpaceStore";
1716
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
@@ -35,6 +34,7 @@ export interface PrimaryFilter {
3534

3635
interface FilteredRooms {
3736
primaryFilters: PrimaryFilter[];
37+
isLoadingRooms: boolean;
3838
rooms: Room[];
3939
activateSecondaryFilter: (filter: SecondaryFilters) => void;
4040
activeSecondaryFilter: SecondaryFilters;
@@ -115,6 +115,7 @@ export function useFilteredRooms(): FilteredRooms {
115115
);
116116

117117
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
118+
const [isLoadingRooms, setIsLoadingRooms] = useState(() => RoomListStoreV3.instance.isLoadingRooms);
118119

119120
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
120121
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
@@ -139,6 +140,10 @@ export function useFilteredRooms(): FilteredRooms {
139140
updateRoomsFromStore(filters);
140141
});
141142

143+
useEventEmitter(RoomListStoreV3.instance, LISTS_LOADED_EVENT, () => {
144+
setIsLoadingRooms(false);
145+
});
146+
142147
/**
143148
* Secondary filters are activated using this function.
144149
* This is different to how primary filters work because the secondary
@@ -194,5 +199,12 @@ export function useFilteredRooms(): FilteredRooms {
194199

195200
const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]);
196201

197-
return { primaryFilters, activePrimaryFilter, rooms, activateSecondaryFilter, activeSecondaryFilter };
202+
return {
203+
isLoadingRooms,
204+
primaryFilters,
205+
activePrimaryFilter,
206+
rooms,
207+
activateSecondaryFilter,
208+
activeSecondaryFilter,
209+
};
198210
}

src/components/views/rooms/RoomListPanel/RoomListView.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,19 @@ import { RoomListSecondaryFilters } from "./RoomListSecondaryFilters";
1919
export function RoomListView(): JSX.Element {
2020
const vm = useRoomListViewModel();
2121
const isRoomListEmpty = vm.rooms.length === 0;
22-
22+
let listBody;
23+
if (vm.isLoadingRooms) {
24+
listBody = <div className="mx_RoomListSkeleton" />;
25+
} else if (isRoomListEmpty) {
26+
listBody = <EmptyRoomList vm={vm} />;
27+
} else {
28+
listBody = <RoomList vm={vm} />;
29+
}
2330
return (
2431
<>
2532
<RoomListPrimaryFilters vm={vm} />
2633
<RoomListSecondaryFilters vm={vm} />
27-
{isRoomListEmpty ? <EmptyRoomList vm={vm} /> : <RoomList vm={vm} />}
34+
{listBody}
2835
</>
2936
);
3037
}

src/stores/room-list-v3/RoomListStoreV3.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
1616
import SettingsStore from "../../settings/SettingsStore";
1717
import { VisibilityProvider } from "../room-list/filters/VisibilityProvider";
1818
import defaultDispatcher from "../../dispatcher/dispatcher";
19-
import { LISTS_UPDATE_EVENT } from "../room-list/RoomListStore";
2019
import { RoomSkipList } from "./skip-list/RoomSkipList";
2120
import { RecencySorter } from "./skip-list/sorters/RecencySorter";
2221
import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter";
@@ -49,6 +48,15 @@ const FILTERS = [
4948
new LowPriorityFilter(),
5049
];
5150

51+
export enum RoomListStoreV3Event {
52+
// The event/channel which is called when the room lists have been changed.
53+
ListsUpdate = "lists_update",
54+
// The event which is called when the room list is loaded.
55+
ListsLoaded = "lists_loaded",
56+
}
57+
58+
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
59+
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
5260
/**
5361
* This store allows for fast retrieval of the room list in a sorted and filtered manner.
5462
* This is the third such implementation hence the "V3".
@@ -76,6 +84,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
7684
return rooms;
7785
}
7886

87+
/**
88+
* Check whether the initial list of rooms has loaded.
89+
*/
90+
public get isLoadingRooms(): boolean {
91+
return !this.roomSkipList?.initialized;
92+
}
93+
7994
/**
8095
* Get a list of sorted rooms.
8196
*/
@@ -127,6 +142,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
127142
await SpaceStore.instance.storeReadyPromise;
128143
const rooms = this.getRooms();
129144
this.roomSkipList.seed(rooms);
145+
this.emit(LISTS_LOADED_EVENT);
130146
this.emit(LISTS_UPDATE_EVENT);
131147
}
132148

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@ import { range } from "lodash";
99
import { act, renderHook, waitFor } from "jest-matrix-react";
1010
import { mocked } from "jest-mock";
1111

12-
import RoomListStoreV3 from "../../../../../src/stores/room-list-v3/RoomListStoreV3";
12+
import RoomListStoreV3, { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list-v3/RoomListStoreV3";
1313
import { mkStubRoom } from "../../../../test-utils";
14-
import { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list/RoomListStore";
1514
import { useRoomListViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
1615
import { FilterKey } from "../../../../../src/stores/room-list-v3/skip-list/filters";
1716
import { SecondaryFilters } from "../../../../../src/components/viewmodels/roomlist/useFilteredRooms";

test/unit-tests/components/views/rooms/RoomListPanel/EmptyRoomList-test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ describe("<EmptyRoomList />", () => {
2020

2121
beforeEach(() => {
2222
vm = {
23+
isLoadingRooms: false,
2324
rooms: [],
2425
primaryFilters: [],
2526
activateSecondaryFilter: jest.fn().mockReturnValue({}),

test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe("<RoomList />", () => {
2929
matrixClient = stubClient();
3030
const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`));
3131
vm = {
32+
isLoadingRooms: false,
3233
rooms,
3334
primaryFilters: [],
3435
activateSecondaryFilter: () => {},

test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe("<RoomListFilterMenu />", () => {
2626

2727
beforeEach(() => {
2828
vm = {
29+
isLoadingRooms: false,
2930
rooms: [],
3031
canCreateRoom: true,
3132
createRoom: jest.fn(),

test/unit-tests/components/views/rooms/RoomListPanel/RoomListPrimaryFilters-test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ describe("<RoomListPrimaryFilters />", () => {
2020

2121
beforeEach(() => {
2222
vm = {
23+
isLoadingRooms: false,
2324
rooms: [],
2425
canCreateRoom: true,
2526
createRoom: jest.fn(),

test/unit-tests/components/views/rooms/RoomListPanel/RoomListSecondaryFilters-test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe("<RoomListSecondaryFilters />", () => {
1818

1919
beforeEach(() => {
2020
vm = {
21+
isLoadingRooms: false,
2122
rooms: [],
2223
canCreateRoom: true,
2324
createRoom: jest.fn(),

test/unit-tests/components/views/rooms/RoomListPanel/RoomListView-test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListViewMode
2424

2525
describe("<RoomListView />", () => {
2626
const defaultValue: RoomListViewState = {
27+
isLoadingRooms: false,
2728
rooms: [],
2829
primaryFilters: [],
2930
activateSecondaryFilter: jest.fn().mockReturnValue({}),
@@ -43,6 +44,16 @@ describe("<RoomListView />", () => {
4344
jest.resetAllMocks();
4445
});
4546

47+
it("should render the loading room list", () => {
48+
mocked(useRoomListViewModel).mockReturnValue({
49+
...defaultValue,
50+
isLoadingRooms: true,
51+
});
52+
53+
const roomList = render(<RoomListView />);
54+
expect(roomList.container.querySelector(".mx_RoomListSkeleton")).not.toBeNull();
55+
});
56+
4657
it("should render an empty room list", () => {
4758
mocked(useRoomListViewModel).mockReturnValue(defaultValue);
4859

test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap

Lines changed: 4 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -183,47 +183,8 @@ exports[`<RoomListPanel /> should not render the RoomListSearch component when U
183183
</button>
184184
</div>
185185
<div
186-
class="mx_Flex mx_EmptyRoomList_GenericPlaceholder"
187-
data-testid="empty-room-list"
188-
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
189-
>
190-
<span
191-
class="mx_EmptyRoomList_GenericPlaceholder_title"
192-
>
193-
No chats yet
194-
</span>
195-
<span
196-
class="mx_EmptyRoomList_GenericPlaceholder_description"
197-
>
198-
Get started by messaging someone
199-
</span>
200-
<div
201-
class="mx_Flex mx_EmptyRoomList_DefaultPlaceholder"
202-
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
203-
>
204-
<button
205-
class="_button_vczzf_8 _has-icon_vczzf_57"
206-
data-kind="secondary"
207-
data-size="sm"
208-
role="button"
209-
tabindex="0"
210-
>
211-
<svg
212-
aria-hidden="true"
213-
fill="currentColor"
214-
height="20"
215-
viewBox="0 0 24 24"
216-
width="20"
217-
xmlns="http://www.w3.org/2000/svg"
218-
>
219-
<path
220-
d="M10 12q-1.65 0-2.825-1.175T6 8t1.175-2.825T10 4t2.825 1.175T14 8t-1.175 2.825T10 12m-8 6v-.8q0-.85.438-1.562.437-.713 1.162-1.088a14.8 14.8 0 0 1 3.15-1.163A13.8 13.8 0 0 1 10 13q1.65 0 3.25.387 1.6.388 3.15 1.163.724.375 1.163 1.087Q18 16.35 18 17.2v.8q0 .824-.587 1.413A1.93 1.93 0 0 1 16 20H4q-.824 0-1.412-.587A1.93 1.93 0 0 1 2 18m2 0h12v-.8a.97.97 0 0 0-.5-.85q-1.35-.675-2.725-1.012a11.6 11.6 0 0 0-5.55 0Q5.85 15.675 4.5 16.35a.97.97 0 0 0-.5.85zm6-8q.825 0 1.412-.588Q12 8.826 12 8q0-.824-.588-1.412A1.93 1.93 0 0 0 10 6q-.825 0-1.412.588A1.93 1.93 0 0 0 8 8q0 .825.588 1.412Q9.175 10 10 10m7 1h2v2q0 .424.288.713.287.287.712.287.424 0 .712-.287A.97.97 0 0 0 21 13v-2h2q.424 0 .712-.287A.97.97 0 0 0 24 10a.97.97 0 0 0-.288-.713A.97.97 0 0 0 23 9h-2V7a.97.97 0 0 0-.288-.713A.97.97 0 0 0 20 6a.97.97 0 0 0-.712.287A.97.97 0 0 0 19 7v2h-2a.97.97 0 0 0-.712.287A.97.97 0 0 0 16 10q0 .424.288.713.287.287.712.287"
221-
/>
222-
</svg>
223-
New message
224-
</button>
225-
</div>
226-
</div>
186+
class="mx_RoomListSkeleton"
187+
/>
227188
</section>
228189
</DocumentFragment>
229190
`;
@@ -473,68 +434,8 @@ exports[`<RoomListPanel /> should render the RoomListSearch component when UICom
473434
</button>
474435
</div>
475436
<div
476-
class="mx_Flex mx_EmptyRoomList_GenericPlaceholder"
477-
data-testid="empty-room-list"
478-
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
479-
>
480-
<span
481-
class="mx_EmptyRoomList_GenericPlaceholder_title"
482-
>
483-
No chats yet
484-
</span>
485-
<span
486-
class="mx_EmptyRoomList_GenericPlaceholder_description"
487-
>
488-
Get started by messaging someone or by creating a room
489-
</span>
490-
<div
491-
class="mx_Flex mx_EmptyRoomList_DefaultPlaceholder"
492-
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
493-
>
494-
<button
495-
class="_button_vczzf_8 _has-icon_vczzf_57"
496-
data-kind="secondary"
497-
data-size="sm"
498-
role="button"
499-
tabindex="0"
500-
>
501-
<svg
502-
aria-hidden="true"
503-
fill="currentColor"
504-
height="20"
505-
viewBox="0 0 24 24"
506-
width="20"
507-
xmlns="http://www.w3.org/2000/svg"
508-
>
509-
<path
510-
d="M10 12q-1.65 0-2.825-1.175T6 8t1.175-2.825T10 4t2.825 1.175T14 8t-1.175 2.825T10 12m-8 6v-.8q0-.85.438-1.562.437-.713 1.162-1.088a14.8 14.8 0 0 1 3.15-1.163A13.8 13.8 0 0 1 10 13q1.65 0 3.25.387 1.6.388 3.15 1.163.724.375 1.163 1.087Q18 16.35 18 17.2v.8q0 .824-.587 1.413A1.93 1.93 0 0 1 16 20H4q-.824 0-1.412-.587A1.93 1.93 0 0 1 2 18m2 0h12v-.8a.97.97 0 0 0-.5-.85q-1.35-.675-2.725-1.012a11.6 11.6 0 0 0-5.55 0Q5.85 15.675 4.5 16.35a.97.97 0 0 0-.5.85zm6-8q.825 0 1.412-.588Q12 8.826 12 8q0-.824-.588-1.412A1.93 1.93 0 0 0 10 6q-.825 0-1.412.588A1.93 1.93 0 0 0 8 8q0 .825.588 1.412Q9.175 10 10 10m7 1h2v2q0 .424.288.713.287.287.712.287.424 0 .712-.287A.97.97 0 0 0 21 13v-2h2q.424 0 .712-.287A.97.97 0 0 0 24 10a.97.97 0 0 0-.288-.713A.97.97 0 0 0 23 9h-2V7a.97.97 0 0 0-.288-.713A.97.97 0 0 0 20 6a.97.97 0 0 0-.712.287A.97.97 0 0 0 19 7v2h-2a.97.97 0 0 0-.712.287A.97.97 0 0 0 16 10q0 .424.288.713.287.287.712.287"
511-
/>
512-
</svg>
513-
New message
514-
</button>
515-
<button
516-
class="_button_vczzf_8 _has-icon_vczzf_57"
517-
data-kind="secondary"
518-
data-size="sm"
519-
role="button"
520-
tabindex="0"
521-
>
522-
<svg
523-
aria-hidden="true"
524-
fill="currentColor"
525-
height="20"
526-
viewBox="0 0 24 24"
527-
width="20"
528-
xmlns="http://www.w3.org/2000/svg"
529-
>
530-
<path
531-
d="m8.566 17-.944 4.094q-.086.406-.372.656t-.687.25q-.543 0-.887-.469a1.18 1.18 0 0 1-.2-1.031l.801-3.5H3.158q-.572 0-.916-.484a1.27 1.27 0 0 1-.2-1.078 1.12 1.12 0 0 1 1.116-.938H6.85l1.145-5h-3.12q-.57 0-.915-.484a1.27 1.27 0 0 1-.2-1.078A1.12 1.12 0 0 1 4.875 7h3.691l.945-4.094q.085-.406.372-.656.286-.25.686-.25.544 0 .887.469.345.468.2 1.031l-.8 3.5h4.578l.944-4.094q.085-.406.372-.656.286-.25.687-.25.543 0 .887.469t.2 1.031L17.723 7h3.119q.573 0 .916.484.343.485.2 1.079a1.12 1.12 0 0 1-1.116.937H17.15l-1.145 5h3.12q.57 0 .915.484.343.485.2 1.079a1.12 1.12 0 0 1-1.116.937h-3.691l-.944 4.094q-.087.406-.373.656t-.686.25q-.544 0-.887-.469a1.18 1.18 0 0 1-.2-1.031l.8-3.5zm.573-2.5h4.578l1.144-5h-4.578z"
532-
/>
533-
</svg>
534-
New room
535-
</button>
536-
</div>
537-
</div>
437+
class="mx_RoomListSkeleton"
438+
/>
538439
</section>
539440
</DocumentFragment>
540441
`;

0 commit comments

Comments
 (0)