diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index ec10fe14685..7615d3ec67d 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -485,13 +485,12 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { /** * 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 { - const sectionIsCreated = await createSection(); - if (!sectionIsCreated) return; - this.emit(SECTION_CREATED_EVENT); - this.scheduleEmit(); + const tag = await createSection(); + if (!tag) return; + this.emit(SECTION_CREATED_EVENT, tag); } /** diff --git a/apps/web/src/stores/room-list-v3/section.ts b/apps/web/src/stores/room-list-v3/section.ts index f11a0286933..8fba0111c37 100644 --- a/apps/web/src/stores/room-list-v3/section.ts +++ b/apps/web/src/stores/room-list-v3/section.ts @@ -35,13 +35,13 @@ export type OrderedCustomSections = Tag[]; * 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. * - * @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 { +export async function createSection(): Promise { const modal = Modal.createDialog(CreateSectionDialog); const [shouldCreateSection, sectionName] = await modal.finished; - if (!shouldCreateSection || !sectionName) return false; + if (!shouldCreateSection || !sectionName) return undefined; const tag = `element.io.section.${uuidv4()}`; const newSection: CustomSection = { tag, name: sectionName }; @@ -55,5 +55,5 @@ export async function createSection(): Promise { const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections") || []; orderedSections.push(tag); await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections); - return true; + return tag; } diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index 99e2b3dc19a..eeac64a3c2c 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -153,7 +153,7 @@ export class RoomListViewModel this.disposables.trackListener( RoomListStoreV3.instance, RoomListStoreV3Event.SectionCreated as any, - this.onSectionCreated, + this.onSectionCreated as (...args: unknown[]) => void, ); // Subscribe to active room changes to update selected room @@ -500,6 +500,7 @@ export class RoomListViewModel private async updateRoomListData( isRoomChange: boolean = false, roomIdOverride: string | null = null, + scrollToSectionTag: string | undefined = undefined, ): Promise { // Determine the room ID to use for calculations // 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 const previousFilterKeys = this.snapshot.current.roomListState.filterKeys; 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 = { activeRoomIndex, spaceId: this.roomsResult.spaceId, filterKeys: keepIfSame(previousFilterKeys, newFilterKeys), + scrollToSectionTag: resolvedScrollToSectionTag, }; const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined; const isRoomListEmpty = this.roomsResult.sections.every((section) => section.rooms.length === 0); const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms; - - const viewSections = toRoomListSection(this.sections); const previousSections = this.snapshot.current.sections; // 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); this.snapshot.merge({ toast: "section_created", diff --git a/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index 11ba95d8642..aac4cdaa2fd 100644 --- a/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -1015,7 +1015,7 @@ describe("RoomListStoreV3", () => { it("emits SECTION_CREATED_EVENT and LISTS_UPDATE_EVENT when section is created", async () => { enableSections(); getClientAndRooms(); - jest.spyOn(sectionModule, "createSection").mockResolvedValue(true); + jest.spyOn(sectionModule, "createSection").mockResolvedValue("element.io.section.test-tag"); const store = new RoomListStoreV3Class(dispatcher); await store.start(); @@ -1027,14 +1027,13 @@ describe("RoomListStoreV3", () => { await store.createSection(); - expect(sectionCreatedListener).toHaveBeenCalled(); - expect(listsUpdateListener).toHaveBeenCalled(); + expect(sectionCreatedListener).toHaveBeenCalledWith("element.io.section.test-tag"); }); it("does not emit when section creation is cancelled", async () => { enableSections(); getClientAndRooms(); - jest.spyOn(sectionModule, "createSection").mockResolvedValue(false); + jest.spyOn(sectionModule, "createSection").mockResolvedValue(undefined); const store = new RoomListStoreV3Class(dispatcher); await store.start(); diff --git a/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts b/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts index 9fb8f40d33f..29177846389 100644 --- a/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts +++ b/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts @@ -21,10 +21,9 @@ describe("createSection", () => { }); it.each([ - [false, "", false], - [true, "", false], - [true, "My Section", true], - ])("returns %s when shouldCreate=%s and name='%s'", async (shouldCreate, name, expected) => { + [false, "", undefined], + [true, "", undefined], + ])("returns undefined when shouldCreate=%s and name='%s'", async (shouldCreate, name, expected) => { jest.spyOn(Modal, "createDialog").mockReturnValue({ finished: Promise.resolve([shouldCreate, name]), close: jest.fn(), @@ -34,6 +33,16 @@ describe("createSection", () => { 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 () => { const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ finished: Promise.resolve([false, ""]), diff --git a/packages/shared-components/src/core/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx b/packages/shared-components/src/core/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx index a20d64d5304..eb183586e2d 100644 --- a/packages/shared-components/src/core/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx +++ b/packages/shared-components/src/core/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx @@ -6,7 +6,7 @@ */ 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"; @@ -33,6 +33,11 @@ export interface GroupedVirtualizedListProps extends Omit VirtualizedListProps, "items" | "isItemFocusable" | "getItemKey" > { + /** + * Optional ref to the underlying Virtuoso handle, for imperative scrolling. + */ + scrollHandleRef?: React.RefCallback; + /** * The groups to display in the virtualized list. * Each group has a header and an array of child items. @@ -126,6 +131,7 @@ export function GroupedVirtualizedList( isGroupHeaderFocusable, getItemKey, getHeaderKey, + scrollHandleRef, ...restProps } = props; @@ -171,6 +177,7 @@ export function GroupedVirtualizedList( isItemFocusable: wrappedIsEntryFocusable, getItemKey: wrappedGetEntryKey, }, + scrollHandleRef, ); // Convert (Item, e) → (NavigationEntry, e) for regular items diff --git a/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx b/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx index 2324ec2b3d9..0eb632f3f16 100644 --- a/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx +++ b/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx @@ -128,7 +128,7 @@ export interface UseVirtualizedListResult extends Omit< VirtuosoProps>, "data" | "itemContent" | "context" | "onKeyDown" | "onFocus" | "onBlur" | "rangeChanged" | "scrollerRef" | "ref" > { - ref: React.RefObject; + ref: React.RefCallback; scrollerRef: (element: HTMLElement | Window | null) => void; onKeyDown: (e: React.KeyboardEvent) => void; onFocus: (e: React.FocusEvent) => void; @@ -155,6 +155,7 @@ export interface UseVirtualizedListResult extends Omit< */ export function useVirtualizedList( props: VirtualizedListProps, + handleRef?: React.RefCallback, ): UseVirtualizedListResult { // Extract our custom props to avoid conflicts with Virtuoso props const { @@ -380,9 +381,17 @@ export function useVirtualizedList( [rangeChanged, mapRangeIndex], ); + const setRef = useCallback( + (handle: VirtuosoHandle | null) => { + virtuosoHandleRef.current = handle; + handleRef?.(handle); + }, + [handleRef], + ); + return { ...virtuosoProps, - ref: virtuosoHandleRef, + ref: setRef, scrollerRef, onKeyDown: keyDownCallback, onFocus, diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx index 2d5ff3ed343..f37302a1299 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx @@ -13,7 +13,7 @@ import { describe, it, expect } from "vitest"; import * as stories from "./VirtualizedRoomListView.stories"; -const { Default } = composeStories(stories); +const { Default, Sections } = composeStories(stories); const renderWithMockContext = (component: React.ReactElement): ReturnType => { return render(component, { @@ -64,4 +64,28 @@ describe("", () => { renderWithMockContext(); 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(); + 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(); + expect(screen.getByRole("treegrid", { name: "Room list" })).toBeInTheDocument(); + }); + }); }); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx index 180723ba44e..2cf7ce76c7e 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx @@ -5,8 +5,8 @@ * 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 React, { useCallback, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from "react"; +import { type ScrollIntoViewLocation, type VirtuosoHandle } from "react-virtuoso"; import { isEqual } from "lodash"; import { type Room } from "./RoomListItemAccessibilityWrapper/RoomListItemView"; @@ -38,6 +38,8 @@ export interface RoomListViewState { spaceId?: string; /** Active filter keys for context tracking */ 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 { roomListState, sections, isFlatList } = snapshot; const activeRoomIndex = roomListState.activeRoomIndex; + const scrollToSectionTag = roomListState.scrollToSectionTag; const lastSpaceId = useRef(undefined); const lastFilterKeys = useRef(undefined); + const virtuosoHandleRef = useRef(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; @@ -328,6 +335,16 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual [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( @@ -369,6 +386,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual {...commonProps} {...getContainerAccessibleProps("treegrid", totalCount)} + scrollHandleRef={setVirtuosoHandle} groups={groups} getHeaderKey={getHeaderKey} getGroupHeaderComponent={getGroupHeaderComponent}