Files
ThreadNet-Web/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx
T
Michael TelatynskiandGitHub eca2f39dbb Switch from prettier to oxfmt (#33844)
* Switch from prettier to oxfmt

* Make knip happy

* Make knip happy

* Apply suggestion from @t3chguy

* Rename .oxfmtrc.json to .oxfmtrc.jsonc

* Make oxfmt happier
2026-06-16 12:17:18 +00:00

466 lines
18 KiB
TypeScript

/*
* Copyright 2026 Element Creations 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, { useCallback, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from "react";
import { type ScrollIntoViewLocation, type VirtuosoHandle } from "react-virtuoso";
import { isEqual } from "lodash";
import { DragDropProvider, DragOverlay, useDragOperation } from "@dnd-kit/react";
import { KeyboardSensor, PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
import { type Room } from "./RoomListItemWrapper/RoomListItemView";
import { useViewModel } from "../../core/viewmodel";
import { _t } from "../../core/i18n/i18n";
import {
FlatVirtualizedList,
getContainerAccessibleProps,
type VirtualizedListContext,
} from "../../core/VirtualizedList";
import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView";
import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "../../core/VirtualizedList";
import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
import { RoomListItemWrapper } from "./RoomListItemWrapper";
import { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
import styles from "./VirtualizedRoomListView.module.css";
/**
* Filter key type - opaque string type for filter identifiers
*/
export type FilterKey = string;
/**
* State for the room list data (nested within RoomListViewSnapshot)
*/
export interface RoomListViewState {
/** Optional active room index for keyboard navigation */
activeRoomIndex?: number;
/** Space ID for context tracking */
spaceId?: string;
/** Active filter keys for context tracking */
filterKeys?: FilterKey[];
/** Tag of a newly created section header to scroll into view */
scrollToSectionTag?: string;
}
/**
* Props for the VirtualizedRoomListView component
*/
export interface VirtualizedRoomListViewProps {
/**
* The view model containing all room list data and callbacks
*/
vm: RoomListViewModel;
/**
* Render function for room avatar
* @param room - The opaque Room object from the client
*/
renderAvatar: (room: Room) => ReactNode;
/**
* Optional callback for keyboard key down events
*/
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
}
/** Height of a single room list item in pixels (44px item + 8px padding bottom) */
const ROOM_LIST_ITEM_HEIGHT = 52;
/**
* Type for context used in ListView
*/
type Context = {
/** Space ID for context tracking */
spaceId: string;
/** Active filter keys for context tracking */
filterKeys: FilterKey[] | undefined;
/** Active room index for keyboard navigation */
activeRoomIndex: number | undefined;
/** Sections of the room list */
sections: RoomListViewSnapshot["sections"];
/** Total number of rooms in the list */
roomCount: number;
/** Number of sections in the list */
sectionCount: number;
/** Room list view model */
vm: RoomListViewModel;
/** List is in flat or section mode */
isFlatList: boolean;
};
/**
* Amount to extend the top and bottom of the viewport by.
* From manual testing and user feedback 25 items is reported to be enough to avoid blank space
* when using the mouse wheel, and the trackpad scrolling at a slow to moderate speed where you
* can still see/read the content. Using the trackpad to sling through a large percentage of the
* list quickly will still show blank space. We would likely need to simplify the item content to
* improve this case.
*/
const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
/**
* A virtualized list of rooms.
* This component provides efficient rendering of large room lists using virtualization,
* and renders RoomListItemView components for each room.
*
* @example
* ```tsx
* <VirtualizedRoomListView vm={roomListViewModel} renderAvatar={(room) => <Avatar room={room} />} />
* ```
*/
export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: VirtualizedRoomListViewProps): JSX.Element {
const snapshot = useViewModel(vm);
const { roomListState, sections, isFlatList } = snapshot;
const activeRoomIndex = roomListState.activeRoomIndex;
const scrollToSectionTag = roomListState.scrollToSectionTag;
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const virtuosoHandleRef = useRef<VirtuosoHandle | null>(null);
const setVirtuosoHandle = useCallback((handle: VirtuosoHandle | null) => {
virtuosoHandleRef.current = handle;
}, []);
const roomIds = useMemo(() => sections.flatMap((section) => section.roomIds), [sections]);
const roomCount = roomIds.length;
const sectionCount = sections.length;
const totalCount = roomCount + sectionCount;
const groups = useMemo(
() =>
sections.map((section) => ({
header: section.id,
items: section.roomIds,
})),
[sections],
);
/**
* Callback when the visible range changes
* Notifies the view model which rooms are visible
*/
const rangeChanged = useCallback(
(range: { startIndex: number; endIndex: number }) => {
vm.updateVisibleRooms(range.startIndex, range.endIndex);
},
[vm],
);
/**
* Get the item component for a specific index
* Gets the room's view model and passes it to RoomListItemView
*
* @param index - The index of the item in the list
* @param roomId - The ID of the room for this item
* @param context - The virtualization context containing list state
* @param onFocus - Callback to call when the item is focused
* @param isInLastSection - Whether this item is in the last section
* @param roomIndexInSection - The index of this room within its section
*/
const getItemComponent = useCallback(
(
index: number,
roomId: string,
context: VirtualizedListContext<Context>,
onFocus: (item: string, e: React.FocusEvent) => void,
isInLastSection?: boolean,
roomIndexInSection?: number,
): JSX.Element => {
const { activeRoomIndex, roomCount, vm, isFlatList } = context.context;
const isSelected = activeRoomIndex === index;
const roomItemVM = vm.getRoomItemViewModel(roomId);
// If we don't have a view model for this room, it means the room has been removed since the list was rendered - return an empty placeholder
if (!roomItemVM) {
return <React.Fragment key={`stale-${index}`} />;
}
// Item is focused when the list has focus AND this item's key matches tabIndexKey
// This matches the old RoomList implementation's roving tabindex pattern
const isFocused = context.focused && context.tabIndexKey === roomId;
const isFirstItem = isFlatList && index === 0;
const isLastItem = Boolean((isFlatList || isInLastSection) && index === roomCount - 1);
return (
<RoomListItemWrapper
key={roomId}
vm={roomItemVM}
renderAvatar={renderAvatar}
isSelected={isSelected}
isFocused={isFocused}
onFocus={onFocus}
roomIndex={index}
// For a flat list, we don't have sections, so roomIndexInSection is unused and can be set to 0
roomIndexInSection={roomIndexInSection || 0}
roomCount={roomCount}
isFirstItem={isFirstItem}
isLastItem={isLastItem}
isInFlatList={isFlatList}
/>
);
},
[renderAvatar],
);
/**
* Get the item component for a specific index in a grouped list
* Gets the room's view model and passes it to RoomListItemView
*/
const getItemComponentForGroupedList = useCallback(
(
index: number,
roomId: string,
context: VirtualizedListContext<Context>,
onFocus: (item: string, e: React.FocusEvent) => void,
groupIndex: number,
): JSX.Element => {
const { sections } = context.context;
const roomIndexInSection = sections[groupIndex].roomIds.findIndex((id) => id === roomId);
const isInLastSection = groupIndex === sections.length - 1;
return getItemComponent(index, roomId, context, onFocus, isInLastSection, roomIndexInSection);
},
[getItemComponent],
);
/**
* Get the item component for a specific index in a flat list
* Gets the room's view model and passes it to RoomListItemView
*/
const getItemComponentForFlatList = useCallback(
(
index: number,
roomId: string,
context: VirtualizedListContext<Context>,
onFocus: (item: string, e: React.FocusEvent) => void,
): JSX.Element => {
return getItemComponent(index, roomId, context, onFocus);
},
[getItemComponent],
);
/**
* Get the group header component for a specific group
*/
const getGroupHeaderComponent = useCallback(
(
groupIndex: number,
headerId: string,
context: VirtualizedListContext<Context>,
onFocus: (header: string, e: React.FocusEvent) => void,
): JSX.Element => {
const { vm, sectionCount, sections } = context.context;
const sectionHeaderVM = vm.getSectionHeaderViewModel(headerId);
const indexInList = sections
.slice(0, groupIndex)
// +1 for each section header
.reduce((acc, section) => acc + section.roomIds.length + 1, 0);
const roomCountInSection = sections[groupIndex].roomIds.length;
// Item is focused when the list has focus AND this item's key matches tabIndexKey
// This matches the old RoomList implementation's roving tabindex pattern
const isFocused = context.focused && context.tabIndexKey === headerId;
return (
<RoomListSectionHeaderView
// Stable key per section avoids a @dnd-kit registration race when a new section is inserted.
key={headerId}
vm={sectionHeaderVM}
isFocused={isFocused}
onFocus={onFocus}
indexInList={indexInList}
sectionIndex={groupIndex}
sectionCount={sectionCount}
roomCountInSection={roomCountInSection}
/>
);
},
[],
);
/**
* Get the key for a room item
* Since we're using virtualization, items are always room ID strings
*/
const getItemKey = useCallback((item: string): string => item, []);
/**
* Get the key for a group header
* We are passing the section ID as the header key, which is a string, so we can return it directly
*/
const getHeaderKey = useCallback((header: string): string => header, []);
const context = useMemo(
() => ({
spaceId: roomListState.spaceId || "",
filterKeys: roomListState.filterKeys,
sections,
activeRoomIndex,
roomCount,
sectionCount,
vm,
isFlatList,
}),
[
roomListState.spaceId,
roomListState.filterKeys,
sections,
activeRoomIndex,
roomCount,
sectionCount,
vm,
isFlatList,
],
);
/**
* Determine if we should scroll the active index into view
* This happens when the space or filters change
*/
const scrollIntoViewOnChange = useCallback(
(params: {
context: VirtualizedListContext<{ spaceId: string; filterKeys: FilterKey[] | undefined }>;
}): ScrollIntoViewLocation | null | undefined | false => {
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: activeRoomIndex || 0,
behavior: "auto",
};
}
return false;
},
[activeRoomIndex],
);
// Imperatively scroll to a newly created section header.
// scrollIntoView on virtuoso handle is more reliable in this case vs scrollIntoViewOnChange
useLayoutEffect(() => {
if (scrollToSectionTag === undefined) return;
const sectionIndex = sections.findIndex((s) => s.id === scrollToSectionTag);
if (sectionIndex === -1) return;
const flatIndex = sections.slice(0, sectionIndex).reduce((acc, s) => acc + s.roomIds.length + 1, 0);
virtuosoHandleRef.current?.scrollIntoView({ index: flatIndex, align: "start", behavior: "auto" });
}, [scrollToSectionTag, sections]);
const isItemFocusable = useCallback(() => true, []);
const isGroupHeaderFocusable = useCallback(() => true, []);
const increaseViewportBy = useMemo(
() => ({
top: EXTENDED_VIEWPORT_HEIGHT,
bottom: EXTENDED_VIEWPORT_HEIGHT,
}),
[],
);
const commonProps = {
context,
scrollIntoViewOnChange,
// If fixedItemHeight is not set and initialTopMostItemIndex=undefined, virtuoso crashes
// If we don't set it, it works
...(activeRoomIndex !== undefined ? { initialTopMostItemIndex: activeRoomIndex } : {}),
["data-testid"]: "room-list",
["aria-label"]: _t("room_list|list_title"),
getItemKey,
isItemFocusable,
rangeChanged,
onKeyDown,
increaseViewportBy,
"className": styles.roomList,
};
if (isFlatList) {
return (
<FlatVirtualizedList
{...commonProps}
{...getContainerAccessibleProps("listbox")}
items={roomIds}
getItemComponent={getItemComponentForFlatList}
/>
);
}
return (
<DragDropProvider
onDragEnd={(event) => {
if (event.canceled) return;
const { target, source } = event.operation;
if (!source || !target) return;
vm.changeRoomSection(source.id as string, target.id as string);
}}
sensors={[
// By default, the PointerSensor activates dragging immediately on pointer down, which interferes with keyboard navigation.
// So we start dragging after the pointer has moved by 5 pixels, to allow for click without dragging
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 5 })],
}),
// By default, the KeyboardSensor uses both space and enter to start dragging, which interferes with the keyboard enter shortcut to open a room.
KeyboardSensor.configure({
keyboardCodes: {
start: ["Space"],
cancel: ["Escape"],
end: ["Space"],
up: ["ArrowUp"],
down: ["ArrowDown"],
left: ["ArrowLeft"],
right: ["ArrowRight"],
},
}),
]}
>
<DragOverlay dropAnimation={null}>
<DragOverlayContent vm={vm} renderAvatar={renderAvatar} />
</DragOverlay>
<GroupedRoomList
{...commonProps}
{...getContainerAccessibleProps("treegrid", totalCount)}
scrollHandleRef={setVirtuosoHandle}
groups={groups}
getHeaderKey={getHeaderKey}
getGroupHeaderComponent={getGroupHeaderComponent}
getItemComponent={getItemComponentForGroupedList}
isGroupHeaderFocusable={isGroupHeaderFocusable}
/>
</DragDropProvider>
);
}
/**
* Inner component rendered inside DragDropProvider that renders the grouped virtualized list.
* Uses useDragOperation to detect active keyboard drags and disable the list's own keyboard
* navigation shortcuts while a drag is in progress, preventing unwanted list scrolling.
*/
function GroupedRoomList(props: GroupedVirtualizedListProps<string, string, Context>): JSX.Element {
const { source } = useDragOperation();
return <GroupedVirtualizedList<string, string, Context> {...props} disableKeyboardNavigation={source !== null} />;
}
interface DragOverlayContentProps {
/** The room list view model */
vm: RoomListViewModel;
/** Function to render the room avatar */
renderAvatar: (room: Room) => ReactNode;
}
/**
* Component rendered in the drag overlay when dragging a room item. Renders a copy of the dragged item to avoid dragging the actual element out of virtualization.
*/
function DragOverlayContent({ vm, renderAvatar }: DragOverlayContentProps): JSX.Element | null {
const { source } = useDragOperation();
if (!source) return null;
const itemVm = vm.getRoomItemViewModel(source.id as string);
if (!itemVm) return null;
return <RoomListItemDragOverlayView vm={itemVm} renderAvatar={renderAvatar} />;
}