diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index 540d03c160..01f8cd92f5 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -332,10 +332,13 @@ export class RoomListViewModel * Update which rooms are currently visible. * Called by the view when scroll position changes. * Disposes of view models for rooms no longer visible. + * + * Indices are in room-index space (section header entries excluded): + * startIndex is inclusive, endIndex is exclusive. */ public updateVisibleRooms(startIndex: number, endIndex: number): void { const allRoomIds = this.roomIds; - const newVisibleIds = allRoomIds.slice(startIndex, Math.min(endIndex, allRoomIds.length)); + const newVisibleIds = allRoomIds.slice(startIndex, endIndex); const newVisibleSet = new Set(newVisibleIds); diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx index 6de5ab2f29..e515197ed3 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx @@ -73,7 +73,10 @@ export interface RoomListViewActions { * Allow undefined to be returned if we don't have a view model for the room. In this case the room will not be rendered. */ getRoomItemViewModel: (roomId: string) => RoomListItemViewModel | undefined; - /** Called when the visible range changes (virtualization API) */ + /** + * Called when the rendered range changes (virtualization API). Indices are in room-index + * space (section header entries excluded): startIndex is inclusive, endIndex exclusive. + */ updateVisibleRooms: (startIndex: number, endIndex: number) => void; /** * Called when the last genuinely-visible item index changes (excluding the rendered 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 d1a2fc6f53..7ca3dd8e86 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx @@ -67,6 +67,27 @@ describe("", () => { expect(Default.args.updateVisibleRooms).toHaveBeenCalled(); }); + describe("updateVisibleRooms range reporting", () => { + beforeEach(() => { + (Default.args.updateVisibleRooms as any).mockClear?.(); + (Sections.args.updateVisibleRooms as any).mockClear?.(); + }); + + it("reports an exclusive end bound in flat mode", () => { + renderWithMockContext(); + // 10 rooms, all rendered by the mock viewport: Virtuoso reports the inclusive + // range [0, 9], which must reach the view model as the exclusive window [0, 10). + expect(Default.args.updateVisibleRooms).toHaveBeenLastCalledWith(0, 10); + }); + + it("maps entry-space indices to room indices in grouped mode", () => { + renderWithMockContext(); + // 13 entries (3 section headers + 10 rooms) are all rendered: Virtuoso reports the + // inclusive entry range [0, 12], which must map back to the room window [0, 10). + expect(Sections.args.updateVisibleRooms).toHaveBeenLastCalledWith(0, 10); + }); + }); + describe("drag and drop", () => { beforeEach(() => { // Storybook fn() spies are shared across tests; vi.clearAllMocks() may not diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx index 5641b652e0..f54323d1a5 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx @@ -241,17 +241,63 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual [sections], ); + // In a grouped list, Virtuoso's range counts section headers as entries, so entry indices + // don't match room indices. This maps an inclusive entry range to a [start, end) room-index + // range. A header entry maps to the section boundary: "from this section's first room" as a + // start, "up to the previous section's last room" as an end. + const mapEntryRangeToRoomRange = useCallback( + (startEntry: number, endEntry: number): [start: number, endExclusive: number] => { + let start: number | undefined; + let end: number | undefined; + let headerEntry = 0; // entry index of the current section's header + let roomsBefore = 0; // number of rooms in the sections above the current one + for (const section of sections) { + // Last entry of this section: the header entry followed by one entry per room + const lastEntry = headerEntry + section.roomIds.length; + + // The range starts in this section: on the header (-1 clamped to the first room) + // or on one of its rooms + if (start === undefined && startEntry <= lastEntry) { + start = roomsBefore + Math.max(0, startEntry - headerEntry - 1); + } + + // The range ends in this section: on the header (0 rooms of this section + // included) or on one of its rooms (exclusive bound, hence no -1) + if (end === undefined && endEntry <= lastEntry) { + end = roomsBefore + Math.max(0, endEntry - headerEntry); + } + + // Both bounds found, no need to look at the remaining sections + if (start !== undefined && end !== undefined) break; + + // Move to the next section + headerEntry = lastEntry + 1; + roomsBefore += section.roomIds.length; + } + // The range can transiently point past the sections when the list shrinks before + // Virtuoso reports the new range; fall back to the widest valid window. + return [start ?? 0, end ?? roomsBefore]; + }, + [sections], + ); + /** * Callback when the visible range changes * Notifies the view model which rooms are visible */ const rangeChanged = useCallback( (range: { startIndex: number; endIndex: number }) => { - vm.updateVisibleRooms(range.startIndex, range.endIndex); + // Virtuoso's endIndex is inclusive; updateVisibleRooms takes an exclusive end. + if (isFlatList) { + vm.updateVisibleRooms(range.startIndex, range.endIndex + 1); + } else { + const [start, end] = mapEntryRangeToRoomRange(range.startIndex, range.endIndex); + vm.updateVisibleRooms(start, end); + } // The rendered set changed; (un)observe items so the fold stays accurate. scheduleSyncObservedItems(); }, - [vm, scheduleSyncObservedItems], + [vm, scheduleSyncObservedItems, isFlatList, mapEntryRangeToRoomRange], ); // Builds the accessibility plugin (live-region announcements) for keyboard/pointer drags,