Skip to content

Implement the member list with virtuoso #29869

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 35 commits into from
Jul 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c764fc8
implement basic scrolling and keyboard navigation
langleyd May 2, 2025
5166501
Merge branch 'develop' of github.com:vector-im/element-web into langl…
langleyd May 7, 2025
e661903
Update focus style and improve keyboard navigation
langleyd May 7, 2025
b83a865
lint
langleyd May 7, 2025
e567d80
Use avatar tootltip for the title rather than the whole button
langleyd May 7, 2025
cc6b3f0
lint
langleyd May 7, 2025
7571ea3
Add tooltip for invite buttons active state
langleyd May 8, 2025
919b5ee
Fix location of scrollToIndex and add useCallback
langleyd May 8, 2025
ce68db5
Improve voiceover experience
langleyd May 8, 2025
fe67aec
Fix jest tests
langleyd May 14, 2025
0a85d5f
Add aria index/counts and remove repeating "Open" string in label
langleyd May 14, 2025
23af58d
update snapshot
langleyd May 14, 2025
9288b88
Add the rest of the keyboard navigation and handle the case when the …
langleyd May 15, 2025
f5243ed
lint and update snapshot
langleyd May 15, 2025
8379213
lint
langleyd May 15, 2025
0f02d6a
Only focus first/lastFocsed cell if focus.currentTarget is the overal…
langleyd May 28, 2025
31f24d1
Merge branch 'develop' of github.com:vector-im/element-web into langl…
langleyd Jul 22, 2025
aa745e0
Put back overscan and fix formatting
langleyd Jul 22, 2025
9c7bd34
Extract ListView out of MemberList
langleyd Jul 25, 2025
ae87097
lint and fix e2e test
langleyd Jul 25, 2025
376f6ab
Update screenshot
langleyd Jul 25, 2025
dc45381
Merge branch 'develop' into langleyd/memberlist_to_virtuoso
langleyd Jul 25, 2025
c0ca9fa
Fix default overscan value and add ListView tests
langleyd Jul 28, 2025
1d17d35
Merge branch 'langleyd/memberlist_to_virtuoso' of github.com:vector-i…
langleyd Jul 28, 2025
b6cbf2d
Just leave the avatar as it was
langleyd Jul 28, 2025
094eb4c
We removed the tooltip that showed power level. Removing string.
langleyd Jul 28, 2025
76623a9
Use key rather than index to track focus.
langleyd Jul 28, 2025
e66f540
Remove overscan, fix typos, fix scrollToItem logic
langleyd Jul 29, 2025
a09197d
Use listbox role for member list and correct position/count values to…
langleyd Jul 29, 2025
4fecf14
Fix inadvertant scrolling of the timeline when using pageUp/pageDown
langleyd Jul 29, 2025
2db428f
Always set the roving tab index regardless of whether we are actually…
langleyd Jul 30, 2025
561920c
Add aria-hidden to items within the option to avoid the SR calling it…
langleyd Jul 30, 2025
4e169cf
Make sure there is a roving tab set if the last one has been removed …
langleyd Jul 30, 2025
1c04dad
Update snapshot
langleyd Jul 30, 2025
876bb7d
Merge branch 'develop' into langleyd/memberlist_to_virtuoso
langleyd Jul 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions playwright/e2e/lazy-loading/lazy-loading.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion playwright/e2e/share-dialog/share-dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
272 changes: 272 additions & 0 deletions src/components/utils/ListView.tsx
Original file line number Diff line number Diff line change
@@ -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<Context> = {
/** 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<Item, Context>
extends Omit<VirtuosoProps<Item, ListContext<Context>>, "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<Context>) => 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<Item, Context = any>(props: IListViewProps<Item, Context>): 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<VirtuosoHandle>(null);
/** Reference to the DOM element containing the virtualized list */
const virtuosoDomRef = useRef<HTMLElement | Window>(null);
/** Key of the item that should have tabIndex == 0 */
const [tabIndexKey, setTabIndexKey] = useState<string | undefined>(
props.items[0] ? getItemKey(props.items[0]) : undefined,
);
/** Range of currently visible items in the viewport */
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
/** Map from item keys to their indices in the items array */
const [keyToIndexMap, setKeyToIndexMap] = useState<Map<string, number>>(new Map());
/** Whether the list is currently scrolling to an item */
const isScrollingToItem = useRef<boolean>(false);
/** Whether the list is currently focused */
const [isFocused, setIsFocused] = useState<boolean>(false);

// Update the key-to-index mapping whenever items change
useEffect(() => {
const newKeyToIndexMap = new Map<string, number>();
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<Context> = {
tabIndexKey: tabIndexKey,
focused: isFocused,
context: props.context || ({} as Context),
};

return (
<Virtuoso
tabIndex={props.tabIndex || undefined} // We don't need to focus the container, so leave it undefined by default
scrollerRef={scrollerRef}
ref={virtuosoHandleRef}
onKeyDown={keyDownCallback}
context={listContext}
rangeChanged={setVisibleRange}
// virtuoso errors internally if you pass undefined.
overscan={props.overscan || 0}
data={props.items}
onFocus={onFocus}
onBlur={onBlur}
itemContent={props.getItemComponent}
{...virtuosoProps}
/>
);
}
12 changes: 12 additions & 0 deletions src/components/viewmodels/memberlist/MemberListViewModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;

Expand Down Expand Up @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -267,6 +278,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
isPresenceEnabled,
isLoading,
onInviteButtonClick,
onClickMember,
shouldShowSearch: totalMemberCount >= 20,
canInvite,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 {
Expand All @@ -28,7 +27,6 @@ export interface MemberTileViewState extends MemberTileViewModelProps {
e2eStatus?: E2EStatus;
name: string;
onClick: () => void;
title?: string;
userLabel?: string;
}

Expand Down Expand Up @@ -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) {
Expand All @@ -149,7 +138,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT
}

return {
title,
member,
name,
onClick,
Expand Down
Loading
Loading