Move the room list to the new ListView(backed by react-virtuoso) (#30515)

* Move Room List to ListView

- Also remove Space/Enter handing from keyboard navigation we can just leave the default behaviour of those keys and handle via onClick

* Update rooms when the primary filter changes

Otherwise when changing spaces, the filter does not reset until the next update to the RVS is made.

* Fix stickyRow/scrollIntoView when switiching space or changing filters

- Also remove the rest of space/enter keyboard handling use

* Remove the rest of space/enter keyboard handling use

* Remove useCombinedRef and add @radix-ui/react-compose-refs as we already depend on it

- Also remove eact-virtualized dep

* Update RoomList unit test

* Update snapshots and unit tests

* Fix e2e tests

* Remove react-virtualized from tests

* Fix e2e flake

* Update more screenshots

* Fix e2e test case where were should scroll to the top when the active room is no longer in the list

* Move from gitpkg to package-patch

* Update to latest react virtuoso release/api.

Also pass spaceId to the room list and scroll the activeIndex into view when spaceId or primaryFilter change.

* Use listbox/option roles to improve ScreenReader experience

* Change onKeyDown e.stopPropogation to cover context menu

* lint

* Remove unneeded exposure of the listView ref

Also move scrollIntoViewOnChange to useCallback

* Update unit test and snapshot

* Fix e2e tests and update screenshots

* Fix unit test and snapshot

* Update more unit tests

* Fix keyboard shortcuts and e2e test

* Fix another e2e and unit test

* lint

* Improve the naming for RoomResult and the documentation on it's fields meaning.

Also update the login in RoomList to check for any change in filters, this is a bit more future proof for when we introduce multi select than using activePrimaryFilter.

* Put back and fix landmark tests

* Fix test import

* Add comment regarding context object getting rendered.

* onKeyDown should be optional

* Use SpaceKey type on RoomResult

* lint
This commit is contained in:
David Langley
2025-08-21 15:43:40 +01:00
committed by GitHub
parent ef3a6a9429
commit c842b615db
50 changed files with 1139 additions and 1021 deletions

View File

@@ -79,3 +79,12 @@ export function isOnlyCtrlOrCmdKeyEvent(ev: React.KeyboardEvent | KeyboardEvent)
return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
}
}
/**
* Checks if the given keyboard event is a modified key event (i.e., if any modifier keys are active).
* @param ev The keyboard event to check
* @returns True if the event is a modified key event, false otherwise
*/
export function isModifiedKeyEvent(ev: React.KeyboardEvent | KeyboardEvent): boolean {
return ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey;
}

View File

