Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 4 additions & 5 deletions apps/web/src/stores/room-list-v3/RoomListStoreV3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,13 +485,12 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {

/**
* 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> {
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);
}

/**
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/stores/room-list-v3/section.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
export async function createSection(): Promise<string | undefined> {
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 };
Expand All @@ -55,5 +55,5 @@ export async function createSection(): Promise<boolean> {
const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections") || [];
orderedSections.push(tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
return true;
return tag;
}
17 changes: 13 additions & 4 deletions apps/web/src/viewmodels/room-list/RoomListViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -500,6 +500,7 @@ export class RoomListViewModel
private async updateRoomListData(
isRoomChange: boolean = false,
roomIdOverride: string | null = null,
scrollToSectionTag: string | undefined = undefined,
): Promise<void> {
// Determine the room ID to use for calculations
// Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down
17 changes: 13 additions & 4 deletions apps/web/test/unit-tests/stores/room-list-v3/section-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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, ""]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -33,6 +33,11 @@ export interface GroupedVirtualizedListProps<Header, Item, Context> extends Omit
VirtualizedListProps<Item, Context>,
"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.
* Each group has a header and an array of child items.
Expand Down Expand Up @@ -126,6 +131,7 @@ export function GroupedVirtualizedList<Header, Item, Context>(
isGroupHeaderFocusable,
getItemKey,
getHeaderKey,
scrollHandleRef,
...restProps
} = props;

Expand Down Expand Up @@ -171,6 +177,7 @@ export function GroupedVirtualizedList<Header, Item, Context>(
isItemFocusable: wrappedIsEntryFocusable,
getItemKey: wrappedGetEntryKey,
},
scrollHandleRef,
);

// Convert (Item, e) → (NavigationEntry, e) for regular items
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export interface UseVirtualizedListResult<Item, Context> extends Omit<
VirtuosoProps<Item, VirtualizedListContext<Context>>,
"data" | "itemContent" | "context" | "onKeyDown" | "onFocus" | "onBlur" | "rangeChanged" | "scrollerRef" | "ref"
> {
ref: React.RefObject<VirtuosoHandle | null>;
ref: React.RefCallback<VirtuosoHandle>;
scrollerRef: (element: HTMLElement | Window | null) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
onFocus: (e: React.FocusEvent) => void;
Expand All @@ -155,6 +155,7 @@ export interface UseVirtualizedListResult<Item, Context> extends Omit<
*/
export function useVirtualizedList<Item, Context>(
props: VirtualizedListProps<Item, Context>,
handleRef?: React.RefCallback<VirtuosoHandle>,
): UseVirtualizedListResult<Item, Context> {
// Extract our custom props to avoid conflicts with Virtuoso props
const {
Expand Down Expand Up @@ -380,9 +381,17 @@ export function useVirtualizedList<Item, Context>(
[rangeChanged, mapRangeIndex],
);

const setRef = useCallback(
(handle: VirtuosoHandle | null) => {
virtuosoHandleRef.current = handle;
handleRef?.(handle);
},
[handleRef],
);

return {
...virtuosoProps,
ref: virtuosoHandleRef,
ref: setRef,
scrollerRef,
onKeyDown: keyDownCallback,
onFocus,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof render> => {
return render(component, {
Expand Down Expand Up @@ -64,4 +64,28 @@ describe("<VirtualizedRoomListView />", () => {
renderWithMockContext(<Default />);
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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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<string | 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 roomCount = roomIds.length;
const sectionCount = sections.length;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -369,6 +386,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
<GroupedVirtualizedList<string, string, Context>
{...commonProps}
{...getContainerAccessibleProps("treegrid", totalCount)}
scrollHandleRef={setVirtuosoHandle}
groups={groups}
getHeaderKey={getHeaderKey}
getGroupHeaderComponent={getGroupHeaderComponent}
Expand Down
Loading