Room list: scroll to newly creation section (#33210)

* feat(rls): emit tag when section is created

* feat(vm): scroll to newly section tag

* feat(view): scroll to new section
This commit is contained in:
Florian Duros
2026-04-22 12:21:41 +00:00
committed by GitHub
parent 29411f0ded
commit 9df9fb9428
9 changed files with 101 additions and 27 deletions
@@ -485,13 +485,12 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
/** /**
* Create a new section. * Create a new section.
* Emits {@link SECTION_CREATED_EVENT} and {@link LISTS_UPDATE_EVENT} if the section was successfully created. * Emits {@link SECTION_CREATED_EVENT} if the section was successfully created.
*/ */
public async createSection(): Promise<void> { public async createSection(): Promise<void> {
const sectionIsCreated = await createSection(); const tag = await createSection();
if (!sectionIsCreated) return; if (!tag) return;
this.emit(SECTION_CREATED_EVENT); this.emit(SECTION_CREATED_EVENT, tag);
this.scheduleEmit();
} }
/** /**
+4 -4
View File
@@ -33,13 +33,13 @@ export type OrderedCustomSections = Tag[];
* Creates a new custom section by showing a dialog to the user to enter the section name. * Creates a new custom section by showing a dialog to the user to enter the section name.
* If the user confirms, it generates a unique tag for the section, saves the section data in the settings, and updates the ordered list of sections. * If the user confirms, it generates a unique tag for the section, saves the section data in the settings, and updates the ordered list of sections.
* *
* @return A promise that resolves to true if the section was created, or false if the user cancelled the creation or if there was an error. * @return A promise that resolves to the new section tag if created, or undefined if cancelled.
*/ */
export async function createSection(): Promise<boolean> { export async function createSection(): Promise<string | undefined> {
const modal = Modal.createDialog(CreateSectionDialog); const modal = Modal.createDialog(CreateSectionDialog);
const [shouldCreateSection, sectionName] = await modal.finished; const [shouldCreateSection, sectionName] = await modal.finished;
if (!shouldCreateSection || !sectionName) return false; if (!shouldCreateSection || !sectionName) return undefined;
const tag = `element.io.section.${window.crypto.randomUUID()}`; const tag = `element.io.section.${window.crypto.randomUUID()}`;
const newSection: CustomSection = { tag, name: sectionName }; const newSection: CustomSection = { tag, name: sectionName };
@@ -53,5 +53,5 @@ export async function createSection(): Promise<boolean> {
const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections") || []; const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections") || [];
orderedSections.push(tag); orderedSections.push(tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections); await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
return true; return tag;
} }
@@ -153,7 +153,7 @@ export class RoomListViewModel
this.disposables.trackListener( this.disposables.trackListener(
RoomListStoreV3.instance, RoomListStoreV3.instance,
RoomListStoreV3Event.SectionCreated as any, RoomListStoreV3Event.SectionCreated as any,
this.onSectionCreated, this.onSectionCreated as (...args: unknown[]) => void,
); );
// Subscribe to active room changes to update selected room // Subscribe to active room changes to update selected room
@@ -500,6 +500,7 @@ export class RoomListViewModel
private async updateRoomListData( private async updateRoomListData(
isRoomChange: boolean = false, isRoomChange: boolean = false,
roomIdOverride: string | null = null, roomIdOverride: string | null = null,
scrollToSectionTag: string | undefined = undefined,
): Promise<void> { ): Promise<void> {
// Determine the room ID to use for calculations // Determine the room ID to use for calculations
// Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore // Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore
@@ -544,17 +545,23 @@ export class RoomListViewModel
// Update filter keys - only update if they have actually changed to prevent unnecessary re-renders of the room list // Update filter keys - only update if they have actually changed to prevent unnecessary re-renders of the room list
const previousFilterKeys = this.snapshot.current.roomListState.filterKeys; const previousFilterKeys = this.snapshot.current.roomListState.filterKeys;
const newFilterKeys = this.roomsResult.filterKeys?.map((k) => String(k)); const newFilterKeys = this.roomsResult.filterKeys?.map((k) => String(k));
const viewSections = toRoomListSection(this.sections);
const resolvedScrollToSectionTag =
scrollToSectionTag && viewSections.some((s) => s.id === scrollToSectionTag)
? scrollToSectionTag
: undefined;
const roomListState: RoomListViewState = { const roomListState: RoomListViewState = {
activeRoomIndex, activeRoomIndex,
spaceId: this.roomsResult.spaceId, spaceId: this.roomsResult.spaceId,
filterKeys: keepIfSame(previousFilterKeys, newFilterKeys), filterKeys: keepIfSame(previousFilterKeys, newFilterKeys),
scrollToSectionTag: resolvedScrollToSectionTag,
}; };
const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined; const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined;
const isRoomListEmpty = this.roomsResult.sections.every((section) => section.rooms.length === 0); const isRoomListEmpty = this.roomsResult.sections.every((section) => section.rooms.length === 0);
const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms; const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms;
const viewSections = toRoomListSection(this.sections);
const previousSections = this.snapshot.current.sections; const previousSections = this.snapshot.current.sections;
// Single atomic snapshot update // Single atomic snapshot update
@@ -586,7 +593,9 @@ export class RoomListViewModel
} }
}; };
public onSectionCreated = (): void => { public onSectionCreated = (tag: string): void => {
this.updateRoomListData(false, null, tag);
clearTimeout(this.toastRef); clearTimeout(this.toastRef);
this.snapshot.merge({ this.snapshot.merge({
toast: "section_created", toast: "section_created",
@@ -1015,7 +1015,7 @@ describe("RoomListStoreV3", () => {
it("emits SECTION_CREATED_EVENT and LISTS_UPDATE_EVENT when section is created", async () => { it("emits SECTION_CREATED_EVENT and LISTS_UPDATE_EVENT when section is created", async () => {
enableSections(); enableSections();
getClientAndRooms(); getClientAndRooms();
jest.spyOn(sectionModule, "createSection").mockResolvedValue(true); jest.spyOn(sectionModule, "createSection").mockResolvedValue("element.io.section.test-tag");
const store = new RoomListStoreV3Class(dispatcher); const store = new RoomListStoreV3Class(dispatcher);
await store.start(); await store.start();
@@ -1027,14 +1027,13 @@ describe("RoomListStoreV3", () => {
await store.createSection(); await store.createSection();
expect(sectionCreatedListener).toHaveBeenCalled(); expect(sectionCreatedListener).toHaveBeenCalledWith("element.io.section.test-tag");
expect(listsUpdateListener).toHaveBeenCalled();
}); });
it("does not emit when section creation is cancelled", async () => { it("does not emit when section creation is cancelled", async () => {
enableSections(); enableSections();
getClientAndRooms(); getClientAndRooms();
jest.spyOn(sectionModule, "createSection").mockResolvedValue(false); jest.spyOn(sectionModule, "createSection").mockResolvedValue(undefined);
const store = new RoomListStoreV3Class(dispatcher); const store = new RoomListStoreV3Class(dispatcher);
await store.start(); await store.start();
@@ -21,10 +21,9 @@ describe("createSection", () => {
}); });
it.each([ it.each([
[false, "", false], [false, "", undefined],
[true, "", false], [true, "", undefined],
[true, "My Section", true], ])("returns undefined when shouldCreate=%s and name='%s'", async (shouldCreate, name, expected) => {
])("returns %s when shouldCreate=%s and name='%s'", async (shouldCreate, name, expected) => {
jest.spyOn(Modal, "createDialog").mockReturnValue({ jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([shouldCreate, name]), finished: Promise.resolve([shouldCreate, name]),
close: jest.fn(), close: jest.fn(),
@@ -34,6 +33,16 @@ describe("createSection", () => {
expect(result).toBe(expected); expect(result).toBe(expected);
}); });
it("returns the new tag when section is created", async () => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, "My Section"]),
close: jest.fn(),
} as any);
const result = await createSection();
expect(result).toMatch(/^element\.io\.section\./);
});
it("opens the CreateSectionDialog", async () => { it("opens the CreateSectionDialog", async () => {
const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([false, ""]), finished: Promise.resolve([false, ""]),
@@ -6,7 +6,7 @@
*/ */
import React, { type JSX, useCallback, useMemo } from "react"; import React, { type JSX, useCallback, useMemo } from "react";
import { Virtuoso } from "react-virtuoso"; import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { useVirtualizedList, type VirtualizedListContext, type VirtualizedListProps } from "../virtualized-list"; import { useVirtualizedList, type VirtualizedListContext, type VirtualizedListProps } from "../virtualized-list";
@@ -33,6 +33,11 @@ export interface GroupedVirtualizedListProps<Header, Item, Context> extends Omit
VirtualizedListProps<Item, Context>, VirtualizedListProps<Item, Context>,
"items" | "isItemFocusable" | "getItemKey" "items" | "isItemFocusable" | "getItemKey"
> { > {
/**
* Optional ref to the underlying Virtuoso handle, for imperative scrolling.
*/
scrollHandleRef?: React.RefCallback<VirtuosoHandle>;
/** /**
* The groups to display in the virtualized list. * The groups to display in the virtualized list.
* Each group has a header and an array of child items. * Each group has a header and an array of child items.
@@ -126,6 +131,7 @@ export function GroupedVirtualizedList<Header, Item, Context>(
isGroupHeaderFocusable, isGroupHeaderFocusable,
getItemKey, getItemKey,
getHeaderKey, getHeaderKey,
scrollHandleRef,
...restProps ...restProps
} = props; } = props;
@@ -171,6 +177,7 @@ export function GroupedVirtualizedList<Header, Item, Context>(
isItemFocusable: wrappedIsEntryFocusable, isItemFocusable: wrappedIsEntryFocusable,
getItemKey: wrappedGetEntryKey, getItemKey: wrappedGetEntryKey,
}, },
scrollHandleRef,
); );
// Convert (Item, e) → (NavigationEntry, e) for regular items // Convert (Item, e) → (NavigationEntry, e) for regular items
@@ -128,7 +128,7 @@ export interface UseVirtualizedListResult<Item, Context> extends Omit<
VirtuosoProps<Item, VirtualizedListContext<Context>>, VirtuosoProps<Item, VirtualizedListContext<Context>>,
"data" | "itemContent" | "context" | "onKeyDown" | "onFocus" | "onBlur" | "rangeChanged" | "scrollerRef" | "ref" "data" | "itemContent" | "context" | "onKeyDown" | "onFocus" | "onBlur" | "rangeChanged" | "scrollerRef" | "ref"
> { > {
ref: React.RefObject<VirtuosoHandle | null>; ref: React.RefCallback<VirtuosoHandle>;
scrollerRef: (element: HTMLElement | Window | null) => void; scrollerRef: (element: HTMLElement | Window | null) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void; onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
onFocus: (e: React.FocusEvent) => void; onFocus: (e: React.FocusEvent) => void;
@@ -155,6 +155,7 @@ export interface UseVirtualizedListResult<Item, Context> extends Omit<
*/ */
export function useVirtualizedList<Item, Context>( export function useVirtualizedList<Item, Context>(
props: VirtualizedListProps<Item, Context>, props: VirtualizedListProps<Item, Context>,
handleRef?: React.RefCallback<VirtuosoHandle>,
): UseVirtualizedListResult<Item, Context> { ): UseVirtualizedListResult<Item, Context> {
// Extract our custom props to avoid conflicts with Virtuoso props // Extract our custom props to avoid conflicts with Virtuoso props
const { const {
@@ -380,9 +381,17 @@ export function useVirtualizedList<Item, Context>(
[rangeChanged, mapRangeIndex], [rangeChanged, mapRangeIndex],
); );
const setRef = useCallback(
(handle: VirtuosoHandle | null) => {
virtuosoHandleRef.current = handle;
handleRef?.(handle);
},
[handleRef],
);
return { return {
...virtuosoProps, ...virtuosoProps,
ref: virtuosoHandleRef, ref: setRef,
scrollerRef, scrollerRef,
onKeyDown: keyDownCallback, onKeyDown: keyDownCallback,
onFocus, onFocus,
@@ -13,7 +13,7 @@ import { describe, it, expect } from "vitest";
import * as stories from "./VirtualizedRoomListView.stories"; import * as stories from "./VirtualizedRoomListView.stories";
const { Default } = composeStories(stories); const { Default, Sections } = composeStories(stories);
const renderWithMockContext = (component: React.ReactElement): ReturnType<typeof render> => { const renderWithMockContext = (component: React.ReactElement): ReturnType<typeof render> => {
return render(component, { return render(component, {
@@ -64,4 +64,28 @@ describe("<VirtualizedRoomListView />", () => {
renderWithMockContext(<Default />); renderWithMockContext(<Default />);
expect(Default.args.updateVisibleRooms).toHaveBeenCalled(); expect(Default.args.updateVisibleRooms).toHaveBeenCalled();
}); });
describe("scrollToSectionTag", () => {
it("skips scroll when scrollToSectionTag does not match any section", () => {
const roomListState = {
activeRoomIndex: 0,
spaceId: "!space:server",
scrollToSectionTag: "nonexistent",
};
renderWithMockContext(<Sections roomListState={roomListState} />);
expect(screen.getByRole("treegrid", { name: "Room list" })).toBeInTheDocument();
});
it("scrolls to the section when scrollToSectionTag matches", () => {
// sections: favourites(3 rooms), chats(1 room), low-priority(6 rooms)
// flat index for "chats" = 3 rooms + 1 header = 4
const roomListState = {
activeRoomIndex: 0,
spaceId: "!space:server",
scrollToSectionTag: "chats",
};
renderWithMockContext(<Sections roomListState={roomListState} />);
expect(screen.getByRole("treegrid", { name: "Room list" })).toBeInTheDocument();
});
});
}); });
@@ -5,8 +5,8 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import React, { useCallback, useMemo, useRef, type JSX, type ReactNode } from "react"; import React, { useCallback, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from "react";
import { type ScrollIntoViewLocation } from "react-virtuoso"; import { type ScrollIntoViewLocation, type VirtuosoHandle } from "react-virtuoso";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import { type Room } from "./RoomListItemAccessibilityWrapper/RoomListItemView"; import { type Room } from "./RoomListItemAccessibilityWrapper/RoomListItemView";
@@ -38,6 +38,8 @@ export interface RoomListViewState {
spaceId?: string; spaceId?: string;
/** Active filter keys for context tracking */ /** Active filter keys for context tracking */
filterKeys?: FilterKey[]; filterKeys?: FilterKey[];
/** Tag of a newly created section header to scroll into view */
scrollToSectionTag?: string;
} }
/** /**
@@ -110,8 +112,13 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
const snapshot = useViewModel(vm); const snapshot = useViewModel(vm);
const { roomListState, sections, isFlatList } = snapshot; const { roomListState, sections, isFlatList } = snapshot;
const activeRoomIndex = roomListState.activeRoomIndex; const activeRoomIndex = roomListState.activeRoomIndex;
const scrollToSectionTag = roomListState.scrollToSectionTag;
const lastSpaceId = useRef<string | undefined>(undefined); const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | 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 roomIds = useMemo(() => sections.flatMap((section) => section.roomIds), [sections]);
const roomCount = roomIds.length; const roomCount = roomIds.length;
const sectionCount = sections.length; const sectionCount = sections.length;
@@ -328,6 +335,16 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
[activeRoomIndex], [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 isItemFocusable = useCallback(() => true, []);
const isGroupHeaderFocusable = useCallback(() => true, []); const isGroupHeaderFocusable = useCallback(() => true, []);
const increaseViewportBy = useMemo( const increaseViewportBy = useMemo(
@@ -369,6 +386,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
<GroupedVirtualizedList<string, string, Context> <GroupedVirtualizedList<string, string, Context>
{...commonProps} {...commonProps}
{...getContainerAccessibleProps("treegrid", totalCount)} {...getContainerAccessibleProps("treegrid", totalCount)}
scrollHandleRef={setVirtuosoHandle}
groups={groups} groups={groups}
getHeaderKey={getHeaderKey} getHeaderKey={getHeaderKey}
getGroupHeaderComponent={getGroupHeaderComponent} getGroupHeaderComponent={getGroupHeaderComponent}