@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React, { useRef, type JSX, useCallback, useEffect, useState } from "react";
import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso";
import { isModifiedKeyEvent, Key } from "../../Keyboard";
/**
* Context object passed to each list item containing the currently focused key
* and any additional context data from the parent component.
@@ -34,6 +35,7 @@ export interface IListViewProps<Item, Context>
* @param index - The index of the item in the list
* @param item - The data item to render
* @param context - The context object containing the focused key and any additional data
* @param onFocus - A callback that is required to be called when the item component receives focus
* @returns JSX element representing the rendered item
*/
getItemComponent: (
@@ -62,6 +64,14 @@ export interface IListViewProps<Item, Context>
* @return The key to use for focusing the item
*/
getItemKey: (item: Item) => string;
/**
* Callback function to handle key down events on the list container.
* ListView handles keyboard navigation for focus(up, down, home, end, pageUp, pageDown)
* and stops propagation otherwise the event bubbles and this callback is called for the use of the parent.
* @param e - The keyboard event
* @returns
*/
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
}
/**
@@ -73,7 +83,7 @@ export interface IListViewProps<Item, Context>
*/
export function ListView<Item, Context = any>(props: IListViewProps<Item, Context>): React.ReactElement {
// Extract our custom props to avoid conflicts with Virtuoso props
const { items, getItemComponent, isItemFocusable, getItemKey, context, ...virtuosoProps } = props;
const { items, getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, ...virtuosoProps } = props;
/** Reference to the Virtuoso component for programmatic scrolling */
const virtuosoHandleRef = useRef<VirtuosoHandle>(null);
/** Reference to the DOM element containing the virtualized list */
@@ -125,7 +135,7 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
const key = getItemKey(items[clampedIndex]);
setTabIndexKey(key);
isScrollingToItem.current = true;
virtuosoHandleRef?.current?.scrollIntoView({
virtuosoHandleRef.current?.scrollIntoView({
index: clampedIndex,
align: align,
behavior: "auto",
@@ -168,40 +178,44 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
* Supports Arrow keys, Home, End, Page Up/Down, Enter, and Space.
*/
const keyDownCallback = useCallback(
(e: React.KeyboardEvent) => {
if (!e) return; // Guard against null/undefined events
(e: React.KeyboardEvent<HTMLDivElement>) => {
const currentIndex = tabIndexKey ? keyToIndexMap.get(tabIndexKey) : undefined;
let handled = false;
if (e.code === "ArrowUp" && currentIndex !== undefined) {
scrollToItem(currentIndex - 1, false);
handled = true;
} else if (e.code === "ArrowDown" && currentIndex !== undefined) {
scrollToItem(currentIndex + 1, true);
handled = true;
} else if (e.code === "Home") {
scrollToIndex(0);
handled = true;
} else if (e.code === "End") {
scrollToIndex(items.length - 1);
handled = true;
} else if (e.code === "PageDown" && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, `start`);
handled = true;
} else if (e.code === "PageUp" && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, `start`);
handled = true;
// Guard against null/undefined events and modified keys which we don't want to handle here but do
// at the settings level shortcuts(E.g. Select next room, etc )
if (e || !isModifiedKeyEvent(e)) {
if (e.code === Key.ARROW_UP && currentIndex !== undefined) {
scrollToItem(currentIndex - 1, false);
handled = true;
} else if (e.code === Key.ARROW_DOWN && currentIndex !== undefined) {
scrollToItem(currentIndex + 1, true);
handled = true;
} else if (e.code === Key.HOME) {
scrollToIndex(0);
handled = true;
} else if (e.code === Key.END) {
scrollToIndex(items.length - 1);
handled = true;
} else if (e.code === Key.PAGE_DOWN && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, `start`);
handled = true;
} else if (e.code === Key.PAGE_UP && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, `start`);
handled = true;
}
}
if (handled) {
e.stopPropagation();
e.preventDefault();
} else {
onKeyDown?.(e);
}
},
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items],
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onKeyDown],
);
/**
@@ -251,8 +265,12 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
[keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey],
);
const onBlur = useCallback((): void => {
setIsFocused(false);
const onBlur = useCallback((event: React.FocusEvent<HTMLDivElement>): void => {
// Only set isFocused to false if the focus is moving outside the list
// This prevents the list from losing focus when interacting with menus inside it
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsFocused(false);
}
}, []);
const listContext: ListContext<Context> = {
@@ -264,8 +282,8 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
return (
<Virtuoso
tabIndex={props.tabIndex || undefined} // We don't need to focus the container, so leave it undefined by default
scrollerRef={scrollerRef}
ref={virtuosoHandleRef}
scrollerRef={scrollerRef}
onKeyDown={keyDownCallback}
context={listContext}
rangeChanged={setVisibleRange}

View File

@@ -18,6 +18,7 @@ import { Action } from "../../../dispatcher/actions";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useStickyRoomList } from "./useStickyRoomList";
import { useRoomListNavigation } from "./useRoomListNavigation";
import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
export interface RoomListViewState {
/**
@@ -26,9 +27,9 @@ export interface RoomListViewState {
isLoadingRooms: boolean;
/**
* A list of rooms to be displayed in the left panel.
* The room results to be displayed (along with the spaceId and filter keys at the time of query)
*/
rooms: Room[];
roomsResult: RoomsResult;
/**
* Create a chat room
@@ -71,10 +72,10 @@ export interface RoomListViewState {
*/
export function useRoomListViewModel(): RoomListViewState {
const matrixClient = useMatrixClientContext();
const { isLoadingRooms, primaryFilters, activePrimaryFilter, rooms: filteredRooms } = useFilteredRooms();
const { activeIndex, rooms } = useStickyRoomList(filteredRooms);
const { isLoadingRooms, primaryFilters, activePrimaryFilter, roomsResult: filteredRooms } = useFilteredRooms();
const { activeIndex, roomsResult } = useStickyRoomList(filteredRooms);
useRoomListNavigation(rooms);
useRoomListNavigation(roomsResult.rooms);
const currentSpace = useEventEmitterState<Room | null>(
SpaceStore.instance,
@@ -88,7 +89,7 @@ export function useRoomListViewModel(): RoomListViewState {
return {
isLoadingRooms,
rooms,
roomsResult,
canCreateRoom,
createRoom,
createChatRoom,

View File

@@ -5,12 +5,15 @@ 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, useMemo, useState } from "react";
import { useCallback, useEffect, 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, { LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT } from "../../../stores/room-list-v3/RoomListStoreV3";
import RoomListStoreV3, {
LISTS_LOADED_EVENT,
LISTS_UPDATE_EVENT,
type RoomsResult,
} 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,7 +38,7 @@ export interface PrimaryFilter {
interface FilteredRooms {
primaryFilters: PrimaryFilter[];
isLoadingRooms: boolean;
rooms: Room[];
roomsResult: RoomsResult;
/**
* The currently active primary filter.
* If no primary filter is active, this will be undefined.
@@ -63,12 +66,12 @@ export function useFilteredRooms(): FilteredRooms {
*/
const [primaryFilter, setPrimaryFilter] = useState<FilterKey | undefined>();
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
const [roomsResult, setRoomsResult] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
const [isLoadingRooms, setIsLoadingRooms] = useState(() => RoomListStoreV3.instance.isLoadingRooms);
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
setRooms(newRooms);
setRoomsResult(newRooms);
}, []);
// Reset filters when active space changes
@@ -77,9 +80,15 @@ export function useFilteredRooms(): FilteredRooms {
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
array.filter((f) => f !== undefined) as FilterKey[];
const getAppliedFilters = (): FilterKey[] => {
const getAppliedFilters = useCallback((): FilterKey[] => {
return filterUndefined([primaryFilter]);
};
}, [primaryFilter]);
useEffect(() => {
// Update the rooms state when the primary filter changes
const filters = getAppliedFilters();
updateRoomsFromStore(filters);
}, [getAppliedFilters, updateRoomsFromStore]);
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
const filters = getAppliedFilters();
@@ -122,6 +131,6 @@ export function useFilteredRooms(): FilteredRooms {
isLoadingRooms,
primaryFilters,
activePrimaryFilter,
rooms,
roomsResult,
};
}

View File

@@ -14,6 +14,7 @@ import { Action } from "../../../dispatcher/actions";
import type { Room } from "matrix-js-sdk/src/matrix";
import type { Optional } from "matrix-events-sdk";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
function getIndexByRoomId(rooms: Room[], roomId: Optional<string>): number | undefined {
const index = rooms.findIndex((room) => room.roomId === roomId);
@@ -67,11 +68,11 @@ function getRoomsWithStickyRoom(
return { newIndex: oldIndex, newRooms };
}
interface StickyRoomListResult {
export interface StickyRoomListResult {
/**
* List of rooms with sticky active room.
* The rooms result with the active sticky room applied
*/
rooms: Room[];
roomsResult: RoomsResult;
/**
* Index of the active room in the room list.
*/
@@ -85,10 +86,10 @@ interface StickyRoomListResult {
* @param rooms list of rooms
* @see {@link StickyRoomListResult} details what this hook returns..
*/
export function useStickyRoomList(rooms: Room[]): StickyRoomListResult {
const [listState, setListState] = useState<{ index: number | undefined; roomsWithStickyRoom: Room[] }>({
index: undefined,
roomsWithStickyRoom: rooms,
export function useStickyRoomList(roomsResult: RoomsResult): StickyRoomListResult {
const [listState, setListState] = useState<StickyRoomListResult>({
activeIndex: getIndexByRoomId(roomsResult.rooms, SdkContextClass.instance.roomViewStore.getRoomId()),
roomsResult: roomsResult,
});
const currentSpaceRef = useRef(SpaceStore.instance.activeSpace);
@@ -97,13 +98,18 @@ export function useStickyRoomList(rooms: Room[]): StickyRoomListResult {
(newRoomId: string | null, isRoomChange: boolean = false) => {
setListState((current) => {
const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId();
const newActiveIndex = getIndexByRoomId(rooms, activeRoomId);
const oldIndex = current.index;
const { newIndex, newRooms } = getRoomsWithStickyRoom(rooms, oldIndex, newActiveIndex, isRoomChange);
return { index: newIndex, roomsWithStickyRoom: newRooms };
const newActiveIndex = getIndexByRoomId(roomsResult.rooms, activeRoomId);
const oldIndex = current.activeIndex;
const { newIndex, newRooms } = getRoomsWithStickyRoom(
roomsResult.rooms,
oldIndex,
newActiveIndex,
isRoomChange,
);
return { activeIndex: newIndex, roomsResult: { ...roomsResult, rooms: newRooms } };
});
},
[rooms],
[roomsResult],
);
// Re-calculate the index when the active room has changed.
@@ -115,20 +121,19 @@ export function useStickyRoomList(rooms: Room[]): StickyRoomListResult {
useEffect(() => {
let newRoomId: string | null = null;
let isRoomChange = false;
const newSpace = SpaceStore.instance.activeSpace;
if (currentSpaceRef.current !== newSpace) {
if (currentSpaceRef.current !== roomsResult.spaceId) {
/*
If the space has changed, we check if we can immediately set the active
index to the last opened room in that space. Otherwise, we might see a
flicker because of the delay between the space change event and
active room change dispatch.
*/
newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpace);
newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(roomsResult.spaceId);
isRoomChange = true;
currentSpaceRef.current = newSpace;
currentSpaceRef.current = roomsResult.spaceId;
}
updateRoomsAndIndex(newRoomId, isRoomChange);
}, [rooms, updateRoomsAndIndex]);
}, [roomsResult, updateRoomsAndIndex]);
return { activeIndex: listState.index, rooms: listState.roomsWithStickyRoom };
return listState;
}

View File

@@ -5,13 +5,16 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback, type JSX } from "react";
import { AutoSizer, List, type ListRowProps } from "react-virtualized";
import React, { useCallback, useRef, type JSX } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { type ScrollIntoViewLocation } from "react-virtuoso";
import { isEqual } from "lodash";
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { _t } from "../../../../languageHandler";
import { RoomListItemView } from "./RoomListItemView";
import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex";
import { type ListContext, ListView } from "../../../utils/ListView";
import { type FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation";
@@ -26,55 +29,93 @@ interface RoomListProps {
/**
* A virtualized list of rooms.
*/
export function RoomList({ vm: { rooms, activeIndex } }: RoomListProps): JSX.Element {
const roomRendererMemoized = useCallback(
({ key, index, style }: ListRowProps) => (
<RoomListItemView room={rooms[index]} key={key} style={style} isSelected={activeIndex === index} />
),
[rooms, activeIndex],
export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): JSX.Element {
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const roomCount = roomsResult.rooms.length;
const getItemComponent = useCallback(
(
index: number,
item: Room,
context: ListContext<{
spaceId: string;
filterKeys: FilterKey[] | undefined;
}>,
onFocus: (e: React.FocusEvent) => void,
): JSX.Element => {
const itemKey = item.roomId;
const isRovingItem = itemKey === context.tabIndexKey;
const isFocused = isRovingItem && context.focused;
const isSelected = activeIndex === index;
return (
<RoomListItemView
room={item}
key={itemKey}
isSelected={isSelected}
isFocused={isFocused}
tabIndex={isRovingItem ? 0 : -1}
roomIndex={index}
roomCount={roomCount}
onFocus={onFocus}
/>
);
},
[activeIndex, roomCount],
);
// The first div is needed to make the virtualized list take all the remaining space and scroll correctly
const getItemKey = useCallback((item: Room): string => {
return item.roomId;
}, []);
const scrollIntoViewOnChange = useCallback(
(params: {
context: ListContext<{ spaceId: string; filterKeys: FilterKey[] | undefined }>;
}): ScrollIntoViewLocation | null | undefined | false | void => {
const { spaceId, filterKeys } = params.context.context;
const shouldScrollIndexIntoView =
lastSpaceId.current !== spaceId || !isEqual(lastFilterKeys.current, filterKeys);
lastFilterKeys.current = filterKeys;
lastSpaceId.current = spaceId;
if (shouldScrollIndexIntoView) {
return {
align: `start`,
index: activeIndex || 0,
behavior: "auto",
};
}
return false;
},
[activeIndex],
);
const keyDownCallback = useCallback((ev: React.KeyboardEvent) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (navAction === KeyBindingAction.NextLandmark || navAction === KeyBindingAction.PreviousLandmark) {
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ROOM_LIST,
navAction === KeyBindingAction.PreviousLandmark,
);
ev.stopPropagation();
ev.preventDefault();
return;
}
}, []);
return (
<RovingTabIndexProvider handleHomeEnd={true} handleUpDown={true}>
{({ onKeyDownHandler }) => (
<div
className="mx_RoomList"
data-testid="room-list"
onKeyDown={(ev) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (
navAction === KeyBindingAction.NextLandmark ||
navAction === KeyBindingAction.PreviousLandmark
) {
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ROOM_LIST,
navAction === KeyBindingAction.PreviousLandmark,
);
ev.stopPropagation();
ev.preventDefault();
return;
}
onKeyDownHandler(ev);
}}
>
<AutoSizer>
{({ height, width }) => (
<List
aria-label={_t("room_list|list_title")}
className="mx_RoomList_List"
rowRenderer={roomRendererMemoized}
rowCount={rooms.length}
rowHeight={48}
height={height}
width={width}
scrollToIndex={activeIndex ?? 0}
tabIndex={-1}
/>
)}
</AutoSizer>
</div>
)}
</RovingTabIndexProvider>
<ListView
context={{ spaceId: roomsResult.spaceId, filterKeys: roomsResult.filterKeys }}
scrollIntoViewOnChange={scrollIntoViewOnChange}
initialTopMostItemIndex={activeIndex}
data-testid="room-list"
role="listbox"
aria-label={_t("room_list|list_title")}
fixedItemHeight={48}
items={roomsResult.rooms}
getItemComponent={getItemComponent}
getItemKey={getItemKey}
isItemFocusable={() => true}
onKeyDown={keyDownCallback}
/>
);
}

View File

@@ -97,7 +97,10 @@ interface MoreOptionContentProps {
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
return (
<>
<div
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
onKeyDown={(e) => e.stopPropagation()}
>
{vm.canMarkAsRead && (
<MenuItem
Icon={MarkAsReadIcon}
@@ -157,7 +160,7 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
</>
</div>
);
}
@@ -196,54 +199,59 @@ function NotificationMenu({ vm, setMenuOpen }: NotificationMenuProps): JSX.Eleme
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
return (
<Menu
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
setMenuOpen(isOpen);
}}
title={_t("room_list|notification_options")}
showTitle={false}
align="start"
trigger={<NotificationButton isRoomMuted={vm.isNotificationMute} size="24px" />}
<div
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
onKeyDown={(e) => e.stopPropagation()}
>
<MenuItem
aria-selected={vm.isNotificationAllMessage}
hideChevron={true}
label={_t("notifications|default_settings")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessages)}
onClick={(evt) => evt.stopPropagation()}
<Menu
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
setMenuOpen(isOpen);
}}
title={_t("room_list|notification_options")}
showTitle={false}
align="start"
trigger={<NotificationButton isRoomMuted={vm.isNotificationMute} size="24px" />}
>
{vm.isNotificationAllMessage && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationAllMessageLoud}
hideChevron={true}
label={_t("notifications|all_messages")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessagesLoud)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationAllMessageLoud && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationMentionOnly}
hideChevron={true}
label={_t("notifications|mentions_keywords")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.MentionsOnly)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationMentionOnly && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationMute}
hideChevron={true}
label={_t("notifications|mute_room")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.Mute)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationMute && checkComponent}
</MenuItem>
</Menu>
<MenuItem
aria-selected={vm.isNotificationAllMessage}
hideChevron={true}
label={_t("notifications|default_settings")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessages)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationAllMessage && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationAllMessageLoud}
hideChevron={true}
label={_t("notifications|all_messages")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessagesLoud)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationAllMessageLoud && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationMentionOnly}
hideChevron={true}
label={_t("notifications|mentions_keywords")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.MentionsOnly)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationMentionOnly && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationMute}
hideChevron={true}
label={_t("notifications|mute_room")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.Mute)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationMute && checkComponent}
</MenuItem>
</Menu>
</div>
);
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, memo, useCallback, useRef, useState } from "react";
import React, { type JSX, memo, useCallback, useEffect, useRef, useState } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
@@ -14,7 +14,6 @@ import { Flex } from "../../../../shared-components/utils/Flex";
import { RoomListItemMenuView } from "./RoomListItemMenuView";
import { NotificationDecoration } from "../NotificationDecoration";
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
import { RoomListItemContextMenuView } from "./RoomListItemContextMenuView";
interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement> {
@@ -26,6 +25,22 @@ interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement>
* Whether the room is selected
*/
isSelected: boolean;
/**
* Whether the room is focused
*/
isFocused: boolean;
/**
* A callback that indicates the item has received focus
*/
onFocus: (e: React.FocusEvent) => void;
/**
* The index of the room in the list
*/
roomIndex: number;
/**
* The total number of rooms in the list
*/
roomCount: number;
}
/**
@@ -34,18 +49,19 @@ interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement>
export const RoomListItemView = memo(function RoomListItemView({
room,
isSelected,
isFocused,
onFocus,
roomIndex: index,
roomCount: count,
...props
}: RoomListItemViewProps): JSX.Element {
const buttonRef = useRef<HTMLButtonElement>(null);
const [onFocus, isActive, ref] = useRovingTabIndex(buttonRef);
const ref = useRef<HTMLButtonElement>(null);
const vm = useRoomListItemViewModel(room);
const [isHover, setIsHoverWithDelay] = useIsHover();
const [isHover, setHover] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// The compound menu in RoomListItemMenuView needs to be rendered when the hover menu is shown
// Using display: none; and then display:flex when hovered in CSS causes the menu to be misaligned
const showHoverDecoration = isMenuOpen || isHover;
const showHoverDecoration = isMenuOpen || isFocused || isHover;
const showHoverMenu = showHoverDecoration && vm.showHoverMenu;
const closeMenu = useCallback(() => {
@@ -54,8 +70,15 @@ export const RoomListItemView = memo(function RoomListItemView({
setTimeout(() => setIsMenuOpen(false), 10);
}, []);
useEffect(() => {
if (isFocused) {
ref.current?.focus({ preventScroll: true, focusVisible: true });
}
}, [isFocused]);
const content = (
<button
<Flex
as="button"
ref={ref}
className={classNames("mx_RoomListItemView", {
mx_RoomListItemView_hover: showHoverDecoration,
@@ -63,63 +86,59 @@ export const RoomListItemView = memo(function RoomListItemView({
mx_RoomListItemView_selected: isSelected,
mx_RoomListItemView_bold: vm.isBold,
})}
gap="var(--cpd-space-3x)"
align="center"
type="button"
role="option"
aria-posinset={index + 1}
aria-setsize={count}
aria-selected={isSelected}
aria-label={vm.a11yLabel}
onClick={() => vm.openRoom()}
onMouseOver={() => setIsHoverWithDelay(true)}
onMouseOut={() => setIsHoverWithDelay(false)}
onFocus={() => {
setIsHoverWithDelay(true);
onFocus();
}}
// Adding a timeout because when tabbing to go to the more options and notification menu, the focus moves out of the button
// The blur makes the button lose the hover state and these menu are not shown
// We delay the blur event to give time to the focus to move to the menu
onBlur={() => setIsHoverWithDelay(false, 10)}
tabIndex={isActive ? 0 : -1}
onFocus={onFocus}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onBlur={() => setHover(false)}
tabIndex={isFocused ? 0 : -1}
{...props}
>
{/* We need this extra div between the button and the content in order to add a padding which is not messing with the virtualized list */}
<Flex className="mx_RoomListItemView_container" gap="var(--cpd-space-3x)" align="center">
<RoomAvatarView room={room} />
<Flex
className="mx_RoomListItemView_content"
gap="var(--cpd-space-2x)"
align="center"
justify="space-between"
>
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<div className="mx_RoomListItemView_text">
<div className="mx_RoomListItemView_roomName" title={vm.name}>
{vm.name}
</div>
{vm.messagePreview && (
<div className="mx_RoomListItemView_messagePreview" title={vm.messagePreview}>
{vm.messagePreview}
</div>
)}
<RoomAvatarView room={room} />
<Flex
className="mx_RoomListItemView_content"
gap="var(--cpd-space-2x)"
align="center"
justify="space-between"
>
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<div className="mx_RoomListItemView_text">
<div className="mx_RoomListItemView_roomName" title={vm.name}>
{vm.name}
</div>
{showHoverMenu ? (
<RoomListItemMenuView
room={room}
setMenuOpen={(isOpen) => (isOpen ? setIsMenuOpen(true) : closeMenu())}
/>
) : (
<>
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */}
{vm.showNotificationDecoration && (
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
hasVideoCall={vm.hasParticipantInCall}
/>
)}
</>
{vm.messagePreview && (
<div className="mx_RoomListItemView_messagePreview" title={vm.messagePreview}>
{vm.messagePreview}
</div>
)}
</Flex>
</div>
{showHoverMenu ? (
<RoomListItemMenuView
room={room}
setMenuOpen={(isOpen) => (isOpen ? setIsMenuOpen(true) : closeMenu())}
/>
) : (
<>
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */}
{vm.showNotificationDecoration && (
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
hasVideoCall={vm.hasParticipantInCall}
/>
)}
</>
)}
</Flex>
</button>
</Flex>
);
if (!vm.showContextMenu) return content;
@@ -140,33 +159,3 @@ export const RoomListItemView = memo(function RoomListItemView({
</RoomListItemContextMenuView>
);
});
/**
* Custom hook to manage the hover state of the room list item
* If the timeout is set, it will set the hover state after the timeout
* If the timeout is not set, it will set the hover state immediately
* When the set method is called, it will clear any existing timeout
*
* @returns {boolean} isHover - The hover state
*/
function useIsHover(): [boolean, (value: boolean, timeout?: number) => void] {
const [isHover, setIsHover] = useState(false);
// Store the timeout ID
const timeoutRef = useRef<number | undefined>(undefined);
const setIsHoverWithDelay = useCallback((value: boolean, timeout?: number): void => {
// Clear the timeout if it exists
clearTimeout(timeoutRef.current);
// No delay, set the value immediately
if (timeout === undefined) {
setIsHover(value);
return;
}
// Set a timeout to set the value after the delay
timeoutRef.current = setTimeout(() => setIsHover(value), timeout);
}, []);
return [isHover, setIsHoverWithDelay];
}

