diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomList/RoomList.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomList/RoomList.stories.tsx/default-auto.png new file mode 100644 index 0000000000..9cf03c0899 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomList/RoomList.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.module.css b/packages/shared-components/src/room-list/RoomList/RoomList.module.css new file mode 100644 index 0000000000..c444c8c1cd --- /dev/null +++ b/packages/shared-components/src/room-list/RoomList/RoomList.module.css @@ -0,0 +1,14 @@ +/* + * Copyright 2025 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. + */ + +/** + * Room list container styles + */ +.roomList { + height: 100%; + width: 100%; +} diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.stories.tsx b/packages/shared-components/src/room-list/RoomList/RoomList.stories.tsx new file mode 100644 index 0000000000..a76ffe7e34 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomList/RoomList.stories.tsx @@ -0,0 +1,87 @@ +/* + * 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, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RoomList, type RoomListViewState } from "./RoomList"; +import type { RoomListSnapshot, RoomListViewActions } from "../RoomListView"; +import { useMockedViewModel } from "../../viewmodel"; +import type { FilterId } from "../RoomListPrimaryFilters"; +import { renderAvatar, createGetRoomItemViewModel, mockRoomIds } from "../story-mocks"; + +type RoomListStoryProps = RoomListSnapshot & RoomListViewActions & { renderAvatar: (room: any) => React.ReactElement }; + +// Use first 10 room IDs for this story +const storyRoomIds = mockRoomIds.slice(0, 10); + +// Wrapper component that creates a mocked ViewModel +const RoomListWrapper = ({ + onToggleFilter, + createChatRoom, + createRoom, + getRoomItemViewModel, + updateVisibleRooms, + renderAvatar: renderAvatarProp, + ...rest +}: RoomListStoryProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onToggleFilter, + createChatRoom, + createRoom, + getRoomItemViewModel, + updateVisibleRooms, + }); + + return ( +
+ +
+ ); +}; + +const mockFilterIds: FilterId[] = ["unread", "people"]; + +const defaultRoomListState: RoomListViewState = { + activeRoomIndex: 0, + spaceId: "!space:server", + filterKeys: undefined, +}; + +const meta: Meta = { + title: "Room List/RoomList", + component: RoomListWrapper, + tags: ["autodocs"], + args: { + isLoadingRooms: false, + isRoomListEmpty: false, + filterIds: mockFilterIds, + activeFilterId: undefined, + roomIds: storyRoomIds, + roomListState: defaultRoomListState, + canCreateRoom: true, + onToggleFilter: fn(), + createChatRoom: fn(), + createRoom: fn(), + getRoomItemViewModel: createGetRoomItemViewModel(storyRoomIds), + updateVisibleRooms: fn(), + renderAvatar, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx b/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx new file mode 100644 index 0000000000..c5ee9b8b75 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx @@ -0,0 +1,67 @@ +/* + * 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 from "react"; +import { render, screen, fireEvent } from "@test-utils"; +import { VirtuosoMockContext } from "react-virtuoso"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect } from "vitest"; + +import * as stories from "./RoomList.stories"; + +const { Default } = composeStories(stories); + +const renderWithMockContext = (component: React.ReactElement): ReturnType => { + return render(component, { + wrapper: ({ children }) => ( + + {children} + + ), + }); +}; + +describe("", () => { + it("renders Default story", () => { + const { container } = renderWithMockContext(); + expect(container).toMatchSnapshot(); + }); + + it("should render the room list listbox", () => { + renderWithMockContext(); + expect(screen.getByRole("listbox", { name: "Room list" })).toBeInTheDocument(); + }); + + it("should render room items", () => { + renderWithMockContext(); + const items = screen.getAllByRole("option"); + expect(items.length).toBeGreaterThan(0); + }); + + it("should mark selected room with aria-selected true", () => { + renderWithMockContext(); + const items = screen.getAllByRole("option"); + // The first item (index 0) should be selected based on Default story (activeRoomIndex: 0) + expect(items[0]).toHaveAttribute("aria-selected", "true"); + }); + + it("should handle focus state correctly", () => { + renderWithMockContext(); + + const listbox = screen.getByRole("listbox", { name: "Room list" }); + fireEvent.focus(listbox); + + const items = screen.getAllByRole("option"); + // First item should have tabIndex 0 (focusable) when list is focused + expect(items[0]).toHaveAttribute("tabIndex", "0"); + }); + + it("should call updateVisibleRooms on render", () => { + renderWithMockContext(); + expect(Default.args.updateVisibleRooms).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.tsx b/packages/shared-components/src/room-list/RoomList/RoomList.tsx new file mode 100644 index 0000000000..ee5f7b3961 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomList/RoomList.tsx @@ -0,0 +1,197 @@ +/* + * 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, useMemo, useRef, type JSX, type ReactNode } from "react"; +import { type ScrollIntoViewLocation } from "react-virtuoso"; +import { isEqual } from "lodash"; + +import { useViewModel } from "../../viewmodel"; +import { _t } from "../../utils/i18n"; +import { VirtualizedList, type VirtualizedListContext } from "../../utils/VirtualizedList"; +import { RoomListItemView } from "../RoomListItem"; +import type { RoomListViewModel } from "../RoomListView"; + +/** + * Filter key type - opaque string type for filter identifiers + */ +export type FilterKey = string; + +/** + * State for the room list data (nested within RoomListSnapshot) + */ +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[]; +} + +/** + * Props for the RoomList component + */ +export interface RoomListProps { + /** + * 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: any) => ReactNode; + + /** + * Optional callback for keyboard key down events + */ + onKeyDown?: (e: React.KeyboardEvent) => void; +} + +/** Height of a single room list item in pixels */ +const ROOM_LIST_ITEM_HEIGHT = 48; + +/** + * Type for context used in ListView + */ +type Context = { spaceId: string; filterKeys: FilterKey[] | undefined }; + +/** + * 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 + * } /> + * ``` + */ +export function RoomList({ vm, renderAvatar, onKeyDown }: RoomListProps): JSX.Element { + const snapshot = useViewModel(vm); + const { roomListState, roomIds } = snapshot; + const activeRoomIndex = roomListState.activeRoomIndex; + const lastSpaceId = useRef(undefined); + const lastFilterKeys = useRef(undefined); + const roomCount = roomIds.length; + + /** + * 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 + */ + const getItemComponent = useCallback( + ( + index: number, + roomId: string, + context: VirtualizedListContext, + onFocus: (item: string, e: React.FocusEvent) => void, + ): JSX.Element => { + const isSelected = activeRoomIndex === index; + const roomItemVM = vm.getRoomItemViewModel(roomId); + + // 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; + + return ( + + ); + }, + [activeRoomIndex, roomCount, renderAvatar, vm], + ); + + /** + * Get the key for a room item + * Since we're using virtualization, items are always room ID strings + */ + const getItemKey = useCallback((item: string): string => { + return item; + }, []); + + const context = useMemo( + () => ({ spaceId: roomListState.spaceId || "", filterKeys: roomListState.filterKeys }), + [roomListState.spaceId, roomListState.filterKeys], + ); + + /** + * 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], + ); + + return ( + true} + rangeChanged={rangeChanged} + onKeyDown={onKeyDown} + increaseViewportBy={{ + bottom: EXTENDED_VIEWPORT_HEIGHT, + top: EXTENDED_VIEWPORT_HEIGHT, + }} + /> + ); +} diff --git a/packages/shared-components/src/room-list/RoomList/__snapshots__/RoomList.test.tsx.snap b/packages/shared-components/src/room-list/RoomList/__snapshots__/RoomList.test.tsx.snap new file mode 100644 index 0000000000..f14e886bb1 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomList/__snapshots__/RoomList.test.tsx.snap @@ -0,0 +1,1277 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders Default story 1`] = ` +
+
+
+
+
+
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ +
+ + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + + + + + + +`; diff --git a/packages/shared-components/src/room-list/RoomList/index.ts b/packages/shared-components/src/room-list/RoomList/index.ts new file mode 100644 index 0000000000..0b0498d139 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomList/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { RoomList } from "./RoomList"; +export type { RoomListProps, RoomListViewState, FilterKey } from "./RoomList"; diff --git a/packages/shared-components/src/room-list/story-mocks.tsx b/packages/shared-components/src/room-list/story-mocks.tsx new file mode 100644 index 0000000000..03450469c6 --- /dev/null +++ b/packages/shared-components/src/room-list/story-mocks.tsx @@ -0,0 +1,136 @@ +/* + * 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 from "react"; +import { fn } from "storybook/test"; + +import type { RoomListItemSnapshot } from "./RoomListItem"; +import { RoomNotifState } from "./RoomListItem/RoomNotifs"; + +/** + * Mock avatar component for stories + */ +export const mockAvatar = (name: string): React.ReactElement => ( +
+ {name.substring(0, 2).toUpperCase()} +
+); + +/** + * Render avatar function for stories + */ +export const renderAvatar = (room: any): React.ReactElement => { + return mockAvatar(room?.name || "Room"); +}; + +/** + * Room names used for mock data + */ +const roomNames = [ + "General", + "Random", + "Engineering", + "Design", + "Product", + "Marketing", + "Sales", + "Support", + "Announcements", + "Off-topic", + "Team Alpha", + "Team Beta", + "Project X", + "Project Y", + "Water Cooler", + "Feedback", + "Ideas", + "Bugs", + "Features", + "Releases", +]; + +/** + * Create a mock room item snapshot for stories + */ +export const createMockRoomSnapshot = (id: string, name: string, index: number): RoomListItemSnapshot => ({ + id, + room: { name }, + name, + isBold: index % 3 === 0, + messagePreview: index % 2 === 0 ? `Last message in ${name}` : undefined, + notification: { + hasAnyNotificationOrActivity: index % 5 === 0, + isUnsentMessage: false, + invited: false, + isMention: index % 5 === 0, + isActivityNotification: false, + isNotification: index % 5 === 0, + hasUnreadCount: index % 5 === 0, + count: index % 5 === 0 ? index : 0, + muted: false, + }, + showMoreOptionsMenu: true, + showNotificationMenu: true, + isFavourite: false, + isLowPriority: false, + canInvite: true, + canCopyRoomLink: true, + canMarkAsRead: false, + canMarkAsUnread: true, + roomNotifState: RoomNotifState.AllMessages, +}); + +/** + * Create a mock getRoomItemViewModel function for stories + */ +export const createGetRoomItemViewModel = (roomIds: string[]): ((roomId: string) => any) => { + const viewModels = new Map(); + roomIds.forEach((roomId, index) => { + const name = roomNames[index % roomNames.length]; + const snapshot = createMockRoomSnapshot(roomId, name, index); + + const mockViewModel = { + getSnapshot: () => snapshot, + subscribe: fn(), + unsubscribe: fn(), + onOpenRoom: fn(), + onMarkAsRead: fn(), + onMarkAsUnread: fn(), + onToggleFavorite: fn(), + onToggleLowPriority: fn(), + onInvite: fn(), + onCopyRoomLink: fn(), + onLeaveRoom: fn(), + onSetRoomNotifState: fn(), + }; + viewModels.set(roomId, mockViewModel); + }); + + return (roomId: string) => viewModels.get(roomId); +}; + +/** + * Mock room IDs for different list sizes + */ +export const mockRoomIds = Array.from({ length: 20 }, (_, i) => `!room${i}:server`); +export const smallListRoomIds = mockRoomIds.slice(0, 5); +export const largeListRoomIds = Array.from({ length: 100 }, (_, i) => `!room${i}:server`);