diff --git a/package.json b/package.json index bbb0b176a1..363276af27 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "react-string-replace": "^1.1.1", "react-transition-group": "^4.4.1", "react-virtualized": "^9.22.5", + "react-virtuoso": "^4.12.6", "rfc4648": "^1.4.0", "sanitize-filename": "^1.6.3", "sanitize-html": "2.17.0", diff --git a/playwright/e2e/lazy-loading/lazy-loading.spec.ts b/playwright/e2e/lazy-loading/lazy-loading.spec.ts index 7c31c288fa..f6f098a079 100644 --- a/playwright/e2e/lazy-loading/lazy-loading.spec.ts +++ b/playwright/e2e/lazy-loading/lazy-loading.spec.ts @@ -30,6 +30,10 @@ test.describe("Lazy Loading", () => { }); test.beforeEach(async ({ page, homeserver, user, bot, app }) => { + // The charlies were running off the bottom of the screen. + // We no longer overscan the member list so the result is they are not in the dom. + // Increase the viewport size to ensure they are. + await page.setViewportSize({ width: 1000, height: 1000 }); for (let i = 1; i <= 10; i++) { const displayName = `Charly #${i}`; const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false }); diff --git a/playwright/e2e/share-dialog/share-dialog.spec.ts b/playwright/e2e/share-dialog/share-dialog.spec.ts index 58574a46ff..a77e89fcdc 100644 --- a/playwright/e2e/share-dialog/share-dialog.spec.ts +++ b/playwright/e2e/share-dialog/share-dialog.spec.ts @@ -35,7 +35,7 @@ test.describe("Share dialog", () => { const rightPanel = await app.toggleRoomInfoPanel(); await rightPanel.getByRole("menuitem", { name: "People" }).click(); - await rightPanel.getByRole("button", { name: `${user.userId} (power 100)` }).click(); + await rightPanel.getByRole("option", { name: user.displayName }).click(); await rightPanel.getByRole("button", { name: "Share profile" }).click(); const dialog = page.getByRole("dialog", { name: "Share User" }); diff --git a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png index a5db88aae6..ab5b0ea76c 100644 Binary files a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png and b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png differ diff --git a/src/components/utils/ListView.tsx b/src/components/utils/ListView.tsx new file mode 100644 index 0000000000..377ffb141b --- /dev/null +++ b/src/components/utils/ListView.tsx @@ -0,0 +1,272 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useRef, type JSX, useCallback, useEffect, useState } from "react"; +import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso"; + +/** + * Context object passed to each list item containing the currently focused key + * and any additional context data from the parent component. + */ +export type ListContext = { + /** The key of item that should have tabIndex == 0 */ + tabIndexKey?: string; + /** Whether an item in the list is currently focused */ + focused: boolean; + /** Additional context data passed from the parent component */ + context: Context; +}; + +export interface IListViewProps + extends Omit>, "data" | "itemContent" | "context"> { + /** + * The array of items to display in the virtualized list. + * Each item will be passed to getItemComponent for rendering. + */ + items: Item[]; + + /** + * Callback function called when an item is selected (via Enter/Space key). + * @param item - The selected item from the items array + */ + onSelectItem: (item: Item) => void; + + /** + * Function that renders each list item as a JSX element. + * @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 + * @returns JSX element representing the rendered item + */ + getItemComponent: (index: number, item: Item, context: ListContext) => JSX.Element; + + /** + * Optional additional context data to pass to each rendered item. + * This will be available in the ListContext passed to getItemComponent. + */ + context?: Context; + + /** + * Function to determine if an item can receive focus during keyboard navigation. + * @param item - The item to check for focusability + * @returns true if the item can be focused, false otherwise + */ + isItemFocusable: (item: Item) => boolean; + + /** + * Function to get the key to use for focusing an item. + * @param item - The item to get the key for + * @return The key to use for focusing the item + */ + getItemKey: (item: Item) => string; +} + +/** + * A generic virtualized list component built on top of react-virtuoso. + * Provides keyboard navigation and virtualized rendering for performance with large lists. + * + * @template Item - The type of data items in the list + * @template Context - The type of additional context data passed to items + */ +export function ListView(props: IListViewProps): React.ReactElement { + // Extract our custom props to avoid conflicts with Virtuoso props + const { items, onSelectItem, getItemComponent, isItemFocusable, getItemKey, context, ...virtuosoProps } = props; + /** Reference to the Virtuoso component for programmatic scrolling */ + const virtuosoHandleRef = useRef(null); + /** Reference to the DOM element containing the virtualized list */ + const virtuosoDomRef = useRef(null); + /** Key of the item that should have tabIndex == 0 */ + const [tabIndexKey, setTabIndexKey] = useState( + props.items[0] ? getItemKey(props.items[0]) : undefined, + ); + /** Range of currently visible items in the viewport */ + const [visibleRange, setVisibleRange] = useState(undefined); + /** Map from item keys to their indices in the items array */ + const [keyToIndexMap, setKeyToIndexMap] = useState>(new Map()); + /** Whether the list is currently scrolling to an item */ + const isScrollingToItem = useRef(false); + /** Whether the list is currently focused */ + const [isFocused, setIsFocused] = useState(false); + + // Update the key-to-index mapping whenever items change + useEffect(() => { + const newKeyToIndexMap = new Map(); + items.forEach((item, index) => { + const key = getItemKey(item); + newKeyToIndexMap.set(key, index); + }); + setKeyToIndexMap(newKeyToIndexMap); + }, [items, getItemKey]); + + // Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed + useEffect(() => { + if (items.length && (!tabIndexKey || keyToIndexMap.get(tabIndexKey) === undefined)) { + setTabIndexKey(getItemKey(items[0])); + } + }, [items, getItemKey, tabIndexKey, keyToIndexMap]); + + /** + * Scrolls to a specific item index and sets it as focused. + * Uses Virtuoso's scrollIntoView method for smooth scrolling. + */ + const scrollToIndex = useCallback( + (index: number, align?: "center" | "end" | "start"): void => { + // Ensure index is within bounds + const clampedIndex = Math.max(0, Math.min(index, items.length - 1)); + if (isScrollingToItem.current) { + // If already scrolling to an item drop this request. Adding further requests + // causes the event to bubble up and be handled by other components(unintentional timeline scrolling was observed). + return; + } + if (items[clampedIndex]) { + const key = getItemKey(items[clampedIndex]); + setTabIndexKey(key); + isScrollingToItem.current = true; + virtuosoHandleRef?.current?.scrollIntoView({ + index: clampedIndex, + align: align, + behavior: "auto", + done: () => { + isScrollingToItem.current = false; + }, + }); + } + }, + [items, getItemKey], + ); + + /** + * Scrolls to an item, skipping over non-focusable items if necessary. + * This is used for keyboard navigation to ensure focus lands on valid items. + */ + const scrollToItem = useCallback( + (index: number, isDirectionDown: boolean, align?: "center" | "end" | "start"): void => { + const totalRows = items.length; + let nextIndex: number | undefined; + + for (let i = index; isDirectionDown ? i < totalRows : i >= 0; i = i + (isDirectionDown ? 1 : -1)) { + if (isItemFocusable(items[i])) { + nextIndex = i; + break; + } + } + + if (nextIndex === undefined) { + return; + } + + scrollToIndex(nextIndex, align); + }, + [scrollToIndex, items, isItemFocusable], + ); + + /** + * Handles keyboard navigation for the list. + * 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 + + 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 === "Enter" || e.code === "Space") && currentIndex !== undefined) { + const item = items[currentIndex]; + onSelectItem(item); + 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; + } + + if (handled) { + e.stopPropagation(); + e.preventDefault(); + } + }, + [scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onSelectItem], + ); + + /** + * Callback ref for the Virtuoso scroller element. + * Stores the reference for use in focus management. + */ + const scrollerRef = useCallback((element: HTMLElement | Window | null) => { + virtuosoDomRef.current = element; + }, []); + + /** + * Handles focus events on the list. + * Sets the focused state and scrolls to the focused item if it is not currently visible. + */ + const onFocus = useCallback( + (e?: React.FocusEvent): void => { + if (e?.currentTarget !== virtuosoDomRef.current || typeof tabIndexKey !== "string") { + return; + } + + setIsFocused(true); + const index = keyToIndexMap.get(tabIndexKey); + if ( + index !== undefined && + visibleRange && + (index < visibleRange.startIndex || index > visibleRange.endIndex) + ) { + scrollToIndex(index); + } + e?.stopPropagation(); + e?.preventDefault(); + }, + [keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey], + ); + + const onBlur = useCallback((): void => { + setIsFocused(false); + }, []); + + const listContext: ListContext = { + tabIndexKey: tabIndexKey, + focused: isFocused, + context: props.context || ({} as Context), + }; + + return ( + + ); +} diff --git a/src/components/viewmodels/memberlist/MemberListViewModel.tsx b/src/components/viewmodels/memberlist/MemberListViewModel.tsx index a03f703511..55220d29a9 100644 --- a/src/components/viewmodels/memberlist/MemberListViewModel.tsx +++ b/src/components/viewmodels/memberlist/MemberListViewModel.tsx @@ -38,6 +38,8 @@ import { isValid3pidInvite } from "../../../RoomInvite"; import { type ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite"; import { type XOR } from "../../../@types/common"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; +import { Action } from "../../../dispatcher/actions"; +import dis from "../../../dispatcher/dispatcher"; type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>; @@ -111,6 +113,7 @@ export interface MemberListViewState { shouldShowSearch: boolean; isLoading: boolean; canInvite: boolean; + onClickMember: (member: RoomMember | ThreePIDInvite) => void; onInviteButtonClick: (ev: ButtonEvent) => void; } export function useMemberListViewModel(roomId: string): MemberListViewState { @@ -133,6 +136,14 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { */ const [memberCount, setMemberCount] = useState(0); + const onClickMember = (member: RoomMember | ThreePIDInvite): void => { + dis.dispatch({ + action: Action.ViewUser, + member: member, + push: true, + }); + }; + const loadMembers = useMemo( () => throttle( @@ -267,6 +278,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { isPresenceEnabled, isLoading, onInviteButtonClick, + onClickMember, shouldShowSearch: totalMemberCount >= 20, canInvite, }; diff --git a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx index 4f6814caae..0355fe47a3 100644 --- a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx +++ b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx @@ -5,7 +5,7 @@ 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 { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { RoomStateEvent, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; import { type UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; @@ -16,7 +16,6 @@ import { asyncSome } from "../../../../utils/arrays"; import { getUserDeviceIds } from "../../../../utils/crypto/deviceInfo"; import { type RoomMember } from "../../../../models/rooms/RoomMember"; import { _t, _td, type TranslationKey } from "../../../../languageHandler"; -import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier"; import { E2EStatus } from "../../../../utils/ShieldUtils"; interface MemberTileViewModelProps { @@ -28,7 +27,6 @@ export interface MemberTileViewState extends MemberTileViewModelProps { e2eStatus?: E2EStatus; name: string; onClick: () => void; - title?: string; userLabel?: string; } @@ -130,15 +128,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT } } - const title = useMemo(() => { - return _t("member_list|power_label", { - userName: UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, { - roomId: member.roomId, - }), - powerLevelNumber: member.powerLevel, - }).trim(); - }, [member.powerLevel, member.roomId, member.userId]); - let userLabel; const powerStatus = powerStatusMap.get(powerLevel); if (powerStatus) { @@ -149,7 +138,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT } return { - title, member, name, onClick, diff --git a/src/components/views/rooms/MemberList/MemberListHeaderView.tsx b/src/components/views/rooms/MemberList/MemberListHeaderView.tsx index c1520acd44..5d096c7228 100644 --- a/src/components/views/rooms/MemberList/MemberListHeaderView.tsx +++ b/src/components/views/rooms/MemberList/MemberListHeaderView.tsx @@ -19,10 +19,10 @@ interface TooltipProps { children: React.ReactNode; } -const OptionalTooltip: React.FC = ({ canInvite, children }) => { - if (canInvite) return children; +const InviteTooltip: React.FC = ({ canInvite, children }) => { + const description: string = canInvite ? _t("action|invite") : _t("member_list|invite_button_no_perms_tooltip"); // If the user isn't allowed to invite others to this room, wrap with a relevant tooltip. - return {children}; + return {children}; }; interface Props { @@ -42,7 +42,7 @@ const InviteButton: React.FC = ({ vm }) => { if (shouldShowSearch) { /// When rendered alongside a search box, the invite button is just an icon. return ( - + - + ); }; diff --git a/src/components/views/rooms/MemberList/MemberListView.tsx b/src/components/views/rooms/MemberList/MemberListView.tsx index 1c85b3188e..8afdeaf990 100644 --- a/src/components/views/rooms/MemberList/MemberListView.tsx +++ b/src/components/views/rooms/MemberList/MemberListView.tsx @@ -6,9 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import { Form } from "@vector-im/compound-web"; -import React, { type JSX } from "react"; -import { List, type ListRowProps } from "react-virtualized/dist/commonjs/List"; -import { AutoSizer } from "react-virtualized"; +import React, { type JSX, useCallback } from "react"; import { Flex } from "../../../../shared-components/utils/Flex"; import { @@ -21,7 +19,7 @@ import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView"; import { MemberListHeaderView } from "./MemberListHeaderView"; import BaseCard from "../../right_panel/BaseCard"; import { _t } from "../../../../languageHandler"; -import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex"; +import { type ListContext, ListView } from "../../../utils/ListView"; interface IProps { roomId: string; @@ -30,53 +28,67 @@ interface IProps { const MemberListView: React.FC = (props: IProps) => { const vm = useMemberListViewModel(props.roomId); + const { isPresenceEnabled, onClickMember, memberCount } = vm; - const totalRows = vm.members.length; - - const getRowComponent = (item: MemberWithSeparator): JSX.Element => { + const getItemKey = useCallback((item: MemberWithSeparator): string => { if (item === SEPARATOR) { - return
; + return "separator"; } else if (item.member) { - return ; + return `member-${item.member.userId}`; } else { - return ; + return `threePidInvite-${item.threePidInvite.event.getContent().public_key}`; } - }; + }, []); - const getRowHeight = ({ index }: { index: number }): number => { - if (vm.members[index] === SEPARATOR) { - /** - * This is a separator of 2px height rendered between - * joined and invited members. - */ - return 2; - } else if (totalRows && index === totalRows) { - /** - * The empty spacer div rendered at the bottom should - * have a height of 32px. - */ - return 32; - } else { - /** - * The actual member tiles have a height of 56px. - */ - return 56; - } - }; + const getItemComponent = useCallback( + (index: number, item: MemberWithSeparator, context: ListContext): JSX.Element => { + const itemKey = getItemKey(item); + const isRovingItem = itemKey === context.tabIndexKey; + const focused = isRovingItem && context.focused; + if (item === SEPARATOR) { + return
; + } else if (item.member) { + return ( + + ); + } else { + return ( + + ); + } + }, + [isPresenceEnabled, getItemKey, memberCount], + ); - const rowRenderer = ({ key, index, style }: ListRowProps): JSX.Element => { - if (index === totalRows) { - // We've rendered all the members, - // now we render an empty div to add some space to the end of the list. - return
; - } - const item = vm.members[index]; - return ( -
- {getRowComponent(item)} -
- ); - }; + const handleSelectItem = useCallback( + (item: MemberWithSeparator): void => { + if (item !== SEPARATOR) { + if (item.member) { + onClickMember(item.member); + } else { + onClickMember(item.threePidInvite); + } + } + }, + [onClickMember], + ); + + const isItemFocusable = useCallback((item: MemberWithSeparator): boolean => { + return item !== SEPARATOR; + }, []); return ( = (props: IProps) => { header={_t("common|people")} onClose={props.onClose} > - - {({ onKeyDownHandler }) => ( - - e.preventDefault()}> - - - - {({ height, width }) => ( - - )} - - - )} - + + e.preventDefault()}> + + + + ); }; diff --git a/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx index f5fd5203a5..4837972da3 100644 --- a/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx +++ b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx @@ -19,7 +19,11 @@ import { InvitedIconView } from "./common/InvitedIconView"; interface IProps { member: RoomMember; + index: number; + memberCount: number; showPresence?: boolean; + focused?: boolean; + tabIndex?: number; } export function RoomMemberTileView(props: IProps): JSX.Element { @@ -36,7 +40,7 @@ export function RoomMemberTileView(props: IProps): JSX.Element { /> ); const name = vm.name; - const nameJSX = ; + const nameJSX = ; const presenceState = member.presenceState; let presenceJSX: JSX.Element | undefined; @@ -54,13 +58,17 @@ export function RoomMemberTileView(props: IProps): JSX.Element { return ( ); } diff --git a/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx b/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx index 4f6caf06f6..0a93727f5f 100644 --- a/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx +++ b/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx @@ -15,20 +15,30 @@ import { InvitedIconView } from "./common/InvitedIconView"; interface Props { threePidInvite: ThreePIDInvite; + memberIndex: number; + memberCount: number; + focused?: boolean; + tabIndex?: number; } export function ThreePidInviteTileView(props: Props): JSX.Element { const vm = useThreePidTileViewModel(props); const av =