View File

@@ -17,7 +17,7 @@ import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
*/
export function RoomListView(): JSX.Element {
const vm = useRoomListViewModel();
const isRoomListEmpty = vm.rooms.length === 0;
const isRoomListEmpty = vm.roomsResult.rooms.length === 0;
let listBody;
if (vm.isLoadingRooms) {
listBody = <div className="mx_RoomListSkeleton" />;

View File

@@ -22,7 +22,7 @@ import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter";
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import { EffectiveMembership, getEffectiveMembership, getEffectiveMembershipTag } from "../../utils/membership";
import SpaceStore from "../spaces/SpaceStore";
import { UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces";
import { type SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces";
import { FavouriteFilter } from "./skip-list/filters/FavouriteFilter";
import { UnreadFilter } from "./skip-list/filters/UnreadFilter";
import { PeopleFilter } from "./skip-list/filters/PeopleFilter";
@@ -56,6 +56,16 @@ export enum RoomListStoreV3Event {
ListsLoaded = "lists_loaded",
}
// The result object for returning rooms from the store
export type RoomsResult = {
// The ID of the active space queried
spaceId: SpaceKey;
// The filter queried
filterKeys?: FilterKey[];
// The resulting list of rooms
rooms: Room[];
};
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
/**
@@ -107,9 +117,15 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
* @param filterKeys Optional array of filters that the rooms must match against.
*/
public getSortedRoomsInActiveSpace(filterKeys?: FilterKey[]): Room[] {
if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList.getRoomsInActiveSpace(filterKeys));
else return [];
public getSortedRoomsInActiveSpace(filterKeys?: FilterKey[]): RoomsResult {
const spaceId = SpaceStore.instance.activeSpace;
if (this.roomSkipList?.initialized)
return {
spaceId: spaceId,
filterKeys,
rooms: Array.from(this.roomSkipList.getRoomsInActiveSpace(filterKeys)),
};
else return { spaceId: spaceId, filterKeys, rooms: [] };
}
/**