New room list: add empty state (#29512)
* refactor: extract room creation and right verification * refactor: update `RoomListHeaderViewModel` to use utils * feat(room list filter): add filter key to `PrimaryFilter` model * feat(room list filter): return active primary filter * feat(room list): add create room action and rights verification * test: update room list tests * feat(empty room list): add empty room list * test(empty room list): add empty room list tests * feat(room list): use empty room list in `RoomListView` * test(room list panel): update tests * test(e2e): add e2e tests for empty room list * test(e2e): update room list header snapshot
This commit is contained in:
@@ -6,10 +6,8 @@
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { EventTimeline, EventType, JoinRule, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix";
|
||||
import { JoinRule, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../../settings/UIFeature";
|
||||
import { useFeatureEnabled } from "../../../hooks/useSettings";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
@@ -32,6 +30,7 @@ import {
|
||||
} from "../../../utils/space";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { createRoom, hasCreateRoomRights } from "./utils";
|
||||
|
||||
/**
|
||||
* Hook to get the active space and its title.
|
||||
@@ -128,14 +127,7 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
|
||||
const { activeSpace, title } = useSpace();
|
||||
const isSpaceRoom = Boolean(activeSpace);
|
||||
|
||||
const canCreateRoomInSpace = Boolean(
|
||||
activeSpace
|
||||
?.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.maySendStateEvent(EventType.RoomAvatar, matrixClient.getSafeUserId()),
|
||||
);
|
||||
// If we are in a space, we check canCreateRoomInSpace
|
||||
const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms) && (!isSpaceRoom || canCreateRoomInSpace);
|
||||
const canCreateRoom = hasCreateRoomRights(matrixClient, activeSpace);
|
||||
const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms");
|
||||
const displayComposeMenu = canCreateRoom || canCreateVideoRoom;
|
||||
const displaySpaceMenu = isSpaceRoom;
|
||||
@@ -151,13 +143,9 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
|
||||
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e);
|
||||
}, []);
|
||||
|
||||
const createRoom = useCallback(
|
||||
const createRoomMemoized = useCallback(
|
||||
(e: Event) => {
|
||||
if (activeSpace) {
|
||||
showCreateNewRoom(activeSpace);
|
||||
} else {
|
||||
defaultDispatcher.fire(Action.CreateRoom);
|
||||
}
|
||||
createRoom(activeSpace);
|
||||
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
|
||||
},
|
||||
[activeSpace],
|
||||
@@ -213,7 +201,7 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
|
||||
canInviteInSpace,
|
||||
canAccessSpaceSettings,
|
||||
createChatRoom,
|
||||
createRoom,
|
||||
createRoom: createRoomMemoized,
|
||||
createVideoRoom,
|
||||
openSpaceHome,
|
||||
inviteInSpace,
|
||||
|
||||
@@ -5,22 +5,55 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type PrimaryFilter, type SecondaryFilters, useFilteredRooms } from "./useFilteredRooms";
|
||||
import { type SortOption, useSorter } from "./useSorter";
|
||||
import { useMessagePreviewToggle } from "./useMessagePreviewToggle";
|
||||
import { createRoom as createRoomFunc, hasCreateRoomRights } from "./utils";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
|
||||
export interface RoomListViewState {
|
||||
/**
|
||||
* A list of rooms to be displayed in the left panel.
|
||||
*/
|
||||
rooms: Room[];
|
||||
|
||||
/**
|
||||
* Create a chat room
|
||||
* @param e - The click event
|
||||
*/
|
||||
createChatRoom: () => void;
|
||||
|
||||
/**
|
||||
* Whether the user can create a room in the current space
|
||||
*/
|
||||
canCreateRoom: boolean;
|
||||
|
||||
/**
|
||||
* Create a room
|
||||
* @param e - The click event
|
||||
*/
|
||||
createRoom: () => void;
|
||||
|
||||
/**
|
||||
* A list of objects that provide the view enough information
|
||||
* to render primary room filters.
|
||||
*/
|
||||
primaryFilters: PrimaryFilter[];
|
||||
|
||||
/**
|
||||
* The currently active primary filter.
|
||||
* If no primary filter is active, this will be undefined.
|
||||
*/
|
||||
activePrimaryFilter?: PrimaryFilter;
|
||||
|
||||
/**
|
||||
* A function to activate a given secondary filter.
|
||||
*/
|
||||
@@ -57,13 +90,30 @@ export interface RoomListViewState {
|
||||
* @see {@link RoomListViewState} for more information about what this view model returns.
|
||||
*/
|
||||
export function useRoomListViewModel(): RoomListViewState {
|
||||
const { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter } = useFilteredRooms();
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const { primaryFilters, activePrimaryFilter, rooms, activateSecondaryFilter, activeSecondaryFilter } =
|
||||
useFilteredRooms();
|
||||
|
||||
const currentSpace = useEventEmitterState<Room | null>(
|
||||
SpaceStore.instance,
|
||||
UPDATE_SELECTED_SPACE,
|
||||
() => SpaceStore.instance.activeSpaceRoom,
|
||||
);
|
||||
const canCreateRoom = hasCreateRoomRights(matrixClient, currentSpace);
|
||||
|
||||
const { activeSortOption, sort } = useSorter();
|
||||
const { shouldShowMessagePreview, toggleMessagePreview } = useMessagePreviewToggle();
|
||||
|
||||
const createChatRoom = useCallback(() => dispatcher.fire(Action.CreateChat), []);
|
||||
const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]);
|
||||
|
||||
return {
|
||||
rooms,
|
||||
canCreateRoom,
|
||||
createRoom,
|
||||
createChatRoom,
|
||||
primaryFilters,
|
||||
activePrimaryFilter,
|
||||
activateSecondaryFilter,
|
||||
activeSecondaryFilter,
|
||||
activeSortOption,
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface PrimaryFilter {
|
||||
active: boolean;
|
||||
// Text that can be used in the UI to represent this filter.
|
||||
name: string;
|
||||
// The key of the filter
|
||||
key: FilterKey;
|
||||
}
|
||||
|
||||
interface FilteredRooms {
|
||||
@@ -34,6 +36,11 @@ interface FilteredRooms {
|
||||
rooms: Room[];
|
||||
activateSecondaryFilter: (filter: SecondaryFilters) => void;
|
||||
activeSecondaryFilter: SecondaryFilters;
|
||||
/**
|
||||
* The currently active primary filter.
|
||||
* If no primary filter is active, this will be undefined.
|
||||
*/
|
||||
activePrimaryFilter?: PrimaryFilter;
|
||||
}
|
||||
|
||||
const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
|
||||
@@ -172,6 +179,7 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
},
|
||||
active: primaryFilter === key,
|
||||
name,
|
||||
key,
|
||||
};
|
||||
};
|
||||
const filters: PrimaryFilter[] = [];
|
||||
@@ -184,5 +192,7 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
return filters;
|
||||
}, [primaryFilter, updateRoomsFromStore, secondaryFilter]);
|
||||
|
||||
return { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter };
|
||||
const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]);
|
||||
|
||||
return { primaryFilters, activePrimaryFilter, rooms, activateSecondaryFilter, activeSecondaryFilter };
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Room, KnownMembership } from "matrix-js-sdk/src/matrix";
|
||||
import { type Room, KnownMembership, EventTimeline, EventType, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { isKnockDenied } from "../../../utils/membership";
|
||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../../settings/UIFeature";
|
||||
import { showCreateNewRoom } from "../../../utils/space";
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
|
||||
/**
|
||||
* Check if the user has access to the options menu.
|
||||
@@ -23,3 +26,33 @@ export function hasAccessToOptionsMenu(room: Room): boolean {
|
||||
shouldShowComponent(UIComponent.RoomOptionsMenu))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a room
|
||||
* @param space - The space to create the room in
|
||||
*/
|
||||
export async function createRoom(space?: Room | null): Promise<void> {
|
||||
if (space) {
|
||||
await showCreateNewRoom(space);
|
||||
} else {
|
||||
dispatcher.fire(Action.CreateRoom);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has the rights to create a room in the given space
|
||||
* If the space is not provided, it will check if the user has the rights to create a room in general
|
||||
* @param matrixClient
|
||||
* @param space
|
||||
*/
|
||||
export function hasCreateRoomRights(matrixClient: MatrixClient, space?: Room | null): boolean {
|
||||
const hasUIRight = shouldShowComponent(UIComponent.CreateRooms);
|
||||
if (!space || !hasUIRight) return hasUIRight;
|
||||
|
||||
return Boolean(
|
||||
space
|
||||
?.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.maySendStateEvent(EventType.RoomAvatar, matrixClient.getSafeUserId()),
|
||||
);
|
||||
}
|
||||
|
||||
149
src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx
Normal file
149
src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 React, { type JSX, type PropsWithChildren } from "react";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
|
||||
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
|
||||
|
||||
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
|
||||
import { Flex } from "../../../utils/Flex";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
|
||||
import { type PrimaryFilter } from "../../../viewmodels/roomlist/useFilteredRooms";
|
||||
|
||||
interface EmptyRoomListProps {
|
||||
/**
|
||||
* The view model for the room list
|
||||
*/
|
||||
vm: RoomListViewState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The empty state for the room list
|
||||
*/
|
||||
export function EmptyRoomList({ vm }: EmptyRoomListProps): JSX.Element | undefined {
|
||||
// If there is no active primary filter, show the default empty state
|
||||
if (!vm.activePrimaryFilter) return <DefaultPlaceholder vm={vm} />;
|
||||
|
||||
switch (vm.activePrimaryFilter.key) {
|
||||
case FilterKey.FavouriteFilter:
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_favourites")}
|
||||
description={_t("room_list|empty|no_favourites_description")}
|
||||
/>
|
||||
);
|
||||
case FilterKey.PeopleFilter:
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_people")}
|
||||
description={_t("room_list|empty|no_people_description")}
|
||||
/>
|
||||
);
|
||||
case FilterKey.RoomsFilter:
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_rooms")}
|
||||
description={_t("room_list|empty|no_rooms_description")}
|
||||
/>
|
||||
);
|
||||
case FilterKey.UnreadFilter:
|
||||
return <UnreadPlaceholder filter={vm.activePrimaryFilter} />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface GenericPlaceholderProps {
|
||||
/**
|
||||
* The title of the placeholder
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* The description of the placeholder
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic placeholder for the room list
|
||||
*/
|
||||
function GenericPlaceholder({ title, description, children }: PropsWithChildren<GenericPlaceholderProps>): JSX.Element {
|
||||
return (
|
||||
<Flex
|
||||
data-testid="empty-room-list"
|
||||
className="mx_EmptyRoomList_GenericPlaceholder"
|
||||
direction="column"
|
||||
align="stretch"
|
||||
justify="center"
|
||||
gap="var(--cpd-space-2x)"
|
||||
>
|
||||
<span className="mx_EmptyRoomList_GenericPlaceholder_title">{title}</span>
|
||||
{description && <span className="mx_EmptyRoomList_GenericPlaceholder_description">{description}</span>}
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
interface DefaultPlaceholderProps {
|
||||
/**
|
||||
* The view model for the room list
|
||||
*/
|
||||
vm: RoomListViewState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default empty state for the room list when no primary filter is active
|
||||
* The user can create chat or room (if they have the permission)
|
||||
*/
|
||||
function DefaultPlaceholder({ vm }: DefaultPlaceholderProps): JSX.Element {
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_chats")}
|
||||
description={
|
||||
vm.canCreateRoom
|
||||
? _t("room_list|empty|no_chats_description")
|
||||
: _t("room_list|empty|no_chats_description_no_room_rights")
|
||||
}
|
||||
>
|
||||
<Flex
|
||||
className="mx_EmptyRoomList_DefaultPlaceholder"
|
||||
align="center"
|
||||
justify="center"
|
||||
direction="column"
|
||||
gap="var(--cpd-space-4x)"
|
||||
>
|
||||
<Button size="sm" kind="secondary" Icon={UserAddIcon} onClick={vm.createChatRoom}>
|
||||
{_t("action|new_message")}
|
||||
</Button>
|
||||
{vm.canCreateRoom && (
|
||||
<Button size="sm" kind="secondary" Icon={RoomIcon} onClick={vm.createRoom}>
|
||||
{_t("action|new_room")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</GenericPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
interface UnreadPlaceholderProps {
|
||||
filter: PrimaryFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* The empty state for the room list when the unread filter is active
|
||||
*/
|
||||
function UnreadPlaceholder({ filter }: UnreadPlaceholderProps): JSX.Element {
|
||||
return (
|
||||
<GenericPlaceholder title={_t("room_list|empty|no_unread")}>
|
||||
<Button kind="tertiary" onClick={filter.toggle}>
|
||||
{_t("room_list|empty|show_chats")}
|
||||
</Button>
|
||||
</GenericPlaceholder>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import React, { type JSX } from "react";
|
||||
|
||||
import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel";
|
||||
import { RoomList } from "./RoomList";
|
||||
import { EmptyRoomList } from "./EmptyRoomList";
|
||||
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
|
||||
|
||||
/**
|
||||
@@ -16,10 +17,12 @@ import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
|
||||
*/
|
||||
export function RoomListView(): JSX.Element {
|
||||
const vm = useRoomListViewModel();
|
||||
const isRoomListEmpty = vm.rooms.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoomListPrimaryFilters vm={vm} />
|
||||
<RoomList vm={vm} />
|
||||
{isRoomListEmpty ? <EmptyRoomList vm={vm} /> : <RoomList vm={vm} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user