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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user