Compare commits

...

15 Commits

Author SHA1 Message Date
David Langley
fc332bf490 fix tests 2025-04-30 10:45:56 +01:00
David Langley
b2c086d88b Merge branch 'develop' into langleyd/roomListLoadingState 2025-04-30 10:36:46 +01:00
David Langley
b3df072946 Fix test that wasn't doing anything 2025-04-25 17:38:37 +01:00
David Langley
f892293d01 Fix _components.pcss order 2025-04-25 17:27:18 +01:00
David Langley
be16e0f1f3 Forcing an empty commit to fix PR 2025-04-25 17:18:56 +01:00
David Langley
12fa604acf update snapshots and fix test 2025-04-25 17:05:19 +01:00
David Langley
c4d8685f11 Fix isLoadingRooms logic 2025-04-25 16:50:51 +01:00
David Langley
db17fa0de6 Merge branch 'develop' of github.com:vector-im/element-web into langleyd/roomListLoadingState 2025-04-25 16:16:43 +01:00
David Langley
4c09430fe1 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.
2025-04-25 16:04:41 +01:00
David Langley
1c279aeb99 Add room list skeleton loading state 2025-04-25 15:56:44 +01:00
David Langley
6e0bd2f0f5 Merge branch 'develop' of github.com:vector-im/element-web into langleyd/roomListLoadingState 2025-04-23 09:57:38 +01:00
David Langley
c67cec3f56 Merge branch 'develop' into langleyd/roomListLoadingState 2025-04-14 17:26:28 +01:00
David Langley
8b1d0f5aff avoid nested ternary operator 2025-04-14 17:25:42 +01:00
David Langley
57f5832a63 Update snapshots and add loading test 2025-04-14 16:12:21 +01:00
David Langley
c6b1a09b55 add loading state to view model and spinner to room list vieqw 2025-04-11 17:57:17 +01:00
17 changed files with 110 additions and 115 deletions

View File

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

View File

@@ -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");
}
}

View File

@@ -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%;

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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 = <div className="mx_RoomListSkeleton" />;
} else if (isRoomListEmpty) {
listBody = <EmptyRoomList vm={vm} />;
} else {
listBody = <RoomList vm={vm} />;
}
return (
<>
<RoomListPrimaryFilters vm={vm} />
<RoomListSecondaryFilters vm={vm} />
{isRoomListEmpty ? <EmptyRoomList vm={vm} /> : <RoomList vm={vm} />}
{listBody}
</>
);
}

View File

@@ -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<EmptyObject> {
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<EmptyObject> {
await SpaceStore.instance.storeReadyPromise;
const rooms = this.getRooms();
this.roomSkipList.seed(rooms);
this.emit(LISTS_LOADED_EVENT);
this.emit(LISTS_UPDATE_EVENT);
}

View File

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

View File

@@ -20,6 +20,7 @@ describe("<EmptyRoomList />", () => {
beforeEach(() => {
vm = {
isLoadingRooms: false,
rooms: [],
primaryFilters: [],
activateSecondaryFilter: jest.fn().mockReturnValue({}),

View File

@@ -29,6 +29,7 @@ describe("<RoomList />", () => {
matrixClient = stubClient();
const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`));
vm = {
isLoadingRooms: false,
rooms,
primaryFilters: [],
activateSecondaryFilter: () => {},

View File

@@ -26,6 +26,7 @@ describe("<RoomListFilterMenu />", () => {
beforeEach(() => {
vm = {
isLoadingRooms: false,
rooms: [],
canCreateRoom: true,
createRoom: jest.fn(),

View File

@@ -20,6 +20,7 @@ describe("<RoomListPrimaryFilters />", () => {
beforeEach(() => {
vm = {
isLoadingRooms: false,
rooms: [],
canCreateRoom: true,
createRoom: jest.fn(),

View File

@@ -18,6 +18,7 @@ describe("<RoomListSecondaryFilters />", () => {
beforeEach(() => {
vm = {
isLoadingRooms: false,
rooms: [],
canCreateRoom: true,
createRoom: jest.fn(),

View File

@@ -24,6 +24,7 @@ jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListViewMode
describe("<RoomListView />", () => {
const defaultValue: RoomListViewState = {
isLoadingRooms: false,
rooms: [],
primaryFilters: [],
activateSecondaryFilter: jest.fn().mockReturnValue({}),
@@ -43,6 +44,16 @@ describe("<RoomListView />", () => {
jest.resetAllMocks();
});
it("should render the loading room list", () => {
mocked(useRoomListViewModel).mockReturnValue({
...defaultValue,
isLoadingRooms: true,
});
const roomList = render(<RoomListView />);
expect(roomList.container.querySelector(".mx_RoomListSkeleton")).not.toBeNull();
});
it("should render an empty room list", () => {
mocked(useRoomListViewModel).mockReturnValue(defaultValue);

View File

@@ -183,47 +183,8 @@ exports[`<RoomListPanel /> should not render the RoomListSearch component when U
</button>
</div>
<div
class="mx_Flex mx_EmptyRoomList_GenericPlaceholder"
data-testid="empty-room-list"
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;"
>
<span
class="mx_EmptyRoomList_GenericPlaceholder_title"
>
No chats yet
</span>
<span
class="mx_EmptyRoomList_GenericPlaceholder_description"
>
Get started by messaging someone
</span>
<div
class="mx_Flex mx_EmptyRoomList_DefaultPlaceholder"
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;"
>
<button
class="_button_vczzf_8 _has-icon_vczzf_57"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
class="mx_RoomListSkeleton"
/>
</svg>
New message
</button>
</div>
</div>
</section>
</DocumentFragment>
`;
@@ -473,68 +434,8 @@ exports[`<RoomListPanel /> should render the RoomListSearch component when UICom
</button>
</div>
<div
class="mx_Flex mx_EmptyRoomList_GenericPlaceholder"
data-testid="empty-room-list"
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;"
>
<span
class="mx_EmptyRoomList_GenericPlaceholder_title"
>
No chats yet
</span>
<span
class="mx_EmptyRoomList_GenericPlaceholder_description"
>
Get started by messaging someone or by creating a room
</span>
<div
class="mx_Flex mx_EmptyRoomList_DefaultPlaceholder"
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;"
>
<button
class="_button_vczzf_8 _has-icon_vczzf_57"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
class="mx_RoomListSkeleton"
/>
</svg>
New message
</button>
<button
class="_button_vczzf_8 _has-icon_vczzf_57"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
/>
</svg>
New room
</button>
</div>
</div>
</section>
</DocumentFragment>
`;

View File

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